diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index b4f621d6c03..9bca6f360e9 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -442,6 +442,168 @@ describe("resolveExecutionRunAdapterConfig", () => { ]); }); + it("passes with an ambient gh credential without injecting a token into the run env", async () => { + const probe = vi.fn().mockResolvedValue("gh_cli"); + const getByNameInsensitive = vi.fn(); + const resolveInjectedEnvBindingForRuntime = vi.fn(); + + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + executionRunConfig: { env: { AGENT_ONLY: "agent-only" } }, + projectEnv: null, + requiredScopedEnvBinding: { + keys: ["GH_TOKEN", "GITHUB_TOKEN"], + consumerScopes: ["agent", "project", "environment", "routine"], + reason: "push_write_credential_missing", + remediation: "No GitHub credential found.", + fallbackSecretNames: ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"], + fallbackInjectionEnvKey: "GH_TOKEN", + ambientHostCredential: { + enabled: true, + checkoutCwd: "/checkout", + probe, + }, + }, + secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn().mockResolvedValue({ + config: { env: { AGENT_ONLY: "agent-only" } }, + secretKeys: new Set(), + manifest: [], + }), + resolveEnvBindings: vi.fn(), + resolveInjectedEnvBindingForRuntime, + getByNameInsensitive, + collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]), + } as any, + }); + + expect(probe).toHaveBeenCalledWith("/checkout"); + expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" }); + expect(result.secretKeys).toEqual(new Set()); + expect(getByNameInsensitive).not.toHaveBeenCalled(); + expect(resolveInjectedEnvBindingForRuntime).not.toHaveBeenCalled(); + }); + + it.each([ + ["timeout", Object.assign(new Error("timed out"), { code: "ETIMEDOUT" })], + ["non-zero exit", Object.assign(new Error("exited 1"), { code: 1 })], + ])("falls through to the company-secret tier after an ambient gh probe %s", async (_case, probeError) => { + const probe = vi.fn().mockRejectedValue(probeError); + const resolveInjectedEnvBindingForRuntime = vi.fn().mockResolvedValue({ + env: { GH_TOKEN: "resolved-token" }, + secretKeys: new Set(["GH_TOKEN"]), + manifest: [], + }); + + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + executionRunConfig: { env: {} }, + projectEnv: null, + requiredScopedEnvBinding: { + keys: ["GH_TOKEN", "GITHUB_TOKEN"], + consumerScopes: ["agent", "project", "environment", "routine"], + reason: "push_write_credential_missing", + remediation: "No GitHub credential found.", + fallbackSecretNames: ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"], + fallbackInjectionEnvKey: "GH_TOKEN", + ambientHostCredential: { enabled: true, checkoutCwd: "/checkout", probe }, + }, + secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn().mockResolvedValue({ + config: { env: {} }, + secretKeys: new Set(), + manifest: [], + }), + resolveEnvBindings: vi.fn(), + resolveInjectedEnvBindingForRuntime, + getByNameInsensitive: vi.fn().mockResolvedValue({ id: "secret-1", name: "GH_TOKEN" }), + collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]), + } as any, + }); + + expect(probe).toHaveBeenCalledOnce(); + expect(result.resolvedConfig.env).toEqual({ GH_TOKEN: "resolved-token" }); + expect(resolveInjectedEnvBindingForRuntime).toHaveBeenCalledOnce(); + }); + + it("passes with an SSH-style push remote and no injected token", async () => { + const probe = vi.fn().mockResolvedValue("ssh_push_remote"); + const resolveInjectedEnvBindingForRuntime = vi.fn(); + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + executionRunConfig: { env: {} }, + projectEnv: null, + requiredScopedEnvBinding: { + keys: ["GH_TOKEN", "GITHUB_TOKEN"], + consumerScopes: ["agent", "project", "environment", "routine"], + reason: "push_write_credential_missing", + remediation: "No GitHub credential found.", + fallbackSecretNames: ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"], + fallbackInjectionEnvKey: "GH_TOKEN", + ambientHostCredential: { enabled: true, checkoutCwd: "/checkout", probe }, + }, + secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn().mockResolvedValue({ + config: { env: {} }, + secretKeys: new Set(), + manifest: [], + }), + resolveEnvBindings: vi.fn(), + resolveInjectedEnvBindingForRuntime, + getByNameInsensitive: vi.fn(), + collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]), + } as any, + }); + + expect(result.resolvedConfig.env).toEqual({}); + expect(resolveInjectedEnvBindingForRuntime).not.toHaveBeenCalled(); + }); + + it("skips the ambient tier for a remote execution target", async () => { + const probe = vi.fn(); + const resolveInjectedEnvBindingForRuntime = vi.fn().mockResolvedValue({ + env: { GH_TOKEN: "resolved-token" }, + secretKeys: new Set(["GH_TOKEN"]), + manifest: [], + }); + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + executionRunConfig: { env: {} }, + projectEnv: null, + requiredScopedEnvBinding: { + keys: ["GH_TOKEN", "GITHUB_TOKEN"], + consumerScopes: ["agent", "project", "environment", "routine"], + reason: "push_write_credential_missing", + remediation: "No GitHub credential found.", + fallbackSecretNames: ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"], + fallbackInjectionEnvKey: "GH_TOKEN", + ambientHostCredential: { enabled: false, checkoutCwd: "/checkout", probe }, + }, + secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn().mockResolvedValue({ + config: { env: {} }, + secretKeys: new Set(), + manifest: [], + }), + resolveEnvBindings: vi.fn(), + resolveInjectedEnvBindingForRuntime, + getByNameInsensitive: vi.fn().mockResolvedValue({ id: "secret-1", name: "GH_TOKEN" }), + collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]), + } as any, + }); + + expect(probe).not.toHaveBeenCalled(); + expect(result.resolvedConfig.env).toEqual({ GH_TOKEN: "resolved-token" }); + }); + it("does not accept a lowercase GitHub credential binding key", async () => { await expect(resolveExecutionRunAdapterConfig({ companyId: "company-1", diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 963c10ad2fb..dc201887ae1 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -22,6 +22,7 @@ import { mergeExecutionWorkspaceMetadataForPersistence, mergeCoalescedContextSnapshot, preflightLowTrustWorkspaceIsolation, + probeAmbientHostPushCredential, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, provisionExecutionWorkspaceForFreshnessDecision, @@ -36,6 +37,7 @@ import { resolveWorkspaceAfterLowTrustPreflight, resolveRuntimeSessionParamsForWorkspace, shouldDeferFollowupWakeForSameIssue, + shouldProbeAmbientHostPushCredential, stripHostWorkspaceProvisionForLowTrustSandbox, stripWorkspaceRuntimeFromExecutionRunConfig, shouldResetTaskSessionForModelChange, @@ -972,6 +974,84 @@ describe("assertPushCapabilityCheckoutValid", () => { }); }); +describe("probeAmbientHostPushCredential", () => { + it("uses the host gh auth store without passing an injected environment", async () => { + const execFileMock = vi.fn().mockResolvedValue({ stdout: "gho_ambient\n", stderr: "" }); + + await expect(probeAmbientHostPushCredential("/checkout", { + execFile: execFileMock as unknown as typeof execFile, + })).resolves.toBe("gh_cli"); + + expect(execFileMock).toHaveBeenCalledOnce(); + expect(execFileMock).toHaveBeenCalledWith( + "gh", + ["auth", "token", "--hostname", "github.com"], + expect.objectContaining({ timeout: 2_000 }), + ); + expect(execFileMock.mock.calls[0]?.[2]).not.toHaveProperty("env"); + }); + + it.each([ + "ssh://git@github.com/example/repo.git", + "git@github.com:example/repo.git", + ])("accepts SSH push remote %s after the gh probe misses", async (pushUrl) => { + const execFileMock = vi.fn(async (command: string, args: string[]) => { + if (command === "gh") throw Object.assign(new Error("not logged in"), { code: 1 }); + if (args[0] === "remote" && args.length === 1) return { stdout: "origin\n", stderr: "" }; + return { stdout: `${pushUrl}\n`, stderr: "" }; + }); + + await expect(probeAmbientHostPushCredential("/checkout", { + execFile: execFileMock as unknown as typeof execFile, + })).resolves.toBe("ssh_push_remote"); + expect(execFileMock.mock.calls[0]?.[0]).toBe("gh"); + expect(execFileMock.mock.calls[2]?.[1]).toEqual(["remote", "get-url", "--push", "origin"]); + }); + + it("treats gh and git probe failures as a tier miss", async () => { + const execFileMock = vi.fn().mockRejectedValue(Object.assign(new Error("timed out"), { + code: "ETIMEDOUT", + })); + + await expect(probeAmbientHostPushCredential("/checkout", { + execFile: execFileMock as unknown as typeof execFile, + })).resolves.toBeNull(); + }); +}); + +describe("shouldProbeAmbientHostPushCredential", () => { + it.each(["ssh", "sandbox", "plugin"])("skips the ambient host tier for %s execution", (driver) => { + expect(shouldProbeAmbientHostPushCredential({ + pushCapabilityPreflightRequired: true, + environmentDriver: driver, + })).toBe(false); + }); + + it("enables the ambient host tier for a standard local execution target", () => { + expect(shouldProbeAmbientHostPushCredential({ + pushCapabilityPreflightRequired: true, + environmentDriver: "local", + })).toBe(true); + }); + + it("skips the host tier when low-trust preflight will redirect local selection to a sandbox", () => { + expect(shouldProbeAmbientHostPushCredential({ + pushCapabilityPreflightRequired: true, + environmentDriver: "local", + trustPreset: { + kind: "low_trust_review", + preset: "low_trust_review", + boundary: { + mode: "low_trust_review", + companyId: "company-1", + issueIds: ["issue-1"], + }, + sourcePresets: {}, + }, + })).toBe(false); + }); +}); + describe("requiresPushCapabilityPreflight", () => { it("only enables the guard when the issue explicitly mentions the GitHub PR workflow skill", () => { expect(requiresPushCapabilityPreflight({ diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f34fddd8a2c..a40c3a16f5d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -463,6 +463,7 @@ const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE = "execution_review_participan const GITHUB_PR_WORKFLOW_SKILL_KEY = "paperclipai/bundled/software-development/github-pr-workflow"; const GITHUB_PR_WORKFLOW_SKILL_SLUG = "github-pr-workflow"; const PUSH_CAPABILITY_ENV_KEYS = ["GH_TOKEN", "GITHUB_TOKEN"] as const; +const AMBIENT_GITHUB_CREDENTIAL_PROBE_TIMEOUT_MS = 2_000; // Keep this in sync with local adapters that require a git workspace before launch. const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([ "claude_local", @@ -841,6 +842,72 @@ function hasGithubPrWorkflowSkill(desiredSkills: string[]) { }); } +export type AmbientHostPushCredential = "gh_cli" | "ssh_push_remote"; + +/** + * Probe credentials that are already available to a local execution host without + * copying them into the dispatched run. In particular, do not pass the resolved + * run env to `gh`: an injected GH_TOKEN would override a working hosts.yml login. + */ +export async function probeAmbientHostPushCredential( + cwd: string | null | undefined, + deps: { execFile: typeof execFile } = { execFile }, +): Promise { + try { + const result = await deps.execFile( + "gh", + ["auth", "token", "--hostname", "github.com"], + { timeout: AMBIENT_GITHUB_CREDENTIAL_PROBE_TIMEOUT_MS, windowsHide: true }, + ); + if (readNonEmptyString(result.stdout)) return "gh_cli"; + } catch { + // A missing CLI, timeout, non-zero exit, or unreadable auth state is a tier miss. + } + + const normalizedCwd = readNonEmptyString(cwd); + if (!normalizedCwd) return null; + const remoteNames = await deps.execFile( + "git", + ["remote"], + { + cwd: normalizedCwd, + timeout: AMBIENT_GITHUB_CREDENTIAL_PROBE_TIMEOUT_MS, + windowsHide: true, + }, + ).then((result) => + result.stdout + .split(/\r?\n/) + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ).catch(() => []); + + for (const remoteName of remoteNames) { + const pushUrl = await deps.execFile( + "git", + ["remote", "get-url", "--push", remoteName], + { + cwd: normalizedCwd, + timeout: AMBIENT_GITHUB_CREDENTIAL_PROBE_TIMEOUT_MS, + windowsHide: true, + }, + ).then((result) => readNonEmptyString(result.stdout)).catch(() => null); + if (pushUrl && (/^ssh:\/\//i.test(pushUrl) || /^[^\s/@:]+@[^\s/:]+:.+/.test(pushUrl))) { + return "ssh_push_remote"; + } + } + return null; +} + +export function shouldProbeAmbientHostPushCredential(input: { + pushCapabilityPreflightRequired: boolean; + environmentDriver: string | null | undefined; + trustPreset?: TrustPresetResolution; +}) { + return input.pushCapabilityPreflightRequired + && input.trustPreset?.kind !== "low_trust_review" + && !isRemoteExecutionEnvironmentDriver(input.environmentDriver); +} + export function requiresPushCapabilityPreflight(input: { adapterType: string; issueId: string | null | undefined; @@ -922,6 +989,11 @@ export async function resolveExecutionRunAdapterConfig(input: { remediation: string; fallbackSecretNames?: readonly string[]; fallbackInjectionEnvKey?: string; + ambientHostCredential?: { + enabled: boolean; + checkoutCwd?: string | null; + probe?: typeof probeAmbientHostPushCredential; + }; }; }) { const executionRunConfig = stripForbiddenEnvFromAdapterConfig(input.executionRunConfig); @@ -954,6 +1026,13 @@ export async function resolveExecutionRunAdapterConfig(input: { && isConfiguredEnvBindingValue(routineEnv?.[key]) )) : false; + const ambientHostCredential = requiredScopedEnvBinding + && !requiredScopedBindingsConfigured + && requiredScopedEnvBinding.ambientHostCredential?.enabled + ? await ( + requiredScopedEnvBinding.ambientHostCredential.probe ?? probeAmbientHostPushCredential + )(requiredScopedEnvBinding.ambientHostCredential.checkoutCwd).catch(() => null) + : null; let fallbackSecret: Awaited> | null = null; let fallbackAuthorization: { bindingId: string; @@ -964,6 +1043,7 @@ export async function resolveExecutionRunAdapterConfig(input: { if ( requiredScopedEnvBinding && !requiredScopedBindingsConfigured + && !ambientHostCredential && requiredScopedEnvBinding.fallbackSecretNames?.length && typeof input.secretsSvc.getByNameInsensitive === "function" ) { @@ -999,7 +1079,12 @@ export async function resolveExecutionRunAdapterConfig(input: { break; } } - if (requiredScopedEnvBinding && !requiredScopedBindingsConfigured && !fallbackSecret) { + if ( + requiredScopedEnvBinding + && !requiredScopedBindingsConfigured + && !ambientHostCredential + && !fallbackSecret + ) { throw new ConfigurationIncompleteFailure(`configuration incomplete: ${requiredScopedEnvBinding.remediation}`, { configurationIncomplete: { reason: requiredScopedEnvBinding.reason, @@ -1302,13 +1387,19 @@ export async function resolveExecutionRunAdapterConfig(input: { } } if (requiredScopedEnvBinding) { + const tier = requiredScopedBindingsConfigured + ? "T1_env_binding" + : ambientHostCredential + ? "T2_ambient_host_credential" + : "T3_well_known_company_secret"; logger.info( { companyId: input.companyId, agentId: input.agentId ?? null, issueId: input.issueId ?? null, heartbeatRunId: input.heartbeatRunId ?? null, - tier: fallbackSecret ? "T3_well_known_company_secret" : "T1_env_binding", + tier, + ambientCredentialKind: ambientHostCredential, selectedSecretName: fallbackSecret?.name ?? null, }, "Push-capability preflight satisfied", @@ -14677,6 +14768,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueId, explicitRunScopedSkillKeys: runScopedMentionedSkillKeys, }); + const ambientHostPushCredentialEnabled = shouldProbeAmbientHostPushCredential({ + pushCapabilityPreflightRequired, + environmentDriver: selectedEnvironmentForConfig?.driver ?? null, + trustPreset, + }); const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({ companyId: agent.companyId, agentId: agent.id, @@ -14699,10 +14795,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) keys: [...PUSH_CAPABILITY_ENV_KEYS], consumerScopes: ["agent", "project", "environment", "routine"], reason: "push_write_credential_missing", - remediation: - "GitHub PR workflow found no GH_TOKEN/GITHUB_TOKEN binding at agent, project, environment, or routine scope and no company secret named GITHUB_TOKEN, GH_TOKEN, or PAPERCLIP_GITHUB_TOKEN. Bind GH_TOKEN or GITHUB_TOKEN at one of those scopes, or configure one of the well-known company secrets.", + remediation: ambientHostPushCredentialEnabled + ? "GitHub PR workflow found no GH_TOKEN/GITHUB_TOKEN binding at agent, project, environment, or routine scope, no ambient gh/ssh credential on host, and no company secret named GITHUB_TOKEN, GH_TOKEN, or PAPERCLIP_GITHUB_TOKEN. Bind GH_TOKEN or GITHUB_TOKEN at one of those scopes, authenticate gh or configure an SSH push remote on the local host, or configure one of the well-known company secrets." + : "GitHub PR workflow found no GH_TOKEN/GITHUB_TOKEN binding at agent, project, environment, or routine scope and no company secret named GITHUB_TOKEN, GH_TOKEN, or PAPERCLIP_GITHUB_TOKEN. Bind GH_TOKEN or GITHUB_TOKEN at one of those scopes, or configure one of the well-known company secrets.", fallbackSecretNames: DEFAULT_GITHUB_TOKEN_SECRET_NAMES, fallbackInjectionEnvKey: "GH_TOKEN", + ambientHostCredential: { + enabled: ambientHostPushCredentialEnabled, + checkoutCwd: + reusableExistingExecutionWorkspace?.cwd + ?? executionRunConfig.cwd as string | null | undefined, + }, } : undefined, });