From 7cb5536459c703ec1c9aef3aec1f90711befc2de Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 19 Aug 2026 15:12:11 +0000 Subject: [PATCH] Add well-known GitHub secret fallback Co-Authored-By: Paperclip --- server/src/__tests__/git-credentials.test.ts | 33 +++- .../__tests__/heartbeat-project-env.test.ts | 185 +++++++++++++++++- server/src/__tests__/secrets-service.test.ts | 141 +++++++++++++ server/src/services/git-credentials.ts | 8 +- server/src/services/heartbeat.ts | 77 +++++++- server/src/services/secrets.ts | 94 ++++++++- 6 files changed, 522 insertions(+), 16 deletions(-) diff --git a/server/src/__tests__/git-credentials.test.ts b/server/src/__tests__/git-credentials.test.ts index deba88d5787..11d92558cfc 100644 --- a/server/src/__tests__/git-credentials.test.ts +++ b/server/src/__tests__/git-credentials.test.ts @@ -17,9 +17,11 @@ import { const fakeDb = null as unknown as Db; function buildSecretsFake(byName: Record) { - const getByName = vi.fn(async (_companyId: string, name: string) => { - if (!(name in byName)) return null; - return { id: `secret-${name}` }; + const getByNameInsensitive = vi.fn(async (_companyId: string, name: string) => { + const matches = Object.keys(byName).filter((candidate) => candidate.toLowerCase() === name.toLowerCase()); + const selectedName = matches.find((candidate) => candidate === name) ?? matches[0]; + if (!selectedName) return null; + return { id: `secret-${selectedName}`, name: selectedName }; }); const resolveSecretValue = vi.fn(async (_companyId: string, secretId: string) => { const name = secretId.replace(/^secret-/, ""); @@ -27,7 +29,7 @@ function buildSecretsFake(byName: Record) { if (value instanceof Error) throw value; return value ?? ""; }); - return { getByName, resolveSecretValue }; + return { getByNameInsensitive, resolveSecretValue }; } describe("isGitHubHttpsRemoteUrl", () => { @@ -61,7 +63,24 @@ describe("createGitRemoteAuthProvider", () => { expect(invocation?.source).toBe("company_secret"); expect(invocation?.secretName).toBe("GH_TOKEN"); // GITHUB_TOKEN is probed first even though only GH_TOKEN exists. - expect(secrets.getByName.mock.calls.map((call) => call[1])).toEqual(["GITHUB_TOKEN", "GH_TOKEN"]); + expect(secrets.getByNameInsensitive.mock.calls.map((call) => call[1])).toEqual(["GITHUB_TOKEN", "GH_TOKEN"]); + }); + + it("resolves a lowercase well-known company secret", async () => { + const secrets = buildSecretsFake({ gh_token: "lowercase-token" }); + const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, { + secrets, + env: {}, + }); + + const invocation = await provider(githubUrl); + + expect(invocation?.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("lowercase-token"); + expect(invocation?.secretName).toBe("gh_token"); + expect(secrets.getByNameInsensitive.mock.calls.map((call) => call[1])).toEqual([ + "GITHUB_TOKEN", + "GH_TOKEN", + ]); }); it("falls back to the server env, GITHUB_TOKEN before GH_TOKEN", async () => { @@ -91,7 +110,7 @@ describe("createGitRemoteAuthProvider", () => { }); await expect(provider("git@github.com:example/repo.git")).resolves.toBeNull(); await expect(provider("https://gitlab.com/example/repo.git")).resolves.toBeNull(); - expect(secrets.getByName).not.toHaveBeenCalled(); + expect(secrets.getByNameInsensitive).not.toHaveBeenCalled(); }); it("memoizes the credential lookup across calls", async () => { @@ -103,7 +122,7 @@ describe("createGitRemoteAuthProvider", () => { await provider(githubUrl); await provider(githubUrl); await provider("https://github.com/example/another.git"); - expect(secrets.getByName).toHaveBeenCalledTimes(1); + expect(secrets.getByNameInsensitive).toHaveBeenCalledTimes(1); expect(secrets.resolveSecretValue).toHaveBeenCalledTimes(1); }); diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 3ec35f44acd..d3a28be748c 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -402,6 +402,7 @@ describe("resolveExecutionRunAdapterConfig", () => { }); it("fails push-capability preflight when no GitHub write credential is bound at any supported scope", async () => { + const getByNameInsensitive = vi.fn().mockResolvedValue(null); await expect(resolveExecutionRunAdapterConfig({ companyId: "company-1", agentId: "agent-1", @@ -413,15 +414,18 @@ describe("resolveExecutionRunAdapterConfig", () => { 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.", + "No GH_TOKEN/GITHUB_TOKEN binding at agent, project, environment, or routine scope; no company secret named GITHUB_TOKEN, GH_TOKEN, or PAPERCLIP_GITHUB_TOKEN.", + fallbackSecretNames: ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"], + fallbackInjectionEnvKey: "GH_TOKEN", }, secretsSvc: { resolveAdapterConfigForRuntime: vi.fn(), resolveEnvBindings: vi.fn(), + getByNameInsensitive, } as any, })).rejects.toMatchObject({ code: "configuration_incomplete", - message: expect.stringContaining("agent, project, environment, or routine scope"), + message: expect.stringMatching(/agent, project, environment, or routine scope.*GITHUB_TOKEN, GH_TOKEN, or PAPERCLIP_GITHUB_TOKEN/), resultJson: { configurationIncomplete: { reason: "push_write_credential_missing", @@ -431,6 +435,11 @@ describe("resolveExecutionRunAdapterConfig", () => { }, }, }); + expect(getByNameInsensitive.mock.calls.map((call) => call[1])).toEqual([ + "GITHUB_TOKEN", + "GH_TOKEN", + "PAPERCLIP_GITHUB_TOKEN", + ]); }); it("does not accept a lowercase GitHub credential binding key", async () => { @@ -602,6 +611,178 @@ describe("resolveExecutionRunAdapterConfig", () => { consumerId: "routine-1", }); }); + + it("auto-injects a lowercase well-known company secret through the audited secret-ref path", async () => { + const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ + config: { env: { AGENT_ONLY: "agent-only" } }, + secretKeys: new Set(), + manifest: [], + }); + const resolveInjectedEnvBindingForRuntime = vi.fn().mockResolvedValue({ + env: { GH_TOKEN: "resolved-token" }, + secretKeys: new Set(["GH_TOKEN"]), + manifest: [{ + configPath: "env.GH_TOKEN", + envKey: "GH_TOKEN", + secretId: "secret-lowercase", + bindingId: null, + secretKey: "gh_token", + version: 1, + provider: "local_encrypted", + outcome: "success", + }], + }); + const getByNameInsensitive = vi.fn(async (_companyId: string, name: string) => + name === "GH_TOKEN" + ? { id: "secret-lowercase", name: "gh_token" } + : null, + ); + + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + heartbeatRunId: "run-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", + }, + secretsSvc: { + resolveAdapterConfigForRuntime, + resolveEnvBindings: vi.fn(), + resolveInjectedEnvBindingForRuntime, + getByNameInsensitive, + collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]), + } as any, + }); + + expect(result.resolvedConfig.env).toEqual({ + AGENT_ONLY: "agent-only", + GH_TOKEN: "resolved-token", + }); + expect(result.secretKeys).toEqual(new Set(["GH_TOKEN"])); + expect(result.secretManifest).toEqual([ + expect.objectContaining({ + configPath: "env.GH_TOKEN", + secretId: "secret-lowercase", + outcome: "success", + }), + ]); + expect(resolveAdapterConfigForRuntime).toHaveBeenCalledWith( + "company-1", + { env: { AGENT_ONLY: "agent-only" } }, + expect.any(Object), + expect.any(Object), + ); + expect(resolveInjectedEnvBindingForRuntime).toHaveBeenCalledWith( + "company-1", + "GH_TOKEN", + { + type: "secret_ref", + secretId: "secret-lowercase", + version: "latest", + }, + expect.objectContaining({ + consumerType: "run", + consumerId: "run-1", + heartbeatRunId: "run-1", + }), + null, + ); + expect(JSON.stringify(resolveInjectedEnvBindingForRuntime.mock.calls[0]?.[2])).not.toContain("resolved-token"); + }); + + it("does not satisfy the well-known-secret tier outside a low-trust binding allowlist", async () => { + const resolveInjectedEnvBindingForRuntime = vi.fn(); + await expect(resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + executionRunConfig: { env: {} }, + projectEnv: null, + trustPreset: { + kind: "low_trust_review", + preset: LOW_TRUST_REVIEW_PRESET, + boundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: "company-1", + issueIds: ["issue-1"], + allowedSecretBindingIds: ["allowed-binding"], + }, + sourcePresets: {}, + }, + 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", + }, + secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn(), + resolveEnvBindings: vi.fn(), + resolveInjectedEnvBindingForRuntime, + getByNameInsensitive: vi.fn().mockResolvedValue({ id: "secret-1", name: "gh_token" }), + listBindings: vi.fn().mockResolvedValue([{ id: "different-binding" }]), + } as any, + })).rejects.toMatchObject({ + code: "configuration_incomplete", + resultJson: { + configurationIncomplete: { + reason: "push_write_credential_missing", + }, + }, + }); + expect(resolveInjectedEnvBindingForRuntime).not.toHaveBeenCalled(); + }); + + it("skips well-known-secret injection when a supported-scope binding exists", async () => { + const getByNameInsensitive = vi.fn(); + const resolveInjectedEnvBindingForRuntime = vi.fn(); + 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: {} }, + 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", + }, + secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn().mockResolvedValue({ + config: { env: {} }, + secretKeys: new Set(), + manifest: [], + }), + resolveEnvBindings: vi.fn().mockResolvedValue({ + env: { GH_TOKEN: "environment-token" }, + secretKeys: new Set(["GH_TOKEN"]), + manifest: [], + }), + resolveInjectedEnvBindingForRuntime, + getByNameInsensitive, + collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]), + } as any, + }); + + expect(result.resolvedConfig.env).toEqual({ GH_TOKEN: "environment-token" }); + expect(getByNameInsensitive).not.toHaveBeenCalled(); + expect(resolveInjectedEnvBindingForRuntime).not.toHaveBeenCalled(); + }); }); describe("resolveExecutionRunAdapterConfig codex_local credential pre-dispatch gate", () => { diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index bd30d873c25..78e34b46f7f 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -24,6 +24,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js"; import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; import { SecretProviderClientError } from "../secrets/types.js"; +import { logger } from "../middleware/logger.js"; import { secretService } from "../services/secrets.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -131,6 +132,146 @@ describeEmbeddedPostgres("secretService", () => { return { agentId, heartbeatRunId }; } + it("selects case-insensitive secret names deterministically and logs case-folded matches", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const lowercase = await svc.create(companyId, { + name: "gh_token", + provider: "local_encrypted", + value: "lowercase-value", + }); + const exactId = randomUUID(); + await db.insert(companySecrets).values({ + id: exactId, + companyId, + key: "gh-token-exact-case", + name: "GH_TOKEN", + provider: "local_encrypted", + }); + await db + .update(companySecrets) + .set({ createdAt: new Date("2026-01-01T00:00:00.000Z") }) + .where(eq(companySecrets.id, lowercase.id)); + await db + .update(companySecrets) + .set({ createdAt: new Date("2026-02-01T00:00:00.000Z") }) + .where(eq(companySecrets.id, exactId)); + const logSpy = vi.spyOn(logger, "info").mockImplementation(() => undefined); + + await expect(svc.getByNameInsensitive(companyId, "GH_TOKEN")).resolves.toMatchObject({ + id: exactId, + name: "GH_TOKEN", + }); + expect(logSpy).not.toHaveBeenCalled(); + + await expect(svc.getByNameInsensitive(companyId, "Gh_ToKeN")).resolves.toMatchObject({ + id: lowercase.id, + name: "gh_token", + }); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + companyId, + probeName: "Gh_ToKeN", + selectedSecretName: "gh_token", + secretId: lowercase.id, + }), + "Resolved a case-insensitive company secret name", + ); + }); + + it("resolves an injected secret_ref through the audited runtime path", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: "gh_token", + provider: "local_encrypted", + value: "runtime-token", + }); + + const resolved = await svc.resolveInjectedEnvBindingForRuntime( + companyId, + "GH_TOKEN", + { type: "secret_ref", secretId: secret.id, version: "latest" }, + { + consumerType: "run", + consumerId: "run-1", + actorType: "agent", + actorId: null, + issueId: null, + heartbeatRunId: null, + }, + ); + + expect(resolved.env).toEqual({ GH_TOKEN: "runtime-token" }); + expect(resolved.secretKeys).toEqual(new Set(["GH_TOKEN"])); + expect(resolved.manifest).toEqual([ + expect.objectContaining({ + configPath: "env.GH_TOKEN", + envKey: "GH_TOKEN", + secretId: secret.id, + bindingId: null, + outcome: "success", + }), + ]); + expect(await svc.listAccessEvents(companyId, secret.id)).toEqual([ + expect.objectContaining({ + consumerType: "run", + consumerId: "run-1", + configPath: "env.GH_TOKEN", + issueId: null, + outcome: "success", + }), + ]); + }); + + it("requires an explicitly authorized binding for low-trust injected secret refs", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: "gh_token", + provider: "local_encrypted", + value: "runtime-token", + }); + const binding = await svc.createBinding({ + companyId, + secretId: secret.id, + targetType: "agent", + targetId: "agent-1", + configPath: "access.GITHUB", + }); + const context = { + consumerType: "run" as const, + consumerId: "run-1", + actorType: "agent" as const, + actorId: "agent-1", + }; + + await expect(svc.resolveInjectedEnvBindingForRuntime( + companyId, + "GH_TOKEN", + { type: "secret_ref", secretId: secret.id, version: "latest" }, + context, + randomUUID(), + )).rejects.toMatchObject({ + status: 422, + details: { code: "binding_not_allowed" }, + }); + + const resolved = await svc.resolveInjectedEnvBindingForRuntime( + companyId, + "GH_TOKEN", + { type: "secret_ref", secretId: secret.id, version: "latest" }, + context, + binding.id, + ); + expect(resolved.manifest[0]).toMatchObject({ + configPath: "env.GH_TOKEN", + bindingId: binding.id, + secretId: secret.id, + outcome: "success", + }); + }); + it("rejects cross-company secret references during env normalization", async () => { const companyA = await seedCompany("A"); const companyB = await seedCompany("B"); diff --git a/server/src/services/git-credentials.ts b/server/src/services/git-credentials.ts index 520ee6e81a0..a312808cdc7 100644 --- a/server/src/services/git-credentials.ts +++ b/server/src/services/git-credentials.ts @@ -134,10 +134,10 @@ export function describeGitAuthFailure(input: { type SecretServiceLike = ReturnType; type GitCredentialSecretsDeps = { - getByName: ( + getByNameInsensitive: ( companyId: string, name: string, - ) => Promise<{ id: string } | null | undefined> | ReturnType; + ) => Promise<{ id: string; name?: string } | null | undefined> | ReturnType; resolveSecretValue: SecretServiceLike["resolveSecretValue"]; }; @@ -169,7 +169,7 @@ export function createGitRemoteAuthProvider( const resolveCredential = async (): Promise => { for (const secretName of secretNames) { - const secret = await Promise.resolve(secrets.getByName(companyId, secretName)).catch(() => null); + const secret = await Promise.resolve(secrets.getByNameInsensitive(companyId, secretName)).catch(() => null); if (!secret) continue; // A resolution failure (inactive secret, provider outage) records its own failure audit // event; fall through to the next source instead of failing the whole git operation here. @@ -186,7 +186,7 @@ export function createGitRemoteAuthProvider( }) .then((value) => value.trim()) .catch(() => ""); - if (token) return { token, source: "company_secret", secretName }; + if (token) return { token, source: "company_secret", secretName: secret.name ?? secretName }; } const envToken = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim() || ""; if (envToken) return { token: envToken, source: "server_env", secretName: null }; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index d78d720c4ef..1dd92ff8b61 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -72,6 +72,7 @@ import { conflict, HttpError, notFound } from "../errors.js"; import { getStartupTraceContext } from "../instrumentation.js"; import { logger } from "../middleware/logger.js"; import { + DEFAULT_GITHUB_TOKEN_SECRET_NAMES, createGitRemoteAuthProvider, describeGitAuthFailure, scrubGitCredentialText, @@ -798,6 +799,9 @@ type RuntimeConfigSecretResolver = Pick< ReturnType, | "resolveAdapterConfigForRuntime" | "resolveEnvBindings" + | "resolveInjectedEnvBindingForRuntime" + | "getByNameInsensitive" + | "listBindings" | "collectMissingRuntimeBindings" | "collectMissingAdapterConfigRuntimeBindings" >; @@ -916,6 +920,8 @@ export async function resolveExecutionRunAdapterConfig(input: { consumerScopes: Array<"agent" | "project" | "environment" | "routine">; reason: string; remediation: string; + fallbackSecretNames?: readonly string[]; + fallbackInjectionEnvKey?: string; }; }) { const executionRunConfig = stripForbiddenEnvFromAdapterConfig(input.executionRunConfig); @@ -948,7 +954,28 @@ export async function resolveExecutionRunAdapterConfig(input: { && isConfiguredEnvBindingValue(routineEnv?.[key]) )) : false; - if (requiredScopedEnvBinding && !requiredScopedBindingsConfigured) { + let fallbackSecret: Awaited> = null; + let fallbackAuthorizationBindingId: string | null = null; + if ( + requiredScopedEnvBinding + && !requiredScopedBindingsConfigured + && requiredScopedEnvBinding.fallbackSecretNames?.length + && typeof input.secretsSvc.getByNameInsensitive === "function" + ) { + for (const secretName of requiredScopedEnvBinding.fallbackSecretNames) { + const candidate = await input.secretsSvc.getByNameInsensitive(input.companyId, secretName); + if (!candidate) continue; + if (lowTrustAllowedBindingIds !== undefined) { + const bindings = await input.secretsSvc.listBindings(input.companyId, candidate.id); + const bindingIds = new Set(bindings.map((binding) => binding.id)); + fallbackAuthorizationBindingId = lowTrustAllowedBindingIds.find((id) => bindingIds.has(id)) ?? null; + if (!fallbackAuthorizationBindingId) continue; + } + fallbackSecret = candidate; + break; + } + } + if (requiredScopedEnvBinding && !requiredScopedBindingsConfigured && !fallbackSecret) { throw new ConfigurationIncompleteFailure(`configuration incomplete: ${requiredScopedEnvBinding.remediation}`, { configurationIncomplete: { reason: requiredScopedEnvBinding.reason, @@ -1175,6 +1202,36 @@ export async function resolveExecutionRunAdapterConfig(input: { secretKeys.add(key); } } + const injectedEnvResolution = fallbackSecret && requiredScopedEnvBinding?.fallbackInjectionEnvKey + ? await input.secretsSvc.resolveInjectedEnvBindingForRuntime( + input.companyId, + requiredScopedEnvBinding.fallbackInjectionEnvKey, + { + type: "secret_ref", + secretId: fallbackSecret.id, + version: "latest", + }, + { + consumerType: "run", + consumerId: input.heartbeatRunId ?? input.agentId ?? "push-capability-preflight", + actorType: "agent", + actorId: input.agentId ?? null, + responsibleUserId: input.responsibleUserId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + }, + fallbackAuthorizationBindingId, + ) + : { env: {}, secretKeys: new Set(), manifest: [] }; + if (Object.keys(injectedEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...parseObject(resolvedConfig.env), + ...injectedEnvResolution.env, + }; + for (const key of injectedEnvResolution.secretKeys) { + secretKeys.add(key); + } + } // Pre-dispatch credential gate for codex_local: a managed Codex home with no // usable auth.json and an empty OPENAI_API_KEY would dispatch a run that // immediately fails with "no Codex credentials provisioned" (adapter_failed), @@ -1220,6 +1277,19 @@ export async function resolveExecutionRunAdapterConfig(input: { ); } } + if (requiredScopedEnvBinding) { + 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", + selectedSecretName: fallbackSecret?.name ?? null, + }, + "Push-capability preflight satisfied", + ); + } return { resolvedConfig, secretKeys, @@ -1228,6 +1298,7 @@ export async function resolveExecutionRunAdapterConfig(input: { ...(manifest ?? []), ...(projectEnvResolution.manifest ?? []), ...(routineEnvResolution.manifest ?? []), + ...(injectedEnvResolution.manifest ?? []), ], }; } @@ -14605,7 +14676,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) 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.", + "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", } : undefined, }); diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index fada3eca4c3..a5c73c58de1 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { and, desc, eq, inArray, like, ne, notInArray, notLike, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, like, ne, notInArray, notLike, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -952,6 +952,36 @@ export function secretService(db: Db) { .then((rows) => rows[0] ?? null); } + async function getByNameInsensitive(companyId: string, name: string) { + const secret = await db + .select() + .from(companySecrets) + .where(and( + eq(companySecrets.companyId, companyId), + eq(companySecrets.scope, "company"), + sql`lower(${companySecrets.name}) = lower(${name})`, + ne(companySecrets.status, "deleted"), + )) + .orderBy( + sql`case when ${companySecrets.name} = ${name} then 0 else 1 end`, + asc(companySecrets.createdAt), + asc(companySecrets.id), + ) + .then((rows) => rows[0] ?? null); + if (secret && secret.name !== name) { + logger.info( + { + companyId, + probeName: name, + selectedSecretName: secret.name, + secretId: secret.id, + }, + "Resolved a case-insensitive company secret name", + ); + } + return secret; + } + async function getByKey(companyId: string, key: string) { return db .select() @@ -3865,6 +3895,7 @@ export function secretService(db: Db) { getById, getByName, + getByNameInsensitive, getByKey, resolveSecretValue, resolveSecretVersion, @@ -4889,6 +4920,67 @@ export function secretService(db: Db) { return normalized; }, + resolveInjectedEnvBindingForRuntime: async ( + companyId: string, + envKey: string, + rawBinding: unknown, + context: Omit, + authorizedBindingId?: string | null, + ): Promise<{ env: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { + if (!ENV_KEY_RE.test(envKey)) { + throw unprocessable(`Invalid environment variable name: ${envKey}`); + } + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) { + throw unprocessable(`Invalid environment binding for key: ${envKey}`); + } + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type !== "secret_ref") { + throw unprocessable(`Injected environment binding must be a company secret reference: ${envKey}`); + } + + let bindingContext: SecretBindingContext | undefined; + if (authorizedBindingId) { + const authorizationBinding = await db + .select() + .from(companySecretBindings) + .where(and( + eq(companySecretBindings.id, authorizedBindingId), + eq(companySecretBindings.companyId, companyId), + eq(companySecretBindings.secretId, binding.secretId), + )) + .then((rows) => rows[0] ?? null); + if (!authorizationBinding) { + throw unprocessable( + "Secret binding is outside the active low-trust boundary", + { code: "binding_not_allowed" }, + ); + } + bindingContext = { + ...context, + consumerType: authorizationBinding.targetType as SecretBindingTargetType, + consumerId: authorizationBinding.targetId, + configPath: authorizationBinding.configPath, + allowedBindingIds: [authorizationBinding.id], + }; + } + + const secretResolution = await resolveSecretValueInternal( + companyId, + binding.secretId, + binding.version, + { + bindingContext, + accessContext: { ...context, configPath: `env.${envKey}` }, + }, + ); + return { + env: { [envKey]: secretResolution.value }, + secretKeys: new Set([envKey]), + manifest: [secretResolution.manifestEntry], + }; + }, + resolveEnvBindings: async ( companyId: string, envValue: unknown,