mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
merge: reconcile published native prerequisite and current master
Preserve canonical startup ownership, scoped evidence, current Storybook IDs, and the Codex form controls while carrying Claude effort and immutable source bindings. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
48ffca9438
415 files changed
+22501
-15340
No files matched your search
@@ -261,6 +261,17 @@ function makeAgent(adapterType: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function skillSnapshot(adapterType = "claude_local", mode = "ephemeral") {
|
||||
return {
|
||||
adapterType: adapterType,
|
||||
supported: true,
|
||||
mode: mode,
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent skill routes", () => {
|
||||
beforeEach(async () => {
|
||||
runnerTarget.platform = "linux";
|
||||
@@ -320,22 +331,8 @@ describe("agent skill routes", () => {
|
||||
unresolved: [],
|
||||
}),
|
||||
);
|
||||
mockAdapter.listSkills.mockResolvedValue({
|
||||
adapterType: "claude_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockAdapter.syncSkills.mockResolvedValue({
|
||||
adapterType: "claude_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockAdapter.listSkills.mockResolvedValue(skillSnapshot());
|
||||
mockAdapter.syncSkills.mockResolvedValue(skillSnapshot());
|
||||
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => {
|
||||
const previousAgent = persistedAgent ?? makeAgent("claude_local");
|
||||
persistedAgent = {
|
||||
@@ -478,61 +475,11 @@ describe("agent skill routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("threads a non-undefined actor secret context into resolveAdapterConfigForRuntime on both skills routes (audit fidelity, skipUserSecrets preserved)", async () => {
|
||||
const expectedContext = {
|
||||
consumerType: "agent",
|
||||
consumerId: "11111111-1111-4111-8111-111111111111",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
actorSource: "local_implicit",
|
||||
responsibleUserId: "local-board",
|
||||
};
|
||||
|
||||
// GET /agents/:id/skills
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
|
||||
const listRes = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"),
|
||||
);
|
||||
expect(listRes.status, JSON.stringify(listRes.body)).toBe(200);
|
||||
const listCall = mockSecretService.resolveAdapterConfigForRuntime.mock.calls.at(-1);
|
||||
expect(listCall?.[2]).toBeDefined();
|
||||
expect(listCall?.[2]).toEqual(expectedContext);
|
||||
expect(listCall?.[3]).toEqual({ adapterType: "claude_local", skipUserSecrets: true });
|
||||
|
||||
// POST /agents/:id/skills/sync
|
||||
mockAdapter.syncSkills.mockResolvedValue({
|
||||
adapterType: "claude_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
const syncRes = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
|
||||
.send({ desiredSkills: ["paperclip"], mode: "replace" }),
|
||||
);
|
||||
expect(syncRes.status, JSON.stringify(syncRes.body)).toBe(200);
|
||||
const syncCall = mockSecretService.resolveAdapterConfigForRuntime.mock.calls.at(-1);
|
||||
expect(syncCall?.[2]).toBeDefined();
|
||||
expect(syncCall?.[2]).toEqual(expectedContext);
|
||||
expect(syncCall?.[3]).toEqual({ adapterType: "claude_local", skipUserSecrets: true });
|
||||
});
|
||||
|
||||
it("skips runtime materialization when listing Codex skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("codex_local"));
|
||||
mockAdapter.listSkills.mockResolvedValue({
|
||||
adapterType: "codex_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockAdapter.listSkills.mockResolvedValue(skillSnapshot("codex_local"));
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
@@ -555,14 +502,7 @@ describe("agent skill routes", () => {
|
||||
mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValueOnce({
|
||||
config: { agent: "claude" },
|
||||
});
|
||||
mockAdapter.listSkills.mockResolvedValue({
|
||||
adapterType: "acpx_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockAdapter.listSkills.mockResolvedValue(skillSnapshot("acpx_local"));
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
@@ -603,14 +543,7 @@ describe("agent skill routes", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mockAdapter.syncSkills.mockResolvedValue({
|
||||
adapterType: "acpx_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockAdapter.syncSkills.mockResolvedValue(skillSnapshot("acpx_local"));
|
||||
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
|
||||
@@ -831,14 +764,7 @@ describe("agent skill routes", () => {
|
||||
|
||||
it("skips runtime materialization when listing persistent skill adapters", async () => {
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("cursor"));
|
||||
mockAdapter.listSkills.mockResolvedValue({
|
||||
adapterType: "cursor",
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills: ["paperclipai/paperclip/paperclip"],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockAdapter.listSkills.mockResolvedValue(skillSnapshot("cursor", "persistent"));
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import * as activityLogModule from "../services/activity-log.js";
|
||||
import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
|
||||
import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js";
|
||||
import { buildSlackAppManifest, SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
import { registerChatActionWork, dueChatAction } from "../services/chat-action-work.js";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { registerChatDeliveryWork } from "../services/chat-delivery-work.js";
|
||||
@@ -7,14 +11,11 @@ import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { fastResponseService } from "../services/fast-responses.js";
|
||||
import { fastResponseReceipt } from "../services/fast-response-provider.js";
|
||||
import { fastResponseRequests } from "@paperclipai/db";
|
||||
import { chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
|
||||
import { buildSlackAppManifest } from "@paperclipai/shared";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { toolActionRequests, toolInvocations } from "@paperclipai/db";
|
||||
import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { githubChatWizardService } from "../services/chat-github-wizard.js";
|
||||
import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js";
|
||||
import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js";
|
||||
import { githubChatManagementService } from "../services/chat-github-management.js";
|
||||
import { githubChatReviewService } from "../services/chat-github-reviews.js";
|
||||
@@ -6262,7 +6263,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const token = `xoxb-bot-token-canary-${id}`;
|
||||
const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot;
|
||||
const state = {
|
||||
failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false,
|
||||
managedAccessToken: token, rejectOldBotToken: false,
|
||||
userListCursor: null as string | null, userListNextCursor: "",
|
||||
managedBotAppId: appId, grantedScopes: scopes, managedError: null as string | null, failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false,
|
||||
failManifestUpdate: false, timeoutManifestUpdate: false,
|
||||
beforeManifestUpdate: null as null | (() => Promise<void>),
|
||||
failIcon: false, timeoutIcon: false, failUser: false, failWelcome: false, timeoutWelcome: false,
|
||||
@@ -6270,20 +6273,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
user: { id: installerId, team_id: "TAUTO", name: "installer", real_name: "Installing Person", is_bot: false, deleted: false },
|
||||
beforeIcon: null as null | (() => Promise<void>),
|
||||
beforeAuth: null as null | (() => Promise<void>),
|
||||
beforeExchange: null as null | (() => Promise<void>),
|
||||
oauth: { ok: true, app_id: appId, token_type: "bot", access_token: token, team: { id: "TAUTO" }, bot_user_id: botId, authed_user: { id: installerId }, scope: scopes.join(",") },
|
||||
};
|
||||
const fallback = fakeSlackFetch(botId);
|
||||
const provider = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BMANAGED", app_id: state.managedBotAppId, user_id: botId, deleted: false } });
|
||||
if (url.endsWith("apps.managedInstall")) return Response.json(state.managedError ? { ok: false, error: state.managedError } : { ok: true, app_id: appId, team_id: "TAUTO", api_access_tokens: { bot_access_token: state.managedAccessToken } });
|
||||
if (url.endsWith("apps.manifest.validate")) return Response.json(state.failValidate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true });
|
||||
if (url.endsWith("apps.manifest.create")) {
|
||||
if (state.failCreate) throw new Error(`Timeout ${configToken} ${clientSecret}`);
|
||||
return Response.json({ ok: true, app_id: appId, credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } });
|
||||
return Response.json({ ok: true, app_id: appId, team_id: "TAUTO", credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } });
|
||||
}
|
||||
if (url.endsWith("apps.manifest.update")) {
|
||||
await state.beforeManifestUpdate?.();
|
||||
if (state.timeoutManifestUpdate) throw new Error(`Manifest timeout ${configToken} ${clientSecret}`);
|
||||
return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId });
|
||||
return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId, permissions_updated: true });
|
||||
}
|
||||
if (url.endsWith("apps.icon.set")) {
|
||||
await state.beforeIcon?.();
|
||||
@@ -6291,13 +6297,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
return Response.json(state.failIcon ? { ok: false, error: "missing_scope", detail: `${configToken} ${signingSecret}` } : { ok: true });
|
||||
}
|
||||
if (url.endsWith("oauth.v2.access")) {
|
||||
await state.beforeExchange?.();
|
||||
if (state.failExchange) throw new Error(`Timeout ${clientSecret} ${token}`);
|
||||
return Response.json(state.oauth);
|
||||
}
|
||||
if (url.endsWith("auth.test")) {
|
||||
await state.beforeAuth?.();
|
||||
if (state.rejectOldBotToken && new Headers(init?.headers).get("authorization") !== `Bearer ${state.managedAccessToken}`) return Response.json({ ok: false, error: "token_revoked" });
|
||||
if (state.failAuth) throw new Error(`Temporary failure ${token}`);
|
||||
return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
|
||||
return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, bot_id: "BMANAGED", user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": state.grantedScopes.join(",") } });
|
||||
}
|
||||
if (url.endsWith("users.list")) {
|
||||
state.userListCursor = new URLSearchParams(String(init?.body)).get("cursor");
|
||||
return Response.json({ ok: true, members: [state.user], response_metadata: { next_cursor: state.userListNextCursor } });
|
||||
}
|
||||
if (url.endsWith("users.info")) {
|
||||
await state.beforeUser?.();
|
||||
@@ -6323,6 +6335,408 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const install = async () => new URL((await service.slackRegistration.install(endpoint.id, actor)).authorizationUrl).searchParams.get("state")!;
|
||||
return { ...company, endpoint, service, sdk, app, state, provider, create, install, createInput, token, appId, botId, installerId, avatarBytes, renderAvatar };
|
||||
}
|
||||
async function managedFixture() {
|
||||
const broker = {
|
||||
getCapabilities: vi.fn(async () => ["slack.manager"]),
|
||||
getSlackManagerAppId: vi.fn(async () => "AMANAGER"),
|
||||
startAuthorization: vi.fn(async (_input: { returnState: string }) => ({ authorizationUrl: "https://slack.com/oauth/v2/authorize?state=broker", expiresAt: new Date(Date.now() + 600_000).toISOString() })),
|
||||
claim: vi.fn(), refresh: vi.fn(), revoke: vi.fn(async () => undefined),
|
||||
};
|
||||
const f = await fixture({ slackManaged: { enabled: true, broker: broker as unknown as PaperclipCloudConnector } });
|
||||
const credentials: SealedConnectorCredentials = { v: 1, provider: "slack", profile: "slack.manager", instanceId: "test", environment: "staging", subject: actor.userId, companyId: f.companyId,
|
||||
accessToken: "xoxe.xoxp-manager-access-canary", refreshToken: "xoxe-manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: new Date(Date.now() + 43_200_000).toISOString(), refreshTokenExpiresAt: null,
|
||||
scopes: [...SLACK_MANAGER_SCOPES], slackManager: { appId: "AMANAGER", workspaceId: "TAUTO", workspaceName: "Test workspace", userId: f.installerId } };
|
||||
broker.claim.mockResolvedValue(credentials);
|
||||
broker.refresh.mockResolvedValue({ ...credentials, accessToken: "xoxe.rotated-canary", refreshToken: "xoxe.rotated-refresh-canary" });
|
||||
await f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId);
|
||||
const grants = f.service.slackRegistration.managerGrants;
|
||||
const authorize = async () => { await grants.authorize(f.endpoint.id, actor); return broker.startAuthorization.mock.calls.at(-1)![0].returnState; };
|
||||
const authorizeState = await authorize();
|
||||
await grants.complete(authorizeState, "claim-canary", null, actor);
|
||||
const choices = await grants.choices(f.companyId, actor);
|
||||
expect(choices.workspaces).toHaveLength(1);
|
||||
const grantId = choices.workspaces[0]!.grantId;
|
||||
const input = { requestId: randomUUID(), grantId };
|
||||
const provision = () => f.service.slackRegistration.managed.provision(f.endpoint.id, actor, input);
|
||||
return { ...f, broker, credentials, grants, authorize, authorizeState, grantId, input, provision };
|
||||
}
|
||||
describe("Slack test messages", () => {
|
||||
const posts = (f: Awaited<ReturnType<typeof managedFixture>>) => f.provider.mock.calls.filter(([url]) => String(url).endsWith("chat.postMessage"));
|
||||
it("sends to the current linked user and reuses a durable result on duplicate requests", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
const input = { requestId: randomUUID() };
|
||||
const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(200);
|
||||
expect(result.body).toEqual({ status: "sent", slackUrl: "https://app.slack.com/client/TAUTO/DWELCOME" });
|
||||
await f.service.sendSlackTestMessage(f.endpoint.id, actor, input);
|
||||
expect(posts(f)).toHaveLength(1);
|
||||
expect(new URLSearchParams(String(posts(f)[0]![1]!.body)).get("channel")).toBe(f.installerId);
|
||||
});
|
||||
it("links the actor's own manager identity without requiring a connect command", async () => {
|
||||
const f = await managedFixture(); await f.provision();
|
||||
await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).toMatchObject({ status: "sent" });
|
||||
expect(await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id))).toEqual([expect.objectContaining({ paperclipUserId: actor.userId, status: "linked" })]);
|
||||
});
|
||||
it("requires a private Slack challenge for an unknown account before linking and sending the test", async () => {
|
||||
const f = await managedFixture(); await f.provision();
|
||||
await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
f.provider.mockClear(); const input = { requestId: randomUUID() };
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual({ status: "connection_required", accounts: [{ id: f.installerId, name: "Installing Person" }], nextCursor: null });
|
||||
expect(posts(f)).toHaveLength(0);
|
||||
const pending = await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, slackUserId: f.installerId });
|
||||
expect(pending.status).toBe("connection_pending");
|
||||
const links = await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
expect(links).toEqual([expect.objectContaining({ status: "pending", paperclipUserId: null })]);
|
||||
const fields = new URLSearchParams(String(posts(f)[0]![1]!.body));
|
||||
expect(fields.get("text")).not.toContain("This is your test message");
|
||||
const blocks = JSON.parse(fields.get("blocks")!);
|
||||
const invitationUrl = /<([^|]+)\|/.exec(blocks[1].text.text)![1]!;
|
||||
const token = new URL(invitationUrl).searchParams.get("token")!;
|
||||
expect(JSON.stringify(pending)).not.toContain(token);
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual(pending);
|
||||
expect(posts(f)).toHaveLength(1);
|
||||
await f.service.confirmIdentityLink(token, actor.userId);
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" });
|
||||
expect(new URLSearchParams(String(posts(f).at(-1)![1]!.body)).get("text")).toContain("This is your test message");
|
||||
});
|
||||
it("loads another workspace page instead of truncating account discovery", async () => {
|
||||
const f = await managedFixture(); await f.provision();
|
||||
await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
f.provider.mockClear(); f.state.userListNextCursor = "next-page";
|
||||
const input = { requestId: randomUUID() };
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "connection_required", nextCursor: "next-page" });
|
||||
f.state.userListNextCursor = "";
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, accountCursor: "next-page" })).toMatchObject({ status: "connection_required", nextCursor: null });
|
||||
expect(f.state.userListCursor).toBe("next-page");
|
||||
expect(posts(f)).toHaveLength(0);
|
||||
});
|
||||
it("does not downgrade confirmed delivery when activity logging fails", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
const original = activityLogModule.logActivity;
|
||||
const audit = vi.spyOn(activityLogModule, "logActivity").mockImplementation(async (database, entry) => {
|
||||
if (entry.action === "chat_slack.test_message_sent") throw new Error("Synthetic activity failure");
|
||||
return original(database, entry);
|
||||
});
|
||||
onTestFinished(() => audit.mockRestore());
|
||||
const input = { requestId: randomUUID() };
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" });
|
||||
expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" });
|
||||
expect(posts(f)).toHaveLength(1);
|
||||
});
|
||||
it("does not restore revoked access or take another user's identity", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("existing link");
|
||||
await db.update(chatIdentityLinks).set({ status: "linked", paperclipUserId: "another-user" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID(), slackUserId: f.installerId })).rejects.toThrow("existing link");
|
||||
expect(posts(f)).toHaveLength(0);
|
||||
});
|
||||
it("rejects wrong-workspace users, disabled DMs, and membership revocation", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
f.state.user.team_id = "TOTHER";
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("personal Slack account");
|
||||
f.state.user.team_id = "TAUTO";
|
||||
await db.update(chatEndpoints).set({ allowDirectMessages: false }).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("Enable direct messages");
|
||||
await db.update(chatEndpoints).set({ allowDirectMessages: true }).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, actor.userId)));
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("active Paperclip account");
|
||||
expect(posts(f)).toHaveLength(0);
|
||||
});
|
||||
it("redacts provider failures and never retries an ambiguous send", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear(); f.state.timeoutWelcome = true;
|
||||
const input = { requestId: randomUUID() };
|
||||
const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(422);
|
||||
expect(JSON.stringify(result.body)).not.toContain("canary");
|
||||
await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).rejects.toThrow("may already have been sent");
|
||||
expect(posts(f)).toHaveLength(1);
|
||||
const actions = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "slack_test_message")));
|
||||
expect(JSON.stringify(actions)).not.toContain("canary");
|
||||
expect(actions[0]!.status).toBe("uncertain");
|
||||
});
|
||||
});
|
||||
describe("managed Slack", () => {
|
||||
it("keeps open-source setup independent of the broker", async () => {
|
||||
const f = await fixture();
|
||||
expect(await f.service.slackRegistration.managerGrants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "automatic", workspaces: [] });
|
||||
await f.create();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackSetupMethod).toBe("automatic");
|
||||
await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId)).rejects.toThrow();
|
||||
});
|
||||
it("keeps configured Cloud drafts managed when the broker profile disappears", async () => {
|
||||
const f = await managedFixture();
|
||||
f.broker.getSlackManagerAppId.mockResolvedValue(null);
|
||||
expect(f.grants.defaultMethod()).toBe("managed");
|
||||
expect(await f.grants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "managed", workspaces: [] });
|
||||
const next = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId }, actor.userId);
|
||||
expect(next.setup.slackSetupMethod).toBe("managed");
|
||||
await expect(f.provision()).rejects.toThrow();
|
||||
});
|
||||
it("creates and installs once, uploads the avatar and links only the manager grant owner", async () => {
|
||||
const f = await managedFixture();
|
||||
await Promise.all([f.provision(), f.provision()]);
|
||||
const saved = await f.service.get(f.endpoint.id);
|
||||
expect(saved.setup).toMatchObject({ slackSetupMethod: "managed", slackRegistration: { status: "configured", appId: f.appId }, slackAvatar: { status: "uploaded" }, slackAccount: { status: "linked", paperclipUserId: actor.userId, externalUserId: f.installerId } });
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
const activities = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId));
|
||||
for (const secret of [f.credentials.accessToken, f.credentials.refreshToken!, f.token, clientSecret, signingSecret, "claim-canary"]) expect(JSON.stringify({ saved, activities, choices: await f.grants.choices(f.companyId, actor) })).not.toContain(secret);
|
||||
await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "automatic" }, actor.userId)).rejects.toThrow();
|
||||
});
|
||||
it("reuses the same personal workspace grant for another draft without another authorization", async () => {
|
||||
const f = await managedFixture();
|
||||
const draft = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed" }, actor.userId);
|
||||
expect((await f.grants.choices(f.companyId, actor)).workspaces[0]!.grantId).toBe(f.grantId);
|
||||
const otherActor = { ...actor, userId: "another-user", bypassPermissionCheck: true };
|
||||
expect((await f.grants.choices(f.companyId, otherActor)).workspaces).toEqual([]);
|
||||
await expect(f.service.slackRegistration.managed.provision(draft.id, otherActor, { ...f.input, requestId: randomUUID() })).rejects.toThrow();
|
||||
expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("installs a second bot using the same grant without new manager consent", async () => {
|
||||
const f = await managedFixture(); await f.provision();
|
||||
const secondBot = `${f.botId}SECOND`;
|
||||
const secondApp = `${f.appId}SECOND`;
|
||||
const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot;
|
||||
const provider: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("apps.manifest.create")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", credentials: { client_id: "222.333", client_secret: clientSecret, signing_secret: signingSecret } });
|
||||
if (url.endsWith("apps.managedInstall")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", api_access_tokens: { bot_access_token: "xoxb-second-managed" } });
|
||||
if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TAUTO", user_id: secondBot, bot_id: "BSECOND", team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
|
||||
if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BSECOND", app_id: secondApp, user_id: secondBot, deleted: false } });
|
||||
return f.provider(input, init);
|
||||
};
|
||||
const { service } = createService(new FakeChatSdkRuntime(), provider, { renderSlackAvatar: f.renderAvatar, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } });
|
||||
const second = await service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed", slackApp: appDetails }, actor.userId);
|
||||
await service.slackRegistration.managed.provision(second.id, actor, { grantId: f.grantId, requestId: randomUUID() });
|
||||
expect((await service.get(second.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: secondApp });
|
||||
expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(f.broker.claim).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("rejects missing tool scopes before activating a managed bot", async () => {
|
||||
const f = await managedFixture(); f.state.grantedScopes = [];
|
||||
await f.provision();
|
||||
const saved = await f.service.get(f.endpoint.id);
|
||||
expect(saved.providerAccountId).toBeNull();
|
||||
expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_scopes_missing" });
|
||||
});
|
||||
it.each(["revoked", "attention", "active"] as const)("reinstalls the configured app on an explicit %s reconnect", async status => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
await db.update(chatEndpoints).set({ status }).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
f.state.managedAccessToken = `xoxb-reconnected-${f.botId}`; f.state.rejectOldBotToken = true;
|
||||
const reconnectInput = { ...f.input, requestId: randomUUID(), reconnect: true };
|
||||
await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0);
|
||||
expect((await f.service.get(f.endpoint.id)).status).not.toBe("revoked");
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: f.appId });
|
||||
await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput);
|
||||
await f.provision();
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
});
|
||||
it("preserves configured-app reconnect intent when managed installation fails", async () => {
|
||||
const f = await managedFixture(); await f.provision(); f.provider.mockClear();
|
||||
await db.update(chatEndpoints).set({ status: "revoked" }).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
f.state.managedError = "temporary_error";
|
||||
await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", appId: f.appId });
|
||||
f.state.managedError = null;
|
||||
await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0);
|
||||
});
|
||||
it("reinstalls the saved app after missing bot scopes without creating another app", async () => {
|
||||
const f = await managedFixture(); const scopes = f.state.grantedScopes;
|
||||
f.state.grantedScopes = []; await f.provision();
|
||||
f.state.grantedScopes = scopes; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(2);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("retains reinstall intent after a provider failure instead of retrying rejected credentials", async () => {
|
||||
const f = await managedFixture(); const scopes = f.state.grantedScopes;
|
||||
f.state.grantedScopes = []; await f.provision();
|
||||
f.state.managedError = "internal_error"; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", errorCode: "slack_provider_failure" });
|
||||
f.state.managedError = null; f.state.grantedScopes = scopes; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(3);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("clears a manager reconnect error after renewing the bound grant", async () => {
|
||||
const f = await managedFixture(); f.state.managedError = "invalid_auth"; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manager_reauthorize");
|
||||
await f.grants.complete(await f.authorize(), "new-claim", null, actor);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBeNull();
|
||||
f.state.managedError = null; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("reconnects a rejected manager token without losing pending manifest configuration", async () => {
|
||||
const f = await managedFixture(); f.state.failManifestUpdate = true; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manifest_update_pending");
|
||||
expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]);
|
||||
expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("reauthorize");
|
||||
await f.grants.complete(await f.authorize(), "new-claim", null, actor);
|
||||
f.state.failManifestUpdate = false; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackAvatar?.status).toBe("uploaded");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.icon.set"))).toHaveLength(1);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("does not restore a revoked grant when an in-flight refresh returns", async () => {
|
||||
const f = await managedFixture();
|
||||
await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
f.broker.refresh.mockImplementation(async () => {
|
||||
await f.grants.revoke(f.grantId, f.companyId, actor);
|
||||
return f.credentials;
|
||||
});
|
||||
await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow();
|
||||
expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("revoked");
|
||||
});
|
||||
it("claims concurrent manager callbacks once", async () => {
|
||||
const f = await managedFixture(); const state = await f.authorize();
|
||||
const results = await Promise.allSettled([1, 2].map(() => f.grants.complete(state, "claim", null, actor)));
|
||||
expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
|
||||
expect(f.broker.claim).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("atomically consumes manager state and rejects wrong actors and expired attempts", async () => {
|
||||
const f = await managedFixture();
|
||||
await expect(f.grants.complete(f.authorizeState, "replay", null, actor)).rejects.toThrow();
|
||||
const state = await f.authorize();
|
||||
await expect(f.grants.complete(state, "claim", null, { ...actor, userId: "another-user", bypassPermissionCheck: true })).rejects.toThrow();
|
||||
await db.update(toolOauthStates).set({ expiresAt: new Date(0) }).where(eq(toolOauthStates.state, state));
|
||||
await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow();
|
||||
expect(f.broker.claim).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("preserves declined manager authorization as a safe recoverable outcome", async () => {
|
||||
const f = await managedFixture();
|
||||
await f.grants.complete(await f.authorize(), null, "access_denied", actor);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBe("slack_install_declined");
|
||||
});
|
||||
it("resumes saved installation credentials after inventory failure without creating or reinstalling", async () => {
|
||||
const f = await managedFixture(); f.state.failInventory = true;
|
||||
await f.provision();
|
||||
expect((await f.service.slackRegistration.registration(f.endpoint.id))?.status).toBe("credentials_saved");
|
||||
f.state.failInventory = false; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("never recreates an uncertain app", async () => {
|
||||
const f = await managedFixture(); f.state.failCreate = true;
|
||||
await f.provision(); await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("uncertain");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("uses bound OAuth for approval fallback and rejects a different installer", async () => {
|
||||
const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible";
|
||||
const result = await f.provision();
|
||||
const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!;
|
||||
f.state.oauth.authed_user.id = "UOTHER";
|
||||
await f.service.slackRegistration.complete(state, "code-canary", null, actor);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch");
|
||||
expect((await f.service.get(f.endpoint.id)).providerAccountId).toBeNull();
|
||||
});
|
||||
it("does not activate a bot when the manager grant is revoked during child OAuth", async () => {
|
||||
const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible";
|
||||
const result = await f.provision();
|
||||
const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!;
|
||||
f.state.beforeExchange = () => f.grants.revoke(f.grantId, f.companyId, actor);
|
||||
await f.service.slackRegistration.complete(state, "code-canary", null, actor);
|
||||
const saved = await f.service.get(f.endpoint.id);
|
||||
expect(saved.providerAccountId).toBeNull();
|
||||
expect(saved.setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch");
|
||||
});
|
||||
it("uses the held transaction for grant queries without acquiring another pool connection", async () => {
|
||||
const f = await managedFixture();
|
||||
const transactionContext = new AsyncLocalStorage<boolean>();
|
||||
const originalTransaction = db.transaction.bind(db);
|
||||
const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((work, ...args) =>
|
||||
originalTransaction(tx => transactionContext.run(true, () => work(tx)), ...args));
|
||||
const querySpies = (["select", "insert", "update", "delete", "execute"] as const).map(method => {
|
||||
const original = db[method].bind(db) as (...args: unknown[]) => unknown;
|
||||
return vi.spyOn(db, method).mockImplementation(((...args: unknown[]) => {
|
||||
if (transactionContext.getStore()) throw new Error("Acquired outer pool connection from held transaction");
|
||||
return original(...args);
|
||||
}) as never);
|
||||
});
|
||||
try {
|
||||
await f.grants.token(f.grantId, f.companyId, actor);
|
||||
await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
await f.grants.token(f.grantId, f.companyId, actor);
|
||||
expect(f.broker.refresh).toHaveBeenCalledTimes(1);
|
||||
await f.grants.request(f.grantId, f.companyId, actor, "apps.manifest.validate", { manifest: "{}" });
|
||||
await f.grants.complete(await f.authorize(), "renewed-claim", null, actor);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBeUndefined();
|
||||
await f.grants.revoke(f.grantId, f.companyId, actor);
|
||||
expect(f.broker.revoke).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
transactionSpy.mockRestore();
|
||||
for (const spy of querySpies) spy.mockRestore();
|
||||
}
|
||||
});
|
||||
it("serializes refreshes across endpoints and never replays an ambiguous refresh", async () => {
|
||||
const f = await managedFixture();
|
||||
await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
const tokens = await Promise.all([1, 2].map(() => f.grants.token(f.grantId, f.companyId, actor)));
|
||||
expect(tokens.every(result => result.token === "xoxe.rotated-canary")).toBe(true);
|
||||
expect(f.broker.refresh).toHaveBeenCalledTimes(1);
|
||||
await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
f.broker.refresh.mockRejectedValue(new Error("provider echoed manager-canary"));
|
||||
await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow();
|
||||
await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow();
|
||||
expect(f.broker.refresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("rejects a bot token belonging to a different Slack app before activation", async () => {
|
||||
const f = await managedFixture(); f.state.managedBotAppId = "ADIFFERENT";
|
||||
await f.provision();
|
||||
const saved = await f.service.get(f.endpoint.id);
|
||||
expect(saved.providerAccountId).toBeNull();
|
||||
expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_identity_mismatch" });
|
||||
});
|
||||
it("rejects manager identity changes and both callback-origin and ingress-origin drift", async () => {
|
||||
const f = await managedFixture(); const state = await f.authorize();
|
||||
for (const change of [{ publicBaseUrl: "https://changed.example" }, { webhookPublicBaseUrl: "https://changed.example" }]) {
|
||||
const changed = createService(new FakeChatSdkRuntime(), f.provider, { ...change, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }).service;
|
||||
await expect(changed.slackRegistration.managerGrants.complete(state, "claim", null, actor)).rejects.toThrow();
|
||||
}
|
||||
f.broker.getSlackManagerAppId.mockResolvedValue("ANEWMANAGER");
|
||||
await expect(f.provision()).rejects.toThrow();
|
||||
expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]);
|
||||
});
|
||||
it("preserves provider rate-limit backoff across subsequent attempts", async () => {
|
||||
const f = await managedFixture(); f.state.managedError = "ratelimited";
|
||||
await f.provision();
|
||||
f.state.managedError = null; await f.provision();
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1);
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_setup_rate_limited");
|
||||
});
|
||||
it("routes the broker claim_id callback back to the saved wizard without exposing the handoff", async () => {
|
||||
const f = await managedFixture(); const state = await f.authorize();
|
||||
const response = await request(f.app).get(`/api/chat-slack/managed/oauth/callback?state=${state}&claim_id=sealed-claim-canary`).set("accept", "application/json").expect(303);
|
||||
expect(f.broker.claim.mock.calls.at(-1)![0].claimId).toBe("sealed-claim-canary");
|
||||
expect(response.headers.location).toContain(f.endpoint.id);
|
||||
expect(response.text).not.toContain("sealed-claim-canary");
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
});
|
||||
it("does not accept another company's grant or keep authorizations after removal", async () => {
|
||||
const f = await managedFixture(); const other = await seedCompany();
|
||||
await expect(f.grants.token(f.grantId, other.companyId, { ...actor, bypassPermissionCheck: true })).rejects.toThrow();
|
||||
const state = await f.authorize();
|
||||
await f.service.configure(f.endpoint.id, { action: "remove" }, actor.userId);
|
||||
await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow();
|
||||
expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("active");
|
||||
});
|
||||
it("revocation invalidates pending authorization and prevents future provisioning", async () => {
|
||||
const f = await managedFixture(); const state = await f.authorize();
|
||||
await f.grants.revoke(f.grantId, f.companyId, actor);
|
||||
await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow();
|
||||
await expect(f.provision()).rejects.toThrow();
|
||||
expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
function setupEvent(f: Awaited<ReturnType<typeof fixture>>, patch: Record<string, unknown> = {}, url = f.endpoint.setup.webhookUrl!, secret = signingSecret, timestamp?: string) {
|
||||
return signedSlackWebhookRequest({ url, contentType: "application/json", signingSecret: secret, ...(timestamp ? { timestamp } : {}),
|
||||
body: JSON.stringify({ type: "event_callback", api_app_id: f.appId, team_id: "TAUTO", event_id: "EvSETUP", event: {
|
||||
|
||||
@@ -522,6 +522,17 @@ describe("company portability", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
function exportCompanyAndAgents(portability: ReturnType<typeof companyPortabilityService>) {
|
||||
return portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("renders high-volume YAML blocks without overflowing the call stack", () => {
|
||||
const tasks = Array.from({ length: 130_000 }, (_, index) => `issue-${index}`);
|
||||
|
||||
@@ -705,14 +716,7 @@ describe("company portability", () => {
|
||||
};
|
||||
const portability = companyPortabilityService(db as any);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("permissionGrants:");
|
||||
@@ -794,14 +798,7 @@ describe("company portability", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
const serialized = JSON.stringify(exported);
|
||||
expect(serialized).not.toContain("sk-inline-secret-value");
|
||||
@@ -1778,14 +1775,7 @@ describe("company portability", () => {
|
||||
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
const preview = await portability.previewImport({
|
||||
source: {
|
||||
@@ -3321,14 +3311,7 @@ describe("company portability", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("APIKEY:");
|
||||
@@ -3365,14 +3348,7 @@ describe("company portability", () => {
|
||||
reason: "Existing skill matched; renamed to paperclip-2.",
|
||||
}]);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
@@ -3445,14 +3421,7 @@ describe("company portability", () => {
|
||||
});
|
||||
|
||||
const portability = companyPortabilityService({} as any, storage as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
exported.files["images/company-logo.png"] = {
|
||||
encoding: "base64",
|
||||
@@ -3518,14 +3487,7 @@ describe("company portability", () => {
|
||||
name: "ClaudeCoder",
|
||||
});
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
@@ -3585,14 +3547,7 @@ describe("company portability", () => {
|
||||
})));
|
||||
agentSvc.list.mockClear();
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
@@ -3637,14 +3592,7 @@ describe("company portability", () => {
|
||||
it("imports only selected files and leaves unchecked company metadata alone", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
@@ -3735,14 +3683,7 @@ describe("company portability", () => {
|
||||
name: "ClaudeCoder",
|
||||
});
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
@@ -3818,14 +3759,7 @@ describe("company portability", () => {
|
||||
adapterConfig: input.adapterConfig,
|
||||
}));
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
@@ -5517,14 +5451,7 @@ describe("company portability", () => {
|
||||
name: "ClaudeCoder",
|
||||
});
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
const originalAgentsMarkdown = exported.files["agents/claudecoder/AGENTS.md"];
|
||||
expect(typeof originalAgentsMarkdown).toBe("string");
|
||||
|
||||
@@ -5576,14 +5503,7 @@ describe("company portability", () => {
|
||||
|
||||
it("rejects dangerous adapter types on agent-safe imports", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
@@ -5760,14 +5680,7 @@ describe("company portability", () => {
|
||||
|
||||
it("imports new agents as active while preserving future hire approval settings", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({
|
||||
@@ -5836,14 +5749,7 @@ describe("company portability", () => {
|
||||
},
|
||||
})));
|
||||
agentSvc.list.mockClear();
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
const exported = await exportCompanyAndAgents(portability);
|
||||
|
||||
secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({
|
||||
normalized: "updated",
|
||||
|
||||
@@ -201,6 +201,75 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
function boardActor(userId = "local-board", source = "local_implicit") {
|
||||
return {
|
||||
type: "board",
|
||||
userId: userId,
|
||||
companyIds: ["company-1"],
|
||||
source: source,
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
}
|
||||
|
||||
function assignedAgentActor() {
|
||||
return {
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
};
|
||||
}
|
||||
|
||||
function createCatalogSkill() {
|
||||
return {
|
||||
id: "paperclipai:bundled:software-development:review",
|
||||
key: "paperclipai/bundled/software-development/review",
|
||||
kind: "bundled",
|
||||
category: "software-development",
|
||||
slug: "review",
|
||||
name: "review",
|
||||
description: "Review code",
|
||||
path: "catalog/bundled/software-development/review",
|
||||
entrypoint: "SKILL.md",
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
defaultInstall: false,
|
||||
recommendedForRoles: ["engineer"],
|
||||
requires: [],
|
||||
tags: ["review"],
|
||||
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }],
|
||||
contentHash: "sha256:abc",
|
||||
};
|
||||
}
|
||||
|
||||
function createSkillTestRunDetails(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
skillId: "skill-1",
|
||||
inputId: "11111111-1111-4111-8111-111111111111",
|
||||
inputSnapshot: "Try the skill",
|
||||
skillVersionId: "33333333-3333-4333-8333-333333333333",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
agentConfigSnapshot: { adapterType: "codex_local" },
|
||||
issueId: "44444444-4444-4444-8444-444444444444",
|
||||
templateId: "built-in:default-test-template",
|
||||
templateName: "Default test template",
|
||||
templateBody: "Default {{skillName}}",
|
||||
renderedTemplateBody: "Default Review",
|
||||
harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review",
|
||||
status: "queued",
|
||||
outputDocumentKey: "output",
|
||||
outputSnapshot: "",
|
||||
error: null,
|
||||
deletedAt: null,
|
||||
supersededAt: null,
|
||||
harnessIssueExpiresAt: null,
|
||||
harnessIssueDeletedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("company skill mutation permissions", () => {
|
||||
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
|
||||
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
|
||||
@@ -399,25 +468,7 @@ describe("company skill mutation permissions", () => {
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
},
|
||||
catalogSkill: {
|
||||
id: "paperclipai:bundled:software-development:review",
|
||||
key: "paperclipai/bundled/software-development/review",
|
||||
kind: "bundled",
|
||||
category: "software-development",
|
||||
slug: "review",
|
||||
name: "review",
|
||||
description: "Review code",
|
||||
path: "catalog/bundled/software-development/review",
|
||||
entrypoint: "SKILL.md",
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
defaultInstall: false,
|
||||
recommendedForRoles: ["engineer"],
|
||||
requires: [],
|
||||
tags: ["review"],
|
||||
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }],
|
||||
contentHash: "sha256:abc",
|
||||
},
|
||||
catalogSkill: createCatalogSkill(),
|
||||
warnings: [],
|
||||
});
|
||||
mockCompanySkillService.createLocalSkill.mockResolvedValue({
|
||||
@@ -590,56 +641,14 @@ describe("company skill mutation permissions", () => {
|
||||
mockCompanySkillService.listTestRuns.mockResolvedValue([]);
|
||||
mockCompanySkillService.getTestRunDetail.mockResolvedValue(null);
|
||||
mockCompanySkillService.createTestRun.mockResolvedValue({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
skillId: "skill-1",
|
||||
inputId: "11111111-1111-4111-8111-111111111111",
|
||||
inputSnapshot: "Try the skill",
|
||||
skillVersionId: "33333333-3333-4333-8333-333333333333",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
agentConfigSnapshot: { adapterType: "codex_local" },
|
||||
issueId: "44444444-4444-4444-8444-444444444444",
|
||||
templateId: "built-in:default-test-template",
|
||||
templateName: "Default test template",
|
||||
templateBody: "Default {{skillName}}",
|
||||
renderedTemplateBody: "Default Review",
|
||||
harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review",
|
||||
status: "queued",
|
||||
outputDocumentKey: "output",
|
||||
outputSnapshot: "",
|
||||
error: null,
|
||||
deletedAt: null,
|
||||
supersededAt: null,
|
||||
harnessIssueExpiresAt: null,
|
||||
harnessIssueDeletedAt: null,
|
||||
...createSkillTestRunDetails(),
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
taskExpired: false,
|
||||
});
|
||||
mockCompanySkillService.cancelTestRun.mockResolvedValue({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
skillId: "skill-1",
|
||||
inputId: "11111111-1111-4111-8111-111111111111",
|
||||
inputSnapshot: "Try the skill",
|
||||
skillVersionId: "33333333-3333-4333-8333-333333333333",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
agentConfigSnapshot: { adapterType: "codex_local" },
|
||||
issueId: "44444444-4444-4444-8444-444444444444",
|
||||
templateId: "built-in:default-test-template",
|
||||
templateName: "Default test template",
|
||||
templateBody: "Default {{skillName}}",
|
||||
renderedTemplateBody: "Default Review",
|
||||
harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review",
|
||||
status: "cancelled",
|
||||
outputDocumentKey: "output",
|
||||
outputSnapshot: "",
|
||||
error: "Cancelled by operator",
|
||||
deletedAt: null,
|
||||
supersededAt: null,
|
||||
harnessIssueExpiresAt: null,
|
||||
harnessIssueDeletedAt: null,
|
||||
...createSkillTestRunDetails({ status: "cancelled", error: "Cancelled by operator" }),
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:01:00.000Z"),
|
||||
cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
@@ -661,25 +670,7 @@ describe("company skill mutation permissions", () => {
|
||||
mockHeartbeatService.wakeup.mockResolvedValue({});
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue({});
|
||||
mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([]);
|
||||
mockCatalogService.getCatalogSkillOrThrow.mockReturnValue({
|
||||
id: "paperclipai:bundled:software-development:review",
|
||||
key: "paperclipai/bundled/software-development/review",
|
||||
kind: "bundled",
|
||||
category: "software-development",
|
||||
slug: "review",
|
||||
name: "review",
|
||||
description: "Review code",
|
||||
path: "catalog/bundled/software-development/review",
|
||||
entrypoint: "SKILL.md",
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
defaultInstall: false,
|
||||
recommendedForRoles: ["engineer"],
|
||||
requires: [],
|
||||
tags: ["review"],
|
||||
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }],
|
||||
contentHash: "sha256:abc",
|
||||
});
|
||||
mockCatalogService.getCatalogSkillOrThrow.mockReturnValue(createCatalogSkill());
|
||||
mockCatalogService.readCatalogSkillFile.mockResolvedValue({
|
||||
catalogSkillId: "paperclipai:bundled:software-development:review",
|
||||
path: "SKILL.md",
|
||||
@@ -709,13 +700,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("allows local board operators to mutate company skills", async () => {
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
@@ -750,13 +735,7 @@ describe("company skill mutation permissions", () => {
|
||||
}],
|
||||
warnings: [],
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const preview = await request(app)
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
@@ -798,13 +777,7 @@ describe("company skill mutation permissions", () => {
|
||||
"Actor is restricted from changing skill configuration.",
|
||||
));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor("board-user", "session")))
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({ mode: "preview", workspaceIds: [workspaceId] });
|
||||
|
||||
@@ -814,13 +787,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("allows board users with skills:create to create, import, install, update, delete, audit, and reset company skills", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const app = await createApp(boardActor("board-user", "session"));
|
||||
|
||||
await request(app)
|
||||
.post("/api/companies/company-1/skills")
|
||||
@@ -867,13 +834,7 @@ describe("company skill mutation permissions", () => {
|
||||
it("allows board users without skills:create when no explicit skill policy exists", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision());
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor("board-user", "session")))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
@@ -897,12 +858,7 @@ describe("company skill mutation permissions", () => {
|
||||
remediation: "Contact a company administrator to change the skill policy.",
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
const res = await request(await createApp(assignedAgentActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://packages.example.com/skill.tgz" });
|
||||
|
||||
@@ -921,13 +877,7 @@ describe("company skill mutation permissions", () => {
|
||||
it("rejects secret-bearing remote import URLs without echoing the secret", async () => {
|
||||
const source = "https://github.com/acme/private-skill?token=secret#token=secret";
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source });
|
||||
|
||||
@@ -941,13 +891,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("rejects malformed remote import URLs before policy evaluation", async () => {
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://" });
|
||||
|
||||
@@ -965,12 +909,7 @@ describe("company skill mutation permissions", () => {
|
||||
"Low-trust agents cannot use company-wide skill APIs.",
|
||||
));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
const res = await request(await createApp(assignedAgentActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
@@ -998,13 +937,7 @@ describe("company skill mutation permissions", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor("board-user", "session")))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "vercel-labs/agent-browser" });
|
||||
|
||||
@@ -1042,13 +975,7 @@ describe("company skill mutation permissions", () => {
|
||||
}
|
||||
));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor("board-user", "session")))
|
||||
.patch("/api/companies/company-1/skills/skill-1")
|
||||
.send({ name: "Updated review" });
|
||||
|
||||
@@ -1073,13 +1000,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
mockCompanySkillPolicyService.evaluate.mockResolvedValue(denySkillPolicy("skills.create"));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor("board-user", "session")))
|
||||
.post("/api/companies/company-1/skills/skill-1/versions")
|
||||
.send({ label: "v1" });
|
||||
|
||||
@@ -1110,13 +1031,7 @@ describe("company skill mutation permissions", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor("board-user", "session")))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "npx skills add Vercel-Labs/Agent-Browser --skill agent-browser -g" });
|
||||
|
||||
@@ -1160,13 +1075,7 @@ describe("company skill mutation permissions", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor("board-user", "session")))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source });
|
||||
|
||||
@@ -1185,34 +1094,10 @@ describe("company skill mutation permissions", () => {
|
||||
|
||||
it("serves catalog listing without mutating company skills", async () => {
|
||||
mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([
|
||||
{
|
||||
id: "paperclipai:bundled:software-development:review",
|
||||
key: "paperclipai/bundled/software-development/review",
|
||||
kind: "bundled",
|
||||
category: "software-development",
|
||||
slug: "review",
|
||||
name: "review",
|
||||
description: "Review code",
|
||||
path: "catalog/bundled/software-development/review",
|
||||
entrypoint: "SKILL.md",
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
defaultInstall: false,
|
||||
recommendedForRoles: ["engineer"],
|
||||
requires: [],
|
||||
tags: ["review"],
|
||||
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }],
|
||||
contentHash: "sha256:abc",
|
||||
},
|
||||
createCatalogSkill(),
|
||||
]);
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.get("/api/skills/catalog?kind=bundled&q=review");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
@@ -1267,13 +1152,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("serves catalog detail and files by catalog reference", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const detail = await request(app)
|
||||
.get("/api/skills/catalog/review");
|
||||
@@ -1288,13 +1167,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("installs catalog skills with mutation permissions and logs provenance", async () => {
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post("/api/companies/company-1/skills/install-catalog")
|
||||
.send({
|
||||
catalogSkillId: "paperclipai:bundled:software-development:review",
|
||||
@@ -1348,13 +1221,7 @@ describe("company skill mutation permissions", () => {
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
@@ -1486,13 +1353,7 @@ describe("company skill mutation permissions", () => {
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://ghe.example.com/acme/private-skill" });
|
||||
|
||||
@@ -1528,13 +1389,7 @@ describe("company skill mutation permissions", () => {
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/acme/private-skill" });
|
||||
|
||||
@@ -1553,12 +1408,7 @@ describe("company skill mutation permissions", () => {
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
const res = await request(await createApp(assignedAgentActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
@@ -1578,12 +1428,7 @@ describe("company skill mutation permissions", () => {
|
||||
permissions: { canCreateSkills: true },
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
const res = await request(await createApp(assignedAgentActor()))
|
||||
.post("/api/companies/company-2/skills/install-catalog")
|
||||
.send({ catalogSkillId: "paperclipai:bundled:software-development:review" });
|
||||
|
||||
@@ -1861,12 +1706,7 @@ describe("company skill mutation permissions", () => {
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
const res = await request(await createApp(assignedAgentActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
@@ -1906,26 +1746,7 @@ describe("company skill mutation permissions", () => {
|
||||
expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invoke the legacy consent gate under the open default", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision(
|
||||
"deny_missing_consent",
|
||||
"Permission skills:suggest-changes requires accepted change consent before applying this mutation.",
|
||||
));
|
||||
mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(new Error("database unavailable"));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "reflection-coach",
|
||||
companyId: "company-1",
|
||||
runId: "run-apply",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills")
|
||||
.send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockReflectionCoachMutationGate.assertConsented).not.toHaveBeenCalled();
|
||||
expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows suggest-tier skill mutations without a second consent decision", async () => {
|
||||
mockAccessService.decide
|
||||
@@ -1940,12 +1761,7 @@ describe("company skill mutation permissions", () => {
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
const res = await request(await createApp(assignedAgentActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
@@ -1966,12 +1782,7 @@ describe("company skill mutation permissions", () => {
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
const res = await request(await createApp(assignedAgentActor()))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
@@ -2016,13 +1827,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("routes skill test input CRUD through skills mutation permissions", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const created = await request(app)
|
||||
.post("/api/companies/company-1/skills/skill-1/test-inputs")
|
||||
@@ -2057,13 +1862,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("routes skill test run template CRUD through skills mutation permissions", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const listed = await request(app).get("/api/companies/company-1/skill-test-run-templates");
|
||||
expect(listed.status, JSON.stringify(listed.body)).toBe(200);
|
||||
@@ -2124,28 +1923,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
await deps.wakeHarnessIssue("44444444-4444-4444-8444-444444444444", "55555555-5555-4555-8555-555555555555");
|
||||
return {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
skillId: "skill-1",
|
||||
inputId: "11111111-1111-4111-8111-111111111111",
|
||||
inputSnapshot: "Try the skill",
|
||||
skillVersionId: "33333333-3333-4333-8333-333333333333",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
agentConfigSnapshot: { adapterType: "codex_local" },
|
||||
issueId: "44444444-4444-4444-8444-444444444444",
|
||||
templateId: "built-in:default-test-template",
|
||||
templateName: "Default test template",
|
||||
templateBody: "Default {{skillName}}",
|
||||
renderedTemplateBody: "Default Review",
|
||||
harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review",
|
||||
status: "queued",
|
||||
outputDocumentKey: "output",
|
||||
outputSnapshot: "",
|
||||
error: null,
|
||||
deletedAt: null,
|
||||
supersededAt: null,
|
||||
harnessIssueExpiresAt: null,
|
||||
harnessIssueDeletedAt: null,
|
||||
...createSkillTestRunDetails(),
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
@@ -2160,28 +1938,7 @@ describe("company skill mutation permissions", () => {
|
||||
) => {
|
||||
await deps.cancelHarnessIssue("44444444-4444-4444-8444-444444444444");
|
||||
return {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
skillId: "skill-1",
|
||||
inputId: "11111111-1111-4111-8111-111111111111",
|
||||
inputSnapshot: "Try the skill",
|
||||
skillVersionId: "33333333-3333-4333-8333-333333333333",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
agentConfigSnapshot: { adapterType: "codex_local" },
|
||||
issueId: "44444444-4444-4444-8444-444444444444",
|
||||
templateId: "built-in:default-test-template",
|
||||
templateName: "Default test template",
|
||||
templateBody: "Default {{skillName}}",
|
||||
renderedTemplateBody: "Default Review",
|
||||
harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review",
|
||||
status: "cancelled",
|
||||
outputDocumentKey: "output",
|
||||
outputSnapshot: "",
|
||||
error: "Cancelled by operator",
|
||||
deletedAt: null,
|
||||
supersededAt: null,
|
||||
harnessIssueExpiresAt: null,
|
||||
harnessIssueDeletedAt: null,
|
||||
...createSkillTestRunDetails({ status: "cancelled", error: "Cancelled by operator" }),
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:01:00.000Z"),
|
||||
cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
@@ -2189,13 +1946,7 @@ describe("company skill mutation permissions", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const created = await request(app)
|
||||
.post("/api/companies/company-1/skills/skill-1/test-runs")
|
||||
@@ -2282,13 +2033,7 @@ describe("company skill mutation permissions", () => {
|
||||
harnessContent: { available: false, unavailableReason: "expired", documents: [], attachments: [], workProducts: [] },
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const listed = await request(app)
|
||||
.get("/api/companies/company-1/skills/skill-1/test-runs");
|
||||
@@ -2322,28 +2067,7 @@ describe("company skill mutation permissions", () => {
|
||||
) => {
|
||||
await deps.hideHarnessIssue("44444444-4444-4444-8444-444444444444");
|
||||
return {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
skillId: "skill-1",
|
||||
inputId: null,
|
||||
inputSnapshot: "Try the skill",
|
||||
skillVersionId: "33333333-3333-4333-8333-333333333333",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
agentConfigSnapshot: { adapterType: "codex_local" },
|
||||
issueId: "44444444-4444-4444-8444-444444444444",
|
||||
templateId: "built-in:default-test-template",
|
||||
templateName: "Default test template",
|
||||
templateBody: "Default {{skillName}}",
|
||||
renderedTemplateBody: "Default Review",
|
||||
harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review",
|
||||
status: "succeeded",
|
||||
outputDocumentKey: "output",
|
||||
outputSnapshot: "",
|
||||
error: null,
|
||||
deletedAt: new Date("2026-05-26T00:02:00.000Z"),
|
||||
supersededAt: null,
|
||||
harnessIssueExpiresAt: null,
|
||||
harnessIssueDeletedAt: null,
|
||||
...createSkillTestRunDetails({ inputId: null, status: "succeeded", deletedAt: new Date("2026-05-26T00:02:00.000Z") }),
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:02:00.000Z"),
|
||||
cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
@@ -2351,13 +2075,7 @@ describe("company skill mutation permissions", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const deleted = await request(app)
|
||||
.delete("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222");
|
||||
@@ -2371,13 +2089,7 @@ describe("company skill mutation permissions", () => {
|
||||
|
||||
it("returns 404 when deleting a missing test run", async () => {
|
||||
mockCompanySkillService.deleteTestRun.mockResolvedValueOnce(null);
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
const app = await createApp(boardActor());
|
||||
const res = await request(app)
|
||||
.delete("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222");
|
||||
expect(res.status).toBe(404);
|
||||
@@ -2391,13 +2103,7 @@ describe("company skill mutation permissions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.delete("/api/companies/company-1/skills/skill-1");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
|
||||
@@ -149,7 +149,6 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
fs.utimesSync(target, current, current);
|
||||
const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture };
|
||||
expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false);
|
||||
// A newer Linux build-host output cannot mask the remote Mac artifact.
|
||||
fs.writeFileSync(checkout.binary, nativeHeader("linux", "x64"));
|
||||
const wrongHostNewer = new Date(Date.now() + 4000);
|
||||
fs.utimesSync(checkout.binary, wrongHostNewer, wrongHostNewer);
|
||||
|
||||
@@ -223,11 +223,7 @@ function createDeleteBlastRadius(overrides: Partial<{
|
||||
}
|
||||
|
||||
let server: Server | null = null;
|
||||
let currentActor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
};
|
||||
let currentActor: Record<string, unknown> = localBoardActor();
|
||||
const routeOptions: Record<string, unknown> = {};
|
||||
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
|
||||
@@ -260,6 +256,67 @@ function createApp(actor: Record<string, unknown>, options: Record<string, unkno
|
||||
return server;
|
||||
}
|
||||
|
||||
function localBoardActor(userId = "user-1") {
|
||||
return {
|
||||
type: "board",
|
||||
userId: userId,
|
||||
source: "local_implicit",
|
||||
};
|
||||
}
|
||||
|
||||
function createSecurePluginDriver() {
|
||||
return {
|
||||
driverKey: "secure-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Secure Sandbox",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: { type: "string" },
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
timeoutMs: { type: "number" },
|
||||
reuseLease: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSecretBackedSshEnvironment() {
|
||||
return {
|
||||
...createEnvironment(),
|
||||
id: "env-ssh",
|
||||
name: "SSH Fixture",
|
||||
driver: "ssh" as const,
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSshConfig() {
|
||||
return {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("environment routes", () => {
|
||||
afterAll(async () => {
|
||||
if (originalSecretsProviderEnv === undefined) {
|
||||
@@ -369,20 +426,7 @@ describe("environment routes", () => {
|
||||
? {
|
||||
pluginId: "plugin-secure",
|
||||
pluginKey: "acme.secure-sandbox-provider",
|
||||
driver: {
|
||||
driverKey: "secure-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Secure Sandbox",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: { type: "string" },
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
timeoutMs: { type: "number" },
|
||||
reuseLease: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
driver: createSecurePluginDriver(),
|
||||
}
|
||||
: null
|
||||
));
|
||||
@@ -401,11 +445,7 @@ describe("environment routes", () => {
|
||||
|
||||
it("lists instance-scoped environments through the company route alias", async () => {
|
||||
mockEnvironmentService.list.mockResolvedValue([createEnvironment()]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/environments?driver=local");
|
||||
|
||||
@@ -718,15 +758,7 @@ describe("environment routes", () => {
|
||||
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still rejects deletion of the managed sandbox row", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment());
|
||||
const app = createApp(ownerAdminActor);
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-managed-1");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.details).toMatchObject({ code: "environment_platform_managed" });
|
||||
});
|
||||
|
||||
it("hides the local environment from every read surface under managed-sandbox-only", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
||||
@@ -1173,11 +1205,7 @@ describe("environment routes", () => {
|
||||
});
|
||||
|
||||
it("returns provider capabilities for the company", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
||||
|
||||
@@ -1213,11 +1241,7 @@ describe("environment routes", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
||||
|
||||
@@ -1271,11 +1295,7 @@ describe("environment routes", () => {
|
||||
configSchema: { type: "object", properties: {} },
|
||||
},
|
||||
]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
||||
|
||||
@@ -1295,11 +1315,7 @@ describe("environment routes", () => {
|
||||
configSchema: { type: "object", properties: {} },
|
||||
},
|
||||
]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
||||
|
||||
@@ -1322,11 +1338,7 @@ describe("environment routes", () => {
|
||||
configSchema: { type: "object", properties: {} },
|
||||
},
|
||||
]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/environments/capabilities");
|
||||
|
||||
@@ -1384,11 +1396,7 @@ describe("environment routes", () => {
|
||||
it("creates an environment and logs activity", async () => {
|
||||
const environment = createEnvironment();
|
||||
mockEnvironmentService.create.mockResolvedValue(environment);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -1425,11 +1433,7 @@ describe("environment routes", () => {
|
||||
|
||||
it("returns conflict when creating a second local environment", async () => {
|
||||
mockEnvironmentService.list.mockResolvedValue([createEnvironment()]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -1529,11 +1533,7 @@ describe("environment routes", () => {
|
||||
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
||||
isManagedLocal: true,
|
||||
}));
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-1");
|
||||
|
||||
@@ -1549,26 +1549,13 @@ describe("environment routes", () => {
|
||||
...createEnvironment(),
|
||||
driver: "ssh" as const,
|
||||
name: "SSH Fixture",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
config: createSshConfig(),
|
||||
};
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
||||
isInstanceDefault: true,
|
||||
}));
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-1");
|
||||
|
||||
@@ -1585,26 +1572,13 @@ describe("environment routes", () => {
|
||||
...createEnvironment(),
|
||||
driver: "ssh" as const,
|
||||
name: "SSH Fixture",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
config: createSshConfig(),
|
||||
};
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
||||
pendingCleanupLeaseCount: 1,
|
||||
}));
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-1");
|
||||
|
||||
@@ -1632,11 +1606,7 @@ describe("environment routes", () => {
|
||||
activeLeaseCount: 1,
|
||||
reusableSandboxLeaseCount: 1,
|
||||
}));
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-1");
|
||||
|
||||
@@ -1662,11 +1632,7 @@ describe("environment routes", () => {
|
||||
});
|
||||
mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment);
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true");
|
||||
|
||||
@@ -1696,11 +1662,7 @@ describe("environment routes", () => {
|
||||
isInstanceDefault: true,
|
||||
reusableSandboxLeaseCount: 1,
|
||||
}));
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true");
|
||||
|
||||
@@ -1722,11 +1684,7 @@ describe("environment routes", () => {
|
||||
failed: 1,
|
||||
skippedLiveRun: 0,
|
||||
});
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true");
|
||||
|
||||
@@ -1746,24 +1704,11 @@ describe("environment routes", () => {
|
||||
id: "env-ssh",
|
||||
driver: "ssh" as const,
|
||||
name: "SSH Fixture",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
config: createSshConfig(),
|
||||
};
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/environments/env-ssh")
|
||||
@@ -1790,25 +1735,12 @@ describe("environment routes", () => {
|
||||
id: "env-ssh",
|
||||
driver: "ssh" as const,
|
||||
name: "SSH Fixture",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
config: createSshConfig(),
|
||||
};
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true);
|
||||
mockEnvironmentService.update.mockResolvedValue({ ...environment, description: "Updated" });
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/environments/env-ssh")
|
||||
@@ -1844,11 +1776,7 @@ describe("environment routes", () => {
|
||||
companyName: "Other Team",
|
||||
},
|
||||
]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app).get("/api/environments/env-sandbox/secret-refs");
|
||||
|
||||
@@ -1890,35 +1818,12 @@ describe("environment routes", () => {
|
||||
});
|
||||
|
||||
it("clears environment selections and secret bindings across all companies when deleting an environment", async () => {
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
id: "env-ssh",
|
||||
name: "SSH Fixture",
|
||||
driver: "ssh" as const,
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
const environment = createSecretBackedSshEnvironment();
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius());
|
||||
mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment);
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor("admin-1"));
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-ssh");
|
||||
|
||||
@@ -1956,11 +1861,7 @@ describe("environment routes", () => {
|
||||
});
|
||||
|
||||
it("rejects invalid SSH config on create", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -1979,33 +1880,10 @@ describe("environment routes", () => {
|
||||
});
|
||||
|
||||
it("normalizes SSH private keys into secret refs before persistence", async () => {
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
id: "env-ssh",
|
||||
name: "SSH Fixture",
|
||||
driver: "ssh" as const,
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
const environment = createSecretBackedSshEnvironment();
|
||||
mockEnvironmentService.create.mockResolvedValue(environment);
|
||||
const pluginWorkerManager = {};
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager });
|
||||
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -2045,32 +1923,9 @@ describe("environment routes", () => {
|
||||
|
||||
it("uses the configured provider for SSH private key secret materialization", async () => {
|
||||
process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager";
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
id: "env-ssh",
|
||||
name: "SSH Fixture",
|
||||
driver: "ssh" as const,
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
const environment = createSecretBackedSshEnvironment();
|
||||
mockEnvironmentService.create.mockResolvedValue(environment);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -2097,11 +1952,7 @@ describe("environment routes", () => {
|
||||
});
|
||||
|
||||
it("rejects persisted fake sandbox environments", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -2134,11 +1985,7 @@ describe("environment routes", () => {
|
||||
};
|
||||
mockEnvironmentService.create.mockResolvedValue(environment);
|
||||
const pluginWorkerManager = {};
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager });
|
||||
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -2223,11 +2070,7 @@ describe("environment routes", () => {
|
||||
},
|
||||
);
|
||||
const pluginWorkerManager = {};
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager });
|
||||
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -2277,27 +2120,10 @@ describe("environment routes", () => {
|
||||
},
|
||||
pluginId: "plugin-secure",
|
||||
pluginKey: "acme.secure-sandbox-provider",
|
||||
driver: {
|
||||
driverKey: "secure-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Secure Sandbox",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: { type: "string" },
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
timeoutMs: { type: "number" },
|
||||
reuseLease: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
driver: createSecurePluginDriver(),
|
||||
});
|
||||
const pluginWorkerManager = {};
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager });
|
||||
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -2369,27 +2195,10 @@ describe("environment routes", () => {
|
||||
normalizedConfig: { ...config },
|
||||
pluginId: "plugin-secure",
|
||||
pluginKey: "acme.secure-sandbox-provider",
|
||||
driver: {
|
||||
driverKey: "secure-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Secure Sandbox",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: { type: "string" },
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
timeoutMs: { type: "number" },
|
||||
reuseLease: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
driver: createSecurePluginDriver(),
|
||||
}));
|
||||
const pluginWorkerManager = {};
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager });
|
||||
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -2452,27 +2261,10 @@ describe("environment routes", () => {
|
||||
},
|
||||
pluginId: "plugin-secure",
|
||||
pluginKey: "acme.secure-sandbox-provider",
|
||||
driver: {
|
||||
driverKey: "secure-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Secure Sandbox",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: { type: "string" },
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
timeoutMs: { type: "number" },
|
||||
reuseLease: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
driver: createSecurePluginDriver(),
|
||||
});
|
||||
const pluginWorkerManager = {};
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager });
|
||||
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -2516,11 +2308,7 @@ describe("environment routes", () => {
|
||||
mockValidatePluginEnvironmentDriverConfig.mockResolvedValue(environment.config);
|
||||
mockEnvironmentService.create.mockResolvedValue(environment);
|
||||
const pluginWorkerManager = {};
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager });
|
||||
const app = createApp(localBoardActor(), { pluginWorkerManager });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/environments")
|
||||
@@ -2604,11 +2392,7 @@ describe("environment routes", () => {
|
||||
updatedAt: new Date("2026-04-16T05:05:00.000Z"),
|
||||
},
|
||||
]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app).get(`/api/environments/${environment.id}/leases?status=active`);
|
||||
|
||||
@@ -2640,11 +2424,7 @@ describe("environment routes", () => {
|
||||
createdAt: new Date("2026-04-16T05:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-16T05:05:00.000Z"),
|
||||
});
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app).get("/api/environment-leases/lease-1");
|
||||
|
||||
@@ -2677,11 +2457,7 @@ describe("environment routes", () => {
|
||||
...environment,
|
||||
status: "archived",
|
||||
});
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/environments/${environment.id}?companyId=company-1`)
|
||||
@@ -2736,11 +2512,7 @@ describe("environment routes", () => {
|
||||
driver: "local" as const,
|
||||
config: {},
|
||||
});
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/environments/${environment.id}?companyId=company-1`)
|
||||
@@ -2783,30 +2555,13 @@ describe("environment routes", () => {
|
||||
normalizedConfig: { ...config },
|
||||
pluginId: "plugin-secure",
|
||||
pluginKey: "acme.secure-sandbox-provider",
|
||||
driver: {
|
||||
driverKey: "secure-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Secure Sandbox",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: { type: "string" },
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
timeoutMs: { type: "number" },
|
||||
reuseLease: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
driver: createSecurePluginDriver(),
|
||||
}));
|
||||
// The environment's only binding still lives in another company. Before
|
||||
// bindings moved with the referenced secret, this state made every save
|
||||
// from the caller's company fail, with no route-level way out.
|
||||
mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue(["company-old"]);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager: {} });
|
||||
const app = createApp(localBoardActor(), { pluginWorkerManager: {} });
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/environments/env-sandbox?companyId=company-new")
|
||||
@@ -2856,11 +2611,7 @@ describe("environment routes", () => {
|
||||
mockSecretService.replaceSecretRefsForInstanceTarget.mockRejectedValue(
|
||||
unprocessable("Secret referenced at apiKey was not found", { code: "secret_missing" }),
|
||||
);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
}, { pluginWorkerManager: {} });
|
||||
const app = createApp(localBoardActor(), { pluginWorkerManager: {} });
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/environments/env-sandbox?companyId=company-1")
|
||||
@@ -2876,11 +2627,7 @@ describe("environment routes", () => {
|
||||
|
||||
it("requires explicit SSH config when switching from local to SSH", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/environments/env-1?companyId=company-1")
|
||||
@@ -2895,11 +2642,7 @@ describe("environment routes", () => {
|
||||
|
||||
it("rejects switching an environment to the built-in fake sandbox provider", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/environments/env-1?companyId=company-1")
|
||||
@@ -2918,11 +2661,7 @@ describe("environment routes", () => {
|
||||
|
||||
it("returns 404 when patching a missing environment", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue(null);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
const app = createApp(localBoardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/environments/missing-env")
|
||||
@@ -3200,20 +2939,7 @@ describe("environment routes", () => {
|
||||
},
|
||||
pluginId: "plugin-secure",
|
||||
pluginKey: "acme.secure-sandbox-provider",
|
||||
driver: {
|
||||
driverKey: "secure-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Secure Sandbox",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template: { type: "string" },
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
timeoutMs: { type: "number" },
|
||||
reuseLease: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
driver: createSecurePluginDriver(),
|
||||
});
|
||||
mockProbeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -154,8 +154,6 @@ describeEmbeddedPostgres("environmentService leases", () => {
|
||||
config: { config: { ...config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } },
|
||||
driver: { driver: "ssh", config: { host: "fixture.example.test", username: "fixture" } },
|
||||
};
|
||||
// Interleave the real probe UPDATE after the save is prepared but before its
|
||||
// real UPDATE executes. This reproduces the stale route-read/write window.
|
||||
const delayedWriteDb = {
|
||||
update(table: typeof environments) {
|
||||
const prepared = db.update(table);
|
||||
|
||||
@@ -292,6 +292,104 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
function insertBlockedSourceTask(issueId: string, companyId: string, projectId: string) {
|
||||
return db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Source task",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
}
|
||||
|
||||
function insertBoardWorkspaceRecovery(companyId: string, issueId: string, fingerprint: string) {
|
||||
return db.insert(issueRecoveryActions).values({
|
||||
companyId,
|
||||
sourceIssueId: issueId,
|
||||
kind: "workspace_validation",
|
||||
status: "active",
|
||||
ownerType: "board",
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint,
|
||||
evidence: {
|
||||
workspaceValidation: {
|
||||
fingerprint,
|
||||
},
|
||||
},
|
||||
nextAction: "Repair the source issue workspace link.",
|
||||
});
|
||||
}
|
||||
|
||||
function insertMissingRecordedBranchWorkspace(executionWorkspaceId: string, companyId: string, projectId: string, issueId: string, worktreePath: string) {
|
||||
return db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
sourceIssueId: issueId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "feature/never-created",
|
||||
status: "idle",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "feature/never-created",
|
||||
baseRef: "main",
|
||||
});
|
||||
}
|
||||
|
||||
function insertRecordedBranchWorkspace(executionWorkspaceId: string, companyId: string, projectId: string, projectWorkspaceId: string, issueId: string, worktreePath: string) {
|
||||
return db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
sourceIssueId: issueId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "feature/recorded",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "feature/recorded",
|
||||
baseRef: "main",
|
||||
});
|
||||
}
|
||||
|
||||
function insertRunningWorkspaceService(runtimeServiceId: string, companyId: string, projectId: string, executionWorkspaceId: string, issueId: string, worktreePath: string) {
|
||||
return db.insert(workspaceRuntimeServices).values({
|
||||
id: runtimeServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
executionWorkspaceId,
|
||||
issueId,
|
||||
scopeType: "execution_workspace",
|
||||
serviceName: "web",
|
||||
status: "running",
|
||||
lifecycle: "shared",
|
||||
command: "pnpm dev",
|
||||
cwd: worktreePath,
|
||||
provider: "local_process",
|
||||
healthStatus: "healthy",
|
||||
});
|
||||
}
|
||||
|
||||
function insertActiveCodexCoder(agentId: string, companyId: string) {
|
||||
return db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function seedTerminalWorkspace(options: {
|
||||
mergedPr?: boolean;
|
||||
activeRun?: boolean;
|
||||
@@ -2202,21 +2300,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
branchName: "feature/recorded",
|
||||
baseRef: "main",
|
||||
});
|
||||
await db.insert(issueRecoveryActions).values({
|
||||
companyId,
|
||||
sourceIssueId: issueId,
|
||||
kind: "workspace_validation",
|
||||
status: "active",
|
||||
ownerType: "board",
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint,
|
||||
evidence: {
|
||||
workspaceValidation: {
|
||||
fingerprint,
|
||||
},
|
||||
},
|
||||
nextAction: "Repair the source issue workspace link.",
|
||||
});
|
||||
await insertBoardWorkspaceRecovery(companyId, issueId, fingerprint);
|
||||
|
||||
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
||||
mode: "forward",
|
||||
@@ -2309,21 +2393,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
sourceIssueId: issueId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "feature/never-created",
|
||||
status: "idle",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "feature/never-created",
|
||||
baseRef: "main",
|
||||
});
|
||||
await insertMissingRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, issueId, worktreePath);
|
||||
|
||||
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
||||
mode: "forward",
|
||||
@@ -2390,21 +2460,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
sourceIssueId: issueId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "feature/never-created",
|
||||
status: "idle",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "feature/never-created",
|
||||
baseRef: "main",
|
||||
});
|
||||
await insertMissingRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, issueId, worktreePath);
|
||||
|
||||
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
||||
mode: "forward",
|
||||
@@ -2463,21 +2519,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
sourceIssueId: issueId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "feature/never-created",
|
||||
status: "idle",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "feature/never-created",
|
||||
baseRef: "main",
|
||||
});
|
||||
await insertMissingRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, issueId, worktreePath);
|
||||
|
||||
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
||||
mode: "forward",
|
||||
@@ -2527,17 +2569,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await insertActiveCodexCoder(agentId, companyId);
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
@@ -2563,37 +2595,8 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
sourceIssueId: issueId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "feature/recorded",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "feature/recorded",
|
||||
baseRef: "main",
|
||||
});
|
||||
await db.insert(issueRecoveryActions).values({
|
||||
companyId,
|
||||
sourceIssueId: issueId,
|
||||
kind: "workspace_validation",
|
||||
status: "active",
|
||||
ownerType: "board",
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint,
|
||||
evidence: {
|
||||
workspaceValidation: {
|
||||
fingerprint,
|
||||
},
|
||||
},
|
||||
nextAction: "Repair the source issue workspace link.",
|
||||
});
|
||||
await insertRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, projectWorkspaceId, issueId, worktreePath);
|
||||
await insertBoardWorkspaceRecovery(companyId, issueId, fingerprint);
|
||||
|
||||
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
||||
mode: "quarantine_restore",
|
||||
@@ -2717,21 +2720,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
branchName: "feature/recorded",
|
||||
baseRef: "main",
|
||||
});
|
||||
await db.insert(workspaceRuntimeServices).values({
|
||||
id: runtimeServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
executionWorkspaceId,
|
||||
issueId,
|
||||
scopeType: "execution_workspace",
|
||||
serviceName: "web",
|
||||
status: "running",
|
||||
lifecycle: "shared",
|
||||
command: "pnpm dev",
|
||||
cwd: worktreePath,
|
||||
provider: "local_process",
|
||||
healthStatus: "healthy",
|
||||
});
|
||||
await insertRunningWorkspaceService(runtimeServiceId, companyId, projectId, executionWorkspaceId, issueId, worktreePath);
|
||||
|
||||
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
||||
mode: "quarantine_restore",
|
||||
@@ -2875,37 +2864,8 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
sourceIssueId: issueId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "feature/recorded",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
providerRef: worktreePath,
|
||||
branchName: "feature/recorded",
|
||||
baseRef: "main",
|
||||
});
|
||||
await db.insert(issueRecoveryActions).values({
|
||||
companyId,
|
||||
sourceIssueId: issueId,
|
||||
kind: "workspace_validation",
|
||||
status: "active",
|
||||
ownerType: "board",
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint,
|
||||
evidence: {
|
||||
workspaceValidation: {
|
||||
fingerprint,
|
||||
},
|
||||
},
|
||||
nextAction: "Repair the source issue workspace link.",
|
||||
});
|
||||
await insertRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, projectWorkspaceId, issueId, worktreePath);
|
||||
await insertBoardWorkspaceRecovery(companyId, issueId, fingerprint);
|
||||
|
||||
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
||||
mode: "quarantine_restore",
|
||||
@@ -2986,17 +2946,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await insertActiveCodexCoder(agentId, companyId);
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
@@ -3159,14 +3109,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
name: "Branch reconcile",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Source task",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await insertBlockedSourceTask(issueId, companyId, projectId);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
@@ -3244,14 +3187,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
name: "Branch reconcile",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Source task",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await insertBlockedSourceTask(issueId, companyId, projectId);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
@@ -3329,14 +3265,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
name: "Branch reconcile",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Source task",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await insertBlockedSourceTask(issueId, companyId, projectId);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
@@ -3433,14 +3362,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
name: "Branch reconcile",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Source task",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await insertBlockedSourceTask(issueId, companyId, projectId);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
@@ -3456,21 +3378,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
branchName: "feature/recorded",
|
||||
baseRef: "main",
|
||||
});
|
||||
await db.insert(workspaceRuntimeServices).values({
|
||||
id: runtimeServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
executionWorkspaceId,
|
||||
issueId,
|
||||
scopeType: "execution_workspace",
|
||||
serviceName: "web",
|
||||
status: "running",
|
||||
lifecycle: "shared",
|
||||
command: "pnpm dev",
|
||||
cwd: worktreePath,
|
||||
provider: "local_process",
|
||||
healthStatus: "healthy",
|
||||
});
|
||||
await insertRunningWorkspaceService(runtimeServiceId, companyId, projectId, executionWorkspaceId, issueId, worktreePath);
|
||||
|
||||
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
||||
mode: "override",
|
||||
@@ -3540,14 +3448,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
name: "Branch reconcile",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Source task",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await insertBlockedSourceTask(issueId, companyId, projectId);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
@@ -3669,14 +3570,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
name: "Branch reconcile",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Source task",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await insertBlockedSourceTask(issueId, companyId, projectId);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
@@ -3852,14 +3746,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
name: "Branch reconcile",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Source task",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
});
|
||||
await insertBlockedSourceTask(issueId, companyId, projectId);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("isPrivateWebhookHttpRequest", () => {
|
||||
describe("isSecretSensitiveHttpRequest", () => {
|
||||
it("protects Slack registration, installation, recovery, and all callback methods", () => {
|
||||
for (const suffix of ["registration", "install", "resume"]) expect(isSecretSensitiveHttpRequest("POST", `/api/chat-endpoints/id/slack/${suffix}`)).toBe(true);
|
||||
for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"])
|
||||
for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "/api/chat-slack/managed/oauth/callback?claim=canary", "/api/chat-slack/managed/oauth/callback?code=canary", "/api/chat-endpoints/endpoint/slack/managed/authorize", "/api/chat-endpoints/endpoint/slack/managed/provision", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"])
|
||||
expect(isSecretSensitiveHttpRequest(method, path)).toBe(true);
|
||||
});
|
||||
it("identifies credential-bearing chat setup mutations", () => {
|
||||
|
||||
@@ -68,6 +68,15 @@ const mockDb = {
|
||||
select: vi.fn(() => ({ from: () => Promise.resolve(mockCompanyRows) })),
|
||||
};
|
||||
|
||||
function localAdminActor() {
|
||||
return {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("instance settings routes", () => {
|
||||
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
|
||||
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
|
||||
@@ -234,12 +243,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("allows local board users to read and update experimental settings", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
const getRes = await request(app).get("/api/instance/settings/experimental");
|
||||
expect(getRes.status).toBe(200);
|
||||
@@ -273,12 +277,7 @@ describe("instance settings routes", () => {
|
||||
}, 10_000);
|
||||
|
||||
it("does not expose the retired liveness auto-recovery endpoints", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
await request(app)
|
||||
.post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview")
|
||||
@@ -303,12 +302,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("accepts the instance-wide Streamlined UI preference", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
@@ -321,12 +315,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("strips server-managed worktree run execution fields before updating experimental settings", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
@@ -343,12 +332,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("allows local board users to read and update the instance default environment", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
const getRes = await request(app).get("/api/instance/settings");
|
||||
expect(getRes.status).toBe(200);
|
||||
@@ -379,12 +363,7 @@ describe("instance settings routes", () => {
|
||||
envVars: {},
|
||||
metadata: { managedByPaperclip: true, managedDefaultStamped: true },
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
const patchRes = await request(app)
|
||||
.patch("/api/instance/settings")
|
||||
@@ -419,12 +398,7 @@ describe("instance settings routes", () => {
|
||||
metadata: { managedByPaperclip: true, managedDefaultStamped: true },
|
||||
});
|
||||
mockEnvironmentService.update.mockRejectedValue(new Error("metadata write failed"));
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
const patchRes = await request(app)
|
||||
.patch("/api/instance/settings")
|
||||
@@ -436,12 +410,7 @@ describe("instance settings routes", () => {
|
||||
|
||||
it("rejects unknown defaultEnvironmentId values with 422", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue(null);
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/instance/settings")
|
||||
@@ -453,12 +422,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("allows local board users to update guarded dev-server auto-restart", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
@@ -473,12 +437,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("allows local board users to update external object detection", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
@@ -491,12 +450,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("allows local board users to update built-in agents", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
@@ -509,12 +463,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("allows local board users to update the goals sidebar link", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
@@ -527,12 +476,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("allows local board users to update the server info debug view", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
@@ -545,12 +489,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("allows local board users to update environment controls", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
@@ -582,12 +521,7 @@ describe("instance settings routes", () => {
|
||||
});
|
||||
|
||||
it("allows local board users to read and update general settings", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
const app = await createApp(localAdminActor());
|
||||
|
||||
const getRes = await request(app).get("/api/instance/settings/general");
|
||||
expect(getRes.status).toBe(200);
|
||||
|
||||
@@ -321,6 +321,56 @@ async function waitForWakeup(assertion: () => void) {
|
||||
await vi.waitFor(assertion);
|
||||
}
|
||||
|
||||
function createAgentReviewPolicy(reviewerAgentId: string) {
|
||||
return normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function createReviewIssue(policy: NonNullable<Awaited<ReturnType<typeof normalizePolicy>>>, reviewerAgentId: string) {
|
||||
return {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createScheduledRetry() {
|
||||
return {
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
error: null,
|
||||
errorCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue comment reopen routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -956,7 +1006,7 @@ describe("issue comment reopen routes", () => {
|
||||
|
||||
// Same guard on cancelled status — explicit resume must use `resume: true`,
|
||||
// a log-class self-comment with `reopen: true` is not a reopen signal.
|
||||
it("does not reopen via POST comment+reopen when the assignee agent is the actor on a cancelled issue", async () => {
|
||||
it("rejects bare agent reopen requests on cancelled issues before writing a comment", async () => {
|
||||
const assigneeAgentId = "22222222-2222-4222-8222-222222222222";
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("cancelled"));
|
||||
mockIssueService.update.mockImplementation(
|
||||
@@ -985,14 +1035,13 @@ describe("issue comment reopen routes", () => {
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
// Cancelled issues reject explicit resume entirely, so only reopen=true
|
||||
// is observable here — the guard is what keeps it from flipping back.
|
||||
.send({ body: "log line", reopen: true });
|
||||
|
||||
// Cancelled issues are rejected at assertExplicitResumeIntentAllowed for
|
||||
// agent actors with reopen=true (409). The guard runs after that, but
|
||||
// either way no reopen wakeup must fire and no status update to todo.
|
||||
expect([200, 201, 409]).toContain(res.status);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe(
|
||||
"Cancelled issues must be restored through the dedicated restore flow",
|
||||
);
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({ status: "todo" }),
|
||||
@@ -1142,18 +1191,7 @@ describe("issue comment reopen routes", () => {
|
||||
executionRunId: "retry-run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
error: null,
|
||||
errorCode: null,
|
||||
});
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry());
|
||||
mockIssueService.update.mockImplementation(
|
||||
async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
@@ -1214,18 +1252,7 @@ describe("issue comment reopen routes", () => {
|
||||
executionRunId: "retry-run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
error: null,
|
||||
errorCode: null,
|
||||
});
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry());
|
||||
mockHeartbeatService.cancelRun.mockRejectedValue(
|
||||
new Error("cancel failed"),
|
||||
);
|
||||
@@ -1890,18 +1917,7 @@ describe("issue comment reopen routes", () => {
|
||||
executionRunId: "retry-run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
error: null,
|
||||
errorCode: null,
|
||||
});
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry());
|
||||
mockIssueService.update.mockImplementation(
|
||||
async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
@@ -1950,18 +1966,7 @@ describe("issue comment reopen routes", () => {
|
||||
executionRunId: "retry-run-1",
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue({
|
||||
runId: "retry-run-1",
|
||||
status: "scheduled_retry",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
agentName: "CodexCoder",
|
||||
retryOfRunId: "source-run-1",
|
||||
scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"),
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
error: null,
|
||||
errorCode: null,
|
||||
});
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry());
|
||||
mockHeartbeatService.cancelRun.mockRejectedValue(
|
||||
new Error("cancel failed"),
|
||||
);
|
||||
@@ -2635,8 +2640,8 @@ describe("issue comment reopen routes", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["invalid", "wrong agent", "wrong company"])(
|
||||
"rejects comment and PATCH writes with a %s run",
|
||||
it(
|
||||
"rejects comment and PATCH writes when influence validation rejects the run",
|
||||
async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockObserveCrossIssueInfluence.mockRejectedValue(
|
||||
@@ -2940,35 +2945,8 @@ describe("issue comment reopen routes", () => {
|
||||
|
||||
it("auto-approves a reviewer comment with the APPROVED review marker", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = createReviewIssue(policy, reviewerAgentId);
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
@@ -3032,35 +3010,8 @@ describe("issue comment reopen routes", () => {
|
||||
|
||||
it("auto-approves a reviewer comment with structured review metadata", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = createReviewIssue(policy, reviewerAgentId);
|
||||
const reviewBody = "kind: review\ndecision: approved\nsummary: ship it";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
@@ -3125,15 +3076,7 @@ describe("issue comment reopen routes", () => {
|
||||
it("auto-approves a reviewer comment and wakes dependents when the final blocker resolves", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const dependentAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
@@ -3220,15 +3163,7 @@ describe("issue comment reopen routes", () => {
|
||||
it("does not wake the returnAssignee with issue_commented when auto-approval reassigns the issue", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const returnAssigneeAgentId = "22222222-2222-4222-8222-222222222222";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
@@ -3298,35 +3233,8 @@ describe("issue comment reopen routes", () => {
|
||||
|
||||
it("does not auto-approve APPROVED comments from a non-review participant", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = createReviewIssue(policy, reviewerAgentId);
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
@@ -3422,35 +3330,8 @@ describe("issue comment reopen routes", () => {
|
||||
// Even though both `kind: review` and `decision: approved` appear, a blank line between
|
||||
// them means they are not on truly consecutive lines, so the strict regex must reject it.
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = createReviewIssue(policy, reviewerAgentId);
|
||||
const reviewBody = "kind: review\n\ndecision: approved";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
@@ -3483,35 +3364,8 @@ describe("issue comment reopen routes", () => {
|
||||
|
||||
it("does not auto-approve reviewer comments without an approval marker", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = createReviewIssue(policy, reviewerAgentId);
|
||||
const reviewBody = "Looks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
@@ -3545,15 +3399,7 @@ describe("issue comment reopen routes", () => {
|
||||
|
||||
it("does not auto-approve approval comments outside the in_review status", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_progress",
|
||||
@@ -3633,35 +3479,8 @@ describe("issue comment reopen routes", () => {
|
||||
])("does not auto-approve negated approval phrasings ($name)", ({ body }) => {
|
||||
it("rejects the auto-approval transition and keeps the comment as a regular comment", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = createReviewIssue(policy, reviewerAgentId);
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-negated",
|
||||
@@ -3705,35 +3524,8 @@ describe("issue comment reopen routes", () => {
|
||||
])("auto-approves positive approval phrasings ($name)", ({ body }) => {
|
||||
it("triggers the auto-approval transition", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = createReviewIssue(policy, reviewerAgentId);
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-positive",
|
||||
@@ -3794,35 +3586,8 @@ describe("issue comment reopen routes", () => {
|
||||
|
||||
it("rolls back the comment when the auto-approval status transition fails", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = createReviewIssue(policy, reviewerAgentId);
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
@@ -3869,35 +3634,8 @@ describe("issue comment reopen routes", () => {
|
||||
|
||||
it("rolls back the auto-approval comment when the issue is concurrently deleted", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const policy = await createAgentReviewPolicy(reviewerAgentId)!;
|
||||
const issue = createReviewIssue(policy, reviewerAgentId);
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
|
||||
@@ -81,17 +81,55 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
function interactionCompanyRow(companyId: string): typeof companies.$inferInsert {
|
||||
return {
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parentIssueRow(issueId: string, companyId: string, goalId: string): typeof issues.$inferInsert {
|
||||
return {
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
};
|
||||
}
|
||||
|
||||
function acceptIssueScope(issueId: string, companyId: string, goalId: string) {
|
||||
return {
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function activeCodexAgentRow(agentId: string, companyId: string, name: string, role: string): typeof agents.$inferInsert {
|
||||
return {
|
||||
id: agentId,
|
||||
companyId,
|
||||
name,
|
||||
role,
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedConfirmationIssue(title = "Comment supersede") {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
@@ -100,14 +138,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
||||
|
||||
return { companyId, goalId, issueId };
|
||||
}
|
||||
@@ -174,17 +205,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Source question race");
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Questioner",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Questioner", "engineer"));
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
@@ -596,17 +617,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
it("rejects an explicitly mismatched source-run issue or agent", async () => {
|
||||
const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } });
|
||||
const otherAgentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: otherAgentId,
|
||||
companyId: fixture.companyId,
|
||||
name: "Other questioner",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(otherAgentId, fixture.companyId, "Other questioner", "engineer"));
|
||||
|
||||
// Simulate historical corruption to retain the service-level defense test.
|
||||
await db.transaction(async (tx) => {
|
||||
@@ -764,17 +775,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const agentId = randomUUID();
|
||||
const firstRunId = randomUUID();
|
||||
const secondRunId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Researcher",
|
||||
role: "researcher",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Researcher", "researcher"));
|
||||
await db.update(issues).set({ assigneeAgentId: agentId, status: "in_progress" }).where(eq(issues.id, issueId));
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
@@ -1168,12 +1169,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const assigneeAgentId = randomUUID();
|
||||
const responsibleUserId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
|
||||
await db.insert(goals).values({
|
||||
@@ -1183,17 +1179,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(assigneeAgentId, companyId, "CodexCoder", "engineer"));
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
@@ -1233,12 +1219,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
|
||||
expect(created.status).toBe("pending");
|
||||
|
||||
const accepted = await interactionsSvc.acceptSuggestedTasks({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
const accepted = await interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
@@ -1295,12 +1276,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0]?.status).toBe("accepted");
|
||||
|
||||
await expect(interactionsSvc.acceptSuggestedTasks({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
await expect(interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toThrow("Interaction has already been resolved");
|
||||
|
||||
@@ -1313,12 +1289,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
|
||||
await db.insert(goals).values({
|
||||
@@ -1366,12 +1337,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptSuggestedTasks({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {
|
||||
const accepted = await interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
||||
selectedClientKeys: ["root"],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
@@ -1395,12 +1361,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
|
||||
await db.insert(goals).values({
|
||||
@@ -1410,14 +1371,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
@@ -1444,12 +1398,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptSuggestedTasks({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {
|
||||
interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
||||
selectedClientKeys: ["child"],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
@@ -1462,12 +1411,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
|
||||
await db.insert(goals).values({
|
||||
@@ -1564,12 +1508,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
@@ -1951,12 +1890,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
|
||||
await db.insert(goals).values({
|
||||
@@ -1966,25 +1900,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer"));
|
||||
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
@@ -2039,28 +1956,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const firstAgentId = randomUUID();
|
||||
const secondAgentId = randomUUID();
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: firstAgentId,
|
||||
companyId,
|
||||
name: "First agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: secondAgentId,
|
||||
companyId,
|
||||
name: "Second agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"),
|
||||
activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"),
|
||||
]);
|
||||
|
||||
const older = await interactionsSvc.create({ id: issueId, companyId }, {
|
||||
@@ -2117,28 +2014,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const probingAgentId = randomUUID();
|
||||
const otherAgentId = randomUUID();
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: probingAgentId,
|
||||
companyId,
|
||||
name: "Probing agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: otherAgentId,
|
||||
companyId,
|
||||
name: "Other agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
activeCodexAgentRow(probingAgentId, companyId, "Probing agent", "engineer"),
|
||||
activeCodexAgentRow(otherAgentId, companyId, "Other agent", "engineer"),
|
||||
]);
|
||||
|
||||
const question = (prompt: string) => ({
|
||||
@@ -2198,23 +2075,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Chief of staff",
|
||||
role: "chief_of_staff",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Chief of staff", "chief_of_staff"));
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
@@ -2291,28 +2153,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const firstAgentId = randomUUID();
|
||||
const secondAgentId = randomUUID();
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: firstAgentId,
|
||||
companyId,
|
||||
name: "First agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: secondAgentId,
|
||||
companyId,
|
||||
name: "Second agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"),
|
||||
activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"),
|
||||
]);
|
||||
|
||||
const firstAgentIds = [randomUUID(), randomUUID(), randomUUID()];
|
||||
@@ -2411,12 +2253,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
@@ -2425,14 +2262,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
@@ -2454,12 +2284,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
expect(created.kind).toBe("request_confirmation");
|
||||
expect(created.status).toBe("pending");
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
@@ -2551,17 +2376,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const resolverAgentId = randomUUID();
|
||||
const resolverRunId = randomUUID();
|
||||
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId));
|
||||
await db.insert(agents).values({
|
||||
id: resolverAgentId,
|
||||
companyId,
|
||||
name: "Review agent",
|
||||
role: "reviewer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer"));
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: resolverRunId,
|
||||
companyId,
|
||||
@@ -2579,12 +2394,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
await recordReviewTransition({ companyId, issueId, interactionId: created.id });
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
||||
agentId: resolverAgentId,
|
||||
runId: resolverRunId,
|
||||
resolverPolicyRestriction: "anyone",
|
||||
@@ -2604,17 +2414,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const { companyId, goalId, issueId } = await seedConfirmationIssue(`Locked ${action} policy`);
|
||||
const resolverAgentId = randomUUID();
|
||||
const resolverRunId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: resolverAgentId,
|
||||
companyId,
|
||||
name: "Review agent",
|
||||
role: "reviewer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer"));
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: resolverRunId,
|
||||
companyId,
|
||||
@@ -2705,17 +2505,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const resolverAgentId = randomUUID();
|
||||
const resolverRunId = randomUUID();
|
||||
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId));
|
||||
await db.insert(agents).values({
|
||||
id: resolverAgentId,
|
||||
companyId,
|
||||
name: "Review agent",
|
||||
role: "reviewer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer"));
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: resolverRunId,
|
||||
companyId,
|
||||
@@ -2806,12 +2596,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {
|
||||
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
||||
selectedOptionIds: ["file-c", "file-a"],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
@@ -2854,34 +2639,19 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
await expect(interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {
|
||||
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
||||
selectedOptionIds: [],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toThrow("Select at least 1 checkbox confirmation option(s)");
|
||||
|
||||
await expect(interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {
|
||||
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
||||
selectedOptionIds: ["missing"],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toThrow("Unknown checkbox confirmation optionId: missing");
|
||||
|
||||
await expect(interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {
|
||||
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {
|
||||
selectedOptionIds: ["one", "two", "three"],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
@@ -3217,12 +2987,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const issueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
@@ -3231,17 +2996,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Senior Product Engineer",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Senior Product Engineer", "engineer"));
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
@@ -3268,12 +3023,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
agentId,
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
@@ -3315,12 +3065,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
agentId,
|
||||
});
|
||||
|
||||
const resumed = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, agentOwnedConfirmation.id, {}, {
|
||||
const resumed = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), agentOwnedConfirmation.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
@@ -3358,12 +3103,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
agentId,
|
||||
});
|
||||
|
||||
const acceptedWhileActive = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, activeConfirmation.id, {}, {
|
||||
const acceptedWhileActive = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), activeConfirmation.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
@@ -3380,17 +3120,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
it("atomically returns an accepted Plan-mode issue to its agent in Auto mode", async () => {
|
||||
const { companyId, goalId, issueId } = await seedConfirmationIssue("Accept a plan into Auto mode");
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Plan owner",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer"));
|
||||
await db.update(issues).set({
|
||||
status: "in_review",
|
||||
workMode: "planning",
|
||||
@@ -3403,12 +3133,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
payload: { version: 1, prompt: "Accept this plan?", target },
|
||||
}, { agentId });
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, { userId: "local-board" });
|
||||
const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board" });
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: created.id,
|
||||
@@ -3433,17 +3158,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
it("keeps Plan mode for non-plan and checkbox confirmations", async () => {
|
||||
const { companyId, goalId, issueId } = await seedConfirmationIssue("Do not auto-transition other confirmations");
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Plan owner",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer"));
|
||||
await db.update(issues).set({
|
||||
status: "in_review",
|
||||
workMode: "planning",
|
||||
@@ -3454,7 +3169,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
kind: "request_confirmation",
|
||||
payload: { version: 1, prompt: "Accept this unrelated decision?" },
|
||||
}, { agentId });
|
||||
await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, nonPlan.id, {}, {
|
||||
await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), nonPlan.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode))
|
||||
@@ -3471,7 +3186,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
target,
|
||||
},
|
||||
}, { agentId });
|
||||
await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, checkbox.id, {
|
||||
await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), checkbox.id, {
|
||||
selectedOptionIds: ["phase-1"],
|
||||
}, { userId: "local-board" });
|
||||
await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode))
|
||||
@@ -3487,7 +3202,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
payload: { version: 1, prompt: "Accept this plan?", target },
|
||||
}, { userId: "local-board" });
|
||||
|
||||
await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, created.id, {}, {
|
||||
await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
@@ -3929,12 +3644,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const revisionId = randomUUID();
|
||||
const nextRevisionId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
@@ -3943,14 +3653,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
@@ -4013,12 +3716,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
latestRevisionNumber: 2,
|
||||
});
|
||||
|
||||
await expect(interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
@@ -4057,12 +3755,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const revisionId = randomUUID();
|
||||
const nextRevisionId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
@@ -4071,14 +3764,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
||||
await db.update(issues).set({ workMode: "planning" }).where(eq(issues.id, issueId));
|
||||
// Document is already at revision 2 — revision 1 is stale.
|
||||
await db.insert(documents).values({
|
||||
@@ -4172,12 +3858,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
userId: "local-board",
|
||||
});
|
||||
expect(created).toMatchObject({ status: "pending", kind: "request_confirmation" });
|
||||
await expect(interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, {
|
||||
userId: "local-board",
|
||||
})).resolves.toMatchObject({
|
||||
interaction: { status: "accepted" },
|
||||
@@ -4196,12 +3877,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const revisionId = randomUUID();
|
||||
const nextRevisionId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
@@ -4210,14 +3886,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId));
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
@@ -4357,12 +4026,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
const foreignRunId = randomUUID();
|
||||
const kind = options?.kind ?? "request_confirmation";
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companies).values(interactionCompanyRow(companyId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
@@ -4379,17 +4043,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer"));
|
||||
const sourceRunStatus = options?.sourceRunStatus ?? "succeeded";
|
||||
const sourceRunTerminal = sourceRunStatus !== "running";
|
||||
await db.insert(heartbeatRuns).values([
|
||||
@@ -4596,7 +4250,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
@@ -4624,7 +4278,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
@@ -4654,7 +4308,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
@@ -4692,7 +4346,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
@@ -4722,7 +4376,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
@@ -4752,7 +4406,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
@@ -4780,7 +4434,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
@@ -4815,7 +4469,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-b"] },
|
||||
{ userId: "local-board" },
|
||||
@@ -4846,7 +4500,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
@@ -4869,7 +4523,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
@@ -4896,7 +4550,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
@@ -4946,7 +4600,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
acceptIssueScope(issueId, companyId, goalId),
|
||||
created.id,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
stripSecretBearingUrlParts,
|
||||
} from "../middleware/redact-sensitive.js";
|
||||
|
||||
it("redacts Slack manager grants and sealed OAuth handoffs", () => {
|
||||
const result = redactSensitive({ managerToken: "manager-canary", refreshToken: "refresh-canary", sealed: { ct: "sealed-canary" }, handoff: { token: "handoff-canary" }, claim_id: "claim-canary" });
|
||||
expect(JSON.stringify(result)).not.toContain("canary");
|
||||
});
|
||||
|
||||
describe("redactSensitive", () => {
|
||||
it("redacts a plaintext password field on a sign-in body", () => {
|
||||
const body = {
|
||||
|
||||
@@ -63,7 +63,6 @@ describe("removeTestHome", () => {
|
||||
const home = makeHome();
|
||||
fs.mkdirSync(path.join(home, "instances", "default"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, "instances", "default", "paperclip.json"), "{}\n");
|
||||
// An unrelated ENOTEMPTY must not cause permission changes or be swallowed.
|
||||
const error = Object.assign(new Error("unrelated nonempty directory"), { code: "ENOTEMPTY" });
|
||||
const removal = vi.spyOn(fs, "rmSync").mockImplementation(() => { throw error; });
|
||||
const permissionChange = vi.spyOn(fs, "chmodSync");
|
||||
|
||||
@@ -9,8 +9,7 @@ import path from "node:path";
|
||||
* in them read-only (`makeDirectoriesReadOnly` in
|
||||
* services/native-runtime/runtime-context.ts), and a run that is still
|
||||
* finishing when the worker exits can leave such a bundle under the worker's
|
||||
* home. Removing a file inside a read-only directory fails with `EACCES`
|
||||
* or, on macOS, the recursive removal can report `ENOTEMPTY` instead.
|
||||
* home. Removing a file inside a read-only directory fails with `EACCES`,
|
||||
* Vitest reports it as an unhandled error, and the shard fails after every
|
||||
* test passed. So on a permission error every directory in the tree is made
|
||||
* writable first and the removal is tried once more.
|
||||
@@ -23,8 +22,6 @@ export function removeTestHome(home: string): void {
|
||||
const permissionError = isPermissionError(error);
|
||||
if (!permissionError && (error as NodeJS.ErrnoException | null)?.code !== "ENOTEMPTY") throw error;
|
||||
const restored = makeDirectoriesWritable(home);
|
||||
// ENOTEMPTY also has unrelated causes; retry only after restoring an
|
||||
// actual read-only directory, and still surface a failed second removal.
|
||||
if (!permissionError && !restored) throw error;
|
||||
fs.rmSync(home, options);
|
||||
}
|
||||
@@ -51,7 +48,6 @@ function makeDirectoriesWritable(directory: string): boolean {
|
||||
fs.chmodSync(directory, stat.mode | 0o700);
|
||||
restored = true;
|
||||
} catch {
|
||||
// A directory that cannot be changed still fails the removal below, loudly.
|
||||
}
|
||||
}
|
||||
let entries: fs.Dirent[];
|
||||
|
||||
@@ -184,6 +184,46 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function awsVaultInput(): Parameters<ReturnType<typeof secretService>["createProviderConfig"]>[1] {
|
||||
return {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
};
|
||||
}
|
||||
|
||||
function preparedAwsVersion(externalRef: string, versionId: string, valueSha256: string, fingerprintSha256: string): Awaited<ReturnType<typeof awsSecretsManagerProvider.createSecret>> {
|
||||
return {
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: externalRef,
|
||||
versionId,
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256,
|
||||
fingerprintSha256,
|
||||
externalRef,
|
||||
providerVersionRef: versionId,
|
||||
};
|
||||
}
|
||||
|
||||
function managedAwsVersionRow(secretId: string, externalRef: string): typeof companySecretVersions.$inferInsert {
|
||||
return {
|
||||
secretId,
|
||||
version: 1,
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: externalRef,
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
providerVersionRef: "aws-version-1",
|
||||
status: "current",
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCompany(name = "Acme") {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
@@ -925,52 +965,62 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
// provider is `local_encrypted`.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret",
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const createSecretSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret", "aws-version-1", "value-sha-1", "fingerprint-sha-1"));
|
||||
|
||||
const originalProviderWrite = createSecretSpy.getMockImplementation()!;
|
||||
const uncontendedEntryDurationMs = await measureUncontendedEntryDurationMs(
|
||||
createSecretSpy,
|
||||
originalProviderWrite,
|
||||
() => svc.create(companyId, {
|
||||
name: `aws-baseline-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
value: "baseline-runtime-secret",
|
||||
}),
|
||||
);
|
||||
|
||||
const events: string[] = [];
|
||||
const cleanupEntered = deferred<void>();
|
||||
const providerEntered = deferred<void>();
|
||||
createSecretSpy.mockImplementationOnce(async (input) => {
|
||||
events.push("aws-provider-enter");
|
||||
providerEntered.resolve();
|
||||
return originalProviderWrite(input);
|
||||
});
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
||||
events.push("cleanup-enter");
|
||||
cleanupEntered.resolve();
|
||||
await cleanupGate;
|
||||
events.push("cleanup-exit");
|
||||
});
|
||||
// Give the cleanup a chance to acquire the lock before the AWS create
|
||||
// starts racing for the same lock.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const createCall = svc.create(companyId, {
|
||||
name: `aws-secret-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
value: "runtime-secret",
|
||||
});
|
||||
// The AWS create must stay queued behind the held lock, the same way a
|
||||
// `local_encrypted` create does.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["cleanup-enter"]);
|
||||
|
||||
releaseCleanup();
|
||||
await cleanupCall;
|
||||
const created = await createCall;
|
||||
expect(events).toEqual(["cleanup-enter", "cleanup-exit"]);
|
||||
let createCall: ReturnType<typeof svc.create> | undefined;
|
||||
let outcomes: PromiseSettledResult<unknown>[] = [];
|
||||
try {
|
||||
await awaitEntryOrOperationFailure(cleanupEntered.promise, cleanupCall, "cleanupCall");
|
||||
createCall = svc.create(companyId, {
|
||||
name: `aws-secret-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
value: "runtime-secret",
|
||||
});
|
||||
void createCall.catch(() => {});
|
||||
await waitEntryDetectionWindow(uncontendedEntryDurationMs, providerEntered.promise);
|
||||
expect(events).toEqual(["cleanup-enter"]);
|
||||
expect(createSecretSpy).toHaveBeenCalledTimes(ENTRY_DETECTION_BASELINE_SAMPLE_COUNT);
|
||||
} finally {
|
||||
releaseCleanup();
|
||||
outcomes = await Promise.allSettled([cleanupCall, createCall]);
|
||||
}
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.status === "rejected") throw outcome.reason;
|
||||
}
|
||||
const created = await createCall!;
|
||||
expect(events).toEqual(["cleanup-enter", "cleanup-exit", "aws-provider-enter"]);
|
||||
expect(created.status).toBe("active");
|
||||
});
|
||||
|
||||
@@ -980,35 +1030,9 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
// `local_encrypted` rotate.
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
|
||||
versionId: "aws-version-2",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-2",
|
||||
fingerprintSha256: "fingerprint-sha-2",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
|
||||
providerVersionRef: "aws-version-2",
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", "aws-version-1", "value-sha-1", "fingerprint-sha-1"));
|
||||
const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", "aws-version-2", "value-sha-2", "fingerprint-sha-2"));
|
||||
const existing = await svc.create(companyId, {
|
||||
name: `aws-secret-rotate-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
@@ -1016,25 +1040,55 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
value: "runtime-secret",
|
||||
});
|
||||
|
||||
const baselineSecret = await svc.create(companyId, {
|
||||
name: `aws-baseline-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
value: "baseline-runtime-secret",
|
||||
});
|
||||
const originalProviderWrite = createVersionSpy.getMockImplementation()!;
|
||||
const uncontendedEntryDurationMs = await measureUncontendedEntryDurationMs(
|
||||
createVersionSpy,
|
||||
originalProviderWrite,
|
||||
() => svc.rotate(baselineSecret.id, { value: "baseline-rotated-runtime-secret" }),
|
||||
);
|
||||
|
||||
const events: string[] = [];
|
||||
const cleanupEntered = deferred<void>();
|
||||
const providerEntered = deferred<void>();
|
||||
createVersionSpy.mockImplementationOnce(async (input) => {
|
||||
events.push("aws-provider-enter");
|
||||
providerEntered.resolve();
|
||||
return originalProviderWrite(input);
|
||||
});
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
||||
events.push("cleanup-enter");
|
||||
cleanupEntered.resolve();
|
||||
await cleanupGate;
|
||||
events.push("cleanup-exit");
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const rotateCall = svc.rotate(existing.id, { value: "rotated-runtime-secret" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(events).toEqual(["cleanup-enter"]);
|
||||
|
||||
releaseCleanup();
|
||||
await cleanupCall;
|
||||
const rotated = await rotateCall;
|
||||
expect(events).toEqual(["cleanup-enter", "cleanup-exit"]);
|
||||
let rotateCall: ReturnType<typeof svc.rotate> | undefined;
|
||||
let outcomes: PromiseSettledResult<unknown>[] = [];
|
||||
try {
|
||||
await awaitEntryOrOperationFailure(cleanupEntered.promise, cleanupCall, "cleanupCall");
|
||||
rotateCall = svc.rotate(existing.id, { value: "rotated-runtime-secret" });
|
||||
void rotateCall.catch(() => {});
|
||||
await waitEntryDetectionWindow(uncontendedEntryDurationMs, providerEntered.promise);
|
||||
expect(events).toEqual(["cleanup-enter"]);
|
||||
expect(createVersionSpy).toHaveBeenCalledTimes(ENTRY_DETECTION_BASELINE_SAMPLE_COUNT);
|
||||
} finally {
|
||||
releaseCleanup();
|
||||
outcomes = await Promise.allSettled([cleanupCall, rotateCall]);
|
||||
}
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.status === "rejected") throw outcome.reason;
|
||||
}
|
||||
const rotated = await rotateCall!;
|
||||
expect(events).toEqual(["cleanup-enter", "cleanup-exit", "aws-provider-enter"]);
|
||||
expect(rotated.latestVersion).toBe(2);
|
||||
});
|
||||
|
||||
@@ -2008,11 +2062,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1", "owner");
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
@@ -2070,11 +2120,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1", "owner");
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
@@ -2083,18 +2129,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
});
|
||||
const externalRef =
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user/github-token";
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: externalRef,
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef,
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion(externalRef, "aws-version-1", "value-sha-1", "fingerprint-sha-1"));
|
||||
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
||||
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed"));
|
||||
vi.spyOn(db, "delete").mockImplementationOnce(() => {
|
||||
@@ -2164,11 +2199,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
await seedCompanyMember(companyId, "user-1", "owner");
|
||||
await seedCompanyMember(companyId, "user-2", "member");
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
@@ -2180,18 +2211,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
nextVersion += 1;
|
||||
const externalRef =
|
||||
`arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/${input.context.secretKey}`;
|
||||
return {
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: externalRef,
|
||||
versionId: `aws-version-${nextVersion}`,
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: `value-sha-${nextVersion}`,
|
||||
fingerprintSha256: `fingerprint-sha-${nextVersion}`,
|
||||
externalRef,
|
||||
providerVersionRef: `aws-version-${nextVersion}`,
|
||||
};
|
||||
return preparedAwsVersion(externalRef, `aws-version-${nextVersion}`, `value-sha-${nextVersion}`, `fingerprint-sha-${nextVersion}`);
|
||||
});
|
||||
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
||||
const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
|
||||
@@ -2245,29 +2265,14 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1", "owner");
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret",
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret", "aws-version-1", "value-sha-1", "fingerprint-sha-1"));
|
||||
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
||||
const userSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
|
||||
definitionId: definition.id,
|
||||
@@ -2622,11 +2627,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("stores external references without requiring or persisting secret values", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
@@ -2727,11 +2728,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("removes provider vault config locally without deleting remote AWS secrets", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const vault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const vault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
@@ -2782,11 +2779,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("rejects bindings and env refs to soft-deleted external reference secrets", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const deleted = await svc.create(companyId, {
|
||||
name: "Deleted external",
|
||||
key: "deleted-external",
|
||||
@@ -2816,11 +2809,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("rejects updates to already soft-deleted external reference secrets", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const deleted = await svc.create(companyId, {
|
||||
name: "Deleted patch target",
|
||||
key: "deleted-patch-target",
|
||||
@@ -2839,11 +2828,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("allows re-importing a remote secret after the prior external reference is soft-deleted", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const externalRef =
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/reimportable";
|
||||
const deleted = await svc.create(companyId, {
|
||||
@@ -2892,11 +2877,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("ignores soft-deleted name and key conflicts during remote import", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const deleted = await svc.create(companyId, {
|
||||
name: "Deleted external",
|
||||
key: "deleted-external",
|
||||
@@ -2992,30 +2973,8 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const createSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
|
||||
versionId: "aws-version-2",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-2",
|
||||
fingerprintSha256: "fingerprint-sha-2",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
|
||||
providerVersionRef: "aws-version-2",
|
||||
});
|
||||
const createSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", "aws-version-1", "value-sha-1", "fingerprint-sha-1"));
|
||||
const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", "aws-version-2", "value-sha-2", "fingerprint-sha-2"));
|
||||
const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion").mockResolvedValue("resolved-secret");
|
||||
|
||||
const secret = await svc.create(companyId, {
|
||||
@@ -3048,25 +3007,8 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("cleans up managed provider secrets when create persistence fails", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const prepared = {
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback",
|
||||
providerVersionRef: "aws-version-1",
|
||||
};
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback", "aws-version-1", "value-sha-1", "fingerprint-sha-1");
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared);
|
||||
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
||||
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db insert failed"));
|
||||
@@ -3103,25 +3045,8 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("keeps a local cleanup handle when create rollback cleanup fails", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const prepared = {
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle",
|
||||
providerVersionRef: "aws-version-1",
|
||||
};
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle", "aws-version-1", "value-sha-1", "fingerprint-sha-1");
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared);
|
||||
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue(
|
||||
new Error("cleanup failed"),
|
||||
@@ -3171,25 +3096,8 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("reports managed secret persistence rollback failures when local cleanup cannot remove the reserved row", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const prepared = {
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-local-cleanup",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-local-cleanup",
|
||||
providerVersionRef: "aws-version-1",
|
||||
};
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-local-cleanup", "aws-version-1", "value-sha-1", "fingerprint-sha-1");
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared);
|
||||
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
||||
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed"));
|
||||
@@ -3227,25 +3135,8 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("archives managed provider versions when rotate persistence fails", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", "aws-version-1", "value-sha-1", "fingerprint-sha-1"));
|
||||
const secret = await svc.create(companyId, {
|
||||
name: "Rotate Rollback",
|
||||
key: "rotate-rollback",
|
||||
@@ -3253,20 +3144,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
providerConfigId: awsVault.id,
|
||||
value: "runtime-secret",
|
||||
});
|
||||
const prepared = {
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
|
||||
versionId: "aws-version-2",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-2",
|
||||
fingerprintSha256: "fingerprint-sha-2",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
|
||||
providerVersionRef: "aws-version-2",
|
||||
};
|
||||
const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", "aws-version-2", "value-sha-2", "fingerprint-sha-2");
|
||||
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared);
|
||||
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
||||
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db rotate failed"));
|
||||
@@ -3292,25 +3170,8 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("keeps a disabled version cleanup handle when rotate rollback cleanup fails", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", "aws-version-1", "value-sha-1", "fingerprint-sha-1"));
|
||||
const secret = await svc.create(companyId, {
|
||||
name: "Rotate Cleanup Handle",
|
||||
key: "rotate-cleanup-handle",
|
||||
@@ -3318,20 +3179,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
providerConfigId: awsVault.id,
|
||||
value: "runtime-secret",
|
||||
});
|
||||
const prepared = {
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
|
||||
versionId: "aws-version-2",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-2",
|
||||
fingerprintSha256: "fingerprint-sha-2",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
|
||||
providerVersionRef: "aws-version-2",
|
||||
};
|
||||
const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", "aws-version-2", "value-sha-2", "fingerprint-sha-2");
|
||||
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared);
|
||||
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue(
|
||||
new Error("cleanup failed"),
|
||||
@@ -3372,20 +3220,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
displayName: "AWS secondary",
|
||||
config: { region: "us-west-2", namespace: "prod-usw2" },
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign",
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign",
|
||||
providerVersionRef: "aws-version-1",
|
||||
});
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign", "aws-version-1", "value-sha-1", "fingerprint-sha-1"));
|
||||
const secret = await svc.create(companyId, {
|
||||
name: "Vault Reassign",
|
||||
key: "vault-reassign",
|
||||
@@ -3426,11 +3261,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("previews AWS remote import candidates with duplicate and collision enrichment", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const duplicate = await svc.create(companyId, {
|
||||
name: "Existing duplicate",
|
||||
provider: "aws_secrets_manager",
|
||||
@@ -3504,11 +3335,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("sanitizes AWS remote import preview provider errors before crossing the service boundary", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const rawProviderMessage =
|
||||
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets";
|
||||
|
||||
@@ -3542,11 +3369,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("sanitizes AWS managed secret create failures and removes the reserved row", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const rawProviderMessage =
|
||||
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:CreateSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1";
|
||||
|
||||
@@ -3600,11 +3423,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("reports rollback failures when AWS managed secret create cleanup cannot remove the reserved row", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
|
||||
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockRejectedValueOnce(
|
||||
new SecretProviderClientError({
|
||||
@@ -3777,11 +3596,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("writes external reference rotations through the provider when a value is given", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/neon-admin";
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
@@ -3832,11 +3647,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("restores the provider current version when external value rotation persistence fails", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/rollback";
|
||||
const secret = await svc.create(companyId, {
|
||||
name: "External rollback",
|
||||
@@ -3885,11 +3696,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("rejects external value rotations that also retarget or pin versions", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
@@ -3927,11 +3734,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const duplicate = await svc.create(companyId, {
|
||||
name: "Existing duplicate",
|
||||
provider: "aws_secrets_manager",
|
||||
@@ -4000,11 +3803,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("sanitizes AWS remote import row provider errors", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const rawProviderMessage =
|
||||
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:DescribeSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
|
||||
vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret").mockRejectedValueOnce(
|
||||
@@ -4047,11 +3846,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("rejects Paperclip-managed AWS namespace refs during preview and import commit", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
|
||||
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
|
||||
secrets: [
|
||||
@@ -4112,11 +3907,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("skips duplicate AWS remote imports for the same provider vault and canonical ref", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
|
||||
const first = await svc.importRemoteSecrets(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
@@ -4200,11 +3991,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("rejects generic update retargeting for external reference secrets", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const awsVault = await svc.createProviderConfig(companyId, awsVaultInput());
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
@@ -4263,20 +4050,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await db.insert(companySecretVersions).values({
|
||||
secretId: secret.id,
|
||||
version: 1,
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: externalRef,
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
providerVersionRef: "aws-version-1",
|
||||
status: "current",
|
||||
});
|
||||
await db.insert(companySecretVersions).values(managedAwsVersionRow(secret.id, externalRef));
|
||||
|
||||
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
||||
|
||||
@@ -4329,20 +4103,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await db.insert(companySecretVersions).values({
|
||||
secretId: secret.id,
|
||||
version: 1,
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: externalRef,
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
providerVersionRef: "aws-version-1",
|
||||
status: "current",
|
||||
});
|
||||
await db.insert(companySecretVersions).values(managedAwsVersionRow(secret.id, externalRef));
|
||||
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce(
|
||||
new Error("provider delete failed"),
|
||||
);
|
||||
@@ -4386,20 +4147,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
status: "deleted",
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
await db.insert(companySecretVersions).values({
|
||||
secretId,
|
||||
version: 1,
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: externalRef,
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
providerVersionRef: "aws-version-1",
|
||||
status: "current",
|
||||
});
|
||||
await db.insert(companySecretVersions).values(managedAwsVersionRow(secretId, externalRef));
|
||||
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce(
|
||||
new SecretProviderClientError({
|
||||
code: "not_found",
|
||||
@@ -4449,20 +4197,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await db.insert(companySecretVersions).values({
|
||||
secretId: secret.id,
|
||||
version: 1,
|
||||
material: {
|
||||
scheme: "aws_secrets_manager_v1",
|
||||
secretId: externalRef,
|
||||
versionId: "aws-version-1",
|
||||
source: "managed",
|
||||
},
|
||||
valueSha256: "value-sha-1",
|
||||
fingerprintSha256: "fingerprint-sha-1",
|
||||
providerVersionRef: "aws-version-1",
|
||||
status: "current",
|
||||
});
|
||||
await db.insert(companySecretVersions).values(managedAwsVersionRow(secret.id, externalRef));
|
||||
await svc.disableProviderConfig(vault.id);
|
||||
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
import { developmentSlackManager } from "../services/connectors/slack/setup/development-manager.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => roots.splice(0).forEach(path => rmSync(path, { recursive: true, force: true })));
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), "slack-development-")); roots.push(root);
|
||||
const path = join(root, "manager.json");
|
||||
writeFileSync(path, JSON.stringify({ appId: "A123", clientId: "123.456", clientSecret: "client-secret-canary" }), { mode: 0o600 });
|
||||
return { path, env: { NODE_ENV: "development", PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "true", PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG: path } };
|
||||
}
|
||||
const origin = () => "https://local-test.trycloudflare.com";
|
||||
const identity = { subject: "actor", companyId: "company", profile: "slack.manager" as const };
|
||||
const user = { id: "U123", token_type: "user", access_token: "access-canary", refresh_token: "refresh-canary", expires_in: 43200, scope: SLACK_MANAGER_SCOPES.join(",") };
|
||||
const response = (value: unknown) => new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } });
|
||||
|
||||
describe("local managed Slack qualification", () => {
|
||||
it("requires explicit development opt-in and refuses production", () => {
|
||||
const { env } = fixture();
|
||||
expect(developmentSlackManager(origin, {})).toBeNull();
|
||||
expect(developmentSlackManager(origin, { ...env, NODE_ENV: "production" })).toBeNull();
|
||||
expect(developmentSlackManager(origin, { ...env, PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "false" })).toBeNull();
|
||||
});
|
||||
it("rejects readable secrets and invalid HTTPS origins", async () => {
|
||||
const { env, path } = fixture(); chmodSync(path, 0o644);
|
||||
expect(() => developmentSlackManager(origin, env)).toThrow();
|
||||
chmodSync(path, 0o600);
|
||||
await expect(developmentSlackManager(() => "http://local.test", env)!.getSlackManagerAppId()).rejects.toThrow();
|
||||
});
|
||||
it("builds a scoped authorization URL without exposing the client secret", async () => {
|
||||
const { env } = fixture();
|
||||
const client = developmentSlackManager(origin, env)!;
|
||||
const result = await client.startAuthorization({ ...identity, returnUri: `${origin()}/api/chat-slack/managed/oauth/callback`, returnState: "bound-state" });
|
||||
const url = new URL(result.authorizationUrl);
|
||||
expect(url.origin).toBe("https://slack.com");
|
||||
expect(url.searchParams.get("state")).toBe("bound-state");
|
||||
expect(url.searchParams.get("user_scope")).toBe(SLACK_MANAGER_SCOPES.join(","));
|
||||
expect(JSON.stringify(result)).not.toContain("client-secret-canary");
|
||||
await expect(client.startAuthorization({ ...identity, returnUri: "https://other.test/callback", returnState: "bound-state" })).rejects.toThrow();
|
||||
});
|
||||
it("exchanges the code against the exact callback and preserves actor/workspace identity", async () => {
|
||||
const { env } = fixture();
|
||||
const fetcher = vi.fn().mockResolvedValue(response({ ok: true, app_id: "A123", team: { id: "T123", name: "Paperclip" }, authed_user: user }));
|
||||
const result = await developmentSlackManager(origin, env, fetcher)!.claim({ ...identity, claimId: "code-canary", redemptionId: "once" });
|
||||
expect(result.slackManager).toEqual({ appId: "A123", workspaceId: "T123", workspaceName: "Paperclip", userId: "U123" });
|
||||
expect(result.subject).toBe("actor"); expect(result.companyId).toBe("company");
|
||||
const [url, request] = fetcher.mock.calls[0];
|
||||
expect(url).toBe("https://slack.com/api/oauth.v2.access");
|
||||
expect(request.redirect).toBe("error");
|
||||
expect(request.body.get("redirect_uri")).toBe(`${origin()}/api/chat-slack/managed/oauth/callback`);
|
||||
expect(request.body.get("code")).toBe("code-canary");
|
||||
});
|
||||
it.each(["wrong-app", "missing-scope", "provider-echo"])("rejects %s without echoing secrets", async reason => {
|
||||
const { env } = fixture();
|
||||
const value = reason === "provider-echo" ? { ok: false, error: "client-secret-canary code-canary" } : { ok: true, app_id: reason === "wrong-app" ? "AOTHER" : "A123", team: { id: "T123", name: "Paperclip" }, authed_user: { ...user, scope: reason === "missing-scope" ? "" : user.scope } };
|
||||
const client = developmentSlackManager(origin, env, vi.fn().mockResolvedValue(response(value)))!;
|
||||
const error = await client.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }).catch(error => error);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(String(error)).not.toMatch(/canary/);
|
||||
});
|
||||
it("rotates refresh credentials and verifies revocation", async () => {
|
||||
const { env } = fixture();
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(response({ ok: true, ...user })).mockResolvedValueOnce(response({ ok: false, error: "invalid_auth" }));
|
||||
const client = developmentSlackManager(origin, env, fetcher)!;
|
||||
expect((await client.refresh({ ...identity, refreshToken: "old-refresh" })).refreshToken).toBe("refresh-canary");
|
||||
expect(fetcher.mock.calls[0][1].body.get("grant_type")).toBe("refresh_token");
|
||||
await expect(client.revoke({ ...identity, token: "access-canary" })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -149,6 +149,56 @@ async function createApprovedToolAction(input: {
|
||||
return { decisionInput, invocation: recorded.invocation, actionRequest: updated };
|
||||
}
|
||||
|
||||
type IdRef = { id: string };
|
||||
|
||||
function sendEmailGrantRow(company: IdRef, agent: IdRef): typeof principalPermissionGrants.$inferInsert {
|
||||
return {
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: agent.id,
|
||||
permissionKey: "tools:use",
|
||||
scope: { toolName: "send_email" },
|
||||
};
|
||||
}
|
||||
|
||||
function agentProfileBindingRow(company: IdRef, profile: IdRef, agent: IdRef): typeof toolProfileBindings.$inferInsert {
|
||||
return {
|
||||
companyId: company.id,
|
||||
profileId: profile.id,
|
||||
targetType: "agent",
|
||||
targetId: agent.id,
|
||||
};
|
||||
}
|
||||
|
||||
function sendEmailDecisionInput(company: IdRef, agent: IdRef, connection: IdRef, catalogEntry: IdRef): Parameters<ReturnType<typeof toolAccessPolicyService>["decide"]>[0] {
|
||||
return {
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
||||
};
|
||||
}
|
||||
|
||||
function reviewSendPolicyRow(company: IdRef, name: string): typeof toolPolicies.$inferInsert {
|
||||
return {
|
||||
companyId: company.id,
|
||||
name: name,
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "send_email" },
|
||||
};
|
||||
}
|
||||
|
||||
function executedActionInput(db: ReturnType<typeof createDb>, company: IdRef, agent: IdRef, connection: IdRef, catalogEntry: IdRef, args: Record<string, unknown>): Parameters<typeof createApprovedToolAction>[0] {
|
||||
return {
|
||||
db,
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
connectionId: connection.id,
|
||||
catalogEntryId: catalogEntry.id,
|
||||
argumentsValue: args,
|
||||
status: "executed",
|
||||
};
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("tool access policy service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
@@ -190,11 +240,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection, catalogEntry } = await createTool(db, company.id);
|
||||
|
||||
const result = await toolAccessPolicyService(db).decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
||||
});
|
||||
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
@@ -213,12 +259,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
name: "Write tools",
|
||||
defaultAction: "deny",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(toolProfileBindings).values({
|
||||
companyId: company.id,
|
||||
profileId: profile.id,
|
||||
targetType: "agent",
|
||||
targetId: agent.id,
|
||||
});
|
||||
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
||||
await db.insert(toolProfileEntries).values({
|
||||
companyId: company.id,
|
||||
profileId: profile.id,
|
||||
@@ -227,11 +268,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
toolName: "send_email",
|
||||
});
|
||||
|
||||
const result = await toolAccessPolicyService(db).decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
||||
});
|
||||
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
@@ -468,11 +505,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
]);
|
||||
|
||||
for (const agent of [draftAgent, archivedAgent]) {
|
||||
const result = await toolAccessPolicyService(db).decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
||||
});
|
||||
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
decision: "deny",
|
||||
@@ -486,13 +519,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { application, connection, catalogEntry } = await createTool(db, company.id);
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: agent.id,
|
||||
permissionKey: "tools:use",
|
||||
scope: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
||||
const input = {
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
||||
@@ -615,12 +642,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
name: "Write tools",
|
||||
defaultAction: "allow",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(toolProfileBindings).values({
|
||||
companyId: company.id,
|
||||
profileId: profile.id,
|
||||
targetType: "agent",
|
||||
targetId: agent.id,
|
||||
});
|
||||
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
||||
const wildcardPolicy = await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review wildcard-looking sends",
|
||||
@@ -825,13 +847,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection } = await createTool(db, company.id);
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: agent.id,
|
||||
permissionKey: "tools:use",
|
||||
scope: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
||||
const input = {
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
||||
@@ -857,13 +873,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection } = await createTool(db, company.id);
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: agent.id,
|
||||
permissionKey: "tools:use",
|
||||
scope: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
||||
const input = {
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
|
||||
@@ -889,13 +899,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection } = await createTool(db, company.id);
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: agent.id,
|
||||
permissionKey: "tools:use",
|
||||
scope: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "One send per minute",
|
||||
@@ -925,13 +929,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection } = await createTool(db, company.id);
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: agent.id,
|
||||
permissionKey: "tools:use",
|
||||
scope: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "One concurrent send per minute",
|
||||
@@ -1105,12 +1103,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
name: "Fallback allow",
|
||||
defaultAction: "allow",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(toolProfileBindings).values({
|
||||
companyId: company.id,
|
||||
profileId: profile.id,
|
||||
targetType: "agent",
|
||||
targetId: agent.id,
|
||||
});
|
||||
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
||||
const [redactPolicy] = await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Legacy redact",
|
||||
@@ -1128,17 +1121,9 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
priority: 2,
|
||||
}).returning();
|
||||
|
||||
const redactDecision = await toolAccessPolicyService(db).decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
||||
});
|
||||
const redactDecision = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
||||
await db.update(toolPolicies).set({ enabled: false }).where(eq(toolPolicies.id, redactPolicy!.id));
|
||||
const validateDecision = await toolAccessPolicyService(db).decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
||||
});
|
||||
const validateDecision = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
||||
|
||||
expect(redactDecision).toMatchObject({
|
||||
allowed: false,
|
||||
@@ -1164,12 +1149,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
name: "Fallback allow",
|
||||
defaultAction: "allow",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(toolProfileBindings).values({
|
||||
companyId: company.id,
|
||||
profileId: profile.id,
|
||||
targetType: "agent",
|
||||
targetId: agent.id,
|
||||
});
|
||||
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
||||
const [policy] = await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Legacy conditional allow",
|
||||
@@ -1179,11 +1159,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
priority: 1,
|
||||
}).returning();
|
||||
|
||||
const result = await toolAccessPolicyService(db).decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
||||
});
|
||||
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
@@ -1203,12 +1179,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
name: "Fallback allow",
|
||||
defaultAction: "allow",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(toolProfileBindings).values({
|
||||
companyId: company.id,
|
||||
profileId: profile.id,
|
||||
targetType: "agent",
|
||||
targetId: agent.id,
|
||||
});
|
||||
await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent));
|
||||
const [policy] = await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Broken rate limit",
|
||||
@@ -1218,11 +1189,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
priority: 1,
|
||||
}).returning();
|
||||
|
||||
const result = await toolAccessPolicyService(db).decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" },
|
||||
});
|
||||
const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
@@ -1311,12 +1278,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const agentA = await createAgent(db, company.id);
|
||||
const agentB = await createAgent(db, company.id);
|
||||
const { connection, catalogEntry } = await createTool(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review scoped sends",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review scoped sends"));
|
||||
const args = { to: "ops@example.com", body: "same reviewed payload" };
|
||||
await createApprovedToolAction({
|
||||
db,
|
||||
@@ -1348,35 +1310,14 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection, catalogEntry } = await createTool(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review versioned sends",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review versioned sends"));
|
||||
const args = { to: "ops@example.com", body: "same payload after tool change" };
|
||||
await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
connectionId: connection.id,
|
||||
catalogEntryId: catalogEntry.id,
|
||||
argumentsValue: args,
|
||||
status: "executed",
|
||||
});
|
||||
await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
||||
await db
|
||||
.update(toolCatalogEntries)
|
||||
.set({ versionHash: randomUUID(), schemaHash: randomUUID(), updatedAt: new Date() })
|
||||
.where(eq(toolCatalogEntries.id, catalogEntry.id));
|
||||
const currentVersionAction = await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
connectionId: connection.id,
|
||||
catalogEntryId: catalogEntry.id,
|
||||
argumentsValue: args,
|
||||
status: "executed",
|
||||
});
|
||||
const currentVersionAction = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
||||
|
||||
await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
||||
companyId: company.id,
|
||||
@@ -1389,31 +1330,10 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection, catalogEntry } = await createTool(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review scoped sends",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review scoped sends"));
|
||||
const args = { to: "ops@example.com", body: "same reviewed payload" };
|
||||
const first = await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
connectionId: connection.id,
|
||||
catalogEntryId: catalogEntry.id,
|
||||
argumentsValue: args,
|
||||
status: "executed",
|
||||
});
|
||||
await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
connectionId: connection.id,
|
||||
catalogEntryId: catalogEntry.id,
|
||||
argumentsValue: args,
|
||||
status: "executed",
|
||||
});
|
||||
const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
||||
await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
||||
|
||||
await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
||||
companyId: company.id,
|
||||
@@ -1435,31 +1355,10 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection, catalogEntry } = await createTool(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review exact payload sends",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review exact payload sends"));
|
||||
const args = { to: "ops@example.com", body: "same reviewed payload" };
|
||||
const first = await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
connectionId: connection.id,
|
||||
catalogEntryId: catalogEntry.id,
|
||||
argumentsValue: args,
|
||||
status: "executed",
|
||||
});
|
||||
await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
connectionId: connection.id,
|
||||
catalogEntryId: catalogEntry.id,
|
||||
argumentsValue: args,
|
||||
status: "executed",
|
||||
});
|
||||
const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
||||
await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
||||
|
||||
await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({
|
||||
companyId: company.id,
|
||||
@@ -1475,22 +1374,9 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection, catalogEntry } = await createTool(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review changed sends",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review changed sends"));
|
||||
const args = { to: "ops@example.com", body: "repeatable" };
|
||||
const first = await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
connectionId: connection.id,
|
||||
catalogEntryId: catalogEntry.id,
|
||||
argumentsValue: args,
|
||||
status: "executed",
|
||||
});
|
||||
const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
||||
await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
@@ -1523,22 +1409,9 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection, catalogEntry } = await createTool(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review revocable sends",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "send_email" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review revocable sends"));
|
||||
const args = { to: "ops@example.com", body: "revocable" };
|
||||
const first = await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
connectionId: connection.id,
|
||||
catalogEntryId: catalogEntry.id,
|
||||
argumentsValue: args,
|
||||
status: "executed",
|
||||
});
|
||||
const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args));
|
||||
await createApprovedToolAction({
|
||||
db,
|
||||
companyId: company.id,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -169,6 +169,34 @@ function fakePluginDispatcher(): PluginToolDispatcher {
|
||||
};
|
||||
}
|
||||
|
||||
type IdRef = { id: string };
|
||||
|
||||
function reviewNotePolicyRow(company: IdRef): typeof toolPolicies.$inferInsert {
|
||||
return {
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
};
|
||||
}
|
||||
|
||||
function readPolicyRow(company: IdRef, name: string): typeof toolPolicies.$inferInsert {
|
||||
return {
|
||||
companyId: company.id,
|
||||
name: name,
|
||||
policyType: "allow",
|
||||
selectors: { riskLevel: "read" },
|
||||
};
|
||||
}
|
||||
|
||||
function runSessionInput(company: IdRef, agent: IdRef, run: IdRef): Parameters<ReturnType<typeof createToolGatewayService>["createSession"]>[0] {
|
||||
return {
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
};
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("tool gateway service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
@@ -208,18 +236,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("gates write tools with an action request and executes only stored reviewed arguments once", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
|
||||
const notified = vi.fn();
|
||||
const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.toolAction, notified);
|
||||
@@ -313,18 +332,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
companyId: company.id, runId: run.id, issueId: issue.id,
|
||||
responsibleUserId: null, cause: "company_default",
|
||||
});
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
@@ -379,7 +389,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
});
|
||||
const deliveries = toolActionDeliveryService(db, { wakeup });
|
||||
const gateway = createTestToolGatewayService(db, { onToolActionSettled: deliveries.deliver });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "n1", body: "receipt" } })).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
const [request] = await db.select().from(toolActionRequests);
|
||||
await gateway.approveActionRequest({ companyId: company.id, actionRequestId: request.id, actor: { userId: "reviewer" } });
|
||||
@@ -419,7 +429,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
const wakeup = vi.fn(async (agentId: string, input: any) => (await db.insert(agentWakeupRequests).values({ companyId: company.id, agentId, source: input.source, idempotencyKey: input.idempotencyKey, payload: input.payload }).returning())[0] as any);
|
||||
const delivery = toolActionDeliveryService(db, { wakeup });
|
||||
const gateway = createTestToolGatewayService(db, { onToolActionSettled: delivery.deliver });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
for (const noteId of ["one", "two"]) await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId, body: "reviewed body" } })).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
const requests = await db.select().from(toolActionRequests);
|
||||
expect(requests).toHaveLength(2);
|
||||
@@ -470,7 +480,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } });
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
for (let n = 0; n < 12; n++) {
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: `note-${n}`, body: "review me" } })).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
}
|
||||
@@ -500,7 +510,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
const { company, agent, issue, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } });
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "reassigned" } })).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
const [request] = await db.select().from(toolActionRequests);
|
||||
await gateway.declineActionRequest({ companyId: company.id, actionRequestId: request.id, actor: { userId: "reviewer" } });
|
||||
@@ -518,7 +528,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } });
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "expire" } })).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
const [request] = await db.select().from(toolActionRequests);
|
||||
await db.update(toolActionRequests).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(toolActionRequests.id, request.id));
|
||||
@@ -537,7 +547,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
const { company, agent, issue, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } });
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "expire" } })).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
const [expiring] = await db.select().from(toolActionRequests);
|
||||
await db.update(toolActionRequests).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(toolActionRequests.id, expiring.id));
|
||||
@@ -572,7 +582,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
if (body.method === "tools/call") calls++;
|
||||
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "Roadmap and meeting notes" }] } }), { headers: { "content-type": "application/json" } });
|
||||
} });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === connection.id)!;
|
||||
const call = (limit: number) => gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { limit } });
|
||||
await expect(call(10)).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
@@ -611,7 +621,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } });
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "n1" } })).rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
const [request] = await db.select().from(toolActionRequests);
|
||||
// This internal fixture has no connection and cannot grant action-wide access.
|
||||
@@ -624,14 +634,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("refuses to approve an action request through a different interaction", async () => {
|
||||
const { company, agent, issue, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
@@ -654,14 +659,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("prevents another run from consuming an approved action request by id", async () => {
|
||||
const { company, agent, issue, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const originatingSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const originatingSession = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: originatingSession.token,
|
||||
@@ -702,14 +702,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("executes an approved identical-args race once and returns the winner result", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const parameters = { noteId: "n1", body: "race body" };
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
@@ -737,14 +732,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("refuses to execute an approved action after its issue closes", async () => {
|
||||
const { company, agent, issue, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const parameters = { noteId: "n1", body: "post-close body" };
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
@@ -774,14 +764,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("keeps pre-execute-on-approve approved requests inert", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const parameters = { noteId: "n1", body: "legacy" };
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters }))
|
||||
.rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
@@ -833,12 +818,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("does not let a stale legacy consumer overwrite the winning approved execution", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
|
||||
let observeLegacyClaim!: () => void;
|
||||
const legacyClaimObserved = new Promise<void>((resolve) => {
|
||||
@@ -854,11 +834,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
await legacyClaimBlocked;
|
||||
},
|
||||
});
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const parameters = { noteId: "n1", body: "legacy race" };
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
@@ -941,18 +917,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
it("does not leave unsigned action requests pending when signing is unavailable", async () => {
|
||||
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "");
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db, { toolActionSigningSecret: " " });
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
@@ -993,14 +960,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
status: "running",
|
||||
contextSnapshot: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
@@ -1016,18 +978,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("cancels a stale pending action request when direct approval sees an invalid signature", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
tool: "mcp-remote-fixture:update_note",
|
||||
@@ -1053,18 +1006,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("declines a pending action request and rejects the invocation (PAP-10859)", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
|
||||
await expect(gateway.executeTool({
|
||||
sessionToken: session.token,
|
||||
@@ -1107,14 +1051,9 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
|
||||
it("expires a stale identical request and creates a fresh approval", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Review note writes",
|
||||
policyType: "require_approval",
|
||||
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const parameters = { noteId: "n1", body: "expires" };
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters }))
|
||||
.rejects.toMatchObject({ reasonCode: "approval_required" });
|
||||
@@ -1138,11 +1077,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
selectors: { toolName: "fixture:delete_everything" },
|
||||
});
|
||||
const gateway = createTestToolGatewayService(db, { pluginToolDispatcher: fakePluginDispatcher() });
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
|
||||
let approvalRequired: ToolGatewayHttpError | null = null;
|
||||
try {
|
||||
@@ -1204,12 +1139,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
it("maps remote MCP elicitation to a durable issue interaction", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await createRemoteMcpToolFixture(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Allow read tools",
|
||||
policyType: "allow",
|
||||
selectors: { riskLevel: "read" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools"));
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (_url, init) => new Response(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
@@ -1235,11 +1165,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
try {
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token))
|
||||
.find((candidate) => candidate.providerType === "mcp_remote_http");
|
||||
expect(tool).toBeTruthy();
|
||||
@@ -1296,12 +1222,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
...(persistPreference ? { mcpSessionRequired: true } : {}),
|
||||
},
|
||||
}).where(eq(toolConnections.id, connection.id));
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Allow read tools",
|
||||
policyType: "allow",
|
||||
selectors: { riskLevel: "read" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools"));
|
||||
const requests: Array<{ method: string; sessionId: string | null; protocolVersion: string | null }> = [];
|
||||
const gateway = createTestToolGatewayService(db, {
|
||||
remoteHttpRequest: async (_url, init) => {
|
||||
@@ -1334,11 +1255,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
},
|
||||
});
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token))
|
||||
.find((candidate) => candidate.providerType === "mcp_remote_http");
|
||||
|
||||
@@ -1359,12 +1276,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
it("keeps direct tools/call for generic connections without the curated session preference", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
const { connection } = await createRemoteMcpToolFixture(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Allow read tools",
|
||||
policyType: "allow",
|
||||
selectors: { riskLevel: "read" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools"));
|
||||
const requests: string[] = [];
|
||||
const gateway = createTestToolGatewayService(db, {
|
||||
remoteHttpRequest: async (_url, init) => {
|
||||
@@ -1377,7 +1289,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
});
|
||||
},
|
||||
});
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token))
|
||||
.find((candidate) => candidate.providerType === "mcp_remote_http");
|
||||
|
||||
@@ -1406,9 +1318,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
} },
|
||||
} },
|
||||
}).where(eq(toolCatalogEntries.id, catalogEntry.id));
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id, name: "Allow Chat reads", policyType: "allow", selectors: { riskLevel: "read" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow Chat reads"));
|
||||
const provider = vi.fn(async (_url, init) => {
|
||||
const payload = JSON.parse(String(init.body));
|
||||
if (payload.method === "notifications/initialized") return new Response(null, { status: 202 });
|
||||
@@ -1417,7 +1327,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
: { content: [{ type: "text", text: "Found matching messages." }] } });
|
||||
});
|
||||
const gateway = createTestToolGatewayService(db, { remoteHttpRequest: provider });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token))
|
||||
.find((candidate) => candidate.upstreamToolName === "search_messages")!;
|
||||
expect(tool).toBeTruthy();
|
||||
@@ -1457,12 +1367,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
sourceTemplateKey: "google-drive",
|
||||
},
|
||||
}).where(eq(toolConnections.id, connection.id));
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Allow Drive reads",
|
||||
policyType: "allow",
|
||||
selectors: { riskLevel: "read" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow Drive reads"));
|
||||
const gateway = createTestToolGatewayService(db, {
|
||||
remoteHttpRequest: async (_url, init) => {
|
||||
const requestBody = JSON.parse(String(init.body)) as { id: string };
|
||||
@@ -1476,11 +1381,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
},
|
||||
});
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token))
|
||||
.find((candidate) => candidate.providerType === "mcp_remote_http");
|
||||
|
||||
@@ -1517,12 +1418,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
externalCredential: { provider: "vercel_connect", subjectType: "app" },
|
||||
credentialSecretRefs: [],
|
||||
}).where(eq(connectionGrants.connectionId, connection.id));
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Allow Vercel-backed reads",
|
||||
policyType: "allow",
|
||||
selectors: { riskLevel: "read" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow Vercel-backed reads"));
|
||||
|
||||
const getToken = vi.fn<VercelConnectClient["getToken"]>(async (_request, options) => ({
|
||||
token: options?.forceRefresh ? "fresh-provider-bearer" : "stale-provider-bearer",
|
||||
@@ -1552,7 +1448,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
},
|
||||
});
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token))
|
||||
.find((candidate) => candidate.providerType === "mcp_remote_http");
|
||||
|
||||
@@ -1596,8 +1492,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
}))).returning();
|
||||
await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id,
|
||||
responsibleUserId: "A", cause: "instruction" });
|
||||
await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads",
|
||||
policyType: "allow", selectors: { riskLevel: "read" } });
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow reads"));
|
||||
const resolvedGrants: string[] = [];
|
||||
const gateway = createTestToolGatewayService(db, {
|
||||
oauthGrantRefresher: async ({ grantId }) => {
|
||||
@@ -1608,7 +1503,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
id: JSON.parse(String(init.body)).id, result: { content: [{ type: "text", text: "ok" }] },
|
||||
}), { status: 200, headers: { "content-type": "application/json" } }),
|
||||
});
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!;
|
||||
for (const [index, user] of ["A", "B", "A"].entries()) {
|
||||
if (index > 0) {
|
||||
@@ -1655,8 +1550,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
}
|
||||
await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id,
|
||||
responsibleUserId: "A", cause: "instruction" });
|
||||
await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads",
|
||||
policyType: "allow", selectors: { riskLevel: "read" } });
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow reads"));
|
||||
const refreshed: string[] = [];
|
||||
const dispatched = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
expect(new Headers(init.headers).get("authorization")).toBe(`Bearer token-${upstreamFailure ? 1 : 0}`);
|
||||
@@ -1674,7 +1568,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
return grants.find(grant => grant.id === grantId)!;
|
||||
}, remoteHttpRequest: dispatched,
|
||||
});
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!;
|
||||
const execution = gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
|
||||
if (upstreamFailure) await expect(execution).rejects.toMatchObject({ status: 502 });
|
||||
@@ -1732,12 +1626,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
},
|
||||
},
|
||||
}).where(eq(connectionGrants.id, grant.id));
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Allow customer OAuth reads",
|
||||
policyType: "allow",
|
||||
selectors: { riskLevel: "read" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow customer OAuth reads"));
|
||||
|
||||
const oauthGrantRefresher: NonNullable<ToolGatewayServiceOptions["oauthGrantRefresher"]> = vi.fn(async (input) => {
|
||||
if (input.forceRefresh) {
|
||||
@@ -1784,7 +1673,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
},
|
||||
});
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token))
|
||||
.find((candidate) => candidate.providerType === "mcp_remote_http");
|
||||
|
||||
@@ -1865,12 +1754,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
},
|
||||
},
|
||||
}).where(eq(connectionGrants.id, grant.id));
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Allow managed OAuth reads",
|
||||
policyType: "allow",
|
||||
selectors: { riskLevel: "read" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow managed OAuth reads"));
|
||||
|
||||
const oauthGrantRefresher: NonNullable<ToolGatewayServiceOptions["oauthGrantRefresher"]> = vi.fn(async (input) => {
|
||||
if (input.forceRefresh) {
|
||||
@@ -1887,7 +1771,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
return new Response(null, { status: 401 });
|
||||
},
|
||||
});
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token))
|
||||
.find((candidate) => candidate.providerType === "mcp_remote_http");
|
||||
|
||||
@@ -1926,12 +1810,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
contextSnapshot: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await createRemoteMcpToolFixture(db, company.id);
|
||||
await db.insert(toolPolicies).values({
|
||||
companyId: company.id,
|
||||
name: "Allow read tools",
|
||||
policyType: "allow",
|
||||
selectors: { riskLevel: "read" },
|
||||
});
|
||||
await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools"));
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (_url, init) => new Response(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
@@ -1940,11 +1819,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
try {
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({
|
||||
companyId: company.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
});
|
||||
const session = await gateway.createSession(runSessionInput(company, agent, run));
|
||||
const tool = (await gateway.listToolsForSession(session.token))
|
||||
.find((candidate) => candidate.providerType === "mcp_remote_http");
|
||||
expect(tool).toBeTruthy();
|
||||
@@ -2073,7 +1948,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
actor: { type: "agent", companyId: company.id, agentId: agent.id, runId: run.id },
|
||||
tool: "fixture:read_status",
|
||||
parameters: { query: "ok", apiKey: "sk-secret-value" },
|
||||
runContext: { companyId: company.id, agentId: agent.id, runId: run.id },
|
||||
runContext: runSessionInput(company, agent, run),
|
||||
});
|
||||
|
||||
expect(executedParameters).toEqual({ query: "ok", apiKey: "sk-secret-value" });
|
||||
|
||||
Reference in new issue
Block a user