@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.
- package/bin/doctor-cmd.js +149 -0
- package/bin/doctor-cmd.test.js +50 -0
- package/bin/install-root.js +194 -0
- package/bin/install-root.test.js +121 -0
- package/bin/server-cmd.js +90 -111
- package/bin/swarmclaw.js +83 -3
- package/bin/update-cmd.js +33 -20
- package/bin/update-cmd.test.js +1 -36
- package/bin/worker-cmd.js +23 -17
- package/next.config.ts +2 -0
- package/package.json +11 -10
- package/src/app/api/gateways/[id]/health/route.ts +2 -32
- package/src/app/api/gateways/health-route.test.ts +1 -1
- package/src/app/api/openclaw/dashboard-url/route.test.ts +166 -0
- package/src/app/api/openclaw/dashboard-url/route.ts +68 -0
- package/src/app/api/setup/check-provider/helpers.ts +28 -0
- package/src/app/api/setup/check-provider/route.test.ts +17 -1
- package/src/app/api/setup/check-provider/route.ts +29 -36
- package/src/app/api/tasks/import/github/helpers.ts +100 -0
- package/src/app/api/tasks/import/github/route.test.ts +1 -1
- package/src/app/api/tasks/import/github/route.ts +2 -92
- package/src/app/api/webhooks/[id]/helpers.ts +253 -0
- package/src/app/api/webhooks/[id]/route.ts +2 -243
- package/src/app/api/webhooks/route.test.ts +4 -2
- package/src/cli/binary.test.js +57 -0
- package/src/cli/index.js +14 -1
- package/src/cli/server-cmd.test.js +21 -20
- package/src/components/auth/setup-wizard/index.tsx +16 -0
- package/src/components/auth/setup-wizard/step-agents.tsx +34 -23
- package/src/components/auth/setup-wizard/step-connect.tsx +8 -0
- package/src/components/auth/setup-wizard/types.ts +2 -0
- package/src/components/auth/setup-wizard/utils.test.ts +79 -0
- package/src/components/chat/chat-header.tsx +45 -2
- package/src/lib/providers/openclaw-exports.test.ts +23 -0
- package/src/lib/providers/openclaw.ts +1 -1
- package/src/lib/server/data-dir.test.ts +35 -0
- package/src/lib/server/data-dir.ts +11 -0
- package/src/lib/server/openclaw/health.ts +30 -1
- package/src/lib/server/session-tools/file-send.test.ts +18 -2
- package/src/lib/server/session-tools/file.ts +11 -7
- package/src/lib/server/skills/skill-discovery.test.ts +34 -1
- package/src/lib/server/skills/skill-discovery.ts +9 -4
- package/src/lib/setup-defaults.test.ts +42 -0
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
4
|
-
"description": "Self-hosted AI agent orchestration dashboard
|
|
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
|
|
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 '
|
|
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 './
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|