mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user