Files
PaperClipAI/server/src/services/tool-access/connection-methods.ts
T
Devin FoleyandPaperclip 0e49576fd9 fix(github): default repository owner to the only installed organization (#20141)
## 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>
2026-11-08 17:08:13 -08:00

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();
}