Add ambient host credential push preflight

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-19 15:28:02 +00:00
1 parent fb4d54e46d
commit 9fe17a8c6a
3 files changed
+349 -4

No files matched your search

@@ -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<string>(),
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<string>(),
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<string>(),
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<string>(),
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",
@@ -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({
+107 -4
View File
@@ -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<AmbientHostPushCredential | null> {
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<ReturnType<RuntimeConfigSecretResolver["getByNameInsensitive"]>> | 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,
});