Skip to content
Open
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
136 changes: 134 additions & 2 deletions src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
61 changes: 58 additions & 3 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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?: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ??
Expand Down Expand Up @@ -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),
});
}

Expand Down