Files
PaperClipAI/server/src/services/git-credentials.ts
T
DottaandPaperclip 82f662656a fix(runner): restore legacy Git access and independent networking (#13094)
## Thinking Path

> - Paperclip runs agents for people with different GitHub accounts.
> - Managed operations must use the intended person's eligible
connection.
> - A failed duplicate connection must not hide a healthy grant for the
same account.
> - Legacy hosts also need their existing Git configuration when managed
access is not configured.
> - Runner networking and local Git operations must not depend on GitHub
broker availability.
> - This pull request separates those policies and improves failure
diagnostics.

## Linked Issues or Issue Description

**What happened?** New runs always cleared host Git credentials and
installed managed launchers. Network permission depended on GitHub
environment variables. A launcher failure could stop even local `git
status`. A newer unhealthy duplicate could take precedence over a
healthy connection, and generic health errors were shown as reconnect
requirements.

**Expected behavior:** Use a healthy eligible managed connection for the
intended account. Preserve host authentication only for unconfigured
standard-trust local or SSH execution. Permit local Git during broker
failures and keep network permission independent of GitHub credentials.

**Steps to reproduce:** Configure healthy and unhealthy grants for one
GitHub account, dispatch an agent, and execute Git commands. Separately
run an unconfigured legacy host with existing GitHub CLI authentication.
Stop the broker and run local `git status`.

**Paperclip version or commit:** Master at 668110469. **Deployment
mode:** Self-hosted. **Installation method:** Git checkout. **Agent
adapters involved:** Native Codex runner and shared adapter execution
setup. **Database mode:** Existing instance database; no migration.
**Access context:** Responsible person's managed grant, or explicitly
unconfigured legacy host. **Node.js version:** 26.4.0 locally.
**Operating system:** macOS development and Linux execution hosts.

**Relevant logs or output:** Previously `GitHub credential context
unavailable` hid configuration, transport, and capability errors. New
diagnostics identify these categories without credential values.

**Additional context:** Refs #13005 and #13022. Dependency provisioning
is addressed separately in #13093.

## What Changed

- Prefer healthy eligible grants and retry credential acquisition once
for the same principal and account before starting an operation.
- Preserve host Git configuration only when managed access is
unconfigured on a standard-trust local or SSH target.
- Project authentication mode and validated Git metadata into native
runner boundaries; refresh resumed provider settings when modes change.
- Enable network access through an explicit standard-trust controller
decision, independently of GitHub. Omitted or restricted decisions stay
disabled; replace warm providers when that decision changes.
- Run local Git with cleared credentials when the managed broker fails,
with specific redacted diagnostics.
- Retry access-refresh conflicts once without treating concurrency as
expired authorization.
- Show retry instead of reconnect for transient GitHub health failures.
Add optional authorization and run-diagnostic fields without a database
migration.

## Verification

- All latest-head CI gates are green, including typecheck, general and
serialized suites, browser tests, canary, native runner verification,
and build. Greptile is 5/5 with no remaining findings; the security scan
passed.
- Full recursive typecheck and build passed. UI token gates passed.
- Full general server run: 7,110 passed, one transient socket hangup;
that file passed on retry. All remaining workspace groups passed,
including 5,552 UI and 478 CLI tests. The complete serialized rerun
passed all 144 suites / 2,179 tests after the initial isolated timeout
passed on retry.
- 195 final launcher and native session tests passed, including
host/managed transitions, local/remote warm network-policy changes,
broker rotation, and attempts to override validated controller
filesystem roots.
- GitHub gateway fallback, duplicate connection selection, refresh
conflicts, per-user reauthorization, and native transport/security
suites passed.
- Additional live native fixtures passed SSH public-key authentication
and a Git credential helper in fresh and resumed host-mode sessions. An
unwritable managed configuration directory preserved local Git (exit 0)
while GitHub CLI failed with `configuration_directory_unavailable` (exit
4). The temporary SSH listener and keys were removed.
- Applicable Rust suites passed except two timing failures under load;
each failed case passed in isolation. The final environment contract
test passed.
- Linux native runner acceptance passed in both managed and legacy host
modes: DNS, HTTPS, npm package download, fresh-worktree Git status,
authenticated GitHub user lookup, repository read, and a new run
continuing the same provider conversation. Managed broker outage
preserved local Git and rejected authenticated access without host
fallback.
- Matching Linux server/runner artifacts and the separate provisioning
repair are deployed to the development instance. A fresh UI-dispatched
task and a new run after a server restart both passed all six shell
checks through the live controller and credential broker. Both runs
selected the expected healthy connection/grant and retained the same
provider conversation. Neither connection was repaired or reconnected.

The sandbox roots are assigned from the validated execution-target probe
**after** ordinary bindings are merged. Regression coverage supplies
forged roots and verifies they cannot override the controller paths.
Networking is enabled only for an explicit
`PAPERCLIP_RUNNER_NETWORK_ACCESS=enabled` controller decision; omitted
values remain disabled.

## Risks

Unconfigured standard-trust local and SSH runs regain access to host Git
authentication resources. Managed, sandbox, plugin, and low-trust runs
do not gain this fallback. Revoked managed access never falls back to
another account. Deploy server and runner artifacts together;
already-started operations retain their captured identity. An
unauthenticated command can still fail when it requires GitHub access.

## Model Used

OpenAI GPT-6 through Codex, with code editing, shell execution, tests,
and browser inspection. 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>
2026-09-09 10:15:10 -05:00

580 lines
28 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";
connectionId?: string;
grantId?: string;
};
/** 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,
});
if (result.status === "absent") {
const credential = await resolveCredential();
return credential ? buildGitAuthInvocation(credential) : null;
}
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;
excludeGrantId?: string;
},
): 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, healthy authorization for this 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 healthRank = (candidate: typeof connectionGrants.$inferSelect) => {
const health = githubConnections.find((connection) => connection.id === candidate.connectionId)?.healthStatus;
return health === "ok" || health === "healthy" ? 2 : health === "unknown" ? 1 : 0;
};
const grant = candidates.filter((candidate) => candidate.id !== context.excludeGrantId).sort((a, b) =>
Number(isAvailable(b)) - Number(isAvailable(a))
|| healthRank(b) - healthRank(a)
|| b.createdAt.getTime() - a.createdAt.getTime()
|| a.id.localeCompare(b.id),
)[0]!;
if (!grant) return { configured: true, identitySource, error: "No alternative managed GitHub authorization is available" };
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 };
const acquire = async (selection: Awaited<ReturnType<typeof resolveManagedGitHubIdentitySelection>>) => {
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" as const,
secretName: null,
githubIdentity: { userId: github.userId, login: github.login },
identitySource: grant.kind === "agent" ? "dedicated" as const : "personal" as const,
connectionId: grant.connectionId,
grantId: grant.id,
},
};
};
let failure: { configured: boolean; identitySource?: "personal" | "dedicated"; error?: string };
try {
const result = await acquire(selection);
if (result.credential) return result;
failure = result;
} catch {
failure = { configured: true, identitySource: selection.identitySource, error: "GitHub credentials are temporarily unavailable" };
}
// Retry credential acquisition, never the GitHub operation. An alternate
// authorization must still belong to this exact principal and account.
const alternate = await resolveManagedGitHubIdentitySelection(db, companyId, {
...context, excludeGrantId: selection.grant.id,
});
const accountId = selection.grant.providerTenant?.github?.userId;
if (!accountId || !alternate.grant || alternate.identitySource !== selection.identitySource
|| alternate.grant.providerTenant?.github?.userId !== accountId
|| alternate.grant.subjectUserId !== selection.grant.subjectUserId
|| alternate.grant.subjectAgentId !== selection.grant.subjectAgentId) return failure;
try { return await acquire(alternate); } catch { return failure; }
}