mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 04:02:28 +02:00
## Thinking Path > - Paperclip lets people share agents while keeping GitHub access personal. > - Each managed Git or GitHub operation selects an eligible connection grant. > - Connecting the same GitHub account twice creates two grants. > - The old resolver counted grants and rejected them as competing identities. > - Managed commands then ran anonymously and reported a misleading login failure. > - This change compares GitHub account IDs and selects one eligible grant for the same account. > - The benefit is reliable access after reconnecting, with clear diagnostics for real failures. ## Linked Issues or Issue Description Refs #13005. **What happened?** Two active connections owned by one Paperclip user pointed to the same GitHub account. Managed Git refused both as ambiguous. The agent could not push, although the account was connected and had repository access. **Expected behavior** Multiple grants for the same GitHub account resolve to one eligible authorization. Different accounts remain ambiguous. Unavailable access explains its cause without blocking unrelated work. **Steps to reproduce** 1. Connect the same GitHub account twice for one Paperclip user and allow the shared agent through both connection audiences. 2. Start an instruction as that user. 3. Run managed gh or git push. Before this fix, no credential is provided. ## What Changed - Compare stable GitHub account IDs when more than one eligible grant exists. Never deduplicate by login alone. - Prefer an available grant, then the newest authorization with a stable ID tie-breaker. Refresh and webhook timestamps do not change the selection. - Keep the selected credential and connection policy together. Do not combine permissions or fall back from a dedicated account to a personal account. - Print the redacted unavailable reason in managed command output. Unrelated local operations still work anonymously. - Add database and executable launcher regressions, and document selection behavior. ## Verification - Final `pnpm -r typecheck` and `pnpm build` passed. - Fourteen operation credential integration tests passed, covering duplicate personal/dedicated grants, incomplete credentials, distinct accounts with the same login, missing identity metadata, revocation, membership, connection audiences, and A → B → A steering. Existing Git credential and gateway suites and both executable launcher tests also passed. - The local broad test run encountered three embedded-Postgres lifecycle timeouts and stale modules from edits made during that run. A fresh process rerun of all four affected suites passed all 35 tests. The full Node 24 CI test matrix passed on the final commit. - CI passed all 31 checks on `797973b30beb16ba5fa69ed281835e1ab812b449` (Storybook visual regression was correctly skipped). An unrelated Company Settings UI test failed once; the focused local reproduction and rerun of its CI shard both passed without code changes. - Fresh Greptile review of the final commit: 5/5, with no open findings. Security checks passed. - Live acceptance passed with both duplicate connections enabled: managed `gh api user` returned the expected account, managed `git push` succeeded, and the agent created #13023 and pushed its review fixes. No host login or credential changes were used. - Applied the final source/compiled patch to the affected instance with backups, after confirming no runs were active. Restarted service health and the final resolver selection were verified. The patch is an overlay on the existing deployment; this PR supplies the upstream fix. ## Risks The resolver selects one authorization for an already permitted GitHub account. It does not combine repository permissions across connections. If the selected authorization has narrower access, that operation can still be denied by GitHub. Different provider account IDs and unknown duplicate identities continue to fail closed. No schema, host credential, or connection permission changes are included. ## Model Used OpenAI GPT-6 through Codex assisted implementation and verification with shell, database, and browser tools. The exact model variant and context-window size are not exposed in this session. ## 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>
544 lines
26 KiB
TypeScript
544 lines
26 KiB
TypeScript
import {
|
|
companySecrets,
|
|
heartbeatRuns,
|
|
companyMemberships,
|
|
connectionGrantDelegations,
|
|
connectionGrants,
|
|
toolConnectionInstalls,
|
|
toolConnections,
|
|
userSecretDefinitions,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import { and, eq, inArray, or } from "drizzle-orm";
|
|
import { isGitHubDotCom } from "./github-fetch.js";
|
|
import { secretService } from "./secrets.js";
|
|
import { toolAccessService } from "./tool-access.js";
|
|
|
|
/**
|
|
* Server-side git credentials for managed project checkouts and execution-workspace base
|
|
* refreshes. Operators store a GitHub token as a company secret under one of the well-known
|
|
* names below (the same convention the GitHub external-object provider reads); this module
|
|
* resolves it and turns it into a git invocation that authenticates clone/fetch against
|
|
* github.com over HTTPS without ever placing the token in argv, URLs, or on disk.
|
|
*
|
|
* The provider factory is deliberately the single seam for future credential sources (for
|
|
* example a brokered GitHub connection): swap the factory, keep every call site unchanged.
|
|
*/
|
|
|
|
/** Company-secret names probed for a GitHub token, in priority order. */
|
|
export const DEFAULT_GITHUB_TOKEN_SECRET_NAMES = ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"] as const;
|
|
|
|
/** Env var the credential helper reads the token from; never appears in argv. */
|
|
export const GIT_CREDENTIAL_TOKEN_ENV_KEY = "PAPERCLIP_GIT_TOKEN";
|
|
|
|
// `!`-prefixed helpers run via `sh -c` with the credential action appended as "$1". Only the
|
|
// `get` action answers; store/erase drain stdin and exit 0 silently. `x-access-token`
|
|
// authenticates classic PATs, fine-grained PATs, and GitHub App installation tokens alike.
|
|
//
|
|
// The helper re-validates the credential request from its stdin description and answers only
|
|
// for `protocol=https` + `host=github.com`/`www.github.com`. The pre-invocation URL check
|
|
// runs before git applies configuration like repository-local `url.<base>.insteadOf`
|
|
// rewrites, so a rewritten remote could otherwise request the token for an arbitrary host.
|
|
// The helper is additionally installed URL-scoped (`credential.https://github.com.helper`)
|
|
// so git does not consult it for other hosts in the first place — two independent gates.
|
|
const GIT_CREDENTIAL_HELPER =
|
|
`!f() { ok=; proto=; while IFS= read -r l && [ -n "$l" ]; do case "$l" in host=github.com|host=www.github.com) ok=1;; protocol=https) proto=1;; esac; done; if [ "$1" = get ] && [ -n "$ok" ] && [ -n "$proto" ]; then printf 'username=x-access-token\\npassword=%s\\n' "$PAPERCLIP_GIT_TOKEN"; fi; }; f`;
|
|
|
|
export type GitCredential = {
|
|
token: string;
|
|
source: "managed_connection" | "company_secret" | "server_env";
|
|
/** The company-secret name the token came from; null for a server-environment token. */
|
|
secretName: string | null;
|
|
githubIdentity?: { userId: string; login: string };
|
|
identitySource?: "personal" | "dedicated";
|
|
};
|
|
|
|
/** A prepared, credential-bearing git invocation: config args plus the env that carries the token. */
|
|
export type GitAuthInvocation = {
|
|
configArgs: string[];
|
|
env: Record<string, string>;
|
|
source: GitCredential["source"];
|
|
secretName: string | null;
|
|
};
|
|
|
|
/**
|
|
* Resolve auth for one remote URL. Returns null when the URL is out of scope (non-GitHub,
|
|
* ssh, or already credentialed) or when no token is available — callers then run git with
|
|
* ambient behavior, exactly as before this module existed.
|
|
*/
|
|
export type GitRemoteAuthProvider = (remoteUrl: string) => Promise<GitAuthInvocation | null>;
|
|
|
|
/**
|
|
* True only for `https://github.com/...` (or `www.`) URLs without inline userinfo. GHES and
|
|
* other hosts are out of scope for now — sending a github.com token to an arbitrary host
|
|
* would leak it, and an operator's inline URL credential must never be overridden.
|
|
*/
|
|
export function isGitHubHttpsRemoteUrl(remoteUrl: string): boolean {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(remoteUrl);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (parsed.protocol !== "https:") return false;
|
|
if (parsed.username || parsed.password) return false;
|
|
return isGitHubDotCom(parsed.hostname);
|
|
}
|
|
|
|
function isSupportedGitHubRemoteUrl(remoteUrl: string): boolean {
|
|
if (isGitHubHttpsRemoteUrl(remoteUrl)) return true;
|
|
if (/^git@(?:www\.)?github\.com:[^\s]+$/i.test(remoteUrl)) return true;
|
|
try {
|
|
const parsed = new URL(remoteUrl);
|
|
return parsed.protocol === "ssh:" && parsed.username === "git" && !parsed.password && isGitHubDotCom(parsed.hostname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mask credential material embedded in URLs so it never reaches warnings, run errors, or
|
|
* persisted payloads: userinfo on any scheme (`https://user:token@host`,
|
|
* `ssh://user:pass@host`) and the entire query string of any URL (`?access_token=…` and
|
|
* every other parameter — masked wholesale rather than by an inevitably incomplete
|
|
* parameter-name list). Scp-style remotes (`git@host:path`) carry no password and are left
|
|
* alone.
|
|
*/
|
|
export function scrubGitCredentialText(text: string): string {
|
|
return text
|
|
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1***@")
|
|
.replace(/([a-z][a-z0-9+.-]*:\/\/[^\s"'?]*)\?[^\s"']*/gi, "$1?***");
|
|
}
|
|
|
|
export function buildGitAuthInvocation(credential: GitCredential): GitAuthInvocation {
|
|
const identity = credential.githubIdentity;
|
|
const noreplyEmail = identity ? `${identity.userId}+${identity.login}@users.noreply.github.com` : null;
|
|
const configEntries = [
|
|
["credential.helper", ""],
|
|
["credential.https://github.com.helper", GIT_CREDENTIAL_HELPER],
|
|
["credential.https://www.github.com.helper", GIT_CREDENTIAL_HELPER],
|
|
["url.https://github.com/.insteadOf", "git@github.com:"],
|
|
["url.https://github.com/.insteadOf", "ssh://git@github.com/"],
|
|
["url.https://github.com/.insteadOf", "git@www.github.com:"],
|
|
["url.https://github.com/.insteadOf", "ssh://git@www.github.com/"],
|
|
...(identity ? [
|
|
["user.name", identity.login],
|
|
["user.email", noreplyEmail!],
|
|
] : []),
|
|
];
|
|
return {
|
|
// The leading empty helper clears ambient helpers (gh, osxkeychain, credential-store) so
|
|
// they neither outrank the resolved token nor receive store/erase callbacks for it. The
|
|
// token helper is installed URL-scoped: git consults it only for credential requests
|
|
// whose context matches github.com over https, so an `insteadOf`-rewritten remote never
|
|
// reaches it (and the helper itself re-checks the request host — see above).
|
|
configArgs: [
|
|
"-c", "credential.helper=",
|
|
"-c", `credential.https://github.com.helper=${GIT_CREDENTIAL_HELPER}`,
|
|
"-c", `credential.https://www.github.com.helper=${GIT_CREDENTIAL_HELPER}`,
|
|
],
|
|
env: {
|
|
[GIT_CREDENTIAL_TOKEN_ENV_KEY]: credential.token,
|
|
GH_TOKEN: credential.token,
|
|
GITHUB_TOKEN: credential.token,
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
...(identity ? {
|
|
GIT_AUTHOR_NAME: identity.login,
|
|
GIT_AUTHOR_EMAIL: noreplyEmail!,
|
|
GIT_COMMITTER_NAME: identity.login,
|
|
GIT_COMMITTER_EMAIL: noreplyEmail!,
|
|
} : {}),
|
|
GIT_CONFIG_COUNT: String(configEntries.length),
|
|
...Object.fromEntries(configEntries.flatMap(([key, value], index) => [
|
|
[`GIT_CONFIG_KEY_${index}`, key],
|
|
[`GIT_CONFIG_VALUE_${index}`, value],
|
|
])),
|
|
},
|
|
source: credential.source,
|
|
secretName: credential.secretName,
|
|
};
|
|
}
|
|
|
|
const GIT_AUTH_FAILURE_PATTERN =
|
|
/authentication failed|could not read username|could not read password|invalid username or password|terminal prompts disabled|repository not found|not accessible|permission denied|HTTP 40[13]|The requested URL returned error: 40[13]/i;
|
|
|
|
/**
|
|
* Turn a failed git network operation into an actionable suffix for the error message.
|
|
* Returns null when the failure does not look auth-related — a credential that was merely
|
|
* present during an unrelated failure (network outage, target-path collision) must not be
|
|
* blamed for it.
|
|
*/
|
|
export function describeGitAuthFailure(input: {
|
|
error: string;
|
|
used: { source: GitCredential["source"]; secretName: string | null } | null;
|
|
}): string | null {
|
|
if (!GIT_AUTH_FAILURE_PATTERN.test(input.error)) {
|
|
return null;
|
|
}
|
|
if (input.used) {
|
|
const label = input.used.secretName
|
|
? `the ${input.used.secretName} company-secret GitHub credential`
|
|
: input.used.source === "managed_connection"
|
|
? "the resolved GitHub connection"
|
|
: "the server-environment GitHub credential";
|
|
return `The operation authenticated with ${label}, which was rejected or lacks access to this repository.`;
|
|
}
|
|
return "No GitHub credential is configured — add a GITHUB_TOKEN or GH_TOKEN company secret in Settings → Secrets, or configure a local checkout cwd for this project workspace.";
|
|
}
|
|
|
|
type SecretServiceLike = ReturnType<typeof secretService>;
|
|
|
|
type GitCredentialSecretsDeps = {
|
|
getByName: (
|
|
companyId: string,
|
|
name: string,
|
|
) => Promise<{ id: string } | null | undefined> | ReturnType<SecretServiceLike["getByName"]>;
|
|
resolveSecretValue: SecretServiceLike["resolveSecretValue"];
|
|
resolveUserSecretValue?: SecretServiceLike["resolveUserSecretValue"];
|
|
};
|
|
|
|
/**
|
|
* Build the credential provider for one run. Resolution order: the managed GitHub identity
|
|
* resolver, then a company secret by well-known name, then the server process environment
|
|
* (`GITHUB_TOKEN`/`GH_TOKEN`) for self-hosted operators. A configured managed identity fails
|
|
* closed instead of falling through to legacy credentials. The lookup is memoized per
|
|
* provider instance so one run performs at most one secret resolution (and writes at most
|
|
* one audit event) no matter how many git operations it authenticates.
|
|
*/
|
|
export function createGitRemoteAuthProvider(
|
|
db: Db,
|
|
companyId: string,
|
|
context?: {
|
|
issueId?: string | null;
|
|
heartbeatRunId?: string | null;
|
|
responsibleUserId?: string | null;
|
|
agentId?: string | null;
|
|
},
|
|
deps?: {
|
|
secrets?: GitCredentialSecretsDeps;
|
|
env?: NodeJS.ProcessEnv;
|
|
secretNames?: readonly string[];
|
|
},
|
|
): GitRemoteAuthProvider {
|
|
const secrets: GitCredentialSecretsDeps = deps?.secrets ?? secretService(db);
|
|
const env = deps?.env ?? process.env;
|
|
const secretNames = deps?.secretNames ?? DEFAULT_GITHUB_TOKEN_SECRET_NAMES;
|
|
let credentialPromise: Promise<GitCredential | null> | null = null;
|
|
|
|
const resolveCredential = async (): Promise<GitCredential | null> => {
|
|
// Unit callers historically pass a null DB through the typed test seam. Production
|
|
// always supplies a real DB and therefore always checks managed identities before
|
|
// considering legacy secrets or process environment credentials.
|
|
const managed = db
|
|
? await resolveManagedGitHubCredential(db, secrets, companyId, context ?? {})
|
|
: { configured: false as const };
|
|
if (managed.configured) {
|
|
if (!managed.credential) throw new Error(managed.error ?? "Managed GitHub connection is unavailable");
|
|
return managed.credential;
|
|
}
|
|
for (const secretName of secretNames) {
|
|
const secret = await Promise.resolve(secrets.getByName(companyId, secretName)).catch(() => null);
|
|
if (!secret) continue;
|
|
// A resolution failure (inactive secret, provider outage) records its own failure audit
|
|
// event; fall through to the next source instead of failing the whole git operation here.
|
|
const token = await secrets
|
|
.resolveSecretValue(companyId, secret.id, "latest", {
|
|
accessContext: {
|
|
consumerType: "system",
|
|
consumerId: "workspace-git-credential",
|
|
actorType: "system",
|
|
issueId: context?.issueId ?? null,
|
|
heartbeatRunId: context?.heartbeatRunId ?? null,
|
|
responsibleUserId: context?.responsibleUserId ?? null,
|
|
},
|
|
})
|
|
.then((value) => value.trim())
|
|
.catch(() => "");
|
|
if (token) return { token, source: "company_secret", secretName };
|
|
}
|
|
const envToken = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim() || "";
|
|
if (envToken) return { token: envToken, source: "server_env", secretName: null };
|
|
return null;
|
|
};
|
|
|
|
return async (remoteUrl: string) => {
|
|
if (!isSupportedGitHubRemoteUrl(remoteUrl)) return null;
|
|
if (db && context?.heartbeatRunId && context.agentId) {
|
|
const [run] = await db.select({ contextId: heartbeatRuns.activeIdentityContextId }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, context.heartbeatRunId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, context.agentId),
|
|
));
|
|
if (run?.contextId) {
|
|
const { resolveGitHubOperationCredentials } = await import("./github-operation-credentials.js");
|
|
const result = await resolveGitHubOperationCredentials(db, {
|
|
companyId, runId: context.heartbeatRunId, agentId: context.agentId,
|
|
});
|
|
const anonymous = buildGitAuthInvocation({ token: "", source: "managed_connection", secretName: null });
|
|
return { ...anonymous, env: {
|
|
...anonymous.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null",
|
|
GIT_AUTHOR_NAME: "", GIT_AUTHOR_EMAIL: "", GIT_COMMITTER_NAME: "", GIT_COMMITTER_EMAIL: "",
|
|
...result.env,
|
|
} };
|
|
}
|
|
}
|
|
credentialPromise ??= resolveCredential();
|
|
const credential = await credentialPromise;
|
|
if (!credential) return null;
|
|
return buildGitAuthInvocation(credential);
|
|
};
|
|
}
|
|
|
|
export async function resolveManagedGitHubIdentitySelection(
|
|
db: Db,
|
|
companyId: string,
|
|
context: {
|
|
responsibleUserId?: string | null;
|
|
agentId?: string | null;
|
|
allowStandingDelegation?: boolean;
|
|
},
|
|
): Promise<{
|
|
configured: boolean;
|
|
identitySource?: "personal" | "dedicated";
|
|
grant?: typeof connectionGrants.$inferSelect;
|
|
error?: string;
|
|
}> {
|
|
const connections = await db.select().from(toolConnections).where(and(
|
|
eq(toolConnections.companyId, companyId),
|
|
));
|
|
const githubConnections = connections.filter((connection) => {
|
|
const config = connection.config && typeof connection.config === "object" ? connection.config as Record<string, unknown> : {};
|
|
const transportConfig = connection.transportConfig && typeof connection.transportConfig === "object"
|
|
? connection.transportConfig as Record<string, unknown>
|
|
: {};
|
|
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
|
|
});
|
|
if (githubConnections.length === 0) return { configured: false };
|
|
|
|
const connectionIds = githubConnections.map((connection) => connection.id);
|
|
const installs = await db.select().from(toolConnectionInstalls).where(and(
|
|
eq(toolConnectionInstalls.companyId, companyId),
|
|
inArray(toolConnectionInstalls.connectionId, connectionIds),
|
|
));
|
|
const eligibleConnectionIds = new Set(githubConnections.filter((connection) => installs.some((install) =>
|
|
install.connectionId === connection.id && (
|
|
(install.targetType === "company" && install.targetId === companyId)
|
|
|| (install.targetType === "agent" && install.targetId === context.agentId)
|
|
)
|
|
)).map((connection) => connection.id));
|
|
// A GitHub connection installed only for another agent is not configured for
|
|
// this run. Treating the company-wide connection as configured here would
|
|
// make unrelated agents fail before their adapter starts and would also
|
|
// suppress their otherwise-eligible legacy credential fallback.
|
|
if (eligibleConnectionIds.size === 0) return { configured: false };
|
|
const grants = await db.select().from(connectionGrants).where(and(
|
|
eq(connectionGrants.companyId, companyId),
|
|
inArray(connectionGrants.connectionId, [...eligibleConnectionIds]),
|
|
or(eq(connectionGrants.kind, "agent"), eq(connectionGrants.kind, "user")),
|
|
));
|
|
const dedicated = context.agentId
|
|
? grants.filter((grant) => grant.kind === "agent" && grant.subjectAgentId === context.agentId)
|
|
: [];
|
|
// Connections are already restricted above to the owner-selected install
|
|
// targets. Within that consent boundary the server-resolved responsible user
|
|
// is authoritative; standing delegation is only an ownerless-run fallback.
|
|
const personal = context.responsibleUserId
|
|
? grants.filter((grant) => grant.kind === "user" && grant.subjectUserId === context.responsibleUserId)
|
|
: [];
|
|
const delegated = context.allowStandingDelegation !== false && !context.responsibleUserId && context.agentId
|
|
? await db.select({ grantId: connectionGrantDelegations.grantId }).from(connectionGrantDelegations).where(and(
|
|
eq(connectionGrantDelegations.companyId, companyId),
|
|
eq(connectionGrantDelegations.agentId, context.agentId),
|
|
inArray(connectionGrantDelegations.grantId, grants.map((grant) => grant.id)),
|
|
)).then((rows) => {
|
|
const delegatedIds = new Set(rows.map((row) => row.grantId));
|
|
return grants.filter((grant) => grant.kind === "user" && delegatedIds.has(grant.id));
|
|
})
|
|
: [];
|
|
const candidates = dedicated.length > 0 ? dedicated : personal.length > 0 ? personal : delegated;
|
|
const identitySource = dedicated.length > 0 ? "dedicated" as const : "personal" as const;
|
|
// Reconnecting can create another connection/grant for the same GitHub
|
|
// account. Ambiguity is about provider identities, not the number of rows.
|
|
// Only trust GitHub's stable account ID; equal logins or missing metadata
|
|
// cannot establish that two grants belong to the same person.
|
|
const githubUserIds = candidates.map((candidate) => candidate.providerTenant?.github?.userId?.trim());
|
|
if (candidates.length === 0 || (candidates.length > 1 && (
|
|
githubUserIds.some((id) => !id) || new Set(githubUserIds).size !== 1
|
|
))) {
|
|
return {
|
|
configured: true, identitySource,
|
|
error: candidates.length === 0
|
|
? "No managed GitHub identity is available for this run"
|
|
: "More than one managed GitHub identity matches this run",
|
|
};
|
|
}
|
|
const credentialIds = candidates.flatMap((grant) => grant.credentialSecretRefs
|
|
.filter((ref) => ref.configPath === "oauth.access_token").map((ref) => ref.secretId));
|
|
const credentialRecords = candidates.length > 1 && credentialIds.length > 0
|
|
? await db.select({
|
|
id: companySecrets.id, status: companySecrets.status, deletedAt: companySecrets.deletedAt,
|
|
scope: companySecrets.scope, ownerUserId: companySecrets.ownerUserId,
|
|
definitionStatus: userSecretDefinitions.status, definitionDeletedAt: userSecretDefinitions.deletedAt,
|
|
}).from(companySecrets).leftJoin(userSecretDefinitions, and(
|
|
eq(userSecretDefinitions.id, companySecrets.userSecretDefinitionId),
|
|
eq(userSecretDefinitions.companyId, companyId),
|
|
)).where(and(
|
|
eq(companySecrets.companyId, companyId), inArray(companySecrets.id, credentialIds),
|
|
))
|
|
: [];
|
|
const hasCredentialRecord = (grant: typeof connectionGrants.$inferSelect) => {
|
|
if (candidates.length === 1) return true;
|
|
const github = grant.providerTenant?.github;
|
|
const ref = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
|
|
return Boolean(github && github.installationCount > 0 && github.repositoryCount > 0 && ref
|
|
&& credentialRecords.some((secret) => secret.id === ref.secretId
|
|
&& secret.status === "active" && !secret.deletedAt
|
|
&& (grant.kind === "user"
|
|
? secret.scope === "user" && secret.ownerUserId === grant.subjectUserId
|
|
&& secret.definitionStatus === "active" && !secret.definitionDeletedAt
|
|
: secret.scope === "company")));
|
|
};
|
|
const isAvailable = (grant: typeof connectionGrants.$inferSelect) =>
|
|
grant.status === "active" && hasCredentialRecord(grant) && githubConnections.some((connection) =>
|
|
connection.id === grant.connectionId && connection.enabled && connection.status === "active",
|
|
);
|
|
// Prefer an available authorization for this same account, then the newest
|
|
// connection grant. Do not rank by updatedAt: refreshes/webhooks change it.
|
|
// Select one grant, preserving its credential and connection policy intact.
|
|
const grant = [...candidates].sort((a, b) =>
|
|
Number(isAvailable(b)) - Number(isAvailable(a))
|
|
|| b.createdAt.getTime() - a.createdAt.getTime()
|
|
|| a.id.localeCompare(b.id),
|
|
)[0]!;
|
|
const connection = githubConnections.find((candidate) => candidate.id === grant.connectionId);
|
|
if (!connection?.enabled || connection.status !== "active") {
|
|
return { configured: true, identitySource, error: "The managed GitHub connection is unavailable" };
|
|
}
|
|
if (grant.status !== "active") return { configured: true, identitySource, error: "The managed GitHub identity must be reconnected" };
|
|
return { configured: true, identitySource, grant };
|
|
}
|
|
|
|
export async function filterResolvedGitHubConnectionsForRun<T extends {
|
|
id: string;
|
|
config?: unknown;
|
|
transportConfig?: unknown;
|
|
}>(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
agentId: string;
|
|
responsibleUserId?: string | null;
|
|
connections: T[];
|
|
}): Promise<T[]> {
|
|
const githubConnections = input.connections.filter((connection) => {
|
|
const config = connection.config && typeof connection.config === "object"
|
|
? connection.config as Record<string, unknown>
|
|
: {};
|
|
const transportConfig = connection.transportConfig && typeof connection.transportConfig === "object"
|
|
? connection.transportConfig as Record<string, unknown>
|
|
: {};
|
|
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
|
|
});
|
|
if (githubConnections.length === 0) return input.connections;
|
|
const selection = await resolveManagedGitHubIdentitySelection(input.db, input.companyId, {
|
|
agentId: input.agentId,
|
|
responsibleUserId: input.responsibleUserId ?? null,
|
|
});
|
|
const selectedConnectionId = selection.grant?.connectionId ?? null;
|
|
const githubIds = new Set(githubConnections.map((connection) => connection.id));
|
|
return input.connections.filter((connection) =>
|
|
!githubIds.has(connection.id) || connection.id === selectedConnectionId,
|
|
);
|
|
}
|
|
|
|
export async function resolveManagedGitHubCredential(
|
|
db: Db,
|
|
secrets: GitCredentialSecretsDeps,
|
|
companyId: string,
|
|
context: {
|
|
issueId?: string | null;
|
|
heartbeatRunId?: string | null;
|
|
responsibleUserId?: string | null;
|
|
agentId?: string | null;
|
|
allowStandingDelegation?: boolean;
|
|
},
|
|
): Promise<{ configured: boolean; identitySource?: "personal" | "dedicated"; credential?: GitCredential; error?: string }> {
|
|
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
|
|
if (!selection.configured) return { configured: false };
|
|
if (!selection.grant) return { configured: true, identitySource: selection.identitySource, error: selection.error };
|
|
let grant = selection.grant;
|
|
if (grant.kind === "user" && grant.subjectUserId) {
|
|
const [membership] = await db.select({ id: companyMemberships.id, role: companyMemberships.membershipRole }).from(companyMemberships).where(and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, grant.subjectUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
)).limit(1);
|
|
if (!membership || membership.role === "viewer") return { configured: true, identitySource: selection.identitySource, error: "The managed GitHub identity owner is not an authorized company member" };
|
|
}
|
|
const expiresAt = grant.providerTenant?.oauth?.accessTokenExpiresAt;
|
|
const refreshedAt = grant.providerTenant?.oauth?.refreshedAt;
|
|
const expiryMs = typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
|
|
const refreshedMs = typeof refreshedAt === "string" ? Date.parse(refreshedAt) : Number.NaN;
|
|
if (Number.isFinite(expiryMs) && (
|
|
expiryMs <= Date.now() + 60 * 60_000
|
|
|| !Number.isFinite(refreshedMs)
|
|
|| refreshedMs <= Date.now() - 30 * 24 * 60 * 60_000
|
|
)) {
|
|
grant = await toolAccessService(db).refreshOAuthGrantCredentials({
|
|
companyId,
|
|
connectionId: grant.connectionId,
|
|
grantId: grant.id,
|
|
actor: { actorType: "system", actorId: "workspace-git-credential" },
|
|
issueId: context.issueId,
|
|
heartbeatRunId: context.heartbeatRunId,
|
|
});
|
|
}
|
|
const accessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
|
|
const github = grant.providerTenant?.github;
|
|
if (!accessRef || !github) return { configured: true, identitySource: selection.identitySource, error: "The managed GitHub identity is incomplete" };
|
|
if (github.installationCount < 1 || github.repositoryCount < 1) {
|
|
return { configured: true, identitySource: selection.identitySource, error: "The managed GitHub identity no longer has repository access" };
|
|
}
|
|
const accessContext = {
|
|
consumerType: "system" as const,
|
|
consumerId: "workspace-git-credential",
|
|
actorType: "system" as const,
|
|
actorId: context.agentId ?? undefined,
|
|
issueId: context.issueId ?? null,
|
|
heartbeatRunId: context.heartbeatRunId ?? null,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
};
|
|
let token: string;
|
|
if (grant.kind === "user") {
|
|
if (!grant.subjectUserId || !secrets.resolveUserSecretValue) {
|
|
return { configured: true, identitySource: selection.identitySource, error: "The personal GitHub credential cannot be resolved" };
|
|
}
|
|
const [secret] = await db.select({
|
|
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
|
}).from(companySecrets).where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.id, accessRef.secretId),
|
|
eq(companySecrets.ownerUserId, grant.subjectUserId),
|
|
)).limit(1);
|
|
if (!secret?.userSecretDefinitionId) return { configured: true, identitySource: selection.identitySource, error: "The personal GitHub credential is invalid" };
|
|
const resolved = await secrets.resolveUserSecretValue(companyId, {
|
|
definitionId: secret.userSecretDefinitionId,
|
|
responsibleUserId: grant.subjectUserId,
|
|
version: accessRef.versionSelector ?? "latest",
|
|
required: true,
|
|
}, accessContext);
|
|
if (!resolved) return { configured: true, identitySource: selection.identitySource, error: "The personal GitHub credential is missing" };
|
|
token = resolved.value;
|
|
} else {
|
|
token = await secrets.resolveSecretValue(companyId, accessRef.secretId, accessRef.versionSelector ?? "latest", { accessContext });
|
|
}
|
|
return {
|
|
configured: true, identitySource: selection.identitySource,
|
|
credential: {
|
|
token,
|
|
source: "managed_connection",
|
|
secretName: null,
|
|
githubIdentity: { userId: github.userId, login: github.login },
|
|
identitySource: grant.kind === "agent" ? "dedicated" : "personal",
|
|
},
|
|
};
|
|
}
|