@swarmclawai/swarmclaw 0.9.9 → 1.0.2

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.
Files changed (44) hide show
  1. package/bin/doctor-cmd.js +149 -0
  2. package/bin/doctor-cmd.test.js +50 -0
  3. package/bin/install-root.js +194 -0
  4. package/bin/install-root.test.js +121 -0
  5. package/bin/server-cmd.js +90 -111
  6. package/bin/swarmclaw.js +83 -3
  7. package/bin/update-cmd.js +33 -20
  8. package/bin/update-cmd.test.js +1 -36
  9. package/bin/worker-cmd.js +23 -17
  10. package/next.config.ts +2 -0
  11. package/package.json +11 -10
  12. package/src/app/api/gateways/[id]/health/route.ts +2 -32
  13. package/src/app/api/gateways/health-route.test.ts +1 -1
  14. package/src/app/api/openclaw/dashboard-url/route.test.ts +166 -0
  15. package/src/app/api/openclaw/dashboard-url/route.ts +68 -0
  16. package/src/app/api/setup/check-provider/helpers.ts +28 -0
  17. package/src/app/api/setup/check-provider/route.test.ts +17 -1
  18. package/src/app/api/setup/check-provider/route.ts +29 -36
  19. package/src/app/api/tasks/import/github/helpers.ts +100 -0
  20. package/src/app/api/tasks/import/github/route.test.ts +1 -1
  21. package/src/app/api/tasks/import/github/route.ts +2 -92
  22. package/src/app/api/webhooks/[id]/helpers.ts +253 -0
  23. package/src/app/api/webhooks/[id]/route.ts +2 -243
  24. package/src/app/api/webhooks/route.test.ts +4 -2
  25. package/src/cli/binary.test.js +57 -0
  26. package/src/cli/index.js +14 -1
  27. package/src/cli/server-cmd.test.js +21 -20
  28. package/src/components/auth/setup-wizard/index.tsx +16 -0
  29. package/src/components/auth/setup-wizard/step-agents.tsx +34 -23
  30. package/src/components/auth/setup-wizard/step-connect.tsx +8 -0
  31. package/src/components/auth/setup-wizard/types.ts +2 -0
  32. package/src/components/auth/setup-wizard/utils.test.ts +79 -0
  33. package/src/components/chat/chat-header.tsx +45 -2
  34. package/src/lib/providers/openclaw-exports.test.ts +23 -0
  35. package/src/lib/providers/openclaw.ts +1 -1
  36. package/src/lib/server/data-dir.test.ts +35 -0
  37. package/src/lib/server/data-dir.ts +11 -0
  38. package/src/lib/server/openclaw/health.ts +30 -1
  39. package/src/lib/server/session-tools/file-send.test.ts +18 -2
  40. package/src/lib/server/session-tools/file.ts +11 -7
  41. package/src/lib/server/skills/skill-discovery.test.ts +34 -1
  42. package/src/lib/server/skills/skill-discovery.ts +9 -4
  43. package/src/lib/setup-defaults.test.ts +42 -0
  44. package/src/lib/setup-defaults.ts +1 -1
package/bin/worker-cmd.js CHANGED
@@ -1,11 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- const fs = require('node:fs')
5
- const path = require('node:path')
6
- const os = require('node:os')
7
4
  const { spawn } = require('node:child_process')
8
5
 
6
+ const {
7
+ BROWSER_PROFILES_DIR,
8
+ DATA_DIR,
9
+ PKG_ROOT,
10
+ SWARMCLAW_HOME,
11
+ WORKSPACE_DIR,
12
+ findStandaloneServer,
13
+ } = require('./server-cmd.js')
14
+
9
15
  function printHelp() {
10
16
  const help = `
11
17
  Usage: swarmclaw worker [options]
@@ -19,8 +25,7 @@ Options:
19
25
  console.log(help)
20
26
  }
21
27
 
22
- function main() {
23
- const args = process.argv.slice(3)
28
+ function main(args = process.argv.slice(3)) {
24
29
  for (const arg of args) {
25
30
  if (arg === '-h' || arg === '--help') {
26
31
  printHelp()
@@ -32,27 +37,28 @@ function main() {
32
37
  }
33
38
  }
34
39
 
35
- const SWARMCLAW_HOME = process.env.SWARMCLAW_HOME || path.join(os.homedir(), '.swarmclaw')
36
- const DATA_DIR = path.join(SWARMCLAW_HOME, 'data')
37
-
40
+ process.env.SWARMCLAW_HOME = SWARMCLAW_HOME
38
41
  process.env.DATA_DIR = DATA_DIR
42
+ process.env.WORKSPACE_DIR = WORKSPACE_DIR
43
+ process.env.BROWSER_PROFILES_DIR = BROWSER_PROFILES_DIR
39
44
  process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = '1'
40
- // Flag that tells Next.js NOT to start the HTTP/Websocket listener, just boot the daemon.
41
45
  process.env.SWARMCLAW_WORKER_ONLY = '1'
42
46
 
43
- console.log(`[swarmclaw] Starting dedicated background worker...`)
47
+ console.log('[swarmclaw] Starting dedicated background worker...')
48
+ console.log(`[swarmclaw] Package root: ${PKG_ROOT}`)
49
+ console.log(`[swarmclaw] Home: ${SWARMCLAW_HOME}`)
44
50
  console.log(`[swarmclaw] Data directory: ${DATA_DIR}`)
51
+ console.log(`[swarmclaw] Workspace directory: ${WORKSPACE_DIR}`)
52
+ console.log(`[swarmclaw] Browser profiles: ${BROWSER_PROFILES_DIR}`)
45
53
 
46
- // We reuse the built server.js but signal it to only run the daemon
47
- const standaloneBase = path.join(SWARMCLAW_HOME, '.next', 'standalone')
48
- let serverJs = path.join(standaloneBase, 'server.js')
49
-
50
- if (!fs.existsSync(serverJs)) {
51
- console.error('Standalone server.js not found. Try running: swarmclaw server --build')
52
- process.exit(1)
54
+ const serverJs = findStandaloneServer()
55
+ if (!serverJs) {
56
+ console.error('[swarmclaw] Standalone server.js not found in the installed package. Try running: swarmclaw server --build')
57
+ process.exit(1)
53
58
  }
54
59
 
55
60
  const child = spawn(process.execPath, [serverJs], {
61
+ cwd: PKG_ROOT,
56
62
  env: process.env,
57
63
  stdio: 'inherit',
58
64
  })
package/next.config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { NextConfig } from "next";
2
2
  import { execSync } from "child_process";
3
+ import { existsSync } from "fs";
3
4
  import { networkInterfaces } from "os";
4
5
  import path from "path";
5
6
  import { fileURLToPath } from "url";
@@ -8,6 +9,7 @@ const PROJECT_ROOT = path.dirname(fileURLToPath(import.meta.url))
8
9
 
9
10
  function getGitSha(): string {
10
11
  try {
12
+ if (!existsSync(path.join(PROJECT_ROOT, '.git'))) return 'unknown'
11
13
  return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim()
12
14
  } catch {
13
15
  return 'unknown'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.9.9",
4
- "description": "Self-hosted AI agent orchestration dashboard manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
3
+ "version": "1.0.2",
4
+ "description": "Self-hosted AI agent orchestration dashboard with OpenClaw integration, multi-provider support, LangGraph workflows, and chat platform connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
7
7
  "access": "public",
@@ -51,8 +51,8 @@
51
51
  "dev": "next dev --turbopack --hostname 0.0.0.0 -p 3456",
52
52
  "dev:webpack": "next dev --webpack --hostname 0.0.0.0 -p 3456",
53
53
  "dev:clean": "rm -rf .next && next dev --turbopack --hostname 0.0.0.0 -p 3456",
54
- "build": "next build",
55
- "build:ci": "NEXT_DISABLE_ESLINT=1 next build",
54
+ "build": "next build --webpack",
55
+ "build:ci": "NEXT_DISABLE_ESLINT=1 next build --webpack",
56
56
  "start": "node .next/standalone/server.js",
57
57
  "start:standalone": "node .next/standalone/server.js",
58
58
  "smoke:browser": "node ./scripts/browser-route-smoke.mjs",
@@ -66,9 +66,10 @@
66
66
  "lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
67
67
  "cli": "node ./bin/swarmclaw.js",
68
68
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js",
69
- "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts",
70
- "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts",
69
+ "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts",
70
+ "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
71
71
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
72
+ "prepack": "npm run build:ci",
72
73
  "postinstall": "node ./scripts/postinstall.mjs"
73
74
  },
74
75
  "dependencies": {
@@ -76,6 +77,7 @@
76
77
  "@langchain/anthropic": "^1.3.18",
77
78
  "@langchain/core": "^1.1.31",
78
79
  "@modelcontextprotocol/sdk": "^1.27.1",
80
+ "@tailwindcss/postcss": "^4",
79
81
  "@langchain/langgraph": "^1.2.2",
80
82
  "@langchain/openai": "^1.2.8",
81
83
  "@multiavatar/multiavatar": "^1.0.7",
@@ -116,14 +118,16 @@
116
118
  "rehype-highlight": "^7.0.2",
117
119
  "remark-gfm": "^4.0.1",
118
120
  "remove-markdown": "^0.6.3",
121
+ "shadcn": "^3.8.5",
119
122
  "sonner": "^2.0.7",
123
+ "tailwindcss": "^4",
120
124
  "tailwind-merge": "^3.4.1",
125
+ "tw-animate-css": "^1.4.0",
121
126
  "ws": "^8.19.0",
122
127
  "zod": "^4.3.6",
123
128
  "zustand": "^5.0.11"
124
129
  },
125
130
  "devDependencies": {
126
- "@tailwindcss/postcss": "^4",
127
131
  "@types/better-sqlite3": "^7.6.13",
128
132
  "@types/mailparser": "^3.4.6",
129
133
  "@types/node": "^20",
@@ -134,10 +138,7 @@
134
138
  "@types/ws": "^8.18.1",
135
139
  "eslint": "^9",
136
140
  "eslint-config-next": "16.1.6",
137
- "shadcn": "^3.8.5",
138
- "tailwindcss": "^4",
139
141
  "tsx": "^4.20.6",
140
- "tw-animate-css": "^1.4.0",
141
142
  "typescript": "^5"
142
143
  },
143
144
  "optionalDependencies": {
@@ -1,39 +1,9 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { probeOpenClawHealth } from '@/lib/server/openclaw/health'
3
- import { loadGatewayProfiles, saveGatewayProfiles } from '@/lib/server/storage'
2
+ import { probeOpenClawHealth, persistGatewayHealthResult } from '@/lib/server/openclaw/health'
3
+ import { loadGatewayProfiles } from '@/lib/server/storage'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
- import { notify } from '@/lib/server/ws-hub'
6
- import type { GatewayProfile } from '@/types'
7
- import type { OpenClawHealthResult } from '@/lib/server/openclaw/health'
8
5
  export const dynamic = 'force-dynamic'
9
6
 
10
- export function persistGatewayHealthResult(
11
- id: string,
12
- result: OpenClawHealthResult,
13
- now = Date.now(),
14
- ): GatewayProfile | null {
15
- const gateways = loadGatewayProfiles()
16
- const gateway = gateways[id]
17
- if (!gateway) return null
18
-
19
- gateway.status = result.ok ? 'healthy' : (result.authProvided ? 'degraded' : 'offline')
20
- gateway.lastCheckedAt = now
21
- gateway.lastError = result.ok ? null : (result.error || result.hint || 'Gateway health check failed.')
22
- gateway.lastModelCount = Array.isArray(result.models) ? result.models.length : 0
23
- gateway.deployment = {
24
- ...(gateway.deployment || {}),
25
- lastVerifiedAt: now,
26
- lastVerifiedOk: result.ok,
27
- lastVerifiedMessage: result.ok
28
- ? result.message
29
- : (result.error || result.hint || 'Gateway health check failed.'),
30
- }
31
- gateway.updatedAt = now
32
- saveGatewayProfiles(gateways)
33
- notify('gateways')
34
- return gateway
35
- }
36
-
37
7
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
38
8
  const { id } = await params
39
9
  const gateways = loadGatewayProfiles()
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { afterEach, test } from 'node:test'
3
3
 
4
- import { persistGatewayHealthResult } from './[id]/health/route'
4
+ import { persistGatewayHealthResult } from '@/lib/server/openclaw/health'
5
5
  import { loadGatewayProfiles, saveGatewayProfiles } from '@/lib/server/storage'
6
6
 
7
7
  const originalGateways = loadGatewayProfiles()
@@ -0,0 +1,166 @@
1
+ import assert from 'node:assert/strict'
2
+ import { afterEach, test } from 'node:test'
3
+
4
+ import type { Agent } from '@/types'
5
+ import {
6
+ encryptKey,
7
+ loadAgents,
8
+ loadCredentials,
9
+ loadGatewayProfiles,
10
+ saveAgents,
11
+ saveCredentials,
12
+ saveGatewayProfiles,
13
+ } from '@/lib/server/storage'
14
+ import type { GatewayProfile } from '@/types'
15
+
16
+ const originalAgents = loadAgents({ includeTrashed: true })
17
+ const originalCredentials = loadCredentials()
18
+ const originalGateways = loadGatewayProfiles()
19
+
20
+ afterEach(() => {
21
+ saveAgents(originalAgents)
22
+ saveCredentials(originalCredentials)
23
+ saveGatewayProfiles(originalGateways)
24
+ })
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helper: call the route handler directly
28
+ // ---------------------------------------------------------------------------
29
+
30
+ async function callDashboardUrl(agentId: string): Promise<{ status: number; body: Record<string, unknown> }> {
31
+ const { GET } = await import('./route')
32
+ const req = new Request(`http://localhost:3456/api/openclaw/dashboard-url?agentId=${agentId}`)
33
+ const res = await GET(req)
34
+ return { status: res.status, body: await res.json() as Record<string, unknown> }
35
+ }
36
+
37
+ function createAgent(id: string, overrides: Partial<Agent> = {}): void {
38
+ const agents = loadAgents({ includeTrashed: true })
39
+ agents[id] = {
40
+ id,
41
+ name: `Agent ${id}`,
42
+ systemPrompt: '',
43
+ provider: 'openclaw',
44
+ model: '',
45
+ createdAt: Date.now(),
46
+ updatedAt: Date.now(),
47
+ ...overrides,
48
+ } as Agent
49
+ saveAgents(agents)
50
+ }
51
+
52
+ function createCredential(id: string, token: string): void {
53
+ const creds = loadCredentials()
54
+ creds[id] = {
55
+ id,
56
+ provider: 'openclaw',
57
+ name: `Cred ${id}`,
58
+ encryptedKey: encryptKey(token),
59
+ createdAt: Date.now(),
60
+ }
61
+ saveCredentials(creds)
62
+ }
63
+
64
+ function createGateway(id: string, overrides: Partial<GatewayProfile> = {}): void {
65
+ const gateways = loadGatewayProfiles()
66
+ gateways[id] = {
67
+ id,
68
+ name: `Gateway ${id}`,
69
+ provider: 'openclaw',
70
+ endpoint: 'http://10.0.0.5:18789',
71
+ status: 'healthy',
72
+ createdAt: Date.now(),
73
+ updatedAt: Date.now(),
74
+ ...overrides,
75
+ } as GatewayProfile
76
+ saveGatewayProfiles(gateways)
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Tests
81
+ // ---------------------------------------------------------------------------
82
+
83
+ test('dashboard-url returns 400 for missing agentId', async () => {
84
+ const { GET } = await import('./route')
85
+ const req = new Request('http://localhost:3456/api/openclaw/dashboard-url')
86
+ const res = await GET(req)
87
+ assert.equal(res.status, 400)
88
+ })
89
+
90
+ test('dashboard-url returns 404 for unknown agent', async () => {
91
+ const { status } = await callDashboardUrl('nonexistent')
92
+ assert.equal(status, 404)
93
+ })
94
+
95
+ test('dashboard-url returns 400 for non-OpenClaw agent', async () => {
96
+ createAgent('oai-agent', { provider: 'openai' as Agent['provider'] })
97
+ const { status } = await callDashboardUrl('oai-agent')
98
+ assert.equal(status, 400)
99
+ })
100
+
101
+ test('dashboard-url returns base URL when no credential', async () => {
102
+ createAgent('oc-no-cred', { apiEndpoint: 'http://192.168.1.10:18789' })
103
+ const { status, body } = await callDashboardUrl('oc-no-cred')
104
+ assert.equal(status, 200)
105
+ assert.equal(body.url, 'http://192.168.1.10:18789')
106
+ })
107
+
108
+ test('dashboard-url includes token from agent credential', async () => {
109
+ createCredential('cred-tok-1', 'my-gateway-token')
110
+ createAgent('oc-with-cred', {
111
+ apiEndpoint: 'http://localhost:18789',
112
+ credentialId: 'cred-tok-1',
113
+ })
114
+ const { status, body } = await callDashboardUrl('oc-with-cred')
115
+ assert.equal(status, 200)
116
+ assert.equal(body.url, 'http://localhost:18789?token=my-gateway-token')
117
+ })
118
+
119
+ test('dashboard-url uses gateway profile endpoint and credential', async () => {
120
+ createCredential('gw-cred-1', 'gw-token-1')
121
+ createGateway('gw-prof-1', {
122
+ endpoint: 'http://10.0.0.5:19000',
123
+ credentialId: 'gw-cred-1',
124
+ })
125
+ createAgent('oc-with-gw', {
126
+ apiEndpoint: 'http://localhost:18789',
127
+ gatewayProfileId: 'gw-prof-1',
128
+ })
129
+ const { status, body } = await callDashboardUrl('oc-with-gw')
130
+ assert.equal(status, 200)
131
+ // Should use gateway profile endpoint, not agent's apiEndpoint
132
+ assert.ok(typeof body.url === 'string')
133
+ assert.ok((body.url as string).startsWith('http://10.0.0.5:19000'))
134
+ assert.ok((body.url as string).includes('token=gw-token-1'))
135
+ })
136
+
137
+ test('dashboard-url defaults to localhost when no endpoint', async () => {
138
+ createAgent('oc-no-ep', { apiEndpoint: undefined })
139
+ const { status, body } = await callDashboardUrl('oc-no-ep')
140
+ assert.equal(status, 200)
141
+ assert.equal(body.url, 'http://localhost:18789')
142
+ })
143
+
144
+ test('dashboard-url strips path from endpoint', async () => {
145
+ createAgent('oc-with-path', { apiEndpoint: 'http://localhost:18789/v1' })
146
+ const { status, body } = await callDashboardUrl('oc-with-path')
147
+ assert.equal(status, 200)
148
+ assert.equal(body.url, 'http://localhost:18789')
149
+ })
150
+
151
+ test('dashboard-url URL-encodes special characters in token', async () => {
152
+ createCredential('cred-special', 'tok/with spaces&chars=yes')
153
+ createAgent('oc-special-tok', {
154
+ apiEndpoint: 'http://localhost:18789',
155
+ credentialId: 'cred-special',
156
+ })
157
+ const { status, body } = await callDashboardUrl('oc-special-tok')
158
+ assert.equal(status, 200)
159
+ const url = body.url as string
160
+ assert.ok(url.includes('token='))
161
+ // Token should be URL-encoded
162
+ assert.ok(!url.includes(' '), 'spaces should be encoded')
163
+ // Decode and verify the token
164
+ const parsed = new URL(url)
165
+ assert.equal(parsed.searchParams.get('token'), 'tok/with spaces&chars=yes')
166
+ })
@@ -0,0 +1,68 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { loadAgents, loadGatewayProfiles, loadCredentials, decryptKey } from '@/lib/server/storage'
3
+
4
+ /** GET ?agentId=X — resolve the tokenized dashboard URL for an OpenClaw agent's gateway */
5
+ export async function GET(req: Request) {
6
+ const { searchParams } = new URL(req.url)
7
+ const agentId = searchParams.get('agentId')
8
+ if (!agentId) {
9
+ return NextResponse.json({ error: 'Missing agentId' }, { status: 400 })
10
+ }
11
+
12
+ const agents = loadAgents()
13
+ const agent = agents[agentId]
14
+ if (!agent) {
15
+ return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
16
+ }
17
+ if (agent.provider !== 'openclaw') {
18
+ return NextResponse.json({ error: 'Not an OpenClaw agent' }, { status: 400 })
19
+ }
20
+
21
+ // Resolve the gateway endpoint
22
+ let endpoint = agent.apiEndpoint || ''
23
+ let credentialId = agent.credentialId || null
24
+
25
+ // If agent has a gatewayProfileId, prefer its endpoint and credential
26
+ if (agent.gatewayProfileId) {
27
+ const gateways = loadGatewayProfiles()
28
+ const gw = gateways[agent.gatewayProfileId]
29
+ if (gw) {
30
+ endpoint = gw.endpoint || endpoint
31
+ credentialId = gw.credentialId || credentialId
32
+ }
33
+ }
34
+
35
+ if (!endpoint) endpoint = 'http://localhost:18789'
36
+
37
+ // Build the base dashboard URL (strip path, use http)
38
+ let dashboardUrl: string
39
+ try {
40
+ const parsed = new URL(/^(https?|wss?):\/\//i.test(endpoint) ? endpoint : `http://${endpoint}`)
41
+ if (parsed.protocol === 'wss:') parsed.protocol = 'https:'
42
+ if (parsed.protocol === 'ws:') parsed.protocol = 'http:'
43
+ parsed.pathname = ''
44
+ parsed.search = ''
45
+ parsed.hash = ''
46
+ dashboardUrl = parsed.toString().replace(/\/+$/, '')
47
+ } catch {
48
+ dashboardUrl = 'http://localhost:18789'
49
+ }
50
+
51
+ // Decrypt the token if we have a credential
52
+ if (credentialId) {
53
+ try {
54
+ const creds = loadCredentials()
55
+ const cred = creds[credentialId]
56
+ if (cred?.encryptedKey) {
57
+ const token = decryptKey(cred.encryptedKey)
58
+ if (token) {
59
+ dashboardUrl = `${dashboardUrl}?token=${encodeURIComponent(token)}`
60
+ }
61
+ }
62
+ } catch {
63
+ // If decryption fails, return the URL without token
64
+ }
65
+ }
66
+
67
+ return NextResponse.json({ url: dashboardUrl })
68
+ }
@@ -0,0 +1,28 @@
1
+ export function normalizeOllamaSetupEndpoint(endpoint: string, useCloud: boolean): string {
2
+ const normalized = endpoint.replace(/\/+$/, '')
3
+ if (useCloud) return normalized
4
+ return normalized.replace(/\/v1$/i, '')
5
+ }
6
+
7
+ export async function parseErrorMessage(res: Response, fallback: string): Promise<string> {
8
+ const text = await res.text().catch(() => '')
9
+ if (!text) return fallback
10
+ try {
11
+ const parsed = JSON.parse(text)
12
+ if (typeof parsed?.error?.message === 'string' && parsed.error.message.trim()) return parsed.error.message.trim()
13
+ if (typeof parsed?.error === 'string' && parsed.error.trim()) return parsed.error.trim()
14
+ if (typeof parsed?.message === 'string' && parsed.message.trim()) return parsed.message.trim()
15
+ if (typeof parsed?.detail === 'string' && parsed.detail.trim()) return parsed.detail.trim()
16
+ } catch {
17
+ // Non-JSON response body.
18
+ }
19
+ return text.slice(0, 300).trim() || fallback
20
+ }
21
+
22
+ export function normalizeOpenClawUrl(raw: string): { httpUrl: string; wsUrl: string } {
23
+ let url = (raw || 'http://localhost:18789').replace(/\/+$/, '')
24
+ if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
25
+ const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
26
+ const wsUrl = httpUrl.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
27
+ return { httpUrl, wsUrl }
28
+ }
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
 
4
- import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './route'
4
+ import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
5
5
 
6
6
  test('normalizeOllamaSetupEndpoint strips local /v1 suffixes but preserves cloud endpoints', () => {
7
7
  assert.equal(
@@ -101,3 +101,19 @@ test('parseErrorMessage extracts .message from JSON', async () => {
101
101
  const res = fakeResponse(JSON.stringify({ message: 'quota exceeded' }))
102
102
  assert.equal(await parseErrorMessage(res, 'fallback'), 'quota exceeded')
103
103
  })
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // normalizeOpenClawUrl — additional edge cases
107
+ // ---------------------------------------------------------------------------
108
+
109
+ test('normalizeOpenClawUrl handles https:// endpoint correctly', () => {
110
+ const { httpUrl, wsUrl } = normalizeOpenClawUrl('https://gateway.tailnet.ts.net')
111
+ assert.equal(httpUrl, 'https://gateway.tailnet.ts.net')
112
+ assert.equal(wsUrl, 'wss://gateway.tailnet.ts.net')
113
+ })
114
+
115
+ test('normalizeOpenClawUrl handles bare IP:port', () => {
116
+ const { httpUrl, wsUrl } = normalizeOpenClawUrl('10.0.0.5:18789')
117
+ assert.equal(httpUrl, 'http://10.0.0.5:18789')
118
+ assert.equal(wsUrl, 'ws://10.0.0.5:18789')
119
+ })
@@ -1,8 +1,9 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { loadCredentials, decryptKey } from '@/lib/server/storage'
3
- import { getDeviceId, wsConnect } from '@/lib/providers/openclaw'
3
+ import { getDeviceId, wsConnect, rpcOnConnectedGateway } from '@/lib/providers/openclaw'
4
4
  import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
5
5
  import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
6
+ import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
6
7
 
7
8
  type SetupProvider =
8
9
  | 'openai'
@@ -29,32 +30,11 @@ function clean(value: unknown): string {
29
30
  return typeof value === 'string' ? value.trim() : ''
30
31
  }
31
32
 
32
- export function normalizeOllamaSetupEndpoint(endpoint: string, useCloud: boolean): string {
33
- const normalized = endpoint.replace(/\/+$/, '')
34
- if (useCloud) return normalized
35
- return normalized.replace(/\/v1$/i, '')
36
- }
37
-
38
33
  function parseBody(input: unknown): SetupCheckBody {
39
34
  if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
40
35
  return input as SetupCheckBody
41
36
  }
42
37
 
43
- export async function parseErrorMessage(res: Response, fallback: string): Promise<string> {
44
- const text = await res.text().catch(() => '')
45
- if (!text) return fallback
46
- try {
47
- const parsed = JSON.parse(text)
48
- if (typeof parsed?.error?.message === 'string' && parsed.error.message.trim()) return parsed.error.message.trim()
49
- if (typeof parsed?.error === 'string' && parsed.error.trim()) return parsed.error.trim()
50
- if (typeof parsed?.message === 'string' && parsed.message.trim()) return parsed.message.trim()
51
- if (typeof parsed?.detail === 'string' && parsed.detail.trim()) return parsed.detail.trim()
52
- } catch {
53
- // Non-JSON response body.
54
- }
55
- return text.slice(0, 300).trim() || fallback
56
- }
57
-
58
38
  async function checkOpenAiCompatible(
59
39
  providerName: string,
60
40
  apiKey: string,
@@ -234,27 +214,40 @@ async function checkOllama(params: {
234
214
  }
235
215
  }
236
216
 
237
- export function normalizeOpenClawUrl(raw: string): { httpUrl: string; wsUrl: string } {
238
- let url = (raw || 'http://localhost:18789').replace(/\/+$/, '')
239
- if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
240
- const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
241
- const wsUrl = httpUrl.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
242
- return { httpUrl, wsUrl }
243
- }
244
-
245
- async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok: boolean; message: string; normalizedEndpoint: string; deviceId?: string; errorCode?: string }> {
217
+ async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok: boolean; message: string; normalizedEndpoint: string; deviceId?: string; errorCode?: string; recommendedModel?: string }> {
246
218
  const { httpUrl: normalizedEndpoint, wsUrl } = normalizeOpenClawUrl(endpointRaw)
247
219
  const token = apiKey || undefined
248
220
  const deviceId = getDeviceId()
249
221
 
250
222
  const result = await wsConnect(wsUrl, token, true, 10_000)
251
- // Close the WebSocket immediately — we only care about the handshake result
252
- if (result.ws) try { result.ws.close() } catch {}
253
223
 
254
- if (result.ok) {
255
- return { ok: true, message: 'Connected to OpenClaw gateway.', normalizedEndpoint, deviceId }
224
+ if (!result.ok) {
225
+ if (result.ws) try { result.ws.close() } catch {}
226
+ return { ok: false, message: result.message, normalizedEndpoint, deviceId, errorCode: result.errorCode }
256
227
  }
257
- return { ok: false, message: result.message, normalizedEndpoint, deviceId, errorCode: result.errorCode }
228
+
229
+ // Attempt model discovery via RPC before closing the connection
230
+ let recommendedModel: string | undefined
231
+ if (result.ws) {
232
+ try {
233
+ const payload = await rpcOnConnectedGateway(result.ws, 'models.list', {}, 8_000) as Record<string, unknown> | unknown[] | undefined
234
+ const p = payload as Record<string, unknown> | undefined
235
+ const models: unknown[] = Array.isArray(p?.models) ? p.models as unknown[] : Array.isArray(p?.data) ? p.data as unknown[] : Array.isArray(payload) ? payload : []
236
+ const first = models[0] as Record<string, unknown> | string | undefined
237
+ if (typeof first === 'string') {
238
+ recommendedModel = first
239
+ } else if (typeof first?.id === 'string') {
240
+ recommendedModel = first.id
241
+ } else if (typeof first?.name === 'string') {
242
+ recommendedModel = first.name
243
+ }
244
+ } catch {
245
+ // Model discovery is non-fatal — connection still counts as successful
246
+ }
247
+ try { result.ws.close() } catch {}
248
+ }
249
+
250
+ return { ok: true, message: 'Connected to OpenClaw gateway.', normalizedEndpoint, deviceId, recommendedModel }
258
251
  }
259
252
 
260
253
  export async function POST(req: Request) {