mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip manages AI agents and their access to external services. > - A GitHub bot connection can add external contributors by username. > - The lookup sent an anonymous request even when the bot had an App installation. > - Anonymous requests share the server IP quota and can fail for a valid username. > - This pull request uses the selected bot installation for the lookup. > - It also gives users clear errors without exposing credentials or provider response text. ## Linked Issues or Issue Description **What happened?** The Access tab could reject a valid external contributor username with a generic GitHub error. The lookup did not use the connected bot's identity. **Expected behavior** The lookup uses the selected bot's active App installation. It returns the numeric user identity or a clear account, rate limit, or installation error. **Steps to reproduce** 1. Configure a GitHub review bot and open its Access tab. 2. Exhaust the server IP's anonymous GitHub quota. 3. Look up a valid external contributor username. 4. Observe the generic rejection even though the bot has an installation. The original provider HTTP status was not retained. Source inspection proves the anonymous request. The historical cause remains unconfirmed. **Paperclip version or commit** The defect is present at `eda2a03bfe5213ffef2fbbdefdf572be775f1e14`. **Deployment mode** Server deployment with an active GitHub App bot connection. ## What Changed - Pass the endpoint ID from the existing authorized lookup route. - Issue a short-lived installation token with metadata read permission. - Reject inactive connections and invalid installation IDs without an anonymous fallback. - Distinguish missing accounts, rate limits, and installation access failures. - Add 25 lookup tests and update the connector guide and both access maps. The GitHub management service owns the provider behavior. The shared route only passes its authorized endpoint ID. The request body and response shape stay the same. ## Verification - Passed: [full CI](https://github.com/paperclipai/paperclip/actions/runs/40535979115) on `38d1ebcebb08553f159b3b81ee1bb31d1fbbba8e`, including typecheck, build, server and workspace tests, Runner checks, serialized suites, eight E2E shards, and the final verification gate. - Passed: Greptile 5/5 for the same head, with no open review threads. - Passed: 25 lookup regression tests on the final test fixtures. - Passed: 91 tests across five GitHub service suites before the fixture-only change. - Passed: `node scripts/check-maps.mjs` and `git diff --check`. - The tests exercise actual GitHub request construction with provider response fixtures. They assert the installation identity, metadata-only token request, request count, and sanitized error details. - `pnpm build` and `pnpm -r typecheck` could not complete because this environment has no Cargo executable. - Two standalone server TypeScript attempts were killed. The second ran after the full suite was stopped, with a 5120 MiB Node heap limit. The 8 GiB container recorded two OOM kills. Neither attempt produced a typecheck verdict. - `pnpm test:run` was started, then interrupted after resource exhaustion. Its first server phase did not produce a final verdict. These local attempts remain incomplete. Full CI later passed for the same head. A live authenticated bot lookup remains unverified. - The initial frozen install stopped on an existing patch configuration mismatch. Installation then succeeded with lockfile writes disabled. No dependency or lockfile changes are included. - GitHub documents installation-token support for [Get a user](https://docs.github.com/en/rest/users/users#get-a-user) without extra user permissions. ## Risks - The lookup now requires an active bot connection and a saved installation ID. - GitHub rate limits can still occur. The error keeps the retry metadata and tells the user to retry. - No database migration or permission-policy change is included. - This PR does not prove that a deployment contains the fix. ## Model Used OpenAI Codex, GPT-6 family, with code editing, tool use, and test execution. The recovered implementation was assisted by `gpt-6.1-sol`. This session does not expose its exact model ID or context window limit. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
696 lines
26 KiB
TypeScript
696 lines
26 KiB
TypeScript
import type { ConnectionManagementContext } from "./connection-management.js";
|
|
import { githubBotCredentials } from "./chat-github-client.js";
|
|
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
|
import { agents, toolCatalogEntries } from "@paperclipai/db";
|
|
import { GITHUB_BOT_TOOLS, syncGitHubBotTools } from "./chat-github-tools.js";
|
|
import { and, desc, eq, sql } from "drizzle-orm";
|
|
import {
|
|
chatEndpoints,
|
|
chatEndpointResources,
|
|
chatExternalPrincipals,
|
|
chatIdentityLinks,
|
|
chatGitHubConfigurations,
|
|
chatGitHubReviews,
|
|
companyMemberships,
|
|
connectionGrants,
|
|
toolConnections,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
defaultGitHubReviewPolicy,
|
|
updateGitHubChatConfigurationSchema,
|
|
type GitHubChatConfiguration,
|
|
} from "@paperclipai/shared";
|
|
import { HttpError, badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
|
import { toolAccessService } from "./tool-access.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
import { githubBotRequest } from "./chat-github-client.js";
|
|
import { environmentService } from "./environments.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import { resolveExecutionWorkspaceEnvironmentId } from "./execution-workspace-policy.js";
|
|
|
|
export function githubChatManagementService(db: Db, fetchImpl = fetch) {
|
|
async function endpoint(id: string) {
|
|
const [row] = await db
|
|
.select()
|
|
.from(chatEndpoints)
|
|
.where(
|
|
and(eq(chatEndpoints.id, id), eq(chatEndpoints.provider, "github")),
|
|
);
|
|
if (!row || row.status === "archived")
|
|
throw notFound("GitHub bot not found");
|
|
return row;
|
|
}
|
|
async function member(companyId: string, userId: string) {
|
|
const [row] = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
);
|
|
if (!row || row.membershipRole === "viewer")
|
|
throw forbidden(
|
|
"An active company member with permission to start work is required",
|
|
);
|
|
return row;
|
|
}
|
|
async function configuration(endpointId: string, userId: string) {
|
|
const bot = await endpoint(endpointId);
|
|
const [saved] = await db
|
|
.select()
|
|
.from(chatGitHubConfigurations)
|
|
.where(
|
|
and(
|
|
eq(chatGitHubConfigurations.companyId, bot.companyId),
|
|
eq(chatGitHubConfigurations.endpointId, endpointId),
|
|
),
|
|
);
|
|
return (
|
|
saved ?? {
|
|
endpointId,
|
|
companyId: bot.companyId,
|
|
revision: 0,
|
|
configuration: {
|
|
version: 1,
|
|
toolsEnabled: false,
|
|
responsibleUserId: userId,
|
|
memberAccess: "all_linked",
|
|
people: [],
|
|
defaults: defaultGitHubReviewPolicy(),
|
|
repositories: {},
|
|
} satisfies GitHubChatConfiguration,
|
|
}
|
|
);
|
|
}
|
|
async function saveConfiguration(
|
|
endpointId: string,
|
|
input: unknown,
|
|
userId: string,
|
|
managementContext?: ConnectionManagementContext,
|
|
) {
|
|
await managementContext?.reauthorize();
|
|
const bot = await endpoint(endpointId);
|
|
await member(bot.companyId, userId);
|
|
const parsed = updateGitHubChatConfigurationSchema.parse(input);
|
|
const config = parsed.configuration;
|
|
await member(bot.companyId, config.responsibleUserId);
|
|
for (const person of config.people) {
|
|
if (person.kind === "guest") {
|
|
await member(bot.companyId, person.sponsorUserId);
|
|
continue;
|
|
}
|
|
await member(bot.companyId, person.userId);
|
|
const [linked] = await db
|
|
.select({ externalId: chatExternalPrincipals.externalId })
|
|
.from(chatIdentityLinks)
|
|
.innerJoin(
|
|
chatExternalPrincipals,
|
|
eq(chatExternalPrincipals.id, chatIdentityLinks.principalId),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatIdentityLinks.companyId, bot.companyId),
|
|
eq(chatIdentityLinks.endpointId, endpointId),
|
|
eq(chatIdentityLinks.paperclipUserId, person.userId),
|
|
eq(chatIdentityLinks.status, "linked"),
|
|
eq(chatExternalPrincipals.externalId, person.githubUserId),
|
|
),
|
|
);
|
|
if (!linked)
|
|
throw badRequest(
|
|
"Each member must connect and confirm their own GitHub account before being added",
|
|
);
|
|
}
|
|
const resources = await db
|
|
.select()
|
|
.from(chatEndpointResources)
|
|
.where(
|
|
and(
|
|
eq(chatEndpointResources.companyId, bot.companyId),
|
|
eq(chatEndpointResources.endpointId, endpointId),
|
|
),
|
|
);
|
|
const repositoryIds = new Set(
|
|
resources.map((resource) =>
|
|
String(resource.metadata?.providerRepositoryId ?? ""),
|
|
),
|
|
);
|
|
if (Object.keys(config.repositories).some((id) => !repositoryIds.has(id)))
|
|
throw badRequest(
|
|
"Repository overrides must refer to this App installation",
|
|
);
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.select({ id: chatEndpoints.id })
|
|
.from(chatEndpoints)
|
|
.where(eq(chatEndpoints.id, endpointId))
|
|
.for("update");
|
|
await tx.execute(
|
|
sql`select pg_advisory_xact_lock(hashtextextended(${`github-config:${endpointId}`}, 0))`,
|
|
);
|
|
const [current] = await tx
|
|
.select()
|
|
.from(chatGitHubConfigurations)
|
|
.where(eq(chatGitHubConfigurations.endpointId, endpointId))
|
|
.for("update");
|
|
if ((current?.revision ?? 0) !== parsed.expectedRevision)
|
|
throw conflict("This configuration changed. Refresh before saving.");
|
|
const values = {
|
|
companyId: bot.companyId,
|
|
endpointId,
|
|
revision: parsed.expectedRevision + 1,
|
|
configuration: config,
|
|
updatedByUserId: userId,
|
|
updatedAt: new Date(),
|
|
};
|
|
await tx
|
|
.insert(chatGitHubConfigurations)
|
|
.values(values)
|
|
.onConflictDoUpdate({
|
|
target: chatGitHubConfigurations.endpointId,
|
|
set: values,
|
|
});
|
|
// The list controls admission; this flag enables the existing restricted
|
|
// guest execution path only when an explicit guest is present.
|
|
await tx
|
|
.update(chatEndpoints)
|
|
.set({
|
|
allowUnlinkedPeople: config.people.some((p) => p.kind === "guest"),
|
|
sponsorUserId: config.responsibleUserId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(chatEndpoints.id, endpointId));
|
|
await syncGitHubBotTools(tx, bot, userId, config.toolsEnabled);
|
|
await logActivity(tx as unknown as Db, {
|
|
companyId: bot.companyId,
|
|
...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
|
|
action: "chat_github.configuration_updated",
|
|
entityType: "tool_connection",
|
|
entityId: bot.connectionId,
|
|
details: { endpointId, revision: values.revision },
|
|
});
|
|
});
|
|
return configuration(endpointId, userId);
|
|
}
|
|
async function personalConnections(endpointId: string, userId: string) {
|
|
const bot = await endpoint(endpointId);
|
|
await member(bot.companyId, userId);
|
|
const rows = await db
|
|
.select({ connection: toolConnections, grant: connectionGrants })
|
|
.from(connectionGrants)
|
|
.innerJoin(
|
|
toolConnections,
|
|
and(
|
|
eq(toolConnections.id, connectionGrants.connectionId),
|
|
eq(toolConnections.companyId, bot.companyId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, bot.companyId),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
),
|
|
);
|
|
return rows
|
|
.filter(
|
|
({ connection }) =>
|
|
connection.config.sourceTemplateKey === "github" ||
|
|
connection.transportConfig?.sourceTemplateKey === "github",
|
|
)
|
|
.map(({ connection, grant }) => ({
|
|
connectionId: connection.id,
|
|
name: connection.name,
|
|
status: grant.status,
|
|
login: grant.providerTenant?.github?.login ?? null,
|
|
enabled: connection.enabled,
|
|
}));
|
|
}
|
|
async function identity(
|
|
endpointId: string,
|
|
connectionId: string,
|
|
userId: string,
|
|
confirmedGithubUserId?: string,
|
|
) {
|
|
const bot = await endpoint(endpointId);
|
|
await member(bot.companyId, userId);
|
|
if (!bot.providerAccountId)
|
|
throw conflict("Verify the bot App installation first");
|
|
const verified = await toolAccessService(db).verifyPersonalGitHubIdentity(
|
|
bot.companyId,
|
|
connectionId,
|
|
userId,
|
|
);
|
|
if (confirmedGithubUserId === undefined) return verified;
|
|
if (confirmedGithubUserId !== verified.githubUserId)
|
|
throw conflict(
|
|
"The connected GitHub account changed. Check and confirm its identity again.",
|
|
);
|
|
await linkObservedIdentity(endpointId, userId, verified);
|
|
return verified;
|
|
}
|
|
async function linkObservedIdentity(endpointId: string, userId: string, verified: { githubUserId: string; login: string; avatarUrl?: string | null; grantId?: string; connectionId?: string }) {
|
|
const bot = await endpoint(endpointId);
|
|
await member(bot.companyId, userId);
|
|
if (!bot.providerAccountId) throw conflict("Verify the App installation first");
|
|
await db.transaction(async (tx) => {
|
|
const [membership] = await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, bot.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId))).for("update");
|
|
if (!membership || membership.status !== "active" || membership.membershipRole === "viewer") throw forbidden("An active company manager is required");
|
|
const [principal] = await tx
|
|
.insert(chatExternalPrincipals)
|
|
.values({
|
|
companyId: bot.companyId,
|
|
provider: "github",
|
|
providerAccountId: bot.providerAccountId!,
|
|
externalId: verified.githubUserId,
|
|
kind: "user",
|
|
handle: verified.login,
|
|
displayName: verified.login,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
chatExternalPrincipals.companyId,
|
|
chatExternalPrincipals.provider,
|
|
chatExternalPrincipals.providerAccountId,
|
|
chatExternalPrincipals.externalId,
|
|
],
|
|
set: { handle: verified.login, updatedAt: new Date() },
|
|
})
|
|
.returning();
|
|
await tx.execute(
|
|
sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${bot.companyId}:${principal!.id}`}, 0))`,
|
|
);
|
|
const [prior] = await tx
|
|
.select()
|
|
.from(chatIdentityLinks)
|
|
.where(
|
|
and(
|
|
eq(chatIdentityLinks.endpointId, endpointId),
|
|
eq(chatIdentityLinks.principalId, principal!.id),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (prior?.status === "linked" && prior.paperclipUserId !== userId)
|
|
throw conflict(
|
|
"This GitHub account is already linked to another Paperclip member",
|
|
);
|
|
if (verified.grantId) {
|
|
const [grant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, bot.companyId),
|
|
eq(connectionGrants.id, verified.grantId),
|
|
eq(connectionGrants.status, "active"),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (!grant)
|
|
throw forbidden("Your personal GitHub connection was revoked");
|
|
}
|
|
const values = {
|
|
companyId: bot.companyId,
|
|
endpointId,
|
|
principalId: principal!.id,
|
|
paperclipUserId: userId,
|
|
status: "linked" as const,
|
|
confirmedAt: new Date(),
|
|
revokedAt: null,
|
|
confirmationTokenHash: null,
|
|
expiresAt: null,
|
|
updatedAt: new Date(),
|
|
};
|
|
await tx
|
|
.insert(chatIdentityLinks)
|
|
.values(values)
|
|
.onConflictDoUpdate({
|
|
target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId],
|
|
set: values,
|
|
});
|
|
await logActivity(tx as unknown as Db, {
|
|
companyId: bot.companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "chat_github.identity_confirmed",
|
|
entityType: "tool_connection",
|
|
entityId: bot.connectionId,
|
|
details: {
|
|
endpointId,
|
|
connectionId: verified.connectionId ?? bot.connectionId,
|
|
githubUserId: verified.githubUserId,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
return {
|
|
linkObservedIdentity,
|
|
verification: async (endpointId: string) => {
|
|
const bot = await endpoint(endpointId);
|
|
const checks: Array<{
|
|
key: string;
|
|
label: string;
|
|
ok: boolean;
|
|
detail: string;
|
|
}> = [];
|
|
checks.push({
|
|
key: "delivery",
|
|
label: "Signed webhook delivery",
|
|
ok: !!bot.setup.webhookVerifiedAt,
|
|
detail: bot.setup.webhookVerifiedAt
|
|
? "GitHub delivered a signed webhook to this instance."
|
|
: "Send or redeliver a ping from your App's webhook settings.",
|
|
});
|
|
const credentials = await githubBotCredentials(
|
|
db,
|
|
bot.companyId,
|
|
endpointId,
|
|
);
|
|
const app = await githubBotRequest<{
|
|
id?: number;
|
|
permissions?: Record<string, string>;
|
|
events?: string[];
|
|
}>(fetchImpl, credentials.appJwt, "/app");
|
|
checks.push({
|
|
key: "app",
|
|
label: "GitHub App identity",
|
|
ok: String(app.id) === bot.botExternalId,
|
|
detail:
|
|
String(app.id) === bot.botExternalId
|
|
? "The vaulted credentials identify this bot's App."
|
|
: "The vaulted credentials identify a different GitHub App.",
|
|
});
|
|
const installation = credentials.credentials.installationId
|
|
? await githubBotRequest<{
|
|
permissions?: Record<string, string>;
|
|
suspended_at?: string | null;
|
|
}>(
|
|
fetchImpl,
|
|
credentials.appJwt,
|
|
`/app/installations/${credentials.credentials.installationId}`,
|
|
)
|
|
: null;
|
|
const permissions = installation?.permissions ?? {};
|
|
const permissionsOk =
|
|
!!installation &&
|
|
!installation.suspended_at &&
|
|
permissions.contents === "read" &&
|
|
permissions.pull_requests === "write" &&
|
|
permissions.checks === "write" &&
|
|
app.events?.includes("pull_request") === true;
|
|
checks.push({
|
|
key: "permissions",
|
|
label: "Review installation permissions",
|
|
ok: permissionsOk,
|
|
detail: permissionsOk
|
|
? "Contents read, Pull requests write, Checks write, and PR events are enabled."
|
|
: "Set Contents to read-only and Checks to write, subscribe to pull_request, and approve the permission upgrade on GitHub.",
|
|
});
|
|
const resources = await db
|
|
.select()
|
|
.from(chatEndpointResources)
|
|
.where(
|
|
and(
|
|
eq(chatEndpointResources.companyId, bot.companyId),
|
|
eq(chatEndpointResources.endpointId, endpointId),
|
|
eq(chatEndpointResources.enabled, true),
|
|
),
|
|
);
|
|
const inaccessible: string[] = [];
|
|
// Ask GitHub to authorize the selected repository IDs now. A cached
|
|
// inventory alone cannot prove access after an installation is changed.
|
|
const verifiable = resources.filter((resource) => {
|
|
const repositoryId = String(resource.metadata?.providerRepositoryId ?? "");
|
|
if (
|
|
!permissionsOk ||
|
|
resource.availability !== "available" ||
|
|
!/^[1-9][0-9]*$/.test(repositoryId) ||
|
|
!Number.isSafeInteger(Number(repositoryId))
|
|
) {
|
|
inaccessible.push(resource.label ?? resource.providerResourceId);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
// GitHub authorizes up to 500 explicit IDs in one scoped token request.
|
|
// Keep these tokens inside verification; task credentials remain scoped
|
|
// separately. Never omit repository_ids, which would request all access.
|
|
for (let offset = 0; offset < verifiable.length; offset += 500) {
|
|
const batch = verifiable.slice(offset, offset + 500);
|
|
try {
|
|
const issued = await githubBotRequest<{ token?: string }>(
|
|
fetchImpl,
|
|
credentials.appJwt,
|
|
`/app/installations/${credentials.credentials.installationId}/access_tokens`,
|
|
{
|
|
method: "POST",
|
|
body: {
|
|
repository_ids: batch.map((resource) =>
|
|
Number(resource.metadata!.providerRepositoryId),
|
|
),
|
|
permissions: {
|
|
contents: "read",
|
|
metadata: "read",
|
|
issues: "write",
|
|
pull_requests: "write",
|
|
checks: "write",
|
|
},
|
|
},
|
|
},
|
|
);
|
|
if (!issued.token) throw new Error("Missing installation token");
|
|
} catch {
|
|
// A rejected batch is unverified; do not activate from cached access.
|
|
inaccessible.push(
|
|
...batch.map((resource) => resource.label ?? resource.providerResourceId),
|
|
);
|
|
}
|
|
}
|
|
checks.push({
|
|
key: "repositories",
|
|
label: "Enabled repositories",
|
|
ok: resources.length > 0 && inaccessible.length === 0,
|
|
detail: inaccessible.length
|
|
? `Restore installation access and refresh: ${inaccessible.join(", ")}.`
|
|
: resources.length
|
|
? `GitHub confirmed review access to all ${resources.length} enabled repositories.`
|
|
: "Enable at least one repository from the App installation.",
|
|
});
|
|
const [agent] = await db
|
|
.select()
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.companyId, bot.companyId),
|
|
eq(agents.id, bot.assignedAgentId),
|
|
),
|
|
);
|
|
checks.push({
|
|
key: "runtime",
|
|
label: "Agent runtime supports bot tools",
|
|
ok:
|
|
!!agent &&
|
|
["paperclip_runner", "codex_local"].includes(agent.adapterType),
|
|
detail:
|
|
"Use Paperclip Runner or Codex with managed MCP tools. Low-trust execution also requires a valid scoped boundary and isolated sandbox; the test task proves runtime execution.",
|
|
});
|
|
const [savedConfig] = await db
|
|
.select()
|
|
.from(chatGitHubConfigurations)
|
|
.where(eq(chatGitHubConfigurations.endpointId, endpointId));
|
|
if (
|
|
agent &&
|
|
(agent.permissions.trustPreset === "low_trust_review" ||
|
|
savedConfig?.configuration.people.some(
|
|
(person) => person.kind === "guest",
|
|
))
|
|
) {
|
|
const settings = instanceSettingsService(db);
|
|
const experimental = await settings.getExperimental();
|
|
const environments = environmentService(db);
|
|
const local = await environments.ensureLocalEnvironment(bot.companyId);
|
|
const managed = experimental.enableManagedSandboxOnly
|
|
? await environments.findManagedSandboxEnvironment(bot.companyId)
|
|
: null;
|
|
const selected = resolveExecutionWorkspaceEnvironmentId({
|
|
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
|
instanceDefaultEnvironmentId:
|
|
(await settings.get()).defaultEnvironmentId ?? null,
|
|
localDefaultEnvironmentId: local.id,
|
|
managedSandboxOnly: experimental.enableManagedSandboxOnly,
|
|
managedSandboxEnvironmentId: managed?.id,
|
|
});
|
|
const environment = await environments.getById(selected.environmentId);
|
|
const owners = environment
|
|
? await environments.listBoundCompanyIds(environment.id)
|
|
: [];
|
|
const sandboxOk =
|
|
experimental.enableIsolatedWorkspaces &&
|
|
environment?.driver === "sandbox" &&
|
|
environment.status === "active" &&
|
|
(!owners.length || owners.includes(bot.companyId));
|
|
checks.push({
|
|
key: "isolation",
|
|
label: "Low-trust and guest execution",
|
|
ok: !!sandboxOk,
|
|
detail: sandboxOk
|
|
? "An active sandbox environment is selected. Each task receives its own restricted trust boundary."
|
|
: "Enable isolated workspaces and select an active sandbox environment for this agent before running low-trust or guest tasks.",
|
|
});
|
|
}
|
|
const entries = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, bot.companyId),
|
|
eq(toolCatalogEntries.connectionId, bot.connectionId),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
);
|
|
const requiredTools = GITHUB_BOT_TOOLS.filter(
|
|
(tool) => tool.name !== "formal_review",
|
|
);
|
|
const denied: string[] = requiredTools
|
|
.filter(
|
|
(tool) =>
|
|
!entries.some(
|
|
(entry) =>
|
|
entry.toolName === tool.name &&
|
|
entry.name === `github_bot:${tool.name}`,
|
|
),
|
|
)
|
|
.map((tool) => tool.title);
|
|
let toolsOk = denied.length === 0;
|
|
for (const entry of entries) {
|
|
const decision = await toolAccessPolicyService(db).decide({
|
|
companyId: bot.companyId,
|
|
actor: {
|
|
actorType: "agent",
|
|
actorId: bot.assignedAgentId,
|
|
agentId: bot.assignedAgentId,
|
|
},
|
|
runContext: {},
|
|
request: {
|
|
toolName: `github-bot.${bot.id}:${entry.toolName}`,
|
|
applicationId: credentials.connection.applicationId,
|
|
applicationKey: "github-chat",
|
|
connectionId: bot.connectionId,
|
|
catalogEntryId: entry.id,
|
|
providerType: "paperclip_github_chat",
|
|
upstreamToolName: entry.toolName,
|
|
riskLevel: entry.riskLevel,
|
|
arguments: {},
|
|
sideEffecting: entry.riskLevel !== "read",
|
|
},
|
|
});
|
|
if (
|
|
entry.toolName !== "formal_review" &&
|
|
!decision.allowed &&
|
|
decision.decision !== "require_approval"
|
|
) {
|
|
toolsOk = false;
|
|
denied.push(entry.title ?? entry.toolName);
|
|
}
|
|
}
|
|
checks.push({
|
|
key: "tools",
|
|
label: "Assigned agent's effective GitHub tools",
|
|
ok: toolsOk,
|
|
detail: toolsOk
|
|
? "The bot App's task-scoped tools are assigned. Each run and publication rechecks access."
|
|
: denied.length
|
|
? `Repair tool policy for: ${denied.join(", ")}.`
|
|
: "Assign the bot's GitHub tools to this agent, then verify again.",
|
|
});
|
|
return { checks, ready: checks.every((check) => check.ok), connectionReady: checks.filter(check => !["runtime", "isolation"].includes(check.key)).every(check => check.ok) };
|
|
},
|
|
configuration,
|
|
saveConfiguration,
|
|
personalConnections,
|
|
identity,
|
|
lookupPerson: async (endpointId: string, login: string) => {
|
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}(?:\[bot\])?$/.test(login))
|
|
throw badRequest("Enter a GitHub username");
|
|
const bot = await endpoint(endpointId);
|
|
const material = await githubBotCredentials(db, bot.companyId, endpointId);
|
|
if (
|
|
!material.connection.enabled ||
|
|
material.connection.status !== "active" ||
|
|
!["active", "verifying"].includes(material.endpoint.status)
|
|
)
|
|
throw conflict("GitHub bot connection is not active");
|
|
const installationId = material.credentials.installationId;
|
|
if (!installationId || !/^[1-9][0-9]*$/.test(installationId))
|
|
throw conflict("Verify the GitHub App installation before looking up people");
|
|
let lookingUpPerson = false;
|
|
try {
|
|
const issued = await githubBotRequest<{ token?: string }>(
|
|
fetchImpl,
|
|
material.appJwt,
|
|
`/app/installations/${installationId}/access_tokens`,
|
|
{ method: "POST", body: { permissions: { metadata: "read" } } },
|
|
);
|
|
if (!issued.token)
|
|
throw unprocessable("GitHub did not issue an installation token. Reconnect the bot and try again.");
|
|
lookingUpPerson = true;
|
|
const user = await githubBotRequest<{
|
|
id?: number;
|
|
login?: string;
|
|
type?: string;
|
|
}>(fetchImpl, issued.token, `/users/${encodeURIComponent(login)}`);
|
|
if (
|
|
!Number.isSafeInteger(user.id) ||
|
|
!user.id ||
|
|
!["User", "Bot"].includes(user.type ?? "") ||
|
|
!user.login
|
|
)
|
|
throw badRequest("Choose a GitHub person or bot account");
|
|
return { githubUserId: String(user.id), login: user.login };
|
|
} catch (error) {
|
|
if (!(error instanceof HttpError)) throw error;
|
|
const details = error.details as { code?: string; providerStatus?: number } | undefined;
|
|
if (details?.code === "github_bot_rate_limited")
|
|
throw unprocessable("GitHub's rate limit was reached. Wait and try the lookup again.", details);
|
|
if (details?.providerStatus === 404 && lookingUpPerson)
|
|
throw badRequest("GitHub could not find this account. Check the username and the bot's access to the account.", details);
|
|
if (details?.providerStatus === 401 || details?.providerStatus === 404)
|
|
throw unprocessable("GitHub could not authenticate this bot installation. Reconnect the bot and try again.", details);
|
|
if (details?.providerStatus === 403)
|
|
throw unprocessable("GitHub denied this bot installation access. Check its installation on GitHub and try again.", details);
|
|
throw error;
|
|
}
|
|
},
|
|
review: async (endpointId: string, reviewId: string) => {
|
|
const bot = await endpoint(endpointId);
|
|
const [review] = await db.select().from(chatGitHubReviews).where(and(
|
|
eq(chatGitHubReviews.companyId, bot.companyId),
|
|
eq(chatGitHubReviews.endpointId, endpointId),
|
|
eq(chatGitHubReviews.id, reviewId),
|
|
));
|
|
if (!review) throw notFound("This review was not found in this connection");
|
|
return review;
|
|
},
|
|
reviews: async (endpointId: string) => {
|
|
const bot = await endpoint(endpointId);
|
|
return db
|
|
.select()
|
|
.from(chatGitHubReviews)
|
|
.where(
|
|
and(
|
|
eq(chatGitHubReviews.companyId, bot.companyId),
|
|
eq(chatGitHubReviews.endpointId, endpointId),
|
|
),
|
|
)
|
|
.orderBy(desc(chatGitHubReviews.createdAt))
|
|
.limit(100);
|
|
},
|
|
};
|
|
}
|