mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents use a managed GitHub connection to create repositories and to fork repositories > - The Paperclip GitHub App can be installed only on an organization, and not on the user's personal account > - Then `create_repository` and `fork_repository` fail when `organization` is empty, because GitHub sends them to the personal account > - Agents, approval cards, and connection tests all fail on their first attempt with `403 Resource not accessible by integration` > - This pull request fills an empty `organization` with the only organization where the App is installed > - The benefit is that repository creation works on the first attempt for these connections ## Linked Issues or Issue Description No public issue exists. Refs #19655 (GitHub repair guidance for the same error). **What happened?** A user connected GitHub through the managed connection. The Paperclip GitHub App was installed on one organization and not on the user's personal account. An agent called `create_repository` without `organization`. GitHub returned `POST https://api.github.com/user/repos: 403 Resource not accessible by integration`. The test button on the connection page gave the same error. The agent then stopped and asked the user to create the repository manually. **Expected behavior** When the App can create repositories only in one organization, an empty `organization` uses that organization. The approval card and the test result show that owner. **Steps to reproduce** 1. Install the Paperclip GitHub App on one organization only. Do not install it on the personal account. 2. Connect GitHub with the managed connection. 3. Run `create_repository` with only `name`, from an agent or from the connection test dialog. 4. GitHub returns 403. ## What Changed - Added `server/src/services/tool-access/github-default-owner.ts`. It finds the default organization from the grant's access snapshot. It returns an organization only when there is exactly one installation, and the snapshot marks that owner's repositories as organization repositories. Older snapshots without account types get no default until access is refreshed. - `governedToolArguments` in `tool-gateway.ts` fills an empty `organization` for `create_repository` and `fork_repository` on managed GitHub connections. The change occurs before policy, approval signing, previews, and audit. Thus agent calls, approval cards, and connection tests use the same owner. - The grant lookup runs only for these two tools, and only when `organization` is empty. If the credential is unavailable, the call does not change and fails with its own error at dispatch. - An explicit owner does not change. A fork into its own owner does not change. Token (PAT) connections, personal installations, and multiple organizations keep GitHub's behavior. - `projectedConnectionToolInputSchema` describes the default on the `organization` argument. Agents and the connection test form see where the repository goes. - Updated `doc/connections/GITHUB.md`. ## Verification - `pnpm --filter @paperclipai/server typecheck` passes. - `npx vitest run src/services/tool-access/github-default-owner.test.ts`: new unit tests for the owner rules (single organization, personal installation, several organizations, another person's account, older snapshots, blank values, forks into the same owner, schema text). - New gateway test in `src/__tests__/tool-gateway.test.ts`: "defaults GitHub repository creation to the only installed organization for agents and connection tests". It covers an agent call, an explicit owner, a connection test call, and a personal installation. It fails without the gateway change. - Related suites pass, 393 tests in 9 files: `tool-gateway.test.ts`, `tool-gateway-service.test.ts`, `connection-instructions.test.ts`, `github-access-errors.test.ts`, `github-default-owner.test.ts`, `tool-gateway-discovery-http.test.ts`, `github-operation-credentials.test.ts`, `project-git-authorization.test.ts`, `prompt-injection-gateway.test.ts`. - Not tested against a live GitHub App installation. ## Risks - Low risk. The default applies only when the grant snapshot shows one organization installation and no personal installation. Without the change, these calls fail with 403. - A stale access snapshot can choose the organization after a user installs the App on the personal account. Installation events and **Refresh access** update the snapshot. - An approval that was created before this change, without `organization`, no longer matches the projected arguments. That approval cannot run and must be requested again. Without the change, it fails with 403. - `gh repo create` and `gh repo fork` in agent shells do not use the gateway, so they keep GitHub's personal default. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (code reading, shell commands, and test runs). ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
import { parseRemoteHttpEndpoint } from "../remote-http-endpoint-guard.js";
|
|
import type { AppDefinition, ConnectionMethodDef, ToolConnectionTransport } from "@paperclipai/shared";
|
|
import {
|
|
credentialConfigPath,
|
|
getAvailableConnectionMethod,
|
|
getAvailableConnectionMethods,
|
|
getConnectableAppDefinition,
|
|
resolveConnectionMethodServerUrl,
|
|
checkMcpRemoteHeaderName,
|
|
checkMcpRemoteHeaderValue,
|
|
mcpRemoteHeaderRejectionMessage,
|
|
} from "@paperclipai/shared";
|
|
import { badRequest, unprocessable } from "../../errors.js";
|
|
import { isPaperclipCloudConnectorStrategy } from "../paperclip-cloud-connector.js";
|
|
import { honchoManagedArguments } from "../honcho-connection.js";
|
|
import { assertGoogleChatToolArgumentsSupported, googleChatToolInputSchema } from "../google-chat-tool-policy.js";
|
|
import { gmailArchiveToolInputSchema } from "../gmail-archive-tool-policy.js";
|
|
import { githubOwnerToolInputSchema } from "./github-default-owner.js";
|
|
|
|
type ConnectionSettings = {
|
|
config: Record<string, unknown>;
|
|
transport: ToolConnectionTransport;
|
|
transportConfig?: Record<string, unknown>;
|
|
};
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
if (value && typeof value === "object" && !Array.isArray(value))
|
|
return value as Record<string, unknown>;
|
|
return {};
|
|
}
|
|
|
|
export function connectionMethodFor(app: AppDefinition, methodKey?: string | null) {
|
|
const normalizedMethodKey =
|
|
app.slug === "gmail" && methodKey === "paperclip-id-oauth"
|
|
? "paperclip-draft"
|
|
: methodKey;
|
|
// Stored managed connections must remain recognizable for callback, refresh,
|
|
// and revoke even though static definitions omit instance availability. New
|
|
// setup passes a definition filtered by signed profiles before reaching here;
|
|
// the broker independently enforces availability on authorization and refresh.
|
|
const availableMethods = new Set(getAvailableConnectionMethods(app));
|
|
const toolMethods = app.methods.filter(
|
|
(candidate) =>
|
|
candidate.purpose !== "channel" && candidate.purpose !== "ai"
|
|
&& candidate.transport !== "chat_sdk" && candidate.transport !== "runtime_auth"
|
|
&& (availableMethods.has(candidate) || isPaperclipCloudConnectorStrategy(candidate.oauthStrategy)),
|
|
);
|
|
const method = normalizedMethodKey
|
|
? (toolMethods.find((candidate) => candidate.key === normalizedMethodKey) ??
|
|
null)
|
|
: getAvailableConnectionMethod({ ...app, methods: toolMethods }, null);
|
|
if (!method)
|
|
throw unprocessable(
|
|
"This app does not have an available connection method",
|
|
);
|
|
return method;
|
|
}
|
|
|
|
export function connectionMethodForConnection(
|
|
app: AppDefinition,
|
|
connection: ConnectionSettings,
|
|
) {
|
|
const methodKey =
|
|
typeof connection.config.connectionMethodKey === "string"
|
|
? connection.config.connectionMethodKey
|
|
: null;
|
|
return connectionMethodFor(app, methodKey);
|
|
}
|
|
|
|
export function credentialFieldsFor(app: AppDefinition, methodKey?: string | null) {
|
|
const method = connectionMethodFor(app, methodKey);
|
|
return (method.credentialFields ?? []).map((field) => ({
|
|
label: field.label,
|
|
configPath: credentialConfigPath(field, method),
|
|
helpUrl: method.consoleLinks?.keys ?? method.consoleLinks?.docs ?? "",
|
|
required: field.required,
|
|
placement:
|
|
method.keyPlacement?.location === "header"
|
|
? ("header" as const)
|
|
: undefined,
|
|
key: method.keyPlacement?.name,
|
|
prefix: method.keyPlacement?.prefix,
|
|
}));
|
|
}
|
|
|
|
|
|
export function normalizeConnectionMethodConfig(
|
|
method: ConnectionMethodDef,
|
|
configValues: Record<string, unknown> | undefined,
|
|
): {
|
|
values: Record<string, string | boolean>;
|
|
url?: string;
|
|
headers?: Record<string, string>;
|
|
} {
|
|
const fields = [
|
|
...(method.tenantFields ?? []),
|
|
...(method.extensionFields ?? []),
|
|
];
|
|
const allowedKeys = new Set(fields.map((field) => field.key));
|
|
for (const key of Object.keys(configValues ?? {})) {
|
|
if (!allowedKeys.has(key))
|
|
throw badRequest(`Unknown connection setting: ${key}`);
|
|
}
|
|
|
|
const values: Record<string, string | boolean> = {};
|
|
for (const field of fields) {
|
|
const raw = configValues?.[field.key] ?? field.defaultValue;
|
|
if (field.type === "checkbox") {
|
|
if (raw !== undefined && typeof raw !== "boolean")
|
|
throw badRequest(`${field.label} must be true or false`);
|
|
if (raw !== undefined) values[field.key] = raw;
|
|
continue;
|
|
}
|
|
if (raw !== undefined && typeof raw !== "string")
|
|
throw badRequest(`${field.label} must be text`);
|
|
let value = raw?.trim() ?? "";
|
|
if (field.transport?.format === "csv") {
|
|
value = Array.from(
|
|
new Set(
|
|
value
|
|
.split(/[\n,]/g)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean),
|
|
),
|
|
).join(",");
|
|
}
|
|
if (field.required && !value)
|
|
throw badRequest(`Missing connection setting: ${field.label}`);
|
|
if (!value) continue;
|
|
if (
|
|
field.validation?.maxLength &&
|
|
value.length > field.validation.maxLength
|
|
) {
|
|
throw badRequest(
|
|
`${field.label} must be at most ${field.validation.maxLength} characters`,
|
|
);
|
|
}
|
|
if (
|
|
field.validation?.pattern &&
|
|
!new RegExp(field.validation.pattern).test(value)
|
|
) {
|
|
throw badRequest(`${field.label} has an invalid value`);
|
|
}
|
|
if (
|
|
field.type === "select" &&
|
|
!field.options?.some((option) => option.value === value)
|
|
) {
|
|
throw badRequest(`${field.label} has an invalid option`);
|
|
}
|
|
values[field.key] = value;
|
|
}
|
|
for (const keys of method.configRequirements?.atLeastOneOf
|
|
? [method.configRequirements.atLeastOneOf]
|
|
: []) {
|
|
if (
|
|
!keys.some(
|
|
(key) => typeof values[key] === "string" && values[key].length > 0,
|
|
)
|
|
) {
|
|
throw badRequest(`Provide at least one of: ${keys.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
const resolvedServerUrl = resolveConnectionMethodServerUrl(method, values);
|
|
if (method.defaults?.serverUrlTemplate && !resolvedServerUrl) {
|
|
throw badRequest(
|
|
"Missing or invalid connection settings for the server URL",
|
|
);
|
|
}
|
|
const endpoint = resolvedServerUrl ? new URL(resolvedServerUrl) : null;
|
|
if (endpoint && endpoint.protocol !== "https:") {
|
|
throw badRequest("Connection server URL must use HTTPS");
|
|
}
|
|
const headers: Record<string, string> = {};
|
|
for (const field of fields) {
|
|
const transport = field.transport;
|
|
const value = values[field.key];
|
|
if (
|
|
!transport ||
|
|
value === undefined ||
|
|
(value === false && transport.omitFalse)
|
|
)
|
|
continue;
|
|
const serialized = typeof value === "boolean" ? String(value) : value;
|
|
if (transport.location === "query")
|
|
endpoint?.searchParams.set(transport.name, serialized);
|
|
else {
|
|
const nameCheck = checkMcpRemoteHeaderName(transport.name);
|
|
const valueCheck = checkMcpRemoteHeaderValue(serialized);
|
|
if (!nameCheck.ok || !valueCheck.ok) {
|
|
throw badRequest(
|
|
mcpRemoteHeaderRejectionMessage(
|
|
transport.name,
|
|
nameCheck.reason ?? valueCheck.reason!,
|
|
),
|
|
);
|
|
}
|
|
headers[transport.name] = serialized;
|
|
}
|
|
}
|
|
return {
|
|
values,
|
|
...(endpoint ? { url: endpoint.toString() } : {}),
|
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
};
|
|
}
|
|
|
|
export function projectedConnectionHeaders(
|
|
connection: ConnectionSettings,
|
|
): Record<string, string> {
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const app = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
const headers: Record<string, string> = {};
|
|
if (app) {
|
|
const method = connectionMethodForConnection(app, connection);
|
|
// Non-transport settings do not participate in header projection. In
|
|
// particular, legacy connections can lack a newly introduced setting.
|
|
if ([...(method.tenantFields ?? []), ...(method.extensionFields ?? [])].some((field) => field.transport?.location === "header")) {
|
|
Object.assign(headers, normalizeConnectionMethodConfig(
|
|
method,
|
|
asRecord(connection.config.methodConfig),
|
|
).headers);
|
|
}
|
|
}
|
|
if (
|
|
connection.transport === "mcp_remote" &&
|
|
(sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github")
|
|
) {
|
|
// GitHub excludes Actions from its default catalog. Project this on every
|
|
// discovery and invocation so existing connections gain the toolset too.
|
|
headers["X-MCP-Toolsets"] = "default,actions";
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function mergeManagedToolArguments(
|
|
supplied: Record<string, unknown>,
|
|
managed: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const merged = { ...supplied };
|
|
for (const [key, value] of Object.entries(managed)) {
|
|
const suppliedValue = merged[key];
|
|
merged[key] =
|
|
asRecord(value) === value && asRecord(suppliedValue) === suppliedValue
|
|
? mergeManagedToolArguments(
|
|
suppliedValue as Record<string, unknown>,
|
|
value as Record<string, unknown>,
|
|
)
|
|
: value;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export function projectConnectionMethodToolArguments(
|
|
method: ConnectionMethodDef,
|
|
parameters: unknown,
|
|
): Record<string, unknown> {
|
|
const supplied = asRecord(parameters);
|
|
const managed = method.defaults?.toolArgumentDefaults;
|
|
return managed ? mergeManagedToolArguments(supplied, managed) : supplied;
|
|
}
|
|
|
|
function stripManagedToolArgumentSchema(
|
|
schema: Record<string, unknown>,
|
|
managed: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const properties = asRecord(schema.properties);
|
|
if (Object.keys(properties).length === 0) return schema;
|
|
const nextProperties = { ...properties };
|
|
for (const [key, managedValue] of Object.entries(managed)) {
|
|
const propertySchema = asRecord(nextProperties[key]);
|
|
const managedRecord = asRecord(managedValue);
|
|
if (
|
|
Object.keys(propertySchema).length === 0 ||
|
|
Object.keys(managedRecord).length === 0
|
|
) {
|
|
delete nextProperties[key];
|
|
continue;
|
|
}
|
|
const projectedProperty = stripManagedToolArgumentSchema(
|
|
propertySchema,
|
|
managedRecord,
|
|
);
|
|
if (Object.keys(asRecord(projectedProperty.properties)).length === 0)
|
|
delete nextProperties[key];
|
|
else nextProperties[key] = projectedProperty;
|
|
}
|
|
const nextSchema: Record<string, unknown> = {
|
|
...schema,
|
|
properties: nextProperties,
|
|
};
|
|
if (Array.isArray(schema.required)) {
|
|
const required = schema.required.filter(
|
|
(key): key is string => typeof key === "string" && key in nextProperties,
|
|
);
|
|
if (required.length > 0) nextSchema.required = required;
|
|
else delete nextSchema.required;
|
|
}
|
|
return nextSchema;
|
|
}
|
|
|
|
export function projectConnectionMethodToolInputSchema(
|
|
method: ConnectionMethodDef,
|
|
inputSchema: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const managed = method.defaults?.toolArgumentDefaults;
|
|
return managed
|
|
? stripManagedToolArgumentSchema(inputSchema, managed)
|
|
: inputSchema;
|
|
}
|
|
|
|
export function projectedConnectionToolArguments(
|
|
connection: ConnectionSettings,
|
|
parameters: unknown,
|
|
toolName: string,
|
|
inputSchema: Record<string, unknown> = {},
|
|
): Record<string, unknown> {
|
|
const managed = honchoManagedArguments(connection, inputSchema);
|
|
if (managed) parameters = mergeManagedToolArguments(asRecord(parameters), managed);
|
|
assertGoogleChatToolArgumentsSupported(connection, toolName, parameters);
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const app = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
if (!app) return asRecord(parameters);
|
|
return projectConnectionMethodToolArguments(
|
|
connectionMethodForConnection(app, connection),
|
|
parameters,
|
|
);
|
|
}
|
|
|
|
export function projectedConnectionToolInputSchema(
|
|
connection: ConnectionSettings,
|
|
inputSchema: Record<string, unknown>,
|
|
toolName: string,
|
|
): Record<string, unknown> {
|
|
const managed = honchoManagedArguments(connection, inputSchema);
|
|
if (managed) inputSchema = stripManagedToolArgumentSchema(inputSchema, managed);
|
|
inputSchema = googleChatToolInputSchema(connection, toolName, inputSchema);
|
|
inputSchema = gmailArchiveToolInputSchema(connection, toolName, inputSchema);
|
|
inputSchema = githubOwnerToolInputSchema(connection, toolName, inputSchema);
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const app = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
if (!app) return inputSchema;
|
|
return projectConnectionMethodToolInputSchema(
|
|
connectionMethodForConnection(app, connection),
|
|
inputSchema,
|
|
);
|
|
}
|
|
|
|
export function remoteEndpoint(config: Record<string, unknown>): string {
|
|
const value = config.url ?? config.endpoint ?? config.remoteUrl;
|
|
const parsed = parseRemoteHttpEndpoint(value, (message, code) =>
|
|
badRequest(message, { code }),
|
|
);
|
|
return parsed.toString();
|
|
}
|