diff --git a/doc/execution-github-identity.md b/doc/execution-github-identity.md index 4779bbb56a..45d78bbfd6 100644 --- a/doc/execution-github-identity.md +++ b/doc/execution-github-identity.md @@ -12,7 +12,7 @@ Delegated work and interactions persist their originating context. Retries retai ## Managed GitHub operations -New executions receive token-free `git` and `gh` launchers and a run-scoped capability. Each launcher invocation requests the active context through the authenticated runtime transport and resolves one eligible credential at operation start. A `gh` command's child Git processes inherit that command's captured identity. Later steering does not change already-started operations. When a subsequent run resumes a settled native conversation, the controller starts a fresh provider process with that run’s capability and rebinds its token-free launcher paths. The durable conversation and protected provider settings remain unchanged. Local and remote durable runners complete their bounded suspension before the controller releases the session for the next run, so a queued continuation cannot race unfinished cleanup. +Executions with managed GitHub configured receive token-free `git` and `gh` launchers and a run-scoped capability. Each launcher invocation requests the active context through the authenticated runtime transport and resolves one eligible credential at operation start. A `gh` command's child Git processes inherit that command's captured identity. Later steering does not change already-started operations. When a subsequent run resumes a settled native conversation, the controller starts a fresh provider process with that run’s capability and rebinds its token-free launcher paths. The durable conversation and protected provider settings remain unchanged. Local and remote durable runners complete their bounded suspension before the controller releases the session for the next run, so a queued continuation cannot race unfinished cleanup. The broker endpoint rejects browser origins and session cookies, validates a distinct signed runtime scope, and rechecks the company, agent, and live run. Sandboxes relay the capability through the existing authenticated callback bridge. Tokens are returned only to the managed command process. They are not persisted in identity history or injected into the long-lived provider process. @@ -32,6 +32,41 @@ discovery stops startup instead of silently falling back to a minimal path. Scripts that previously read a persistent `GH_TOKEN` must use managed `git`, `gh`, or GitHub gateway tools. Managed execution skips legacy GitHub token bindings in agent, environment, project, and routine configuration before secret preflight. Configure personal or dedicated access through the GitHub connection instead. Directly invoking an unmanaged executable or retaining a token obtained during an earlier invocation is outside the managed invocation contract. +## Legacy hosts and networking + +When no managed GitHub connection is installed for an agent, standard-trust +local and SSH executions retain that execution host's existing Git and GitHub +CLI credentials, configuration, credential helpers, and SSH agent. Paperclip +does not import controller credentials into an SSH target. Sandbox, plugin, +and low-trust executions do not receive this compatibility fallback. Once a +managed connection is configured, unavailable or revoked access never falls +back to host authentication. Switching modes replaces the provider process +while preserving the settled conversation. + +Runner network access is independent of GitHub credentials. The controller +enables networking for standard-trust execution. Low-trust runs and runners +without a controller network decision retain a restricted default. An operator +can set `PAPERCLIP_RUNNER_NETWORK_ACCESS=disabled` to restrict normal execution; +user environment bindings cannot override that decision. Outer execution- +environment network restrictions still apply. The controller projects the assigned worktree's Git metadata paths so +Git can operate without exposing unrelated workspace or provider state. The +sandbox also receives read access to validated provider executable resources +and the target host's DNS and CA files, including resolver symlink targets +outside `/etc`. Provider credential directories remain isolated. + +A managed broker outage does not prevent local Git operations. Launchers clear +credentials and run the command without authentication, with a redacted error +category identifying configuration setup, transport, or capability rejection. +They do not retain a previous operation's token or replay a GitHub operation. + +Healthy eligible grants for the same stable GitHub account ID take precedence +over duplicates with failed health checks. Credential acquisition can retry +once against another grant for that same principal and account, before any +GitHub operation begins. Run identity diagnostics include the selected +connection and grant IDs, without credential values. Access-refresh conflicts +retry once against current state and never turn a concurrency conflict into +a reconnect requirement. + ## Dedicated accounts and diagnostics An explicit dedicated-agent grant overrides personal selection. Revoked, disabled, unavailable, or ambiguous dedicated grants do not fall back to a person's account. Removing the dedicated configuration restores personal selection. diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index dcede3ea14..7ba9109809 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1,4 +1,6 @@ import fs from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -1569,6 +1571,118 @@ async function githubOperationLauncherBasePath( return remotePath; } +/** Read only execution-target Git context; never import the controller's credentials into SSH. */ +export async function prepareGitHubExecutionEnvironment(input: { + target: AdapterExecutionTarget | null | undefined; + cwd: string; + env: Record; + hostCredentials: boolean; + networkAccess: boolean; +}): Promise> { + const script = String.raw` +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); +const env = {}; +env.PAPERCLIP_RUNNER_NETWORK_ROOTS = JSON.stringify(['/etc/resolv.conf','/etc/hosts','/etc/nsswitch.conf','/etc/ssl/certs','/etc/ssl/cert.pem'].flatMap(p => { try { return [fs.realpathSync(p)]; } catch { return []; } })); +if (process.argv[1] === 'host') { + for (const [key, value] of Object.entries(process.env)) { + if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN|GH_CONFIG_DIR|GIT_CONFIG_(GLOBAL|SYSTEM|NOSYSTEM|COUNT|KEY_\d+|VALUE_\d+)|GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL)|GIT_ASKPASS|SSH_ASKPASS|SSH_AUTH_SOCK|GIT_SSH_COMMAND|GIT_SSH)$/.test(key)) env[key] = value; + } + env.PAPERCLIP_GITHUB_HOST_HOME = process.env.HOME || ''; + env.GH_CONFIG_DIR ||= path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'gh'); +} +try { + const top = cp.execFileSync('git', ['rev-parse', '--show-toplevel'], {encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim(); + if (fs.realpathSync(top) === fs.realpathSync(process.cwd())) { + env.PAPERCLIP_GIT_METADATA_ROOTS = JSON.stringify(cp.execFileSync('git', ['rev-parse','--path-format=absolute','--git-common-dir','--git-dir'], {encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim().split('\n').map(p => fs.realpathSync(p))); + } +} catch {} +process.stdout.write("\0" + JSON.stringify(env) + "\0"); +`; + const args = ["-e", script, input.hostCredentials ? "host" : "managed"]; + const remote = input.target?.kind === "remote" ? input.target : null; + let discovered: Record; + if (remote) { + // A legacy SSH host may run a standalone agent binary without Node. Use + // only the shell and Git, and emit bounded, NUL-framed environment records. + const probe = String.raw` +printf '\0PAPERCLIP_GIT_CONTEXT_V1\0' +if [ "$1" = host ]; then + for key in GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN PAPERCLIP_GIT_TOKEN GH_CONFIG_DIR GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM GIT_CONFIG_NOSYSTEM GIT_CONFIG_COUNT GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_ASKPASS SSH_ASKPASS SSH_AUTH_SOCK GIT_SSH_COMMAND GIT_SSH; do + eval 'value=${"$"}{'"$key"'-}' + [ -z "$value" ] || printf '%s\0%s\0' "$key" "$value" + done + index=0 + while [ "$index" -lt 32 ]; do + for prefix in GIT_CONFIG_KEY_ GIT_CONFIG_VALUE_; do + key="$prefix$index" + eval 'value=${"$"}{'"$key"'-}' + [ -z "$value" ] || printf '%s\0%s\0' "$key" "$value" + done + index=$((index + 1)) + done + printf 'PAPERCLIP_GITHUB_HOST_HOME\0%s\0' "$HOME" + printf 'GH_CONFIG_DIR\0%s\0' "${"$"}{GH_CONFIG_DIR:-${"$"}{XDG_CONFIG_HOME:-$HOME/.config}/gh}" +fi +for file in /etc/resolv.conf /etc/hosts /etc/nsswitch.conf /etc/ssl/certs /etc/ssl/cert.pem; do + index=0 + while [ -L "$file" ] && [ "$index" -lt 40 ]; do + target=$(readlink "$file") || break + case "$target" in /*) file="$target" ;; *) file="$(dirname "$file")/$target" ;; esac + index=$((index + 1)) + done + if [ -e "$file" ]; then + parent=$(cd "$(dirname "$file")" && pwd -P) || continue + printf 'PAPERCLIP_RUNNER_NETWORK_ROOT\0%s\0' "$parent/$(basename "$file")" + fi +done +cwd=$(pwd -P) +top=$(git rev-parse --show-toplevel 2>/dev/null) || top= +if [ -n "$top" ] && [ "$(cd "$top" && pwd -P)" = "$cwd" ]; then + for kind in --git-common-dir --git-dir; do + root=$(git rev-parse --path-format=absolute "$kind" 2>/dev/null) || continue + root=$(cd "$root" && pwd -P) || continue + printf 'PAPERCLIP_GIT_METADATA_ROOT\0%s\0' "$root" + done +fi +printf '\0PAPERCLIP_GIT_CONTEXT_END\0' +`; + const result = await adapterExecutionTargetCommandRunner(remote).execute({ + command: "sh", args: ["-c", probe, "paperclip-git-context", input.hostCredentials ? "host" : "managed"], + cwd: input.cwd, timeoutMs: 15_000, + }); + if (result.exitCode !== 0) throw new Error("Could not read execution-target Git context"); + const payload = result.stdout.split("\0PAPERCLIP_GIT_CONTEXT_V1\0")[1]?.split("\0PAPERCLIP_GIT_CONTEXT_END\0")[0]; + if (payload === undefined) throw new Error("Could not read execution-target Git context"); + discovered = {}; + const records = payload.split("\0"); + const roots: string[] = []; + const networkRoots: string[] = []; + for (let index = 0; index + 1 < records.length; index += 2) { + const key = records[index]!; + const value = records[index + 1]!; + if (key === "PAPERCLIP_GIT_METADATA_ROOT") roots.push(value); + else if (key === "PAPERCLIP_RUNNER_NETWORK_ROOT") networkRoots.push(value); + else discovered[key] = value; + } + discovered.PAPERCLIP_GIT_METADATA_ROOTS = JSON.stringify([...new Set(roots)]); + discovered.PAPERCLIP_RUNNER_NETWORK_ROOTS = JSON.stringify([...new Set(networkRoots)]); + } else { + const result = await promisify(execFile)(process.execPath, args, { cwd: input.cwd, timeout: 15_000, maxBuffer: 1024 * 1024 }); + try { discovered = JSON.parse(result.stdout.split("\0")[1] ?? ""); } + catch { throw new Error("Could not read execution-target Git context"); } + } + // Controller-derived roots and mode must not be replaced by agent bindings. + return { ...discovered, ...input.env, + ...(input.hostCredentials ? { PAPERCLIP_GITHUB_HOST_HOME: discovered.PAPERCLIP_GITHUB_HOST_HOME } : {}), + PAPERCLIP_GIT_METADATA_ROOTS: discovered.PAPERCLIP_GIT_METADATA_ROOTS ?? "[]", + PAPERCLIP_RUNNER_NETWORK_ROOTS: discovered.PAPERCLIP_RUNNER_NETWORK_ROOTS ?? "[]", + PAPERCLIP_GITHUB_AUTH_MODE: input.hostCredentials ? "host" : "managed", + PAPERCLIP_RUNNER_NETWORK_ACCESS: input.networkAccess ? "enabled" : "disabled", + }; +} + /** Stage token-free launchers next to the execution, not in shared global Git config. */ export async function prepareGitHubOperationLaunchers(input: { runId: string; target: AdapterExecutionTarget | null | undefined; cwd: string; env: Record; diff --git a/packages/adapter-utils/src/github-launcher-environment.test.ts b/packages/adapter-utils/src/github-launcher-environment.test.ts index 2c3768fb15..4534b4dec0 100644 --- a/packages/adapter-utils/src/github-launcher-environment.test.ts +++ b/packages/adapter-utils/src/github-launcher-environment.test.ts @@ -9,6 +9,7 @@ import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; import { ensureAdapterExecutionTargetCommandResolvable, prepareGitHubOperationLaunchers, + prepareGitHubExecutionEnvironment, runAdapterExecutionTargetProcess, } from "./execution-target.js"; @@ -65,6 +66,54 @@ async function sandbox(layout: string) { } describe("managed GitHub launcher environment", () => { + it("uses target Git configuration without importing controller credentials", async () => { + const fixture = await sandbox("usr/bin"); + vi.stubEnv("GH_TOKEN", "controller-secret"); + await mkdir(path.join(fixture.root, ".config/gh"), { recursive: true }); + await writeFile(path.join(fixture.root, ".config/gh/hosts.yml"), "host credential fixture"); + const execute = fixture.runner.execute.getMockImplementation()!; + fixture.runner.execute.mockImplementation(async (input) => { + expect(input.command).toBe("sh"); // No Node executable is required on the SSH host. + const result = await execute(input); + return { ...result, stdout: `SSH login banner\n${result.stdout}\nlogout` }; + }); + const env = await prepareGitHubExecutionEnvironment({ + target: fixture.target, cwd: fixture.root, env: { + PAPERCLIP_GIT_METADATA_ROOTS: '["/injected"]', + PAPERCLIP_RUNNER_NETWORK_ROOTS: '["/injected"]', + PAPERCLIP_GITHUB_HOST_HOME: "/injected", + PAPERCLIP_GITHUB_AUTH_MODE: "managed", + PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled", + }, hostCredentials: true, networkAccess: true, + }); + expect(env.PAPERCLIP_GIT_METADATA_ROOTS).not.toContain("/injected"); + expect(env.PAPERCLIP_RUNNER_NETWORK_ROOTS).not.toContain("/injected"); + expect(env.PAPERCLIP_GITHUB_AUTH_MODE).toBe("host"); + expect(env.PAPERCLIP_RUNNER_NETWORK_ACCESS).toBe("enabled"); + expect(env.PAPERCLIP_GITHUB_HOST_HOME).toBe(fixture.root); + expect(env.GH_CONFIG_DIR).toBe(path.join(fixture.root, ".config/gh")); + expect(env.GH_TOKEN).toBeUndefined(); + expect(env.PAPERCLIP_GITHUB_LAUNCHER_DIR).toBeUndefined(); + }); + + it("preserves local host credential helpers and validates worktree metadata", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-host-git-")); roots.push(root); + vi.stubEnv("HOME", root); + vi.stubEnv("GH_TOKEN", "legacy-token"); + await writeFile(path.join(root, ".gitconfig"), '[credential]\n helper = store\n'); + await exec("git", ["init", path.join(root, "repo")]); + const env = await prepareGitHubExecutionEnvironment({ target: null, cwd: path.join(root, "repo"), env: {}, hostCredentials: true, networkAccess: true }); + expect(env.GH_TOKEN).toBe("legacy-token"); + expect(env.GIT_CONFIG_GLOBAL).toBeUndefined(); + expect(env.PAPERCLIP_GIT_METADATA_ROOTS).toContain("/repo/.git"); + const config = await exec("git", ["config", "credential.helper"], { cwd: root, env: { ...process.env, ...env } }); + expect(config.stdout.trim()).toBe("store"); + const isolated = await prepareGitHubExecutionEnvironment({ target: null, cwd: root, env: {}, hostCredentials: false, networkAccess: false }); + expect(isolated.GH_TOKEN).toBeUndefined(); + expect(isolated.PAPERCLIP_RUNNER_NETWORK_ACCESS).toBe("disabled"); + expect(isolated.PAPERCLIP_GITHUB_HOST_HOME).toBeUndefined(); + }); + it.each(["nvm/current/bin", "usr/local/bin", "tools with 'quotes'/bin"])( "preserves %s CLIs and keeps GitHub wrappers first in child shells", async (layout) => { diff --git a/packages/adapter-utils/src/github-launcher.test.ts b/packages/adapter-utils/src/github-launcher.test.ts index 00f3ed6815..4e15066c5a 100644 --- a/packages/adapter-utils/src/github-launcher.test.ts +++ b/packages/adapter-utils/src/github-launcher.test.ts @@ -11,6 +11,28 @@ const cleanups: Array<() => Promise> = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); describe("managed GitHub launchers", () => { + it.each(["broker-offline", "config-unwritable", "capability-rejected"])("keeps real local Git usable when %s", async (failure) => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-failure-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + const bin = path.join(root, "managed"); + await mkdir(bin); + await exec("git", ["init", root]); + await writeFile(path.join(bin, "git"), githubLauncherSource(), { mode: 0o700 }); + const server = createServer((_req, res) => { res.writeHead(403); res.end(); }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as { port: number }; + if (failure === "broker-offline") await new Promise(resolve => server.close(() => resolve())); + else cleanups.push(() => new Promise(resolve => server.close(() => resolve()))); + const configRoot = path.join(root, "config"); + if (failure === "config-unwritable") await writeFile(configRoot, "not a directory"); + const result = await exec(path.join(bin, "git"), ["status", "--porcelain"], { cwd: root, env: { + ...process.env, ...githubBrokerEnvironment({ GH_TOKEN: "host-must-not-leak" }, { url: `http://127.0.0.1:${port}`, token: "private-capability" }), + GH_CONFIG_DIR: configRoot, PATH: `${bin}:${process.env.PATH}`, + } }); + expect(result.stderr).toContain(failure === "broker-offline" ? "broker_transport_unavailable" : failure === "config-unwritable" ? "configuration_directory_unavailable" : "capability_rejected"); + expect(result.stderr).not.toMatch(/host-must-not-leak|private-capability/); + }); + it("explains unavailable access while allowing local work without credentials", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-diagnostic-")); cleanups.push(() => rm(root, {recursive:true,force:true})); diff --git a/packages/adapter-utils/src/github-launcher.ts b/packages/adapter-utils/src/github-launcher.ts index b84234b861..2c5579ed40 100644 --- a/packages/adapter-utils/src/github-launcher.ts +++ b/packages/adapter-utils/src/github-launcher.ts @@ -19,18 +19,25 @@ if (!['git', 'gh'].includes(program) || !executable) { } async function main() { let env = { ...process.env }; + const diagnostic = (code) => process.stderr.write('Paperclip: GitHub ' + code + '; continuing without managed credentials.\n'); const configRoot = env.GH_CONFIG_DIR || os.tmpdir(); - fs.mkdirSync(configRoot, { recursive: true, mode: 0o700 }); - const configDirectory = fs.mkdtempSync(path.join(configRoot, 'paperclip-github-operation-')); - fs.chmodSync(configDirectory, 0o700); - const cleanup = () => fs.rmSync(configDirectory, { recursive: true, force: true }); - process.once('exit', cleanup); + // A missing/unwritable scratch directory must not break local Git. The + // fallback deliberately cannot load the host's gh authentication files. + let configDirectory = path.join(directory, 'unavailable-gh-config'); + let configReady = false; + try { + fs.mkdirSync(configRoot, { recursive: true, mode: 0o700 }); + configDirectory = fs.mkdtempSync(path.join(configRoot, 'paperclip-github-operation-')); + fs.chmodSync(configDirectory, 0o700); + configReady = true; + process.once('exit', () => { try { fs.rmSync(configDirectory, { recursive: true, force: true }); } catch {} }); + } catch { diagnostic('configuration_directory_unavailable'); } { for (const key of Object.keys(env)) { - if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN|GIT_AUTHOR_.*|GIT_COMMITTER_.*|GIT_CONFIG_.*|GIT_ASKPASS|SSH_ASKPASS|GIT_SSH.*)$/.test(key)) delete env[key]; + if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN|GIT_AUTHOR_.*|GIT_COMMITTER_.*|GIT_CONFIG_.*|GIT_ASKPASS|SSH_ASKPASS|SSH_AUTH_SOCK|GIT_SSH.*)$/.test(key)) delete env[key]; } Object.assign(env, { - GH_CONFIG_DIR: configDirectory, + GH_CONFIG_DIR: configDirectory, SSH_AUTH_SOCK: '', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: '', GIT_AUTHOR_EMAIL: '', GIT_COMMITTER_NAME: '', GIT_COMMITTER_EMAIL: '', @@ -39,7 +46,8 @@ async function main() { GIT_CONFIG_KEY_2: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_2: 'ssh://git@github.com/', GIT_CONFIG_KEY_3: 'core.askPass', GIT_CONFIG_VALUE_3: '', }); - const base = env.PAPERCLIP_API_URL || env.PAPERCLIP_GITHUB_BROKER_URL; + const base = env.PAPERCLIP_GITHUB_BROKER_URL || env.PAPERCLIP_API_URL; + try { let response; if (base && env.PAPERCLIP_GITHUB_BROKER_TOKEN) { const url = base.replace(/\/+$/, '').replace(/\/api$/, '') + '/runtime-tools/github/credentials'; @@ -54,7 +62,9 @@ async function main() { await response.arrayBuffer(); await new Promise(resolve => setTimeout(resolve, 1000)); } - if (!response.ok) throw new Error('GitHub credential context unavailable; retry this operation'); + if (!response.ok) { + diagnostic(response.status === 401 || response.status === 403 ? 'capability_rejected' : 'broker_response_unavailable'); + } else { const result = await response.json(); if (result.status === 'unavailable') { const reason = typeof result.reason === 'string' @@ -62,12 +72,14 @@ async function main() { : 'Check the GitHub connection in Paperclip'; process.stderr.write('Paperclip: GitHub access unavailable: ' + reason + '. Continuing without GitHub credentials.\n'); } - if (result.status === 'available') { + if (result.status === 'available' && configReady) { for (const [key, value] of Object.entries(result.env || {})) { if (/^(GH_TOKEN|GITHUB_TOKEN|PAPERCLIP_GIT_TOKEN|GIT_TERMINAL_PROMPT|GIT_AUTHOR_(NAME|EMAIL)|GIT_COMMITTER_(NAME|EMAIL)|GIT_CONFIG_COUNT|GIT_CONFIG_(KEY|VALUE)_\d+)$/.test(key) && typeof value === 'string') env[key] = value; } } - } + } + } else { diagnostic('capability_missing'); } + } catch { diagnostic('broker_transport_unavailable'); } } // Only this invocation and its children inherit the captured credential. // Its Git children use the real binary, so steering cannot split a gh operation. @@ -82,7 +94,7 @@ async function main() { child.once('error', () => { process.stderr.write('Paperclip: GitHub command could not start.\n'); process.exitCode = 1; }); child.once('exit', (code, signal) => { process.exitCode = code === null ? 128 : code; }); } -main().catch(() => { process.stderr.write('Paperclip: GitHub credential context unavailable; retry this operation.\n'); process.exitCode = 1; }); +main().catch(() => { process.stderr.write('Paperclip: GitHub launcher_setup_failed.\n'); process.exitCode = 1; }); `; } diff --git a/packages/db/src/schema/run_identity_contexts.ts b/packages/db/src/schema/run_identity_contexts.ts index 9a7ff675d8..90229e483f 100644 --- a/packages/db/src/schema/run_identity_contexts.ts +++ b/packages/db/src/schema/run_identity_contexts.ts @@ -16,7 +16,7 @@ export const runIdentityContexts = pgTable("run_identity_contexts", { correlationId: text("correlation_id").notNull(), status: text("status").notNull().default("accepted"), acceptedAt: timestamp("accepted_at", { withTimezone: true }), - github: jsonb("github").$type<{ status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string }>(), + github: jsonb("github").$type<{ status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string; connectionId?: string; grantId?: string; authenticationMode?: "managed" | "host" | "anonymous" }>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ revisionIdx: uniqueIndex("run_identity_contexts_run_revision_idx").on(t.runId, t.revision), diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 1ea4c769a8..89af487576 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -578,6 +578,12 @@ pub struct CodexProvider { // entry from this static ceiling, but cannot introduce another environment // variable by changing GIT_CONFIG_COUNT. const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[ + "PAPERCLIP_RUNNER_NETWORK_ACCESS", + "PAPERCLIP_RUNNER_NETWORK_ROOTS", + "PAPERCLIP_GITHUB_AUTH_MODE", + "PAPERCLIP_GITHUB_HOST_HOME", + "PAPERCLIP_GIT_METADATA_ROOTS", + "GIT_SSH", "ZDOTDIR", "BASH_ENV", "PAPERCLIP_GITHUB_BROKER_URL", @@ -3551,8 +3557,14 @@ mod tests { #[test] fn github_credentials_cross_only_the_bounded_provider_environment() { - assert_eq!(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.len(), 89); + assert_eq!(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.len(), 95); for key in [ + "PAPERCLIP_RUNNER_NETWORK_ACCESS", + "PAPERCLIP_RUNNER_NETWORK_ROOTS", + "PAPERCLIP_GITHUB_AUTH_MODE", + "PAPERCLIP_GITHUB_HOST_HOME", + "PAPERCLIP_GIT_METADATA_ROOTS", + "GIT_SSH", "PAPERCLIP_GITHUB_BROKER_URL", "PAPERCLIP_GITHUB_BROKER_TOKEN", "PAPERCLIP_GITHUB_LAUNCHER_DIR", diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index 0db3d85d27..0b5002ac5e 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -1,3 +1,4 @@ +import { codexExecutableReadOnlyRoots } from "./codex-security-config.js"; import { resolve } from "node:path"; import type { @@ -40,6 +41,7 @@ import { CODEX_SKILLLESS_PERMISSION_PROFILE as SKILLLESS_PERMISSION_PROFILE, codexCommandEnvironment, createIsolatedCodexAppServerArgs, + codexNetworkAccess, createSecuredCodexThreadParams, createSkilllessCodexThreadConfig, } from "./codex-security-config.js"; @@ -619,7 +621,7 @@ export class CodexAppServerDriver implements HarnessDriver { return ( this.#options.transportFactory?.(context) ?? new ProcessCodexAppServerTransport({ - args: createIsolatedCodexAppServerArgs(this.#options.environment), + args: createIsolatedCodexAppServerArgs(this.#options.environment, codexExecutableReadOnlyRoots(this.#options.environment ?? process.env)), environment: createSanitizedCodexEnvironment(this.#options.environment), onDiagnostic: this.#options.onDiagnostic, processGroup: true, @@ -812,7 +814,8 @@ export class CodexAppServerDriver implements HarnessDriver { rootAccess: "none", minimalRuntimeAccess: "read", workspaceAccess: requestedMode === "plan" ? "read" : "write", - networkAccess: false, + networkAccess: codexNetworkAccess(this.#options.environment), + githubAuthenticationMode: this.#options.environment?.PAPERCLIP_GITHUB_AUTH_MODE ?? "managed", }, approvalPolicy: boundedCodexValue( response.approvalPolicy ?? diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts index d4741c3fd6..f22d328ffb 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts @@ -1,12 +1,79 @@ +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { evalProviderTransportOptions } from "../../cli/eval-provider-runtime.js"; import { describe, expect, it } from "vitest"; import { + codexExecutableReadOnlyRoots, + codexNetworkReadOnlyRoots, createIsolatedCodexAppServerArgs, createSecuredCodexThreadParams, createSkilllessCodexThreadConfig, } from "./codex-security-config.js"; describe("Codex security configuration", () => { + it("makes the installed npm Codex native sandbox executable readable without exposing its parent workspace", () => { + const command = evalProviderTransportOptions("codex").codexCommand!; + const manifest = createRequire(command).resolve(`@openai/codex-${process.platform}-${process.arch}/package.json`); + const vendor = resolve(dirname(manifest), "vendor"); + const roots = codexExecutableReadOnlyRoots({ HOME: "/private-provider-home", PATH: "/usr/bin" }, command); + expect(roots).toContain(vendor); + expect(roots).toContain(process.execPath); + expect(roots).not.toContain(dirname(manifest)); + expect(roots).not.toContain("/private-provider-home"); + const args = createIsolatedCodexAppServerArgs({ HOME: "/private-provider-home" }, roots).join("\n"); + expect(args).toContain(`${JSON.stringify(vendor)}="read"`); + expect(args).toContain('"/private-provider-home"="none"'); + }); + + it("preserves target DNS symlink resources without opening all of /run", () => { + const source = { PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled", PAPERCLIP_RUNNER_NETWORK_ROOTS: '["/run/systemd/resolve/stub-resolv.conf","/etc/ssl/certs"]' }; + const args = createIsolatedCodexAppServerArgs(source).join("\n"); + expect(args).toContain('"/run/systemd/resolve/stub-resolv.conf"="read"'); + expect(args).not.toContain('"/run"="read"'); + expect(codexNetworkReadOnlyRoots({ ...source, PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled" })).toEqual([]); + }); + + it("requires the controller's network decision even when GitHub credentials exist", () => { + for (const GH_TOKEN of [undefined, "managed-token"]) { + const args = createIsolatedCodexAppServerArgs({ GH_TOKEN }).join("\n"); + expect(args).toContain("network.enabled=false"); + expect(args).not.toContain("network.enabled=true"); + } + }); + + it("honors an explicit network restriction independently of GitHub", () => { + for (const GH_TOKEN of [undefined, "managed-token"]) { + const args = createIsolatedCodexAppServerArgs({ GH_TOKEN, PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled" }).join("\n"); + expect(args).toContain("network.enabled=false"); + expect(args).not.toContain("network.enabled=true"); + } + }); + + it("restores host Git resources without exposing the provider home", () => { + const args = createIsolatedCodexAppServerArgs({ + HOME: "/provider", CODEX_HOME: "/provider", PATH: "/usr/bin:/bin", PAPERCLIP_GITHUB_AUTH_MODE: "host", + OPENAI_API_KEY: "must-not-cross", DATABASE_URL: "must-not-cross", PAPERCLIP_API_KEY: "must-not-cross", + PAPERCLIP_GITHUB_HOST_HOME: "/legacy", GH_CONFIG_DIR: "/legacy/.config/gh", + SSH_AUTH_SOCK: "/agent/socket", PAPERCLIP_GIT_METADATA_ROOTS: '["/repo/.git","/repo/.git"]', + }).join("\n"); + const allowlist = JSON.parse(args.split("\n").find((arg) => arg.startsWith("shell_environment_policy.include_only="))!.split("=", 2)[1]!); + expect(allowlist).toEqual(["GH_CONFIG_DIR", "HOME", "PAPERCLIP_GITHUB_AUTH_MODE", "PAPERCLIP_GITHUB_HOST_HOME", "PAPERCLIP_GIT_METADATA_ROOTS", "PATH", "SSH_AUTH_SOCK"]); + expect(args).not.toContain("must-not-cross"); + expect(args).not.toContain("OPENAI_API_KEY"); + expect(args).not.toContain("DATABASE_URL"); + expect(args).not.toContain("PAPERCLIP_API_KEY"); + expect(args).toContain('HOME="/legacy"'); + expect(args).toContain('"/legacy/.gitconfig"="read"'); + expect(args).toContain('"/legacy/.ssh"="read"'); + expect(args).toContain('"/agent/socket"="read"'); + expect(args).toContain('"/repo/.git"="write"'); + expect(args).toContain('"/repo/.git"="read"'); + expect(args).toContain('"/provider"="none"'); + expect(args).not.toContain('"/legacy"="read"'); + expect(args.match(/"\/legacy\/.config\/gh"="read"/g)).toHaveLength(2); + }); + it("disables host extensions and makes collaboration instructions explicit", () => { expect(createSkilllessCodexThreadConfig("/workspace", {}, false)).toEqual({ "skills.include_instructions": false, @@ -20,12 +87,13 @@ describe("Codex security configuration", () => { }); }); - it("keeps automatic execution inside the workspace without credential or network access", () => { + it("keeps automatic execution inside the workspace without host credentials and with normal network access", () => { const args = createIsolatedCodexAppServerArgs( { HOME: "/host/home", CODEX_HOME: "/host/codex", PATH: "/safe/bin", + PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled", LANG: "C.UTF-8", OPENAI_API_KEY: "must-not-cross", }, @@ -43,8 +111,8 @@ describe("Codex security configuration", () => { expect(serialized).toContain('"/runner/context"="read"'); expect(serialized).toContain('":workspace_roots"={"."="write"}'); expect(serialized).toContain('":workspace_roots"={"."="read"}'); - expect(serialized).toContain("network.enabled=false"); - expect(serialized).toContain('shell_environment_policy.inherit="none"'); + expect(serialized).toContain("network.enabled=true"); + expect(serialized).toContain('shell_environment_policy.include_only=["LANG","PAPERCLIP_RUNNER_NETWORK_ACCESS","PATH"]'); expect(serialized).toContain('PATH="/safe/bin"'); expect(serialized).toContain('LANG="C.UTF-8"'); expect(serialized).not.toContain("OPENAI_API_KEY"); @@ -54,6 +122,7 @@ describe("Codex security configuration", () => { it("inherits only projected GitHub credentials without serializing their values", () => { const args = createIsolatedCodexAppServerArgs({ PATH: "/safe/bin", + PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled", GH_TOKEN: "must-remain-in-process-environment", GITHUB_TOKEN: "must-remain-in-process-environment", PAPERCLIP_GIT_TOKEN: "must-remain-in-process-environment", @@ -115,7 +184,7 @@ describe("Codex security configuration", () => { }); it("uses the outer sandbox for default-mode commands only when the controller authorizes it", () => { - const source = { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1" }; + const source = { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1", PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled" }; const externalArgs = createIsolatedCodexAppServerArgs(source); const serializedExternalArgs = externalArgs.join("\n"); expect(externalArgs).toContain( diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index 2d0cd4c5bb..b041ff4363 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -1,10 +1,72 @@ -import { resolve } from "node:path"; +import { resolve, isAbsolute, join, dirname, delimiter } from "node:path"; +import { existsSync, realpathSync, readFileSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; import { githubCredentialEnvironmentKeys, - hasGitHubCredentialEnvironment, } from "../../github-credential-environment.js"; +/** DNS and CA files can point outside the minimal /etc filesystem (systemd). */ +export function codexNetworkReadOnlyRoots(source: NodeJS.ProcessEnv): string[] { + if (!codexNetworkAccess(source)) return []; + const roots = new Set(); + if (source.PAPERCLIP_RUNNER_NETWORK_ROOTS !== undefined) { + try { + const projected: unknown = JSON.parse(source.PAPERCLIP_RUNNER_NETWORK_ROOTS); + if (Array.isArray(projected)) for (const root of projected) { + if (typeof root === "string" && isAbsolute(root) && resolve(root) !== "/") roots.add(resolve(root)); + } + } catch { /* A malformed controller projection must not expand access. */ } + } else { + for (const file of ["/etc/resolv.conf", "/etc/hosts", "/etc/nsswitch.conf", "/etc/ssl/certs", "/etc/ssl/cert.pem"]) { + try { roots.add(realpathSync(file)); } catch { /* Platform-specific optional resource. */ } + } + } + return [...roots]; +} + +/** Resolve executable resources only, without exposing their enclosing home. */ +export function codexExecutableReadOnlyRoots(source: NodeJS.ProcessEnv, command = "codex"): string[] { + const roots = new Set(); + const add = (path: string) => { + try { + if (!isAbsolute(path) || resolve(path) === "/" || !existsSync(path)) return; + roots.add(resolve(path)); + roots.add(realpathSync(path)); + } catch { /* Missing resources remain a normal executable-resolution error. */ } + }; + add(process.execPath); + const candidates = isAbsolute(command) ? [command] + : (source.PATH ?? "").split(delimiter).filter(isAbsolute).map(root => resolve(root, command)); + const executable = candidates.find(path => { + try { const stat = statSync(path); return stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0); } catch { return false; } + }); + if (!executable) return [...roots]; + add(executable); + try { + const canonical = realpathSync(executable); + const manifestPath = resolve(dirname(canonical), "../package.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex; + if (manifest.name !== "@openai/codex" || typeof bin !== "string" + || realpathSync(resolve(dirname(manifestPath), bin)) !== canonical) return [...roots]; + // The npm entrypoint launches the platform package's native executable, + // which Codex invokes again inside bwrap when starting each shell command. + const platformPackage = `@openai/codex-${process.platform}-${process.arch}`; + if (manifest.optionalDependencies?.[platformPackage]) { + const platformManifest = createRequire(manifestPath).resolve(`${platformPackage}/package.json`); + const packageRoot = realpathSync(dirname(platformManifest)); + const vendor = realpathSync(resolve(packageRoot, "vendor")); + if (vendor.startsWith(`${packageRoot}/`)) add(vendor); + } else { + const packageRoot = realpathSync(dirname(manifestPath)); + const vendor = realpathSync(resolve(packageRoot, "vendor")); + if (vendor.startsWith(`${packageRoot}/`)) add(vendor); + } + } catch { /* Standalone executable installations need no npm resources. */ } + return [...roots]; +} + export const CODEX_SKILLLESS_PERMISSION_PROFILE = "paperclip-runner-workspace-only"; export const CODEX_PLANNING_PERMISSION_PROFILE = @@ -12,6 +74,30 @@ export const CODEX_PLANNING_PERMISSION_PROFILE = export const CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE = "paperclip-runner-external-sandbox"; +export function codexNetworkAccess(source: NodeJS.ProcessEnv = process.env): boolean { + return source.PAPERCLIP_RUNNER_NETWORK_ACCESS === "enabled"; +} + +function gitFilesystemRoots(source: NodeJS.ProcessEnv): { read: string[]; write: string[] } { + const read: string[] = []; + const write: string[] = []; + try { + const roots: unknown = JSON.parse(source.PAPERCLIP_GIT_METADATA_ROOTS ?? "[]"); + if (Array.isArray(roots)) for (const root of roots) { + if (typeof root === "string" && isAbsolute(root) && resolve(root) !== "/") write.push(resolve(root)); + } + } catch { /* Older controllers do not project Git metadata roots. */ } + if (source.PAPERCLIP_GITHUB_AUTH_MODE === "host" && source.PAPERCLIP_GITHUB_HOST_HOME) { + for (const relative of [".gitconfig", ".git-credentials", ".config/git", ".config/gh", ".ssh"]) { + read.push(join(source.PAPERCLIP_GITHUB_HOST_HOME, relative)); + } + for (const root of [source.GH_CONFIG_DIR, source.GIT_CONFIG_GLOBAL, source.GIT_CONFIG_SYSTEM, source.SSH_AUTH_SOCK]) { + if (root && isAbsolute(root) && resolve(root) !== "/") read.push(resolve(root)); + } + } + return { read: [...new Set(read)], write: [...new Set(write)] }; +} + function usesExternalRunnerSandbox(source: NodeJS.ProcessEnv): boolean { return source.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX === "1"; } @@ -42,7 +128,9 @@ export function codexCommandEnvironment( const value = source[key]; if (value !== undefined) environment[key] = value; } - if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) { + if (source.PAPERCLIP_GITHUB_AUTH_MODE === "host" && source.PAPERCLIP_GITHUB_HOST_HOME) { + environment.HOME = source.PAPERCLIP_GITHUB_HOST_HOME; + } else if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) { environment.HOME = source.PAPERCLIP_GITHUB_LAUNCHER_DIR; environment.ZDOTDIR = source.PAPERCLIP_GITHUB_LAUNCHER_DIR; environment.BASH_ENV = `${source.PAPERCLIP_GITHUB_LAUNCHER_DIR}/.bashrc`; @@ -82,9 +170,16 @@ export function createIsolatedCodexAppServerArgs( source: NodeJS.ProcessEnv = process.env, readOnlyRoots: string[] = [], ): string[] { - const hasGitHubCredential = hasGitHubCredentialEnvironment(source); + const gitRoots = gitFilesystemRoots(source); + readOnlyRoots = [...new Set([...readOnlyRoots, ...codexNetworkReadOnlyRoots(source)])]; + const networkAccess = codexNetworkAccess(source); const externalRunnerSandbox = usesExternalRunnerSandbox(source); const inheritedGitHubKeys = githubCredentialEnvironmentKeys(source); + const hasProjectedEnvironment = inheritedGitHubKeys.length > 0; + // Codex filters the configured `set` values through include_only as well. + // Retain the explicit command PATH/HOME/locale settings, not ambient secrets. + const commandEnvironment = codexCommandEnvironment(source); + const shellEnvironmentKeys = [...new Set([...inheritedGitHubKeys, ...Object.keys(commandEnvironment)])].sort(); if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR]; const deniedHostRoots = [ ...new Set( @@ -102,6 +197,8 @@ export function createIsolatedCodexAppServerArgs( `":tmpdir"="none"`, ...deniedHostRoots.map((path) => `${tomlString(path)}="none"`), ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), + ...gitRoots.read.map((path) => `${tomlString(path)}="read"`), + ...gitRoots.write.map((path) => `${tomlString(path)}="write"`), ...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR ? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []), `":workspace_roots"={"."="write"}`, @@ -112,11 +209,12 @@ export function createIsolatedCodexAppServerArgs( `":tmpdir"="none"`, ...deniedHostRoots.map((path) => `${tomlString(path)}="none"`), ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), + ...[...gitRoots.read, ...gitRoots.write].map((path) => `${tomlString(path)}="read"`), ...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR ? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []), `":workspace_roots"={"."="read"}`, ].join(","); - const commandEnv = Object.entries(codexCommandEnvironment(source)) + const commandEnv = Object.entries(commandEnvironment) .map(([key, value]) => `${key}=${tomlString(value)}`) .join(","); const defaultPermissionProfile = externalRunnerSandbox @@ -128,29 +226,28 @@ export function createIsolatedCodexAppServerArgs( "-c", `permissions.${CODEX_SKILLLESS_PERMISSION_PROFILE}.filesystem={${filesystemRules}}`, "-c", - `permissions.${CODEX_SKILLLESS_PERMISSION_PROFILE}.network.enabled=${hasGitHubCredential}`, + `permissions.${CODEX_SKILLLESS_PERMISSION_PROFILE}.network.enabled=${networkAccess}`, ...(externalRunnerSandbox ? [ "-c", `permissions.${CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE}.filesystem={":root"="write"}`, "-c", - `permissions.${CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE}.network.enabled=true`, + `permissions.${CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE}.network.enabled=${networkAccess}`, ] : []), "-c", `permissions.${CODEX_PLANNING_PERMISSION_PROFILE}.filesystem={${planningFilesystemRules}}`, "-c", - `permissions.${CODEX_PLANNING_PERMISSION_PROFILE}.network.enabled=${hasGitHubCredential}`, + `permissions.${CODEX_PLANNING_PERMISSION_PROFILE}.network.enabled=${networkAccess}`, "-c", - `shell_environment_policy.inherit=${tomlString(hasGitHubCredential ? "all" : "none")}`, + `shell_environment_policy.inherit=${tomlString(hasProjectedEnvironment ? "all" : "none")}`, "-c", - `shell_environment_policy.ignore_default_excludes=${hasGitHubCredential}`, - ...(hasGitHubCredential - ? [ - "-c", - `shell_environment_policy.include_only=${JSON.stringify(inheritedGitHubKeys)}`, - ] - : []), + `shell_environment_policy.ignore_default_excludes=${hasProjectedEnvironment}`, + // Codex applies include_only after inheritance. Always emit the bounded + // allowlist: neither host mode nor a broker enables ambient secret access. + // Keep values in the process environment, never in argv/config diagnostics. + "-c", + `shell_environment_policy.include_only=${JSON.stringify(shellEnvironmentKeys)}`, ...(commandEnv.length > 0 ? ["-c", `shell_environment_policy.set={${commandEnv}}`] : []), diff --git a/packages/paperclip-runner/src/github-credential-environment.ts b/packages/paperclip-runner/src/github-credential-environment.ts index 0aa880eaf3..7b0c122344 100644 --- a/packages/paperclip-runner/src/github-credential-environment.ts +++ b/packages/paperclip-runner/src/github-credential-environment.ts @@ -1,4 +1,9 @@ const STATIC_GITHUB_CREDENTIAL_ENVIRONMENT_KEYS = [ + "PAPERCLIP_RUNNER_NETWORK_ACCESS", + "PAPERCLIP_RUNNER_NETWORK_ROOTS", + "PAPERCLIP_GITHUB_AUTH_MODE", + "PAPERCLIP_GITHUB_HOST_HOME", + "PAPERCLIP_GIT_METADATA_ROOTS", "ZDOTDIR", "BASH_ENV", "PAPERCLIP_GITHUB_BRIDGE_TOKEN", @@ -15,6 +20,7 @@ const STATIC_GITHUB_CREDENTIAL_ENVIRONMENT_KEYS = [ "SSH_ASKPASS", "SSH_AUTH_SOCK", "GIT_SSH_COMMAND", + "GIT_SSH", "GH_TOKEN", "GITHUB_TOKEN", "PAPERCLIP_GIT_TOKEN", @@ -72,14 +78,3 @@ export function githubCredentialEnvironmentKeys( ): string[] { return Object.keys(githubCredentialEnvironment(source)).sort(); } - -export function hasGitHubCredentialEnvironment( - source: NodeJS.ProcessEnv, -): boolean { - return [ - source.PAPERCLIP_GITHUB_BROKER_TOKEN, - source.GH_TOKEN, - source.GITHUB_TOKEN, - source.PAPERCLIP_GIT_TOKEN, - ].some((value) => typeof value === "string" && value.trim().length > 0); -} diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index a2cfcddaef..ef248dc8b5 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,3 +1,4 @@ +import { codexExecutableReadOnlyRoots } from "../drivers/codex/codex-security-config.js"; import { isCanonicalProviderEventType } from "../provider-events.js"; import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; @@ -2097,6 +2098,7 @@ export function trustedRuntimeReadOnlyRoots( export function createRunnerdCodexAppServerArgs(input: { environment: NodeJS.ProcessEnv | undefined; codexHome: string; + codexCommand?: string; readOnlyRoots?: string[]; }): string[] { // The filesystem policy denies HOME and CODEX_HOME to keep credentials and @@ -2109,7 +2111,7 @@ export function createRunnerdCodexAppServerArgs(input: { HOME: input.codexHome, CODEX_HOME: input.codexHome, }, - input.readOnlyRoots, + [...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)], ); } @@ -3360,6 +3362,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { createRunnerdCodexAppServerArgs({ environment: this.options.environment, codexHome, + codexCommand: this.options.codexCommand, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots( this.options.environment, @@ -3755,12 +3758,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#authorizedTools, this.options.resumeCompletionContract, ); - if (provider === "codex" && this.options.environment?.PAPERCLIP_GITHUB_BROKER_TOKEN) { + if (provider === "codex") { // These controller-owned, token-free paths belong to the new run. // Keep the durable provider profile and thread identity unchanged. runAttachTemplate.runtimeLaunchArgs = this.options.codexArgs ?? createRunnerdCodexAppServerArgs({ environment: this.options.environment, codexHome, + codexCommand: this.options.codexCommand, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots(this.options.environment), ...(runtimeContext ? [ diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index ce8affb0e4..3c9082d1e9 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -171,7 +171,7 @@ export interface HeartbeatRun { identityHistory?: Array<{ id: string; revision: number; responsibleUserId: string | null; messageId: string | null; parentContextId: string | null; cause: string; status: string; acceptedAt: Date | string | null; - github: { status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string } | null; + github: { status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string; connectionId?: string; grantId?: string; authenticationMode?: "managed" | "host" | "anonymous" } | null; }>; startedAt: Date | null; finishedAt: Date | null; diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index 5144c4ffc7..970761481e 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -177,6 +177,8 @@ export interface ToolConnection { credentialSecretRefs: ToolCredentialSecretRef[]; credentialRefs?: McpConnectionCredentialRef[]; healthStatus: ToolConnectionHealthStatus; + /** Managed GitHub grant state; transient health failures do not require sign-in. */ + requiresReauthorization?: boolean; healthMessage?: string | null; healthCheckedAt: Date | null; lastHealthAt?: Date | string | null; diff --git a/server/src/__tests__/github-operation-credentials.test.ts b/server/src/__tests__/github-operation-credentials.test.ts index 44b3efd1fe..b4f0dbd35b 100644 --- a/server/src/__tests__/github-operation-credentials.test.ts +++ b/server/src/__tests__/github-operation-credentials.test.ts @@ -73,6 +73,29 @@ const support = await getEmbeddedPostgresTestSupport(); expect((await resolveGitHubOperationCredentials(db,input)).reason).toMatch(/More than one/); await expect(resolveGitHubOperationCredentials(db,{...input,companyId:randomUUID()})).rejects.toThrow(); }); + it.each([true, false])("prefers the healthy duplicate regardless of grant age (%s)", async (healthyNewer) => { + const input = await seed(); + const healthy = await grant(input, "A"); + const broken = await grant(input, "A"); + await db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, healthy.connectionId)); + await db.update(toolConnections).set({ healthStatus: "error", healthMessage: "GitHub access changed during refresh. Try again." }).where(eq(toolConnections.id, broken.connectionId)); + await db.update(connectionGrants).set({ createdAt: new Date(healthyNewer ? "2026-02-01" : "2026-01-01") }).where(eq(connectionGrants.id, healthy.id)); + await db.update(connectionGrants).set({ createdAt: new Date(healthyNewer ? "2026-01-01" : "2026-02-01") }).where(eq(connectionGrants.id, broken.id)); + expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({ + status: "available", connectionId: healthy.connectionId, grantId: healthy.id, authenticationMode: "managed", + }); + }); + + it("retries credential acquisition once using another grant for the same account", async () => { + const input = await seed(); + const older = await grant(input, "A"); + const newer = await grant(input, "A"); + await db.update(connectionGrants).set({ createdAt: new Date("2026-01-01") }).where(eq(connectionGrants.id, older.id)); + await db.update(connectionGrants).set({ createdAt: new Date("2026-02-01") }).where(eq(connectionGrants.id, newer.id)); + vault.resolveUserSecretValue.mockRejectedValueOnce(new Error("secret provider failed")); + expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({ status: "available", grantId: older.id }); + }); + it("uses one stable grant when the same person connects the same GitHub account twice", async () => { const input = await seed(); const first = await grant(input, "A"); diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 8592c686b0..b14ba118ae 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -194,6 +194,7 @@ describe("resolveExecutionRunAdapterConfig", () => { environmentId: "environment-1", environmentEnv: { PAPERCLIP_API_KEY: "environment-api-key", + PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled", PAPERCLIP_CLOUD_PROVIDER_TOKEN_ENV: "environment-cloud", ENV_ONLY: "environment-only", }, @@ -250,6 +251,7 @@ describe("resolveExecutionRunAdapterConfig", () => { ROUTINE_ONLY: "routine-only", }); expect(JSON.stringify(result.resolvedConfig.env)).not.toContain("PAPERCLIP_API_KEY"); + expect(JSON.stringify(result.resolvedConfig.env)).not.toContain("PAPERCLIP_RUNNER_NETWORK_ACCESS"); }); it("skips project env resolution when the project has no bindings", async () => { diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 1a7fcc585f..ea2f3e10f0 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -5156,7 +5156,27 @@ describeEmbeddedPostgres("tool access service", () => { } }, 15_000); - it.each(["none", "event", "same-time-refresh"])("binds a managed GitHub identity and protects refresh from concurrent access changes (%s)", async (concurrentChange) => { + it("reports GitHub reauthorization for the viewer without borrowing another user's grant", async () => { + const company = await createCompany(db); + const [application] = await db.insert(toolApplications).values({ companyId: company.id, + name: "GitHub authorization fixture", type: "mcp_http", status: "active" }).returning(); + const [connection] = await db.insert(toolConnections).values({ companyId: company.id, + applicationId: application!.id, name: "GitHub authorization fixture", uid: randomUUID(), + transport: "mcp_remote", status: "active", enabled: true, credentialPolicy: "per_user", + createdByUserId: "A", config: { sourceTemplateKey: "github" }, + }).returning(); + await db.insert(connectionGrants).values(["A", "B"].map(user => ({ companyId: company.id, + connectionId: connection!.id, kind: "user" as const, subjectUserId: user, + status: user === "A" ? "revoked" as const : "active" as const, credentialSecretRefs: [], + }))); + const service = createTestToolAccessService(db); + expect((await service.getConnection(connection!.id, company.id, "A")).requiresReauthorization).toBe(true); + expect((await service.getConnection(connection!.id, company.id, "B")).requiresReauthorization).toBe(false); + expect((await service.listConnections(company.id, "A"))[0]?.requiresReauthorization).toBe(true); + expect((await service.listConnections(company.id, "B"))[0]?.requiresReauthorization).toBe(false); + }); + + it.each(["none", "event", "same-time-refresh", "one-conflict"])("binds a managed GitHub identity and protects refresh from concurrent access changes (%s)", async (concurrentChange) => { const company = await createCompany(db); const userId = `github-manager-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); @@ -5284,6 +5304,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.mocked(connector.setWebhookBinding).mockClear(); if (concurrentChange !== "none") { beforeRepositoryResponse = async () => { + if (concurrentChange === "one-conflict") beforeRepositoryResponse = async () => {}; const [latest] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id)); await db.update(connectionGrants).set({ providerTenant: { ...latest!.providerTenant, @@ -5298,6 +5319,15 @@ describeEmbeddedPostgres("tool access service", () => { }, } }).where(eq(connectionGrants.id, grant!.id)); }; + if (concurrentChange === "one-conflict") { + await expect(service.checkHealth(connected.connectionId, actor)) + .resolves.toMatchObject({ connection: { healthStatus: "ok" } }); + expect(connector.setWebhookBinding).toHaveBeenCalled(); + const [latest] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id)); + expect(latest?.status).toBe("active"); + expect(latest?.providerTenant?.github?.repositoryCount).toBe(3); + return; + } await expect(service.checkHealth(connected.connectionId, actor)) .rejects.toThrow("GitHub access changed during refresh. Try again."); const [latest] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id)); diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index b4686d973b..d6691d07ca 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -1487,6 +1487,62 @@ describeEmbeddedPostgres("tool gateway service", () => { expect(resolvedGrants).toHaveLength(3); }); + it.each([false, true])("reselects a duplicate only before GitHub dispatch (upstream failure: %s)", async (upstreamFailure) => { + const { company, agent, issue, run } = await createRunFixture(db); + const first = await createRemoteMcpToolFixture(db, company.id); + await db.update(toolApplications).set({ name: "Older GitHub" }).where(eq(toolApplications.id, first.application.id)); + const fixtures = [first, await createRemoteMcpToolFixture(db, company.id)]; + const grants = []; + for (const [index, { connection }] of fixtures.entries()) { + const secret = await secretService(db).create(company.id, { + provider: "local_encrypted", name: `GitHub ${index}`, key: `github.${randomUUID()}`, value: `token-${index}`, + }); + await db.insert(companySecretBindings).values({ companyId: company.id, secretId: secret.id, + targetType: "tool_connection", targetId: connection.id, configPath: "oauth.access_token" }); + await db.update(toolConnections).set({ authKind: "oauth", credentialSource: "paperclip_vault", + config: { ...connection.config, sourceTemplateKey: "github" }, + }).where(eq(toolConnections.id, connection.id)); + await db.insert(toolConnectionInstalls).values({ companyId: company.id, + connectionId: connection.id, targetType: "agent", targetId: agent.id }); + const [grant] = await db.insert(connectionGrants).values({ companyId: company.id, + connectionId: connection.id, kind: "agent", subjectAgentId: agent.id, status: "active", + createdAt: new Date(index === 0 ? "2026-01-01" : "2026-02-01"), + credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token", versionSelector: "latest" }], + providerTenant: { github: { userId: "42", login: "octocat", installationCount: 1, + repositoryCount: 1, repositorySelection: "all", installationIds: ["101"] } }, + }).returning(); + grants.push(grant!); + } + await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id, + responsibleUserId: "A", cause: "instruction" }); + await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads", + policyType: "allow", selectors: { riskLevel: "read" } }); + const refreshed: string[] = []; + const dispatched = vi.fn(async (_url: string, init: RequestInit) => { + expect(new Headers(init.headers).get("authorization")).toBe(`Bearer token-${upstreamFailure ? 1 : 0}`); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: JSON.parse(String(init.body)).id, + result: { content: [{ type: "text", text: "ok" }] } }), + { status: upstreamFailure ? 500 : 200, headers: { "content-type": "application/json" } }); + }); + const gateway = createTestToolGatewayService(db, { + oauthGrantRefresher: async ({ grantId }) => { + refreshed.push(grantId); + if (!upstreamFailure && grantId === grants[1]!.id) { + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, grantId)); + throw new Error("refresh invalidated the selected authorization"); + } + return grants.find(grant => grant.id === grantId)!; + }, remoteHttpRequest: dispatched, + }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!; + const execution = gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }); + if (upstreamFailure) await expect(execution).rejects.toMatchObject({ status: 502 }); + else expect((await execution).status).toBe("completed"); + expect(dispatched).toHaveBeenCalledTimes(1); + expect(refreshed).toEqual(upstreamFailure ? [grants[1]!.id] : [grants[1]!.id, grants[0]!.id]); + }); + it("refreshes a customer OAuth grant once and retries after an upstream 401", async () => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index aada324715..9d6503320b 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -1598,7 +1598,7 @@ function connectorEnrollmentPrincipal(req: Request): string { assertBoard(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const connections = await svc.listConnections(companyId); + const connections = await svc.listConnections(companyId, req.actor.userId); const canManageConnections = await isToolConnectionManagerQuiet(req, companyId); res.json({ connections: filterVisibleToolConnections(connections, { @@ -1638,7 +1638,7 @@ function connectorEnrollmentPrincipal(req: Request): string { const connection = await getAccessibleResource( req, res, - svc.getConnection(req.params.connectionId as string), + svc.getConnection(req.params.connectionId as string, undefined, req.actor.userId), "Tool connection not found", ); if (!connection) return; diff --git a/server/src/services/git-credentials.ts b/server/src/services/git-credentials.ts index 5b4bb6b8ab..21899e5cef 100644 --- a/server/src/services/git-credentials.ts +++ b/server/src/services/git-credentials.ts @@ -51,6 +51,8 @@ export type GitCredential = { 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. */ @@ -272,6 +274,10 @@ export function createGitRemoteAuthProvider( 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", @@ -294,6 +300,7 @@ export async function resolveManagedGitHubIdentitySelection( responsibleUserId?: string | null; agentId?: string | null; allowStandingDelegation?: boolean; + excludeGrantId?: string; }, ): Promise<{ configured: boolean; @@ -400,14 +407,20 @@ export async function resolveManagedGitHubIdentitySelection( 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 + // 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 grant = [...candidates].sort((a, b) => + 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" }; @@ -463,81 +476,104 @@ export async function resolveManagedGitHubCredential( 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 acquire = async (selection: Awaited>) => { + 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 [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", - }, + 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; } } diff --git a/server/src/services/github-operation-credentials.ts b/server/src/services/github-operation-credentials.ts index 660232f643..79049c1dc7 100644 --- a/server/src/services/github-operation-credentials.ts +++ b/server/src/services/github-operation-credentials.ts @@ -10,6 +10,9 @@ export type GitHubCredentialSummary = { source?: "personal" | "dedicated"; login?: string; reason?: string; + connectionId?: string; + grantId?: string; + authenticationMode?: "managed" | "host" | "anonymous"; }; /** No company secrets or ambient credentials are consulted by this path. */ @@ -28,7 +31,7 @@ export async function resolveGitHubOperationCredentials(db: Db, input: { issueId: typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null, }); if (resolved.credential) { - summary = { status: "available", source: resolved.credential.identitySource, login: resolved.credential.githubIdentity?.login }; + summary = { status: "available", source: resolved.credential.identitySource, login: resolved.credential.githubIdentity?.login, connectionId: resolved.credential.connectionId, grantId: resolved.credential.grantId, authenticationMode: "managed" }; env = buildGitAuthInvocation(resolved.credential).env; } else { summary = { status: resolved.configured ? "unavailable" : "absent", source: resolved.identitySource ?? "personal", reason: resolved.error ?? "No GitHub identity connected" }; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 608321fd1b..1af976dd84 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -8,7 +8,7 @@ import { buildExecutionContinuation } from "./execution-continuation.js"; import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils"; import { initializeRunIdentity } from "./run-identity.js"; import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher"; -import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers, startAdapterExecutionTargetPaperclipBridge } from "@paperclipai/adapter-utils/execution-target"; +import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers, prepareGitHubExecutionEnvironment, startAdapterExecutionTargetPaperclipBridge } from "@paperclipai/adapter-utils/execution-target"; import { agentService } from "./agents.js"; import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils"; import fs from "node:fs/promises"; @@ -115,6 +115,7 @@ import { incrementToolRuntimeMetricCounter } from "./tool-runtime-metrics.js"; import { logger } from "../middleware/logger.js"; import { createGitRemoteAuthProvider, + resolveManagedGitHubIdentitySelection, describeGitAuthFailure, filterResolvedGitHubConnectionsForRun, scrubGitCredentialText, @@ -1261,7 +1262,12 @@ const LOW_TRUST_SENSITIVE_ENV_KEY_RE = // binding; adapters enforce this at env-merge time. // 3. Any other PAPERCLIP_*-named binding is user data and flows through to // the run env like any non-prefixed binding. -const FORBIDDEN_ENV_BINDING_KEYS = new Set(["PAPERCLIP_API_KEY"]); +const FORBIDDEN_ENV_BINDING_KEYS = new Set([ + "PAPERCLIP_RUNNER_NETWORK_ACCESS", "PAPERCLIP_RUNNER_NETWORK_ROOTS", + "PAPERCLIP_API_KEY", "PAPERCLIP_GITHUB_AUTH_MODE", "PAPERCLIP_GITHUB_HOST_HOME", + "PAPERCLIP_GIT_METADATA_ROOTS", "PAPERCLIP_GITHUB_BROKER_TOKEN", "PAPERCLIP_GITHUB_BROKER_URL", + "PAPERCLIP_GITHUB_BRIDGE_TOKEN", "PAPERCLIP_GITHUB_LAUNCHER_DIR", +]); const MANAGED_GITHUB_TOKEN_KEYS = new Set([ "GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "PAPERCLIP_GIT_TOKEN", ]); @@ -18299,9 +18305,14 @@ export function heartbeatService( !acceptedPlanWakeRoutingDecision?.suppressAcceptedContinuation ? [...runScopedMentionedSkillKeys, ACCEPTED_PLAN_CONVERSION_SKILL_KEY] : runScopedMentionedSkillKeys; + const githubSelection = await resolveManagedGitHubIdentitySelection(db, agent.companyId, { + agentId: agent.id, responsibleUserId, allowStandingDelegation: false, + }); + const useHostGitHub = !githubSelection.configured && trustPreset.kind === "standard" + && ["local", "ssh"].includes(selectedEnvironmentForConfig?.driver ?? "local"); const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({ - managedGitHubCredentials: true, + managedGitHubCredentials: !useHostGitHub, companyId: agent.companyId, agentId: agent.id, adapterType: agent.adapterType, @@ -19264,19 +19275,33 @@ export function heartbeatService( } else { delete context.paperclipScratch; } - const githubBrokerToken = createRuntimeToolsToken({ - agentId: agent.id, companyId: agent.companyId, runId: run.id, - responsibleUserId: responsibleUserId ?? "", scope: "github_credentials", + const gitExecutionEnv = await prepareGitHubExecutionEnvironment({ + target: executionTarget, cwd: executionWorkspace.cwd, + env: Object.fromEntries(Object.entries(parseObject(runtimeConfig.env)).filter((entry): entry is [string, string] => typeof entry[1] === "string")), + hostCredentials: useHostGitHub, + // Networking is a controller-owned trust decision, independent of + // whether GitHub is configured or a credential can be acquired. + networkAccess: trustPreset.kind === "standard" + && process.env.PAPERCLIP_RUNNER_NETWORK_ACCESS !== "disabled", }); - const githubBrokerEnv = githubBrokerEnvironment(parseObject(runtimeConfig.env), { - url: configuredPaperclipApiBaseUrl() ?? "", token: githubBrokerToken?.token ?? "", - }); - githubLauncherLocation = { runId: run.id, target: executionTarget }; - runtimeConfig = { ...runtimeConfig, env: await prepareGitHubOperationLaunchers({ - runId: run.id, target: executionTarget, cwd: executionWorkspace.cwd, - env: githubBrokerEnv, - }) }; - secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN"); + runtimeConfig = { ...runtimeConfig, env: gitExecutionEnv }; + for (const key of MANAGED_GITHUB_TOKEN_KEYS) secretKeys.add(key); + context.githubAuthenticationMode = useHostGitHub ? "host" : "managed"; + if (!useHostGitHub) { + const githubBrokerToken = createRuntimeToolsToken({ + agentId: agent.id, companyId: agent.companyId, runId: run.id, + responsibleUserId: responsibleUserId ?? "", scope: "github_credentials", + }); + const githubBrokerEnv = githubBrokerEnvironment(gitExecutionEnv, { + url: configuredPaperclipApiBaseUrl() ?? "", token: githubBrokerToken?.token ?? "", + }); + githubLauncherLocation = { runId: run.id, target: executionTarget }; + runtimeConfig = { ...runtimeConfig, env: await prepareGitHubOperationLaunchers({ + runId: run.id, target: executionTarget, cwd: executionWorkspace.cwd, + env: githubBrokerEnv, + }) }; + secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN"); + } context.paperclipEnvironment = { id: selectedEnvironment.id, name: selectedEnvironment.name, diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 6160a1038c..8ad9d03380 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -3404,11 +3404,18 @@ describe("native warm session supervision", () => { expect(close).not.toHaveBeenCalled(); }); - it.each([false, true].flatMap((useBroker) => + it.each([...[false, true].flatMap((useBroker) => [false, true].flatMap((projectless) => - [false, true].map((local) => ({ useBroker, projectless, local })), + [false, true].map((local) => ({ useBroker, projectless, local, firstMode: "host", secondMode: "host" })), ), - ))("verifies a live warm owner before refreshing run authority (broker: $useBroker, projectless: $projectless, local: $local)", async ({ useBroker, projectless, local }) => { + ), ...["host", "managed"].flatMap((firstMode) => [false, true].map((local) => ({ + useBroker: false, projectless: false, local, firstMode, + secondMode: firstMode === "host" ? "managed" : "host", + }))), ...[false, true].flatMap((local) => [ + { useBroker: false, projectless: false, local, firstMode: "host", secondMode: "host", firstNetwork: "enabled", secondNetwork: "disabled" }, + { useBroker: false, projectless: false, local, firstMode: "managed", secondMode: "managed", firstNetwork: "disabled", secondNetwork: "enabled" }, + ])].map((scenario) => ({ firstNetwork: "disabled", secondNetwork: "disabled", ...scenario })))("verifies a live warm owner before refreshing run authority (broker: $useBroker, projectless: $projectless, local: $local, auth: $firstMode -> $secondMode, network: $firstNetwork -> $secondNetwork)", async ({ useBroker, projectless, local, firstMode, secondMode, firstNetwork, secondNetwork }) => { + const replacesProvider = useBroker || firstMode !== secondMode || firstNetwork !== secondNetwork; const stateBase = await mkdtemp( join(tmpdir(), "paperclip-runnerd-warm-authority-"), ); @@ -3489,7 +3496,7 @@ describe("native warm session supervision", () => { return result; }) .mockImplementationOnce(async (options) => { - if (useBroker) { + if (replacesProvider) { expect(options.existingSession).toBeUndefined(); expect(options.persistedSession?.providerSessionId).toBe("provider-runnerd-warm"); expect(options.persistedSession?.semanticResult).toBeNull(); @@ -3506,7 +3513,7 @@ describe("native warm session supervision", () => { await executePaperclipNativeSession({ db: leaseDb(first), execution: first, - runnerEnvironment: useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "first-run-capability" } : undefined, + runnerEnvironment: { PAPERCLIP_GITHUB_AUTH_MODE: firstMode, PAPERCLIP_RUNNER_NETWORK_ACCESS: firstNetwork, ...(useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "first-run-capability" } : {}) }, runnerInstanceId: "runner-runnerd-warm", useRunnerd: true, runnerExecutionTarget: remoteTarget, @@ -3577,12 +3584,12 @@ describe("native warm session supervision", () => { await executePaperclipNativeSession({ db: continuationDb, execution: second, - runnerEnvironment: useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "second-run-capability" } : undefined, + runnerEnvironment: { PAPERCLIP_GITHUB_AUTH_MODE: secondMode, PAPERCLIP_RUNNER_NETWORK_ACCESS: secondNetwork, ...(useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "second-run-capability" } : {}) }, runnerInstanceId: "runner-runnerd-warm", useRunnerd: true, runnerExecutionTarget: remoteTarget, }); - if (useBroker) { + if (replacesProvider) { expect(firstClose).toHaveBeenCalledOnce(); expect(firstClose).toHaveBeenCalledWith({ reason: "warm native session configuration changed" }); } else { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 0b743be2f5..097f26221f 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -273,6 +273,8 @@ function clearNativeRuntimeRequestResolutions(runId: string): void { type WarmNativeSession = { credentialRunId?: string; + githubAuthenticationMode?: string; + networkAccess: boolean; session: NativeSession; ownerToken: symbol; configDigest: string; @@ -4727,7 +4729,9 @@ async function executePaperclipNativeSessionWithinScope( // settled provider checkpoint retains the conversation across runs. const credentialRunChanged = Boolean(input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN) && entry.credentialRunId !== input.execution.binding.runId; - if (entry.configDigest !== warmConfigDigest || credentialRunChanged) { + if (entry.configDigest !== warmConfigDigest || credentialRunChanged + || entry.githubAuthenticationMode !== input.runnerEnvironment?.PAPERCLIP_GITHUB_AUTH_MODE + || entry.networkAccess !== (input.runnerEnvironment?.PAPERCLIP_RUNNER_NETWORK_ACCESS === "enabled")) { if (entry.busy) throw new Error("native_session_supervisor_busy"); if (entry.idleTimer !== null) clearTimeout(entry.idleTimer); warmNativeSessions.delete(warmSessionId); @@ -4982,6 +4986,8 @@ async function executePaperclipNativeSessionWithinScope( existing.session = session; } else warmNativeSessions.set(warmSessionId, { + githubAuthenticationMode: input.runnerEnvironment?.PAPERCLIP_GITHUB_AUTH_MODE, + networkAccess: input.runnerEnvironment?.PAPERCLIP_RUNNER_NETWORK_ACCESS === "enabled", credentialRunId: input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN ? input.execution.binding.runId : undefined, session, diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 2ff558391c..2ac3bdbf7c 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -5435,6 +5435,22 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return localTools(connection); } + async function annotateGitHubAuthorization(connections: ToolConnection[], viewerUserId?: string) { + const github = connections.filter((connection) => asRecord(connection.config).sourceTemplateKey === "github"); + if (!github.length) return; + const grants = await db.select({ connectionId: connectionGrants.connectionId, status: connectionGrants.status, kind: connectionGrants.kind, subjectUserId: connectionGrants.subjectUserId }) + .from(connectionGrants).where(and( + eq(connectionGrants.companyId, github[0].companyId), + inArray(connectionGrants.connectionId, github.map((connection) => connection.id)), + )); + for (const connection of github) { + const userId = viewerUserId ?? connection.createdByUserId; + const eligible = grants.filter((grant) => grant.connectionId === connection.id + && (connection.credentialPolicy !== "per_user" || (grant.kind === "user" && grant.subjectUserId === userId))); + connection.requiresReauthorization = !eligible.some((grant) => grant.status === "active"); + } + } + async function updateConnectionHealth( connection: typeof toolConnections.$inferSelect, status: ToolConnectionHealthStatus, @@ -5517,6 +5533,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} }); return { connection: toConnection(updated), runtimeSlot }; } catch (error) { + if (error instanceof HttpError && asRecord(error.details).code === "github_access_changed") throw error; const failure = sanitizeHttpFailure(error); const updated = await updateConnectionHealth(connection, failure.status, failure.message); const runtimeSlot = connection.transport === "local_stdio" ? await ensureRuntimeSlot(updated) : null; @@ -5557,6 +5574,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} try { descriptors = await discoverTools(connection, refreshOptions.credentialHeaders, actor); } catch (error) { + if (error instanceof HttpError && asRecord(error.details).code === "github_access_changed") throw error; const failure = sanitizeHttpFailure(error); const updated = await updateConnectionHealth(connection, failure.status, failure.message); await audit({ @@ -8594,6 +8612,24 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connection: typeof toolConnections.$inferSelect, initialGrant: typeof connectionGrants.$inferSelect, actor?: ActorInfo, + ) { + try { + return await refreshManagedGitHubGrantAccessOnce(connection, initialGrant, actor); + } catch (error) { + if (!(error instanceof HttpError) || asRecord(error.details).code !== "github_access_changed") throw error; + const [current] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.id, initialGrant.id), eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + )); + if (!current || current.status !== "active") throw error; + return refreshManagedGitHubGrantAccessOnce(connection, current, actor); + } + } + + async function refreshManagedGitHubGrantAccessOnce( + connection: typeof toolConnections.$inferSelect, + initialGrant: typeof connectionGrants.$inferSelect, + actor?: ActorInfo, ) { let grant = await refreshOAuthGrantCredentials({ companyId: connection.companyId, @@ -12495,7 +12531,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return { repositories: [...repositories.values()].sort((a, b) => a.fullName.localeCompare(b.fullName)), connectionCount, failedConnectionCount }; }, - listConnections: async (companyId: string): Promise => { + listConnections: async (companyId: string, viewerUserId?: string): Promise => { const rows = await db .select() .from(toolConnections) @@ -12539,6 +12575,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} for (const connection of connections) { connection.lastUsedAt = lastUsedByConnection.get(connection.id) ?? null; } + await annotateGitHubAuthorization(connections, viewerUserId); return connections; }, @@ -12616,9 +12653,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return toConnection(row); }, - getConnection: async (connectionId: string, companyId?: string): Promise => { + getConnection: async (connectionId: string, companyId?: string, viewerUserId?: string): Promise => { const connection = toConnection(await getConnectionRow(connectionId, companyId)); connection.installs = await listConnectionInstalls(connection.id, connection.companyId); + await annotateGitHubAuthorization([connection], viewerUserId); return connection; }, diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 2a484d2789..b12801f189 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -894,6 +894,12 @@ export function createToolGatewayService( const interactions = issueThreadInteractionService(db); const policyService = toolAccessPolicyService(db); const secrets = secretService(db); + // Authentication produces a new session object for every operation. Keep + // credential acquisition scoped to that object and out of persisted inputs. + const githubOperationCredentials = new WeakMap; + }>(); const configuredCloudConnector = options.paperclipCloudConnector ?? options.paperclipIdGmailConnector; const connectorWasProvided = options.paperclipCloudConnector !== undefined || options.paperclipIdGmailConnector !== undefined; let cachedCloudConnector = configuredCloudConnector ?? null; @@ -2066,14 +2072,42 @@ export function createToolGatewayService( eq(toolConnections.id, tool.connectionId), eq(toolConnections.companyId, session.companyId), )); if (connection?.config.sourceTemplateKey === "github" || connection?.transportConfig?.sourceTemplateKey === "github") { - const selected = await resolveManagedGitHubIdentitySelection(db, session.companyId, { + let selected = await resolveManagedGitHubIdentitySelection(db, session.companyId, { agentId: session.agentId, responsibleUserId: session.responsibleUserId, allowStandingDelegation: false, }); if (!selected.grant) throw new ToolGatewayHttpError(409, selected.error ?? "No GitHub identity connected", "github_identity_unavailable"); - const target = connectedTools.find((candidate) => candidate.connectionId === selected.grant!.connectionId - && candidate.upstreamToolName === tool.upstreamToolName && candidate.providerType === tool.providerType); - if (!target) throw new ToolGatewayHttpError(404, "This GitHub tool is unavailable for the responsible person", "github_tool_unavailable"); - return target; + const original = selected.grant; + // Acquire before policy evaluation or dispatch. An alternate connection + // gets its own catalog descriptor and policy checks; never replay a call. + for (let attempt = 0; attempt < 2; attempt += 1) { + const grant = selected.grant!; + const target = connectedTools.find((candidate) => candidate.connectionId === grant.connectionId + && candidate.upstreamToolName === tool.upstreamToolName && candidate.providerType === tool.providerType); + if (!target) throw new ToolGatewayHttpError(404, "This GitHub tool is unavailable for the responsible person", "github_tool_unavailable"); + const [selectedConnection] = await db.select().from(toolConnections).where(and( + eq(toolConnections.id, grant.connectionId), eq(toolConnections.companyId, session.companyId), + )); + if (!selectedConnection) throw new ToolGatewayHttpError(409, "GitHub connection is unavailable", "github_identity_unavailable"); + if (attempt === 0) await resolveConnectionGrant(session, selectedConnection); + try { + const headers = await resolveCredentialHeaders(session, selectedConnection, grant); + githubOperationCredentials.set(session, { grant, headers }); + return target; + } catch (error) { + if (attempt !== 0) throw error; + const alternate = await resolveManagedGitHubIdentitySelection(db, session.companyId, { + agentId: session.agentId, responsibleUserId: session.responsibleUserId, + allowStandingDelegation: false, excludeGrantId: original.id, + }); + const accountId = original.providerTenant?.github?.userId; + if (!accountId || !alternate.grant + || alternate.grant.providerTenant?.github?.userId !== accountId + || alternate.grant.subjectUserId !== original.subjectUserId + || alternate.grant.subjectAgentId !== original.subjectAgentId) throw error; + selected = alternate; + } + } + throw new ToolGatewayHttpError(409, "GitHub credentials are unavailable", "github_identity_unavailable"); } } return tool; @@ -2838,10 +2872,14 @@ export function createToolGatewayService( const tracked = session.identityContextId && (connection.config.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github"); try { - const headers = await resolveCredentialHeadersUnrecorded(session, connection, grant, resolveOptions); + const captured = githubOperationCredentials.get(session); + const headers = !resolveOptions.forceRefresh && captured?.grant.id === grant.id + ? captured.headers + : await resolveCredentialHeadersUnrecorded(session, connection, grant, resolveOptions); if (tracked) await db.update(runIdentityContexts).set({ github: { status: "available", login: grant.providerTenant?.github?.login, source: grant.kind === "agent" ? "dedicated" : "personal", + connectionId: connection.id, grantId: grant.id, authenticationMode: "managed", } }).where(and(eq(runIdentityContexts.id, session.identityContextId!), eq(runIdentityContexts.companyId, session.companyId))); return headers; } catch (error) { @@ -3211,9 +3249,12 @@ export function createToolGatewayService( if (session.identityContextId && session.agentId && ( connection.config.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github" )) { - const selected = await resolveManagedGitHubIdentitySelection(db, session.companyId, { - agentId: session.agentId, responsibleUserId: session.responsibleUserId, allowStandingDelegation: false, - }); + const captured = githubOperationCredentials.get(session); + const selected = captured + ? { grant: captured.grant, error: undefined } + : await resolveManagedGitHubIdentitySelection(db, session.companyId, { + agentId: session.agentId, responsibleUserId: session.responsibleUserId, allowStandingDelegation: false, + }); if (!selected.grant || selected.grant.connectionId !== connection.id) { throw new ToolGatewayHttpError(409, selected.error ?? "GitHub identity changed; retry through the managed tool", "github_identity_unavailable"); } diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 044806fddb..2dc27c6dfd 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -1172,6 +1172,17 @@ describe("AppDetail", () => { expect(container.textContent).toContain("Which agents can use this connection?"); }); + it("offers retry for a transient GitHub error without asking for another login", async () => { + mockParams.tab = "permissions"; + getConnectionMock.mockResolvedValue(connection({ + authKind: "oauth", healthStatus: "error", requiresReauthorization: false, + healthMessage: "GitHub access changed during refresh. Try again.", + })); + await renderAppDetail(); + expect(container.textContent).toContain("Retry access"); + expect(container.textContent).not.toContain("Reconnect required"); + }); + it("shows terminal OAuth failures as reconnect-required sign-in", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index bde7e92ba9..93b6972675 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -149,7 +149,7 @@ export function AppDetail() { grant.kind === "organization" && grant.isDefault )) ?? grantRows.find((grant) => grant.kind === "organization") ?? null; const managedIdentityGrant = connection?.credentialPolicy === "per_user" - ? retainedPersonalGrant + ? currentUserPersonalGrant ?? retainedPersonalGrant : connection?.credentialPolicy === "per_agent" ? retainedAgentGrant : connection?.credentialPolicy === "per_user_with_fallback" @@ -476,7 +476,8 @@ export function AppDetail() { } const status = statusFor(connection); - const needsReconnect = status.tone === "attention" && connection.healthStatus !== "unknown"; + const needsReconnect = connection.requiresReauthorization + ?? (status.tone === "attention" && connection.healthStatus !== "unknown"); const quarantined = catalog.filter((e) => e.status === "quarantined"); const active = catalog.filter((e) => e.status === "active"); const readOnly = active.filter((e) => e.isReadOnly); @@ -512,6 +513,14 @@ export function AppDetail() { }} /> + {status.tone === "attention" && connection.requiresReauthorization === false && ( +
+

{connection.healthMessage || "GitHub access could not be checked. Try again."}

+ +
+ )} {needsReconnect && ( onNavigate(actionHref)} > - {state.kind === "attention" ? "Reconnect" : "Finish setup"} + {state.kind === "attention" ? connection.requiresReauthorization === false ? "Retry access" : "Reconnect" : "Finish setup"} ) : null}