diff --git a/packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts b/packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts index 5b5d535d0a..6dc802d773 100644 --- a/packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts @@ -69,6 +69,12 @@ vi.mock("./mcp/tool-metadata", () => ({ getMcpToolMetadata: vi.fn().mockReturnValue(undefined), })); +const createLocalToolsMcpServer = vi.hoisted(() => vi.fn(() => undefined)); + +vi.mock("./mcp/local-tools", () => ({ + createLocalToolsMcpServer, +})); + // Import after the mocks so ClaudeAcpAgent resolves the mocked SDK const { ClaudeAcpAgent } = await import("./claude-agent"); type Agent = InstanceType; @@ -135,6 +141,7 @@ describe("ClaudeAcpAgent session creation", () => { commands: [], models: [], }); + createLocalToolsMcpServer.mockClear(); // No gateway: fetchGatewayModels returns [] and the requested model is // kept as a custom option — mirrors the gateway-outage failure mode. delete process.env.ANTHROPIC_BASE_URL; @@ -289,6 +296,28 @@ describe("ClaudeAcpAgent session creation", () => { }, ); + it("passes repository-less mode to the local-tools server", async () => { + const agent = makeAgent(); + + await agent.newSession({ + cwd, + mcpServers: [], + _meta: { + environment: "cloud", + channelMode: true, + taskRunId: "run-channel", + }, + }); + + expect(createLocalToolsMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ cwd }), + expect.objectContaining({ + environment: "cloud", + channelMode: true, + }), + ); + }); + // The SDK does not carry the model across resume — without an explicit // setModel the resumed session silently runs the SDK default (opus). it.each([ diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index e07a2c404f..b0984bac65 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -1979,6 +1979,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // needs so the session doesn't pin the whole meta object. const baseBranch = meta?.baseBranch; const environment = meta?.environment; + const channelMode = meta?.channelMode; const spokenNarration = resolveSpokenNarration(meta); const requestFinish = this.buildRequestFinish(taskId, meta?.taskRunId); const buildInProcessMcpServers = (): Record< @@ -1996,6 +1997,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, { environment, + channelMode, spokenNarration, background: meta?.mode === "background", }, diff --git a/packages/agent/src/adapters/claude/mcp/local-tools.test.ts b/packages/agent/src/adapters/claude/mcp/local-tools.test.ts index 2525432245..ed66813da9 100644 --- a/packages/agent/src/adapters/claude/mcp/local-tools.test.ts +++ b/packages/agent/src/adapters/claude/mcp/local-tools.test.ts @@ -79,4 +79,27 @@ describe("createLocalToolsMcpServer", () => { await client.close(); }); + + it("exposes lazy repository tools in a repository-less cloud run", async () => { + const server = createLocalToolsMcpServer( + { cwd: "/tmp/workspace", token: "ghs_x" }, + { environment: "cloud", channelMode: true }, + ); + if (!server) { + throw new Error("expected the local-tools server to be registered"); + } + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.instance.connect(serverTransport); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(clientTransport); + + const { tools } = await client.listTools(); + const names = tools.map((tool) => tool.name); + expect(names).toContain("list_repos"); + expect(names).toContain("clone_repo"); + + await client.close(); + }); }); diff --git a/packages/agent/src/adapters/claude/tools.test.ts b/packages/agent/src/adapters/claude/tools.test.ts index 6f87485db4..7686269f2a 100644 --- a/packages/agent/src/adapters/claude/tools.test.ts +++ b/packages/agent/src/adapters/claude/tools.test.ts @@ -18,12 +18,18 @@ describe("toSdkPermissionMode", () => { }); describe("isToolAllowedForMode stays authoritative for auto", () => { - it.each(["Bash", "Edit", "Write", "NotebookEdit", "BashOutput", "KillShell"])( - "auto-allows %s in auto mode", - (tool) => { - expect(isToolAllowedForMode(tool, "auto")).toBe(true); - }, - ); + it.each([ + "Bash", + "Edit", + "Write", + "NotebookEdit", + "BashOutput", + "KillShell", + "mcp__posthog-code-tools__list_repos", + "mcp__posthog-code-tools__clone_repo", + ])("auto-allows %s in auto mode", (tool) => { + expect(isToolAllowedForMode(tool, "auto")).toBe(true); + }); it.each(["Bash", "Edit", "Write"])( "still gates %s in default mode", diff --git a/packages/agent/src/adapters/claude/tools.ts b/packages/agent/src/adapters/claude/tools.ts index 1911b037fd..97fd18e60e 100644 --- a/packages/agent/src/adapters/claude/tools.ts +++ b/packages/agent/src/adapters/claude/tools.ts @@ -43,6 +43,11 @@ const BASE_ALLOWED_TOOLS = [ ...AGENT_TOOLS, ]; +const AUTO_ALLOWED_LOCAL_TOOLS = [ + "mcp__posthog-code-tools__list_repos", + "mcp__posthog-code-tools__clone_repo", +]; + const AUTO_ALLOWED_TOOLS: Record> = { // Auto mode is hands-off: it auto-approves file edits and shell commands on // top of the base read/search/web/agent tools. Without WRITE_TOOLS and @@ -50,7 +55,12 @@ const AUTO_ALLOWED_TOOLS: Record> = { // call, which contradicts what the mode advertises. MCP tools are still gated // separately (do_not_use is denied, needs_approval still prompts) in // canUseTool, so auto stays narrower than bypassPermissions. - auto: new Set([...BASE_ALLOWED_TOOLS, ...WRITE_TOOLS, ...BASH_TOOLS]), + auto: new Set([ + ...BASE_ALLOWED_TOOLS, + ...WRITE_TOOLS, + ...BASH_TOOLS, + ...AUTO_ALLOWED_LOCAL_TOOLS, + ]), default: new Set(BASE_ALLOWED_TOOLS), acceptEdits: new Set([...BASE_ALLOWED_TOOLS, ...WRITE_TOOLS]), plan: new Set(BASE_ALLOWED_TOOLS), diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 1dbfc4b87d..f243da7039 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -929,6 +929,54 @@ describe("CodexAppServerAgent", () => { expect(requestPermission).not.toHaveBeenCalled(); }); + it("auto-accepts repository tools from the built-in local MCP server in auto mode", async () => { + const stub = makeStubRpc({ + initialize: {}, + "thread/start": { thread: { id: "thr_1" } }, + }); + const requestPermission = vi.fn(); + const client = { + sessionUpdate: async () => {}, + requestPermission, + extNotification: async () => {}, + } as unknown as AgentSideConnection; + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/bundle/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.initialize(init); + await agent.newSession({ + cwd: "/repo", + _meta: { + environment: "cloud", + channelMode: true, + permissionMode: "auto", + }, + } as unknown as NewSessionRequest); + + stub.emit("item/started", { + item: { + type: "mcpToolCall", + id: "m1", + server: "posthog-code-tools", + tool: "clone_repo", + arguments: { repo: "PostHog/posthog" }, + }, + }); + const decision = await stub.invokeRequest("mcpServer/elicitation/request", { + threadId: "thr_1", + turnId: "turn_1", + serverName: "posthog-code-tools", + mode: "form", + message: + 'Allow the posthog-code-tools MCP server to run tool "clone_repo"?', + }); + + expect(decision).toMatchObject({ action: "accept" }); + expect(requestPermission).not.toHaveBeenCalled(); + }); + it("auto-accepts a gated PostHog exec sub-tool in local hands-off modes", async () => { const stub = makeStubRpc({ initialize: {}, diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 25e54abe5b..b9fefdbc1f 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -53,6 +53,7 @@ import { estimateTokens, } from "../claude/context-breakdown"; import { isLocalSkillCommandChunk } from "../local-skill"; +import { LOCAL_TOOLS_MCP_NAME } from "../local-tools"; import { resolveSpokenNarration } from "../session-meta"; import { AppServerClient, @@ -1939,6 +1940,22 @@ export class CodexAppServerAgent extends BaseAcpAgent { ); } + private shouldAutoAcceptMcpToolCall(mcp: { + server: string; + tool: string; + args: unknown; + }): boolean { + const isHandsOffMode = + this.config.mode === "auto" || this.config.mode === "full-access"; + const isRepositoryTool = + mcp.server === LOCAL_TOOLS_MCP_NAME && + (mcp.tool === "list_repos" || mcp.tool === "clone_repo"); + return ( + (isHandsOffMode && isRepositoryTool) || + this.shouldAutoAcceptPostHogExec(mcp) + ); + } + /** * Server-initiated requests. Simple approvals resolve to a `{ decision }` envelope (a bare * string is rejected); richer ones (AskUserQuestion / permission profile / elicitation) go @@ -1953,7 +1970,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { logger: this.logger, resolveMcpToolCall: (serverName) => this.mcp.byServer(serverName), shouldAutoAcceptMcpToolCall: (mcp) => - this.shouldAutoAcceptPostHogExec(mcp), + this.shouldAutoAcceptMcpToolCall(mcp), }); if (richer.handled) { return richer.response; @@ -2001,7 +2018,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { // Codex has no MCP-specific approval; a known MCP call surfaces the real server/tool/args // so the host renders the proper MCP permission (incl. PostHog `exec` unwrapping). const mcp = this.mcp.byItemId(detail.itemId); - if (mcp && this.shouldAutoAcceptPostHogExec(mcp)) { + if (mcp && this.shouldAutoAcceptMcpToolCall(mcp)) { return { decision: "accept" }; } // kind + content route plain command/file approvals to Execute/EditPermission (not the fallback). diff --git a/packages/agent/src/adapters/local-tools/tools/clone-repo.test.ts b/packages/agent/src/adapters/local-tools/tools/clone-repo.test.ts new file mode 100644 index 0000000000..437344a6e3 --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/clone-repo.test.ts @@ -0,0 +1,160 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createGitClient } from "@posthog/git/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + cloneRun: vi.fn(), + getCurrentBranch: vi.fn(), +})); + +vi.mock("@posthog/git/sagas/clone", () => ({ + CloneSaga: class { + run = mocks.cloneRun; + }, +})); + +vi.mock("@posthog/git/queries", () => ({ + getCurrentBranch: mocks.getCurrentBranch, +})); + +vi.mock("../../../utils/github-token", () => ({ + resolveGithubToken: vi.fn(() => undefined), +})); + +const { cloneRepoTool } = await import("./clone-repo"); + +describe("clone_repo", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), "posthog-code-clone-tool-")); + mocks.cloneRun.mockReset().mockResolvedValue({ + success: true, + data: { targetPath: path.join(cwd, "repos", "PostHog", "posthog") }, + }); + mocks.getCurrentBranch.mockReset().mockResolvedValue("feature"); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it("authenticates a shallow clone without persisting the token in origin", async () => { + mocks.cloneRun.mockImplementationOnce(async (input) => { + await mkdir(input.targetPath, { recursive: true }); + const git = createGitClient(input.targetPath); + await git.init(); + await git.addRemote("origin", input.repoUrl); + return { success: true, data: { targetPath: input.targetPath } }; + }); + + const result = await cloneRepoTool.handler( + { cwd, token: "test-token" }, + { repo: "PostHog/posthog", branch: "feature" }, + ); + + expect(result.isError).toBeUndefined(); + const cloneInput = mocks.cloneRun.mock.calls[0]?.[0]; + expect(cloneInput).toMatchObject({ + repoUrl: "https://github.com/PostHog/posthog.git", + targetPath: path.join(cwd, "repos", "PostHog", "posthog"), + branch: "feature", + shallow: true, + }); + expect(cloneInput.env).toMatchObject({ + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.extraHeader", + }); + expect(cloneInput.env.GIT_CONFIG_VALUE_0).not.toContain("test-token"); + expect( + Buffer.from( + cloneInput.env.GIT_CONFIG_VALUE_0.replace("AUTHORIZATION: basic ", ""), + "base64", + ).toString("utf8"), + ).toBe("x-access-token:test-token"); + const targetGit = createGitClient( + path.join(cwd, "repos", "PostHog", "posthog"), + ); + expect((await targetGit.remote(["get-url", "origin"]))?.trim()).toBe( + "https://github.com/PostHog/posthog.git", + ); + }); + + it("removes credentials from an existing clone origin", async () => { + const targetPath = path.join(cwd, "repos", "PostHog", "posthog"); + await mkdir(targetPath, { recursive: true }); + const git = createGitClient(targetPath); + await git.init(); + await git.addRemote( + "origin", + "https://x-access-token:stale-token@github.com/PostHog/posthog.git", + ); + + const result = await cloneRepoTool.handler( + { cwd, token: "test-token" }, + { repo: "PostHog/posthog" }, + ); + + expect(result.isError).toBeUndefined(); + expect((await git.remote(["get-url", "origin"]))?.trim()).toBe( + "https://github.com/PostHog/posthog.git", + ); + }); + + it("fetches a missing branch into an existing shallow clone", async () => { + const sourcePath = path.join(cwd, "source"); + const targetPath = path.join(cwd, "repos", "PostHog", "posthog"); + await mkdir(sourcePath, { recursive: true }); + await createGitClient().raw([ + "init", + "--initial-branch=master", + sourcePath, + ]); + + const sourceGit = createGitClient(sourcePath); + await sourceGit.addConfig("user.name", "PostHog Code test"); + await sourceGit.addConfig("user.email", "test@posthog.com"); + await writeFile(path.join(sourcePath, "README.md"), "master\n"); + await sourceGit.add("README.md"); + await sourceGit.commit("initial commit"); + await sourceGit.checkoutLocalBranch("feature"); + await writeFile(path.join(sourcePath, "README.md"), "feature\n"); + await sourceGit.add("README.md"); + await sourceGit.commit("feature commit"); + await sourceGit.checkout("master"); + + await mkdir(path.dirname(targetPath), { recursive: true }); + await createGitClient().clone(pathToFileURL(sourcePath).href, targetPath, [ + "--depth", + "1", + "--single-branch", + "--no-tags", + "--branch", + "master", + ]); + + const result = await cloneRepoTool.handler( + { cwd, token: "test-token" }, + { repo: "PostHog/posthog", branch: "feature" }, + ); + + expect(result.isError).toBeUndefined(); + const targetGit = createGitClient(targetPath); + expect((await targetGit.branchLocal()).current).toBe("feature"); + expect( + await targetGit.raw([ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ]), + ).toBe("origin/feature"); + expect(await targetGit.raw(["rev-parse", "--is-shallow-repository"])).toBe( + "true", + ); + expect(await targetGit.raw(["rev-list", "--count", "HEAD"])).toBe("1"); + }); +}); diff --git a/packages/agent/src/adapters/local-tools/tools/clone-repo.ts b/packages/agent/src/adapters/local-tools/tools/clone-repo.ts index 41e7ab0430..8781fb1a58 100644 --- a/packages/agent/src/adapters/local-tools/tools/clone-repo.ts +++ b/packages/agent/src/adapters/local-tools/tools/clone-repo.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { createGitClient } from "@posthog/git/client"; +import { getCleanEnv } from "@posthog/git/operation-manager"; import { getCurrentBranch } from "@posthog/git/queries"; import { CloneSaga } from "@posthog/git/sagas/clone"; import { parseGithubUrl } from "@posthog/git/utils"; @@ -26,6 +27,28 @@ function fail(text: string): LocalToolResult { return { content: [{ type: "text", text }], isError: true }; } +function githubAuthEnv(token: string | undefined): Record { + if (!token) return {}; + const basicAuth = Buffer.from(`x-access-token:${token}`).toString("base64"); + return { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.extraHeader", + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basicAuth}`, + }; +} + +function hasHttpCredentials(remoteUrl: string): boolean { + try { + const url = new URL(remoteUrl); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + Boolean(url.username || url.password) + ); + } catch { + return false; + } +} + /** * Lazily brings a repo into a repo-less channel session's scratch workspace. * Clones into `/repos/` (a subdir of the session cwd, so no session @@ -61,6 +84,11 @@ export const cloneRepoTool = defineLocalTool({ const slug = `${parsed.owner}/${parsed.repo}`; const repoName = parsed.repo; const targetPath = path.join(ctx.cwd, "repos", slug); + const cloneUrl = `https://github.com/${slug}.git`; + const authenticatedGitEnv = { + ...getCleanEnv(), + ...githubAuthEnv(token), + }; const done = async (note?: string): Promise => { const checkedOut = (await getCurrentBranch(targetPath)) ?? branch ?? null; @@ -78,15 +106,36 @@ export const cloneRepoTool = defineLocalTool({ const checkout = async (): Promise => { if (!branch) return null; + const git = createGitClient(targetPath).env(authenticatedGitEnv); try { - await createGitClient(targetPath).checkout(branch); + await git.checkout(branch); return null; - } catch (err) { - return fail( - `Cloned ${slug} to ${targetPath} but failed to check out branch "${branch}": ${redact( - err instanceof Error ? err.message : String(err), - )}. The default branch is checked out instead.`, - ); + } catch { + try { + const refspec = `+refs/heads/${branch}:refs/remotes/origin/${branch}`; + await git.raw([ + "fetch", + "--depth", + "1", + "--no-tags", + "origin", + refspec, + ]); + const configuredRefspecs = ( + await git.raw(["config", "--get-all", "remote.origin.fetch"]) + ).split("\n"); + if (!configuredRefspecs.includes(refspec)) { + await git.raw(["config", "--add", "remote.origin.fetch", refspec]); + } + await git.checkout(["-b", branch, "--track", `origin/${branch}`]); + return null; + } catch (err) { + return fail( + `Cloned ${slug} to ${targetPath} but failed to check out branch "${branch}": ${redact( + err instanceof Error ? err.message : String(err), + )}. The previously checked out branch is still active.`, + ); + } } }; @@ -94,28 +143,41 @@ export const cloneRepoTool = defineLocalTool({ // the repo in place. Reuse it instead of letting git abort on a non-empty // destination, which the agent would receive as an opaque error. if (fs.existsSync(path.join(targetPath, ".git"))) { + const git = createGitClient(targetPath); + try { + const originUrl = await git.remote(["get-url", "origin"]); + if ( + typeof originUrl === "string" && + hasHttpCredentials(originUrl.trim()) + ) { + await git.remote(["set-url", "origin", cloneUrl]); + } + } catch (err) { + return fail( + `clone_repo couldn't secure the existing origin: ${redact( + err instanceof Error ? err.message : String(err), + )}`, + ); + } return ( (await checkout()) ?? (await done(`${slug} already cloned at ${targetPath}`)) ); } - // GitHub accepts a token as the basic-auth username for https clones; this - // covers private repos. Public repos clone fine without it. - const cloneUrl = token - ? `https://x-access-token:${token}@github.com/${slug}.git` - : `https://github.com/${slug}.git`; - try { const result = await new CloneSaga().run({ repoUrl: cloneUrl, targetPath, + branch, + shallow: true, + env: githubAuthEnv(token), }); if (!result.success) { return fail(`clone_repo failed: ${redact(result.error)}`); } - return (await checkout()) ?? (await done()); + return done(); } catch (err) { return fail( `clone_repo failed: ${redact( diff --git a/packages/agent/src/pi/repository-tools-extension.test.ts b/packages/agent/src/pi/repository-tools-extension.test.ts new file mode 100644 index 0000000000..b149676c18 --- /dev/null +++ b/packages/agent/src/pi/repository-tools-extension.test.ts @@ -0,0 +1,35 @@ +import type { + ExtensionAPI, + ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { createPiRepositoryToolsExtension } from "./repository-tools-extension"; + +describe("createPiRepositoryToolsExtension", () => { + it("registers the repo-less clone and discovery tools", async () => { + type RegisteredTool = Pick; + const registered: RegisteredTool[] = []; + const extension = createPiRepositoryToolsExtension("/tmp/workspace"); + await extension.factory({ + registerTool: (tool: ToolDefinition) => { + registered.push(tool); + }, + } as unknown as ExtensionAPI); + + expect(registered.map((tool) => tool.name)).toEqual([ + "list_repos", + "clone_repo", + ]); + const cloneTool = registered.find((tool) => tool.name === "clone_repo"); + expect(cloneTool).toBeDefined(); + await expect( + cloneTool?.execute( + "call-1", + { repo: "not a repository" }, + undefined, + undefined, + {} as never, + ), + ).rejects.toThrow('clone_repo: invalid repo "not a repository"'); + }); +}); diff --git a/packages/agent/src/pi/repository-tools-extension.ts b/packages/agent/src/pi/repository-tools-extension.ts new file mode 100644 index 0000000000..ae700fefe0 --- /dev/null +++ b/packages/agent/src/pi/repository-tools-extension.ts @@ -0,0 +1,61 @@ +import type { + ExtensionFactory, + InlineExtension, +} from "@earendil-works/pi-coding-agent"; +import { defineTool } from "@earendil-works/pi-coding-agent"; +import { convertJsonSchemaToTypebox } from "@posthog/harness/extensions/mcp/schema"; +import { z } from "zod"; +import { enabledLocalTools, type LocalToolCtx } from "../adapters/local-tools"; + +const REPOSITORY_TOOL_NAMES = new Set(["list_repos", "clone_repo"]); +type NamedInlineExtension = Exclude; + +function toolLabel(name: string): string { + return name + .replaceAll("_", " ") + .replace(/^./, (first) => first.toUpperCase()); +} + +function createRepositoryToolsFactory(cwd: string): ExtensionFactory { + return (pi) => { + const context: LocalToolCtx = { cwd }; + const tools = enabledLocalTools(context, { channelMode: true }).filter( + (tool) => REPOSITORY_TOOL_NAMES.has(tool.name), + ); + + for (const localTool of tools) { + const schema = z.object(localTool.schema); + pi.registerTool( + defineTool({ + name: localTool.name, + label: toolLabel(localTool.name), + description: localTool.description, + promptSnippet: localTool.description, + parameters: convertJsonSchemaToTypebox(z.toJSONSchema(schema)), + execute: async (_toolCallId, params) => { + const parsed = schema.safeParse(params); + if (!parsed.success) { + throw new Error(parsed.error.message); + } + const result = await localTool.handler(context, parsed.data); + if (result.isError) { + throw new Error( + result.content.map((item) => item.text).join("\n"), + ); + } + return { content: result.content, details: {} }; + }, + }), + ); + } + }; +} + +export function createPiRepositoryToolsExtension( + cwd: string, +): NamedInlineExtension { + return { + name: "posthog-code-repository-tools", + factory: createRepositoryToolsFactory(cwd), + }; +} diff --git a/packages/agent/src/pi/rpc-client.test.ts b/packages/agent/src/pi/rpc-client.test.ts index eb0abae742..d6bf365264 100644 --- a/packages/agent/src/pi/rpc-client.test.ts +++ b/packages/agent/src/pi/rpc-client.test.ts @@ -31,17 +31,22 @@ describe("createPiRpcClient", () => { ).toBeUndefined(); }); - it("runs the RPC host with Electron's Node mode enabled", async () => { + it("passes channel mode privately and enables Electron's Node mode", async () => { const directory = await mkdtemp(join(tmpdir(), "pi-electron-node-mode-")); const hostPath = join(directory, "host.mjs"); const capturePath = join(directory, "capture.txt"); await writeFile( hostPath, ` -import { closeSync, writeFileSync } from "node:fs"; +import { closeSync, readFileSync, writeFileSync } from "node:fs"; +const bootstrap = JSON.parse(readFileSync(3, "utf8")); closeSync(3); -writeFileSync(${JSON.stringify(capturePath)}, process.env.ELECTRON_RUN_AS_NODE ?? ""); +writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ + nodeMode: process.env.ELECTRON_RUN_AS_NODE ?? "", + channelMode: bootstrap.channelMode, + apiKey: bootstrap.providerOptions.apiKey, +})); process.stdin.resume(); `, ); @@ -49,12 +54,19 @@ process.stdin.resume(); cliPath: hostPath, cwd: directory, providerOptions: { apiKey: "proxy-key" }, + channelMode: true, }); try { await client.start(); await vi.waitFor(async () => { - await expect(readFile(capturePath, "utf8")).resolves.toBe("1"); + await expect(readFile(capturePath, "utf8")).resolves.toBe( + JSON.stringify({ + nodeMode: "1", + channelMode: true, + apiKey: "proxy-key", + }), + ); }); } finally { await client.stop(); diff --git a/packages/agent/src/pi/rpc-client.ts b/packages/agent/src/pi/rpc-client.ts index 757ac8fca9..5211437663 100644 --- a/packages/agent/src/pi/rpc-client.ts +++ b/packages/agent/src/pi/rpc-client.ts @@ -21,6 +21,11 @@ export interface PiRpcProviderOptions { baseUrl?: string; } +interface PiRpcBootstrap { + providerOptions: PiRpcProviderOptions; + channelMode?: boolean; +} + type RpcClientProcessAccess = { process?: ChildProcess; }; @@ -84,6 +89,7 @@ class SecurePiRpcClient extends RpcClient { constructor( private readonly secureOptions: RpcClientOptions, private readonly providerOptions: PiRpcProviderOptions, + private readonly channelMode: boolean, ) { super(secureOptions); } @@ -162,7 +168,10 @@ class SecurePiRpcClient extends RpcClient { const bootstrapPipe = child.stdio[3] as Writable | null; bootstrapPipe?.on("error", () => {}); bootstrapPipe?.end( - JSON.stringify({ providerOptions: this.providerOptions }), + JSON.stringify({ + providerOptions: this.providerOptions, + channelMode: this.channelMode, + } satisfies PiRpcBootstrap), ); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -265,10 +274,11 @@ export type PiRpcClientOptions = Pick< > & { sessionFile?: string; providerOptions: PiRpcProviderOptions; + channelMode?: boolean; }; export function createPiRpcClient(options: PiRpcClientOptions): PiRpcClient { - const { sessionFile, providerOptions, ...rpcOptions } = options; + const { sessionFile, providerOptions, channelMode, ...rpcOptions } = options; const args = sessionFile ? ["--session-file", sessionFile] : []; const cliPath = rpcOptions.cliPath ?? @@ -281,5 +291,6 @@ export function createPiRpcClient(options: PiRpcClientOptions): PiRpcClient { provider: "posthog", }, providerOptions, + channelMode === true, ); } diff --git a/packages/agent/src/pi/rpc-host.ts b/packages/agent/src/pi/rpc-host.ts index 9516177b4e..f60e0560d4 100644 --- a/packages/agent/src/pi/rpc-host.ts +++ b/packages/agent/src/pi/rpc-host.ts @@ -6,10 +6,12 @@ import { POSTHOG_PI_QUEUE_ENTRY_TYPE, readPersistedPiQueue, } from "./queue-persistence"; +import { createPiRepositoryToolsExtension } from "./repository-tools-extension"; import { sanitizePiHostEnvironment } from "./rpc-environment"; interface PiRpcBootstrap { providerOptions?: PosthogProviderOptions; + channelMode?: boolean; } interface PiHostRequest { @@ -38,6 +40,13 @@ const sessionManager = sessionFile const runtime = await createHarnessRuntime({ cwd, sessionManager, + ...(bootstrap.channelMode + ? { + resourceLoaderOptions: { + extensionFactories: [createPiRepositoryToolsExtension(cwd)], + }, + } + : {}), ...providerOptions, }); diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 47e8c485b0..b47bf71bbe 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -14,6 +14,7 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import type { Adapter } from "@posthog/shared"; import { zipSync } from "fflate"; import jwt from "jsonwebtoken"; +import { HttpResponse, http } from "msw"; import { type SetupServerApi, setupServer } from "msw/node"; import { afterAll, @@ -547,6 +548,35 @@ describe("AgentServer HTTP Mode", () => { }); }, 30000); + it("enables repository tools for a repository-less cloud session", async () => { + await mkdir("/tmp/workspace", { recursive: true }); + mswServer.use( + http.get( + "http://localhost:8000/api/projects/:projectId/tasks/:taskId/", + () => + HttpResponse.json({ + id: "test-task-id", + title: "Test task", + description: null, + origin_product: "user_created", + repository: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }), + ), + ); + + const testServer = createServer({ + repositoryPath: undefined, + }) as unknown as { + start(): Promise; + session: { sessionMeta: { channelMode?: boolean } } | null; + }; + await testServer.start(); + + expect(testServer.session?.sessionMeta.channelMode).toBe(true); + }, 30000); + it("links native agent state before initializing the session", async () => { const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; const originalCodexHome = process.env.CODEX_HOME; @@ -4003,8 +4033,11 @@ describe("AgentServer HTTP Mode", () => { config: { repositoryPath: undefined }, shouldContain: [ "Cloud Task Execution — No Repository Mode", - "Clone the repository into /tmp/workspace/repos//", - "gh repo clone / /tmp/workspace/repos//", + "call `list_repos`", + "Call `clone_repo`", + "It creates a shallow clone", + "git fetch --deepen=50 origin ", + "git fetch --deepen=200 origin ", "If the user explicitly asks you to open or update a pull request", "open a draft pull request", "unless the user explicitly asks", @@ -4014,17 +4047,22 @@ describe("AgentServer HTTP Mode", () => { "Generated-By: PostHog Code", "Task-Id: test-task-id", ], - shouldNotContain: [], + shouldNotContain: ["gh repo clone"], }, { label: "createPr false", config: { repositoryPath: undefined, createPr: false }, shouldContain: [ "Cloud Task Execution — No Repository Mode", - "You may clone a repository and make local edits in that clone", + "Call `clone_repo`", + "You may make local edits in a repository cloned with `clone_repo`", "Do NOT create branches, commits, push changes, or open pull requests in this run", ], - shouldNotContain: ["open a draft pull request", "gh pr create --draft"], + shouldNotContain: [ + "open a draft pull request", + "gh pr create --draft", + "gh repo clone", + ], }, ])( "returns no-repository prompt for $label", diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index f53594a274..4cb0b38a8d 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1741,6 +1741,7 @@ export class AgentServer { ) : []; const sessionCwd = this.config.repositoryPath ?? "/tmp/workspace"; + const channelMode = !this.config.repositoryPath; const sessionMeta = { sessionId: payload.run_id, taskRunId: payload.run_id, @@ -1752,6 +1753,7 @@ export class AgentServer { allowedDomains: this.config.allowedDomains, jsonSchema: preTask?.json_schema ?? null, permissionMode: initialPermissionMode, + ...(channelMode && { channelMode: true }), posthogExecPermissionRegex: this.posthogExecPermissionRegexSource, ...(this.config.baseBranch && { baseBranch: this.config.baseBranch }), ...(runtimeAdapter === "claude" && @@ -3678,17 +3680,22 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} } if (!this.config.repositoryPath) { + const repositoryInstructions = ` +When the task requires a GitHub repository: +- If the repository is not specified, call \`list_repos\` and use the task context to choose it. If multiple repositories remain plausible, ask the user. +- Call \`clone_repo\` with the chosen \`owner/repo\` and optional branch. It creates a shallow clone under \`/tmp/workspace/repos//\` and returns the path. +- Work from inside the returned path for all code changes. +- The clone starts with one commit. If older history is genuinely needed, fetch it in bounded steps with \`git fetch --deepen=50 origin \`, then \`git fetch --deepen=200 origin \`. Use \`git fetch --unshallow\` only when the task explicitly requires full history, such as a long-range blame or bisect. +`; const publishInstructions = this.config.createPr === false ? ` When the user asks for code changes: -- You may clone a repository and make local edits in that clone +- You may make local edits in a repository cloned with \`clone_repo\` - Do NOT create branches, commits, push changes, or open pull requests in this run` : shouldAutoCreatePr ? ` -When the user asks to clone or work in a GitHub repository: -- Clone the repository into /tmp/workspace/repos// using \`gh repo clone / /tmp/workspace/repos//\` -- Work from inside that cloned repository for follow-up code changes +When the user asks for code changes in a GitHub repository: - After completing code changes in a cloned repository, create a branch, stage your changes with \`git add\` and commit them with the \`git_signed_commit\` tool (do NOT use \`git commit\`/\`git push\` — they are blocked), and open a draft pull request from inside the clone without waiting to be asked. Before opening the PR, check the cloned repo for a PR template at \`.github/pull_request_template.md\` (or variants; fall back to the org's \`.github\` repo via \`gh api\`) and use it as the body structure, and search for matching open issues with \`gh issue list --search\` to include \`Closes #\` / \`Refs #\` links. - Keep the PR description brief overall. Summarize only the most important changes — do NOT enumerate every change you made. A few sentences or bullets is plenty. ${whyContextInstruction.trimStart()} @@ -3697,9 +3704,7 @@ ${prMentionSafetyInstruction.trimStart()} - End the PR description with a horizontal rule followed by this footer line: ${prFooter} - Always create the PR as a draft. Do not ask for confirmation before publishing completed code changes` : ` -When the user explicitly asks to clone or work in a GitHub repository: -- Clone the repository into /tmp/workspace/repos// using \`gh repo clone / /tmp/workspace/repos//\` -- Work from inside that cloned repository for follow-up code changes +When the user explicitly asks for code changes in a GitHub repository: - If the user explicitly asks you to open or update a pull request, create a branch, stage your changes with \`git add\` and commit them with the \`git_signed_commit\` tool (do NOT use \`git commit\`/\`git push\` — they are blocked), and open a draft pull request from inside the clone. Before opening the PR, check the cloned repo for a PR template at \`.github/pull_request_template.md\` (or variants; fall back to the org's \`.github\` repo via \`gh api\`) and use it as the body structure, and search for matching open issues with \`gh issue list --search\` to include \`Closes #\` / \`Refs #\` links. - Keep the PR description brief overall. Summarize only the most important changes — do NOT enumerate every change you made. A few sentences or bullets is plenty. ${whyContextInstruction.trimStart()} @@ -3719,9 +3724,8 @@ When the user asks about analytics, data, metrics, events, funnels, dashboards, - Use tools like insight-query, query-run, event-definitions-list, and others to answer questions directly When the user asks for code changes or software engineering tasks: -- Let them know you can help but don't have a repository connected for this session -- If they have not specified a repository to clone, offer to write code snippets, scripts, or provide guidance -${publishInstructions} +- Choose and clone a repository only when the task requires one. For questions and analysis, answer without cloning when possible. +${repositoryInstructions}${publishInstructions} Important: - Prefer using MCP tools to answer questions with real data over giving generic advice. diff --git a/packages/agent/src/server/pi-agent-server.ts b/packages/agent/src/server/pi-agent-server.ts index 0fb220e900..13269d83e3 100644 --- a/packages/agent/src/server/pi-agent-server.ts +++ b/packages/agent/src/server/pi-agent-server.ts @@ -500,6 +500,7 @@ export class PiAgentServer { this.config.apiUrl, ), }, + channelMode: !this.config.repositoryPath, }); const runtime = new PiRuntime(client); const unsubscribeConversation = runtime.onConversationEvent((event) => diff --git a/packages/git/src/sagas/clone.test.ts b/packages/git/src/sagas/clone.test.ts new file mode 100644 index 0000000000..925d33aa15 --- /dev/null +++ b/packages/git/src/sagas/clone.test.ts @@ -0,0 +1,75 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createGitClient } from "../client"; +import { CloneSaga } from "./clone"; + +describe("CloneSaga", () => { + let testRoot: string; + let sourcePath: string; + let targetPath: string; + + beforeEach(async () => { + testRoot = await mkdtemp(path.join(tmpdir(), "posthog-code-clone-")); + sourcePath = path.join(testRoot, "source"); + targetPath = path.join(testRoot, "target"); + await mkdir(sourcePath); + + const git = createGitClient(sourcePath); + await git.init(["--initial-branch", "main"]); + await git.addConfig("user.name", "PostHog Code Test"); + await git.addConfig("user.email", "posthog-code-test@example.com"); + await git.addConfig("commit.gpgsign", "false"); + + await writeFile(path.join(sourcePath, "first.txt"), "first\n"); + await git.add(["first.txt"]); + await git.commit("first"); + + await writeFile(path.join(sourcePath, "second.txt"), "second\n"); + await git.add(["second.txt"]); + await git.commit("second"); + + await git.checkoutLocalBranch("feature"); + await writeFile(path.join(sourcePath, "feature.txt"), "feature\n"); + await git.add(["feature.txt"]); + await git.commit("feature"); + await git.addTag("source-tag"); + }); + + afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); + }); + + it("clones the requested branch with one commit and no tags", async () => { + const cleanRemoteUrl = "https://github.com/PostHog/posthog.git"; + const result = await new CloneSaga().run({ + repoUrl: cleanRemoteUrl, + targetPath, + branch: "feature", + shallow: true, + env: { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.${pathToFileURL(sourcePath).href}.insteadOf`, + GIT_CONFIG_VALUE_0: cleanRemoteUrl, + }, + }); + + if (!result.success) { + throw new Error(result.error); + } + const git = createGitClient(targetPath); + await expect(git.revparse(["--abbrev-ref", "HEAD"])).resolves.toBe( + "feature", + ); + await expect(git.revparse(["--is-shallow-repository"])).resolves.toBe( + "true", + ); + await expect(git.raw(["rev-list", "--count", "HEAD"])).resolves.toBe("1"); + await expect(git.tags()).resolves.toMatchObject({ all: [] }); + await expect( + git.remote(["get-url", "origin"]).then((origin) => origin?.trim()), + ).resolves.toBe(cleanRemoteUrl); + }); +}); diff --git a/packages/git/src/sagas/clone.ts b/packages/git/src/sagas/clone.ts index 08df9bbf24..216088e0cc 100644 --- a/packages/git/src/sagas/clone.ts +++ b/packages/git/src/sagas/clone.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs/promises"; +import path from "node:path"; import { Saga } from "@posthog/shared"; import { createGitClient } from "../client"; import { getCleanEnv, getGitOperationManager } from "../operation-manager"; @@ -6,6 +7,9 @@ import { getCleanEnv, getGitOperationManager } from "../operation-manager"; export interface CloneInput { repoUrl: string; targetPath: string; + branch?: string; + shallow?: boolean; + env?: Record; signal?: AbortSignal; onProgress?: ( stage: string, @@ -23,11 +27,14 @@ export class CloneSaga extends Saga { readonly sagaName = "CloneSaga"; protected async execute(input: CloneInput): Promise { - const { repoUrl, targetPath, signal, onProgress } = input; + const { repoUrl, targetPath, branch, shallow, env, signal, onProgress } = + input; const manager = getGitOperationManager(); + const targetParent = path.dirname(targetPath); + await fs.mkdir(targetParent, { recursive: true }); await manager.executeWrite( - targetPath, + targetParent, async () => { await this.step({ name: "clone", @@ -39,9 +46,16 @@ export class CloneSaga extends Saga { onProgress(stage, progress, processed, total) : undefined, }); + const cloneArgs = ["--progress"]; + if (shallow) { + cloneArgs.push("--depth", "1", "--single-branch", "--no-tags"); + } + if (branch) { + cloneArgs.push("--branch", branch); + } await git - .env(getCleanEnv()) - .clone(repoUrl, targetPath, ["--progress"]); + .env({ ...getCleanEnv(), ...env }) + .clone(repoUrl, targetPath, cloneArgs); }, rollback: async () => { try {