From 6d4664801c3f332f38691f5e6ed8a191860e9ccb Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 19 Aug 2026 15:06:48 +0000 Subject: [PATCH] Widen push preflight env scopes Co-Authored-By: Paperclip --- .../__tests__/heartbeat-project-env.test.ts | 140 +++++++++++++++++- server/src/services/heartbeat.ts | 14 +- 2 files changed, 143 insertions(+), 11 deletions(-) diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 0be33c0b4f2..3ec35f44acd 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -401,7 +401,7 @@ describe("resolveExecutionRunAdapterConfig", () => { }); }); - it("fails push-capability preflight when no GitHub write credential is bound at agent or project scope", async () => { + it("fails push-capability preflight when no GitHub write credential is bound at any supported scope", async () => { await expect(resolveExecutionRunAdapterConfig({ companyId: "company-1", agentId: "agent-1", @@ -410,9 +410,10 @@ describe("resolveExecutionRunAdapterConfig", () => { projectEnv: { PROJECT_ONLY: "project-only" }, requiredScopedEnvBinding: { keys: ["GH_TOKEN", "GITHUB_TOKEN"], - consumerScopes: ["agent", "project"], + consumerScopes: ["agent", "project", "environment", "routine"], reason: "push_write_credential_missing", - remediation: "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at project or agent scope.", + remediation: + "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at agent, project, environment, or routine scope.", }, secretsSvc: { resolveAdapterConfigForRuntime: vi.fn(), @@ -420,18 +421,50 @@ describe("resolveExecutionRunAdapterConfig", () => { } as any, })).rejects.toMatchObject({ code: "configuration_incomplete", - message: expect.stringContaining("GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN"), + message: expect.stringContaining("agent, project, environment, or routine scope"), resultJson: { configurationIncomplete: { reason: "push_write_credential_missing", requiredEnvKeys: ["GH_TOKEN", "GITHUB_TOKEN"], - requiredScopes: ["agent", "project"], + requiredScopes: ["agent", "project", "environment", "routine"], missingBindings: [], }, }, }); }); + it("does not accept a lowercase GitHub credential binding key", async () => { + await expect(resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + environmentId: "environment-1", + routineId: "routine-1", + executionRunConfig: { env: { gh_token: "agent-token" } }, + environmentEnv: { gh_token: "environment-token" }, + projectEnv: { gh_token: "project-token" }, + routineEnv: { gh_token: "routine-token" }, + requiredScopedEnvBinding: { + keys: ["GH_TOKEN", "GITHUB_TOKEN"], + consumerScopes: ["agent", "project", "environment", "routine"], + reason: "push_write_credential_missing", + remediation: + "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at agent, project, environment, or routine scope.", + }, + secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn(), + resolveEnvBindings: vi.fn(), + } as any, + })).rejects.toMatchObject({ + code: "configuration_incomplete", + resultJson: { + configurationIncomplete: { + reason: "push_write_credential_missing", + }, + }, + }); + }); + it("passes push-capability preflight when a project-scoped GitHub credential is configured", async () => { const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ config: { env: { AGENT_ONLY: "agent-only" } }, @@ -454,9 +487,10 @@ describe("resolveExecutionRunAdapterConfig", () => { projectEnv: { GH_TOKEN: { type: "plain", value: "github-token" } }, requiredScopedEnvBinding: { keys: ["GH_TOKEN", "GITHUB_TOKEN"], - consumerScopes: ["agent", "project"], + consumerScopes: ["agent", "project", "environment", "routine"], reason: "push_write_credential_missing", - remediation: "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at project or agent scope.", + remediation: + "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at agent, project, environment, or routine scope.", }, secretsSvc: { resolveAdapterConfigForRuntime, @@ -476,6 +510,98 @@ describe("resolveExecutionRunAdapterConfig", () => { consumerId: "project-1", }); }); + + it("passes push-capability preflight for an environment-scoped GitHub credential", async () => { + const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ + config: { env: { AGENT_ONLY: "agent-only" } }, + secretKeys: new Set(), + manifest: [], + }); + const resolveEnvBindings = vi.fn().mockResolvedValue({ + env: { GH_TOKEN: "environment-token" }, + secretKeys: new Set(["GH_TOKEN"]), + manifest: [], + }); + const collectMissingRuntimeBindings = vi.fn().mockResolvedValue([]); + + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + environmentId: "environment-1", + environmentEnv: { GH_TOKEN: { type: "plain", value: "environment-token" } }, + 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: + "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at agent, project, environment, or routine scope.", + }, + secretsSvc: { + resolveAdapterConfigForRuntime, + resolveEnvBindings, + collectMissingRuntimeBindings, + } as any, + }); + + expect(result.resolvedConfig.env).toEqual({ + GH_TOKEN: "environment-token", + AGENT_ONLY: "agent-only", + }); + expect(resolveEnvBindings).toHaveBeenCalledOnce(); + expect(collectMissingRuntimeBindings.mock.calls[0]?.[2]).toMatchObject({ + consumerType: "environment", + consumerId: "environment-1", + }); + }); + + it("passes push-capability preflight for a routine-scoped GitHub credential", async () => { + const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ + config: { env: { AGENT_ONLY: "agent-only" } }, + secretKeys: new Set(), + manifest: [], + }); + const resolveEnvBindings = vi.fn().mockResolvedValue({ + env: { GITHUB_TOKEN: "routine-token" }, + secretKeys: new Set(["GITHUB_TOKEN"]), + manifest: [], + }); + const collectMissingRuntimeBindings = vi.fn().mockResolvedValue([]); + + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + routineId: "routine-1", + executionRunConfig: { env: { AGENT_ONLY: "agent-only" } }, + projectEnv: null, + routineEnv: { GITHUB_TOKEN: { type: "plain", value: "routine-token" } }, + requiredScopedEnvBinding: { + keys: ["GH_TOKEN", "GITHUB_TOKEN"], + consumerScopes: ["agent", "project", "environment", "routine"], + reason: "push_write_credential_missing", + remediation: + "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at agent, project, environment, or routine scope.", + }, + secretsSvc: { + resolveAdapterConfigForRuntime, + resolveEnvBindings, + collectMissingRuntimeBindings, + } as any, + }); + + expect(result.resolvedConfig.env).toEqual({ + AGENT_ONLY: "agent-only", + GITHUB_TOKEN: "routine-token", + }); + expect(resolveEnvBindings).toHaveBeenCalledOnce(); + expect(collectMissingRuntimeBindings.mock.calls[1]?.[2]).toMatchObject({ + consumerType: "routine", + consumerId: "routine-1", + }); + }); }); describe("resolveExecutionRunAdapterConfig codex_local credential pre-dispatch gate", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index cbeb0978f29..d78d720c4ef 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -913,7 +913,7 @@ export async function resolveExecutionRunAdapterConfig(input: { trustPreset?: TrustPresetResolution; requiredScopedEnvBinding?: { keys: string[]; - consumerScopes: Array<"agent" | "project">; + consumerScopes: Array<"agent" | "project" | "environment" | "routine">; reason: string; remediation: string; }; @@ -940,6 +940,12 @@ export async function resolveExecutionRunAdapterConfig(input: { ) || ( requiredScopedEnvBinding.consumerScopes.includes("project") && isConfiguredEnvBindingValue(projectEnv?.[key]) + ) || ( + requiredScopedEnvBinding.consumerScopes.includes("environment") + && isConfiguredEnvBindingValue(environmentEnv?.[key]) + ) || ( + requiredScopedEnvBinding.consumerScopes.includes("routine") + && isConfiguredEnvBindingValue(routineEnv?.[key]) )) : false; if (requiredScopedEnvBinding && !requiredScopedBindingsConfigured) { @@ -1033,7 +1039,7 @@ export async function resolveExecutionRunAdapterConfig(input: { const requiredEnvKeys = new Set(requiredScopedEnvBinding.keys); const requiredScopes = new Set(requiredScopedEnvBinding.consumerScopes); const requiredMissingBindings = missingBindings.filter((binding) => - requiredScopes.has(binding.consumerType as "agent" | "project") + requiredScopes.has(binding.consumerType as "agent" | "project" | "environment" | "routine") && requiredEnvKeys.has(binding.envKey), ); if (requiredMissingBindings.length > 0) { @@ -14596,10 +14602,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) requiredScopedEnvBinding: pushCapabilityPreflightRequired ? { keys: [...PUSH_CAPABILITY_ENV_KEYS], - consumerScopes: ["agent", "project"], + consumerScopes: ["agent", "project", "environment", "routine"], reason: "push_write_credential_missing", remediation: - "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at project or agent scope.", + "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at agent, project, environment, or routine scope.", } : undefined, });