Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ xcuserdata/
apps/ios/.dry-run-derived-data/
apps/ios/build/
ios-signing/
.asc/artifacts/
/.asc/artifacts/

# Tool configs (personal)
.codex/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record<string
"This creates a full ADE chat with UI, streaming, tool approval, and service integration. " +
"Use this when the user asks for 'a chat' or 'an agent'. If they explicitly want a terminal or CLI tool, use createTerminal instead.",
inputSchema: z.object({
laneId: z.string().optional().describe("Lane to run in. Defaults to CTO's lane. A new lane is auto-created if needed."),
laneId: z.string().optional().describe("Lane to run in. Defaults to the primary lane. A new lane is auto-created if needed."),
modelId: z.string().optional().describe("Full model ID (e.g. 'anthropic/claude-sonnet-4-6'). MUST be set when user specifies a model."),
reasoningEffort: z.string().nullable().optional().describe("Reasoning effort: 'low', 'medium', 'high', 'max' (opus), 'xhigh' (openai)."),
title: z.string().optional().describe("Display title for the chat session."),
Expand Down
106 changes: 103 additions & 3 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1950,7 +1950,7 @@ describe("createAgentChatService", () => {
});

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 () => {
Expand Down Expand Up @@ -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: "",
Expand All @@ -1995,7 +1995,107 @@ 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");
});

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");
});
});

Expand Down
102 changes: 70 additions & 32 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ import {
shouldFlushBufferedAssistantTextForEvent,
type BufferedAssistantText,
} from "./chatTextBatching";
import {
isPrimaryPinnedIdentity,
normalizeIdentityPermissionMode,
resolveIdentityExecutionLane,
} from "./identitySessionPolicy";
import type { Logger } from "../logging/logger";
import type { createLaneService } from "../lanes/laneService";
import { resolveLaneLaunchContext, type LaneLaunchContext } from "../lanes/laneLaunchContext";
Expand Down Expand Up @@ -2416,17 +2421,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<AgentChatSession, "sessionProfile">): boolean {
return session.sessionProfile === "light";
}
Expand Down Expand Up @@ -4791,9 +4785,12 @@ export function createAgentChatService(args: {
const resolvePrimaryIdentityLane = async (): Promise<string> => {
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;
};
Expand Down Expand Up @@ -10379,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(requestedPermMode, effectiveProvider)
? normalizeIdentityPermissionMode(identityKey, requestedPermMode, effectiveProvider)
: requestedPermMode;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const chatConfig = resolveChatConfig();
let requestedOpenCodePermissionMode = requestedOpenCodePermissionModeArg;
let requestedOpenCodePermissionMode = identityPinned ? undefined : requestedOpenCodePermissionModeArg;
const localHarnessPermissions = applyLocalHarnessPermissionMode({
descriptor: resolvedDescriptor,
requestedPermissionMode: effectivePermissionMode,
Expand All @@ -10394,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(
Expand All @@ -10411,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,
Expand Down Expand Up @@ -12300,7 +12308,21 @@ export function createAgentChatService(args: {
};

const resumeSession = async ({ sessionId }: { sessionId: string }): Promise<AgentChatSession> => {
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 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

This migration only rewrites the session row’s host laneId, but the runtime chooses its execution lane from persisted selectors first. ts // apps/desktop/src/main/services/chat/agentChatService.ts if (canonicalLaneId && managed.session.laneId !== canonicalLaneId) { sessionService.updateMeta({ sessionId, laneId: canonicalLaneId }); managedSessions.delete(sessionId); managed = ensureManagedSession(sessionId); } ensureManagedSession() reloads preferredExecutionLaneId / selectedExecutionLaneId from the persisted chat state, and resolveManagedExecutionLaneId() prefers those fields over session.laneId, so a pre-pinning CTO/worker session that last ran on lane-2 will still resume and compute git state against lane-2 after restart. That breaks the new primary-lane pinning contract and can send automated work to the wrong branch. Normalize or clear the persisted execution-lane selectors for pinned identities during this migration before reloading the managed session.

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);
Expand Down Expand Up @@ -12554,7 +12576,11 @@ export function createAgentChatService(args: {
}

const canonicalLaneId = await resolvePrimaryIdentityLane();
const selectedExecutionLaneId = requestedLaneId || null;
const selectedExecutionLaneId = resolveIdentityExecutionLane(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This change now persists the canonical lane as the session’s default execution lane, not just the hosted lane:

// apps/desktop/src/main/services/chat/agentChatService.ts
const selectedExecutionLaneId = resolveIdentityExecutionLane(
  args.identityKey,
  requestedLaneId,
  canonicalLaneId,
);

runSessionTurn later resolves execution context through resolveManagedExecutionLaneId(), which prefers selectedExecutionLaneId, so existing active coverage in @apps/desktop/src/main/services/chat/agentChatService.test.ts:1657-1682 that still expects tools/system-prompt routing to lane-2 will now fail, and any caller with the same assumption will silently switch to lane-1. If full primary-lane execution is intentional, update those remaining tests and expectations in this PR; otherwise keep the hosted lane pinned without overwriting the per-turn execution default.

args.identityKey,
requestedLaneId,
canonicalLaneId,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const existing = await listSessions(undefined, { includeIdentity: true });
const identitySessions = existing
.filter((entry) => entry.identityKey === args.identityKey)
Expand All @@ -12566,13 +12592,19 @@ export function createAgentChatService(args: {

const preferred = canonicalExisting;
if (preferred) {
// `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);
if (args.reasoningEffort) {
managed.session.reasoningEffort = normalizeReasoningEffort(args.reasoningEffort);
}
managed.session.permissionMode = normalizeIdentityPermissionMode(
args.identityKey,
args.permissionMode ?? managed.session.permissionMode,
managed.session.provider,
);
Expand Down Expand Up @@ -12640,7 +12672,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
});

Expand Down Expand Up @@ -13272,6 +13304,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;
Expand Down Expand Up @@ -13338,6 +13371,7 @@ export function createAgentChatService(args: {

if (isIdentitySession) {
managed.session.permissionMode = normalizeIdentityPermissionMode(
managed.session.identityKey,
managed.session.permissionMode,
nextProvider,
);
Expand Down Expand Up @@ -13393,36 +13427,40 @@ 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 {
managed.session.claudePermissionMode = claudePermissionMode;
}
}

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;
}

Expand Down
Loading
Loading