Skip to content

Commit 4bc7cb3

Browse files
committed
fix: repair intel desktop and provider saves
1 parent f546db7 commit 4bc7cb3

8 files changed

Lines changed: 234 additions & 38 deletions

File tree

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,14 @@ clawhub install swarmclaw
151151

152152
[Browse on ClawHub](https://clawhub.ai/skills/swarmclaw)
153153

154-
## v1.9.26 Highlights
154+
## v1.9.27 Highlights
155155

156-
Output hygiene follow-up: empty successful LLM turns now stay silent instead of being rewritten as user-visible errors.
156+
Desktop compatibility and provider-save repair for Intel Mac users and OpenRouter setup.
157157

158-
- **Silent empty completions.** Blank successful runs no longer become `Error: Run completed...` assistant messages.
159-
- **Connector-safe final text.** Slack and other connectors no longer receive synthetic error text for intentional silence or quiet no-op turns.
160-
- **Real errors preserved.** Explicit provider failures and streamed provider errors still surface as terminal errors.
161-
- **Regression coverage.** Chat-execution tests now lock the distinction between empty success and real failure.
158+
- **Intel macOS native modules.** The desktop packaging hook now rebuilds Electron-loaded native modules with the target architecture and blocks a release if an x64 macOS bundle contains an arm64-only required addon.
159+
- **OpenRouter save repair.** Provider updates now tolerate UI metadata fields like `id`, `type`, `createdAt`, and `updatedAt` without persisting them, while still rejecting unrelated unknown fields.
160+
- **Downloads clarity.** The downloads page no longer guesses Apple Silicon when a browser hides the Mac architecture, so Intel users can choose the x64 DMG explicitly.
161+
- **Regression coverage.** Provider route and Electron after-pack tests cover the reported failure modes.
162162

163163
## Hosted Deploys
164164

@@ -410,6 +410,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
410410

411411
## Releases
412412

413+
### v1.9.27 Highlights
414+
415+
Desktop compatibility and provider-save repair for Intel Mac users and OpenRouter setup.
416+
417+
- **Intel macOS native modules.** The desktop packaging hook now rebuilds Electron-loaded native modules with the target architecture and blocks a release if an x64 macOS bundle contains an arm64-only required addon.
418+
- **OpenRouter save repair.** Provider updates now tolerate UI metadata fields like `id`, `type`, `createdAt`, and `updatedAt` without persisting them, while still rejecting unrelated unknown fields.
419+
- **Downloads clarity.** The downloads page no longer guesses Apple Silicon when a browser hides the Mac architecture, so Intel users can choose the x64 DMG explicitly.
420+
- **Regression coverage.** Provider route and Electron after-pack tests cover the reported failure modes.
421+
413422
### v1.9.26 Highlights
414423

415424
Output hygiene follow-up: empty successful LLM turns now stay silent instead of being rewritten as user-visible errors.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@swarmclawai/swarmclaw",
3-
"version": "1.9.26",
3+
"version": "1.9.27",
44
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
55
"main": "electron-dist/main.js",
66
"license": "MIT",

scripts/electron-after-pack.cjs

Lines changed: 113 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,78 @@ const STANDALONE_REBUILD_MODULES = [
1616
'better-sqlite3',
1717
'utf-8-validate',
1818
]
19+
const STANDALONE_NATIVE_ARCH_CHECKS = [
20+
['better-sqlite3', 'build', 'Release', 'better_sqlite3.node'],
21+
['utf-8-validate', 'build', 'Release', 'validation.node'],
22+
]
23+
const EXPECTED_MACHO_ARCH = {
24+
x64: 'x86_64',
25+
arm64: 'arm64',
26+
}
27+
28+
function nativeBinaryPartsForModule(moduleName) {
29+
return STANDALONE_NATIVE_ARCH_CHECKS.filter(([nativeModuleName]) => nativeModuleName === moduleName)
30+
}
1931

2032
function readElectronVersion(projectDir) {
2133
const electronPkg = path.join(projectDir, 'node_modules', 'electron', 'package.json')
2234
const raw = fs.readFileSync(electronPkg, 'utf8')
2335
return JSON.parse(raw).version
2436
}
2537

38+
function copyNativeModuleSourceForRebuild(projectDir, standaloneDir, moduleName) {
39+
const sourceDir = path.join(projectDir, 'node_modules', moduleName)
40+
const targetDir = path.join(standaloneDir, 'node_modules', moduleName)
41+
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) return false
42+
43+
fs.rmSync(targetDir, { recursive: true, force: true })
44+
fs.cpSync(sourceDir, targetDir, {
45+
recursive: true,
46+
force: true,
47+
filter: (src) => {
48+
const relative = path.relative(sourceDir, src)
49+
return relative !== 'build' && !relative.startsWith(`build${path.sep}`)
50+
},
51+
})
52+
return true
53+
}
54+
55+
function prepareStandaloneNativeModuleSources(projectDir, standaloneDir, modules) {
56+
for (const moduleName of modules) {
57+
copyNativeModuleSourceForRebuild(projectDir, standaloneDir, moduleName)
58+
}
59+
}
60+
61+
function snapshotProjectNativeBinaries(projectDir, modules = STANDALONE_REBUILD_MODULES) {
62+
const snapshots = []
63+
64+
for (const moduleName of modules) {
65+
for (const nativeParts of nativeBinaryPartsForModule(moduleName)) {
66+
const nativePath = path.join(projectDir, 'node_modules', ...nativeParts)
67+
if (!fs.existsSync(nativePath)) continue
68+
snapshots.push({
69+
nativePath,
70+
data: fs.readFileSync(nativePath),
71+
mode: fs.statSync(nativePath).mode,
72+
})
73+
}
74+
}
75+
76+
return () => {
77+
for (const snapshot of snapshots) {
78+
fs.mkdirSync(path.dirname(snapshot.nativePath), { recursive: true })
79+
fs.writeFileSync(snapshot.nativePath, snapshot.data, { mode: snapshot.mode })
80+
}
81+
}
82+
}
83+
2684
function rebuildStandaloneNativeModules(projectDir, standaloneDir, archName) {
2785
const modules = STANDALONE_REBUILD_MODULES.filter((moduleName) => fs.existsSync(path.join(standaloneDir, 'node_modules', moduleName)))
2886
if (modules.length === 0) return
2987

88+
const restoreProjectNativeBinaries = snapshotProjectNativeBinaries(projectDir, modules)
89+
prepareStandaloneNativeModuleSources(projectDir, standaloneDir, modules)
90+
3091
const electronRebuild = path.join(
3192
projectDir,
3293
'node_modules',
@@ -35,29 +96,56 @@ function rebuildStandaloneNativeModules(projectDir, standaloneDir, archName) {
3596
)
3697
const electronVersion = readElectronVersion(projectDir)
3798
const cacheDir = path.join(projectDir, '.tmp-electron-rebuild-cache')
38-
const result = spawnSync(
39-
electronRebuild,
40-
[
41-
'--version', electronVersion,
42-
'--module-dir', standaloneDir,
43-
'--only', modules.join(','),
44-
'--arch', archName,
45-
'--sequential',
46-
'--force',
47-
'--disable-pre-gyp-copy',
48-
],
49-
{
50-
cwd: projectDir,
51-
stdio: 'inherit',
52-
shell: process.platform === 'win32',
53-
env: {
54-
...process.env,
55-
npm_config_cache: process.env.npm_config_cache || cacheDir,
99+
try {
100+
const result = spawnSync(
101+
electronRebuild,
102+
[
103+
'--version', electronVersion,
104+
'--module-dir', standaloneDir,
105+
'--only', modules.join(','),
106+
'--arch', archName,
107+
'--sequential',
108+
'--force',
109+
'--disable-pre-gyp-copy',
110+
'--build-from-source',
111+
],
112+
{
113+
cwd: projectDir,
114+
stdio: 'inherit',
115+
shell: process.platform === 'win32',
116+
env: {
117+
...process.env,
118+
npm_config_arch: archName,
119+
npm_config_target_arch: archName,
120+
npm_config_build_from_source: 'true',
121+
npm_config_cache: process.env.npm_config_cache || cacheDir,
122+
},
56123
},
57-
},
58-
)
59-
if (result.status !== 0) {
60-
throw new Error(`afterPack: electron-rebuild failed with status ${result.status}`)
124+
)
125+
if (result.status !== 0) {
126+
throw new Error(`afterPack: electron-rebuild failed with status ${result.status}`)
127+
}
128+
} finally {
129+
restoreProjectNativeBinaries()
130+
}
131+
}
132+
133+
function validateStandaloneNativeModuleArch(standaloneDir, archName) {
134+
const expected = EXPECTED_MACHO_ARCH[archName]
135+
if (!expected) return
136+
137+
for (const nativeParts of STANDALONE_NATIVE_ARCH_CHECKS) {
138+
const nativePath = path.join(standaloneDir, 'node_modules', ...nativeParts)
139+
if (!fs.existsSync(nativePath)) continue
140+
141+
const result = spawnSync('file', [nativePath], { encoding: 'utf8' })
142+
if (result.status !== 0) {
143+
throw new Error(`afterPack: failed to inspect ${nativePath}: ${result.stderr || result.error?.message || 'file command failed'}`)
144+
}
145+
const output = `${result.stdout || ''}${result.stderr || ''}`
146+
if (!output.includes(expected)) {
147+
throw new Error(`afterPack: ${nativePath} expected ${expected} for arch=${archName}, got: ${output.trim()}`)
148+
}
61149
}
62150
}
63151

@@ -99,6 +187,9 @@ exports.default = async function afterPack(context) {
99187

100188
console.log(`[after-pack] rebuilding required standalone native modules for arch=${archName}`)
101189
rebuildStandaloneNativeModules(projectDir, standaloneDir, archName)
190+
if (context.electronPlatformName === 'darwin') validateStandaloneNativeModuleArch(standaloneDir, archName)
102191

103192
if (context.electronPlatformName === 'darwin') signMacApp(context)
104193
}
194+
195+
exports.validateStandaloneNativeModuleArch = validateStandaloneNativeModuleArch

scripts/electron-after-pack.test.mjs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import { describe, it } from 'node:test'
88

99
const require = createRequire(import.meta.url)
1010
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
11-
const { default: afterPack } = require(path.join(repoRoot, 'scripts', 'electron-after-pack.cjs'))
11+
const {
12+
default: afterPack,
13+
validateStandaloneNativeModuleArch,
14+
} = require(path.join(repoRoot, 'scripts', 'electron-after-pack.cjs'))
1215

1316
describe('electron afterPack hook', () => {
1417
it('rebuilds required native modules inside Linux standalone resources', async () => {
@@ -17,11 +20,18 @@ describe('electron afterPack hook', () => {
1720
const appOutDir = path.join(tempDir, 'dist', 'linux-unpacked')
1821
const electronPkg = path.join(projectDir, 'node_modules', 'electron', 'package.json')
1922
const rebuildBin = path.join(projectDir, 'node_modules', '.bin', 'electron-rebuild')
23+
const sourcePkg = path.join(projectDir, 'node_modules', 'better-sqlite3')
2024
const standalonePkg = path.join(appOutDir, 'resources', '.next', 'standalone', 'node_modules', 'better-sqlite3')
2125
const standaloneNative = path.join(standalonePkg, 'build', 'Release', 'better_sqlite3.node')
2226

2327
fs.mkdirSync(path.dirname(electronPkg), { recursive: true })
2428
fs.writeFileSync(electronPkg, JSON.stringify({ version: '33.4.11' }))
29+
fs.mkdirSync(path.join(sourcePkg, 'src'), { recursive: true })
30+
fs.mkdirSync(path.join(sourcePkg, 'build', 'Release'), { recursive: true })
31+
fs.writeFileSync(path.join(sourcePkg, 'package.json'), JSON.stringify({ name: 'better-sqlite3' }))
32+
fs.writeFileSync(path.join(sourcePkg, 'binding.gyp'), '{}')
33+
fs.writeFileSync(path.join(sourcePkg, 'src', 'addon.cpp'), '// source')
34+
fs.writeFileSync(path.join(sourcePkg, 'build', 'Release', 'better_sqlite3.node'), 'host-arch')
2535
fs.mkdirSync(path.dirname(rebuildBin), { recursive: true })
2636
fs.writeFileSync(rebuildBin, `#!/bin/sh
2737
set -eu
@@ -34,13 +44,18 @@ while [ "$#" -gt 0 ]; do
3444
*) shift ;;
3545
esac
3646
done
47+
test -f "$module_dir/node_modules/better-sqlite3/binding.gyp"
48+
test ! -f "$module_dir/node_modules/better-sqlite3/build/Release/stale.node"
3749
mkdir -p "$module_dir/node_modules/better-sqlite3/build/Release"
38-
printf "electron-abi-build-%s" "$arch" > "$module_dir/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
50+
printf "electron-abi-build-%s env:%s/%s" "$arch" "$npm_config_arch" "$npm_config_target_arch" > "$module_dir/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
51+
project_dir="$(cd "$(dirname "$0")/../.." && pwd)"
52+
printf "mutated-root-%s" "$arch" > "$project_dir/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
3953
`)
4054
fs.chmodSync(rebuildBin, 0o755)
4155
fs.mkdirSync(standalonePkg, { recursive: true })
4256
fs.mkdirSync(path.dirname(standaloneNative), { recursive: true })
4357
fs.writeFileSync(standaloneNative, 'host-node-build')
58+
fs.writeFileSync(path.join(path.dirname(standaloneNative), 'stale.node'), 'stale')
4459

4560
try {
4661
await afterPack({
@@ -53,9 +68,40 @@ printf "electron-abi-build-%s" "$arch" > "$module_dir/node_modules/better-sqlite
5368
},
5469
})
5570

56-
assert.equal(fs.readFileSync(standaloneNative, 'utf8'), 'electron-abi-build-x64')
71+
assert.equal(fs.readFileSync(standaloneNative, 'utf8'), 'electron-abi-build-x64 env:x64/x64')
72+
assert.equal(
73+
fs.readFileSync(path.join(sourcePkg, 'build', 'Release', 'better_sqlite3.node'), 'utf8'),
74+
'host-arch',
75+
)
5776
} finally {
5877
fs.rmSync(tempDir, { recursive: true, force: true })
5978
}
6079
})
80+
81+
it('rejects a macOS x64 package that contains an arm64-only required native module', () => {
82+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-after-pack-arch-'))
83+
const standaloneDir = path.join(tempDir, 'standalone')
84+
const nativePath = path.join(standaloneDir, 'node_modules', 'better-sqlite3', 'build', 'Release', 'better_sqlite3.node')
85+
const binDir = path.join(tempDir, 'bin')
86+
87+
fs.mkdirSync(path.dirname(nativePath), { recursive: true })
88+
fs.writeFileSync(nativePath, 'fake-native')
89+
fs.mkdirSync(binDir, { recursive: true })
90+
fs.writeFileSync(path.join(binDir, 'file'), `#!/bin/sh
91+
printf '%s: Mach-O 64-bit bundle arm64\\n' "$1"
92+
`)
93+
fs.chmodSync(path.join(binDir, 'file'), 0o755)
94+
95+
const oldPath = process.env.PATH
96+
process.env.PATH = `${binDir}${path.delimiter}${oldPath || ''}`
97+
try {
98+
assert.throws(
99+
() => validateStandaloneNativeModuleArch(standaloneDir, 'x64'),
100+
/expected x86_64/,
101+
)
102+
} finally {
103+
process.env.PATH = oldPath
104+
fs.rmSync(tempDir, { recursive: true, force: true })
105+
}
106+
})
61107
})

src/app/api/providers/[id]/route.test.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,52 @@ test('provider route upserts builtin override records for enablement changes', (
4848
assert.equal(output.responsePayload.isEnabled, false)
4949
})
5050

51-
test('provider route rejects unknown fields per ProviderUpdateSchema.strict()', () => {
51+
test('provider route ignores frontend metadata fields without persisting them', () => {
52+
const output = runWithTempDataDir<{
53+
status: number
54+
providerConfig: {
55+
id: string
56+
type: string
57+
name: string
58+
isEnabled: boolean
59+
updatedAt: number
60+
}
61+
}>(`
62+
const storageMod = await import('./src/lib/server/storage')
63+
const routeMod = await import('./src/app/api/providers/[id]/route')
64+
const storage = storageMod.default || storageMod
65+
const route = routeMod.default || routeMod
66+
67+
const response = await route.PUT(
68+
new Request('http://local/api/providers/openai', {
69+
method: 'PUT',
70+
headers: { 'content-type': 'application/json' },
71+
body: JSON.stringify({
72+
id: 'wrong-id',
73+
type: 'custom',
74+
createdAt: '123',
75+
updatedAt: '456',
76+
isEnabled: false,
77+
}),
78+
}),
79+
{ params: Promise.resolve({ id: 'openai' }) },
80+
)
81+
82+
console.log(JSON.stringify({
83+
status: response.status,
84+
providerConfig: storage.loadProviderConfigs().openai,
85+
}))
86+
`, { prefix: 'swarmclaw-provider-route-strict-test-' })
87+
88+
assert.equal(output.status, 200)
89+
assert.equal(output.providerConfig.id, 'openai')
90+
assert.equal(output.providerConfig.type, 'builtin')
91+
assert.equal(output.providerConfig.name, 'OpenAI')
92+
assert.equal(output.providerConfig.isEnabled, false)
93+
assert.equal(typeof output.providerConfig.updatedAt, 'number')
94+
})
95+
96+
test('provider route still rejects unknown non-metadata fields', () => {
5297
const output = runWithTempDataDir<{ status: number }>(`
5398
const routeMod = await import('./src/app/api/providers/[id]/route')
5499
const route = routeMod.default || routeMod
@@ -57,13 +102,13 @@ test('provider route rejects unknown fields per ProviderUpdateSchema.strict()',
57102
new Request('http://local/api/providers/openai', {
58103
method: 'PUT',
59104
headers: { 'content-type': 'application/json' },
60-
body: JSON.stringify({ type: 'builtin', isEnabled: true }),
105+
body: JSON.stringify({ unexpectedField: true, isEnabled: true }),
61106
}),
62107
{ params: Promise.resolve({ id: 'openai' }) },
63108
)
64109
65110
console.log(JSON.stringify({ status: response.status }))
66-
`, { prefix: 'swarmclaw-provider-route-strict-test-' })
111+
`, { prefix: 'swarmclaw-provider-route-unknown-field-test-' })
67112

68113
assert.equal(output.status, 400)
69114
})

0 commit comments

Comments
 (0)