mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Fix fresh workspace ambient credential probing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
014390c9c0
commit
309aa0f33f
3 files changed
+145
-29
No files matched your search
@@ -479,7 +479,7 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(probe).toHaveBeenCalledWith("/checkout");
|
||||
expect(probe).toHaveBeenCalledWith("/checkout", { configuredPushUrls: undefined });
|
||||
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
|
||||
expect(result.secretKeys).toEqual(new Set());
|
||||
expect(getByNameInsensitive).not.toHaveBeenCalled();
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
preflightLowTrustWorkspaceIsolation,
|
||||
probeAmbientHostPushCredential,
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
resolveAmbientHostCredentialCheckout,
|
||||
parseSessionCompactionPolicy,
|
||||
provisionExecutionWorkspaceForFreshnessDecision,
|
||||
reconcileReusedExecutionWorkspaceProjectWorkspaceId,
|
||||
@@ -1017,6 +1018,52 @@ describe("probeAmbientHostPushCredential", () => {
|
||||
execFile: execFileMock as unknown as typeof execFile,
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("accepts the selected fresh workspace SSH URL before its checkout exists", async () => {
|
||||
const execFileMock = vi.fn(async (command: string) => {
|
||||
if (command === "gh") throw Object.assign(new Error("not logged in"), { code: 1 });
|
||||
throw Object.assign(new Error("checkout not realized"), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
await expect(probeAmbientHostPushCredential("/future/checkout", {
|
||||
execFile: execFileMock as unknown as typeof execFile,
|
||||
configuredPushUrls: ["git@github.com:example/repo.git"],
|
||||
})).resolves.toBe("ssh_push_remote");
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAmbientHostCredentialCheckout", () => {
|
||||
it("uses the preferred fresh project workspace instead of the static adapter cwd", () => {
|
||||
expect(resolveAmbientHostCredentialCheckout({
|
||||
adapterCwd: "/static/adapter",
|
||||
preferredProjectWorkspaceId: "workspace-2",
|
||||
projectWorkspaces: [
|
||||
{ id: "workspace-1", cwd: "/checkout/one", repoUrl: "https://github.com/example/one.git" },
|
||||
{ id: "workspace-2", cwd: "/__paperclip_repo_only__", repoUrl: "git@github.com:example/two.git" },
|
||||
],
|
||||
})).toEqual({
|
||||
checkoutCwd: "/static/adapter",
|
||||
configuredPushUrls: ["git@github.com:example/two.git"],
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a reusable realized checkout over project and adapter paths", () => {
|
||||
expect(resolveAmbientHostCredentialCheckout({
|
||||
adapterCwd: "/static/adapter",
|
||||
preferredProjectWorkspaceId: "workspace-1",
|
||||
projectWorkspaces: [
|
||||
{ id: "workspace-1", cwd: "/project/checkout", repoUrl: "git@github.com:example/project.git" },
|
||||
],
|
||||
reusableExecutionWorkspace: {
|
||||
cwd: "/reused/checkout",
|
||||
repoUrl: "ssh://git@github.com/example/reused.git",
|
||||
},
|
||||
})).toEqual({
|
||||
checkoutCwd: "/reused/checkout",
|
||||
configuredPushUrls: ["ssh://git@github.com/example/reused.git"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldProbeAmbientHostPushCredential", () => {
|
||||
|
||||
@@ -855,10 +855,14 @@ export type AmbientHostPushCredential = "gh_cli" | "ssh_push_remote";
|
||||
*/
|
||||
export async function probeAmbientHostPushCredential(
|
||||
cwd: string | null | undefined,
|
||||
deps: { execFile: typeof execFile } = { execFile },
|
||||
deps: {
|
||||
execFile?: typeof execFile;
|
||||
configuredPushUrls?: readonly string[];
|
||||
} = {},
|
||||
): Promise<AmbientHostPushCredential | null> {
|
||||
const execFileImpl = deps.execFile ?? execFile;
|
||||
try {
|
||||
const result = await deps.execFile(
|
||||
const result = await execFileImpl(
|
||||
"gh",
|
||||
["auth", "token", "--hostname", "github.com"],
|
||||
{ timeout: AMBIENT_GITHUB_CREDENTIAL_PROBE_TIMEOUT_MS, windowsHide: true },
|
||||
@@ -868,40 +872,75 @@ export async function probeAmbientHostPushCredential(
|
||||
// A missing CLI, timeout, non-zero exit, or unreadable auth state is a tier miss.
|
||||
}
|
||||
|
||||
const pushUrls: string[] = [];
|
||||
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(
|
||||
if (normalizedCwd) {
|
||||
const remoteNames = await execFileImpl(
|
||||
"git",
|
||||
["remote", "get-url", "--push", remoteName],
|
||||
["remote"],
|
||||
{
|
||||
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";
|
||||
).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 execFileImpl(
|
||||
"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) pushUrls.push(pushUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh workspace has no checkout to inspect yet. Its selected project-workspace URL becomes
|
||||
// the clone's origin, so treat that URL as the future push remote. This remains deliberately
|
||||
// network-free: the tier identifies SSH transport, while the eventual push remains the authority
|
||||
// for key, agent, host, and repository authorization.
|
||||
pushUrls.push(...(deps.configuredPushUrls ?? []));
|
||||
if (pushUrls.some((pushUrl) => (
|
||||
/^ssh:\/\//i.test(pushUrl) || /^[^\s/@:]+@[^\s/:]+:.+/.test(pushUrl)
|
||||
))) {
|
||||
return "ssh_push_remote";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveAmbientHostCredentialCheckout(input: {
|
||||
adapterCwd: string | null | undefined;
|
||||
preferredProjectWorkspaceId: string | null | undefined;
|
||||
projectWorkspaces: Array<{ id: string; cwd: string | null; repoUrl: string | null }>;
|
||||
reusableExecutionWorkspace?: { cwd: string | null; repoUrl: string | null } | null;
|
||||
}) {
|
||||
const selectedProjectWorkspace = prioritizeProjectWorkspaceCandidatesForRun(
|
||||
input.projectWorkspaces,
|
||||
input.preferredProjectWorkspaceId,
|
||||
)[0] ?? null;
|
||||
const reusableCwd = readNonEmptyString(input.reusableExecutionWorkspace?.cwd);
|
||||
const projectCwd = readNonEmptyString(selectedProjectWorkspace?.cwd);
|
||||
const checkoutCwd = reusableCwd
|
||||
?? (projectCwd === REPO_ONLY_CWD_SENTINEL ? null : projectCwd)
|
||||
?? readNonEmptyString(input.adapterCwd);
|
||||
const configuredPushUrl = readNonEmptyString(
|
||||
input.reusableExecutionWorkspace?.repoUrl ?? selectedProjectWorkspace?.repoUrl,
|
||||
);
|
||||
return {
|
||||
checkoutCwd,
|
||||
configuredPushUrls: configuredPushUrl ? [configuredPushUrl] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldProbeAmbientHostPushCredential(input: {
|
||||
pushCapabilityPreflightRequired: boolean;
|
||||
environmentDriver: string | null | undefined;
|
||||
@@ -996,6 +1035,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
ambientHostCredential?: {
|
||||
enabled: boolean;
|
||||
checkoutCwd?: string | null;
|
||||
configuredPushUrls?: readonly string[];
|
||||
probe?: typeof probeAmbientHostPushCredential;
|
||||
};
|
||||
};
|
||||
@@ -1050,7 +1090,10 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
&& requiredScopedEnvBinding.ambientHostCredential?.enabled
|
||||
? await (
|
||||
requiredScopedEnvBinding.ambientHostCredential.probe ?? probeAmbientHostPushCredential
|
||||
)(requiredScopedEnvBinding.ambientHostCredential.checkoutCwd).catch(() => null)
|
||||
)(
|
||||
requiredScopedEnvBinding.ambientHostCredential.checkoutCwd,
|
||||
{ configuredPushUrls: requiredScopedEnvBinding.ambientHostCredential.configuredPushUrls },
|
||||
).catch(() => null)
|
||||
: null;
|
||||
let fallbackSecret: Awaited<ReturnType<RuntimeConfigSecretResolver["getByNameInsensitive"]>> | null = null;
|
||||
let fallbackAuthorization: {
|
||||
@@ -14794,6 +14837,33 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
|
||||
trustPreset,
|
||||
});
|
||||
const ambientProjectWorkspaceRows = ambientHostPushCredentialEnabled
|
||||
&& requestedExecutionWorkspaceMode !== "agent_default"
|
||||
&& projectContext?.id
|
||||
? await db
|
||||
.select({
|
||||
id: projectWorkspaces.id,
|
||||
cwd: projectWorkspaces.cwd,
|
||||
repoUrl: projectWorkspaces.repoUrl,
|
||||
})
|
||||
.from(projectWorkspaces)
|
||||
.where(and(
|
||||
eq(projectWorkspaces.companyId, agent.companyId),
|
||||
eq(projectWorkspaces.projectId, projectContext.id),
|
||||
))
|
||||
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
||||
: [];
|
||||
const ambientHostCredentialCheckout = resolveAmbientHostCredentialCheckout({
|
||||
adapterCwd: executionRunConfig.cwd as string | null | undefined,
|
||||
preferredProjectWorkspaceId: issueRef?.projectWorkspaceId,
|
||||
projectWorkspaces: ambientProjectWorkspaceRows,
|
||||
reusableExecutionWorkspace: reusableExistingExecutionWorkspace
|
||||
? {
|
||||
cwd: reusableExistingExecutionWorkspace.cwd,
|
||||
repoUrl: reusableExistingExecutionWorkspace.repoUrl,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
@@ -14823,9 +14893,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
fallbackInjectionEnvKey: "GH_TOKEN",
|
||||
ambientHostCredential: {
|
||||
enabled: ambientHostPushCredentialEnabled,
|
||||
checkoutCwd:
|
||||
reusableExistingExecutionWorkspace?.cwd
|
||||
?? executionRunConfig.cwd as string | null | undefined,
|
||||
checkoutCwd: ambientHostCredentialCheckout.checkoutCwd,
|
||||
configuredPushUrls: ambientHostCredentialCheckout.configuredPushUrls,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
||||
Reference in new issue
Block a user