Skip to content

Commit e42e8dc

Browse files
authored
Merge branch 'main' into posthog-code/group-configure-settings
2 parents 3eceb1f + 2f3bc90 commit e42e8dc

40 files changed

Lines changed: 1773 additions & 340 deletions

.github/workflows/agent-release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ jobs:
5757
- name: Build enricher (agent dependency)
5858
run: pnpm --filter @posthog/enricher run build
5959

60+
- name: Build harness (agent dependency)
61+
run: pnpm --filter @posthog/harness run build
62+
6063
- name: Build the package
6164
run: pnpm --filter agent run build
6265

packages/agent/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ Four modes defined in `src/execution-mode.ts`:
7474

7575
In cloud background mode, permissions are always auto-approved. In interactive mode, the permission system is active and configurable per session. Tool categorization lives in `src/adapters/claude/tools.ts` — each tool belongs to a group (read, write, bash, search, web, agent) and modes whitelist groups.
7676

77+
Cloud provisioning can pass `--posthogExecPermissionRegex <regex>` to require one-time client approval for matching PostHog MCP `exec` sub-tools in every interactive cloud Claude and Codex permission mode. Non-matching sub-tools never prompt. Locally, hands-off modes stay hands-off: Claude `auto` and `bypassPermissions`, and Codex `auto` and `full-access`, auto-approve matching sub-tools; other local modes prompt. Matching is case-insensitive against the delegated name in `call [--json] <sub-tool> ...`. These prompts offer Claude users an always-allow choice remembered in local repository settings; Codex approvals remain one-time. An invalid or empty regex is logged and falls back to the default. Background runs keep their existing auto-approval behavior. The default is `(^|-)(partial-update|update|patch|delete|destroy)(-|$)`.
78+
7779
## ACP connection layer
7880

7981
`createAcpConnection()` in `src/adapters/acp-connection.ts` is the heart of the package. It's a factory that returns a `{ clientStreams, cleanup }` object — a pair of ndJson `ReadableStream`/`WritableStream` that the caller uses to speak ACP.
@@ -142,9 +144,9 @@ JWT validation (`src/server/jwt.ts`) uses RS256 with a configurable public key.
142144

143145
When `POST /command` receives a `user_message`, it doesn't handle it directly — it calls `clientConnection.prompt()` on the ACP `ClientSideConnection`, which sends a `session/prompt` message through the ACP streams to the agent. Similarly, `cancel` sends `session/cancel`. This means all commands follow the same path as in-process calls from PostHog Code, with the HTTP layer just being a thin translation.
144146

145-
### Auto-approval in cloud mode
147+
### Permission routing in cloud mode
146148

147-
The `AgentServer` provides a `requestPermission` callback to the `ClientSideConnection` that always selects the "allow" option. In background mode this is necessary (no human to ask). In interactive mode it currently does the same, with a TODO for future per-tool approval via SSE round-trips.
149+
The `AgentServer` provides the `requestPermission` callback to the `ClientSideConnection`. Background mode selects an allow option automatically. Interactive mode relays approvals that need a person over SSE and parks them until a client responds; other requests follow the selected permission mode.
148150

149151
### Checkpoint capture
150152

@@ -157,6 +159,7 @@ npx agent-server \
157159
--port 3001 \
158160
--mode interactive \
159161
--repositoryPath /path/to/repo \
162+
--posthogExecPermissionRegex '(^|-)(partial-update|update|patch|delete|destroy)(-|$)' \
160163
--taskId task_123 \
161164
--runId run_456
162165
```

packages/agent/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
"@posthog/shared": "workspace:*",
146146
"@posthog/git": "workspace:*",
147147
"@posthog/enricher": "workspace:*",
148+
"@posthog/harness": "workspace:*",
148149
"@types/bun": "latest",
149150
"@types/tar": "^6.1.13",
150151
"msw": "^2.12.7",
@@ -167,7 +168,6 @@
167168
"@opentelemetry/resources": "^2.0.0",
168169
"@opentelemetry/sdk-logs": "^0.208.0",
169170
"@opentelemetry/semantic-conventions": "^1.28.0",
170-
"@posthog/harness": "workspace:*",
171171
"@types/jsonwebtoken": "^9.0.10",
172172
"commander": "^14.0.2",
173173
"fflate": "^0.8.2",

packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { mkdtempSync, rmSync } from "node:fs";
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
import * as os from "node:os";
33
import * as path from "node:path";
44
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
5+
import type { HookInput, Options } from "@anthropic-ai/claude-agent-sdk";
56
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
67
import { DEFAULT_GATEWAY_MODEL } from "../../gateway-models";
78

@@ -38,11 +39,13 @@ function makeQueryHandle(): SdkQueryHandle {
3839
}
3940

4041
const createdQueries: SdkQueryHandle[] = [];
42+
const createdQueryOptions: Options[] = [];
4143

4244
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
43-
query: vi.fn(() => {
45+
query: vi.fn(({ options }: { options: Options }) => {
4446
const handle = makeQueryHandle();
4547
createdQueries.push(handle);
48+
createdQueryOptions.push(options);
4649
return handle;
4750
}),
4851
getSessionMessages: vi.fn().mockResolvedValue([]),
@@ -87,6 +90,14 @@ const cwd = mkdtempSync(path.join(os.tmpdir(), "claude-agent-test-cwd-"));
8790
const configDir = mkdtempSync(
8891
path.join(os.tmpdir(), "claude-agent-test-config-"),
8992
);
93+
const permissionCwd = mkdtempSync(
94+
path.join(os.tmpdir(), "claude-agent-permission-test-cwd-"),
95+
);
96+
mkdirSync(path.join(permissionCwd, ".claude"), { recursive: true });
97+
writeFileSync(
98+
path.join(permissionCwd, ".claude", "settings.json"),
99+
JSON.stringify({ permissions: { allow: ["mcp__posthog__exec"] } }),
100+
);
90101
const savedEnv = {
91102
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
92103
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
@@ -95,6 +106,7 @@ const savedEnv = {
95106
afterAll(() => {
96107
rmSync(cwd, { recursive: true, force: true });
97108
rmSync(configDir, { recursive: true, force: true });
109+
rmSync(permissionCwd, { recursive: true, force: true });
98110
process.env.ANTHROPIC_BASE_URL = savedEnv.ANTHROPIC_BASE_URL;
99111
process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR;
100112
if (savedEnv.ANTHROPIC_BASE_URL === undefined) {
@@ -105,9 +117,10 @@ afterAll(() => {
105117
}
106118
});
107119

108-
describe("ClaudeAcpAgent session model on resume", () => {
120+
describe("ClaudeAcpAgent session creation", () => {
109121
beforeEach(() => {
110122
createdQueries.length = 0;
123+
createdQueryOptions.length = 0;
111124
nextInitPromise = Promise.resolve({
112125
result: "success",
113126
commands: [],
@@ -119,6 +132,154 @@ describe("ClaudeAcpAgent session model on resume", () => {
119132
process.env.CLAUDE_CONFIG_DIR = configDir;
120133
});
121134

135+
async function runPostHogExecPreToolUse(
136+
options: Options,
137+
subTool: string,
138+
): Promise<string | undefined> {
139+
const input = {
140+
session_id: "permission-session",
141+
transcript_path: "/tmp/transcript",
142+
cwd: permissionCwd,
143+
hook_event_name: "PreToolUse",
144+
tool_name: "mcp__posthog__exec",
145+
tool_use_id: "toolu_permission",
146+
tool_input: { command: `call ${subTool} {}` },
147+
} as HookInput;
148+
149+
for (const hook of (options.hooks?.PreToolUse ?? []).flatMap(
150+
(entry) => entry.hooks ?? [],
151+
)) {
152+
const result = await hook(input, undefined, {
153+
signal: new AbortController().signal,
154+
});
155+
const decision = (
156+
result as {
157+
hookSpecificOutput?: { permissionDecision?: string };
158+
}
159+
).hookSpecificOutput?.permissionDecision;
160+
if (decision) return decision;
161+
}
162+
163+
return undefined;
164+
}
165+
166+
it.each(["new", "resume", "load"] as const)(
167+
"uses the default PostHog exec permission regex for local %s sessions when metadata omits it",
168+
async (sessionKind) => {
169+
const agent = makeAgent();
170+
const sessionIds = {
171+
new: "0197a000-0000-7000-8000-000000000101",
172+
resume: "0197a000-0000-7000-8000-000000000102",
173+
load: "0197a000-0000-7000-8000-000000000103",
174+
};
175+
const params = {
176+
sessionId: sessionIds[sessionKind],
177+
cwd: permissionCwd,
178+
mcpServers: [],
179+
_meta: { taskRunId: `run-permission-${sessionKind}` },
180+
};
181+
182+
if (sessionKind === "new") {
183+
await agent.newSession(params);
184+
} else if (sessionKind === "resume") {
185+
await agent.resumeSession(params);
186+
} else {
187+
await agent.loadSession(params);
188+
}
189+
190+
expect(createdQueryOptions).toHaveLength(1);
191+
await expect(
192+
runPostHogExecPreToolUse(
193+
createdQueryOptions[0] as Options,
194+
"dashboard-update",
195+
),
196+
).resolves.toBe("ask");
197+
},
198+
);
199+
200+
it("uses an explicit PostHog exec permission regex instead of the default", async () => {
201+
const agent = makeAgent();
202+
203+
await agent.newSession({
204+
cwd: permissionCwd,
205+
mcpServers: [],
206+
_meta: {
207+
taskRunId: "run-permission-custom",
208+
posthogExecPermissionRegex: "(^|-)archive(-|$)",
209+
},
210+
});
211+
212+
expect(createdQueryOptions).toHaveLength(1);
213+
await expect(
214+
runPostHogExecPreToolUse(
215+
createdQueryOptions[0] as Options,
216+
"dashboard-update",
217+
),
218+
).resolves.toBe("allow");
219+
await expect(
220+
runPostHogExecPreToolUse(
221+
createdQueryOptions[0] as Options,
222+
"dashboard-archive",
223+
),
224+
).resolves.toBe("ask");
225+
});
226+
227+
it.each(["[", ""])(
228+
"warns and falls back to the default regex when metadata carries the invalid regex %j",
229+
async (posthogExecPermissionRegex) => {
230+
const agent = makeAgent();
231+
const warnSpy = vi.spyOn(agent.logger, "warn");
232+
233+
await agent.newSession({
234+
cwd: permissionCwd,
235+
mcpServers: [],
236+
_meta: {
237+
taskRunId: "run-permission-invalid",
238+
posthogExecPermissionRegex,
239+
},
240+
});
241+
242+
expect(warnSpy).toHaveBeenCalledWith(
243+
expect.stringContaining("Invalid posthogExecPermissionRegex"),
244+
expect.anything(),
245+
);
246+
expect(createdQueryOptions).toHaveLength(1);
247+
await expect(
248+
runPostHogExecPreToolUse(
249+
createdQueryOptions[0] as Options,
250+
"dashboard-update",
251+
),
252+
).resolves.toBe("ask");
253+
await expect(
254+
runPostHogExecPreToolUse(
255+
createdQueryOptions[0] as Options,
256+
"dashboard-get",
257+
),
258+
).resolves.toBe("allow");
259+
},
260+
);
261+
262+
it.each([
263+
{ environment: "local", expectedCloudMode: false },
264+
{ environment: "cloud", expectedCloudMode: true },
265+
] as const)(
266+
"records $environment sessions as cloudMode=$expectedCloudMode",
267+
async ({ environment, expectedCloudMode }) => {
268+
const agent = makeAgent();
269+
270+
await agent.newSession({
271+
cwd,
272+
mcpServers: [],
273+
_meta: { environment, taskRunId: `run-${environment}` },
274+
});
275+
276+
expect(
277+
(agent as unknown as { session: { cloudMode: boolean } }).session
278+
.cloudMode,
279+
).toBe(expectedCloudMode);
280+
},
281+
);
282+
122283
// The SDK does not carry the model across resume — without an explicit
123284
// setModel the resumed session silently runs the SDK default (opus).
124285
it.each([

packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,17 @@ function textDelta(sessionId: string, text: string) {
149149
};
150150
}
151151

152-
function assistantMessage(sessionId: string, apiId: string, text: string) {
152+
function assistantMessage(
153+
sessionId: string,
154+
apiId: string,
155+
text: string,
156+
usage?: {
157+
input_tokens: number;
158+
output_tokens: number;
159+
cache_read_input_tokens: number;
160+
cache_creation_input_tokens: number;
161+
},
162+
) {
153163
return {
154164
type: "assistant",
155165
parent_tool_use_id: null,
@@ -159,6 +169,20 @@ function assistantMessage(sessionId: string, apiId: string, text: string) {
159169
id: apiId,
160170
role: "assistant",
161171
content: [{ type: "text", text }],
172+
...(usage ? { usage } : {}),
173+
},
174+
};
175+
}
176+
177+
function compactBoundary(sessionId: string) {
178+
return {
179+
type: "system",
180+
subtype: "compact_boundary",
181+
session_id: sessionId,
182+
uuid: "compact-1",
183+
compact_metadata: {
184+
trigger: "auto",
185+
pre_tokens: 434_000,
162186
},
163187
};
164188
}
@@ -192,6 +216,23 @@ function messageChunkTexts(
192216
.map((update) => update?.content?.text ?? "");
193217
}
194218

219+
function usageUpdates(
220+
calls: ClientMocks["sessionUpdate"]["mock"]["calls"],
221+
): Array<{ used: number; size: number }> {
222+
return calls.flatMap(([call]) => {
223+
const update = (
224+
call as {
225+
update?: { sessionUpdate?: string; used?: number; size?: number };
226+
}
227+
).update;
228+
return update?.sessionUpdate === "usage_update" &&
229+
typeof update.used === "number" &&
230+
typeof update.size === "number"
231+
? [{ used: update.used, size: update.size }]
232+
: [];
233+
});
234+
}
235+
195236
describe("ClaudeAcpAgent.prompt — streamed assistant text wiring", () => {
196237
beforeEach(() => {
197238
vi.clearAllMocks();
@@ -243,6 +284,48 @@ describe("ClaudeAcpAgent.prompt — streamed assistant text wiring", () => {
243284
]);
244285
});
245286

287+
it("does not replace known context usage with incomplete gateway snapshots", async () => {
288+
const { agent, client } = makeAgent();
289+
const sessionId = "s-context-usage";
290+
const { query, input } = installFakeSession(agent, sessionId);
291+
const session = (
292+
agent as unknown as {
293+
session: { contextUsed?: number; lastContextWindowSize?: number };
294+
}
295+
).session;
296+
session.contextUsed = 434_000;
297+
session.lastContextWindowSize = 1_000_000;
298+
vi.mocked(query.getContextUsage).mockRejectedValue(
299+
new Error("context usage unavailable"),
300+
);
301+
302+
const promptPromise = agent.prompt({
303+
sessionId,
304+
prompt: [{ type: "text", text: "continue" }],
305+
});
306+
await tick();
307+
308+
await echoUserMessage(query, input);
309+
await send(query, messageStart(sessionId, "msg-context"));
310+
await send(query, compactBoundary(sessionId));
311+
await send(
312+
query,
313+
assistantMessage(sessionId, "msg-context", "done", {
314+
input_tokens: 440_000,
315+
output_tokens: 100,
316+
cache_read_input_tokens: 0,
317+
cache_creation_input_tokens: 0,
318+
}),
319+
);
320+
await send(query, resultSuccess(sessionId));
321+
await promptPromise;
322+
323+
const updates = usageUpdates(client.sessionUpdate.mock.calls);
324+
expect(updates.length).toBeGreaterThan(0);
325+
expect(updates.every(({ used }) => used >= 434_000)).toBe(true);
326+
expect(updates.every(({ size }) => size === 1_000_000)).toBe(true);
327+
});
328+
246329
it("keeps the original turn open until a pending steer is consumed", async () => {
247330
const { agent, client } = makeAgent();
248331
const sessionId = "s-steer-ordering";

0 commit comments

Comments
 (0)