mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task conversations contain links to pull requests and other work. > - A URL alone cannot tell the operator whether a PR is open, merged, or blocked by conflicts. > - Fetching every link at render time wastes API calls and can expose private data through a shared cache. > - This pull request adds a demand-driven links service and a GitHub PR adapter. > - Each viewer and company has a separate cache. Repeated links share one request and result. > - Operators get context when they hover or focus a link. Normal links stay useful when a provider is unavailable. ## Linked Issues or Issue Description **Subsystem affected** Task Markdown links, shared link metadata, and the server GitHub API boundary. **Problem or motivation** PR links have compact labels and neutral icons. They do not provide a rich hover card or a common status source across repeated mentions. A background resolver cannot borrow a person's GitHub connection because it has no viewer identity. **Proposed solution** Recognize supported links locally. Request metadata after a delayed hover or keyboard focus. Show a card at the first line of the link, aligned with its icon. Share the result across all mentions in the current viewer and company. Keep the exact original navigation URL. The first adapter supports GitHub pull requests. Open is green, merged is purple, and merge conflicts are orange. Draft, closed, unknown, loading and unavailable states remain quiet. **Alternatives considered** Eager page scans would spend API quota on links the operator does not inspect. Company-wide caching of personal GitHub results would cross the credential boundary. This change uses neither approach. **Roadmap alignment** ROADMAP.md was checked. This extends existing task links and the GitHub resolver. It adds no deployment adapter or new background worker. Searches of open and closed PRs for link previews and PR hover cards found no matching implementation. ## What Changed - Add host-owned link recognition and a normalized preview contract. The service selects an adapter by provider. - Add a board-only company read endpoint. It checks company access before cache reads and sends a private, no-store response. - Use the current viewer's eligible GitHub connection. A configured connection that cannot supply credentials fails closed. With no managed connection, reuse existing company credentials or public access. Instance credentials retain the public-repository check. - Reuse the GitHub external-object resolver for API transport and response parsing. Add title and mergeability to its normalized snapshot without changing its existing state contract. - Coalesce requests by company, viewer and canonical PR URL. Bound cached entries and concurrent work. Cache success for one minute and failures for a short period or the provider retry window. - Add an abortable request deadline. Timed-out work stays in the concurrency budget until it settles. No periodic worker or idle polling is added. - Add a shared browser query and anchored preview card. Repeated Markdown links observe the same result. There are no automatic retries, window-focus refreshes, toasts or connection prompts. - Add ten production-component Storybook examples and real pointer/keyboard browser checks. Document the API and verification route. ## Verification - Linux source checkout: 233 focused tests passed in nine suites. They cover recognition, fixed API destinations, provider states, access failures, credential selection, viewer/company isolation, request coalescing, expiry, cache bounds, concurrency bounds and mounted OpenAPI coverage. - A live public GitHub smoke returned this PR's correct title and open state. Two equivalent URLs shared one real network request. Personal connection access was not part of that smoke. - A controlled HTTP server stalls a real response body. The test proves cancellation while another lookup completes. - UI typecheck and the server layered typecheck passed. The UI production build passed. - Token gates and query policy gates passed. All 30 map/catalog support checks passed. The three new server test files also passed a focused TypeScript check. - All 27 focused browser checks passed on the reviewed source tree. They cover light/dark themes, narrow layouts, open/merged/conflict/draft/closed/loading/unavailable/failed states, fixed positioning during pointer movement, exact navigation and keyboard access. A real browser HTTP interception counts one deferred lookup for two equivalent PR links. - The first browser attempt lacked Chromium. A later combined build/test attempt exhausted the sandbox's 8 GiB memory limit and killed Storybook. Those attempts supply no pass evidence. Chromium is installed, and the final browser checks run with the build stopped. One initial Storybook dependency reload interrupted a streaming check. All eight existing inline-link cases passed on the warmed server, followed by a complete 27-case pass. - Synthetic Storybook pointer events did not exercise the native hover boundary. The assertions now run through real Playwright pointer and keyboard actions. The fixture stories retain the production components. - Review fixes preserve the Draft label when GitHub reports both draft and conflicts. They also preserve neutral link colors in dark mode. The 25 affected service checks pass, including the added combined-state regression. Browser checks compare unknown, draft and closed links with the muted theme color. - The first CI policy run rejected a literal retry opt-out. The code now uses the existing explicit zero-retry helper. A new test-spy type error was also corrected. These failures are fixed; their earlier runs remain failed. - `pnpm verify --explain` returned exit 2 because the browser test path selects broad CI. No full local suite is claimed. The final CI run supplies that coverage. - The previous head `437913c797963aa113bb82e2ae8b836969317ef9` passed CI and received a 5/5 Greptile score. All four review threads are resolved. Master then advanced. The current head `ae2a1c2adbd61a621cca9bdd160561952613df6e` resolves the source-manifest conflict against master `9b21dcbdda01296349f3cfe0a3a6891e79df6130`. Fresh CI and review are running. - The previous CI run found a lost class on decorated PR links and a stale SDK source manifest. Both are fixed. The existing CommentThread integration test and 26 focused link service tests pass. All 45 SDK source/privacy tests pass. All eight source-manifest helper tests pass. The manifest helper changed only source identities and added the two new shared files. Runtime, compiler, parser and library pins stay unchanged. - A later review found that expired timeouts could reuse a settled promise. The service now removes settled requests from its shared-request map and counts unfinished provider work separately. The new regression failed before the fix and passes after it. The concurrency-bound and real stalled-HTTP tests also pass. - UI typecheck and token gates pass after these fixes. The browser rerun needed Chromium in the new session. That first attempt did not exercise the UI. The complete 27-case browser rerun passed before the master merge with retries disabled. All 27 browser cases also passed on the merge revision with retries disabled. - After the master merge, all 84 focused link, OpenAPI and integration tests pass. All 55 SDK source/privacy/manifest tests pass. UI typecheck and token gates pass again. The source manifest retains master's reviewed runtime pins and includes both sets of source changes. GitHub confirms the PR can merge cleanly. The preflight helper rebuilt the shared package and plugin SDK; Cargo is unavailable locally. Final CI supplies Rust coverage. - The latest master update changed the reviewed report-lock policy but left the SDK lock hash at its previous value. The SDK source/evidence checks reproduced the mismatch. This change sets only the SDK lock hash to the authenticated output of the existing master policy and patch. Compiler, parser and Zod pins stay unchanged. All 56 SDK source/evidence/manifest tests now pass. All 84 focused link/API/integration tests, UI typecheck and token gates also pass on the updated source. ## Risks - This cache is process-local. Separate server replicas can make separate GitHub requests. - A cached result can remain visible for its short lifetime after GitHub changes. A later hover refreshes expired data. - GitHub can report unknown mergeability. The UI shows conflicts only when GitHub explicitly reports them. - Personal preview data stays in viewer/company caches. It is not written to company external-object records or persisted browser query storage. - Stories simulate the API. They do not prove a live GitHub login or private-repository access. - No schema migration is required. ## Model Used OpenAI Codex, based on GPT-6. The runtime does not expose the exact serving model ID or context-window limit. Used reasoning, repository inspection, source editing, shell execution and browser tools. No subagents were used. ## 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>
650 lines
32 KiB
TypeScript
650 lines
32 KiB
TypeScript
import {
|
|
companySecrets,
|
|
heartbeatRuns,
|
|
issues,
|
|
companyMemberships,
|
|
connectionGrantDelegations,
|
|
connectionGrants,
|
|
toolConnectionInstalls,
|
|
toolConnections,
|
|
userSecretDefinitions,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import { and, eq, inArray, or, sql } 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"];
|
|
};
|
|
|
|
export type GitHubApiCredentialSource = "managed_connection" | "company_secret" | "server_env";
|
|
|
|
export type GitHubApiCredential = {
|
|
token: string;
|
|
source: GitHubApiCredentialSource;
|
|
/** The company-secret name the token came from; null for a server-environment token. */
|
|
secretName: string | null;
|
|
};
|
|
|
|
/**
|
|
* Resolve a GitHub API credential for company-scoped, non-run work (for example the
|
|
* external-object refresh sweep, which has no agent or responsible-user context).
|
|
*
|
|
* This is the background-safe subset of the git credential order: it reads a company
|
|
* secret by well-known name, then the server process environment (`GITHUB_TOKEN`/`GH_TOKEN`)
|
|
* that self-hosted operators already set for Git. It deliberately cannot resolve a managed
|
|
* GitHub connection, because every managed grant is scoped to one agent or one person and a
|
|
* background sweep has neither. Callers that have a run context must use
|
|
* `createGitRemoteAuthProvider`/`resolveManagedGitHubCredential` instead so the managed
|
|
* identity and its consent boundary are honored.
|
|
*
|
|
* The `source` matters to callers that show company data. A server-environment token is
|
|
* instance-wide, so it can see repositories the company cannot. A caller must treat a
|
|
* `server_env` token differently from a `company_secret` token and keep it away from
|
|
* private repositories. Without any credential a caller runs anonymous, and anonymous
|
|
* GitHub requests are capped at 60/hour, so a busy company is rate-limited into
|
|
* misleading failures.
|
|
*/
|
|
export async function resolveGitHubApiCredential(
|
|
db: Db,
|
|
companyId: string,
|
|
opts: { secretNames?: readonly string[]; env?: NodeJS.ProcessEnv; secrets?: GitCredentialSecretsDeps } = {},
|
|
): Promise<GitHubApiCredential | null> {
|
|
const secrets = opts.secrets ?? secretService(db);
|
|
for (const secretName of opts.secretNames ?? DEFAULT_GITHUB_TOKEN_SECRET_NAMES) {
|
|
const secret = await Promise.resolve(secrets.getByName(companyId, secretName)).catch(() => null);
|
|
if (!secret) continue;
|
|
const token = await secrets
|
|
.resolveSecretValue(companyId, secret.id, "latest")
|
|
.then((value) => value.trim())
|
|
.catch(() => "");
|
|
if (token) return { token, source: "company_secret", secretName };
|
|
}
|
|
const env = opts.env ?? process.env;
|
|
const envToken = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim() || "";
|
|
return envToken ? { token: envToken, source: "server_env", secretName: null } : null;
|
|
}
|
|
|
|
/**
|
|
* 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 createIssueGitRemoteAuthProvider(db: Db, companyId: string, issueId: string,
|
|
create: typeof createGitRemoteAuthProvider = createGitRemoteAuthProvider): GitRemoteAuthProvider {
|
|
let provider: Promise<GitRemoteAuthProvider> | null = null;
|
|
return async (remoteUrl: string) => {
|
|
provider ??= db.select({ responsibleUserId: issues.responsibleUserId, assigneeAgentId: issues.assigneeAgentId }).from(issues)
|
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
|
|
.then(([issue]) => create(db, companyId, {
|
|
issueId, responsibleUserId: issue?.responsibleUserId ?? null, agentId: issue?.assigneeAgentId ?? null,
|
|
}));
|
|
return (await provider)(remoteUrl);
|
|
};
|
|
}
|
|
|
|
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;
|
|
explicitGrantId?: 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),
|
|
or(
|
|
sql`${toolConnections.config}->'sourceTemplateKey' = '"github"'::jsonb`,
|
|
sql`${toolConnections.transportConfig}->'sourceTemplateKey' = '"github"'::jsonb`,
|
|
),
|
|
));
|
|
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]),
|
|
context.explicitGrantId ? eq(connectionGrants.id, context.explicitGrantId) : undefined,
|
|
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;
|
|
explicitGrantId?: string;
|
|
},
|
|
): 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" };
|
|
}
|
|
if (context.explicitGrantId) return failure;
|
|
// 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; }
|
|
}
|