@swarmclawai/swarmclaw 1.2.2 → 1.2.3

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
@@ -190,6 +190,13 @@ The building blocks are the same: **agents, tools, memory, delegation, schedules
190
190
 
191
191
  ## Release Notes
192
192
 
193
+ ### v1.2.3 Highlights
194
+
195
+ - **Standalone asset staging repair**: `swarmclaw server` now copies `.next/static` and `public/` into the Next.js standalone runtime after the first build, preventing blank UI loads and 503s for CSS, JS, and image assets.
196
+ - **OpenClaw SSH port fix**: remote OpenClaw deploys now preserve well-known SSH ports like `22` instead of clamping them to `1024`.
197
+ - **OpenClaw image source fix**: generated remote deploy bundles and default upgrade actions now use the official `ghcr.io/openclaw/openclaw:latest` image instead of the missing Docker Hub shorthand.
198
+ - **Standalone self-healing**: server startup now repairs older incomplete standalone bundles by staging missing runtime assets before launching `server.js`.
199
+
193
200
  ### v1.2.2 Highlights
194
201
 
195
202
  - **Modular chat execution pipeline**: decomposed the monolithic chat-execution module into 6 focused stages (preflight, preparation, stream execution, partial persistence, finalization, types) for maintainability and testability.
@@ -226,91 +233,6 @@ The building blocks are the same: **agents, tools, memory, delegation, schedules
226
233
  - **Playwright proxy hardening**: improved stdio pipe handling for dev server restarts.
227
234
  - **Scheduler and run ledger fixes**: improved scheduler reliability and run ledger state tracking.
228
235
 
229
- ### v1.1.8 Highlights
230
-
231
- - **Agent live status**: real-time `/agents/:id/status` endpoint exposes goal, progress, and plan steps; org chart detail panel consumes it via `useAgentLiveStatus` hook.
232
- - **Learned skills lifecycle**: promote, dismiss, and delete learned skills via `/learned-skills/:id`; `/skill-review-counts` provides badge counts for the skills workspace.
233
- - **Gemini CLI provider**: Google Gemini CLI joins the provider roster alongside claude-cli and codex-cli, with shared CLI utilities factored into `cli-utils.ts`.
234
- - **Peer query & team context tools**: new session tools let agents query peers and access team context during conversations.
235
- - **Team resolution**: dedicated `team-resolution.ts` module resolves agent teams for delegation routing.
236
- - **Org chart activity feed**: timeline feed component and delegation bubble visualization for the org chart view.
237
- - **Skills workspace improvements**: expanded skills management UI with review-ready badges.
238
- - **Cost trend chart**: new dashboard component for cost visualization.
239
- - **Streaming fix**: text no longer gets stuck on the thinking indicator.
240
- - **Delegation normalization**: `delegationEnabled` now derived from agent role, removed from starter kit templates.
241
- - **Chat execution refinements**: improved continuation limits, post-stream finalization, and stream continuation.
242
- - **Memory and storage improvements**: memory tier management, consolidation enhancements, and storage cache updates.
243
- - **WebSocket and provider health**: improved WS client handling, delegation edge state, and provider health monitoring.
244
-
245
- ### v1.1.7 Highlights
246
-
247
- - **Projects page redesign**: tabbed navigation (Overview, Work, Operations, Activity) with health grid, sortable task list, and timeline feed.
248
- - **Delegation visualization**: live org chart edges show active delegations with status, direction, and message popover on click.
249
- - **Credential self-service**: agents can check whether a credential exists and request missing ones from humans with structured messages, signup URLs, and durable wait.
250
- - **Main loop state persistence**: autonomous operation state now survives server restarts via on-disk persistence.
251
- - **Internal metadata stripping**: classification JSON and loop detection messages no longer leak into streamed agent output.
252
- - **Response completeness evaluator**: LLM-based detection of incomplete agent responses triggers continuation nudges.
253
- - **Coordinator delegation nudging**: coordinators that make 3+ direct tool calls get prompted to delegate to workers.
254
- - **Inspector panel overhaul**: new dashboard/config/files tabs absorb model switcher and workspace controls from chat header.
255
- - **Streaming phase indicators**: agent chat list shows queued, tool-in-use, responding, and reconnecting states.
256
- - **Shell safety**: agents can no longer kill SwarmClaw's own process or port.
257
- - **Worker-only providers**: CLI-backed providers (claude-cli, codex-cli, etc.) properly restricted from coordinator/heartbeat roles.
258
- - **HTTP tool removed**: the built-in HTTP session tool was removed from the standard toolkit.
259
-
260
- ### v1.1.6 Highlights
261
-
262
- - **Org chart view**: visual agent hierarchy with drag-and-drop reparenting, team grouping, and context-menu actions for managing agent relationships directly from the canvas.
263
- - **Dashboard API**: server-side metrics endpoint with cost tracking, usage aggregation, and budget warning thresholds for operator visibility.
264
- - **Subagent lifecycle overhaul**: state-machine lineage tracking, `delegationDepth` limits, auto-announce on spawn, and cleaner parent-child session management.
265
- - **Chat execution refactor**: composable prompt sections replace monolithic prompt building, continuation evaluator consolidation, and extracted stream-continuation logic for maintainability.
266
- - **Per-agent cost attribution**: token costs are tracked and attributed per agent, enabling budget controls and cost reporting at the agent level.
267
- - **Capability-based task routing**: tasks can match agents by declared capabilities, not just explicit assignment, enabling smarter automatic dispatch.
268
- - **Bulk agent operations**: new `/api/agents/bulk` endpoint for batch updates across multiple agents in a single request.
269
- - **Document revisions API**: version history for documents with `/api/documents/[id]/revisions` endpoint.
270
- - **Store loader consolidation**: async loaders now use `createLoader()` and `setIfChanged` to eliminate redundant re-renders from polling.
271
-
272
- ### v1.1.4 Highlights
273
-
274
- - **Orchestrator agents return as a first-class autonomy mode**: eligible agents can now run scheduled orchestrator wake cycles with their own mission, governance policy, wake interval, cycle cap, Autonomy-desk controls, and setup/editor support.
275
- - **Runtime durability is much harder to knock over**: the task queue now supports parallel execution with restart-safe swarm state, orphaned running-task recovery, stuck-task idle timeout detection, and provider-health persistence across daemon restarts.
276
- - **Recovery and safety paths are tighter**: provider errors are classified for smarter failover, unavailable agents defer work instead of burning it, supervisor blocks can create executable notifications, and agent budget limits now gate task execution before work starts.
277
- - **Temporary session rooms are easier to inspect**: chatrooms now split persistent rooms from temporary session-style rooms so orchestrator or structured-session conversations can stay visible without polluting the normal room list.
278
-
279
- ### v1.1.3 Highlights
280
-
281
- - **Release integrity repair**: `build:ci` no longer trips over the langgraph checkpoint duplicate-column path, which restores clean build validation for the release line.
282
- - **Storage writes are safer**: credential and agent saves were tightened to upsert-only behavior and bulk-delete safety guards so tests or scripts cannot accidentally wipe live state.
283
- - **Plugin-to-extension cleanup finished**: remaining rename residue in scripts and tests was cleaned up so packaging and release tooling stay aligned with the current extensions model.
284
- - **Safe body parsing utility**: shared `safeParseBody()` replaces scattered `await req.json()` try/catch blocks across API routes.
285
-
286
- ### v1.1.2 Highlights
287
-
288
- - **Structured Sessions expanded into richer orchestration**: ProtocolRun-based sessions now support dependency-aware step graphs, reusable step outputs, and a broader advanced execution model on the same durable runtime instead of bringing back a separate orchestrator.
289
- - **Explicit Ollama local/cloud routing**: agents and sessions now persist the user-selected Ollama mode directly, so local Ollama no longer flips to cloud because of model naming or leftover credentials.
290
- - **Chat and runtime regression hardening**: live-streamed inline media, stale streaming cleanup, exact-output handling, and chat rendering bugs were tightened again, including the recent message-row and avatar rendering regressions.
291
- - **Nebius and DeepInfra as built-in providers**: both are now first-class providers with setup wizard entries, model discovery, and pre-configured defaults instead of requiring the custom provider workaround.
292
- - **`stream_options` resilience**: the OpenAI-compatible streaming handler now retries without `stream_options` if a provider rejects it with 400, fixing connectivity for strict endpoints.
293
-
294
- ### v1.1.1 Highlights
295
-
296
- - **Structured Sessions are now contextual**: start bounded structured runs from direct chats, chatrooms, tasks, missions, or schedules, including a new chatroom `/breakout` command that spins up a focused session from the current room with auto-filled participants and kickoff context.
297
- - **ProtocolRun orchestration matured**: structured sessions now run on the same durable engine for step-based branching, repeat loops, parallel branches, and explicit joins instead of growing a separate orchestration subsystem.
298
- - **Live-agent runtime hardening**: exact-output contracts, memory preflight behavior, same-channel delivery rendering, inline media, and grounded runtime inspection were all tightened through live-agent validation before release.
299
-
300
- ### v1.1.0 Highlights
301
-
302
- - **Mission controller and Missions UI**: SwarmClaw now tracks durable multi-step objectives as missions with status, phase, linked tasks, queued turns, recent runs, event history, and operator actions from the new **Missions** surface.
303
- - **Autonomy safety desk and run replay**: the new **Autonomy Control** page adds estop visibility, resume policy controls, incident review, and run replay backed by durable run history rather than transient in-memory state.
304
- - **Durable queued follow-ups**: direct chat and connector follow-up turns now use a backend queue so queued work survives reloads, drains in order, and stays attached to the right mission/session context.
305
- - **Chat execution and UX hardening**: streamed handoff, memory writes, inline media, queue state, and tool-policy fallback behavior were cleaned up so agents are less noisy, less brittle, and easier to follow in real chats.
306
-
307
- ### v1.0.9 Highlights
308
-
309
- - **Quieter chat and inbox replies**: chat-origin and connector turns now suppress more hidden control text, stop replaying connector-tool output as normal assistant prose, and avoid extra empty follow-up chatter after successful tool work.
310
- - **Sender-aware direct inbox replies**: direct connector sessions can honor stored sender display names and reply-medium preferences, including voice-note-first replies when the connector supports binary media and the agent has a configured voice.
311
- - **Cleaner connector delivery reconciliation**: connector delivery markers now track what was actually sent, response previews prefer the delivered transcript, and task/connector followups resolve local output files more reliably.
312
- - **Memory-write followthrough hardening**: successful memory store/update turns terminate more cleanly, which reduces unnecessary post-tool loops while still allowing a natural acknowledgement when the user needs one.
313
-
314
236
  ## What SwarmClaw Focuses On
315
237
 
316
238
  - **Delegation, orchestrators, and background execution**: delegated work, orchestrator agents, subagents, durable jobs, checkpointing, and background task execution.
package/bin/server-cmd.js CHANGED
@@ -47,6 +47,10 @@ function ensureDir(dir) {
47
47
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
48
48
  }
49
49
 
50
+ function resolveStandaloneRuntimeDir(serverJs) {
51
+ return path.dirname(serverJs)
52
+ }
53
+
50
54
  function log(msg) {
51
55
  process.stdout.write(`[swarmclaw] ${msg}\n`)
52
56
  }
@@ -206,6 +210,51 @@ function symlinkDir(targetPath, linkPath) {
206
210
  fs.symlinkSync(targetPath, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
207
211
  }
208
212
 
213
+ function syncStandaloneRuntimeAssets({
214
+ sourceRoot,
215
+ runtimeDir,
216
+ force = false,
217
+ } = {}) {
218
+ const assets = [
219
+ {
220
+ key: 'staticCopied',
221
+ sourcePath: path.join(sourceRoot, '.next', 'static'),
222
+ targetPath: path.join(runtimeDir, '.next', 'static'),
223
+ optional: false,
224
+ },
225
+ {
226
+ key: 'publicCopied',
227
+ sourcePath: path.join(sourceRoot, 'public'),
228
+ targetPath: path.join(runtimeDir, 'public'),
229
+ optional: true,
230
+ },
231
+ ]
232
+
233
+ const result = {
234
+ staticCopied: false,
235
+ publicCopied: false,
236
+ }
237
+
238
+ for (const asset of assets) {
239
+ if (!fs.existsSync(asset.sourcePath)) {
240
+ if (!asset.optional) result[asset.key] = false
241
+ continue
242
+ }
243
+ if (!force && fs.existsSync(asset.targetPath)) continue
244
+
245
+ ensureDir(path.dirname(asset.targetPath))
246
+ fs.rmSync(asset.targetPath, { recursive: true, force: true })
247
+ fs.cpSync(asset.sourcePath, asset.targetPath, {
248
+ recursive: true,
249
+ force: true,
250
+ dereference: true,
251
+ })
252
+ result[asset.key] = true
253
+ }
254
+
255
+ return result
256
+ }
257
+
209
258
  function prepareBuildWorkspace({ pkgRoot = PKG_ROOT, buildRoot = resolvePackageBuildRoot(pkgRoot), nodeModulesDir } = {}) {
210
259
  copyBuildWorkspaceContents(pkgRoot, buildRoot)
211
260
  symlinkDir(nodeModulesDir, path.join(buildRoot, 'node_modules'))
@@ -285,6 +334,15 @@ function runBuild({ pkgRoot = PKG_ROOT } = {}) {
285
334
  },
286
335
  })
287
336
 
337
+ const standalone = locateStandaloneServer({ pkgRoot: buildRoot })
338
+ if (standalone) {
339
+ syncStandaloneRuntimeAssets({
340
+ sourceRoot: buildRoot,
341
+ runtimeDir: resolveStandaloneRuntimeDir(standalone.serverJs),
342
+ force: true,
343
+ })
344
+ }
345
+
288
346
  log('Build complete.')
289
347
  }
290
348
 
@@ -306,10 +364,12 @@ async function startServer(opts, { pkgRoot = PKG_ROOT } = {}) {
306
364
  logError('Standalone server.js not found in the installed package. Try running: swarmclaw server --build')
307
365
  process.exit(1)
308
366
  }
309
- const { root: runtimeRoot, serverJs } = standalone
367
+ const { root: buildRoot, serverJs } = standalone
368
+ const runtimeRoot = resolveStandaloneRuntimeDir(serverJs)
310
369
 
311
370
  ensureDir(SWARMCLAW_HOME)
312
371
  ensureDir(DATA_DIR)
372
+ syncStandaloneRuntimeAssets({ sourceRoot: buildRoot, runtimeDir: runtimeRoot })
313
373
 
314
374
  const port = opts.port || '3456'
315
375
  const wsPort = opts.wsPort || String(Number(port) + 1)
@@ -328,6 +388,7 @@ async function startServer(opts, { pkgRoot = PKG_ROOT } = {}) {
328
388
 
329
389
  log(`Starting server on ${host}:${port} (WebSocket: ${wsPort})...`)
330
390
  log(`Package root: ${pkgRoot}`)
391
+ log(`Build root: ${buildRoot}`)
331
392
  log(`Runtime root: ${runtimeRoot}`)
332
393
  log(`Home: ${SWARMCLAW_HOME}`)
333
394
  log(`Data directory: ${DATA_DIR}`)
@@ -545,6 +606,8 @@ module.exports = {
545
606
  resolveReadyCheckHost,
546
607
  resolveStandaloneCandidateRoots,
547
608
  resolveStandaloneBase,
609
+ resolveStandaloneRuntimeDir,
548
610
  runBuild,
611
+ syncStandaloneRuntimeAssets,
549
612
  waitForPortReady,
550
613
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Self-hosted AI runtime for OpenClaw, delegation, autonomy, runtime skills, crypto wallets, and chat platform connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -136,6 +136,80 @@ test('prepareBuildWorkspace copies the package tree and links node_modules outsi
136
136
  fs.rmSync(externalNodeModules, { recursive: true, force: true })
137
137
  })
138
138
 
139
+ test('syncStandaloneRuntimeAssets copies .next/static and public into a direct standalone runtime', () => {
140
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
141
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
142
+ const serverCmd = loadServerCmdForHome(homeDir)
143
+ const runtimeDir = path.join(pkgRoot, '.next', 'standalone')
144
+
145
+ fs.mkdirSync(path.join(pkgRoot, '.next', 'static', 'chunks'), { recursive: true })
146
+ fs.mkdirSync(path.join(pkgRoot, 'public', 'branding'), { recursive: true })
147
+ fs.writeFileSync(path.join(pkgRoot, '.next', 'static', 'chunks', 'app.js'), 'chunk\n', 'utf8')
148
+ fs.writeFileSync(path.join(pkgRoot, 'public', 'branding', 'logo.svg'), '<svg />\n', 'utf8')
149
+
150
+ const result = serverCmd.syncStandaloneRuntimeAssets({
151
+ sourceRoot: pkgRoot,
152
+ runtimeDir,
153
+ force: true,
154
+ })
155
+
156
+ assert.deepEqual(result, { staticCopied: true, publicCopied: true })
157
+ assert.equal(fs.readFileSync(path.join(runtimeDir, '.next', 'static', 'chunks', 'app.js'), 'utf8'), 'chunk\n')
158
+ assert.equal(fs.readFileSync(path.join(runtimeDir, 'public', 'branding', 'logo.svg'), 'utf8'), '<svg />\n')
159
+
160
+ fs.rmSync(homeDir, { recursive: true, force: true })
161
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
162
+ })
163
+
164
+ test('syncStandaloneRuntimeAssets targets the resolved nested runtime directory', () => {
165
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
166
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
167
+ const serverCmd = loadServerCmdForHome(homeDir)
168
+ const serverJs = path.join(pkgRoot, '.next', 'standalone', 'Users', 'wayde', 'Dev', 'swarmclaw', 'server.js')
169
+ const runtimeDir = serverCmd.resolveStandaloneRuntimeDir(serverJs)
170
+
171
+ fs.mkdirSync(path.dirname(serverJs), { recursive: true })
172
+ fs.writeFileSync(serverJs, 'console.log("ok")\n', 'utf8')
173
+ fs.mkdirSync(path.join(pkgRoot, '.next', 'static', 'css'), { recursive: true })
174
+ fs.writeFileSync(path.join(pkgRoot, '.next', 'static', 'css', 'app.css'), 'body{}\n', 'utf8')
175
+
176
+ const result = serverCmd.syncStandaloneRuntimeAssets({
177
+ sourceRoot: pkgRoot,
178
+ runtimeDir,
179
+ })
180
+
181
+ assert.deepEqual(result, { staticCopied: true, publicCopied: false })
182
+ assert.equal(fs.readFileSync(path.join(runtimeDir, '.next', 'static', 'css', 'app.css'), 'utf8'), 'body{}\n')
183
+ assert.equal(fs.existsSync(path.join(runtimeDir, 'public')), false)
184
+
185
+ fs.rmSync(homeDir, { recursive: true, force: true })
186
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
187
+ })
188
+
189
+ test('syncStandaloneRuntimeAssets repairs missing assets without overwriting an existing target by default', () => {
190
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
191
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
192
+ const serverCmd = loadServerCmdForHome(homeDir)
193
+ const runtimeDir = path.join(pkgRoot, '.next', 'standalone')
194
+
195
+ fs.mkdirSync(path.join(pkgRoot, '.next', 'static', 'chunks'), { recursive: true })
196
+ fs.writeFileSync(path.join(pkgRoot, '.next', 'static', 'chunks', 'main.js'), 'fresh\n', 'utf8')
197
+ fs.mkdirSync(path.join(runtimeDir, 'public'), { recursive: true })
198
+ fs.writeFileSync(path.join(runtimeDir, 'public', 'keep.txt'), 'keep\n', 'utf8')
199
+
200
+ const result = serverCmd.syncStandaloneRuntimeAssets({
201
+ sourceRoot: pkgRoot,
202
+ runtimeDir,
203
+ })
204
+
205
+ assert.deepEqual(result, { staticCopied: true, publicCopied: false })
206
+ assert.equal(fs.readFileSync(path.join(runtimeDir, '.next', 'static', 'chunks', 'main.js'), 'utf8'), 'fresh\n')
207
+ assert.equal(fs.readFileSync(path.join(runtimeDir, 'public', 'keep.txt'), 'utf8'), 'keep\n')
208
+
209
+ fs.rmSync(homeDir, { recursive: true, force: true })
210
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
211
+ })
212
+
139
213
  test('resolveReadyCheckHost maps wildcard bind hosts to loopback', () => {
140
214
  const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
141
215
  const serverCmd = loadServerCmdForHome(homeDir)
@@ -851,6 +851,8 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
851
851
  <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Port</label>
852
852
  <input
853
853
  type="number"
854
+ min={1024}
855
+ max={65535}
854
856
  value={localPort}
855
857
  onChange={(e) => setLocalPort(Number.parseInt(e.target.value, 10) || 18789)}
856
858
  className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text outline-none focus:border-accent-bright/30"
@@ -1168,6 +1170,8 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
1168
1170
  />
1169
1171
  <input
1170
1172
  type="number"
1173
+ min={1}
1174
+ max={65535}
1171
1175
  value={sshPort}
1172
1176
  onChange={(e) => setSshPort(Number.parseInt(e.target.value, 10) || 22)}
1173
1177
  placeholder="22"
@@ -6,6 +6,8 @@ import {
6
6
  getOpenClawLocalDeployStatus,
7
7
  getOpenClawRemoteDeployCollectionStatus,
8
8
  getOpenClawRemoteDeployStatus,
9
+ sanitizeLocalPort,
10
+ sanitizeSshConfig,
9
11
  } from './deploy'
10
12
 
11
13
  const GLOBAL_KEY = '__swarmclaw_openclaw_deploy__' as const
@@ -42,13 +44,17 @@ test('docker smart deploy bundle uses official image and provider-specific metad
42
44
 
43
45
  const envFile = bundle.files.find((file) => file.name === '.env')
44
46
  assert.ok(envFile)
45
- assert.match(envFile.content, /OPENCLAW_IMAGE=openclaw:latest/)
47
+ assert.match(envFile.content, /OPENCLAW_IMAGE=ghcr\.io\/openclaw\/openclaw:latest/)
46
48
  assert.match(envFile.content, /OPENCLAW_GATEWAY_TOKEN=test-token/)
47
49
 
50
+ const dockerCompose = bundle.files.find((file) => file.name === 'docker-compose.yml')
51
+ assert.ok(dockerCompose)
52
+ assert.match(dockerCompose.content, /image: \$\{OPENCLAW_IMAGE:-ghcr\.io\/openclaw\/openclaw:latest\}/)
53
+
48
54
  const cloudInit = bundle.files.find((file) => file.name === 'cloud-init.yaml')
49
55
  assert.ok(cloudInit)
50
56
  assert.match(cloudInit.content, /docker\.io/)
51
- assert.match(cloudInit.content, /docker pull "\$\{OPENCLAW_IMAGE:-openclaw:latest\}"/)
57
+ assert.match(cloudInit.content, /docker pull "\$\{OPENCLAW_IMAGE:-ghcr\.io\/openclaw\/openclaw:latest\}"/)
52
58
  assert.match(cloudInit.content, /\/opt\/openclaw\/docker-compose\.yml/)
53
59
 
54
60
  const caddyfile = bundle.files.find((file) => file.name === 'Caddyfile')
@@ -74,6 +80,39 @@ test('render bundle stays aligned with the official repo flow', () => {
74
80
  assert.match(bundle.runbook[0] || '', /official OpenClaw GitHub repo/i)
75
81
  })
76
82
 
83
+ test('remote bundle preserves low HTTP ports below 1024', () => {
84
+ const bundle = buildOpenClawDeployBundle({
85
+ template: 'docker',
86
+ target: 'gateway.example.com',
87
+ scheme: 'http',
88
+ port: 81,
89
+ })
90
+
91
+ assert.equal(bundle.endpoint, 'http://gateway.example.com:81/v1')
92
+ assert.equal(bundle.wsUrl, 'ws://gateway.example.com:81')
93
+ })
94
+
95
+ test('ssh config preserves port 22', () => {
96
+ const config = sanitizeSshConfig({
97
+ host: 'gateway.example.com',
98
+ port: 22,
99
+ })
100
+
101
+ assert.deepEqual(config, {
102
+ host: 'gateway.example.com',
103
+ user: 'root',
104
+ port: 22,
105
+ keyPath: null,
106
+ targetDir: '/opt/openclaw',
107
+ })
108
+ })
109
+
110
+ test('local managed deploy ports stay clamped to unprivileged values', () => {
111
+ assert.equal(sanitizeLocalPort(22), 1024)
112
+ assert.equal(sanitizeLocalPort('443'), 1024)
113
+ assert.equal(sanitizeLocalPort(18789), 18789)
114
+ })
115
+
77
116
  test('local deploy status exposes a sensible default endpoint before startup', () => {
78
117
  const status = getOpenClawLocalDeployStatus()
79
118
  const collection = getOpenClawLocalDeployCollectionStatus()
@@ -159,7 +198,7 @@ test('remote deploy collection preserves multiple remotes and targeted lookup',
159
198
  createdAt: 30,
160
199
  updatedAt: 40,
161
200
  lastError: null,
162
- lastSummary: 'Pulling openclaw:latest and recreating the OpenClaw stack on beta.example.com.',
201
+ lastSummary: 'Pulling ghcr.io/openclaw/openclaw:latest and recreating the OpenClaw stack on beta.example.com.',
163
202
  lastCommandPreview: 'ssh root@beta.example.com docker compose up -d',
164
203
  lastBackupPath: null,
165
204
  },
@@ -192,6 +192,7 @@ interface ExposureMeta {
192
192
 
193
193
  const DEFAULT_LOCAL_PORT = 18789
194
194
  const DEFAULT_REMOTE_PORT = 18789
195
+ const DEFAULT_OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
195
196
  const OC_DEPLOY_KEY = '__swarmclaw_openclaw_deploy__'
196
197
 
197
198
  const REMOTE_PROVIDER_META: Record<OpenClawRemoteDeployProvider, RemoteProviderMeta> = {
@@ -604,14 +605,27 @@ function buildLocalInstallCommand(port: number, token?: string | null, localId =
604
605
  return `${parts.join(' ')} && npx openclaw gateway start`
605
606
  }
606
607
 
607
- function sanitizePort(value: unknown, fallback = DEFAULT_LOCAL_PORT): number {
608
+ function parsePortNumber(value: unknown): number | null {
608
609
  const parsed = typeof value === 'number'
609
610
  ? value
610
611
  : typeof value === 'string'
611
612
  ? Number.parseInt(value, 10)
612
613
  : Number.NaN
613
- if (!Number.isFinite(parsed)) return fallback
614
- return Math.max(1024, Math.min(65535, Math.trunc(parsed)))
614
+ return Number.isFinite(parsed) ? parsed : null
615
+ }
616
+
617
+ function sanitizePortInRange(value: unknown, fallback: number, minimum: number): number {
618
+ const parsed = parsePortNumber(value)
619
+ if (parsed === null) return fallback
620
+ return Math.max(minimum, Math.min(65535, Math.trunc(parsed)))
621
+ }
622
+
623
+ export function sanitizeLocalPort(value: unknown, fallback = DEFAULT_LOCAL_PORT): number {
624
+ return sanitizePortInRange(value, fallback, 1024)
625
+ }
626
+
627
+ function sanitizeRemotePort(value: unknown, fallback = DEFAULT_REMOTE_PORT): number {
628
+ return sanitizePortInRange(value, fallback, 1)
615
629
  }
616
630
 
617
631
  function normalizeToken(value: unknown): string | null {
@@ -666,10 +680,10 @@ function normalizeExposurePreset(value: unknown, fallback?: OpenClawUseCaseTempl
666
680
  return useCase?.defaultExposure || 'private-lan'
667
681
  }
668
682
 
669
- function sanitizeSshConfig(input?: Partial<OpenClawSshConfig> | null): OpenClawSshConfig | null {
683
+ export function sanitizeSshConfig(input?: Partial<OpenClawSshConfig> | null): OpenClawSshConfig | null {
670
684
  const host = typeof input?.host === 'string' && input.host.trim() ? input.host.trim() : ''
671
685
  if (!host) return null
672
- const port = sanitizePort(input?.port, 22)
686
+ const port = sanitizePortInRange(input?.port, 22, 1)
673
687
  return {
674
688
  host,
675
689
  user: typeof input?.user === 'string' && input.user.trim() ? input.user.trim() : 'root',
@@ -1034,7 +1048,7 @@ export async function startOpenClawLocalDeploy(input?: {
1034
1048
  makePrimary?: boolean
1035
1049
  }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string }> {
1036
1050
  const state = getRuntimeState()
1037
- const port = sanitizePort(input?.port, DEFAULT_LOCAL_PORT)
1051
+ const port = sanitizeLocalPort(input?.port, DEFAULT_LOCAL_PORT)
1038
1052
  const requestedLocalId = typeof input?.localId === 'string' && input.localId.trim()
1039
1053
  ? input.localId.trim()
1040
1054
  : null
@@ -1238,7 +1252,7 @@ function resolveHostBindAddress(useCase: OpenClawUseCaseTemplate, exposure: Open
1238
1252
  function buildDockerComposeFile(options: DockerBundleOptions): string {
1239
1253
  return `services:
1240
1254
  openclaw-gateway:
1241
- image: \${OPENCLAW_IMAGE:-openclaw:latest}
1255
+ image: \${OPENCLAW_IMAGE:-${DEFAULT_OPENCLAW_IMAGE}}
1242
1256
  environment:
1243
1257
  HOME: /home/node
1244
1258
  TERM: xterm-256color
@@ -1284,7 +1298,7 @@ function buildDockerComposeFile(options: DockerBundleOptions): string {
1284
1298
  }
1285
1299
 
1286
1300
  function buildDockerEnvFile(options: DockerBundleOptions): string {
1287
- return `OPENCLAW_IMAGE=openclaw:latest
1301
+ return `OPENCLAW_IMAGE=${DEFAULT_OPENCLAW_IMAGE}
1288
1302
  OPENCLAW_GATEWAY_TOKEN=${options.token}
1289
1303
  OPENCLAW_GATEWAY_BIND=lan
1290
1304
  OPENCLAW_HOST_BIND=${resolveHostBindAddress(options.useCase, options.exposure)}
@@ -1314,7 +1328,7 @@ if ! command -v docker >/dev/null 2>&1; then
1314
1328
  exit 1
1315
1329
  fi
1316
1330
 
1317
- docker pull "\${OPENCLAW_IMAGE:-openclaw:latest}"
1331
+ docker pull "\${OPENCLAW_IMAGE:-${DEFAULT_OPENCLAW_IMAGE}}"
1318
1332
  docker compose up -d
1319
1333
  if [ -f docker-compose.proxy.yml ]; then
1320
1334
  docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
@@ -1365,7 +1379,7 @@ ${extraFiles ? `${extraFiles}
1365
1379
  ` : ''}runcmd:
1366
1380
  - mkdir -p /opt/openclaw/.openclaw /opt/openclaw/workspace /opt/openclaw/backups
1367
1381
  - systemctl enable --now docker
1368
- - bash -lc 'cd /opt/openclaw && docker pull "\${OPENCLAW_IMAGE:-openclaw:latest}"'
1382
+ - bash -lc 'cd /opt/openclaw && docker pull "\${OPENCLAW_IMAGE:-${DEFAULT_OPENCLAW_IMAGE}}"'
1369
1383
  - bash -lc 'cd /opt/openclaw && docker compose up -d'
1370
1384
  - bash -lc 'cd /opt/openclaw && if [ -f docker-compose.proxy.yml ]; then docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d; fi'
1371
1385
  final_message: "OpenClaw gateway bootstrap complete. Run: sudo docker compose -f /opt/openclaw/docker-compose.yml ps"
@@ -1597,7 +1611,7 @@ export function buildOpenClawDeployBundle(input?: {
1597
1611
  const template = input?.template || 'docker'
1598
1612
  const token = normalizeToken(input?.token) || generateOpenClawGatewayToken()
1599
1613
  const scheme = input?.scheme === 'http' ? 'http' : 'https'
1600
- const port = sanitizePort(input?.port, DEFAULT_REMOTE_PORT)
1614
+ const port = sanitizeRemotePort(input?.port, DEFAULT_REMOTE_PORT)
1601
1615
  const rawTarget = typeof input?.target === 'string' ? input.target.trim() : ''
1602
1616
  const endpoint = normalizeOpenClawEndpoint(ensureSchemeAndPort(rawTarget || 'openclaw.example.com', scheme, port))
1603
1617
  const wsUrl = deriveOpenClawWsUrl(endpoint)
@@ -1802,7 +1816,7 @@ export async function runOpenClawRemoteLifecycleAction(input?: {
1802
1816
  const sshConfig = sanitizeSshConfig(input?.ssh)
1803
1817
  if (!sshConfig) throw new Error('SSH host is required for remote lifecycle actions.')
1804
1818
  const remoteDir = sshConfig.targetDir || '/opt/openclaw'
1805
- const image = normalizeText(input?.image) || 'openclaw:latest'
1819
+ const image = normalizeText(input?.image) || DEFAULT_OPENCLAW_IMAGE
1806
1820
  const action = input?.action || 'restart'
1807
1821
  let remoteCommand = ''
1808
1822
  let summary = ''