Add well-known GitHub secret fallback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-19 15:12:11 +00:00
1 parent 6d4664801c
commit 7cb5536459
6 files changed
+522 -16

No files matched your search

+26 -7
View File
@@ -17,9 +17,11 @@ import {
const fakeDb = null as unknown as Db;
function buildSecretsFake(byName: Record<string, string | Error>) {
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<string, string | Error>) {
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);
});
@@ -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<string>(),
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<string>(),
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", () => {
@@ -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");
+4 -4
View File
@@ -134,10 +134,10 @@ export function describeGitAuthFailure(input: {
type SecretServiceLike = ReturnType<typeof secretService>;
type GitCredentialSecretsDeps = {
getByName: (
getByNameInsensitive: (
companyId: string,
name: string,
) => Promise<{ id: string } | null | undefined> | ReturnType<SecretServiceLike["getByName"]>;
) => Promise<{ id: string; name?: string } | null | undefined> | ReturnType<SecretServiceLike["getByNameInsensitive"]>;
resolveSecretValue: SecretServiceLike["resolveSecretValue"];
};
@@ -169,7 +169,7 @@ export function createGitRemoteAuthProvider(
const resolveCredential = async (): Promise<GitCredential | null> => {
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 };
+75 -2
View File
@@ -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<typeof secretService>,
| "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<ReturnType<RuntimeConfigSecretResolver["getByNameInsensitive"]>> = 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<string>(), 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,
});
+93 -1
View File
@@ -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<SecretConsumerContext, "configPath" | "allowedBindingIds">,
authorizedBindingId?: string | null,
): Promise<{ env: Record<string, string>; secretKeys: Set<string>; 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,