Separate GitHub bot opt-in and add review detail pages

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 16:17:14 -05:00
1 parent 7c1d2503db
commit 7dcdbe7eb6
36 files changed
+647 -204

No files matched your search

@@ -38,6 +38,7 @@ import { materializeNativeInteractionResponses } from "../services/native-runtim
import { toolAccessService } from "../services/tool-access.js";
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -198,6 +199,29 @@ describeEmbeddedPostgres("connectionIntentService", () => {
expect(serialized).not.toContain(claims.sub);
});
it("discovers GitHub review bots independently from chat connectors", async () => {
const settings = instanceSettingsService(db);
const previous = await settings.getExperimental();
const service = connectionIntentService(db);
try {
await settings.updateExperimental({ enableChatConnectors: false, enableGitHubReviewBots: true });
expect((await service.search(claims, "github")).results).toEqual(expect.arrayContaining([
expect.objectContaining({ service: "github-code-review-bot" }),
expect.objectContaining({ service: "github" }),
]));
expect((await service.search(claims, "slack")).results.some(item =>
item.methods.some(method => method.purpose === "channel"))).toBe(false);
await settings.updateExperimental({ enableChatConnectors: true, enableGitHubReviewBots: false });
expect((await service.search(claims, "github")).results.some(item =>
item.service === "github-code-review-bot")).toBe(false);
} finally {
await settings.updateExperimental({
enableChatConnectors: previous.enableChatConnectors,
enableGitHubReviewBots: previous.enableGitHubReviewBots,
});
}
});
it("creates one addressed request, resolves after install, and then reports ready for the responsible user", async () => {
const service = connectionIntentService(db);
const first = await service.request(claims, "notion");
@@ -16,6 +16,11 @@ describe("instance settings service", () => {
const disabled = applyExperimentalSettingsPatch(enabled, { enableChatConnectors: false });
expect(disabled).toMatchObject({ enableApps: true, enableChatConnectors: false });
});
it("keeps GitHub review bots independent across legacy normalization and persistence", () => {
expect(normalizeExperimentalSettings({ enableChatConnectors: true })).toMatchObject({ enableChatConnectors: true, enableGitHubReviewBots: false });
const saved = applyExperimentalSettingsPatch({}, { enableGitHubReviewBots: true });
expect(normalizeExperimentalSettings(JSON.parse(JSON.stringify(saved)))).toMatchObject({ enableChatConnectors: false, enableGitHubReviewBots: true });
});
it("ignores retired experimental flags without resetting current settings", () => {
expect(normalizeExperimentalSettings({
enableEnvironments: true,
@@ -48,6 +53,7 @@ describe("instance settings service", () => {
enablePublicMcp: false,
enableCombinedInboxTasks: false,
enableChatConnectors: false,
enableGitHubReviewBots: false,
enableMemoryConnectors: false,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
@@ -65,6 +65,19 @@ describe("chat identity-link preview authority", () => {
expect(f.requestIdentityAccess).not.toHaveBeenCalled();
});
it.each(["github", "slack"])("uses the token’s verified provider when only GitHub bots are enabled (%s)", async (provider) => {
settings.getExperimental.mockResolvedValue({ enableChatConnectors: false, enableGitHubReviewBots: true });
const f = fixture();
f.previewIdentityLink.mockResolvedValue({ ...preview, provider } as typeof preview);
await request(f.app).get("/api/chat-identity-links/preview").query({ token }).expect(provider === "github" ? 200 : 403);
await request(f.app).post("/api/chat-identity-links/confirm").send({ token }).expect(provider === "github" ? 200 : 403);
expect(f.confirmIdentityLink.mock.calls.length).toBe(provider === "github" ? 1 : 0);
});
it("does not admit GitHub links under chat-only opt-in", async () => {
const f = fixture();
f.previewIdentityLink.mockResolvedValue({ ...preview, provider: "github" } as unknown as typeof preview);
await request(f.app).get("/api/chat-identity-links/preview").query({ token }).expect(403);
});
it("returns the preview to a Board member of its exact company", async () => {
const { app, previewIdentityLink } = fixture();
const response = await request(app)
+22 -6
View File
@@ -92,16 +92,27 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
const access = accessService(db);
const github = githubChatManagementService(db, options.fetch);
async function assertIdentityLinkAccess(req: ExpressRequest): Promise<string> {
async function assertIdentityLinkAccess(req: ExpressRequest) {
assertBoard(req);
const userId = actorUserId(req);
if (!userId) throw badRequest("A signed-in Paperclip user is required");
// Enforce rollout here: invited nonmembers cannot read the board's
// experimental-settings API. A private token never bypasses this gate.
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) {
const experimental = await instanceSettingsService(db).getExperimental();
if (!experimental.enableChatConnectors && !experimental.enableGitHubReviewBots) {
throw forbidden("Chat connectors are not enabled on this instance");
}
return userId;
return { userId, experimental };
}
function assertIdentityProviderEnabled(
provider: string,
experimental: { enableChatConnectors: boolean; enableGitHubReviewBots: boolean },
) {
const enabled = provider === "agentmail" || (provider === "github"
? experimental.enableGitHubReviewBots
: experimental.enableChatConnectors);
if (!enabled) throw forbidden("This connector is not enabled on this instance");
}
async function assertConnectionManager(
@@ -353,18 +364,22 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
"/chat-identity-links/confirm",
validate(confirmChatIdentityLinkSchema),
async (req, res) => {
const userId = await assertIdentityLinkAccess(req);
const { userId, experimental } = await assertIdentityLinkAccess(req);
const preview = await service.previewIdentityLink(req.body.token, userId);
assertIdentityProviderEnabled(preview.provider, experimental);
res.json(await service.confirmIdentityLink(req.body.token, userId));
},
);
router.post("/chat-identity-links/request-access", validate(confirmChatIdentityLinkSchema), async (req, res) => {
const userId = await assertIdentityLinkAccess(req);
const { userId, experimental } = await assertIdentityLinkAccess(req);
const preview = await service.previewIdentityLink(req.body.token, userId);
assertIdentityProviderEnabled(preview.provider, experimental);
res.json(await service.requestIdentityAccess(req.body.token, userId, req.ip ?? "unknown"));
});
router.get("/chat-identity-links/preview", async (req, res) => {
const userId = await assertIdentityLinkAccess(req);
const { userId, experimental } = await assertIdentityLinkAccess(req);
const token = typeof req.query.token === "string" ? req.query.token : "";
if (token.length < 32 || token.length > 4096)
throw badRequest("A valid identity-link token is required");
@@ -379,6 +394,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
req, res, Promise.resolve(invitation), "Identity-link request not found",
);
if (!preview) return;
assertIdentityProviderEnabled(preview.provider, experimental);
res.json(preview);
});
+2 -1
View File
@@ -409,7 +409,8 @@ export function connectionIntentService(db: Db) {
const candidates: Array<{ item: ConnectionSearchResultItem; score: number; nameScore: number }> = [];
const authorizedCatalogs = new Map<string, Awaited<ReturnType<typeof indexedCatalog>>>();
const discoveryMethods = (app: (typeof APP_STORE_DEFINITIONS)[number]) => getAvailableConnectionMethods(app)
.filter(method => method.purpose !== "channel" || app.slug === "agentmail" || settings.enableChatConnectors);
.filter(method => method.purpose !== "channel" || app.slug === "agentmail"
|| (method.provider === "github" ? settings.enableGitHubReviewBots : settings.enableChatConnectors));
const services = [...APP_STORE_DEFINITIONS.filter(app => discoveryMethods(app).length).map((app) => app.slug),
...inventory.connections.filter((connection) =>
sourceSlugForConnection(connection, inventory.applicationsById)?.startsWith("connection:")
+2
View File
@@ -235,6 +235,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableMcpAggregators: true,
enablePublicMcp: parsed.data.enablePublicMcp ?? false,
enableChatConnectors: parsed.data.enableChatConnectors ?? false,
enableGitHubReviewBots: parsed.data.enableGitHubReviewBots ?? false,
enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false,
enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? false,
@@ -281,6 +282,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableMcpAggregators: true,
enablePublicMcp: false,
enableChatConnectors: false,
enableGitHubReviewBots: false,
enableMemoryConnectors: false,
enablePipelines: false,
enableCases: false,