mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
Authenticate Boat initial checkouts with run-scoped Git credentials
Pass the existing workspace credential provider through computer realization separately from durable metadata, and supply its URL-scoped helper only to the clone subprocess. Verify authenticated clone behavior and credential non-retention. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7315159c3d
commit
b52863e616
9 files changed
+87
-6
No files matched your search
@@ -303,6 +303,23 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
|
||||
expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("resolves run-scoped checkout credentials without persisting them in workspace metadata", async () => {
|
||||
const runtime = makeMockRuntime();
|
||||
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
|
||||
const request = mockBuildWorkspaceRealizationRequest.getMockImplementation()!();
|
||||
request.source.repoUrl = "https://github.com/company/private-repo.git";
|
||||
mockBuildWorkspaceRealizationRequest.mockReturnValue(request);
|
||||
const gitAuth = { configArgs: ["-c", "credential.helper="], env: { PAPERCLIP_GIT_TOKEN: "scoped-fixture-token" }, source: "managed_connection" as const, secretName: null };
|
||||
const resolveGitAuth = vi.fn(async () => gitAuth);
|
||||
await orchestrator.realizeForRun({ ...makeRealizeInput({ environment: makeEnvironment("computer") }), resolveGitAuth });
|
||||
expect(resolveGitAuth).toHaveBeenCalledExactlyOnceWith(request.source.repoUrl);
|
||||
expect(runtime.realizeWorkspace).toHaveBeenCalledWith(expect.objectContaining({ gitAuth }));
|
||||
const dispatched = vi.mocked(runtime.realizeWorkspace).mock.calls[0]![0];
|
||||
expect(JSON.stringify(dispatched.workspace)).not.toContain("scoped-fixture-token");
|
||||
expect(JSON.stringify(mockUpdateLeaseMetadata.mock.calls)).not.toContain("scoped-fixture-token");
|
||||
expect(JSON.stringify(mockUpdateExecutionWorkspace.mock.calls)).not.toContain("scoped-fixture-token");
|
||||
});
|
||||
|
||||
it("uses an in-place authoritative root on the adapter execution target", async () => {
|
||||
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
|
||||
kind: "remote",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
@@ -13,6 +14,7 @@ import { spawnSync } from "node:child_process";
|
||||
import { remoteProgram } from "./remote-program.js";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { boatBackend } from "./boat.js";
|
||||
import { buildGitAuthInvocation } from "../../../services/git-credentials.js";
|
||||
import type { ComputerRecord } from "../domain/ledger.js";
|
||||
const record: ComputerRecord = {
|
||||
id: "computer",
|
||||
@@ -113,11 +115,11 @@ function fixture() {
|
||||
const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-files-")));
|
||||
roots.push(temp);
|
||||
const root = join(temp, "home");
|
||||
const call = (input: Record<string, unknown>) => {
|
||||
const call = (input: Record<string, unknown>, env = process.env) => {
|
||||
const result = spawnSync(
|
||||
"python3",
|
||||
["-c", remoteProgram.replaceAll("/home/user/paperclip/", `${temp}/`)],
|
||||
{ input: JSON.stringify({ ...input, root }), encoding: "utf8" },
|
||||
{ input: JSON.stringify({ ...input, root }), encoding: "utf8", env },
|
||||
);
|
||||
if (result.status !== 0) throw new Error(result.stderr);
|
||||
return JSON.parse(result.stdout);
|
||||
@@ -238,6 +240,40 @@ describe("confined computer files", () => {
|
||||
expect(f.call({ action: "list", limit: 2 }).entries).toHaveLength(2);
|
||||
expect(f.call({ action: "list", limit: 1001 })).toEqual({ error: "invalid" });
|
||||
});
|
||||
it("clones a private checkout with operation-scoped auth and leaves no credential material behind", () => {
|
||||
const f = fixture();
|
||||
const source = join(f.temp, "source");
|
||||
mkdirSync(source);
|
||||
const realGit = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim();
|
||||
for (const args of [["init"], ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "base"]]) {
|
||||
expect(spawnSync(realGit, args, { cwd: source, encoding: "utf8" }).status).toBe(0);
|
||||
}
|
||||
const bin = join(f.temp, "bin");
|
||||
mkdirSync(bin);
|
||||
// Stand in for an authenticated GitHub transport, using real Git and the
|
||||
// production URL-scoped credential helper, without any external network.
|
||||
writeFileSync(join(bin, "git"), `#!${process.execPath}
|
||||
const {spawnSync}=require("node:child_process");
|
||||
const args=process.argv.slice(2), clone=args.indexOf("clone");
|
||||
if(clone<0) process.exit(9);
|
||||
if(args.some(arg=>arg.includes("scoped-fixture-token"))) process.exit(10);
|
||||
const credential=spawnSync(process.env.FIXTURE_REAL_GIT,[...args.slice(0,clone),"credential","fill"],{input:"protocol=https\\nhost=github.com\\n\\n",encoding:"utf8"});
|
||||
if(credential.status!==0||!credential.stdout.includes("password=scoped-fixture-token")) process.exit(11);
|
||||
args[clone+2]=process.env.FIXTURE_SOURCE;
|
||||
const result=spawnSync(process.env.FIXTURE_REAL_GIT,args,{encoding:"utf8"});
|
||||
process.stdout.write(result.stdout||"");process.stderr.write(result.stderr||"");process.exit(result.status??1);
|
||||
`, { mode: 0o700 });
|
||||
const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, FIXTURE_REAL_GIT: realGit, FIXTURE_SOURCE: source };
|
||||
const request = { action: "workspace", mode: "shared", repositoryUrl: "https://github.com/company/private.git" };
|
||||
expect(f.call(request, env)).toEqual({ error: "command_failed" });
|
||||
const gitAuth = buildGitAuthInvocation({ token: "scoped-fixture-token", source: "managed_connection", secretName: null });
|
||||
const result = f.call({ ...request, gitAuth }, env);
|
||||
expect(result.remoteCwd).toBe(join(f.root, "checkout"));
|
||||
const config = readFileSync(join(result.remoteCwd, ".git", "config"), "utf8");
|
||||
expect(config).not.toContain("scoped-fixture-token");
|
||||
expect(config).not.toContain("credential");
|
||||
expect(spawnSync(realGit, ["rev-parse", "HEAD"], { cwd: result.remoteCwd }).status).toBe(0);
|
||||
});
|
||||
it.each([false, true])("uses the requested worktree branch separately from its start ref (explicit base: %s)", explicitBase => {
|
||||
const f = fixture();
|
||||
const checkout = join(f.root, "checkout");
|
||||
|
||||
@@ -5,8 +5,8 @@ p=json.load(sys.stdin)
|
||||
def fail(code):
|
||||
print(json.dumps({'error':code}));sys.exit(0)
|
||||
def digest(data):return hashlib.sha256(data).hexdigest()
|
||||
def run(args,cwd=None):
|
||||
r=subprocess.run(args,cwd=cwd,capture_output=True,text=True)
|
||||
def run(args,cwd=None,env=None):
|
||||
r=subprocess.run(args,cwd=cwd,env=env,capture_output=True,text=True)
|
||||
if r.returncode:fail('command_failed')
|
||||
return r.stdout.strip()
|
||||
root=p['root']
|
||||
@@ -65,7 +65,13 @@ if act=='workspace':
|
||||
checkout=os.path.join(root,'checkout')
|
||||
if repo:
|
||||
if not (repo.startswith('https://') or repo.startswith('ssh://') or repo.startswith('git@')) or '\n' in repo:fail('invalid')
|
||||
if not os.path.exists(checkout):run(['git','clone','--',repo,checkout])
|
||||
if not os.path.exists(checkout):
|
||||
# Resolve credentials on the controller for this operation. Only the clone
|
||||
# child receives the helper environment; no credential file or Git config persists.
|
||||
auth=p.get('gitAuth') or {};git_env=dict(os.environ)
|
||||
git_env.update({'GIT_TERMINAL_PROMPT':'0','GIT_CONFIG_GLOBAL':'/dev/null','GIT_CONFIG_NOSYSTEM':'1'})
|
||||
git_env.update(auth.get('env',{}))
|
||||
run(['git']+auth.get('configArgs',[])+['clone','--',repo,checkout],env=git_env)
|
||||
elif run(['git','remote','get-url','origin'],checkout)!=repo:fail('conflict')
|
||||
else:
|
||||
os.makedirs(checkout,exist_ok=True)
|
||||
|
||||
@@ -113,6 +113,16 @@ function fixture() {
|
||||
};
|
||||
}
|
||||
describe("computer ownership", () => {
|
||||
it("passes checkout credentials transiently without retaining them in computer state", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
const binding = await f.admit();
|
||||
const gitAuth = { configArgs: ["-c", "credential.helper="], env: { PAPERCLIP_GIT_TOKEN: "scoped-fixture-token" } };
|
||||
await f.service.realizeWorkspace({ ...f.scope, owner: binding.owner, projectId: "project", mode: "shared",
|
||||
repositoryUrl: "https://github.com/company/private.git", gitAuth });
|
||||
expect(f.backend.remote).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "workspace", gitAuth }));
|
||||
expect(JSON.stringify(await f.repository.get(f.scope))).not.toContain("scoped-fixture-token");
|
||||
});
|
||||
it("explains an unavailable dev server without masking provider errors", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
|
||||
@@ -770,6 +770,8 @@ finally:
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
repositoryUrl?: string;
|
||||
/** Transient Git helper configuration; sent over stdin, never retained in the ledger. */
|
||||
gitAuth?: { configArgs: string[]; env: Record<string, string> };
|
||||
branch?: string;
|
||||
baseRef?: string;
|
||||
mode: "shared" | "worktree";
|
||||
|
||||
@@ -77,11 +77,12 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive
|
||||
if (!result.retired) throw new Error("computer_owner_retirement_superseded");
|
||||
return { providerLeaseId: lease.providerLeaseId, state: "stopped" };
|
||||
},
|
||||
async realizeWorkspace({ lease, workspace }) {
|
||||
async realizeWorkspace({ lease, workspace, gitAuth }) {
|
||||
const request = workspace.metadata?.workspaceRealizationRequest as { source?: { projectId?: string; repoUrl?: string; repoRef?: string; branchName?: string; strategy?: string }; issueId?: string } | undefined;
|
||||
const result = await computers.realizeWorkspace({ ...scope(lease), owner: computerOwnerFromLease(lease),
|
||||
projectId: request?.source?.projectId ?? undefined, taskId: request?.issueId ?? undefined,
|
||||
repositoryUrl: request?.source?.repoUrl ?? undefined,
|
||||
gitAuth,
|
||||
branch: request?.source?.branchName ?? request?.source?.repoRef ?? undefined,
|
||||
baseRef: request?.source?.repoRef ?? undefined,
|
||||
mode: request?.source?.strategy === "git_worktree" ? "worktree" : "shared" });
|
||||
|
||||
@@ -51,6 +51,7 @@ import { buildWorkspaceRealizationRequest } from "./workspace-realization.js";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import type { GitRemoteAuthProvider } from "./git-credentials.js";
|
||||
import { parseObject } from "../adapters/utils.js";
|
||||
import type { RealizedExecutionWorkspace } from "./workspace-runtime.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
@@ -393,6 +394,8 @@ export function environmentRunOrchestrator(
|
||||
* target. Absent keeps the safe no-op default in the bridge.
|
||||
*/
|
||||
duplexObservabilityRecorder?: DuplexObservabilityRecorder | null;
|
||||
/** Run-scoped auth for initial persistent checkouts; never persisted as workspace metadata. */
|
||||
resolveGitAuth?: GitRemoteAuthProvider;
|
||||
}): Promise<EnvironmentRealizationResult> {
|
||||
const {
|
||||
environment,
|
||||
@@ -430,6 +433,9 @@ export function environmentRunOrchestrator(
|
||||
const workspaceRealizationResult = await environmentRuntime.realizeWorkspace({
|
||||
environment,
|
||||
lease,
|
||||
gitAuth: environment.driver === "computer" && workspaceRealizationRequest.source.repoUrl
|
||||
? await input.resolveGitAuth?.(workspaceRealizationRequest.source.repoUrl) ?? undefined
|
||||
: undefined,
|
||||
workspace: {
|
||||
localPath: executionWorkspace.cwd,
|
||||
remotePath: remoteCwd,
|
||||
|
||||
@@ -526,6 +526,8 @@ export interface EnvironmentDriverLeaseInput {
|
||||
}
|
||||
|
||||
export interface EnvironmentDriverRealizeWorkspaceInput extends EnvironmentDriverLeaseInput {
|
||||
/** Ephemeral authentication for the initial checkout, excluded from durable metadata. */
|
||||
gitAuth?: { configArgs: string[]; env: Record<string, string> };
|
||||
workspace: {
|
||||
localPath?: string;
|
||||
remotePath?: string;
|
||||
|
||||
@@ -4529,6 +4529,7 @@ export function heartbeatService(
|
||||
effectiveExecutionWorkspaceMode,
|
||||
persistedExecutionWorkspace,
|
||||
duplexObservabilityRecorder,
|
||||
resolveGitAuth: workspaceGitAuthProvider,
|
||||
});
|
||||
nativeRunnerPreparationSpans.push({
|
||||
name: "environment.workspace.realize",
|
||||
|
||||
Reference in new issue
Block a user