@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.
- package/dist/adapters/claude/permissions/permission-options.js +700 -0
- package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
- package/dist/adapters/claude/tools.js +700 -0
- package/dist/adapters/claude/tools.js.map +1 -1
- package/dist/adapters/codex/local-tools-mcp-server.d.ts +2 -0
- package/dist/adapters/codex/local-tools-mcp-server.js +1172 -0
- package/dist/adapters/codex/local-tools-mcp-server.js.map +1 -0
- package/dist/agent.js +1488 -219
- package/dist/agent.js.map +1 -1
- package/dist/execution-mode.js +700 -0
- package/dist/execution-mode.js.map +1 -1
- package/dist/handoff-checkpoint.js.map +1 -1
- package/dist/posthog-api.d.ts +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +1637 -342
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +1553 -261
- package/dist/server/bin.cjs.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/claude/claude-agent.ts +32 -2
- package/src/adapters/claude/hooks.test.ts +54 -0
- package/src/adapters/claude/hooks.ts +86 -0
- package/src/adapters/claude/mcp/local-tools.test.ts +50 -0
- package/src/adapters/claude/mcp/local-tools.ts +40 -0
- package/src/adapters/claude/session/options.ts +14 -9
- package/src/adapters/claude/types.ts +1 -0
- package/src/adapters/codex/codex-agent.ts +117 -22
- package/src/adapters/codex/local-tools-mcp-server.ts +71 -0
- package/src/adapters/local-tools/index.ts +22 -0
- package/src/adapters/local-tools/registry.test.ts +57 -0
- package/src/adapters/local-tools/registry.ts +81 -0
- package/src/adapters/local-tools/tools/signed-commit.ts +26 -0
- package/src/adapters/session-meta.ts +16 -0
- package/src/adapters/signed-commit-shared.ts +82 -0
- package/src/server/agent-server.configure-environment.test.ts +64 -1
- package/src/server/agent-server.test.ts +2 -4
- package/src/server/agent-server.ts +60 -35
- package/src/types.ts +2 -1
- package/src/utils/common.ts +14 -0
- package/src/utils/gateway.test.ts +70 -0
- 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
|
|
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 =
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
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.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
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?: {
|
|
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
|
|