diff --git a/doc/execution-github-identity.md b/doc/execution-github-identity.md index c98452969ca..85699e141f7 100644 --- a/doc/execution-github-identity.md +++ b/doc/execution-github-identity.md @@ -62,6 +62,13 @@ the same sanitized environment as execution, so a CLI visible only in the provider's default environment cannot pass the launch check. Failed path discovery stops startup instead of silently falling back to a minimal path. +Providers that declare concurrent sync support stage at most three independent +launcher files at once. Other providers keep serial staging. Each file retains +its content hash, upload lock, integrity check and atomic rename. A failed batch +settles all started uploads before startup rejects; later files and the final +permission step do not start. This changes startup scheduling, not credentials +or callback transport selection. + Scripts that previously read a persistent `GH_TOKEN` must use managed `git`, `gh`, or GitHub gateway tools. Managed execution skips legacy GitHub token bindings in agent, environment, project, and routine configuration before secret preflight. Configure personal or dedicated access through the GitHub connection instead. Directly invoking an unmanaged executable or retaining a token obtained during an earlier invocation is outside the managed invocation contract. ## Legacy hosts and networking diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 79c0a4495f3..7d96f2cc63e 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1770,14 +1770,19 @@ export async function prepareGitHubOperationLaunchers(input: { ]); if (remote) { const runner = adapterExecutionTargetCommandRunner(remote); - for (const [program, body] of Object.entries(files)) { - await syncRemoteTextFileWithHashSkip({ - runner, remoteCwd: remote.remoteCwd, remoteDir: directory, - remotePath: path.posix.join(directory, program), body, - label: "GitHub operation launcher", action: "stage GitHub operation launcher", - lockDir: path.posix.join(directory, `.${program}.lock`), - timeoutMs: 15_000, shellCommand: adapterExecutionTargetShellCommand(remote), - }); + const entries = Object.entries(files); + const width = runner.allowConcurrentSyncOperations === true ? 3 : 1; + for (let offset = 0; offset < entries.length; offset += width) { + const results = await Promise.allSettled(entries.slice(offset, offset + width).map(([program, body]) => + syncRemoteTextFileWithHashSkip({ + runner, remoteCwd: remote.remoteCwd, remoteDir: directory, + remotePath: path.posix.join(directory, program), body, + label: "GitHub operation launcher", action: "stage GitHub operation launcher", + lockDir: path.posix.join(directory, `.${program}.lock`), + timeoutMs: 15_000, shellCommand: adapterExecutionTargetShellCommand(remote), + }))); + const failure = results.find((result) => result.status === "rejected"); + if (failure?.status === "rejected") throw failure.reason; } const permissions = await runner.execute({ command: "sh", args: ["-c", `chmod 700 ${shellQuote(directory)}/git ${shellQuote(directory)}/gh && mkdir -p ${shellQuote(configDirectory)}`], cwd: remote.remoteCwd, timeoutMs: 15_000 }); if (permissions.exitCode !== 0) throw new Error("Could not prepare managed GitHub launchers"); diff --git a/packages/adapter-utils/src/github-launcher-environment.test.ts b/packages/adapter-utils/src/github-launcher-environment.test.ts index 7259d65451b..ed091f03b66 100644 --- a/packages/adapter-utils/src/github-launcher-environment.test.ts +++ b/packages/adapter-utils/src/github-launcher-environment.test.ts @@ -23,7 +23,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); -async function sandbox(layout: string) { +async function sandbox(layout: string, concurrent?: boolean) { const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-launcher-env-")); roots.push(root); const bin = path.join(root, layout); @@ -61,13 +61,119 @@ async function sandbox(layout: string) { stdout: result.stdout ?? "", stderr: result.stderr ?? "", pid: null, startedAt }; } }; - const runner = { execute: vi.fn(execute) }; + const runner = { allowConcurrentSyncOperations: concurrent, execute: vi.fn(execute) }; const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "fixture", remoteCwd: root, runner }; return { root, bin, remotePath, runner, target }; } describe("managed GitHub launcher environment", () => { + it("bounds independent remote launcher uploads and preserves the staged environment", async () => { + const fixture = await sandbox("tools with 'quotes'/bin", true); + const execute = fixture.runner.execute.getMockImplementation()!; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let started = 0; + let active = 0; + let maximum = 0; + fixture.runner.execute.mockImplementation(async (input) => { + if (!input.stdin) return execute(input); + started += 1; + active += 1; + maximum = Math.max(maximum, active); + try { + await gate; + return await execute(input); + } finally { active -= 1; } + }); + const preparation = prepareGitHubOperationLaunchers({ + runId: "bounded-staging", target: fixture.target, cwd: fixture.root, + env: githubBrokerEnvironment({ PATH: fixture.remotePath }, { url: "", token: "" }), + }); + try { + await vi.waitFor(() => expect(started).toBe(3)); + expect(active).toBe(3); + } finally { release(); await preparation; } + const env = await preparation; + expect(maximum).toBe(3); + expect(active).toBe(0); + expect(started).toBe(9); + expect(env.PATH).toBe(`${env.PAPERCLIP_GITHUB_LAUNCHER_DIR}:${fixture.remotePath}`); + const command = await fixture.runner.execute({ command: "bash", args: ["--noprofile", "--norc", "-c", "git; gh"], env }); + expect(command.exitCode, command.stderr).toBe(0); + expect(command.stdout).toBe("git started\ngh started\n"); + for (const name of [".profile", ".bash_profile", ".bashrc", ".zshenv", ".zprofile", ".zshrc"]) { + const script = await readFile(path.join(env.PAPERCLIP_GITHUB_LAUNCHER_DIR, name), "utf8"); + const result = await fixture.runner.execute({ command: "sh", args: ["-c", `${script}\nprintf '%s' "$PATH"`] }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe(env.PATH); + } + }); + + it("settles failed upload batches before rejecting and starting no later files", async () => { + const fixture = await sandbox("usr/bin", true); + const execute = fixture.runner.execute.getMockImplementation()!; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let started = 0; + let active = 0; + let settled = false; + fixture.runner.execute.mockImplementation(async (input) => { + if (!input.stdin) return execute(input); + const index = started++; + active += 1; + try { + if (index === 0) return { exitCode: 42, signal: null, timedOut: false, pid: null, + startedAt: new Date().toISOString(), stdout: "", stderr: "fixture upload failed" }; + await gate; + return await execute(input); + } finally { active -= 1; } + }); + const preparation = prepareGitHubOperationLaunchers({ + runId: "failed-batch", target: fixture.target, cwd: fixture.root, env: { PATH: fixture.remotePath }, + }); + const outcome = preparation.then(() => { settled = true; return null; }, (error: unknown) => { settled = true; return error; }); + try { + await vi.waitFor(() => expect(started).toBe(3)); + expect(settled).toBe(false); + expect(active).toBe(2); + } finally { release(); await outcome; } + expect(await outcome).toBeInstanceOf(Error); + expect(String(await outcome)).toContain("stage GitHub operation launcher"); + expect(started).toBe(3); + expect(active).toBe(0); + expect(fixture.runner.execute.mock.calls.some(([input]) => input.args?.join(" ").startsWith("chmod 700"))).toBe(false); + }); + + it("keeps concurrent staging of the same remote launcher directory coherent", async () => { + const fixture = await sandbox("usr/bin", true); + const input = { runId: "same-directory", target: fixture.target, cwd: fixture.root, + env: githubBrokerEnvironment({ PATH: fixture.remotePath }, { url: "", token: "" }) }; + const [first, second] = await Promise.all([prepareGitHubOperationLaunchers(input), prepareGitHubOperationLaunchers(input)]); + expect(first).toEqual(second); + const command = await fixture.runner.execute({ command: "bash", args: ["--noprofile", "--norc", "-c", "git; gh"], env: second }); + expect(command.exitCode, command.stderr).toBe(0); + expect(command.stdout).toBe("git started\ngh started\n"); + expect(await readFile(path.join(first.PAPERCLIP_GITHUB_LAUNCHER_DIR, "package.json"), "utf8")).toBe('{"type":"commonjs"}\n'); + }); + + it.each([undefined, false])("keeps providers without a concurrent capability serial (%s)", async (concurrent) => { + const fixture = await sandbox("usr/bin", concurrent); + const execute = fixture.runner.execute.getMockImplementation()!; + let active = 0; + let maximum = 0; + fixture.runner.execute.mockImplementation(async (input) => { + active += 1; + maximum = Math.max(maximum, active); + try { return await execute(input); } finally { active -= 1; } + }); + await prepareGitHubOperationLaunchers({ runId: "serial-provider", target: fixture.target, + cwd: fixture.root, env: { PATH: fixture.remotePath } }); + expect(maximum).toBe(1); + expect(active).toBe(0); + expect(fixture.runner.execute).toHaveBeenCalledTimes(10); + }); + // The Daytona native directory oracle uses Linux inotify. Run its real // filesystem regression on that platform; shell/launcher tests cover both. it.runIf(process.platform === "linux")("keeps launcher restaging and command scratch inside the runner runtime", async () => {