diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts new file mode 100644 index 000000000..b749929f9 --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const createSignedCommit = vi.fn(); + +vi.mock("@posthog/git/signed-commit", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createSignedCommit: (...args: unknown[]) => createSignedCommit(...args), + }; +}); + +// Importing the tool after the mock so its transitive `createSignedCommit` +// reference resolves to the mock above. +const { signedCommitTool } = await import("./signed-commit"); + +describe("signed-commit tool handler", () => { + const savedSandbox = process.env.IS_SANDBOX; + + beforeEach(() => { + createSignedCommit.mockReset(); + createSignedCommit.mockResolvedValue({ + branch: "posthog-code/feature", + commits: [ + { sha: "deadbeef", url: "https://github.com/x/y/commit/deadbeef" }, + ], + }); + }); + + afterEach(() => { + if (savedSandbox === undefined) { + delete process.env.IS_SANDBOX; + } else { + process.env.IS_SANDBOX = savedSandbox; + } + }); + + it("defaults to the session cwd when args.cwd is absent", async () => { + await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code", token: "ghs_x" }, + { message: "chore: bump" }, + ); + const [ctx] = createSignedCommit.mock.calls[0]; + expect(ctx.cwd).toBe("/tmp/workspace/repos/posthog/code"); + }); + + it("uses an absolute args.cwd verbatim so a sibling clone is reachable", async () => { + await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code", token: "ghs_x" }, + { + message: "chore: bump", + cwd: "/tmp/workspace/repos/posthog/posthog", + }, + ); + const [ctx] = createSignedCommit.mock.calls[0]; + expect(ctx.cwd).toBe("/tmp/workspace/repos/posthog/posthog"); + }); + + it("resolves a relative args.cwd against the session cwd", async () => { + await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code", token: "ghs_x" }, + { message: "chore: bump", cwd: "../posthog" }, + ); + const [ctx] = createSignedCommit.mock.calls[0]; + expect(ctx.cwd).toBe("/tmp/workspace/repos/posthog/posthog"); + }); + + it("does not forward cwd to createSignedCommit input", async () => { + await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code", token: "ghs_x" }, + { message: "chore: bump", cwd: "/elsewhere" }, + ); + const [, input] = createSignedCommit.mock.calls[0]; + expect(input).not.toHaveProperty("cwd"); + expect(input).toEqual({ message: "chore: bump" }); + }); + + it("returns the no-token error without invoking createSignedCommit", async () => { + const savedGh = process.env.GH_TOKEN; + const savedGithub = process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + try { + const result = await signedCommitTool.handler( + { cwd: "/tmp/workspace/repos/posthog/code" }, + { message: "chore: bump" }, + ); + expect(result.isError).toBe(true); + expect(createSignedCommit).not.toHaveBeenCalled(); + } finally { + if (savedGh !== undefined) process.env.GH_TOKEN = savedGh; + if (savedGithub !== undefined) process.env.GITHUB_TOKEN = savedGithub; + } + }); +}); diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.ts index 24b78e5f0..0b82a8f5f 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-commit.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.ts @@ -1,3 +1,4 @@ +import * as path from "node:path"; import { isCloudRun, resolveGithubToken } from "../../../utils/common"; import { runSignedCommitTool, @@ -33,9 +34,12 @@ export const signedCommitTool = defineLocalTool({ isError: true, }); } - return runSignedCommitTool( - { cwd: ctx.cwd, token, taskId: ctx.taskId }, - args, - ); + // Resolve an explicit `cwd` arg against the session cwd so the agent can + // commit from any clone reachable in the sandbox, not just the one the + // session was rooted at. Absolute paths fall through `path.resolve` + // unchanged; relative paths join the session cwd. + const { cwd: argCwd, ...input } = args; + const cwd = argCwd ? path.resolve(ctx.cwd, argCwd) : ctx.cwd; + return runSignedCommitTool({ cwd, token, taskId: ctx.taskId }, input); }, }); diff --git a/packages/agent/src/adapters/signed-commit-shared.ts b/packages/agent/src/adapters/signed-commit-shared.ts index d1014031a..4f7c24fe4 100644 --- a/packages/agent/src/adapters/signed-commit-shared.ts +++ b/packages/agent/src/adapters/signed-commit-shared.ts @@ -41,6 +41,14 @@ export const signedCommitToolSchema = { .describe( "Files to stage before committing; defaults to already-staged files.", ), + cwd: z + .string() + .optional() + .describe( + "Path to the git checkout to commit from; defaults to the session's working directory. " + + "Pass this when committing to a clone outside the session cwd (e.g. a sibling repo cloned during the run). " + + "Relative paths resolve against the session cwd.", + ), }; export function formatSignedCommitResult(result: SignedCommitResult): string {