@swarmclawai/swarmclaw 1.5.34 → 1.5.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,6 +69,19 @@ Extension tutorial: https://swarmclaw.ai/docs/extension-tutorial
69
69
 
70
70
  ## Quick Start
71
71
 
72
+ ### Desktop app (recommended for non-technical users)
73
+
74
+ Download the one-click installer from [swarmclaw.ai/downloads](https://swarmclaw.ai/downloads).
75
+ Available for macOS (Apple Silicon & Intel), Windows, and Linux (AppImage + .deb).
76
+
77
+ Current builds are unsigned, so on first launch:
78
+ - **macOS:** right-click the app in Finder → **Open** → **Open** to bypass Gatekeeper.
79
+ - **Windows:** if SmartScreen appears, click **More info** → **Run anyway**.
80
+ - **Linux (AppImage):** `chmod +x` the downloaded file, then run it.
81
+
82
+ Data lives in your OS app-data directory (`~/Library/Application Support/SwarmClaw`,
83
+ `%APPDATA%\SwarmClaw`, or `~/.config/SwarmClaw`), separate from any CLI or Docker install.
84
+
72
85
  ### Global install
73
86
 
74
87
  ```bash
@@ -375,6 +388,20 @@ Operational docs: https://swarmclaw.ai/docs/observability
375
388
 
376
389
  ## Releases
377
390
 
391
+ ### v1.5.36 Highlights
392
+
393
+ - **Desktop app (Electron)**: SwarmClaw now ships as a native desktop app for macOS (Apple Silicon + Intel), Windows, and Linux (AppImage + .deb). Download from [swarmclaw.ai/downloads](https://swarmclaw.ai/downloads). The app wraps the existing standalone server inside an Electron shell, stores data in the OS app-data directory, and auto-updates via GitHub Releases (notify-only on unsigned macOS builds).
394
+ - **Desktop release CI**: new `desktop-release.yml` workflow builds and publishes installers for all three platforms to GitHub Releases on every version tag.
395
+ - **UI cleanup**: removed sibling-product navigation links from the in-app sidebar rail and login gate so the open-source app focuses on SwarmClaw itself. Those links remain in the project README and on swarmclaw.ai.
396
+
397
+ ### v1.5.35 Highlights
398
+
399
+ - **Update safety: prevent DB corruption on Linux**: `npm run update:easy`, `swarmclaw update`, and the in-app update endpoint now stop the running server (or checkpoint the SQLite WAL) before rebuilding native modules, preventing the WAL journal corruption that forced some Linux users back to the setup wizard.
400
+ - **SQLite graceful shutdown**: the server now checkpoints and closes the database on SIGTERM/SIGINT, eliminating stale WAL state after any clean stop.
401
+ - **Doctor: detect dangling gateway credentials**: the setup doctor now flags gateway profiles that reference deleted or missing credentials, explaining the "gateway token missing" connection errors.
402
+ - **Gateway credential resolution logging**: when a gateway credential can't be resolved, the server now logs a clear warning identifying the missing credential ID.
403
+ - **Credential decryption error logging**: when a stored credential can't be decrypted (e.g. after `CREDENTIAL_SECRET` changes), the server now logs the credential ID and provider so users know which key to re-add.
404
+
378
405
  ### v1.5.34 Highlights
379
406
 
380
407
  - **Ollama Cloud auth fix**: SwarmClaw now normalizes `api.ollama.com` and `www.ollama.com` to `ollama.com` before making authenticated requests, avoiding the redirect that was dropping authorization headers and causing false provider-health/runtime failures.
package/bin/update-cmd.js CHANGED
@@ -98,6 +98,29 @@ function runRegistrySelfUpdate(
98
98
  return 0
99
99
  }
100
100
 
101
+ function stopRunningServer(logger = { log, logError }) {
102
+ // Stop the server gracefully before updating to prevent SQLite WAL corruption.
103
+ try {
104
+ const serverCmd = path.join(PKG_ROOT, 'bin', 'swarmclaw.js')
105
+ const status = execFileSync(process.execPath, [serverCmd, 'status'], {
106
+ encoding: 'utf-8',
107
+ cwd: PKG_ROOT,
108
+ timeout: 10_000,
109
+ }).trim()
110
+ if (status.toLowerCase().includes('running')) {
111
+ logger.log('Stopping running server before update...')
112
+ execFileSync(process.execPath, [serverCmd, 'stop'], {
113
+ encoding: 'utf-8',
114
+ cwd: PKG_ROOT,
115
+ timeout: 15_000,
116
+ })
117
+ logger.log('Server stopped.')
118
+ }
119
+ } catch {
120
+ // Server may not be running or status command unavailable — continue.
121
+ }
122
+ }
123
+
101
124
  function main(args = process.argv.slice(3)) {
102
125
  if (args.includes('-h') || args.includes('--help')) {
103
126
  console.log(`
@@ -112,9 +135,12 @@ If running from a registry install, update the global package with its owning pa
112
135
  try {
113
136
  run('git rev-parse --git-dir')
114
137
  } catch {
138
+ stopRunningServer()
115
139
  process.exit(runRegistrySelfUpdate())
116
140
  }
117
141
 
142
+ stopRunningServer()
143
+
118
144
  const beforeRef = run('git rev-parse HEAD')
119
145
  const beforeSha = run('git rev-parse --short HEAD')
120
146
 
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.34",
3
+ "version": "1.5.36",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
+ "main": "electron-dist/main.js",
5
6
  "license": "MIT",
6
7
  "publishConfig": {
7
8
  "access": "public",
@@ -78,6 +79,13 @@
78
79
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
79
80
  "test:e2e": "tsx .workbench/browser-e2e/run.ts",
80
81
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
82
+ "electron:compile": "tsc -p electron/tsconfig.json",
83
+ "electron:dev": "npm run electron:compile && electron electron-dist/main.js",
84
+ "electron:build": "node ./scripts/build-electron.mjs",
85
+ "electron:build:mac": "node ./scripts/build-electron.mjs --mac",
86
+ "electron:build:win": "node ./scripts/build-electron.mjs --win",
87
+ "electron:build:linux": "node ./scripts/build-electron.mjs --linux",
88
+ "electron:build:publish": "node ./scripts/build-electron.mjs --publish",
81
89
  "prepack": "npm run build:ci",
82
90
  "postinstall": "node ./scripts/postinstall.mjs"
83
91
  },
@@ -114,6 +122,7 @@
114
122
  "class-variance-authority": "^0.7.1",
115
123
  "clsx": "^2.1.1",
116
124
  "commander": "^13.1.0",
125
+ "electron-updater": "^6.3.9",
117
126
  "cron-parser": "^5.5.0",
118
127
  "cronstrue": "^3.12.0",
119
128
  "dagre": "^0.8.5",
@@ -156,7 +165,10 @@
156
165
  "zustand": "^5.0.11"
157
166
  },
158
167
  "devDependencies": {
168
+ "@electron/rebuild": "^3.7.2",
159
169
  "@types/dagre": "^0.7.54",
170
+ "electron": "^33.3.0",
171
+ "electron-builder": "^25.1.8",
160
172
  "eslint": "^9",
161
173
  "eslint-config-next": "16.1.7"
162
174
  },
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
3
5
  import { spawnSync } from 'node:child_process'
4
6
 
5
7
  const args = new Set(process.argv.slice(2))
@@ -68,12 +70,33 @@ function getLatestStableTag() {
68
70
  return tags.find((tag) => RELEASE_TAG_RE.test(tag)) || null
69
71
  }
70
72
 
73
+ function stopRunningServer() {
74
+ // Try to stop the SwarmClaw server gracefully before updating.
75
+ // An unclean shutdown while npm rebuild replaces native modules
76
+ // can corrupt the SQLite WAL journal on Linux.
77
+ const cliPath = path.join(cwd, 'bin', 'swarmclaw.js')
78
+ if (!fs.existsSync(cliPath)) return
79
+
80
+ const status = run('node', [cliPath, 'status'])
81
+ if (!status.ok || !status.out.toLowerCase().includes('running')) return
82
+
83
+ log('Stopping running SwarmClaw server before update...')
84
+ const stop = run('node', [cliPath, 'stop'])
85
+ if (stop.ok) {
86
+ log('Server stopped.')
87
+ } else {
88
+ log('Could not stop the server automatically. Please stop it manually before updating.')
89
+ }
90
+ }
91
+
71
92
  function main() {
72
93
  const gitCheck = run('git', ['rev-parse', '--is-inside-work-tree'])
73
94
  if (!gitCheck.ok) {
74
95
  fail('This folder is not a git repository. Automatic updates require git.')
75
96
  }
76
97
 
98
+ stopRunningServer()
99
+
77
100
  const dirty = run('git', ['status', '--porcelain'])
78
101
  const isDirty = !!dirty.out
79
102
  if (isDirty && !allowDirty) {
@@ -3,7 +3,7 @@ import path from 'node:path'
3
3
  import { spawnSync } from 'node:child_process'
4
4
  import { NextResponse } from 'next/server'
5
5
  import { DATA_DIR } from '@/lib/server/data-dir'
6
- import { loadAgents, loadCredentials, loadSettings } from '@/lib/server/storage'
6
+ import { loadAgents, loadCredentials, loadSettings, loadCollection } from '@/lib/server/storage'
7
7
  import { dedup, errorMessage } from '@/lib/shared-utils'
8
8
  import { detectDocker } from '@/lib/server/sandbox/docker-detect'
9
9
 
@@ -160,6 +160,7 @@ export async function GET(req: Request) {
160
160
  }
161
161
 
162
162
  const credentials = Object.values(loadCredentials() || {})
163
+ const credentialIds = new Set(credentials.map((c) => (c as Record<string, unknown>).id as string).filter(Boolean))
163
164
  if (credentials.length > 0) {
164
165
  pushCheck(checks, 'credentials', 'Credentials', 'pass', `${credentials.length} credential(s) saved.`)
165
166
  } else {
@@ -167,6 +168,35 @@ export async function GET(req: Request) {
167
168
  actions.push('If using cloud providers, add an API key in the setup wizard or Settings → Providers.')
168
169
  }
169
170
 
171
+ // Check for undecryptable credentials (CREDENTIAL_SECRET may have changed)
172
+ if (credentials.length > 0) {
173
+ try {
174
+ const { resolveCredentialSecret } = await import('@/lib/server/credentials/credential-service')
175
+ let undecryptable = 0
176
+ for (const cred of credentials) {
177
+ const c = cred as Record<string, unknown>
178
+ if (c.encryptedKey && !resolveCredentialSecret(c.id as string)) undecryptable++
179
+ }
180
+ if (undecryptable > 0) {
181
+ pushCheck(checks, 'credential-decrypt', 'Credential decryption', 'fail',
182
+ `${undecryptable} of ${credentials.length} credential(s) cannot be decrypted. CREDENTIAL_SECRET may have changed since these keys were stored.`, true)
183
+ actions.push('Your CREDENTIAL_SECRET changed (possibly from a container restart). Re-add your API keys in Settings → Providers.')
184
+ }
185
+ } catch { /* best-effort */ }
186
+ }
187
+
188
+ // Check for dangling credential references in gateway profiles
189
+ try {
190
+ const gateways = Object.values(loadCollection('gateway_profiles') || {}) as Array<Record<string, unknown>>
191
+ const dangling = gateways.filter((gw) => gw.credentialId && !credentialIds.has(gw.credentialId as string))
192
+ if (dangling.length > 0) {
193
+ const names = dangling.map((gw) => gw.name || gw.id).join(', ')
194
+ pushCheck(checks, 'gateway-credentials', 'Gateway credential references', 'warn',
195
+ `${dangling.length} gateway profile(s) reference missing credentials: ${names}. This causes "gateway token missing" errors.`)
196
+ actions.push('Re-add the missing API key in Settings → Gateways, or update the gateway profile to use an existing credential.')
197
+ }
198
+ } catch { /* best-effort */ }
199
+
170
200
  const optionalBinaries: Array<{ id: string; label: string; command: string }> = [
171
201
  { id: 'claude-cli', label: 'Claude Code CLI', command: 'claude' },
172
202
  { id: 'codex-cli', label: 'OpenAI Codex CLI', command: 'codex' },
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { execSync } from 'child_process'
3
+ import { getDb } from '@/lib/server/storage'
3
4
 
4
5
  const RELEASE_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
5
6
 
@@ -7,6 +8,19 @@ function run(cmd: string): string {
7
8
  return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 60_000 }).trim()
8
9
  }
9
10
 
11
+ /**
12
+ * Checkpoint the SQLite WAL before operations that replace native modules
13
+ * (npm install rebuilds better-sqlite3). Without this, an unclean WAL state
14
+ * combined with a replaced native binary can corrupt the database on Linux.
15
+ */
16
+ function checkpointDatabase(): void {
17
+ try {
18
+ getDb().pragma('wal_checkpoint(TRUNCATE)')
19
+ } catch {
20
+ // Best-effort — the database may already be in a good state.
21
+ }
22
+ }
23
+
10
24
  function getLatestStableTag(): string | null {
11
25
  const tags = run(`git tag --list 'v*' --sort=-v:refname`)
12
26
  .split('\n')
@@ -67,6 +81,9 @@ export async function POST() {
67
81
  try {
68
82
  const diff = run(`git diff --name-only ${beforeSha}..HEAD`)
69
83
  if (diff.includes('package-lock.json') || diff.includes('package.json')) {
84
+ // Checkpoint WAL before npm install — the postinstall hook rebuilds
85
+ // better-sqlite3's native module, which can corrupt an open WAL journal.
86
+ checkpointDatabase()
70
87
  run('npm install --omit=dev')
71
88
  installedDeps = true
72
89
  }
@@ -9,11 +9,6 @@ interface AccessKeyGateProps {
9
9
  }
10
10
 
11
11
  const AUTH_CHECK_TIMEOUT_MS = 8_000
12
- const NETWORK_LINKS = [
13
- { href: 'https://www.swarmdock.ai', label: 'SwarmDock' },
14
- { href: 'https://swarmrecall.ai', label: 'SwarmRecall' },
15
- { href: 'https://swarmrelay.ai', label: 'SwarmRelay' },
16
- ]
17
12
 
18
13
  function isExpectedAuthCheckError(err: unknown): boolean {
19
14
  return isAbortError(err) || isTimeoutError(err)
@@ -427,25 +422,6 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
427
422
  </>
428
423
  )}
429
424
 
430
- <div className="mt-10 border-t border-white/[0.06] pt-5" style={{ animation: 'fade-up 0.6s var(--ease-spring) 0.35s both' }}>
431
- <p className="text-[10px] font-700 uppercase tracking-[0.18em] text-text-3/55">
432
- Network
433
- </p>
434
- <div className="mt-3 flex flex-wrap items-center justify-center gap-2.5">
435
- {NETWORK_LINKS.map((link) => (
436
- <a
437
- key={link.href}
438
- href={link.href}
439
- target="_blank"
440
- rel="noopener noreferrer"
441
- className="rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-[12px] text-text-3
442
- no-underline transition-all duration-200 hover:border-white/[0.14] hover:bg-white/[0.06] hover:text-text"
443
- >
444
- {link.label}
445
- </a>
446
- ))}
447
- </div>
448
- </div>
449
425
  </div>
450
426
  </div>
451
427
  )
@@ -17,11 +17,6 @@ import type { AppView } from '@/types'
17
17
  const RAIL_EXPANDED_KEY = 'sc_rail_expanded'
18
18
  const GITHUB_REPO_URL = 'https://github.com/swarmclawai/swarmclaw'
19
19
  const DISCORD_URL = 'https://discord.gg/sbEavS8cPV'
20
- const NETWORK_LINKS = [
21
- { href: 'https://www.swarmdock.ai', label: 'SwarmDock', abbr: 'DO' },
22
- { href: 'https://swarmrecall.ai', label: 'SwarmRecall', abbr: 'RE' },
23
- { href: 'https://swarmrelay.ai', label: 'SwarmRelay', abbr: 'RL' },
24
- ]
25
20
 
26
21
  export function SidebarRail({
27
22
  onSwitchUser,
@@ -394,48 +389,6 @@ export function SidebarRail({
394
389
 
395
390
  {/* Bottom: Docs + Daemon + Settings + User */}
396
391
  <div className={`flex flex-col gap-1 ${railExpanded ? 'px-3' : 'items-center'}`}>
397
- {railExpanded ? (
398
- <div className="mb-1">
399
- <div className="px-3 pb-1 text-[10px] font-700 uppercase tracking-[0.12em] text-text-3/45">Network</div>
400
- <div className="flex flex-col gap-1">
401
- {NETWORK_LINKS.map((link) => (
402
- <a
403
- key={link.href}
404
- href={link.href}
405
- target="_blank"
406
- rel="noopener noreferrer"
407
- className="w-full flex items-center gap-2.5 px-3 py-2 rounded-[10px] text-[13px] font-500 cursor-pointer transition-all
408
- bg-transparent text-text-3 hover:text-text hover:bg-white/[0.04] no-underline"
409
- style={{ fontFamily: 'inherit' }}
410
- >
411
- <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[6px] border border-white/[0.08] bg-white/[0.03] text-[10px] font-700 uppercase tracking-[0.08em] text-text-3/80">
412
- {link.abbr}
413
- </span>
414
- {link.label}
415
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="ml-auto opacity-40">
416
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
417
- </svg>
418
- </a>
419
- ))}
420
- </div>
421
- </div>
422
- ) : (
423
- <>
424
- <div className="my-1 h-px w-6 bg-white/[0.06]" />
425
- {NETWORK_LINKS.map((link) => (
426
- <RailTooltip key={link.href} label={link.label} description="Open product site in a new tab">
427
- <a
428
- href={link.href}
429
- target="_blank"
430
- rel="noopener noreferrer"
431
- className="rail-btn text-[10px] font-700 uppercase tracking-[0.08em] no-underline"
432
- >
433
- {link.abbr}
434
- </a>
435
- </RailTooltip>
436
- ))}
437
- </>
438
- )}
439
392
  {railExpanded ? (
440
393
  <a
441
394
  href="https://swarmclaw.ai/docs"
@@ -9,6 +9,9 @@ import {
9
9
  loadCredentials,
10
10
  saveCredential,
11
11
  } from '@/lib/server/credentials/credential-repository'
12
+ import { log } from '@/lib/server/logger'
13
+
14
+ const TAG = 'credential-service'
12
15
 
13
16
  export type CredentialSummary = Pick<Credential, 'id' | 'provider' | 'name' | 'createdAt'>
14
17
 
@@ -55,7 +58,12 @@ export function resolveCredentialSecret(credentialId: string | null | undefined)
55
58
  if (!credential?.encryptedKey) return null
56
59
  try {
57
60
  return decryptKey(credential.encryptedKey)
58
- } catch {
61
+ } catch (err) {
62
+ log.warn(TAG, `Failed to decrypt credential "${id}" — CREDENTIAL_SECRET may have changed since this key was stored. Re-add the API key to fix.`, {
63
+ credentialId: id,
64
+ provider: credential.provider,
65
+ error: err instanceof Error ? err.message : String(err),
66
+ })
59
67
  return null
60
68
  }
61
69
  }
@@ -81,7 +81,14 @@ function normalizeWsUrl(raw: string): string {
81
81
  }
82
82
 
83
83
  function resolveTokenForCredential(credentialId?: string | null): string | undefined {
84
- return resolveCredentialSecret(credentialId) || undefined
84
+ if (!credentialId) return undefined
85
+ const secret = resolveCredentialSecret(credentialId)
86
+ if (!secret) {
87
+ log.warn(TAG, `Credential "${credentialId}" is referenced but could not be resolved — gateway connection will lack a token`, {
88
+ credentialId,
89
+ })
90
+ }
91
+ return secret || undefined
85
92
  }
86
93
 
87
94
  export function resolveGatewayConfig(target?: {
@@ -94,6 +94,21 @@ if (!IS_BUILD_BOOTSTRAP) {
94
94
  }
95
95
  db.pragma('foreign_keys = ON')
96
96
 
97
+ // Graceful shutdown: checkpoint WAL and close the database to prevent
98
+ // corruption when the process is killed (e.g. during npm run update:easy).
99
+ if (!IS_BUILD_BOOTSTRAP) {
100
+ const shutdownDb = () => {
101
+ try {
102
+ db.pragma('wal_checkpoint(TRUNCATE)')
103
+ db.close()
104
+ } catch {
105
+ // Best-effort — process is exiting.
106
+ }
107
+ }
108
+ process.on('SIGTERM', shutdownDb)
109
+ process.on('SIGINT', shutdownDb)
110
+ }
111
+
97
112
  /** Run a function inside an immediate SQLite transaction for atomicity. */
98
113
  export function withTransaction<T>(fn: () => T): T {
99
114
  const wrapped = db.transaction(fn)