feat(listen): isolate permission mode per conversation [LET-8050] (#1425)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import type { MessageCreateParams as ConversationMessageCreateParams } from "@le
|
||||
import {
|
||||
type ClientTool,
|
||||
captureToolExecutionContext,
|
||||
type PermissionModeState,
|
||||
waitForToolsetReady,
|
||||
} from "../tools/manager";
|
||||
import { debugLog, debugWarn, isDebugEnabled } from "../utils/debug";
|
||||
@@ -58,6 +59,9 @@ export type SendMessageStreamOptions = {
|
||||
agentId?: string; // Required when conversationId is "default"
|
||||
approvalNormalization?: ApprovalNormalizationOptions;
|
||||
workingDirectory?: string;
|
||||
/** Per-conversation permission mode state. When provided, tool execution uses
|
||||
* this scoped state instead of the global permissionMode singleton. */
|
||||
permissionModeState?: PermissionModeState;
|
||||
};
|
||||
|
||||
export function buildConversationMessagesCreateRequestBody(
|
||||
@@ -123,6 +127,7 @@ export async function sendMessageStream(
|
||||
await waitForToolsetReady();
|
||||
const { clientTools, contextId } = captureToolExecutionContext(
|
||||
opts.workingDirectory,
|
||||
opts.permissionModeState,
|
||||
);
|
||||
const { clientSkills, errors: clientSkillDiscoveryErrors } =
|
||||
await buildClientSkillsPayload({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ApprovalContext } from "../../permissions/analyzer";
|
||||
import { checkToolPermission, getToolSchema } from "../../tools/manager";
|
||||
import {
|
||||
checkToolPermission,
|
||||
getToolSchema,
|
||||
type PermissionModeState,
|
||||
} from "../../tools/manager";
|
||||
import { safeJsonParseOr } from "./safeJsonParse";
|
||||
import type { ApprovalRequest } from "./streamProcessor";
|
||||
|
||||
@@ -33,6 +37,7 @@ export type ClassifyApprovalsOptions<TContext = ApprovalContext | null> = {
|
||||
requireArgsForAutoApprove?: boolean;
|
||||
missingArgsReason?: (missing: string[]) => string;
|
||||
workingDirectory?: string;
|
||||
permissionModeState?: PermissionModeState;
|
||||
};
|
||||
|
||||
export async function getMissingRequiredArgs(
|
||||
@@ -80,6 +85,7 @@ export async function classifyApprovals<TContext = ApprovalContext | null>(
|
||||
toolName,
|
||||
parsedArgs,
|
||||
opts.workingDirectory,
|
||||
opts.permissionModeState,
|
||||
);
|
||||
const context = opts.getContext
|
||||
? await opts.getContext(toolName, parsedArgs, opts.workingDirectory)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { relative, resolve } from "node:path";
|
||||
import { getCurrentAgentId } from "../agent/context";
|
||||
import { runPermissionRequestHooks } from "../hooks";
|
||||
import type { PermissionModeState } from "../tools/manager";
|
||||
import { canonicalToolName, isShellToolName } from "./canonical";
|
||||
import { cliPermissions } from "./cli";
|
||||
import {
|
||||
@@ -133,6 +134,7 @@ export function checkPermission(
|
||||
toolArgs: ToolArgs,
|
||||
permissions: PermissionRules,
|
||||
workingDirectory: string = process.cwd(),
|
||||
modeState?: PermissionModeState,
|
||||
): PermissionCheckResult {
|
||||
const engine: PermissionEngine = isPermissionsV2Enabled() ? "v2" : "v1";
|
||||
const primary = checkPermissionForEngine(
|
||||
@@ -141,6 +143,7 @@ export function checkPermission(
|
||||
toolArgs,
|
||||
permissions,
|
||||
workingDirectory,
|
||||
modeState,
|
||||
);
|
||||
|
||||
let result: PermissionCheckResult = primary.result;
|
||||
@@ -170,6 +173,7 @@ export function checkPermission(
|
||||
toolArgs,
|
||||
permissions,
|
||||
workingDirectory,
|
||||
modeState,
|
||||
);
|
||||
|
||||
const mismatch =
|
||||
@@ -244,6 +248,7 @@ function checkPermissionForEngine(
|
||||
toolArgs: ToolArgs,
|
||||
permissions: PermissionRules,
|
||||
workingDirectory: string,
|
||||
modeState?: PermissionModeState,
|
||||
): { result: PermissionCheckResult; trace: PermissionCheckTrace } {
|
||||
const canonicalTool = canonicalToolName(toolName);
|
||||
const queryTool = engine === "v2" ? canonicalTool : toolName;
|
||||
@@ -298,22 +303,27 @@ function checkPermissionForEngine(
|
||||
}
|
||||
}
|
||||
|
||||
// Use the scoped permission mode state when available (listener/remote mode),
|
||||
// otherwise fall back to the global singleton (local/CLI mode).
|
||||
const effectiveMode = modeState?.mode ?? permissionMode.getMode();
|
||||
const effectivePlanFilePath =
|
||||
modeState?.planFilePath ?? permissionMode.getPlanFilePath();
|
||||
const modeOverride = permissionMode.checkModeOverride(
|
||||
toolName,
|
||||
toolArgs,
|
||||
workingDirectory,
|
||||
effectiveMode,
|
||||
effectivePlanFilePath,
|
||||
);
|
||||
if (modeOverride) {
|
||||
const currentMode = permissionMode.getMode();
|
||||
let reason = `Permission mode: ${currentMode}`;
|
||||
if (currentMode === "plan" && modeOverride === "deny") {
|
||||
const planFilePath = permissionMode.getPlanFilePath();
|
||||
const applyPatchRelativePath = planFilePath
|
||||
? relative(workingDirectory, planFilePath).replace(/\\/g, "/")
|
||||
let reason = `Permission mode: ${effectiveMode}`;
|
||||
if (effectiveMode === "plan" && modeOverride === "deny") {
|
||||
const applyPatchRelativePath = effectivePlanFilePath
|
||||
? relative(workingDirectory, effectivePlanFilePath).replace(/\\/g, "/")
|
||||
: null;
|
||||
reason =
|
||||
`Plan mode is active. You can only use read-only tools (Read, Grep, Glob, etc.) and write to the plan file. ` +
|
||||
`Write your plan to: ${planFilePath || "(error: plan file path not configured)"}. ` +
|
||||
`Write your plan to: ${effectivePlanFilePath || "(error: plan file path not configured)"}. ` +
|
||||
(applyPatchRelativePath
|
||||
? `If using apply_patch, use this exact relative path in patch headers: ${applyPatchRelativePath}. `
|
||||
: "") +
|
||||
@@ -323,7 +333,7 @@ function checkPermissionForEngine(
|
||||
return {
|
||||
result: {
|
||||
decision: modeOverride,
|
||||
matchedRule: `${currentMode} mode`,
|
||||
matchedRule: `${effectiveMode} mode`,
|
||||
reason,
|
||||
},
|
||||
trace,
|
||||
@@ -765,6 +775,7 @@ export async function checkPermissionWithHooks(
|
||||
toolArgs: ToolArgs,
|
||||
permissions: PermissionRules,
|
||||
workingDirectory: string = process.cwd(),
|
||||
modeState?: PermissionModeState,
|
||||
): Promise<PermissionCheckResult> {
|
||||
// First, check permission using normal rules
|
||||
const result = checkPermission(
|
||||
@@ -772,6 +783,7 @@ export async function checkPermissionWithHooks(
|
||||
toolArgs,
|
||||
permissions,
|
||||
workingDirectory,
|
||||
modeState,
|
||||
);
|
||||
|
||||
// If decision is "ask", run PermissionRequest hooks to see if they auto-allow/deny
|
||||
|
||||
@@ -246,15 +246,25 @@ class PermissionModeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool should be auto-allowed based on current mode
|
||||
* Returns null if mode doesn't apply to this tool
|
||||
* Check if a tool should be auto-allowed based on current mode.
|
||||
* Accepts explicit `mode` and `planFilePath` overrides so callers with a
|
||||
* scoped PermissionModeState (listener/remote mode) can bypass the global
|
||||
* singleton without requiring a temporary mutation of global state.
|
||||
* Returns null if mode doesn't apply to this tool.
|
||||
*/
|
||||
checkModeOverride(
|
||||
toolName: string,
|
||||
toolArgs?: Record<string, unknown>,
|
||||
workingDirectory: string = process.cwd(),
|
||||
modeOverride?: PermissionMode,
|
||||
planFilePathOverride?: string | null,
|
||||
): "allow" | "deny" | null {
|
||||
switch (this.currentMode) {
|
||||
const effectiveMode = modeOverride ?? this.currentMode;
|
||||
const effectivePlanFilePath =
|
||||
planFilePathOverride !== undefined
|
||||
? planFilePathOverride
|
||||
: this.getPlanFilePath();
|
||||
switch (effectiveMode) {
|
||||
case "bypassPermissions":
|
||||
// Auto-allow everything (except explicit deny rules checked earlier)
|
||||
return "allow";
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { relative } from "node:path";
|
||||
import { generatePlanFilePath } from "../../cli/helpers/planName";
|
||||
import { permissionMode } from "../../permissions/mode";
|
||||
import { getExecutionContextPermissionModeState } from "../manager";
|
||||
|
||||
interface EnterPlanModeArgs {
|
||||
[key: string]: never;
|
||||
/** Injected by executeTool — do not pass manually */
|
||||
_executionContextId?: string;
|
||||
}
|
||||
|
||||
interface EnterPlanModeResult {
|
||||
@@ -11,22 +13,40 @@ interface EnterPlanModeResult {
|
||||
}
|
||||
|
||||
export async function enter_plan_mode(
|
||||
_args: EnterPlanModeArgs,
|
||||
args: EnterPlanModeArgs,
|
||||
): Promise<EnterPlanModeResult> {
|
||||
// Resolve the permission mode state: prefer the per-conversation scoped
|
||||
// state when an execution context is present (listener/remote mode);
|
||||
// fall back to a wrapper around the global singleton for local/CLI mode.
|
||||
const scopedState = args._executionContextId
|
||||
? getExecutionContextPermissionModeState(args._executionContextId)
|
||||
: undefined;
|
||||
|
||||
// Normally this is handled by handleEnterPlanModeApprove in the UI layer,
|
||||
// which sets up state and returns a precomputed result (so this function
|
||||
// never runs). But if the generic approval flow is used for any reason,
|
||||
// we need to set up state here as a defensive fallback.
|
||||
if (
|
||||
permissionMode.getMode() !== "plan" ||
|
||||
!permissionMode.getPlanFilePath()
|
||||
) {
|
||||
const planFilePath = generatePlanFilePath();
|
||||
permissionMode.setMode("plan");
|
||||
permissionMode.setPlanFilePath(planFilePath);
|
||||
if (scopedState) {
|
||||
if (scopedState.mode !== "plan" || !scopedState.planFilePath) {
|
||||
const planFilePath = generatePlanFilePath();
|
||||
scopedState.modeBeforePlan =
|
||||
scopedState.modeBeforePlan ?? scopedState.mode;
|
||||
scopedState.mode = "plan";
|
||||
scopedState.planFilePath = planFilePath;
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
permissionMode.getMode() !== "plan" ||
|
||||
!permissionMode.getPlanFilePath()
|
||||
) {
|
||||
const planFilePath = generatePlanFilePath();
|
||||
permissionMode.setMode("plan");
|
||||
permissionMode.setPlanFilePath(planFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
const planFilePath = permissionMode.getPlanFilePath();
|
||||
const planFilePath =
|
||||
scopedState?.planFilePath ?? permissionMode.getPlanFilePath();
|
||||
const cwd = process.env.USER_CWD || process.cwd();
|
||||
const applyPatchRelativePath = planFilePath
|
||||
? relative(cwd, planFilePath).replace(/\\/g, "/")
|
||||
|
||||
@@ -4,12 +4,33 @@
|
||||
*/
|
||||
|
||||
import { permissionMode } from "../../permissions/mode";
|
||||
import { getExecutionContextPermissionModeState } from "../manager";
|
||||
|
||||
interface ExitPlanModeArgs {
|
||||
/** Injected by executeTool — do not pass manually */
|
||||
_executionContextId?: string;
|
||||
}
|
||||
|
||||
export async function exit_plan_mode(
|
||||
args: ExitPlanModeArgs = {},
|
||||
): Promise<{ message: string }> {
|
||||
// Resolve the permission mode state: prefer the per-conversation scoped
|
||||
// state when an execution context is present (listener/remote mode);
|
||||
// fall back to a wrapper around the global singleton for local/CLI mode.
|
||||
const scopedState = args._executionContextId
|
||||
? getExecutionContextPermissionModeState(args._executionContextId)
|
||||
: undefined;
|
||||
|
||||
export async function exit_plan_mode(): Promise<{ message: string }> {
|
||||
// In interactive mode, the UI restores mode before calling this tool.
|
||||
// In headless/bidirectional mode, there is no UI layer to do that, so
|
||||
// restore here as a fallback to avoid getting stuck in plan mode.
|
||||
if (permissionMode.getMode() === "plan") {
|
||||
if (scopedState) {
|
||||
if (scopedState.mode === "plan") {
|
||||
scopedState.mode = scopedState.modeBeforePlan ?? "default";
|
||||
scopedState.modeBeforePlan = null;
|
||||
scopedState.planFilePath = null;
|
||||
}
|
||||
} else if (permissionMode.getMode() === "plan") {
|
||||
const restoredMode = permissionMode.getModeBeforePlan() ?? "default";
|
||||
permissionMode.setMode(restoredMode);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
runPostToolUseHooks,
|
||||
runPreToolUseHooks,
|
||||
} from "../hooks";
|
||||
import {
|
||||
permissionMode as globalPermissionMode,
|
||||
type PermissionMode,
|
||||
} from "../permissions/mode";
|
||||
import { OPENAI_CODEX_PROVIDER_NAME } from "../providers/openai-codex-provider";
|
||||
import { telemetry } from "../telemetry";
|
||||
import { debugLog } from "../utils/debug";
|
||||
@@ -303,11 +307,25 @@ function getSwitchLock(): SwitchLockState {
|
||||
const toolRegistry = getRegistry();
|
||||
let toolExecutionContextCounter = 0;
|
||||
|
||||
/**
|
||||
* Mutable, shared-by-reference permission mode state.
|
||||
* Stored in each ToolExecutionContextSnapshot so tools like EnterPlanMode
|
||||
* and ExitPlanMode can update the mode without touching the global singleton.
|
||||
* Listener mode populates this from ConversationRuntime; CLI mode uses a
|
||||
* wrapper around the global permissionMode singleton.
|
||||
*/
|
||||
export type PermissionModeState = {
|
||||
mode: PermissionMode;
|
||||
planFilePath: string | null;
|
||||
modeBeforePlan: PermissionMode | null;
|
||||
};
|
||||
|
||||
type ToolExecutionContextSnapshot = {
|
||||
toolRegistry: ToolRegistry;
|
||||
externalTools: Map<string, ExternalToolDefinition>;
|
||||
externalExecutor?: ExternalToolExecutor;
|
||||
workingDirectory: string;
|
||||
permissionModeState: PermissionModeState;
|
||||
};
|
||||
|
||||
export type CapturedToolExecutionContext = {
|
||||
@@ -346,6 +364,17 @@ function getExecutionContextById(
|
||||
return getExecutionContexts().get(contextId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the mutable PermissionModeState for an execution context.
|
||||
* EnterPlanMode / ExitPlanMode use this to update the per-conversation
|
||||
* state without touching the global singleton.
|
||||
*/
|
||||
export function getExecutionContextPermissionModeState(
|
||||
contextId: string,
|
||||
): PermissionModeState | undefined {
|
||||
return getExecutionContextById(contextId)?.permissionModeState;
|
||||
}
|
||||
|
||||
export function clearCapturedToolExecutionContexts(): void {
|
||||
getExecutionContexts().clear();
|
||||
}
|
||||
@@ -618,12 +647,37 @@ export function getClientToolsFromRegistry(): ClientTool[] {
|
||||
*/
|
||||
export function captureToolExecutionContext(
|
||||
workingDirectory: string = process.env.USER_CWD || process.cwd(),
|
||||
permissionModeState?: PermissionModeState,
|
||||
): CapturedToolExecutionContext {
|
||||
// When no scoped state is provided (local/CLI mode), create a live proxy to
|
||||
// the global singleton so EnterPlanMode/ExitPlanMode still work correctly.
|
||||
const effectivePermissionModeState: PermissionModeState =
|
||||
permissionModeState ?? {
|
||||
get mode() {
|
||||
return globalPermissionMode.getMode();
|
||||
},
|
||||
set mode(value: PermissionMode) {
|
||||
globalPermissionMode.setMode(value);
|
||||
},
|
||||
get planFilePath() {
|
||||
return globalPermissionMode.getPlanFilePath();
|
||||
},
|
||||
set planFilePath(value: string | null) {
|
||||
globalPermissionMode.setPlanFilePath(value);
|
||||
},
|
||||
get modeBeforePlan() {
|
||||
return globalPermissionMode.getModeBeforePlan();
|
||||
},
|
||||
set modeBeforePlan(_value: PermissionMode | null) {
|
||||
// managed internally by globalPermissionMode
|
||||
},
|
||||
};
|
||||
const snapshot: ToolExecutionContextSnapshot = {
|
||||
toolRegistry: new Map(toolRegistry),
|
||||
externalTools: new Map(getExternalToolsRegistry()),
|
||||
externalExecutor: getExternalToolExecutor(),
|
||||
workingDirectory,
|
||||
permissionModeState: effectivePermissionModeState,
|
||||
};
|
||||
const contextId = saveExecutionContext(snapshot);
|
||||
|
||||
@@ -699,6 +753,7 @@ export async function checkToolPermission(
|
||||
toolName: string,
|
||||
toolArgs: ToolArgs,
|
||||
workingDirectory: string = process.cwd(),
|
||||
permissionModeStateArg?: PermissionModeState,
|
||||
): Promise<{
|
||||
decision: "allow" | "deny" | "ask";
|
||||
matchedRule?: string;
|
||||
@@ -713,6 +768,7 @@ export async function checkToolPermission(
|
||||
toolArgs,
|
||||
permissions,
|
||||
workingDirectory,
|
||||
permissionModeStateArg,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1285,6 +1341,21 @@ export async function executeTool(
|
||||
enhancedArgs = { ...enhancedArgs, toolCallId: options.toolCallId };
|
||||
}
|
||||
|
||||
// Inject the execution context id for plan-mode tools so they can update
|
||||
// the per-conversation PermissionModeState without touching the global singleton.
|
||||
const PLAN_MODE_TOOL_NAMES = new Set([
|
||||
"EnterPlanMode",
|
||||
"enter_plan_mode",
|
||||
"ExitPlanMode",
|
||||
"exit_plan_mode",
|
||||
]);
|
||||
if (PLAN_MODE_TOOL_NAMES.has(internalName) && options?.toolContextId) {
|
||||
enhancedArgs = {
|
||||
...enhancedArgs,
|
||||
_executionContextId: options.toolContextId,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await withExecutionWorkingDirectory(workingDirectory, () =>
|
||||
tool.fn(enhancedArgs),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import WebSocket from "ws";
|
||||
import { getClient } from "../../agent/client";
|
||||
import { generatePlanFilePath } from "../../cli/helpers/planName";
|
||||
import { INTERRUPTED_BY_USER } from "../../constants";
|
||||
import { permissionMode } from "../../permissions/mode";
|
||||
import { type DequeuedBatch, QueueRuntime } from "../../queue/queueRuntime";
|
||||
import { createSharedReminderState } from "../../reminders/state";
|
||||
import { settingsManager } from "../../settings-manager";
|
||||
@@ -47,6 +46,10 @@ import {
|
||||
populateInterruptQueue,
|
||||
stashRecoveredApprovalInterrupts,
|
||||
} from "./interrupts";
|
||||
import {
|
||||
getConversationPermissionModeState,
|
||||
setConversationPermissionModeState,
|
||||
} from "./permissionMode";
|
||||
import { parseServerMessage } from "./protocol-inbound";
|
||||
import {
|
||||
buildDeviceStatus,
|
||||
@@ -106,7 +109,10 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Handle mode change request from cloud
|
||||
* Handle mode change request from cloud.
|
||||
* Stores the new mode in ListenerRuntime.permissionModeByConversation so
|
||||
* each agent/conversation is isolated and the state outlives the ephemeral
|
||||
* ConversationRuntime (which gets evicted between turns).
|
||||
*/
|
||||
function handleModeChange(
|
||||
msg: ModeChangePayload,
|
||||
@@ -118,13 +124,34 @@ function handleModeChange(
|
||||
},
|
||||
): void {
|
||||
try {
|
||||
permissionMode.setMode(msg.mode);
|
||||
const agentId = scope?.agent_id ?? null;
|
||||
const conversationId = scope?.conversation_id ?? "default";
|
||||
const current = getConversationPermissionModeState(
|
||||
runtime,
|
||||
agentId,
|
||||
conversationId,
|
||||
);
|
||||
|
||||
// If entering plan mode, generate and set plan file path
|
||||
if (msg.mode === "plan" && !permissionMode.getPlanFilePath()) {
|
||||
const planFilePath = generatePlanFilePath();
|
||||
permissionMode.setPlanFilePath(planFilePath);
|
||||
const next = { ...current };
|
||||
|
||||
// Track previous mode so ExitPlanMode can restore it
|
||||
if (msg.mode === "plan" && current.mode !== "plan") {
|
||||
next.modeBeforePlan = current.mode;
|
||||
}
|
||||
next.mode = msg.mode;
|
||||
|
||||
// Generate plan file path when entering plan mode
|
||||
if (msg.mode === "plan" && !current.planFilePath) {
|
||||
next.planFilePath = generatePlanFilePath();
|
||||
}
|
||||
|
||||
// Clear plan-related state when leaving plan mode
|
||||
if (msg.mode !== "plan") {
|
||||
next.planFilePath = null;
|
||||
next.modeBeforePlan = null;
|
||||
}
|
||||
|
||||
setConversationPermissionModeState(runtime, agentId, conversationId, next);
|
||||
|
||||
emitDeviceStatusUpdate(socket, runtime, scope);
|
||||
|
||||
@@ -385,6 +412,7 @@ function createRuntime(): ListenerRuntime {
|
||||
reminderState: createSharedReminderState(),
|
||||
bootWorkingDirectory,
|
||||
workingDirectoryByConversation: loadPersistedCwdMap(),
|
||||
permissionModeByConversation: new Map(),
|
||||
connectionId: null,
|
||||
connectionName: null,
|
||||
conversationRuntimes: new Map(),
|
||||
@@ -1004,6 +1032,7 @@ function createLegacyTestRuntime(): ConversationRuntime & {
|
||||
activeConversationId: string;
|
||||
socket: WebSocket | null;
|
||||
workingDirectoryByConversation: Map<string, string>;
|
||||
permissionModeByConversation: ListenerRuntime["permissionModeByConversation"];
|
||||
bootWorkingDirectory: string;
|
||||
connectionId: string | null;
|
||||
connectionName: string | null;
|
||||
@@ -1031,6 +1060,7 @@ function createLegacyTestRuntime(): ConversationRuntime & {
|
||||
activeConversationId: string;
|
||||
socket: WebSocket | null;
|
||||
workingDirectoryByConversation: Map<string, string>;
|
||||
permissionModeByConversation: ListenerRuntime["permissionModeByConversation"];
|
||||
bootWorkingDirectory: string;
|
||||
connectionId: string | null;
|
||||
connectionName: string | null;
|
||||
@@ -1064,6 +1094,12 @@ function createLegacyTestRuntime(): ConversationRuntime & {
|
||||
listener.workingDirectoryByConversation = value;
|
||||
},
|
||||
},
|
||||
permissionModeByConversation: {
|
||||
get: () => listener.permissionModeByConversation,
|
||||
set: (value: ListenerRuntime["permissionModeByConversation"]) => {
|
||||
listener.permissionModeByConversation = value;
|
||||
},
|
||||
},
|
||||
bootWorkingDirectory: {
|
||||
get: () => listener.bootWorkingDirectory,
|
||||
set: (value: string) => {
|
||||
|
||||
65
src/websocket/listener/permissionMode.ts
Normal file
65
src/websocket/listener/permissionMode.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Per-conversation permission mode storage.
|
||||
*
|
||||
* Mirrors the CWD isolation pattern in cwd.ts:
|
||||
* - State is stored in a Map on the long-lived ListenerRuntime (not on the
|
||||
* ephemeral ConversationRuntime, which gets evicted between turns).
|
||||
* - A scope key derived from agentId + conversationId is used as the map key.
|
||||
*/
|
||||
|
||||
import type { PermissionMode } from "../../permissions/mode";
|
||||
import { permissionMode as globalPermissionMode } from "../../permissions/mode";
|
||||
import { normalizeConversationId, normalizeCwdAgentId } from "./scope";
|
||||
import type { ListenerRuntime } from "./types";
|
||||
|
||||
export type ConversationPermissionModeState = {
|
||||
mode: PermissionMode;
|
||||
planFilePath: string | null;
|
||||
modeBeforePlan: PermissionMode | null;
|
||||
};
|
||||
|
||||
export function getPermissionModeScopeKey(
|
||||
agentId?: string | null,
|
||||
conversationId?: string | null,
|
||||
): string {
|
||||
const normalizedConversationId = normalizeConversationId(conversationId);
|
||||
const normalizedAgentId = normalizeCwdAgentId(agentId);
|
||||
if (normalizedConversationId === "default") {
|
||||
return `agent:${normalizedAgentId ?? "__unknown__"}::conversation:default`;
|
||||
}
|
||||
return `conversation:${normalizedConversationId}`;
|
||||
}
|
||||
|
||||
export function getConversationPermissionModeState(
|
||||
runtime: ListenerRuntime,
|
||||
agentId?: string | null,
|
||||
conversationId?: string | null,
|
||||
): ConversationPermissionModeState {
|
||||
const scopeKey = getPermissionModeScopeKey(agentId, conversationId);
|
||||
return (
|
||||
runtime.permissionModeByConversation.get(scopeKey) ?? {
|
||||
mode: globalPermissionMode.getMode(),
|
||||
planFilePath: null,
|
||||
modeBeforePlan: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function setConversationPermissionModeState(
|
||||
runtime: ListenerRuntime,
|
||||
agentId: string | null,
|
||||
conversationId: string,
|
||||
state: ConversationPermissionModeState,
|
||||
): void {
|
||||
const scopeKey = getPermissionModeScopeKey(agentId, conversationId);
|
||||
// Only store if different from the global default to keep the map lean.
|
||||
if (
|
||||
state.mode === globalPermissionMode.getMode() &&
|
||||
state.planFilePath === null &&
|
||||
state.modeBeforePlan === null
|
||||
) {
|
||||
runtime.permissionModeByConversation.delete(scopeKey);
|
||||
} else {
|
||||
runtime.permissionModeByConversation.set(scopeKey, { ...state });
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
} from "../../types/protocol_v2";
|
||||
import { SYSTEM_REMINDER_RE } from "./constants";
|
||||
import { getConversationWorkingDirectory } from "./cwd";
|
||||
import { getConversationPermissionModeState } from "./permissionMode";
|
||||
import {
|
||||
getConversationRuntime,
|
||||
getPendingControlRequests,
|
||||
@@ -120,12 +121,18 @@ export function buildDeviceStatus(
|
||||
return "auto" as const;
|
||||
}
|
||||
})();
|
||||
// Read mode from the persistent ListenerRuntime map (outlives ConversationRuntime).
|
||||
const conversationPermissionModeState = getConversationPermissionModeState(
|
||||
listener,
|
||||
scopedAgentId,
|
||||
scopedConversationId,
|
||||
);
|
||||
return {
|
||||
current_connection_id: listener.connectionId,
|
||||
connection_name: listener.connectionName,
|
||||
is_online: listener.socket?.readyState === WebSocket.OPEN,
|
||||
is_processing: !!conversationRuntime?.isProcessing,
|
||||
current_permission_mode: permissionMode.getMode(),
|
||||
current_permission_mode: conversationPermissionModeState.mode,
|
||||
current_working_directory: getConversationWorkingDirectory(
|
||||
listener,
|
||||
scopedAgentId,
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
emitToolExecutionFinishedEvents,
|
||||
emitToolExecutionStartedEvents,
|
||||
} from "./interrupts";
|
||||
import { getConversationPermissionModeState } from "./permissionMode";
|
||||
import {
|
||||
emitRetryDelta,
|
||||
emitRuntimeStateUpdates,
|
||||
@@ -144,6 +145,11 @@ async function resolveStaleApprovals(
|
||||
requireArgsForAutoApprove: true,
|
||||
missingNameReason: "Tool call incomplete - missing name",
|
||||
workingDirectory: recoveryWorkingDirectory,
|
||||
permissionModeState: getConversationPermissionModeState(
|
||||
runtime.listener,
|
||||
runtime.agentId,
|
||||
runtime.conversationId,
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ export async function handleApprovalStop(params: {
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
turnWorkingDirectory: string;
|
||||
turnPermissionModeState: import("../../tools/manager").PermissionModeState;
|
||||
dequeuedBatchId: string;
|
||||
runId?: string;
|
||||
msgRunIds: string[];
|
||||
@@ -101,6 +102,7 @@ export async function handleApprovalStop(params: {
|
||||
agentId,
|
||||
conversationId,
|
||||
turnWorkingDirectory,
|
||||
turnPermissionModeState,
|
||||
dequeuedBatchId,
|
||||
runId,
|
||||
msgRunIds,
|
||||
@@ -161,6 +163,7 @@ export async function handleApprovalStop(params: {
|
||||
requireArgsForAutoApprove: true,
|
||||
missingNameReason: "Tool call incomplete - missing name",
|
||||
workingDirectory: turnWorkingDirectory,
|
||||
permissionModeState: turnPermissionModeState,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
normalizeToolReturnWireMessage,
|
||||
populateInterruptQueue,
|
||||
} from "./interrupts";
|
||||
import {
|
||||
getConversationPermissionModeState,
|
||||
setConversationPermissionModeState,
|
||||
} from "./permissionMode";
|
||||
import {
|
||||
emitCanonicalMessageDelta,
|
||||
emitInterruptedStatusDelta,
|
||||
@@ -91,6 +95,18 @@ export async function handleIncomingMessage(
|
||||
normalizedAgentId,
|
||||
conversationId,
|
||||
);
|
||||
|
||||
// Build a mutable permission mode state object for this turn, seeded from the
|
||||
// persistent ListenerRuntime map. Tool implementations (EnterPlanMode, ExitPlanMode)
|
||||
// mutate it in place; we sync the final value back to the map after the turn.
|
||||
const turnPermissionModeState = {
|
||||
...getConversationPermissionModeState(
|
||||
runtime.listener,
|
||||
normalizedAgentId,
|
||||
conversationId,
|
||||
),
|
||||
};
|
||||
|
||||
const msgRunIds: string[] = [];
|
||||
let postStopApprovalRecoveryRetries = 0;
|
||||
let llmApiErrorRetries = 0;
|
||||
@@ -197,6 +213,7 @@ export async function handleIncomingMessage(
|
||||
streamTokens: true,
|
||||
background: true,
|
||||
workingDirectory: turnWorkingDirectory,
|
||||
permissionModeState: turnPermissionModeState,
|
||||
...(pendingNormalizationInterruptedToolCallIds.length > 0
|
||||
? {
|
||||
approvalNormalization: {
|
||||
@@ -641,6 +658,7 @@ export async function handleIncomingMessage(
|
||||
agentId,
|
||||
conversationId,
|
||||
turnWorkingDirectory,
|
||||
turnPermissionModeState,
|
||||
dequeuedBatchId,
|
||||
runId,
|
||||
msgRunIds,
|
||||
@@ -742,6 +760,15 @@ export async function handleIncomingMessage(
|
||||
console.error("[Listen] Error handling message:", error);
|
||||
}
|
||||
} finally {
|
||||
// Sync any permission mode changes made by tools (EnterPlanMode/ExitPlanMode)
|
||||
// back to the persistent ListenerRuntime map so the state survives eviction.
|
||||
setConversationPermissionModeState(
|
||||
runtime.listener,
|
||||
normalizedAgentId,
|
||||
conversationId,
|
||||
turnPermissionModeState,
|
||||
);
|
||||
|
||||
runtime.activeAbortController = null;
|
||||
runtime.cancelRequested = false;
|
||||
runtime.isRecoveringApprovals = false;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApprovalCreate } from "@letta-ai/letta-client/resources/agents/mes
|
||||
import type WebSocket from "ws";
|
||||
import type { ApprovalResult } from "../../agent/approval-execution";
|
||||
import type { ApprovalRequest } from "../../cli/helpers/stream";
|
||||
|
||||
import type {
|
||||
DequeuedBatch,
|
||||
QueueBlockedReason,
|
||||
@@ -148,6 +149,11 @@ export type ListenerRuntime = {
|
||||
reminderState: SharedReminderState;
|
||||
bootWorkingDirectory: string;
|
||||
workingDirectoryByConversation: Map<string, string>;
|
||||
/** Per-conversation permission mode state. Mirrors workingDirectoryByConversation. */
|
||||
permissionModeByConversation: Map<
|
||||
string,
|
||||
import("./permissionMode").ConversationPermissionModeState
|
||||
>;
|
||||
connectionId: string | null;
|
||||
connectionName: string | null;
|
||||
conversationRuntimes: Map<string, ConversationRuntime>;
|
||||
|
||||
Reference in New Issue
Block a user