mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
merge: preserve current canonical source and Claude fixture review
Keep the qualified four-target selection and both Linux provider-pack assembly paths while carrying the current shared source. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
f29dad6c94
449 files changed
+22989
-15619
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" },
|
||||
|
||||
@@ -276,8 +276,6 @@ describe("issue update comment wakeups", () => {
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
|
||||
mockIssueService.listReviewAttention.mockResolvedValue(new Map());
|
||||
// A fresh route graph is fixture preparation, not request execution. Keep
|
||||
// per-case module resets while charging the cold transform to hookTimeout.
|
||||
routeFixture = await loadRouteFixture();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { teamsCatalogService } from "../services/teams-catalog.js";
|
||||
|
||||
// Bind the real creation resolver to the release target under test so the
|
||||
// catalog contract is stable across the machines running this fixture.
|
||||
vi.mock("../services/agent-runner-selection.js", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-runner-selection.js")>();
|
||||
return { ...actual, resolveNewAgentRunnerForCompany: async (_db: unknown, _companyId: string, input: Parameters<typeof actual.resolveNewAgentRunnerForCompany>[2]) =>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -93,7 +93,7 @@ export function isSecretSensitiveHttpRequest(
|
||||
// Avatar uploads and dedicated Dot MCP calls may contain private image bytes.
|
||||
if (url && /^(?:\/mcp\/runner(?:\/|$)|\/api\/companies\/[^/]+\/agents\/[^/]+\/avatar(?:\/|$))/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (!method || !url) return false;
|
||||
if (/^\/api\/chat-slack\/oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (/^\/api\/chat-slack\/(?:managed\/)?oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (/^\/api\/chat-endpoints\/[^/]+\/slack(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true;
|
||||
if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false;
|
||||
const pathname = normalizePath(url);
|
||||
|
||||
@@ -16,6 +16,13 @@ const SENSITIVE_KEYS = new Set<string>([
|
||||
// material under `credentials`. Redact the whole subtree instead of trying
|
||||
// to keep an ever-changing allowlist of provider-specific field names in
|
||||
// sync with every connector.
|
||||
"sealed",
|
||||
"handoff",
|
||||
"claim",
|
||||
"claimid",
|
||||
"claim_id",
|
||||
"managertoken",
|
||||
"manager_token",
|
||||
"configurationtoken",
|
||||
"configuration_token",
|
||||
"credential",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { slackRegistrationSchema, slackSetupActionSchema } from "@paperclipai/shared";
|
||||
import { slackTestMessageSchema, slackRegistrationSchema, slackSetupActionSchema, slackManagedProvisionSchema } from "@paperclipai/shared";
|
||||
import { isCrossSiteOAuthCallbackNavigation, oauthCallbackInterstitialHtml } from "../lib/oauth-browser-return.js";
|
||||
import {
|
||||
Router,
|
||||
@@ -164,6 +164,10 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertConnectionManager(req, companyId);
|
||||
if (req.body.provider === "slack" && !req.body.slackSetupMethod) {
|
||||
req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod();
|
||||
}
|
||||
if (req.body.slackSetupMethod === "managed" && service.slackRegistration.managerGrants.defaultMethod() !== "managed") throw forbidden("Managed Slack setup is unavailable");
|
||||
res
|
||||
.status(201)
|
||||
.json(await service.create(companyId, req.body, actorUserId(req)));
|
||||
@@ -182,6 +186,50 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
|
||||
return { userId: actor.actorId, sessionId: actor.sessionId,
|
||||
bypassPermissionCheck: req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true };
|
||||
};
|
||||
router.get("/companies/:companyId/chat-slack/setup-options", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertConnectionManager(req, companyId);
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json(await service.slackRegistration.managerGrants.choices(companyId, slackActor(req)));
|
||||
});
|
||||
router.post("/chat-endpoints/:endpointId/slack/managed/authorize", validate(slackSetupActionSchema), async (req, res) => {
|
||||
if (!(await assertEndpointManagementAccess(req, res))) return;
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json(await service.slackRegistration.managerGrants.authorize(endpointId(req), slackActor(req)));
|
||||
});
|
||||
router.post("/chat-endpoints/:endpointId/slack/managed/provision", validate(slackManagedProvisionSchema), async (req, res) => {
|
||||
if (!(await assertEndpointManagementAccess(req, res))) return;
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json(await service.slackRegistration.managed.provision(endpointId(req), slackActor(req), req.body));
|
||||
});
|
||||
router.delete("/companies/:companyId/chat-slack/manager-grants/:grantId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertConnectionManager(req, companyId);
|
||||
await service.slackRegistration.managerGrants.revoke(req.params.grantId as string, companyId, slackActor(req));
|
||||
res.sendStatus(204);
|
||||
});
|
||||
router.get("/chat-slack/managed/oauth/callback", async (req, res) => {
|
||||
res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer");
|
||||
const actor = slackActor(req);
|
||||
const state = typeof req.query.state === "string" ? req.query.state : "";
|
||||
const expiredId = await service.slackRegistration.managerGrants.expiredReturn(state, actor);
|
||||
if (expiredId) { res.redirect(303, await service.slackRegistration.returnPath(expiredId)); return; }
|
||||
const pending = await service.slackRegistration.managerGrants.pending(state, actor);
|
||||
await assertConnectionManager(req, pending.current.endpoint.companyId);
|
||||
if (req.get("accept")?.includes("text/html") && isCrossSiteOAuthCallbackNavigation(req)) {
|
||||
res.type("html").send(oauthCallbackInterstitialHtml()); return;
|
||||
}
|
||||
const id = await service.slackRegistration.managerGrants.complete(state,
|
||||
typeof req.query.claim_id === "string" ? req.query.claim_id : null,
|
||||
typeof req.query.error === "string" ? req.query.error : null, actor,
|
||||
typeof req.query.code === "string" ? req.query.code : null);
|
||||
res.redirect(303, await service.slackRegistration.returnPath(id));
|
||||
});
|
||||
router.post("/chat-endpoints/:endpointId/slack/test-message", validate(slackTestMessageSchema), async (req, res) => {
|
||||
if (!(await assertEndpointManagementAccess(req, res))) return;
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json(await service.sendSlackTestMessage(endpointId(req), slackActor(req), req.body));
|
||||
});
|
||||
router.post("/chat-endpoints/:endpointId/slack/registration", validate(slackRegistrationSchema), async (req, res) => {
|
||||
if (!(await assertEndpointManagementAccess(req, res))) return;
|
||||
res.set("Cache-Control", "no-store");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { updateFastResponseSchema } from "@paperclipai/shared";
|
||||
import { slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared";
|
||||
import { slackTestMessageSchema, slackTestMessageResultSchema, slackManagerAuthorizationSchema, slackSetupOptionsSchema, slackManagedProvisionSchema, slackManagedResultSchema, slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared";
|
||||
import { experimentalApiMetadata } from "./experimental-api-metadata.js";
|
||||
import {
|
||||
experimentalApiPaths,
|
||||
@@ -803,8 +803,9 @@ const chatAdapterCapabilitiesResponseSchema = z
|
||||
const chatEndpointSetupResponseSchema = z
|
||||
.object({
|
||||
step: z.enum(["choose_agent", "provider_setup", "test", "complete"]),
|
||||
slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(),
|
||||
slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(),
|
||||
slackRegistration: slackRegistrationStateSchema.optional(),
|
||||
slackManagerError: z.string().optional(),
|
||||
slackAvatar: slackAvatarStateSchema.optional(),
|
||||
slackAccount: slackAccountStateSchema.optional(),
|
||||
slackOAuthCallbackUri: z.string().nullable().optional(),
|
||||
@@ -2563,6 +2564,41 @@ registry.registerPath({
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get", path: "/api/companies/{companyId}/chat-slack/setup-options", tags: ["chat-channels"],
|
||||
summary: "Discover Slack setup methods and the current user's authorized workspaces",
|
||||
description: "Managed setup is advertised only for approved Cloud deployments with the broker profile enabled. Self-hosted discovery makes no hosted-service requests. Grants are personal and company scoped.",
|
||||
request: { params: z.object({ companyId: z.string().uuid() }) }, responses: { 200: r.ok(slackSetupOptionsSchema), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
for (const action of ["authorize", "provision"] as const) registry.registerPath({
|
||||
method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, tags: ["chat-channels"],
|
||||
summary: action === "authorize" ? "Authorize a personal Slack manager workspace grant" : "Provision the saved managed Slack app",
|
||||
description: "Requires the selected managed method, company membership, connection-management permission and Cloud availability. Authorization state is actor/session bound and single use. Provisioning accepts a personal grant ID and idempotent request ID; no provider credentials or arbitrary manifests are accepted. Explicit reconnect uses reconnect=true with a fresh request ID to reinstall the saved app. Repeating that reconnect request returns its saved result without reinstalling a healthy connection.",
|
||||
request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "authorize" ? slackSetupActionSchema : slackManagedProvisionSchema) },
|
||||
responses: { 200: r.ok(action === "authorize" ? slackManagerAuthorizationSchema : slackManagedResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
|
||||
});
|
||||
registry.registerPath({
|
||||
method: "delete", path: "/api/companies/{companyId}/chat-slack/manager-grants/{grantId}", tags: ["chat-channels"],
|
||||
summary: "Revoke the current user's workspace grant for future managed provisioning",
|
||||
description: "Removes local manager credentials and invalidates pending manager authorization. Installed bots remain connected; this does not uninstall Slack apps.",
|
||||
request: { params: z.object({ companyId: z.string().uuid(), grantId: z.string().uuid() }) }, responses: { 204: { description: "Personal manager grant revoked" }, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
registry.registerPath({
|
||||
method: "get", path: "/api/chat-slack/managed/oauth/callback", tags: ["chat-channels"],
|
||||
summary: "Redeem a sealed manager authorization and return to Slack setup",
|
||||
description: "Authenticated, same-origin continuation with company, actor, session, endpoint revision and configured-origin checks. The broker handoff is single use; credentials are never returned to the browser. Explicit local development qualification accepts a Slack code instead of a broker claim; production ignores code.",
|
||||
request: { query: z.object({ state: z.string(), claim_id: z.string().optional(), code: z.string().optional(), error: z.string().optional() }) },
|
||||
responses: { 200: { description: "Same-origin continuation page" }, 303: { description: "Return to saved setup" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post", path: "/api/chat-endpoints/{endpointId}/slack/test-message", tags: ["chat-channels"],
|
||||
summary: "Connect the current user's Slack identity and send a test DM",
|
||||
description: "Requires a board connection manager and active company membership. Reuses only the actor's verified identity in the same workspace; otherwise returns paginated account choices and sends a private connection challenge. The account holder must confirm it while signed in before linking or test delivery. Revoked and conflicting links cannot be overwritten. The request ID prevents duplicate or ambiguous sends from being replayed.",
|
||||
request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(slackTestMessageSchema) },
|
||||
responses: { 200: r.ok(slackTestMessageResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable, 429: { description: "Wait before sending another test message" } },
|
||||
});
|
||||
|
||||
for (const action of ["registration", "install", "resume"] as const) {
|
||||
registry.registerPath({
|
||||
method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, tags: ["chat-channels"],
|
||||
|
||||
@@ -301,6 +301,39 @@ describeEmbeddedPostgres("reportRunFailure", () => {
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ agentAdapter: "unknown" }));
|
||||
});
|
||||
|
||||
function providerQuotaRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
return buildRun({
|
||||
error: "ACP agent reported a terminal limit failure.\nYou've hit your session limit · resets 2am (UTC)",
|
||||
errorCode: "provider_quota", executionStage: "dispatching", exitCode: 1, signal: null,
|
||||
resultJson: { errorFamily: "provider_quota", stopReason: "adapter_failed" }, ...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["claude_local", "codex_local"])("keeps a classified %s provider quota wait local", async (adapterType) => {
|
||||
await seedCompanyAndAgent({ adapterType });
|
||||
const [run] = await db.insert(heartbeatRuns).values(providerQuotaRun()).returning();
|
||||
const before = structuredClone(run);
|
||||
for (const phase of [undefined, "execute"] as const) await reportRunFailure(db, run!, { phase });
|
||||
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(heartbeatRuns)).toEqual([before]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "timed_out" }, { status: "cancelled", startedAt: new Date(0) }, { signal: "SIGTERM" },
|
||||
{ errorCode: "adapter_failed" }, { errorCode: "claude_transient_upstream" },
|
||||
{ resultJson: {} }, { resultJson: null }, { resultJson: { errorFamily: "transient_upstream" } },
|
||||
])("reports a provider quota outcome without matching adapter evidence: %j", async (overrides) => {
|
||||
await seedCompanyAndAgent({ adapterType: "claude_local" });
|
||||
await reportRunFailure(db, providerQuotaRun(overrides), { phase: "execute" });
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a provider quota code reportable during setup", async () => {
|
||||
await seedCompanyAndAgent({ adapterType: "claude_local" });
|
||||
await reportRunFailure(db, providerQuotaRun(), { phase: "setup" });
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
function aiSelectionRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
return buildRun({
|
||||
errorCode: "configuration_incomplete",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
captureAdapterStopOwnership,
|
||||
createAdapterExecutionControl,
|
||||
registerAdapterExecutionControl,
|
||||
reportAdapterStopIfUnsettled,
|
||||
waitForAdapterStop,
|
||||
} from "./adapter-execution-control.js";
|
||||
|
||||
@@ -204,3 +205,72 @@ it.each(["replaced", "wrong_run", "wrong_promise", "throwing_reader"])("omits pe
|
||||
other.finish();
|
||||
adapterExecutionControls.delete(runId);
|
||||
});
|
||||
|
||||
it("reports a Stop once, with its pending phase, only when it stays unsettled", async () => {
|
||||
vi.useFakeTimers();
|
||||
const runId = "11111111-1111-4111-8111-111111111111";
|
||||
const control = createAdapterExecutionControl();
|
||||
await registerAdapterExecutionControl(runId, control);
|
||||
const report = vi.fn();
|
||||
try {
|
||||
control.phases.enter("workspace_restore");
|
||||
reportAdapterStopIfUnsettled(control.settled, report, { runId, adapterType: "codex_local" }, control, { reportAfterMs: 5000 });
|
||||
// A repeated Stop for the same execution must not add another report.
|
||||
reportAdapterStopIfUnsettled(control.settled, report, { runId }, control, { reportAfterMs: 5000 });
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
const error = report.mock.calls[0]![0] as AdapterStopTimeoutError;
|
||||
expect(error).toBeInstanceOf(AdapterStopTimeoutError);
|
||||
expect(error.diagnostics).toMatchObject({
|
||||
runId, adapterType: "codex_local", timeoutMs: 5000, phase: "workspace_restore", phaseElapsedMs: expect.any(Number),
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(adapterExecutionControls.get(runId)).toBe(control);
|
||||
} finally {
|
||||
control.finish();
|
||||
adapterExecutionControls.delete(runId);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not report a Stop that settles before the report bound", async () => {
|
||||
vi.useFakeTimers();
|
||||
const control = createAdapterExecutionControl();
|
||||
const report = vi.fn();
|
||||
reportAdapterStopIfUnsettled(control.settled, report, undefined, control, { reportAfterMs: 5000 });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
control.finish();
|
||||
await control.settled;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("measures the report bound from the Stop request, including its response wait", async () => {
|
||||
vi.useFakeTimers();
|
||||
const control = createAdapterExecutionControl();
|
||||
const report = vi.fn();
|
||||
const stopRequestedAt = Date.now();
|
||||
// The Stop request's own response wait elapses before the watch starts.
|
||||
const responseWait = stopTimeout(waitForAdapterStop(control.settled, 2000));
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await responseWait;
|
||||
reportAdapterStopIfUnsettled(control.settled, report, undefined, control, { stopRequestedAt, reportAfterMs: 5000 });
|
||||
await vi.advanceTimersByTimeAsync(2999);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
expect((report.mock.calls[0]![0] as AdapterStopTimeoutError).diagnostics).toMatchObject({ timeoutMs: 5000 });
|
||||
control.finish();
|
||||
});
|
||||
|
||||
it("keeps a failing reporter from escaping its timer", async () => {
|
||||
vi.useFakeTimers();
|
||||
const control = createAdapterExecutionControl();
|
||||
const report = vi.fn(() => { throw new Error("reporter down"); });
|
||||
reportAdapterStopIfUnsettled(control.settled, report, undefined, undefined, { reportAfterMs: 1000 });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
control.finish();
|
||||
});
|
||||
@@ -68,9 +68,9 @@ export async function registerAdapterExecutionControl(
|
||||
adapterExecutionControls.set(runId, control);
|
||||
}
|
||||
|
||||
export async function waitForAdapterStop(
|
||||
function adapterStopTimeoutError(
|
||||
settled: Promise<void>,
|
||||
timeoutMs = 60_000,
|
||||
timeoutMs: number,
|
||||
diagnostics?: AdapterStopContext,
|
||||
owner?: ReturnType<typeof createAdapterExecutionControl>,
|
||||
) {
|
||||
@@ -83,21 +83,27 @@ export async function waitForAdapterStop(
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return new AdapterStopTimeoutError(timeoutMs, {
|
||||
...diagnostics,
|
||||
phase: undefined,
|
||||
phaseElapsedMs: undefined,
|
||||
...currentPhase(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForAdapterStop(
|
||||
settled: Promise<void>,
|
||||
timeoutMs = 60_000,
|
||||
diagnostics?: AdapterStopContext,
|
||||
owner?: ReturnType<typeof createAdapterExecutionControl>,
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
settled,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new AdapterStopTimeoutError(timeoutMs, {
|
||||
...diagnostics,
|
||||
phase: undefined,
|
||||
phaseElapsedMs: undefined,
|
||||
...currentPhase(),
|
||||
}),
|
||||
),
|
||||
() => reject(adapterStopTimeoutError(settled, timeoutMs, diagnostics, owner)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
@@ -106,3 +112,42 @@ export async function waitForAdapterStop(
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// A Stop request's wait is a response budget, not the adapter's settlement
|
||||
// bound: workspace sync-back and cooperative provider cancellation routinely
|
||||
// settle a few minutes after it. Only a Stop still unsettled this long after
|
||||
// it was requested is an operational failure worth reporting.
|
||||
export const UNSETTLED_ADAPTER_STOP_REPORT_MS = 10 * 60_000;
|
||||
const watchedAdapterStops = new WeakSet<Promise<void>>();
|
||||
|
||||
/** Reports at most once per execution, and only if it never settles in time. */
|
||||
export function reportAdapterStopIfUnsettled(
|
||||
settled: Promise<void>,
|
||||
report: (error: AdapterStopTimeoutError) => void,
|
||||
diagnostics?: AdapterStopContext,
|
||||
owner?: ReturnType<typeof createAdapterExecutionControl>,
|
||||
{
|
||||
stopRequestedAt = Date.now(),
|
||||
reportAfterMs = UNSETTLED_ADAPTER_STOP_REPORT_MS,
|
||||
}: { stopRequestedAt?: number; reportAfterMs?: number } = {},
|
||||
) {
|
||||
if (watchedAdapterStops.has(settled)) return;
|
||||
watchedAdapterStops.add(settled);
|
||||
let done = false;
|
||||
// The bound runs from the Stop request, which already spent its response wait.
|
||||
const remainingMs = Math.max(0, stopRequestedAt + reportAfterMs - Date.now());
|
||||
const timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
try {
|
||||
report(adapterStopTimeoutError(settled, reportAfterMs, diagnostics, owner));
|
||||
} catch {
|
||||
// Reporting is diagnostic only.
|
||||
}
|
||||
}, remainingMs);
|
||||
// Never hold process shutdown open for a diagnostic.
|
||||
timer.unref?.();
|
||||
void settled.then(() => {
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
@@ -55,7 +55,6 @@ export function poolMemberRuntimeConfig(member: AiConnectionPoolMember, adapterT
|
||||
const efforts = provider === "openai" ? codexLocalReasoningEffortsForModel(model)
|
||||
: provider === "anthropic" ? claudeLocalReasoningEffortsForModel(model)
|
||||
: provider === "xai" ? grokLocalReasoningEffortsForModel(model) : [];
|
||||
// Claude and Codex carry effort through native admission; other ACPX profiles keep their existing limit.
|
||||
const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" || (profile.provider === "acpx" && profile.acpxAgent === "claude") ? "modelReasoningEffort" : null
|
||||
: provider === "anthropic" ? "effort" : provider === "xai" ? "reasoningEffort" : provider === "openai" ? "modelReasoningEffort" : "variant";
|
||||
const requested = overrides.modelReasoningEffort ?? overrides.reasoningEffort ?? overrides.effort ?? overrides.variant;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared";
|
||||
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
||||
|
||||
// Direct Anthropic API list prices, verified per model below:
|
||||
// https://platform.claude.com/docs/en/about-claude/pricing
|
||||
// Keep this bounded to exact qualified models; aliases and other billers stay unknown.
|
||||
const RATE_CARDS: Record<string, { rates: readonly string[]; version: string }> = {
|
||||
"claude-sonnet-5": { rates: ["2", "0.2", "4", "10"], version: "anthropic-standard-2026-10-07" },
|
||||
"claude-opus-5-5": { rates: ["4", "0.2", "8", "20"], version: "anthropic-standard-2026-10-12" },
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,616 @@
|
||||
import { providerEffectPayload } from "./provider-effects.js";
|
||||
|
||||
import { slackSlashTaskRecoveryPayload } from "./slack/task-start.js";
|
||||
import { GITHUB_RECOVERY_STATE_KEY } from "./github/webhook-ingress.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { alias, type AnyPgColumn } from "drizzle-orm/pg-core";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatPublications,
|
||||
chatSdkState,
|
||||
chatTeamsFileTransfers,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatPublicationSummary,
|
||||
SafeChatPublicationPayload,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
teamsFileTransferService,
|
||||
} from "../chat-teams-file-transfers.js";
|
||||
|
||||
import {
|
||||
chatFileTransferResolutionActions,
|
||||
projectChatFileTransfer,
|
||||
} from "../chat-publication-batches.js";
|
||||
|
||||
import {
|
||||
badRequest,
|
||||
notFound,
|
||||
} from "../../errors.js";
|
||||
|
||||
import { chatProviderConversationUrl } from "../chat-provider-links.js";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
import { slackTaskStartActivityStatus } from "./slack/task-start.js";
|
||||
import { fileTransferProjectionColumns } from "./microsoft-teams/file-transfer-projection.js";
|
||||
|
||||
function publicationSummary(
|
||||
row: typeof chatPublications.$inferSelect,
|
||||
): ChatPublicationSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
state: row.state,
|
||||
providerUrl: row.providerUrl,
|
||||
attempts: row.attempts,
|
||||
redactedError: row.redactedError,
|
||||
nextAttemptAt: row.nextAttemptAt?.toISOString() ?? null,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
export { publicationSummary };
|
||||
|
||||
type ChatActivityOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
teamsTransferProtocol: () => Pick<ReturnType<typeof teamsFileTransferService>, "canCancelConflict">;
|
||||
};
|
||||
|
||||
export function createChatActivity(db: Db, options: ChatActivityOptions) {
|
||||
const { endpointRecord, teamsTransferProtocol } = options;
|
||||
|
||||
|
||||
async function listConversations(endpointId: string) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
const conversations = await db
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(eq(chatConversations.endpointId, endpointId))
|
||||
.orderBy(
|
||||
desc(chatConversations.lastActivityAt),
|
||||
desc(chatConversations.createdAt),
|
||||
);
|
||||
if (!conversations.length) return [];
|
||||
const issueRows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
inArray(
|
||||
issues.id,
|
||||
conversations.map((row) => row.issueId),
|
||||
),
|
||||
);
|
||||
const publications = await db
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(eq(chatPublications.endpointId, endpointId))
|
||||
.orderBy(desc(chatPublications.createdAt));
|
||||
const issueById = new Map(issueRows.map((row) => [row.id, row]));
|
||||
const lastPublication = new Map<
|
||||
string,
|
||||
typeof chatPublications.$inferSelect
|
||||
>();
|
||||
for (const publication of publications)
|
||||
if (!lastPublication.has(publication.conversationId))
|
||||
lastPublication.set(publication.conversationId, publication);
|
||||
return conversations.map((conversation) => {
|
||||
const issue = issueById.get(conversation.issueId);
|
||||
const externalUrl =
|
||||
record.endpoint.provider === "telegram" && conversation.isDirectMessage
|
||||
? chatProviderConversationUrl({
|
||||
provider: "telegram",
|
||||
botUsername: record.endpoint.botUsername,
|
||||
threadId: conversation.externalThreadId,
|
||||
providerMessageId: "",
|
||||
raw: { chat: { type: "private" } },
|
||||
})
|
||||
: (conversation.providerUrl ??
|
||||
(record.endpoint.provider === "slack"
|
||||
? chatProviderConversationUrl({
|
||||
provider: "slack",
|
||||
providerAccountId: record.endpoint.providerAccountId,
|
||||
threadId: conversation.externalThreadId,
|
||||
providerMessageId: "",
|
||||
})
|
||||
: null));
|
||||
return {
|
||||
id: conversation.id,
|
||||
companyId: conversation.companyId,
|
||||
endpointId: conversation.endpointId,
|
||||
resourceId: conversation.resourceId,
|
||||
externalLabel: conversation.externalLabel,
|
||||
externalUrl,
|
||||
externalConversationId: conversation.externalConversationId,
|
||||
externalThreadId: conversation.externalThreadId,
|
||||
sessionGeneration: conversation.sessionGeneration,
|
||||
issueId: conversation.issueId,
|
||||
issueIdentifier: issue?.identifier ?? null,
|
||||
issueTitle: issue?.title ?? null,
|
||||
isDirectMessage: conversation.isDirectMessage,
|
||||
state:
|
||||
record.endpoint.provider !== "imessage-photon" &&
|
||||
(issue?.status === "done" || issue?.status === "cancelled")
|
||||
? "completed"
|
||||
: conversation.state,
|
||||
lastActivityAt: conversation.lastActivityAt?.toISOString() ?? null,
|
||||
createdAt: conversation.createdAt.toISOString(),
|
||||
updatedAt: (
|
||||
conversation.lastActivityAt ?? conversation.updatedAt
|
||||
).toISOString(),
|
||||
lastPublicationStatus:
|
||||
lastPublication.get(conversation.id)?.state ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) {
|
||||
const limit = page ? page.limit + 1 : 100;
|
||||
// Match the millisecond precision of the public timestamp and use a UUID
|
||||
// tie-breaker so equal timestamps never skip records between pages.
|
||||
const before = (date: AnyPgColumn, id: AnyPgColumn) => page?.before
|
||||
? sql`(date_trunc('milliseconds', ${date}), ${id}) < (${page.before.createdAt}::timestamptz, ${page.before.id}::uuid)`
|
||||
: undefined;
|
||||
const recoveryIngress = alias(chatActions, "github_recovery_ingress");
|
||||
const [
|
||||
deliveries,
|
||||
publications,
|
||||
slashActions,
|
||||
providerEffects,
|
||||
githubIngressFailures,
|
||||
slackSessionActions,
|
||||
githubRecoveries,
|
||||
githubRecoveryScans,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(and(eq(chatDeliveries.endpointId, endpointId), before(chatDeliveries.createdAt, chatDeliveries.id)))
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatDeliveries.createdAt})`), desc(chatDeliveries.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(and(eq(chatPublications.endpointId, endpointId), before(chatPublications.createdAt, chatPublications.id)))
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatPublications.createdAt})`), desc(chatPublications.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.createdAt, chatActions.id),
|
||||
eq(chatActions.kind, "slash_task_start"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.createdAt, chatActions.id),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
eq(chatActions.status, "delivery_unknown"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.createdAt, chatActions.id),
|
||||
eq(chatActions.kind, "github_webhook_ingress"),
|
||||
eq(chatActions.status, "failed"),
|
||||
sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'false'`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.updatedAt, chatActions.id),
|
||||
inArray(chatActions.kind, [
|
||||
"slack_session_sync",
|
||||
"slack_session_stop",
|
||||
]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select({
|
||||
action: chatActions,
|
||||
ingressStatus: recoveryIngress.status,
|
||||
})
|
||||
.from(chatActions)
|
||||
.leftJoin(
|
||||
recoveryIngress,
|
||||
and(
|
||||
eq(recoveryIngress.companyId, chatActions.companyId),
|
||||
eq(recoveryIngress.endpointId, chatActions.endpointId),
|
||||
eq(recoveryIngress.kind, "github_webhook_ingress"),
|
||||
sql`${recoveryIngress.providerActionId} = 'github_webhook_ingress:' || (${chatActions.payload}#>>'{original,guid}')`,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
before(chatActions.updatedAt, chatActions.id),
|
||||
eq(chatActions.kind, "github_webhook_recovery"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(chatSdkState)
|
||||
.where(
|
||||
and(
|
||||
eq(chatSdkState.endpointId, endpointId),
|
||||
before(chatSdkState.updatedAt, chatSdkState.id),
|
||||
eq(chatSdkState.stateKey, GITHUB_RECOVERY_STATE_KEY),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
]);
|
||||
// A provider effect can be on another page. Replay safety must still
|
||||
// consider every unresolved effect associated with these deliveries.
|
||||
const ambiguousEffects = deliveries.length ? await db.select({ deliveryId: chatActions.deliveryId })
|
||||
.from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
eq(chatActions.status, "delivery_unknown"),
|
||||
inArray(chatActions.deliveryId, deliveries.map((row) => row.id)),
|
||||
)) : [];
|
||||
const ambiguousProviderEffectDeliveryIds = new Set(ambiguousEffects.map((row) => row.deliveryId));
|
||||
const transferRows = publications.length
|
||||
? await db
|
||||
.select(fileTransferProjectionColumns)
|
||||
.from(chatTeamsFileTransfers)
|
||||
.innerJoin(
|
||||
chatPublications,
|
||||
and(
|
||||
eq(chatPublications.id, chatTeamsFileTransfers.publicationId),
|
||||
eq(chatPublications.companyId, chatTeamsFileTransfers.companyId),
|
||||
eq(
|
||||
chatPublications.endpointId,
|
||||
chatTeamsFileTransfers.endpointId,
|
||||
),
|
||||
eq(
|
||||
chatPublications.conversationId,
|
||||
chatTeamsFileTransfers.conversationId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatTeamsFileTransfers.companyId, publications[0]!.companyId),
|
||||
eq(chatTeamsFileTransfers.endpointId, endpointId),
|
||||
inArray(
|
||||
chatTeamsFileTransfers.publicationId,
|
||||
publications.map((row) => row.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const transfers = new Map(
|
||||
transferRows.map((row) => [row.publicationId, row]),
|
||||
);
|
||||
const cancellableConflicts = new Map<string, number>();
|
||||
for (const transfer of transferRows) {
|
||||
if (transfer.phase !== "conflict") continue;
|
||||
const publication = publications.find(
|
||||
(row) => row.id === transfer.publicationId,
|
||||
);
|
||||
if (!publication?.conversationId) continue;
|
||||
if (
|
||||
await teamsTransferProtocol().canCancelConflict({
|
||||
companyId: publication.companyId,
|
||||
endpointId,
|
||||
conversationId: publication.conversationId,
|
||||
publicationId: publication.id,
|
||||
version: transfer.version,
|
||||
})
|
||||
)
|
||||
cancellableConflicts.set(publication.id, transfer.version);
|
||||
}
|
||||
return [
|
||||
...deliveries.map((row) => {
|
||||
const normalized =
|
||||
row.normalizedEvent &&
|
||||
typeof row.normalizedEvent === "object" &&
|
||||
!Array.isArray(row.normalizedEvent)
|
||||
? (row.normalizedEvent as Record<string, unknown>)
|
||||
: {};
|
||||
const deduplication =
|
||||
normalized.deduplication &&
|
||||
typeof normalized.deduplication === "object" &&
|
||||
!Array.isArray(normalized.deduplication)
|
||||
? (normalized.deduplication as Record<string, unknown>)
|
||||
: null;
|
||||
const duplicateCount =
|
||||
typeof deduplication?.duplicateCount === "number"
|
||||
? deduplication.duplicateCount
|
||||
: 0;
|
||||
const label = row.eventKind.replaceAll("_", " ");
|
||||
const outcome =
|
||||
row.state === "filtered"
|
||||
? "ignored"
|
||||
: row.state === "processed"
|
||||
? "processed"
|
||||
: row.state;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "delivery" as const,
|
||||
status: row.state,
|
||||
summary: `${label} ${outcome}${duplicateCount ? ` · ${duplicateCount} duplicate${duplicateCount === 1 ? "" : "s"} ignored` : ""}`,
|
||||
detail: row.redactedError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable:
|
||||
row.state === "failed" &&
|
||||
Boolean(row.conversationId) &&
|
||||
!ambiguousProviderEffectDeliveryIds.has(row.id),
|
||||
resolutionActions: [],
|
||||
};
|
||||
}),
|
||||
...publications.map((row) => {
|
||||
const transfer = transfers.get(row.id);
|
||||
const projected = projectChatFileTransfer(
|
||||
publicationSummary(row),
|
||||
transfer,
|
||||
);
|
||||
const payload = row.payload as Partial<SafeChatPublicationPayload>;
|
||||
const summary = payload.progressState
|
||||
? `${payload.progressState.replaceAll("_", " ")} update`
|
||||
: payload.interactionId
|
||||
? "Interactive question"
|
||||
: "Response to external conversation";
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "publication" as const,
|
||||
status: projected.state,
|
||||
summary,
|
||||
detail: projected.redactedError ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable: !transfer && row.state === "failed",
|
||||
...(projected.fileTransfer
|
||||
? { fileTransfer: projected.fileTransfer }
|
||||
: {}),
|
||||
resolutionActions: transfer
|
||||
? chatFileTransferResolutionActions(
|
||||
projected,
|
||||
cancellableConflicts.has(row.id)
|
||||
? {
|
||||
publicationId: row.id,
|
||||
version: cancellableConflicts.get(row.id)!,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
: row.state === "delivery_unknown"
|
||||
? (["mark_delivered", "retry_anyway", "cancel"] as const)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
...slashActions.map((row) => {
|
||||
const recovery = slackSlashTaskRecoveryPayload(row.payload);
|
||||
// Keep this GET read-only while still surfacing a crashed attempt as
|
||||
// actionable. The resolution POST persists the derived quarantine
|
||||
// before it applies the operator's audited decision.
|
||||
const status = slackTaskStartActivityStatus(row);
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "action" as const,
|
||||
actionType: "slash_task_start" as const,
|
||||
status,
|
||||
summary: `Slack slash-command task start ${status.replaceAll("_", " ")}`,
|
||||
detail:
|
||||
status === "delivery_unknown"
|
||||
? recovery
|
||||
? "Slack may have accepted the task-start message, so Paperclip will not replay it automatically. Check Slack first; an explicit retry can create a duplicate starter message and task."
|
||||
: "Slack may have accepted the task-start message. This older action lacks the context required for a safe explicit retry, so check Slack and cancel it here before submitting a new command."
|
||||
: status === "provider_confirmed"
|
||||
? "Slack accepted the task-start message. Paperclip is completing durable task admission without sending another Slack message."
|
||||
: status === "admitting"
|
||||
? "Slack accepted the task-start message. A Paperclip worker is admitting the task without replaying the Slack send."
|
||||
: status === "failed"
|
||||
? "Slack rejected the task-start message. Submit the command again to retry."
|
||||
: status === "cancelled"
|
||||
? "An operator cancelled this unconfirmed task start."
|
||||
: null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions:
|
||||
status === "delivery_unknown"
|
||||
? recovery
|
||||
? (["retry_anyway", "cancel"] as const)
|
||||
: (["cancel"] as const)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
...providerEffects.map((row) => {
|
||||
const payload = providerEffectPayload(row.payload);
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "action" as const,
|
||||
actionType: "provider_effect" as const,
|
||||
status: row.status,
|
||||
summary: "Provider reply delivery unknown",
|
||||
detail: payload?.completeConversationId
|
||||
? "The provider may have accepted this reply, but Paperclip could not confirm it or close the task conversation. Check the provider first. Marking it delivered closes the conversation; retrying can create a duplicate message."
|
||||
: "The provider may have accepted this reply, but Paperclip could not confirm it. Check the provider first. Retrying can create a duplicate message.",
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [
|
||||
"mark_delivered",
|
||||
"retry_anyway",
|
||||
"cancel",
|
||||
] as const,
|
||||
};
|
||||
}),
|
||||
...slackSessionActions.map((row) => {
|
||||
const isSync = row.kind === "slack_session_sync";
|
||||
const retrying =
|
||||
row.status === "received" &&
|
||||
row.result?.code === "slack_session_sync_retry";
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "action" as const,
|
||||
actionType: isSync
|
||||
? ("slack_session_sync" as const)
|
||||
: ("slack_session_stop" as const),
|
||||
status: retrying ? "retry" : row.status,
|
||||
summary: isSync ? "Slack session status" : "Slack Stop request",
|
||||
detail: isSync
|
||||
? row.result?.outcome === "unavailable"
|
||||
? "Slack has not enabled native session status for this destination. Message delivery continues."
|
||||
: row.result?.code === "slack_session_sync_rejected"
|
||||
? "Slack rejected the session indicator update. Check app permissions and channel access; message delivery is tracked separately."
|
||||
: retrying
|
||||
? "The Slack session indicator is waiting to sync. Paperclip will not resend the response."
|
||||
: row.result?.sessionStatus === "processing"
|
||||
? "Working status is refreshed automatically while the run remains active."
|
||||
: null
|
||||
: row.status === "processed"
|
||||
? "Paperclip stopped the work authorized by this request."
|
||||
: row.status === "cancelled"
|
||||
? "No work was stopped: this request was no longer authorized or its target was no longer current."
|
||||
: row.status === "failed"
|
||||
? row.result?.retryable === true
|
||||
? "The Stop request could not finish yet. Paperclip will retry against the original work only."
|
||||
: "The Stop request could not be completed. Check the task's current run before trying again."
|
||||
: "Paperclip is processing this Stop request against its original task and run.",
|
||||
createdAt: row.updatedAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [],
|
||||
};
|
||||
}),
|
||||
...githubIngressFailures.map((row) => {
|
||||
const deliveryId =
|
||||
typeof row.payload.deliveryId === "string"
|
||||
? row.payload.deliveryId
|
||||
: null;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: "action" as const,
|
||||
actionType: "github_webhook_ingress" as const,
|
||||
status: row.status,
|
||||
summary: "GitHub webhook could not be processed",
|
||||
detail: deliveryId
|
||||
? `GitHub delivery ${deliveryId} was authenticated, but Paperclip could not finish processing it. Fix the connection or destination, then redeliver this delivery from the GitHub App's Recent Deliveries page.`
|
||||
: "An authenticated GitHub webhook could not be processed. Fix the connection or destination, then redeliver it from the GitHub App's Recent Deliveries page.",
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [],
|
||||
};
|
||||
}),
|
||||
...githubRecoveries.map(({ action: row, ingressStatus }) => ({
|
||||
id: row.id,
|
||||
kind: "repair" as const,
|
||||
status:
|
||||
row.status === "cancelled"
|
||||
? "cancelled"
|
||||
: ingressStatus
|
||||
? "received"
|
||||
: "pending",
|
||||
summary:
|
||||
row.status === "cancelled"
|
||||
? "GitHub webhook recovery skipped"
|
||||
: ingressStatus
|
||||
? "GitHub webhook received after recovery request"
|
||||
: "GitHub webhook redelivery requested",
|
||||
detail:
|
||||
row.status === "cancelled"
|
||||
? row.result?.code === "source_changed_or_unavailable"
|
||||
? "The original comment changed or is no longer available. Paperclip did not replay its old contents."
|
||||
: "This callback was not an eligible current user comment. Paperclip did not replay it."
|
||||
: ingressStatus
|
||||
? "Paperclip received this callback. Its normal access checks and processing still apply."
|
||||
: "Paperclip asked GitHub to resend a recent missed message. This does not yet confirm receipt or a reply. Automatic requests are limited; if it remains unanswered, check the App's Recent Deliveries and send your request again in the current conversation.",
|
||||
createdAt: row.updatedAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [],
|
||||
})),
|
||||
...githubRecoveryScans.flatMap((row) => {
|
||||
const value = row.value as { outcome?: string } | null;
|
||||
const detail =
|
||||
value?.outcome === "history_limit_reached"
|
||||
? "Recent webhook volume exceeded automatic recovery's bounded scan. No partial history was replayed. Check the App's Recent Deliveries for missed messages."
|
||||
: value?.outcome === "callback_mismatch"
|
||||
? "The GitHub App's callback no longer matches this connection. Reconnect the App to repair it; repository access will not change."
|
||||
: value?.outcome === "scan_failed"
|
||||
? "Paperclip could not check GitHub's failed deliveries. The check will retry with backoff; normal callbacks are still processed."
|
||||
: null;
|
||||
return detail
|
||||
? [
|
||||
{
|
||||
id: row.id,
|
||||
kind: "health" as const,
|
||||
status: "attention",
|
||||
summary: "GitHub webhook recovery needs attention",
|
||||
detail,
|
||||
createdAt: row.updatedAt.toISOString(),
|
||||
replayable: false,
|
||||
resolutionActions: [],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
]
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function listActivityPage(endpointId: string, limit = 25, cursor?: string) {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw badRequest("Activity limit must be between 1 and 100");
|
||||
let before: { createdAt: string; id: string } | undefined;
|
||||
if (cursor !== undefined) {
|
||||
try {
|
||||
if (cursor.length > 256) throw new Error("Invalid cursor");
|
||||
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
||||
if (!Array.isArray(value) || value.length !== 2
|
||||
|| typeof value[0] !== "string" || new Date(value[0]).toISOString() !== value[0]
|
||||
|| typeof value[1] !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value[1])) throw new Error("Invalid cursor");
|
||||
before = { createdAt: value[0], id: value[1] };
|
||||
} catch { throw badRequest("Invalid activity cursor"); }
|
||||
}
|
||||
const rows = await listActivity(endpointId, { limit, before });
|
||||
const items = rows.slice(0, limit);
|
||||
const last = items.at(-1);
|
||||
return {
|
||||
items,
|
||||
nextCursor: rows.length > limit && last
|
||||
? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString("base64url")
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return { listConversations, listActivity, listActivityPage };
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence, InternalSetupState } from "../runtime-types.js";
|
||||
|
||||
import { notifyChatEndpointWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatConversations,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEndpointSetupState,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
type DiscordGatewayCallbackEvent,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import {
|
||||
type ChatProviderLifecycleEffect,
|
||||
} from "../../chat-provider-lifecycle.js";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type DiscordGatewayEventsOptions = {
|
||||
ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise<boolean>;
|
||||
discordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => boolean;
|
||||
runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise<EndpointRecord | null>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
applyProviderLifecycleEffect: (endpoint: EndpointRow, effect: ChatProviderLifecycleEffect, context?: RuntimeContext) => Promise<boolean>;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
discordGatewayEventBarrier?: (event: DiscordGatewayCallbackEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
export function createDiscordGatewayEvents(db: Db, options: DiscordGatewayEventsOptions) {
|
||||
const { ensureDiscordGatewayRuntimeIsCurrent, discordGatewayRuntimeIsCurrent, runtimeCallbackRecord, runtimeCallbackEndpoint, applyProviderLifecycleEffect, runtimeGeneration } = options;
|
||||
|
||||
|
||||
function discordGatewayFailureDetail(
|
||||
event: DiscordGatewayCallbackEvent["event"],
|
||||
): string {
|
||||
if (event.type === "guild_removed") {
|
||||
return "Discord bot was removed from the configured server";
|
||||
}
|
||||
if (event.type === "guild_unavailable") {
|
||||
return "Discord server is temporarily unavailable; reconnecting";
|
||||
}
|
||||
if (event.type === "disconnected") {
|
||||
return `Discord Gateway disconnected${event.code === undefined ? "" : ` (code ${event.code})`}; reconnecting`;
|
||||
}
|
||||
if (event.type === "failure") {
|
||||
const code = event.error.code ?? event.error.status;
|
||||
return `Discord Gateway failed${code === undefined ? "" : ` (${String(code).slice(0, 64)})`}; ${event.fatal ? "credentials or privileged intents need attention" : "reconnecting"}`;
|
||||
}
|
||||
return "Discord Gateway connection needs attention";
|
||||
}
|
||||
|
||||
function handleDiscordGatewayEvent(
|
||||
callback: DiscordGatewayCallbackEvent,
|
||||
context: RuntimeContext,
|
||||
): Promise<void> {
|
||||
const pending = (context.discordGatewayEventQueue ?? Promise.resolve())
|
||||
.catch(() => undefined)
|
||||
.then(() => processDiscordGatewayEvent(callback, context));
|
||||
context.discordGatewayEventQueue = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function processDiscordGatewayEvent(
|
||||
callback: DiscordGatewayCallbackEvent,
|
||||
context: RuntimeContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!(await ensureDiscordGatewayRuntimeIsCurrent(
|
||||
callback.endpointId,
|
||||
context,
|
||||
))
|
||||
)
|
||||
return;
|
||||
if (callback.sequence <= (context.discordGatewaySequence ?? 0)) return;
|
||||
context.discordGatewaySequence = callback.sequence;
|
||||
await options.discordGatewayEventBarrier?.(callback);
|
||||
const record = await runtimeCallbackRecord(callback.endpointId, context, [
|
||||
"verifying",
|
||||
"active",
|
||||
"attention",
|
||||
]);
|
||||
if (!record || record.endpoint.provider !== "discord") return;
|
||||
const event = callback.event;
|
||||
if (event.type === "channel_removed") {
|
||||
await applyProviderLifecycleEffect(
|
||||
record.endpoint,
|
||||
{
|
||||
kind: "resource",
|
||||
provider: "discord",
|
||||
providerEventId: `gateway:channel_removed:${event.channelId}`,
|
||||
providerResourceId: event.channelId,
|
||||
resourceType: "channel",
|
||||
label: event.label?.trim() || event.channelId,
|
||||
availability: "removed",
|
||||
metadata: { source: "discord_gateway" },
|
||||
},
|
||||
context,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const current = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
callback.endpointId,
|
||||
context,
|
||||
["verifying", "active", "attention"],
|
||||
);
|
||||
if (
|
||||
!current ||
|
||||
current.provider !== "discord" ||
|
||||
!discordGatewayRuntimeIsCurrent(callback.endpointId, context)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
if (event.type === "connecting") {
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
healthMessage: "Discord Gateway connecting",
|
||||
lastError: null,
|
||||
lastEventAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, current.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
healthStatus: "unchecked",
|
||||
healthMessage: "Discord Gateway connecting",
|
||||
lastError: null,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, current.connectionId));
|
||||
return;
|
||||
}
|
||||
if (event.type === "ready" || event.type === "guild_available") {
|
||||
const gatewayOwnedAttention =
|
||||
current.status === "attention" &&
|
||||
current.healthMessage?.startsWith("Discord ") === true;
|
||||
if (current.status === "attention" && !gatewayOwnedAttention) return;
|
||||
const status =
|
||||
current.setup.step === "complete" ? "active" : "verifying";
|
||||
if (status !== current.status) await notifyChatEndpointWork(tx);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status,
|
||||
healthMessage:
|
||||
status === "active"
|
||||
? "Connected"
|
||||
: "Waiting for a test conversation",
|
||||
lastError: null,
|
||||
lastEventAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, current.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "healthy",
|
||||
healthMessage: "Connected",
|
||||
lastError: null,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, current.connectionId));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = discordGatewayFailureDetail(event);
|
||||
const transientGuildOutage = event.type === "guild_unavailable";
|
||||
const terminal =
|
||||
event.type === "guild_removed" ||
|
||||
((event.type === "failure" || event.type === "disconnected") &&
|
||||
event.fatal);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: transientGuildOutage
|
||||
? current.status
|
||||
: event.type === "guild_removed"
|
||||
? "revoked"
|
||||
: "attention",
|
||||
healthMessage: detail,
|
||||
lastError: detail,
|
||||
lastEventAt: now,
|
||||
...(terminal
|
||||
? {
|
||||
setup: {
|
||||
...current.setup,
|
||||
runtimeGeneration: runtimeGeneration(current.setup) + 1,
|
||||
} as InternalSetupState,
|
||||
}
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, current.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: terminal ? "disabled" : "active",
|
||||
enabled: !terminal,
|
||||
healthStatus: event.type === "guild_removed" ? "failed" : "degraded",
|
||||
healthMessage: detail,
|
||||
lastError: detail,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, current.connectionId));
|
||||
if (event.type === "guild_removed") {
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, current.companyId),
|
||||
eq(chatEndpointResources.endpointId, current.id),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, current.companyId),
|
||||
eq(chatConversations.endpointId, current.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return { handleDiscordGatewayEvent };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FileUpload } from "chat";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
|
||||
@@ -28,3 +29,11 @@ export function discordLifecycleFilesDigest(raw: unknown): string {
|
||||
: [];
|
||||
return createHash("sha256").update(JSON.stringify(projection)).digest("hex");
|
||||
}
|
||||
|
||||
export function discordMarkdownAttachment(text: string): FileUpload {
|
||||
return {
|
||||
data: Buffer.from(text, "utf8"),
|
||||
filename: "paperclip-response.md",
|
||||
mimeType: "text/markdown; charset=utf-8",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "../runtime-types.js";
|
||||
|
||||
import { notifyChatDeliveryWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
||||
|
||||
import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
chatExternalPrincipals,
|
||||
companyMemberships,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
conflict,
|
||||
} from "../../../errors.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
|
||||
import {
|
||||
DiscordAdapterCompatibilityError,
|
||||
type ChatSdkMessageCallbackEvent,
|
||||
type DiscordRootMentionAdmissionEvent,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import { classifyChatPublicationError } from "../../chat-publication-errors.js";
|
||||
|
||||
import type {
|
||||
Message,
|
||||
Thread,
|
||||
} from "chat";
|
||||
|
||||
type DeliveryRow = typeof chatDeliveries.$inferSelect;
|
||||
|
||||
type DiscordRootThreadsOptions = {
|
||||
ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise<boolean>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean }>;
|
||||
processMessage: (endpoint: EndpointRow, thread: Thread, message: Message, trigger: ChatSdkMessageCallbackEvent["trigger"], ingressOnly?: boolean, recoveredProviderUrl?: string | null, runtimeContext?: InboundRuntimeContext, providerUpdateId?: number, admittedDeliveryId?: string | null, receiptReactionSupported?: boolean, preauthorizedUserId?: string | null, deferDrainUntilFollowup?: boolean) => Promise<void>;
|
||||
redactError: (error: unknown) => string;
|
||||
scheduleConversationDrain: (endpointId: string, threadId: string) => void;
|
||||
lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS: number;
|
||||
discordRootThreadTransportBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createDiscordRootThreads(db: Db, options: DiscordRootThreadsOptions) {
|
||||
const { ensureDiscordGatewayRuntimeIsCurrent, withCredentialMutationLease, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, processMessage, redactError, scheduleConversationDrain, lifecycleRuntimeFence, runtimeFor, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS } = options;
|
||||
|
||||
|
||||
async function admitDiscordRootMention(
|
||||
configuredEndpoint: EndpointRow,
|
||||
event: DiscordRootMentionAdmissionEvent,
|
||||
context: RuntimeContext,
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await ensureDiscordGatewayRuntimeIsCurrent(
|
||||
configuredEndpoint.id,
|
||||
context,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return withCredentialMutationLease(
|
||||
configuredEndpoint,
|
||||
async (credentialLease) => {
|
||||
const threadId = `discord:${event.guildId}:${event.channelId}:${event.messageId}`;
|
||||
const providerEventId = `${threadId}:${event.messageId}`;
|
||||
const allowed = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const endpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
configuredEndpoint.id,
|
||||
context,
|
||||
["verifying", "active"],
|
||||
);
|
||||
const existing = await tx
|
||||
.select({ state: chatDeliveries.state })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, configuredEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
existing &&
|
||||
["processed", "filtered", "failed"].includes(existing.state)
|
||||
)
|
||||
return false;
|
||||
|
||||
let reason: string | null = null;
|
||||
if (!endpoint || endpoint.providerAccountId !== event.guildId) {
|
||||
reason = "Connection is not active";
|
||||
}
|
||||
const resource = endpoint
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, "channel"),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
event.channelId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const enabledCount =
|
||||
endpoint?.status === "verifying"
|
||||
? await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, "channel"),
|
||||
eq(chatEndpointResources.enabled, true),
|
||||
eq(chatEndpointResources.availability, "available"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.count ?? 0)
|
||||
: 0;
|
||||
const setupDestination =
|
||||
endpoint?.status === "verifying" &&
|
||||
enabledCount === 0 &&
|
||||
resource?.availability === "available";
|
||||
if (
|
||||
!reason &&
|
||||
!(
|
||||
endpoint?.allowGroupChats &&
|
||||
resource?.availability === "available" &&
|
||||
(resource.enabled || setupDestination)
|
||||
)
|
||||
) {
|
||||
reason = "Destination is not enabled in Paperclip";
|
||||
}
|
||||
|
||||
if (!reason && endpoint) {
|
||||
const principal = await tx
|
||||
.select({ id: chatExternalPrincipals.id })
|
||||
.from(chatExternalPrincipals)
|
||||
.where(
|
||||
and(
|
||||
eq(chatExternalPrincipals.companyId, endpoint.companyId),
|
||||
eq(chatExternalPrincipals.provider, "discord"),
|
||||
eq(chatExternalPrincipals.providerAccountId, event.guildId),
|
||||
eq(chatExternalPrincipals.externalId, event.userId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
let allowed: boolean;
|
||||
let linkedDenied = false;
|
||||
if (principal) {
|
||||
const authorization = await lockCurrentPrincipalAuthorization(
|
||||
tx,
|
||||
endpoint,
|
||||
principal.id,
|
||||
);
|
||||
allowed = authorization.allowed;
|
||||
linkedDenied = authorization.linkedDenied;
|
||||
} else if (!endpoint.allowUnlinkedPeople) {
|
||||
allowed = false;
|
||||
} else if (!endpoint.sponsorUserId) {
|
||||
allowed = true;
|
||||
} else {
|
||||
const sponsor = await tx
|
||||
.select({
|
||||
status: companyMemberships.status,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, endpoint.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, endpoint.sponsorUserId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
allowed =
|
||||
sponsor?.status === "active" &&
|
||||
sponsor.membershipRole !== "viewer";
|
||||
}
|
||||
if (!allowed) {
|
||||
reason = linkedDenied
|
||||
? "Linked Paperclip account is not currently permitted"
|
||||
: endpoint.allowUnlinkedPeople
|
||||
? "Endpoint sponsor can no longer authorize external guests"
|
||||
: "External identity must be linked to a Paperclip account";
|
||||
}
|
||||
}
|
||||
if (!reason) {
|
||||
await credentialLease.assertOwned(tx);
|
||||
return true;
|
||||
}
|
||||
|
||||
const filteredAt = new Date();
|
||||
const normalizedEvent = {
|
||||
providerEventId,
|
||||
kind: "mention",
|
||||
trigger: "mention",
|
||||
resource: {
|
||||
type: "channel",
|
||||
providerResourceId: event.channelId,
|
||||
},
|
||||
conversation: { externalThreadId: threadId },
|
||||
message: { providerMessageId: event.messageId },
|
||||
filtering: { contentRetained: false, providerThreadCreated: false },
|
||||
};
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.insert(chatDeliveries)
|
||||
.values({
|
||||
companyId: configuredEndpoint.companyId,
|
||||
endpointId: configuredEndpoint.id,
|
||||
providerEventId,
|
||||
deduplicationKey: createHash("sha256")
|
||||
.update(providerEventId)
|
||||
.digest("hex"),
|
||||
eventKind: "mention",
|
||||
normalizedEvent,
|
||||
state: "filtered",
|
||||
redactedError: reason,
|
||||
processedAt: filteredAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
chatDeliveries.endpointId,
|
||||
chatDeliveries.providerEventId,
|
||||
],
|
||||
set: {
|
||||
normalizedEvent,
|
||||
principalId: null,
|
||||
state: "filtered",
|
||||
nextAttemptAt: null,
|
||||
redactedError: reason,
|
||||
processedAt: filteredAt,
|
||||
updatedAt: filteredAt,
|
||||
},
|
||||
});
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({ lastEventAt: filteredAt, updatedAt: filteredAt })
|
||||
.where(eq(chatEndpoints.id, configuredEndpoint.id));
|
||||
await credentialLease.assertOwned(tx);
|
||||
return false;
|
||||
});
|
||||
if (!allowed || !event.message || !context.endpointRuntime) {
|
||||
return false;
|
||||
}
|
||||
const thread = context.endpointRuntime.thread(event.threadId);
|
||||
await processMessage(
|
||||
configuredEndpoint,
|
||||
thread,
|
||||
event.message,
|
||||
"mention",
|
||||
true,
|
||||
null,
|
||||
context,
|
||||
undefined,
|
||||
null,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const staged = await db
|
||||
.select({ state: chatDeliveries.state })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, configuredEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!staged ||
|
||||
!["received", "retry", "processing"].includes(staged.state)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await options.discordRootThreadTransportBarrier?.();
|
||||
await credentialLease.assertOwned();
|
||||
if (
|
||||
!(await ensureDiscordGatewayRuntimeIsCurrent(
|
||||
configuredEndpoint.id,
|
||||
context,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await context.endpointRuntime.ensureDiscordRootThread({
|
||||
channelId: event.channelId,
|
||||
content: event.message.text,
|
||||
messageId: event.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
// The durable provisional receipt owns recovery. Do not let a
|
||||
// transient REST failure escape the Gateway event handler or ask the
|
||||
// adapter to create the same thread outside Paperclip's lifecycle
|
||||
// fence.
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: configuredEndpoint.id,
|
||||
error: redactError(error),
|
||||
providerEventId,
|
||||
},
|
||||
"Discord root thread creation will be retried from the durable receipt",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const promoted = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const current = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
configuredEndpoint.id,
|
||||
context,
|
||||
["verifying", "active"],
|
||||
);
|
||||
if (!current) return false;
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const [row] = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "received",
|
||||
normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`,
|
||||
nextAttemptAt: null,
|
||||
redactedError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, configuredEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
eq(chatDeliveries.state, "retry"),
|
||||
sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
await credentialLease.assertOwned(tx);
|
||||
return Boolean(row);
|
||||
});
|
||||
if (promoted)
|
||||
scheduleConversationDrain(configuredEndpoint.id, threadId);
|
||||
// Paperclip created the provider thread inside its credential/reach
|
||||
// lease. Returning false prevents the adapter from creating it again;
|
||||
// the durable drain owns the remaining task mutation.
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function discordRootThreadPending(
|
||||
delivery: Pick<DeliveryRow, "normalizedEvent">,
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(delivery.normalizedEvent) &&
|
||||
typeof delivery.normalizedEvent === "object" &&
|
||||
!Array.isArray(delivery.normalizedEvent) &&
|
||||
(delivery.normalizedEvent as { providerThreadPending?: unknown })
|
||||
.providerThreadPending === true
|
||||
);
|
||||
}
|
||||
|
||||
async function reconcileDiscordRootThread(
|
||||
endpoint: EndpointRow,
|
||||
delivery: DeliveryRow,
|
||||
threadId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const normalized = delivery.normalizedEvent as {
|
||||
message?: { providerMessageId?: unknown; text?: unknown };
|
||||
resource?: { providerResourceId?: unknown };
|
||||
};
|
||||
const discordThreadParts = threadId.split(":");
|
||||
const channelId =
|
||||
discordThreadParts[0] === "discord" && discordThreadParts.length >= 4
|
||||
? discordThreadParts[2]
|
||||
: normalized.resource?.providerResourceId;
|
||||
const messageId = normalized.message?.providerMessageId;
|
||||
if (typeof channelId !== "string" || typeof messageId !== "string") {
|
||||
throw new DiscordAdapterCompatibilityError(
|
||||
"the provisional root receipt is missing its channel or message id",
|
||||
);
|
||||
}
|
||||
const runtimeFence = lifecycleRuntimeFence(delivery);
|
||||
if (!runtimeFence) {
|
||||
throw new DiscordAdapterCompatibilityError(
|
||||
"the provisional root receipt is missing its runtime fence",
|
||||
);
|
||||
}
|
||||
return await withCredentialMutationLease(
|
||||
endpoint,
|
||||
async (credentialLease) => {
|
||||
const currentEndpoint = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const current = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
endpoint.id,
|
||||
runtimeFence,
|
||||
["verifying", "active"],
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
return current;
|
||||
});
|
||||
if (!currentEndpoint) {
|
||||
throw conflict(
|
||||
"Discord root thread credentials or connection state changed before recovery",
|
||||
{ code: "chat_endpoint_runtime_superseded" },
|
||||
);
|
||||
}
|
||||
const endpointRuntime = await runtimeFor(currentEndpoint);
|
||||
await credentialLease.assertOwned();
|
||||
await endpointRuntime.ensureDiscordRootThread({
|
||||
channelId,
|
||||
messageId,
|
||||
content:
|
||||
typeof normalized.message?.text === "string"
|
||||
? normalized.message.text
|
||||
: "Paperclip task",
|
||||
});
|
||||
return await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const current = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
endpoint.id,
|
||||
runtimeFence,
|
||||
["verifying", "active"],
|
||||
);
|
||||
if (!current) return false;
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const [promoted] = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "received",
|
||||
normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`,
|
||||
nextAttemptAt: null,
|
||||
redactedError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, delivery.id),
|
||||
eq(chatDeliveries.state, "retry"),
|
||||
sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
await credentialLease.assertOwned(tx);
|
||||
return Boolean(promoted);
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const disposition = classifyChatPublicationError(
|
||||
error,
|
||||
delivery.attempts + 1,
|
||||
);
|
||||
const terminal =
|
||||
disposition.kind === "resource_unavailable" ||
|
||||
disposition.kind === "failed";
|
||||
const retryAfterMs =
|
||||
disposition.kind === "retry"
|
||||
? disposition.retryAfterMs
|
||||
: DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS;
|
||||
const now = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
if (disposition.kind === "resource_unavailable") {
|
||||
const fence = lifecycleRuntimeFence(delivery);
|
||||
const normalized = delivery.normalizedEvent as {
|
||||
resource?: { providerResourceId?: unknown };
|
||||
};
|
||||
const providerResourceId = normalized.resource?.providerResourceId;
|
||||
const current = fence
|
||||
? await runtimeCallbackEndpoint(tx, endpoint.id, fence, [
|
||||
"verifying",
|
||||
"active",
|
||||
"attention",
|
||||
])
|
||||
: null;
|
||||
const resource =
|
||||
current && typeof providerResourceId === "string"
|
||||
? await tx
|
||||
.select({ id: chatEndpointResources.id })
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, current.companyId),
|
||||
eq(chatEndpointResources.endpointId, current.id),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
providerResourceId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
if (resource) {
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: now })
|
||||
.where(eq(chatEndpointResources.id, resource.id));
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, current!.companyId),
|
||||
eq(chatConversations.endpointId, current!.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: terminal ? "filtered" : "retry",
|
||||
attempts: delivery.attempts + 1,
|
||||
nextAttemptAt: terminal
|
||||
? null
|
||||
: new Date(now.getTime() + retryAfterMs),
|
||||
processedAt: terminal ? now : null,
|
||||
redactedError: terminal
|
||||
? "Discord thread creation was not confirmed"
|
||||
: redactError(error),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, delivery.id),
|
||||
eq(chatDeliveries.state, "retry"),
|
||||
sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`,
|
||||
),
|
||||
);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return { admitDiscordRootMention, discordRootThreadPending, reconcileDiscordRootThread };
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "./runtime-types.js";
|
||||
|
||||
import type { ChatEndpointRuntime } from "../voice/voice-runtime.js";
|
||||
|
||||
import { takePhotonCompanion } from "../photon/attachments.js";
|
||||
|
||||
import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "../photon/media.js";
|
||||
|
||||
import { PhotonError } from "../photon/cloud.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
assets,
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
issueComments,
|
||||
issueAttachments,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
isAllowedContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
normalizeContentType,
|
||||
normalizeUploadAttachmentContentType,
|
||||
} from "../../attachment-types.js";
|
||||
|
||||
import {
|
||||
hasTelegramMediaProvenance,
|
||||
identifyTelegramMedia,
|
||||
telegramMediaNeedsIdentification,
|
||||
} from "../chat-telegram-media-intake.js";
|
||||
|
||||
import {
|
||||
forbidden,
|
||||
} from "../../errors.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
|
||||
import { issueService } from "../issues.js";
|
||||
|
||||
import { isExternalChatWaitAuthorizationContention } from "../native-runtime/chat-attachment-reuse.js";
|
||||
|
||||
import {
|
||||
GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS,
|
||||
GitHubAttachmentUnavailableError,
|
||||
prepareGitHubPublicAttachment,
|
||||
} from "../chat-github-attachments.js";
|
||||
|
||||
import type {
|
||||
Attachment,
|
||||
} from "chat";
|
||||
|
||||
type DeliveryRow = typeof chatDeliveries.$inferSelect;
|
||||
|
||||
type InboundAttachmentsOptions = {
|
||||
storage?: StorageService;
|
||||
authorizeInboundWakeup: (tx: DbOrTransaction, action: typeof chatActions.$inferSelect) => Promise<{ delivery: DeliveryRow; conversation: typeof chatConversations.$inferSelect }>;
|
||||
lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null;
|
||||
sanitizeFilename: (value: string | undefined) => string | null;
|
||||
issuesSvc: Pick<ReturnType<typeof issueService>, "createAttachment">;
|
||||
isExternalActionAuthorizationChange: (error: unknown) => boolean;
|
||||
redactError: (error: unknown) => string;
|
||||
};
|
||||
|
||||
export function createInboundAttachments(db: Db, options: InboundAttachmentsOptions) {
|
||||
const { authorizeInboundWakeup, lifecycleRuntimeFence, sanitizeFilename, issuesSvc, isExternalActionAuthorizationChange, redactError } = options;
|
||||
|
||||
|
||||
async function ingestAttachments(input: {
|
||||
endpoint: EndpointRow;
|
||||
endpointRuntime: ChatEndpointRuntime;
|
||||
deliveryId: string;
|
||||
issueId: string;
|
||||
issueCommentId: string;
|
||||
attachments: Attachment[];
|
||||
attachmentLimitOmissions?: number;
|
||||
unavailableReferenceCount?: number;
|
||||
actorUserId: string | null;
|
||||
}): Promise<{
|
||||
storedIds: string[];
|
||||
omissionReasons: Record<string, number>;
|
||||
}> {
|
||||
const omissionReasons: Record<string, number> = {};
|
||||
const omit = (reason: string, count = 1) => {
|
||||
omissionReasons[reason] = (omissionReasons[reason] ?? 0) + count;
|
||||
};
|
||||
const boundedAttachments = input.attachments.slice(0, 20);
|
||||
const photonDerivatives = new Map<Attachment, { source: Attachment; sourceHash: string; kind: "heif_jpeg_preview" | "live_photo_video" }>();
|
||||
const originalPhotonAttachments = new Map<Attachment, string>();
|
||||
if (
|
||||
input.endpoint.provider === "microsoft-teams" &&
|
||||
input.unavailableReferenceCount
|
||||
) {
|
||||
// A channel/group reference is not a download capability. Still tell
|
||||
// the exact current-turn consumer that those bytes are unavailable,
|
||||
// including when there are no ingestible attachments at all.
|
||||
omit("download_unavailable", input.unavailableReferenceCount);
|
||||
}
|
||||
if (
|
||||
["github", "imessage-photon"].includes(input.endpoint.provider) &&
|
||||
input.attachmentLimitOmissions
|
||||
) {
|
||||
omit("attachment_limit", input.attachmentLimitOmissions);
|
||||
}
|
||||
if (input.attachments.length > boundedAttachments.length) {
|
||||
omit(
|
||||
"attachment_limit",
|
||||
input.attachments.length - boundedAttachments.length,
|
||||
);
|
||||
}
|
||||
if (!options.storage) {
|
||||
if (boundedAttachments.length)
|
||||
omit("storage_unavailable", boundedAttachments.length);
|
||||
return { storedIds: [], omissionReasons };
|
||||
}
|
||||
const existingByFingerprint = new Map<string, string[]>();
|
||||
const existingAttachments = await db
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
byteSize: assets.byteSize,
|
||||
contentType: assets.contentType,
|
||||
originalFilename: assets.originalFilename,
|
||||
sha256: assets.sha256,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(
|
||||
assets,
|
||||
and(
|
||||
eq(assets.companyId, issueAttachments.companyId),
|
||||
eq(assets.id, issueAttachments.assetId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(issueAttachments.companyId, input.endpoint.companyId),
|
||||
eq(issueAttachments.issueId, input.issueId),
|
||||
eq(issueAttachments.issueCommentId, input.issueCommentId),
|
||||
),
|
||||
);
|
||||
for (const existing of existingAttachments) {
|
||||
const fingerprint = JSON.stringify([
|
||||
existing.sha256,
|
||||
existing.byteSize,
|
||||
existing.contentType,
|
||||
existing.originalFilename,
|
||||
]);
|
||||
const ids = existingByFingerprint.get(fingerprint) ?? [];
|
||||
ids.push(existing.id);
|
||||
existingByFingerprint.set(fingerprint, ids);
|
||||
}
|
||||
const storedIds: string[] = [];
|
||||
const withCurrentAttachmentAuthorization = async <T>(
|
||||
work: (tx: DbTransaction) => Promise<T>,
|
||||
enforceUnchangedSource = false,
|
||||
telegramMedia?: Attachment,
|
||||
): Promise<T> => {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const action = await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.companyId, input.endpoint.companyId),
|
||||
eq(chatActions.endpointId, input.endpoint.id),
|
||||
eq(chatActions.deliveryId, input.deliveryId),
|
||||
eq(chatActions.kind, "inbound_wakeup"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
if (
|
||||
!action ||
|
||||
!["preparing", "issued", "processing", "processed"].includes(
|
||||
action.status,
|
||||
) ||
|
||||
action.payload.issueId !== input.issueId ||
|
||||
action.payload.commentId !== input.issueCommentId
|
||||
)
|
||||
throw forbidden(
|
||||
"The attachment's current chat admission is unavailable",
|
||||
{ code: "chat_action_authorization_changed" },
|
||||
);
|
||||
const current = await authorizeInboundWakeup(tx, action);
|
||||
// Delivery settlement/replay can update these receipts without
|
||||
// taking the issue lock. Lock in delivery -> action order only
|
||||
// after policy authorization; NOWAIT breaks cross-worker lock
|
||||
// inversions and the outer loop retries the whole transaction.
|
||||
const lockedDelivery = await tx
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.companyId, input.endpoint.companyId),
|
||||
eq(chatDeliveries.id, input.deliveryId),
|
||||
),
|
||||
)
|
||||
.for("update", { noWait: true })
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
const lockedAction = await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.companyId, input.endpoint.companyId),
|
||||
eq(chatActions.id, action.id),
|
||||
),
|
||||
)
|
||||
.for("update", { noWait: true })
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
const scopeKeys = [
|
||||
"version",
|
||||
"issueId",
|
||||
"commentId",
|
||||
"agentId",
|
||||
"sessionGeneration",
|
||||
"requestedByActorType",
|
||||
"requestedByActorId",
|
||||
] as const;
|
||||
if (
|
||||
current.delivery.state !== "processing" ||
|
||||
!lockedDelivery ||
|
||||
!lockedAction ||
|
||||
lockedDelivery.state !== "processing" ||
|
||||
lockedDelivery.endpointId !== action.endpointId ||
|
||||
lockedDelivery.conversationId !== action.conversationId ||
|
||||
lockedDelivery.principalId !== action.principalId ||
|
||||
lockedDelivery.attempts !== current.delivery.attempts ||
|
||||
JSON.stringify(lifecycleRuntimeFence(lockedDelivery)) !==
|
||||
JSON.stringify(lifecycleRuntimeFence(current.delivery)) ||
|
||||
lockedAction.endpointId !== action.endpointId ||
|
||||
lockedAction.deliveryId !== action.deliveryId ||
|
||||
lockedAction.conversationId !== action.conversationId ||
|
||||
lockedAction.principalId !== action.principalId ||
|
||||
lockedAction.kind !== "inbound_wakeup" ||
|
||||
!["preparing", "issued", "processing", "processed"].includes(
|
||||
lockedAction.status,
|
||||
) ||
|
||||
scopeKeys.some(
|
||||
(key) => lockedAction.payload[key] !== action.payload[key],
|
||||
)
|
||||
)
|
||||
throw forbidden(
|
||||
"The attachment's current chat admission is unavailable",
|
||||
{ code: "chat_action_authorization_changed" },
|
||||
);
|
||||
if (enforceUnchangedSource) {
|
||||
if (telegramMedia) {
|
||||
const normalized = lockedDelivery.normalizedEvent as {
|
||||
principal?: { externalId?: string };
|
||||
message?: {
|
||||
providerMessageId?: string;
|
||||
attachments?: Array<{ recovery?: unknown }>;
|
||||
};
|
||||
};
|
||||
const fence = lifecycleRuntimeFence(lockedDelivery);
|
||||
const descriptor =
|
||||
input.endpointRuntime.attachmentRecoveryDescriptor(
|
||||
telegramMedia,
|
||||
{
|
||||
threadId: current.conversation.externalThreadId,
|
||||
messageId: normalized.message?.providerMessageId ?? "",
|
||||
principalExternalId: normalized.principal?.externalId,
|
||||
runtimeGeneration: fence?.generation,
|
||||
credentialFingerprint: fence?.credentialFingerprint,
|
||||
},
|
||||
);
|
||||
if (
|
||||
descriptor?.locator.kind !== "telegram_media" ||
|
||||
!normalized.message?.attachments?.some((item) => {
|
||||
const retained = item.recovery as {
|
||||
version?: unknown;
|
||||
provider?: unknown;
|
||||
locator?: Record<string, unknown>;
|
||||
attachment?: Record<string, unknown>;
|
||||
} | null;
|
||||
return (
|
||||
retained?.version === 1 &&
|
||||
retained.provider === "telegram" &&
|
||||
Object.entries(descriptor.locator).every(
|
||||
([key, value]) => retained.locator?.[key] === value,
|
||||
) &&
|
||||
Object.entries(descriptor.attachment).every(
|
||||
([key, value]) => retained.attachment?.[key] === value,
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
throw forbidden(
|
||||
"The media's admitted source is unavailable",
|
||||
{ code: "chat_action_authorization_changed" },
|
||||
);
|
||||
}
|
||||
const [source] = await tx
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.endpoint.companyId),
|
||||
eq(issueComments.issueId, input.issueId),
|
||||
eq(issueComments.id, input.issueCommentId),
|
||||
isNull(issueComments.deletedAt),
|
||||
sql`${issueComments.updatedAt} = ${issueComments.createdAt}`,
|
||||
),
|
||||
)
|
||||
.for("share", { noWait: true });
|
||||
// Lifecycle admission precedes its conversation drain. Pending
|
||||
// source changes already invalidate this image download, even
|
||||
// when the append-only original comment is still untouched.
|
||||
const [lifecycle] = await tx
|
||||
.select({ id: chatDeliveries.id })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.companyId, input.endpoint.companyId),
|
||||
eq(chatDeliveries.endpointId, input.endpoint.id),
|
||||
or(
|
||||
isNull(chatDeliveries.conversationId),
|
||||
eq(
|
||||
chatDeliveries.conversationId,
|
||||
current.conversation.id,
|
||||
),
|
||||
),
|
||||
ne(chatDeliveries.state, "filtered"),
|
||||
inArray(chatDeliveries.eventKind, [
|
||||
"message_updated",
|
||||
"message_deleted",
|
||||
"message_restored",
|
||||
]),
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(lockedDelivery))}::jsonb`,
|
||||
sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${lockedDelivery.providerEventId}`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!source || lifecycle)
|
||||
throw forbidden(
|
||||
"The image's admitted source changed before attachment registration",
|
||||
{
|
||||
code: "chat_action_authorization_changed",
|
||||
},
|
||||
);
|
||||
}
|
||||
return await work(tx);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isExternalChatWaitAuthorizationContention(error) || attempt >= 4)
|
||||
throw error;
|
||||
// Retry the complete short transaction; no network/storage work or
|
||||
// backoff occurs while governance rows are locked.
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 20 * (attempt + 1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
// One shared deadline bounds the sequential batch; an expired batch never
|
||||
// starts another GitHub request. Other providers retain their own policy.
|
||||
const githubBatchSignal =
|
||||
input.endpoint.provider === "github"
|
||||
? AbortSignal.timeout(GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS)
|
||||
: undefined;
|
||||
// This new image-only lane shares one budget across token acquisition and
|
||||
// downloads. DB/storage commit semantics remain explicit and unchanged.
|
||||
const teamsInlineBatchSignal =
|
||||
input.endpoint.provider === "microsoft-teams"
|
||||
? AbortSignal.timeout(10_000)
|
||||
: undefined;
|
||||
for (let attachment of boundedAttachments) {
|
||||
const teamsInlineImage =
|
||||
input.endpoint.provider === "microsoft-teams" &&
|
||||
input.endpointRuntime.attachmentRecoveryDescriptor(attachment)?.locator
|
||||
.kind === "teams_inline_image";
|
||||
const telegramMedia =
|
||||
input.endpoint.provider === "telegram" &&
|
||||
hasTelegramMediaProvenance(attachment);
|
||||
const sourceBoundMedia =
|
||||
teamsInlineImage ||
|
||||
telegramMedia ||
|
||||
input.endpoint.provider === "imessage-photon";
|
||||
const requireCurrentAttachmentAuthorization =
|
||||
input.endpoint.provider === "github" || sourceBoundMedia;
|
||||
try {
|
||||
if (teamsInlineImage && teamsInlineBatchSignal?.aborted) {
|
||||
omit("download_unavailable");
|
||||
continue;
|
||||
}
|
||||
// GitHub's anonymized upload URLs carry no trustworthy MIME metadata.
|
||||
// Resolve public bytes only here, after durable comment admission, and
|
||||
// then apply the same storage/type policy as every native attachment.
|
||||
if (input.endpoint.provider === "github") {
|
||||
await withCurrentAttachmentAuthorization(async () => {});
|
||||
attachment = await prepareGitHubPublicAttachment(
|
||||
attachment,
|
||||
githubBatchSignal,
|
||||
async (request, signal) => {
|
||||
await withCurrentAttachmentAuthorization(async () => {});
|
||||
return (
|
||||
(await input.endpointRuntime.resolveGitHubAttachmentComment?.(
|
||||
request,
|
||||
signal,
|
||||
)) ?? null
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
requireCurrentAttachmentAuthorization &&
|
||||
input.endpoint.provider !== "github"
|
||||
)
|
||||
await withCurrentAttachmentAuthorization(
|
||||
async () => {},
|
||||
sourceBoundMedia,
|
||||
telegramMedia ? attachment : undefined,
|
||||
);
|
||||
if (
|
||||
attachment.size !== undefined &&
|
||||
attachment.size > MAX_ATTACHMENT_BYTES
|
||||
) {
|
||||
omit("declared_too_large");
|
||||
continue;
|
||||
}
|
||||
if (!attachment.fetchData) {
|
||||
omit("download_unavailable");
|
||||
continue;
|
||||
}
|
||||
const originalFilename = sanitizeFilename(attachment.name);
|
||||
let contentType = normalizeUploadAttachmentContentType({
|
||||
contentType: normalizeContentType(
|
||||
attachment.mimeType ?? "application/octet-stream",
|
||||
),
|
||||
originalFilename,
|
||||
isAllowedContentType,
|
||||
});
|
||||
// Reject a provider-declared type before allocating the downloaded
|
||||
// payload. The actual byte length is checked again after the adapter's
|
||||
// bounded fetch contract resolves.
|
||||
// A filename may infer an Office MIME for ordinary documents, but it
|
||||
// must not bypass byte identification for native media without MIME.
|
||||
const identifyTelegram =
|
||||
telegramMedia &&
|
||||
telegramMediaNeedsIdentification(attachment) &&
|
||||
normalizeContentType(
|
||||
attachment.mimeType ?? "application/octet-stream",
|
||||
) === "application/octet-stream";
|
||||
if (!isAllowedContentType(contentType) && !identifyTelegram) {
|
||||
omit("unsupported_type");
|
||||
continue;
|
||||
}
|
||||
const fetched = teamsInlineImage
|
||||
? await input.endpointRuntime.fetchTeamsInlineImage(
|
||||
attachment,
|
||||
teamsInlineBatchSignal!,
|
||||
)
|
||||
: await attachment.fetchData();
|
||||
const body = Buffer.isBuffer(fetched) ? fetched : Buffer.from(fetched);
|
||||
if (body.length === 0) {
|
||||
omit("empty_download");
|
||||
continue;
|
||||
}
|
||||
if (body.length > MAX_ATTACHMENT_BYTES) {
|
||||
omit("downloaded_too_large");
|
||||
continue;
|
||||
}
|
||||
if (identifyTelegram) {
|
||||
contentType =
|
||||
identifyTelegramMedia(attachment, body) ??
|
||||
"application/octet-stream";
|
||||
if (!isAllowedContentType(contentType)) {
|
||||
omit("unsupported_type");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const photonSourceHash = createHash("sha256")
|
||||
.update(body)
|
||||
.digest("hex");
|
||||
if (input.endpoint.provider === "imessage-photon") {
|
||||
await validatePhotonImage(body, contentType);
|
||||
const companion = takePhotonCompanion(body);
|
||||
if (companion?.unavailable) omit("companion_unavailable");
|
||||
else if (companion) {
|
||||
const video: Attachment = { type: "video", name: companion.fileName, mimeType: companion.mimeType, size: companion.data.length, fetchData: async () => companion.data };
|
||||
photonDerivatives.set(video, { source: attachment, sourceHash: photonSourceHash, kind: "live_photo_video" });
|
||||
boundedAttachments.push(video);
|
||||
}
|
||||
if (
|
||||
HEIF_CONTENT_TYPES.has(contentType) &&
|
||||
isAllowedContentType("image/jpeg")
|
||||
) {
|
||||
try {
|
||||
const preview = await photonHeifPreview(body);
|
||||
const derivative: Attachment = {
|
||||
type: "image",
|
||||
name: `${originalFilename} (JPEG preview).jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
size: preview.length,
|
||||
fetchData: async () => preview,
|
||||
};
|
||||
photonDerivatives.set(derivative, { source: attachment, sourceHash: photonSourceHash, kind: "heif_jpeg_preview" });
|
||||
boundedAttachments.push(derivative);
|
||||
} catch {
|
||||
omit("preview_unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
const fingerprint = JSON.stringify([
|
||||
photonSourceHash,
|
||||
body.length,
|
||||
contentType,
|
||||
originalFilename,
|
||||
]);
|
||||
const existingIds = existingByFingerprint.get(fingerprint);
|
||||
const existingId = existingIds?.shift();
|
||||
if (requireCurrentAttachmentAuthorization) {
|
||||
// Revoke during either API resolution or the anonymous byte download
|
||||
// must prevent storage and attachment registration for that input.
|
||||
await withCurrentAttachmentAuthorization(
|
||||
async () => {},
|
||||
sourceBoundMedia,
|
||||
telegramMedia ? attachment : undefined,
|
||||
);
|
||||
}
|
||||
const registerPhotonProvenance = async (attachmentId: string) => {
|
||||
if (input.endpoint.provider !== "imessage-photon") return;
|
||||
const relation = photonDerivatives.get(attachment);
|
||||
if (!relation) {
|
||||
originalPhotonAttachments.set(attachment, attachmentId);
|
||||
return;
|
||||
}
|
||||
const originalAttachmentId =
|
||||
originalPhotonAttachments.get(relation.source);
|
||||
if (!originalAttachmentId)
|
||||
throw new Error("Photon related attachment source is missing");
|
||||
await db
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: input.endpoint.companyId,
|
||||
endpointId: input.endpoint.id,
|
||||
deliveryId: input.deliveryId,
|
||||
kind: relation.kind === "heif_jpeg_preview" ? "attachment_derivative" : "attachment_companion",
|
||||
providerActionId: `photon-${relation.kind}:${input.deliveryId}:${originalAttachmentId}`,
|
||||
payload: {
|
||||
originalAttachmentId,
|
||||
derivativeAttachmentId: attachmentId,
|
||||
originalSha256: relation.sourceHash,
|
||||
derivativeSha256: photonSourceHash,
|
||||
kind: relation.kind,
|
||||
},
|
||||
status: "processed",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
};
|
||||
if (existingId) {
|
||||
storedIds.push(existingId);
|
||||
await registerPhotonProvenance(existingId);
|
||||
continue;
|
||||
}
|
||||
const stored = await options.storage.putFile({
|
||||
companyId: input.endpoint.companyId,
|
||||
namespace: `issues/${input.issueId}`,
|
||||
originalFilename,
|
||||
contentType,
|
||||
body,
|
||||
});
|
||||
const registration = {
|
||||
issueId: input.issueId,
|
||||
issueCommentId: input.issueCommentId,
|
||||
provider: stored.provider,
|
||||
objectKey: stored.objectKey,
|
||||
contentType: stored.contentType,
|
||||
byteSize: stored.byteSize,
|
||||
sha256: stored.sha256,
|
||||
originalFilename: stored.originalFilename,
|
||||
createdByUserId: input.actorUserId,
|
||||
};
|
||||
let row;
|
||||
try {
|
||||
row = requireCurrentAttachmentAuthorization
|
||||
? await withCurrentAttachmentAuthorization(
|
||||
(tx) =>
|
||||
issueService(tx as unknown as Db).createAttachment(
|
||||
registration,
|
||||
),
|
||||
sourceBoundMedia,
|
||||
telegramMedia ? attachment : undefined,
|
||||
)
|
||||
: await issuesSvc.createAttachment(registration);
|
||||
} catch (error) {
|
||||
// An explicit authorization denial happens before registration. Do
|
||||
// not delete on ambiguous database errors which may have committed.
|
||||
if (
|
||||
requireCurrentAttachmentAuthorization &&
|
||||
isExternalActionAuthorizationChange(error)
|
||||
)
|
||||
await options.storage
|
||||
.deleteObject(input.endpoint.companyId, stored.objectKey)
|
||||
.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
storedIds.push(row.id);
|
||||
await registerPhotonProvenance(row.id);
|
||||
} catch (error) {
|
||||
if (isExternalActionAuthorizationChange(error)) throw error;
|
||||
if (
|
||||
error instanceof PhotonError &&
|
||||
["attachment_not_ready", "network", "quota", "credentials"].includes(
|
||||
error.code,
|
||||
)
|
||||
)
|
||||
throw error;
|
||||
// Use the closed current-input omission vocabulary consumed by native
|
||||
// prompts; provider-specific diagnostics remain redacted log codes.
|
||||
omit(
|
||||
error instanceof GitHubAttachmentUnavailableError
|
||||
? "download_unavailable"
|
||||
: "processing_failed",
|
||||
);
|
||||
// A malformed or unavailable provider attachment must not strand the
|
||||
// durable text delivery. The rejected file is intentionally omitted;
|
||||
// the delivery remains auditable through its normalized attachment
|
||||
// metadata without persisting provider credentials or download URLs.
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: input.endpoint.id,
|
||||
issueId: input.issueId,
|
||||
...(error instanceof GitHubAttachmentUnavailableError
|
||||
? {
|
||||
deliveryId: input.deliveryId,
|
||||
attachmentDiagnosticCode: error.code,
|
||||
}
|
||||
: {}),
|
||||
attachmentName: sanitizeFilename(attachment.name),
|
||||
error: redactError(error),
|
||||
},
|
||||
"external chat attachment was rejected",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { storedIds, omissionReasons };
|
||||
}
|
||||
|
||||
function attachmentOmissionDetail(result: {
|
||||
omissionReasons: Record<string, number>;
|
||||
}): string | null {
|
||||
const entries = Object.entries(result.omissionReasons).filter(
|
||||
([, count]) => count > 0,
|
||||
);
|
||||
const omitted = entries.reduce((total, [, count]) => total + count, 0);
|
||||
if (!omitted) return null;
|
||||
const reasons = entries
|
||||
.map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`)
|
||||
.join(", ");
|
||||
return `${omitted} external attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons})`;
|
||||
}
|
||||
return { ingestAttachments, attachmentOmissionDetail };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { chatTeamsFileTransfers } from "@paperclipai/db";
|
||||
|
||||
export const fileTransferProjectionColumns = {
|
||||
publicationId: chatTeamsFileTransfers.publicationId,
|
||||
phase: chatTeamsFileTransfers.phase,
|
||||
version: chatTeamsFileTransfers.version,
|
||||
filename: chatTeamsFileTransfers.filename,
|
||||
expiresAt: chatTeamsFileTransfers.expiresAt,
|
||||
consentMessageId: chatTeamsFileTransfers.consentMessageId,
|
||||
responseActivityId: chatTeamsFileTransfers.responseActivityId,
|
||||
fileInfoMessageId: chatTeamsFileTransfers.fileInfoMessageId,
|
||||
operatorConfirmed: sql<boolean>`
|
||||
coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,schema}', '') = 'paperclip.teams.file-resolution.v1'
|
||||
and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,action}', '') = 'mark_delivered'
|
||||
and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,fromPhase}', '') = 'file_info_unknown'
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
assets,
|
||||
chatPublications,
|
||||
issueAttachments,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
SafeChatPublicationPayload,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
isAllowedContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
} from "../../attachment-types.js";
|
||||
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
|
||||
import {
|
||||
isTeamsInlineImageContentType,
|
||||
TEAMS_INLINE_IMAGE_MAX_BYTES,
|
||||
} from "../chat-teams-inline-images.js";
|
||||
|
||||
import type {
|
||||
FileUpload,
|
||||
} from "chat";
|
||||
|
||||
const OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS = 10_000;
|
||||
|
||||
class OutboundAttachmentHydrationError extends Error {
|
||||
readonly code = "CHAT_ATTACHMENT_HYDRATION_FAILED";
|
||||
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "OutboundAttachmentHydrationError";
|
||||
}
|
||||
}
|
||||
|
||||
class OutboundAttachmentValidationError extends Error {
|
||||
readonly code = "CHAT_ATTACHMENT_VALIDATION_FAILED";
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OutboundAttachmentValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
function isOutboundAttachmentHydrationError(
|
||||
error: unknown,
|
||||
): error is OutboundAttachmentHydrationError {
|
||||
return error instanceof OutboundAttachmentHydrationError;
|
||||
}
|
||||
|
||||
function isOutboundAttachmentValidationError(
|
||||
error: unknown,
|
||||
): error is OutboundAttachmentValidationError {
|
||||
return error instanceof OutboundAttachmentValidationError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one already-authorized attachment before any provider transport starts.
|
||||
* The database digest, rather than mutable object-store metadata, is the
|
||||
* integrity boundary. Both acquisition and streaming are bounded so a stuck
|
||||
* storage backend cannot hold the publication lane indefinitely.
|
||||
*/
|
||||
export async function hydrateOutboundAttachment(input: {
|
||||
storage: StorageService;
|
||||
companyId: string;
|
||||
objectKey: string;
|
||||
byteSize: number;
|
||||
sha256: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<FileUpload> {
|
||||
const timeoutMs =
|
||||
typeof input.timeoutMs === "number" &&
|
||||
Number.isFinite(input.timeoutMs) &&
|
||||
input.timeoutMs > 0
|
||||
? input.timeoutMs
|
||||
: OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(input.byteSize) ||
|
||||
input.byteSize <= 0 ||
|
||||
input.byteSize > MAX_ATTACHMENT_BYTES ||
|
||||
!/^[0-9a-f]{64}$/iu.test(input.sha256)
|
||||
) {
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment metadata is invalid",
|
||||
);
|
||||
}
|
||||
const acquisitionTimeoutError = new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment storage read timed out",
|
||||
);
|
||||
let acquisitionTimedOut = false;
|
||||
let rejectAcquisition!: (error: Error) => void;
|
||||
const acquisitionTimeout = new Promise<never>((_resolve, reject) => {
|
||||
rejectAcquisition = reject;
|
||||
});
|
||||
const acquisitionTimer = setTimeout(() => {
|
||||
acquisitionTimedOut = true;
|
||||
rejectAcquisition(acquisitionTimeoutError);
|
||||
}, timeoutMs);
|
||||
acquisitionTimer.unref?.();
|
||||
const objectPromise = Promise.resolve()
|
||||
.then(() => input.storage.getObject(input.companyId, input.objectKey))
|
||||
.then((object) => {
|
||||
if (acquisitionTimedOut) object.stream.destroy();
|
||||
return object;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw isOutboundAttachmentHydrationError(error)
|
||||
? error
|
||||
: new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment storage read failed",
|
||||
error,
|
||||
);
|
||||
});
|
||||
let object: Awaited<ReturnType<StorageService["getObject"]>>;
|
||||
try {
|
||||
object = await Promise.race([objectPromise, acquisitionTimeout]);
|
||||
} finally {
|
||||
clearTimeout(acquisitionTimer);
|
||||
}
|
||||
|
||||
if (
|
||||
object.contentLength !== undefined &&
|
||||
object.contentLength !== input.byteSize
|
||||
) {
|
||||
object.stream.destroy();
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment size changed after registration",
|
||||
);
|
||||
}
|
||||
|
||||
const streamTimeoutError = new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment storage read timed out",
|
||||
);
|
||||
const streamTimer = setTimeout(() => {
|
||||
object.stream.destroy(streamTimeoutError);
|
||||
}, timeoutMs);
|
||||
streamTimer.unref?.();
|
||||
const chunks: Buffer[] = [];
|
||||
const digest = createHash("sha256");
|
||||
let bytes = 0;
|
||||
try {
|
||||
for await (const chunk of object.stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
bytes += buffer.length;
|
||||
if (bytes > input.byteSize || bytes > MAX_ATTACHMENT_BYTES) {
|
||||
object.stream.destroy();
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment exceeded its streaming byte limit",
|
||||
);
|
||||
}
|
||||
digest.update(buffer);
|
||||
chunks.push(buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
throw isOutboundAttachmentHydrationError(error)
|
||||
? error
|
||||
: new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment storage read failed",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(streamTimer);
|
||||
}
|
||||
|
||||
const actualDigest = digest.digest("hex");
|
||||
if (bytes !== input.byteSize || actualDigest !== input.sha256.toLowerCase()) {
|
||||
if (!object.stream.destroyed) object.stream.destroy();
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment integrity changed after registration",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
data: Buffer.concat(chunks, bytes),
|
||||
filename: input.filename,
|
||||
mimeType: input.mimeType,
|
||||
};
|
||||
}
|
||||
export { isOutboundAttachmentHydrationError, isOutboundAttachmentValidationError };
|
||||
|
||||
type OutboundAttachmentsOptions = { storage?: StorageService; };
|
||||
|
||||
export function createOutboundAttachments(db: Db, options: OutboundAttachmentsOptions) {
|
||||
|
||||
|
||||
|
||||
async function publicationFiles(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
payload: SafeChatPublicationPayload,
|
||||
preparation: { teamsInlineImagesOnly?: boolean } = {},
|
||||
): Promise<FileUpload[]> {
|
||||
if (!payload.attachmentIds?.length) return [];
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
issueId: issueAttachments.issueId,
|
||||
issueCommentId: issueAttachments.issueCommentId,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
sha256: assets.sha256,
|
||||
originalFilename: assets.originalFilename,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueAttachments.companyId, publication.companyId),
|
||||
eq(issueAttachments.issueId, publication.issueId),
|
||||
inArray(issueAttachments.id, payload.attachmentIds),
|
||||
),
|
||||
);
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
const orderedRows = payload.attachmentIds.map((attachmentId) => {
|
||||
const row = byId.get(attachmentId);
|
||||
if (
|
||||
!row ||
|
||||
(publication.commentId &&
|
||||
row.issueCommentId !== publication.commentId) ||
|
||||
row.byteSize <= 0 ||
|
||||
row.byteSize > MAX_ATTACHMENT_BYTES ||
|
||||
!row.objectKey.trim() ||
|
||||
!/^[0-9a-f]{64}$/iu.test(row.sha256) ||
|
||||
!isAllowedContentType(row.contentType)
|
||||
)
|
||||
throw new OutboundAttachmentValidationError(
|
||||
"Chat publication attachment is invalid or outside its authorized task comment",
|
||||
);
|
||||
return row;
|
||||
});
|
||||
// Picture messages have a different Teams contract from arbitrary files.
|
||||
// Preserve the normal scope/metadata checks above, but never hydrate a
|
||||
// document or oversized image just to discover it needs a task link.
|
||||
if (
|
||||
preparation.teamsInlineImagesOnly &&
|
||||
orderedRows.some(
|
||||
(row) =>
|
||||
row.byteSize > TEAMS_INLINE_IMAGE_MAX_BYTES ||
|
||||
!isTeamsInlineImageContentType(row.contentType),
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
// Authorization and persisted metadata must fail definitively even when
|
||||
// storage is unavailable; do not mask an invalid file as a transient outage.
|
||||
if (!options.storage)
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Attachment storage is unavailable for chat publication",
|
||||
);
|
||||
const files: FileUpload[] = [];
|
||||
for (const row of orderedRows) {
|
||||
files.push(
|
||||
await hydrateOutboundAttachment({
|
||||
storage: options.storage,
|
||||
companyId: publication.companyId,
|
||||
objectKey: row.objectKey,
|
||||
byteSize: row.byteSize,
|
||||
sha256: row.sha256,
|
||||
filename: row.originalFilename ?? `attachment-${row.id}`,
|
||||
mimeType: row.contentType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return files;
|
||||
} catch (error) {
|
||||
if (
|
||||
isOutboundAttachmentHydrationError(error) ||
|
||||
isOutboundAttachmentValidationError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new OutboundAttachmentHydrationError(
|
||||
"Chat publication attachment preparation failed",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { publicationFiles };
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
import { createTelegramCallbackNoticeAuthority } from "./telegram/callback-notices.js";
|
||||
|
||||
import { slackThreadChannelId } from "./slack/messages.js";
|
||||
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "./runtime-types.js";
|
||||
|
||||
import { baseTeamsConversationId } from "./microsoft-teams/resources.js";
|
||||
|
||||
import { dueChatAction, PROVIDER_EFFECT_STALE_MS } from "../chat-action-work.js";
|
||||
|
||||
import { notifyChatDeliveryWork, notifyChatActionWork } from "../chat-work-notifications.js";
|
||||
|
||||
import type { ChatEndpointRuntime } from "../voice/voice-runtime.js";
|
||||
|
||||
import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js";
|
||||
|
||||
import { projectSafeChatPublicationText } from "../chat-publication-projection.js";
|
||||
|
||||
import { PhotonChatAdapter } from "../photon/adapter.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
inArray,
|
||||
lte,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import {
|
||||
teamsConversationId,
|
||||
} from "../chat-control-chronology.js";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatProvider,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
parseTelegramCallbackReceipt,
|
||||
telegramCallbackThreadId,
|
||||
TELEGRAM_PRIVATE_ACTION_UNAVAILABLE,
|
||||
type TelegramCallbackReceipt,
|
||||
} from "../chat-telegram-ephemeral.js";
|
||||
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { redactSensitiveText } from "../../redaction.js";
|
||||
|
||||
import { classifyChatPublicationError } from "../chat-publication-errors.js";
|
||||
|
||||
import type {
|
||||
Thread,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
|
||||
type ProviderEffectTarget = {
|
||||
post: Thread["post"];
|
||||
postEphemeral?: Thread["postEphemeral"];
|
||||
};
|
||||
type ProviderEffectPayload = {
|
||||
version: 1;
|
||||
effect: "thread_message" | "ephemeral_message" | "telegram_callback_notice";
|
||||
telegramCallback?: TelegramCallbackReceipt;
|
||||
telegramAuthorizationSha256?: string;
|
||||
authorizationMode?: "principal" | "safe_notice";
|
||||
threadId: string;
|
||||
userId?: string;
|
||||
text: string;
|
||||
fallbackText?: string;
|
||||
settleDelivery: boolean;
|
||||
completeConversationId?: string;
|
||||
resourceId?: string;
|
||||
runtimeGeneration: number;
|
||||
credentialFingerprint: string;
|
||||
};
|
||||
|
||||
function providerEffectPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): ProviderEffectPayload | null {
|
||||
if (
|
||||
payload.version !== 1 ||
|
||||
(payload.effect !== "thread_message" &&
|
||||
payload.effect !== "ephemeral_message" &&
|
||||
payload.effect !== "telegram_callback_notice") ||
|
||||
typeof payload.threadId !== "string" ||
|
||||
!payload.threadId ||
|
||||
typeof payload.text !== "string" ||
|
||||
!payload.text ||
|
||||
typeof payload.settleDelivery !== "boolean" ||
|
||||
typeof payload.runtimeGeneration !== "number" ||
|
||||
!Number.isSafeInteger(payload.runtimeGeneration) ||
|
||||
typeof payload.credentialFingerprint !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
payload.effect === "ephemeral_message" &&
|
||||
(typeof payload.userId !== "string" || !payload.userId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
for (const key of [
|
||||
"authorizationMode",
|
||||
"userId",
|
||||
"fallbackText",
|
||||
"completeConversationId",
|
||||
"resourceId",
|
||||
] as const) {
|
||||
const value = payload[key];
|
||||
if (value !== undefined && (typeof value !== "string" || !value)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
payload.authorizationMode !== undefined &&
|
||||
payload.authorizationMode !== "principal" &&
|
||||
payload.authorizationMode !== "safe_notice"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (payload.effect === "telegram_callback_notice") {
|
||||
const receipt = parseTelegramCallbackReceipt(payload.telegramCallback);
|
||||
if (
|
||||
!receipt ||
|
||||
payload.authorizationMode !== "safe_notice" ||
|
||||
payload.text !== TELEGRAM_PRIVATE_ACTION_UNAVAILABLE ||
|
||||
payload.threadId !== telegramCallbackThreadId(receipt) ||
|
||||
payload.userId !== receipt.receiverUserId ||
|
||||
payload.settleDelivery !== false ||
|
||||
payload.fallbackText !== undefined ||
|
||||
payload.completeConversationId !== undefined ||
|
||||
typeof payload.telegramAuthorizationSha256 !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(payload.telegramAuthorizationSha256)
|
||||
)
|
||||
return null;
|
||||
} else if (
|
||||
payload.telegramCallback !== undefined ||
|
||||
payload.telegramAuthorizationSha256 !== undefined
|
||||
)
|
||||
return null;
|
||||
return payload as ProviderEffectPayload;
|
||||
}
|
||||
export type { ProviderEffectTarget, ProviderEffectPayload };
|
||||
export { providerEffectPayload };
|
||||
|
||||
type ProviderEffectsOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
invalidateRuntime: (endpointId: string) => Promise<boolean>;
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>;
|
||||
redactError: (error: unknown) => string;
|
||||
MAX_ERROR_TEXT: number;
|
||||
nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean;
|
||||
lockTelegramCallbackNoticeAuthority: ReturnType<typeof createTelegramCallbackNoticeAuthority>["lockTelegramCallbackNoticeAuthority"];
|
||||
};
|
||||
|
||||
export function createProviderEffects(db: Db, options: ProviderEffectsOptions) {
|
||||
const { endpointRecord, runtimeCallbackEndpoint, withCredentialMutationLease, runtimeFor, invalidateRuntime, lockCurrentPrincipalAuthorization, redactError, MAX_ERROR_TEXT, nonDirectDestinationAllowed, lockTelegramCallbackNoticeAuthority } = options;
|
||||
|
||||
|
||||
async function stageProviderEffect(
|
||||
database: DbOrTransaction,
|
||||
input: {
|
||||
endpoint: EndpointRow;
|
||||
deliveryId?: string | null;
|
||||
conversationId?: string | null;
|
||||
principalId?: string | null;
|
||||
providerActionId: string;
|
||||
payload: Omit<
|
||||
ProviderEffectPayload,
|
||||
"runtimeGeneration" | "credentialFingerprint"
|
||||
>;
|
||||
runtimeContext: LifecycleRuntimeFence;
|
||||
},
|
||||
) {
|
||||
await notifyChatActionWork(database, "provider_effect");
|
||||
const [inserted] = await database
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: input.endpoint.companyId,
|
||||
endpointId: input.endpoint.id,
|
||||
deliveryId: input.deliveryId ?? null,
|
||||
conversationId: input.conversationId ?? null,
|
||||
principalId: input.principalId ?? null,
|
||||
kind: "provider_effect",
|
||||
providerActionId: input.providerActionId,
|
||||
payload: {
|
||||
...input.payload,
|
||||
runtimeGeneration: input.runtimeContext.generation,
|
||||
credentialFingerprint: input.runtimeContext.credentialFingerprint,
|
||||
},
|
||||
status: "received",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (inserted) return inserted;
|
||||
return database
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, input.endpoint.id),
|
||||
eq(chatActions.providerActionId, input.providerActionId),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
function providerEffectDisposition(
|
||||
error: unknown,
|
||||
attempt: number,
|
||||
providerEffectCompleted: boolean,
|
||||
) {
|
||||
if (!providerEffectCompleted)
|
||||
return classifyChatPublicationError(error, attempt);
|
||||
return {
|
||||
kind: "delivery_unknown" as const,
|
||||
reason:
|
||||
"Provider effect completed, but Paperclip could not confirm its durable result",
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeProviderEffectFailure(input: {
|
||||
action: typeof chatActions.$inferSelect;
|
||||
endpoint: EndpointRow;
|
||||
payload: ProviderEffectPayload;
|
||||
disposition: ReturnType<typeof providerEffectDisposition>;
|
||||
attempt: number;
|
||||
}): Promise<boolean> {
|
||||
const failure = redactSensitiveText(input.disposition.reason).slice(
|
||||
0,
|
||||
MAX_ERROR_TEXT,
|
||||
);
|
||||
const retryable = input.disposition.kind === "retry";
|
||||
const retryAt =
|
||||
input.disposition.kind === "retry"
|
||||
? new Date(Date.now() + input.disposition.retryAfterMs)
|
||||
: null;
|
||||
let invalidateCurrentRuntime = false;
|
||||
const finalized = await db.transaction(async (tx) => {
|
||||
// Admission locks the endpoint before its delivery. Keep that order
|
||||
// when failure settlement also needs to change endpoint/resource health.
|
||||
const currentEndpoint =
|
||||
input.disposition.kind === "endpoint_attention" ||
|
||||
input.disposition.kind === "resource_unavailable"
|
||||
? await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
input.endpoint.id,
|
||||
{
|
||||
generation: input.payload.runtimeGeneration,
|
||||
credentialFingerprint: input.payload.credentialFingerprint,
|
||||
},
|
||||
["verifying", "active"],
|
||||
)
|
||||
: null;
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [ownedAction] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status:
|
||||
input.disposition.kind === "delivery_unknown"
|
||||
? "delivery_unknown"
|
||||
: "failed",
|
||||
result: {
|
||||
code: `provider_effect_${input.disposition.kind}`,
|
||||
attempts: input.attempt,
|
||||
redactedError: failure,
|
||||
retryable,
|
||||
retryAt: retryAt?.toISOString() ?? null,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, input.action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${input.attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!ownedAction) return false;
|
||||
if (input.payload.settleDelivery && input.action.deliveryId) {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: retryable ? "retry" : "failed",
|
||||
nextAttemptAt: retryAt,
|
||||
redactedError:
|
||||
input.disposition.kind === "delivery_unknown"
|
||||
? `Provider response is unconfirmed: ${failure}`.slice(
|
||||
0,
|
||||
MAX_ERROR_TEXT,
|
||||
)
|
||||
: failure,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, input.action.deliveryId),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (input.disposition.kind === "endpoint_attention" && currentEndpoint) {
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: "attention",
|
||||
healthMessage: "Provider credentials or permissions need attention",
|
||||
lastError: failure,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "disabled",
|
||||
enabled: false,
|
||||
healthStatus: "degraded",
|
||||
healthMessage: "Provider credentials or permissions need attention",
|
||||
lastError: failure,
|
||||
healthCheckedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(toolConnections.id, currentEndpoint.connectionId));
|
||||
invalidateCurrentRuntime = true;
|
||||
} else if (
|
||||
input.disposition.kind === "resource_unavailable" &&
|
||||
input.payload.resourceId &&
|
||||
currentEndpoint
|
||||
) {
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.id, input.payload.resourceId),
|
||||
eq(chatEndpointResources.endpointId, currentEndpoint.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (finalized && invalidateCurrentRuntime) {
|
||||
await invalidateRuntime(input.endpoint.id).catch(() => undefined);
|
||||
}
|
||||
return finalized;
|
||||
}
|
||||
|
||||
async function quarantineStaleProviderEffect(
|
||||
action: typeof chatActions.$inferSelect,
|
||||
) {
|
||||
const payload = providerEffectPayload(action.payload);
|
||||
const failure =
|
||||
"Provider delivery could not be confirmed after the worker stopped. The effect will not be replayed automatically.";
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [quarantined] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "delivery_unknown",
|
||||
result: {
|
||||
code: "provider_effect_delivery_unknown",
|
||||
redactedError: failure,
|
||||
retryable: false,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
eq(chatActions.updatedAt, action.updatedAt),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${
|
||||
typeof action.result?.attempts === "number"
|
||||
? action.result.attempts
|
||||
: -1
|
||||
}`,
|
||||
lte(
|
||||
chatActions.updatedAt,
|
||||
new Date(Date.now() - PROVIDER_EFFECT_STALE_MS),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (quarantined && payload?.settleDelivery && action.deliveryId) {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "failed",
|
||||
nextAttemptAt: null,
|
||||
redactedError: failure,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function providerEffectThreadResourceId(
|
||||
provider: ChatProvider,
|
||||
threadId: string,
|
||||
): string | null {
|
||||
if (provider === "slack") {
|
||||
return (
|
||||
slackThreadChannelId(threadId) ??
|
||||
(!threadId.includes(":") ? threadId : null)
|
||||
);
|
||||
}
|
||||
if (provider === "telegram") {
|
||||
return /^telegram:([^:]+)/.exec(threadId)?.[1] ?? null;
|
||||
}
|
||||
if (provider === "github") {
|
||||
return /^github:([^:]+)/.exec(threadId)?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
if (provider === "discord") {
|
||||
return /^discord:[^:]+:([^:]+)/.exec(threadId)?.[1] ?? null;
|
||||
}
|
||||
const conversationId = teamsConversationId(threadId);
|
||||
return conversationId ? baseTeamsConversationId(conversationId) : null;
|
||||
}
|
||||
|
||||
async function lockCurrentProviderEffectAuthorization(
|
||||
tx: DbTransaction,
|
||||
action: typeof chatActions.$inferSelect,
|
||||
endpoint: EndpointRow,
|
||||
payload: ProviderEffectPayload,
|
||||
): Promise<boolean> {
|
||||
if (payload.effect === "telegram_callback_notice") {
|
||||
const receipt = parseTelegramCallbackReceipt(payload.telegramCallback);
|
||||
if (!receipt || !action.deliveryId) return false;
|
||||
const delivery = await tx
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.companyId, action.companyId),
|
||||
eq(chatDeliveries.endpointId, action.endpointId),
|
||||
eq(chatDeliveries.state, "filtered"),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const retained =
|
||||
delivery &&
|
||||
parseTelegramCallbackReceipt(delivery.normalizedEvent.telegramCallback);
|
||||
if (
|
||||
!delivery ||
|
||||
delivery.principalId !== action.principalId ||
|
||||
retained?.sourceSha256 !== receipt.sourceSha256 ||
|
||||
delivery.normalizedEvent.telegramAuthorizationSha256 !==
|
||||
payload.telegramAuthorizationSha256
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
(await lockTelegramCallbackNoticeAuthority(
|
||||
tx,
|
||||
endpoint,
|
||||
receipt,
|
||||
action.principalId,
|
||||
)) === payload.telegramAuthorizationSha256
|
||||
);
|
||||
}
|
||||
const conversation = action.conversationId
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.id, action.conversationId),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
if (action.conversationId && !conversation) return false;
|
||||
const inferredResourceId = providerEffectThreadResourceId(
|
||||
endpoint.provider,
|
||||
payload.threadId,
|
||||
);
|
||||
const resource = await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
payload.resourceId || conversation?.resourceId
|
||||
? eq(
|
||||
chatEndpointResources.id,
|
||||
payload.resourceId ?? conversation!.resourceId!,
|
||||
)
|
||||
: inferredResourceId
|
||||
? eq(chatEndpointResources.providerResourceId, inferredResourceId)
|
||||
: sql`false`,
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const slackChannelId =
|
||||
endpoint.provider === "slack"
|
||||
? slackThreadChannelId(payload.threadId)
|
||||
: null;
|
||||
const isDirectMessage =
|
||||
conversation?.isDirectMessage === true ||
|
||||
resource?.type === "direct_message" ||
|
||||
Boolean(slackChannelId && /^D[A-Z0-9]+$/i.test(slackChannelId));
|
||||
const destinationAllowed = isDirectMessage
|
||||
? endpoint.allowDirectMessages
|
||||
: nonDirectDestinationAllowed(endpoint, resource);
|
||||
if (payload.authorizationMode === "safe_notice") return true;
|
||||
if (!destinationAllowed) return false;
|
||||
if (!action.principalId) return false;
|
||||
return (
|
||||
await lockCurrentPrincipalAuthorization(tx, endpoint, action.principalId)
|
||||
).allowed;
|
||||
}
|
||||
|
||||
async function cancelUnauthorizedProviderEffect(
|
||||
tx: DbTransaction,
|
||||
action: typeof chatActions.$inferSelect,
|
||||
attempt: number,
|
||||
): Promise<boolean> {
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [cancelled] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "provider_effect_no_longer_authorized",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!cancelled) return false;
|
||||
if (action.deliveryId) {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "filtered",
|
||||
processedAt: new Date(),
|
||||
nextAttemptAt: null,
|
||||
redactedError:
|
||||
"Provider reply was suppressed because current chat authorization changed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function processProviderEffect(
|
||||
actionId: string,
|
||||
liveTarget?: ProviderEffectTarget,
|
||||
): Promise<"processed" | "pending" | "failed" | "delivery_unknown"> {
|
||||
const initial = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, actionId),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!initial) return "failed";
|
||||
const initialEndpoint = await endpointRecord(initial.endpointId);
|
||||
if (!initialEndpoint) return "failed";
|
||||
|
||||
const processClaimedEffect = async () => {
|
||||
let action = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(eq(chatActions.id, actionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!action) return "failed";
|
||||
const payload = providerEffectPayload(action.payload);
|
||||
if (!payload) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: { code: "provider_effect_payload_invalid" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatActions.id, action.id));
|
||||
});
|
||||
return "failed";
|
||||
}
|
||||
if (action.status === "processed") return "processed";
|
||||
if (action.status === "delivery_unknown") return "delivery_unknown";
|
||||
if (action.status === "processing") {
|
||||
if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS))
|
||||
return "pending";
|
||||
await quarantineStaleProviderEffect(action);
|
||||
return "delivery_unknown";
|
||||
}
|
||||
if (action.status === "failed") {
|
||||
if (action.result?.retryable !== true) return "failed";
|
||||
const retryAt =
|
||||
typeof action.result.retryAt === "string"
|
||||
? new Date(action.result.retryAt)
|
||||
: null;
|
||||
if (retryAt && retryAt > new Date()) return "pending";
|
||||
} else if (action.status !== "received") {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const attempt =
|
||||
(typeof action.result?.attempts === "number"
|
||||
? action.result.attempts
|
||||
: 0) + 1;
|
||||
const record = await endpointRecord(action.endpointId);
|
||||
let providerEffectCompleted = false;
|
||||
try {
|
||||
if (!record) {
|
||||
throw Object.assign(
|
||||
new Error("Chat endpoint is unavailable for this provider effect"),
|
||||
{ code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" },
|
||||
);
|
||||
}
|
||||
return await withCredentialMutationLease(
|
||||
record.endpoint,
|
||||
async (credentialLease) => {
|
||||
const claim = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const currentAction = await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.kind, "provider_effect"),
|
||||
eq(chatActions.status, action!.status),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!currentAction) return null;
|
||||
const currentEndpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
currentAction.endpointId,
|
||||
{
|
||||
generation: payload.runtimeGeneration,
|
||||
credentialFingerprint: payload.credentialFingerprint,
|
||||
},
|
||||
["verifying", "active"],
|
||||
);
|
||||
const authorized = currentEndpoint
|
||||
? await lockCurrentProviderEffectAuthorization(
|
||||
tx,
|
||||
currentAction,
|
||||
currentEndpoint,
|
||||
payload,
|
||||
)
|
||||
: false;
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [transportClaim] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processing",
|
||||
result: { attempts: attempt },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, currentAction.id),
|
||||
eq(chatActions.status, currentAction.status),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!transportClaim) return null;
|
||||
if (!currentEndpoint || !authorized) {
|
||||
const cancelled = await cancelUnauthorizedProviderEffect(
|
||||
tx,
|
||||
transportClaim,
|
||||
attempt,
|
||||
);
|
||||
if (!cancelled) {
|
||||
throw new Error(
|
||||
"Provider effect ownership changed before authorization cancellation",
|
||||
);
|
||||
}
|
||||
return { outcome: "cancelled" as const };
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
return {
|
||||
action: transportClaim,
|
||||
endpoint: currentEndpoint,
|
||||
outcome: "claimed" as const,
|
||||
};
|
||||
});
|
||||
if (!claim) return "pending";
|
||||
if (claim.outcome === "cancelled") return "failed";
|
||||
action = claim.action;
|
||||
|
||||
// `processing` is the durable, single-owner transport claim and
|
||||
// authorization linearization point. Its transaction has committed
|
||||
// before provider I/O begins. The renewable credential lease keeps
|
||||
// runtime identity stable without holding database row locks.
|
||||
let sent: { id: string; threadId: string } | null = null;
|
||||
if (payload.effect === "telegram_callback_notice") {
|
||||
sent = await (
|
||||
await runtimeFor(claim.endpoint)
|
||||
).sendTelegramCallbackNotice(
|
||||
payload.telegramCallback!,
|
||||
payload.text,
|
||||
);
|
||||
} else {
|
||||
const target =
|
||||
liveTarget ??
|
||||
(await runtimeFor(claim.endpoint)).thread(payload.threadId);
|
||||
if (payload.effect === "ephemeral_message") {
|
||||
if (!target.postEphemeral) {
|
||||
if (!payload.fallbackText) {
|
||||
throw Object.assign(
|
||||
new Error("Provider does not support ephemeral messages"),
|
||||
{ code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" },
|
||||
);
|
||||
}
|
||||
sent = await target.post(payload.fallbackText);
|
||||
} else {
|
||||
try {
|
||||
sent = await target.postEphemeral(
|
||||
payload.userId!,
|
||||
payload.text,
|
||||
{ fallbackToDM: false },
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
!payload.fallbackText ||
|
||||
classifyChatPublicationError(error, attempt).kind !==
|
||||
"failed"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!sent && payload.fallbackText) {
|
||||
sent = await target.post(payload.fallbackText);
|
||||
}
|
||||
}
|
||||
} else if (claim.endpoint.provider === "imessage-photon") {
|
||||
const adapter = (await runtimeFor(claim.endpoint)).getProviderAdapter();
|
||||
if (!(adapter instanceof PhotonChatAdapter)) throw new Error("Photon runtime unavailable");
|
||||
const receipt = await adapter.publish(payload.threadId, `effect:${action.id}`, projectSafeChatPublicationText(payload.text), {
|
||||
assertCurrent: async () => { await credentialLease.assertOwned(); },
|
||||
});
|
||||
sent = { id: receipt.id, threadId: payload.threadId };
|
||||
} else {
|
||||
sent = await target.post(payload.text);
|
||||
}
|
||||
}
|
||||
if (!sent) {
|
||||
throw Object.assign(
|
||||
new Error("Provider did not accept the message"),
|
||||
{ code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" },
|
||||
);
|
||||
}
|
||||
providerEffectCompleted = true;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
// A duplicate webhook holds the endpoint while waiting for the
|
||||
// delivery's unique-index entry. Lock this endpoint first too,
|
||||
// before settling the action/conversation/delivery, so neither
|
||||
// transaction can wait on a row already held by the other.
|
||||
const currentEndpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
claim.endpoint.id,
|
||||
{
|
||||
generation: payload.runtimeGeneration,
|
||||
credentialFingerprint: payload.credentialFingerprint,
|
||||
},
|
||||
["verifying", "active"],
|
||||
);
|
||||
await notifyChatActionWork(tx, "provider_effect");
|
||||
const [completed] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
providerMessageId: sent.id,
|
||||
threadId: sent.threadId,
|
||||
...(payload.effect === "telegram_callback_notice"
|
||||
? {
|
||||
code:
|
||||
payload.telegramCallback?.chatType === "private"
|
||||
? "telegram_private_api_accepted"
|
||||
: "telegram_ephemeral_api_accepted",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!completed) {
|
||||
throw new Error(
|
||||
"Provider effect ownership changed before commit",
|
||||
);
|
||||
}
|
||||
if (payload.completeConversationId) {
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "completed", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.id, payload.completeConversationId),
|
||||
eq(chatConversations.endpointId, claim.endpoint.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (payload.settleDelivery && action!.deliveryId) {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const [settled] = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "processed",
|
||||
processedAt: new Date(),
|
||||
nextAttemptAt: null,
|
||||
redactedError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action!.deliveryId),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
if (!settled) {
|
||||
throw new Error(
|
||||
"Provider effect delivery ownership changed before commit",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (currentEndpoint) {
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({ lastEventAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return "processed";
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const disposition = providerEffectDisposition(
|
||||
error,
|
||||
attempt,
|
||||
providerEffectCompleted,
|
||||
);
|
||||
try {
|
||||
await finalizeProviderEffectFailure({
|
||||
action,
|
||||
endpoint: record?.endpoint ?? initialEndpoint.endpoint,
|
||||
payload,
|
||||
disposition,
|
||||
attempt,
|
||||
});
|
||||
} catch (finalizationError) {
|
||||
if (disposition.kind === "delivery_unknown") {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
"Provider effect is ambiguous and its durable quarantine failed",
|
||||
{ cause: finalizationError },
|
||||
),
|
||||
{ code: "CHAT_PROVIDER_EFFECT_AMBIGUOUS" },
|
||||
);
|
||||
}
|
||||
throw finalizationError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return processClaimedEffect();
|
||||
}
|
||||
|
||||
async function processPendingProviderEffects(limit = 25) {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(now.getTime() - PROVIDER_EFFECT_STALE_MS);
|
||||
const actions = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(dueChatAction("provider_effect", now.getTime()))
|
||||
.orderBy(asc(chatActions.createdAt))
|
||||
.limit(limit);
|
||||
for (const action of actions) {
|
||||
if (action.status === "processing" && action.updatedAt <= staleBefore) {
|
||||
await quarantineStaleProviderEffect(action);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await processProviderEffect(action.id);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: action.endpointId,
|
||||
actionId: action.id,
|
||||
error: redactError(error),
|
||||
},
|
||||
"chat provider effect reconciliation failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return actions.length;
|
||||
}
|
||||
return { stageProviderEffect, processProviderEffect, processPendingProviderEffects };
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "./runtime-types.js";
|
||||
|
||||
import {
|
||||
stableExternalPrincipalId,
|
||||
} from "./message-lifecycle.js";
|
||||
|
||||
import { notifyChatDeliveryWork } from "../chat-work-notifications.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
inArray,
|
||||
isNull,
|
||||
lt,
|
||||
lte,
|
||||
or,
|
||||
sql,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import {
|
||||
externalThreadIdentityCondition,
|
||||
} from "../chat-control-chronology.js";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpointResources,
|
||||
chatMessageLinks,
|
||||
chatPublications,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEventKind,
|
||||
ChatProvider,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
|
||||
import {
|
||||
type ChatSdkCallbackEvent,
|
||||
} from "../chat-sdk-runtime.js";
|
||||
|
||||
import type {
|
||||
Author,
|
||||
ReactionEvent,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
import { slackThreadChannelId } from "./slack/messages.js";
|
||||
|
||||
// Reactions can beat the transaction that persists a just-sent provider
|
||||
// message link. Keep that narrow gap durable, but never retain an unbound
|
||||
// reaction indefinitely or let it enter the ordinary inbound-message FIFO.
|
||||
const REACTION_LINK_RETRY_DELAY_MS = 250;
|
||||
const REACTION_LINK_MAX_DELAY_MS = 5_000;
|
||||
const REACTION_LINK_MAX_ATTEMPTS = 20;
|
||||
const REACTION_LINK_MAX_AGE_MS = 2 * 60_000;
|
||||
type DeliveryRow = typeof chatDeliveries.$inferSelect;
|
||||
type ConversationRow = typeof chatConversations.$inferSelect;
|
||||
|
||||
type InboundReactionsOptions = {
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>;
|
||||
runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise<EndpointRecord | null>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: { id: string } }>;
|
||||
durableExternalThreadIdentity: (threadId: string) => string;
|
||||
recordCurrentMicrosoftTeamsRoute: (endpoint: EndpointRow, context: InboundRuntimeContext | undefined, threadId: string, raw: unknown) => Promise<void>;
|
||||
lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null;
|
||||
deliveryReady: (delivery: DeliveryRow, now: Date) => boolean;
|
||||
redactError: (error: unknown) => string;
|
||||
nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean;
|
||||
reactionLinkPreflightBarrier?: () => Promise<void>;
|
||||
reactionReplayEndpointLockBarrier?: () => Promise<void>;
|
||||
reactionReplayConversationLockBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createInboundReactions(db: Db, options: InboundReactionsOptions) {
|
||||
const { lockCurrentPrincipalAuthorization, runtimeCallbackRecord, runtimeCallbackEndpoint, ensurePrincipal, durableExternalThreadIdentity, recordCurrentMicrosoftTeamsRoute, lifecycleRuntimeFence, deliveryReady, redactError, nonDirectDestinationAllowed } = options;
|
||||
|
||||
|
||||
function reactionConversationThreadCondition(
|
||||
provider: ChatProvider,
|
||||
threadId: string,
|
||||
): SQL | undefined {
|
||||
const exact = externalThreadIdentityCondition(
|
||||
sql<string>`${chatConversations.externalThreadId}`,
|
||||
threadId,
|
||||
);
|
||||
const slackDmChannel =
|
||||
provider === "slack" ? slackThreadChannelId(threadId) : null;
|
||||
return slackDmChannel && /^D[A-Z0-9-]*$/i.test(slackDmChannel)
|
||||
? or(
|
||||
exact,
|
||||
and(
|
||||
eq(chatConversations.isDirectMessage, true),
|
||||
eq(chatConversations.externalThreadId, `slack:${slackDmChannel}:`),
|
||||
),
|
||||
)
|
||||
: exact;
|
||||
}
|
||||
|
||||
async function reactionConversationForMessage(
|
||||
database: DbOrTransaction,
|
||||
endpoint: EndpointRow,
|
||||
threadId: string,
|
||||
providerMessageId: string,
|
||||
direction?: "outbound",
|
||||
): Promise<ConversationRow | null> {
|
||||
return database
|
||||
.select({ conversation: chatConversations })
|
||||
.from(chatMessageLinks)
|
||||
.innerJoin(
|
||||
chatConversations,
|
||||
and(
|
||||
eq(chatConversations.companyId, chatMessageLinks.companyId),
|
||||
eq(chatConversations.endpointId, chatMessageLinks.endpointId),
|
||||
eq(chatConversations.id, chatMessageLinks.conversationId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, endpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, endpoint.id),
|
||||
eq(chatMessageLinks.providerMessageId, providerMessageId),
|
||||
direction ? eq(chatMessageLinks.direction, direction) : undefined,
|
||||
reactionConversationThreadCondition(endpoint.provider, threadId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatConversations.sessionGeneration))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]?.conversation ?? null);
|
||||
}
|
||||
|
||||
async function lockReactionConversationForMessage(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
threadId: string,
|
||||
providerMessageId: string,
|
||||
direction?: "outbound",
|
||||
): Promise<ConversationRow | null> {
|
||||
const candidate = await reactionConversationForMessage(
|
||||
tx,
|
||||
endpoint,
|
||||
threadId,
|
||||
providerMessageId,
|
||||
direction,
|
||||
);
|
||||
if (!candidate) return null;
|
||||
const current = await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.id, candidate.id),
|
||||
),
|
||||
)
|
||||
// Publication settlement inserts the outbound message link before it
|
||||
// updates the endpoint. NO KEY UPDATE serializes state changes while
|
||||
// remaining compatible with that link's conversation FK KEY SHARE.
|
||||
.for("no key update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!current ||
|
||||
!(current.isDirectMessage
|
||||
? ["active", "waiting", "completed"].includes(current.state)
|
||||
: ["active", "waiting"].includes(current.state))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const linkStillExists = await tx
|
||||
.select({ id: chatMessageLinks.id })
|
||||
.from(chatMessageLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, endpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, endpoint.id),
|
||||
eq(chatMessageLinks.conversationId, current.id),
|
||||
eq(chatMessageLinks.providerMessageId, providerMessageId),
|
||||
direction ? eq(chatMessageLinks.direction, direction) : undefined,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
return linkStillExists ? current : null;
|
||||
}
|
||||
|
||||
async function streamingReactionConversation(
|
||||
database: DbOrTransaction,
|
||||
endpoint: EndpointRow,
|
||||
threadId: string,
|
||||
): Promise<ConversationRow | null> {
|
||||
const candidates = await database
|
||||
.selectDistinct({
|
||||
conversation: chatConversations,
|
||||
sessionGeneration: chatConversations.sessionGeneration,
|
||||
})
|
||||
.from(chatConversations)
|
||||
.innerJoin(
|
||||
chatPublications,
|
||||
and(
|
||||
eq(chatPublications.companyId, chatConversations.companyId),
|
||||
eq(chatPublications.endpointId, chatConversations.endpointId),
|
||||
eq(chatPublications.conversationId, chatConversations.id),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
or(
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
and(
|
||||
eq(chatConversations.isDirectMessage, true),
|
||||
eq(chatConversations.state, "completed"),
|
||||
),
|
||||
),
|
||||
reactionConversationThreadCondition(endpoint.provider, threadId),
|
||||
eq(chatPublications.state, "streaming"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatConversations.sessionGeneration))
|
||||
.limit(2);
|
||||
if (candidates.length !== 1) return null;
|
||||
return candidates[0]!.conversation;
|
||||
}
|
||||
|
||||
async function lockStreamingReactionConversation(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
threadId: string,
|
||||
): Promise<ConversationRow | null> {
|
||||
const candidate = await streamingReactionConversation(
|
||||
tx,
|
||||
endpoint,
|
||||
threadId,
|
||||
);
|
||||
if (!candidate) return null;
|
||||
const current = await tx
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.id, candidate.id),
|
||||
or(
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
and(
|
||||
eq(chatConversations.isDirectMessage, true),
|
||||
eq(chatConversations.state, "completed"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Keep the same lock strength/order as the exact-link path above so an
|
||||
// in-flight message-link insert cannot deadlock reaction admission.
|
||||
.for("no key update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!current) return null;
|
||||
const stillStreaming = await tx
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, endpoint.companyId),
|
||||
eq(chatPublications.endpointId, endpoint.id),
|
||||
eq(chatPublications.conversationId, current.id),
|
||||
eq(chatPublications.state, "streaming"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
return stillStreaming ? current : null;
|
||||
}
|
||||
|
||||
async function authorizeReactionConversation(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
conversation: ConversationRow,
|
||||
principalId: string,
|
||||
): Promise<boolean> {
|
||||
const resource =
|
||||
conversation.resourceId && !conversation.isDirectMessage
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.id, conversation.resourceId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const destinationAllowed = conversation.isDirectMessage
|
||||
? endpoint.allowDirectMessages
|
||||
: nonDirectDestinationAllowed(endpoint, resource);
|
||||
if (!destinationAllowed) return false;
|
||||
return (await lockCurrentPrincipalAuthorization(tx, endpoint, principalId))
|
||||
.allowed;
|
||||
}
|
||||
|
||||
async function handleReaction(
|
||||
event: ChatSdkCallbackEvent<ReactionEvent>,
|
||||
runtimeContext: RuntimeContext,
|
||||
) {
|
||||
const record = await runtimeCallbackRecord(
|
||||
event.endpointId,
|
||||
runtimeContext,
|
||||
["active"],
|
||||
);
|
||||
if (
|
||||
!record ||
|
||||
record.endpoint.status !== "active" ||
|
||||
record.endpoint.provider !== event.provider ||
|
||||
record.endpoint.capabilities.reactions !== true ||
|
||||
event.event.user.isMe ||
|
||||
event.event.user.isBot === true ||
|
||||
event.event.user.isSystem
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// A DM can have a newer task generation while this reaction still belongs
|
||||
// to an older linked message. Resolve its exact lineage before choosing a
|
||||
// generation; never attach it to the latest task merely sharing the chat.
|
||||
// A just-sent provider message can be visible before its publication/link
|
||||
// transaction commits. Only that exact thread's streaming publication is
|
||||
// enough evidence to stage an otherwise-unbound reaction for replay.
|
||||
let conversation = await reactionConversationForMessage(
|
||||
db,
|
||||
record.endpoint,
|
||||
event.event.threadId,
|
||||
event.event.messageId,
|
||||
);
|
||||
await options.reactionLinkPreflightBarrier?.();
|
||||
const plausibleStreamingConversation = conversation
|
||||
? null
|
||||
: await streamingReactionConversation(
|
||||
db,
|
||||
record.endpoint,
|
||||
event.event.threadId,
|
||||
);
|
||||
if (!conversation && !plausibleStreamingConversation) {
|
||||
// Settlement can commit between the first exact-link lookup and the
|
||||
// streaming-publication lookup. Recheck the durable link before treating
|
||||
// the callback as an arbitrary unknown message.
|
||||
conversation = await reactionConversationForMessage(
|
||||
db,
|
||||
record.endpoint,
|
||||
event.event.threadId,
|
||||
event.event.messageId,
|
||||
);
|
||||
if (!conversation) return;
|
||||
}
|
||||
|
||||
const principal = await ensurePrincipal(
|
||||
record.endpoint,
|
||||
event.event.user,
|
||||
event.event.raw,
|
||||
);
|
||||
let rawFingerprint: string;
|
||||
try {
|
||||
rawFingerprint = createHash("sha256")
|
||||
.update(JSON.stringify(event.event.raw ?? null))
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
} catch {
|
||||
rawFingerprint = "unavailable";
|
||||
}
|
||||
const eventKind: ChatEventKind = event.event.added
|
||||
? "reaction_added"
|
||||
: "reaction_removed";
|
||||
const providerEventId = [
|
||||
eventKind,
|
||||
durableExternalThreadIdentity(event.event.threadId),
|
||||
event.event.messageId,
|
||||
stableExternalPrincipalId(
|
||||
record.endpoint.provider,
|
||||
event.event.user,
|
||||
event.event.raw,
|
||||
),
|
||||
event.event.rawEmoji,
|
||||
rawFingerprint,
|
||||
].join(":");
|
||||
const normalizedEvent = {
|
||||
providerEventId,
|
||||
kind: eventKind,
|
||||
conversation: { externalThreadId: event.event.threadId },
|
||||
message: { providerMessageId: event.event.messageId },
|
||||
reaction: {
|
||||
emoji: event.event.emoji.name,
|
||||
rawEmoji: event.event.rawEmoji,
|
||||
added: event.event.added,
|
||||
},
|
||||
runtimeContext: {
|
||||
generation: runtimeContext.generation,
|
||||
credentialFingerprint: runtimeContext.credentialFingerprint,
|
||||
},
|
||||
};
|
||||
const admitted = await db.transaction(async (tx) => {
|
||||
const currentEndpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
event.endpointId,
|
||||
runtimeContext,
|
||||
["active"],
|
||||
);
|
||||
if (!currentEndpoint) return false;
|
||||
const linkedConversation = await lockReactionConversationForMessage(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
event.event.threadId,
|
||||
event.event.messageId,
|
||||
);
|
||||
const currentConversation =
|
||||
linkedConversation ??
|
||||
(await lockStreamingReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
event.event.threadId,
|
||||
));
|
||||
if (
|
||||
!currentConversation ||
|
||||
!(await authorizeReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
currentConversation,
|
||||
principal.principal.id,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date();
|
||||
if (linkedConversation) {
|
||||
const expiryCutoff = new Date(now.getTime() - REACTION_LINK_MAX_AGE_MS);
|
||||
const hasOutboundLink = await tx
|
||||
.select({ id: chatMessageLinks.id })
|
||||
.from(chatMessageLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, currentEndpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, currentEndpoint.id),
|
||||
eq(chatMessageLinks.conversationId, linkedConversation.id),
|
||||
eq(chatMessageLinks.providerMessageId, event.event.messageId),
|
||||
eq(chatMessageLinks.direction, "outbound"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const reconciled = hasOutboundLink
|
||||
? await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
conversationId: linkedConversation.id,
|
||||
state: "processed",
|
||||
attempts: sql`${chatDeliveries.attempts} + 1`,
|
||||
nextAttemptAt: null,
|
||||
processedAt: now,
|
||||
redactedError: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, currentEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
inArray(chatDeliveries.state, ["received", "retry"]),
|
||||
gt(chatDeliveries.receivedAt, expiryCutoff),
|
||||
lt(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS),
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`,
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id })
|
||||
: [];
|
||||
if (reconciled.length > 0) return true;
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const expired = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "filtered",
|
||||
nextAttemptAt: null,
|
||||
processedAt: now,
|
||||
redactedError:
|
||||
"Reaction target did not become linked before replay expiry",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, currentEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
inArray(chatDeliveries.state, ["received", "retry"]),
|
||||
or(
|
||||
lte(chatDeliveries.receivedAt, expiryCutoff),
|
||||
gte(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS),
|
||||
),
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`,
|
||||
sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
if (expired.length > 0) return false;
|
||||
}
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const inserted = await tx
|
||||
.insert(chatDeliveries)
|
||||
.values({
|
||||
companyId: currentEndpoint.companyId,
|
||||
endpointId: currentEndpoint.id,
|
||||
conversationId: linkedConversation?.id ?? null,
|
||||
principalId: principal.principal.id,
|
||||
providerEventId,
|
||||
deduplicationKey: createHash("sha256")
|
||||
.update(providerEventId)
|
||||
.digest("hex"),
|
||||
eventKind,
|
||||
normalizedEvent,
|
||||
state: linkedConversation ? "processed" : "received",
|
||||
attempts: linkedConversation ? 1 : 0,
|
||||
nextAttemptAt: linkedConversation
|
||||
? null
|
||||
: new Date(now.getTime() + REACTION_LINK_RETRY_DELAY_MS),
|
||||
processedAt: linkedConversation ? now : null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: chatDeliveries.id });
|
||||
if (inserted.length > 0) return true;
|
||||
return tx
|
||||
.select({ id: chatDeliveries.id })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, currentEndpoint.id),
|
||||
eq(chatDeliveries.providerEventId, providerEventId),
|
||||
inArray(chatDeliveries.state, ["received", "retry", "processed"]),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
});
|
||||
if (admitted) {
|
||||
await recordCurrentMicrosoftTeamsRoute(
|
||||
record.endpoint,
|
||||
runtimeContext,
|
||||
event.event.threadId,
|
||||
event.event.raw,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedPendingReaction(delivery: DeliveryRow): {
|
||||
added: boolean;
|
||||
emoji: string;
|
||||
messageId: string;
|
||||
rawEmoji: string;
|
||||
runtimeContext: LifecycleRuntimeFence;
|
||||
threadId: string;
|
||||
} | null {
|
||||
if (
|
||||
delivery.eventKind !== "reaction_added" &&
|
||||
delivery.eventKind !== "reaction_removed"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalized = delivery.normalizedEvent;
|
||||
if (
|
||||
!normalized ||
|
||||
typeof normalized !== "object" ||
|
||||
Array.isArray(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const value = normalized as Record<string, unknown>;
|
||||
const conversation = value.conversation;
|
||||
const message = value.message;
|
||||
const reaction = value.reaction;
|
||||
const runtimeContext = lifecycleRuntimeFence(delivery);
|
||||
if (
|
||||
!conversation ||
|
||||
typeof conversation !== "object" ||
|
||||
Array.isArray(conversation) ||
|
||||
!message ||
|
||||
typeof message !== "object" ||
|
||||
Array.isArray(message) ||
|
||||
!reaction ||
|
||||
typeof reaction !== "object" ||
|
||||
Array.isArray(reaction) ||
|
||||
!runtimeContext
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const threadId = (conversation as Record<string, unknown>).externalThreadId;
|
||||
const messageId = (message as Record<string, unknown>).providerMessageId;
|
||||
const reactionRecord = reaction as Record<string, unknown>;
|
||||
const added = reactionRecord.added;
|
||||
const emoji = reactionRecord.emoji;
|
||||
const rawEmoji = reactionRecord.rawEmoji;
|
||||
return typeof threadId === "string" &&
|
||||
threadId.length > 0 &&
|
||||
typeof messageId === "string" &&
|
||||
messageId.length > 0 &&
|
||||
typeof added === "boolean" &&
|
||||
added === (delivery.eventKind === "reaction_added") &&
|
||||
typeof emoji === "string" &&
|
||||
emoji.length > 0 &&
|
||||
typeof rawEmoji === "string" &&
|
||||
rawEmoji.length > 0
|
||||
? { added, emoji, messageId, rawEmoji, runtimeContext, threadId }
|
||||
: null;
|
||||
}
|
||||
|
||||
async function processPendingReactionDelivery(
|
||||
candidate: DeliveryRow,
|
||||
): Promise<void> {
|
||||
const reaction = normalizedPendingReaction(candidate);
|
||||
if (!reaction || !candidate.principalId) {
|
||||
const now = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
return tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "failed",
|
||||
nextAttemptAt: null,
|
||||
processedAt: now,
|
||||
redactedError: "Normalized reaction delivery is incomplete",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, candidate.id),
|
||||
inArray(chatDeliveries.state, ["received", "retry"]),
|
||||
),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const principalId = candidate.principalId;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const currentEndpoint = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
candidate.endpointId,
|
||||
reaction.runtimeContext,
|
||||
["active"],
|
||||
);
|
||||
let conversation: ConversationRow | null = null;
|
||||
let disposition:
|
||||
| { kind: "processed"; conversationId: string }
|
||||
| { kind: "retry" }
|
||||
| { kind: "filtered"; reason: string };
|
||||
|
||||
if (!currentEndpoint) {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction callback belonged to an unavailable runtime",
|
||||
};
|
||||
} else {
|
||||
await options.reactionReplayEndpointLockBarrier?.();
|
||||
conversation = await lockReactionConversationForMessage(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
reaction.threadId,
|
||||
reaction.messageId,
|
||||
"outbound",
|
||||
);
|
||||
if (conversation) {
|
||||
await options.reactionReplayConversationLockBarrier?.();
|
||||
}
|
||||
if (conversation) {
|
||||
disposition = (await authorizeReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
conversation,
|
||||
principalId,
|
||||
))
|
||||
? { kind: "processed", conversationId: conversation.id }
|
||||
: {
|
||||
kind: "filtered",
|
||||
reason:
|
||||
"Reaction destination or principal is no longer authorized",
|
||||
};
|
||||
} else {
|
||||
conversation = await lockStreamingReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
reaction.threadId,
|
||||
);
|
||||
if (conversation) {
|
||||
await options.reactionReplayConversationLockBarrier?.();
|
||||
}
|
||||
if (!conversation) {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction target was not linked to a sent message",
|
||||
};
|
||||
} else {
|
||||
disposition = (await authorizeReactionConversation(
|
||||
tx,
|
||||
currentEndpoint,
|
||||
conversation,
|
||||
principalId,
|
||||
))
|
||||
? { kind: "retry" }
|
||||
: {
|
||||
kind: "filtered",
|
||||
reason:
|
||||
"Reaction destination or principal is no longer authorized",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoint, conversation, destination and identity locks are acquired
|
||||
// before the delivery row so pause/revoke and replay use one lock order.
|
||||
const locked = await tx
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, candidate.id),
|
||||
inArray(chatDeliveries.state, ["received", "retry"]),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
// Authorization locks may wait behind a concurrent lifecycle mutation.
|
||||
// Sample the expiry clock only after this delivery is exclusively held.
|
||||
const decisionAt = new Date();
|
||||
if (!locked || !deliveryReady(locked, decisionAt)) return;
|
||||
const lockedReaction = normalizedPendingReaction(locked);
|
||||
if (
|
||||
!lockedReaction ||
|
||||
lockedReaction.runtimeContext.generation !==
|
||||
reaction.runtimeContext.generation ||
|
||||
lockedReaction.runtimeContext.credentialFingerprint !==
|
||||
reaction.runtimeContext.credentialFingerprint
|
||||
) {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction delivery identity changed before replay",
|
||||
};
|
||||
}
|
||||
|
||||
const nextAttempts = locked.attempts + 1;
|
||||
const expired =
|
||||
decisionAt.getTime() - locked.receivedAt.getTime() >=
|
||||
REACTION_LINK_MAX_AGE_MS;
|
||||
if (expired || locked.attempts >= REACTION_LINK_MAX_ATTEMPTS) {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction target did not become linked before replay expiry",
|
||||
};
|
||||
}
|
||||
if (disposition.kind === "retry" && !expired) {
|
||||
if (nextAttempts < REACTION_LINK_MAX_ATTEMPTS) {
|
||||
const delay = Math.min(
|
||||
REACTION_LINK_RETRY_DELAY_MS *
|
||||
2 ** Math.min(Math.max(locked.attempts, 0), 5),
|
||||
REACTION_LINK_MAX_DELAY_MS,
|
||||
);
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "retry",
|
||||
attempts: nextAttempts,
|
||||
nextAttemptAt: new Date(decisionAt.getTime() + delay),
|
||||
redactedError: "Waiting for the sent message link",
|
||||
updatedAt: decisionAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, locked.id),
|
||||
eq(chatDeliveries.state, locked.state),
|
||||
eq(chatDeliveries.attempts, locked.attempts),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction target did not become linked before retry expiry",
|
||||
};
|
||||
} else if (disposition.kind === "retry") {
|
||||
disposition = {
|
||||
kind: "filtered",
|
||||
reason: "Reaction target did not become linked before age expiry",
|
||||
};
|
||||
}
|
||||
|
||||
const processedAt = new Date();
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set(
|
||||
disposition.kind === "processed"
|
||||
? {
|
||||
conversationId: disposition.conversationId,
|
||||
state: "processed",
|
||||
attempts: nextAttempts,
|
||||
nextAttemptAt: null,
|
||||
processedAt,
|
||||
redactedError: null,
|
||||
updatedAt: processedAt,
|
||||
}
|
||||
: {
|
||||
state: "filtered",
|
||||
attempts: nextAttempts,
|
||||
nextAttemptAt: null,
|
||||
processedAt,
|
||||
redactedError: disposition.reason,
|
||||
updatedAt: processedAt,
|
||||
},
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, locked.id),
|
||||
eq(chatDeliveries.state, locked.state),
|
||||
eq(chatDeliveries.attempts, locked.attempts),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function processPendingReactionDeliveries(
|
||||
limit: number,
|
||||
onlyDeliveryId?: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`,
|
||||
onlyDeliveryId ? eq(chatDeliveries.id, onlyDeliveryId) : undefined,
|
||||
inArray(chatDeliveries.eventKind, [
|
||||
"reaction_added",
|
||||
"reaction_removed",
|
||||
]),
|
||||
or(
|
||||
and(
|
||||
eq(chatDeliveries.state, "received"),
|
||||
or(
|
||||
isNull(chatDeliveries.nextAttemptAt),
|
||||
lte(chatDeliveries.nextAttemptAt, now),
|
||||
),
|
||||
),
|
||||
and(
|
||||
eq(chatDeliveries.state, "retry"),
|
||||
or(
|
||||
isNull(chatDeliveries.nextAttemptAt),
|
||||
lte(chatDeliveries.nextAttemptAt, now),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatDeliveries.receivedAt), asc(chatDeliveries.id))
|
||||
.limit(limit);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await processPendingReactionDelivery(candidate);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: candidate.endpointId,
|
||||
deliveryId: candidate.id,
|
||||
error: redactError(error),
|
||||
},
|
||||
"chat reaction-link reconciliation failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return candidates.length;
|
||||
}
|
||||
return { handleReaction, processPendingReactionDeliveries };
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import type { ChatRuntime } from "../chat-runtime.js";
|
||||
import type { EndpointRow, RuntimeContext, LifecycleRuntimeFence } from "./runtime-types.js";
|
||||
|
||||
import { dueChatAction, PROVIDER_EFFECT_STALE_MS } from "../chat-action-work.js";
|
||||
|
||||
import { notifyChatActionWork } from "../chat-work-notifications.js";
|
||||
|
||||
import type { ChatEndpointRuntime } from "../voice/voice-runtime.js";
|
||||
|
||||
import {
|
||||
receiptReactionPayload,
|
||||
type ReceiptReactionPayload,
|
||||
} from "../chat-receipt-reactions.js";
|
||||
|
||||
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
like,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatDeliveries,
|
||||
chatEndpoints,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEndpointSetupState,
|
||||
ChatProvider,
|
||||
ToolCredentialSecretRef,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
parseGitHubReceiptIdentity,
|
||||
type GitHubReceiptIdentity,
|
||||
} from "../chat-github-receipt-reactions.js";
|
||||
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
|
||||
import { classifyChatPublicationError } from "../chat-publication-errors.js";
|
||||
|
||||
import type {
|
||||
Message,
|
||||
Thread,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
import { isSlackReceiptReactionAlreadyApplied } from "./slack/messages.js";
|
||||
|
||||
type ReceiptReactionsOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
fetchImpl: typeof fetch;
|
||||
runtime: Pick<ChatRuntime, "get">;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
|
||||
redactError: (error: unknown) => string;
|
||||
MAX_ERROR_TEXT: number;
|
||||
receiptReactionTransportBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createReceiptReactions(db: Db, options: ReceiptReactionsOptions) {
|
||||
const { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, runtimeFor, fetchImpl, runtime, runtimeGeneration, credentialFingerprint, redactError, MAX_ERROR_TEXT } = options;
|
||||
|
||||
|
||||
async function addReceiptReaction(input: {
|
||||
deliveryId: string;
|
||||
endpoint: EndpointRow;
|
||||
message: Message;
|
||||
runtimeContext?: LifecycleRuntimeFence;
|
||||
thread: Thread;
|
||||
}): Promise<void> {
|
||||
// GitHub acknowledges through one editable response comment. Continue to
|
||||
// drain legacy eyes removals, but never create new eyes for these requests.
|
||||
if (input.endpoint.provider === "github") return;
|
||||
const record = await endpointRecord(input.endpoint.id);
|
||||
if (!record) return;
|
||||
// A live SDK object remains bound to the credentials that authenticated
|
||||
// its callback. Preserve that original fence rather than sampling the
|
||||
// endpoint again after a rotation and accidentally blessing an obsolete
|
||||
// adapter with the new generation.
|
||||
const context = input.runtimeContext ?? runtimeContextForRecord(record);
|
||||
const [inserted] = await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
return tx
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: input.endpoint.companyId,
|
||||
endpointId: input.endpoint.id,
|
||||
deliveryId: input.deliveryId,
|
||||
kind: "receipt_reaction",
|
||||
providerActionId: `receipt_reaction:${input.deliveryId}`,
|
||||
payload: {
|
||||
version: 1,
|
||||
operation: "add",
|
||||
threadId: input.thread.id,
|
||||
messageId: input.message.id,
|
||||
reaction: "eyes",
|
||||
runtimeGeneration: context.generation,
|
||||
credentialFingerprint: context.credentialFingerprint,
|
||||
} satisfies ReceiptReactionPayload,
|
||||
status: "received",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: chatActions.id });
|
||||
});
|
||||
const actionId =
|
||||
inserted?.id ??
|
||||
(await db
|
||||
.select({ id: chatActions.id })
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, input.endpoint.id),
|
||||
eq(
|
||||
chatActions.providerActionId,
|
||||
`receipt_reaction:${input.deliveryId}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.id ?? null));
|
||||
if (!actionId) return;
|
||||
await options.receiptReactionTransportBarrier?.();
|
||||
await processReceiptReaction(actionId);
|
||||
}
|
||||
|
||||
async function processReceiptReaction(actionId: string): Promise<void> {
|
||||
const initialAction = await db
|
||||
.select({ endpointId: chatActions.endpointId })
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, actionId),
|
||||
eq(chatActions.kind, "receipt_reaction"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!initialAction) return;
|
||||
const initialEndpoint = await endpointRecord(initialAction.endpointId);
|
||||
if (!initialEndpoint) return;
|
||||
// Receipt reactions are GitHub repository mutations too. Serialize their
|
||||
// final runtime-fence check and provider call with reconnect, secret
|
||||
// rotation, pause, and removal so credentials cannot become obsolete
|
||||
// between authorization and transport.
|
||||
await withCredentialMutationLease(initialEndpoint.endpoint, (lease) =>
|
||||
processReceiptReactionWithCredentialLease(actionId, lease),
|
||||
);
|
||||
}
|
||||
|
||||
async function processReceiptReactionWithCredentialLease(
|
||||
actionId: string,
|
||||
credentialLease: CredentialMutationLeaseGuard,
|
||||
): Promise<void> {
|
||||
let action = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, actionId),
|
||||
eq(chatActions.kind, "receipt_reaction"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!action || ["processed", "cancelled"].includes(action.status)) return;
|
||||
const payload = receiptReactionPayload(action.payload);
|
||||
if (!payload) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: {
|
||||
code: "receipt_reaction_payload_invalid",
|
||||
retryable: false,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatActions.id, action.id));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (payload.operation === "add" && action.deliveryId) {
|
||||
const terminalRemoval = await db
|
||||
.select({ id: chatActions.id })
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, action.endpointId),
|
||||
eq(
|
||||
chatActions.providerActionId,
|
||||
`receipt_reaction_remove:${action.deliveryId}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (terminalRemoval) {
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
...(typeof action!.result?.attempts === "number"
|
||||
? { attempts: action!.result.attempts }
|
||||
: {}),
|
||||
code: "receipt_reaction_superseded_by_terminal_publication",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, action!.status),
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (action.status === "processing") {
|
||||
if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS))
|
||||
return;
|
||||
// Adding or removing the same reaction is idempotent. A worker
|
||||
// interruption can be retried safely without an ambiguous
|
||||
// duplicate-message boundary.
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
return tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: {
|
||||
...(action.result ?? {}),
|
||||
retryable: true,
|
||||
retryAt: new Date().toISOString(),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
eq(chatActions.updatedAt, action.updatedAt),
|
||||
),
|
||||
);
|
||||
});
|
||||
action = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(eq(chatActions.id, action.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!action) return;
|
||||
}
|
||||
if (action.status === "failed") {
|
||||
if (action.result?.retryable !== true) return;
|
||||
const retryAt =
|
||||
typeof action.result.retryAt === "string"
|
||||
? new Date(action.result.retryAt)
|
||||
: null;
|
||||
if (retryAt && retryAt > new Date()) return;
|
||||
} else if (action.status !== "received") {
|
||||
return;
|
||||
}
|
||||
const attempt =
|
||||
(typeof action.result?.attempts === "number" ? action.result.attempts : 0) +
|
||||
1;
|
||||
const retainedGitHubReceipt = parseGitHubReceiptIdentity(
|
||||
action.result?.githubReceipt,
|
||||
);
|
||||
const [claimed] = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
const rows = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processing",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
...(retainedGitHubReceipt
|
||||
? { githubReceipt: retainedGitHubReceipt }
|
||||
: {}),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, action!.status),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
await credentialLease.assertOwned(tx);
|
||||
return rows;
|
||||
});
|
||||
if (!claimed) return;
|
||||
|
||||
let reactionProvider: ChatProvider | null = null;
|
||||
let githubReceipt: GitHubReceiptIdentity | null = retainedGitHubReceipt;
|
||||
try {
|
||||
const record = await endpointRecord(action.endpointId);
|
||||
if (
|
||||
!record ||
|
||||
!["verifying", "active"].includes(record.endpoint.status) ||
|
||||
runtimeGeneration(record.endpoint.setup) !== payload.runtimeGeneration ||
|
||||
credentialFingerprint(record.credentialSecretRefs) !==
|
||||
payload.credentialFingerprint
|
||||
) {
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "receipt_reaction_runtime_superseded",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
reactionProvider = record.endpoint.provider;
|
||||
// Resolve the adapter only after acquiring the credential lease and
|
||||
// proving the action's durable generation/fingerprint is still current.
|
||||
// Never reuse the live callback's adapter: it may hold an installation
|
||||
// token minted by a superseded private key.
|
||||
const endpointRuntime = await runtimeFor(record.endpoint);
|
||||
const adapter = endpointRuntime.thread(payload.threadId).adapter;
|
||||
await credentialLease.assertOwned();
|
||||
if (record.endpoint.provider === "slack") {
|
||||
await endpointRuntime.applySlackReceiptReaction(payload, fetchImpl);
|
||||
} else if (record.endpoint.provider === "github") {
|
||||
const assertCurrent = async () => {
|
||||
await credentialLease.assertOwned();
|
||||
const [current] = await db
|
||||
.select({ endpoint: chatEndpoints, connection: toolConnections })
|
||||
.from(chatEndpoints)
|
||||
.innerJoin(
|
||||
toolConnections,
|
||||
and(
|
||||
eq(toolConnections.id, chatEndpoints.connectionId),
|
||||
eq(toolConnections.companyId, chatEndpoints.companyId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.id, action!.endpointId),
|
||||
eq(chatEndpoints.companyId, action!.companyId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (
|
||||
!current ||
|
||||
!["active", "verifying"].includes(current.endpoint.status) ||
|
||||
!current.connection.enabled ||
|
||||
current.connection.status !== "active" ||
|
||||
runtimeGeneration(current.endpoint.setup) !==
|
||||
payload.runtimeGeneration ||
|
||||
credentialFingerprint(current.connection.credentialSecretRefs) !==
|
||||
payload.credentialFingerprint ||
|
||||
runtime.get(action!.endpointId) !== endpointRuntime
|
||||
)
|
||||
throw new Error("GitHub receipt authority is no longer current");
|
||||
};
|
||||
const applied = await endpointRuntime.applyGitHubReceiptReaction(
|
||||
{ ...payload, ...(githubReceipt ? { githubReceipt } : {}) },
|
||||
assertCurrent,
|
||||
fetchImpl,
|
||||
);
|
||||
githubReceipt = parseGitHubReceiptIdentity(applied);
|
||||
if (
|
||||
!githubReceipt ||
|
||||
(payload.operation === "add" && !githubReceipt.reactionId)
|
||||
)
|
||||
throw new Error("GitHub receipt identity was not confirmed");
|
||||
} else if (payload.operation === "remove") {
|
||||
await adapter.removeReaction(
|
||||
payload.threadId,
|
||||
payload.messageId,
|
||||
payload.reaction,
|
||||
);
|
||||
} else {
|
||||
await adapter.addReaction(
|
||||
payload.threadId,
|
||||
payload.messageId,
|
||||
payload.reaction,
|
||||
);
|
||||
}
|
||||
// The provider may already have accepted the idempotent reaction. Fence
|
||||
// the local success receipt so a reclaimed lease cannot report success
|
||||
// for an obsolete runtime.
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
const processed = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
...(githubReceipt ? { githubReceipt } : {}),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!processed.length) {
|
||||
throw new Error(
|
||||
"Receipt-reaction ownership changed before success settlement",
|
||||
);
|
||||
}
|
||||
if (action.deliveryId) {
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({ redactedError: null, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
like(chatDeliveries.redactedError, "Receipt reaction %"),
|
||||
),
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
payload.operation === "add" &&
|
||||
reactionProvider === "slack" &&
|
||||
isSlackReceiptReactionAlreadyApplied(error)
|
||||
) {
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
const processed = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "processed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
code: "receipt_reaction_already_applied",
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action!.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!processed.length) {
|
||||
throw new Error(
|
||||
"Receipt-reaction ownership changed before idempotent settlement",
|
||||
);
|
||||
}
|
||||
if (action.deliveryId) {
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({ redactedError: null, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
like(chatDeliveries.redactedError, "Receipt reaction %"),
|
||||
),
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const disposition = classifyChatPublicationError(error, attempt);
|
||||
const retryable =
|
||||
disposition.kind === "retry" ||
|
||||
(disposition.kind === "delivery_unknown" && attempt < 3);
|
||||
const retryAfterMs =
|
||||
disposition.kind === "retry"
|
||||
? disposition.retryAfterMs
|
||||
: 100 * 2 ** Math.max(0, attempt - 1);
|
||||
const operationLabel =
|
||||
payload.operation === "remove" ? " reaction removal" : " reaction";
|
||||
const diagnostic =
|
||||
`Receipt${operationLabel} ${retryable ? "deferred" : "failed"} after ${attempt} attempt${attempt === 1 ? "" : "s"} (${disposition.kind}): ${redactError(error)}`.slice(
|
||||
0,
|
||||
MAX_ERROR_TEXT,
|
||||
);
|
||||
const retryAt = retryable
|
||||
? new Date(Date.now() + retryAfterMs).toISOString()
|
||||
: null;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await notifyChatActionWork(tx, "receipt_reaction");
|
||||
const [failed] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: {
|
||||
attempts: attempt,
|
||||
...(githubReceipt ? { githubReceipt } : {}),
|
||||
code:
|
||||
payload.operation === "remove"
|
||||
? `receipt_reaction_removal_${disposition.kind}`
|
||||
: `receipt_reaction_${disposition.kind}`,
|
||||
redactedError: diagnostic,
|
||||
retryable,
|
||||
retryAt,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "processing"),
|
||||
sql`(${chatActions.result}->>'attempts')::int = ${attempt}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (failed && action.deliveryId) {
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({ redactedError: diagnostic, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, action.deliveryId),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
),
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: action.endpointId,
|
||||
deliveryId: action.deliveryId,
|
||||
attempts: attempt,
|
||||
disposition: disposition.kind,
|
||||
operation: payload.operation,
|
||||
retryAt,
|
||||
},
|
||||
"external chat receipt reaction deferred",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function processPendingReceiptReactions(
|
||||
limit = 25,
|
||||
onlyActionId?: string,
|
||||
) {
|
||||
const actions = await db.select({ id: chatActions.id }).from(chatActions)
|
||||
.where(and(dueChatAction("receipt_reaction"), onlyActionId ? eq(chatActions.id, onlyActionId) : undefined))
|
||||
.orderBy(asc(chatActions.createdAt)).limit(limit);
|
||||
for (const action of actions) await processReceiptReaction(action.id);
|
||||
return actions.length;
|
||||
}
|
||||
return { addReceiptReaction, processReceiptReaction, processPendingReceiptReactions };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Db, chatEndpoints } from "@paperclipai/db";
|
||||
import type { ToolCredentialSecretRef } from "@paperclipai/shared";
|
||||
import type { ToolCredentialSecretRef, ChatEndpointSetupState, ChatEndpointCallbackSurfaces } from "@paperclipai/shared";
|
||||
import type { ChatEndpointRuntime } from "../voice/voice-runtime.js";
|
||||
|
||||
export type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
@@ -36,3 +36,17 @@ export type EndpointRecord = {
|
||||
applicationId: string;
|
||||
credentialSecretRefs: ToolCredentialSecretRef[];
|
||||
};
|
||||
|
||||
export type SlackCallbackSurface = keyof ChatEndpointCallbackSurfaces;
|
||||
|
||||
export type SlackCallbackObservation = { url: string; observedAt: string };
|
||||
|
||||
export type InternalSetupState = ChatEndpointSetupState & {
|
||||
photonIntakeAfter?: string;
|
||||
runtimeGeneration?: number;
|
||||
/** Binds pre-install URL verification to the signing secret later configured. */
|
||||
slackVerificationSigningFingerprint?: string;
|
||||
slackCallbackSurfaces?: Partial<
|
||||
Record<SlackCallbackSurface, SlackCallbackObservation>
|
||||
>;
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import type { EndpointRow, DbOrTransaction } from "./runtime-types.js";
|
||||
|
||||
import { notifyChatEndpointWork } from "../chat-work-notifications.js";
|
||||
|
||||
import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js";
|
||||
|
||||
import { githubChatManagementService } from "../chat-github-management.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpoints,
|
||||
chatIdentityLinks,
|
||||
chatMessageLinks,
|
||||
chatPublications,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEndpoint,
|
||||
ChatEndpointSetupState,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
conflict,
|
||||
forbidden,
|
||||
notFound,
|
||||
} from "../../errors.js";
|
||||
|
||||
import {
|
||||
logActivity,
|
||||
} from "../activity-log.js";
|
||||
|
||||
import {
|
||||
githubRunReplyState,
|
||||
} from "../chat-run-publications.js";
|
||||
|
||||
import type { EndpointRecord } from "./runtime-types.js";
|
||||
|
||||
type SetupProbesOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
fetchImpl: typeof fetch;
|
||||
findAuthorizedIdentityLink: (endpoint: EndpointRow, userId: string) => Promise<{ id: string } | null>;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>;
|
||||
get: (endpointId: string) => Promise<ChatEndpoint>;
|
||||
setupTestActivationBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createSetupProbes(db: Db, options: SetupProbesOptions) {
|
||||
const { endpointRecord, withCredentialMutationLease, fetchImpl, findAuthorizedIdentityLink, runtimeGeneration, lockCurrentPrincipalAuthorization, get } = options;
|
||||
|
||||
|
||||
async function setupTestStatus(endpointId: string, userId: string) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
const startedAt = record.endpoint.setup.testStartedAt;
|
||||
if (!startedAt) return { messageReceivedAt: null };
|
||||
const links = await db.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and(
|
||||
eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId),
|
||||
eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"),
|
||||
));
|
||||
if (!links.length) return { messageReceivedAt: null };
|
||||
const principalIds = links.map((link) => link.principalId);
|
||||
const [delivery, command] = await Promise.all([
|
||||
db.select({ at: chatDeliveries.createdAt }).from(chatDeliveries).where(and(
|
||||
eq(chatDeliveries.companyId, record.endpoint.companyId), eq(chatDeliveries.endpointId, endpointId),
|
||||
inArray(chatDeliveries.principalId, principalIds), gte(chatDeliveries.createdAt, new Date(startedAt)),
|
||||
inArray(chatDeliveries.eventKind, ["message", "mention"]),
|
||||
)).orderBy(asc(chatDeliveries.createdAt)).limit(1).then((rows) => rows[0]),
|
||||
db.select({ at: chatActions.createdAt }).from(chatActions).where(and(
|
||||
eq(chatActions.companyId, record.endpoint.companyId), eq(chatActions.endpointId, endpointId),
|
||||
inArray(chatActions.principalId, principalIds), eq(chatActions.kind, "slash_task_start"), gte(chatActions.createdAt, new Date(startedAt)),
|
||||
)).orderBy(asc(chatActions.createdAt)).limit(1).then((rows) => rows[0]),
|
||||
]);
|
||||
const at = [delivery?.at, command?.at].filter((value): value is Date => Boolean(value)).sort((a, b) => a.getTime() - b.getTime())[0];
|
||||
return { messageReceivedAt: at?.toISOString() ?? null };
|
||||
}
|
||||
|
||||
async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) {
|
||||
const initial = await endpointRecord(endpointId);
|
||||
if (!initial) throw notFound("Chat endpoint not found");
|
||||
return withCredentialMutationLease(
|
||||
initial.endpoint,
|
||||
async (credentialLease) => {
|
||||
// Reload only after owning the same lane as reconnect, pause, removal,
|
||||
// and credential rotation. Qualification and activation must describe
|
||||
// one exact provider runtime, never a setup snapshot that a concurrent
|
||||
// reconnect has already superseded.
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
const endpoint = record.endpoint;
|
||||
if (endpoint.status !== "verifying" || endpoint.setup.step !== "test") {
|
||||
throw conflict("This connection is not waiting for a setup test", {
|
||||
code: "chat_endpoint_not_testing",
|
||||
});
|
||||
}
|
||||
if (endpoint.provider === "github" && endpoint.setup.github) {
|
||||
const verified = await githubChatManagementService(db, fetchImpl).verification(endpoint.id);
|
||||
if (!verified.ready && !(finishOptions?.optionalSlackTestForUser && verified.connectionReady)) throw conflict("Finish verifying the App, repositories, and assigned agent's tools before activating this bot", { checks: verified.checks });
|
||||
}
|
||||
const testStartedAtValue = endpoint.setup.testStartedAt;
|
||||
const testStartedAt = testStartedAtValue
|
||||
? new Date(testStartedAtValue)
|
||||
: null;
|
||||
const optionalSlackTest = Boolean(finishOptions?.optionalSlackTestForUser);
|
||||
if (optionalSlackTest) {
|
||||
if (!["slack", "github"].includes(endpoint.provider) || !endpoint.setup.webhookVerifiedAt || !endpoint.providerAccountId) {
|
||||
throw conflict("Verify the connection before finishing setup");
|
||||
}
|
||||
const linked = await findAuthorizedIdentityLink(endpoint, finishOptions!.optionalSlackTestForUser);
|
||||
if (!linked) throw forbidden("Connect your account before finishing setup");
|
||||
}
|
||||
if (!optionalSlackTest) {
|
||||
if (
|
||||
!endpoint.lastEventAt ||
|
||||
!testStartedAtValue ||
|
||||
!testStartedAt ||
|
||||
Number.isNaN(testStartedAt.getTime()) ||
|
||||
endpoint.lastEventAt < testStartedAt
|
||||
) {
|
||||
throw conflict(
|
||||
"Send the test message in the provider before completing setup",
|
||||
{
|
||||
code: "chat_test_message_missing",
|
||||
},
|
||||
);
|
||||
}
|
||||
const requiredTrigger =
|
||||
["telegram", "imessage-photon"].includes(endpoint.provider)
|
||||
? "direct_message"
|
||||
: "subscribed_message";
|
||||
const qualifyingDelivery = await db
|
||||
.select({
|
||||
id: chatDeliveries.id,
|
||||
conversationId: chatDeliveries.conversationId,
|
||||
processedAt: chatDeliveries.processedAt,
|
||||
})
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.companyId, endpoint.companyId),
|
||||
eq(chatDeliveries.endpointId, endpoint.id),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
gte(chatDeliveries.processedAt, testStartedAt),
|
||||
sql`${chatDeliveries.normalizedEvent}->>'trigger' = ${requiredTrigger}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatDeliveries.processedAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
!qualifyingDelivery?.conversationId ||
|
||||
!qualifyingDelivery.processedAt
|
||||
) {
|
||||
throw conflict(
|
||||
["telegram", "imessage-photon"].includes(endpoint.provider)
|
||||
? "Send the test direct message before completing setup"
|
||||
: "Reply once without mentioning the agent before completing setup",
|
||||
{ code: "chat_test_follow_up_missing" },
|
||||
);
|
||||
}
|
||||
const finalPublication = await db
|
||||
.select({
|
||||
commentId: chatPublications.commentId,
|
||||
payload: chatPublications.payload,
|
||||
})
|
||||
.from(chatPublications)
|
||||
.innerJoin(
|
||||
issueComments,
|
||||
and(
|
||||
eq(issueComments.id, chatPublications.commentId),
|
||||
eq(issueComments.companyId, chatPublications.companyId),
|
||||
eq(issueComments.issueId, chatPublications.issueId),
|
||||
eq(issueComments.authorType, "agent"),
|
||||
eq(issueComments.authorAgentId, endpoint.assignedAgentId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
chatMessageLinks,
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, chatPublications.companyId),
|
||||
eq(chatMessageLinks.endpointId, chatPublications.endpointId),
|
||||
eq(
|
||||
chatMessageLinks.conversationId,
|
||||
chatPublications.conversationId,
|
||||
),
|
||||
eq(chatMessageLinks.deliveryId, qualifyingDelivery.id),
|
||||
eq(chatMessageLinks.direction, "inbound"),
|
||||
isNotNull(chatMessageLinks.commentId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
heartbeatRuns,
|
||||
and(
|
||||
eq(heartbeatRuns.id, issueComments.createdByRunId),
|
||||
eq(heartbeatRuns.companyId, chatPublications.companyId),
|
||||
eq(heartbeatRuns.agentId, endpoint.assignedAgentId),
|
||||
eq(heartbeatRuns.status, "succeeded"),
|
||||
eq(
|
||||
sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
sql<string>`${chatPublications.issueId}::text`,
|
||||
),
|
||||
or(
|
||||
sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`,
|
||||
sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'commentId'`,
|
||||
sql`coalesce(${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, endpoint.companyId),
|
||||
eq(chatPublications.endpointId, endpoint.id),
|
||||
eq(
|
||||
chatPublications.conversationId,
|
||||
qualifyingDelivery.conversationId,
|
||||
),
|
||||
eq(chatPublications.state, "published"),
|
||||
gte(chatPublications.publishedAt, qualifyingDelivery.processedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatPublications.publishedAt))
|
||||
.then((rows) =>
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.payload.interactionId === undefined &&
|
||||
row.payload.progressState === undefined &&
|
||||
row.commentId !== null,
|
||||
),
|
||||
);
|
||||
let githubToolReply = false;
|
||||
if (!finalPublication && endpoint.provider === "github") {
|
||||
const setupRuns = await db.select({ id: heartbeatRuns.id, issueId: chatConversations.issueId })
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(chatMessageLinks, and(
|
||||
eq(chatMessageLinks.companyId, endpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, endpoint.id),
|
||||
eq(chatMessageLinks.deliveryId, qualifyingDelivery.id),
|
||||
eq(chatMessageLinks.direction, "inbound"),
|
||||
or(
|
||||
sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`,
|
||||
sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`,
|
||||
),
|
||||
))
|
||||
.innerJoin(chatConversations, and(
|
||||
eq(chatConversations.id, qualifyingDelivery.conversationId),
|
||||
eq(chatConversations.id, chatMessageLinks.conversationId),
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${chatConversations.issueId}::text`,
|
||||
))
|
||||
.where(and(eq(heartbeatRuns.companyId, endpoint.companyId),
|
||||
eq(heartbeatRuns.agentId, endpoint.assignedAgentId), eq(heartbeatRuns.status, "succeeded")))
|
||||
.orderBy(desc(heartbeatRuns.createdAt)).limit(20);
|
||||
for (const run of setupRuns) {
|
||||
if (await githubRunReplyState(db, { companyId: endpoint.companyId, endpointId: endpoint.id,
|
||||
issueId: run.issueId, runId: run.id }) === "confirmed") {
|
||||
githubToolReply = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!finalPublication && !githubToolReply) {
|
||||
throw conflict(
|
||||
"Wait for the Paperclip agent to reply to the setup turn before completing setup",
|
||||
{
|
||||
code: "chat_test_round_trip_incomplete",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await options.setupTestActivationBarrier?.();
|
||||
const expectedGeneration = runtimeGeneration(endpoint.setup);
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
if (optionalSlackTest) {
|
||||
const linked = await tx.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and(
|
||||
eq(chatIdentityLinks.endpointId, endpoint.id), eq(chatIdentityLinks.paperclipUserId, finishOptions!.optionalSlackTestForUser), eq(chatIdentityLinks.status, "linked"),
|
||||
)).limit(1).then((rows) => rows[0]);
|
||||
if (!linked || !(await lockCurrentPrincipalAuthorization(tx, endpoint, linked.principalId)).allowed) throw forbidden("Your connected account is no longer authorized");
|
||||
}
|
||||
await notifyChatEndpointWork(tx);
|
||||
const [activated] = await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: "active",
|
||||
setup: {
|
||||
...endpoint.setup,
|
||||
step: "complete",
|
||||
testStartedAt: null,
|
||||
testSkipped: optionalSlackTest,
|
||||
},
|
||||
healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected",
|
||||
activatedAt: endpoint.activatedAt ?? new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.id, endpointId),
|
||||
eq(chatEndpoints.status, "verifying"),
|
||||
sql`${chatEndpoints.setup}->>'step' = 'test'`,
|
||||
sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${expectedGeneration}`,
|
||||
sql`${chatEndpoints.setup}->>'testStartedAt' = ${testStartedAtValue}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: chatEndpoints.id });
|
||||
if (!activated) {
|
||||
throw conflict("This setup test was superseded by a reconnect", {
|
||||
code: "chat_endpoint_test_superseded",
|
||||
});
|
||||
}
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "healthy",
|
||||
healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected",
|
||||
lastError: null,
|
||||
healthCheckedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(toolConnections.id, endpoint.connectionId));
|
||||
if (optionalSlackTest) await logActivity(tx as unknown as Db, {
|
||||
companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser,
|
||||
action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId,
|
||||
details: { conversationTestOptional: true },
|
||||
});
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
return get(endpointId);
|
||||
},
|
||||
);
|
||||
}
|
||||
return { setupTestStatus, test };
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { isProvisionedSlackSetup } from "@paperclipai/shared";
|
||||
import type { EndpointRow, DbOrTransaction, SlackCallbackSurface, InternalSetupState } from "../runtime-types.js";
|
||||
|
||||
import { notifyChatVerificationWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import { slackChatRegistrationService } from "../../chat-slack-registration.js";
|
||||
import { chatSlackRegistrations } from "@paperclipai/db";
|
||||
|
||||
import { runtimeCanonicalOrigin } from "../../cloud-runtime-identity.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
createHmac,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatEndpoints,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ToolCredentialSecretRef,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
logActivity,
|
||||
} from "../../activity-log.js";
|
||||
|
||||
import { secretService } from "../../secrets.js";
|
||||
|
||||
function slackRequestSignatureIsValid(
|
||||
request: Request,
|
||||
body: string,
|
||||
signingSecret: string,
|
||||
): boolean {
|
||||
const timestamp = request.headers.get("x-slack-request-timestamp");
|
||||
const signature = request.headers.get("x-slack-signature");
|
||||
if (!timestamp || !signature) return false;
|
||||
const timestampSeconds = Number(timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestampSeconds) ||
|
||||
Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300
|
||||
)
|
||||
return false;
|
||||
const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${body}`).digest("hex")}`;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function slackRequestWorkspaceId(
|
||||
body: string,
|
||||
contentType: string,
|
||||
): string | null {
|
||||
let payload: unknown;
|
||||
try {
|
||||
if (contentType.includes("application/json")) {
|
||||
payload = JSON.parse(body);
|
||||
} else {
|
||||
const form = new URLSearchParams(body);
|
||||
const interactivePayload = form.get("payload");
|
||||
payload = interactivePayload
|
||||
? JSON.parse(interactivePayload)
|
||||
: Object.fromEntries(form.entries());
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const nestedId = (value: unknown): string | null => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const id = (value as Record<string, unknown>).id;
|
||||
return typeof id === "string" && id.length > 0 ? id : null;
|
||||
};
|
||||
const stringField = (key: string): string | null => {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
};
|
||||
const authorization = Array.isArray(record.authorizations)
|
||||
? record.authorizations.find(
|
||||
(value): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object",
|
||||
)
|
||||
: null;
|
||||
const authorizationField = (key: string): string | null => {
|
||||
const value = authorization?.[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
};
|
||||
|
||||
// A Grid event may carry both an enterprise id and the concrete workspace
|
||||
// id. The bot identity is verified against auth.test's team_id, so prefer a
|
||||
// team id and use the enterprise id only when Slack omitted team context.
|
||||
return (
|
||||
stringField("team_id") ??
|
||||
nestedId(record.team) ??
|
||||
authorizationField("team_id") ??
|
||||
stringField("enterprise_id") ??
|
||||
nestedId(record.enterprise) ??
|
||||
authorizationField("enterprise_id")
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalCallbackUrl(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
||||
url.hostname = url.hostname.replace(/\.$/, "");
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// TLS terminates at a reverse proxy in many self-hosted deployments. The
|
||||
// transport scheme is not evidence of URL drift; authority and path still are.
|
||||
// This is diagnostic only: it does not trust forwarded headers or change auth.
|
||||
function slackCallbackMatchesPublicUrl(observed: string, current: string): boolean {
|
||||
const canonical = canonicalCallbackUrl(observed);
|
||||
if (canonical === current) return true;
|
||||
if (!canonical) return false;
|
||||
const observedUrl = new URL(canonical);
|
||||
const currentUrl = new URL(current);
|
||||
return observedUrl.protocol === "http:"
|
||||
&& currentUrl.protocol === "https:"
|
||||
&& observedUrl.host === currentUrl.host
|
||||
&& observedUrl.pathname === currentUrl.pathname;
|
||||
}
|
||||
|
||||
async function inspectSlackCallback(
|
||||
request: Request,
|
||||
): Promise<SlackCallbackInspection | null> {
|
||||
const url = slackCallbackObservationUrl(request);
|
||||
if (!url) return null;
|
||||
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
let body: string;
|
||||
try {
|
||||
body = await request.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const payload = JSON.parse(body) as {
|
||||
type?: unknown;
|
||||
challenge?: unknown;
|
||||
};
|
||||
const isUrlVerification =
|
||||
payload.type === "url_verification" &&
|
||||
typeof payload.challenge === "string" &&
|
||||
payload.challenge.length > 0;
|
||||
if (isUrlVerification || payload.type === "event_callback") {
|
||||
return {
|
||||
surface: "events",
|
||||
url,
|
||||
isUrlVerification,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const form = new URLSearchParams(body);
|
||||
if (form.get("command")) {
|
||||
return { surface: "slashCommands", url, isUrlVerification: false };
|
||||
}
|
||||
const interactivePayload = form.get("payload");
|
||||
if (!interactivePayload) return null;
|
||||
try {
|
||||
const payload = JSON.parse(interactivePayload) as { type?: unknown };
|
||||
if (
|
||||
typeof payload.type === "string" &&
|
||||
[
|
||||
"block_actions",
|
||||
"block_suggestion",
|
||||
"dialog_submission",
|
||||
"message_action",
|
||||
"shortcut",
|
||||
"view_closed",
|
||||
"view_submission",
|
||||
"workflow_step_edit",
|
||||
].includes(payload.type)
|
||||
) {
|
||||
return { surface: "interactivity", url, isUrlVerification: false };
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type SlackCallbackInspection = {
|
||||
surface: SlackCallbackSurface;
|
||||
url: string;
|
||||
isUrlVerification: boolean;
|
||||
};
|
||||
|
||||
// Cloud records the public request host in dedicated headers so provider edges
|
||||
// cannot replace it with their upstream host. Only use callback-health evidence on a
|
||||
// claimed Cloud instance, after provider authentication succeeded. It must not
|
||||
// change the Request passed to the adapter, signature verification, routing,
|
||||
// board identity, or the configured URL used to generate callbacks.
|
||||
function slackCallbackObservationUrl(request: Request): string | null {
|
||||
const directUrl = canonicalCallbackUrl(request.url);
|
||||
if (!directUrl || !runtimeCanonicalOrigin()) return directUrl;
|
||||
const hasCloudEvidence = request.headers.has("x-paperclip-cloud-forwarded-host");
|
||||
const host = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-host" : "x-forwarded-host")?.trim();
|
||||
const protocol = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-proto" : "x-forwarded-proto")?.trim();
|
||||
if (!host || /[\s/@\\?#,]/.test(host) || (protocol !== "https" && protocol !== "http")) return directUrl;
|
||||
try {
|
||||
const origin = new URL(`${protocol}://${host}`);
|
||||
// Accept one authority only, never credentials, paths, queries, or lists.
|
||||
if (!origin.host || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) return directUrl;
|
||||
const observed = new URL(directUrl);
|
||||
observed.protocol = origin.protocol;
|
||||
observed.host = origin.host;
|
||||
observed.port = origin.port;
|
||||
return canonicalCallbackUrl(observed.toString());
|
||||
} catch {
|
||||
return directUrl;
|
||||
}
|
||||
}
|
||||
export { slackRequestSignatureIsValid, slackRequestWorkspaceId, canonicalCallbackUrl, slackCallbackMatchesPublicUrl, inspectSlackCallback };
|
||||
|
||||
type SlackCallbackVerificationOptions = {
|
||||
resolveCredentialRefs: (endpoint: EndpointRow, refs: ToolCredentialSecretRef[], database?: DbOrTransaction) => Promise<Record<string, string>>;
|
||||
getWebhookPublicBaseUrl: () => string | null;
|
||||
scheduleMessageProcessing: (task: () => Promise<void>) => void;
|
||||
slackRegistration: Pick<ReturnType<typeof slackChatRegistrationService>, "notifyVerified">;
|
||||
};
|
||||
|
||||
export function createSlackCallbackVerification(db: Db, options: SlackCallbackVerificationOptions) {
|
||||
const { resolveCredentialRefs, getWebhookPublicBaseUrl, scheduleMessageProcessing, slackRegistration } = options;
|
||||
|
||||
|
||||
async function verifySlackRequestUrl(endpoint: EndpointRow, request: Request): Promise<Response | null> {
|
||||
const callback = await inspectSlackCallback(request.clone());
|
||||
if (!callback?.isUrlVerification) return null;
|
||||
const body = await request.clone().text();
|
||||
let notifyAccount = false;
|
||||
// URL verification needs only the signing secret. Do not initialize the
|
||||
// SDK (which calls Slack auth.test), wait for OAuth, or acquire the creation
|
||||
// lease: Slack can deliver this check while app setup still owns that lease.
|
||||
const response = await db.transaction(async tx => {
|
||||
const [current] = await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)).for("no key update");
|
||||
if (!current || ["archived", "paused", "revoked"].includes(current.status)) return new Response("ignored", { status: 200 });
|
||||
const [connection] = await tx.select({ status: toolConnections.status, refs: toolConnections.credentialSecretRefs }).from(toolConnections)
|
||||
.where(and(eq(toolConnections.id, current.connectionId), eq(toolConnections.companyId, current.companyId)));
|
||||
if (!connection || connection.status === "archived") return new Response("ignored", { status: 200 });
|
||||
let secret = (await resolveCredentialRefs(current, connection.refs.filter(ref => ref.configPath.replace(/^credentials\./, "") === "signingSecret"), tx)).signingSecret;
|
||||
if (!secret && isProvisionedSlackSetup(current.setup.slackSetupMethod)) {
|
||||
const [registration] = await tx.select().from(chatSlackRegistrations)
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, current.id), eq(chatSlackRegistrations.companyId, current.companyId)));
|
||||
if (registration?.status !== "removed" && registration?.secretIds.signingSecret) {
|
||||
secret = await secretService(tx as unknown as Db).resolveSecretValue(current.companyId, registration.secretIds.signingSecret, "latest", {
|
||||
accessContext: { consumerType: "system", consumerId: `slack-registration:${current.id}`, configPath: "slack_registration.signingSecret", actorType: "system" },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!secret) return new Response("Slack signing secret is not ready yet", { status: 503 });
|
||||
if (!slackRequestSignatureIsValid(request, body, secret)) return new Response("Invalid signature", { status: 401 });
|
||||
const workspaceId = slackRequestWorkspaceId(body, request.headers.get("content-type") ?? "");
|
||||
if (workspaceId && current.providerAccountId && workspaceId !== current.providerAccountId) return new Response("ignored", { status: 200 });
|
||||
const observedAt = new Date();
|
||||
const setup = current.setup as InternalSetupState;
|
||||
const matchesCurrentUrl = slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`);
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatEndpoints).set({ setup: {
|
||||
...setup,
|
||||
...(matchesCurrentUrl ? { webhookVerifiedAt: setup.webhookVerifiedAt ?? observedAt.toISOString(),
|
||||
slackVerificationSigningFingerprint: createHash("sha256").update(secret).digest("hex") } : {}),
|
||||
slackCallbackSurfaces: { ...setup.slackCallbackSurfaces, events: { url: callback.url, observedAt: observedAt.toISOString() } },
|
||||
...(matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus
|
||||
? { slackAccount: { ...setup.slackAccount, verificationStatus: "pending" as const } } : {}),
|
||||
} as InternalSetupState, ...(matchesCurrentUrl ? { healthMessage: "Slack Events Request URL verified" } : {}), updatedAt: observedAt }).where(eq(chatEndpoints.id, current.id));
|
||||
if (matchesCurrentUrl && !setup.webhookVerifiedAt) await logActivity(tx as unknown as Db, { companyId: current.companyId, actorType: "system", actorId: "slack-webhook",
|
||||
action: "chat_endpoint.webhook_verified", entityType: "tool_connection", entityId: current.connectionId,
|
||||
details: { endpointId: current.id, provider: "slack" } });
|
||||
notifyAccount = matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus;
|
||||
return Response.json({ challenge: (JSON.parse(body) as { challenge: string }).challenge });
|
||||
});
|
||||
if (notifyAccount) scheduleMessageProcessing(() => slackRegistration.notifyVerified(endpoint.id));
|
||||
return response;
|
||||
}
|
||||
return { verifySlackRequestUrl };
|
||||
}
|
||||
@@ -41,3 +41,49 @@ export function slackMessageSentAt(raw: unknown, messageId: string): Date | null
|
||||
const date = new Date(Number(ts) * 1_000);
|
||||
return Number.isFinite(date.getTime()) ? date : null;
|
||||
}
|
||||
|
||||
export function isSlackReceiptReactionAlreadyApplied(error: unknown): boolean {
|
||||
const pending: Array<{ depth: number; value: unknown }> = [
|
||||
{ depth: 0, value: error },
|
||||
];
|
||||
const seen = new Set<object>();
|
||||
while (pending.length > 0) {
|
||||
const current = pending.shift();
|
||||
if (
|
||||
!current ||
|
||||
current.depth > 4 ||
|
||||
!current.value ||
|
||||
typeof current.value !== "object" ||
|
||||
seen.has(current.value)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current.value);
|
||||
const record = current.value as {
|
||||
cause?: unknown;
|
||||
code?: unknown;
|
||||
data?: { error?: unknown };
|
||||
original?: unknown;
|
||||
originalError?: unknown;
|
||||
};
|
||||
if (
|
||||
record.code === "already_reacted" ||
|
||||
record.data?.error === "already_reacted"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
for (const value of [record.cause, record.original, record.originalError]) {
|
||||
pending.push({ depth: current.depth + 1, value });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function slackThreadChannelId(threadId: string): string | null {
|
||||
if (!threadId.startsWith("slack:")) return null;
|
||||
const channelAndThread = threadId.slice("slack:".length);
|
||||
const separator = channelAndThread.indexOf(":");
|
||||
const channelId =
|
||||
separator === -1 ? channelAndThread : channelAndThread.slice(0, separator);
|
||||
return channelId || null;
|
||||
}
|
||||
@@ -1073,3 +1073,21 @@ export function createSlackTaskStart(db: Db, options: SlackTaskStartOptions) {
|
||||
}
|
||||
return { latestSlackDmControlThreadId, finalizeSlackTaskStartFailure, admitConfirmedSlackTaskStart, processSlackTaskStart, processPendingSlackTaskStarts };
|
||||
}
|
||||
|
||||
export function slackTaskStartActivityStatus(action: {
|
||||
status: string;
|
||||
updatedAt: Date;
|
||||
}): string {
|
||||
const age = Date.now() - action.updatedAt.getTime();
|
||||
if (action.status === "validating" && age >= SLACK_COMMAND_POST_STALE_MS) {
|
||||
return "queued";
|
||||
}
|
||||
if (
|
||||
(action.status === "received" && age >= SLACK_COMMAND_POST_STALE_MS) ||
|
||||
(action.status === "resolving" &&
|
||||
age >= SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS)
|
||||
) {
|
||||
return "delivery_unknown";
|
||||
}
|
||||
return action.status;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { EndpointRow, DbTransaction, DbOrTransaction } from "../runtime-types.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import {
|
||||
chatEndpointResources,
|
||||
chatExternalPrincipals,
|
||||
chatIdentityLinks,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
type TelegramCallbackReceipt,
|
||||
} from "../../chat-telegram-ephemeral.js";
|
||||
|
||||
type TelegramCallbackNoticeAuthorityOptions = {
|
||||
nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean;
|
||||
lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean; userId: string | null; sponsorUserId?: string | null }>;
|
||||
};
|
||||
|
||||
export function createTelegramCallbackNoticeAuthority(options: TelegramCallbackNoticeAuthorityOptions) {
|
||||
const { nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization } = options;
|
||||
|
||||
|
||||
// This is permission to return one fixed, neutral callback denial, not
|
||||
// permission to execute the denied action or publish to the conversation.
|
||||
// Retain the initial identity-policy snapshot so queued notices cannot cross
|
||||
// a subsequent actor/link/reach change. No provider I/O occurs under locks.
|
||||
async function lockTelegramCallbackNoticeAuthority(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
receipt: TelegramCallbackReceipt,
|
||||
principalId: string | null,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
endpoint.provider !== "telegram" ||
|
||||
endpoint.status !== "active" ||
|
||||
endpoint.companyId !== receipt.companyId ||
|
||||
endpoint.id !== receipt.endpointId ||
|
||||
endpoint.botExternalId !== receipt.botUserId ||
|
||||
!endpoint.providerAccountId ||
|
||||
!principalId ||
|
||||
Date.now() < receipt.receivedAtMs ||
|
||||
Date.now() >= receipt.deadlineAtMs
|
||||
)
|
||||
return null;
|
||||
const resource =
|
||||
receipt.chatType === "private"
|
||||
? null
|
||||
: await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.providerResourceId, receipt.chatId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
receipt.chatType === "private"
|
||||
? !endpoint.allowDirectMessages
|
||||
: !resource || !nonDirectDestinationAllowed(endpoint, resource)
|
||||
)
|
||||
return null;
|
||||
const principal = await tx
|
||||
.select()
|
||||
.from(chatExternalPrincipals)
|
||||
.where(
|
||||
and(
|
||||
eq(chatExternalPrincipals.companyId, endpoint.companyId),
|
||||
eq(chatExternalPrincipals.provider, endpoint.provider),
|
||||
eq(
|
||||
chatExternalPrincipals.providerAccountId,
|
||||
endpoint.providerAccountId,
|
||||
),
|
||||
eq(chatExternalPrincipals.id, principalId),
|
||||
eq(chatExternalPrincipals.externalId, receipt.receiverUserId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!principal || principal.kind !== "user" || principal.isBot) return null;
|
||||
const authorization = await lockCurrentPrincipalAuthorization(
|
||||
tx,
|
||||
endpoint,
|
||||
principal.id,
|
||||
);
|
||||
const link = await tx
|
||||
.select({
|
||||
status: chatIdentityLinks.status,
|
||||
userId: chatIdentityLinks.paperclipUserId,
|
||||
})
|
||||
.from(chatIdentityLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.companyId, endpoint.companyId),
|
||||
eq(chatIdentityLinks.endpointId, endpoint.id),
|
||||
eq(chatIdentityLinks.principalId, principal.id),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
endpoint.companyId,
|
||||
endpoint.id,
|
||||
receipt.botUserId,
|
||||
resource?.id ?? null,
|
||||
principal.id,
|
||||
principal.externalId,
|
||||
link,
|
||||
authorization,
|
||||
endpoint.allowUnlinkedPeople,
|
||||
endpoint.sponsorUserId,
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
return { lockTelegramCallbackNoticeAuthority };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Attachment } from "chat";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { chatDeliveries } from "@paperclipai/db";
|
||||
import type { ChatProvider } from "@paperclipai/shared";
|
||||
@@ -221,3 +222,14 @@ export function telegramMessageSentAt(raw: unknown): Date | null {
|
||||
const sentAt = new Date(value * 1_000);
|
||||
return Number.isFinite(sentAt.getTime()) ? sentAt : null;
|
||||
}
|
||||
|
||||
export function telegramMarkdownAttachment(text: string): Attachment {
|
||||
const data = Buffer.from(text, "utf8");
|
||||
return {
|
||||
data,
|
||||
mimeType: "text/markdown; charset=utf-8",
|
||||
name: "paperclip-response.md",
|
||||
size: data.byteLength,
|
||||
type: "file",
|
||||
};
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-le
|
||||
// Keep generic connection removal independent of the registration service's
|
||||
// authorization graph; access routes already load tool-access through the barrel.
|
||||
export async function removeSlackRegistration(db: Db, endpointId: string, lease: CredentialMutationLeaseGuard) {
|
||||
await lease.assertOwned();
|
||||
const vault = secretService(db);
|
||||
const [row] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
|
||||
if (endpoint) await db.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, endpoint.connectionId), like(toolOauthStates.state, "slack-manager.%")));
|
||||
if (!row) return;
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ status: "removed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
@@ -16,4 +19,4 @@ export async function removeSlackRegistration(db: Db, endpointId: string, lease:
|
||||
for (const id of Object.values(row.secretIds)) { await lease.assertOwned(); await vault.remove(id); }
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ secretIds: {} }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
}
|
||||
}
|
||||
@@ -1,531 +1,3 @@
|
||||
import { notifyChatVerificationWork } from "./chat-work-notifications.js";
|
||||
import { createAgentAvatarPool } from "./agent-avatar-pool.js";
|
||||
import type { AgentAvatarRequest } from "./agent-avatars.js";
|
||||
import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq, gt, inArray, like, sql } from "drizzle-orm";
|
||||
import { agents, chatEndpoints, chatSlackRegistrations, companies, toolConnections, toolOauthStates, type Db } from "@paperclipai/db";
|
||||
import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, slackRegistrationErrorMessage, type SlackRegistrationInput, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState } from "@paperclipai/shared";
|
||||
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { writeConnectionCredential } from "./connection-credentials.js";
|
||||
import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-lease.js";
|
||||
|
||||
export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean }
|
||||
type Registration = typeof chatSlackRegistrations.$inferSelect;
|
||||
const prefix = "slack-install.";
|
||||
const timeoutMs = 20_000;
|
||||
const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code });
|
||||
const knownProviderErrors: Record<string, string> = {
|
||||
invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid",
|
||||
token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid",
|
||||
invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid",
|
||||
ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied",
|
||||
missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid",
|
||||
};
|
||||
|
||||
export function slackRegistrationProjection(row: Registration): SlackRegistrationState {
|
||||
return {
|
||||
status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status,
|
||||
appId: row.appId,
|
||||
managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps",
|
||||
errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode,
|
||||
};
|
||||
}
|
||||
|
||||
export function slackChatRegistrationService(db: Db, options: {
|
||||
publicOrigin: () => string | null;
|
||||
webhookOrigin: () => string | null;
|
||||
fetch?: typeof fetch;
|
||||
renderAvatar?: (request: AgentAvatarRequest) => Promise<Buffer>;
|
||||
withLock: <T>(endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise<T>) => Promise<T>;
|
||||
runtimeSigningSecret: (endpointId: string) => Promise<string>;
|
||||
runtimeBotToken: (endpointId: string) => Promise<string>;
|
||||
canWelcome: (endpointId: string, account: SlackAccountState) => Promise<boolean>;
|
||||
linkInstaller: (endpointId: string, user: Record<string, unknown>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<void>;
|
||||
configure: (endpointId: string, credentials: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<unknown>;
|
||||
}) {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const vault = secretService(db);
|
||||
let avatarPool: ReturnType<typeof createAgentAvatarPool> | undefined;
|
||||
function origin(value: string | null) {
|
||||
if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app");
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
||||
throw badRequest("Slack setup requires a configured public HTTPS origin");
|
||||
return url.origin;
|
||||
}
|
||||
const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`;
|
||||
async function registration(endpointId: string) {
|
||||
return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0];
|
||||
}
|
||||
async function endpoint(endpointId: string, actor: SlackSetupActor) {
|
||||
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
|
||||
const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance })
|
||||
.from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId)))
|
||||
.innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId)))
|
||||
.where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack")));
|
||||
if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found");
|
||||
if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections"))
|
||||
throw forbidden("Missing permission: tools:manage_connections");
|
||||
return row;
|
||||
}
|
||||
function assertOrigins(row: Registration, publicId: string) {
|
||||
if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup.");
|
||||
const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`;
|
||||
if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected)
|
||||
throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup.");
|
||||
}
|
||||
async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) {
|
||||
await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId,
|
||||
action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId,
|
||||
details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } });
|
||||
}
|
||||
async function api(method: string, fields: Record<string, string> | FormData, bearer?: string) {
|
||||
const response = await fetchImpl(`https://slack.com/api/${method}`, {
|
||||
method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },
|
||||
body: fields instanceof FormData ? fields : new URLSearchParams(fields),
|
||||
});
|
||||
const result = object(await response.json());
|
||||
if (!response.ok || result.ok !== true) {
|
||||
const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure");
|
||||
throw providerFailure(code);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function saveSecrets(row: Registration, values: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard,
|
||||
patch: Partial<Registration> = {}, account?: SlackAccountState) {
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
|
||||
if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
const ids = { ...current.secretIds };
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration",
|
||||
configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId },
|
||||
...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) });
|
||||
ids[key] = result.secret.id;
|
||||
}
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId));
|
||||
if (account) {
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
if (!saved || saved.status === "archived") throw conflict("Slack setup changed");
|
||||
// Reauthorization never changes an established personal account binding.
|
||||
if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId));
|
||||
}
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function readSecret(row: Registration, key: string) {
|
||||
if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection.");
|
||||
return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", {
|
||||
accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" },
|
||||
});
|
||||
}
|
||||
async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) {
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId)));
|
||||
}
|
||||
async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
|
||||
if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
if (!saved || saved.status === "archived" || saved.setup.slackSetupMethod !== "automatic") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() })
|
||||
.where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId)));
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const previous = await registration(endpointId);
|
||||
if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup.");
|
||||
if (previous?.appId) {
|
||||
if (previous.errorCode === "slack_manifest_update_pending") {
|
||||
await configureManifest(previous, actor, input.credentials.configurationToken, lease);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (previous?.requestId === input.requestId) return;
|
||||
if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) {
|
||||
await setFailure(previous, "uncertain", "slack_creation_uncertain", lease);
|
||||
return;
|
||||
}
|
||||
if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== "automatic")
|
||||
throw conflict("Automatic creation is only available for a new Slack connection");
|
||||
const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp);
|
||||
const redirect = callbackUri();
|
||||
const manifest = buildSlackAppManifest({ app, agentName: current.agentName,
|
||||
webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect });
|
||||
// Slack cannot authenticate a challenge until its creation response gives us
|
||||
// the signing secret. Add event subscriptions only after that secret is durable.
|
||||
const { event_subscriptions: _events, ...creationSettings } = manifest.settings;
|
||||
const creationManifest = { ...manifest, settings: creationSettings };
|
||||
try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); }
|
||||
catch (error) {
|
||||
if (error instanceof Error && "status" in error) throw error;
|
||||
throw providerFailure("slack_provider_failure");
|
||||
}
|
||||
// No provider creation has happened until this intent is durable.
|
||||
await endpoint(endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const,
|
||||
manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect,
|
||||
createdByUserId: actor.userId, errorCode: null, updatedAt: new Date() };
|
||||
const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values })
|
||||
.onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning();
|
||||
await audit(row, actor, "creation_started");
|
||||
let createdAppId: string;
|
||||
try {
|
||||
const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken);
|
||||
const credentials = object(result.credentials);
|
||||
if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id)
|
||||
|| typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id)
|
||||
|| typeof credentials.client_secret !== "string" || !credentials.client_secret
|
||||
|| typeof credentials.signing_secret !== "string" || !credentials.signing_secret)
|
||||
throw new Error("Incomplete Slack creation response");
|
||||
await endpoint(endpointId, actor);
|
||||
await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease,
|
||||
{ appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" });
|
||||
createdAppId = result.app_id;
|
||||
} catch (error) {
|
||||
// Only documented rejection responses prove that creation did not happen.
|
||||
const code = object(object(error).details).code;
|
||||
const rejected = typeof code === "string" && Object.values(knownProviderErrors).includes(code);
|
||||
await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease);
|
||||
await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain");
|
||||
return;
|
||||
}
|
||||
// The app and credentials are durable before optional icon provisioning.
|
||||
// A retry/restart always reuses this app, even if icon upload is interrupted.
|
||||
const created = { ...row, appId: createdAppId };
|
||||
if (!await configureManifest(created, actor, input.credentials.configurationToken, lease)) return;
|
||||
await saveAvatar(created, { status: "pending" }, actor, lease);
|
||||
let avatar: SlackAvatarState;
|
||||
try {
|
||||
await endpoint(endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
// Cloud ingress requires a tenant session. Send the preset PNG bytes so
|
||||
// Slack does not need to fetch an authenticated Paperclip URL.
|
||||
const render = options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render;
|
||||
const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) };
|
||||
delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets.
|
||||
const png = await render({ appearance,
|
||||
size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" });
|
||||
await endpoint(endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
const upload = new FormData();
|
||||
upload.set("app_id", createdAppId);
|
||||
upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png");
|
||||
await api("apps.icon.set", upload, input.credentials.configurationToken);
|
||||
avatar = { status: "uploaded", uploadedAt: new Date().toISOString() };
|
||||
} catch {
|
||||
// Provider echoes (including tokens) never become saved state or activity.
|
||||
avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" };
|
||||
}
|
||||
await saveAvatar(created, avatar, actor, lease);
|
||||
try { await audit(created, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); }
|
||||
catch { logger.warn({ endpointId, appId: createdAppId }, "Slack avatar activity could not be recorded"); }
|
||||
// An audit outage cannot change the saved creation outcome.
|
||||
try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); }
|
||||
catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); }
|
||||
});
|
||||
}
|
||||
async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== "automatic")
|
||||
throw conflict("Resume the saved Slack connection before configuring its app");
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
await lease.assertOwned();
|
||||
try {
|
||||
await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken);
|
||||
} catch {
|
||||
// The pending marker was saved with the app secrets before dispatch. Retrying
|
||||
// the same manifest is safe even when Slack's response was lost.
|
||||
await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending");
|
||||
return false;
|
||||
}
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId)));
|
||||
await audit(row, actor, "manifest_updated");
|
||||
return true;
|
||||
}
|
||||
async function install(endpointId: string, actor: SlackSetupActor) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const row = await registration(endpointId);
|
||||
if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it");
|
||||
if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it");
|
||||
if (current.endpoint.setup.slackSetupMethod !== "automatic" || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it");
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
const state = `${prefix}${randomBytes(32).toString("base64url")}`;
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[];
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`)));
|
||||
await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id,
|
||||
codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri,
|
||||
endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }),
|
||||
createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId,
|
||||
requestedScopes: scopes, expiresAt });
|
||||
await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
const url = new URL("https://slack.com/oauth/v2/authorize");
|
||||
url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri,
|
||||
...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString();
|
||||
await audit(row, actor, "installation_started");
|
||||
return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() };
|
||||
});
|
||||
}
|
||||
async function expiredReturn(state: string, actor: SlackSetupActor) {
|
||||
if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null;
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null;
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
return options.withLock(current.endpoint.id, async lease => {
|
||||
const latest = await endpoint(current.endpoint.id, actor);
|
||||
const row = await registration(current.endpoint.id);
|
||||
if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId
|
||||
|| latest.endpoint.setup.slackSetupMethod !== "automatic" || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null;
|
||||
assertOrigins(row, latest.endpoint.publicId);
|
||||
await lease.assertOwned();
|
||||
await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
await setFailure(row, row.status, "slack_install_expired", lease);
|
||||
await audit(row, actor, "installation_failed", "slack_install_expired");
|
||||
return returnPath(row.endpointId);
|
||||
});
|
||||
}
|
||||
async function pending(state: string, actor: SlackSetupActor) {
|
||||
if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state");
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date())));
|
||||
if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId)
|
||||
throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again.");
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
const row = await registration(current.endpoint.id);
|
||||
if (!row || row.status === "removed" || current.endpoint.setup.slackSetupMethod !== "automatic"
|
||||
|| current.endpoint.status === "paused"
|
||||
|| current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked"
|
||||
|| binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)
|
||||
|| row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri
|
||||
|| row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id)
|
||||
throw conflict("Slack setup changed. Resume the saved connection and install again.");
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
return { attempt, current, row };
|
||||
}
|
||||
async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0];
|
||||
if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed");
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId));
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
const account = current.endpoint.setup.slackAccount;
|
||||
if (!account) throw providerFailure("slack_install_account_missing");
|
||||
if (account.status === "linked") return;
|
||||
const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId);
|
||||
const user = object((await api("users.info", { user: account.externalUserId }, token)).user);
|
||||
if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing");
|
||||
await endpoint(row.endpointId, actor);
|
||||
await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease);
|
||||
await saveAccount(row, { ...account, status: "linked" }, actor, lease);
|
||||
try { await audit(row, actor, "account_linked"); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); }
|
||||
}
|
||||
async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
const account = current.endpoint.setup.slackAccount;
|
||||
if (!account || account.status !== "linked") return;
|
||||
if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return;
|
||||
const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus";
|
||||
const status = account[field] ?? "pending";
|
||||
if (status === "sending") {
|
||||
await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease);
|
||||
return; // Never replay an ambiguous dispatch after a process restart.
|
||||
}
|
||||
if (status !== "pending") return;
|
||||
await saveAccount(row, { ...account, [field]: "sending" }, actor, lease);
|
||||
let result: SlackAccountState;
|
||||
try {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed");
|
||||
const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId,
|
||||
text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance."
|
||||
: `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`,
|
||||
unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId));
|
||||
if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response");
|
||||
result = { ...account, [field]: "sent", dmChannelId: sent.channel };
|
||||
} catch (error) {
|
||||
result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" };
|
||||
}
|
||||
await saveAccount(row, result, actor, lease);
|
||||
try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); }
|
||||
}
|
||||
async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await sendSetupMessage(row, actor, lease, "welcome");
|
||||
await sendSetupMessage(row, actor, lease, "verification");
|
||||
}
|
||||
async function notifyVerified(endpointId: string) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status !== "configured") return;
|
||||
const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
|
||||
const account = saved?.setup.slackAccount;
|
||||
if (!account || !["verifying", "active"].includes(saved.status)) return;
|
||||
await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification");
|
||||
});
|
||||
}
|
||||
function pendingVerificationMessage() {
|
||||
// A pending verification cannot progress until linking, welcome, and an
|
||||
// authenticated callback finish. Their commits wake this lane.
|
||||
return and(eq(chatSlackRegistrations.status, "configured"),
|
||||
inArray(chatEndpoints.status, ["verifying", "active"]),
|
||||
sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`,
|
||||
sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`,
|
||||
sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`,
|
||||
sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`);
|
||||
}
|
||||
function pendingVerificationMessages(limit: number) {
|
||||
return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints)
|
||||
.innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId)))
|
||||
.where(pendingVerificationMessage()).limit(limit);
|
||||
}
|
||||
async function nextVerificationMessageAt(): Promise<number | null> {
|
||||
return (await pendingVerificationMessages(1)).length ? Date.now() : null;
|
||||
}
|
||||
async function processPendingVerificationMessages(limit = 25) {
|
||||
for (const row of await pendingVerificationMessages(limit)) {
|
||||
try { await notifyVerified(row.endpointId); }
|
||||
catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); }
|
||||
}
|
||||
}
|
||||
async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status === "removed") throw conflict("Slack registration is unavailable");
|
||||
if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; }
|
||||
if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it");
|
||||
try {
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
const botToken = await readSecret(row, "botToken");
|
||||
const auth = await api("auth.test", {}, botToken);
|
||||
if (auth.team_id !== row.workspaceId || auth.user_id !== row.botUserId) throw providerFailure("slack_install_identity_mismatch");
|
||||
await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret
|
||||
? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, {
|
||||
assertOwned: async database => {
|
||||
await lease.assertOwned(database);
|
||||
await endpoint(endpointId, actor);
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
},
|
||||
});
|
||||
await linkAccount(row, actor, lease);
|
||||
} catch (error) {
|
||||
const code = object(object(error).details).code;
|
||||
const safeCode = code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed"
|
||||
? "slack_install_identity_mismatch" : code === "chat_provider_permissions_missing" ? "slack_install_scopes_missing"
|
||||
: code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete";
|
||||
await setFailure(row, "credentials_saved", safeCode, lease);
|
||||
await audit(row, actor, "configuration_failed", safeCode);
|
||||
return;
|
||||
}
|
||||
await lease.assertOwned();
|
||||
await db.transaction(async tx => {
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
// Runtime now owns a separate vaulted copy. Keep the registration IDs until
|
||||
// deletion succeeds so cleanup itself is retryable.
|
||||
await cleanupStaged(endpointId, lease);
|
||||
try { await audit(row, actor, "installation_completed"); }
|
||||
catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); }
|
||||
await welcome(row, actor, lease);
|
||||
}
|
||||
async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) {
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status !== "configured") return;
|
||||
const ids = { ...row.secretIds };
|
||||
for (const key of ["botToken", "signingSecret"]) {
|
||||
if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; }
|
||||
}
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
}
|
||||
async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) {
|
||||
const before = await pending(state, actor);
|
||||
return options.withLock(before.row.endpointId, async lease => {
|
||||
const { row, attempt, current } = await pending(state, actor);
|
||||
const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning();
|
||||
if (!claimed) throw conflict("This Slack authorization was already used");
|
||||
if (error) {
|
||||
const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed";
|
||||
await setFailure(row, row.status, failureCode, lease);
|
||||
await audit(row, actor, "installation_failed", failureCode);
|
||||
return row.endpointId;
|
||||
}
|
||||
try {
|
||||
if (!code || code.length > 4096) throw providerFailure("slack_install_failed");
|
||||
const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri });
|
||||
const teamId = object(result.team).id;
|
||||
const installerId = object(result.authed_user).id;
|
||||
if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-")
|
||||
|| typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id)
|
||||
|| row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id)
|
||||
throw providerFailure("slack_install_identity_mismatch");
|
||||
const granted = new Set(String(result.scope ?? "").split(","));
|
||||
if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing");
|
||||
if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId
|
||||
|| current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id)
|
||||
throw providerFailure("slack_install_identity_mismatch");
|
||||
if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing");
|
||||
await endpoint(row.endpointId, actor);
|
||||
// Reauthorization reuses the runtime signing secret after staging was cleaned.
|
||||
await saveSecrets(row, { botToken: result.access_token }, actor, lease,
|
||||
{ status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null },
|
||||
{ externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" });
|
||||
} catch (failure) {
|
||||
const code = object(object(failure).details).code;
|
||||
const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed";
|
||||
await setFailure(row, row.status, safeCode, lease);
|
||||
await audit(row, actor, "installation_failed", safeCode);
|
||||
return row.endpointId;
|
||||
}
|
||||
await resumeLocked(row.endpointId, actor, lease);
|
||||
return row.endpointId;
|
||||
});
|
||||
}
|
||||
const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease);
|
||||
async function returnPath(endpointId: string) {
|
||||
const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints)
|
||||
.innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId));
|
||||
if (!row) throw notFound("Slack connection not found");
|
||||
return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`;
|
||||
}
|
||||
return { create, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt,
|
||||
close: async () => { await avatarPool?.close(); },
|
||||
resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, lease => resumeLocked(endpointId, actor, lease)) };
|
||||
}
|
||||
// Compatibility boundary: both provisioning paths converge on the same Slack runtime.
|
||||
export { slackChatRegistrationService } from "./connectors/slack/setup/service.js";
|
||||
export { slackRegistrationProjection, type SlackSetupActor } from "./connectors/slack/setup/registration.js";
|
||||
@@ -0,0 +1,164 @@
|
||||
import { notifyChatVerificationWork } from "../../../chat-work-notifications.js";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { chatEndpoints, chatSlackRegistrations } from "@paperclipai/db";
|
||||
import { SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES, type SlackAccountState } from "@paperclipai/shared";
|
||||
import { conflict, forbidden } from "../../../../errors.js";
|
||||
import { logger } from "../../../../middleware/logger.js";
|
||||
import { secretService } from "../../../secrets.js";
|
||||
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
|
||||
import { object, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js";
|
||||
export function createSlackCompletion(store: SlackRegistrationStore) {
|
||||
const { db, options, endpoint, registration, assertOrigins, api, readSecret, audit, setFailure } = store;
|
||||
const vault = secretService(db);
|
||||
async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0];
|
||||
if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed");
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId));
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
const account = current.endpoint.setup.slackAccount;
|
||||
if (!account) throw providerFailure("slack_install_account_missing");
|
||||
if (account.status === "linked") return;
|
||||
const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId);
|
||||
const user = object((await api("users.info", { user: account.externalUserId }, token)).user);
|
||||
if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing");
|
||||
await endpoint(row.endpointId, actor);
|
||||
await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease);
|
||||
await saveAccount(row, { ...account, status: "linked" }, actor, lease);
|
||||
try { await audit(row, actor, "account_linked"); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); }
|
||||
}
|
||||
async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
const account = current.endpoint.setup.slackAccount;
|
||||
if (!account || account.status !== "linked") return;
|
||||
if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return;
|
||||
const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus";
|
||||
const status = account[field] ?? "pending";
|
||||
if (status === "sending") {
|
||||
await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease);
|
||||
return; // Never replay an ambiguous dispatch after a process restart.
|
||||
}
|
||||
if (status !== "pending") return;
|
||||
await saveAccount(row, { ...account, [field]: "sending" }, actor, lease);
|
||||
let result: SlackAccountState;
|
||||
try {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed");
|
||||
const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId,
|
||||
text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance."
|
||||
: `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`,
|
||||
unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId));
|
||||
if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response");
|
||||
result = { ...account, [field]: "sent", dmChannelId: sent.channel };
|
||||
} catch (error) {
|
||||
result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" };
|
||||
}
|
||||
await saveAccount(row, result, actor, lease);
|
||||
try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); }
|
||||
}
|
||||
async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await sendSetupMessage(row, actor, lease, "welcome");
|
||||
await sendSetupMessage(row, actor, lease, "verification");
|
||||
}
|
||||
async function notifyVerified(endpointId: string) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status !== "configured") return;
|
||||
const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId));
|
||||
const account = saved?.setup.slackAccount;
|
||||
if (!account || !["verifying", "active"].includes(saved.status)) return;
|
||||
await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification");
|
||||
});
|
||||
}
|
||||
function pendingVerificationMessages(limit: number) {
|
||||
return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints)
|
||||
.innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId)))
|
||||
.where(and(eq(chatSlackRegistrations.status, "configured"), inArray(chatEndpoints.status, ["verifying", "active"]),
|
||||
sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`,
|
||||
sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`,
|
||||
sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`,
|
||||
sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`)).limit(limit);
|
||||
}
|
||||
async function nextVerificationMessageAt(): Promise<number | null> {
|
||||
return (await pendingVerificationMessages(1)).length ? Date.now() : null;
|
||||
}
|
||||
async function processPendingVerificationMessages(limit = 25) {
|
||||
for (const row of await pendingVerificationMessages(limit)) {
|
||||
try { await notifyVerified(row.endpointId); }
|
||||
catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); }
|
||||
}
|
||||
}
|
||||
async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status === "removed") throw conflict("Slack registration is unavailable");
|
||||
if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; }
|
||||
if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it");
|
||||
try {
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
const botToken = await readSecret(row, "botToken");
|
||||
const auth = await api("auth.test", {}, botToken, row.managerGrantId ? [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES] : undefined);
|
||||
if (auth.team_id !== row.workspaceId || row.botUserId && auth.user_id !== row.botUserId || typeof auth.user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(auth.user_id)) throw providerFailure("slack_install_identity_mismatch");
|
||||
if (row.managerGrantId) {
|
||||
if (typeof auth.bot_id !== "string" || !/^B[A-Z0-9]+$/.test(auth.bot_id)) throw providerFailure("slack_install_identity_mismatch");
|
||||
const bot = object((await api("bots.info", { bot: auth.bot_id }, botToken)).bot);
|
||||
if (bot.id !== auth.bot_id || bot.app_id !== row.appId || bot.user_id !== auth.user_id || bot.deleted !== false) throw providerFailure("slack_install_identity_mismatch");
|
||||
}
|
||||
if (!row.botUserId) {
|
||||
if (!row.managerGrantId) throw providerFailure("slack_install_identity_mismatch");
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ botUserId: auth.user_id }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
}
|
||||
await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret
|
||||
? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, {
|
||||
assertOwned: async database => {
|
||||
await lease.assertOwned(database);
|
||||
await endpoint(endpointId, actor);
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
},
|
||||
});
|
||||
await linkAccount(row, actor, lease);
|
||||
} catch (error) {
|
||||
const code = object(object(error).details).code;
|
||||
const safeCode = code === "slack_configuration_token_invalid" ? "slack_install_token_invalid" : code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed"
|
||||
? "slack_install_identity_mismatch" : (code === "chat_provider_permissions_missing" || code === "slack_install_scopes_missing" || code === "slack_setup_permission_denied") ? "slack_install_scopes_missing"
|
||||
: code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete";
|
||||
await setFailure(row, "credentials_saved", safeCode, lease);
|
||||
await audit(row, actor, "configuration_failed", safeCode);
|
||||
return;
|
||||
}
|
||||
await lease.assertOwned();
|
||||
await db.transaction(async tx => {
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
// Runtime now owns a separate vaulted copy. Keep the registration IDs until
|
||||
// deletion succeeds so cleanup itself is retryable.
|
||||
await cleanupStaged(endpointId, lease);
|
||||
try { await audit(row, actor, "installation_completed"); }
|
||||
catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); }
|
||||
await welcome(row, actor, lease);
|
||||
}
|
||||
async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) {
|
||||
const row = await registration(endpointId);
|
||||
if (!row || row.status !== "configured") return;
|
||||
const ids = { ...row.secretIds };
|
||||
for (const key of ["botToken", "signingSecret"]) {
|
||||
if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; }
|
||||
}
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
}
|
||||
return { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt };
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { chatSlackRegistrations } from "@paperclipai/db";
|
||||
import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, type SlackRegistrationInput, type SlackAvatarState } from "@paperclipai/shared";
|
||||
import { conflict } from "../../../../errors.js";
|
||||
import { logger } from "../../../../middleware/logger.js";
|
||||
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
|
||||
import { object, knownProviderErrors, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js";
|
||||
export function createCustomerOwnedSetup(store: SlackRegistrationStore) {
|
||||
const { db, options, endpoint, registration, callbackUri, origin, api, audit, saveSecrets, setFailure, saveAvatar, assertOrigins, renderAvatar } = store;
|
||||
async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput, managed?: { grantId: string; workspaceId: string; request: typeof api }) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const api = managed?.request ?? store.api;
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const previous = await registration(endpointId);
|
||||
const method = managed ? "managed" : "automatic";
|
||||
if (current.endpoint.setup.slackSetupMethod !== method) throw conflict("Resume the selected Slack setup method");
|
||||
if (previous && previous.managerGrantId !== (managed?.grantId ?? null)) throw conflict("Resume setup with the original Slack workspace");
|
||||
if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup.");
|
||||
if (previous?.appId) {
|
||||
if (previous.errorCode === "slack_manifest_update_pending") {
|
||||
if (!await configureManifest(previous, actor, input.credentials.configurationToken, lease, method, api)) return;
|
||||
if (current.endpoint.setup.slackAvatar?.status !== "uploaded") await configureAvatar(previous, actor, input.credentials.configurationToken, lease, api);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (previous?.requestId === input.requestId) return;
|
||||
if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) {
|
||||
await setFailure(previous, "uncertain", "slack_creation_uncertain", lease);
|
||||
return;
|
||||
}
|
||||
if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== method)
|
||||
throw conflict("Automatic creation is only available for a new Slack connection");
|
||||
const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp);
|
||||
const redirect = callbackUri();
|
||||
const manifest = buildSlackAppManifest({ app, agentName: current.agentName,
|
||||
webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect });
|
||||
// Slack cannot authenticate a challenge until its creation response gives us
|
||||
// the signing secret. Add event subscriptions only after that secret is durable.
|
||||
const { event_subscriptions: _events, ...creationSettings } = manifest.settings;
|
||||
const creationManifest = { ...manifest, settings: creationSettings };
|
||||
try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); }
|
||||
catch (error) {
|
||||
if (error instanceof Error && "status" in error) throw error;
|
||||
throw providerFailure("slack_provider_failure");
|
||||
}
|
||||
// No provider creation has happened until this intent is durable.
|
||||
await endpoint(endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const,
|
||||
manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect,
|
||||
createdByUserId: actor.userId, managerGrantId: managed?.grantId ?? null, workspaceId: managed?.workspaceId ?? null, errorCode: null, updatedAt: new Date() };
|
||||
const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values })
|
||||
.onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning();
|
||||
await audit(row, actor, "creation_started");
|
||||
let createdAppId: string;
|
||||
try {
|
||||
const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken);
|
||||
const credentials = object(result.credentials);
|
||||
if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id)
|
||||
|| typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id)
|
||||
|| typeof credentials.client_secret !== "string" || !credentials.client_secret
|
||||
|| typeof credentials.signing_secret !== "string" || !credentials.signing_secret)
|
||||
throw new Error("Incomplete Slack creation response");
|
||||
if (managed && result.team_id !== managed.workspaceId) throw new Error("Slack creation workspace mismatch");
|
||||
await endpoint(endpointId, actor);
|
||||
await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease,
|
||||
{ appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" });
|
||||
createdAppId = result.app_id;
|
||||
} catch (error) {
|
||||
// Only documented rejection responses prove that creation did not happen.
|
||||
const code = object(object(error).details).code;
|
||||
const rejected = typeof code === "string" && [...Object.values(knownProviderErrors), "slack_manager_reauthorize"].includes(code);
|
||||
await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease);
|
||||
await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain");
|
||||
return;
|
||||
}
|
||||
// The app and credentials are durable before optional icon provisioning.
|
||||
// A retry/restart always reuses this app, even if icon upload is interrupted.
|
||||
const created = { ...row, appId: createdAppId };
|
||||
if (!await configureManifest(created, actor, input.credentials.configurationToken, lease, method, api)) return;
|
||||
await configureAvatar(created, actor, input.credentials.configurationToken, lease, api);
|
||||
// An audit outage cannot change the saved creation outcome.
|
||||
try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); }
|
||||
catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); }
|
||||
});
|
||||
}
|
||||
async function configureAvatar(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, api = store.api) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
await saveAvatar(row, { status: "pending" }, actor, lease);
|
||||
let avatar: SlackAvatarState;
|
||||
try {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
// Cloud ingress requires a tenant session. Send the preset PNG bytes so
|
||||
// Slack does not need to fetch an authenticated Paperclip URL.
|
||||
const render = renderAvatar;
|
||||
const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) };
|
||||
delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets.
|
||||
const png = await render({ appearance,
|
||||
size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" });
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
const upload = new FormData();
|
||||
upload.set("app_id", row.appId!);
|
||||
upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png");
|
||||
await api("apps.icon.set", upload, configurationToken);
|
||||
avatar = { status: "uploaded", uploadedAt: new Date().toISOString() };
|
||||
} catch {
|
||||
// Provider echoes (including tokens) never become saved state or activity.
|
||||
avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" };
|
||||
}
|
||||
await saveAvatar(row, avatar, actor, lease);
|
||||
try { await audit(row, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); }
|
||||
catch { logger.warn({ endpointId: row.endpointId, appId: row.appId! }, "Slack avatar activity could not be recorded"); }
|
||||
}
|
||||
async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, method: "automatic" | "managed" = "automatic", api = store.api) {
|
||||
const current = await endpoint(row.endpointId, actor);
|
||||
if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== method)
|
||||
throw conflict("Resume the saved Slack connection before configuring its app");
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
await lease.assertOwned();
|
||||
try {
|
||||
await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken);
|
||||
} catch {
|
||||
// The pending marker was saved with the app secrets before dispatch. Retrying
|
||||
// the same manifest is safe even when Slack's response was lost.
|
||||
await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending");
|
||||
return false;
|
||||
}
|
||||
await endpoint(row.endpointId, actor);
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId)));
|
||||
await audit(row, actor, "manifest_updated");
|
||||
return true;
|
||||
}
|
||||
return { create, configureManifest };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js";
|
||||
import { createSlackSetupClient, object, providerFailure } from "./provider-client.js";
|
||||
|
||||
export type SlackManagerClient = Pick<PaperclipCloudConnector,
|
||||
"getSlackManagerAppId" | "startAuthorization" | "claim" | "refresh" | "revoke"
|
||||
> & { authorizationMode?: "code" };
|
||||
|
||||
// Explicit local qualification only. Production continues using the sealed Cloud
|
||||
// broker; ordinary self-hosted installs neither load credentials nor contact it.
|
||||
export function developmentSlackManager(
|
||||
publicOrigin: () => string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): SlackManagerClient | null {
|
||||
if (env.NODE_ENV !== "development" || env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true"
|
||||
|| !env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG) return null;
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
const path = env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG;
|
||||
if (!isAbsolute(path)) throw new Error();
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error();
|
||||
config = object(JSON.parse(readFileSync(path, "utf8")));
|
||||
} catch { throw providerFailure("slack_managed_unavailable"); }
|
||||
const { appId, clientId, clientSecret } = config;
|
||||
if (typeof appId !== "string" || !/^A[A-Z0-9]+$/.test(appId)
|
||||
|| typeof clientId !== "string" || !/^\d+\.\d+$/.test(clientId)
|
||||
|| typeof clientSecret !== "string" || !clientSecret) throw providerFailure("slack_managed_unavailable");
|
||||
const callback = () => {
|
||||
const url = new URL(publicOrigin());
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw providerFailure("slack_managed_unavailable");
|
||||
return `${url.origin}/api/chat-slack/managed/oauth/callback`;
|
||||
};
|
||||
const api = createSlackSetupClient(fetchImpl);
|
||||
async function credentials(fields: Record<string, string>, subject: string, companyId: string, initial: boolean): Promise<SealedConnectorCredentials> {
|
||||
let result: Record<string, unknown>;
|
||||
try { result = await api("oauth.v2.access", { client_id: clientId as string, client_secret: clientSecret as string, ...fields }); }
|
||||
catch { throw providerFailure("slack_manager_reauthorize"); }
|
||||
const user = initial ? object(result.authed_user) : result;
|
||||
const scopes = typeof user.scope === "string" ? user.scope.split(/[, ]+/) : [];
|
||||
const team = object(result.team);
|
||||
if (typeof user.access_token !== "string" || !user.access_token || typeof user.refresh_token !== "string" || !user.refresh_token
|
||||
|| user.token_type !== "user" || typeof user.expires_in !== "number" || !Number.isFinite(user.expires_in) || user.expires_in <= 0
|
||||
|| SLACK_MANAGER_SCOPES.some(scope => !scopes.includes(scope))
|
||||
|| (initial && (result.app_id !== appId || typeof team.id !== "string" || !/^T[A-Z0-9]+$/.test(team.id)
|
||||
|| typeof team.name !== "string" || !team.name || typeof user.id !== "string" || !/^[UW][A-Z0-9]+$/.test(user.id)))) throw providerFailure("slack_manager_reauthorize");
|
||||
return {
|
||||
v: 1, accessToken: user.access_token, refreshToken: user.refresh_token, tokenType: "user",
|
||||
accessTokenExpiresAt: new Date(Date.now() + user.expires_in * 1000).toISOString(), refreshTokenExpiresAt: null,
|
||||
scopes, subject, companyId, instanceId: "local-slack-qualification", environment: "development", provider: "slack", profile: "slack.manager",
|
||||
...(initial ? { slackManager: { appId: appId as string, workspaceId: team.id as string, workspaceName: team.name as string, userId: user.id as string } } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
authorizationMode: "code",
|
||||
async getSlackManagerAppId() { callback(); return appId as string; },
|
||||
async startAuthorization(values) {
|
||||
if (values.returnUri !== callback() || values.profile !== "slack.manager") throw providerFailure("slack_manager_reauthorize");
|
||||
const url = new URL("https://slack.com/oauth/v2/authorize");
|
||||
url.search = new URLSearchParams({ client_id: clientId as string, user_scope: SLACK_MANAGER_SCOPES.join(","), redirect_uri: callback(), state: values.returnState }).toString();
|
||||
return { authorizationUrl: url.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() };
|
||||
},
|
||||
async claim(values) { return credentials({ code: values.claimId, redirect_uri: callback() }, values.subject, values.companyId, true); },
|
||||
async refresh(values) { return credentials({ grant_type: "refresh_token", refresh_token: values.refreshToken }, values.subject, values.companyId, false); },
|
||||
async revoke(values) {
|
||||
try { await api("auth.revoke", {}, values.token); }
|
||||
catch { throw providerFailure("slack_manager_reauthorize"); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { chatActions, chatSlackRegistrations } from "@paperclipai/db";
|
||||
import { type SlackManagedProvisionInput, type SlackManagedResult } from "@paperclipai/shared";
|
||||
import { conflict } from "../../../../errors.js";
|
||||
import type { createCustomerOwnedSetup } from "./customer-owned.js";
|
||||
import type { createSlackManagerGrants } from "./manager-grants.js";
|
||||
import type { createSlackCompletion } from "./completion.js";
|
||||
import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js";
|
||||
|
||||
/** Managed provisioning ends at the same saved-credential boundary as child OAuth. */
|
||||
export function createManagedSlackSetup(store: SlackRegistrationStore, grants: ReturnType<typeof createSlackManagerGrants>,
|
||||
provisioner: ReturnType<typeof createCustomerOwnedSetup>, completion: ReturnType<typeof createSlackCompletion>,
|
||||
installFallback: (endpointId: string, actor: SlackSetupActor) => Promise<{ authorizationUrl: string; expiresAt: string }>) {
|
||||
async function provision(endpointId: string, actor: SlackSetupActor, input: SlackManagedProvisionInput): Promise<SlackManagedResult> {
|
||||
const current = await store.endpoint(endpointId, actor);
|
||||
if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method");
|
||||
const authorized = await grants.token(input.grantId, current.endpoint.companyId, actor);
|
||||
await provisioner.create(endpointId, actor, { requestId: input.requestId, confirmedNoAppCreated: input.confirmedNoAppCreated,
|
||||
credentials: { configurationToken: authorized.token } }, { grantId: authorized.grant.id, workspaceId: authorized.grant.workspaceId, request: (method, fields) => grants.request(input.grantId, current.endpoint.companyId, actor, method, fields) });
|
||||
let fallback = false;
|
||||
await store.options.withLock(endpointId, async lease => {
|
||||
const latest = await store.endpoint(endpointId, actor);
|
||||
const row = await store.registration(endpointId);
|
||||
if (!row?.appId || row.status === "removed" || row.errorCode === "slack_manifest_update_pending") return;
|
||||
if (row.managerGrantId !== authorized.grant.id || latest.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Slack setup changed");
|
||||
store.assertOrigins(row, latest.endpoint.publicId);
|
||||
const reconnectKey = input.reconnect ? `slack_managed_reconnect:${actor.userId}:${input.requestId}` : null;
|
||||
const [previous] = reconnectKey ? await store.db.select().from(chatActions).where(and(eq(chatActions.companyId, row.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))) : [];
|
||||
async function completeSaved() {
|
||||
await completion.resumeLocked(endpointId, actor, lease);
|
||||
if (reconnectKey && (await store.registration(endpointId))?.status === "configured") {
|
||||
await lease.assertOwned();
|
||||
await store.db.update(chatActions).set({ status: "processed", updatedAt: new Date() }).where(and(eq(chatActions.companyId, row!.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey)));
|
||||
}
|
||||
}
|
||||
if (previous && row.status === "configured" && !["revoked", "attention"].includes(latest.endpoint.status)) {
|
||||
await completeSaved(); return;
|
||||
}
|
||||
const needsReinstall = row.status === "configured" && (input.reconnect || ["revoked", "attention"].includes(latest.endpoint.status))
|
||||
|| row.status === "credentials_saved" && ["slack_install_token_invalid", "slack_install_scopes_missing"].includes(row.errorCode ?? "");
|
||||
if (reconnectKey && !previous) {
|
||||
// Atomically bind an explicit reconnect to its durable installation intent.
|
||||
// Duplicate requests can return the saved connection without reinstalling.
|
||||
await store.db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
await tx.insert(chatActions).values({ companyId: row.companyId, endpointId, kind: "slack_managed_reconnect", providerActionId: reconnectKey, status: "processing", payload: { grantId: row.managerGrantId, registrationRequestId: row.requestId } });
|
||||
if (needsReinstall) await tx.update(chatSlackRegistrations).set({ status: "install", errorCode: row.errorCode ?? "slack_install_failed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
}
|
||||
if ((row.status === "configured" || row.status === "credentials_saved") && !needsReinstall) {
|
||||
await completeSaved();
|
||||
return;
|
||||
}
|
||||
// Keep reinstall intent durable even if this dispatch fails or the process exits.
|
||||
if (needsReinstall && (!reconnectKey || previous)) await store.setFailure(row, "install", row.errorCode ?? "slack_install_failed", lease);
|
||||
const { grant } = await grants.token(input.grantId, row.companyId, actor);
|
||||
try {
|
||||
await lease.assertOwned();
|
||||
const result = await grants.request(grant.id, row.companyId, actor, "apps.managedInstall", { app_id: row.appId, team_id: grant.workspaceId });
|
||||
const botToken = object(result.api_access_tokens).bot_access_token;
|
||||
if (result.app_id !== row.appId || result.team_id !== grant.workspaceId || typeof botToken !== "string" || !botToken.startsWith("xoxb-")) throw providerFailure("slack_install_identity_mismatch");
|
||||
if ((await grants.get(grant.id, row.companyId, actor)).status !== "active") throw providerFailure("slack_manager_reauthorize");
|
||||
await store.endpoint(endpointId, actor);
|
||||
// Vault before auth.test/inventory so network failures never lose this installation.
|
||||
await store.saveSecrets(row, { botToken }, actor, lease, { status: "credentials_saved", workspaceId: grant.workspaceId, errorCode: null },
|
||||
{ externalUserId: grant.slackUserId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" });
|
||||
} catch (error) {
|
||||
const code = object(object(error).details).code;
|
||||
const safe = typeof code === "string" ? code : "slack_install_failed";
|
||||
await store.setFailure(row, needsReinstall ? "install" : row.status, safe, lease);
|
||||
await store.audit(row, actor, "installation_failed", safe);
|
||||
fallback = ["slack_approval_required", "slack_approval_pending", "slack_approval_denied"].includes(safe);
|
||||
return;
|
||||
}
|
||||
await completeSaved();
|
||||
});
|
||||
// OAuth's state/actor binding is ours; never trust a provider-supplied redirect.
|
||||
return fallback ? { authorization: await installFallback(endpointId, actor) } : {};
|
||||
}
|
||||
return { provision };
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, eq, gt, like, sql } from "drizzle-orm";
|
||||
import { chatEndpoints, chatSlackManagerGrants, chatSlackRegistrations, companySecrets, toolOauthStates } from "@paperclipai/db";
|
||||
import { SLACK_MANAGER_SCOPES, type SlackSetupOptions as SetupChoices } from "@paperclipai/shared";
|
||||
import { conflict, forbidden } from "../../../../errors.js";
|
||||
import { getCloudRuntimeIdentity } from "../../../cloud-runtime-identity.js";
|
||||
import { createPaperclipCloudConnector, paperclipCloudConnectorConfigFromEnv, type SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js";
|
||||
import { instanceSettingsService } from "../../../instance-settings.js";
|
||||
import { accessService } from "../../../access.js";
|
||||
import { secretService } from "../../../secrets.js";
|
||||
import { writeConnectionCredential } from "../../../connection-credentials.js";
|
||||
import { logActivity } from "../../../activity-log.js";
|
||||
import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js";
|
||||
import { developmentSlackManager, type SlackManagerClient } from "./development-manager.js";
|
||||
|
||||
export const SLACK_MANAGER_STATE_PREFIX = "slack-manager.";
|
||||
export function createSlackManagerGrants(store: SlackRegistrationStore) {
|
||||
const { db, options, endpoint, origin, api } = store;
|
||||
type Database = Parameters<typeof secretService>[0];
|
||||
function broker(): SlackManagerClient | null {
|
||||
if (options.managed) return options.managed.enabled ? options.managed.broker : null;
|
||||
if (!getCloudRuntimeIdentity()) {
|
||||
const development = developmentSlackManager(() => origin(options.publicOrigin()));
|
||||
if (development) return development;
|
||||
}
|
||||
// Open-source instances never contact a hosted service to discover this path.
|
||||
if (!getCloudRuntimeIdentity() || process.env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true") return null;
|
||||
const config = paperclipCloudConnectorConfigFromEnv();
|
||||
return config ? createPaperclipCloudConnector({ config }) : null;
|
||||
}
|
||||
async function access(companyId: string, actor: SlackSetupActor, database: Database = db) {
|
||||
if (!(await instanceSettingsService(database as typeof db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
|
||||
if (!actor.bypassPermissionCheck && !await accessService(database as typeof db).hasPermission(companyId, "user", actor.userId, "tools:manage_connections")) throw forbidden("Missing permission: tools:manage_connections");
|
||||
}
|
||||
// Deployment intent is stable across broker outages and preview rollbacks.
|
||||
// A missing live profile pauses managed setup; it must not select token entry.
|
||||
function defaultMethod(): "managed" | "automatic" { return broker() ? "managed" : "automatic"; }
|
||||
async function available() {
|
||||
const client = broker();
|
||||
if (!client || !(await instanceSettingsService(db).getExperimental()).enableChatConnectors) return false;
|
||||
try { origin(options.publicOrigin()); origin(options.webhookOrigin()); return Boolean(await client.getSlackManagerAppId()); }
|
||||
catch { return false; }
|
||||
}
|
||||
async function requiredBroker() {
|
||||
if (!await available()) throw providerFailure("slack_managed_unavailable");
|
||||
return broker()!;
|
||||
}
|
||||
async function choices(companyId: string, actor: SlackSetupActor): Promise<SetupChoices> {
|
||||
await access(companyId, actor);
|
||||
const managedAvailable = await available();
|
||||
const managerAppId = managedAvailable ? await broker()!.getSlackManagerAppId() : null;
|
||||
const grants = managedAvailable && managerAppId ? await db.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.status, "active"), eq(chatSlackManagerGrants.managerAppId, managerAppId))) : [];
|
||||
return { managedAvailable, defaultMethod: defaultMethod(), workspaces: grants.map(g => ({ grantId: g.id, workspaceId: g.workspaceId, workspaceName: g.workspaceName, userId: g.slackUserId })) };
|
||||
}
|
||||
const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/managed/oauth/callback`;
|
||||
async function authorize(endpointId: string, actor: SlackSetupActor) {
|
||||
const client = await requiredBroker();
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method");
|
||||
const row = await store.registration(endpointId);
|
||||
const revisions = await db.select({ id: chatSlackManagerGrants.id, revision: chatSlackManagerGrants.revision }).from(chatSlackManagerGrants)
|
||||
.where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId)));
|
||||
const state = `${SLACK_MANAGER_STATE_PREFIX}${randomBytes(32).toString("base64url")}`;
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
await lease.assertOwned();
|
||||
await db.insert(toolOauthStates).values({ state, companyId: current.endpoint.companyId, connectionId: current.connection.id,
|
||||
codeVerifier: JSON.stringify({ endpointId, revisions, authorizationMode: client.authorizationMode ?? "claim", requestId: row?.requestId ?? null, callbackUri: callbackUri(), webhookOrigin: origin(options.webhookOrigin()), runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }),
|
||||
createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, requestedScopes: [...SLACK_MANAGER_SCOPES], expiresAt });
|
||||
try { return await client.startAuthorization({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, returnUri: callbackUri(), returnState: state }); }
|
||||
catch { await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); throw providerFailure("slack_manager_reauthorize"); }
|
||||
});
|
||||
}
|
||||
async function pending(state: string, actor: SlackSetupActor, allowExpired = false) {
|
||||
if (!/^slack-manager\.[A-Za-z0-9_-]{43}$/.test(state)) throw forbidden("Invalid Slack authorization state");
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), ...(allowExpired ? [] : [gt(toolOauthStates.expiresAt, new Date())])));
|
||||
if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) throw forbidden("Slack authorization expired or belongs to another session");
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
const row = await store.registration(current.endpoint.id);
|
||||
if (current.endpoint.setup.slackSetupMethod !== "managed" || current.connection.id !== attempt.connectionId || current.endpoint.companyId !== attempt.companyId
|
||||
|| ["paused", "revoked"].includes(current.endpoint.status) || row?.status === "removed"
|
||||
|| (binding.authorizationMode ?? "claim") !== (broker()?.authorizationMode ?? "claim")
|
||||
|| (row?.requestId ?? null) !== binding.requestId || binding.callbackUri !== callbackUri() || binding.webhookOrigin !== origin(options.webhookOrigin())
|
||||
|| binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)) throw conflict("Slack setup changed. Resume authorization.");
|
||||
return { attempt, current, row, binding };
|
||||
}
|
||||
async function expiredReturn(state: string, actor: SlackSetupActor) {
|
||||
const { current, attempt } = await pending(state, actor, true);
|
||||
if (attempt.expiresAt.getTime() > Date.now()) return null;
|
||||
return options.withLock(current.endpoint.id, async lease => {
|
||||
const latest = await pending(state, actor, true);
|
||||
await lease.assertOwned();
|
||||
await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
await db.update(chatEndpoints).set({ setup: { ...latest.current.endpoint.setup, slackManagerError: "slack_install_expired" } }).where(eq(chatEndpoints.id, current.endpoint.id));
|
||||
return current.endpoint.id;
|
||||
});
|
||||
}
|
||||
async function complete(state: string, claimId: string | null, error: string | null, actor: SlackSetupActor, authorizationCode: string | null = null) {
|
||||
const client = await requiredBroker();
|
||||
claimId = client.authorizationMode === "code" ? authorizationCode : claimId;
|
||||
const before = await pending(state, actor);
|
||||
return options.withLock(before.current.endpoint.id, async lease => {
|
||||
const { current, row, binding } = await pending(state, actor);
|
||||
await lease.assertOwned();
|
||||
const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning();
|
||||
if (!claimed) throw conflict("Slack authorization was already used");
|
||||
async function outcome(code: string | null) {
|
||||
await lease.assertOwned();
|
||||
const latest = await endpoint(current.endpoint.id, actor);
|
||||
await db.update(chatEndpoints).set({ setup: { ...latest.endpoint.setup, slackManagerError: code ?? undefined } }).where(eq(chatEndpoints.id, current.endpoint.id));
|
||||
if (code) await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorization_failed", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { code } });
|
||||
return current.endpoint.id;
|
||||
}
|
||||
if (error || !claimId || claimId.length > 4096) return outcome(error === "access_denied" ? "slack_install_declined" : "slack_manager_reauthorize");
|
||||
try {
|
||||
let grant: SealedConnectorCredentials;
|
||||
try { grant = await client.claim({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, claimId, redemptionId: randomUUID() }); }
|
||||
catch { throw providerFailure("slack_manager_reauthorize"); }
|
||||
const identity = grant.slackManager;
|
||||
if (!identity || identity.appId !== await client.getSlackManagerAppId() || !/^A[A-Z0-9]+$/.test(identity.appId) || !/^T[A-Z0-9]+$/.test(identity.workspaceId) || !/^[UW][A-Z0-9]+$/.test(identity.userId) || typeof identity.workspaceName !== "string" || !identity.workspaceName || identity.workspaceName.length > 255) throw providerFailure("slack_manager_reauthorize");
|
||||
if (row?.managerGrantId) {
|
||||
const existing = await get(row.managerGrantId, current.endpoint.companyId, actor);
|
||||
if (existing.managerAppId !== identity.appId || existing.workspaceId !== identity.workspaceId || existing.slackUserId !== identity.userId) throw providerFailure("slack_install_identity_mismatch");
|
||||
}
|
||||
await endpoint(current.endpoint.id, actor);
|
||||
await access(current.endpoint.companyId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager:${current.endpoint.companyId}:${actor.userId}:${identity.appId}:${identity.workspaceId}`}, 0))`);
|
||||
const [existing] = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.managerAppId, identity.appId), eq(chatSlackManagerGrants.workspaceId, identity.workspaceId)));
|
||||
if (existing) {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${existing.id}`}, 0))`);
|
||||
const [latest] = await tx.select().from(chatSlackManagerGrants).where(eq(chatSlackManagerGrants.id, existing.id));
|
||||
const revision = Array.isArray(binding.revisions) ? binding.revisions.map(object).find(value => value.id === existing.id)?.revision : undefined;
|
||||
if (!latest || latest.revision !== revision) throw conflict("Workspace authorization changed. Start again.");
|
||||
}
|
||||
const [saved] = await tx.insert(chatSlackManagerGrants).values({ companyId: current.endpoint.companyId, userId: actor.userId, slackUserId: identity.userId, workspaceId: identity.workspaceId, workspaceName: identity.workspaceName, managerAppId: identity.appId })
|
||||
.onConflictDoUpdate({ target: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.userId, chatSlackManagerGrants.managerAppId, chatSlackManagerGrants.workspaceId], set: { updatedAt: new Date() } }).returning();
|
||||
// A reauthorization must never change which Slack person this grant represents.
|
||||
if (saved.slackUserId !== identity.userId) throw conflict("Reconnect using the original Slack account");
|
||||
await access(current.endpoint.companyId, actor, tx);
|
||||
await save(tx, saved, grant, actor);
|
||||
if (row?.managerGrantId === saved.id) {
|
||||
await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, current.endpoint.id), eq(chatSlackRegistrations.managerGrantId, saved.id), eq(chatSlackRegistrations.errorCode, "slack_manager_reauthorize")));
|
||||
}
|
||||
});
|
||||
await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorized", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { workspaceId: identity.workspaceId, managerAppId: identity.appId } });
|
||||
return outcome(null);
|
||||
} catch { return outcome("slack_manager_reauthorize"); }
|
||||
});
|
||||
}
|
||||
type Grant = typeof chatSlackManagerGrants.$inferSelect;
|
||||
async function get(id: string, companyId: string, actor: SlackSetupActor) {
|
||||
await access(companyId, actor);
|
||||
return readGrant(db, id, companyId, actor);
|
||||
}
|
||||
async function readGrant(database: Database, id: string, companyId: string, actor: SlackSetupActor) {
|
||||
await access(companyId, actor, database);
|
||||
const [grant] = await database.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.id, id), eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId)));
|
||||
if (!grant) throw forbidden("This Slack workspace authorization belongs to another account");
|
||||
return grant;
|
||||
}
|
||||
async function save(tx: Parameters<Parameters<typeof db.transaction>[0]>[0], row: Grant, credentials: SealedConnectorCredentials, actor: SlackSetupActor) {
|
||||
if (credentials.tokenType !== "user" || !credentials.accessToken || !credentials.refreshToken || !credentials.accessTokenExpiresAt || !Number.isFinite(Date.parse(credentials.accessTokenExpiresAt)) || Date.parse(credentials.accessTokenExpiresAt) <= Date.now() || credentials.provider !== "slack" || credentials.profile !== "slack.manager" || credentials.subject !== row.userId || credentials.companyId !== row.companyId || SLACK_MANAGER_SCOPES.some(scope => !credentials.scopes.includes(scope))) throw providerFailure("slack_manager_reauthorize");
|
||||
const write = (key: string, value: string, secretId: string | null) => writeConnectionCredential(tx, { companyId: row.companyId, ownerUserId: row.userId, actor: { userId: actor.userId }, connectionName: "Slack workspace management", configPath: `slack_manager.${key}`, definitionKey: `slack_manager.${row.id}.${key}`, label: key, value, ...(secretId ? { existingRef: { secretId, configPath: `slack_manager.${key}` } } : {}) });
|
||||
const accessToken = await write("access_token", credentials.accessToken, row.accessSecretId);
|
||||
const refreshToken = credentials.refreshToken ? await write("refresh_token", credentials.refreshToken, row.refreshSecretId) : null;
|
||||
await tx.update(chatSlackManagerGrants).set({ accessSecretId: accessToken.secret.id, refreshSecretId: refreshToken?.secret.id ?? row.refreshSecretId, expiresAt: credentials.accessTokenExpiresAt ? new Date(credentials.accessTokenExpiresAt) : null, status: "active", revision: row.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, row.id));
|
||||
}
|
||||
async function secret(row: Grant, id: string | null, database: Database = db) {
|
||||
if (!id) throw providerFailure("slack_manager_reauthorize");
|
||||
const [ref] = await database.select().from(companySecrets).where(and(eq(companySecrets.id, id), eq(companySecrets.companyId, row.companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, row.userId)));
|
||||
if (!ref?.userSecretDefinitionId) throw providerFailure("slack_manager_reauthorize");
|
||||
// Owner-scoped system consumer; the durable grant is its authority, not a tool binding.
|
||||
const value = await secretService(database).resolveUserSecretValue(row.companyId, { definitionId: ref.userSecretDefinitionId, responsibleUserId: row.userId }, { consumerType: "system", consumerId: `slack-manager:${row.id}`, actorType: "user", actorId: row.userId, responsibleUserId: row.userId });
|
||||
if (!value) throw providerFailure("slack_manager_reauthorize");
|
||||
return value.value;
|
||||
}
|
||||
const tokenRequests = new Map<string, Promise<{ grant: Grant; token: string }>>();
|
||||
async function token(id: string, companyId: string, actor: SlackSetupActor) {
|
||||
await access(companyId, actor);
|
||||
const client = await requiredBroker();
|
||||
const managerAppId = await client.getSlackManagerAppId();
|
||||
const key = `${companyId}:${actor.userId}:${id}`;
|
||||
const running = tokenRequests.get(key);
|
||||
if (running) return running;
|
||||
const operation = (async () => {
|
||||
// Commit the single-use refresh claim before dispatch, without holding a
|
||||
// pool connection while another query or the broker needs it.
|
||||
const prepared = await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`);
|
||||
const grant = await readGrant(tx, id, companyId, actor);
|
||||
if (grant.managerAppId !== managerAppId || grant.status !== "active") throw providerFailure("slack_manager_reauthorize");
|
||||
if (!grant.expiresAt || grant.expiresAt.getTime() > Date.now() + 60_000) return { grant, token: await secret(grant, grant.accessSecretId, tx) };
|
||||
const refreshToken = await secret(grant, grant.refreshSecretId, tx);
|
||||
const [claimed] = await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: grant.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)).returning();
|
||||
return { grant: claimed!, refreshToken };
|
||||
});
|
||||
if ("token" in prepared) return { grant: prepared.grant, token: prepared.token! };
|
||||
try {
|
||||
const refreshed = await client.refresh({ profile: "slack.manager", subject: actor.userId, companyId, refreshToken: prepared.refreshToken });
|
||||
await access(companyId, actor);
|
||||
return await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`);
|
||||
const current = await readGrant(tx, id, companyId, actor);
|
||||
if (current.status !== "reauthorize" || current.revision !== prepared.grant.revision) throw providerFailure("slack_manager_reauthorize");
|
||||
await save(tx, current, refreshed, actor);
|
||||
return { grant: await readGrant(tx, id, companyId, actor), token: refreshed.accessToken };
|
||||
});
|
||||
} catch { throw providerFailure("slack_manager_reauthorize"); }
|
||||
})();
|
||||
tokenRequests.set(key, operation);
|
||||
try { return await operation; } finally { if (tokenRequests.get(key) === operation) tokenRequests.delete(key); }
|
||||
}
|
||||
async function revoke(id: string, companyId: string, actor: SlackSetupActor) {
|
||||
await access(companyId, actor);
|
||||
let providerRevoked = true;
|
||||
const grant = await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`);
|
||||
const current = await readGrant(tx, id, companyId, actor);
|
||||
await tx.update(chatSlackManagerGrants).set({ status: "revoked", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id));
|
||||
await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.companyId, companyId), eq(toolOauthStates.createdByActorId, actor.userId), like(toolOauthStates.state, `${SLACK_MANAGER_STATE_PREFIX}%`)));
|
||||
const client = broker();
|
||||
for (const secretId of [current.accessSecretId, current.refreshSecretId]) if (secretId) {
|
||||
try {
|
||||
if (!client) throw providerFailure("slack_managed_unavailable");
|
||||
await client.revoke({ subject: actor.userId, companyId, profile: "slack.manager", token: await secret(current, secretId, tx) });
|
||||
} catch { providerRevoked = false; }
|
||||
await secretService(tx).remove(secretId);
|
||||
}
|
||||
await tx.update(chatSlackManagerGrants).set({ accessSecretId: null, refreshSecretId: null }).where(eq(chatSlackManagerGrants.id, id));
|
||||
return current;
|
||||
});
|
||||
await logActivity(db, { companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_revoked", entityType: "slack_manager_grant", entityId: id, details: { workspaceId: grant.workspaceId, providerRevoked } });
|
||||
}
|
||||
async function request(id: string, companyId: string, actor: SlackSetupActor, method: string, fields: Record<string, string> | FormData) {
|
||||
const authorized = await token(id, companyId, actor);
|
||||
const { grant } = authorized;
|
||||
// Slack shares a method budget across manager tokens in the same workspace.
|
||||
// Persist Retry-After across grants and process restarts; never retry a dispatch.
|
||||
const result = await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-api:${companyId}:${grant.managerAppId}:${grant.workspaceId}:${method}`}, 0))`);
|
||||
// Serialize provider dispatch against refresh/revocation for this personal grant.
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`);
|
||||
const peers = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.managerAppId, grant.managerAppId), eq(chatSlackManagerGrants.workspaceId, grant.workspaceId))).orderBy(chatSlackManagerGrants.id);
|
||||
if (peers.some(peer => Date.parse(peer.rateLimits[method] ?? "") > Date.now())) return { error: providerFailure("slack_setup_rate_limited") };
|
||||
const current = await readGrant(tx, id, companyId, actor);
|
||||
if (current.status !== "active") return { error: providerFailure("slack_manager_reauthorize") };
|
||||
try { return { value: await api(method, fields, await secret(current, current.accessSecretId, tx)) }; }
|
||||
catch (error) {
|
||||
const details = object(object(error).details);
|
||||
if (details.code === "slack_setup_rate_limited") {
|
||||
const until = new Date(Date.now() + Math.min(Number(details.retryAfterSeconds) || 60, 86_400) * 1000).toISOString();
|
||||
for (const peer of peers) await tx.update(chatSlackManagerGrants).set({ rateLimits: sql`${chatSlackManagerGrants.rateLimits} || ${JSON.stringify({ [method]: until })}::jsonb` }).where(eq(chatSlackManagerGrants.id, peer.id));
|
||||
}
|
||||
if (details.code === "slack_configuration_token_invalid") {
|
||||
await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id));
|
||||
return { error: providerFailure("slack_manager_reauthorize") };
|
||||
}
|
||||
return { error };
|
||||
}
|
||||
});
|
||||
if ("error" in result) throw result.error;
|
||||
return result.value!;
|
||||
}
|
||||
return { choices, defaultMethod, available, authorize, pending, expiredReturn, complete, get, token, revoke, request };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { slackRegistrationErrorMessage } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../../../../errors.js";
|
||||
export const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
export const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code });
|
||||
export const knownProviderErrors: Record<string, string> = {
|
||||
app_approval_request_eligible: "slack_approval_required", app_approval_request_pending: "slack_approval_pending",
|
||||
app_approval_request_denied: "slack_approval_denied", admin_approval_required: "slack_approval_required",
|
||||
manager_app_not_eligible: "slack_managed_unavailable", feature_not_enabled: "slack_managed_unavailable",
|
||||
invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid",
|
||||
token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid",
|
||||
invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid",
|
||||
ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied",
|
||||
missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid",
|
||||
};
|
||||
|
||||
const timeoutMs = 20_000;
|
||||
export function createSlackSetupClient(fetchImpl: typeof fetch = fetch) {
|
||||
return async function api(method: string, fields: Record<string, string> | FormData, bearer?: string, requiredScopes?: readonly string[]) {
|
||||
const response = await fetchImpl(`https://slack.com/api/${method}`, {
|
||||
method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },
|
||||
body: fields instanceof FormData ? fields : new URLSearchParams(fields),
|
||||
});
|
||||
// Bound the streamed response as well as the request lifetime.
|
||||
const reader = response.body?.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
size += part.value.byteLength;
|
||||
if (size > 1_048_576) { await reader.cancel(); throw providerFailure("slack_provider_failure"); }
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
}
|
||||
let result: Record<string, unknown>;
|
||||
try { result = object(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
||||
catch { result = {}; }
|
||||
if (!response.ok || result.ok !== true) {
|
||||
const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure");
|
||||
const retry = Number(response.headers.get("retry-after"));
|
||||
if (response.status === 429 || result.error === "ratelimited") {
|
||||
throw unprocessable(slackRegistrationErrorMessage("slack_setup_rate_limited"), {
|
||||
code: "slack_setup_rate_limited", retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? Math.ceil(retry) : 60,
|
||||
});
|
||||
}
|
||||
throw providerFailure(code);
|
||||
}
|
||||
if (requiredScopes) {
|
||||
const granted = new Set((response.headers.get("x-oauth-scopes") ?? "").split(/[, ]+/));
|
||||
if (requiredScopes.some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { notifyChatVerificationWork } from "../../../chat-work-notifications.js";
|
||||
import { createAgentAvatarPool } from "../../../agent-avatar-pool.js";
|
||||
import type { AgentAvatarRequest } from "../../../agent-avatars.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { agents, chatEndpoints, chatSlackRegistrations, toolConnections, type Db } from "@paperclipai/db";
|
||||
import { slackRegistrationErrorMessage, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState, isProvisionedSlackSetup } from "@paperclipai/shared";
|
||||
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../../errors.js";
|
||||
import { accessService } from "../../../access.js";
|
||||
import { logActivity } from "../../../activity-log.js";
|
||||
import { instanceSettingsService } from "../../../instance-settings.js";
|
||||
import { secretService } from "../../../secrets.js";
|
||||
import { writeConnectionCredential } from "../../../connection-credentials.js";
|
||||
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
|
||||
|
||||
export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean }
|
||||
export type Registration = typeof chatSlackRegistrations.$inferSelect;
|
||||
|
||||
|
||||
export { object, providerFailure, knownProviderErrors } from "./provider-client.js";
|
||||
import { object } from "./provider-client.js";
|
||||
|
||||
export function slackRegistrationProjection(row: Registration): SlackRegistrationState {
|
||||
return {
|
||||
status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status,
|
||||
appId: row.appId,
|
||||
...(row.managerGrantId ? { managerGrantId: row.managerGrantId } : {}),
|
||||
managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps",
|
||||
errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode,
|
||||
};
|
||||
}
|
||||
|
||||
import { createSlackSetupClient } from "./provider-client.js";
|
||||
export type SlackSetupOptions = {
|
||||
managed?: { enabled: boolean; broker: import("../../../paperclip-cloud-connector.js").PaperclipCloudConnector };
|
||||
publicOrigin: () => string | null;
|
||||
webhookOrigin: () => string | null;
|
||||
fetch?: typeof fetch;
|
||||
renderAvatar?: (request: AgentAvatarRequest) => Promise<Buffer>;
|
||||
withLock: <T>(endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise<T>) => Promise<T>;
|
||||
runtimeSigningSecret: (endpointId: string) => Promise<string>;
|
||||
runtimeBotToken: (endpointId: string) => Promise<string>;
|
||||
canWelcome: (endpointId: string, account: SlackAccountState) => Promise<boolean>;
|
||||
linkInstaller: (endpointId: string, user: Record<string, unknown>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<void>;
|
||||
configure: (endpointId: string, credentials: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<unknown>;
|
||||
};
|
||||
export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions) {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const vault = secretService(db);
|
||||
let avatarPool: ReturnType<typeof createAgentAvatarPool> | undefined;
|
||||
function origin(value: string | null) {
|
||||
if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app");
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
||||
throw badRequest("Slack setup requires a configured public HTTPS origin");
|
||||
return url.origin;
|
||||
}
|
||||
const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`;
|
||||
async function registration(endpointId: string) {
|
||||
return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0];
|
||||
}
|
||||
async function endpoint(endpointId: string, actor: SlackSetupActor) {
|
||||
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
|
||||
const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance })
|
||||
.from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId)))
|
||||
.innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId)))
|
||||
.where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack")));
|
||||
if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found");
|
||||
if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections"))
|
||||
throw forbidden("Missing permission: tools:manage_connections");
|
||||
return row;
|
||||
}
|
||||
function assertOrigins(row: Registration, publicId: string) {
|
||||
if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup.");
|
||||
const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`;
|
||||
if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected)
|
||||
throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup.");
|
||||
}
|
||||
async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) {
|
||||
await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId,
|
||||
action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId,
|
||||
details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } });
|
||||
}
|
||||
const api = createSlackSetupClient(fetchImpl);
|
||||
async function saveSecrets(row: Registration, values: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard,
|
||||
patch: Partial<Registration> = {}, account?: SlackAccountState) {
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
|
||||
if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
const ids = { ...current.secretIds };
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration",
|
||||
configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId },
|
||||
...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) });
|
||||
ids[key] = result.secret.id;
|
||||
}
|
||||
await notifyChatVerificationWork(tx);
|
||||
await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId));
|
||||
if (account) {
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
if (!saved || saved.status === "archived") throw conflict("Slack setup changed");
|
||||
// Reauthorization never changes an established personal account binding.
|
||||
if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId));
|
||||
}
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
async function readSecret(row: Registration, key: string) {
|
||||
if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection.");
|
||||
return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", {
|
||||
accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" },
|
||||
});
|
||||
}
|
||||
async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) {
|
||||
await lease.assertOwned();
|
||||
await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() })
|
||||
.where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId)));
|
||||
}
|
||||
async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) {
|
||||
await endpoint(row.endpointId, actor);
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
|
||||
if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0];
|
||||
if (!saved || saved.status === "archived" || !isProvisionedSlackSetup(saved.setup.slackSetupMethod)) throw conflict("Slack setup changed. Resume the saved connection.");
|
||||
await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() })
|
||||
.where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId)));
|
||||
await lease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
const renderAvatar = (request: AgentAvatarRequest) => (options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render)(request);
|
||||
return { db, options, origin, callbackUri, registration, endpoint, assertOrigins, audit, api, saveSecrets, readSecret, setFailure, saveAvatar, renderAvatar,
|
||||
close: async () => { await avatarPool?.close(); } };
|
||||
}
|
||||
export type SlackRegistrationStore = ReturnType<typeof createSlackRegistrationStore>;
|
||||
@@ -0,0 +1,151 @@
|
||||
import { isProvisionedSlackSetup } from "@paperclipai/shared";
|
||||
import { createSlackManagerGrants } from "./manager-grants.js";
|
||||
import { createManagedSlackSetup } from "./managed.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, gt, like } from "drizzle-orm";
|
||||
import { chatEndpoints, chatSlackRegistrations, companies, toolOauthStates, type Db } from "@paperclipai/db";
|
||||
import { badRequest, conflict, forbidden, notFound } from "../../../../errors.js";
|
||||
import { removeSlackRegistration } from "../../../chat-slack-registration-cleanup.js";
|
||||
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
|
||||
import { object, providerFailure, createSlackRegistrationStore, type SlackSetupOptions, type SlackSetupActor } from "./registration.js";
|
||||
import { createCustomerOwnedSetup } from "./customer-owned.js";
|
||||
import { createSlackCompletion } from "./completion.js";
|
||||
const prefix = "slack-install.";
|
||||
export function slackChatRegistrationService(db: Db, options: SlackSetupOptions) {
|
||||
const store = createSlackRegistrationStore(db, options);
|
||||
const { endpoint, registration, assertOrigins, callbackUri, audit, api, readSecret, setFailure, saveSecrets } = store;
|
||||
const provisioner = createCustomerOwnedSetup(store);
|
||||
const { create } = provisioner;
|
||||
const completion = createSlackCompletion(store);
|
||||
const { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt } = completion;
|
||||
const managerGrants = createSlackManagerGrants(store);
|
||||
const managed = createManagedSlackSetup(store, managerGrants, provisioner, completion, install);
|
||||
async function install(endpointId: string, actor: SlackSetupActor) {
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const current = await endpoint(endpointId, actor);
|
||||
const row = await registration(endpointId);
|
||||
if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it");
|
||||
if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it");
|
||||
if (!isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it");
|
||||
if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor);
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
const state = `${prefix}${randomBytes(32).toString("base64url")}`;
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[];
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`)));
|
||||
await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id,
|
||||
codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri,
|
||||
endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }),
|
||||
createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId,
|
||||
requestedScopes: scopes, expiresAt });
|
||||
await tx.update(chatSlackRegistrations).set({ errorCode: row.managerGrantId ? row.errorCode : null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId));
|
||||
});
|
||||
const url = new URL("https://slack.com/oauth/v2/authorize");
|
||||
url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri,
|
||||
...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString();
|
||||
await audit(row, actor, "installation_started");
|
||||
return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() };
|
||||
});
|
||||
}
|
||||
async function expiredReturn(state: string, actor: SlackSetupActor) {
|
||||
if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null;
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null;
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
return options.withLock(current.endpoint.id, async lease => {
|
||||
const latest = await endpoint(current.endpoint.id, actor);
|
||||
const row = await registration(current.endpoint.id);
|
||||
if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId
|
||||
|| !isProvisionedSlackSetup(latest.endpoint.setup.slackSetupMethod) || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null;
|
||||
assertOrigins(row, latest.endpoint.publicId);
|
||||
await lease.assertOwned();
|
||||
await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state));
|
||||
await setFailure(row, row.status, "slack_install_expired", lease);
|
||||
await audit(row, actor, "installation_failed", "slack_install_expired");
|
||||
return returnPath(row.endpointId);
|
||||
});
|
||||
}
|
||||
async function pending(state: string, actor: SlackSetupActor) {
|
||||
if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state");
|
||||
const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date())));
|
||||
if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId)
|
||||
throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again.");
|
||||
const binding = object(JSON.parse(attempt.codeVerifier));
|
||||
const current = await endpoint(String(binding.endpointId), actor);
|
||||
const row = await registration(current.endpoint.id);
|
||||
if (!row || row.status === "removed" || !isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod)
|
||||
|| current.endpoint.status === "paused"
|
||||
|| current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked"
|
||||
|| binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)
|
||||
|| row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri
|
||||
|| row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id)
|
||||
throw conflict("Slack setup changed. Resume the saved connection and install again.");
|
||||
if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor);
|
||||
assertOrigins(row, current.endpoint.publicId);
|
||||
return { attempt, current, row };
|
||||
}
|
||||
async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) {
|
||||
const before = await pending(state, actor);
|
||||
return options.withLock(before.row.endpointId, async lease => {
|
||||
const { row, attempt, current } = await pending(state, actor);
|
||||
const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning();
|
||||
if (!claimed) throw conflict("This Slack authorization was already used");
|
||||
if (error) {
|
||||
const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed";
|
||||
await setFailure(row, row.status, failureCode, lease);
|
||||
await audit(row, actor, "installation_failed", failureCode);
|
||||
return row.endpointId;
|
||||
}
|
||||
try {
|
||||
if (!code || code.length > 4096) throw providerFailure("slack_install_failed");
|
||||
const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri });
|
||||
const teamId = object(result.team).id;
|
||||
const installerId = object(result.authed_user).id;
|
||||
if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-")
|
||||
|| typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id)
|
||||
|| row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id)
|
||||
throw providerFailure("slack_install_identity_mismatch");
|
||||
const granted = new Set(String(result.scope ?? "").split(","));
|
||||
if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing");
|
||||
if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId
|
||||
|| current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id)
|
||||
throw providerFailure("slack_install_identity_mismatch");
|
||||
if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing");
|
||||
if (row.managerGrantId) {
|
||||
const grant = await managerGrants.get(row.managerGrantId, row.companyId, actor);
|
||||
if (grant.status !== "active" || installerId !== grant.slackUserId || teamId !== grant.workspaceId) throw providerFailure("slack_install_identity_mismatch");
|
||||
}
|
||||
await endpoint(row.endpointId, actor);
|
||||
// Reauthorization reuses the runtime signing secret after staging was cleaned.
|
||||
await saveSecrets(row, { botToken: result.access_token }, actor, lease,
|
||||
{ status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null },
|
||||
{ externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" });
|
||||
} catch (failure) {
|
||||
const code = object(object(failure).details).code;
|
||||
const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed";
|
||||
await setFailure(row, row.status, safeCode, lease);
|
||||
await audit(row, actor, "installation_failed", safeCode);
|
||||
return row.endpointId;
|
||||
}
|
||||
await resumeLocked(row.endpointId, actor, lease);
|
||||
return row.endpointId;
|
||||
});
|
||||
}
|
||||
const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease);
|
||||
async function returnPath(endpointId: string) {
|
||||
const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints)
|
||||
.innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId));
|
||||
if (!row) throw notFound("Slack connection not found");
|
||||
return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`;
|
||||
}
|
||||
return { create, managerGrants, managed, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt,
|
||||
close: store.close,
|
||||
resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, async lease => {
|
||||
const row = await registration(endpointId);
|
||||
if (row?.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor);
|
||||
return resumeLocked(endpointId, actor, lease);
|
||||
}) };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { and, eq, gte, inArray, ne } from "drizzle-orm";
|
||||
import { agents, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatSlackManagerGrants, companyMemberships, toolConnections, type Db } from "@paperclipai/db";
|
||||
import { slackTestMessageSchema, type SlackTestMessageInput, type SlackTestMessageResult } from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../../../errors.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
import { logActivity } from "../../activity-log.js";
|
||||
import { instanceSettingsService } from "../../instance-settings.js";
|
||||
import { createSlackSetupClient, object } from "./setup/provider-client.js";
|
||||
import type { SlackSetupActor, SlackSetupOptions } from "./setup/registration.js";
|
||||
|
||||
type Options = Pick<SlackSetupOptions, "fetch" | "withLock" | "runtimeBotToken" | "linkInstaller"> & {
|
||||
ensureAccount: (endpointId: string, user: Record<string, unknown>) => Promise<string>;
|
||||
createLinkIntent: (endpointId: string, principalId: string, expiresInSeconds: number) => Promise<{ confirmationUrl: string; expiresAt: string }>;
|
||||
};
|
||||
const human = (user: Record<string, unknown>, workspaceId: string) =>
|
||||
typeof user.id === "string" && /^[UW][A-Z0-9]+$/.test(user.id) && user.team_id === workspaceId &&
|
||||
user.is_bot === false && user.is_app_user !== true && user.deleted !== true && user.id !== "USLACKBOT";
|
||||
|
||||
export function slackTestMessageService(db: Db, options: Options) {
|
||||
const api = createSlackSetupClient(options.fetch);
|
||||
return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise<SlackTestMessageResult> => {
|
||||
const input = slackTestMessageSchema.parse(rawInput);
|
||||
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
|
||||
return options.withLock(endpointId, async lease => {
|
||||
const [record] = await db.select({ endpoint: chatEndpoints, agentName: agents.name }).from(chatEndpoints)
|
||||
.innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId)))
|
||||
.innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId), eq(toolConnections.enabled, true), eq(toolConnections.status, "active")))
|
||||
.where(eq(chatEndpoints.id, endpointId));
|
||||
if (!record || record.endpoint.provider !== "slack") throw notFound("Active Slack connection not found");
|
||||
const endpoint = record.endpoint;
|
||||
if (!["active", "verifying"].includes(endpoint.status) || !endpoint.providerAccountId || !endpoint.allowDirectMessages) throw conflict("Enable direct messages on an active Slack connection first");
|
||||
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, endpoint.companyId), eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"),
|
||||
));
|
||||
if (!membership) throw forbidden("An active Paperclip account is required");
|
||||
const actionKey = `slack_test_message:${actor.userId}:${input.requestId}`;
|
||||
const [previous] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, actionKey)));
|
||||
if (previous) {
|
||||
if (previous.status === "processed" && typeof previous.result?.slackUrl === "string") return { status: "sent", slackUrl: previous.result.slackUrl };
|
||||
throw conflict("This message may already have been sent. Check your Slack DMs before sending another.");
|
||||
}
|
||||
const recent = await db.select({ id: chatActions.id }).from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), inArray(chatActions.kind, ["slack_test_message", "slack_test_message_invitation"]), gte(chatActions.createdAt, new Date(Date.now() - 60_000)),
|
||||
)).limit(5);
|
||||
if (recent.length >= 5) throw tooManyRequests("Wait a minute before sending another test message");
|
||||
const links = await db.select({ endpointId: chatIdentityLinks.endpointId, slackUserId: chatExternalPrincipals.externalId }).from(chatIdentityLinks)
|
||||
.innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId)))
|
||||
.where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.paperclipUserId, actor.userId), eq(chatIdentityLinks.status, "linked"),
|
||||
eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId), eq(chatExternalPrincipals.isBot, false)));
|
||||
const current = links.filter(link => link.endpointId === endpointId);
|
||||
const grants = await db.select({ slackUserId: chatSlackManagerGrants.slackUserId }).from(chatSlackManagerGrants).where(and(
|
||||
eq(chatSlackManagerGrants.companyId, endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.workspaceId, endpoint.providerAccountId), eq(chatSlackManagerGrants.status, "active"),
|
||||
));
|
||||
const known = [...new Set((current.length ? current : [...links, ...grants]).map(link => link.slackUserId))];
|
||||
const [invitation] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, `${actionKey}:invite`)));
|
||||
const invitedUser = typeof invitation?.payload.slackUserId === "string" ? invitation.payload.slackUserId : undefined;
|
||||
if (invitedUser && input.slackUserId && input.slackUserId !== invitedUser) throw conflict("This request already invited a different Slack account");
|
||||
const userId = input.slackUserId ?? invitedUser ?? (known.length === 1 ? known[0] : undefined);
|
||||
const token = await options.runtimeBotToken(endpointId);
|
||||
if (!token) throw conflict("Reconnect this Slack app before sending a message");
|
||||
async function call(method: string, fields: Record<string, string>) {
|
||||
try { return await api(method, fields, token); }
|
||||
catch { throw unprocessable("Slack could not complete this request. Check the connection and try again."); }
|
||||
}
|
||||
if (!userId) {
|
||||
const accounts: { id: string; name: string }[] = [];
|
||||
const result = await call("users.list", { limit: "200", ...(input.accountCursor ? { cursor: input.accountCursor } : {}) });
|
||||
for (const value of Array.isArray(result.members) ? result.members : []) {
|
||||
const user = object(value);
|
||||
if (human(user, endpoint.providerAccountId)) accounts.push({ id: String(user.id), name: String(user.real_name || user.name || user.id) });
|
||||
}
|
||||
const cursor = object(result.response_metadata).next_cursor;
|
||||
return { status: "connection_required", accounts, nextCursor: typeof cursor === "string" && cursor ? cursor : null };
|
||||
}
|
||||
const user = object((await call("users.info", { user: userId })).user);
|
||||
if (user.id !== userId || !human(user, endpoint.providerAccountId)) throw unprocessable("Choose your personal Slack account in this workspace");
|
||||
if (!known.includes(userId)) {
|
||||
const principalId = await options.ensureAccount(endpointId, user);
|
||||
const principalLinks = await db.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.principalId, principalId)));
|
||||
if (principalLinks.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId)
|
||||
|| principalLinks.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link");
|
||||
if (invitation) {
|
||||
if (invitation.status !== "processed") throw conflict("The invitation may already have been sent. Check Slack before requesting another.");
|
||||
const expiresAt = String(invitation.result?.expiresAt ?? "");
|
||||
if (!expiresAt || new Date(expiresAt).getTime() <= Date.now()) throw conflict("The Slack invitation expired. Request a new test message.");
|
||||
return { status: "connection_pending", principalId, expiresAt };
|
||||
}
|
||||
// The account holder must follow the private Slack challenge and sign in.
|
||||
// Selecting a workspace member does not prove identity ownership.
|
||||
const intent = await options.createLinkIntent(endpointId, principalId, 600);
|
||||
const [pending] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, principalId,
|
||||
kind: "slack_test_message_invitation", providerActionId: `${actionKey}:invite`, status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning();
|
||||
try {
|
||||
const response = await call("chat.postMessage", { channel: userId,
|
||||
text: `Connect your Slack account to ${record.agentName} in Paperclip to receive your test message.`,
|
||||
blocks: JSON.stringify([{ type: "section", text: { type: "plain_text", text: `Connect your Slack account to ${record.agentName} in Paperclip.` } },
|
||||
{ type: "section", text: { type: "mrkdwn", text: `<${intent.confirmationUrl}|Connect my account> to receive your test message.` } }]),
|
||||
unfurl_links: "false", unfurl_media: "false" });
|
||||
if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm invitation delivery");
|
||||
await lease.assertOwned();
|
||||
await db.update(chatActions).set({ status: "processed", result: { expiresAt: intent.expiresAt }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id));
|
||||
} catch {
|
||||
await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_invitation_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id));
|
||||
throw unprocessable("Invitation delivery was not confirmed. Check Slack before requesting another.");
|
||||
}
|
||||
try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_invited", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); }
|
||||
catch { logger.warn({ endpointId }, "Slack test invitation activity could not be recorded"); }
|
||||
return { status: "connection_pending", principalId, expiresAt: intent.expiresAt };
|
||||
}
|
||||
await options.linkInstaller(endpointId, user, actor, lease);
|
||||
await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_requested", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } });
|
||||
await lease.assertOwned();
|
||||
// Persist before dispatch: a crash or ambiguous response must never replay the DM.
|
||||
const [action] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, kind: "slack_test_message", providerActionId: actionKey,
|
||||
status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning();
|
||||
let slackUrl: string;
|
||||
try {
|
||||
const response = await call("chat.postMessage", { channel: userId,
|
||||
text: `Hi, I’m ${record.agentName}. This is your test message. Reply here to talk to me.`, unfurl_links: "false", unfurl_media: "false" });
|
||||
if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm delivery. Check your DMs before trying again.");
|
||||
slackUrl = `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/${response.channel}`;
|
||||
await lease.assertOwned();
|
||||
await db.update(chatActions).set({ status: "processed", result: { slackUrl }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id));
|
||||
} catch {
|
||||
await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_test_message_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id));
|
||||
throw unprocessable("Delivery was not confirmed. Check your Slack DMs before sending another test message.");
|
||||
}
|
||||
try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); }
|
||||
catch { logger.warn({ endpointId }, "Slack test message activity could not be recorded"); }
|
||||
return { status: "sent", slackUrl };
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-sec
|
||||
type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect;
|
||||
|
||||
export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding";
|
||||
/** Measured platform evidence is reserved to explicit server-side environment probes. */
|
||||
export const SANDBOX_PLATFORM_PROBE_METADATA_KEY = "paperclipSandboxPlatformProbes";
|
||||
export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [
|
||||
"timeoutMs",
|
||||
|
||||
@@ -41,14 +41,12 @@ async function sandboxBinding(db: Db, environment: Environment, companyId: strin
|
||||
});
|
||||
if (parsed.driver !== "sandbox") return null;
|
||||
const image = parsed.config.image;
|
||||
// Names, mutable tags, provider defaults and snapshot names are not immutable identities.
|
||||
const template = selectedTemplate.value;
|
||||
if (!isImmutableImage(image) || ["snapshot", "template", "templateRef"].some(key => record(parsed.config)[key])
|
||||
|| (template && (template.templateKind !== "image" || template.bindingField !== "image"))) return null;
|
||||
return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config as unknown as Record<string, unknown>), image, template };
|
||||
}
|
||||
|
||||
/** Read measured, company-scoped image evidence without leasing or contacting a provider. */
|
||||
export async function readSandboxPlatformQualification(db: Db, environment: Environment, companyId: string): Promise<PlatformFacts | null> {
|
||||
const proof = record(record(environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY])[companyId]);
|
||||
if (proof.v !== 1 || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null;
|
||||
|
||||
@@ -1144,8 +1144,6 @@ export function environmentService(db: Db) {
|
||||
values.metadata = userMetadata;
|
||||
} else {
|
||||
const replacement = userMetadata === null ? sql`null::jsonb` : sql`${JSON.stringify(userMetadata)}::jsonb`;
|
||||
// Preserve the current private subtree in the same statement. A route's
|
||||
// earlier read must never replace a newer concurrent probe receipt.
|
||||
metadataUpdate = sql`case when ${environments.metadata} ? ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}
|
||||
then coalesce(${replacement}, '{}'::jsonb) || jsonb_build_object(
|
||||
${SANDBOX_PLATFORM_PROBE_METADATA_KEY}::text, ${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY})
|
||||
|
||||
@@ -5,7 +5,15 @@ import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { runningProcesses } from "../../adapters/index.js";
|
||||
import { adapterExecutionControls } from "../adapter-execution-control.js";
|
||||
import {
|
||||
adapterExecutionControls,
|
||||
createAdapterExecutionControl,
|
||||
reportAdapterStopIfUnsettled,
|
||||
waitForAdapterStop,
|
||||
} from "../adapter-execution-control.js";
|
||||
import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js";
|
||||
import { HttpError } from "../../errors.js";
|
||||
import { captureException } from "../../sentry.js";
|
||||
import { terminateLocalService } from "../local-service-supervisor.js";
|
||||
import { createHeartbeatRunState } from "./run-state.js";
|
||||
import { createHeartbeatRunPreparation } from "./run-preparation.js";
|
||||
@@ -13,6 +21,14 @@ import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from ".
|
||||
import { createHeartbeatRunControl, type HeartbeatRunControlDependencies } from "./run-control.js";
|
||||
|
||||
vi.mock("../live-events.js", () => ({ publishLiveEvent: vi.fn() }));
|
||||
vi.mock("../adapter-execution-control.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../adapter-execution-control.js")>();
|
||||
return {
|
||||
...actual,
|
||||
waitForAdapterStop: vi.fn(actual.waitForAdapterStop),
|
||||
reportAdapterStopIfUnsettled: vi.fn(actual.reportAdapterStopIfUnsettled),
|
||||
};
|
||||
});
|
||||
vi.mock("../local-service-supervisor.js", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("../local-service-supervisor.js")>(),
|
||||
terminateLocalService: vi.fn(async () => undefined),
|
||||
@@ -256,6 +272,67 @@ describe.skipIf(!support.supported)("heartbeat run-control database wiring", ()
|
||||
expect(deps.failedProcessRunCancellations.get(run.id)?.error).toBe(failure);
|
||||
});
|
||||
|
||||
async function ownedAdapterRun() {
|
||||
const { company, agent } = await fixture();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", runtimeMode: "legacy", runtimeModeResolvedAt: new Date() }).returning();
|
||||
const control = createAdapterExecutionControl();
|
||||
adapterExecutionControls.set(run.id, control);
|
||||
return { run, control };
|
||||
}
|
||||
|
||||
it("answers an owned adapter Stop that is still settling with a pending conflict", async () => {
|
||||
const { run, control } = await ownedAdapterRun();
|
||||
const deps = callbacks(db);
|
||||
deps.activeRunExecutions.add(run.id);
|
||||
try {
|
||||
let waitStartedAt = 0;
|
||||
vi.mocked(waitForAdapterStop).mockImplementationOnce(async () => {
|
||||
waitStartedAt = Date.now();
|
||||
throw new AdapterStopTimeoutError(60_000, { runId: run.id });
|
||||
});
|
||||
const requestedAt = Date.now();
|
||||
const error = await createHeartbeatRunControl(db, deps).cancelRunInternal(run.id).catch((err: unknown) => err);
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect(error).toMatchObject({
|
||||
status: 409,
|
||||
message: "Execution is still stopping; termination has not been verified.",
|
||||
details: { code: "adapter_stop_pending", runId: run.id },
|
||||
});
|
||||
expect(control.controller.signal.aborted).toBe(true);
|
||||
// Only a Stop that never settles is reported, from the same execution.
|
||||
expect(reportAdapterStopIfUnsettled).toHaveBeenCalledWith(control.settled, captureException,
|
||||
expect.objectContaining({ runId: run.id, adapterType: "codex_local", runtimeMode: "legacy", abortRequested: true }), control,
|
||||
{ stopRequestedAt: expect.any(Number) });
|
||||
// Its report bound runs from the Stop request, before the response wait.
|
||||
const { stopRequestedAt } = vi.mocked(reportAdapterStopIfUnsettled).mock.calls.at(-1)![4]!;
|
||||
expect(stopRequestedAt).toBeGreaterThanOrEqual(requestedAt);
|
||||
expect(stopRequestedAt).toBeLessThanOrEqual(waitStartedAt);
|
||||
// A pending Stop is not a cancellation receipt.
|
||||
expect(deps.setRunStatusFromLive).not.toHaveBeenCalled();
|
||||
expect(deps.finalizeAgentStatus).not.toHaveBeenCalled();
|
||||
expect((await deps.getRun(run.id))?.status).toBe("running");
|
||||
expect(deps.failedProcessRunCancellations.has(run.id)).toBe(false);
|
||||
} finally {
|
||||
control.finish();
|
||||
adapterExecutionControls.delete(run.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps other owned adapter Stop failures unchanged", async () => {
|
||||
const { run, control } = await ownedAdapterRun();
|
||||
const deps = callbacks(db);
|
||||
vi.mocked(reportAdapterStopIfUnsettled).mockClear();
|
||||
try {
|
||||
const failure = new Error("owner wait failed");
|
||||
vi.mocked(waitForAdapterStop).mockRejectedValueOnce(failure);
|
||||
await expect(createHeartbeatRunControl(db, deps).cancelRunInternal(run.id)).rejects.toBe(failure);
|
||||
expect(reportAdapterStopIfUnsettled).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
control.finish();
|
||||
adapterExecutionControls.delete(run.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates agent batches and cancels only unclaimed pending wakes", async () => {
|
||||
const { company, agent } = await fixture();
|
||||
const [otherAgent] = await db.insert(agents).values({ companyId: company.id, name: "Other agent" }).returning();
|
||||
|
||||
@@ -44,8 +44,11 @@ import {
|
||||
import {
|
||||
adapterExecutionControls,
|
||||
captureAdapterStopOwnership,
|
||||
reportAdapterStopIfUnsettled,
|
||||
waitForAdapterStop,
|
||||
} from "../adapter-execution-control.js";
|
||||
import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js";
|
||||
import { captureException } from "../../sentry.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
@@ -999,6 +1002,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
|
||||
processCancellationSettlement,
|
||||
);
|
||||
}
|
||||
const stopRequestedAt = Date.now();
|
||||
const cancellation = await (async () => {
|
||||
try {
|
||||
if (control) {
|
||||
@@ -1059,12 +1063,24 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
|
||||
}
|
||||
|
||||
if (control) {
|
||||
await waitForAdapterStop(control.settled, undefined, {
|
||||
const stopDiagnostics = {
|
||||
runId: run.id,
|
||||
adapterType: agent?.adapterType,
|
||||
runtimeMode: run.runtimeMode,
|
||||
abortRequested: control.controller.signal.aborted,
|
||||
}, control);
|
||||
};
|
||||
try {
|
||||
await waitForAdapterStop(control.settled, undefined, stopDiagnostics, control);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AdapterStopTimeoutError)) throw error;
|
||||
// The owned adapter is still settling (for example copying the
|
||||
// workspace back) and keeps its Stop proof requirements. Answer
|
||||
// this request honestly; report only a Stop that never settles.
|
||||
reportAdapterStopIfUnsettled(control.settled, captureException, stopDiagnostics, control, {
|
||||
stopRequestedAt,
|
||||
});
|
||||
throw conflict(error.message, { code: "adapter_stop_pending", runId: run.id });
|
||||
}
|
||||
const stopped = await getRun(run.id);
|
||||
if (stopped && isHeartbeatRunTerminalStatus(stopped.status)) {
|
||||
if (
|
||||
|
||||
@@ -45,8 +45,6 @@ function targetPlatform(platform: string, architecture: string, glibc = true, gl
|
||||
}
|
||||
|
||||
function resolveFixtureCodexCommand(home: string): string {
|
||||
// Vitest's global dependency paths can supply a foreign bridge before the
|
||||
// fixture wrapper. Reuse the existing Codex security test's closed Node seam.
|
||||
const source = `
|
||||
import { resolveCodexCommand } from ${JSON.stringify(new URL("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts", import.meta.url).href)};
|
||||
try { console.log(JSON.stringify({ command: resolveCodexCommand(${JSON.stringify(path.join(root!, "issuer.js"))}, { PATH: "/missing-codex" }, ${JSON.stringify(home)}) })); }
|
||||
|
||||
@@ -56,8 +56,6 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s
|
||||
async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> {
|
||||
let executable = "claude";
|
||||
if (provider === "openai") {
|
||||
// The installed Codex CLI is independent of the runner daemon's glibc floor.
|
||||
// Supported CLI hosts use owned resolution; other platforms keep PATH lookup.
|
||||
const legacyPlatform = !((process.platform === "linux" || process.platform === "darwin")
|
||||
&& (process.arch === "x64" || process.arch === "arm64"));
|
||||
try {
|
||||
|
||||
@@ -208,7 +208,6 @@ export function resolvePaperclipRunnerBinary(
|
||||
);
|
||||
}
|
||||
|
||||
/** Select controller-owned bytes for the actual remote host, never its controller. */
|
||||
export async function resolveRemotePaperclipRunnerBinary(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
@@ -224,8 +223,6 @@ export async function resolveRemotePaperclipRunnerBinary(input: {
|
||||
}
|
||||
const configured = input.configuredPath?.trim();
|
||||
if (configured && !isAbsolute(configured)) throw new Error("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH must be an absolute path");
|
||||
// A same-host sandbox can use the supervisor's fresh development artifact.
|
||||
// Cross-platform targets must never inherit the controller's override.
|
||||
const controllerOverride = platform === process.platform && architecture === process.arch
|
||||
? process.env.PAPERCLIP_RUNNER_BINARY : undefined;
|
||||
const binary = configured || (controllerOverride
|
||||
|
||||
@@ -12392,8 +12392,6 @@ describe("runnerd provider runtime wiring", () => {
|
||||
{ version: "0.156.0", model: "gpt-6.1-sol", floor: null, recover: true },
|
||||
{ version: "0.158.0", model: "gpt-6.1-sol", floor: null, recover: true },
|
||||
])("lets sandbox Codex $version reach actual model verification ($model, recover=$recover)", async ({ version, model, recover }) => {
|
||||
// Version metadata is diagnostic. A working app-server reaches the model
|
||||
// turn instead of being rejected or replaced by a static CLI version floor.
|
||||
const preparedModel = recover ? resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: "codex_app_server", adapterConfig: { provider: "codex", model }, codexCliVersion: version,
|
||||
}).model : model;
|
||||
|
||||
@@ -13407,7 +13407,6 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
} satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession };
|
||||
}
|
||||
|
||||
/** Shared task/setup preparation; setup exercises the same remote artifact contract. */
|
||||
export function createRemoteNativeArtifactPreparation(input: {
|
||||
target: Extract<AdapterExecutionTarget, { kind: "remote" }>;
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
@@ -13420,7 +13419,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
runnerRemoteCodexNpmSpec?: string | null;
|
||||
model?: string | null;
|
||||
reuseRetainedRunner?: boolean;
|
||||
/** The selected Pi companion or bundled provider image verifies its target-platform artifact. */
|
||||
targetPlatformArtifactVerified?: boolean;
|
||||
trace?: NativeRunTrace;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
@@ -13473,9 +13471,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
}
|
||||
const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`;
|
||||
const version = parseCodexCliVersion(versionOutput);
|
||||
// The install pin describes our tested image, not a CLI admission rule.
|
||||
// Ordinary installations reach the real app-server/model probe; a version
|
||||
// difference alone must neither fail setup nor replace a working CLI.
|
||||
if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) {
|
||||
reportedCodexVersions.add(version);
|
||||
await input.onLog?.(
|
||||
@@ -13485,9 +13480,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
}
|
||||
};
|
||||
|
||||
// Image policy: keep one latest stable CLI installation shared by native and
|
||||
// local adapters. Preferred bin entries must point to that same installation;
|
||||
// never bake an older global CLI alongside a private runner-only version.
|
||||
const discoverPreinstalledExecutable = async (
|
||||
name: "paperclip-runnerd" | "codex",
|
||||
) => {
|
||||
@@ -13522,8 +13514,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
};
|
||||
|
||||
const prepare = async (requiredMode: "dial_wss" | "listen_ws") => {
|
||||
// Compatibility metadata does not prove artifact identity. Reuse only the
|
||||
// exact controller-owned bytes when the controller artifact is available.
|
||||
const matchesControllerRunnerArtifact = async (executable: string): Promise<boolean> => {
|
||||
const expected = createHash("sha256")
|
||||
.update(readFileSync(controllerRunnerBinary))
|
||||
@@ -13545,7 +13535,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
/^[a-f0-9]{64}\s/.test(probe.stdout) &&
|
||||
probe.stdout.trim().split(/\s+/)[0] === expected;
|
||||
} catch {
|
||||
// An unavailable checksum uses the verified staging path.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -13594,9 +13583,6 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
}
|
||||
}
|
||||
if (!runnerArtifactPrepared) {
|
||||
// Upload the same artifact used for the controller identity. The server
|
||||
// vendors the runner under vendor/paperclip-runner/bin, so the package
|
||||
// development fallback cannot locate it in a deployed server.
|
||||
const sourceBinary = controllerRunnerBinary;
|
||||
if (!existsSync(sourceBinary)) {
|
||||
throw new Error("runner_remote_artifact_unavailable");
|
||||
|
||||
@@ -167,7 +167,6 @@ export function validatePaperclipRunnerDotConfig(config: Record<string, unknown>
|
||||
return bindingId;
|
||||
}
|
||||
|
||||
/** A task may change a local Runner model and supported effort, but not provider identity. */
|
||||
export function projectPaperclipRunnerTaskConfig(
|
||||
backend: "codex_app_server" | "opencode_server" | "acpx_runtime",
|
||||
agentConfig: unknown,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
randomBytes,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -406,6 +407,16 @@ describe("Paperclip Cloud connector", () => {
|
||||
})).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
|
||||
});
|
||||
|
||||
it("opens Slack manager handoffs with their exact profile and personal identity", async () => {
|
||||
const keys = config();
|
||||
const credentials = { v: 1, accessToken: "manager-access-canary", refreshToken: "manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: "2030-01-01T00:00:00.000Z", refreshTokenExpiresAt: null,
|
||||
scopes: [...SLACK_MANAGER_SCOPES], subject, companyId, instanceId, environment: "staging", provider: "slack", profile: "slack.manager",
|
||||
slackManager: { appId: "AMANAGER", workspaceId: "TWORKSPACE", workspaceName: "Paperclip", userId: "UOWNER" } };
|
||||
const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "slack.manager");
|
||||
const connector = createPaperclipCloudConnector({ config: keys.config, request: vi.fn(async () => Response.json({ claimId: "claim", scopes: [...SLACK_MANAGER_SCOPES], sealed })) as typeof fetch });
|
||||
await expect(connector.claim({ subject, companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).resolves.toEqual(credentials);
|
||||
await expect(connector.claim({ subject: "another-user", companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).rejects.toThrow();
|
||||
});
|
||||
it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => {
|
||||
const keys = config();
|
||||
const credentials = {
|
||||
@@ -612,10 +623,10 @@ function seal(
|
||||
recipientPublicKey: KeyObject,
|
||||
purpose: "initial" | "access" | "events",
|
||||
configValue: PaperclipCloudConnectorConfig,
|
||||
profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "github.bot" | "github.code",
|
||||
profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "slack.manager" | "github.bot" | "github.code",
|
||||
) {
|
||||
const provider = profile.startsWith("github.") ? "github" : "google";
|
||||
const scopes = provider === "github" ? [] : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes;
|
||||
const provider = profile.startsWith("github.") ? "github" : profile === "slack.manager" ? "slack" : "google";
|
||||
const scopes = provider === "github" ? [] : profile === "slack.manager" ? SLACK_MANAGER_SCOPES : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes;
|
||||
const ephemeral = generateKeyPairSync("x25519");
|
||||
const ephemeralJwk = ephemeral.publicKey.export({ format: "jwk" }) as { x: string };
|
||||
const recipientJwk = recipientPublicKey.export({ format: "jwk" }) as { x: string };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared";
|
||||
import { SLACK_MANAGER_SCOPES, ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared";
|
||||
import {
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
@@ -34,8 +34,8 @@ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES };
|
||||
|
||||
export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production";
|
||||
export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app";
|
||||
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "github.bot";
|
||||
export type PaperclipCloudConnectorProvider = "google" | "github" | "asana";
|
||||
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot";
|
||||
export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack";
|
||||
|
||||
export type PaperclipCloudConnectorConfig = {
|
||||
baseUrl: string;
|
||||
@@ -60,6 +60,7 @@ export type SealedConnectorCredentials = {
|
||||
provider: PaperclipCloudConnectorProvider;
|
||||
profile: string;
|
||||
appSlug?: string;
|
||||
slackManager?: { appId: string; workspaceId: string; workspaceName: string; userId: string };
|
||||
};
|
||||
|
||||
export type SealedGmailCredentials = SealedConnectorCredentials & { provider: "google" };
|
||||
@@ -95,6 +96,7 @@ type SealedEnvelope = {
|
||||
};
|
||||
|
||||
type ConnectorResponse = {
|
||||
slackManager?: unknown;
|
||||
confirmationUrl?: unknown;
|
||||
authorizationUrl?: unknown;
|
||||
handoff?: unknown;
|
||||
@@ -413,6 +415,13 @@ export function createPaperclipCloudConnector(input: {
|
||||
typeof value === "string" && isPaperclipCloudConnectorProfileId(value) ? [value] : []
|
||||
))];
|
||||
},
|
||||
async getSlackManagerAppId(): Promise<string | null> {
|
||||
const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" });
|
||||
const manager = response.slackManager;
|
||||
if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles) || !response.profiles.includes("slack.manager")
|
||||
|| !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null;
|
||||
return manager.appId;
|
||||
},
|
||||
async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) {
|
||||
const profile = values.profile ?? "gmail.draft";
|
||||
const response = await call("session", { ...values, profile });
|
||||
@@ -782,6 +791,7 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId):
|
||||
provider: PaperclipCloudConnectorProvider;
|
||||
scopes: readonly string[];
|
||||
} {
|
||||
if (profile === "slack.manager") return { provider: "slack", scopes: SLACK_MANAGER_SCOPES };
|
||||
if (profile === "github.bot") return { provider: "github", scopes: [] };
|
||||
if (isAsanaConnectorProfileId(profile)) {
|
||||
return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES };
|
||||
@@ -796,6 +806,7 @@ function isExpectedProviderAuthorizationUrl(
|
||||
profile: PaperclipCloudConnectorProfileId,
|
||||
url: URL,
|
||||
): boolean {
|
||||
if (profile === "slack.manager") return url.origin === "https://slack.com" && url.pathname === "/oauth/v2/authorize";
|
||||
if (isAsanaConnectorProfileId(profile)) {
|
||||
return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize";
|
||||
}
|
||||
@@ -806,7 +817,7 @@ function isExpectedProviderAuthorizationUrl(
|
||||
}
|
||||
|
||||
function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId {
|
||||
return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value);
|
||||
return value === "slack.manager" || isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value);
|
||||
}
|
||||
|
||||
async function sha256Base64Url(value: string): Promise<string> {
|
||||
|
||||
@@ -164,6 +164,16 @@ function isHermesConnectionFailure(run: HeartbeatRun, options: RunFailureReportO
|
||||
(connection.reason === "remote_unavailable" && code === "hermes_gateway_upstream_error");
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter proved the user's provider account is out of quota, and recovery
|
||||
* waits for its reset. Both fields come from the adapter's own classifier.
|
||||
*/
|
||||
function isProviderQuotaFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
|
||||
return run.status === "failed" && run.errorCode === "provider_quota" &&
|
||||
asRecord(run.resultJson)?.errorFamily === "provider_quota" &&
|
||||
options.phase !== "setup" && run.signal == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a terminal run failure to Sentry. Returns at once for any status
|
||||
* other than failures and unexpected started cancellations. Never throws — a Sentry failure or a
|
||||
@@ -182,6 +192,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR
|
||||
if (isAiConnectionSelectionBlocker(run, options)) return Promise.resolve();
|
||||
if (isLocalPathWorkspaceConfigurationBlocker(run, options)) return Promise.resolve();
|
||||
if (isGitConnectionFailure(run, options)) return Promise.resolve();
|
||||
if (isProviderQuotaFailure(run, options)) return Promise.resolve();
|
||||
const runStatus = run.status;
|
||||
const report = captureTerminalRunFailure(db, run, runStatus, options);
|
||||
pendingRunFailureReports.add(report);
|
||||
|
||||
Reference in new issue
Block a user