mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +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();
|
||||
|
||||
@@ -22,5 +22,6 @@ export function isWorkspaceValidationFailedRun(run: { errorCode: string | null }
|
||||
}
|
||||
|
||||
export function isConfigurationIncompleteFailedRun(run: { errorCode: string | null }): boolean {
|
||||
return run.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE || run.errorCode === "model_not_found";
|
||||
return run.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE || run.errorCode === "model_not_found" ||
|
||||
run.errorCode === "native_provider_model_rejected";
|
||||
}
|
||||
@@ -93,7 +93,7 @@ import {
|
||||
normalizeHumanRole,
|
||||
resolveHumanInviteRole,
|
||||
} from "../services/company-member-roles.js";
|
||||
import { humanJoinGrantsFromDefaults } from "../services/invite-grants.js";
|
||||
import { agentJoinGrantsFromDefaults as standardAgentJoinGrantsFromDefaults, humanJoinGrantsFromDefaults } from "../services/invite-grants.js";
|
||||
import {
|
||||
collapseDuplicatePendingHumanJoinRequests,
|
||||
findReusableHumanJoinRequest,
|
||||
@@ -2246,22 +2246,13 @@ function grantsFromDefaults(
|
||||
}
|
||||
|
||||
export function agentJoinGrantsFromDefaults(
|
||||
defaultsPayload: Record<string, unknown> | null | undefined
|
||||
defaultsPayload: Record<string, unknown> | null | undefined,
|
||||
agentId: string,
|
||||
): Array<{
|
||||
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||
scope: Record<string, unknown> | null;
|
||||
}> {
|
||||
const grants = grantsFromDefaults(defaultsPayload, "agent");
|
||||
if (grants.some((grant) => grant.permissionKey === "tasks:assign")) {
|
||||
return grants;
|
||||
}
|
||||
return [
|
||||
...grants,
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null
|
||||
}
|
||||
];
|
||||
return standardAgentJoinGrantsFromDefaults(defaultsPayload, agentId);
|
||||
}
|
||||
|
||||
type JoinRequestManagerCandidate = {
|
||||
@@ -4297,7 +4288,8 @@ export function accessRoutes(
|
||||
"active"
|
||||
);
|
||||
const grants = agentJoinGrantsFromDefaults(
|
||||
invite.defaultsPayload as Record<string, unknown> | null
|
||||
invite.defaultsPayload as Record<string, unknown> | null,
|
||||
created.id,
|
||||
);
|
||||
await access.setPrincipalGrants(
|
||||
companyId,
|
||||
|
||||
+103
-1
@@ -16,6 +16,7 @@ import { isAiConnectionCompatible } from "@paperclipai/shared";
|
||||
import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSkills, isConnectorSkill } from "../services/connector-runtime.js";
|
||||
import { getExecutionBlocker } from "../services/execution-blocker.js";
|
||||
import { canRetryStoppedRun } from "../services/cancelled-native-startup.js";
|
||||
import { getNativeReviewAssignment, readNativeReviewAssignmentContext, type NativeReviewAssignmentContext } from "../services/native-runtime/native-review-participant.js";
|
||||
import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils";
|
||||
import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js";
|
||||
import { selectDashboardRunIds } from "../services/dashboard-run-selection.js";
|
||||
@@ -2914,6 +2915,69 @@ export function agentRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
function assertNoAgentProcessAdapterMutation(req: Request, adapterType: string, mutatesConfiguration: boolean) {
|
||||
if (req.actor.type !== "agent" || adapterType !== "process" || !mutatesConfiguration) return;
|
||||
throw forbidden("Agent keys cannot configure host-executed process adapters.");
|
||||
}
|
||||
|
||||
const LOCAL_ADAPTER_HOST_COMMAND_KEYS = [
|
||||
"command",
|
||||
"hermesCommand",
|
||||
"args",
|
||||
"extraArgs",
|
||||
"cwd",
|
||||
"env",
|
||||
"filesystemSandboxCommand",
|
||||
] as const;
|
||||
|
||||
const LOCAL_ADAPTER_CREDENTIAL_ENV_KEYS: Record<string, readonly string[]> = {
|
||||
...INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS,
|
||||
gemini_local: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
||||
kimi_local: ["KIMI_MODEL_API_KEY"],
|
||||
opencode_local: ["PAPERCLIP_OPENCODE_PROVIDERS", "OPENCODE_AUTH_JSON"],
|
||||
};
|
||||
const POOL_AUTH_OVERRIDE_ENV_KEYS = [
|
||||
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "PAPERCLIP_OPENCODE_PROVIDERS", "OPENCODE_AUTH_JSON",
|
||||
] as const;
|
||||
|
||||
async function callerUsesAiConnectionPool(req: Request, companyId: string): Promise<boolean> {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId) return false;
|
||||
const caller = await svc.getById(req.actor.agentId);
|
||||
if (!caller || caller.companyId !== companyId) return false;
|
||||
return asRecord(asRecord(caller.runtimeConfig)?.aiConnection)?.mode === "router";
|
||||
}
|
||||
|
||||
function containsOnlyLocalAdapterCredentialRefs(
|
||||
adapterType: string,
|
||||
env: unknown,
|
||||
allowPlainCredentials: boolean,
|
||||
): boolean {
|
||||
const envRecord = asRecord(env);
|
||||
if (!envRecord) return false;
|
||||
const allowedKeys = LOCAL_ADAPTER_CREDENTIAL_ENV_KEYS[adapterType] ?? [];
|
||||
return Object.entries(envRecord).every(([key, value]) =>
|
||||
(allowedKeys.includes(key) && isInheritableCredentialReference(value)) ||
|
||||
(allowPlainCredentials && POOL_AUTH_OVERRIDE_ENV_KEYS.includes(key as typeof POOL_AUTH_OVERRIDE_ENV_KEYS[number]) && asEnvBindingString(value) !== null),
|
||||
);
|
||||
}
|
||||
|
||||
function assertNoAgentLocalAdapterHostCommandMutation(
|
||||
req: Request,
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
allowPlainCredentials = false,
|
||||
) {
|
||||
if (req.actor.type !== "agent" || !adapterType.endsWith("_local")) return;
|
||||
const changedKeys = LOCAL_ADAPTER_HOST_COMMAND_KEYS.filter((key) =>
|
||||
adapterConfig[key] !== undefined &&
|
||||
(key !== "env" || !containsOnlyLocalAdapterCredentialRefs(adapterType, adapterConfig.env, allowPlainCredentials)),
|
||||
);
|
||||
if (changedKeys.length === 0) return;
|
||||
throw forbidden(
|
||||
`Agent keys cannot configure host-executed local adapter settings (${changedKeys.join(", ")}).`,
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeAgentUpdateDetails(patch: Record<string, unknown>) {
|
||||
const changedTopLevelKeys = Object.keys(patch).sort();
|
||||
const details: Record<string, unknown> = { changedTopLevelKeys };
|
||||
@@ -4419,6 +4483,13 @@ export function agentRoutes(
|
||||
await assertSelectableAdapterType(rollbackAdapterType);
|
||||
}
|
||||
const rollbackAdapterConfig = asRecord(rollbackConfig.adapterConfig) ?? {};
|
||||
assertNoAgentAdapterConfigMutation(req, rollbackAdapterConfig);
|
||||
assertNoAgentLocalAdapterHostCommandMutation(req, rollbackAdapterType, rollbackAdapterConfig);
|
||||
assertNoAgentProcessAdapterMutation(
|
||||
req,
|
||||
rollbackAdapterType,
|
||||
true,
|
||||
);
|
||||
assertExternalInstructionsAdmin(req, existing);
|
||||
assertExternalInstructionsAdmin(req, {
|
||||
...existing,
|
||||
@@ -4581,6 +4652,8 @@ export function agentRoutes(
|
||||
rawHireAdapterConfig,
|
||||
);
|
||||
assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig);
|
||||
assertNoAgentLocalAdapterHostCommandMutation(req, hireInput.adapterType, rawHireAdapterConfig, await callerUsesAiConnectionPool(req, companyId));
|
||||
assertNoAgentProcessAdapterMutation(req, hireInput.adapterType, Object.keys(rawHireAdapterConfig).length > 0);
|
||||
const hiredAgentId = randomUUID();
|
||||
const authInheritance = await applyHiringAgentAuthInheritance(
|
||||
req,
|
||||
@@ -4890,6 +4963,8 @@ export function agentRoutes(
|
||||
rawCreateAdapterConfig,
|
||||
);
|
||||
assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig);
|
||||
assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType, rawCreateAdapterConfig, await callerUsesAiConnectionPool(req, companyId));
|
||||
assertNoAgentProcessAdapterMutation(req, createInput.adapterType, Object.keys(rawCreateAdapterConfig).length > 0);
|
||||
const agentId = randomUUID();
|
||||
const requestedAdapterConfig = applyCodexLocalKeyIsolation(
|
||||
companyId,
|
||||
@@ -5528,6 +5603,7 @@ export function agentRoutes(
|
||||
const touchesAdapterConfiguration =
|
||||
hasOwn(patchData, "adapterType") ||
|
||||
hasOwn(patchData, "adapterConfig");
|
||||
assertNoAgentProcessAdapterMutation(req, requestedAdapterType, touchesAdapterConfiguration);
|
||||
if (touchesAdapterConfiguration) {
|
||||
assertExternalInstructionsAdmin(req, existing);
|
||||
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
|
||||
@@ -5575,6 +5651,13 @@ export function agentRoutes(
|
||||
if (requestedAdapterType === "paperclip_runner") {
|
||||
rawEffectiveAdapterConfig = normalizePaperclipRunnerAdapterConfig(requestedAdapterType, rawEffectiveAdapterConfig);
|
||||
}
|
||||
if (changingAdapterType || requestedAdapterConfig) {
|
||||
assertNoAgentLocalAdapterHostCommandMutation(
|
||||
req,
|
||||
requestedAdapterType,
|
||||
changingAdapterType ? rawEffectiveAdapterConfig : (requestedAdapterConfig ?? {}),
|
||||
);
|
||||
}
|
||||
const existingRunnerProvider =
|
||||
existing.adapterType === "paperclip_runner"
|
||||
? existingAdapterConfig.provider
|
||||
@@ -6037,6 +6120,7 @@ export function agentRoutes(
|
||||
|
||||
let wakePayload = req.body.payload ?? null;
|
||||
let retryConversationContext: Record<string, unknown> = {};
|
||||
let retryNativeReviewContext: NativeReviewAssignmentContext | null = null;
|
||||
if (req.body.failedRunId) {
|
||||
assertBoard(req);
|
||||
if (
|
||||
@@ -6090,7 +6174,22 @@ export function agentRoutes(
|
||||
},
|
||||
});
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
if (issue.assigneeAgentId !== agent.id) throw conflict("The task is no longer assigned to this agent.");
|
||||
const savedReviewContext = readNativeReviewAssignmentContext(failedContext);
|
||||
if (failedRun.runtimeMode === "native" && failedRun.status === "failed" &&
|
||||
failedRun.errorCode === "native_provider_model_rejected" &&
|
||||
failedRun.nativeIssueId === issue.id && savedReviewContext) {
|
||||
const review = await getNativeReviewAssignment(db, {
|
||||
companyId: agent.companyId, issueId: issue.id, agentId: agent.id,
|
||||
contextSnapshot: savedReviewContext,
|
||||
});
|
||||
if (review?.interaction.status !== "pending") {
|
||||
throw conflict("The failed run's review is no longer current.");
|
||||
}
|
||||
retryNativeReviewContext = savedReviewContext;
|
||||
}
|
||||
if (issue.assigneeAgentId !== agent.id && !retryNativeReviewContext) {
|
||||
throw conflict("The task is no longer assigned to this agent.");
|
||||
}
|
||||
if (issue.conversationAgentId) {
|
||||
// Agent Chat has no task description to replay. Recover the exact
|
||||
// request from the selected server-owned run, never caller markers.
|
||||
@@ -6197,6 +6296,9 @@ export function agentRoutes(
|
||||
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||
contextSnapshot: {
|
||||
...retryConversationContext,
|
||||
// Only the selected server-owned run can carry review authority into
|
||||
// an exact retry. Dispatch revalidates the current review assignment.
|
||||
...retryNativeReviewContext,
|
||||
triggeredBy: req.actor.type,
|
||||
originIdentityContextId: req.actor.identityContextId ?? null,
|
||||
responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { issues, type Db } from "@paperclipai/db";
|
||||
import {
|
||||
browserUseControlSchema,
|
||||
@@ -15,10 +16,19 @@ import {
|
||||
} from "./authz.js";
|
||||
import { browserUseService } from "../services/browser-use.js";
|
||||
|
||||
const issueIdSchema = z.string().uuid();
|
||||
|
||||
export function browserUseRoutes(db: Db, service = browserUseService(db)) {
|
||||
const router = Router();
|
||||
router.use("/issues/:issueId/browsers", async (req, res, next) => {
|
||||
assertBoard(req);
|
||||
if (
|
||||
req.params.issueId !== req.params.issueId.trim() ||
|
||||
!issueIdSchema.safeParse(req.params.issueId).success
|
||||
) {
|
||||
res.status(404).json({ error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
const [issue] = await db
|
||||
.select({ companyId: issues.companyId })
|
||||
.from(issues)
|
||||
|
||||
@@ -7,6 +7,19 @@ const run = (overrides: Partial<Run> = {}) => ({ resultJson: null, ...overrides
|
||||
const collect = (error: unknown) => collectRunFailureDiagnostics(run(), { error });
|
||||
|
||||
describe("run failure diagnostics", () => {
|
||||
it("exports only the closed native model/auth rejection vocabulary", () => {
|
||||
const diagnostic = { provider: "codex", category: "model_auth_incompatible", status: 400, authMode: "chatgpt" };
|
||||
const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: {
|
||||
nativeProviderFailure: { ...diagnostic, model: "private-model", response: "private-provider-response", prompt: "private-prompt" },
|
||||
} }), {}));
|
||||
expect(result.provider).toEqual(diagnostic);
|
||||
expect(JSON.stringify(result)).not.toContain("private");
|
||||
for (const value of [null, [], { ...diagnostic, category: "private-category" },
|
||||
Object.defineProperty({}, "provider", { get() { throw new Error("private"); } })]) {
|
||||
expect(collectRunFailureDiagnostics(run({ resultJson: { nativeProviderFailure: value } }), {}).provider).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
it("selects bounded lock-owner evidence from a caught timeout cause", () => {
|
||||
const error = new Error("outer", { cause: Object.assign(new Error("lock timeout"), {
|
||||
code: "ERR_WORKSPACE_RESTORE_LOCK_TIMEOUT",
|
||||
|
||||
@@ -403,6 +403,9 @@ export function activityService(db: Db) {
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
error: sql<string | null>`case when ${heartbeatRuns.status} = 'failed'
|
||||
and ${heartbeatRuns.errorCode} = 'native_provider_model_rejected'
|
||||
then left(${heartbeatRuns.error}, 2000) else null end`,
|
||||
usageJson: summarizedUsageJson,
|
||||
resultJson: summarizedResultJson,
|
||||
logBytes: heartbeatRuns.logBytes,
|
||||
@@ -497,7 +500,7 @@ export function activityService(db: Db) {
|
||||
const [exhaustionRows, leaseRows, executionByRunId, [savedPlan]] = await Promise.all([
|
||||
exhaustionRowsQuery,
|
||||
leaseRowsQuery,
|
||||
executionProjectionsForRuns(db, companyId, runIds),
|
||||
executionProjectionsForRuns(db, companyId, runIds, new Date(), { retryDatabaseReads: true }),
|
||||
savedPlanQuery,
|
||||
]);
|
||||
const retryExhaustedReasonByRunId = new Map<string, string>();
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
||||
import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared";
|
||||
|
||||
/** Direct grants for newly activated standard agents. Existing agents are not backfilled. */
|
||||
export const NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS = [
|
||||
"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",
|
||||
] as const satisfies readonly PermissionKey[];
|
||||
|
||||
export function newStandardAgentGrantScope(
|
||||
permissionKey: PermissionKey,
|
||||
agentId: string,
|
||||
): Record<string, unknown> | null {
|
||||
if (permissionKey === "tasks:assign_scope") return { subtreeRootAgentId: agentId };
|
||||
if (permissionKey === "inbox:manage") return { responsibleUserOnly: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
export type NormalizedAgentPermissions = Record<string, unknown> & {
|
||||
canCreateAgents: boolean;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
issueExecutionDecisions,
|
||||
issues,
|
||||
issueComments,
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
@@ -38,8 +39,8 @@ import {
|
||||
syncAgentAdapterEnvBindings,
|
||||
} from "./agent-secret-bindings.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope, normalizeAgentPermissions, permissionsImplyLowTrust } from "./agent-permissions.js";
|
||||
import { recordAgentStatusEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
|
||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||
import {
|
||||
assertClaudeOAuthBindingInvariant,
|
||||
@@ -944,6 +945,20 @@ export function agentService(db: Db) {
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
// New standard agents receive the standard direct grants at activation.
|
||||
// Low-trust and bundled agents keep their explicit, narrower grants.
|
||||
if (created.status !== "pending_approval" && !permissionsImplyLowTrust(normalizedPermissions) &&
|
||||
!readBuiltInAgentMarker(created.metadata)) {
|
||||
await tx.insert(principalPermissionGrants).values(
|
||||
NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
|
||||
companyId,
|
||||
principalType: "agent" as const,
|
||||
principalId: created.id,
|
||||
permissionKey,
|
||||
scope: newStandardAgentGrantScope(permissionKey, created.id),
|
||||
})),
|
||||
).onConflictDoNothing();
|
||||
}
|
||||
if (options?.aiConnectionInstall) {
|
||||
const install = options.aiConnectionInstall;
|
||||
const connectionIds = [...new Set([install.connectionId, ...(install.memberConnectionIds ?? [])])];
|
||||
@@ -1084,6 +1099,11 @@ export function agentService(db: Db) {
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.agentId, id));
|
||||
await tx.delete(principalPermissionGrants).where(and(
|
||||
eq(principalPermissionGrants.companyId, existing.companyId),
|
||||
eq(principalPermissionGrants.principalType, "agent"),
|
||||
eq(principalPermissionGrants.principalId, id),
|
||||
));
|
||||
const deleted = await tx
|
||||
.delete(agents)
|
||||
.where(eq(agents.id, id))
|
||||
@@ -1151,6 +1171,17 @@ export function agentService(db: Db) {
|
||||
});
|
||||
}
|
||||
await syncAgentSecretBindings(updated, txDb, existing.adapterConfig);
|
||||
if (!permissionsImplyLowTrust(updated.permissions) && !readBuiltInAgentMarker(updated.metadata)) {
|
||||
await tx.insert(principalPermissionGrants).values(
|
||||
NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
|
||||
companyId: updated.companyId,
|
||||
principalType: "agent" as const,
|
||||
principalId: updated.id,
|
||||
permissionKey,
|
||||
scope: newStandardAgentGrantScope(permissionKey, updated.id),
|
||||
})),
|
||||
).onConflictDoNothing();
|
||||
}
|
||||
await recordResourceCreationEvent(txDb, existing.companyId, "agent", updated.id);
|
||||
const agent = await agentService(txDb).getById(updated.id);
|
||||
if (!agent) {
|
||||
|
||||
@@ -2060,7 +2060,8 @@ export function authorizationService(db: Db | DbTransaction) {
|
||||
// implicit default-open policy remains responsible-user-only so an
|
||||
// absent row never becomes a company-wide cross-user grant.
|
||||
const grant = await findGrant(companyId, "agent", actorAgentId, "inbox:manage");
|
||||
if (grant && (await scopeAllows(db, companyId, grant.scope, { userId: targetUserId }))) {
|
||||
if (grant && grant.scope?.responsibleUserOnly !== true &&
|
||||
(await scopeAllows(db, companyId, grant.scope, { userId: targetUserId }))) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { getTableName, type SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { activityService } from "./activity.js";
|
||||
import { executionProjectionsForRuns } from "./execution-projection.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const companyId = "00000000-0000-4000-8000-000000000001";
|
||||
const issueId = "00000000-0000-4000-8000-000000000002";
|
||||
const runId = "00000000-0000-4000-8000-000000000003";
|
||||
const now = new Date("2026-01-01T00:00:00Z");
|
||||
const tables = ["heartbeat_runs", "native_run_finalizations", "issue_thread_interactions", "issue_recovery_actions"];
|
||||
const dialect = new PgDialect();
|
||||
function disconnected(code = "CONNECTION_CLOSED") {
|
||||
return new Error("Failed query", { cause: Object.assign(new Error("Connection failed"), { code }) });
|
||||
}
|
||||
|
||||
function fixture(options: {
|
||||
failures?: Record<string, Error[]>;
|
||||
missing?: boolean;
|
||||
noIssue?: boolean;
|
||||
backfill?: boolean;
|
||||
writeFailure?: Error;
|
||||
} = {}) {
|
||||
const attempts: Array<{ key: string; params: unknown[]; order: string[] }> = [];
|
||||
const row = {
|
||||
id: runId, companyId, runId, status: "succeeded", runtimeMode: "native", errorCode: null,
|
||||
nativeIssueId: options.noIssue ? null : issueId, contextSnapshot: {},
|
||||
finishedAt: now, startedAt: now, createdAt: now, resultJson: null, usageJson: null,
|
||||
scheduledRetryAt: null, scheduledRetryAttempt: 0, retryOfRunId: null, nextAction: null,
|
||||
};
|
||||
const select = vi.fn((fields: Record<string, unknown> = {}) => ({
|
||||
from(table: Parameters<typeof getTableName>[0]) {
|
||||
const name = getTableName(table);
|
||||
const key = name === "heartbeat_runs" && "runId" in fields ? "run_list"
|
||||
: name === "heartbeat_runs" && "stdoutExcerpt" in fields ? "liveness_backfill"
|
||||
: name;
|
||||
let condition: SQL;
|
||||
let order: SQL[] = [];
|
||||
let result: Promise<unknown[]> | undefined;
|
||||
const query = {
|
||||
where(value: SQL) { condition = value; return query; },
|
||||
innerJoin() { return query; },
|
||||
orderBy(...values: SQL[]) { order = values; return query; },
|
||||
limit() { return query; },
|
||||
then(resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) {
|
||||
// Re-awaiting the same failed builder cannot recover the connection.
|
||||
result ??= Promise.resolve().then(() => {
|
||||
const attempt = attempts.filter((read) => read.key === key).length;
|
||||
attempts.push({ key, params: dialect.sqlToQuery(condition).params,
|
||||
order: order.map((value) => dialect.sqlToQuery(value).sql) });
|
||||
const error = options.failures?.[key]?.[attempt];
|
||||
if (error) throw error;
|
||||
if (options.missing) return [];
|
||||
if (key === "heartbeat_runs" || key === "run_list") return [row];
|
||||
if (key === "liveness_backfill") return options.backfill ? [row] : [];
|
||||
if (key === "issues") return [{ status: "in_progress", title: "Fixture", workMode: "standard" }];
|
||||
if (key === "issue_thread_interactions") return [{ issueId, kind: "ask_user_questions" }];
|
||||
return [];
|
||||
});
|
||||
return result.then(resolve, reject);
|
||||
},
|
||||
};
|
||||
return query;
|
||||
},
|
||||
}));
|
||||
const updateWhere = vi.fn(async () => { if (options.writeFailure) throw options.writeFailure; });
|
||||
const update = vi.fn(() => ({ set: () => ({ where: updateWhere }) }));
|
||||
const transaction = vi.fn(() => { throw new Error("Must not start or replay a transaction"); });
|
||||
const db = { select, update, transaction } as unknown as Db;
|
||||
return { db, attempts, select, update, updateWhere, transaction,
|
||||
count: (key: string) => attempts.filter((read) => read.key === key).length };
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("execution projection read retries", () => {
|
||||
it.each(tables)("rebuilds only the failed %s SELECT within the existing retry budget", async (table) => {
|
||||
const test = fixture({ failures: { [table]: [disconnected(), disconnected("CONNECTION_ENDED")] } });
|
||||
const result = await executionProjectionsForRuns(test.db, companyId, [runId], now, { retryDatabaseReads: true });
|
||||
expect(result.get(runId)).toMatchObject({ phase: "waiting_for_answer", label: "Waiting for answer" });
|
||||
for (const name of tables) expect(test.count(name)).toBe(name === table ? 3 : 1);
|
||||
expect(test.select).toHaveBeenCalledTimes(6);
|
||||
const reads = test.attempts.filter((read) => read.key === table);
|
||||
expect(reads[1]).toEqual(reads[0]);
|
||||
expect(reads[2]).toEqual(reads[0]);
|
||||
expect(reads[0].params).toContain(companyId);
|
||||
expect(reads[0].params).toContain(table === "heartbeat_runs" || table === "native_run_finalizations" ? runId : issueId);
|
||||
if (table === "issue_thread_interactions") expect(reads[0].params).toContain("pending");
|
||||
if (table === "issue_recovery_actions") expect(reads[0].order).toEqual(['"issue_recovery_actions"."updated_at" desc']);
|
||||
expect(test.update).not.toHaveBeenCalled();
|
||||
expect(test.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(tables)("preserves the last %s error after three attempts", async (table) => {
|
||||
const errors = [disconnected(), disconnected(), disconnected()];
|
||||
const test = fixture({ failures: { [table]: errors } });
|
||||
await expect(executionProjectionsForRuns(test.db, companyId, [runId], now, { retryDatabaseReads: true })).rejects.toBe(errors[2]);
|
||||
for (const [index, name] of tables.entries()) {
|
||||
expect(test.count(name)).toBe(index < tables.indexOf(table) ? 1 : name === table ? 3 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(tables)("keeps the default transaction-compatible %s path single-attempt", async (table) => {
|
||||
const error = disconnected();
|
||||
const test = fixture({ failures: { [table]: [error] } });
|
||||
await expect(executionProjectionsForRuns(test.db, companyId, [runId], now)).rejects.toBe(error);
|
||||
expect(test.count(table)).toBe(1);
|
||||
expect(test.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([new Error("CONNECTION_CLOSED"), disconnected("28P01"), disconnected("42501"), disconnected("22P02"), disconnected("25P02")])(
|
||||
"does not retry an unclassified or non-transient error", async (error) => {
|
||||
const test = fixture({ failures: { issue_thread_interactions: [error] } });
|
||||
await expect(executionProjectionsForRuns(test.db, companyId, [runId], now, { retryDatabaseReads: true })).rejects.toBe(error);
|
||||
expect(test.count("issue_thread_interactions")).toBe(1);
|
||||
expect(test.count("issue_recovery_actions")).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves empty and taskless projection behavior without unnecessary reads", async () => {
|
||||
const empty = fixture();
|
||||
expect(await executionProjectionsForRuns(empty.db, companyId, [], now, { retryDatabaseReads: true })).toEqual(new Map());
|
||||
expect(empty.select).not.toHaveBeenCalled();
|
||||
for (const options of [{ missing: true }, { noIssue: true }]) {
|
||||
const test = fixture(options);
|
||||
const result = await executionProjectionsForRuns(test.db, companyId, [runId], now, { retryDatabaseReads: true });
|
||||
expect(test.select).toHaveBeenCalledTimes(2);
|
||||
expect(options.missing ? result.size : result.get(runId)?.phase).toBe(options.missing ? 0 : "completed");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("the run list retries projection reads without replaying other reads or backfill writes (write fails: %s)", async (writeFails) => {
|
||||
vi.spyOn(logger, "warn").mockImplementation(() => undefined);
|
||||
const test = fixture({ backfill: true, writeFailure: writeFails ? disconnected() : undefined,
|
||||
failures: { issue_thread_interactions: [disconnected(), disconnected()] } });
|
||||
const result = await activityService(test.db).runsForIssue(companyId, issueId);
|
||||
expect(result).toMatchObject([{ runId, execution: { phase: "waiting_for_answer" } }]);
|
||||
expect(test.count("issue_thread_interactions")).toBe(3);
|
||||
for (const key of ["run_list", "liveness_backfill", "heartbeat_runs", "native_run_finalizations", "environment_leases", "issue_recovery_actions"]) {
|
||||
expect(test.count(key)).toBe(1);
|
||||
}
|
||||
// One retry-exhaustion lookup plus the independent backfill's event count.
|
||||
expect(test.count("heartbeat_run_events")).toBe(2);
|
||||
expect(test.update).toHaveBeenCalledTimes(1);
|
||||
expect(test.updateWhere).toHaveBeenCalledTimes(1);
|
||||
expect(test.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { ExecutionProjection } from "@paperclipai/shared";
|
||||
import { EXECUTION_CONTROL_DEADLINE_MS } from "./execution-control-deadline.js";
|
||||
import { executionFailureRetryCount } from "./execution-recovery-attempt.js";
|
||||
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
|
||||
const text = (v: unknown) => (typeof v === "string" ? v : null);
|
||||
const executionRunColumns = {
|
||||
id: heartbeatRuns.id,
|
||||
@@ -48,10 +49,16 @@ export async function executionProjectionsForRuns(
|
||||
companyId: string,
|
||||
runIds: string[],
|
||||
now = new Date(),
|
||||
options: { retryDatabaseReads?: boolean } = {},
|
||||
) {
|
||||
const projections = new Map<string, ExecutionProjection>();
|
||||
if (!runIds.length) return projections;
|
||||
const runs = await db
|
||||
// Opt in only with a pooled Db outside a transaction. Issue enrichment also
|
||||
// calls this helper with a transaction, whose failed reads must not replay.
|
||||
const read = <T>(query: () => Promise<T>) => options.retryDatabaseReads
|
||||
? retryIdempotentDatabaseOperation(query)
|
||||
: query();
|
||||
const runs = await read(async () => db
|
||||
.select(executionRunColumns)
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
@@ -59,8 +66,8 @@ export async function executionProjectionsForRuns(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(heartbeatRuns.id, runIds),
|
||||
),
|
||||
);
|
||||
const coordinators = await db
|
||||
));
|
||||
const coordinators = await read(async () => db
|
||||
.select()
|
||||
.from(nativeRunFinalizations)
|
||||
.where(
|
||||
@@ -68,7 +75,7 @@ export async function executionProjectionsForRuns(
|
||||
eq(nativeRunFinalizations.companyId, companyId),
|
||||
inArray(nativeRunFinalizations.runId, runIds),
|
||||
),
|
||||
);
|
||||
));
|
||||
const issueIds = [
|
||||
...new Set(
|
||||
runs
|
||||
@@ -77,7 +84,7 @@ export async function executionProjectionsForRuns(
|
||||
),
|
||||
];
|
||||
const pending = issueIds.length
|
||||
? await db
|
||||
? await read(async () => db
|
||||
.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
kind: issueThreadInteractions.kind,
|
||||
@@ -89,10 +96,10 @@ export async function executionProjectionsForRuns(
|
||||
inArray(issueThreadInteractions.issueId, issueIds),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
),
|
||||
)
|
||||
))
|
||||
: [];
|
||||
const recovery = issueIds.length
|
||||
? await db
|
||||
? await read(async () => db
|
||||
.select({
|
||||
issueId: issueRecoveryActions.sourceIssueId,
|
||||
cause: issueRecoveryActions.cause,
|
||||
@@ -112,7 +119,7 @@ export async function executionProjectionsForRuns(
|
||||
]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issueRecoveryActions.updatedAt))
|
||||
.orderBy(desc(issueRecoveryActions.updatedAt)))
|
||||
: [];
|
||||
const coordinatorByRun = new Map(coordinators.map((row) => [row.runId, row]));
|
||||
for (const run of runs) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PERMISSION_KEYS } from "@paperclipai/shared";
|
||||
import type { HumanCompanyMembershipRole } from "@paperclipai/shared";
|
||||
import { NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope } from "./agent-permissions.js";
|
||||
import { grantsForHumanRole } from "./company-member-roles.js";
|
||||
|
||||
export function grantsFromDefaults(
|
||||
@@ -38,22 +39,21 @@ export function grantsFromDefaults(
|
||||
}
|
||||
|
||||
export function agentJoinGrantsFromDefaults(
|
||||
defaultsPayload: Record<string, unknown> | null | undefined
|
||||
defaultsPayload: Record<string, unknown> | null | undefined,
|
||||
agentId: string,
|
||||
): Array<{
|
||||
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||
scope: Record<string, unknown> | null;
|
||||
}> {
|
||||
const grants = grantsFromDefaults(defaultsPayload, "agent");
|
||||
if (grants.some((grant) => grant.permissionKey === "tasks:assign")) {
|
||||
return grants;
|
||||
// Invitation approval replaces the whole grant set after agent creation.
|
||||
// Retain new-agent defaults, but never widen an explicitly scoped grant.
|
||||
for (const permissionKey of NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS) {
|
||||
if (!grants.some((grant) => grant.permissionKey === permissionKey)) {
|
||||
grants.push({ permissionKey, scope: newStandardAgentGrantScope(permissionKey, agentId) });
|
||||
}
|
||||
}
|
||||
return [
|
||||
...grants,
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
},
|
||||
];
|
||||
return grants;
|
||||
}
|
||||
|
||||
export function humanJoinGrantsFromDefaults(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { heartbeatRunEvents, heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { parseNativeExecutionInput, validatePrpEvent, type PrpTerminalState } from "../../vendor/paperclip-runner/index.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { createNativeProviderFailureObservation } from "./native-provider-failure.js";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
/** Re-derive authority from immutable run-bound evidence, including after restart. */
|
||||
export async function readPersistedNativeModelRejection(
|
||||
db: Db,
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
turnId: string | null,
|
||||
terminal: PrpTerminalState,
|
||||
) {
|
||||
if (run.runtimeMode !== "native" || !turnId || !run.nativeSessionId || !run.runnerInstanceId ||
|
||||
terminal.turnTerminalState !== "failed" || terminal.runTerminalState !== "failed") return null;
|
||||
let execution;
|
||||
try { execution = parseNativeExecutionInput(record(run.runnerProfileJson).nativeExecutionInput); } catch { return null; }
|
||||
if (execution.provider.kind !== "codex" || !execution.provider.model ||
|
||||
execution.binding.runId !== run.id || execution.binding.companyId !== run.companyId ||
|
||||
execution.binding.agentId !== run.agentId || execution.binding.issueId !== run.nativeIssueId ||
|
||||
execution.session.normalizedSessionId !== run.nativeSessionId ||
|
||||
execution.completionContract.id !== run.completionContractId ||
|
||||
execution.completionContract.sha256 !== run.completionContractSha256) return null;
|
||||
|
||||
const [row] = await db.select().from(heartbeatRunEvents).where(and(
|
||||
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
||||
eq(heartbeatRunEvents.sourceInstanceId, run.runnerInstanceId), eq(heartbeatRunEvents.eventType, "turn.failed"),
|
||||
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'turnId' = ${turnId}`,
|
||||
)).orderBy(desc(heartbeatRunEvents.seq)).limit(1);
|
||||
if (!row) return null;
|
||||
const parsed = validatePrpEvent(record(row.payload).prpEvent);
|
||||
if (!parsed.ok || parsed.event.runId !== run.id || parsed.event.normalizedSessionId !== run.nativeSessionId ||
|
||||
parsed.event.sourceKind !== "runner" || parsed.event.sourceInstanceId !== run.runnerInstanceId ||
|
||||
parsed.event.sourceEventId !== row.sourceEventId || parsed.event.sourceSeq !== row.sourceSeq ||
|
||||
parsed.event.eventType !== row.eventType || parsed.event.turnId !== turnId ||
|
||||
row.sourcePayloadSha256 !== nativeSha256(parsed.event)) return null;
|
||||
const observed = createNativeProviderFailureObservation(execution.provider);
|
||||
observed.observe(parsed.event);
|
||||
return observed.forTerminal(turnId, terminal);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, agentWakeupRequests, companies, completionContracts, createDb, heartbeatRunEvents, heartbeatRuns, issueRecoveryActions, issueThreadInteractions, issues, nativeRunFinalizations, nativeRunResults, statusDecisions, workAssessments } from "@paperclipai/db";
|
||||
import type { ControlPlanePort, NativeExecutionInputV1, PrpEvent, PrpTerminalState } from "@paperclipai/paperclip-runner";
|
||||
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { CONTROL_PLANE_CONFORMANCE_RESULT } from "../../vendor/paperclip-runner/testing.js";
|
||||
|
||||
const provider = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../vendor/paperclip-runner/index.js")>()),
|
||||
executeNativeSession: provider.execute,
|
||||
}));
|
||||
|
||||
import { executePaperclipNativeSession } from "./native-session-executor.js";
|
||||
import { buildNativeCompletionContract } from "./completion-contracts.js";
|
||||
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
|
||||
import { prepareNativeHeartbeatRun } from "./prepare-native-run.js";
|
||||
import { NATIVE_MODEL_REJECTION_DIAGNOSTIC, NATIVE_MODEL_REJECTION_MESSAGE } from "./native-provider-failure.js";
|
||||
import { collectRunFailureDiagnostics } from "../run-failure-diagnostics.js";
|
||||
import { finalizeNativeRun } from "./native-run-finalizer.js";
|
||||
import { readPersistedNativeModelRejection } from "./native-provider-failure-evidence.js";
|
||||
import { heartbeatService } from "../heartbeat.js";
|
||||
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js";
|
||||
import { claimQueuedNativeReviewRun } from "./native-review-dispatch.js";
|
||||
import { commitNativeStatusDecision, NativeStatusRaceError } from "./status-decision-committer.js";
|
||||
import { NATIVE_STATUS_ARBITER_POLICY_VERSION } from "./status-arbiter.js";
|
||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||
import { createReleaseIssueExecution } from "../../modules/wake-queue/application/use-cases.js";
|
||||
import { createPostgresWakeQueueAdapter } from "../../modules/wake-queue/adapters/postgres.js";
|
||||
|
||||
describe("native completed failure diagnostics", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const workerId = randomUUID();
|
||||
const model = "test-model";
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("native-model-failure-");
|
||||
db = createDb(temporary.connectionString);
|
||||
await db.insert(companies).values({ id: companyId, name: "Native failure regression", issuePrefix: "NMF" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Native failure agent", status: "active", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model } });
|
||||
await db.insert(agents).values({ id: workerId, companyId, name: "Review source worker", status: "active", adapterType: "paperclip_runner" });
|
||||
});
|
||||
afterAll(async () => { await temporary?.cleanup(); });
|
||||
|
||||
it.each(["committed", "duplicate", "pending-review", "superseded-review", "resolved-review", "reassigned", "successor-race", "different-turn", "unknown-error", "untrusted-source", "untrusted-run", "untrusted-session", "succeeded", "cancelled"] as const)(
|
||||
"preserves accepted results and only labels the matching provider rejection (%s)", async (scenario) => {
|
||||
const issueId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Explain a provider rejection", status: "in_progress", workMode: "standard", assigneeAgentId: agentId }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } }).returning();
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
||||
const native = await prepareNativeHeartbeatRun({ db, run, issue, environmentLeaseId: randomUUID() });
|
||||
const [contract] = await db.select().from(completionContracts).where(eq(completionContracts.issueId, issueId));
|
||||
const completionInput = buildNativeCompletionContract(issue, { revision: contract.revision });
|
||||
await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "observed" });
|
||||
const execution: NativeExecutionInputV1 = {
|
||||
schema: "paperclip.native-execution-input.v1",
|
||||
binding: { companyId, runId, issueId, agentId, executionWorkspaceId: runId },
|
||||
provider: { kind: "codex", model },
|
||||
task: { identifier: issueId, title: issue.title, description: null, prompt: "Reply to the current message.", workMode: "standard" },
|
||||
workspace: { cwd: "/tmp", repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: native.normalizedSessionId, driverKind: "codex_app_server", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
|
||||
completionContract: { id: contract.id, sha256: contract.canonicalSha256, schemaVersion: "paperclip.completion-contract.v1", contract: completionInput },
|
||||
interactionResponses: [], credentialBindings: [],
|
||||
};
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeExecutionInput: execution } }).where(eq(heartbeatRuns.id, runId));
|
||||
if (scenario.endsWith("-review")) {
|
||||
const sourceRunId = randomUUID(), sourceResultId = randomUUID(), assessmentId = randomUUID();
|
||||
const decisionId = randomUUID(), interactionId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId: workerId, nativeIssueId: issueId, runtimeMode: "native", status: "succeeded", completionContractId: contract.id, completionContractSha256: contract.canonicalSha256 });
|
||||
await db.insert(nativeRunResults).values({ id: sourceResultId, companyId, issueId, runId: sourceRunId, completionContractId: contract.id, serverFingerprint: sourceResultId, schemaStatus: "accepted", resultJson: {}, canonicalSha256: sourceResultId });
|
||||
await db.insert(workAssessments).values({ id: assessmentId, companyId, issueId, runId: sourceRunId, contractId: contract.id, resultId: sourceResultId, triggerKind: "native_result", triggerActorCompanyId: companyId, priorIssueStatus: "in_progress", priorStatusVersion: 0, policyVersion: "fixture", assessmentJson: {}, inputDigest: assessmentId });
|
||||
const [reviewIssue] = await db.update(issues).set({ status: "in_review", assigneeAgentId: workerId }).where(eq(issues.id, issueId)).returning();
|
||||
await db.insert(statusDecisions).values({ id: decisionId, companyId, issueId, runId: sourceRunId, assessmentId, decisionVersion: 1, policyVersion: "fixture", fromStatus: "in_progress", toStatus: "in_review", reasonCode: "explicit_review", decisionJson: { projectedStatusVersion: reviewIssue.statusVersion }, decisionDigest: decisionId, applicationState: "applied" });
|
||||
await db.update(issues).set({ lastStatusDecisionId: decisionId }).where(eq(issues.id, issueId));
|
||||
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId, kind: "request_confirmation", sourceRunId, addresseeAgentId: agentId, createdByAgentId: workerId,
|
||||
continuationPolicy: "wake_assignee",
|
||||
status: scenario === "resolved-review" ? "accepted" : "pending",
|
||||
...(scenario === "resolved-review" ? { resolvedByRunId: runId, resolvedByAgentId: agentId } : {}),
|
||||
payload: { version: 1, prompt: "Review the completed work.", target: { type: "custom", key: "native_completion_review", revisionId: decisionId } },
|
||||
});
|
||||
const contextSnapshot = { issueId, wakeReason: "native_completion_review", nativeReviewInteractionId: interactionId, nativeReviewDecisionId: decisionId };
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot }).where(eq(heartbeatRuns.id, runId));
|
||||
if (scenario === "superseded-review") await db.update(issues).set({ lastStatusDecisionId: null }).where(eq(issues.id, issueId));
|
||||
const review = await getNativeReviewAssignment(db, { companyId, issueId, agentId, contextSnapshot, allowResolvedByRunId: runId });
|
||||
if (scenario === "superseded-review") expect(review).toBeNull();
|
||||
else expect(review?.interaction.status).toBe(scenario === "resolved-review" ? "accepted" : "pending");
|
||||
}
|
||||
const event: PrpEvent = {
|
||||
schema: "paperclip.prp.event.v1", runId, normalizedSessionId: native.normalizedSessionId,
|
||||
turnId: native.turnId, sourceInstanceId: native.runnerInstanceId, sourceEventId: "model-terminal", sourceSeq: 1,
|
||||
sourceKind: "runner", eventType: "turn.failed", schemaVersion: 1, priority: 0, emittedAt: new Date().toISOString(),
|
||||
payload: { status: "failed", error: { codexErrorInfo: "other", message: JSON.stringify({
|
||||
type: "error", status: 400, error: { type: "invalid_request_error", message: scenario === "unknown-error" ? "Private unknown failure" : `The '${model}' model is not supported when using Codex with a ChatGPT account.` },
|
||||
}) } },
|
||||
};
|
||||
const openInput = { identity: { companyId, issueId, runId, agentId, sessionId: native.normalizedSessionId }, backendKind: "mock" as const, sourceInstanceId: native.runnerInstanceId };
|
||||
if (scenario === "duplicate") {
|
||||
const prior = new PaperclipControlPlanePort(db, { companyId, issueId, runId, agentId, sessionId: native.normalizedSessionId, sourceInstanceId: native.runnerInstanceId, controlPlaneSourceInstanceId: "prior-controller", completionContractId: contract.id, completionContractSha256: contract.canonicalSha256 });
|
||||
await prior.openRun(openInput);
|
||||
expect((await prior.appendEvent(event)).disposition).toBe("committed");
|
||||
}
|
||||
const turnId = scenario === "different-turn" ? "later-turn" : native.turnId;
|
||||
const terminal: PrpTerminalState = {
|
||||
schema: "paperclip.prp.terminal.v1", turnTerminalState: scenario === "succeeded" ? "completed" : scenario === "cancelled" ? "cancelled" : "failed",
|
||||
runTerminalState: scenario === "succeeded" ? "succeeded" : scenario === "cancelled" ? "cancelled" : "failed", reportedWorkDisposition: "blocked",
|
||||
};
|
||||
const result = { ...CONTROL_PLANE_CONFORMANCE_RESULT, reportedWorkDisposition: "blocked" as const,
|
||||
blocker: { reasonCode: "provider_rejected", owner: { kind: "user" as const, name: "Task owner" }, unblockAction: "Select a compatible connection.", scope: "task_wide" as const },
|
||||
completionClaim: { ...CONTROL_PLANE_CONFORMANCE_RESULT.completionClaim, contractRevision: completionInput.revision } };
|
||||
provider.execute.mockReset().mockImplementation(async (options: { controlPlane: ControlPlanePort }) => {
|
||||
await options.controlPlane.openRun(openInput);
|
||||
if (scenario === "untrusted-source") {
|
||||
await expect(options.controlPlane.appendEvent({ ...event, sourceInstanceId: "wrong-runner" })).rejects.toThrow("native_event_source_binding_mismatch");
|
||||
} else if (scenario === "untrusted-run" || scenario === "untrusted-session") {
|
||||
await expect(options.controlPlane.appendEvent({ ...event,
|
||||
...(scenario === "untrusted-run" ? { runId: randomUUID() } : { normalizedSessionId: "wrong-session" }),
|
||||
})).rejects.toThrow("native_event_binding_mismatch");
|
||||
} else {
|
||||
expect((await options.controlPlane.appendEvent(event)).disposition).toBe(scenario === "duplicate" ? "duplicate" : "committed");
|
||||
}
|
||||
await options.controlPlane.completeRun({ result, terminal, turnId });
|
||||
return { result, terminal, turnId, normalizedSessionId: native.normalizedSessionId, providerSessionId: "fixture-provider-session", driverKind: "codex_app_server", driverVersion: "fixture", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null };
|
||||
});
|
||||
const adapter = await executePaperclipNativeSession({ db, execution, runnerInstanceId: native.runnerInstanceId, backend: {
|
||||
descriptor: async () => { throw new Error("Unexpected provider call"); },
|
||||
openSession: async () => { throw new Error("Unexpected provider call"); },
|
||||
} });
|
||||
const classified = scenario === "committed" || scenario === "duplicate" || scenario.endsWith("-review") || scenario === "reassigned" || scenario === "successor-race";
|
||||
const reviewAuthorityEnded = scenario === "superseded-review" || scenario === "resolved-review";
|
||||
const ownerChanged = scenario === "reassigned" || scenario === "successor-race";
|
||||
expect(adapter.errorCode).toBe(classified ? "native_provider_model_rejected" : undefined);
|
||||
expect(adapter.errorMessage).toBe(classified ? NATIVE_MODEL_REJECTION_MESSAGE : scenario === "succeeded" ? null : `Native session ${terminal.runTerminalState}`);
|
||||
expect(adapter.resultJson?.nativeProviderFailure).toEqual(classified ? NATIVE_MODEL_REJECTION_DIAGNOSTIC : undefined);
|
||||
expect(adapter.nativeFinalization).toMatchObject({ result, terminal, turnId, workspaceFinalizeStatus: "pending" });
|
||||
expect(provider.execute).toHaveBeenCalledTimes(1);
|
||||
expect(execution.provider).toEqual({ kind: "codex", model });
|
||||
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId));
|
||||
expect(coordinator).toMatchObject({ phase: "workspace_finalizing", leaseOwner: null, attempt: 1, nextAttemptAt: null });
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))).toHaveLength(1);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(0);
|
||||
expect(await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId))).toHaveLength(scenario.startsWith("untrusted-") ? 0 : 1);
|
||||
const diagnostics = collectRunFailureDiagnostics({ ...run, resultJson: adapter.resultJson ?? null }, {});
|
||||
expect(diagnostics.provider).toEqual(classified ? NATIVE_MODEL_REJECTION_DIAGNOSTIC : {});
|
||||
expect(JSON.stringify(diagnostics)).not.toContain(model);
|
||||
expect(JSON.stringify(diagnostics)).not.toContain("Private");
|
||||
const [persistedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(persistedRun.errorCode).toBeNull(); // No adapter metadata has been saved yet.
|
||||
expect(await readPersistedNativeModelRejection(db, persistedRun, turnId, terminal)).toEqual(classified ? {
|
||||
errorCode: "native_provider_model_rejected", errorMessage: NATIVE_MODEL_REJECTION_MESSAGE, diagnostic: NATIVE_MODEL_REJECTION_DIAGNOSTIC,
|
||||
} : null);
|
||||
if (classified) {
|
||||
if (scenario === "committed") {
|
||||
// Neither saved display diagnostics nor a different execution binding
|
||||
// can confer the right to change the finalizer's retry decision.
|
||||
for (const changed of [
|
||||
{ ...persistedRun, runnerProfileJson: null },
|
||||
{ ...persistedRun, nativeSessionId: "foreign-session" },
|
||||
{ ...persistedRun, runnerInstanceId: "foreign-source" },
|
||||
{ ...persistedRun, companyId: randomUUID() },
|
||||
{ ...persistedRun, completionContractSha256: "foreign-contract" },
|
||||
{ ...persistedRun, runnerProfileJson: { nativeExecutionInput: { ...execution, provider: { kind: "codex", model: "different-model" } } } },
|
||||
{ ...persistedRun, runnerProfileJson: { nativeExecutionInput: { ...execution, binding: { ...execution.binding, runId: randomUUID() } } } },
|
||||
]) expect(await readPersistedNativeModelRejection(db, changed, turnId, terminal)).toBeNull();
|
||||
const [stored] = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId));
|
||||
await db.update(heartbeatRunEvents).set({ sourcePayloadSha256: "invalid-hash" }).where(eq(heartbeatRunEvents.id, stored.id));
|
||||
expect(await readPersistedNativeModelRejection(db, persistedRun, turnId, terminal)).toBeNull();
|
||||
await db.update(heartbeatRunEvents).set({ sourcePayloadSha256: stored.sourcePayloadSha256 }).where(eq(heartbeatRunEvents.id, stored.id));
|
||||
}
|
||||
// Simulate a restart after native result acceptance, before heartbeat's
|
||||
// adapter update. Finalization must use durable provider evidence.
|
||||
const acceptedBefore = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId));
|
||||
if (scenario === "reassigned") {
|
||||
await db.update(issues).set({ assigneeAgentId: workerId, executionRunId: null }).where(eq(issues.id, issueId));
|
||||
} else if (scenario === "successor-race") {
|
||||
const [snapshot] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const newerRunId = randomUUID(), raceAssessmentId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: newerRunId, companyId, agentId, status: "running", nativeIssueId: issueId });
|
||||
await db.update(issues).set({ executionRunId: newerRunId }).where(eq(issues.id, issueId));
|
||||
const [changed] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(changed.statusVersion).toBe(snapshot.statusVersion);
|
||||
await db.insert(workAssessments).values({ id: raceAssessmentId, companyId, issueId, runId, contractId: contract.id, resultId: acceptedBefore[0].id, triggerKind: "native_result", triggerActorCompanyId: companyId, priorIssueStatus: snapshot.status, priorStatusVersion: snapshot.statusVersion, policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, assessmentJson: {}, inputDigest: raceAssessmentId });
|
||||
await expect(commitNativeStatusDecision({ db, companyId, issueId, runId, assessmentId: raceAssessmentId,
|
||||
priorStatus: snapshot.status, priorStatusVersion: snapshot.statusVersion, priorDecisionId: snapshot.lastStatusDecisionId,
|
||||
requireModelRejectionOwner: { agentId, reviewContext: null },
|
||||
decision: { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "blocked", toStatus: "blocked", reasonCode: "native_provider_model_rejected", unblockDescriptor: { owner: "board", action: NATIVE_MODEL_REJECTION_MESSAGE }, effects: [{ kind: "bind_blocker", owner: "board", action: NATIVE_MODEL_REJECTION_MESSAGE }] },
|
||||
})).rejects.toBeInstanceOf(NativeStatusRaceError);
|
||||
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, runId))).toHaveLength(0);
|
||||
}
|
||||
for (let retry = 0; retry < 2; retry++) {
|
||||
await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
|
||||
}
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))).toEqual(acceptedBefore);
|
||||
const [finalRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(finalRun).toMatchObject({ status: "failed", errorCode: "native_provider_model_rejected", error: NATIVE_MODEL_REJECTION_MESSAGE });
|
||||
const [finalIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(finalIssue.status).toBe(scenario.endsWith("-review") ? "in_review" : ownerChanged ? "in_progress" : "blocked");
|
||||
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, runId));
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0].reasonCode).toBe(reviewAuthorityEnded ? "native_review_action_finished" : ownerChanged ? "run_failed_partial_evidence_preserved" : "native_provider_model_rejected");
|
||||
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId));
|
||||
expect(wakes.filter(wake => wake.payload?.issueId === issueId)).toHaveLength(ownerChanged ? 1 : 0);
|
||||
if (!ownerChanged) expect(await heartbeatService(db).dispatchPendingNativeStatusWakeups({ companyId })).toEqual({ scanned: 0, dispatched: 0, recovered: 0, deferred: 0 });
|
||||
expect(provider.execute).toHaveBeenCalledTimes(1);
|
||||
if (scenario === "pending-review") {
|
||||
const reviewContext = readNativeReviewAssignmentContext(finalRun.contextSnapshot)!;
|
||||
expect(finalIssue).toMatchObject({ lastStatusDecisionId: reviewContext.nativeReviewDecisionId, executionRunId: null, checkoutRunId: null });
|
||||
const originalReview = await getNativeReviewAssignment(db, { companyId, issueId, agentId, contextSnapshot: reviewContext });
|
||||
expect(originalReview?.interaction.status).toBe("pending");
|
||||
expect(finalIssue.statusVersion).toBe(originalReview!.sourceDecision.decisionJson.projectedStatusVersion);
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(0);
|
||||
|
||||
// Exercise the real release and periodic recovery lanes, rather than
|
||||
// assuming that absence of a finalizer wake prevents later retries.
|
||||
const unexpected = vi.fn(async () => { throw new Error("Unexpected automatic recovery"); });
|
||||
const release = createReleaseIssueExecution({
|
||||
issueLock: createPostgresWakeQueueAdapter(db, { resolveResponsibleUserId: unexpected, getRoutineEnv: unexpected, resolveSessionBeforeForWakeup: unexpected }),
|
||||
recovery: { escalateStrandedAssignedIssue: unexpected, escalateStrandedRecoveryIssueInPlace: unexpected },
|
||||
});
|
||||
expect((await release({ companyId, runId, now: new Date() })).outcome.kind).toBe("released");
|
||||
await heartbeatService(db).reconcileStrandedAssignedIssues();
|
||||
expect(unexpected).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(0);
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))).filter(wake => wake.payload?.issueId === issueId)).toHaveLength(0);
|
||||
const [afterRecovery] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(afterRecovery).toMatchObject({ status: "in_review", statusVersion: finalIssue.statusVersion, lastStatusDecisionId: finalIssue.lastStatusDecisionId });
|
||||
|
||||
// The operator repairs configuration and explicitly retries the
|
||||
// assigned reviewer. No review rebinding or status reset is needed.
|
||||
await db.update(agents).set({ adapterConfig: { provider: "codex", model: "repaired-model" }, runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } }).where(eq(agents.id, agentId));
|
||||
// Occupy the execution slot to exercise real wake admission without
|
||||
// starting a provider. The normal atomic review claim follows below.
|
||||
const occupiedRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: occupiedRunId, companyId, agentId, status: "running", startedAt: new Date() });
|
||||
const retryRun = await heartbeatService(db, { runtimeEnv: {} }).wakeup(agentId, {
|
||||
failedRunId: runId, source: "on_demand", triggerDetail: "manual", reason: "retry_failed_run",
|
||||
requestedByActorType: "user", requestedByActorId: "review-repair-operator",
|
||||
payload: { issueId }, contextSnapshot: { ...reviewContext },
|
||||
});
|
||||
expect(retryRun).toMatchObject({ status: "queued", retryOfRunId: runId, contextSnapshot: { issueId, ...reviewContext } });
|
||||
const retryRunId = retryRun!.id;
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, occupiedRunId));
|
||||
expect(await claimQueuedNativeReviewRun(db, { run: retryRun!, agentNameKey: "reviewer", claimedAt: new Date(), claimValues: { nativeIssueId: issueId, runtimeMode: "native" } }))
|
||||
.toMatchObject({ id: retryRunId, status: "running" });
|
||||
expect(await getNativeReviewAssignment(db, { companyId, issueId, agentId, contextSnapshot: reviewContext, actingRunId: retryRunId, issueExecutionRunId: retryRunId })).not.toBeNull();
|
||||
await issueThreadInteractionService(db).acceptInteraction(
|
||||
{ id: issueId, companyId, projectId: null, goalId: null, status: "in_review" },
|
||||
reviewContext.nativeReviewInteractionId, {}, { agentId, runId: retryRunId },
|
||||
);
|
||||
const [resolved] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, reviewContext.nativeReviewInteractionId));
|
||||
expect(resolved).toMatchObject({ status: "accepted", resolvedByAgentId: agentId, resolvedByRunId: retryRunId });
|
||||
expect(await db.select().from(issues).where(eq(issues.id, issueId))).toMatchObject([{ status: "done", assigneeAgentId: workerId }]);
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))).toEqual(acceptedBefore);
|
||||
expect(provider.execute).toHaveBeenCalledTimes(1);
|
||||
await db.update(agents).set({ adapterConfig: { provider: "codex", model } }).where(eq(agents.id, agentId));
|
||||
}
|
||||
} else if (scenario === "unknown-error") {
|
||||
// A lookalike diagnostic flag does not alter unknown failed-result policy.
|
||||
await db.update(heartbeatRuns).set({ resultJson: { nativeProviderFailure: NATIVE_MODEL_REJECTION_DIAGNOSTIC } }).where(eq(heartbeatRuns.id, runId));
|
||||
await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded" });
|
||||
const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, runId));
|
||||
expect(decision.reasonCode).toBe("run_failed_partial_evidence_preserved");
|
||||
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId));
|
||||
expect(wakes.filter(wake => wake.payload?.issueId === issueId)).toHaveLength(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrpEvent, PrpTerminalState } from "../../vendor/paperclip-runner/index.js";
|
||||
import { createNativeProviderFailureObservation, NATIVE_MODEL_REJECTION_DIAGNOSTIC, NATIVE_MODEL_REJECTION_MESSAGE } from "./native-provider-failure.js";
|
||||
|
||||
const model = "test-model";
|
||||
const response = {
|
||||
type: "error", status: 400,
|
||||
error: { type: "invalid_request_error", message: `The '${model}' model is not supported when using Codex with a ChatGPT account.` },
|
||||
};
|
||||
const event: PrpEvent = {
|
||||
schema: "paperclip.prp.event.v1", runId: "run", normalizedSessionId: "session", turnId: "turn",
|
||||
sourceInstanceId: "runner", sourceEventId: "failure", sourceSeq: 1, sourceKind: "runner",
|
||||
eventType: "turn.failed", schemaVersion: 1, priority: 0, emittedAt: "2026-01-01T00:00:00.000Z",
|
||||
payload: { status: "failed", error: { message: JSON.stringify(response), codexErrorInfo: "other" } },
|
||||
};
|
||||
const terminal: PrpTerminalState = {
|
||||
schema: "paperclip.prp.terminal.v1", turnTerminalState: "failed", runTerminalState: "failed", reportedWorkDisposition: "blocked",
|
||||
};
|
||||
const observation = () => createNativeProviderFailureObservation({ kind: "codex", model });
|
||||
|
||||
describe("native provider failure observation", () => {
|
||||
it("binds the structured rejection to the failed turn and emits no response or model text", () => {
|
||||
const observed = observation();
|
||||
observed.observe(event);
|
||||
observed.observe(event); // A byte-identical committed replay remains idempotent.
|
||||
const result = observed.forTerminal("turn", terminal);
|
||||
expect(result).toEqual({ errorCode: "native_provider_model_rejected", errorMessage: NATIVE_MODEL_REJECTION_MESSAGE, diagnostic: NATIVE_MODEL_REJECTION_DIAGNOSTIC });
|
||||
expect(JSON.stringify(result)).not.toContain(model);
|
||||
expect(JSON.stringify(result)).not.toContain("invalid_request_error");
|
||||
expect(observed.forTerminal("later-turn", terminal)).toBeNull();
|
||||
expect(observed.forTerminal(null, terminal)).toBeNull();
|
||||
expect(observed.forTerminal("turn", { ...terminal, runTerminalState: "succeeded" })).toBeNull();
|
||||
expect(observed.forTerminal("turn", { ...terminal, turnTerminalState: "cancelled" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...event, sourceKind: "control_plane" as const },
|
||||
{ ...event, eventType: "item.completed" as const },
|
||||
{ ...event, eventType: "harness.diagnostic" as const },
|
||||
{ ...event, turnId: undefined },
|
||||
{ ...event, payload: { ...event.payload, status: "completed" } },
|
||||
{ ...event, payload: { ...event.payload, recoverable: true } },
|
||||
{ ...event, payload: { status: "failed", error: { message: JSON.stringify(response), recoverable: true } } },
|
||||
])("ignores non-authoritative or retryable evidence %#", (unrelated) => {
|
||||
const observed = observation();
|
||||
observed.observe(unrelated);
|
||||
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
response.error.message, "malformed JSON", " ".repeat(4_097),
|
||||
JSON.stringify({ ...response, status: 401 }),
|
||||
JSON.stringify({ ...response, status: "400" }),
|
||||
JSON.stringify({ ...response, type: "notice" }),
|
||||
JSON.stringify({ ...response, error: { ...response.error, type: "server_error" } }),
|
||||
JSON.stringify({ ...response, error: { ...response.error, message: "Private arbitrary provider text" } }),
|
||||
JSON.stringify({ ...response, error: { ...response.error, message: response.error.message + " Private suffix" } }),
|
||||
JSON.stringify({ ...response, error: { ...response.error, message: response.error.message.replace(model, "another-model") } }),
|
||||
JSON.stringify([response]), "null",
|
||||
])("leaves unknown failures unchanged %#", (message) => {
|
||||
const observed = observation();
|
||||
observed.observe({ ...event, payload: { status: "failed", error: { message } } });
|
||||
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([{ kind: "claude", model }, { kind: "codex" }, { kind: "codex", model: "other" }])(
|
||||
"does not apply a rejection to another selected provider/model (%j)", (provider) => {
|
||||
const observed = createNativeProviderFailureObservation(provider);
|
||||
observed.observe(event);
|
||||
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { PrpEvent, PrpTerminalState } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
export const NATIVE_MODEL_REJECTION_MESSAGE =
|
||||
"The selected model is not supported by the current ChatGPT connection. Choose a supported model or a compatible AI connection.";
|
||||
|
||||
export const NATIVE_MODEL_REJECTION_DIAGNOSTIC = {
|
||||
provider: "codex",
|
||||
category: "model_auth_incompatible",
|
||||
status: 400,
|
||||
authMode: "chatgpt",
|
||||
} as const;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
/** Reconstruct only the closed diagnostic vocabulary, never provider text. */
|
||||
export function readNativeModelRejectionDiagnostic(value: unknown) {
|
||||
try {
|
||||
const entry = record(value);
|
||||
return Object.entries(NATIVE_MODEL_REJECTION_DIAGNOSTIC).every(([key, expected]) => entry[key] === expected)
|
||||
? { ...NATIVE_MODEL_REJECTION_DIAGNOSTIC }
|
||||
: null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Observe only events accepted by the bound control-plane persistence port. */
|
||||
export function createNativeProviderFailureObservation(provider: { kind: string; model?: string | null }) {
|
||||
let rejectedTurnId: string | null = null;
|
||||
return {
|
||||
observe(event: PrpEvent) {
|
||||
if (provider.kind !== "codex" || !provider.model ||
|
||||
event.sourceKind !== "runner" || event.eventType !== "turn.failed" || !event.turnId) return;
|
||||
const payload = record(event.payload);
|
||||
const failure = record(payload.error);
|
||||
if (payload.status !== "failed" || payload.recoverable === true || failure.recoverable === true ||
|
||||
typeof failure.message !== "string" || failure.message.length > 4_096) return;
|
||||
let response: Record<string, unknown>;
|
||||
try { response = record(JSON.parse(failure.message)); } catch { return; }
|
||||
const error = record(response.error);
|
||||
// This is a provider terminal envelope, not text emitted by a tool or an
|
||||
// agent. Bind the exact rejection to the configured model without storing
|
||||
// its name or the response. Unknown provider errors retain their behavior.
|
||||
if (response.type !== "error" || response.status !== 400 || error.type !== "invalid_request_error" ||
|
||||
error.message !== `The '${provider.model}' model is not supported when using Codex with a ChatGPT account.`) return;
|
||||
rejectedTurnId = event.turnId;
|
||||
},
|
||||
forTerminal(turnId: string | null | undefined, terminal: PrpTerminalState) {
|
||||
if (!rejectedTurnId || turnId !== rejectedTurnId ||
|
||||
terminal.turnTerminalState !== "failed" || terminal.runTerminalState !== "failed") return null;
|
||||
return {
|
||||
errorCode: "native_provider_model_rejected" as const,
|
||||
errorMessage: NATIVE_MODEL_REJECTION_MESSAGE,
|
||||
diagnostic: { ...NATIVE_MODEL_REJECTION_DIAGNOSTIC },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { redactSensitiveText } from "../../redaction.js";
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { reportRunFailure } from "../run-failure-report.js";
|
||||
import { isNativeRunnerOwnershipHeld } from "./native-runner-ownership.js";
|
||||
import { nativeRestartInterruptedTurnId, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
export type NativeControllerIdentity = {
|
||||
bootId: string;
|
||||
@@ -764,7 +765,8 @@ export async function claimNativeRestartRecoveries(input: {
|
||||
hasProviderEvidence,
|
||||
checkpointFailed:
|
||||
(checkpointRecord.terminal as Record<string, unknown> | undefined)
|
||||
?.runTerminalState === "failed",
|
||||
?.runTerminalState === "failed" &&
|
||||
nativeRestartInterruptedTurnId(checkpointRecord as unknown as PersistedNativeSession) === null,
|
||||
providerAttempt: row.coordinator.attempt,
|
||||
});
|
||||
const ownershipChanged =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { settleSlackConversation } from "../slack-conversation-lifecycle.js";
|
||||
import { readPersistedNativeModelRejection } from "./native-provider-failure-evidence.js";
|
||||
import { dismissAutomaticCompletionReviews } from "./automatic-completion-reviews.js";
|
||||
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js";
|
||||
import { conversationNativeDecision, isConversation } from "../agent-conversations.js";
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import { classifyNativeEvidence } from "./evidence-classifier.js";
|
||||
import type { PrpIgnoredAttentionRequest } from "@paperclipai/paperclip-runner";
|
||||
import type { PrpIgnoredAttentionRequest, PrpTerminalState } from "@paperclipai/paperclip-runner";
|
||||
import {
|
||||
arbitrateNativeStatus,
|
||||
NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
@@ -1275,7 +1276,15 @@ export async function finalizeNativeRun(input: {
|
||||
companyId: run.companyId, issueId: authoritativeIssue.id, agentId: run.agentId,
|
||||
contextSnapshot: reviewContext, allowResolvedByRunId: run.id,
|
||||
}) : null;
|
||||
const providerModelRejection = await readPersistedNativeModelRejection(
|
||||
input.db, run, resultRow.turnId, envelope.terminal as PrpTerminalState,
|
||||
);
|
||||
const ownsModelRejectionDecision =
|
||||
(!authoritativeIssue.executionRunId || authoritativeIssue.executionRunId === run.id) &&
|
||||
(reviewContext ? nativeReview?.interaction.status === "pending"
|
||||
: authoritativeIssue.assigneeAgentId === run.agentId && !authoritativeIssue.assigneeUserId);
|
||||
const proposedDecision = resolveNativeFinalizerStatus({
|
||||
providerModelRejected: providerModelRejection !== null && ownsModelRejectionDecision,
|
||||
...(reviewContext ? { nativeReviewOutcome: nativeReview
|
||||
? nativeReview.interaction.status === "pending" ? "pending" as const : "resolved" as const
|
||||
: "stale" as const } : {}),
|
||||
@@ -1358,6 +1367,9 @@ export async function finalizeNativeRun(input: {
|
||||
priorStatusVersion: Number(authoritativeIssue.statusVersion),
|
||||
priorDecisionId: authoritativeIssue.lastStatusDecisionId,
|
||||
decision,
|
||||
requireModelRejectionOwner: decision.reasonCode === "native_provider_model_rejected"
|
||||
? { agentId: run.agentId, reviewContext }
|
||||
: undefined,
|
||||
requireBoardResponseWaitSource:
|
||||
decision.reasonCode === "board_response_waiting" || repairBoardResponseWait
|
||||
? boardResponseWait?.source
|
||||
@@ -1400,6 +1412,7 @@ export async function finalizeNativeRun(input: {
|
||||
);
|
||||
const clearExecutionFailure = input.projectRunStatus && finalizationPhase === "committed" && terminalState === "succeeded";
|
||||
const finalizationMetadata = {
|
||||
...(providerModelRejection ? { nativeProviderFailure: providerModelRejection.diagnostic } : {}),
|
||||
finalizationPhase,
|
||||
...(finalizationPhase === "committed" ? { failureCode: null, originalFailureCode: null, nextAttemptAt: null } : {}),
|
||||
assessmentId: assessmentRow.id,
|
||||
@@ -1450,6 +1463,12 @@ export async function finalizeNativeRun(input: {
|
||||
nativePhase: finalizationPhase,
|
||||
nativePhaseUpdatedAt: now,
|
||||
...(clearExecutionFailure ? { error: null, errorCode: null } : {}),
|
||||
...(providerModelRejection ? {
|
||||
// Recovery can finalize before heartbeat saves the adapter result.
|
||||
// Retain any independently recorded execution/cleanup failure.
|
||||
error: sql`coalesce(${heartbeatRuns.error}, ${providerModelRejection.errorMessage})`,
|
||||
errorCode: sql`coalesce(${heartbeatRuns.errorCode}, ${providerModelRejection.errorCode})`,
|
||||
} : {}),
|
||||
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb)
|
||||
|| ${JSON.stringify(finalizationMetadata)}::jsonb
|
||||
|| ${clearExecutionFailure ? recoveredExecutionFailureMetadata() : sql`'{}'::jsonb`}`,
|
||||
|
||||
@@ -1018,6 +1018,30 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([2, 3])("recovers a checkpointed restart interruption within provider budget %s", async (attempt) => {
|
||||
const fixture = await seedRun(`INTERRUPTED-${attempt}`);
|
||||
await fixture.db.update(heartbeatRuns).set({ runnerProfileJson: { sessionCheckpoint: {
|
||||
driverKind: "codex_app_server",
|
||||
sessionId: fixture.native.normalizedSessionId,
|
||||
identity: { companyId, issueId: fixture.issueId, runId: fixture.runId, agentId, sessionId: fixture.native.normalizedSessionId },
|
||||
providerSessionId: "interrupted-provider",
|
||||
activeTurnId: null,
|
||||
semanticResult: null,
|
||||
terminal: { runTerminalState: "failed", turnTerminalState: "failed" },
|
||||
terminalTurns: [{ turnId: "interrupted-turn", fingerprint: JSON.stringify({
|
||||
terminalState: "failed", result: null,
|
||||
error: { code: "provider_turn_lost_on_restore", recoverable: true },
|
||||
}) }],
|
||||
} } }).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
await fixture.db.update(nativeRunFinalizations).set({ attempt }).where(eq(nativeRunFinalizations.runId, fixture.runId));
|
||||
const dispositions = await claimNativeRestartRecoveries({
|
||||
db: fixture.db, controller: successor, restartKind: "hot", runIds: [fixture.runId],
|
||||
});
|
||||
expect(dispositions).toEqual([expect.objectContaining(attempt < 3
|
||||
? { kind: "resume_dead_runner", runId: fixture.runId, providerAttempt: attempt }
|
||||
: { kind: "blocked", runId: fixture.runId, reason: "execution_recovery_budget_exhausted" })]);
|
||||
});
|
||||
|
||||
it("terminalizes the recorded failed-checkpoint incident atomically instead of resuming it on upgrade", async () => {
|
||||
const fixture = await seedRun("FAILED-CHECKPOINT");
|
||||
await fixture.db.update(heartbeatRuns).set({ runnerProfileJson: { sessionCheckpoint: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
REMOTE_CODEX_SUPPORTED_RANGE,
|
||||
} from "./codex-runtime-compatibility.js";
|
||||
import { createNativeToolTrace, type NativeToolTrace } from "./native-tool-trace.js";
|
||||
import { createNativeProviderFailureObservation } from "./native-provider-failure.js";
|
||||
import { createNativeGitHubAccess, type NativeGitHubAccess } from "./native-github-access.js";
|
||||
import { resolveGitHubOperationCredentials } from "../github-operation-credentials.js";
|
||||
import { bindManagedNativeCredentialTurn, completeManagedNativeCredentialTurn } from "./managed-native-credentials.js";
|
||||
@@ -7805,6 +7806,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
let turnStartedAtMs: number | null = null;
|
||||
let firstAgentEventRecorded = false;
|
||||
let providerUsageLimitObserved = false;
|
||||
const providerFailureObservation = createNativeProviderFailureObservation(input.execution.provider);
|
||||
let turnCompletedAtMs: number | null = null;
|
||||
let runnerSessionStartupScope: NativeRunSpanScope | null = null;
|
||||
let agentTurnScope: NativeRunSpanScope | null = null;
|
||||
@@ -7861,6 +7863,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
await liveQuestions.observe(event);
|
||||
await projectSessionGoalEvent(event);
|
||||
providerUsageLimitObserved ||= nativeProviderUsageLimitFromEvent(event);
|
||||
providerFailureObservation.observe(event);
|
||||
const eventAtMs = Date.parse(event.emittedAt);
|
||||
const milestoneAtMs = Number.isFinite(eventAtMs)
|
||||
? eventAtMs
|
||||
@@ -8064,6 +8067,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
await liveQuestions.observe(event);
|
||||
await projectSessionGoalEvent(event);
|
||||
providerUsageLimitObserved ||= nativeProviderUsageLimitFromEvent(event);
|
||||
providerFailureObservation.observe(event);
|
||||
const questionFallback = await materializeRuntimeQuestionFallback({
|
||||
db: input.db,
|
||||
binding: input.execution.binding,
|
||||
@@ -8334,6 +8338,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
trace.activate(runnerSessionStartupScope);
|
||||
const result = await trace.run(runnerSessionStartupScope, () =>
|
||||
executeNativeSession({
|
||||
resumeInterruptedTurn: input.restartRecovery?.kind === "resume_dead_runner",
|
||||
getFreshSessionHandoff: input.getFreshSessionHandoff,
|
||||
onSessionAdmission: async () => {
|
||||
// Invalidate prior stop evidence before a backend can spawn.
|
||||
@@ -9203,6 +9208,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
);
|
||||
if (collectInstructions && !collectedByOwner) await instructionCopy!.collectStopped();
|
||||
}
|
||||
const providerFailure = providerFailureObservation.forTerminal(native.turnId, native.terminal);
|
||||
const adapterResult: AdapterExecutionResult = {
|
||||
exitCode: native.terminal.runTerminalState === "succeeded" ? 0 : 1,
|
||||
signal: null,
|
||||
@@ -9210,10 +9216,12 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
errorMessage:
|
||||
native.terminal.runTerminalState === "succeeded"
|
||||
? null
|
||||
: `Native session ${native.terminal.runTerminalState}`,
|
||||
: providerFailure?.errorMessage ?? `Native session ${native.terminal.runTerminalState}`,
|
||||
...(providerFailure ? { errorCode: providerFailure.errorCode } : {}),
|
||||
resultJson: {
|
||||
nativeResult: native.result as unknown as Record<string, unknown>,
|
||||
nativeTerminal: native.terminal as unknown as Record<string, unknown>,
|
||||
...(providerFailure ? { nativeProviderFailure: providerFailure.diagnostic } : {}),
|
||||
...(native.goalRolloverRequired ? { goalRolloverRequired: true } : {}),
|
||||
planSynchronizations,
|
||||
},
|
||||
|
||||
@@ -44,6 +44,23 @@ function arbitrate(
|
||||
}
|
||||
|
||||
describe("native status authority", () => {
|
||||
it.each(["in_progress", "in_review"] as const)("blocks a current worker's proven model rejection without a retry (%s)", (priorIssueStatus) => {
|
||||
const decision = arbitrate({ priorIssueStatus, terminalState: "failed", providerModelRejected: true });
|
||||
expect(decision).toMatchObject({ statusAction: "blocked", toStatus: "blocked", reasonCode: "native_provider_model_rejected", unblockDescriptor: { owner: "board" } });
|
||||
expect(decision.effects).toEqual([{ kind: "bind_blocker", owner: "board", action: expect.any(String) }]);
|
||||
expect(arbitrate({ terminalState: "failed", providerModelRejected: false }).effects).toContainEqual(expect.objectContaining({ kind: "schedule_retry" }));
|
||||
expect(arbitrate({ terminalState: "succeeded", providerModelRejected: true }).reasonCode).not.toBe("native_provider_model_rejected");
|
||||
expect(arbitrate({ terminalState: "failed", providerModelRejected: true, workspaceFinalizeStatus: "failed" }).reasonCode).toBe("finalization_failed_claim_preserved");
|
||||
expect(arbitrate({ terminalState: "failed", providerModelRejected: true, priorIssueStatus: "done" }).toStatus).toBe("done");
|
||||
for (const nativeReviewOutcome of ["stale", "resolved"] as const) {
|
||||
expect(arbitrate({ terminalState: "failed", providerModelRejected: true, priorIssueStatus, nativeReviewOutcome }))
|
||||
.toMatchObject({ statusAction: "preserve", toStatus: priorIssueStatus, reasonCode: "native_review_action_finished" });
|
||||
}
|
||||
});
|
||||
it("preserves pending review authority without automatic recovery after model rejection", () => {
|
||||
expect(arbitrate({ priorIssueStatus: "in_review", terminalState: "failed", providerModelRejected: true, nativeReviewOutcome: "pending" }))
|
||||
.toMatchObject({ statusAction: "preserve", toStatus: "in_review", reasonCode: "native_provider_model_rejected", unblockDescriptor: null, effects: [{ kind: "release_checkout" }] });
|
||||
});
|
||||
it("a reviewer finishes its decision without completing rejected or still-reviewed work", () => {
|
||||
for (const priorIssueStatus of ["in_progress", "in_review"] as const) {
|
||||
const decision = arbitrate({ priorIssueStatus, nativeReviewOutcome: "resolved" });
|
||||
@@ -511,7 +528,7 @@ describe("native status authority", () => {
|
||||
expect.objectContaining({
|
||||
statusAction: "blocked",
|
||||
toStatus: "blocked",
|
||||
policyVersion: "phase6-v7",
|
||||
policyVersion: "phase6-v8",
|
||||
reasonCode: "current_track_blocker_waiting",
|
||||
unblockDescriptor: {
|
||||
owner: "board",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NativeEvidenceAssessment } from "./evidence-classifier.js";
|
||||
import { NATIVE_MODEL_REJECTION_MESSAGE } from "./native-provider-failure.js";
|
||||
|
||||
export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v7";
|
||||
export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v8";
|
||||
|
||||
export type NativeAuthoritativeIssueStatus =
|
||||
| "backlog"
|
||||
@@ -115,6 +116,8 @@ export function arbitrateNativeStatus(input: {
|
||||
reviewOwnerUserId?: string | null;
|
||||
/** Review decisions own task state; a reviewer's finish report cannot override them. */
|
||||
nativeReviewOutcome?: "resolved" | "pending" | "stale";
|
||||
/** Re-derived from the committed runner terminal and pinned execution identity. */
|
||||
providerModelRejected?: boolean;
|
||||
agentId: string;
|
||||
priorIssueStatus: NativeAuthoritativeIssueStatus;
|
||||
}): NativeStatusDecision {
|
||||
@@ -146,6 +149,31 @@ export function arbitrateNativeStatus(input: {
|
||||
],
|
||||
};
|
||||
}
|
||||
if (input.providerModelRejected && input.terminalState === "failed" &&
|
||||
input.nativeReviewOutcome !== "stale" && input.nativeReviewOutcome !== "resolved") {
|
||||
if (input.nativeReviewOutcome === "pending") {
|
||||
// The pending review is bound to the worker's status decision and
|
||||
// version. Preserve that authority so an explicit retry after a
|
||||
// configuration repair can resolve the same review. In particular,
|
||||
// do not create the normal unresolved-review recovery action.
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "preserve",
|
||||
toStatus: input.priorIssueStatus,
|
||||
reasonCode: "native_provider_model_rejected",
|
||||
unblockDescriptor: null,
|
||||
effects: [{ kind: "release_checkout" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "blocked",
|
||||
toStatus: "blocked",
|
||||
reasonCode: "native_provider_model_rejected",
|
||||
unblockDescriptor: { owner: "board", action: NATIVE_MODEL_REJECTION_MESSAGE },
|
||||
effects: [{ kind: "bind_blocker", owner: "board", action: NATIVE_MODEL_REJECTION_MESSAGE }],
|
||||
};
|
||||
}
|
||||
if (input.nativeReviewOutcome) {
|
||||
return {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type NativeStatusEffect,
|
||||
} from "./status-arbiter.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { getNativeReviewAssignment, type NativeReviewAssignmentContext } from "./native-review-participant.js";
|
||||
import {
|
||||
readNativeBoardResponseWaitSource,
|
||||
readNativeBoardResponseWaitOrigin,
|
||||
@@ -1535,6 +1536,7 @@ export async function commitNativeStatusDecision(input: {
|
||||
preMaterializedEffects?: NativeMaterializedStatusEffect[];
|
||||
supersedesCommittedDecisionId?: string;
|
||||
requireExternalChatResponseWaitAuthorization?: { agentId: string };
|
||||
requireModelRejectionOwner?: { agentId: string; reviewContext: NativeReviewAssignmentContext | null };
|
||||
requireBoardResponseWaitSource?: NativeBoardResponseWaitSource;
|
||||
requireBoardResponseWaitOrigin?: NativeBoardResponseWaitOrigin;
|
||||
reviewResponsePresentation?: {
|
||||
@@ -1547,6 +1549,9 @@ export async function commitNativeStatusDecision(input: {
|
||||
throw new Error("native_status_reason_code_required");
|
||||
}
|
||||
const reasonCode = input.decision.reasonCode;
|
||||
if (reasonCode === "native_provider_model_rejected" && !input.requireModelRejectionOwner) {
|
||||
throw new Error("native_model_rejection_owner_required");
|
||||
}
|
||||
const publications: ActivityPublication[] = [];
|
||||
const terminalRunsToEmit: (typeof heartbeatRuns.$inferSelect)[] = [];
|
||||
const committed = await input.db.transaction(async (tx) => {
|
||||
@@ -1616,6 +1621,20 @@ export async function commitNativeStatusDecision(input: {
|
||||
) {
|
||||
throw new NativeStatusRaceError();
|
||||
}
|
||||
if (input.requireModelRejectionOwner) {
|
||||
const owner = input.requireModelRejectionOwner;
|
||||
// A successor can claim execution without changing statusVersion. Keep
|
||||
// this new blocking authority behind the same locked issue snapshot.
|
||||
if (issue.executionRunId && issue.executionRunId !== input.runId) throw new NativeStatusRaceError();
|
||||
if (owner.reviewContext) {
|
||||
const review = await getNativeReviewAssignment(tx as unknown as Db, {
|
||||
companyId: input.companyId, issueId: input.issueId, agentId: owner.agentId, contextSnapshot: owner.reviewContext,
|
||||
});
|
||||
if (review?.interaction.status !== "pending") throw new NativeStatusRaceError();
|
||||
} else if (issue.assigneeAgentId !== owner.agentId || issue.assigneeUserId) {
|
||||
throw new NativeStatusRaceError();
|
||||
}
|
||||
}
|
||||
if (input.requireExternalChatResponseWaitAuthorization) {
|
||||
let authorization;
|
||||
try {
|
||||
|
||||
@@ -7,8 +7,20 @@ import {
|
||||
classifyContinuationFailure,
|
||||
} from "./service.js";
|
||||
import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery.js";
|
||||
import { isConfigurationIncompleteFailedRun } from "../../modules/wake-queue/domain/values.js";
|
||||
|
||||
describe("classifyAdapterFailureForRecovery", () => {
|
||||
it("waits for model configuration changes instead of retrying a typed native model rejection", () => {
|
||||
const run = { errorCode: "native_provider_model_rejected", error: "Fixed model configuration message", resultJson: null };
|
||||
expect(classifyAdapterFailureForRecovery(run)).toEqual({ kind: "configuration_incomplete" });
|
||||
expect(classifyContinuationFailure(run as never)).toMatchObject({ kind: "non_retryable", maxAttempts: 0 });
|
||||
expect(isConfigurationIncompleteFailedRun(run)).toBe(true);
|
||||
expect(isAiAuthenticationBlocked(run as never)).toBe(false);
|
||||
const unknown = { ...run, errorCode: "adapter_failed", error: "native_provider_model_rejected" };
|
||||
expect(classifyAdapterFailureForRecovery(unknown)).toBeNull();
|
||||
expect(classifyContinuationFailure(unknown as never).kind).toBe("transient_infra");
|
||||
expect(isConfigurationIncompleteFailedRun(unknown)).toBe(false);
|
||||
});
|
||||
it.each(["acpx_auth_required", "claude_auth_required", "codex_auth_required", "adapter_auth_missing", "refresh_token_reused", "authentication_required"])("does not automatically retry provider authentication failure %s", (errorCode) => {
|
||||
const classification = classifyRunLiveness({ runStatus: "failed", issue: null, errorCode, authenticationRepairRequested: true });
|
||||
const run = { errorCode, ...classification };
|
||||
|
||||
@@ -491,6 +491,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set<string>([
|
||||
]);
|
||||
|
||||
const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set<string>([
|
||||
"native_provider_model_rejected",
|
||||
"provider_tool_definition_invalid",
|
||||
"adapter_engine_unavailable",
|
||||
"agent_not_invokable",
|
||||
@@ -626,7 +627,7 @@ export function classifyAdapterFailureForRecovery(
|
||||
): AdapterFailureRecoveryClassification {
|
||||
// An engine prerequisite cannot be repaired by asking the same unavailable
|
||||
// engine to retry. Use the existing configuration-blocker path.
|
||||
if (latestRun.errorCode === "adapter_engine_unavailable" || latestRun.errorCode === "provider_tool_definition_invalid") {
|
||||
if (latestRun.errorCode === "adapter_engine_unavailable" || latestRun.errorCode === "provider_tool_definition_invalid" || latestRun.errorCode === "native_provider_model_rejected") {
|
||||
return { kind: "configuration_incomplete" };
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -665,6 +665,14 @@ function mapRoutineDescriptionDocument(row: {
|
||||
};
|
||||
}
|
||||
|
||||
// Match PostgreSQL UUID input, including compact/braced forms and any version.
|
||||
// Do not trim or resolve prefixes: the exact input must be safe for a UUID query.
|
||||
function isRoutineUuidInput(id: string): boolean {
|
||||
if (/\s/.test(id)) return false;
|
||||
const value = id.startsWith("{") && id.endsWith("}") ? id.slice(1, -1) : id;
|
||||
return /^(?:[0-9a-f]{4}-?){7}[0-9a-f]{4}$/i.test(value);
|
||||
}
|
||||
|
||||
export function routineService(
|
||||
db: Db,
|
||||
deps: {
|
||||
@@ -682,6 +690,7 @@ export function routineService(
|
||||
});
|
||||
|
||||
async function getRoutineById(id: string) {
|
||||
if (!isRoutineUuidInput(id)) return null;
|
||||
return db
|
||||
.select()
|
||||
.from(routines)
|
||||
@@ -766,6 +775,7 @@ export function routineService(
|
||||
}
|
||||
|
||||
async function getTriggerById(id: string) {
|
||||
if (!isRoutineUuidInput(id)) return null;
|
||||
return db
|
||||
.select()
|
||||
.from(routineTriggers)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redacti
|
||||
import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
|
||||
import { readNativeModelRejectionDiagnostic } from "./native-runtime/native-provider-failure.js";
|
||||
|
||||
type Run = typeof heartbeatRuns.$inferSelect;
|
||||
type Context = Record<string, string | number | boolean>;
|
||||
@@ -184,6 +185,8 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport
|
||||
"stackPreview", "status", "statusCode", "requestId",
|
||||
]);
|
||||
const provider = scalars(read(result, "terminalSessionFailure"), ["category", "title", "details"]);
|
||||
const nativeProviderFailure = readNativeModelRejectionDiagnostic(read(result, "nativeProviderFailure"));
|
||||
if (nativeProviderFailure) Object.assign(provider, nativeProviderFailure);
|
||||
const truncatedFields: string[] = [];
|
||||
const providerTruncation = read(read(result, "terminalSessionFailure"), "truncatedFields");
|
||||
if (Array.isArray(providerTruncation)) {
|
||||
|
||||
+1
@@ -128,6 +128,7 @@ export const validatePrpStructuredRunResult =
|
||||
runner.validatePrpStructuredRunResult;
|
||||
|
||||
export const NativeProviderTerminalFailure = runner.NativeProviderTerminalFailure;
|
||||
export const nativeRestartInterruptedTurnId = runner.nativeRestartInterruptedTurnId;
|
||||
|
||||
export const completeTerminatedRemoteNativeSessionCleanup = runner.completeTerminatedRemoteNativeSessionCleanup;
|
||||
export const completeTerminatedLocalNativeSessionCleanup = runner.completeTerminatedLocalNativeSessionCleanup;
|
||||
Reference in new issue
Block a user