From 0b46780dcd588f35902a8f60474181d86e5b188e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 25 Jul 2026 19:19:28 -0700 Subject: [PATCH 1/5] ci: resolve PR merge conflicts with Pi --- .../workflows/pr-merge-conflict-fixer.yaml | 230 +++++++++++ test/helpers/vitest-watch-triggers.ts | 4 + ...e-conflict-fixer-workflow-boundary.test.ts | 139 +++++++ test/pr-merge-conflict-fixer.test.ts | 330 +++++++++++++++ tools/pr-merge-conflict-fixer/discover.mts | 168 ++++++++ tools/pr-merge-conflict-fixer/merge.mts | 196 +++++++++ tools/pr-merge-conflict-fixer/policy.yaml | 26 ++ tools/pr-merge-conflict-fixer/publish.mts | 379 ++++++++++++++++++ tools/pr-merge-conflict-fixer/resolve.mts | 114 ++++++ 9 files changed, 1586 insertions(+) create mode 100644 .github/workflows/pr-merge-conflict-fixer.yaml create mode 100644 test/pr-merge-conflict-fixer-workflow-boundary.test.ts create mode 100644 test/pr-merge-conflict-fixer.test.ts create mode 100755 tools/pr-merge-conflict-fixer/discover.mts create mode 100644 tools/pr-merge-conflict-fixer/merge.mts create mode 100644 tools/pr-merge-conflict-fixer/policy.yaml create mode 100755 tools/pr-merge-conflict-fixer/publish.mts create mode 100755 tools/pr-merge-conflict-fixer/resolve.mts diff --git a/.github/workflows/pr-merge-conflict-fixer.yaml b/.github/workflows/pr-merge-conflict-fixer.yaml new file mode 100644 index 0000000000..3e6087f58b --- /dev/null +++ b/.github/workflows/pr-merge-conflict-fixer.yaml @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: PR Conflicts / Resolve + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + scan: + name: Scan conflicting PRs + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: read + outputs: + count: ${{ steps.scan.outputs.count }} + matrix: ${{ steps.scan.outputs.matrix }} + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - id: scan + name: Reproduce PR merges + env: + GITHUB_TOKEN: ${{ github.token }} + run: node --experimental-strip-types --no-warnings tools/pr-merge-conflict-fixer/discover.mts + + resolve: + name: Resolve PR #${{ matrix.item.pr_number }} + needs: scan + if: ${{ needs.scan.outputs.count != '0' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.scan.outputs.matrix) }} + env: + ARTIFACT_DIR: ${{ runner.temp }}/resolution-artifact + OPENSHELL_GATEWAY: pr-conflict-fixer + PI_IMAGE: ghcr.io/nvidia/openshell-community/sandboxes/pi@sha256:00d0c5e9e733f94f6db3eaa2ab70d4fd75bcc4aace6b13a54535cbf2dd20dfcd + RESOLUTION_WORKDIR: ${{ runner.temp }}/repo + RESOLVER_CONFIG_DIR: ${{ runner.temp }}/pi-config + SANDBOX_NAME: pr-conflict-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.item.pr_number }} + TRUSTED_CHECKOUT: ${{ github.workspace }}/trusted + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + path: trusted + persist-credentials: false + ref: ${{ matrix.item.base_sha }} + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - id: prepare + name: Reproduce the recorded conflict + env: + MATRIX_ENTRY: ${{ toJSON(matrix.item) }} + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" + + - name: Install OpenShell + run: | + env -u GITHUB_TOKEN -u GH_TOKEN -u PR_REVIEW_ADVISOR_API_KEY \ + NEMOCLAW_NON_INTERACTIVE=1 \ + bash "$TRUSTED_CHECKOUT/scripts/install-openshell.sh" + + - name: Configure OpenShell inference + env: + OPENAI_API_KEY: ${{ secrets.PR_REVIEW_ADVISOR_API_KEY }} + run: | + set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + openshell gateway start --name "$OPENSHELL_GATEWAY" --port 8080 + openshell gateway select "$OPENSHELL_GATEWAY" + openshell provider create \ + --name terra \ + --type openai \ + --credential OPENAI_API_KEY \ + --config OPENAI_BASE_URL=https://inference-api.nvidia.com/v1 + openshell inference set \ + --provider terra \ + --model azure/openai/gpt-5.6-terra \ + --timeout 900 + + - name: Create the credential-free sandbox + run: | + set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + openshell sandbox create \ + --name "$SANDBOX_NAME" \ + --from "$PI_IMAGE" \ + --policy "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/policy.yaml" \ + --upload "$RESOLUTION_WORKDIR:/sandbox" \ + --upload "$RESOLVER_CONFIG_DIR:/sandbox" \ + --no-git-ignore \ + --no-tty \ + -- true + + - name: Run one Pi conflict-resolution task + run: | + set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + openshell sandbox exec --name "$SANDBOX_NAME" --timeout 1200 -- \ + env \ + HOME=/sandbox \ + PI_CODING_AGENT_DIR=/sandbox/pi-config \ + PI_OFFLINE=1 \ + bash -lc ' + cd /sandbox/repo + node /usr/bin/pi \ + --provider openshell \ + --model azure/openai/gpt-5.6-terra \ + --thinking medium \ + --tools read,bash,edit,write,grep,find,ls \ + --no-context-files \ + --no-extensions \ + --no-prompt-templates \ + --no-session \ + --no-skills \ + --no-themes \ + --offline \ + --print "$(cat /sandbox/pi-config/task.txt)" + ' + + - name: Export the Git patch + env: + CONFLICT_TREE: ${{ steps.prepare.outputs.conflict_tree }} + run: | + set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + openshell sandbox exec --name "$SANDBOX_NAME" -- \ + env CONFLICT_TREE="$CONFLICT_TREE" \ + bash -lc ' + set -euo pipefail + cd /sandbox/repo + if test -n "$(git ls-files -u)"; then + echo "Pi did not stage every resolved conflict." >&2 + exit 1 + fi + final_tree="$(git write-tree)" + git diff --binary "$CONFLICT_TREE" "$final_tree" > /sandbox/resolution.patch + ' + mkdir -p "$ARTIFACT_DIR" + openshell sandbox download \ + "$SANDBOX_NAME" \ + /sandbox/resolution.patch \ + "$ARTIFACT_DIR/" + + - name: Delete the sandbox + if: always() + run: | + set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + if command -v openshell >/dev/null 2>&1 \ + && openshell sandbox list --names | grep -Fxq -- "$SANDBOX_NAME"; then + openshell sandbox delete "$SANDBOX_NAME" + fi + + - name: Upload the resolution patch + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-conflict-resolution-${{ matrix.item.pr_number }}-${{ matrix.item.head_sha }}-${{ matrix.item.base_sha }} + path: ${{ env.ARTIFACT_DIR }}/resolution.patch + + publish: + name: Publish PR #${{ matrix.item.pr_number }} + needs: + - scan + - resolve + if: ${{ always() && needs.scan.outputs.count != '0' }} + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.scan.outputs.matrix) }} + env: + ARTIFACT_DIR: ${{ runner.temp }}/resolution-artifact + TRUSTED_CHECKOUT: ${{ github.workspace }}/trusted + steps: + - name: Checkout trusted publisher code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + path: trusted + persist-credentials: false + ref: ${{ matrix.item.base_sha }} + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - id: download + name: Download the resolution patch + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: pr-conflict-resolution-${{ matrix.item.pr_number }}-${{ matrix.item.head_sha }}-${{ matrix.item.base_sha }} + path: ${{ env.ARTIFACT_DIR }} + + - name: Validate and publish the merge commit + if: ${{ steps.download.outcome == 'success' }} + env: + GITHUB_TOKEN: ${{ github.token }} + MATRIX_ENTRY: ${{ toJSON(matrix.item) }} + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/publish.mts" diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index 67e7f07263..906f69c34d 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -98,6 +98,10 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ pattern: /(?:^|\/)\.github\/workflows\/pr-e2e-gate\.yaml$/, testsToRun: runTests("test/pr-e2e-gate-workflow.test.ts", "test/pr-e2e-required.test.ts"), }, + { + pattern: /(?:^|\/)\.github\/workflows\/pr-merge-conflict-fixer\.yaml$/, + testsToRun: runTests("test/pr-merge-conflict-fixer-workflow-boundary.test.ts"), + }, { pattern: /(?:^|\/)\.github\/workflows\/(?:hosted-runner-recovery|wsl-e2e|macos-e2e|platform-vitest-main)\.yaml$/, diff --git a/test/pr-merge-conflict-fixer-workflow-boundary.test.ts b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts new file mode 100644 index 0000000000..34f139454d --- /dev/null +++ b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +const root = path.resolve(import.meta.dirname, ".."); +const workflowSource = fs.readFileSync( + path.join(root, ".github", "workflows", "pr-merge-conflict-fixer.yaml"), + "utf8", +); +const workflow = YAML.parse(workflowSource) as Record; +const policy = YAML.parse( + fs.readFileSync(path.join(root, "tools", "pr-merge-conflict-fixer", "policy.yaml"), "utf8"), +) as Record; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function steps(job: Record): Array> { + return Array.isArray(job.steps) ? (job.steps as Array>) : []; +} + +function namedStep(job: Record, name: string): Record { + const step = steps(job).find((candidate) => candidate.name === name); + if (!step) throw new Error(`Missing workflow step: ${name}`); + return step; +} + +function checkout(job: Record): Record { + const step = steps(job).find((candidate) => + String(candidate.uses ?? "").startsWith("actions/checkout@"), + ); + if (!step) throw new Error("Missing checkout step"); + return step; +} + +describe("PR merge conflict fixer workflow boundary", () => { + const jobs = record(workflow.jobs); + const scan = record(jobs.scan); + const resolve = record(jobs.resolve); + const publish = record(jobs.publish); + + it("runs only after pushes to main with one write stage (#7542)", () => { + expect(record(workflow.on)).toEqual({ push: { branches: ["main"] } }); + expect(workflow.permissions).toEqual({ contents: "read" }); + expect(Object.keys(jobs).sort()).toEqual(["publish", "resolve", "scan"]); + expect(scan.permissions).toEqual({ contents: "read", "pull-requests": "read" }); + expect(resolve.permissions).toEqual({ contents: "read" }); + expect(publish.permissions).toEqual({ contents: "write", "pull-requests": "read" }); + expect(resolve["timeout-minutes"]).toBe(30); + expect( + Object.values(jobs).filter((job) => record(job)["timeout-minutes"] !== undefined), + ).toHaveLength(1); + }); + + it("runs pinned trusted code at the recorded base revisions (#7542)", () => { + for (const job of [scan, resolve, publish]) { + for (const step of steps(job)) { + if (step.uses) expect(step.uses).toMatch(/^[^@\s]+\/[^@\s]+@[0-9a-f]{40}$/u); + } + } + expect(checkout(scan).with).toMatchObject({ + "persist-credentials": false, + ref: "${{ github.sha }}", + }); + for (const job of [resolve, publish]) { + expect(checkout(job).with).toMatchObject({ + "persist-credentials": false, + ref: "${{ matrix.item.base_sha }}", + }); + } + expect(namedStep(publish, "Validate and publish the merge commit").run).toContain( + "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/publish.mts", + ); + }); + + it("keeps credentials and ordinary network access out of Pi (#7542)", () => { + const configure = namedStep(resolve, "Configure OpenShell inference"); + expect(configure.env).toEqual({ + OPENAI_API_KEY: "${{ secrets.PR_REVIEW_ADVISOR_API_KEY }}", + }); + expect(configure.run).toContain("--credential OPENAI_API_KEY"); + expect(configure.run).toContain("--model azure/openai/gpt-5.6-terra"); + + const pi = namedStep(resolve, "Run one Pi conflict-resolution task"); + expect(pi.env).toBeUndefined(); + for (const option of [ + "--model azure/openai/gpt-5.6-terra", + "--no-context-files", + "--no-extensions", + "--no-prompt-templates", + "--no-session", + "--no-skills", + "--no-themes", + "--offline", + ]) { + expect(pi.run).toContain(option); + } + expect(String(pi.run)).not.toContain("GITHUB_TOKEN"); + expect(record(resolve.env).PI_IMAGE).toBe( + "ghcr.io/nvidia/openshell-community/sandboxes/pi@sha256:00d0c5e9e733f94f6db3eaa2ab70d4fd75bcc4aace6b13a54535cbf2dd20dfcd", + ); + expect(workflowSource.match(/\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/gu)).toEqual([ + "${{ secrets.PR_REVIEW_ADVISOR_API_KEY }}", + ]); + expect(workflowSource).not.toMatch( + /\b(?:PAT|GitHub App|checks:\s*write|statuses:\s*write)\b/iu, + ); + expect(policy.network_policies).toEqual({}); + expect(record(policy.process).run_as_user).toBe("sandbox"); + expect(record(policy.filesystem_policy).read_write).toContain("/sandbox"); + }); + + it("publishes only a successfully exported patch and always deletes the sandbox (#7542)", () => { + expect(namedStep(resolve, "Create the credential-free sandbox").run).toContain( + "--no-git-ignore", + ); + const cleanup = namedStep(resolve, "Delete the sandbox"); + expect(cleanup.if).toBe("always()"); + expect(cleanup.run).toContain("openshell sandbox delete"); + + const upload = namedStep(resolve, "Upload the resolution patch"); + const download = namedStep(publish, "Download the resolution patch"); + expect(upload.if).toBe("success()"); + expect(download["continue-on-error"]).toBe(true); + expect(record(download.with).name).toBe(record(upload.with).name); + expect(record(download.with).path).toBe("${{ env.ARTIFACT_DIR }}"); + + const publisher = namedStep(publish, "Validate and publish the merge commit"); + expect(publisher.if).toBe("${{ steps.download.outcome == 'success' }}"); + expect(record(publisher.env).GITHUB_TOKEN).toBe("${{ github.token }}"); + }); +}); diff --git a/test/pr-merge-conflict-fixer.test.ts b/test/pr-merge-conflict-fixer.test.ts new file mode 100644 index 0000000000..fc3d68f157 --- /dev/null +++ b/test/pr-merge-conflict-fixer.test.ts @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + type ConflictMatrixEntry, + type PullRequest, + selectConflictingPullRequests, +} from "../tools/pr-merge-conflict-fixer/discover.mts"; +import { prepareMerge, writeTree } from "../tools/pr-merge-conflict-fixer/merge.mts"; +import { + publishResolution, + validatePublicationState, + validateResolutionPatch, +} from "../tools/pr-merge-conflict-fixer/publish.mts"; +import { + resolverModelConfiguration, + resolverPrompt, +} from "../tools/pr-merge-conflict-fixer/resolve.mts"; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-conflict-fixer-test-")); + temporaryDirectories.push(directory); + return directory; +} + +function git(repository: string, args: string[]): string { + return execFileSync("git", args, { + cwd: repository, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function write(repository: string, file: string, content: string): void { + const target = path.join(repository, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +function createConflictFixture(): { + baseSha: string; + headSha: string; + repository: string; +} { + const repository = temporaryDirectory(); + git(repository, ["init", "--initial-branch=main"]); + git(repository, ["config", "user.name", "Conflict Fixer Test"]); + git(repository, ["config", "user.email", "conflict-fixer@example.test"]); + git(repository, ["config", "commit.gpgsign", "false"]); + write(repository, "conflict.txt", "shared\n"); + git(repository, ["add", "conflict.txt"]); + git(repository, ["commit", "-m", "test: add shared file"]); + + git(repository, ["checkout", "-b", "pull-request"]); + write(repository, "conflict.txt", "pull request\n"); + git(repository, ["add", "conflict.txt"]); + git(repository, ["commit", "-m", "test: change PR side"]); + const headSha = git(repository, ["rev-parse", "HEAD"]); + + git(repository, ["checkout", "main"]); + write(repository, "conflict.txt", "main branch\n"); + write(repository, "main-only.txt", "main\n"); + git(repository, ["add", "conflict.txt", "main-only.txt"]); + git(repository, ["commit", "-m", "test: change main side"]); + const baseSha = git(repository, ["rev-parse", "HEAD"]); + return { baseSha, headSha, repository }; +} + +function entryFor(fixture: ReturnType): ConflictMatrixEntry { + return { + base_sha: fixture.baseSha, + conflict_paths: ["conflict.txt"], + head_ref: "pull-request", + head_sha: fixture.headSha, + pr_number: 42, + }; +} + +function createResolutionPatch( + fixture: ReturnType, + patchPath: string, + extraFile = false, +): string { + const repository = path.join(temporaryDirectory(), "resolver"); + const merge = prepareMerge(fixture.repository, repository, fixture.headSha, fixture.baseSha); + expect(merge?.conflictPaths).toEqual(["conflict.txt"]); + write(repository, "conflict.txt", "resolved intent\n"); + git(repository, ["add", "conflict.txt"]); + if (extraFile) { + write(repository, "unrelated.txt", "not part of the conflict\n"); + git(repository, ["add", "unrelated.txt"]); + } + const finalTree = writeTree(repository); + const patch = execFileSync("git", ["diff", "--binary", merge?.conflictTree ?? "", finalTree], { + cwd: repository, + }); + fs.writeFileSync(patchPath, patch); + return finalTree; +} + +function pullRequest(input: { + baseRef?: string; + headRef?: string; + headRepository?: string; + number: number; + repository?: string; + state?: string; +}): PullRequest { + const repository = input.repository ?? "NVIDIA/NemoClaw"; + return { + base: { ref: input.baseRef ?? "main" }, + head: { + ref: input.headRef ?? `branch-${input.number}`, + repo: + input.headRepository === "deleted" + ? null + : { full_name: input.headRepository ?? repository }, + sha: String(input.number).padStart(40, "0"), + }, + number: input.number, + state: input.state ?? "open", + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("PR merge conflict fixer", () => { + it("skips fork PRs before Git conflict analysis (#7542)", () => { + const checkConflict = vi.fn(() => ["conflict.txt"]); + const selected = selectConflictingPullRequests( + [ + pullRequest({ number: 1 }), + pullRequest({ + headRepository: "contributor/NemoClaw", + number: 2, + }), + ], + "NVIDIA/NemoClaw", + "a".repeat(40), + { checkConflict }, + ); + + expect(selected.map((item) => item.pr_number)).toEqual([1]); + expect(checkConflict).toHaveBeenCalledTimes(1); + }); + + it("selects draft and non-draft same-repository conflicts without labels (#7542)", () => { + const draft = { ...pullRequest({ number: 1 }), draft: true } as PullRequest; + const selected = selectConflictingPullRequests( + [draft, pullRequest({ number: 2 })], + "NVIDIA/NemoClaw", + "b".repeat(40), + { checkConflict: () => ["conflict.txt"] }, + ); + + expect(selected.map((item) => item.pr_number)).toEqual([1, 2]); + }); + + it("accepts a patch that changes only the original conflict paths (#7542)", () => { + const fixture = createConflictFixture(); + const patchPath = path.join(temporaryDirectory(), "resolution.patch"); + const expectedTree = createResolutionPatch(fixture, patchPath); + + const result = validateResolutionPatch({ + entry: entryFor(fixture), + patchPath, + sourceRepository: fixture.repository, + workDirectory: path.join(temporaryDirectory(), "publisher"), + }); + + expect(result.changedPaths).toEqual(["conflict.txt"]); + expect(result.finalTree).toBe(expectedTree); + expect(git(result.repository, ["show", `${result.finalTree}:main-only.txt`])).toBe("main"); + }); + + it("rejects a patch that changes a non-conflict path (#7542)", () => { + const fixture = createConflictFixture(); + const patchPath = path.join(temporaryDirectory(), "resolution.patch"); + createResolutionPatch(fixture, patchPath, true); + + expect(() => + validateResolutionPatch({ + entry: entryFor(fixture), + patchPath, + sourceRepository: fixture.repository, + workDirectory: path.join(temporaryDirectory(), "publisher"), + }), + ).toThrow(/non-conflict paths: unrelated\.txt/u); + }); + + it("rejects changed main state without comparing the live PR head SHA (#7542)", () => { + const entry: ConflictMatrixEntry = { + base_sha: "a".repeat(40), + conflict_paths: ["conflict.txt"], + head_ref: "feature", + head_sha: "b".repeat(40), + pr_number: 42, + }; + const livePullRequest = { + base: { + ref: "main", + repo: { full_name: "NVIDIA/NemoClaw", node_id: "R_repo" }, + }, + head: { + ref: "feature", + repo: { full_name: "NVIDIA/NemoClaw" }, + }, + state: "open", + }; + + expect(() => + validatePublicationState(entry, "NVIDIA/NemoClaw", livePullRequest, { + object: { sha: "c".repeat(40) }, + }), + ).toThrow(/main changed/u); + expect(() => + validatePublicationState(entry, "NVIDIA/NemoClaw", livePullRequest, { + object: { sha: entry.base_sha }, + }), + ).not.toThrow(); + }); + + it("creates a verified two-parent commit before the atomic head update (#7542)", async () => { + const fixture = createConflictFixture(); + const entry = entryFor(fixture); + const patchPath = path.join(temporaryDirectory(), "resolution.patch"); + const finalTree = createResolutionPatch(fixture, patchPath); + const commitSha = "c".repeat(40); + const requests: Array<{ body: unknown; method: string; path: string }> = []; + const graphql = vi.fn(async (_query: string, variables: Record) => ({ + updateRefs: { + refUpdates: [ + { + afterOid: commitSha, + name: `refs/heads/${entry.head_ref}`, + }, + ], + }, + variables, + })); + const request = vi.fn(async (method: "GET" | "POST", apiPath: string, body?: unknown) => { + requests.push({ body, method, path: apiPath }); + if (apiPath.endsWith(`/pulls/${entry.pr_number}`)) { + return { + base: { + ref: "main", + repo: { full_name: "NVIDIA/NemoClaw", node_id: "R_repo" }, + }, + head: { + ref: entry.head_ref, + repo: { full_name: "NVIDIA/NemoClaw" }, + }, + state: "open", + }; + } + if (apiPath.endsWith("/git/ref/heads/main")) { + return { object: { sha: entry.base_sha } }; + } + if (apiPath.endsWith("/git/blobs")) { + const encoded = (body as { content: string }).content; + const content = Buffer.from(encoded, "base64"); + const header = Buffer.from(`blob ${content.length}\0`); + return { sha: createHash("sha1").update(header).update(content).digest("hex") }; + } + if (apiPath.endsWith("/git/trees")) return { sha: finalTree }; + if (apiPath.endsWith("/git/commits")) { + return { sha: commitSha, verification: { reason: "valid", verified: true } }; + } + throw new Error(`unexpected request: ${method} ${apiPath}`); + }); + + await expect( + publishResolution({ + entry, + graphql, + patchPath, + repositoryName: "NVIDIA/NemoClaw", + request, + sourceRepository: fixture.repository, + }), + ).resolves.toBe(commitSha); + + const commitRequest = requests.find((item) => item.path.endsWith("/git/commits")); + expect(commitRequest?.body).toEqual({ + message: "merge: resolve conflicts with main", + parents: [entry.head_sha, entry.base_sha], + tree: finalTree, + }); + expect(JSON.stringify(commitRequest?.body)).not.toMatch(/author|committer|signature/u); + expect(graphql).toHaveBeenCalledWith(expect.stringContaining("updateRefs"), { + input: { + refUpdates: [ + { + afterOid: commitSha, + beforeOid: entry.head_sha, + force: false, + name: `refs/heads/${entry.head_ref}`, + }, + ], + repositoryId: "R_repo", + }, + }); + expect(requests.filter((item) => item.path.includes("/pulls/"))).toHaveLength(1); + expect(requests.filter((item) => item.path.endsWith("/git/ref/heads/main"))).toHaveLength(1); + }); + + it("configures Pi for credential-free OpenShell inference (#7542)", () => { + const config = JSON.parse(resolverModelConfiguration()); + expect(config.providers.openshell).toMatchObject({ + api: "openai-completions", + apiKey: "unused", + baseUrl: "https://inference.local/v1", + models: [{ id: "azure/openai/gpt-5.6-terra" }], + }); + expect(resolverPrompt()).toContain("Stage every resolved conflict with Git."); + }); +}); diff --git a/tools/pr-merge-conflict-fixer/discover.mts b/tools/pr-merge-conflict-fixer/discover.mts new file mode 100755 index 0000000000..380dc54395 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/discover.mts @@ -0,0 +1,168 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { appendFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { ConflictFixerError, prepareMerge, requireSha } from "./merge.mts"; + +export type PullRequest = { + base: { ref: string }; + head: { + ref: string; + repo: { full_name: string } | null; + sha: string; + }; + number: number; + state: string; +}; + +export type ConflictMatrixEntry = { + base_sha: string; + conflict_paths: string[]; + head_ref: string; + head_sha: string; + pr_number: number; +}; + +export function parseConflictMatrixEntry(value: string): ConflictMatrixEntry { + const parsed = JSON.parse(value) as ConflictMatrixEntry; + if ( + !parsed || + !Number.isInteger(parsed.pr_number) || + parsed.pr_number < 1 || + typeof parsed.head_ref !== "string" || + !Array.isArray(parsed.conflict_paths) || + !parsed.conflict_paths.every((item) => typeof item === "string") + ) { + throw new ConflictFixerError("MATRIX_ENTRY is invalid"); + } + requireSha(parsed.head_sha, "PR head SHA"); + requireSha(parsed.base_sha, "base SHA"); + return parsed; +} + +type DiscoverOptions = { + checkConflict?: (pullRequest: PullRequest, baseSha: string) => string[] | null; +}; + +function required(value: string | undefined, name: string): string { + if (!value) throw new ConflictFixerError(`${name} is required`); + return value; +} + +export function eligibleSameRepositoryPullRequests( + pullRequests: readonly PullRequest[], + repository: string, +): PullRequest[] { + return pullRequests.filter( + (pullRequest) => + pullRequest.state === "open" && + pullRequest.base.ref === "main" && + pullRequest.head.repo?.full_name === repository, + ); +} + +export function selectConflictingPullRequests( + pullRequests: readonly PullRequest[], + repository: string, + baseShaInput: string, + options: DiscoverOptions = {}, +): ConflictMatrixEntry[] { + const baseSha = requireSha(baseShaInput, "base SHA"); + const checkConflict = + options.checkConflict ?? + (() => { + throw new ConflictFixerError("checkConflict is required"); + }); + + return eligibleSameRepositoryPullRequests(pullRequests, repository).flatMap((pullRequest) => { + const conflictPaths = checkConflict(pullRequest, baseSha); + if (!conflictPaths) return []; + return [ + { + base_sha: baseSha, + conflict_paths: conflictPaths, + head_ref: pullRequest.head.ref, + head_sha: requireSha(pullRequest.head.sha, "PR head SHA"), + pr_number: pullRequest.number, + }, + ]; + }); +} + +async function listOpenPullRequests( + repository: string, + token: string, + request: typeof fetch = fetch, +): Promise { + const pullRequests: PullRequest[] = []; + for (let page = 1; ; page += 1) { + const response = await request( + `https://api.github.com/repos/${repository}/pulls?state=open&base=main&per_page=100&page=${page}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (!response.ok) { + throw new ConflictFixerError( + `GitHub pull request listing failed with HTTP ${response.status}`, + ); + } + const pageItems = (await response.json()) as PullRequest[]; + pullRequests.push(...pageItems); + if (pageItems.length < 100) return pullRequests; + } +} + +export async function discoverConflicts(env = process.env): Promise { + const repository = required(env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); + const baseSha = requireSha(required(env.GITHUB_SHA, "GITHUB_SHA"), "GITHUB_SHA"); + const token = required(env.GITHUB_TOKEN, "GITHUB_TOKEN"); + const sourceRepository = required(env.GITHUB_WORKSPACE, "GITHUB_WORKSPACE"); + const pullRequests = await listOpenPullRequests(repository, token); + + return selectConflictingPullRequests(pullRequests, repository, baseSha, { + checkConflict: (pullRequest) => { + const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), `nemoclaw-pr-${pullRequest.number}-`), + ); + const mergeRepository = path.join(temporaryDirectory, "repository"); + try { + const merge = prepareMerge( + sourceRepository, + mergeRepository, + pullRequest.head.sha, + baseSha, + ); + return merge?.conflictPaths ?? null; + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } + }, + }); +} + +async function main(): Promise { + const outputPath = required(process.env.GITHUB_OUTPUT, "GITHUB_OUTPUT"); + const entries = await discoverConflicts(); + appendFileSync( + outputPath, + `count=${entries.length}\nmatrix=${JSON.stringify({ include: entries.map((item) => ({ item })) })}\n`, + ); + console.log(`Selected ${entries.length} conflicting same-repository pull request(s).`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/tools/pr-merge-conflict-fixer/merge.mts b/tools/pr-merge-conflict-fixer/merge.mts new file mode 100644 index 0000000000..5eccdf2c99 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/merge.mts @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +export type MergeState = { + baseSha: string; + conflictPaths: string[]; + conflictTree: string; + headSha: string; + repository: string; +}; + +export class ConflictFixerError extends Error { + constructor(message: string) { + super(message); + this.name = "ConflictFixerError"; + } +} + +const SHA_PATTERN = /^[0-9a-f]{40}$/u; + +export function requireSha(value: string, name: string): string { + if (!SHA_PATTERN.test(value)) { + throw new ConflictFixerError(`${name} must be a lowercase 40-character Git SHA`); + } + return value; +} + +function gitText( + repository: string, + args: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): string { + return execFileSync("git", args, { + cwd: repository, + encoding: "utf8", + env, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function gitBuffer( + repository: string, + args: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): Buffer { + return execFileSync("git", args, { + cwd: repository, + env, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function gitStatus( + repository: string, + args: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): { status: number; stderr: string; stdout: string } { + const result = spawnSync("git", args, { + cwd: repository, + encoding: "utf8", + env, + stdio: ["ignore", "pipe", "pipe"], + }); + return { + status: result.status ?? 1, + stderr: result.stderr ?? "", + stdout: result.stdout ?? "", + }; +} + +function nulPaths(output: Buffer): string[] { + const text = output.toString("utf8"); + if (!text) return []; + const fields = text.split("\0"); + if (fields.at(-1) === "") fields.pop(); + return fields; +} + +function ensureCommit(sourceRepository: string, sha: string): void { + if (gitStatus(sourceRepository, ["cat-file", "-e", `${sha}^{commit}`]).status === 0) return; + const fetched = gitStatus(sourceRepository, ["fetch", "--no-tags", "--force", "origin", sha]); + if (fetched.status !== 0) { + throw new ConflictFixerError( + `Git could not fetch ${sha}: ${fetched.stderr.trim() || fetched.stdout.trim()}`, + ); + } + if (gitStatus(sourceRepository, ["cat-file", "-e", `${sha}^{commit}`]).status !== 0) { + throw new ConflictFixerError(`Git did not fetch commit ${sha}`); + } +} + +export function listUnmergedPaths(repository: string): string[] { + return nulPaths(gitBuffer(repository, ["diff", "--name-only", "--diff-filter=U", "-z"])); +} + +export function listTreeChanges(repository: string, fromTree: string, toTree: string): string[] { + return nulPaths(gitBuffer(repository, ["diff", "--name-only", "-z", fromTree, toTree])); +} + +export function writeTree(repository: string): string { + return requireSha(gitText(repository, ["write-tree"]), "tree"); +} + +export function writeConflictTree(repository: string): string { + const gitDir = path.resolve(repository, gitText(repository, ["rev-parse", "--git-dir"])); + const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "nemoclaw-conflict-index-")); + const temporaryIndex = path.join(temporaryDirectory, "index"); + try { + copyFileSync(path.join(gitDir, "index"), temporaryIndex); + const env = { ...process.env, GIT_INDEX_FILE: temporaryIndex }; + gitText(repository, ["add", "-A"], env); + return requireSha(gitText(repository, ["write-tree"], env), "conflict tree"); + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} + +export function prepareMerge( + sourceRepository: string, + destination: string, + headShaInput: string, + baseShaInput: string, +): MergeState | null { + const headSha = requireSha(headShaInput, "head SHA"); + const baseSha = requireSha(baseShaInput, "base SHA"); + ensureCommit(sourceRepository, headSha); + ensureCommit(sourceRepository, baseSha); + + rmSync(destination, { force: true, recursive: true }); + const clone = gitStatus(sourceRepository, [ + "clone", + "--no-hardlinks", + "--no-checkout", + sourceRepository, + destination, + ]); + if (clone.status !== 0) { + throw new ConflictFixerError( + `Git could not create the merge workspace: ${clone.stderr.trim() || clone.stdout.trim()}`, + ); + } + + gitText(destination, ["checkout", "--detach", headSha]); + const merge = gitStatus(destination, [ + "-c", + "user.name=NemoClaw Conflict Fixer", + "-c", + "user.email=actions@github.com", + "merge", + "--no-commit", + "--no-ff", + baseSha, + ]); + if (merge.status === 0) return null; + + const conflictPaths = listUnmergedPaths(destination); + if (conflictPaths.length === 0) { + throw new ConflictFixerError( + `Git merge failed without conflict paths: ${merge.stderr.trim() || merge.stdout.trim()}`, + ); + } + + return { + baseSha, + conflictPaths, + conflictTree: writeConflictTree(destination), + headSha, + repository: destination, + }; +} + +export function replaceWithTree(repository: string, tree: string): void { + requireSha(tree, "tree"); + gitText(repository, ["read-tree", "--reset", "-u", tree]); +} + +export function applyResolutionPatch(repository: string, patchPath: string): void { + const applied = gitStatus(repository, ["apply", "--index", "--binary", patchPath]); + if (applied.status !== 0) { + throw new ConflictFixerError( + `Git rejected the resolution patch: ${applied.stderr.trim() || applied.stdout.trim()}`, + ); + } +} + +export function samePaths(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + const sortedLeft = [...left].sort(); + const sortedRight = [...right].sort(); + return sortedLeft.every((value, index) => value === sortedRight[index]); +} diff --git a/tools/pr-merge-conflict-fixer/policy.yaml b/tools/pr-merge-conflict-fixer/policy.yaml new file mode 100644 index 0000000000..6953332697 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/policy.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /etc + read_write: + - /sandbox + - /tmp + - /dev/null + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: {} diff --git a/tools/pr-merge-conflict-fixer/publish.mts b/tools/pr-merge-conflict-fixer/publish.mts new file mode 100755 index 0000000000..898e2607a5 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/publish.mts @@ -0,0 +1,379 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { type ConflictMatrixEntry, parseConflictMatrixEntry } from "./discover.mts"; +import { + applyResolutionPatch, + ConflictFixerError, + listTreeChanges, + listUnmergedPaths, + prepareMerge, + replaceWithTree, + requireSha, + samePaths, + writeTree, +} from "./merge.mts"; + +type LivePullRequest = { + base: { + ref: string; + repo: { full_name: string; node_id: string }; + }; + head: { + ref: string; + repo: { full_name: string } | null; + }; + state: string; +}; + +type LiveRef = { + object: { sha: string }; +}; + +type GitTreeEntry = { + mode: string; + path: string; + sha: string | null; + type: "blob" | "commit"; +}; + +type GitHubRequest = (method: "GET" | "POST", path: string, body?: unknown) => Promise; + +type GraphqlRequest = (query: string, variables: Record) => Promise; + +function required(value: string | undefined, name: string): string { + if (!value) throw new ConflictFixerError(`${name} is required`); + return value; +} + +export function validatePublicationState( + entry: ConflictMatrixEntry, + repository: string, + pullRequest: LivePullRequest, + mainRef: LiveRef, +): void { + if (pullRequest.state !== "open") throw new ConflictFixerError("The pull request is not open"); + if (pullRequest.base.ref !== "main") { + throw new ConflictFixerError("The pull request no longer targets main"); + } + if ( + pullRequest.base.repo.full_name !== repository || + pullRequest.head.repo?.full_name !== repository + ) { + throw new ConflictFixerError("The pull request is not a same-repository pull request"); + } + if (pullRequest.head.ref !== entry.head_ref) { + throw new ConflictFixerError("The pull request head ref changed after the scan"); + } + if (mainRef.object.sha !== entry.base_sha) { + throw new ConflictFixerError("main changed after the scan"); + } +} + +export function validateResolutionPatch(input: { + entry: ConflictMatrixEntry; + patchPath: string; + sourceRepository: string; + workDirectory: string; +}): { + changedPaths: string[]; + finalTree: string; + repository: string; +} { + const merge = prepareMerge( + input.sourceRepository, + input.workDirectory, + input.entry.head_sha, + input.entry.base_sha, + ); + if (!merge) throw new ConflictFixerError("The recorded revisions no longer conflict"); + if (!samePaths(merge.conflictPaths, input.entry.conflict_paths)) { + throw new ConflictFixerError("The conflict paths do not match the scan result"); + } + + replaceWithTree(merge.repository, merge.conflictTree); + applyResolutionPatch(merge.repository, input.patchPath); + const unmergedPaths = listUnmergedPaths(merge.repository); + if (unmergedPaths.length > 0) { + throw new ConflictFixerError("The resolution patch leaves unresolved index entries"); + } + + const finalTree = writeTree(merge.repository); + const changedPaths = listTreeChanges(merge.repository, merge.conflictTree, finalTree); + const allowedPaths = new Set(merge.conflictPaths); + const extraPaths = changedPaths.filter((file) => !allowedPaths.has(file)); + if (extraPaths.length > 0) { + throw new ConflictFixerError( + `The resolution patch changes non-conflict paths: ${extraPaths.join(", ")}`, + ); + } + return { changedPaths, finalTree, repository: merge.repository }; +} + +function gitBuffer(repository: string, args: readonly string[]): Buffer { + return execFileSync("git", args, { + cwd: repository, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function changedPathStatuses( + repository: string, + fromTree: string, + toTree: string, +): Array<{ path: string; status: string }> { + const fields = gitBuffer(repository, [ + "diff", + "--name-status", + "--no-renames", + "-z", + fromTree, + toTree, + ]) + .toString("utf8") + .split("\0"); + if (fields.at(-1) === "") fields.pop(); + if (fields.length % 2 !== 0) { + throw new ConflictFixerError("Git returned an invalid changed-path list"); + } + const results: Array<{ path: string; status: string }> = []; + for (let index = 0; index < fields.length; index += 2) { + const status = fields[index]; + const filePath = fields[index + 1]; + if (!status || !filePath || !/^[ADMT]$/u.test(status)) { + throw new ConflictFixerError(`Git returned an unsupported tree change status: ${status}`); + } + results.push({ path: filePath, status }); + } + return results; +} + +function treeEntry(repository: string, tree: string, filePath: string): GitTreeEntry { + const output = gitBuffer(repository, ["ls-tree", "-z", tree, "--", filePath]).toString("utf8"); + const separator = output.indexOf("\t"); + if (separator < 0) throw new ConflictFixerError(`Git tree does not contain ${filePath}`); + const [mode, type, sha] = output.slice(0, separator).split(" "); + if (!mode || (type !== "blob" && type !== "commit") || !sha || !/^[0-9a-f]{40}$/u.test(sha)) { + throw new ConflictFixerError(`Git returned an invalid tree entry for ${filePath}`); + } + return { mode, path: filePath, sha, type }; +} + +async function createGitHubTree(input: { + finalTree: string; + headSha: string; + repository: string; + repositoryName: string; + request: GitHubRequest; +}): Promise { + const entries: GitTreeEntry[] = []; + for (const change of changedPathStatuses(input.repository, input.headSha, input.finalTree)) { + const sourceTree = change.status === "D" ? input.headSha : input.finalTree; + const entry = treeEntry(input.repository, sourceTree, change.path); + if (change.status === "D") { + entries.push({ ...entry, sha: null }); + continue; + } + if (entry.type === "blob") { + const content = gitBuffer(input.repository, ["cat-file", "blob", entry.sha ?? ""]); + const created = (await input.request("POST", `/repos/${input.repositoryName}/git/blobs`, { + content: content.toString("base64"), + encoding: "base64", + })) as { sha?: string }; + if (created.sha !== entry.sha) { + throw new ConflictFixerError(`GitHub returned an unexpected blob SHA for ${entry.path}`); + } + } + entries.push(entry); + } + + const created = (await input.request("POST", `/repos/${input.repositoryName}/git/trees`, { + base_tree: input.headSha, + tree: entries, + })) as { sha?: string }; + if (created.sha !== input.finalTree) { + throw new ConflictFixerError("GitHub returned a tree that differs from the validated tree"); + } + return input.finalTree; +} + +async function publishValidatedTree(input: { + entry: ConflictMatrixEntry; + finalTree: string; + graphql: GraphqlRequest; + pullRequest: LivePullRequest; + repository: string; + repositoryName: string; + request: GitHubRequest; +}): Promise { + const tree = await createGitHubTree({ + finalTree: input.finalTree, + headSha: input.entry.head_sha, + repository: input.repository, + repositoryName: input.repositoryName, + request: input.request, + }); + const commit = (await input.request("POST", `/repos/${input.repositoryName}/git/commits`, { + message: "merge: resolve conflicts with main", + parents: [input.entry.head_sha, input.entry.base_sha], + tree, + })) as { + sha?: string; + verification?: { reason?: string; verified?: boolean }; + }; + const commitSha = requireSha(commit.sha ?? "", "created commit SHA"); + if (commit.verification?.verified !== true) { + throw new ConflictFixerError( + `GitHub did not verify the merge commit: ${commit.verification?.reason ?? "unknown reason"}`, + ); + } + + const mutation = ` + mutation UpdateConflictFixerRef($input: UpdateRefsInput!) { + updateRefs(input: $input) { + refUpdates { + afterOid + name + } + } + } + `; + const result = (await input.graphql(mutation, { + input: { + refUpdates: [ + { + afterOid: commitSha, + beforeOid: input.entry.head_sha, + force: false, + name: `refs/heads/${input.entry.head_ref}`, + }, + ], + repositoryId: input.pullRequest.base.repo.node_id, + }, + })) as { + updateRefs?: { refUpdates?: Array<{ afterOid?: string; name?: string }> }; + }; + const update = result.updateRefs?.refUpdates?.[0]; + if (update?.afterOid !== commitSha || update.name !== `refs/heads/${input.entry.head_ref}`) { + throw new ConflictFixerError("GitHub did not confirm the atomic PR branch update"); + } + return commitSha; +} + +function githubClient(token: string): { graphql: GraphqlRequest; request: GitHubRequest } { + const headers = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }; + const parse = async (response: Response): Promise => { + const body = (await response.json()) as { + data?: unknown; + errors?: Array<{ message?: string }>; + message?: string; + }; + if (!response.ok || body.errors?.length) { + const message = + body.errors + ?.map((error) => error.message) + .filter(Boolean) + .join("; ") || + body.message || + `HTTP ${response.status}`; + throw new ConflictFixerError(`GitHub API request failed: ${message}`); + } + return body.data ?? body; + }; + return { + graphql: async (query, variables) => + parse( + await fetch("https://api.github.com/graphql", { + body: JSON.stringify({ query, variables }), + headers, + method: "POST", + }), + ), + request: async (method, apiPath, body) => + parse( + await fetch(`https://api.github.com${apiPath}`, { + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers, + method, + }), + ), + }; +} + +export async function publishResolution(input: { + entry: ConflictMatrixEntry; + graphql: GraphqlRequest; + patchPath: string; + repositoryName: string; + request: GitHubRequest; + sourceRepository: string; +}): Promise { + const pullRequest = (await input.request( + "GET", + `/repos/${input.repositoryName}/pulls/${input.entry.pr_number}`, + )) as LivePullRequest; + const mainRef = (await input.request( + "GET", + `/repos/${input.repositoryName}/git/ref/heads/main`, + )) as LiveRef; + validatePublicationState(input.entry, input.repositoryName, pullRequest, mainRef); + + const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "nemoclaw-publish-")); + try { + const validated = validateResolutionPatch({ + entry: input.entry, + patchPath: input.patchPath, + sourceRepository: input.sourceRepository, + workDirectory: path.join(temporaryDirectory, "repository"), + }); + return await publishValidatedTree({ + entry: input.entry, + finalTree: validated.finalTree, + graphql: input.graphql, + pullRequest, + repository: validated.repository, + repositoryName: input.repositoryName, + request: input.request, + }); + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} + +async function main(): Promise { + const entry = parseConflictMatrixEntry(required(process.env.MATRIX_ENTRY, "MATRIX_ENTRY")); + const token = required(process.env.GITHUB_TOKEN, "GITHUB_TOKEN"); + const repositoryName = required(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); + const artifactDirectory = required(process.env.ARTIFACT_DIR, "ARTIFACT_DIR"); + const patchPath = path.join(artifactDirectory, "resolution.patch"); + readFileSync(patchPath); + const client = githubClient(token); + const commitSha = await publishResolution({ + entry, + graphql: client.graphql, + patchPath, + repositoryName, + request: client.request, + sourceRepository: required(process.env.TRUSTED_CHECKOUT, "TRUSTED_CHECKOUT"), + }); + console.log(`Published verified merge commit ${commitSha} to ${entry.head_ref}.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/tools/pr-merge-conflict-fixer/resolve.mts b/tools/pr-merge-conflict-fixer/resolve.mts new file mode 100755 index 0000000000..17d1a71923 --- /dev/null +++ b/tools/pr-merge-conflict-fixer/resolve.mts @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { type ConflictMatrixEntry, parseConflictMatrixEntry } from "./discover.mts"; +import { ConflictFixerError, prepareMerge, samePaths } from "./merge.mts"; + +const MODEL_ID = "azure/openai/gpt-5.6-terra"; + +function required(value: string | undefined, name: string): string { + if (!value) throw new ConflictFixerError(`${name} is required`); + return value; +} + +export function resolverModelConfiguration(): string { + return `${JSON.stringify( + { + providers: { + openshell: { + api: "openai-completions", + apiKey: "unused", + baseUrl: "https://inference.local/v1", + compat: { + maxTokensField: "max_tokens", + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsStore: false, + supportsStrictMode: false, + supportsUsageInStreaming: false, + }, + models: [ + { + contextWindow: 256000, + cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 }, + id: MODEL_ID, + input: ["text"], + maxTokens: 32768, + name: "GPT-5.6 Terra", + reasoning: false, + }, + ], + }, + }, + }, + null, + 2, + )}\n`; +} + +export function resolverPrompt(): string { + return [ + "Resolve the Git merge conflicts in this repository.", + "The repository is merging main into a pull request head.", + "Preserve the intended behavior from both parents.", + "Do not make unrelated changes.", + "Use Git to inspect the merge state.", + "Stage every resolved conflict with Git.", + "Do not create a commit.", + ].join("\n"); +} + +export function prepareResolutionWorkspace(input: { + configDirectory: string; + entry: ConflictMatrixEntry; + sourceRepository: string; + workDirectory: string; +}): string { + const merge = prepareMerge( + input.sourceRepository, + input.workDirectory, + input.entry.head_sha, + input.entry.base_sha, + ); + if (!merge) throw new ConflictFixerError("The recorded PR no longer conflicts with the base SHA"); + if (!samePaths(merge.conflictPaths, input.entry.conflict_paths)) { + throw new ConflictFixerError("The conflict paths do not match the scan result"); + } + + mkdirSync(input.configDirectory, { recursive: true }); + writeFileSync(path.join(input.configDirectory, "models.json"), resolverModelConfiguration(), { + mode: 0o600, + }); + writeFileSync(path.join(input.configDirectory, "task.txt"), `${resolverPrompt()}\n`, { + mode: 0o600, + }); + return merge.conflictTree; +} + +function main(): void { + const entry = parseConflictMatrixEntry(required(process.env.MATRIX_ENTRY, "MATRIX_ENTRY")); + const conflictTree = prepareResolutionWorkspace({ + configDirectory: required(process.env.RESOLVER_CONFIG_DIR, "RESOLVER_CONFIG_DIR"), + entry, + sourceRepository: required(process.env.TRUSTED_CHECKOUT, "TRUSTED_CHECKOUT"), + workDirectory: required(process.env.RESOLUTION_WORKDIR, "RESOLUTION_WORKDIR"), + }); + appendFileSync( + required(process.env.GITHUB_OUTPUT, "GITHUB_OUTPUT"), + `conflict_tree=${conflictTree}\n`, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} From 3a743ca3d7206194252990be84c087940812ca9e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 25 Jul 2026 20:06:13 -0700 Subject: [PATCH 2/5] ci: tighten conflict resolver sandbox --- .../workflows/pr-merge-conflict-fixer.yaml | 101 ++++++++++++------ ...e-conflict-fixer-workflow-boundary.test.ts | 72 ++++++++++--- test/pr-merge-conflict-fixer.test.ts | 79 ++++++++------ tools/pr-merge-conflict-fixer/policy.yaml | 14 ++- tools/pr-merge-conflict-fixer/publish.mts | 3 +- 5 files changed, 176 insertions(+), 93 deletions(-) diff --git a/.github/workflows/pr-merge-conflict-fixer.yaml b/.github/workflows/pr-merge-conflict-fixer.yaml index 3e6087f58b..8694e15671 100644 --- a/.github/workflows/pr-merge-conflict-fixer.yaml +++ b/.github/workflows/pr-merge-conflict-fixer.yaml @@ -52,11 +52,11 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.scan.outputs.matrix) }} env: - ARTIFACT_DIR: ${{ runner.temp }}/resolution-artifact - OPENSHELL_GATEWAY: pr-conflict-fixer + ARTIFACT_DIR: ${{ github.workspace }}/resolution-artifact + OPENSHELL_GATEWAY_ENDPOINT: http://127.0.0.1:8080 PI_IMAGE: ghcr.io/nvidia/openshell-community/sandboxes/pi@sha256:00d0c5e9e733f94f6db3eaa2ab70d4fd75bcc4aace6b13a54535cbf2dd20dfcd - RESOLUTION_WORKDIR: ${{ runner.temp }}/repo - RESOLVER_CONFIG_DIR: ${{ runner.temp }}/pi-config + RESOLUTION_WORKDIR: ${{ github.workspace }}/repo + RESOLVER_CONFIG_DIR: ${{ github.workspace }}/pi-config SANDBOX_NAME: pr-conflict-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.item.pr_number }} TRUSTED_CHECKOUT: ${{ github.workspace }}/trusted steps: @@ -91,8 +91,39 @@ jobs: run: | set -euo pipefail export PATH="$HOME/.local/bin:$PATH" - openshell gateway start --name "$OPENSHELL_GATEWAY" --port 8080 - openshell gateway select "$OPENSHELL_GATEWAY" + GATEWAY_DIR="$RUNNER_TEMP/openshell-gateway" + mkdir -p "$GATEWAY_DIR" + openshell-gateway generate-certs --output-dir "$GATEWAY_DIR" + cat >"$GATEWAY_DIR/gateway.toml" <"$GATEWAY_DIR/gateway.log" 2>&1 & + for _ in {1..30}; do + openshell gateway info >/dev/null 2>&1 && break + sleep 1 + done + openshell gateway info openshell provider create \ --name terra \ --type openai \ @@ -115,33 +146,35 @@ jobs: --upload "$RESOLVER_CONFIG_DIR:/sandbox" \ --no-git-ignore \ --no-tty \ - -- true + -- /usr/bin/git -C /sandbox/repo status --short - name: Run one Pi conflict-resolution task run: | set -euo pipefail export PATH="$HOME/.local/bin:$PATH" - openshell sandbox exec --name "$SANDBOX_NAME" --timeout 1200 -- \ - env \ - HOME=/sandbox \ - PI_CODING_AGENT_DIR=/sandbox/pi-config \ - PI_OFFLINE=1 \ - bash -lc ' - cd /sandbox/repo - node /usr/bin/pi \ - --provider openshell \ - --model azure/openai/gpt-5.6-terra \ - --thinking medium \ - --tools read,bash,edit,write,grep,find,ls \ - --no-context-files \ - --no-extensions \ - --no-prompt-templates \ - --no-session \ - --no-skills \ - --no-themes \ - --offline \ - --print "$(cat /sandbox/pi-config/task.txt)" - ' + openshell sandbox exec \ + --name "$SANDBOX_NAME" \ + --timeout 1200 \ + --workdir /sandbox/repo \ + --env HOME=/sandbox \ + --env PI_CODING_AGENT_DIR=/sandbox/pi-config \ + --env PI_OFFLINE=1 \ + --env TMPDIR=/sandbox \ + -- \ + /usr/bin/node \ + /usr/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ + --provider openshell \ + --model azure/openai/gpt-5.6-terra \ + --thinking medium \ + --tools read,bash,edit,write,grep,find,ls \ + --no-context-files \ + --no-extensions \ + --no-prompt-templates \ + --no-session \ + --no-skills \ + --no-themes \ + --offline \ + --print @/sandbox/pi-config/task.txt - name: Export the Git patch env: @@ -149,11 +182,13 @@ jobs: run: | set -euo pipefail export PATH="$HOME/.local/bin:$PATH" - openshell sandbox exec --name "$SANDBOX_NAME" -- \ - env CONFLICT_TREE="$CONFLICT_TREE" \ - bash -lc ' + openshell sandbox exec \ + --name "$SANDBOX_NAME" \ + --workdir /sandbox/repo \ + --env CONFLICT_TREE="$CONFLICT_TREE" \ + -- \ + /usr/bin/bash -c ' set -euo pipefail - cd /sandbox/repo if test -n "$(git ls-files -u)"; then echo "Pi did not stage every resolved conflict." >&2 exit 1 @@ -198,7 +233,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.scan.outputs.matrix) }} env: - ARTIFACT_DIR: ${{ runner.temp }}/resolution-artifact + ARTIFACT_DIR: ${{ github.workspace }}/resolution-artifact TRUSTED_CHECKOUT: ${{ github.workspace }}/trusted steps: - name: Checkout trusted publisher code diff --git a/test/pr-merge-conflict-fixer-workflow-boundary.test.ts b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts index 34f139454d..733fcf6175 100644 --- a/test/pr-merge-conflict-fixer-workflow-boundary.test.ts +++ b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts @@ -26,18 +26,23 @@ function steps(job: Record): Array> { return Array.isArray(job.steps) ? (job.steps as Array>) : []; } +function required(value: T | undefined, message: string): T { + expect(value, message).toBeDefined(); + return value as T; +} + function namedStep(job: Record, name: string): Record { - const step = steps(job).find((candidate) => candidate.name === name); - if (!step) throw new Error(`Missing workflow step: ${name}`); - return step; + return required( + steps(job).find((candidate) => candidate.name === name), + `Missing workflow step: ${name}`, + ); } function checkout(job: Record): Record { - const step = steps(job).find((candidate) => - String(candidate.uses ?? "").startsWith("actions/checkout@"), + return required( + steps(job).find((candidate) => String(candidate.uses ?? "").startsWith("actions/checkout@")), + "Missing checkout step", ); - if (!step) throw new Error("Missing checkout step"); - return step; } describe("PR merge conflict fixer workflow boundary", () => { @@ -59,12 +64,16 @@ describe("PR merge conflict fixer workflow boundary", () => { ).toHaveLength(1); }); - it("runs pinned trusted code at the recorded base revisions (#7542)", () => { - for (const job of [scan, resolve, publish]) { - for (const step of steps(job)) { - if (step.uses) expect(step.uses).toMatch(/^[^@\s]+\/[^@\s]+@[0-9a-f]{40}$/u); - } - } + it("pins actions and checks out the recorded base SHA (#7542)", () => { + const actionReferences = [scan, resolve, publish] + .flatMap((job) => steps(job)) + .map((step) => step.uses) + .filter((reference): reference is string => typeof reference === "string"); + expect(actionReferences).not.toHaveLength(0); + expect( + actionReferences.every((reference) => /^[^@\s]+\/[^@\s]+@[0-9a-f]{40}$/u.test(reference)), + ).toBe(true); + expect(checkout(scan).with).toMatchObject({ "persist-credentials": false, ref: "${{ github.sha }}", @@ -80,13 +89,17 @@ describe("PR merge conflict fixer workflow boundary", () => { ); }); - it("keeps credentials and ordinary network access out of Pi (#7542)", () => { + it("keeps credentials and direct network egress out of Pi (#7542)", () => { const configure = namedStep(resolve, "Configure OpenShell inference"); expect(configure.env).toEqual({ OPENAI_API_KEY: "${{ secrets.PR_REVIEW_ADVISOR_API_KEY }}", }); expect(configure.run).toContain("--credential OPENAI_API_KEY"); expect(configure.run).toContain("--model azure/openai/gpt-5.6-terra"); + expect(configure.run).toContain("openshell-gateway generate-certs"); + expect(configure.run).toContain("allow_unauthenticated_users = true"); + expect(configure.run).toContain("supervisor_bin ="); + expect(record(resolve.env).OPENSHELL_GATEWAY_ENDPOINT).toBe("http://127.0.0.1:8080"); const pi = namedStep(resolve, "Run one Pi conflict-resolution task"); expect(pi.env).toBeUndefined(); @@ -102,6 +115,10 @@ describe("PR merge conflict fixer workflow boundary", () => { ]) { expect(pi.run).toContain(option); } + expect(pi.run).toContain("--workdir /sandbox/repo"); + expect(pi.run).toContain("--env PI_CODING_AGENT_DIR=/sandbox/pi-config"); + expect(pi.run).toContain("/usr/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"); + expect(pi.run).toContain("--print @/sandbox/pi-config/task.txt"); expect(String(pi.run)).not.toContain("GITHUB_TOKEN"); expect(record(resolve.env).PI_IMAGE).toBe( "ghcr.io/nvidia/openshell-community/sandboxes/pi@sha256:00d0c5e9e733f94f6db3eaa2ab70d4fd75bcc4aace6b13a54535cbf2dd20dfcd", @@ -113,14 +130,37 @@ describe("PR merge conflict fixer workflow boundary", () => { /\b(?:PAT|GitHub App|checks:\s*write|statuses:\s*write)\b/iu, ); expect(policy.network_policies).toEqual({}); - expect(record(policy.process).run_as_user).toBe("sandbox"); - expect(record(policy.filesystem_policy).read_write).toContain("/sandbox"); + }); + + it("allows only the resolver runtime and sandbox paths (#7542)", () => { + expect(policy.filesystem_policy).toEqual({ + include_workdir: false, + read_only: ["/usr/bin", "/usr/lib", "/usr/share/git-core", "/etc"], + read_write: ["/dev", "/sandbox"], + }); + expect(policy.landlock).toEqual({ compatibility: "hard_requirement" }); + expect(policy.process).toEqual({ run_as_group: "sandbox", run_as_user: "sandbox" }); + + const create = namedStep(resolve, "Create the credential-free sandbox"); + expect(record(resolve.env)).toMatchObject({ + ARTIFACT_DIR: "${{ github.workspace }}/resolution-artifact", + RESOLUTION_WORKDIR: "${{ github.workspace }}/repo", + RESOLVER_CONFIG_DIR: "${{ github.workspace }}/pi-config", + }); + expect(record(publish.env).ARTIFACT_DIR).toBe("${{ github.workspace }}/resolution-artifact"); + expect(create.run).toContain('--upload "$RESOLUTION_WORKDIR:/sandbox"'); + expect(create.run).toContain('--upload "$RESOLVER_CONFIG_DIR:/sandbox"'); + expect(create.run).toContain("-- /usr/bin/git -C /sandbox/repo status --short"); }); it("publishes only a successfully exported patch and always deletes the sandbox (#7542)", () => { expect(namedStep(resolve, "Create the credential-free sandbox").run).toContain( "--no-git-ignore", ); + const exporter = namedStep(resolve, "Export the Git patch"); + expect(exporter.run).toContain("--workdir /sandbox/repo"); + expect(exporter.run).toContain("/sandbox/resolution.patch"); + const cleanup = namedStep(resolve, "Delete the sandbox"); expect(cleanup.if).toBe("always()"); expect(cleanup.run).toContain("openshell sandbox delete"); diff --git a/test/pr-merge-conflict-fixer.test.ts b/test/pr-merge-conflict-fixer.test.ts index fc3d68f157..401cf3429c 100644 --- a/test/pr-merge-conflict-fixer.test.ts +++ b/test/pr-merge-conflict-fixer.test.ts @@ -36,6 +36,7 @@ function git(repository: string, args: string[]): string { return execFileSync("git", args, { cwd: repository, encoding: "utf8", + env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }, stdio: ["ignore", "pipe", "pipe"], }).trim(); } @@ -46,6 +47,12 @@ function write(repository: string, file: string, content: string): void { fs.writeFileSync(target, content); } +function required(value: T | null | undefined, message: string): T { + expect(value, message).not.toBeNull(); + expect(value, message).toBeDefined(); + return value as T; +} + function createConflictFixture(): { baseSha: string; headSha: string; @@ -88,19 +95,19 @@ function entryFor(fixture: ReturnType): ConflictMa function createResolutionPatch( fixture: ReturnType, patchPath: string, - extraFile = false, + mutateRepository: (repository: string) => void = () => undefined, ): string { const repository = path.join(temporaryDirectory(), "resolver"); - const merge = prepareMerge(fixture.repository, repository, fixture.headSha, fixture.baseSha); - expect(merge?.conflictPaths).toEqual(["conflict.txt"]); + const merge = required( + prepareMerge(fixture.repository, repository, fixture.headSha, fixture.baseSha), + "expected a conflicting merge fixture", + ); + expect(merge.conflictPaths).toEqual(["conflict.txt"]); write(repository, "conflict.txt", "resolved intent\n"); git(repository, ["add", "conflict.txt"]); - if (extraFile) { - write(repository, "unrelated.txt", "not part of the conflict\n"); - git(repository, ["add", "unrelated.txt"]); - } + mutateRepository(repository); const finalTree = writeTree(repository); - const patch = execFileSync("git", ["diff", "--binary", merge?.conflictTree ?? "", finalTree], { + const patch = execFileSync("git", ["diff", "--binary", merge.conflictTree, finalTree], { cwd: repository, }); fs.writeFileSync(patchPath, patch); @@ -189,7 +196,10 @@ describe("PR merge conflict fixer", () => { it("rejects a patch that changes a non-conflict path (#7542)", () => { const fixture = createConflictFixture(); const patchPath = path.join(temporaryDirectory(), "resolution.patch"); - createResolutionPatch(fixture, patchPath, true); + createResolutionPatch(fixture, patchPath, (repository) => { + write(repository, "unrelated.txt", "not part of the conflict\n"); + git(repository, ["add", "unrelated.txt"]); + }); expect(() => validateResolutionPatch({ @@ -251,35 +261,36 @@ describe("PR merge conflict fixer", () => { }, variables, })); - const request = vi.fn(async (method: "GET" | "POST", apiPath: string, body?: unknown) => { - requests.push({ body, method, path: apiPath }); - if (apiPath.endsWith(`/pulls/${entry.pr_number}`)) { - return { - base: { - ref: "main", - repo: { full_name: "NVIDIA/NemoClaw", node_id: "R_repo" }, - }, - head: { - ref: entry.head_ref, - repo: { full_name: "NVIDIA/NemoClaw" }, - }, - state: "open", - }; - } - if (apiPath.endsWith("/git/ref/heads/main")) { - return { object: { sha: entry.base_sha } }; - } - if (apiPath.endsWith("/git/blobs")) { + const responseHandlers: Record unknown> = { + [`/repos/NVIDIA/NemoClaw/pulls/${entry.pr_number}`]: () => ({ + base: { + ref: "main", + repo: { full_name: "NVIDIA/NemoClaw", node_id: "R_repo" }, + }, + head: { + ref: entry.head_ref, + repo: { full_name: "NVIDIA/NemoClaw" }, + }, + state: "open", + }), + "/repos/NVIDIA/NemoClaw/git/ref/heads/main": () => ({ + object: { sha: entry.base_sha }, + }), + "/repos/NVIDIA/NemoClaw/git/blobs": (body) => { const encoded = (body as { content: string }).content; const content = Buffer.from(encoded, "base64"); const header = Buffer.from(`blob ${content.length}\0`); return { sha: createHash("sha1").update(header).update(content).digest("hex") }; - } - if (apiPath.endsWith("/git/trees")) return { sha: finalTree }; - if (apiPath.endsWith("/git/commits")) { - return { sha: commitSha, verification: { reason: "valid", verified: true } }; - } - throw new Error(`unexpected request: ${method} ${apiPath}`); + }, + "/repos/NVIDIA/NemoClaw/git/trees": () => ({ sha: finalTree }), + "/repos/NVIDIA/NemoClaw/git/commits": () => ({ + sha: commitSha, + verification: { reason: "valid", verified: true }, + }), + }; + const request = vi.fn(async (method: "GET" | "POST", apiPath: string, body?: unknown) => { + requests.push({ body, method, path: apiPath }); + return required(responseHandlers[apiPath], `unexpected request: ${method} ${apiPath}`)(body); }); await expect( diff --git a/tools/pr-merge-conflict-fixer/policy.yaml b/tools/pr-merge-conflict-fixer/policy.yaml index 6953332697..e66709aa89 100644 --- a/tools/pr-merge-conflict-fixer/policy.yaml +++ b/tools/pr-merge-conflict-fixer/policy.yaml @@ -4,20 +4,18 @@ version: 1 filesystem_policy: - include_workdir: true + include_workdir: false read_only: - - /usr - - /lib - - /proc - - /dev/urandom + - /usr/bin + - /usr/lib + - /usr/share/git-core - /etc read_write: + - /dev - /sandbox - - /tmp - - /dev/null landlock: - compatibility: best_effort + compatibility: hard_requirement process: run_as_user: sandbox diff --git a/tools/pr-merge-conflict-fixer/publish.mts b/tools/pr-merge-conflict-fixer/publish.mts index 898e2607a5..48ef73bdd9 100755 --- a/tools/pr-merge-conflict-fixer/publish.mts +++ b/tools/pr-merge-conflict-fixer/publish.mts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -358,7 +358,6 @@ async function main(): Promise { const repositoryName = required(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); const artifactDirectory = required(process.env.ARTIFACT_DIR, "ARTIFACT_DIR"); const patchPath = path.join(artifactDirectory, "resolution.patch"); - readFileSync(patchPath); const client = githubClient(token); const commitSha = await publishResolution({ entry, From ef67db00696cb4a7b8ee32f9648e2bb2d0a9d57b Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 25 Jul 2026 20:21:45 -0700 Subject: [PATCH 3/5] refactor: remove unreachable conflict check --- tools/pr-merge-conflict-fixer/publish.mts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tools/pr-merge-conflict-fixer/publish.mts b/tools/pr-merge-conflict-fixer/publish.mts index 48ef73bdd9..9a94b90dd8 100755 --- a/tools/pr-merge-conflict-fixer/publish.mts +++ b/tools/pr-merge-conflict-fixer/publish.mts @@ -13,7 +13,6 @@ import { applyResolutionPatch, ConflictFixerError, listTreeChanges, - listUnmergedPaths, prepareMerge, replaceWithTree, requireSha, @@ -100,11 +99,6 @@ export function validateResolutionPatch(input: { replaceWithTree(merge.repository, merge.conflictTree); applyResolutionPatch(merge.repository, input.patchPath); - const unmergedPaths = listUnmergedPaths(merge.repository); - if (unmergedPaths.length > 0) { - throw new ConflictFixerError("The resolution patch leaves unresolved index entries"); - } - const finalTree = writeTree(merge.repository); const changedPaths = listTreeChanges(merge.repository, merge.conflictTree, finalTree); const allowedPaths = new Set(merge.conflictPaths); From 6f76cab1cf22fab8b0bc81cafdcf4a17f629fe6c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 25 Jul 2026 21:08:49 -0700 Subject: [PATCH 4/5] refactor(ci): move conflict resolver logic out of workflow --- .../workflows/pr-merge-conflict-fixer.yaml | 123 +----- ...e-conflict-fixer-workflow-boundary.test.ts | 71 ++-- test/pr-merge-conflict-fixer.test.ts | 184 +++++++++ tools/pr-merge-conflict-fixer/resolve.mts | 364 +++++++++++++++++- 4 files changed, 570 insertions(+), 172 deletions(-) diff --git a/.github/workflows/pr-merge-conflict-fixer.yaml b/.github/workflows/pr-merge-conflict-fixer.yaml index 8694e15671..8b5c18f00c 100644 --- a/.github/workflows/pr-merge-conflict-fixer.yaml +++ b/.github/workflows/pr-merge-conflict-fixer.yaml @@ -66,7 +66,7 @@ jobs: fetch-depth: 0 path: trusted persist-credentials: false - ref: ${{ matrix.item.base_sha }} + ref: ${{ github.sha }} - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -77,7 +77,7 @@ jobs: name: Reproduce the recorded conflict env: MATRIX_ENTRY: ${{ toJSON(matrix.item) }} - run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" prepare - name: Install OpenShell run: | @@ -88,129 +88,22 @@ jobs: - name: Configure OpenShell inference env: OPENAI_API_KEY: ${{ secrets.PR_REVIEW_ADVISOR_API_KEY }} - run: | - set -euo pipefail - export PATH="$HOME/.local/bin:$PATH" - GATEWAY_DIR="$RUNNER_TEMP/openshell-gateway" - mkdir -p "$GATEWAY_DIR" - openshell-gateway generate-certs --output-dir "$GATEWAY_DIR" - cat >"$GATEWAY_DIR/gateway.toml" <"$GATEWAY_DIR/gateway.log" 2>&1 & - for _ in {1..30}; do - openshell gateway info >/dev/null 2>&1 && break - sleep 1 - done - openshell gateway info - openshell provider create \ - --name terra \ - --type openai \ - --credential OPENAI_API_KEY \ - --config OPENAI_BASE_URL=https://inference-api.nvidia.com/v1 - openshell inference set \ - --provider terra \ - --model azure/openai/gpt-5.6-terra \ - --timeout 900 + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" configure - name: Create the credential-free sandbox - run: | - set -euo pipefail - export PATH="$HOME/.local/bin:$PATH" - openshell sandbox create \ - --name "$SANDBOX_NAME" \ - --from "$PI_IMAGE" \ - --policy "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/policy.yaml" \ - --upload "$RESOLUTION_WORKDIR:/sandbox" \ - --upload "$RESOLVER_CONFIG_DIR:/sandbox" \ - --no-git-ignore \ - --no-tty \ - -- /usr/bin/git -C /sandbox/repo status --short + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" create - name: Run one Pi conflict-resolution task - run: | - set -euo pipefail - export PATH="$HOME/.local/bin:$PATH" - openshell sandbox exec \ - --name "$SANDBOX_NAME" \ - --timeout 1200 \ - --workdir /sandbox/repo \ - --env HOME=/sandbox \ - --env PI_CODING_AGENT_DIR=/sandbox/pi-config \ - --env PI_OFFLINE=1 \ - --env TMPDIR=/sandbox \ - -- \ - /usr/bin/node \ - /usr/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ - --provider openshell \ - --model azure/openai/gpt-5.6-terra \ - --thinking medium \ - --tools read,bash,edit,write,grep,find,ls \ - --no-context-files \ - --no-extensions \ - --no-prompt-templates \ - --no-session \ - --no-skills \ - --no-themes \ - --offline \ - --print @/sandbox/pi-config/task.txt + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" run - name: Export the Git patch env: CONFLICT_TREE: ${{ steps.prepare.outputs.conflict_tree }} - run: | - set -euo pipefail - export PATH="$HOME/.local/bin:$PATH" - openshell sandbox exec \ - --name "$SANDBOX_NAME" \ - --workdir /sandbox/repo \ - --env CONFLICT_TREE="$CONFLICT_TREE" \ - -- \ - /usr/bin/bash -c ' - set -euo pipefail - if test -n "$(git ls-files -u)"; then - echo "Pi did not stage every resolved conflict." >&2 - exit 1 - fi - final_tree="$(git write-tree)" - git diff --binary "$CONFLICT_TREE" "$final_tree" > /sandbox/resolution.patch - ' - mkdir -p "$ARTIFACT_DIR" - openshell sandbox download \ - "$SANDBOX_NAME" \ - /sandbox/resolution.patch \ - "$ARTIFACT_DIR/" + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" export - name: Delete the sandbox if: always() - run: | - set -euo pipefail - export PATH="$HOME/.local/bin:$PATH" - if command -v openshell >/dev/null 2>&1 \ - && openshell sandbox list --names | grep -Fxq -- "$SANDBOX_NAME"; then - openshell sandbox delete "$SANDBOX_NAME" - fi + run: node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" delete - name: Upload the resolution patch if: success() @@ -242,7 +135,7 @@ jobs: fetch-depth: 0 path: trusted persist-credentials: false - ref: ${{ matrix.item.base_sha }} + ref: ${{ github.sha }} - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/test/pr-merge-conflict-fixer-workflow-boundary.test.ts b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts index 733fcf6175..e4a9c902d6 100644 --- a/test/pr-merge-conflict-fixer-workflow-boundary.test.ts +++ b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts @@ -45,6 +45,10 @@ function checkout(job: Record): Record { ); } +function resolverInvocation(command: string): string { + return `node --experimental-strip-types --no-warnings "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/resolve.mts" ${command}`; +} + describe("PR merge conflict fixer workflow boundary", () => { const jobs = record(workflow.jobs); const scan = record(jobs.scan); @@ -64,7 +68,7 @@ describe("PR merge conflict fixer workflow boundary", () => { ).toHaveLength(1); }); - it("pins actions and checks out the recorded base SHA (#7542)", () => { + it("loads each resolve command from the pushed main SHA (#6952)", () => { const actionReferences = [scan, resolve, publish] .flatMap((job) => steps(job)) .map((step) => step.uses) @@ -74,19 +78,26 @@ describe("PR merge conflict fixer workflow boundary", () => { actionReferences.every((reference) => /^[^@\s]+\/[^@\s]+@[0-9a-f]{40}$/u.test(reference)), ).toBe(true); - expect(checkout(scan).with).toMatchObject({ - "persist-credentials": false, - ref: "${{ github.sha }}", - }); - for (const job of [resolve, publish]) { + for (const job of [scan, resolve, publish]) { expect(checkout(job).with).toMatchObject({ "persist-credentials": false, - ref: "${{ matrix.item.base_sha }}", + ref: "${{ github.sha }}", }); } expect(namedStep(publish, "Validate and publish the merge commit").run).toContain( "$TRUSTED_CHECKOUT/tools/pr-merge-conflict-fixer/publish.mts", ); + + for (const [name, command] of [ + ["Reproduce the recorded conflict", "prepare"], + ["Configure OpenShell inference", "configure"], + ["Create the credential-free sandbox", "create"], + ["Run one Pi conflict-resolution task", "run"], + ["Export the Git patch", "export"], + ["Delete the sandbox", "delete"], + ]) { + expect(namedStep(resolve, name).run).toBe(resolverInvocation(command)); + } }); it("keeps credentials and direct network egress out of Pi (#7542)", () => { @@ -94,32 +105,15 @@ describe("PR merge conflict fixer workflow boundary", () => { expect(configure.env).toEqual({ OPENAI_API_KEY: "${{ secrets.PR_REVIEW_ADVISOR_API_KEY }}", }); - expect(configure.run).toContain("--credential OPENAI_API_KEY"); - expect(configure.run).toContain("--model azure/openai/gpt-5.6-terra"); - expect(configure.run).toContain("openshell-gateway generate-certs"); - expect(configure.run).toContain("allow_unauthenticated_users = true"); - expect(configure.run).toContain("supervisor_bin ="); - expect(record(resolve.env).OPENSHELL_GATEWAY_ENDPOINT).toBe("http://127.0.0.1:8080"); + expect(configure.run).toBe(resolverInvocation("configure")); const pi = namedStep(resolve, "Run one Pi conflict-resolution task"); expect(pi.env).toBeUndefined(); - for (const option of [ - "--model azure/openai/gpt-5.6-terra", - "--no-context-files", - "--no-extensions", - "--no-prompt-templates", - "--no-session", - "--no-skills", - "--no-themes", - "--offline", - ]) { - expect(pi.run).toContain(option); - } - expect(pi.run).toContain("--workdir /sandbox/repo"); - expect(pi.run).toContain("--env PI_CODING_AGENT_DIR=/sandbox/pi-config"); - expect(pi.run).toContain("/usr/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"); - expect(pi.run).toContain("--print @/sandbox/pi-config/task.txt"); - expect(String(pi.run)).not.toContain("GITHUB_TOKEN"); + expect(pi.run).toBe(resolverInvocation("run")); + expect(namedStep(resolve, "Install OpenShell").run).toContain( + "env -u GITHUB_TOKEN -u GH_TOKEN -u PR_REVIEW_ADVISOR_API_KEY", + ); + expect(record(resolve.env).OPENSHELL_GATEWAY_ENDPOINT).toBe("http://127.0.0.1:8080"); expect(record(resolve.env).PI_IMAGE).toBe( "ghcr.io/nvidia/openshell-community/sandboxes/pi@sha256:00d0c5e9e733f94f6db3eaa2ab70d4fd75bcc4aace6b13a54535cbf2dd20dfcd", ); @@ -140,30 +134,25 @@ describe("PR merge conflict fixer workflow boundary", () => { }); expect(policy.landlock).toEqual({ compatibility: "hard_requirement" }); expect(policy.process).toEqual({ run_as_group: "sandbox", run_as_user: "sandbox" }); - - const create = namedStep(resolve, "Create the credential-free sandbox"); expect(record(resolve.env)).toMatchObject({ ARTIFACT_DIR: "${{ github.workspace }}/resolution-artifact", RESOLUTION_WORKDIR: "${{ github.workspace }}/repo", RESOLVER_CONFIG_DIR: "${{ github.workspace }}/pi-config", + TRUSTED_CHECKOUT: "${{ github.workspace }}/trusted", }); expect(record(publish.env).ARTIFACT_DIR).toBe("${{ github.workspace }}/resolution-artifact"); - expect(create.run).toContain('--upload "$RESOLUTION_WORKDIR:/sandbox"'); - expect(create.run).toContain('--upload "$RESOLVER_CONFIG_DIR:/sandbox"'); - expect(create.run).toContain("-- /usr/bin/git -C /sandbox/repo status --short"); }); it("publishes only a successfully exported patch and always deletes the sandbox (#7542)", () => { - expect(namedStep(resolve, "Create the credential-free sandbox").run).toContain( - "--no-git-ignore", - ); const exporter = namedStep(resolve, "Export the Git patch"); - expect(exporter.run).toContain("--workdir /sandbox/repo"); - expect(exporter.run).toContain("/sandbox/resolution.patch"); + expect(exporter.env).toEqual({ + CONFLICT_TREE: "${{ steps.prepare.outputs.conflict_tree }}", + }); + expect(exporter.run).toBe(resolverInvocation("export")); const cleanup = namedStep(resolve, "Delete the sandbox"); expect(cleanup.if).toBe("always()"); - expect(cleanup.run).toContain("openshell sandbox delete"); + expect(cleanup.run).toBe(resolverInvocation("delete")); const upload = namedStep(resolve, "Upload the resolution patch"); const download = namedStep(publish, "Download the resolution patch"); diff --git a/test/pr-merge-conflict-fixer.test.ts b/test/pr-merge-conflict-fixer.test.ts index 401cf3429c..a8ee84260d 100644 --- a/test/pr-merge-conflict-fixer.test.ts +++ b/test/pr-merge-conflict-fixer.test.ts @@ -20,8 +20,14 @@ import { validateResolutionPatch, } from "../tools/pr-merge-conflict-fixer/publish.mts"; import { + configureOpenShellInference, + createResolutionSandbox, + deleteResolutionSandbox, + exportResolutionPatch, + type ResolverTools, resolverModelConfiguration, resolverPrompt, + runResolutionTask, } from "../tools/pr-merge-conflict-fixer/resolve.mts"; const temporaryDirectories: string[] = []; @@ -53,6 +59,35 @@ function required(value: T | null | undefined, message: string): T { return value as T; } +function resolverEnvironment(): NodeJS.ProcessEnv { + const directory = temporaryDirectory(); + return { + ARTIFACT_DIR: path.join(directory, "artifact"), + CONFLICT_TREE: "a".repeat(40), + GH_TOKEN: "gh-secret", + GITHUB_TOKEN: "github-secret", + HOME: path.join(directory, "home"), + OPENAI_API_KEY: "provider-secret", + OPENSHELL_GATEWAY_ENDPOINT: "http://127.0.0.1:8080", + PATH: "/usr/bin", + PI_IMAGE: "pi-image", + PR_REVIEW_ADVISOR_API_KEY: "advisor-secret", + RESOLUTION_WORKDIR: "/resolution", + RESOLVER_CONFIG_DIR: "/config", + RUNNER_TEMP: directory, + SANDBOX_NAME: "sandbox-test", + TRUSTED_CHECKOUT: "/trusted", + }; +} + +function resolverTools(outputs: string[] = []): ResolverTools { + return { + run: vi.fn(() => outputs.shift() ?? ""), + start: vi.fn(), + wait: vi.fn(async () => undefined), + }; +} + function createConflictFixture(): { baseSha: string; headSha: string; @@ -328,6 +363,155 @@ describe("PR merge conflict fixer", () => { expect(requests.filter((item) => item.path.endsWith("/git/ref/heads/main"))).toHaveLength(1); }); + it("configures approved inference through a loopback gateway (#7542)", async () => { + const env = resolverEnvironment(); + const tools = resolverTools(["/trusted/bin/openshell-sandbox"]); + + await configureOpenShellInference(env, tools); + + const gatewayDirectory = path.join( + required(env.RUNNER_TEMP, "RUNNER_TEMP"), + "openshell-gateway", + ); + const configurationPath = path.join(gatewayDirectory, "gateway.toml"); + const configuration = fs.readFileSync(configurationPath, "utf8"); + expect(configuration).toContain('bind_address = "127.0.0.1:8080"'); + expect(configuration).toContain("allow_unauthenticated_users = true"); + expect(configuration).toContain('supervisor_bin = "/trusted/bin/openshell-sandbox"'); + expect(configuration).not.toContain("provider-secret"); + expect(fs.statSync(configurationPath).mode & 0o777).toBe(0o600); + + const run = vi.mocked(tools.run); + expect(run).toHaveBeenCalledWith( + "openshell", + [ + "provider", + "create", + "--name", + "terra", + "--type", + "openai", + "--credential", + "OPENAI_API_KEY", + "--config", + "OPENAI_BASE_URL=https://inference-api.nvidia.com/v1", + ], + expect.objectContaining({ + env: expect.objectContaining({ OPENAI_API_KEY: "provider-secret" }), + }), + ); + expect(run).toHaveBeenCalledWith( + "openshell", + [ + "inference", + "set", + "--provider", + "terra", + "--model", + "azure/openai/gpt-5.6-terra", + "--timeout", + "900", + ], + expect.anything(), + ); + expect(vi.mocked(tools.start)).toHaveBeenCalledWith( + "openshell-gateway", + ["--config", configurationPath], + expect.objectContaining({ + env: expect.not.objectContaining({ OPENAI_API_KEY: expect.anything() }), + logPath: path.join(gatewayDirectory, "gateway.log"), + }), + ); + expect(run.mock.calls.filter(([, , options]) => options.env.OPENAI_API_KEY)).toHaveLength(1); + expect( + run.mock.calls.map(([command, args]) => [command, ...args].join(" ")).join("\n"), + ).not.toContain("provider-secret"); + }); + + it("runs sandbox phases without host credentials (#7542)", () => { + const env = resolverEnvironment(); + const tools = resolverTools(["", "", "", "", "sandbox-test\n", ""]); + + createResolutionSandbox(env, tools); + runResolutionTask(env, tools); + exportResolutionPatch(env, tools); + deleteResolutionSandbox(env, tools); + + const calls = vi.mocked(tools.run).mock.calls; + expect(calls).toHaveLength(6); + expect(required(calls[0], "missing sandbox create call")[1]).toEqual( + expect.arrayContaining([ + "sandbox", + "create", + "--from", + "pi-image", + "--policy", + "/trusted/tools/pr-merge-conflict-fixer/policy.yaml", + "--upload", + "/resolution:/sandbox", + "--upload", + "/config:/sandbox", + "--no-git-ignore", + ]), + ); + expect(required(calls[1], "missing Pi task call")[1]).toEqual( + expect.arrayContaining([ + "sandbox", + "exec", + "--workdir", + "/sandbox/repo", + "PI_CODING_AGENT_DIR=/sandbox/pi-config", + "--model", + "azure/openai/gpt-5.6-terra", + "--no-context-files", + "--no-skills", + "--offline", + ]), + ); + const exportArgs = required(calls[2], "missing patch export call")[1]; + expect(exportArgs).toEqual( + expect.arrayContaining([ + "sandbox", + "exec", + "CONFLICT_TREE=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "/usr/bin/bash", + "-c", + ]), + ); + expect(exportArgs.join("\n")).toContain("git ls-files -u"); + expect(exportArgs.join("\n")).toContain("git diff --binary"); + expect(required(calls[3], "missing patch download call")[1]).toEqual([ + "sandbox", + "download", + "sandbox-test", + "/sandbox/resolution.patch", + `${required(env.ARTIFACT_DIR, "ARTIFACT_DIR")}/`, + ]); + expect(required(calls[4], "missing sandbox list call")[2].capture).toBe(true); + expect(required(calls[5], "missing sandbox delete call")[1]).toEqual([ + "sandbox", + "delete", + "sandbox-test", + ]); + for (const [, , options] of calls) { + expect(options.env.GH_TOKEN).toBeUndefined(); + expect(options.env.GITHUB_TOKEN).toBeUndefined(); + expect(options.env.OPENAI_API_KEY).toBeUndefined(); + expect(options.env.PR_REVIEW_ADVISOR_API_KEY).toBeUndefined(); + } + expect(fs.existsSync(required(env.ARTIFACT_DIR, "ARTIFACT_DIR"))).toBe(true); + }); + + it("skips sandbox cleanup when OpenShell is unavailable (#7542)", () => { + const tools = resolverTools(); + vi.mocked(tools.run).mockImplementation(() => { + throw new Error("openshell unavailable"); + }); + + expect(() => deleteResolutionSandbox(resolverEnvironment(), tools)).not.toThrow(); + expect(tools.run).toHaveBeenCalledOnce(); + }); + it("configures Pi for credential-free OpenShell inference (#7542)", () => { const config = JSON.parse(resolverModelConfiguration()); expect(config.providers.openshell).toMatchObject({ diff --git a/tools/pr-merge-conflict-fixer/resolve.mts b/tools/pr-merge-conflict-fixer/resolve.mts index 17d1a71923..324a9124b1 100755 --- a/tools/pr-merge-conflict-fixer/resolve.mts +++ b/tools/pr-merge-conflict-fixer/resolve.mts @@ -2,14 +2,68 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { execFileSync, spawn } from "node:child_process"; +import { appendFileSync, closeSync, mkdirSync, openSync, writeFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { type ConflictMatrixEntry, parseConflictMatrixEntry } from "./discover.mts"; import { ConflictFixerError, prepareMerge, samePaths } from "./merge.mts"; -const MODEL_ID = "azure/openai/gpt-5.6-terra"; +export const RESOLVER_MODEL_ID = "azure/openai/gpt-5.6-terra"; + +const HOST_CREDENTIALS = [ + "GH_TOKEN", + "GITHUB_TOKEN", + "OPENAI_API_KEY", + "PR_REVIEW_ADVISOR_API_KEY", +] as const; +const PI_COMMAND = [ + "/usr/bin/node", + "/usr/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + "--provider", + "openshell", + "--model", + RESOLVER_MODEL_ID, + "--thinking", + "medium", + "--tools", + "read,bash,edit,write,grep,find,ls", + "--no-context-files", + "--no-extensions", + "--no-prompt-templates", + "--no-session", + "--no-skills", + "--no-themes", + "--offline", + "--print", + "@/sandbox/pi-config/task.txt", +] as const; +const EXPORT_PATCH_COMMAND = ` +set -euo pipefail +if test -n "$(git ls-files -u)"; then + echo "Pi did not stage every resolved conflict." >&2 + exit 1 +fi +final_tree="$(git write-tree)" +git diff --binary "$CONFLICT_TREE" "$final_tree" > /sandbox/resolution.patch +`.trim(); + +export interface ResolverCommandOptions { + capture?: boolean; + env: NodeJS.ProcessEnv; +} + +export interface ResolverStartOptions { + env: NodeJS.ProcessEnv; + logPath: string; +} + +export interface ResolverTools { + run: (command: string, args: readonly string[], options: ResolverCommandOptions) => string; + start: (command: string, args: readonly string[], options: ResolverStartOptions) => void; + wait: (milliseconds: number) => Promise; +} function required(value: string | undefined, name: string): string { if (!value) throw new ConflictFixerError(`${name} is required`); @@ -36,7 +90,7 @@ export function resolverModelConfiguration(): string { { contextWindow: 256000, cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 }, - id: MODEL_ID, + id: RESOLVER_MODEL_ID, input: ["text"], maxTokens: 32768, name: "GPT-5.6 Terra", @@ -51,6 +105,35 @@ export function resolverModelConfiguration(): string { )}\n`; } +export function gatewayConfiguration(input: { + bindAddress: string; + directory: string; + supervisor: string; +}): string { + return `[openshell] +version = 1 + +[openshell.gateway] +bind_address = "${input.bindAddress}" +compute_drivers = ["docker"] +disable_tls = true + +[openshell.gateway.auth] +allow_unauthenticated_users = true + +[openshell.gateway.gateway_jwt] +signing_key_path = "${path.join(input.directory, "jwt", "signing.pem")}" +public_key_path = "${path.join(input.directory, "jwt", "public.pem")}" +kid_path = "${path.join(input.directory, "jwt", "kid")}" +gateway_id = "pr-conflict-fixer" +ttl_secs = 3600 + +[openshell.drivers.docker] +grpc_endpoint = "http://host.openshell.internal:8080" +supervisor_bin = "${input.supervisor}" +`; +} + export function resolverPrompt(): string { return [ "Resolve the Git merge conflicts in this repository.", @@ -90,25 +173,274 @@ export function prepareResolutionWorkspace(input: { return merge.conflictTree; } -function main(): void { - const entry = parseConflictMatrixEntry(required(process.env.MATRIX_ENTRY, "MATRIX_ENTRY")); +function openshellEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const home = required(env.HOME, "HOME"); + const binaryDirectory = env.XDG_BIN_HOME ?? path.join(home, ".local", "bin"); + return { + ...env, + PATH: [binaryDirectory, env.PATH ?? ""].filter(Boolean).join(path.delimiter), + }; +} + +function credentialFreeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result = openshellEnvironment(env); + for (const name of HOST_CREDENTIALS) delete result[name]; + return result; +} + +const defaultTools: ResolverTools = { + run(command, args, options): string { + const output = execFileSync(command, [...args], { + encoding: "utf8", + env: options.env, + stdio: options.capture ? ["ignore", "pipe", "inherit"] : "inherit", + }); + return String(output ?? "").trim(); + }, + start(command, args, options): void { + const log = openSync(options.logPath, "w", 0o600); + try { + const child = spawn(command, [...args], { + detached: true, + env: options.env, + stdio: ["ignore", log, log], + }); + child.on("error", () => undefined); + child.unref(); + } finally { + closeSync(log); + } + }, + wait(milliseconds): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + }, +}; + +export async function configureOpenShellInference( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): Promise { + const providerApiKey = required(env.OPENAI_API_KEY, "OPENAI_API_KEY"); + const commandEnv = credentialFreeEnvironment(env); + const providerEnv = { ...commandEnv, OPENAI_API_KEY: providerApiKey }; + const gatewayDirectory = path.join(required(env.RUNNER_TEMP, "RUNNER_TEMP"), "openshell-gateway"); + const gatewayEndpoint = new URL( + required(env.OPENSHELL_GATEWAY_ENDPOINT, "OPENSHELL_GATEWAY_ENDPOINT"), + ); + const supervisor = required( + tools.run("which", ["openshell-sandbox"], { capture: true, env: commandEnv }), + "openshell-sandbox", + ); + mkdirSync(gatewayDirectory, { recursive: true }); + tools.run("openshell-gateway", ["generate-certs", "--output-dir", gatewayDirectory], { + env: commandEnv, + }); + const configurationPath = path.join(gatewayDirectory, "gateway.toml"); + writeFileSync( + configurationPath, + gatewayConfiguration({ + bindAddress: gatewayEndpoint.host, + directory: gatewayDirectory, + supervisor, + }), + { mode: 0o600 }, + ); + tools.start("openshell-gateway", ["--config", configurationPath], { + env: commandEnv, + logPath: path.join(gatewayDirectory, "gateway.log"), + }); + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + tools.run("openshell", ["gateway", "info"], { env: commandEnv }); + break; + } catch { + await tools.wait(1000); + } + } + tools.run("openshell", ["gateway", "info"], { env: commandEnv }); + tools.run( + "openshell", + [ + "provider", + "create", + "--name", + "terra", + "--type", + "openai", + "--credential", + "OPENAI_API_KEY", + "--config", + "OPENAI_BASE_URL=https://inference-api.nvidia.com/v1", + ], + { env: providerEnv }, + ); + tools.run( + "openshell", + ["inference", "set", "--provider", "terra", "--model", RESOLVER_MODEL_ID, "--timeout", "900"], + { env: commandEnv }, + ); +} + +export function createResolutionSandbox( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): void { + tools.run( + "openshell", + [ + "sandbox", + "create", + "--name", + required(env.SANDBOX_NAME, "SANDBOX_NAME"), + "--from", + required(env.PI_IMAGE, "PI_IMAGE"), + "--policy", + path.join( + required(env.TRUSTED_CHECKOUT, "TRUSTED_CHECKOUT"), + "tools", + "pr-merge-conflict-fixer", + "policy.yaml", + ), + "--upload", + `${required(env.RESOLUTION_WORKDIR, "RESOLUTION_WORKDIR")}:/sandbox`, + "--upload", + `${required(env.RESOLVER_CONFIG_DIR, "RESOLVER_CONFIG_DIR")}:/sandbox`, + "--no-git-ignore", + "--no-tty", + "--", + "/usr/bin/git", + "-C", + "/sandbox/repo", + "status", + "--short", + ], + { env: credentialFreeEnvironment(env) }, + ); +} + +export function runResolutionTask( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): void { + tools.run( + "openshell", + [ + "sandbox", + "exec", + "--name", + required(env.SANDBOX_NAME, "SANDBOX_NAME"), + "--timeout", + "1200", + "--workdir", + "/sandbox/repo", + "--env", + "HOME=/sandbox", + "--env", + "PI_CODING_AGENT_DIR=/sandbox/pi-config", + "--env", + "PI_OFFLINE=1", + "--env", + "TMPDIR=/sandbox", + "--", + ...PI_COMMAND, + ], + { env: credentialFreeEnvironment(env) }, + ); +} + +export function exportResolutionPatch( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): void { + const commandEnv = credentialFreeEnvironment(env); + const sandboxName = required(env.SANDBOX_NAME, "SANDBOX_NAME"); + tools.run( + "openshell", + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--workdir", + "/sandbox/repo", + "--env", + `CONFLICT_TREE=${required(env.CONFLICT_TREE, "CONFLICT_TREE")}`, + "--", + "/usr/bin/bash", + "-c", + EXPORT_PATCH_COMMAND, + ], + { env: commandEnv }, + ); + const artifactDirectory = required(env.ARTIFACT_DIR, "ARTIFACT_DIR"); + mkdirSync(artifactDirectory, { recursive: true }); + tools.run( + "openshell", + ["sandbox", "download", sandboxName, "/sandbox/resolution.patch", `${artifactDirectory}/`], + { env: commandEnv }, + ); +} + +export function deleteResolutionSandbox( + env: NodeJS.ProcessEnv, + tools: ResolverTools = defaultTools, +): void { + const commandEnv = credentialFreeEnvironment(env); + const sandboxName = required(env.SANDBOX_NAME, "SANDBOX_NAME"); + let names: string; + try { + names = tools.run("openshell", ["sandbox", "list", "--names"], { + capture: true, + env: commandEnv, + }); + } catch { + return; + } + if (names.split(/\r?\n/u).includes(sandboxName)) { + tools.run("openshell", ["sandbox", "delete", sandboxName], { env: commandEnv }); + } +} + +function prepare(env: NodeJS.ProcessEnv): void { + const entry = parseConflictMatrixEntry(required(env.MATRIX_ENTRY, "MATRIX_ENTRY")); const conflictTree = prepareResolutionWorkspace({ - configDirectory: required(process.env.RESOLVER_CONFIG_DIR, "RESOLVER_CONFIG_DIR"), + configDirectory: required(env.RESOLVER_CONFIG_DIR, "RESOLVER_CONFIG_DIR"), entry, - sourceRepository: required(process.env.TRUSTED_CHECKOUT, "TRUSTED_CHECKOUT"), - workDirectory: required(process.env.RESOLUTION_WORKDIR, "RESOLUTION_WORKDIR"), + sourceRepository: required(env.TRUSTED_CHECKOUT, "TRUSTED_CHECKOUT"), + workDirectory: required(env.RESOLUTION_WORKDIR, "RESOLUTION_WORKDIR"), }); - appendFileSync( - required(process.env.GITHUB_OUTPUT, "GITHUB_OUTPUT"), - `conflict_tree=${conflictTree}\n`, - ); + appendFileSync(required(env.GITHUB_OUTPUT, "GITHUB_OUTPUT"), `conflict_tree=${conflictTree}\n`); +} + +async function main(): Promise { + const command = required(process.argv[2], "resolve command"); + switch (command) { + case "prepare": + prepare(process.env); + return; + case "configure": + await configureOpenShellInference(process.env); + return; + case "create": + createResolutionSandbox(process.env); + return; + case "run": + runResolutionTask(process.env); + return; + case "export": + exportResolutionPatch(process.env); + return; + case "delete": + deleteResolutionSandbox(process.env); + return; + default: + throw new ConflictFixerError(`Unsupported resolve command: ${command}`); + } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - main(); - } catch (error) { + main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); - } + }); } From c2117daa622481409280f47dfd9b015002a9ceaf Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 25 Jul 2026 21:24:51 -0700 Subject: [PATCH 5/5] fix(ci): bound conflict resolver gateway --- test/pr-merge-conflict-fixer.test.ts | 17 +++++++++++++++++ tools/pr-merge-conflict-fixer/resolve.mts | 18 ++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/test/pr-merge-conflict-fixer.test.ts b/test/pr-merge-conflict-fixer.test.ts index a8ee84260d..68b93675bc 100644 --- a/test/pr-merge-conflict-fixer.test.ts +++ b/test/pr-merge-conflict-fixer.test.ts @@ -423,11 +423,28 @@ describe("PR merge conflict fixer", () => { }), ); expect(run.mock.calls.filter(([, , options]) => options.env.OPENAI_API_KEY)).toHaveLength(1); + const gatewayInfoCalls = run.mock.calls.filter( + ([, args]) => args[0] === "gateway" && args[1] === "info", + ); + expect(gatewayInfoCalls).toHaveLength(2); + expect(gatewayInfoCalls.map(([, , options]) => options.timeout)).toEqual([10_000, 10_000]); expect( run.mock.calls.map(([command, args]) => [command, ...args].join(" ")).join("\n"), ).not.toContain("provider-secret"); }); + it("rejects a non-loopback unauthenticated gateway (#7542)", async () => { + const env = resolverEnvironment(); + env.OPENSHELL_GATEWAY_ENDPOINT = "http://192.0.2.1:8080"; + const tools = resolverTools(); + + await expect(configureOpenShellInference(env, tools)).rejects.toThrow( + "OPENSHELL_GATEWAY_ENDPOINT must use a loopback address", + ); + expect(tools.run).not.toHaveBeenCalled(); + expect(tools.start).not.toHaveBeenCalled(); + }); + it("runs sandbox phases without host credentials (#7542)", () => { const env = resolverEnvironment(); const tools = resolverTools(["", "", "", "", "sandbox-test\n", ""]); diff --git a/tools/pr-merge-conflict-fixer/resolve.mts b/tools/pr-merge-conflict-fixer/resolve.mts index 324a9124b1..7bbabe4b39 100755 --- a/tools/pr-merge-conflict-fixer/resolve.mts +++ b/tools/pr-merge-conflict-fixer/resolve.mts @@ -52,6 +52,7 @@ git diff --binary "$CONFLICT_TREE" "$final_tree" > /sandbox/resolution.patch export interface ResolverCommandOptions { capture?: boolean; env: NodeJS.ProcessEnv; + timeout?: number; } export interface ResolverStartOptions { @@ -105,7 +106,7 @@ export function resolverModelConfiguration(): string { )}\n`; } -export function gatewayConfiguration(input: { +function gatewayConfiguration(input: { bindAddress: string; directory: string; supervisor: string; @@ -188,12 +189,20 @@ function credentialFreeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +function loopbackBindAddress(endpoint: URL): string { + if (!["127.0.0.1", "[::1]"].includes(endpoint.hostname)) { + throw new ConflictFixerError("OPENSHELL_GATEWAY_ENDPOINT must use a loopback address"); + } + return endpoint.host; +} + const defaultTools: ResolverTools = { run(command, args, options): string { const output = execFileSync(command, [...args], { encoding: "utf8", env: options.env, stdio: options.capture ? ["ignore", "pipe", "inherit"] : "inherit", + timeout: options.timeout, }); return String(output ?? "").trim(); }, @@ -227,6 +236,7 @@ export async function configureOpenShellInference( const gatewayEndpoint = new URL( required(env.OPENSHELL_GATEWAY_ENDPOINT, "OPENSHELL_GATEWAY_ENDPOINT"), ); + const bindAddress = loopbackBindAddress(gatewayEndpoint); const supervisor = required( tools.run("which", ["openshell-sandbox"], { capture: true, env: commandEnv }), "openshell-sandbox", @@ -239,7 +249,7 @@ export async function configureOpenShellInference( writeFileSync( configurationPath, gatewayConfiguration({ - bindAddress: gatewayEndpoint.host, + bindAddress, directory: gatewayDirectory, supervisor, }), @@ -251,13 +261,13 @@ export async function configureOpenShellInference( }); for (let attempt = 0; attempt < 30; attempt += 1) { try { - tools.run("openshell", ["gateway", "info"], { env: commandEnv }); + tools.run("openshell", ["gateway", "info"], { env: commandEnv, timeout: 10_000 }); break; } catch { await tools.wait(1000); } } - tools.run("openshell", ["gateway", "info"], { env: commandEnv }); + tools.run("openshell", ["gateway", "info"], { env: commandEnv, timeout: 10_000 }); tools.run( "openshell", [