Skip to content

Commit 234a3b6

Browse files
committed
Merge remote-tracking branch 'upstream/main' into fix/coalesce-channel-restart-status
2 parents 7c2160c + dcec31f commit 234a3b6

22 files changed

Lines changed: 885 additions & 191 deletions

apps/controller/src/lib/openclaw-config-compiler.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,11 +431,19 @@ function compilePlugins(
431431
...(hasMiniMaxOauth ? ["minimax-portal-auth"] : []),
432432
];
433433

434+
// Sort and dedup defensively so `plugins.allow` is fully deterministic.
435+
// Without this, channel reorderings or brief status flaps change the
436+
// output order, which OpenClaw treats as a config change and triggers
437+
// a SIGUSR1 restart + 11s gateway drain per reload.
438+
const allow = Array.from(
439+
new Set([...connectedPluginIds, ...platformPluginIds]),
440+
).sort();
441+
434442
return {
435443
load: {
436444
paths: [env.openclawExtensionsDir],
437445
},
438-
allow: [...connectedPluginIds, ...platformPluginIds],
446+
allow,
439447
entries: {
440448
feishu: {
441449
enabled: true,

apps/controller/src/services/openclaw-sync-service.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,24 +31,30 @@ function resolvePrimaryModelRef(
3131
oauthState: OAuthConnectionState,
3232
): string {
3333
const availableRuntimeModels = collectRuntimeModelRefs(compiled);
34+
const configuredProviderKeys = new Set(
35+
Object.keys(compiled.models?.providers ?? {}),
36+
);
3437

3538
if (typeof model === "string") {
3639
return resolveAvailableRuntimeModel(
3740
resolveModelId(config, env, model, oauthState),
3841
availableRuntimeModels,
42+
configuredProviderKeys,
3943
);
4044
}
4145

4246
if (model && typeof model.primary === "string") {
4347
return resolveAvailableRuntimeModel(
4448
resolveModelId(config, env, model.primary, oauthState),
4549
availableRuntimeModels,
50+
configuredProviderKeys,
4651
);
4752
}
4853

4954
return resolveAvailableRuntimeModel(
5055
resolveModelId(config, env, env.defaultModelId, oauthState),
5156
availableRuntimeModels,
57+
configuredProviderKeys,
5258
);
5359
}
5460

@@ -71,6 +77,7 @@ const OAUTH_PROVIDER_PREFIXES = ["openai-codex/"];
7177
function resolveAvailableRuntimeModel(
7278
desiredRef: string,
7379
availableRuntimeModels: Array<{ id: string; name: string }>,
80+
configuredProviderKeys: ReadonlySet<string>,
7481
): string {
7582
if (availableRuntimeModels.some((model) => model.id === desiredRef)) {
7683
return desiredRef;
@@ -82,6 +89,19 @@ function resolveAvailableRuntimeModel(
8289
return desiredRef;
8390
}
8491

92+
// Trust any model ref whose provider is configured in compiled.models.providers,
93+
// even if the provider's explicit `models` list is empty. This covers BYOK
94+
// flows where the user enabled a provider (e.g. Anthropic) with their own
95+
// API key but never added models to its allowlist — OpenClaw's
96+
// resolveModelWithRegistry has a generic-fallback path that builds a
97+
// synthetic model entry when providerConfig is present, so the request
98+
// still goes through. Without this, the user's explicit selection is
99+
// silently overridden with the link default.
100+
const providerKey = desiredRef.split("/", 1)[0];
101+
if (providerKey && configuredProviderKeys.has(providerKey)) {
102+
return desiredRef;
103+
}
104+
85105
return selectPreferredModel(availableRuntimeModels)?.id ?? desiredRef;
86106
}
87107

apps/controller/src/store/nexu-config-store.ts

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
ConnectWecomInput,
1212
DesktopRewardClaimProof,
1313
DesktopRewardsStatus,
14+
RewardTask,
1415
RewardTaskId,
1516
} from "@nexu/shared";
1617
import {
@@ -23,6 +24,7 @@ import {
2324
type refreshIntegrationSchema,
2425
rewardGroupSchema,
2526
rewardTaskIdSchema,
27+
rewardTasks,
2628
type updateAuthSourceSchema,
2729
type updateUserProfileSchema,
2830
type upsertProviderBodySchema,
@@ -94,6 +96,10 @@ const defaultCloudProfile: CloudProfileEntry = {
9496
linkUrl: "https://link.nexu.io",
9597
};
9698

99+
const rewardTaskTemplateById = new Map<RewardTaskId, RewardTask>(
100+
rewardTasks.map((task) => [task.id, task]),
101+
);
102+
97103
export type DesktopCloudStateChange = {
98104
hadCloudInventory: boolean;
99105
hasCloudInventory: boolean;
@@ -312,6 +318,29 @@ function convertCloudStatusToDesktop(
312318
},
313319
): DesktopRewardsStatus {
314320
const { cloudConnected, activeModelId, activeManagedModel } = viewer;
321+
const tasks = cloudStatus.tasks.flatMap((task) => {
322+
const parsedTaskId = rewardTaskIdSchema.safeParse(task.id);
323+
const parsedGroupId = rewardGroupSchema.safeParse(task.groupId);
324+
if (!parsedTaskId.success || !parsedGroupId.success) {
325+
return [];
326+
}
327+
328+
return {
329+
id: parsedTaskId.data as RewardTaskId,
330+
group: parsedGroupId.data,
331+
icon: task.icon ?? "gift",
332+
reward: task.rewardPoints,
333+
shareMode: task.shareMode as "link" | "tweet" | "image",
334+
repeatMode: task.repeatMode as "once" | "daily" | "weekly",
335+
requiresScreenshot: task.shareMode === "image",
336+
actionUrl:
337+
rewardTaskTemplateById.get(parsedTaskId.data)?.actionUrl ?? null,
338+
isClaimed: task.isClaimed,
339+
lastClaimedAt: task.lastClaimedAt,
340+
claimCount: task.claimCount,
341+
};
342+
});
343+
315344
return {
316345
viewer: {
317346
cloudConnected,
@@ -321,28 +350,12 @@ function convertCloudStatusToDesktop(
321350
(activeManagedModel ? "nexu" : (activeModelId?.split("/")[0] ?? null)),
322351
usingManagedModel: activeManagedModel != null,
323352
},
324-
progress: cloudStatus.progress,
325-
tasks: cloudStatus.tasks.flatMap((task) => {
326-
const parsedTaskId = rewardTaskIdSchema.safeParse(task.id);
327-
const parsedGroupId = rewardGroupSchema.safeParse(task.groupId);
328-
if (!parsedTaskId.success || !parsedGroupId.success) {
329-
return [];
330-
}
331-
332-
return {
333-
id: parsedTaskId.data as RewardTaskId,
334-
group: parsedGroupId.data,
335-
icon: task.icon ?? "gift",
336-
reward: task.rewardPoints,
337-
shareMode: task.shareMode as "link" | "tweet" | "image",
338-
repeatMode: task.repeatMode as "once" | "daily" | "weekly",
339-
requiresScreenshot: task.shareMode === "image",
340-
actionUrl: task.url,
341-
isClaimed: task.isClaimed,
342-
lastClaimedAt: task.lastClaimedAt,
343-
claimCount: task.claimCount,
344-
};
345-
}),
353+
progress: {
354+
...cloudStatus.progress,
355+
claimedCount: tasks.filter((task) => task.isClaimed).length,
356+
totalCount: tasks.length,
357+
},
358+
tasks,
346359
cloudBalance: cloudStatus.cloudBalance
347360
? {
348361
totalBalance: cloudStatus.cloudBalance.totalBalance,
@@ -538,6 +551,15 @@ export class NexuConfigStore {
538551
});
539552
}
540553

554+
private isCurrentPollingSignal(signal: AbortSignal): boolean {
555+
// The polling loop may still be processing a response when a newer
556+
// connectDesktopCloud() call has already aborted it and installed a fresh
557+
// pollingState. Identifying the active poll by AbortSignal identity lets
558+
// any final-state write from a stale loop become a no-op instead of
559+
// clobbering the new flow's pollingState or persisted credentials.
560+
return this.pollingState?.abortController.signal === signal;
561+
}
562+
541563
private async pollDesktopCloudAuthorization(
542564
cloudApiUrl: string,
543565
deviceId: string,
@@ -584,6 +606,9 @@ export class NexuConfigStore {
584606
: ((await this.fetchDesktopCloudModels(linkUrl, data.apiKey)) ??
585607
[]);
586608

609+
if (signal.aborted || !this.isCurrentPollingSignal(signal)) {
610+
return;
611+
}
587612
this.pollingState = null;
588613
await this.setDesktopCloudState({
589614
connected: true,
@@ -605,6 +630,9 @@ export class NexuConfigStore {
605630
}
606631

607632
if (data.status === "expired") {
633+
if (signal.aborted || !this.isCurrentPollingSignal(signal)) {
634+
return;
635+
}
608636
this.pollingState = null;
609637
await this.setDesktopCloudState({
610638
connected: false,
@@ -626,6 +654,9 @@ export class NexuConfigStore {
626654
}
627655
}
628656

657+
if (signal.aborted || !this.isCurrentPollingSignal(signal)) {
658+
return;
659+
}
629660
this.pollingState = null;
630661
await this.setDesktopCloudState({
631662
connected: false,
@@ -1815,17 +1846,38 @@ export class NexuConfigStore {
18151846
});
18161847
}
18171848

1849+
private abortDesktopCloudPolling(): void {
1850+
if (this.pollingState) {
1851+
this.pollingState.abortController.abort();
1852+
this.pollingState = null;
1853+
}
1854+
}
1855+
18181856
async connectDesktopCloud(options?: { source?: string | null }) {
18191857
const config = await this.getConfig();
18201858
const current = readDesktopCloud(config);
18211859
const { activeProfile } =
18221860
await this.readConfiguredDesktopCloudProfile(config);
1823-
if (this.pollingState || current.polling) {
1824-
return { error: "Connection attempt already in progress" };
1825-
}
18261861
if (current.connected && current.apiKey) {
18271862
return { error: "Already connected. Disconnect first." };
18281863
}
1864+
// If a previous connect attempt is still polling (e.g. the user closed the
1865+
// authorization tab without completing the flow), cancel it and clear the
1866+
// persisted polling flag so this call can start a fresh browser login.
1867+
if (this.pollingState || current.polling) {
1868+
this.abortDesktopCloudPolling();
1869+
await this.setDesktopCloudState({
1870+
connected: false,
1871+
polling: false,
1872+
userId: null,
1873+
userName: null,
1874+
userEmail: null,
1875+
connectedAt: null,
1876+
linkUrl: null,
1877+
apiKey: null,
1878+
models: [],
1879+
});
1880+
}
18291881
const trimmedSource = options?.source?.trim();
18301882
const sourceQuery =
18311883
trimmedSource && trimmedSource.length > 0
@@ -2040,10 +2092,7 @@ export class NexuConfigStore {
20402092

20412093
const previousCloud = readDesktopCloud(await this.getConfig());
20422094

2043-
if (this.pollingState) {
2044-
this.pollingState.abortController.abort();
2045-
this.pollingState = null;
2046-
}
2095+
this.abortDesktopCloudPolling();
20472096

20482097
await this.store.update((config) => {
20492098
const currentProfile = readLocalProfile(config);
@@ -2107,10 +2156,7 @@ export class NexuConfigStore {
21072156
throw new Error(`Unknown cloud profile: ${name}`);
21082157
}
21092158

2110-
if (this.pollingState) {
2111-
this.pollingState.abortController.abort();
2112-
this.pollingState = null;
2113-
}
2159+
this.abortDesktopCloudPolling();
21142160

21152161
await this.store.update((currentConfig) => {
21162162
const sessions = readDesktopCloudSessions(currentConfig);
@@ -2252,10 +2298,7 @@ export class NexuConfigStore {
22522298

22532299
async disconnectDesktopCloud() {
22542300
const previousCloud = readDesktopCloud(await this.getConfig());
2255-
if (this.pollingState) {
2256-
this.pollingState.abortController.abort();
2257-
this.pollingState = null;
2258-
}
2301+
this.abortDesktopCloudPolling();
22592302

22602303
await this.setDesktopCloudState({
22612304
connected: false,

apps/controller/tests/nexu-config-store.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -464,9 +464,9 @@ describe("NexuConfigStore", () => {
464464
shareMode: "link",
465465
icon: "calendar",
466466
url: null,
467-
isClaimed: false,
468-
claimCount: 0,
469-
lastClaimedAt: null,
467+
isClaimed: true,
468+
claimCount: 1,
469+
lastClaimedAt: "2026-04-08T00:00:00.000Z",
470470
},
471471
{
472472
id: "xiaohongshu",
@@ -483,7 +483,7 @@ describe("NexuConfigStore", () => {
483483
},
484484
],
485485
progress: {
486-
claimedCount: 0,
486+
claimedCount: 1,
487487
totalCount: 2,
488488
earnedCredits: 0,
489489
},
@@ -505,6 +505,8 @@ describe("NexuConfigStore", () => {
505505
expect(status.cloudBalance?.totalBalance).toBe(1);
506506
expect(status.tasks).toHaveLength(1);
507507
expect(status.tasks[0]?.id).toBe("daily_checkin");
508+
expect(status.progress.claimedCount).toBe(1);
509+
expect(status.progress.totalCount).toBe(1);
508510
} finally {
509511
vi.unstubAllGlobals();
510512
}

apps/desktop/main/services/launchd-manager.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ export class LaunchdManager {
4545
const plistPath = path.join(this.plistDir, `${label}.plist`);
4646
await fs.mkdir(this.plistDir, { recursive: true });
4747

48+
// Always clear any persistent "disabled" override left by legacy
49+
// `launchctl unload -w`. `launchctl enable` is idempotent (no-op when
50+
// not disabled), so this is safe to run on every boot. Without this,
51+
// upgrades that leave the plist unchanged hit the early-return below
52+
// and leak the disabled flag, causing OpenClaw's SIGUSR1 self-restart
53+
// to fail with "Bootstrap failed: 5".
54+
const disabled = await this.isServiceDisabled(label);
55+
if (disabled) {
56+
this.log(
57+
`installService: ${label} has disabled override, clearing with launchctl enable`,
58+
);
59+
await this.enableService(label);
60+
}
61+
4862
const isRegistered = await this.isServiceRegistered(label);
4963

5064
if (isRegistered) {
@@ -74,16 +88,6 @@ export class LaunchdManager {
7488

7589
await fs.writeFile(plistPath, plistContent, "utf8");
7690

77-
// Clear any persistent "disabled" override left by legacy `launchctl unload -w`.
78-
// Without this, bootstrap fails with error 5 (Input/output error).
79-
const disabled = await this.isServiceDisabled(label);
80-
if (disabled) {
81-
this.log(
82-
`installService: ${label} has disabled override, clearing with launchctl enable`,
83-
);
84-
await this.enableService(label);
85-
}
86-
8791
// Bootstrap with retry: "Input/output error" (code 5) means launchd
8892
// has stale state for this label. Bootout to clear it, then retry.
8993
for (let attempt = 0; attempt < 2; attempt++) {

0 commit comments

Comments
 (0)