@posthog/agent 2.3.259 → 2.3.263
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/agent.js +9 -2
- package/dist/agent.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.d.ts +23 -0
- package/dist/server/agent-server.js +223 -20
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +232 -21
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +1 -1
- package/src/acp-extensions.ts +3 -0
- package/src/adapters/claude/permissions/permission-handlers.ts +11 -5
- package/src/server/agent-server.test.ts +140 -7
- package/src/server/agent-server.ts +317 -29
- package/src/server/bin.ts +13 -0
- package/src/server/question-relay.test.ts +34 -5
- package/src/server/schemas.test.ts +52 -0
- package/src/server/schemas.ts +16 -0
- package/src/server/types.ts +1 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ContentBlock,
|
|
3
|
+
RequestPermissionRequest,
|
|
4
|
+
RequestPermissionResponse,
|
|
5
|
+
} from "@agentclientprotocol/sdk";
|
|
2
6
|
import {
|
|
3
7
|
ClientSideConnection,
|
|
4
8
|
ndJsonStream,
|
|
@@ -14,6 +18,7 @@ import {
|
|
|
14
18
|
type InProcessAcpConnection,
|
|
15
19
|
} from "../adapters/acp-connection";
|
|
16
20
|
import { selectRecentTurns } from "../adapters/claude/session/jsonl-hydration";
|
|
21
|
+
import type { CodeExecutionMode } from "../execution-mode";
|
|
17
22
|
import { PostHogAPIClient } from "../posthog-api";
|
|
18
23
|
import {
|
|
19
24
|
type ConversationTurn,
|
|
@@ -157,6 +162,10 @@ interface ActiveSession {
|
|
|
157
162
|
sseController: SseController | null;
|
|
158
163
|
deviceInfo: DeviceInfo;
|
|
159
164
|
logWriter: SessionLogWriter;
|
|
165
|
+
/** Current permission mode, tracked for relay decisions */
|
|
166
|
+
permissionMode: CodeExecutionMode;
|
|
167
|
+
/** Whether a desktop client has ever connected via SSE during this session */
|
|
168
|
+
hasDesktopConnected: boolean;
|
|
160
169
|
}
|
|
161
170
|
|
|
162
171
|
export class AgentServer {
|
|
@@ -176,6 +185,15 @@ export class AgentServer {
|
|
|
176
185
|
// causing a second session to be created and duplicate Slack messages to be sent.
|
|
177
186
|
private initializationPromise: Promise<void> | null = null;
|
|
178
187
|
private pendingEvents: Record<string, unknown>[] = [];
|
|
188
|
+
private pendingPermissions = new Map<
|
|
189
|
+
string,
|
|
190
|
+
{
|
|
191
|
+
resolve: (response: {
|
|
192
|
+
outcome: { outcome: "selected"; optionId: string };
|
|
193
|
+
_meta?: Record<string, unknown>;
|
|
194
|
+
}) => void;
|
|
195
|
+
}
|
|
196
|
+
>();
|
|
179
197
|
|
|
180
198
|
private detachSseController(controller: SseController): void {
|
|
181
199
|
if (this.session?.sseController === controller) {
|
|
@@ -228,6 +246,10 @@ export class AgentServer {
|
|
|
228
246
|
return payload.mode ?? this.config.mode;
|
|
229
247
|
}
|
|
230
248
|
|
|
249
|
+
private getSessionPermissionMode(): CodeExecutionMode {
|
|
250
|
+
return this.session?.permissionMode ?? "default";
|
|
251
|
+
}
|
|
252
|
+
|
|
231
253
|
private createApp(): Hono {
|
|
232
254
|
const app = new Hono();
|
|
233
255
|
|
|
@@ -281,6 +303,7 @@ export class AgentServer {
|
|
|
281
303
|
await this.initializeSession(payload, sseController);
|
|
282
304
|
} else {
|
|
283
305
|
this.session.sseController = sseController;
|
|
306
|
+
this.session.hasDesktopConnected = true;
|
|
284
307
|
this.replayPendingEvents();
|
|
285
308
|
}
|
|
286
309
|
|
|
@@ -511,13 +534,9 @@ export class AgentServer {
|
|
|
511
534
|
prompt,
|
|
512
535
|
...(this.detectedPrUrl && {
|
|
513
536
|
_meta: {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
`You MUST:\n` +
|
|
518
|
-
`1. Check out the existing PR branch with \`gh pr checkout ${this.detectedPrUrl}\`\n` +
|
|
519
|
-
`2. Make changes, commit, and push to that branch\n` +
|
|
520
|
-
`You MUST NOT create a new branch, close the existing PR, or create a new PR.`,
|
|
537
|
+
// Keep the live-session PR override aligned with the startup
|
|
538
|
+
// prompt policy so non-Slack runs remain review-first.
|
|
539
|
+
prContext: this.buildDetectedPrContext(this.detectedPrUrl),
|
|
521
540
|
},
|
|
522
541
|
}),
|
|
523
542
|
});
|
|
@@ -579,6 +598,51 @@ export class AgentServer {
|
|
|
579
598
|
return { closed: true };
|
|
580
599
|
}
|
|
581
600
|
|
|
601
|
+
case "posthog/set_config_option":
|
|
602
|
+
case "set_config_option": {
|
|
603
|
+
const configId = params.configId as string;
|
|
604
|
+
const value = params.value as string;
|
|
605
|
+
|
|
606
|
+
this.logger.info("Set config option requested", { configId, value });
|
|
607
|
+
|
|
608
|
+
const result =
|
|
609
|
+
await this.session.clientConnection.setSessionConfigOption({
|
|
610
|
+
sessionId: this.session.acpSessionId,
|
|
611
|
+
configId,
|
|
612
|
+
value,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
configOptions: result.configOptions,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
case POSTHOG_NOTIFICATIONS.PERMISSION_RESPONSE:
|
|
621
|
+
case "permission_response": {
|
|
622
|
+
const requestId = params.requestId as string;
|
|
623
|
+
const optionId = params.optionId as string;
|
|
624
|
+
const customInput = params.customInput as string | undefined;
|
|
625
|
+
const answers = params.answers as Record<string, string> | undefined;
|
|
626
|
+
|
|
627
|
+
this.logger.info("Permission response received", {
|
|
628
|
+
requestId,
|
|
629
|
+
optionId,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const resolved = this.resolvePermission(
|
|
633
|
+
requestId,
|
|
634
|
+
optionId,
|
|
635
|
+
customInput,
|
|
636
|
+
answers,
|
|
637
|
+
);
|
|
638
|
+
if (!resolved) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`No pending permission request found for id: ${requestId}`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
return { resolved: true };
|
|
644
|
+
}
|
|
645
|
+
|
|
582
646
|
default:
|
|
583
647
|
throw new Error(`Unknown method: ${method}`);
|
|
584
648
|
}
|
|
@@ -740,6 +804,14 @@ export class AgentServer {
|
|
|
740
804
|
this.detectedPrUrl = prUrl;
|
|
741
805
|
}
|
|
742
806
|
|
|
807
|
+
const runState = preTaskRun?.state as Record<string, unknown> | undefined;
|
|
808
|
+
// Cloud runs default to bypassPermissions (auto-approve everything).
|
|
809
|
+
// Only PostHog Code sets initial_permission_mode explicitly (e.g., "plan").
|
|
810
|
+
const initialPermissionMode: CodeExecutionMode =
|
|
811
|
+
typeof runState?.initial_permission_mode === "string"
|
|
812
|
+
? (runState.initial_permission_mode as CodeExecutionMode)
|
|
813
|
+
: "bypassPermissions";
|
|
814
|
+
|
|
743
815
|
const sessionResponse = await clientConnection.newSession({
|
|
744
816
|
cwd: this.config.repositoryPath ?? "/tmp/workspace",
|
|
745
817
|
mcpServers: this.config.mcpServers ?? [],
|
|
@@ -749,6 +821,7 @@ export class AgentServer {
|
|
|
749
821
|
systemPrompt: this.buildSessionSystemPrompt(prUrl),
|
|
750
822
|
allowedDomains: this.config.allowedDomains,
|
|
751
823
|
jsonSchema: preTask?.json_schema ?? null,
|
|
824
|
+
permissionMode: initialPermissionMode,
|
|
752
825
|
...(this.config.claudeCode?.plugins?.length && {
|
|
753
826
|
claudeCode: {
|
|
754
827
|
options: {
|
|
@@ -774,6 +847,8 @@ export class AgentServer {
|
|
|
774
847
|
sseController,
|
|
775
848
|
deviceInfo,
|
|
776
849
|
logWriter,
|
|
850
|
+
permissionMode: initialPermissionMode,
|
|
851
|
+
hasDesktopConnected: sseController !== null,
|
|
777
852
|
};
|
|
778
853
|
|
|
779
854
|
this.logger = new Logger({
|
|
@@ -791,6 +866,7 @@ export class AgentServer {
|
|
|
791
866
|
this.logger.info(
|
|
792
867
|
`Agent version: ${this.config.version ?? packageJson.version}`,
|
|
793
868
|
);
|
|
869
|
+
this.logger.info(`Initial permission mode: ${initialPermissionMode}`);
|
|
794
870
|
|
|
795
871
|
// Signal in_progress so the UI can start polling for updates
|
|
796
872
|
this.posthogAPI
|
|
@@ -1121,13 +1197,53 @@ export class AgentServer {
|
|
|
1121
1197
|
return { append: cloudAppend };
|
|
1122
1198
|
}
|
|
1123
1199
|
|
|
1200
|
+
private getCloudInteractionOrigin(): string | undefined {
|
|
1201
|
+
return (
|
|
1202
|
+
process.env.POSTHOG_CODE_INTERACTION_ORIGIN ??
|
|
1203
|
+
process.env.CODE_INTERACTION_ORIGIN ??
|
|
1204
|
+
process.env.TWIG_INTERACTION_ORIGIN
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Slack-origin cloud runs auto-publish by default. Every other origin is
|
|
1210
|
+
* review-first unless the user explicitly asks, and createPr=false always
|
|
1211
|
+
* disables publishing.
|
|
1212
|
+
*/
|
|
1213
|
+
private shouldAutoPublishCloudChanges(): boolean {
|
|
1214
|
+
return (
|
|
1215
|
+
this.getCloudInteractionOrigin() === "slack" &&
|
|
1216
|
+
this.config.createPr !== false
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
private buildDetectedPrContext(prUrl: string): string {
|
|
1221
|
+
if (!this.shouldAutoPublishCloudChanges()) {
|
|
1222
|
+
return (
|
|
1223
|
+
`An open pull request already exists: ${prUrl}\n` +
|
|
1224
|
+
`Use that PR as context if it is helpful, but stop with local changes ready for review.\n` +
|
|
1225
|
+
`Do NOT create commits, push to the PR branch, update the pull request, create a new branch, or create a new pull request unless the user explicitly asks.`
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
return (
|
|
1230
|
+
`IMPORTANT — OVERRIDE PREVIOUS INSTRUCTIONS ABOUT CREATING BRANCHES/PRs.\n` +
|
|
1231
|
+
`You already have an open pull request: ${prUrl}\n` +
|
|
1232
|
+
`You MUST:\n` +
|
|
1233
|
+
`1. Check out the existing PR branch with \`gh pr checkout ${prUrl}\`\n` +
|
|
1234
|
+
`2. Make changes, commit, and push to that branch\n` +
|
|
1235
|
+
`You MUST NOT create a new branch, close the existing PR, or create a new PR.`
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1124
1239
|
private buildCloudSystemPrompt(prUrl?: string | null): string {
|
|
1125
1240
|
const taskId = this.config.taskId;
|
|
1241
|
+
const shouldAutoCreatePr = this.shouldAutoPublishCloudChanges();
|
|
1126
1242
|
const attributionInstructions = `
|
|
1127
1243
|
## Attribution
|
|
1128
1244
|
Do NOT use Claude Code's default attribution (no "Co-Authored-By" trailers, no "Generated with [Claude Code]" lines).
|
|
1129
1245
|
|
|
1130
|
-
|
|
1246
|
+
If you create a commit, add the following trailers to the commit message (after a blank line at the end):
|
|
1131
1247
|
Generated-By: PostHog Code
|
|
1132
1248
|
Task-Id: ${taskId}
|
|
1133
1249
|
|
|
@@ -1143,6 +1259,21 @@ EOF
|
|
|
1143
1259
|
\`\`\``;
|
|
1144
1260
|
|
|
1145
1261
|
if (prUrl) {
|
|
1262
|
+
if (!shouldAutoCreatePr) {
|
|
1263
|
+
return `
|
|
1264
|
+
# Cloud Task Execution
|
|
1265
|
+
|
|
1266
|
+
This task already has an open pull request: ${prUrl}
|
|
1267
|
+
|
|
1268
|
+
Do the requested work, but stop with local changes ready for review.
|
|
1269
|
+
|
|
1270
|
+
Important:
|
|
1271
|
+
- Do NOT create new commits, push to the branch, or update the pull request unless the user explicitly asks.
|
|
1272
|
+
- Do NOT create a new branch or a new pull request.
|
|
1273
|
+
${attributionInstructions}
|
|
1274
|
+
`;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1146
1277
|
return `
|
|
1147
1278
|
# Cloud Task Execution
|
|
1148
1279
|
|
|
@@ -1180,6 +1311,18 @@ Important:
|
|
|
1180
1311
|
`;
|
|
1181
1312
|
}
|
|
1182
1313
|
|
|
1314
|
+
if (!shouldAutoCreatePr) {
|
|
1315
|
+
return `
|
|
1316
|
+
# Cloud Task Execution
|
|
1317
|
+
|
|
1318
|
+
Do the requested work, but stop with local changes ready for review.
|
|
1319
|
+
|
|
1320
|
+
Important:
|
|
1321
|
+
- Do NOT create a branch, commit, push, or open a pull request unless the user explicitly asks.
|
|
1322
|
+
${attributionInstructions}
|
|
1323
|
+
`;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1183
1326
|
return `
|
|
1184
1327
|
# Cloud Task Execution
|
|
1185
1328
|
|
|
@@ -1304,25 +1447,68 @@ ${attributionInstructions}
|
|
|
1304
1447
|
});
|
|
1305
1448
|
}
|
|
1306
1449
|
|
|
1450
|
+
private buildSlackQuestionRelayResponse(
|
|
1451
|
+
payload: JwtPayload,
|
|
1452
|
+
toolMeta: Record<string, unknown> | null | undefined,
|
|
1453
|
+
): RequestPermissionResponse {
|
|
1454
|
+
this.relaySlackQuestion(payload, toolMeta);
|
|
1455
|
+
return {
|
|
1456
|
+
outcome: { outcome: "cancelled" as const },
|
|
1457
|
+
_meta: {
|
|
1458
|
+
message:
|
|
1459
|
+
"This question has been relayed to the Slack thread where this task originated. " +
|
|
1460
|
+
"The user will reply there. Do NOT re-ask the question or pick an answer yourself. " +
|
|
1461
|
+
"Simply let the user know you are waiting for their reply.",
|
|
1462
|
+
},
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
private shouldBlockPublishPermission(
|
|
1467
|
+
params: RequestPermissionRequest,
|
|
1468
|
+
): boolean {
|
|
1469
|
+
if (this.config.createPr !== false) {
|
|
1470
|
+
return false;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const meta =
|
|
1474
|
+
params.toolCall?._meta &&
|
|
1475
|
+
typeof params.toolCall._meta === "object" &&
|
|
1476
|
+
!Array.isArray(params.toolCall._meta)
|
|
1477
|
+
? (params.toolCall._meta as Record<string, unknown>)
|
|
1478
|
+
: null;
|
|
1479
|
+
const rawInput =
|
|
1480
|
+
params.toolCall?.rawInput &&
|
|
1481
|
+
typeof params.toolCall.rawInput === "object" &&
|
|
1482
|
+
!Array.isArray(params.toolCall.rawInput)
|
|
1483
|
+
? (params.toolCall.rawInput as Record<string, unknown>)
|
|
1484
|
+
: null;
|
|
1485
|
+
const toolName = typeof meta?.toolName === "string" ? meta.toolName : null;
|
|
1486
|
+
const command =
|
|
1487
|
+
typeof rawInput?.command === "string" ? rawInput.command : null;
|
|
1488
|
+
|
|
1489
|
+
return Boolean(
|
|
1490
|
+
toolName &&
|
|
1491
|
+
(toolName === "Bash" || toolName.includes("bash")) &&
|
|
1492
|
+
command &&
|
|
1493
|
+
/\bgit\s+push\b|\bgh\s+pr\s+(create|edit|ready|merge)\b/.test(command),
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1307
1497
|
private createCloudClient(payload: JwtPayload) {
|
|
1308
1498
|
const mode = this.getEffectiveMode(payload);
|
|
1309
1499
|
const interactionOrigin =
|
|
1500
|
+
process.env.POSTHOG_CODE_INTERACTION_ORIGIN ??
|
|
1310
1501
|
process.env.CODE_INTERACTION_ORIGIN ??
|
|
1311
1502
|
process.env.TWIG_INTERACTION_ORIGIN;
|
|
1312
1503
|
|
|
1313
1504
|
return {
|
|
1314
|
-
requestPermission: async (
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
_meta?: Record<string, unknown> | null;
|
|
1318
|
-
};
|
|
1319
|
-
}) => {
|
|
1320
|
-
// Background mode: always auto-approve permissions
|
|
1321
|
-
// Interactive mode: also auto-approve for now (user can monitor via SSE)
|
|
1322
|
-
// Future: interactive mode could pause and wait for user approval via SSE
|
|
1505
|
+
requestPermission: async (
|
|
1506
|
+
params: RequestPermissionRequest,
|
|
1507
|
+
): Promise<RequestPermissionResponse> => {
|
|
1323
1508
|
this.logger.debug("Permission request", {
|
|
1324
1509
|
mode,
|
|
1325
1510
|
interactionOrigin,
|
|
1511
|
+
kind: params.toolCall?.kind,
|
|
1326
1512
|
options: params.options,
|
|
1327
1513
|
});
|
|
1328
1514
|
|
|
@@ -1332,22 +1518,50 @@ ${attributionInstructions}
|
|
|
1332
1518
|
const selectedOptionId =
|
|
1333
1519
|
allowOption?.optionId ?? params.options[0].optionId;
|
|
1334
1520
|
|
|
1521
|
+
const codeToolKind = params.toolCall?._meta?.codeToolKind;
|
|
1522
|
+
const isPlanApproval = params.toolCall?.kind === "switch_mode";
|
|
1523
|
+
|
|
1524
|
+
// Relay questions to Slack when interaction originated there
|
|
1335
1525
|
if (interactionOrigin === "slack") {
|
|
1336
|
-
const codeToolKind = params.toolCall?._meta?.codeToolKind;
|
|
1337
1526
|
if (codeToolKind === "question") {
|
|
1338
|
-
this.
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
message:
|
|
1343
|
-
"This question has been relayed to the Slack thread where this task originated. " +
|
|
1344
|
-
"The user will reply there. Do NOT re-ask the question or pick an answer yourself. " +
|
|
1345
|
-
"Simply let the user know you are waiting for their reply.",
|
|
1346
|
-
},
|
|
1347
|
-
};
|
|
1527
|
+
return this.buildSlackQuestionRelayResponse(
|
|
1528
|
+
payload,
|
|
1529
|
+
params.toolCall?._meta,
|
|
1530
|
+
);
|
|
1348
1531
|
}
|
|
1349
1532
|
}
|
|
1350
1533
|
|
|
1534
|
+
// Relay permission requests to the desktop app when:
|
|
1535
|
+
// - Questions: always relay (need human answers regardless of mode)
|
|
1536
|
+
// - Plan approvals: always relay
|
|
1537
|
+
// - Edit/bash in "default" mode: relay for manual approval
|
|
1538
|
+
// Other modes auto-approve. No client connected → auto-approve.
|
|
1539
|
+
{
|
|
1540
|
+
const isQuestion = codeToolKind === "question";
|
|
1541
|
+
const sessionPermissionMode = this.getSessionPermissionMode();
|
|
1542
|
+
const needsRelay =
|
|
1543
|
+
isQuestion || isPlanApproval || sessionPermissionMode === "default";
|
|
1544
|
+
|
|
1545
|
+
if (needsRelay && this.session?.hasDesktopConnected) {
|
|
1546
|
+
this.logger.info("Relaying permission to connected client", {
|
|
1547
|
+
kind: params.toolCall?.kind,
|
|
1548
|
+
isQuestion,
|
|
1549
|
+
sessionPermissionMode,
|
|
1550
|
+
});
|
|
1551
|
+
return this.relayPermissionToClient(params);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
if (this.shouldBlockPublishPermission(params)) {
|
|
1556
|
+
return {
|
|
1557
|
+
outcome: { outcome: "cancelled" },
|
|
1558
|
+
_meta: {
|
|
1559
|
+
message:
|
|
1560
|
+
"This run is configured to stop before publishing. Do not push commits or create/update pull requests unless the user explicitly asks.",
|
|
1561
|
+
},
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1351
1565
|
return {
|
|
1352
1566
|
outcome: {
|
|
1353
1567
|
outcome: "selected" as const,
|
|
@@ -1365,6 +1579,19 @@ ${attributionInstructions}
|
|
|
1365
1579
|
sessionId: string;
|
|
1366
1580
|
update?: Record<string, unknown>;
|
|
1367
1581
|
}) => {
|
|
1582
|
+
// Track permission mode changes for relay decisions
|
|
1583
|
+
if (
|
|
1584
|
+
params.update?.sessionUpdate === "current_mode_update" &&
|
|
1585
|
+
typeof params.update?.currentModeId === "string" &&
|
|
1586
|
+
this.session
|
|
1587
|
+
) {
|
|
1588
|
+
this.session.permissionMode = params.update
|
|
1589
|
+
.currentModeId as CodeExecutionMode;
|
|
1590
|
+
this.logger.info("Permission mode updated", {
|
|
1591
|
+
mode: params.update.currentModeId,
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1368
1595
|
// session/update notifications flow through the tapped stream (like local transport)
|
|
1369
1596
|
// Only handle tree state capture for file changes here
|
|
1370
1597
|
if (params.update?.sessionUpdate === "tool_call_update") {
|
|
@@ -1614,6 +1841,16 @@ ${attributionInstructions}
|
|
|
1614
1841
|
this.logger.error("Failed to flush session logs", error);
|
|
1615
1842
|
}
|
|
1616
1843
|
|
|
1844
|
+
// Drain pending permissions before ACP cleanup to avoid deadlocks —
|
|
1845
|
+
// cleanup may await operations that are blocked on a permission response.
|
|
1846
|
+
for (const [, pending] of this.pendingPermissions) {
|
|
1847
|
+
pending.resolve({
|
|
1848
|
+
outcome: { outcome: "selected", optionId: "reject" },
|
|
1849
|
+
_meta: { customInput: "Session is shutting down." },
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
this.pendingPermissions.clear();
|
|
1853
|
+
|
|
1617
1854
|
try {
|
|
1618
1855
|
await this.session.acpConnection.cleanup();
|
|
1619
1856
|
} catch (error) {
|
|
@@ -1707,4 +1944,55 @@ ${attributionInstructions}
|
|
|
1707
1944
|
this.detachSseController(controller);
|
|
1708
1945
|
}
|
|
1709
1946
|
}
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* Relay a permission request (e.g., plan approval) to the connected desktop
|
|
1950
|
+
* app via SSE and wait for a response via the `/command` endpoint.
|
|
1951
|
+
*
|
|
1952
|
+
* The promise waits indefinitely — if SSE is disconnected, the event is
|
|
1953
|
+
* buffered by broadcastEvent and replayed when the client reconnects. Session
|
|
1954
|
+
* cleanup force-resolves all pending permissions, so there is no leak.
|
|
1955
|
+
*/
|
|
1956
|
+
private relayPermissionToClient(params: {
|
|
1957
|
+
options: Array<{ kind: string; optionId: string; name?: string }>;
|
|
1958
|
+
toolCall?: Record<string, unknown> | null;
|
|
1959
|
+
}): Promise<{
|
|
1960
|
+
outcome: { outcome: "selected"; optionId: string };
|
|
1961
|
+
_meta?: Record<string, unknown>;
|
|
1962
|
+
}> {
|
|
1963
|
+
const requestId = crypto.randomUUID();
|
|
1964
|
+
|
|
1965
|
+
this.broadcastEvent({
|
|
1966
|
+
type: "permission_request",
|
|
1967
|
+
requestId,
|
|
1968
|
+
options: params.options,
|
|
1969
|
+
toolCall: params.toolCall,
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
return new Promise((resolve) => {
|
|
1973
|
+
this.pendingPermissions.set(requestId, { resolve });
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
private resolvePermission(
|
|
1978
|
+
requestId: string,
|
|
1979
|
+
optionId: string,
|
|
1980
|
+
customInput?: string,
|
|
1981
|
+
answers?: Record<string, string>,
|
|
1982
|
+
): boolean {
|
|
1983
|
+
const pending = this.pendingPermissions.get(requestId);
|
|
1984
|
+
if (!pending) return false;
|
|
1985
|
+
|
|
1986
|
+
this.pendingPermissions.delete(requestId);
|
|
1987
|
+
|
|
1988
|
+
const meta: Record<string, unknown> = {};
|
|
1989
|
+
if (customInput) meta.customInput = customInput;
|
|
1990
|
+
if (answers) meta.answers = answers;
|
|
1991
|
+
|
|
1992
|
+
pending.resolve({
|
|
1993
|
+
outcome: { outcome: "selected" as const, optionId },
|
|
1994
|
+
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
|
1995
|
+
});
|
|
1996
|
+
return true;
|
|
1997
|
+
}
|
|
1710
1998
|
}
|
package/src/server/bin.ts
CHANGED
|
@@ -30,6 +30,16 @@ const envSchema = z.object({
|
|
|
30
30
|
|
|
31
31
|
const program = new Command();
|
|
32
32
|
|
|
33
|
+
function parseBooleanOption(
|
|
34
|
+
raw: string | undefined,
|
|
35
|
+
flag: string,
|
|
36
|
+
): boolean | undefined {
|
|
37
|
+
if (raw === undefined) return undefined;
|
|
38
|
+
if (raw === "true") return true;
|
|
39
|
+
if (raw === "false") return false;
|
|
40
|
+
program.error(`${flag} must be either "true" or "false"`);
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
function parseJsonOption<S extends z.ZodType>(
|
|
34
44
|
raw: string | undefined,
|
|
35
45
|
schema: S,
|
|
@@ -70,6 +80,7 @@ program
|
|
|
70
80
|
"--mcpServers <json>",
|
|
71
81
|
"MCP servers config as JSON array (ACP McpServer[] format)",
|
|
72
82
|
)
|
|
83
|
+
.option("--createPr <boolean>", "Whether this run may publish changes")
|
|
73
84
|
.option("--baseBranch <branch>", "Base branch for PR creation")
|
|
74
85
|
.option(
|
|
75
86
|
"--claudeCodeConfig <json>",
|
|
@@ -93,6 +104,7 @@ program
|
|
|
93
104
|
const env = envResult.data;
|
|
94
105
|
|
|
95
106
|
const mode = options.mode === "background" ? "background" : "interactive";
|
|
107
|
+
const createPr = parseBooleanOption(options.createPr, "--createPr");
|
|
96
108
|
|
|
97
109
|
const mcpServers = parseJsonOption(
|
|
98
110
|
options.mcpServers,
|
|
@@ -122,6 +134,7 @@ program
|
|
|
122
134
|
mode,
|
|
123
135
|
taskId: options.taskId,
|
|
124
136
|
runId: options.runId,
|
|
137
|
+
createPr,
|
|
125
138
|
mcpServers,
|
|
126
139
|
baseBranch: options.baseBranch,
|
|
127
140
|
claudeCode,
|
|
@@ -172,13 +172,13 @@ describe("Question relay", () => {
|
|
|
172
172
|
{ kind: "allow_once", optionId: "allow", name: "Allow" },
|
|
173
173
|
];
|
|
174
174
|
|
|
175
|
-
describe("with
|
|
175
|
+
describe("with POSTHOG_CODE_INTERACTION_ORIGIN=slack", () => {
|
|
176
176
|
beforeEach(() => {
|
|
177
|
-
process.env.
|
|
177
|
+
process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
afterEach(() => {
|
|
181
|
-
delete process.env.
|
|
181
|
+
delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
it("returns cancelled with relay message for question tool", async () => {
|
|
@@ -220,9 +220,9 @@ describe("Question relay", () => {
|
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
describe("without
|
|
223
|
+
describe("without POSTHOG_CODE_INTERACTION_ORIGIN", () => {
|
|
224
224
|
beforeEach(() => {
|
|
225
|
-
delete process.env.
|
|
225
|
+
delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
|
|
226
226
|
});
|
|
227
227
|
|
|
228
228
|
it("auto-approves question tools (no Slack relay)", async () => {
|
|
@@ -301,6 +301,35 @@ describe("Question relay", () => {
|
|
|
301
301
|
expect(appendRawLine).toHaveBeenCalledTimes(2);
|
|
302
302
|
});
|
|
303
303
|
});
|
|
304
|
+
|
|
305
|
+
describe("with createPr disabled", () => {
|
|
306
|
+
it("cancels publish commands", async () => {
|
|
307
|
+
server = new AgentServer({
|
|
308
|
+
port,
|
|
309
|
+
jwtPublicKey: "unused-in-unit-tests",
|
|
310
|
+
repositoryPath: repo.path,
|
|
311
|
+
apiUrl: "http://localhost:8000",
|
|
312
|
+
apiKey: "test-api-key",
|
|
313
|
+
projectId: 1,
|
|
314
|
+
mode: "interactive",
|
|
315
|
+
taskId: "test-task-id",
|
|
316
|
+
runId: "test-run-id",
|
|
317
|
+
createPr: false,
|
|
318
|
+
}) as unknown as TestableAgentServer;
|
|
319
|
+
|
|
320
|
+
const client = server.createCloudClient(TEST_PAYLOAD);
|
|
321
|
+
const result = await client.requestPermission({
|
|
322
|
+
options: ALLOW_OPTIONS,
|
|
323
|
+
toolCall: {
|
|
324
|
+
rawInput: { command: "git push origin my-branch" },
|
|
325
|
+
_meta: { toolName: "Bash" },
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(result.outcome.outcome).toBe("cancelled");
|
|
330
|
+
expect(result._meta?.message).toContain("stop before publishing");
|
|
331
|
+
});
|
|
332
|
+
});
|
|
304
333
|
});
|
|
305
334
|
|
|
306
335
|
describe("relayAgentResponse duplicate suppression", () => {
|
|
@@ -132,4 +132,56 @@ describe("validateCommandParams", () => {
|
|
|
132
132
|
|
|
133
133
|
expect(result.success).toBe(false);
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
it("accepts valid permission_response", () => {
|
|
137
|
+
const result = validateCommandParams("permission_response", {
|
|
138
|
+
requestId: "abc-123",
|
|
139
|
+
optionId: "acceptEdits",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(result.success).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("accepts permission_response with customInput", () => {
|
|
146
|
+
const result = validateCommandParams("permission_response", {
|
|
147
|
+
requestId: "abc-123",
|
|
148
|
+
optionId: "reject_with_feedback",
|
|
149
|
+
customInput: "Please change the approach",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(result.success).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects permission_response without requestId", () => {
|
|
156
|
+
const result = validateCommandParams("permission_response", {
|
|
157
|
+
optionId: "acceptEdits",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.success).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("rejects permission_response without optionId", () => {
|
|
164
|
+
const result = validateCommandParams("permission_response", {
|
|
165
|
+
requestId: "abc-123",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.success).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("accepts valid set_config_option", () => {
|
|
172
|
+
const result = validateCommandParams("set_config_option", {
|
|
173
|
+
configId: "mode",
|
|
174
|
+
value: "plan",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result.success).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("rejects set_config_option without configId", () => {
|
|
181
|
+
const result = validateCommandParams("set_config_option", {
|
|
182
|
+
value: "plan",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(result.success).toBe(false);
|
|
186
|
+
});
|
|
135
187
|
});
|
package/src/server/schemas.ts
CHANGED
|
@@ -48,6 +48,18 @@ export const userMessageParamsSchema = z.object({
|
|
|
48
48
|
]),
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
export const permissionResponseParamsSchema = z.object({
|
|
52
|
+
requestId: z.string().min(1, "requestId is required"),
|
|
53
|
+
optionId: z.string().min(1, "optionId is required"),
|
|
54
|
+
customInput: z.string().optional(),
|
|
55
|
+
answers: z.record(z.string(), z.string()).optional(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const setConfigOptionParamsSchema = z.object({
|
|
59
|
+
configId: z.string().min(1, "configId is required"),
|
|
60
|
+
value: z.string().min(1, "value is required"),
|
|
61
|
+
});
|
|
62
|
+
|
|
51
63
|
export const commandParamsSchemas = {
|
|
52
64
|
user_message: userMessageParamsSchema,
|
|
53
65
|
"posthog/user_message": userMessageParamsSchema,
|
|
@@ -55,6 +67,10 @@ export const commandParamsSchemas = {
|
|
|
55
67
|
"posthog/cancel": z.object({}).optional(),
|
|
56
68
|
close: z.object({}).optional(),
|
|
57
69
|
"posthog/close": z.object({}).optional(),
|
|
70
|
+
permission_response: permissionResponseParamsSchema,
|
|
71
|
+
"posthog/permission_response": permissionResponseParamsSchema,
|
|
72
|
+
set_config_option: setConfigOptionParamsSchema,
|
|
73
|
+
"posthog/set_config_option": setConfigOptionParamsSchema,
|
|
58
74
|
} as const;
|
|
59
75
|
|
|
60
76
|
export type CommandMethod = keyof typeof commandParamsSchemas;
|