From dbf9c48a3ff3366fe74c0997dbeb6669b4c13a79 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:01:29 -0400 Subject: [PATCH 1/5] ship: checkpoint before automate/finalize Bundle in-progress CTO/chat/sync work so automate and finalize agents run against a known committed baseline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/ai/tools/ctoOperatorTools.ts | 2 +- .../services/chat/agentChatService.test.ts | 24 +++++++-- .../main/services/chat/agentChatService.ts | 29 ++++++----- .../chat/identitySessionPolicy.test.ts | 19 +++++++ .../services/chat/identitySessionPolicy.ts | 31 ++++++++++++ .../src/main/services/ipc/registerIpc.ts | 10 ++-- .../sync/syncRemoteCommandService.test.ts | 50 +++++++++++++++++-- .../services/sync/syncRemoteCommandService.ts | 14 +++--- .../chat/AgentChatComposer.test.tsx | 9 ++++ .../components/chat/AgentChatComposer.tsx | 6 +++ .../components/chat/AgentChatPane.tsx | 26 ++++++---- .../src/renderer/components/cto/CtoPage.tsx | 31 ++++++------ .../cto/ctoSessionViewState.test.ts | 22 ++++++++ .../components/cto/ctoSessionViewState.ts | 3 ++ apps/desktop/src/shared/types/agents.ts | 2 - apps/desktop/src/shared/types/cto.ts | 1 - 16 files changed, 216 insertions(+), 63 deletions(-) create mode 100644 apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts create mode 100644 apps/desktop/src/main/services/chat/identitySessionPolicy.ts create mode 100644 apps/desktop/src/renderer/components/cto/ctoSessionViewState.test.ts create mode 100644 apps/desktop/src/renderer/components/cto/ctoSessionViewState.ts diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index ad827c268..6b41b589d 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -773,7 +773,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { }); expect(session.laneId).toBe("lane-1"); - expect(session.permissionMode).toBe("plan"); + expect(session.permissionMode).toBe("full-auto"); }); it("does not reuse a foreign-lane identity session or auto-close it during migration", async () => { @@ -1982,7 +1982,7 @@ describe("createAgentChatService", () => { expect(reused.laneId).toBe("lane-1"); }); - it("records headShaStart for the selected execution lane instead of the canonical host lane", async () => { + it("pins CTO execution state to the primary lane even when a foreign lane is requested", async () => { vi.mocked(runGit).mockImplementation(async (_args, opts) => ({ stdout: String(opts?.cwd ?? "").includes(path.join(tmpRoot, "lane-2")) ? "lane-2-sha\n" : "lane-1-sha\n", stderr: "", @@ -1995,7 +1995,25 @@ describe("createAgentChatService", () => { laneId: "lane-2", }); - expect(sessionService.setHeadShaStart).toHaveBeenLastCalledWith(session.id, "lane-2-sha"); + expect(sessionService.setHeadShaStart).toHaveBeenLastCalledWith(session.id, "lane-1-sha"); + }); + + it("pins worker identity execution state to the primary lane", async () => { + vi.mocked(runGit).mockImplementation(async (_args, opts) => ({ + stdout: String(opts?.cwd ?? "").includes(path.join(tmpRoot, "lane-2")) ? "lane-2-sha\n" : "lane-1-sha\n", + stderr: "", + exitCode: 0, + })); + + const { service, sessionService } = createService(); + const session = await service.ensureIdentitySession({ + identityKey: "agent:worker-1", + laneId: "lane-2", + }); + + expect(session.laneId).toBe("lane-1"); + expect(session.permissionMode).toBe("full-auto"); + expect(sessionService.setHeadShaStart).toHaveBeenLastCalledWith(session.id, "lane-1-sha"); }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 5bc9f2160..9a0847fec 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -37,6 +37,10 @@ import { shouldFlushBufferedAssistantTextForEvent, type BufferedAssistantText, } from "./chatTextBatching"; +import { + normalizeIdentityPermissionMode, + resolveIdentityExecutionLane, +} from "./identitySessionPolicy"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import { resolveLaneLaunchContext, type LaneLaunchContext } from "../lanes/laneLaunchContext"; @@ -2416,17 +2420,6 @@ function inferCapabilityMode(provider: AgentChatProvider): CtoCapabilityMode { return provider === "codex" || provider === "claude" || provider === "cursor" || provider === "opencode" ? "full_tooling" : "fallback"; } -function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] { - return "plan"; -} - -function normalizeIdentityPermissionMode( - mode: AgentChatSession["permissionMode"] | undefined, - provider: AgentChatProvider, -): AgentChatSession["permissionMode"] { - return mode === "plan" ? "plan" : guardedIdentityPermissionModeForProvider(provider); -} - function isLightweightSession(session: Pick): boolean { return session.sessionProfile === "light"; } @@ -10380,7 +10373,7 @@ export function createAgentChatService(args: { const normalizedCursorConfigValues = normalizeCursorConfigValueRecord(requestedCursorConfigValues); const capabilityMode = inferCapabilityMode(effectiveProvider); let effectivePermissionMode = identityKey - ? normalizeIdentityPermissionMode(requestedPermMode, effectiveProvider) + ? normalizeIdentityPermissionMode(identityKey, requestedPermMode, effectiveProvider) : requestedPermMode; const chatConfig = resolveChatConfig(); let requestedOpenCodePermissionMode = requestedOpenCodePermissionModeArg; @@ -12554,7 +12547,11 @@ export function createAgentChatService(args: { } const canonicalLaneId = await resolvePrimaryIdentityLane(); - const selectedExecutionLaneId = requestedLaneId || null; + const selectedExecutionLaneId = resolveIdentityExecutionLane( + args.identityKey, + requestedLaneId, + canonicalLaneId, + ); const existing = await listSessions(undefined, { includeIdentity: true }); const identitySessions = existing .filter((entry) => entry.identityKey === args.identityKey) @@ -12573,6 +12570,7 @@ export function createAgentChatService(args: { managed.session.reasoningEffort = normalizeReasoningEffort(args.reasoningEffort); } managed.session.permissionMode = normalizeIdentityPermissionMode( + args.identityKey, args.permissionMode ?? managed.session.permissionMode, managed.session.provider, ); @@ -12640,7 +12638,7 @@ export function createAgentChatService(args: { model: preferredModel, ...(resolvedModelId ? { modelId: resolvedModelId } : {}), reasoningEffort: args.reasoningEffort ?? pref?.reasoningEffort ?? null, - permissionMode: args.permissionMode ?? "plan", + ...(args.permissionMode ? { permissionMode: args.permissionMode } : {}), identityKey: args.identityKey }); @@ -13338,6 +13336,7 @@ export function createAgentChatService(args: { if (isIdentitySession) { managed.session.permissionMode = normalizeIdentityPermissionMode( + managed.session.identityKey, managed.session.permissionMode, nextProvider, ); @@ -13393,7 +13392,7 @@ export function createAgentChatService(args: { if (permissionMode !== undefined) { managed.session.permissionMode = isIdentitySession - ? normalizeIdentityPermissionMode(permissionMode, managed.session.provider) + ? normalizeIdentityPermissionMode(managed.session.identityKey, permissionMode, managed.session.provider) : permissionMode; applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode); } diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts new file mode 100644 index 000000000..4bfd2e449 --- /dev/null +++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeIdentityPermissionMode, + resolveIdentityExecutionLane, +} from "./identitySessionPolicy"; + +describe("identitySessionPolicy", () => { + it("forces CTO and worker sessions into full-auto permission mode", () => { + expect(normalizeIdentityPermissionMode("cto", "plan", "claude")).toBe("full-auto"); + expect(normalizeIdentityPermissionMode("cto", undefined, "codex")).toBe("full-auto"); + expect(normalizeIdentityPermissionMode("agent:worker-1", undefined, "codex")).toBe("full-auto"); + expect(normalizeIdentityPermissionMode("agent:worker-1", "plan", "claude")).toBe("full-auto"); + }); + + it("pins CTO and worker execution to the canonical lane", () => { + expect(resolveIdentityExecutionLane("cto", "lane-feature", "lane-primary")).toBe("lane-primary"); + expect(resolveIdentityExecutionLane("agent:worker-1", "lane-feature", "lane-primary")).toBe("lane-primary"); + }); +}); diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.ts new file mode 100644 index 000000000..896d868c8 --- /dev/null +++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.ts @@ -0,0 +1,31 @@ +import type { AgentChatIdentityKey, AgentChatProvider, AgentChatSession } from "../../../shared/types"; + +export function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] { + return "plan"; +} + +function isPrimaryPinnedIdentity(identityKey: AgentChatIdentityKey | undefined): boolean { + return identityKey === "cto" || Boolean(identityKey?.startsWith("agent:")); +} + +export function normalizeIdentityPermissionMode( + identityKey: AgentChatIdentityKey | undefined, + mode: AgentChatSession["permissionMode"] | undefined, + provider: AgentChatProvider, +): AgentChatSession["permissionMode"] { + if (isPrimaryPinnedIdentity(identityKey)) { + return "full-auto"; + } + return mode === "plan" ? "plan" : guardedIdentityPermissionModeForProvider(provider); +} + +export function resolveIdentityExecutionLane( + identityKey: AgentChatIdentityKey, + requestedLaneId: string, + canonicalLaneId: string, +): string | null { + if (isPrimaryPinnedIdentity(identityKey)) { + return canonicalLaneId; + } + return requestedLaneId || null; +} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c2225c8ec..e81f2f47a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -6272,15 +6272,16 @@ export function registerIpc({ ipcMain.handle(IPC.ctoEnsureSession, async (_event, arg: CtoEnsureSessionArgs = {}): Promise => { const ctx = getCtx(); - const laneId = await resolveFirstAvailableLaneId(ctx, arg.laneId); + const laneId = await resolveFirstAvailableLaneId(ctx, null); if (!laneId) { - throw new Error("No active lane is available to host the CTO chat session."); + throw new Error("No primary lane is available to host the CTO chat session."); } return ctx.agentChatService.ensureIdentitySession({ identityKey: "cto", laneId, modelId: arg.modelId ?? null, reasoningEffort: arg.reasoningEffort ?? null, + permissionMode: "full-auto", }); }); @@ -6343,13 +6344,14 @@ export function registerIpc({ ipcMain.handle(IPC.ctoEnsureAgentSession, async (_event, arg: CtoEnsureAgentSessionArgs): Promise => { const ctx = getCtx(); if (!ctx.agentChatService) throw new Error("Agent chat service is not available."); - const laneId = await resolveFirstAvailableLaneId(ctx, arg.laneId); - if (!laneId) throw new Error("No lane available for agent session."); + const laneId = await resolveFirstAvailableLaneId(ctx, null); + if (!laneId) throw new Error("No primary lane is available to host the agent chat session."); return ctx.agentChatService.ensureIdentitySession({ identityKey: `agent:${arg.agentId}`, laneId, modelId: arg.modelId ?? null, reasoningEffort: arg.reasoningEffort ?? null, + permissionMode: "full-auto", }); }); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 3b2c1bbb4..47b52331e 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -1769,25 +1769,30 @@ describe("createSyncRemoteCommandService", () => { laneId: "lane-primary", modelId: "claude-opus-4", reasoningEffort: "high", + permissionMode: "full-auto", }); expect(agentChatService.getSessionSummary).toHaveBeenCalledWith("chat-identity-1"); expect(result).toEqual(expect.objectContaining({ sessionId: "chat-1" })); }); - it("cto.ensureSession uses the requested laneId when provided", async () => { + it("cto.ensureSession ignores requested lane overrides and still uses primary", async () => { + laneService.list.mockResolvedValueOnce([ + { id: "lane-primary", laneType: "primary" }, + ]); await service.execute(makePayload("cto.ensureSession", { laneId: "lane-explicit" })); expect(agentChatService.ensureIdentitySession).toHaveBeenCalledWith({ identityKey: "cto", - laneId: "lane-explicit", + laneId: "lane-primary", modelId: null, reasoningEffort: null, + permissionMode: "full-auto", }); }); it("cto.ensureSession throws when no lane is available", async () => { laneService.list.mockResolvedValueOnce([]); await expect(service.execute(makePayload("cto.ensureSession", {}))) - .rejects.toThrow("No active lane is available to host the CTO chat session."); + .rejects.toThrow("No primary lane is available to host the CTO chat session."); }); it("cto.ensureAgentSession requires agentId", async () => { @@ -1795,7 +1800,7 @@ describe("createSyncRemoteCommandService", () => { .rejects.toThrow("cto.ensureAgentSession requires agentId."); }); - it("cto.ensureAgentSession delegates to agentChatService with agent: identityKey", async () => { + it("cto.ensureAgentSession delegates to agentChatService with agent: identityKey on primary", async () => { laneService.list.mockResolvedValueOnce([ { id: "lane-primary", laneType: "primary" }, ]); @@ -1813,10 +1818,34 @@ describe("createSyncRemoteCommandService", () => { laneId: "lane-primary", modelId: null, reasoningEffort: null, + permissionMode: "full-auto", }); expect(result).toEqual(expect.objectContaining({ sessionId: "chat-1" })); }); + it("cto.ensureAgentSession ignores requested lane overrides and still uses primary", async () => { + laneService.list.mockResolvedValueOnce([ + { id: "lane-primary", laneType: "primary" }, + ]); + workerAgentService.getAgent.mockReturnValueOnce({ + id: "worker-42", + name: "Mobile Droid", + slug: "mobile-droid", + status: "running", + }); + await service.execute(makePayload("cto.ensureAgentSession", { + agentId: "worker-42", + laneId: "lane-explicit", + })); + expect(agentChatService.ensureIdentitySession).toHaveBeenCalledWith({ + identityKey: "agent:worker-42", + laneId: "lane-primary", + modelId: null, + reasoningEffort: null, + permissionMode: "full-auto", + }); + }); + it("cto.ensureAgentSession rejects unknown agentIds without creating a session", async () => { workerAgentService.getAgent.mockReturnValueOnce(null); workerAgentService.listAgents.mockReturnValueOnce([]); @@ -1826,6 +1855,19 @@ describe("createSyncRemoteCommandService", () => { expect(agentChatService.ensureIdentitySession).not.toHaveBeenCalled(); }); + it("cto.ensureAgentSession throws when no primary lane is available", async () => { + laneService.list.mockResolvedValueOnce([]); + workerAgentService.getAgent.mockReturnValueOnce({ + id: "worker-42", + name: "Mobile Droid", + slug: "mobile-droid", + status: "running", + }); + await expect(service.execute(makePayload("cto.ensureAgentSession", { + agentId: "worker-42", + }))).rejects.toThrow("No primary lane is available to host the agent chat session."); + }); + it("cto.ensureSession returns the same session on repeat calls (canonical lane reuse)", async () => { // Both calls resolve the same primary lane; ensureIdentitySession is a // mock that always returns the same session id, so the handler must diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index cca8c10fb..414f75624 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1720,12 +1720,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg }); register("cto.ensureSession", { viewerAllowed: true }, async (payload) => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - // When no override is given, resolve the primary lane the same way - // agentChatService.ensureIdentitySession does internally (it reuses a - // session only when session.laneId === canonicalLaneId). Passing the - // canonical lane guarantees the same session is returned on repeat calls. - const laneId = await resolveFirstAvailableLaneIdForSync(args, asTrimmedString(payload.laneId)); - if (!laneId) throw new Error("No active lane is available to host the CTO chat session."); + const laneId = await resolveFirstAvailableLaneIdForSync(args, null); + if (!laneId) throw new Error("No primary lane is available to host the CTO chat session."); const modelId = asTrimmedString(payload.modelId); const reasoningEffort = asTrimmedString(payload.reasoningEffort); const session = await agentChatService.ensureIdentitySession({ @@ -1733,6 +1729,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg laneId, modelId: modelId ?? null, reasoningEffort: reasoningEffort ?? null, + permissionMode: "full-auto", }); return summarizeChatSessionForRemote(agentChatService, session); }); @@ -1749,8 +1746,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg if (!agent) { throw new Error(`cto.ensureAgentSession: unknown agentId '${agentId}'`); } - const laneId = await resolveFirstAvailableLaneIdForSync(args, asTrimmedString(payload.laneId)); - if (!laneId) throw new Error("No active lane is available to host the agent chat session."); + const laneId = await resolveFirstAvailableLaneIdForSync(args, null); + if (!laneId) throw new Error("No primary lane is available to host the agent chat session."); const modelId = asTrimmedString(payload.modelId); const reasoningEffort = asTrimmedString(payload.reasoningEffort); const session = await agentChatService.ensureIdentitySession({ @@ -1758,6 +1755,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg laneId, modelId: modelId ?? null, reasoningEffort: reasoningEffort ?? null, + permissionMode: "full-auto", }); return summarizeChatSessionForRemote(agentChatService, session); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 651ce4c9e..8210f9ff1 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -187,6 +187,15 @@ describe("AgentChatComposer", () => { }); }); + it("can hide native permission controls for fixed-mode surfaces", () => { + renderComposer({ + sessionProvider: "codex", + hideNativeControls: true, + }); + + expect(screen.queryByRole("button", { name: "Codex approval preset" })).toBeNull(); + }); + it("avoids promising option chips when a pending question is freeform only", () => { renderComposer({ pendingInput: { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 2467c1933..de789f2f0 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -344,6 +344,7 @@ export function AgentChatComposer({ executionModeOptions = [], modelSelectionLocked = false, permissionModeLocked = false, + hideNativeControls = false, messagePlaceholder, onModelChange, onReasoningEffortChange, @@ -409,6 +410,7 @@ export function AgentChatComposer({ executionModeOptions?: ExecutionModeOption[]; modelSelectionLocked?: boolean; permissionModeLocked?: boolean; + hideNativeControls?: boolean; messagePlaceholder?: string; onModelChange: (modelId: string) => void; onReasoningEffortChange: (reasoningEffort: string | null) => void; @@ -742,6 +744,9 @@ export function AgentChatComposer({ return codexPresetOptions.find((option) => option.value === codexPreset)?.detail ?? null; }, [codexCustomSummary, codexPreset, codexPresetOptions, hoveredCodexPreset, sessionProvider]); const nativeControlPanel = useMemo(() => { + if (hideNativeControls) { + return null; + } const renderButtonGroup = ( label: string, value: T | undefined, @@ -1109,6 +1114,7 @@ export function AgentChatComposer({ hoveredClaudeMode, hoveredCodexPreset, nativeControlsDisabled, + hideNativeControls, onClaudeModeChange, onClaudePermissionModeChange, onInteractionModeChange, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 5c18768dc..a06a5b29a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -671,6 +671,8 @@ export function AgentChatPane({ initialSessionSummary, lockSessionId, hideSessionTabs = false, + hideNativeControls = false, + hideWorkspaceChrome = false, forceNewSession = false, forceDraftMode = false, availableModelIdsOverride, @@ -691,6 +693,8 @@ export function AgentChatPane({ initialSessionSummary?: AgentChatSessionSummary | null; lockSessionId?: string | null; hideSessionTabs?: boolean; + hideNativeControls?: boolean; + hideWorkspaceChrome?: boolean; forceNewSession?: boolean; forceDraftMode?: boolean; availableModelIdsOverride?: string[]; @@ -723,6 +727,7 @@ export function AgentChatPane({ const preferDraftStart = !lockSessionId && !initialSessionId && !forceNewSession; const surfaceProfile: ChatSurfaceProfile = presentation?.profile ?? "standard"; const isPersistentIdentitySurface = surfaceProfile === "persistent_identity"; + const showWorkspaceChrome = !hideWorkspaceChrome; const modelSwitchPolicy = presentation?.modelSwitchPolicy ?? "same-family-after-launch"; const initialNativeControls = useMemo(() => defaultNativeControls(surfaceProfile), [surfaceProfile]); const [sessions, setSessions] = useState([]); @@ -2606,10 +2611,10 @@ export function AgentChatPane({ ) : null} - {laneId ? : null} + {showWorkspaceChrome && laneId ? : null}
- {laneId ? setTerminalDrawerOpen((v) => !v)} /> : null} + {showWorkspaceChrome && laneId ? setTerminalDrawerOpen((v) => !v)} /> : null} {resolvedChips.map((chip) => ( { void updateNativeControls({ interactionMode: value }); }} @@ -3095,11 +3101,13 @@ export function AgentChatPane({ sessionId={selectedSessionId} /> ) : null} - setTerminalDrawerOpen((v) => !v)} - laneId={laneId} - /> + {showWorkspaceChrome ? ( + setTerminalDrawerOpen((v) => !v)} + laneId={laneId} + /> + ) : null}
{/* Proof panel (push) */} @@ -3147,7 +3155,7 @@ export function AgentChatPane({

{/* Lane selector pill */} - {availableLanes && availableLanes.length > 0 && onLaneChange ? ( + {showWorkspaceChrome && availableLanes && availableLanes.length > 0 && onLaneChange ? ( @@ -3172,7 +3180,7 @@ export function AgentChatPane({ ))} - ) : laneDisplayLabel ? ( + ) : showWorkspaceChrome && laneDisplayLabel ? ( s.lanes); - const selectedLaneId = useAppStore((s) => s.selectedLaneId); const [activeTab, setActiveTab] = useState("chat"); const [session, setSession] = useState(null); @@ -126,10 +126,7 @@ export function CtoPage() { const lastBudgetLoadAtRef = useRef(0); const ctoDisplayName = "CTO"; - const laneId = useMemo(() => { - if (selectedLaneId && lanes.some((lane) => lane.id === selectedLaneId)) return selectedLaneId; - return lanes.find((lane) => lane.laneType === "primary")?.id ?? lanes[0]?.id ?? null; - }, [lanes, selectedLaneId]); + const primaryLaneId = useMemo(() => resolveCtoPrimaryLaneId(lanes), [lanes]); const selectedWorker = useMemo( () => (selectedAgentId ? agents.find((a) => a.id === selectedAgentId) ?? null : null), @@ -294,18 +291,18 @@ export function CtoPage() { setSession(null); return; } - if (!laneId) { setSession(null); return; } + if (!primaryLaneId) { setSession(null); return; } let cancelled = false; setLoading(true); setError(null); const promise = selectedAgentId - ? window.ade.cto.ensureAgentSession({ agentId: selectedAgentId, laneId }) - : window.ade.cto.ensureSession({ laneId }); + ? window.ade.cto.ensureAgentSession({ agentId: selectedAgentId }) + : window.ade.cto.ensureSession(); void promise .then((next) => { if (!cancelled) setSession(next); }) .catch((err) => { if (!cancelled) { setError(err instanceof Error ? err.message : String(err)); setSession(null); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [activeTab, laneId, needsOnboarding, onboardingState, selectedAgentId, showOnboarding]); + }, [activeTab, needsOnboarding, onboardingState, primaryLaneId, selectedAgentId, showOnboarding]); // Deep links for guided setup flows useEffect(() => { @@ -333,15 +330,15 @@ export function CtoPage() { /* ── Callbacks ── */ const refreshPersistentCtoSession = useCallback(async () => { - if (!window.ade?.cto || !laneId || showOnboarding || needsOnboarding) { + if (!window.ade?.cto || !primaryLaneId || showOnboarding || needsOnboarding) { return null; } - const next = await window.ade.cto.ensureSession({ laneId }); + const next = await window.ade.cto.ensureSession(); if (!selectedAgentId) { setSession(next); } return next; - }, [laneId, needsOnboarding, selectedAgentId, showOnboarding]); + }, [needsOnboarding, primaryLaneId, selectedAgentId, showOnboarding]); const handleSaveCoreMemory = useCallback(async (patch: Record) => { if (!window.ade?.cto) throw new Error("CTO bridge unavailable."); @@ -682,9 +679,9 @@ export function CtoPage() {
{loading &&
Connecting persistent session...
} {error &&
{error}
} - {!laneId && ( + {!primaryLaneId && (
- Create a lane to start the persistent CTO session. + ADE could not resolve the primary workspace for the persistent CTO session.
)} @@ -717,10 +714,12 @@ export function CtoPage() {
) : ( )} @@ -863,7 +862,7 @@ export function CtoPage() { {/* Linear tab */} {activeTab === "workflows" && (
- +
)} diff --git a/apps/desktop/src/renderer/components/cto/ctoSessionViewState.test.ts b/apps/desktop/src/renderer/components/cto/ctoSessionViewState.test.ts new file mode 100644 index 000000000..196ebc99a --- /dev/null +++ b/apps/desktop/src/renderer/components/cto/ctoSessionViewState.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { resolveCtoPrimaryLaneId } from "./ctoSessionViewState"; + +describe("resolveCtoPrimaryLaneId", () => { + it("prefers the primary lane even when another lane is selected elsewhere in the app", () => { + expect(resolveCtoPrimaryLaneId([ + { id: "lane-feature", laneType: "worktree" }, + { id: "lane-primary", laneType: "primary" }, + ])).toBe("lane-primary"); + }); + + it("falls back to the first lane when a primary lane has not been materialized yet", () => { + expect(resolveCtoPrimaryLaneId([ + { id: "lane-feature", laneType: "worktree" }, + { id: "lane-bugfix", laneType: "worktree" }, + ])).toBe("lane-feature"); + }); + + it("returns null when no lanes are available", () => { + expect(resolveCtoPrimaryLaneId([])).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/cto/ctoSessionViewState.ts b/apps/desktop/src/renderer/components/cto/ctoSessionViewState.ts new file mode 100644 index 000000000..e4549c6d3 --- /dev/null +++ b/apps/desktop/src/renderer/components/cto/ctoSessionViewState.ts @@ -0,0 +1,3 @@ +export function resolveCtoPrimaryLaneId(lanes: Array<{ id: string; laneType?: string | null }>): string | null { + return lanes.find((lane) => lane.laneType === "primary")?.id ?? lanes[0]?.id ?? null; +} diff --git a/apps/desktop/src/shared/types/agents.ts b/apps/desktop/src/shared/types/agents.ts index d8e80e75f..8724b4f6e 100644 --- a/apps/desktop/src/shared/types/agents.ts +++ b/apps/desktop/src/shared/types/agents.ts @@ -337,10 +337,8 @@ export type CtoRollbackAgentRevisionArgs = { export type CtoEnsureAgentSessionArgs = { agentId: string; - laneId?: string | null; modelId?: ModelId | null; reasoningEffort?: string | null; - taskKey?: string | null; }; export type CtoListAgentTaskSessionsArgs = { diff --git a/apps/desktop/src/shared/types/cto.ts b/apps/desktop/src/shared/types/cto.ts index 5eac4e04c..401adc7a1 100644 --- a/apps/desktop/src/shared/types/cto.ts +++ b/apps/desktop/src/shared/types/cto.ts @@ -91,7 +91,6 @@ export type CtoGetStateArgs = { }; export type CtoEnsureSessionArgs = { - laneId?: string | null; modelId?: ModelId | null; reasoningEffort?: string | null; }; From 05bd5c1647e269db16673782e20bc17d867a3164 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:03:16 -0400 Subject: [PATCH 2/5] Cover non-identity fallback in identity session policy Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/services/chat/identitySessionPolicy.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts index 4bfd2e449..5c046309d 100644 --- a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts @@ -16,4 +16,10 @@ describe("identitySessionPolicy", () => { expect(resolveIdentityExecutionLane("cto", "lane-feature", "lane-primary")).toBe("lane-primary"); expect(resolveIdentityExecutionLane("agent:worker-1", "lane-feature", "lane-primary")).toBe("lane-primary"); }); + + it("falls back to plan/guarded mode for non-identity sessions", () => { + expect(normalizeIdentityPermissionMode(undefined, "plan", "claude")).toBe("plan"); + expect(normalizeIdentityPermissionMode(undefined, "full-auto", "claude")).toBe("plan"); + expect(normalizeIdentityPermissionMode(undefined, undefined, "codex")).toBe("plan"); + }); }); From 20bc2a58e266f18beac3e463a33941e2022ba1dd Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:33:36 -0400 Subject: [PATCH 3/5] Enforce strict primary-lane + full-auto policy on identity sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit/Greptile/capy-ai/Copilot review on #179. - registerIpc.ts / syncRemoteCommandService.ts: replace the lanes[0] fallback paths with resolvePrimaryLaneIdOnly(...) strict resolvers for cto/agent identity ensure-session flows — no primary lane means throw "No primary lane…", not silently bind to a foreign lane. - agentChatService.ts: resolvePrimaryIdentityLane drops the ?? lanes[0] fallback. createSession strips claudePermissionMode, codexApprovalPolicy, codexSandbox, codexConfigSource, opencodePermissionMode, and interactionMode before nativePermissionFields runs when the identity is pinned, so full-auto derivation wins. updateSession gates the same fields behind !identityPinned so stricter native modes can't layer on over IPC. resumeSession detects a pinned-identity session row whose laneId drifted to a foreign lane, rewrites it via sessionService and re-hydrates before spinning up runtime. - sessionService.updateMeta / UpdateSessionMetaArgs: typed laneId field so the resume-time migration can actually persist. - identitySessionPolicy.ts: isPrimaryPinnedIdentity requires a non-empty trimmed agent: suffix. guardedIdentityPermissionModeForProvider un-exported (module-local only). resolveIdentityExecutionLane params widened to string | null | undefined / string | null to match usage. - CtoPage.tsx: "primary workspace" fallback copy → "primary lane" to match main-process error strings. - .gitignore: anchor .asc/artifacts/ with leading / to avoid matching nested paths. Tests added in identitySessionPolicy.test.ts, agentChatService.test.ts, syncRemoteCommandService.test.ts; all affected test files pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 +- .../services/chat/agentChatService.test.ts | 82 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 76 +++++++++++++---- .../chat/identitySessionPolicy.test.ts | 23 ++++++ .../services/chat/identitySessionPolicy.ts | 18 ++-- .../src/main/services/ipc/registerIpc.ts | 19 +++-- .../main/services/sessions/sessionService.ts | 8 ++ .../sync/syncRemoteCommandService.test.ts | 28 +++++++ .../services/sync/syncRemoteCommandService.ts | 19 +++-- .../src/renderer/components/cto/CtoPage.tsx | 2 +- apps/desktop/src/shared/types/sessions.ts | 6 ++ 11 files changed, 240 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 333cafaea..705f05441 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,7 @@ xcuserdata/ apps/ios/.dry-run-derived-data/ apps/ios/build/ ios-signing/ -.asc/artifacts/ +/.asc/artifacts/ # Tool configs (personal) .codex/ diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 057ef2924..26d70069f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2015,6 +2015,88 @@ describe("createAgentChatService", () => { expect(session.permissionMode).toBe("full-auto"); expect(sessionService.setHeadShaStart).toHaveBeenLastCalledWith(session.id, "lane-1-sha"); }); + + it("ignores native provider permission overrides for pinned identities on create", async () => { + const { service } = createService(); + // Callers over IPC could previously pass through `claudePermissionMode: + // "plan"` to keep a CTO session from ever running automatically — the + // identity pin must strip these so full-auto still wins. + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-7", + modelId: "claude-sonnet-4-7", + identityKey: "cto", + claudePermissionMode: "plan", + interactionMode: "plan", + }); + + // `plan` must never be persisted on the claude native fields for a + // pinned identity — otherwise the runtime will ignore full-auto at turn + // start. We do not assert on the synthesized permissionMode here because + // the top-level mock for mapPermissionToClaude collapses to "plan" in + // this test file; the native fields are the real source of truth the + // runtime consults. + expect(session.claudePermissionMode).not.toBe("plan"); + expect(session.interactionMode).not.toBe("plan"); + }); + + it("ignores native codex permission overrides for worker identities on create", async () => { + // Locally map modes so full-auto => danger-full-access / never and the + // default mapping (used when no permissionMode is passed) stays on the + // on-request / read-only baseline. This lets us prove the IPC-provided + // `codexApprovalPolicy: "untrusted"` / `codexSandbox: "read-only"` never + // land on the session — the full-auto derivation is used instead. + vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { + if (mode === "full-auto") return { approvalPolicy: "never", sandbox: "danger-full-access" }; + if (mode === "edit") return { approvalPolicy: "untrusted", sandbox: "workspace-write" }; + return { approvalPolicy: "on-request", sandbox: "read-only" }; + }); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5-codex", + modelId: "gpt-5-codex", + identityKey: "agent:worker-1", + codexApprovalPolicy: "untrusted", + codexSandbox: "read-only", + }); + + expect(session.codexApprovalPolicy).toBe("never"); + expect(session.codexSandbox).toBe("danger-full-access"); + }); + + it("ignores native permission overrides for pinned identities on update", async () => { + const { service } = createService(); + const session = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-1", + }); + const claudeBefore = session.claudePermissionMode; + const opencodeBefore = session.opencodePermissionMode; + + const updated = await service.updateSession({ + sessionId: session.id, + claudePermissionMode: "plan", + interactionMode: "plan", + codexApprovalPolicy: "untrusted", + codexSandbox: "read-only", + opencodePermissionMode: "plan", + }); + + // None of the stricter native modes should have landed on the session. + expect(updated.interactionMode).not.toBe("plan"); + if (claudeBefore !== undefined) { + expect(updated.claudePermissionMode).toBe(claudeBefore); + } + if (opencodeBefore !== undefined) { + expect(updated.opencodePermissionMode).toBe(opencodeBefore); + } + expect(updated.codexApprovalPolicy).not.toBe("untrusted"); + expect(updated.codexSandbox).not.toBe("read-only"); + }); }); describe("identity continuity", () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 9a0847fec..3e98d556e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -38,6 +38,7 @@ import { type BufferedAssistantText, } from "./chatTextBatching"; import { + isPrimaryPinnedIdentity, normalizeIdentityPermissionMode, resolveIdentityExecutionLane, } from "./identitySessionPolicy"; @@ -4784,9 +4785,12 @@ export function createAgentChatService(args: { const resolvePrimaryIdentityLane = async (): Promise => { await laneService.ensurePrimaryLane?.().catch(() => {}); const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); - const primary = lanes.find((lane) => lane.laneType === "primary") ?? lanes[0] ?? null; + // Identity sessions (CTO + worker agents) must pin to the actual primary + // lane. Never fall back to lanes[0] — that would silently land the + // identity on a foreign lane, defeating the whole contract. + const primary = lanes.find((lane) => lane.laneType === "primary") ?? null; if (!primary?.id) { - throw new Error("No lane is available to host the canonical identity chat session."); + throw new Error("No primary lane is available to host the canonical identity chat session."); } return primary.id; }; @@ -10372,11 +10376,22 @@ export function createAgentChatService(args: { : undefined; const normalizedCursorConfigValues = normalizeCursorConfigValueRecord(requestedCursorConfigValues); const capabilityMode = inferCapabilityMode(effectiveProvider); + // Identity-pinned sessions (CTO + worker agents) are locked to full-auto. + // Discard the native provider permission overrides supplied by callers so + // they cannot smuggle `plan` / `ask` / read-only sandboxes past the lock + // via claudePermissionMode / codexApprovalPolicy / codexSandbox / + // opencodePermissionMode. + const identityPinned = isPrimaryPinnedIdentity(identityKey); + const effectiveInteractionMode = identityPinned ? undefined : requestedInteractionMode; + const effectiveClaudePermissionMode = identityPinned ? undefined : requestedClaudePermissionMode; + const effectiveCodexApprovalPolicy = identityPinned ? undefined : requestedCodexApprovalPolicy; + const effectiveCodexSandbox = identityPinned ? undefined : requestedCodexSandbox; + const effectiveCodexConfigSource = identityPinned ? undefined : requestedCodexConfigSource; let effectivePermissionMode = identityKey ? normalizeIdentityPermissionMode(identityKey, requestedPermMode, effectiveProvider) : requestedPermMode; const chatConfig = resolveChatConfig(); - let requestedOpenCodePermissionMode = requestedOpenCodePermissionModeArg; + let requestedOpenCodePermissionMode = identityPinned ? undefined : requestedOpenCodePermissionModeArg; const localHarnessPermissions = applyLocalHarnessPermissionMode({ descriptor: resolvedDescriptor, requestedPermissionMode: effectivePermissionMode, @@ -10387,14 +10402,14 @@ export function createAgentChatService(args: { const nativePermissionFields = (() => { if (effectiveProvider === "claude") { - const interactionMode = requestedInteractionMode - ?? (requestedClaudePermissionMode === "plan" ? "plan" : undefined) + const interactionMode = effectiveInteractionMode + ?? (effectiveClaudePermissionMode === "plan" ? "plan" : undefined) ?? (effectivePermissionMode === "plan" ? "plan" : undefined) ?? (chatConfig.claudePermissionMode === "plan" ? "plan" : undefined) ?? "default"; - const claudePermissionMode = requestedClaudePermissionMode + const claudePermissionMode = effectiveClaudePermissionMode ? resolveSessionClaudeAccessMode( - { claudePermissionMode: requestedClaudePermissionMode, permissionMode: undefined }, + { claudePermissionMode: effectiveClaudePermissionMode, permissionMode: undefined }, chatConfig.claudePermissionMode, ) : resolveSessionClaudeAccessMode( @@ -10404,17 +10419,17 @@ export function createAgentChatService(args: { return { interactionMode, claudePermissionMode }; } if (effectiveProvider === "codex") { - const codexConfigSource = requestedCodexConfigSource + const codexConfigSource = effectiveCodexConfigSource ?? legacyPermissionModeToCodexConfigSource(effectivePermissionMode) ?? "flags"; if (codexConfigSource === "config-toml") { return { codexConfigSource }; } return { - codexApprovalPolicy: requestedCodexApprovalPolicy + codexApprovalPolicy: effectiveCodexApprovalPolicy ?? legacyPermissionModeToCodexApprovalPolicy(effectivePermissionMode) ?? chatConfig.codexApprovalPolicy, - codexSandbox: requestedCodexSandbox + codexSandbox: effectiveCodexSandbox ?? legacyPermissionModeToCodexSandbox(effectivePermissionMode) ?? chatConfig.codexSandboxMode, codexConfigSource, @@ -12293,7 +12308,21 @@ export function createAgentChatService(args: { }; const resumeSession = async ({ sessionId }: { sessionId: string }): Promise => { - const managed = ensureManagedSession(sessionId); + let managed = ensureManagedSession(sessionId); + + // Identity-pinned sessions (CTO + worker agents) must always run on the + // canonical primary lane. If a persisted row points at a foreign lane + // (e.g. pre-pinning session, or the previous primary was archived), + // migrate it to the canonical lane before we spin up a runtime. + if (isPrimaryPinnedIdentity(managed.session.identityKey) && managed.session.laneId) { + const canonicalLaneId = await resolvePrimaryIdentityLane(); + if (canonicalLaneId && managed.session.laneId !== canonicalLaneId) { + sessionService.updateMeta({ sessionId, laneId: canonicalLaneId }); + managedSessions.delete(sessionId); + managed = ensureManagedSession(sessionId); + } + } + refreshManagedLaneLaunchContext(managed, { purpose: "resume this chat" }); const persisted = readPersistedState(sessionId); managed.session.capabilityMode = managed.session.capabilityMode ?? inferCapabilityMode(managed.session.provider); @@ -12563,6 +12592,14 @@ export function createAgentChatService(args: { const preferred = canonicalExisting; if (preferred) { + // Defensive guard: if the selected canonical session ever drifted onto + // a foreign lane (e.g. from a legacy DB write), rewrite it to the + // canonical lane before we hydrate/resume so identity sessions never + // run on a non-primary lane. + if (preferred.laneId !== canonicalLaneId && isPrimaryPinnedIdentity(args.identityKey)) { + sessionService.updateMeta({ sessionId: preferred.sessionId, laneId: canonicalLaneId }); + managedSessions.delete(preferred.sessionId); + } const managed = ensureManagedSession(preferred.sessionId); managed.session.identityKey = args.identityKey; managed.session.capabilityMode = inferCapabilityMode(managed.session.provider); @@ -13270,6 +13307,7 @@ export function createAgentChatService(args: { const managed = ensureManagedSession(sessionId); const chatConfig = resolveChatConfig(); const isIdentitySession = Boolean(managed.session.identityKey); + const identityPinned = isPrimaryPinnedIdentity(managed.session.identityKey); const hasConversation = managed.recentConversationEntries.length > 0 || readTranscriptConversationEntries(managed).length > 0; const prevCodexApprovalPolicy = managed.session.codexApprovalPolicy; const prevCodexSandbox = managed.session.codexSandbox; @@ -13397,11 +13435,15 @@ export function createAgentChatService(args: { applyLegacyPermissionModeToNativeControls(managed.session, managed.session.permissionMode); } - if (interactionMode !== undefined) { + // Identity-pinned sessions (CTO + worker agents) are locked to full-auto. + // Ignore incoming native permission overrides — applyLegacyPermissionMode- + // ToNativeControls() has already derived the correct native fields from + // full-auto, and we must not let callers layer a stricter mode on top. + if (interactionMode !== undefined && !identityPinned) { managed.session.interactionMode = interactionMode; } - if (claudePermissionMode !== undefined) { + if (claudePermissionMode !== undefined && !identityPinned) { if (claudePermissionMode === "plan") { managed.session.interactionMode = "plan"; } else { @@ -13409,19 +13451,19 @@ export function createAgentChatService(args: { } } - if (codexApprovalPolicy !== undefined) { + if (codexApprovalPolicy !== undefined && !identityPinned) { managed.session.codexApprovalPolicy = codexApprovalPolicy; } - if (codexSandbox !== undefined) { + if (codexSandbox !== undefined && !identityPinned) { managed.session.codexSandbox = codexSandbox; } - if (codexConfigSource !== undefined) { + if (codexConfigSource !== undefined && !identityPinned) { managed.session.codexConfigSource = codexConfigSource; } - if (opencodePermissionMode !== undefined) { + if (opencodePermissionMode !== undefined && !identityPinned) { managed.session.opencodePermissionMode = opencodePermissionMode; } diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts index 5c046309d..b594c504b 100644 --- a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isPrimaryPinnedIdentity, normalizeIdentityPermissionMode, resolveIdentityExecutionLane, } from "./identitySessionPolicy"; @@ -22,4 +23,26 @@ describe("identitySessionPolicy", () => { expect(normalizeIdentityPermissionMode(undefined, "full-auto", "claude")).toBe("plan"); expect(normalizeIdentityPermissionMode(undefined, undefined, "codex")).toBe("plan"); }); + + it("treats empty or whitespace-only agent suffixes as non-pinned", () => { + expect(isPrimaryPinnedIdentity("cto")).toBe(true); + expect(isPrimaryPinnedIdentity("agent:worker-1")).toBe(true); + // Cast through unknown so the test can probe malformed identity keys that + // ideally should never reach the helper but still could arrive via IPC. + expect(isPrimaryPinnedIdentity("agent:" as never)).toBe(false); + expect(isPrimaryPinnedIdentity("agent: " as never)).toBe(false); + expect(isPrimaryPinnedIdentity(undefined)).toBe(false); + + // Pinned-identity pathways should fall through to the guarded default for + // malformed agent suffixes so a caller cannot smuggle full-auto in by + // passing `agent: `. + expect(normalizeIdentityPermissionMode("agent: " as never, undefined, "claude")).toBe("plan"); + expect(normalizeIdentityPermissionMode("agent:" as never, "plan", "codex")).toBe("plan"); + }); + + it("returns the canonical lane (including null) for pinned identities", () => { + expect(resolveIdentityExecutionLane("cto", undefined, "lane-primary")).toBe("lane-primary"); + expect(resolveIdentityExecutionLane("cto", null, "lane-primary")).toBe("lane-primary"); + expect(resolveIdentityExecutionLane("cto", "lane-feature", null)).toBe(null); + }); }); diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.ts index 896d868c8..f269497cf 100644 --- a/apps/desktop/src/main/services/chat/identitySessionPolicy.ts +++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.ts @@ -1,11 +1,16 @@ import type { AgentChatIdentityKey, AgentChatProvider, AgentChatSession } from "../../../shared/types"; -export function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] { +function guardedIdentityPermissionModeForProvider(_provider: AgentChatProvider): AgentChatSession["permissionMode"] { return "plan"; } -function isPrimaryPinnedIdentity(identityKey: AgentChatIdentityKey | undefined): boolean { - return identityKey === "cto" || Boolean(identityKey?.startsWith("agent:")); +export function isPrimaryPinnedIdentity(identityKey: AgentChatIdentityKey | undefined): boolean { + if (identityKey === "cto") return true; + if (!identityKey || !identityKey.startsWith("agent:")) return false; + // Require a non-empty trimmed suffix so `agent:` and `agent: ` do not + // masquerade as a worker identity. Matches the stricter checks in + // resolveWorkerIdentityAgentId / normalizeIdentityKey. + return identityKey.slice("agent:".length).trim().length > 0; } export function normalizeIdentityPermissionMode( @@ -21,11 +26,12 @@ export function normalizeIdentityPermissionMode( export function resolveIdentityExecutionLane( identityKey: AgentChatIdentityKey, - requestedLaneId: string, - canonicalLaneId: string, + requestedLaneId: string | null | undefined, + canonicalLaneId: string | null, ): string | null { if (isPrimaryPinnedIdentity(identityKey)) { return canonicalLaneId; } - return requestedLaneId || null; + const trimmedRequested = typeof requestedLaneId === "string" ? requestedLaneId.trim() : ""; + return trimmedRequested.length ? trimmedRequested : null; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e81f2f47a..a231b2e82 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1180,15 +1180,16 @@ function getMemoryHealthStats(ctx: AppContext) { return stats; } -async function resolveFirstAvailableLaneId( - ctx: AppContext, - requestedLaneId: string | undefined | null -): Promise { - const laneId = typeof requestedLaneId === "string" ? requestedLaneId.trim() : ""; - if (laneId) return laneId; +/** + * Strict resolver for identity-pinned sessions (CTO + worker agents). Requires + * an actual primary lane and never slips a foreign lane through via a + * `lanes[0]` fallback — if there is no primary lane the caller must surface + * the error rather than silently landing the identity on a non-primary lane. + */ +async function resolvePrimaryLaneIdOnly(ctx: AppContext): Promise { await ctx.laneService.ensurePrimaryLane().catch(() => {}); const lanes = await ctx.laneService.list({ includeArchived: false, includeStatus: false }); - return (lanes.find((lane) => lane.laneType === "primary") ?? lanes[0])?.id ?? ""; + return lanes.find((lane) => lane.laneType === "primary")?.id ?? ""; } async function resolveLaneOverlayContext(ctx: AppContext, laneId: string) { @@ -6272,7 +6273,7 @@ export function registerIpc({ ipcMain.handle(IPC.ctoEnsureSession, async (_event, arg: CtoEnsureSessionArgs = {}): Promise => { const ctx = getCtx(); - const laneId = await resolveFirstAvailableLaneId(ctx, null); + const laneId = await resolvePrimaryLaneIdOnly(ctx); if (!laneId) { throw new Error("No primary lane is available to host the CTO chat session."); } @@ -6344,7 +6345,7 @@ export function registerIpc({ ipcMain.handle(IPC.ctoEnsureAgentSession, async (_event, arg: CtoEnsureAgentSessionArgs): Promise => { const ctx = getCtx(); if (!ctx.agentChatService) throw new Error("Agent chat service is not available."); - const laneId = await resolveFirstAvailableLaneId(ctx, null); + const laneId = await resolvePrimaryLaneIdOnly(ctx); if (!laneId) throw new Error("No primary lane is available to host the agent chat session."); return ctx.agentChatService.ensureIdentitySession({ identityKey: `agent:${arg.agentId}`, diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 87f7e28d1..fae823408 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -335,6 +335,14 @@ export function createSessionService({ db }: { db: AdeDb }) { params.push(args.manuallyNamed ? 1 : 0); } + if (typeof args.laneId === "string") { + const nextLaneId = args.laneId.trim(); + if (nextLaneId.length) { + sets.push("lane_id = ?"); + params.push(nextLaneId); + } + } + if (args.title !== undefined) { const nextTitle = typeof args.title === "string" ? args.title.trim() : ""; if (nextTitle.length) { diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 47b52331e..121354c67 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -1795,6 +1795,18 @@ describe("createSyncRemoteCommandService", () => { .rejects.toThrow("No primary lane is available to host the CTO chat session."); }); + it("cto.ensureSession refuses to fall back to lanes[0] when no primary exists", async () => { + // Only non-primary lanes are available; identity-pinned sessions must + // not silently land on a foreign lane via the lanes[0] fallback. + laneService.list.mockResolvedValueOnce([ + { id: "lane-feature", laneType: "feature" }, + { id: "lane-scratch", laneType: "feature" }, + ]); + await expect(service.execute(makePayload("cto.ensureSession", {}))) + .rejects.toThrow("No primary lane is available to host the CTO chat session."); + expect(agentChatService.ensureIdentitySession).not.toHaveBeenCalled(); + }); + it("cto.ensureAgentSession requires agentId", async () => { await expect(service.execute(makePayload("cto.ensureAgentSession", {}))) .rejects.toThrow("cto.ensureAgentSession requires agentId."); @@ -1868,6 +1880,22 @@ describe("createSyncRemoteCommandService", () => { }))).rejects.toThrow("No primary lane is available to host the agent chat session."); }); + it("cto.ensureAgentSession refuses to fall back to lanes[0] when no primary exists", async () => { + laneService.list.mockResolvedValueOnce([ + { id: "lane-feature", laneType: "feature" }, + ]); + workerAgentService.getAgent.mockReturnValueOnce({ + id: "worker-42", + name: "Mobile Droid", + slug: "mobile-droid", + status: "running", + }); + await expect(service.execute(makePayload("cto.ensureAgentSession", { + agentId: "worker-42", + }))).rejects.toThrow("No primary lane is available to host the agent chat session."); + expect(agentChatService.ensureIdentitySession).not.toHaveBeenCalled(); + }); + it("cto.ensureSession returns the same session on repeat calls (canonical lane reuse)", async () => { // Both calls resolve the same primary lane; ensureIdentitySession is a // mock that always returns the same session id, so the handler must diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 414f75624..c7d14d872 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1242,15 +1242,16 @@ function applyLeaseToOverrides( }; } -async function resolveFirstAvailableLaneIdForSync( - args: SyncRemoteCommandServiceArgs, - requestedLaneId: string | null, -): Promise { - const laneId = typeof requestedLaneId === "string" ? requestedLaneId.trim() : ""; - if (laneId) return laneId; +/** + * Strict resolver for identity-pinned sessions (CTO + worker agents). Never + * slips a foreign lane through via a `lanes[0]` fallback — if no primary lane + * exists, the caller must error out rather than silently host the identity on + * a non-primary lane. + */ +async function resolvePrimaryLaneIdOnlyForSync(args: SyncRemoteCommandServiceArgs): Promise { await args.laneService.ensurePrimaryLane?.().catch(() => {}); const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); - return (lanes.find((lane) => lane.laneType === "primary") ?? lanes[0])?.id ?? ""; + return lanes.find((lane) => lane.laneType === "primary")?.id ?? ""; } async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) { @@ -1720,7 +1721,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg }); register("cto.ensureSession", { viewerAllowed: true }, async (payload) => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const laneId = await resolveFirstAvailableLaneIdForSync(args, null); + const laneId = await resolvePrimaryLaneIdOnlyForSync(args); if (!laneId) throw new Error("No primary lane is available to host the CTO chat session."); const modelId = asTrimmedString(payload.modelId); const reasoningEffort = asTrimmedString(payload.reasoningEffort); @@ -1746,7 +1747,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg if (!agent) { throw new Error(`cto.ensureAgentSession: unknown agentId '${agentId}'`); } - const laneId = await resolveFirstAvailableLaneIdForSync(args, null); + const laneId = await resolvePrimaryLaneIdOnlyForSync(args); if (!laneId) throw new Error("No primary lane is available to host the agent chat session."); const modelId = asTrimmedString(payload.modelId); const reasoningEffort = asTrimmedString(payload.reasoningEffort); diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx index 4f9351b6d..e9314b949 100644 --- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx @@ -681,7 +681,7 @@ export function CtoPage() { {error &&
{error}
} {!primaryLaneId && (
- ADE could not resolve the primary workspace for the persistent CTO session. + ADE could not resolve the primary lane for the persistent CTO session.
)} diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index fe94fd37c..4c46a6733 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -144,6 +144,12 @@ export type UpdateSessionMetaArgs = { toolType?: TerminalToolType | null; resumeCommand?: string | null; resumeMetadata?: TerminalResumeMetadata | null; + /** + * Migrate the session to a different lane. Used by identity sessions (CTO / + * worker) that must remain pinned to the canonical primary lane even if + * they were previously persisted against a foreign lane. + */ + laneId?: string; }; export type ReadTranscriptTailArgs = { From 495b64cbe3853bdcb22abc527c3e6492714348a3 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:55:58 -0400 Subject: [PATCH 4/5] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20address?= =?UTF-8?q?=20copilot=20follow-ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/services/chat/agentChatService.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 3e98d556e..2c5469745 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -12592,14 +12592,11 @@ export function createAgentChatService(args: { const preferred = canonicalExisting; if (preferred) { - // Defensive guard: if the selected canonical session ever drifted onto - // a foreign lane (e.g. from a legacy DB write), rewrite it to the - // canonical lane before we hydrate/resume so identity sessions never - // run on a non-primary lane. - if (preferred.laneId !== canonicalLaneId && isPrimaryPinnedIdentity(args.identityKey)) { - sessionService.updateMeta({ sessionId: preferred.sessionId, laneId: canonicalLaneId }); - managedSessions.delete(preferred.sessionId); - } + // `canonicalExisting` is already filtered to `entry.laneId === canonicalLaneId`, + // so `preferred` is guaranteed to be on the canonical lane here — no + // migration guard needed. Foreign-lane legacy sessions are left untouched + // (see the `does not reuse a foreign-lane identity session` test); a + // fresh canonical session will be created below when none is found. const managed = ensureManagedSession(preferred.sessionId); managed.session.identityKey = args.identityKey; managed.session.capabilityMode = inferCapabilityMode(managed.session.provider); From cba27202dd79242b59889de4fc022b1716b0f5f2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:56:08 -0400 Subject: [PATCH 5/5] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20address?= =?UTF-8?q?=20copilot=20follow-ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/services/chat/identitySessionPolicy.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts index b594c504b..a7f79bc7c 100644 --- a/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/identitySessionPolicy.test.ts @@ -45,4 +45,10 @@ describe("identitySessionPolicy", () => { expect(resolveIdentityExecutionLane("cto", null, "lane-primary")).toBe("lane-primary"); expect(resolveIdentityExecutionLane("cto", "lane-feature", null)).toBe(null); }); + + it("passes through requested lanes for non-pinned identities", () => { + expect(resolveIdentityExecutionLane("assistant" as never, "lane-feature", "lane-primary")).toBe("lane-feature"); + expect(resolveIdentityExecutionLane("assistant" as never, " lane-feature ", "lane-primary")).toBe("lane-feature"); + expect(resolveIdentityExecutionLane("assistant" as never, " ", "lane-primary")).toBe(null); + }); });