@swarmclawai/swarmclaw 0.7.6 → 0.7.7

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
@@ -54,11 +54,15 @@ The deployment flow stays **in-house and official-only**:
54
54
 
55
55
  Supported VPS presets currently include Hetzner, DigitalOcean, Vultr, Linode, Lightsail, Google Cloud, Azure, OCI, and a generic Ubuntu host path. Smart defaults prefill the gateway token, endpoint, storage paths, and copy-paste commands so the resulting gateway can be saved into SwarmClaw with minimal manual editing.
56
56
 
57
+ For existing hosts, SwarmClaw can also push the same official-image bundle **over SSH** and then keep remote lifecycle controls attached to that saved gateway profile: start, stop, restart, upgrade, backup, restore, and token rotation.
58
+
57
59
  The OpenClaw Control Plane in SwarmClaw adds:
58
60
  - Reload mode switching (`hot`, `hybrid`, `full`)
59
61
  - Config issue detection and guided repair
60
62
  - Remote history sync
61
63
  - Live execution approval handling
64
+ - Gateway import/export JSON, clone flows, and richer external runtime fleet visibility
65
+ - Agent and route-target preferences for steering work toward OpenClaw gateways by tags or use case (`local-dev`, `single-vps`, `private-tailnet`, `browser-heavy`, `team-control`)
62
66
 
63
67
  The Agent Inspector Panel lets you edit OpenClaw files (`SOUL.md`, `IDENTITY.md`, `USER.md`), tune personality/system behavior, and manage OpenClaw-compatible skills. SwarmClaw also supports importing OpenClaw `SKILL.md` files from URL.
64
68
 
@@ -79,8 +83,12 @@ CLI operators can use the same deploy surface without opening the UI:
79
83
  ```bash
80
84
  swarmclaw openclaw deploy-status
81
85
  swarmclaw openclaw deploy-local-start --data '{"port":18789}'
86
+ swarmclaw openclaw deploy-local-restart --data '{"port":18789}'
82
87
  swarmclaw openclaw deploy-bundle --data '{"template":"docker","provider":"hetzner","target":"openclaw.example.com"}'
83
- swarmclaw openclaw deploy-bundle --data '{"template":"render","target":"https://openclaw.example.com"}'
88
+ swarmclaw openclaw deploy-ssh --data '{"target":"openclaw.example.com","provider":"hetzner","ssh":{"host":"your-vps-ip"}}'
89
+ swarmclaw openclaw remote-backup --data '{"ssh":{"host":"your-vps-ip"}}'
90
+ swarmclaw openclaw remote-restore --data '{"backupPath":"/opt/openclaw/backups/openclaw-backup-123.tgz","ssh":{"host":"your-vps-ip"}}'
91
+ swarmclaw openclaw deploy-verify --data '{"endpoint":"https://openclaw.example.com/v1"}'
84
92
  ```
85
93
 
86
94
  ## SwarmClaw ClawHub Skill
@@ -140,7 +148,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
140
148
  ```
141
149
 
142
150
  The installer resolves the latest stable release tag and installs that version by default.
143
- To pin a version: `SWARMCLAW_VERSION=v0.7.6 curl ... | bash`
151
+ To pin a version: `SWARMCLAW_VERSION=v0.7.7 curl ... | bash`
144
152
 
145
153
  Or run locally from the repo (friendly for non-technical users):
146
154
 
@@ -693,7 +701,7 @@ npm run update:easy # safe update helper for local installs
693
701
  SwarmClaw uses tag-based releases (`vX.Y.Z`) as the stable channel.
694
702
 
695
703
  ```bash
696
- # example patch release (v0.7.6 style)
704
+ # example patch release (v0.7.7 style)
697
705
  npm version patch
698
706
  git push origin main --follow-tags
699
707
  ```
@@ -703,15 +711,15 @@ On `v*` tags, GitHub Actions will:
703
711
  2. Create a GitHub Release
704
712
  3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
705
713
 
706
- #### v0.7.6 Release Readiness Notes
714
+ #### v0.7.7 Release Readiness Notes
707
715
 
708
- Before shipping `v0.7.6`, confirm the following user-facing changes are reflected in docs:
716
+ Before shipping `v0.7.7`, confirm the following user-facing changes are reflected in docs:
709
717
 
710
- 1. Sandbox docs are updated everywhere to reflect the current Deno-only `sandbox_exec` behavior and the guidance to prefer `http_request` for simple API calls.
711
- 2. OpenClaw docs cover the current gateway/runtime behavior, including Smart Deploy, official-only Docker/repo paths, local one-click startup, and the main-provider-screen deploy entry points.
712
- 3. Site and README install/version strings are updated to `v0.7.6`, including install snippets, release notes index text, and sidebar/footer labels.
713
- 4. Release notes summarize the user-visible setup/auth/runtime changes from the current worktree, especially Smart Deploy, VPS presets, and onboarding/provider-screen improvements.
714
- 5. CLI and tool docs include the new `openclaw deploy-*` surfaces and do not reference removed or non-functional bridges.
718
+ 1. OpenClaw docs cover Smart Deploy end-to-end: onboarding, Providers, gateway editor, official-only SSH/VPS flows, safe exposure presets, and restore/backup lifecycle controls.
719
+ 2. Agent and provider docs explain gateway routing by tags/use-case, richer external runtime visibility, and import/export/clone flows for saved gateways.
720
+ 3. Site and README install/version strings are updated to `v0.7.7`, including install snippets, release notes index text, and sidebar/footer labels.
721
+ 4. Release notes summarize the user-visible operator changes from the current worktree, especially SSH deploy, remote lifecycle controls, routing preferences, and onboarding persistence.
722
+ 5. CLI docs include the expanded `openclaw deploy-*`, `openclaw remote-*`, and verify surfaces and do not reference removed or unofficial deployment paths.
715
723
 
716
724
  ## CLI
717
725
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -23,6 +23,16 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
23
23
  body.apiEndpoint,
24
24
  )
25
25
  }
26
+ if (body.preferredGatewayTags !== undefined) {
27
+ agent.preferredGatewayTags = Array.isArray(body.preferredGatewayTags)
28
+ ? body.preferredGatewayTags.filter((tag: unknown): tag is string => typeof tag === 'string' && tag.trim().length > 0)
29
+ : []
30
+ }
31
+ if (body.preferredGatewayUseCase !== undefined) {
32
+ agent.preferredGatewayUseCase = typeof body.preferredGatewayUseCase === 'string' && body.preferredGatewayUseCase.trim()
33
+ ? body.preferredGatewayUseCase.trim()
34
+ : null
35
+ }
26
36
  if (body.routingTargets !== undefined && Array.isArray(body.routingTargets)) {
27
37
  agent.routingTargets = body.routingTargets.map((target: Record<string, unknown>, index: number) => ({
28
38
  id: typeof target.id === 'string' && target.id.trim() ? target.id.trim() : `route-${index + 1}`,
@@ -37,6 +47,12 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
37
47
  typeof target.apiEndpoint === 'string' ? target.apiEndpoint : null,
38
48
  ),
39
49
  gatewayProfileId: target.gatewayProfileId ?? null,
50
+ preferredGatewayTags: Array.isArray(target.preferredGatewayTags)
51
+ ? target.preferredGatewayTags.filter((tag: unknown): tag is string => typeof tag === 'string' && tag.trim().length > 0)
52
+ : [],
53
+ preferredGatewayUseCase: typeof target.preferredGatewayUseCase === 'string' && target.preferredGatewayUseCase.trim()
54
+ ? target.preferredGatewayUseCase.trim()
55
+ : null,
40
56
  priority: typeof target.priority === 'number' ? target.priority : index + 1,
41
57
  }))
42
58
  }
@@ -64,6 +64,8 @@ export async function POST(req: Request) {
64
64
  fallbackCredentialIds: body.fallbackCredentialIds,
65
65
  apiEndpoint: normalizeProviderEndpoint(body.provider, body.apiEndpoint || null),
66
66
  gatewayProfileId: body.gatewayProfileId,
67
+ preferredGatewayTags: body.preferredGatewayTags,
68
+ preferredGatewayUseCase: body.preferredGatewayUseCase,
67
69
  routingStrategy: body.routingStrategy,
68
70
  routingTargets: body.routingTargets?.map((target) => ({
69
71
  ...target,
@@ -18,7 +18,20 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
18
18
  }
19
19
 
20
20
  const linkedAgent = nextAgentId ? loadAgents()[nextAgentId] : null
21
- const linkedRoute = linkedAgent ? resolvePrimaryAgentRoute(linkedAgent) : null
21
+ const routePreferredGatewayTags = updates.routePreferredGatewayTags !== undefined
22
+ ? (Array.isArray(updates.routePreferredGatewayTags)
23
+ ? updates.routePreferredGatewayTags.filter((tag: unknown): tag is string => typeof tag === 'string' && tag.trim().length > 0)
24
+ : [])
25
+ : (sessions[id].routePreferredGatewayTags || [])
26
+ const routePreferredGatewayUseCase = updates.routePreferredGatewayUseCase !== undefined
27
+ ? (typeof updates.routePreferredGatewayUseCase === 'string' && updates.routePreferredGatewayUseCase.trim()
28
+ ? updates.routePreferredGatewayUseCase.trim()
29
+ : null)
30
+ : (sessions[id].routePreferredGatewayUseCase || null)
31
+ const linkedRoute = linkedAgent ? resolvePrimaryAgentRoute(linkedAgent, undefined, {
32
+ preferredGatewayTags: routePreferredGatewayTags,
33
+ preferredGatewayUseCase: routePreferredGatewayUseCase,
34
+ }) : null
22
35
 
23
36
  if (updates.name !== undefined) sessions[id].name = updates.name
24
37
  if (updates.cwd !== undefined) sessions[id].cwd = updates.cwd
@@ -39,6 +52,13 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
39
52
  if (updates.gatewayProfileId !== undefined) sessions[id].gatewayProfileId = updates.gatewayProfileId
40
53
  else if (agentIdUpdateProvided && linkedRoute) sessions[id].gatewayProfileId = linkedRoute.gatewayProfileId ?? null
41
54
 
55
+ if (updates.routePreferredGatewayTags !== undefined) {
56
+ sessions[id].routePreferredGatewayTags = routePreferredGatewayTags
57
+ }
58
+ if (updates.routePreferredGatewayUseCase !== undefined) {
59
+ sessions[id].routePreferredGatewayUseCase = routePreferredGatewayUseCase
60
+ }
61
+
42
62
  if (updates.plugins !== undefined) sessions[id].plugins = updates.plugins
43
63
  else if (agentIdUpdateProvided && linkedAgent) sessions[id].plugins = Array.isArray(linkedAgent.plugins) ? linkedAgent.plugins : []
44
64
 
@@ -61,7 +61,16 @@ export async function POST(req: Request) {
61
61
  const id = body.id || genId()
62
62
  const sessions = loadSessions()
63
63
  const agent = body.agentId ? loadAgents()[body.agentId] : null
64
- const resolvedRoute = agent ? resolvePrimaryAgentRoute(agent) : null
64
+ const routePreferredGatewayTags = Array.isArray(body.routePreferredGatewayTags)
65
+ ? body.routePreferredGatewayTags.filter((tag: unknown): tag is string => typeof tag === 'string' && tag.trim().length > 0)
66
+ : []
67
+ const routePreferredGatewayUseCase = typeof body.routePreferredGatewayUseCase === 'string' && body.routePreferredGatewayUseCase.trim()
68
+ ? body.routePreferredGatewayUseCase.trim()
69
+ : null
70
+ const resolvedRoute = agent ? resolvePrimaryAgentRoute(agent, undefined, {
71
+ preferredGatewayTags: routePreferredGatewayTags,
72
+ preferredGatewayUseCase: routePreferredGatewayUseCase,
73
+ }) : null
65
74
  const requestedPlugins = Array.isArray(body.plugins) ? body.plugins : (Array.isArray(body.tools) ? body.tools : null)
66
75
  const resolvedPlugins = requestedPlugins ?? (Array.isArray(agent?.plugins) ? agent.plugins : (Array.isArray(agent?.tools) ? agent.tools : []))
67
76
 
@@ -83,6 +92,8 @@ export async function POST(req: Request) {
83
92
  body.provider || agent?.provider || 'claude-cli',
84
93
  body.apiEndpoint || agent?.apiEndpoint || null,
85
94
  ),
95
+ routePreferredGatewayTags,
96
+ routePreferredGatewayUseCase,
86
97
  claudeSessionId: null,
87
98
  codexThreadId: null,
88
99
  opencodeSessionId: null,
@@ -15,6 +15,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
15
15
  runtime.lastSeenAt = now
16
16
  runtime.updatedAt = now
17
17
  runtime.status = body.status || 'online'
18
+ if (typeof body.lifecycleState === 'string' && body.lifecycleState) runtime.lifecycleState = body.lifecycleState
19
+ if (typeof body.version === 'string') runtime.version = body.version || null
20
+ if (typeof body.lastHealthNote === 'string') runtime.lastHealthNote = body.lastHealthNote || null
18
21
  if (body.tokenStats && typeof body.tokenStats === 'object') {
19
22
  runtime.tokenStats = {
20
23
  ...(runtime.tokenStats || {}),
@@ -10,12 +10,44 @@ const ops: CollectionOps<any> = { load: loadExternalAgents, save: saveExternalAg
10
10
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
11
11
  const { id } = await params
12
12
  const body = await req.json().catch(() => ({}))
13
- const result = mutateItem(ops, id, (runtime) => ({
14
- ...runtime,
15
- ...body,
16
- id,
17
- updatedAt: Date.now(),
18
- }))
13
+ const now = Date.now()
14
+ const result = mutateItem(ops, id, (runtime) => {
15
+ const action = typeof body.action === 'string' ? body.action : ''
16
+ const nextMetadata = body.metadata && typeof body.metadata === 'object'
17
+ ? { ...(runtime.metadata || {}), ...body.metadata }
18
+ : runtime.metadata
19
+
20
+ const next = {
21
+ ...runtime,
22
+ ...body,
23
+ id,
24
+ metadata: nextMetadata,
25
+ updatedAt: now,
26
+ }
27
+
28
+ if (action === 'activate') {
29
+ next.lifecycleState = 'active'
30
+ next.lastHealthNote = body.lastHealthNote || 'Runtime returned to active service.'
31
+ } else if (action === 'drain') {
32
+ next.lifecycleState = 'draining'
33
+ next.lastHealthNote = body.lastHealthNote || 'Runtime draining after current work.'
34
+ } else if (action === 'cordon') {
35
+ next.lifecycleState = 'cordoned'
36
+ next.lastHealthNote = body.lastHealthNote || 'Runtime cordoned from new work.'
37
+ } else if (action === 'restart') {
38
+ next.metadata = {
39
+ ...(next.metadata || {}),
40
+ controlRequest: {
41
+ action: 'restart',
42
+ requestedAt: now,
43
+ source: 'swarmclaw',
44
+ },
45
+ }
46
+ next.lastHealthNote = body.lastHealthNote || 'Restart requested from SwarmClaw.'
47
+ }
48
+
49
+ return next
50
+ })
19
51
  if (!result) return notFound()
20
52
  return NextResponse.json(result)
21
53
  }
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
3
  import { formatZodError, ExternalAgentRegisterSchema } from '@/lib/validation/schemas'
4
- import { loadExternalAgents, saveExternalAgents } from '@/lib/server/storage'
4
+ import { loadExternalAgents, loadGatewayProfiles, saveExternalAgents } from '@/lib/server/storage'
5
5
  import { notify } from '@/lib/server/ws-hub'
6
6
  import type { ExternalAgentRuntime } from '@/types'
7
7
  import { z } from 'zod'
@@ -13,9 +13,20 @@ function withDerivedStatus(record: ExternalAgentRuntime): ExternalAgentRuntime {
13
13
  const staleMs = 3 * 60_000
14
14
  if (!lastSeenAt) return { ...record, status: record.status || 'offline' }
15
15
  if (record.status === 'offline') return record
16
+ const gateways = loadGatewayProfiles()
17
+ const gateway = record.gatewayProfileId ? gateways[record.gatewayProfileId] as Record<string, unknown> | undefined : undefined
18
+ const gatewayTags = Array.isArray(gateway?.tags)
19
+ ? gateway?.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0)
20
+ : []
21
+ const gatewayUseCase = gateway?.deployment && typeof gateway.deployment === 'object' && typeof (gateway.deployment as Record<string, unknown>).useCase === 'string'
22
+ ? (gateway.deployment as Record<string, unknown>).useCase as string
23
+ : null
16
24
  return {
17
25
  ...record,
18
26
  status: now - lastSeenAt > staleMs ? 'stale' : (record.status || 'online'),
27
+ lifecycleState: record.lifecycleState || 'active',
28
+ gatewayTags: record.gatewayTags?.length ? record.gatewayTags : gatewayTags,
29
+ gatewayUseCase: record.gatewayUseCase || gatewayUseCase,
19
30
  }
20
31
  }
21
32
 
@@ -53,6 +64,11 @@ export async function POST(req: Request) {
53
64
  gatewayProfileId: body.gatewayProfileId || null,
54
65
  capabilities: body.capabilities,
55
66
  labels: body.labels,
67
+ lifecycleState: body.lifecycleState || existing?.lifecycleState || 'active',
68
+ gatewayTags: body.gatewayTags,
69
+ gatewayUseCase: body.gatewayUseCase || null,
70
+ version: body.version || null,
71
+ lastHealthNote: body.lastHealthNote || null,
56
72
  metadata: body.metadata,
57
73
  tokenStats: body.tokenStats,
58
74
  lastHeartbeatAt: existing?.lastHeartbeatAt || now,
@@ -20,6 +20,14 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
20
20
  gateway.lastCheckedAt = Date.now()
21
21
  gateway.lastError = result.ok ? null : (result.error || result.hint || 'Gateway health check failed.')
22
22
  gateway.lastModelCount = Array.isArray(result.models) ? result.models.length : 0
23
+ gateway.deployment = {
24
+ ...(gateway.deployment || {}),
25
+ lastVerifiedAt: Date.now(),
26
+ lastVerifiedOk: result.ok,
27
+ lastVerifiedMessage: result.ok
28
+ ? `Verified ${Array.isArray(result.models) ? result.models.length : 0} model${result.models?.length === 1 ? '' : 's'}`
29
+ : (result.error || result.hint || 'Gateway health check failed.'),
30
+ }
23
31
  gateway.updatedAt = Date.now()
24
32
  saveGatewayProfiles(gateways)
25
33
  notify('gateways')
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { normalizeOpenClawEndpoint } from '@/lib/openclaw-endpoint'
3
3
  import { loadAgents, loadGatewayProfiles, saveAgents, saveGatewayProfiles } from '@/lib/server/storage'
4
4
  import { mutateItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
5
- import type { Agent, AgentRoutingTarget, GatewayProfile } from '@/types'
5
+ import type { Agent, AgentRoutingTarget, GatewayProfile, OpenClawDeploymentConfig, OpenClawGatewayStats } from '@/types'
6
6
 
7
7
  const ops: CollectionOps<GatewayProfile> = {
8
8
  load: loadGatewayProfiles,
@@ -17,6 +17,56 @@ function normalizeTags(value: unknown): string[] {
17
17
  .filter(Boolean)
18
18
  }
19
19
 
20
+ function normalizeText(value: unknown): string | null {
21
+ return typeof value === 'string' && value.trim() ? value.trim() : null
22
+ }
23
+
24
+ function normalizeNullableNumber(value: unknown): number | null {
25
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
26
+ }
27
+
28
+ function normalizeDeployment(value: unknown): OpenClawDeploymentConfig | null {
29
+ if (!value || typeof value !== 'object') return null
30
+ const deployment = value as Record<string, unknown>
31
+ return {
32
+ method: normalizeText(deployment.method) as OpenClawDeploymentConfig['method'],
33
+ provider: normalizeText(deployment.provider) as OpenClawDeploymentConfig['provider'],
34
+ remoteTarget: normalizeText(deployment.remoteTarget) as OpenClawDeploymentConfig['remoteTarget'],
35
+ useCase: normalizeText(deployment.useCase) as OpenClawDeploymentConfig['useCase'],
36
+ exposure: normalizeText(deployment.exposure) as OpenClawDeploymentConfig['exposure'],
37
+ managedBy: normalizeText(deployment.managedBy) as OpenClawDeploymentConfig['managedBy'],
38
+ targetHost: normalizeText(deployment.targetHost),
39
+ sshHost: normalizeText(deployment.sshHost),
40
+ sshUser: normalizeText(deployment.sshUser),
41
+ sshPort: normalizeNullableNumber(deployment.sshPort),
42
+ sshKeyPath: normalizeText(deployment.sshKeyPath),
43
+ sshTargetDir: normalizeText(deployment.sshTargetDir),
44
+ image: normalizeText(deployment.image),
45
+ version: normalizeText(deployment.version),
46
+ lastDeployAt: normalizeNullableNumber(deployment.lastDeployAt),
47
+ lastDeployAction: normalizeText(deployment.lastDeployAction),
48
+ lastDeployProcessId: normalizeText(deployment.lastDeployProcessId),
49
+ lastDeploySummary: normalizeText(deployment.lastDeploySummary),
50
+ lastVerifiedAt: normalizeNullableNumber(deployment.lastVerifiedAt),
51
+ lastVerifiedOk: typeof deployment.lastVerifiedOk === 'boolean' ? deployment.lastVerifiedOk : null,
52
+ lastVerifiedMessage: normalizeText(deployment.lastVerifiedMessage),
53
+ lastBackupPath: normalizeText(deployment.lastBackupPath),
54
+ }
55
+ }
56
+
57
+ function normalizeStats(value: unknown): OpenClawGatewayStats | null {
58
+ if (!value || typeof value !== 'object') return null
59
+ const stats = value as Record<string, unknown>
60
+ return {
61
+ nodeCount: normalizeNullableNumber(stats.nodeCount) ?? undefined,
62
+ connectedNodeCount: normalizeNullableNumber(stats.connectedNodeCount) ?? undefined,
63
+ pendingNodePairings: normalizeNullableNumber(stats.pendingNodePairings) ?? undefined,
64
+ pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
65
+ pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
66
+ externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
67
+ }
68
+ }
69
+
20
70
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
21
71
  const { id } = await params
22
72
  const body = await req.json().catch(() => ({}))
@@ -39,6 +89,8 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
39
89
  if (body.lastModelCount !== undefined) gateway.lastModelCount = body.lastModelCount || null
40
90
  if (body.discoveredHost !== undefined) gateway.discoveredHost = body.discoveredHost || null
41
91
  if (body.discoveredPort !== undefined) gateway.discoveredPort = body.discoveredPort || null
92
+ if (body.deployment !== undefined) gateway.deployment = { ...(gateway.deployment || {}), ...(normalizeDeployment(body.deployment) || {}) }
93
+ if (body.stats !== undefined) gateway.stats = { ...(gateway.stats || {}), ...(normalizeStats(body.stats) || {}) }
42
94
  if (body.isDefault !== undefined) gateway.isDefault = body.isDefault === true
43
95
  gateway.updatedAt = Date.now()
44
96
  return gateway
@@ -4,6 +4,7 @@ import { normalizeOpenClawEndpoint } from '@/lib/openclaw-endpoint'
4
4
  import { getGatewayProfiles } from '@/lib/server/agent-runtime-config'
5
5
  import { loadGatewayProfiles, saveGatewayProfiles } from '@/lib/server/storage'
6
6
  import { notify } from '@/lib/server/ws-hub'
7
+ import type { OpenClawDeploymentConfig, OpenClawGatewayStats } from '@/types'
7
8
  export const dynamic = 'force-dynamic'
8
9
 
9
10
  function normalizeTags(value: unknown): string[] {
@@ -13,6 +14,56 @@ function normalizeTags(value: unknown): string[] {
13
14
  .filter(Boolean)
14
15
  }
15
16
 
17
+ function normalizeText(value: unknown): string | null {
18
+ return typeof value === 'string' && value.trim() ? value.trim() : null
19
+ }
20
+
21
+ function normalizeNullableNumber(value: unknown): number | null {
22
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
23
+ }
24
+
25
+ function normalizeDeployment(value: unknown): OpenClawDeploymentConfig | null {
26
+ if (!value || typeof value !== 'object') return null
27
+ const deployment = value as Record<string, unknown>
28
+ return {
29
+ method: normalizeText(deployment.method) as OpenClawDeploymentConfig['method'],
30
+ provider: normalizeText(deployment.provider) as OpenClawDeploymentConfig['provider'],
31
+ remoteTarget: normalizeText(deployment.remoteTarget) as OpenClawDeploymentConfig['remoteTarget'],
32
+ useCase: normalizeText(deployment.useCase) as OpenClawDeploymentConfig['useCase'],
33
+ exposure: normalizeText(deployment.exposure) as OpenClawDeploymentConfig['exposure'],
34
+ managedBy: normalizeText(deployment.managedBy) as OpenClawDeploymentConfig['managedBy'],
35
+ targetHost: normalizeText(deployment.targetHost),
36
+ sshHost: normalizeText(deployment.sshHost),
37
+ sshUser: normalizeText(deployment.sshUser),
38
+ sshPort: normalizeNullableNumber(deployment.sshPort),
39
+ sshKeyPath: normalizeText(deployment.sshKeyPath),
40
+ sshTargetDir: normalizeText(deployment.sshTargetDir),
41
+ image: normalizeText(deployment.image),
42
+ version: normalizeText(deployment.version),
43
+ lastDeployAt: normalizeNullableNumber(deployment.lastDeployAt),
44
+ lastDeployAction: normalizeText(deployment.lastDeployAction),
45
+ lastDeployProcessId: normalizeText(deployment.lastDeployProcessId),
46
+ lastDeploySummary: normalizeText(deployment.lastDeploySummary),
47
+ lastVerifiedAt: normalizeNullableNumber(deployment.lastVerifiedAt),
48
+ lastVerifiedOk: typeof deployment.lastVerifiedOk === 'boolean' ? deployment.lastVerifiedOk : null,
49
+ lastVerifiedMessage: normalizeText(deployment.lastVerifiedMessage),
50
+ lastBackupPath: normalizeText(deployment.lastBackupPath),
51
+ }
52
+ }
53
+
54
+ function normalizeStats(value: unknown): OpenClawGatewayStats | null {
55
+ if (!value || typeof value !== 'object') return null
56
+ const stats = value as Record<string, unknown>
57
+ return {
58
+ nodeCount: normalizeNullableNumber(stats.nodeCount) ?? undefined,
59
+ connectedNodeCount: normalizeNullableNumber(stats.connectedNodeCount) ?? undefined,
60
+ pendingNodePairings: normalizeNullableNumber(stats.pendingNodePairings) ?? undefined,
61
+ pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
62
+ pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
63
+ externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
64
+ }
65
+ }
66
+
16
67
  export async function GET() {
17
68
  return NextResponse.json(getGatewayProfiles('openclaw'))
18
69
  }
@@ -46,6 +97,8 @@ export async function POST(req: Request) {
46
97
  lastModelCount: null,
47
98
  discoveredHost: typeof body.discoveredHost === 'string' ? body.discoveredHost : null,
48
99
  discoveredPort: typeof body.discoveredPort === 'number' ? body.discoveredPort : null,
100
+ deployment: normalizeDeployment(body.deployment),
101
+ stats: normalizeStats(body.stats),
49
102
  isDefault,
50
103
  createdAt: now,
51
104
  updatedAt: now,
@@ -1,11 +1,19 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import {
3
3
  buildOpenClawDeployBundle,
4
+ deployOpenClawOverSsh,
4
5
  getOpenClawLocalDeployStatus,
6
+ getOpenClawRemoteDeployStatus,
7
+ restartOpenClawLocalDeploy,
8
+ runOpenClawRemoteLifecycle,
5
9
  startOpenClawLocalDeploy,
6
10
  stopOpenClawLocalDeploy,
11
+ verifyOpenClawDeployment,
12
+ type OpenClawExposurePreset,
7
13
  type OpenClawRemoteDeployProvider,
8
14
  type OpenClawRemoteDeployTemplate,
15
+ type OpenClawSshConfig,
16
+ type OpenClawUseCaseTemplate,
9
17
  } from '@/lib/server/openclaw-deploy'
10
18
 
11
19
  export const dynamic = 'force-dynamic'
@@ -19,6 +27,12 @@ function parsePort(value: unknown): number | undefined {
19
27
  return Number.isFinite(parsed) ? parsed : undefined
20
28
  }
21
29
 
30
+ function parseIntBounded(value: unknown, fallback: number, min: number, max: number): number {
31
+ const parsed = parsePort(value)
32
+ if (typeof parsed !== 'number') return fallback
33
+ return Math.max(min, Math.min(max, parsed))
34
+ }
35
+
22
36
  function parseTemplate(value: unknown): OpenClawRemoteDeployTemplate | undefined {
23
37
  if (value === 'docker' || value === 'render' || value === 'fly' || value === 'railway') {
24
38
  return value
@@ -43,9 +57,48 @@ function parseProvider(value: unknown): OpenClawRemoteDeployProvider | undefined
43
57
  return undefined
44
58
  }
45
59
 
60
+ function parseUseCase(value: unknown): OpenClawUseCaseTemplate | undefined {
61
+ if (
62
+ value === 'local-dev'
63
+ || value === 'single-vps'
64
+ || value === 'private-tailnet'
65
+ || value === 'browser-heavy'
66
+ || value === 'team-control'
67
+ ) {
68
+ return value
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ function parseExposure(value: unknown): OpenClawExposurePreset | undefined {
74
+ if (
75
+ value === 'private-lan'
76
+ || value === 'tailscale'
77
+ || value === 'caddy'
78
+ || value === 'nginx'
79
+ || value === 'ssh-tunnel'
80
+ ) {
81
+ return value
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ function parseSsh(value: unknown): Partial<OpenClawSshConfig> | null {
87
+ if (!value || typeof value !== 'object') return null
88
+ const ssh = value as Record<string, unknown>
89
+ return {
90
+ host: typeof ssh.host === 'string' ? ssh.host : '',
91
+ user: typeof ssh.user === 'string' ? ssh.user : null,
92
+ port: parsePort(ssh.port),
93
+ keyPath: typeof ssh.keyPath === 'string' ? ssh.keyPath : null,
94
+ targetDir: typeof ssh.targetDir === 'string' ? ssh.targetDir : null,
95
+ }
96
+ }
97
+
46
98
  export async function GET() {
47
99
  return NextResponse.json({
48
100
  local: getOpenClawLocalDeployStatus(),
101
+ remote: getOpenClawRemoteDeployStatus(),
49
102
  })
50
103
  }
51
104
 
@@ -73,6 +126,18 @@ export async function POST(req: Request) {
73
126
  })
74
127
  }
75
128
 
129
+ if (action === 'restart-local') {
130
+ const result = await restartOpenClawLocalDeploy({
131
+ port: parsePort(body.port),
132
+ token: typeof body.token === 'string' ? body.token : null,
133
+ })
134
+ return NextResponse.json({
135
+ ok: true,
136
+ local: result.local,
137
+ token: result.token,
138
+ })
139
+ }
140
+
76
141
  if (action === 'bundle') {
77
142
  const bundle = buildOpenClawDeployBundle({
78
143
  template: parseTemplate(body.template),
@@ -81,6 +146,8 @@ export async function POST(req: Request) {
81
146
  scheme: body.scheme === 'http' ? 'http' : 'https',
82
147
  port: parsePort(body.port),
83
148
  provider: parseProvider(body.provider),
149
+ useCase: parseUseCase(body.useCase),
150
+ exposure: parseExposure(body.exposure),
84
151
  })
85
152
  return NextResponse.json({
86
153
  ok: true,
@@ -88,6 +155,78 @@ export async function POST(req: Request) {
88
155
  })
89
156
  }
90
157
 
158
+ if (action === 'ssh-deploy') {
159
+ const result = await deployOpenClawOverSsh({
160
+ template: parseTemplate(body.template),
161
+ target: typeof body.target === 'string' ? body.target : null,
162
+ token: typeof body.token === 'string' ? body.token : null,
163
+ scheme: body.scheme === 'http' ? 'http' : 'https',
164
+ port: parsePort(body.port),
165
+ provider: parseProvider(body.provider),
166
+ useCase: parseUseCase(body.useCase),
167
+ exposure: parseExposure(body.exposure),
168
+ ssh: parseSsh(body.ssh),
169
+ })
170
+ return NextResponse.json({
171
+ ok: result.ok,
172
+ remote: getOpenClawRemoteDeployStatus(),
173
+ processId: result.processId || null,
174
+ token: result.token,
175
+ bundle: result.bundle,
176
+ summary: result.summary,
177
+ commandPreview: result.commandPreview,
178
+ })
179
+ }
180
+
181
+ if (
182
+ action === 'remote-start'
183
+ || action === 'remote-stop'
184
+ || action === 'remote-restart'
185
+ || action === 'remote-upgrade'
186
+ || action === 'remote-backup'
187
+ || action === 'remote-restore'
188
+ || action === 'remote-rotate-token'
189
+ ) {
190
+ const actionMap = {
191
+ 'remote-start': 'start',
192
+ 'remote-stop': 'stop',
193
+ 'remote-restart': 'restart',
194
+ 'remote-upgrade': 'upgrade',
195
+ 'remote-backup': 'backup',
196
+ 'remote-restore': 'restore',
197
+ 'remote-rotate-token': 'rotate-token',
198
+ } as const
199
+ const lifecycleAction = action as keyof typeof actionMap
200
+ const result = await runOpenClawRemoteLifecycle({
201
+ action: actionMap[lifecycleAction],
202
+ ssh: parseSsh(body.ssh),
203
+ token: typeof body.token === 'string' ? body.token : null,
204
+ backupPath: typeof body.backupPath === 'string' ? body.backupPath : null,
205
+ })
206
+ return NextResponse.json({
207
+ ok: result.ok,
208
+ remote: getOpenClawRemoteDeployStatus(),
209
+ processId: result.processId || null,
210
+ token: result.token,
211
+ summary: result.summary,
212
+ commandPreview: result.commandPreview,
213
+ })
214
+ }
215
+
216
+ if (action === 'verify') {
217
+ const result = await verifyOpenClawDeployment({
218
+ endpoint: typeof body.endpoint === 'string' ? body.endpoint : null,
219
+ credentialId: typeof body.credentialId === 'string' ? body.credentialId : null,
220
+ token: typeof body.token === 'string' ? body.token : null,
221
+ model: typeof body.model === 'string' ? body.model : null,
222
+ timeoutMs: parseIntBounded(body.timeoutMs, 8000, 1000, 30000),
223
+ })
224
+ return NextResponse.json({
225
+ ok: result.ok,
226
+ verify: result,
227
+ })
228
+ }
229
+
91
230
  return NextResponse.json({ ok: false, error: 'Unknown deploy action.' }, { status: 400 })
92
231
  } catch (err: unknown) {
93
232
  return NextResponse.json(