@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:
|
|
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
|
@@ -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
|
|
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
|
-
|
|
614
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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) ||
|
|
1819
|
+
const image = normalizeText(input?.image) || DEFAULT_OPENCLAW_IMAGE
|
|
1806
1820
|
const action = input?.action || 'restart'
|
|
1807
1821
|
let remoteCommand = ''
|
|
1808
1822
|
let summary = ''
|