diff --git a/src/controller.test.ts b/src/controller.test.ts index af9973e..1e9f635 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -2423,7 +2423,16 @@ describe("Discord controller flows", () => { }); it("renders saved conversation preferences in cas_status even if thread reads lag behind", async () => { - const { controller, sendMessageTelegram } = await createControllerHarness(); + const { controller, clientMock, sendMessageTelegram } = await createControllerHarness(); + clientMock.readThreadState.mockResolvedValue({ + threadId: "thread-1", + threadName: "Discord Thread", + model: "openai/gpt-5.4", + cwd: "/repo/openclaw", + serviceTier: "default", + approvalPolicy: "never", + sandbox: "danger-full-access", + }); await (controller as any).store.upsertBinding({ conversation: { channel: "telegram", @@ -2459,6 +2468,53 @@ describe("Discord controller flows", () => { expect(text).toContain("Permissions: Full Access"); }); + it("refreshes stored default mode from the live thread state in cas_status", async () => { + const { controller, clientMock, sendMessageTelegram } = await createControllerHarness(); + clientMock.readThreadState.mockResolvedValue({ + threadId: "thread-1", + threadName: "Discord Thread", + model: "openai/gpt-5.4", + cwd: "/repo/openclaw", + serviceTier: "default", + approvalPolicy: "on-request", + sandbox: "workspace-write", + }); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "123", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + permissionsMode: "full-access", + updatedAt: Date.now(), + }); + + const reply = await controller.handleCommand( + "cas_status", + buildTelegramCommandContext({ + commandBody: "/cas_status", + getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })), + }), + ); + + expect(reply).toEqual({}); + const binding = (controller as any).store.getBinding({ + channel: "telegram", + accountId: "default", + conversationId: "123", + }); + expect(binding?.permissionsMode).toBe("default"); + const firstCall = sendMessageTelegram.mock.calls[0] as unknown as [string, string] | undefined; + const text = firstCall?.[1] ?? ""; + expect(text).toContain("Permissions: Default"); + expect(text).toContain( + "Permissions note: refreshed the stored mode from the live Default thread state.", + ); + }); + it("sends the status card directly to Discord with interactive controls", async () => { const { controller, sendComponentMessage } = await createControllerHarness(); await (controller as any).store.upsertBinding({ @@ -5075,7 +5131,16 @@ describe("Discord controller flows", () => { }); it("passes saved conversation preferences into the next Codex turn", async () => { - const { controller } = await createControllerHarness(); + const { controller, clientMock } = await createControllerHarness(); + clientMock.readThreadState.mockResolvedValue({ + threadId: "thread-1", + threadName: "Discord Thread", + model: "openai/gpt-5.4", + cwd: "/repo/openclaw", + serviceTier: "default", + approvalPolicy: "never", + sandbox: "danger-full-access", + }); const startTurn = vi.fn(() => ({ result: Promise.resolve({ threadId: "thread-1", @@ -5753,6 +5818,73 @@ describe("Discord controller flows", () => { ); }); + it("keeps the live full-access mode when a downgrade request does not stick", async () => { + const { controller, clientMock } = await createControllerHarness(); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "123", + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + permissionsMode: "full-access", + updatedAt: Date.now(), + }); + const editMessage = vi.fn(async (_payload: any) => {}); + clientMock.readThreadState.mockResolvedValue({ + threadId: "thread-1", + threadName: "Discord Thread", + model: "openai/gpt-5.4", + cwd: "/repo/openclaw", + serviceTier: "default", + approvalPolicy: "never", + sandbox: "danger-full-access", + }); + clientMock.setThreadPermissions.mockResolvedValueOnce({ + threadId: "thread-1", + threadName: "Discord Thread", + model: "openai/gpt-5.4", + cwd: "/repo/openclaw", + serviceTier: "default", + approvalPolicy: "never", + sandbox: "danger-full-access", + }); + + const callback = await (controller as any).store.putCallback({ + kind: "toggle-permissions", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "123", + }, + }); + await controller.handleTelegramInteractive({ + channel: "telegram", + accountId: "default", + conversationId: "123", + callback: { payload: callback.token }, + respond: { + clearButtons: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + editMessage, + }, + } as any); + + const binding = (controller as any).store.getBinding({ + channel: "telegram", + accountId: "default", + conversationId: "123", + }); + expect(binding?.permissionsMode).toBe("full-access"); + expect(editMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + text: expect.stringContaining("Permissions: Full Access"), + }), + ); + }); + it("stores permissions mode as a pending default before the thread is materialized", async () => { const { controller, clientMock } = await createControllerHarness(); clientMock.readThreadState.mockRejectedValue( diff --git a/src/controller.ts b/src/controller.ts index 4ba6fb1..ad8f88a 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -963,6 +963,23 @@ function normalizePermissionsMode(value?: string | null): PermissionsMode { return value === "full-access" ? "full-access" : "default"; } +function inferPermissionsModeFromThreadState( + threadState: ThreadState | undefined, +): PermissionsMode | undefined { + const approval = threadState?.approvalPolicy?.trim(); + const sandbox = threadState?.sandbox?.trim(); + if (!approval && !sandbox) { + return undefined; + } + return approval === "never" && sandbox === "danger-full-access" + ? "full-access" + : "default"; +} + +function describePermissionsMode(profile: PermissionsMode): string { + return profile === "full-access" ? "Full Access" : "Default"; +} + function getBindingPermissionsMode(binding: StoredBinding | null): PermissionsMode { return normalizePermissionsMode(binding?.permissionsMode); } @@ -2403,6 +2420,33 @@ export class CodexPluginController { ); } + private async syncBindingPermissionsModeFromThreadState( + binding: StoredBinding, + threadState: ThreadState | undefined, + context: string, + ): Promise<{ binding: StoredBinding; note?: string }> { + const liveProfile = inferPermissionsModeFromThreadState(threadState); + const storedProfile = this.getPermissionsMode(binding); + const pendingProfile = getBindingPendingPermissionsMode(binding); + if (!liveProfile || pendingProfile || liveProfile === storedProfile) { + return { binding }; + } + const nextBinding = await this.persistBindingPermissionsMode(binding, liveProfile); + const conversation: ConversationTarget = { + channel: binding.conversation.channel, + accountId: binding.conversation.accountId, + conversationId: binding.conversation.conversationId, + parentConversationId: binding.conversation.parentConversationId, + }; + this.api.logger.warn( + `codex refreshed binding permissions mode from live thread state ${this.formatConversationForLog(conversation)} stored=${storedProfile} live=${liveProfile} context=${context}`, + ); + return { + binding: nextBinding, + note: `Permissions note: refreshed the stored mode from the live ${describePermissionsMode(liveProfile)} thread state.`, + }; + } + private async reconcileThreadConfiguration( binding: StoredBinding, opts?: { @@ -6204,9 +6248,10 @@ export class CodexPluginController { threadId: binding.threadId, }), ); + const liveProfile = inferPermissionsModeFromThreadState(state) ?? profile; const nextBinding: StoredBinding = { ...binding, - permissionsMode: profile, + permissionsMode: liveProfile, pendingPermissionsMode: undefined, workspaceDir: state.cwd?.trim() || binding.workspaceDir, threadTitle: state.threadName?.trim() || binding.threadTitle, @@ -6602,6 +6647,15 @@ export class CodexPluginController { }).catch(() => []), this.resolveProjectFolder(binding?.workspaceDir || workspaceDir), ]); + const syncedBindingResult: { binding: StoredBinding | null; note?: string } = + binding && !activeRun + ? await this.syncBindingPermissionsModeFromThreadState( + binding, + threadState, + "render status", + ) + : { binding }; + binding = syncedBindingResult.binding; const effectiveThreadState = buildDesiredThreadConfiguration(threadState, binding).effectiveState; const displayThreadState = effectiveThreadState ?? @@ -6633,9 +6687,10 @@ export class CodexPluginController { planMode: bindingActive ? activeRun?.mode === "plan" : undefined, threadNote, permissionNote: - pendingProfile && activeRun + syncedBindingResult.note ?? + (pendingProfile && activeRun ? buildPendingPermissionsMigrationNote(pendingProfile) - : undefined, + : undefined), }); }