diff --git a/apps/code/src/main/services/cloud-task/schemas.ts b/apps/code/src/main/services/cloud-task/schemas.ts index 84a6f532e..69512afb7 100644 --- a/apps/code/src/main/services/cloud-task/schemas.ts +++ b/apps/code/src/main/services/cloud-task/schemas.ts @@ -50,7 +50,13 @@ export const sendCommandInput = z.object({ runId: z.string(), apiHost: z.string(), teamId: z.number(), - method: z.enum(["user_message", "cancel", "close"]), + method: z.enum([ + "user_message", + "cancel", + "close", + "permission_response", + "set_config_option", + ]), params: z.record(z.string(), z.unknown()).optional(), }); diff --git a/apps/code/src/main/services/cloud-task/service.ts b/apps/code/src/main/services/cloud-task/service.ts index 753f23c35..01b9f2326 100644 --- a/apps/code/src/main/services/cloud-task/service.ts +++ b/apps/code/src/main/services/cloud-task/service.ts @@ -1,3 +1,4 @@ +import type { CloudTaskPermissionRequestUpdate } from "@shared/types"; import type { StoredLogEntry } from "@shared/types/session-events"; import { net } from "electron"; import { inject, injectable, preDestroy } from "inversify"; @@ -122,6 +123,24 @@ function isSseErrorEvent(data: unknown): data is SseErrorEventData { ); } +interface PermissionRequestEventData { + type: "permission_request"; + requestId: string; + toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; + options: CloudTaskPermissionRequestUpdate["options"]; +} + +function isPermissionRequestEvent( + data: unknown, +): data is PermissionRequestEventData { + return ( + typeof data === "object" && + data !== null && + (data as { type?: string }).type === "permission_request" && + typeof (data as { requestId?: string }).requestId === "string" + ); +} + function createStreamStatusError(status: number): CloudTaskStreamError { switch (status) { case 401: @@ -682,6 +701,18 @@ export class CloudTaskService extends TypedEventEmitter { return; } + if (isPermissionRequestEvent(event.data)) { + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "permission_request" as const, + requestId: event.data.requestId, + toolCall: event.data.toolCall, + options: event.data.options, + }); + return; + } + watcher.pendingLogEntries.push(event.data as StoredLogEntry); if (watcher.pendingLogEntries.length >= EVENT_BATCH_MAX_SIZE) { this.flushLogBatch(key); diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 2093a5dc8..7ff819aec 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -746,6 +746,7 @@ export class PostHogAPIClient { runSource?: CloudRunSource; signalReportId?: string; githubUserToken?: string; + initialPermissionMode?: string; }, ): Promise { const teamId = await this.getTeamId(); @@ -774,6 +775,9 @@ export class PostHogAPIClient { if (options?.githubUserToken) { body.github_user_token = options.githubUserToken; } + if (options?.initialPermissionMode) { + body.initial_permission_mode = options.initialPermissionMode; + } const data = await this.api.post( `/api/projects/{project_id}/tasks/{id}/run/`, diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts index b41c2e5be..9edee2b22 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts @@ -72,6 +72,10 @@ export function useSessionConnection({ if (!cloudAuthState.projectId || !cloudAuthState.cloudRegion) return; const runId = task.latest_run.id; + const initialMode = + typeof task.latest_run.state?.initial_permission_mode === "string" + ? task.latest_run.state.initial_permission_mode + : undefined; const cleanup = getSessionService().watchCloudTask( task.id, runId, @@ -81,6 +85,7 @@ export function useSessionConnection({ queryClient.invalidateQueries({ queryKey: ["tasks"] }); }, task.latest_run?.log_url, + initialMode, ); return cleanup; }, [ @@ -93,6 +98,7 @@ export function useSessionConnection({ task.id, task.latest_run?.id, task.latest_run?.log_url, + task.latest_run?.state?.initial_permission_mode, ]); useEffect(() => { diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 12cfeb904..edf4ef246 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -23,6 +23,7 @@ import { import type { Adapter, AgentSession, + PermissionRequest, } from "@features/sessions/stores/sessionStore"; import { getConfigOptionByCategory, @@ -32,6 +33,7 @@ import { import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { taskViewedApi } from "@features/sidebar/hooks/useTaskViewed"; import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; +import { getAvailableModes } from "@posthog/agent/execution-mode"; import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; import { getIsOnline } from "@renderer/stores/connectivityStore"; import { trpcClient } from "@renderer/trpc/client"; @@ -39,6 +41,7 @@ import { getGhUserTokenOrThrow } from "@renderer/utils/github"; import { toast } from "@renderer/utils/toast"; import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import { + type CloudTaskPermissionRequestUpdate, type CloudTaskUpdatePayload, type EffortLevel, type ExecutionMode, @@ -70,6 +73,30 @@ import { const log = logger.scope("session-service"); +/** + * Build default configOptions for cloud sessions so the mode switcher + * is available in the UI even without a local agent connection. + */ +function buildCloudDefaultConfigOptions( + initialMode = "plan", +): SessionConfigOption[] { + const modes = getAvailableModes(); + return [ + { + id: "mode", + name: "Approval Preset", + type: "select", + currentValue: initialMode, + options: modes.map((mode) => ({ + value: mode.id, + name: mode.name, + })), + category: "mode" as SessionConfigOption["category"], + description: "Choose an approval and sandboxing preset for your session", + }, + ]; +} + interface AuthCredentials { apiHost: string; projectId: number; @@ -132,6 +159,8 @@ export class SessionService { onStatusChange?: () => void; } >(); + /** Maps toolCallId → cloud requestId for routing permission responses */ + private cloudPermissionRequestIds = new Map(); private idleKilledSubscription: { unsubscribe: () => void } | null = null; constructor() { @@ -730,6 +759,7 @@ export class SessionService { } this.connectingTasks.clear(); + this.cloudPermissionRequestIds.clear(); this.idleKilledSubscription?.unsubscribe(); this.idleKilledSubscription = null; } @@ -948,6 +978,42 @@ export class SessionService { notifyPermissionRequest(session.taskTitle, session.taskId); } + private handleCloudPermissionRequest( + taskRunId: string, + update: CloudTaskPermissionRequestUpdate, + ): void { + log.info("Cloud permission request received", { + taskRunId, + requestId: update.requestId, + toolCallId: update.toolCall.toolCallId, + title: update.toolCall.title, + }); + + const session = sessionStoreSetters.getSessions()[taskRunId]; + if (!session) { + log.warn("Session not found for cloud permission request", { taskRunId }); + return; + } + + // Store the cloud requestId so we can route the response back + this.cloudPermissionRequestIds.set( + update.toolCall.toolCallId, + update.requestId, + ); + + const newPermissions = new Map(session.pendingPermissions); + newPermissions.set(update.toolCall.toolCallId, { + toolCall: update.toolCall as PermissionRequest["toolCall"], + options: update.options as PermissionRequest["options"], + taskRunId, + receivedAt: Date.now(), + }); + + sessionStoreSetters.setPendingPermissions(taskRunId, newPermissions); + taskViewedApi.markActivity(session.taskId); + notifyPermissionRequest(session.taskTitle, session.taskId); + } + // --- Prompt Handling --- /** @@ -1527,6 +1593,29 @@ export class SessionService { }; } + /** + * Send a command to the cloud agent server via the backend proxy. + * Handles auth lookup and throws if credentials are unavailable. + */ + private async sendCloudCommand( + session: AgentSession, + method: "permission_response" | "set_config_option", + params: Record, + ): Promise { + const auth = await this.getCloudCommandAuth(); + if (!auth) { + throw new Error("No cloud auth credentials available"); + } + await trpcClient.cloudTask.sendCommand.mutate({ + taskId: session.taskId, + runId: session.taskRunId, + apiHost: auth.apiHost, + teamId: auth.teamId, + method, + params, + }); + } + // --- Permissions --- private resolvePermission(session: AgentSession, toolCallId: string): void { @@ -1569,21 +1658,33 @@ export class SessionService { ...buildPermissionToolMetadata(permission, optionId, customInput), }); + const cloudRequestId = this.cloudPermissionRequestIds.get(toolCallId); this.resolvePermission(session, toolCallId); try { - await trpcClient.agent.respondToPermission.mutate({ - taskRunId: session.taskRunId, - toolCallId, - optionId, - customInput, - answers, - }); + if (session.isCloud && cloudRequestId) { + this.cloudPermissionRequestIds.delete(toolCallId); + await this.sendCloudCommand(session, "permission_response", { + requestId: cloudRequestId, + optionId, + customInput, + answers, + }); + } else { + await trpcClient.agent.respondToPermission.mutate({ + taskRunId: session.taskRunId, + toolCallId, + optionId, + customInput, + answers, + }); + } log.info("Permission response sent", { taskId, toolCallId, optionId, + isCloud: !!cloudRequestId, hasCustomInput: !!customInput, }); } catch (error) { @@ -1612,15 +1713,29 @@ export class SessionService { ...buildPermissionToolMetadata(permission), }); + const cloudRequestId = this.cloudPermissionRequestIds.get(toolCallId); this.resolvePermission(session, toolCallId); try { - await trpcClient.agent.cancelPermission.mutate({ - taskRunId: session.taskRunId, + if (session.isCloud && cloudRequestId) { + this.cloudPermissionRequestIds.delete(toolCallId); + await this.sendCloudCommand(session, "permission_response", { + requestId: cloudRequestId, + optionId: "reject_with_feedback", + customInput: "User cancelled the permission request.", + }); + } else { + await trpcClient.agent.cancelPermission.mutate({ + taskRunId: session.taskRunId, + toolCallId, + }); + } + + log.info("Permission cancelled", { + taskId, toolCallId, + isCloud: !!cloudRequestId, }); - - log.info("Permission cancelled", { taskId, toolCallId }); } catch (error) { log.error("Failed to cancel permission", { taskId, @@ -1671,11 +1786,18 @@ export class SessionService { updatePersistedConfigOptionValue(session.taskRunId, configId, value); try { - await trpcClient.agent.setConfigOption.mutate({ - sessionId: session.taskRunId, - configId, - value, - }); + if (session.isCloud) { + await this.sendCloudCommand(session, "set_config_option", { + configId, + value, + }); + } else { + await trpcClient.agent.setConfigOption.mutate({ + sessionId: session.taskRunId, + configId, + value, + }); + } } catch (error) { // Rollback on error const rolledBackOptions = configOptions.map((opt) => @@ -1918,6 +2040,7 @@ export class SessionService { teamId: number, onStatusChange?: () => void, logUrl?: string, + initialMode?: string, ): () => void { const taskRunId = runId; const startToken = ++this.nextCloudTaskWatchToken; @@ -1931,6 +2054,13 @@ export class SessionService { existingWatcher.teamId === teamId ) { existingWatcher.onStatusChange = onStatusChange; + // Ensure configOptions is populated on revisit + const existing = sessionStoreSetters.getSessionByTaskId(taskId); + if (existing && !existing.configOptions?.length) { + sessionStoreSetters.updateSession(existing.taskRunId, { + configOptions: buildCloudDefaultConfigOptions(initialMode), + }); + } return () => {}; } @@ -1963,11 +2093,18 @@ export class SessionService { const session = this.createBaseSession(taskRunId, taskId, taskTitle); session.status = "disconnected"; session.isCloud = true; + session.configOptions = buildCloudDefaultConfigOptions(initialMode); sessionStoreSetters.setSession(session); - } else if (!existing.isCloud) { - sessionStoreSetters.updateSession(existing.taskRunId, { - isCloud: true, - }); + } else { + // Ensure cloud flag and configOptions are set on existing sessions + const updates: Partial = {}; + if (!existing.isCloud) updates.isCloud = true; + if (!existing.configOptions?.length) { + updates.configOptions = buildCloudDefaultConfigOptions(initialMode); + } + if (Object.keys(updates).length > 0) { + sessionStoreSetters.updateSession(existing.taskRunId, updates); + } } if (shouldHydrateSession) { @@ -2157,6 +2294,11 @@ export class SessionService { return; } + if (update.kind === "permission_request") { + this.handleCloudPermissionRequest(taskRunId, update); + return; + } + // Append new log entries with dedup guard if ( (update.kind === "logs" || update.kind === "snapshot") && diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index 6fe85fc75..202599746 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -157,6 +157,7 @@ describe("TaskCreationSaga", () => { runSource: "manual", signalReportId: undefined, githubUserToken: undefined, + initialPermissionMode: "plan", }, ); expect(sendRunCommandMock).not.toHaveBeenCalled(); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index e12f14b08..8659d13e3 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -300,6 +300,7 @@ export class TaskCreationSaga extends Saga< runSource: input.cloudRunSource ?? "manual", signalReportId: input.signalReportId, githubUserToken, + initialPermissionMode: input.executionMode ?? "plan", }); }, rollback: async () => { diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index ef98e20e9..1e5751808 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -147,11 +147,33 @@ export interface CloudTaskErrorUpdate extends CloudTaskUpdateBase { retryable: boolean; } +export interface CloudPermissionOption { + kind: string; + optionId: string; + name: string; + _meta?: Record; +} + +export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { + kind: "permission_request"; + requestId: string; + toolCall: { + toolCallId: string; + title: string; + kind: string; + content?: unknown[]; + rawInput?: Record; + _meta?: Record; + }; + options: CloudPermissionOption[]; +} + export type CloudTaskUpdatePayload = | CloudTaskLogsUpdate | CloudTaskStatusUpdate | CloudTaskSnapshotUpdate - | CloudTaskErrorUpdate; + | CloudTaskErrorUpdate + | CloudTaskPermissionRequestUpdate; // Mention types for editors type MentionType = diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index 62a2a1083..3cfeba297 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -63,6 +63,9 @@ export const POSTHOG_NOTIFICATIONS = { /** Token usage update for a session turn */ USAGE_UPDATE: "_posthog/usage_update", + + /** Response to a relayed permission request (plan approval, question) */ + PERMISSION_RESPONSE: "_posthog/permission_response", } as const; type NotificationMethod = diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index d8cabb956..b97ab8fdb 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -490,11 +490,17 @@ export async function canUseTool( return planFileResult; } - // if (session.permissionMode === "dontAsk") { - // const message = "Tool not pre-approved. Denied by dontAsk mode."; - // await emitToolDenial(context, message); - // return { behavior: "deny", message, interrupt: false }; - // } + // In plan mode, deny tools that aren't in the allowed set. The agent must + // write its plan to ~/.claude/plans/ and call ExitPlanMode before it can + // use write or bash tools. Without this guard, cloud runs auto-approve + // restricted tools and the agent skips planning entirely. + if (session.permissionMode === "plan") { + const message = + "This tool is not available in plan mode. Write your plan " + + `to a file in ${getClaudePlansDir()} and call ExitPlanMode when ready.`; + await emitToolDenial(context, message); + return { behavior: "deny", message, interrupt: false }; + } return handleDefaultPermissionFlow(context); } diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 474e4916e..7486f1cb8 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -18,6 +18,7 @@ import { type InProcessAcpConnection, } from "../adapters/acp-connection"; import { selectRecentTurns } from "../adapters/claude/session/jsonl-hydration"; +import type { CodeExecutionMode } from "../execution-mode"; import { PostHogAPIClient } from "../posthog-api"; import { type ConversationTurn, @@ -161,6 +162,10 @@ interface ActiveSession { sseController: SseController | null; deviceInfo: DeviceInfo; logWriter: SessionLogWriter; + /** Current permission mode, tracked for relay decisions */ + permissionMode: CodeExecutionMode; + /** Whether a desktop client has ever connected via SSE during this session */ + hasDesktopConnected: boolean; } export class AgentServer { @@ -180,6 +185,15 @@ export class AgentServer { // causing a second session to be created and duplicate Slack messages to be sent. private initializationPromise: Promise | null = null; private pendingEvents: Record[] = []; + private pendingPermissions = new Map< + string, + { + resolve: (response: { + outcome: { outcome: "selected"; optionId: string }; + _meta?: Record; + }) => void; + } + >(); private detachSseController(controller: SseController): void { if (this.session?.sseController === controller) { @@ -232,6 +246,10 @@ export class AgentServer { return payload.mode ?? this.config.mode; } + private getSessionPermissionMode(): CodeExecutionMode { + return this.session?.permissionMode ?? "default"; + } + private createApp(): Hono { const app = new Hono(); @@ -285,6 +303,7 @@ export class AgentServer { await this.initializeSession(payload, sseController); } else { this.session.sseController = sseController; + this.session.hasDesktopConnected = true; this.replayPendingEvents(); } @@ -579,6 +598,51 @@ export class AgentServer { return { closed: true }; } + case "posthog/set_config_option": + case "set_config_option": { + const configId = params.configId as string; + const value = params.value as string; + + this.logger.info("Set config option requested", { configId, value }); + + const result = + await this.session.clientConnection.setSessionConfigOption({ + sessionId: this.session.acpSessionId, + configId, + value, + }); + + return { + configOptions: result.configOptions, + }; + } + + case POSTHOG_NOTIFICATIONS.PERMISSION_RESPONSE: + case "permission_response": { + const requestId = params.requestId as string; + const optionId = params.optionId as string; + const customInput = params.customInput as string | undefined; + const answers = params.answers as Record | undefined; + + this.logger.info("Permission response received", { + requestId, + optionId, + }); + + const resolved = this.resolvePermission( + requestId, + optionId, + customInput, + answers, + ); + if (!resolved) { + throw new Error( + `No pending permission request found for id: ${requestId}`, + ); + } + return { resolved: true }; + } + default: throw new Error(`Unknown method: ${method}`); } @@ -740,6 +804,14 @@ export class AgentServer { this.detectedPrUrl = prUrl; } + const runState = preTaskRun?.state as Record | undefined; + // Cloud runs default to bypassPermissions (auto-approve everything). + // Only PostHog Code sets initial_permission_mode explicitly (e.g., "plan"). + const initialPermissionMode: CodeExecutionMode = + typeof runState?.initial_permission_mode === "string" + ? (runState.initial_permission_mode as CodeExecutionMode) + : "bypassPermissions"; + const sessionResponse = await clientConnection.newSession({ cwd: this.config.repositoryPath ?? "/tmp/workspace", mcpServers: this.config.mcpServers ?? [], @@ -749,6 +821,7 @@ export class AgentServer { systemPrompt: this.buildSessionSystemPrompt(prUrl), allowedDomains: this.config.allowedDomains, jsonSchema: preTask?.json_schema ?? null, + permissionMode: initialPermissionMode, ...(this.config.claudeCode?.plugins?.length && { claudeCode: { options: { @@ -774,6 +847,8 @@ export class AgentServer { sseController, deviceInfo, logWriter, + permissionMode: initialPermissionMode, + hasDesktopConnected: sseController !== null, }; this.logger = new Logger({ @@ -791,6 +866,7 @@ export class AgentServer { this.logger.info( `Agent version: ${this.config.version ?? packageJson.version}`, ); + this.logger.info(`Initial permission mode: ${initialPermissionMode}`); // Signal in_progress so the UI can start polling for updates this.posthogAPI @@ -1429,12 +1505,10 @@ ${attributionInstructions} requestPermission: async ( params: RequestPermissionRequest, ): Promise => { - // Background mode: always auto-approve permissions - // Interactive mode: also auto-approve for now (user can monitor via SSE) - // Future: interactive mode could pause and wait for user approval via SSE this.logger.debug("Permission request", { mode, interactionOrigin, + kind: params.toolCall?.kind, options: params.options, }); @@ -1444,8 +1518,11 @@ ${attributionInstructions} const selectedOptionId = allowOption?.optionId ?? params.options[0].optionId; + const codeToolKind = params.toolCall?._meta?.codeToolKind; + const isPlanApproval = params.toolCall?.kind === "switch_mode"; + + // Relay questions to Slack when interaction originated there if (interactionOrigin === "slack") { - const codeToolKind = params.toolCall?._meta?.codeToolKind; if (codeToolKind === "question") { return this.buildSlackQuestionRelayResponse( payload, @@ -1454,6 +1531,27 @@ ${attributionInstructions} } } + // Relay permission requests to the desktop app when: + // - Questions: always relay (need human answers regardless of mode) + // - Plan approvals: always relay + // - Edit/bash in "default" mode: relay for manual approval + // Other modes auto-approve. No client connected → auto-approve. + { + const isQuestion = codeToolKind === "question"; + const sessionPermissionMode = this.getSessionPermissionMode(); + const needsRelay = + isQuestion || isPlanApproval || sessionPermissionMode === "default"; + + if (needsRelay && this.session?.hasDesktopConnected) { + this.logger.info("Relaying permission to connected client", { + kind: params.toolCall?.kind, + isQuestion, + sessionPermissionMode, + }); + return this.relayPermissionToClient(params); + } + } + if (this.shouldBlockPublishPermission(params)) { return { outcome: { outcome: "cancelled" }, @@ -1481,6 +1579,19 @@ ${attributionInstructions} sessionId: string; update?: Record; }) => { + // Track permission mode changes for relay decisions + if ( + params.update?.sessionUpdate === "current_mode_update" && + typeof params.update?.currentModeId === "string" && + this.session + ) { + this.session.permissionMode = params.update + .currentModeId as CodeExecutionMode; + this.logger.info("Permission mode updated", { + mode: params.update.currentModeId, + }); + } + // session/update notifications flow through the tapped stream (like local transport) // Only handle tree state capture for file changes here if (params.update?.sessionUpdate === "tool_call_update") { @@ -1730,6 +1841,16 @@ ${attributionInstructions} this.logger.error("Failed to flush session logs", error); } + // Drain pending permissions before ACP cleanup to avoid deadlocks — + // cleanup may await operations that are blocked on a permission response. + for (const [, pending] of this.pendingPermissions) { + pending.resolve({ + outcome: { outcome: "selected", optionId: "reject" }, + _meta: { customInput: "Session is shutting down." }, + }); + } + this.pendingPermissions.clear(); + try { await this.session.acpConnection.cleanup(); } catch (error) { @@ -1823,4 +1944,55 @@ ${attributionInstructions} this.detachSseController(controller); } } + + /** + * Relay a permission request (e.g., plan approval) to the connected desktop + * app via SSE and wait for a response via the `/command` endpoint. + * + * The promise waits indefinitely — if SSE is disconnected, the event is + * buffered by broadcastEvent and replayed when the client reconnects. Session + * cleanup force-resolves all pending permissions, so there is no leak. + */ + private relayPermissionToClient(params: { + options: Array<{ kind: string; optionId: string; name?: string }>; + toolCall?: Record | null; + }): Promise<{ + outcome: { outcome: "selected"; optionId: string }; + _meta?: Record; + }> { + const requestId = crypto.randomUUID(); + + this.broadcastEvent({ + type: "permission_request", + requestId, + options: params.options, + toolCall: params.toolCall, + }); + + return new Promise((resolve) => { + this.pendingPermissions.set(requestId, { resolve }); + }); + } + + private resolvePermission( + requestId: string, + optionId: string, + customInput?: string, + answers?: Record, + ): boolean { + const pending = this.pendingPermissions.get(requestId); + if (!pending) return false; + + this.pendingPermissions.delete(requestId); + + const meta: Record = {}; + if (customInput) meta.customInput = customInput; + if (answers) meta.answers = answers; + + pending.resolve({ + outcome: { outcome: "selected" as const, optionId }, + ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), + }); + return true; + } } diff --git a/packages/agent/src/server/schemas.test.ts b/packages/agent/src/server/schemas.test.ts index 30efddc9f..af47fdaa1 100644 --- a/packages/agent/src/server/schemas.test.ts +++ b/packages/agent/src/server/schemas.test.ts @@ -132,4 +132,56 @@ describe("validateCommandParams", () => { expect(result.success).toBe(false); }); + + it("accepts valid permission_response", () => { + const result = validateCommandParams("permission_response", { + requestId: "abc-123", + optionId: "acceptEdits", + }); + + expect(result.success).toBe(true); + }); + + it("accepts permission_response with customInput", () => { + const result = validateCommandParams("permission_response", { + requestId: "abc-123", + optionId: "reject_with_feedback", + customInput: "Please change the approach", + }); + + expect(result.success).toBe(true); + }); + + it("rejects permission_response without requestId", () => { + const result = validateCommandParams("permission_response", { + optionId: "acceptEdits", + }); + + expect(result.success).toBe(false); + }); + + it("rejects permission_response without optionId", () => { + const result = validateCommandParams("permission_response", { + requestId: "abc-123", + }); + + expect(result.success).toBe(false); + }); + + it("accepts valid set_config_option", () => { + const result = validateCommandParams("set_config_option", { + configId: "mode", + value: "plan", + }); + + expect(result.success).toBe(true); + }); + + it("rejects set_config_option without configId", () => { + const result = validateCommandParams("set_config_option", { + value: "plan", + }); + + expect(result.success).toBe(false); + }); }); diff --git a/packages/agent/src/server/schemas.ts b/packages/agent/src/server/schemas.ts index 96e9cf022..30e7a3633 100644 --- a/packages/agent/src/server/schemas.ts +++ b/packages/agent/src/server/schemas.ts @@ -48,6 +48,18 @@ export const userMessageParamsSchema = z.object({ ]), }); +export const permissionResponseParamsSchema = z.object({ + requestId: z.string().min(1, "requestId is required"), + optionId: z.string().min(1, "optionId is required"), + customInput: z.string().optional(), + answers: z.record(z.string(), z.string()).optional(), +}); + +export const setConfigOptionParamsSchema = z.object({ + configId: z.string().min(1, "configId is required"), + value: z.string().min(1, "value is required"), +}); + export const commandParamsSchemas = { user_message: userMessageParamsSchema, "posthog/user_message": userMessageParamsSchema, @@ -55,6 +67,10 @@ export const commandParamsSchemas = { "posthog/cancel": z.object({}).optional(), close: z.object({}).optional(), "posthog/close": z.object({}).optional(), + permission_response: permissionResponseParamsSchema, + "posthog/permission_response": permissionResponseParamsSchema, + set_config_option: setConfigOptionParamsSchema, + "posthog/set_config_option": setConfigOptionParamsSchema, } as const; export type CommandMethod = keyof typeof commandParamsSchemas;