Reduce packaged app size (#1026)

This commit is contained in:
Lingxuan Zuo
2026-05-17 23:28:09 +08:00
committed by GitHub
parent 34bfae2851
commit 3edeb8cdfa
26 changed files with 760 additions and 116 deletions
@@ -25,7 +25,7 @@ import { listAgentsSnapshot } from '../../utils/agent-config';
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.4`;
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.5`;
/**
* Provider types that are not in the built-in provider registry (no `providerConfig.api`).
+1 -1
View File
@@ -12,7 +12,7 @@ export type BrowserOAuthProviderType = 'google' | 'openai';
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.4';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5';
class BrowserOAuthManager extends EventEmitter {
private activeProvider: BrowserOAuthProviderType | null = null;
+4 -11
View File
@@ -517,8 +517,6 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
enabled: true,
entries: {
[feishuPluginId]: { enabled: true },
// Disable the built-in feishu plugin when using openclaw-lark
...(feishuPluginId !== 'feishu' ? { feishu: { enabled: false } } : {}),
}
};
} else {
@@ -539,15 +537,10 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
// Remove conflicting feishu plugin entries; keep only the resolved plugin id.
// When the resolved plugin id is NOT 'feishu', explicitly disable the
// built-in feishu plugin (OpenClaw ships one in dist/extensions/feishu/)
// to prevent it from conflicting with the official openclaw-lark plugin.
if (feishuPluginId !== 'feishu') {
currentConfig.plugins.entries['feishu'] = { enabled: false };
} else {
delete currentConfig.plugins.entries['feishu'];
}
// Remove conflicting feishu plugin entries; keep only the resolved
// external plugin id. A disabled plugins.entries.feishu record
// blocks openclaw-lark in OpenClaw's gateway startup planner.
delete currentConfig.plugins.entries['feishu'];
for (const candidateId of FEISHU_PLUGIN_ID_CANDIDATES) {
if (candidateId !== feishuPluginId) {
delete currentConfig.plugins.entries[candidateId];
+10 -16
View File
@@ -516,6 +516,7 @@ const BUNDLED_ALLOWLIST_PRESERVE_IDS = new Set([
'browser',
'acpx',
'memory-core',
'codex',
]);
const AUTH_PROFILE_PROVIDER_KEY_MAP: Record<string, string> = {
'openai-codex': 'openai',
@@ -2290,32 +2291,25 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
// ── Disable built-in 'feishu' when official openclaw-lark plugin is active ──
// OpenClaw ships a built-in 'feishu' extension in dist/extensions/feishu/
// that conflicts with the official @larksuite/openclaw-lark plugin
// (id: 'openclaw-lark'). When the feishu channel IS configured and the
// canonical plugin is NOT the built-in 'feishu' itself, we must:
// 1. Remove bare 'feishu' from plugins.allow
// 2. Explicitly disable the built-in feishu extension
// ── Remove legacy built-in 'feishu' registration ───────────────
// ClawX bundles Feishu via the official @larksuite/openclaw-lark
// plugin and removes the old built-in dist/extensions/feishu tree.
// Keeping plugins.entries.feishu={enabled:false} looks harmless, but
// OpenClaw's channel startup planner treats it as an explicit blocker
// for the feishu channel owner and skips openclaw-lark at runtime.
const allowArr2 = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : [];
if (isFeishuConfigured) {
const hasCanonicalFeishu = allowArr2.includes(canonicalFeishuId) || !!pEntries[canonicalFeishuId];
if (hasCanonicalFeishu && canonicalFeishuId !== 'feishu') {
// Remove bare 'feishu' from plugins.allow
const bareFeishuIdx = allowArr2.indexOf('feishu');
if (bareFeishuIdx !== -1) {
allowArr2.splice(bareFeishuIdx, 1);
console.log('[sanitize] Removed bare "feishu" from plugins.allow (openclaw-lark plugin is configured)');
modified = true;
}
// Explicitly disable the built-in feishu extension so it doesn't
// conflict with the official openclaw-lark plugin at runtime.
// Simply deleting the entry is NOT sufficient — the built-in
// extension in dist/extensions/feishu/ (enabledByDefault: true) will
// still load unless explicitly marked as disabled.
if (!pEntries.feishu || (pEntries.feishu as Record<string, unknown>).enabled !== false) {
pEntries.feishu = { enabled: false };
console.log('[sanitize] Disabled built-in feishu plugin (openclaw-lark plugin is configured)');
if (pEntries.feishu) {
delete pEntries.feishu;
console.log('[sanitize] Removed legacy plugins.entries.feishu (openclaw-lark plugin is configured)');
modified = true;
}
}
@@ -0,0 +1,24 @@
---
id: packaged-runtime-pruning-guards
title: Packaged Runtime Pruning Guards
type: ai-coding-rule
appliesTo:
- plugin-lifecycle-management
requiredProfiles:
- fast
---
Packaged runtime cleanup must remove only files that are not needed by the
target artifact.
For macOS universal builds, architecture pruning must preserve both x64 and
arm64 native payloads for the same platform. This includes scoped optional
packages such as `@openai/codex-darwin-x64` and
`@openai/codex-darwin-arm64`, plus per-arch native prebuild directories such
as `tree-sitter-bash/prebuilds/darwin-x64` and
`tree-sitter-bash/prebuilds/darwin-arm64`.
Any change to `scripts/after-pack.cjs` or `scripts/bundle-openclaw.mjs` that
adds native-package pruning or known-runtime-junk cleanup must include a unit
test covering the target architecture behavior, including `arch: universal`
when the rule can affect macOS packaged builds.
@@ -36,6 +36,7 @@ requiredRules:
- channel-plugin-migration-guards
- capability-owner-resolution
- active-config-guards
- packaged-runtime-pruning-guards
---
Plugin lifecycle management covers bundled and external plugins as one system with different source types. The core model has two layers:
@@ -52,5 +53,6 @@ Lifecycle stages:
- Validate: ClawX verifies package manifests, dependencies, capability config, ownership uniqueness, and startup requirements.
- Activate: only resolved and validated capabilities enter final OpenClaw runtime config.
- Recover: failed upgrades, stale registrations, conflicts, and removed channels converge to a single diagnosable state with rollback or cleanup paths.
- Package: cleanup and pruning keep packaged artifacts small without deleting target runtime assets; macOS universal packages keep both x64 and arm64 native payloads.
First-stage priority is integration safety: single-owner capability resolution, active config guards, direct regression tests for migration cases, and explicit task specs for resolution, validation, and recovery work.
@@ -19,6 +19,7 @@ expectedUserBehavior:
- When the pointer's runtimeFile points outside the sessions/ folder (the OPENCLAW_TRAJECTORY_DIR override), that off-disk runtime file is also unlinked so no orphan trajectory remains anywhere on disk.
- The session entry is removed from sessions.json so OpenClaw sessions.list stops returning it.
- The sidebar list, sessionLabels and sessionLastActivity for the deleted key are cleared in the renderer store.
- Any pending optimistic user message cache for the deleted session key is cleared so a later history reload cannot resurrect deleted chat bubbles.
- Token usage history reported by the Dashboard stops including the deleted session.
requiredProfiles:
- fast
@@ -31,6 +32,7 @@ acceptance:
- IPC channel name session:delete and HTTP route POST /api/sessions/delete are unchanged in shape.
- Both the IPC handler in electron/main/ipc-handlers.ts and the HTTP mirror in electron/api/routes/sessions.ts unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift.
- The handler tolerates ENOENT (file already gone) and still updates sessions.json so the sidebar stops listing the entry.
- Renderer delete-session paths clear any in-memory pending optimistic user messages for the deleted key before subsequent history loads run.
- agentId from the sessionKey is validated against /^[A-Za-z0-9][A-Za-z0-9_-]*$/ in both surfaces and any sessionFile resolved to a path outside the agent sessions/ directory is refused (defence-in-depth against a corrupt sessions.json).
- Absolute-path detection accepts POSIX paths, Windows backslash paths (C:\...) and Windows forward-slash paths (C:/...) so the sweep works on every supported OS.
- The sweep also unlinks <id>.trajectory.jsonl and <id>.trajectory-path.json sidecars produced by OpenClaw's runtime trajectory writer.
@@ -68,4 +70,7 @@ Renderer surface is untouched: the confirm dialog in `Sidebar.tsx`, the
`useChatStore.deleteSession` API, and the host-api/api-client boundary all
continue to work without changes. The only user-visible effect is that
deleted conversations no longer leave hidden `.deleted.jsonl` files behind
and no longer contribute to Dashboard token-usage history.
and no longer contribute to Dashboard token-usage history. Deletion also
clears renderer-only pending optimistic user messages keyed by the deleted
session so the UI cannot rehydrate a locally staged message after the
transcript and sidebar entry have been removed.
@@ -0,0 +1,40 @@
---
id: packaged-runtime-pruning-guards
title: Guard packaged runtime pruning for universal builds
scenario: plugin-lifecycle-management
taskType: plugin-lifecycle
intent: Keep packaged OpenClaw runtime cleanup size-conscious without deleting native payloads required by macOS universal artifacts.
touchedAreas:
- scripts/after-pack.cjs
- scripts/bundle-openclaw.mjs
- scripts/openclaw-bundle-config.mjs
- tests/unit/after-pack-cleanup.test.ts
- tests/unit/openclaw-bundle-config.test.ts
- harness/specs/rules/packaged-runtime-pruning-guards.md
- harness/specs/tasks/packaged-runtime-pruning-guards.md
expectedUserBehavior:
- Packaged OpenAI Codex support works in macOS arm64, x64 and universal artifacts.
- Packaged tree-sitter-bash runtime loading keeps a usable native prebuild for every architecture in the target artifact.
- Size cleanup still removes non-target platform packages and known runtime junk for single-architecture builds.
requiredProfiles:
- fast
requiredTests:
- tests/unit/after-pack-cleanup.test.ts
- tests/unit/openclaw-bundle-config.test.ts
acceptance:
- `cleanupNativePlatformPackages` keeps same-platform x64 and arm64 native packages when the electron-builder arch resolves to `universal`.
- `cleanupNodeModulesRuntimeJunk` keeps same-platform x64 and arm64 `tree-sitter-bash/prebuilds` directories when the target arch is `universal`.
- Non-target platforms are still pruned from scoped native packages and tree-sitter-bash prebuilds.
- Extra bundled OpenClaw packages still include `@openclaw/codex`, and the bundle script still skips a duplicate nested `openclaw` package.
docs:
required: false
---
This task captures packaged-runtime cleanup invariants for OpenClaw extension
and native payload bundling. The size optimization path may prune unused
platform binaries, generated declarations, source maps and known non-runtime
files, but it must not treat macOS `universal` as a literal architecture.
Universal macOS artifacts contain both x64 and arm64 slices. Cleanup helpers
therefore keep same-platform x64 and arm64 packages/prebuilds while still
removing Linux, Windows and other non-target platform payloads.
+17 -16
View File
@@ -77,27 +77,12 @@
"postversion": "node scripts/post-version-push.mjs"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@sinclair/typebox": "^0.34.48",
"@types/diff": "^8.0.0",
"chokidar": "^5.0.0",
"clawhub": "^0.5.0",
"diff": "^9.0.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.8.3",
"katex": "^0.16.45",
"lru-cache": "^11.2.6",
"monaco-editor": "^0.55.1",
"ms": "^2.1.3",
"node-machine-id": "^1.1.12",
"pdfjs-dist": "^5.7.284",
"posthog-node": "^5.28.0",
"rehype-katex": "^7.0.1",
"remark-frontmatter": "^5.0.0",
"remark-math": "^6.0.0",
"tar": "^6.2.1",
"ws": "^8.19.0",
"xlsx": "^0.18.5"
"ws": "^8.19.0"
},
"devDependencies": {
"@buape/carbon": "0.16.0",
@@ -108,6 +93,8 @@
"@homebridge/ciao": "^1.3.7",
"@larksuite/openclaw-lark": "2026.5.12",
"@larksuiteoapi/node-sdk": "^1.61.1",
"@monaco-editor/react": "^4.7.0",
"@openclaw/codex": "2026.5.12",
"@openclaw/discord": "2026.5.12",
"@openclaw/qqbot": "2026.5.12",
"@openclaw/whatsapp": "2026.5.12",
@@ -124,11 +111,13 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@sinclair/typebox": "^0.34.48",
"@soimy/dingtalk": "^3.6.2",
"@tencent-connect/qqbot-connector": "^1.1.0",
"@tencent-weixin/openclaw-weixin": "^2.4.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/diff": "^8.0.0",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -140,8 +129,10 @@
"@whiskeysockets/baileys": "7.0.0-rc.9",
"acpx": "0.5.3",
"autoprefixer": "^10.4.24",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^9.0.0",
"discord-api-types": "^0.38.47",
"electron": "^40.6.0",
"electron-builder": "^26.8.1",
@@ -154,10 +145,15 @@
"https-proxy-agent": "^9.0.0",
"i18next": "^25.8.11",
"jsdom": "^28.1.0",
"katex": "^0.16.45",
"lru-cache": "^11.2.6",
"lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1",
"mpg123-decoder": "^1.0.3",
"ms": "^2.1.3",
"openclaw": "2026.5.12",
"opusscript": "^0.1.1",
"pdfjs-dist": "^5.7.284",
"playwright-core": "1.59.1",
"png2icons": "^2.0.1",
"postcss": "^8.5.6",
@@ -167,13 +163,17 @@
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"rehype-katex": "^7.0.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sharp": "^0.34.5",
"silk-wasm": "^3.7.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tar": "^6.2.1",
"typescript": "^5.9.3",
"undici": "8.1.0",
"use-stick-to-bottom": "^1.1.3",
@@ -181,6 +181,7 @@
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^4.0.18",
"xlsx": "^0.18.5",
"zustand": "^5.0.11",
"zx": "^8.8.5"
},
+140 -45
View File
@@ -11,69 +11,24 @@ importers:
.:
dependencies:
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@sinclair/typebox':
specifier: ^0.34.48
version: 0.34.48
'@types/diff':
specifier: ^8.0.0
version: 8.0.0
chokidar:
specifier: ^5.0.0
version: 5.0.0
clawhub:
specifier: ^0.5.0
version: 0.5.0
diff:
specifier: ^9.0.0
version: 9.0.0
electron-store:
specifier: ^11.0.2
version: 11.0.2
electron-updater:
specifier: ^6.8.3
version: 6.8.3
katex:
specifier: ^0.16.45
version: 0.16.45
lru-cache:
specifier: ^11.2.6
version: 11.2.7
monaco-editor:
specifier: ^0.55.1
version: 0.55.1
ms:
specifier: ^2.1.3
version: 2.1.3
node-machine-id:
specifier: ^1.1.12
version: 1.1.12
pdfjs-dist:
specifier: ^5.7.284
version: 5.7.284
posthog-node:
specifier: ^5.28.0
version: 5.28.5
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
remark-frontmatter:
specifier: ^5.0.0
version: 5.0.0
remark-math:
specifier: ^6.0.0
version: 6.0.0
tar:
specifier: ^6.2.1
version: 6.2.1
ws:
specifier: ^8.19.0
version: 8.20.0
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies:
'@buape/carbon':
specifier: 0.16.0
@@ -99,6 +54,12 @@ importers:
'@larksuiteoapi/node-sdk':
specifier: ^1.61.1
version: 1.62.0
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@openclaw/codex':
specifier: 2026.5.12
version: 2026.5.12(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(openclaw@2026.5.12(encoding@0.1.13))
'@openclaw/discord':
specifier: 2026.5.12
version: 2026.5.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)(openclaw@2026.5.12(encoding@0.1.13))
@@ -147,6 +108,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@sinclair/typebox':
specifier: ^0.34.48
version: 0.34.48
'@soimy/dingtalk':
specifier: ^3.6.2
version: 3.6.2(openclaw@2026.5.12(encoding@0.1.13))
@@ -162,6 +126,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/diff':
specifier: ^8.0.0
version: 8.0.0
'@types/node':
specifier: ^25.3.0
version: 25.5.0
@@ -195,12 +162,18 @@ importers:
autoprefixer:
specifier: ^10.4.24
version: 10.4.27(postcss@8.5.8)
chokidar:
specifier: ^5.0.0
version: 5.0.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
diff:
specifier: ^9.0.0
version: 9.0.0
discord-api-types:
specifier: ^0.38.47
version: 0.38.47
@@ -237,18 +210,33 @@ importers:
jsdom:
specifier: ^28.1.0
version: 28.1.0(@noble/hashes@2.0.1)
katex:
specifier: ^0.16.45
version: 0.16.45
lru-cache:
specifier: ^11.2.6
version: 11.2.7
lucide-react:
specifier: ^0.563.0
version: 0.563.0(react@19.2.4)
monaco-editor:
specifier: ^0.55.1
version: 0.55.1
mpg123-decoder:
specifier: ^1.0.3
version: 1.0.3
ms:
specifier: ^2.1.3
version: 2.1.3
openclaw:
specifier: 2026.5.12
version: 2026.5.12(encoding@0.1.13)
opusscript:
specifier: ^0.1.1
version: 0.1.1
pdfjs-dist:
specifier: ^5.7.284
version: 5.7.284
playwright-core:
specifier: 1.59.1
version: 1.59.1
@@ -276,9 +264,18 @@ importers:
react-router-dom:
specifier: ^7.13.0
version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
remark-frontmatter:
specifier: ^5.0.0
version: 5.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
remark-math:
specifier: ^6.0.0
version: 6.0.0
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -297,6 +294,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))
tar:
specifier: ^6.2.1
version: 6.2.1
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -318,6 +318,9 @@ importers:
vitest:
specifier: ^4.0.18
version: 4.1.1(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0))
xlsx:
specifier: ^0.18.5
version: 0.18.5
zustand:
specifier: ^5.0.11
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
@@ -1674,6 +1677,55 @@ packages:
resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==}
engines: {node: ^18.17.0 || >=20.5.0}
'@openai/codex@0.130.0':
resolution: {integrity: sha512-WGDj+RZ3TXWC/7MlwprgLWOqzpwatPIINPhP3IRzHA0ni+o3QZ4i4xrS2uWwGmHUJ395J5JHwoZAAZYyfJyz6w==}
engines: {node: '>=16'}
hasBin: true
'@openai/codex@0.130.0-darwin-arm64':
resolution: {integrity: sha512-R9pkGC7kwC8yQ8el5hvBlmugQlcsG/pHMEFgZluu03X9fD2TezGxdq3KqRDRCZuMYl07ILamVEoqknuJ0cq7MA==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@openai/codex@0.130.0-darwin-x64':
resolution: {integrity: sha512-gJ+7J8djevgtdra+NgDAiQQPW+O3KTsgGfE3E5dpDfww3zS5OCeV0V2dhxqnJdlOjOSDw99o0P2LqBv19mhpRw==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@openai/codex@0.130.0-linux-arm64':
resolution: {integrity: sha512-tFtH0V9/hEI3d9y7zP92BXI9FM4Z3+STNQaOR52Czv18TRtCFUp7CbIUYaToopuq6UBfnE1VKr8RLhwT5FcbmA==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@openai/codex@0.130.0-linux-x64':
resolution: {integrity: sha512-3VcNlez99xdnEf+kB1IOpWv9fICYV9PiGj4sLCO4TCcShLnyxe+YBGa3poknkvXLnMG0qiN9SMnYS2FGrMxQcA==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@openai/codex@0.130.0-win32-arm64':
resolution: {integrity: sha512-vdpmiNp57L/arZabltLXn8TyEtNa7W1meOEkr+3R6W/8ZyBt++wuqz1Orv134OT2grrcFJsIVCAIPiqUxCvBkA==}
engines: {node: '>=16'}
cpu: [arm64]
os: [win32]
'@openai/codex@0.130.0-win32-x64':
resolution: {integrity: sha512-FzMznm7fr5/nbjZgOujZ9Y9AbdGm7ji1FOoWiY3U+srqauvZaTgn6o6aCheSL7kuymu7nTLOO/cAyWV6NuesqQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@openclaw/codex@2026.5.12':
resolution: {integrity: sha512-T2kaIg4+vS8AE0Jr7HgLzHSdhlUvCg0VbNqBto3g7frATj05gG/nX6gx2UEj4Y5f8DEKXHrLKwc+vyywVQemgw==}
peerDependencies:
openclaw: '>=2026.5.12'
peerDependenciesMeta:
openclaw:
optional: true
'@openclaw/discord@2026.5.12':
resolution: {integrity: sha512-nyB2wDpSyHcXbDwWnGQ+KjnPXcbAOj8MiLe8YS6IJbZUjcqjtObuUn5vh72Srcixupugu1PzNZT0WntO8J4n0A==}
peerDependencies:
@@ -8713,6 +8765,49 @@ snapshots:
dependencies:
semver: 7.7.4
'@openai/codex@0.130.0':
optionalDependencies:
'@openai/codex-darwin-arm64': '@openai/codex@0.130.0-darwin-arm64'
'@openai/codex-darwin-x64': '@openai/codex@0.130.0-darwin-x64'
'@openai/codex-linux-arm64': '@openai/codex@0.130.0-linux-arm64'
'@openai/codex-linux-x64': '@openai/codex@0.130.0-linux-x64'
'@openai/codex-win32-arm64': '@openai/codex@0.130.0-win32-arm64'
'@openai/codex-win32-x64': '@openai/codex@0.130.0-win32-x64'
'@openai/codex@0.130.0-darwin-arm64':
optional: true
'@openai/codex@0.130.0-darwin-x64':
optional: true
'@openai/codex@0.130.0-linux-arm64':
optional: true
'@openai/codex@0.130.0-linux-x64':
optional: true
'@openai/codex@0.130.0-win32-arm64':
optional: true
'@openai/codex@0.130.0-win32-x64':
optional: true
'@openclaw/codex@2026.5.12(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(openclaw@2026.5.12(encoding@0.1.13))':
dependencies:
'@earendil-works/pi-coding-agent': 0.74.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)
'@openai/codex': 0.130.0
ajv: 8.20.0
ws: 8.20.0
zod: 4.4.3
optionalDependencies:
openclaw: 2026.5.12(encoding@0.1.13)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
'@openclaw/discord@2026.5.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)(openclaw@2026.5.12(encoding@0.1.13))':
dependencies:
'@discordjs/voice': 0.19.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)(opusscript@0.1.1)
+85 -3
View File
@@ -155,6 +155,7 @@ const PLATFORM_NATIVE_SCOPES = {
'@reflink': /^reflink-(darwin|linux|win32)-(x64|arm64|x64-gnu|x64-musl|arm64-gnu|arm64-musl|x64-msvc|arm64-msvc)/,
'@node-llama-cpp': /^(mac|linux|win)-(arm64|x64|armv7l)(-metal|-cuda|-cuda-ext|-vulkan)?$/,
'@esbuild': /^(darwin|linux|win32|android|freebsd|netbsd|openbsd|sunos|aix|openharmony)-(x64|arm64|arm|ia32|loong64|mips64el|ppc64|riscv64|s390x)/,
'@openai': /^codex-(darwin|linux|win32)-(x64|arm64)$/,
};
// Unscoped packages that follow a <name>-<platform>-<arch> convention.
@@ -173,6 +174,13 @@ function baseArch(rawArch) {
return dash > 0 ? rawArch.slice(0, dash) : rawArch;
}
function matchesTargetArch(pkgArch, targetArch) {
if (targetArch === 'universal') {
return pkgArch === 'x64' || pkgArch === 'arm64' || pkgArch === 'universal';
}
return pkgArch === targetArch || pkgArch === 'universal';
}
function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) {
let removed = 0;
@@ -190,7 +198,7 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) {
const isMatch =
pkgPlatform === platform &&
(pkgArch === arch || pkgArch === 'universal');
matchesTargetArch(pkgArch, arch);
if (!isMatch) {
try {
@@ -215,7 +223,7 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) {
const isMatch =
pkgPlatform === platform &&
(pkgArch === arch || pkgArch === 'universal');
matchesTargetArch(pkgArch, arch);
if (!isMatch) {
try {
@@ -229,6 +237,75 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) {
return removed;
}
function cleanupNodeModulesRuntimeJunk(nodeModulesDir, platform, arch) {
let removed = 0;
const nodeWavDir = join(nodeModulesDir, 'node-wav');
for (const name of ['x.json', 'x.js', 'x.js~', 'file.wav']) {
try {
const target = join(nodeWavDir, name);
if (existsSync(target)) {
rmSync(target, { recursive: true, force: true });
removed++;
}
} catch { /* */ }
}
const treeSitterBashDir = join(nodeModulesDir, 'tree-sitter-bash');
const treeSitterSrc = join(treeSitterBashDir, 'src');
for (const name of ['parser.c', 'scanner.c', 'grammar.json', 'tree_sitter']) {
try {
const target = join(treeSitterSrc, name);
if (existsSync(target)) {
rmSync(target, { recursive: true, force: true });
removed++;
}
} catch { /* */ }
}
const prebuildsDir = join(treeSitterBashDir, 'prebuilds');
if (existsSync(prebuildsDir)) {
for (const entry of readdirSync(prebuildsDir)) {
const [entryPlatform, ...entryArchParts] = entry.split('-');
const entryArch = baseArch(entryArchParts.join('-'));
if (entryPlatform === platform && matchesTargetArch(entryArch, arch)) continue;
try {
rmSync(join(prebuildsDir, entry), { recursive: true, force: true });
removed++;
} catch { /* */ }
}
}
return removed;
}
function cleanupKnownRuntimeJunk(rootDir, platform, arch) {
let removed = 0;
const stack = [rootDir];
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try { entries = readdirSync(normWin(dir), { withFileTypes: true }); } catch { continue; }
if (basename(dir) === 'node_modules') {
removed += cleanupNodeModulesRuntimeJunk(dir, platform, arch);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
stack.push(join(dir, entry.name));
}
}
return removed;
}
exports.__test = {
cleanupNativePlatformPackages,
cleanupNodeModulesRuntimeJunk,
};
// ── Broken module patcher ─────────────────────────────────────────────────────
// Some bundled packages have transpiled CJS that sets `module.exports = exports.default`
// without ever assigning `exports.default`, leaving module.exports === undefined.
@@ -621,6 +698,10 @@ exports.default = async function afterPack(context) {
const pluginNM = join(pluginDestDir, 'node_modules');
cleanupUnnecessaryFiles(pluginDestDir);
if (existsSync(pluginNM)) {
const pluginJunkRemoved = cleanupKnownRuntimeJunk(pluginDestDir, platform, arch);
if (pluginJunkRemoved > 0) {
console.log(`[after-pack] ✅ ${pluginId}: removed ${pluginJunkRemoved} known runtime junk files/directories.`);
}
cleanupKoffi(pluginNM, platform, arch);
cleanupNativePlatformPackages(pluginNM, platform, arch);
}
@@ -761,7 +842,8 @@ exports.default = async function afterPack(context) {
// 2. General cleanup on the full openclaw directory (not just node_modules)
console.log('[after-pack] 🧹 Cleaning up unnecessary files ...');
const removedRoot = cleanupUnnecessaryFiles(openclawRoot);
console.log(`[after-pack] ✅ Removed ${removedRoot} unnecessary files/directories.`);
const removedKnownJunk = cleanupKnownRuntimeJunk(openclawRoot, platform, arch);
console.log(`[after-pack] ✅ Removed ${removedRoot + removedKnownJunk} unnecessary files/directories.`);
// 3. Platform-specific: strip koffi non-target platform binaries
const koffiRemoved = cleanupKoffi(dest, platform, arch);
+66
View File
@@ -177,6 +177,10 @@ echo` Virtual store root: ${openclawVirtualNM}`;
queue.push({ nodeModulesDir: openclawVirtualNM, skipPkg: 'openclaw' });
const SKIP_PACKAGES = new Set([
// Extra bundled extensions such as @openclaw/codex can declare openclaw as a
// peer/optional dependency. The bundle already copies openclaw to OUTPUT root,
// so do not also copy a duplicate into OUTPUT/node_modules/openclaw.
'openclaw',
'typescript',
'@playwright/test',
// @discordjs/opus is a native .node addon compiled for the system Node.js
@@ -447,6 +451,25 @@ function patchBundledExtensionPackageJsons(extensionsRoot) {
patchBundledExtensionPackageJsons(extensionsDir);
function bundleExternalExtension(pkgName, extensionId) {
const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/'));
if (!fs.existsSync(pkgLink)) {
throw new Error(`Missing extension package "${pkgName}". Run pnpm install first.`);
}
const realPath = fs.realpathSync(pkgLink);
const dest = path.join(extensionsDir, extensionId);
fs.rmSync(normWin(dest), { recursive: true, force: true });
fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true });
fs.cpSync(normWin(realPath), normWin(dest), {
recursive: true,
dereference: true,
});
echo` Bundled external extension ${pkgName} -> dist/extensions/${extensionId}`;
}
bundleExternalExtension('@openclaw/codex', 'codex');
// 6. Clean up the bundle to reduce package size
//
// This removes platform-agnostic waste: dev artifacts, docs, source maps,
@@ -482,6 +505,47 @@ function rmSafe(target) {
} catch { return false; }
}
function cleanupNodeModulesRuntimeJunk(nodeModulesDir) {
let removedCount = 0;
const nodeWavDir = path.join(nodeModulesDir, 'node-wav');
for (const name of ['x.json', 'x.js', 'x.js~', 'file.wav']) {
if (rmSafe(path.join(nodeWavDir, name))) removedCount++;
}
// tree-sitter-bash ships C sources for rebuilding its native addon. Packaged
// builds use the prebuilt addon/wasm; keep node-types.json because the CJS
// entry exposes it as optional runtime metadata.
const treeSitterSrc = path.join(nodeModulesDir, 'tree-sitter-bash', 'src');
for (const name of ['parser.c', 'scanner.c', 'grammar.json', 'tree_sitter']) {
if (rmSafe(path.join(treeSitterSrc, name))) removedCount++;
}
return removedCount;
}
function cleanupKnownRuntimeJunk(rootDir) {
let removedCount = 0;
const stack = [rootDir];
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
if (path.basename(dir) === 'node_modules') {
removedCount += cleanupNodeModulesRuntimeJunk(dir);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
stack.push(path.join(dir, entry.name));
}
}
return removedCount;
}
function cleanupBundle(outputDir) {
let removedCount = 0;
const nm = path.join(outputDir, 'node_modules');
@@ -620,6 +684,8 @@ function cleanupBundle(outputDir) {
if (rmSafe(path.join(outputDir, rel))) removedCount++;
}
removedCount += cleanupKnownRuntimeJunk(outputDir);
return removedCount;
}
+5
View File
@@ -28,6 +28,11 @@ export const EXTRA_BUNDLED_PACKAGES = [
// transitive dependency graph from the app bundle context.
'playwright-core',
// The Codex agent harness is published as an OpenClaw extension package.
// Bundle it with the runtime so openai-codex models can register the codex
// harness in packaged builds.
'@openclaw/codex',
// Electron main process QR login flows resolve these files from the
// bundled OpenClaw runtime context in packaged builds.
'qrcode-terminal',
+73 -6
View File
@@ -81,6 +81,15 @@ const CHAT_EVENT_DEDUPE_TTL_MS = 30_000;
const HISTORY_PAGE_SIZE = 200;
const HISTORY_MAX_RENDERED_MESSAGES = 1_000;
const _chatEventDedupe = new Map<string, number>();
const OPTIMISTIC_USER_MESSAGE_TTL_MS = 30 * 60 * 1000;
type PendingOptimisticUserMessage = {
message: RawMessage;
timestampMs: number;
createdAtMs: number;
};
const _pendingOptimisticUserMessages = new Map<string, PendingOptimisticUserMessage[]>();
type SessionLabelSummary = {
sessionKey: string;
@@ -529,6 +538,62 @@ function matchesOptimisticUserMessage(
return false;
}
function rememberPendingOptimisticUserMessage(sessionKey: string, message: RawMessage, timestampMs: number): void {
const now = Date.now();
const existing = (_pendingOptimisticUserMessages.get(sessionKey) || [])
.filter((entry) => now - entry.createdAtMs <= OPTIMISTIC_USER_MESSAGE_TTL_MS);
existing.push({ message, timestampMs, createdAtMs: now });
_pendingOptimisticUserMessages.set(sessionKey, existing);
}
function clearPendingOptimisticUserMessages(sessionKey: string): void {
_pendingOptimisticUserMessages.delete(sessionKey);
}
function mergePendingOptimisticUserMessages(sessionKey: string, loadedMessages: RawMessage[]): RawMessage[] {
const pending = _pendingOptimisticUserMessages.get(sessionKey);
if (!pending || pending.length === 0) return loadedMessages;
const now = Date.now();
let merged = loadedMessages;
const stillPending: PendingOptimisticUserMessage[] = [];
for (const entry of pending) {
if (now - entry.createdAtMs > OPTIMISTIC_USER_MESSAGE_TTL_MS) {
continue;
}
const hasServerEcho = loadedMessages.some((message) =>
matchesOptimisticUserMessage(message, entry.message, entry.timestampMs),
);
if (hasServerEcho) {
continue;
}
const alreadyRendered = merged.some((message) =>
message.id === entry.message.id || matchesOptimisticUserMessage(message, entry.message, entry.timestampMs),
);
if (!alreadyRendered) {
const insertAt = merged.findIndex((message) =>
typeof message.timestamp === 'number' && toMs(message.timestamp) > entry.timestampMs,
);
merged = insertAt === -1
? [...merged, entry.message]
: [...merged.slice(0, insertAt), entry.message, ...merged.slice(insertAt)];
}
stillPending.push(entry);
}
if (stillPending.length > 0) {
_pendingOptimisticUserMessages.set(sessionKey, stillPending);
} else {
_pendingOptimisticUserMessages.delete(sessionKey);
}
return merged;
}
function snapshotStreamingAssistantMessage(
currentStream: RawMessage | null,
existingMessages: RawMessage[],
@@ -1900,6 +1965,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
deleteSession: async (key: string) => {
clearCachedSessionHistory(key);
clearSessionLabelHydrationTracking(key);
clearPendingOptimisticUserMessages(key);
// Hard-delete the session's JSONL transcript on disk.
// The main process unlinks <id>.jsonl plus any leftover
// <id>.deleted.jsonl and <id>.jsonl.reset.* siblings, then removes the
@@ -2141,19 +2207,19 @@ export const useChatStore = create<ChatState>((set, get) => ({
// Restore file attachments for user/assistant messages (from cache + text patterns)
const enrichedMessages = enrichWithCachedImages(filteredMessages);
// Preserve the optimistic user message during an active send.
// The Gateway may not include the user's message in chat.history
// until the run completes, causing it to flash out of the UI.
let finalMessages = enrichedMessages;
// Preserve optimistic user messages independently from sending state.
// Gateway phase=end can clear sending before chat.history has persisted
// the user turn; without this, an early quiet reload briefly removes it.
let finalMessages = mergePendingOptimisticUserMessages(currentSessionKey, enrichedMessages);
const userMsgAt = get().lastUserMessageAt;
if (get().sending && userMsgAt) {
const userMsMs = toMs(userMsgAt);
const optimistic = getLatestOptimisticUserMessage(get().messages, userMsMs);
const hasMatchingUser = optimistic
? enrichedMessages.some((message) => matchesOptimisticUserMessage(message, optimistic, userMsMs))
? finalMessages.some((message) => matchesOptimisticUserMessage(message, optimistic, userMsMs))
: false;
if (optimistic && !hasMatchingUser) {
finalMessages = [...enrichedMessages, optimistic];
finalMessages = [...finalMessages, optimistic];
}
}
@@ -2487,6 +2553,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
filePath: a.stagedPath,
})),
};
rememberPendingOptimisticUserMessage(currentSessionKey, userMsg, nowMs);
set((s) => ({
messages: [...s.messages, userMsg],
sending: true,
+68
View File
@@ -28,6 +28,15 @@ let _errorRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
// the sending state after abortRun clears it.
let _lastAbortedRunId: string | null = null;
const _blockedRunEvents = new Map<string, Record<string, unknown>[]>();
const OPTIMISTIC_USER_MESSAGE_TTL_MS = 30 * 60 * 1000;
type PendingOptimisticUserMessage = {
message: RawMessage;
timestampMs: number;
createdAtMs: number;
};
const _pendingOptimisticUserMessages = new Map<string, PendingOptimisticUserMessage[]>();
function clearErrorRecoveryTimer(): void {
if (_errorRecoveryTimer) {
@@ -198,6 +207,62 @@ function matchesOptimisticUserMessage(
return false;
}
function rememberPendingOptimisticUserMessage(sessionKey: string, message: RawMessage, timestampMs: number): void {
const now = Date.now();
const existing = (_pendingOptimisticUserMessages.get(sessionKey) || [])
.filter((entry) => now - entry.createdAtMs <= OPTIMISTIC_USER_MESSAGE_TTL_MS);
existing.push({ message, timestampMs, createdAtMs: now });
_pendingOptimisticUserMessages.set(sessionKey, existing);
}
function clearPendingOptimisticUserMessages(sessionKey: string): void {
_pendingOptimisticUserMessages.delete(sessionKey);
}
function mergePendingOptimisticUserMessages(sessionKey: string, loadedMessages: RawMessage[]): RawMessage[] {
const pending = _pendingOptimisticUserMessages.get(sessionKey);
if (!pending || pending.length === 0) return loadedMessages;
const now = Date.now();
let merged = loadedMessages;
const stillPending: PendingOptimisticUserMessage[] = [];
for (const entry of pending) {
if (now - entry.createdAtMs > OPTIMISTIC_USER_MESSAGE_TTL_MS) {
continue;
}
const hasServerEcho = loadedMessages.some((message) =>
matchesOptimisticUserMessage(message, entry.message, entry.timestampMs),
);
if (hasServerEcho) {
continue;
}
const alreadyRendered = merged.some((message) =>
message.id === entry.message.id || matchesOptimisticUserMessage(message, entry.message, entry.timestampMs),
);
if (!alreadyRendered) {
const insertAt = merged.findIndex((message) =>
typeof message.timestamp === 'number' && toMs(message.timestamp) > entry.timestampMs,
);
merged = insertAt === -1
? [...merged, entry.message]
: [...merged.slice(0, insertAt), entry.message, ...merged.slice(insertAt)];
}
stillPending.push(entry);
}
if (stillPending.length > 0) {
_pendingOptimisticUserMessages.set(sessionKey, stillPending);
} else {
_pendingOptimisticUserMessages.delete(sessionKey);
}
return merged;
}
function snapshotStreamingAssistantMessage(
currentStream: RawMessage | null,
existingMessages: RawMessage[],
@@ -1235,6 +1300,9 @@ export {
isToolOnlyMessage,
normalizeStreamingMessage,
matchesOptimisticUserMessage,
rememberPendingOptimisticUserMessage,
clearPendingOptimisticUserMessages,
mergePendingOptimisticUserMessages,
snapshotStreamingAssistantMessage,
getLatestOptimisticUserMessage,
setHistoryPollTimer,
+7 -6
View File
@@ -13,6 +13,7 @@ import {
isToolResultRole,
loadMissingPreviews,
matchesOptimisticUserMessage,
mergePendingOptimisticUserMessages,
toMs,
} from './helpers';
import { buildCronSessionHistoryPath, isCronSessionKey } from './cron-session-utils';
@@ -106,19 +107,19 @@ export function createHistoryActions(
// Restore file attachments for user/assistant messages (from cache + text patterns)
const enrichedMessages = enrichWithCachedImages(filteredMessages);
// Preserve the optimistic user message during an active send.
// The Gateway may not include the user's message in chat.history
// until the run completes, causing it to flash out of the UI.
let finalMessages = enrichedMessages;
// Preserve optimistic user messages independently from sending state.
// Gateway phase=end can clear sending before chat.history has persisted
// the user turn; without this, an early quiet reload briefly removes it.
let finalMessages = mergePendingOptimisticUserMessages(currentSessionKey, enrichedMessages);
const userMsgAt = get().lastUserMessageAt;
if (get().sending && userMsgAt) {
const userMsMs = toMs(userMsgAt);
const optimistic = getLatestOptimisticUserMessage(get().messages, userMsMs);
const hasMatchingUser = optimistic
? enrichedMessages.some((message) => matchesOptimisticUserMessage(message, optimistic, userMsMs))
? finalMessages.some((message) => matchesOptimisticUserMessage(message, optimistic, userMsMs))
: false;
if (optimistic && !hasMatchingUser) {
finalMessages = [...enrichedMessages, optimistic];
finalMessages = [...finalMessages, optimistic];
}
}
+2
View File
@@ -8,6 +8,7 @@ import {
setHistoryPollTimer,
setLastChatEventAt,
setLastAbortedRunId,
rememberPendingOptimisticUserMessage,
takeBlockedRunEvents,
upsertImageCacheEntry,
} from './helpers';
@@ -103,6 +104,7 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<Runti
source: 'user-upload',
})),
};
rememberPendingOptimisticUserMessage(currentSessionKey, userMsg, nowMs);
set((s) => ({
messages: [...s.messages, userMsg],
sending: true,
+2 -1
View File
@@ -1,5 +1,5 @@
import { invokeIpc } from '@/lib/api-client';
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
import { clearPendingOptimisticUserMessages, getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
@@ -265,6 +265,7 @@ export function createSessionActions(
deleteSession: async (key: string) => {
clearSessionLabelHydrationTracking(key);
clearPendingOptimisticUserMessages(key);
// Hard-delete the session's JSONL transcript on disk.
// The main process unlinks <id>.jsonl plus any leftover
// <id>.deleted.jsonl and <id>.jsonl.reset.* siblings, then removes the
+65
View File
@@ -0,0 +1,65 @@
// @vitest-environment node
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { afterEach, describe, expect, it } from 'vitest';
const require = createRequire(import.meta.url);
type AfterPackTestHooks = {
cleanupNativePlatformPackages: (nodeModulesDir: string, platform: string, arch: string) => number;
cleanupNodeModulesRuntimeJunk: (nodeModulesDir: string, platform: string, arch: string) => number;
};
const afterPack = require('../../scripts/after-pack.cjs') as { __test?: AfterPackTestHooks };
describe('after-pack cleanup helpers', () => {
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function makeTempNodeModules(): string {
const root = mkdtempSync(join(tmpdir(), 'clawx-after-pack-'));
tempRoots.push(root);
const nodeModules = join(root, 'node_modules');
mkdirSync(nodeModules, { recursive: true });
return nodeModules;
}
function makePackage(dir: string): void {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'package.json'), '{"version":"1.0.0"}\n', 'utf8');
}
it('keeps both mac Codex native packages for universal builds', () => {
const nodeModules = makeTempNodeModules();
makePackage(join(nodeModules, '@openai', 'codex-darwin-arm64'));
makePackage(join(nodeModules, '@openai', 'codex-darwin-x64'));
makePackage(join(nodeModules, '@openai', 'codex-linux-x64'));
afterPack.__test!.cleanupNativePlatformPackages(nodeModules, 'darwin', 'universal');
expect(existsSync(join(nodeModules, '@openai', 'codex-darwin-arm64'))).toBe(true);
expect(existsSync(join(nodeModules, '@openai', 'codex-darwin-x64'))).toBe(true);
expect(existsSync(join(nodeModules, '@openai', 'codex-linux-x64'))).toBe(false);
});
it('keeps both mac tree-sitter-bash prebuilds for universal builds', () => {
const nodeModules = makeTempNodeModules();
const prebuilds = join(nodeModules, 'tree-sitter-bash', 'prebuilds');
mkdirSync(join(prebuilds, 'darwin-arm64'), { recursive: true });
mkdirSync(join(prebuilds, 'darwin-x64'), { recursive: true });
mkdirSync(join(prebuilds, 'linux-x64'), { recursive: true });
afterPack.__test!.cleanupNodeModulesRuntimeJunk(nodeModules, 'darwin', 'universal');
expect(existsSync(join(prebuilds, 'darwin-arm64'))).toBe(true);
expect(existsSync(join(prebuilds, 'darwin-x64'))).toBe(true);
expect(existsSync(join(prebuilds, 'linux-x64'))).toBe(false);
});
});
+2 -2
View File
@@ -159,7 +159,7 @@ describe('WeCom plugin configuration', () => {
expect(plugins.entries['wecom'].enabled).toBe(true);
});
it('normalizes feishu plugin registration to openclaw-lark and disables built-in feishu on save', async () => {
it('normalizes feishu plugin registration to openclaw-lark and removes built-in feishu on save', async () => {
const { saveChannelConfig, writeOpenClawConfig } = await import('@electron/utils/channel-config');
await writeOpenClawConfig({
@@ -184,7 +184,7 @@ describe('WeCom plugin configuration', () => {
expect(plugins.allow).not.toContain('feishu');
expect(plugins.allow).not.toContain('feishu-openclaw-plugin');
expect(plugins.entries['openclaw-lark']).toEqual({ enabled: true });
expect(plugins.entries.feishu).toEqual({ enabled: false });
expect(plugins.entries.feishu).toBeUndefined();
expect(plugins.entries['feishu-openclaw-plugin']).toBeUndefined();
});
+1
View File
@@ -59,6 +59,7 @@ vi.mock('@/stores/chat/helpers', () => ({
isInternalMessage: (...args: unknown[]) => isInternalMessage(...args),
isToolResultRole: (...args: unknown[]) => isToolResultRole(...args),
loadMissingPreviews: (...args: unknown[]) => loadMissingPreviews(...args),
mergePendingOptimisticUserMessages: (_sessionKey: string, messages: unknown[]) => messages,
matchesOptimisticUserMessage: (
candidate: { role: string; timestamp?: number; content?: unknown; _attachedFiles?: Array<{ filePath?: string; fileName?: string; mimeType?: string; fileSize?: number }> },
optimistic: { role: string; timestamp?: number; content?: unknown; _attachedFiles?: Array<{ filePath?: string; fileName?: string; mimeType?: string; fileSize?: number }> },
+129
View File
@@ -483,4 +483,133 @@ describe('useChatStore startup history retry', () => {
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it('keeps the optimistic user message when completion refresh wins the transcript write race', async () => {
const { useChatStore } = await import('@/stores/chat');
let historyMessages: Array<Record<string, unknown>> = [];
let resolveSend: ((value: { runId: string }) => void) | null = null;
gatewayRpcMock.mockImplementation((method: string) => {
if (method === 'chat.send') {
return new Promise((resolve) => {
resolveSend = resolve as (value: { runId: string }) => void;
});
}
if (method === 'chat.history') {
return Promise.resolve({ messages: historyMessages });
}
return Promise.resolve({});
});
useChatStore.setState({
currentSessionKey: 'agent:main:main',
currentAgentId: 'main',
sessions: [{ key: 'agent:main:main' }],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
error: null,
loading: false,
thinkingLevel: null,
});
const sendPromise = useChatStore.getState().sendMessage('hello from app');
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']);
// Simulate Gateway phase=end clearing send state before chat.history has
// persisted the user turn.
useChatStore.setState({
sending: false,
activeRunId: null,
pendingFinal: false,
lastUserMessageAt: null,
});
await useChatStore.getState().loadHistory(true);
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']);
historyMessages = [{
role: 'user',
content: 'hello from app',
timestamp: Date.now() / 1000,
id: 'server-user',
}];
vi.advanceTimersByTime(1_000);
await useChatStore.getState().loadHistory(true);
expect(useChatStore.getState().messages).toHaveLength(1);
expect(useChatStore.getState().messages[0]).toMatchObject({
id: 'server-user',
role: 'user',
content: 'hello from app',
});
resolveSend?.({ runId: 'run-1' });
await sendPromise;
});
it('does not restore a pending optimistic message after deleting the session', async () => {
const { useChatStore } = await import('@/stores/chat');
let resolveSend: ((value: { runId: string }) => void) | null = null;
gatewayRpcMock.mockImplementation((method: string) => {
if (method === 'chat.send') {
return new Promise((resolve) => {
resolveSend = resolve as (value: { runId: string }) => void;
});
}
if (method === 'chat.history') {
return Promise.resolve({ messages: [] });
}
return Promise.resolve({});
});
hostApiFetchMock.mockImplementation((url: string) => {
if (url === '/api/sessions/delete') {
return Promise.resolve({ success: true });
}
return Promise.resolve({ messages: [] });
});
useChatStore.setState({
currentSessionKey: 'agent:main:main',
currentAgentId: 'main',
sessions: [{ key: 'agent:main:main' }],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
error: null,
loading: false,
thinkingLevel: null,
});
const sendPromise = useChatStore.getState().sendMessage('message that will be deleted');
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([
'message that will be deleted',
]);
await useChatStore.getState().deleteSession('agent:main:main');
expect(useChatStore.getState().messages).toEqual([]);
await useChatStore.getState().loadHistory(true);
expect(useChatStore.getState().messages).toEqual([]);
resolveSend?.({ runId: 'run-deleted' });
await sendPromise;
});
});
+4 -2
View File
@@ -465,7 +465,7 @@ describe('sanitizeOpenClawConfig', () => {
expect(telegram.botToken).toBe('telegram-token');
});
it('normalizes legacy feishu plugin state to a single external plugin and disables built-in feishu', async () => {
it('normalizes legacy feishu plugin state to a single external plugin and removes built-in feishu', async () => {
await writeOpenClawJson({
channels: {
feishu: {
@@ -507,7 +507,7 @@ describe('sanitizeOpenClawConfig', () => {
enabled: true,
config: { preserved: true },
});
expect(entries.feishu).toEqual({ enabled: false });
expect(entries.feishu).toBeUndefined();
});
it('removes residual feishu plugin registrations when feishu channel is not configured', async () => {
@@ -738,6 +738,7 @@ describe('sanitizeOpenClawConfig', () => {
{ dir: 'groq', id: 'groq', enabledByDefault: true },
{ dir: 'alibaba', id: 'alibaba', enabledByDefault: true },
{ dir: 'memory-core', id: 'memory-core' },
{ dir: 'codex', id: 'codex', providers: ['codex'] },
{ dir: 'openrouter', id: 'openrouter', enabledByDefault: true, providers: ['openrouter'] },
{ dir: 'anthropic', id: 'anthropic', enabledByDefault: true, providers: ['anthropic'] },
]) {
@@ -780,6 +781,7 @@ describe('sanitizeOpenClawConfig', () => {
expect(allow).toContain('custom-plugin');
expect(allow).toContain('browser');
expect(allow).toContain('memory-core');
expect(allow).toContain('codex');
expect(allow).toContain('alibaba');
expect(allow).not.toContain('groq');
expect(allow).toContain('openrouter');
@@ -22,6 +22,7 @@ describe('openclaw bundle config', () => {
'silk-wasm',
'acpx',
'playwright-core',
'@openclaw/codex',
'qrcode-terminal',
]));
const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as {
+2 -2
View File
@@ -186,7 +186,7 @@ describe('provider-runtime-sync refresh strategy', () => {
expect(gateway.debouncedRestart).not.toHaveBeenCalled();
});
it('uses gpt-5.4 as the browser OAuth default model for OpenAI', async () => {
it('uses gpt-5.5 as the browser OAuth default model for OpenAI', async () => {
mocks.getProvider.mockResolvedValue(
createProvider({
id: 'openai-personal',
@@ -209,7 +209,7 @@ describe('provider-runtime-sync refresh strategy', () => {
expect(mocks.setOpenClawDefaultModel).toHaveBeenCalledWith(
'openai-codex',
'openai-codex/gpt-5.4',
'openai-codex/gpt-5.5',
expect.any(Array),
);
});
+3 -3
View File
@@ -63,7 +63,7 @@ async function sanitizeConfig(
'qqbot',
]);
const BUNDLED_ALLOWLIST_PRESERVE_IDS = new Set(
bundledPlugins?.preserveIds ?? ['browser', 'acpx', 'memory-core'],
bundledPlugins?.preserveIds ?? ['browser', 'acpx', 'memory-core', 'codex'],
);
/** Non-throwing async existence check. */
@@ -852,7 +852,7 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
});
const bundled = {
all: ['browser', 'acpx', 'memory-core', 'openai', 'anthropic', 'diffs'],
all: ['browser', 'acpx', 'memory-core', 'codex', 'openai', 'anthropic', 'diffs'],
enabledByDefault: ['browser', 'acpx', 'openai', 'anthropic'],
providersByPluginId: {
openai: ['openai'],
@@ -866,7 +866,7 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => {
const result = await readConfig();
const plugins = result.plugins as Record<string, unknown>;
const allow = plugins.allow as string[];
expect(allow).toEqual(expect.arrayContaining(['customPlugin', 'browser', 'acpx', 'memory-core']));
expect(allow).toEqual(expect.arrayContaining(['customPlugin', 'browser', 'acpx', 'memory-core', 'codex']));
expect(allow).not.toContain('openai');
expect(allow).not.toContain('anthropic');
expect(allow).not.toContain('diffs');