mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Add well-known GitHub secret fallback
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6d4664801c
commit
7cb5536459
6 files changed
+522
-16
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user