Merge remote-tracking branch 'origin/master' into codex/provider-routing-connections

* origin/master:
  fix(tasks): surface Codex ChatGPT model rejection (#15299)
  fix(runner): continue restart-interrupted Codex turns (#15297)
  fix(agents): grant configuration access by default (#15283)
  Guard routine UUID lookups without narrowing valid inputs (#15313)
  fix(ssh): transport project repositories as their own git checkouts (#14782)
  feat(exe-dev): copy a source VM with exe.dev cp (#14975)
  Diagnose native model rejections and preserve repairable reviews (#15304)
  Verify native semantic input against its raw wire digest (#15301)
  Retry pooled execution projection reads after disconnects (#15300)
  Fix browser polling for unsaved agent chats (#15298)
  test(ui): keep initial reasoning with the saved reply (#15107)
  docs: add a product feature map (#15288)
This commit is contained in:
Dotta committed 2026-10-06 08:42:29 -05:00
commit 7cbd61349a
126 files changed
+5961 -150

No files matched your search

@@ -277,6 +277,26 @@ describe.sequential("activity routes", () => {
expect(mockActivityService.create).not.toHaveBeenCalled();
});
it.each([null, { id: "issue-uuid-1", companyId: "company-2" }])(
"does not start run reads or retries for a missing or cross-company task", async (issue) => {
mockIssueService.getByIdentifier.mockResolvedValue(issue);
const app = await createApp();
const res = await request(app).get("/api/issues/TEST-1/runs");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Issue not found" });
expect(mockActivityService.runsForIssue).not.toHaveBeenCalled();
},
);
it("does not start run reads or retries when task read authorization is denied", async () => {
mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-uuid-1", companyId: "company-1" });
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "denied", explanation: "Denied by test policy." });
const app = await createApp();
const res = await request(app).get("/api/issues/TEST-1/runs");
expect(res.status).toBe(403);
expect(mockActivityService.runsForIssue).not.toHaveBeenCalled();
});
it("returns 200 [] (not 404) when listing issues for another company's run, preserving API contract and the cross-tenant oracle", async () => {
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-2",
@@ -150,7 +150,7 @@ describeEmbeddedPostgres("activity service", () => {
expect(result.map((event) => event.action)).toEqual(["test.newest", "test.middle"]);
});
it("returns compact usage and result summaries for issue runs", async () => {
it.each([null, "native_provider_model_rejected", "adapter_failed"])("returns compact issue runs with bounded model rejection details: %s", async (errorCode) => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
@@ -180,7 +180,9 @@ describeEmbeddedPostgres("activity service", () => {
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
status: errorCode ? "failed" : "succeeded",
errorCode,
error: "provider-error".repeat(200),
contextSnapshot: { issueId },
usageJson: {
inputTokens: 11,
@@ -215,6 +217,7 @@ describeEmbeddedPostgres("activity service", () => {
agentId,
invocationSource: "assignment",
contextIssueId: issueId,
error: errorCode === "native_provider_model_rejected" ? "provider-error".repeat(200).slice(0, 2000) : null,
});
expect(runs[0]?.usageJson).toEqual({
inputTokens: 11,
@@ -0,0 +1,240 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agents, companies, companyMemberships, createDb, principalPermissionGrants } from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentService } from "../services/agents.js";
import { authorizationService } from "../services/authorization.js";
import { accessService } from "../services/access.js";
import { agentJoinGrantsFromDefaults } from "../services/invite-grants.js";
const support = await getEmbeddedPostgresTestSupport();
const describeDatabase = support.supported ? describe : describe.skip;
const expectedNewAgentGrantKeys: PermissionKey[] = [
"agents:configure",
"agents:suggest-changes",
"skills:create",
"skills:suggest-changes",
"tools:manage_connections",
"tools:manage_profiles",
"tools:view_audit",
"audit:view_agent_actions",
"tools:use",
"tools:manage_runtime",
"inbox:manage",
"tasks:assign",
"tasks:assign_scope",
"tasks:manage_active_checkouts",
].sort();
describeDatabase("new agent configuration defaults", () => {
let db!: ReturnType<typeof createDb>;
let cleanup: (() => Promise<void>) | undefined;
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("agent-configure-defaults");
db = createDb(started.connectionString);
cleanup = started.cleanup;
}, 20_000);
afterAll(async () => {
await cleanup?.();
});
it("grants standard new agents direct peer configuration, but keeps restricted agents narrow", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Agent permission defaults",
issuePrefix: `PD${companyId.slice(0, 6).toUpperCase()}`,
});
const create = (name: string, permissions: Record<string, unknown> = {}, metadata?: Record<string, unknown>) =>
agentService(db).create(companyId, {
name,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions,
metadata,
}, metadata ? { allowBuiltInAgentMetadata: true } : undefined);
const standard = await create("Standard");
const peer = await create("Peer");
const lowTrust = await create("Low trust", { trustPreset: LOW_TRUST_REVIEW_PRESET });
const bundled = await create("Bundled", {}, {
paperclipBuiltInAgent: { key: "reflection-coach", featureKeys: [] },
});
const grants = await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId));
for (const agent of [standard, peer]) {
expect(grants.filter((grant) => grant.principalId === agent.id)
.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
expect(grants.find((grant) => grant.principalId === agent.id && grant.permissionKey === "tasks:assign_scope")?.scope)
.toEqual({ subtreeRootAgentId: agent.id });
expect(grants.find((grant) => grant.principalId === agent.id && grant.permissionKey === "inbox:manage")?.scope)
.toEqual({ responsibleUserOnly: true });
}
for (const agent of [lowTrust, bundled]) {
expect(grants.filter((grant) => grant.principalId === agent.id)).toEqual([]);
}
await db.insert(companyMemberships).values({
companyId,
principalType: "agent",
principalId: standard.id,
status: "active",
membershipRole: "member",
});
for (const permissionKey of expectedNewAgentGrantKeys) {
if (permissionKey === "tasks:assign_scope" || permissionKey === "inbox:manage") continue; // These grants require a target scope or the responsible-user inbox path.
expect(await accessService(db).hasPermission(companyId, "agent", standard.id, permissionKey)).toBe(true);
}
expect(await authorizationService(db).decidePrincipalGrant({
companyId,
principalType: "agent",
principalId: standard.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: standard.id },
})).toMatchObject({ allowed: true });
expect(await authorizationService(db).decidePrincipalGrant({
companyId,
principalType: "agent",
principalId: standard.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: peer.id },
})).toMatchObject({ allowed: false, reason: "deny_scope" });
for (const permissionKey of [
"agents:create", "environments:manage", "tools:admin", "users:invite",
"users:manage_permissions", "pipelines:write", "joins:approve",
] as const) {
expect(await accessService(db).hasPermission(companyId, "agent", standard.id, permissionKey)).toBe(false);
}
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: standard.id, companyId, source: "agent_key" },
action: "agent_config:update",
resource: { type: "agent", agentId: peer.id, companyId },
scope: { requiresChangeGrant: true },
});
expect(decision).toMatchObject({ allowed: true, reason: "allow_direct_change" });
// A later ordinary self-update must not recreate a grant removed by an operator.
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, standard.id));
await agentService(db).update(standard.id, { title: "Updated own title" });
expect(await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, standard.id))).toEqual([]);
await agentService(db).remove(peer.id);
expect(await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, peer.id))).toEqual([]);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
it("keeps existing agents and their scoped grants unchanged during ordinary updates", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Existing permission defaults",
issuePrefix: `PE${companyId.slice(0, 6).toUpperCase()}`,
});
const rows = await db.insert(agents).values([
{ companyId, name: "Existing", role: "engineer", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Scoped", role: "engineer", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Restricted", role: "engineer", permissions: { trustPreset: LOW_TRUST_REVIEW_PRESET }, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Built in", role: "engineer", metadata: { paperclipBuiltInAgent: { key: "reflection-coach", featureKeys: [] } }, permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Pending", role: "engineer", status: "pending_approval", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
]).returning();
await db.insert(principalPermissionGrants).values({
companyId,
principalType: "agent",
principalId: rows[1]!.id,
permissionKey: "agents:configure",
scope: { agentIds: [rows[0]!.id] },
});
for (const row of rows) {
if (row.status === "pending_approval") {
await expect(agentService(db).update(row.id, { title: "Updated existing agent" }))
.rejects.toThrow("Pending approval agent configuration cannot be changed before board approval");
} else {
await agentService(db).update(row.id, { title: "Updated existing agent" });
}
}
const grants = await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId));
expect(grants.map((grant) => grant.principalId).sort()).toEqual([rows[1]!.id]);
expect(grants.map((grant) => grant.permissionKey)).toEqual(["agents:configure"]);
expect(grants.find((grant) => grant.principalId === rows[1]!.id)?.scope).toEqual({ agentIds: [rows[0]!.id] });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
it("adds the default grant when a standard pending hire is approved", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Pending permission default",
issuePrefix: `PP${companyId.slice(0, 6).toUpperCase()}`,
});
const pending = await agentService(db).create(companyId, {
name: "Pending hire",
status: "pending_approval",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
});
expect(await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId))).toEqual([]);
const result = await agentService(db).activatePendingApproval(pending.id);
expect(result?.activated).toBe(true);
expect((await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId)))
.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
it("keeps configuration access when invitation approval replaces a new agent's grants", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Invited permission default",
issuePrefix: `PI${companyId.slice(0, 6).toUpperCase()}`,
});
const invited = await agentService(db).create(companyId, {
name: "Invited agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
});
await accessService(db).ensureMembership(companyId, "agent", invited.id, "member", "active");
await accessService(db).setPrincipalGrants(
companyId, "agent", invited.id, agentJoinGrantsFromDefaults(null, invited.id), null,
);
const grants = await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, invited.id));
expect(grants.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
});
@@ -28,6 +28,7 @@ beforeAll(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "paperclip-hire-ai-"));
vi.stubEnv("PAPERCLIP_HOME", home);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "hire-ai");
vi.stubEnv("PAPERCLIP_IN_WORKTREE", "false");
database = await startEmbeddedPostgresTestDatabase("paperclip-hire-ai-db-");
db = createDb(database.connectionString);
}, 90_000);
@@ -312,15 +313,15 @@ describe("agent-created hires use managed AI connections", () => {
["ANTHROPIC_API_KEY", ""],
["CLAUDE_CONFIG_DIR", "/tmp/child-claude-home"],
["ANTHROPIC_BASE_URL", "https://example.invalid"],
])(`${endpoint}: preserves an explicit child auth setting %s=%s`, async (key, value) => {
])(`${endpoint}: rejects an agent-supplied local environment setting %s=%s`, async (key, value) => {
const f = await fixture("anthropic");
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
const response = await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
name: "Explicit auth", role: "engineer", adapterType: f.adapterType,
adapterConfig: { env: { [key]: value } },
}));
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
const [saved] = await db.select().from(agents).where(eq(agents.id, agent.id));
expect((saved.adapterConfig.env as Record<string, unknown>)[key]).toEqual({ type: "plain", value });
});
expect(response.status).toBe(403);
expect(response.body.error).toContain("host-executed local adapter settings");
expect(await db.select().from(agents).where(eq(agents.companyId, f.companyId))).toHaveLength(1);
});
}
@@ -368,13 +369,17 @@ describe("agent-created hires use managed AI connections", () => {
["openai", "paperclip_runner", { provider: "acpx", acpxAgent: "claude" }, "OPENAI_API_KEY"],
] as const)(`${endpoint}: ignores the %s auth key for a different provider in %s`, async (provider, adapterType, config, key) => {
const f = await fixture(provider);
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
const response = await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
name: "Cross-provider config", role: "engineer", adapterType,
adapterConfig: { ...config, env: { [key]: "leftover-parent-setting" } },
}));
expect(agent.runtimeConfig.aiConnection).toMatchObject({
provider: provider === "anthropic" ? "openai" : "anthropic", mode: "responsible_user",
});
if (adapterType.endsWith("_local")) {
expect(response.status).toBe(403);
expect(response.body.error).toContain("host-executed local adapter settings");
return;
}
const agent = hired(response);
expect(agent.runtimeConfig.aiConnection).toMatchObject({ provider: provider === "anthropic" ? "openai" : "anthropic", mode: "responsible_user" });
});
}
@@ -467,7 +472,9 @@ describe("agent-created hires use managed AI connections", () => {
describe("hired agents sharing a subscription", () => {
it.each(["openai", "anthropic"] as const)("runs the %s child alongside a live parent and inherits its connection", async (provider) => {
const f = await fixture(provider, "subscription");
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { cwd: home, engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Concurrent teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, adapterConfig: { engine: "cli" }, runtimeConfig: { heartbeat: { enabled: false } } }));
// Host working directories are configured by an operator, not an agent key.
await db.update(agents).set({ adapterConfig: { ...agent.adapterConfig, cwd: home } }).where(eq(agents.id, agent.id));
const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Subscription child task", status: "todo", assigneeAgentId: agent.id, responsibleUserId: f.userId, createdByUserId: f.userId }).returning();
const parentRuntime = await prepareManagedAiRuntime(db, { companyId: f.companyId, agentId: f.agentId, responsibleUserId: f.userId, adapterType: f.adapterType, binding: f.binding, config: { cwd: home } });
const execute = vi.fn(async () => {
@@ -23,6 +23,7 @@ const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
}));
const mockNativeReviewAssignment = vi.hoisted(() => vi.fn());
const mockExecutionProjection = vi.hoisted(() => ({
executionProjectionForRun: vi.fn(async () => null),
@@ -79,6 +80,10 @@ const mockChatRunRetries = vi.hoisted(() => ({
}));
function registerModuleMocks() {
vi.doMock("../services/native-runtime/native-review-participant.js", async () => ({
...(await vi.importActual<typeof import("../services/native-runtime/native-review-participant.js")>("../services/native-runtime/native-review-participant.js")),
getNativeReviewAssignment: mockNativeReviewAssignment,
}));
vi.doMock("../services/execution-projection.js", () => mockExecutionProjection);
vi.doMock("../routes/authz.js", async () =>
vi.importActual("../routes/authz.js"),
@@ -287,6 +292,7 @@ describe("agent live run routes", () => {
vi.clearAllMocks();
mockChatRunRetries.prepareFailedChatRunRetry.mockReset();
mockChatRunRetries.processFailedChatRunRetry.mockReset();
mockNativeReviewAssignment.mockReset().mockResolvedValue(null);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({
allowed: true,
@@ -966,6 +972,68 @@ describe("agent live run routes", () => {
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, expect.objectContaining({ manualUserWake: true }));
});
describe("exact native model-rejected review retry", () => {
const reviewContext = {
nativeReviewInteractionId: "55555555-5555-4555-8555-555555555555",
nativeReviewDecisionId: "66666666-6666-4666-8666-666666666666",
};
const selectedRun = {
id: failedChatRunId, companyId: "company-1", agentId: routeAgentId,
status: "failed", runtimeMode: "native", errorCode: "native_provider_model_rejected",
nativeIssueId: failedChatIssueId,
contextSnapshot: { issueId: failedChatIssueId, ...reviewContext },
};
const selectedIssue = { id: failedChatIssueId, companyId: "company-1", status: "in_review", assigneeAgentId: "worker", assigneeUserId: null };
const retryBody = { failedRunId: failedChatRunId, reason: "retry_failed_run" };
beforeEach(() => {
mockAgentService.getById.mockResolvedValue({ id: routeAgentId, companyId: "company-1" });
mockHeartbeatService.getRun.mockResolvedValue(selectedRun);
mockIssueService.getById.mockResolvedValue(selectedIssue);
mockNativeReviewAssignment.mockResolvedValue({ interaction: { status: "pending" } });
});
it("permits the Board to retry the exact pending review using only saved context", async () => {
const fixture = createFailedChatRetryDb(false);
const res = await requestApp(await createApp(fixture.db), url => request(url).post(`/api/agents/${routeAgentId}/wakeup`).send({
...retryBody, payload: { issueId: "forged", nativeReviewInteractionId: "forged", nativeReviewDecisionId: "forged" },
}));
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockNativeReviewAssignment).toHaveBeenCalledExactlyOnceWith(fixture.db, {
companyId: "company-1", issueId: failedChatIssueId, agentId: routeAgentId, contextSnapshot: reviewContext,
});
expect(mockHeartbeatService.wakeup).toHaveBeenCalledExactlyOnceWith(routeAgentId, expect.objectContaining({
failedRunId: failedChatRunId, reason: "retry_failed_run", payload: { issueId: failedChatIssueId },
requestedByActorType: "user", contextSnapshot: expect.objectContaining(reviewContext),
}));
expect(JSON.stringify(mockHeartbeatService.wakeup.mock.calls)).not.toContain("forged");
});
it.each(["agent-caller", "denied-task", "foreign-company", "foreign-agent", "foreign-issue", "legacy", "unknown-error", "missing-context", "stale", "resolved", "changed-reviewer"])(
"rejects an ineligible exact review retry (%s)", async scenario => {
let actor: Record<string, unknown> | undefined;
if (scenario === "agent-caller") actor = { type: "agent", agentId: routeAgentId, companyId: "company-1" };
if (scenario === "denied-task") mockAccessService.decide.mockImplementation(async ({ action }) => ({ allowed: action !== "issue:comment", explanation: "Denied" }));
if (scenario === "foreign-company") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, companyId: "company-2" });
if (scenario === "foreign-agent") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, agentId: "other-reviewer" });
if (scenario === "foreign-issue") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, nativeIssueId: "other-task" });
if (scenario === "legacy") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, runtimeMode: "legacy" });
if (scenario === "unknown-error") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, errorCode: "adapter_failed" });
if (scenario === "missing-context") mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, contextSnapshot: { issueId: failedChatIssueId } });
if (scenario === "stale" || scenario === "changed-reviewer") mockNativeReviewAssignment.mockResolvedValue(null);
if (scenario === "resolved") mockNativeReviewAssignment.mockResolvedValue({ interaction: { status: "accepted" } });
const fixture = createFailedChatRetryDb(false);
const res = await requestApp(await createApp(fixture.db, actor), url => request(url).post(`/api/agents/${routeAgentId}/wakeup`).send({
...retryBody, payload: { ...reviewContext },
}));
expect(res.status).toBe(["agent-caller", "denied-task"].includes(scenario) ? 403 : ["foreign-company", "foreign-agent"].includes(scenario) ? 404 : 409);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
if (["agent-caller", "denied-task", "foreign-company", "foreign-agent", "foreign-issue", "legacy", "unknown-error", "missing-context"].includes(scenario)) {
expect(mockNativeReviewAssignment).not.toHaveBeenCalled();
}
},
);
});
describe("exact failed chat run retry", () => {
const retryBody = {
failedRunId: failedChatRunId,
@@ -918,6 +918,217 @@ describe.sequential("agent permission routes", () => {
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.each([
["command", { command: "sh" }],
["arguments", { args: ["-c", "id"] }],
["environment", { env: { PATH: "/tmp" } }],
])("blocks agent-authenticated process adapter %s updates", async (_label, adapterConfig) => {
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterConfig }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed process adapters");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("blocks an agent from switching a peer onto the process adapter", async () => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "codex_local" });
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterType: "process" }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed process adapters");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it.each([
["claude_local", { engine: "cli", command: "/tmp/untrusted" }],
["codex_local", { args: ["-c", "id"] }],
["cursor_local", { env: { PATH: "/tmp" } }],
["hermes_local", { hermesCommand: "/tmp/untrusted" }],
["gemini_local", { cwd: "/tmp" }],
["opencode_local", { filesystemSandboxCommand: "/tmp/untrusted" }],
])("blocks agent-authenticated host settings for %s", async (adapterType, adapterConfig) => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType });
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterConfig }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("allows an agent to update a peer's local model without host settings", async () => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "claude_local" });
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterConfig: { model: "claude-sonnet-4-5" } }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledOnce();
});
it("allows a provider credential reference without allowing arbitrary environment variables", async () => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "claude_local" });
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "33333333-3333-4333-8333-333333333333" } } } }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledOnce();
});
it("blocks an agent from activating inherited host settings by switching onto a local adapter", async () => {
mockAgentService.getById.mockResolvedValue({
...baseAgent,
adapterType: "paperclip_runner",
adapterConfig: { env: { PATH: "/tmp" } },
});
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.patch(`/api/agents/${agentId}`)
.send({ adapterType: "claude_local", adapterConfig: { engine: "cli" } }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it.each([
["create", `/api/companies/${companyId}/agents`],
["hire", `/api/companies/${companyId}/agent-hires`],
])("blocks agent-authenticated process adapter commands on %s", async (_label, path) => {
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(path)
.send({ name: "Host process", role: "engineer", adapterType: "process", adapterConfig: { command: "sh" } }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed process adapters");
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it.each([
["create", `/api/companies/${companyId}/agents`],
["hire", `/api/companies/${companyId}/agent-hires`],
])("blocks agent-authenticated local adapter commands on %s", async (_label, path) => {
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(path)
.send({ name: "Local peer", role: "engineer", adapterType: "claude_local", adapterConfig: { engine: "cli", command: "/tmp/untrusted" } }));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("blocks agent-authenticated rollback into process adapter configuration", async () => {
mockAgentService.getConfigRevision.mockResolvedValue({
id: "33333333-3333-4333-8333-333333333333",
afterConfig: { adapterType: "process", adapterConfig: { command: "sh" }, runtimeConfig: {} },
});
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/agents/${agentId}/config-revisions/33333333-3333-4333-8333-333333333333/rollback`));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed process adapters");
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
});
it("blocks agent-authenticated rollback into local adapter command configuration", async () => {
const revisionId = "33333333-3333-4333-8333-333333333333";
mockAgentService.getConfigRevision.mockResolvedValue({
id: revisionId,
afterConfig: { adapterType: "claude_local", adapterConfig: { engine: "cli", command: "/tmp/untrusted" }, runtimeConfig: {} },
});
const app = await createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed local adapter settings");
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
});
it.each(["provisionCommand", "runtimeProvisionCommand", "teardownCommand"])(
"blocks agent-authenticated rollback of workspace %s",
async (commandKey) => {
const revisionId = "33333333-3333-4333-8333-333333333333";
mockAgentService.getConfigRevision.mockResolvedValue({
id: revisionId,
afterConfig: {
adapterType: "codex_local",
adapterConfig: { workspaceStrategy: { type: "git_worktree", [commandKey]: "sh -c id" } },
runtimeConfig: {},
},
});
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`));
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
},
);
it("allows an agent to restore a non-process revision for a process peer", async () => {
const revisionId = "33333333-3333-4333-8333-333333333333";
mockAgentService.getConfigRevision.mockResolvedValue({
id: revisionId,
afterConfig: { adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {} },
});
mockAgentService.rollbackConfigRevision.mockResolvedValue({
...baseAgent,
adapterType: "codex_local",
});
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post(`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.rollbackConfigRevision).toHaveBeenCalledOnce();
});
it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => {
const app = await createApp({
type: "agent",
@@ -15,6 +15,7 @@ import {
companySecrets,
createDb,
environments,
principalPermissionGrants,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
@@ -245,6 +246,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
await db.delete(userSecretDeclarations);
await db.delete(userSecretDefinitions);
await db.delete(adapterAuthSessions);
await db.delete(principalPermissionGrants);
await db.delete(agents);
await db.delete(environments);
await db.delete(companies);
@@ -8,6 +8,7 @@ import {
budgetPolicies,
companies,
createDb,
principalPermissionGrants,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -40,6 +41,7 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
afterEach(async () => {
await db.delete(activityLog);
await db.delete(principalPermissionGrants);
await db.delete(budgetPolicies);
await db.delete(approvals);
await db.delete(agents);
@@ -13,6 +13,7 @@ import {
companySecretVersions,
companySecrets,
createDb,
principalPermissionGrants,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -46,6 +47,7 @@ describeEmbeddedPostgres("agent service secret binding sync", () => {
afterEach(async () => {
await db.delete(activityLog);
await db.delete(principalPermissionGrants);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
@@ -2606,6 +2606,44 @@ describeEmbeddedPostgres("authorization service", () => {
})).resolves.toMatchObject({ allowed: true, reason: "allow_explicit_grant" });
});
it("keeps a default inbox grant within the responsible user's policy", async () => {
const company = await createCompany(db, "InboxDefaultScoped");
const actorAgent = await createAgent(db, company.id);
const responsibleUserId = await createUser(db);
const disabledUserId = await createUser(db);
const disallowedUserId = await createUser(db);
await db.insert(companyMemberships).values(
[responsibleUserId, disabledUserId, disallowedUserId].map((principalId) => ({
companyId: company.id,
principalType: "user" as const,
principalId,
status: "active" as const,
membershipRole: "operator" as const,
})),
);
await db.insert(userInboxAgentPolicies).values([
{ companyId: company.id, userId: disabledUserId, mode: "disabled" },
{ companyId: company.id, userId: disallowedUserId, mode: "allowlist", allowedAgentIds: [] },
]);
await grantAgentPermission(db, company.id, actorAgent.id, "inbox:manage", { responsibleUserOnly: true });
const decideFor = (userId: string) => authorizationService(db).decide({
actor: {
type: "agent" as const,
agentId: actorAgent.id,
companyId: company.id,
onBehalfOfUserId: responsibleUserId,
source: "agent_jwt" as const,
},
action: "inbox:manage" as const,
resource: { type: "company" as const, companyId: company.id },
scope: { userId },
});
await expect(decideFor(responsibleUserId)).resolves.toMatchObject({ allowed: true, reason: "allow_self" });
await expect(decideFor(disabledUserId)).resolves.toMatchObject({ allowed: false, reason: "inbox_management_disabled" });
await expect(decideFor(disallowedUserId)).resolves.toMatchObject({ allowed: false, reason: "inbox_agent_not_allowed" });
});
it("enforces user-scoped cross-user inbox grants", async () => {
const company = await createCompany(db, "InboxCrossUserScoped");
const actorAgent = await createAgent(db, company.id);
@@ -983,8 +983,15 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
)[0],
).toMatchObject({ eventCursor: 9, eventsDrained: 1, accountedCents: 15 });
});
it("protects interactive viewer routes from agent, other-company and read-only access", async () => {
it.each(["task", "saved chat"])("protects %s browser routes from agent, other-company and read-only access", async (kind) => {
const f = await fixture();
if (kind === "saved chat") {
await db.update(issues).set({
conversationAgentId: f.agent.id,
conversationUserId: actor.actorId,
conversationState: "active",
}).where(eq(issues.id, f.issue.id));
}
await f.service.execute(
f.binding,
f.grant,
@@ -1021,11 +1028,19 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
});
app.use(browserUseRoutes(db, f.service));
app.use(errorHandler);
const listPath = `/issues/${f.issue.id}/browsers`;
const listed = await http(app).get(listPath);
expect(listed.status).toBe(200);
expect(listed.body).toMatchObject([{ id: browser.id, issueId: f.issue.id }]);
const missing = await http(app).get(`/issues/${randomUUID()}/browsers`);
expect(missing.status).toBe(404);
expect(missing.body).toEqual({ error: "Task not found" });
const path = `/issues/${f.issue.id}/browsers/${browser.id}/viewer`;
const ok = await http(app).get(path);
expect(ok.status).toBe(200);
expect(ok.headers["cache-control"]).toBe("no-store");
expect(ok.headers["referrer-policy"]).toBe("no-referrer");
const controlPath = path.replace("/viewer", "/control");
const presencePath = path.replace("/viewer", "/presence");
expect((await http(app).post(presencePath)).status).toBe(200);
const resizePath = path.replace("/viewer", "/viewport");
@@ -1065,7 +1080,10 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
(await http(app).post(resizePath).send({ preset: "custom", width: 1 }))
.status,
).toBe(400);
expect((await http(app).post(controlPath).send({ action: "cancel" })).status).toBe(200);
mode = "agent";
expect((await http(app).get(listPath)).status).toBe(403);
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(403);
expect((await http(app).post(presencePath)).status).toBe(403);
expect(
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
@@ -1076,6 +1094,10 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
).toBe(403);
expect((await http(app).get(path)).status).toBe(403);
mode = "other-company";
const inaccessible = await http(app).get(listPath);
expect(inaccessible.status).toBe(missing.status);
expect(inaccessible.body).toEqual(missing.body);
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(404);
expect((await http(app).post(presencePath)).status).toBe(404);
expect(
(await http(app).post(`${resizePath}/release`).send({ viewerId }))
@@ -1091,6 +1113,8 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
.set({ membershipRole: "viewer" })
.where(eq(companyMemberships.companyId, f.company.id));
expect((await http(app).get(path)).status).toBe(403);
expect((await http(app).get(listPath)).body).toEqual([]);
expect((await http(app).post(controlPath).send({ action: "keep_open" })).status).toBe(403);
expect((await http(app).post(presencePath)).status).toBe(403);
expect(
(await http(app).post(resizePath).send({ preset: "phone" })).status,
@@ -0,0 +1,73 @@
import { randomUUID } from "node:crypto";
import express from "express";
import http from "supertest";
import { describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { errorHandler } from "../middleware/error-handler.js";
import { browserUseRoutes } from "../routes/browser-use.js";
const routes = [
{ method: "get", suffix: "" },
{ method: "get", suffix: "/viewer" },
{ method: "post", suffix: "/presence" },
{ method: "post", suffix: "/viewport" },
{ method: "post", suffix: "/viewport/release" },
{ method: "post", suffix: "/control" },
] as const;
describe("browser task route scope", () => {
it.each([
"12345678-1234-4234-8234-123456789abc",
"12345678-1234-7234-8234-123456789ABC",
"00000000-0000-0000-0000-000000000000",
])("keeps canonical PostgreSQL task UUIDs queryable: %s", async (id) => {
const companyId = randomUUID();
const limit = vi.fn().mockResolvedValue([{ companyId }]);
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
const list = vi.fn().mockResolvedValue([]);
const service = { list } as unknown as NonNullable<Parameters<typeof browserUseRoutes>[1]>;
const app = express();
app.use((req, _res, next) => {
req.actor = { type: "board", userId: "browser-reviewer", source: "session", companyIds: [companyId] };
next();
});
app.use(browserUseRoutes(db, service));
app.use(errorHandler);
expect((await http(app).get(`/issues/${id}/browsers`)).status).toBe(200);
expect(limit).toHaveBeenCalledExactlyOnceWith(1);
expect(list).toHaveBeenCalledExactlyOnceWith(companyId, id, "browser-reviewer");
});
it.each(routes)("rejects invalid task IDs before any query: $method $suffix", async ({ method, suffix }) => {
const select = vi.fn(() => { throw new Error("Invalid scope reached the database"); });
const db = { select } as unknown as Db;
const service = Object.fromEntries(
["list", "viewer", "presence", "resize", "releaseViewport", "humanSession", "control"]
.map((name) => [name, vi.fn()]),
) as unknown as NonNullable<Parameters<typeof browserUseRoutes>[1]>;
let actorType: "board" | "agent" | "none" = "board";
const app = express();
app.use((req, _res, next) => {
req.actor = actorType === "board"
? { type: "board", userId: "browser-reviewer", source: "session", companyIds: [] }
: actorType === "agent"
? { type: "agent", companyId: randomUUID(), agentId: randomUUID(), source: "agent_key" }
: { type: "none" };
next();
});
app.use(browserUseRoutes(db, service));
app.use(errorHandler);
const id = randomUUID();
for (const invalidId of [`chat:${id}`, "not-a-uuid", `${id}'`, ` ${id}`, `${id}\n`]) {
const path = `/issues/${encodeURIComponent(invalidId)}/browsers${suffix ? `/${id}${suffix}` : ""}`;
for (const type of ["board", "agent", "none"] as const) {
actorType = type;
const response = await http(app)[method](path);
expect(response.status).toBe(type === "board" ? 404 : 403);
expect(response.body).toEqual({ error: type === "board" ? "Task not found" : "Board access required" });
}
}
expect(select).not.toHaveBeenCalled();
for (const handler of Object.values(service)) expect(handler).not.toHaveBeenCalled();
});
});
+3 -3
View File
@@ -611,9 +611,9 @@ describeEmbeddedPostgres("built-in agents", () => {
expect(result.defaultGrantsEnsured).toBeGreaterThanOrEqual(4);
const rootGrantKeys = await permissionKeysForAgent(root.id);
expect(rootGrantKeys).toEqual(expect.arrayContaining(["agents:configure", "skills:create"]));
expect(rootGrantKeys).not.toContain("agents:suggest-changes");
expect(rootGrantKeys).not.toContain("skills:suggest-changes");
expect(rootGrantKeys).toEqual(expect.arrayContaining([
"agents:configure", "agents:suggest-changes", "skills:create", "skills:suggest-changes",
]));
const state = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(state).toMatchObject({
@@ -14310,7 +14310,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await commitNativeStatusDecision({ db, companyId: fixture.companyId, issueId: fixture.issueId, runId: fixture.runId,
assessmentId: coordinator!.assessmentId!, priorStatus: issue!.status, priorStatusVersion: issue!.statusVersion,
priorDecisionId: issue!.lastStatusDecisionId,
decision: { policyVersion: "phase6-v7", statusAction: "in_progress", toStatus: "in_progress",
decision: { policyVersion: "phase6-v8", statusAction: "in_progress", toStatus: "in_progress",
reasonCode: "board_response_waiting", unblockDescriptor: null, effects: [] },
requireBoardResponseWaitSource: source!.source, requireBoardResponseWaitOrigin: origin!,
});
@@ -14934,7 +14934,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
priorStatusVersion: issue!.statusVersion,
priorDecisionId: issue!.lastStatusDecisionId,
decision: {
policyVersion: "phase6-v7",
policyVersion: "phase6-v8",
statusAction: reasonCode === "prior_status_preserved_no_live_path" ? "preserve" : "in_progress",
toStatus: "in_progress",
reasonCode,
+24 -24
View File
@@ -8,18 +8,20 @@ import {
normalizeHumanRole,
resolveHumanInviteRole,
} from "../services/company-member-roles.js";
import { NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope } from "../services/agent-permissions.js";
const invitedAgentId = "agent-1";
const defaultAgentGrants = NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
permissionKey,
scope: newStandardAgentGrantScope(permissionKey, invitedAgentId),
}));
describe("agentJoinGrantsFromDefaults", () => {
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
expect(agentJoinGrantsFromDefaults(null)).toEqual([
{
permissionKey: "tasks:assign",
scope: null,
},
]);
it("adds all new-agent grants when invite defaults do not specify grants", () => {
expect(agentJoinGrantsFromDefaults(null, invitedAgentId)).toEqual(defaultAgentGrants);
});
it("preserves invite agent grants and appends tasks:assign", () => {
it("preserves invite agent grants and appends the missing defaults", () => {
expect(
agentJoinGrantsFromDefaults({
agent: {
@@ -30,17 +32,8 @@ describe("agentJoinGrantsFromDefaults", () => {
},
],
},
}),
).toEqual([
{
permissionKey: "agents:create",
scope: null,
},
{
permissionKey: "tasks:assign",
scope: null,
},
]);
}, invitedAgentId),
).toEqual([{ permissionKey: "agents:create", scope: null }, ...defaultAgentGrants]);
});
it("does not duplicate tasks:assign when invite defaults already include it", () => {
@@ -54,12 +47,19 @@ describe("agentJoinGrantsFromDefaults", () => {
},
],
},
}),
}, invitedAgentId),
).toEqual([
{
permissionKey: "tasks:assign",
scope: { projectId: "project-1" },
},
{ permissionKey: "tasks:assign", scope: { projectId: "project-1" } },
...defaultAgentGrants.filter((grant) => grant.permissionKey !== "tasks:assign"),
]);
});
it("preserves an explicit scoped agent configuration grant", () => {
expect(agentJoinGrantsFromDefaults({
agent: { grants: [{ permissionKey: "agents:configure", scope: { agentIds: ["agent-1"] } }] },
}, invitedAgentId)).toEqual([
{ permissionKey: "agents:configure", scope: { agentIds: ["agent-1"] } },
...defaultAgentGrants.filter((grant) => grant.permissionKey !== "agents:configure"),
]);
});
});
@@ -17,6 +17,7 @@ import {
pluginCompanySettings,
pluginManagedResources,
plugins,
principalPermissionGrants,
} from "@paperclipai/db";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
@@ -106,6 +107,7 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
await db.delete(pluginManagedResources);
await db.delete(pluginCompanySettings);
await db.delete(approvals);
await db.delete(principalPermissionGrants);
await db.delete(agents);
await db.delete(plugins);
await db.delete(companies);
@@ -12,6 +12,7 @@ import {
issues,
pluginManagedResources,
plugins,
principalPermissionGrants,
projects,
routineDocuments,
routineRuns,
@@ -121,6 +122,7 @@ describeEmbeddedPostgres("plugin-managed routines", () => {
await db.delete(agentConfigRevisions);
await db.delete(activityLog);
await db.delete(pluginManagedResources);
await db.delete(principalPermissionGrants);
await db.delete(agents);
await db.delete(projects);
await db.delete(plugins);
+113
View File
@@ -238,6 +238,119 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
return { companyId, agentId, projectId, userId };
}
function routineResourceRequests(app: express.Express, routineId: string, triggerId = routineId) {
const path = `/api/routines/${encodeURIComponent(routineId)}`;
const triggerPath = `/api/routine-triggers/${encodeURIComponent(triggerId)}`;
const nestedId = "12345678-1234-4234-8234-123456789abc";
return [
request(app).get(path),
request(app).get(`${path}/runs`),
request(app).get(`${path}/revisions`),
request(app).get(`${path}/description/annotations`),
request(app).get(`${path}/description/annotations/${nestedId}`),
request(app).post(`${path}/description/annotations`).send({
baseRevisionId: nestedId, baseRevisionNumber: 1, body: "Review this text",
selector: {
quote: { exact: "text" },
position: { normalizedStart: 0, normalizedEnd: 4, markdownStart: 0, markdownEnd: 4 },
},
}),
request(app).post(`${path}/description/annotations/${nestedId}/comments`).send({ body: "Review" }),
request(app).patch(`${path}/description/annotations/${nestedId}`).send({ status: "resolved" }),
request(app).patch(path).send({ title: "Updated routine" }),
request(app).post(`${path}/revisions/${nestedId}/restore`).send({}),
request(app).post(`${path}/triggers`).send({ kind: "api" }),
request(app).post(`${path}/run`).send({}),
request(app).patch(triggerPath).send({ enabled: false }),
request(app).delete(triggerPath),
request(app).post(`${triggerPath}/rotate-secret`).send({}),
];
}
it("returns not found without queries for malformed routine and trigger IDs on every resource route", async () => {
const { companyId, agentId, userId } = await seedFixture();
const actors = [
{ type: "board", userId, source: "session", companyIds: [companyId] },
{ type: "agent", agentId, companyId },
{ type: "none" },
];
const fullId = "12345678-1234-4234-8234-123456789abc";
for (const actor of actors) {
const app = await createApp(actor);
const spies = [
vi.spyOn(db, "select"), vi.spyOn(db, "insert"),
vi.spyOn(db, "update"), vi.spyOn(db, "delete"),
];
try {
for (const id of [fullId.slice(0, 8), "not-a-uuid", ` ${fullId}`, `${fullId}\n`, `{${fullId}\n}`]) {
for (const pending of routineResourceRequests(app, id)) {
const response = await pending;
expect(response.status, JSON.stringify(response.body)).toBe(404);
expect(response.body).toEqual({ error: response.req.path.includes("/routine-triggers/")
? "Routine trigger not found" : "Routine not found" });
}
}
const invalidBody = await request(app).patch("/api/routines/not-a-uuid").send({ title: 42 });
expect(invalidBody.status).toBe(400);
for (const spy of spies) expect(spy).not.toHaveBeenCalled();
} finally {
for (const spy of spies) spy.mockRestore();
}
}
}, 20_000);
it("keeps missing and cross-company routine resources indistinguishable", async () => {
const { companyId, agentId, userId } = await seedFixture();
const routineId = randomUUID();
const triggerId = randomUUID();
await db.insert(routines).values({ id: routineId, companyId, assigneeAgentId: agentId, title: "Private routine" });
await db.insert(routineTriggers).values({ id: triggerId, companyId, routineId, kind: "api" });
for (const actor of [
{ type: "board", userId, source: "session", isInstanceAdmin: true, companyIds: [randomUUID()] },
{ type: "agent", agentId: randomUUID(), companyId: randomUUID() },
{ type: "none" },
]) {
const app = await createApp(actor);
for (const [id, tid] of [[routineId, triggerId], [routineId.toUpperCase(), `{${triggerId}}`], [randomUUID(), randomUUID()]]) {
for (const pending of routineResourceRequests(app, id!, tid!)) {
const response = await pending;
expect(response.status, JSON.stringify(response.body)).toBe(404);
expect(response.body).toEqual({ error: response.req.path.includes("/routine-triggers/")
? "Routine trigger not found" : "Routine not found" });
}
}
}
expect(await db.select().from(routines)).toMatchObject([{ id: routineId, title: "Private routine" }]);
expect(await db.select().from(routineTriggers)).toMatchObject([{ id: triggerId, enabled: true }]);
expect(await db.select().from(activityLog)).toEqual([]);
}, 20_000);
it("preserves UUID routine reads and assignee-only mutations", async () => {
const { companyId, agentId, userId } = await seedFixture();
const routineId = randomUUID();
const triggerId = randomUUID();
await db.insert(routines).values({ id: routineId, companyId, assigneeAgentId: agentId, title: "Private routine" });
await db.insert(routineTriggers).values({ id: triggerId, companyId, routineId, kind: "api" });
const owner = await createApp({ type: "agent", agentId, companyId });
const otherAgent = await createApp({ type: "agent", agentId: randomUUID(), companyId });
const board = await createApp({ type: "board", userId, source: "session", companyIds: [companyId] });
for (const app of [owner, otherAgent, board]) {
const response = await request(app).get(`/api/routines/${routineId.toUpperCase()}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(routineId);
}
for (const [index, pending] of routineResourceRequests(otherAgent, routineId, triggerId).entries()) {
const response = await pending;
expect(response.status, JSON.stringify(response.body)).toBe(index < 2 ? 200 : 403);
}
const updated = await request(owner).patch(`/api/routines/${routineId}`).send({ title: "Owner update" });
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
expect(updated.body.title).toBe("Owner update");
const changedTrigger = await request(board).patch(`/api/routine-triggers/${triggerId}`).send({ enabled: false });
expect(changedTrigger.status, JSON.stringify(changedTrigger.body)).toBe(200);
expect(changedTrigger.body.enabled).toBe(false);
}, 20_000);
it.each(["bearer", "hmac_sha256", "github_hmac", "none"] as const)(
"authenticates %s HTTP deliveries, persists payloads, and enforces trigger lifecycle",
async (signingMode) => {
@@ -246,6 +246,63 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
.then((rows) => rows[0]!);
}
it("rejects malformed routine and trigger IDs before any query", async () => {
const { routine, svc } = await seedFixture();
const malformed = [
"", routine.id.slice(0, 8), "not-a-uuid", `${routine.id} `, ` ${routine.id}`,
`${routine.id}\n`, `{${routine.id}`, `${routine.id}}`, `{${routine.id}\n}`,
`${routine.id}-`, routine.id.replace("-", "--"), `g${routine.id.slice(1)}`,
`${routine.id}\0`, `é${routine.id.slice(1)}`,
`${routine.id.slice(0, 3)}-${routine.id.slice(3)}`,
];
const select = vi.spyOn(db, "select");
try {
for (const id of malformed) {
await expect(svc.get(id)).resolves.toBeNull();
await expect(svc.getDetail(id)).resolves.toBeNull();
await expect(svc.getTrigger(id)).resolves.toBeNull();
}
expect(select).not.toHaveBeenCalled();
} finally {
select.mockRestore();
}
});
it("preserves lookup failures for valid routine and trigger IDs", async () => {
const { routine, svc } = await seedFixture();
const failure = new Error("Database unavailable");
const select = vi.spyOn(db, "select").mockImplementation(() => { throw failure; });
try {
await expect(svc.get(routine.id)).rejects.toBe(failure);
await expect(svc.getDetail(routine.id)).rejects.toBe(failure);
await expect(svc.getTrigger(routine.id)).rejects.toBe(failure);
} finally {
select.mockRestore();
}
});
it.each([
"12345678-1234-4234-8234-123456789abc",
"12345678-1234-7234-8234-123456789abc",
"00000000-0000-0000-0000-000000000000",
"ffffffff-ffff-ffff-ffff-ffffffffffff",
])("preserves PostgreSQL UUID lookup forms for %s", async (id) => {
const { companyId, svc } = await seedFixture();
await db.insert(routines).values({ id, companyId, title: "UUID lookup fixture" });
await db.insert(routineTriggers).values({ id, companyId, routineId: id, kind: "api" });
const compact = id.replaceAll("-", "");
const forms = [
id, id.toUpperCase(), `{${id}}`, compact,
compact.match(/.{4}/g)!.join("-"),
`{${compact.slice(0, 8)}-${compact.slice(8, 16)}-${compact.slice(16)}}`,
];
for (const input of forms) {
await expect(svc.get(input)).resolves.toMatchObject({ id, companyId });
await expect(svc.getDetail(input)).resolves.toMatchObject({ id, companyId });
await expect(svc.getTrigger(input)).resolves.toMatchObject({ id, companyId, routineId: id });
}
});
it("clears transient routine run failures when execution issues resume", async () => {
const { companyId, issueSvc, routine, svc } = await seedFixture();
const runId = randomUUID();