@posthog/agent 2.3.647 → 2.3.656

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/adapters/claude/permissions/permission-options.js +700 -0
  2. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  3. package/dist/adapters/claude/tools.js +700 -0
  4. package/dist/adapters/claude/tools.js.map +1 -1
  5. package/dist/adapters/codex/local-tools-mcp-server.d.ts +2 -0
  6. package/dist/adapters/codex/local-tools-mcp-server.js +1172 -0
  7. package/dist/adapters/codex/local-tools-mcp-server.js.map +1 -0
  8. package/dist/agent.js +1488 -219
  9. package/dist/agent.js.map +1 -1
  10. package/dist/execution-mode.js +700 -0
  11. package/dist/execution-mode.js.map +1 -1
  12. package/dist/handoff-checkpoint.js.map +1 -1
  13. package/dist/posthog-api.d.ts +1 -1
  14. package/dist/posthog-api.js +1 -1
  15. package/dist/posthog-api.js.map +1 -1
  16. package/dist/server/agent-server.js +1637 -342
  17. package/dist/server/agent-server.js.map +1 -1
  18. package/dist/server/bin.cjs +1553 -261
  19. package/dist/server/bin.cjs.map +1 -1
  20. package/dist/types.d.ts +1 -1
  21. package/dist/types.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/adapters/claude/claude-agent.ts +32 -2
  24. package/src/adapters/claude/hooks.test.ts +54 -0
  25. package/src/adapters/claude/hooks.ts +86 -0
  26. package/src/adapters/claude/mcp/local-tools.test.ts +50 -0
  27. package/src/adapters/claude/mcp/local-tools.ts +40 -0
  28. package/src/adapters/claude/session/options.ts +14 -9
  29. package/src/adapters/claude/types.ts +1 -0
  30. package/src/adapters/codex/codex-agent.ts +117 -22
  31. package/src/adapters/codex/local-tools-mcp-server.ts +71 -0
  32. package/src/adapters/local-tools/index.ts +22 -0
  33. package/src/adapters/local-tools/registry.test.ts +57 -0
  34. package/src/adapters/local-tools/registry.ts +81 -0
  35. package/src/adapters/local-tools/tools/signed-commit.ts +26 -0
  36. package/src/adapters/session-meta.ts +16 -0
  37. package/src/adapters/signed-commit-shared.ts +82 -0
  38. package/src/server/agent-server.configure-environment.test.ts +64 -1
  39. package/src/server/agent-server.test.ts +2 -4
  40. package/src/server/agent-server.ts +60 -35
  41. package/src/types.ts +2 -1
  42. package/src/utils/common.ts +14 -0
  43. package/src/utils/gateway.test.ts +70 -0
  44. package/src/utils/gateway.ts +31 -1
@@ -38,6 +38,7 @@ import {
38
38
  type SetSessionModeRequest,
39
39
  type SetSessionModeResponse,
40
40
  } from "@agentclientprotocol/sdk";
41
+ import { ghTokenEnv } from "@posthog/git/signed-commit";
41
42
  import packageJson from "../../../package.json" with { type: "json" };
42
43
  import {
43
44
  isMethod,
@@ -56,6 +57,7 @@ import {
56
57
  type PermissionMode,
57
58
  } from "../../execution-mode";
58
59
  import type { PostHogAPIConfig, ProcessSpawnedCallback } from "../../types";
60
+ import { isCloudRun, resolveGithubToken } from "../../utils/common";
59
61
  import { Logger } from "../../utils/logger";
60
62
  import {
61
63
  nodeReadableToWebReadable,
@@ -63,6 +65,12 @@ import {
63
65
  } from "../../utils/streams";
64
66
  import { BaseAcpAgent, type BaseSession } from "../base-acp-agent";
65
67
  import { classifyAgentError } from "../error-classification";
68
+ import {
69
+ enabledLocalTools,
70
+ LOCAL_TOOLS_MCP_NAME,
71
+ type LocalToolCtx,
72
+ } from "../local-tools";
73
+ import { resolveTaskId } from "../session-meta";
66
74
  import { createCodexClient } from "./codex-client";
67
75
  import { normalizeCodexConfigOptions } from "./models";
68
76
  import {
@@ -193,8 +201,7 @@ const STRUCTURED_OUTPUT_INSTRUCTIONS = `\n\nWhen you have completed the task, ca
193
201
  * harness/bin.js, etc), `import.meta.dirname` sits at different depths. Walk
194
202
  * up until we find the script so each bundle locates the shared dist asset.
195
203
  */
196
- function resolveStructuredOutputMcpScript(): string {
197
- const rel = "adapters/codex/structured-output-mcp-server.js";
204
+ function resolveBundledMcpScript(rel: string): string {
198
205
  let dir = import.meta.dirname ?? __dirname;
199
206
  for (let i = 0; i < 5; i++) {
200
207
  const candidate = resolvePath(dir, rel);
@@ -209,7 +216,9 @@ function resolveStructuredOutputMcpScript(): string {
209
216
  function buildStructuredOutputMcpServer(
210
217
  jsonSchema: Record<string, unknown>,
211
218
  ): McpServerStdio {
212
- const scriptPath = resolveStructuredOutputMcpScript();
219
+ const scriptPath = resolveBundledMcpScript(
220
+ "adapters/codex/structured-output-mcp-server.js",
221
+ );
213
222
  const schemaBase64 = Buffer.from(JSON.stringify(jsonSchema)).toString(
214
223
  "base64",
215
224
  );
@@ -221,6 +230,41 @@ function buildStructuredOutputMcpServer(
221
230
  };
222
231
  }
223
232
 
233
+ /**
234
+ * Builds the stdio MCP server config exposing the enabled local tools. Context
235
+ * (cwd, taskId, token) and the enabled tool names are passed base64/CSV-encoded
236
+ * so the child registers the same tools the Claude adapter exposes in-process.
237
+ */
238
+ function buildLocalToolsMcpServer(
239
+ ctx: LocalToolCtx,
240
+ enabledNames: string[],
241
+ ): McpServerStdio {
242
+ const scriptPath = resolveBundledMcpScript(
243
+ "adapters/codex/local-tools-mcp-server.js",
244
+ );
245
+ const ctxBase64 = Buffer.from(JSON.stringify(ctx)).toString("base64");
246
+ const env = [
247
+ { name: "POSTHOG_LOCAL_TOOLS_CTX", value: ctxBase64 },
248
+ { name: "POSTHOG_LOCAL_TOOLS_ENABLED", value: enabledNames.join(",") },
249
+ ];
250
+ if (ctx.token) {
251
+ // Token also on the child env so its own git remote ops (fetch/ls-remote)
252
+ // authenticate; the var names come from the single shared source.
253
+ env.push(
254
+ ...Object.entries(ghTokenEnv(ctx.token)).map(([name, value]) => ({
255
+ name,
256
+ value,
257
+ })),
258
+ );
259
+ }
260
+ return {
261
+ name: LOCAL_TOOLS_MCP_NAME,
262
+ command: process.execPath,
263
+ args: [scriptPath],
264
+ env,
265
+ };
266
+ }
267
+
224
268
  export class CodexAcpAgent extends BaseAcpAgent {
225
269
  readonly adapterName = "codex";
226
270
  declare session: CodexSession;
@@ -338,7 +382,10 @@ export class CodexAcpAgent extends BaseAcpAgent {
338
382
  const meta = params._meta as NewSessionMeta | undefined;
339
383
  const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode);
340
384
 
341
- const injectedParams = this.applyStructuredOutput(params, meta);
385
+ const injectedParams = this.applyLocalTools(
386
+ this.applyStructuredOutput(params, meta),
387
+ meta,
388
+ );
342
389
  const response = await this.codexConnection.newSession(injectedParams);
343
390
  response.configOptions = normalizeCodexConfigOptions(
344
391
  response.configOptions,
@@ -347,7 +394,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
347
394
  // Initialize session state
348
395
  this.sessionState = createSessionState(response.sessionId, params.cwd, {
349
396
  taskRunId: meta?.taskRunId,
350
- taskId: meta?.taskId ?? meta?.persistence?.taskId,
397
+ taskId: resolveTaskId(meta),
351
398
  modeId: response.modes?.currentModeId ?? "auto",
352
399
  modelId: response.models?.currentModelId,
353
400
  permissionMode: requestedPermissionMode,
@@ -380,7 +427,10 @@ export class CodexAcpAgent extends BaseAcpAgent {
380
427
 
381
428
  async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
382
429
  const meta = params._meta as NewSessionMeta | undefined;
383
- const injectedParams = this.applyStructuredOutput(params, meta);
430
+ const injectedParams = this.applyLocalTools(
431
+ this.applyStructuredOutput(params, meta),
432
+ meta,
433
+ );
384
434
  const response = await this.codexConnection.loadSession(injectedParams);
385
435
  response.configOptions = normalizeCodexConfigOptions(
386
436
  response.configOptions,
@@ -396,7 +446,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
396
446
  // not, which silently broke task-completion tracking on re-attach.
397
447
  this.sessionState = createSessionState(params.sessionId, params.cwd, {
398
448
  taskRunId: meta?.taskRunId,
399
- taskId: meta?.taskId ?? meta?.persistence?.taskId,
449
+ taskId: resolveTaskId(meta),
400
450
  modeId: response.modes?.currentModeId ?? "auto",
401
451
  permissionMode: currentPermissionMode,
402
452
  });
@@ -418,13 +468,16 @@ export class CodexAcpAgent extends BaseAcpAgent {
418
468
  params: ResumeSessionRequest,
419
469
  ): Promise<ResumeSessionResponse> {
420
470
  const meta = params._meta as NewSessionMeta | undefined;
421
- const injectedParams = this.applyStructuredOutput(
422
- {
423
- sessionId: params.sessionId,
424
- cwd: params.cwd,
425
- mcpServers: params.mcpServers ?? [],
426
- _meta: params._meta,
427
- },
471
+ const injectedParams = this.applyLocalTools(
472
+ this.applyStructuredOutput(
473
+ {
474
+ sessionId: params.sessionId,
475
+ cwd: params.cwd,
476
+ mcpServers: params.mcpServers ?? [],
477
+ _meta: params._meta,
478
+ },
479
+ meta,
480
+ ),
428
481
  meta,
429
482
  );
430
483
 
@@ -439,7 +492,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
439
492
  );
440
493
  this.sessionState = createSessionState(params.sessionId, params.cwd, {
441
494
  taskRunId: meta?.taskRunId,
442
- taskId: meta?.taskId ?? meta?.persistence?.taskId,
495
+ taskId: resolveTaskId(meta),
443
496
  modeId: loadResponse.modes?.currentModeId ?? "auto",
444
497
  permissionMode: currentPermissionMode,
445
498
  });
@@ -465,12 +518,15 @@ export class CodexAcpAgent extends BaseAcpAgent {
465
518
  params: ForkSessionRequest,
466
519
  ): Promise<ForkSessionResponse> {
467
520
  const meta = params._meta as NewSessionMeta | undefined;
468
- const injectedParams = this.applyStructuredOutput(
469
- {
470
- cwd: params.cwd,
471
- mcpServers: params.mcpServers ?? [],
472
- _meta: params._meta,
473
- },
521
+ const injectedParams = this.applyLocalTools(
522
+ this.applyStructuredOutput(
523
+ {
524
+ cwd: params.cwd,
525
+ mcpServers: params.mcpServers ?? [],
526
+ _meta: params._meta,
527
+ },
528
+ meta,
529
+ ),
474
530
  meta,
475
531
  );
476
532
 
@@ -483,7 +539,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
483
539
  const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode);
484
540
  this.sessionState = createSessionState(newResponse.sessionId, params.cwd, {
485
541
  taskRunId: meta?.taskRunId,
486
- taskId: meta?.taskId ?? meta?.persistence?.taskId,
542
+ taskId: resolveTaskId(meta),
487
543
  modeId: newResponse.modes?.currentModeId ?? "auto",
488
544
  permissionMode: requestedPermissionMode,
489
545
  });
@@ -531,6 +587,45 @@ export class CodexAcpAgent extends BaseAcpAgent {
531
587
  };
532
588
  }
533
589
 
590
+ /**
591
+ * Injects the stdio general local-tools MCP server. Tools self-gate via the
592
+ * registry (e.g. signed-commit is cloud-only and needs a GH token), so the
593
+ * server is only injected when at least one tool's gate passes. Their
594
+ * instructions already live in the shared cloud system prompt, so only the
595
+ * server needs injecting here.
596
+ */
597
+ private applyLocalTools<
598
+ T extends { cwd?: string; mcpServers?: McpServer[]; _meta?: unknown },
599
+ >(request: T, meta: NewSessionMeta | undefined): T {
600
+ const cwd = request.cwd;
601
+ if (!cwd) {
602
+ return request;
603
+ }
604
+ const ctx: LocalToolCtx = {
605
+ cwd,
606
+ token: resolveGithubToken(),
607
+ taskId: resolveTaskId(meta),
608
+ };
609
+ const tools = enabledLocalTools(ctx, meta);
610
+ if (tools.length === 0) {
611
+ if (isCloudRun(meta)) {
612
+ this.logger.warn(
613
+ "Cloud run registered no local tools — missing GH_TOKEN/GITHUB_TOKEN? signed commits unavailable",
614
+ );
615
+ }
616
+ return request;
617
+ }
618
+
619
+ const mcpServer = buildLocalToolsMcpServer(
620
+ ctx,
621
+ tools.map((t) => t.name),
622
+ );
623
+ return {
624
+ ...request,
625
+ mcpServers: [...(request.mcpServers ?? []), mcpServer],
626
+ };
627
+ }
628
+
534
629
  private async applyInitialPermissionMode(
535
630
  sessionId: string,
536
631
  permissionMode?: string,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Standalone stdio MCP server exposing the general local tools to the Codex
3
+ * adapter. Spawned by codex-acp as an MCP server process. Reads its context
4
+ * (cwd, taskId, token) from POSTHOG_LOCAL_TOOLS_CTX and the set of tools to
5
+ * register from POSTHOG_LOCAL_TOOLS_ENABLED (both set by the parent, which has
6
+ * already evaluated each tool's gate) — then registers those registry tools,
7
+ * the same ones the Claude adapter exposes in-process.
8
+ *
9
+ * Usage:
10
+ * POSTHOG_LOCAL_TOOLS_CTX=<base64> \
11
+ * POSTHOG_LOCAL_TOOLS_ENABLED=git_signed_commit \
12
+ * node local-tools-mcp-server.js
13
+ */
14
+
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { readGithubTokenFromEnv } from "@posthog/git/signed-commit";
18
+ import {
19
+ LOCAL_TOOLS,
20
+ LOCAL_TOOLS_MCP_NAME,
21
+ type LocalToolCtx,
22
+ } from "../local-tools";
23
+
24
+ function die(message: string): never {
25
+ process.stderr.write(`[local-tools-mcp-server] ${message}\n`);
26
+ process.exit(1);
27
+ }
28
+
29
+ const ctxEnv = process.env.POSTHOG_LOCAL_TOOLS_CTX;
30
+ if (!ctxEnv) {
31
+ die("POSTHOG_LOCAL_TOOLS_CTX env var is required");
32
+ }
33
+
34
+ let parsed: { cwd: string; taskId?: string; token?: string };
35
+ try {
36
+ parsed = JSON.parse(Buffer.from(ctxEnv, "base64").toString("utf-8"));
37
+ } catch (err) {
38
+ die(`Failed to parse POSTHOG_LOCAL_TOOLS_CTX as base64-encoded JSON: ${err}`);
39
+ }
40
+
41
+ if (!parsed.cwd) {
42
+ die("POSTHOG_LOCAL_TOOLS_CTX must include cwd");
43
+ }
44
+
45
+ const ctx: LocalToolCtx = {
46
+ cwd: parsed.cwd,
47
+ token: parsed.token ?? readGithubTokenFromEnv(),
48
+ taskId: parsed.taskId,
49
+ };
50
+
51
+ const enabledNames = (process.env.POSTHOG_LOCAL_TOOLS_ENABLED ?? "")
52
+ .split(",")
53
+ .filter(Boolean);
54
+ const tools = LOCAL_TOOLS.filter((t) => enabledNames.includes(t.name));
55
+ if (tools.length === 0) {
56
+ die("POSTHOG_LOCAL_TOOLS_ENABLED listed no known tools");
57
+ }
58
+
59
+ const server = new McpServer({
60
+ name: LOCAL_TOOLS_MCP_NAME,
61
+ version: "1.0.0",
62
+ });
63
+
64
+ for (const t of tools) {
65
+ server.tool(t.name, t.description, t.schema, async (args) =>
66
+ t.handler(ctx, args),
67
+ );
68
+ }
69
+
70
+ const transport = new StdioServerTransport();
71
+ await server.connect(transport);
@@ -0,0 +1,22 @@
1
+ import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry";
2
+ import { signedCommitTool } from "./tools/signed-commit";
3
+
4
+ export {
5
+ LOCAL_TOOLS_MCP_NAME,
6
+ type LocalTool,
7
+ type LocalToolCtx,
8
+ type LocalToolGateMeta,
9
+ type LocalToolResult,
10
+ qualifiedLocalToolName,
11
+ } from "./registry";
12
+
13
+ /** Every tool the general local MCP server can expose. Add new tools here. */
14
+ export const LOCAL_TOOLS: LocalTool[] = [signedCommitTool];
15
+
16
+ /** Tools whose gate passes for the given context — the set to actually expose. */
17
+ export function enabledLocalTools(
18
+ ctx: LocalToolCtx,
19
+ meta: LocalToolGateMeta | undefined,
20
+ ): LocalTool[] {
21
+ return LOCAL_TOOLS.filter((t) => t.isEnabled(ctx, meta));
22
+ }
@@ -0,0 +1,57 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ enabledLocalTools,
4
+ LOCAL_TOOLS,
5
+ LOCAL_TOOLS_MCP_NAME,
6
+ qualifiedLocalToolName,
7
+ } from "./index";
8
+
9
+ describe("local-tools registry", () => {
10
+ const savedSandbox = process.env.IS_SANDBOX;
11
+
12
+ beforeEach(() => {
13
+ // isCloudRun also keys off IS_SANDBOX; clear it so meta.taskRunId is the
14
+ // only cloud signal under test.
15
+ delete process.env.IS_SANDBOX;
16
+ });
17
+
18
+ afterEach(() => {
19
+ if (savedSandbox === undefined) {
20
+ delete process.env.IS_SANDBOX;
21
+ } else {
22
+ process.env.IS_SANDBOX = savedSandbox;
23
+ }
24
+ });
25
+
26
+ it("registers tools with unique names", () => {
27
+ const names = LOCAL_TOOLS.map((t) => t.name);
28
+ expect(new Set(names).size).toBe(names.length);
29
+ });
30
+
31
+ it("qualifies tool names under the general server", () => {
32
+ expect(qualifiedLocalToolName("git_signed_commit")).toBe(
33
+ `mcp__${LOCAL_TOOLS_MCP_NAME}__git_signed_commit`,
34
+ );
35
+ });
36
+
37
+ it.each([
38
+ { name: "cloud run with a token", taskRunId: "run-1", token: "ghs_x" },
39
+ { name: "cloud run without a token", taskRunId: "run-1", token: undefined },
40
+ { name: "desktop run with a token", taskRunId: undefined, token: "ghs_x" },
41
+ {
42
+ name: "desktop run without a token",
43
+ taskRunId: undefined,
44
+ token: undefined,
45
+ },
46
+ ])(
47
+ "exposes git_signed_commit only in $name when cloud+token",
48
+ ({ taskRunId, token }) => {
49
+ const tools = enabledLocalTools(
50
+ { cwd: "/repo", token },
51
+ taskRunId ? { taskRunId } : undefined,
52
+ );
53
+ const hasSignedCommit = tools.some((t) => t.name === "git_signed_commit");
54
+ expect(hasSignedCommit).toBe(Boolean(taskRunId) && Boolean(token));
55
+ },
56
+ );
57
+ });
@@ -0,0 +1,81 @@
1
+ import type { z } from "zod";
2
+
3
+ /**
4
+ * A single general-purpose local MCP server hosts every tool registered here,
5
+ * for both adapters: the Claude in-process SDK server and the Codex stdio
6
+ * server. Adding a tool means adding one entry to `LOCAL_TOOLS` (see
7
+ * `./index.ts`) — no per-tool server file or adapter wiring. The name appears
8
+ * in tool ids as `mcp__posthog-local__<tool>`.
9
+ */
10
+ export const LOCAL_TOOLS_MCP_NAME = "posthog-local";
11
+
12
+ /** Runtime context handed to every local tool's handler and gate. */
13
+ export interface LocalToolCtx {
14
+ cwd: string;
15
+ /** GitHub token available to the sandbox, if any. */
16
+ token?: string;
17
+ taskId?: string;
18
+ }
19
+
20
+ /** Minimal session-meta shape needed to gate tools (e.g. cloud-only). */
21
+ export interface LocalToolGateMeta {
22
+ taskRunId?: string;
23
+ }
24
+
25
+ /**
26
+ * MCP tool result shape. Carries an open index signature so the value is
27
+ * assignable to either SDK's `CallToolResult` (the Claude SDK and the MCP SDK
28
+ * both attach an open `_meta`).
29
+ */
30
+ export interface LocalToolResult {
31
+ content: { type: "text"; text: string }[];
32
+ isError?: true;
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ /** Tool definition with its input schema's type preserved for the handler. */
37
+ export interface LocalToolDef<S extends z.ZodRawShape> {
38
+ name: string;
39
+ description: string;
40
+ schema: S;
41
+ /**
42
+ * Keep the tool visible even though MCP tools are offloaded behind ToolSearch
43
+ * by default in the Claude adapter (ENABLE_TOOL_SEARCH). Ignored by Codex.
44
+ */
45
+ alwaysLoad?: boolean;
46
+ isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean;
47
+ handler(
48
+ ctx: LocalToolCtx,
49
+ args: z.infer<z.ZodObject<S>>,
50
+ ): Promise<LocalToolResult>;
51
+ }
52
+
53
+ /** Schema-erased tool, the shape stored in the registry array. */
54
+ export interface LocalTool {
55
+ name: string;
56
+ description: string;
57
+ schema: z.ZodRawShape;
58
+ alwaysLoad?: boolean;
59
+ isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean;
60
+ handler(
61
+ ctx: LocalToolCtx,
62
+ args: Record<string, unknown>,
63
+ ): Promise<LocalToolResult>;
64
+ }
65
+
66
+ /**
67
+ * Registers a tool, preserving its schema's inferred type at the definition
68
+ * site. The returned value erases the schema generic so tools of different
69
+ * shapes can live in one array; the cast is sound because both MCP SDKs
70
+ * validate `args` against `schema` before dispatching to the handler.
71
+ */
72
+ export function defineLocalTool<S extends z.ZodRawShape>(
73
+ def: LocalToolDef<S>,
74
+ ): LocalTool {
75
+ return def as unknown as LocalTool;
76
+ }
77
+
78
+ /** The qualified tool id as the model and tool guards see it. */
79
+ export function qualifiedLocalToolName(toolName: string): string {
80
+ return `mcp__${LOCAL_TOOLS_MCP_NAME}__${toolName}`;
81
+ }
@@ -0,0 +1,26 @@
1
+ import { isCloudRun } from "../../../utils/common";
2
+ import {
3
+ runSignedCommitTool,
4
+ SIGNED_COMMIT_TOOL_DESCRIPTION,
5
+ SIGNED_COMMIT_TOOL_NAME,
6
+ signedCommitToolSchema,
7
+ } from "../../signed-commit-shared";
8
+ import { defineLocalTool } from "../registry";
9
+
10
+ /**
11
+ * `git_signed_commit` as a local tool. Cloud runs only, and only when a GitHub
12
+ * token is available (the commit is created via GitHub's API, which also signs
13
+ * it). Committing is core to cloud tasks, so keep it visible past ToolSearch.
14
+ */
15
+ export const signedCommitTool = defineLocalTool({
16
+ name: SIGNED_COMMIT_TOOL_NAME,
17
+ description: SIGNED_COMMIT_TOOL_DESCRIPTION,
18
+ schema: signedCommitToolSchema,
19
+ alwaysLoad: true,
20
+ isEnabled: (ctx, meta) => isCloudRun(meta) && !!ctx.token,
21
+ handler: (ctx, args) =>
22
+ runSignedCommitTool(
23
+ { cwd: ctx.cwd, token: ctx.token ?? "", taskId: ctx.taskId },
24
+ args,
25
+ ),
26
+ });
@@ -0,0 +1,16 @@
1
+ /** Minimal shape needed to resolve the effective task id from session meta. */
2
+ interface TaskIdSource {
3
+ taskId?: string;
4
+ persistence?: { taskId?: string };
5
+ }
6
+
7
+ /**
8
+ * The task id can arrive directly on the session meta or nested under
9
+ * `persistence`; prefer the top-level value. Shared by the Claude and Codex
10
+ * adapters so the fallback chain stays in sync.
11
+ */
12
+ export function resolveTaskId(
13
+ meta: TaskIdSource | undefined,
14
+ ): string | undefined {
15
+ return meta?.taskId ?? meta?.persistence?.taskId;
16
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ createSignedCommit,
3
+ type SignedCommitCtx,
4
+ type SignedCommitInput,
5
+ type SignedCommitResult,
6
+ } from "@posthog/git/signed-commit";
7
+ import { z } from "zod";
8
+ import { qualifiedLocalToolName } from "./local-tools/registry";
9
+
10
+ /**
11
+ * Shared definitions for the `git_signed_commit` tool, used by the local-tools
12
+ * registry entry (which both adapters expose) so the tool name, schema,
13
+ * description, and result formatting can't drift. The qualified name also
14
+ * appears in the cloud system prompt and the PreToolUse guard message.
15
+ */
16
+
17
+ export const SIGNED_COMMIT_TOOL_NAME = "git_signed_commit";
18
+ export const SIGNED_COMMIT_QUALIFIED_TOOL_NAME = qualifiedLocalToolName(
19
+ SIGNED_COMMIT_TOOL_NAME,
20
+ );
21
+
22
+ export const SIGNED_COMMIT_TOOL_DESCRIPTION =
23
+ "Create a GitHub-signed (Verified) commit on the branch. Stage files with `git add` " +
24
+ "first (or pass `paths`), then call this instead of `git commit`/`git push` — those are " +
25
+ "blocked because all commits must be signed. The commit is created via GitHub's API and " +
26
+ "your local checkout is kept in sync. For a new branch, pass `branch` (prefixed with " +
27
+ "`posthog-code/`) and the tool creates it on the remote.";
28
+
29
+ export const signedCommitToolSchema = {
30
+ message: z.string().describe("Commit headline (first line)."),
31
+ body: z.string().optional().describe("Optional extended commit body."),
32
+ branch: z
33
+ .string()
34
+ .optional()
35
+ .describe(
36
+ "Target branch; defaults to the current branch. Use a posthog-code/ prefix for new branches.",
37
+ ),
38
+ paths: z
39
+ .array(z.string())
40
+ .optional()
41
+ .describe(
42
+ "Files to stage before committing; defaults to already-staged files.",
43
+ ),
44
+ };
45
+
46
+ export function formatSignedCommitResult(result: SignedCommitResult): string {
47
+ const list = result.commits.map((c) => `- ${c.sha} ${c.url}`).join("\n");
48
+ return `Created ${result.commits.length} signed commit(s) on ${result.branch}:\n${list}`;
49
+ }
50
+
51
+ export interface SignedCommitToolResult {
52
+ content: { type: "text"; text: string }[];
53
+ isError?: true;
54
+ // Both SDKs' CallToolResult carries an open `_meta`/index signature; mirror it
55
+ // so this shape is assignable to either adapter's tool-handler return type.
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ /**
60
+ * Runs `git_signed_commit` and formats the MCP result. Shared by the Claude
61
+ * in-process tool and the Codex stdio server so success/error formatting (and
62
+ * the error-message prefix) can't drift between adapters.
63
+ */
64
+ export async function runSignedCommitTool(
65
+ ctx: SignedCommitCtx,
66
+ args: SignedCommitInput,
67
+ ): Promise<SignedCommitToolResult> {
68
+ try {
69
+ const result = await createSignedCommit(ctx, args);
70
+ return {
71
+ content: [{ type: "text", text: formatSignedCommitResult(result) }],
72
+ };
73
+ } catch (err) {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ return {
76
+ content: [
77
+ { type: "text", text: `${SIGNED_COMMIT_TOOL_NAME} failed: ${message}` },
78
+ ],
79
+ isError: true,
80
+ };
81
+ }
82
+ }
@@ -2,13 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
2
  import { AgentServer } from "./agent-server";
3
3
 
4
4
  interface TestableServer {
5
- configureEnvironment(args?: { isInternal?: boolean }): void;
5
+ configureEnvironment(args?: {
6
+ isInternal?: boolean;
7
+ originProduct?: string | null;
8
+ taskId?: string | null;
9
+ taskRunId?: string | null;
10
+ taskUserId?: number | null;
11
+ }): void;
6
12
  }
7
13
 
8
14
  const ENV_KEYS_UNDER_TEST = [
9
15
  "LLM_GATEWAY_URL",
10
16
  "ANTHROPIC_BASE_URL",
11
17
  "OPENAI_BASE_URL",
18
+ "ANTHROPIC_CUSTOM_HEADERS",
12
19
  ] as const;
13
20
 
14
21
  describe("AgentServer.configureEnvironment", () => {
@@ -85,6 +92,62 @@ describe("AgentServer.configureEnvironment", () => {
85
92
  expect(fromBackground).toBe("https://gateway.us.posthog.com/posthog_code");
86
93
  });
87
94
 
95
+ it("tags as signals when an internal task has origin_product 'signal_report'", () => {
96
+ buildServer("background").configureEnvironment({
97
+ isInternal: true,
98
+ originProduct: "signal_report",
99
+ });
100
+
101
+ expect(process.env.LLM_GATEWAY_URL).toBe(
102
+ "https://gateway.us.posthog.com/signals",
103
+ );
104
+ expect(process.env.ANTHROPIC_BASE_URL).toBe(
105
+ "https://gateway.us.posthog.com/signals",
106
+ );
107
+ expect(process.env.OPENAI_BASE_URL).toBe(
108
+ "https://gateway.us.posthog.com/signals/v1",
109
+ );
110
+ });
111
+
112
+ it("does not tag as signals when origin_product is 'signal_report' but the task is not internal", () => {
113
+ buildServer("background").configureEnvironment({
114
+ isInternal: false,
115
+ originProduct: "signal_report",
116
+ });
117
+
118
+ expect(process.env.LLM_GATEWAY_URL).toBe(
119
+ "https://gateway.us.posthog.com/posthog_code",
120
+ );
121
+ });
122
+
123
+ it("forwards task metadata as ANTHROPIC_CUSTOM_HEADERS", () => {
124
+ buildServer("background").configureEnvironment({
125
+ isInternal: true,
126
+ originProduct: "signal_report",
127
+ taskId: "task-abc",
128
+ taskRunId: "run-xyz",
129
+ taskUserId: 42,
130
+ });
131
+
132
+ expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe(
133
+ [
134
+ "x-posthog-property-task_origin_product: signal_report",
135
+ "x-posthog-property-task_internal: true",
136
+ "x-posthog-property-task_id: task-abc",
137
+ "x-posthog-property-task_run_id: run-xyz",
138
+ "x-posthog-property-task_user_id: 42",
139
+ ].join("\n"),
140
+ );
141
+ });
142
+
143
+ it("omits optional task metadata from ANTHROPIC_CUSTOM_HEADERS when not provided", () => {
144
+ buildServer("background").configureEnvironment({ isInternal: false });
145
+
146
+ expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe(
147
+ "x-posthog-property-task_internal: false",
148
+ );
149
+ });
150
+
88
151
  it("respects the LLM_GATEWAY_URL override regardless of internal flag", () => {
89
152
  process.env.LLM_GATEWAY_URL = "http://ngrok.test/proxy";
90
153