@swarmclawai/swarmclaw 0.9.6 → 0.9.8
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 +6 -6
- package/bin/server-cmd.js +1 -3
- package/bin/swarmclaw.js +6 -0
- package/package.json +3 -1
- package/src/app/api/auth/route.ts +20 -4
- package/src/app/api/setup/check-provider/route.test.ts +85 -1
- package/src/app/api/setup/check-provider/route.ts +108 -35
- package/src/app/login/page.tsx +4 -3
- package/src/app/setup/page.tsx +1 -0
- package/src/components/agents/agent-sheet.tsx +19 -0
- package/src/components/auth/access-key-gate.tsx +247 -42
- package/src/components/auth/setup-wizard/index.tsx +463 -0
- package/src/components/auth/setup-wizard/shared.tsx +96 -0
- package/src/components/auth/setup-wizard/step-agents.tsx +542 -0
- package/src/components/auth/setup-wizard/step-connect.tsx +553 -0
- package/src/components/auth/setup-wizard/step-next.tsx +71 -0
- package/src/components/auth/setup-wizard/step-profile.tsx +148 -0
- package/src/components/auth/setup-wizard/step-progress.tsx +56 -0
- package/src/components/auth/setup-wizard/step-providers.tsx +154 -0
- package/src/components/auth/setup-wizard/types.test.ts +11 -0
- package/src/components/auth/setup-wizard/types.ts +149 -0
- package/src/components/auth/setup-wizard/utils.test.ts +159 -0
- package/src/components/auth/setup-wizard/utils.ts +167 -0
- package/src/components/auth/user-picker.tsx +137 -71
- package/src/components/layout/dashboard-shell.tsx +9 -3
- package/src/components/schedules/schedule-sheet.tsx +70 -13
- package/src/hooks/setup-done-detection.test.ts +27 -0
- package/src/hooks/setup-done-detection.ts +13 -0
- package/src/hooks/use-app-bootstrap.ts +6 -3
- package/src/lib/server/build-llm.ts +1 -1
- package/src/lib/server/builtin-plugins.ts +0 -1
- package/src/lib/server/connectors/contact-boundaries.ts +50 -3
- package/src/lib/server/connectors/manager.ts +10 -5
- package/src/lib/server/connectors/policy.ts +7 -4
- package/src/lib/server/connectors/session.ts +18 -0
- package/src/lib/server/ollama-runtime.ts +1 -1
- package/src/lib/server/provider-model-discovery.test.ts +242 -0
- package/src/lib/server/provider-model-discovery.ts +16 -14
- package/src/lib/server/runtime/scheduler.ts +57 -29
- package/src/lib/server/schedules/schedule-normalization.ts +29 -4
- package/src/lib/server/session-tools/context.ts +5 -1
- package/src/lib/server/session-tools/crud.ts +1 -1
- package/src/lib/server/session-tools/document.ts +5 -4
- package/src/lib/server/session-tools/edit_file.ts +3 -3
- package/src/lib/server/session-tools/extract.ts +2 -2
- package/src/lib/server/session-tools/file.ts +20 -19
- package/src/lib/server/session-tools/git.ts +3 -3
- package/src/lib/server/session-tools/index.ts +17 -3
- package/src/lib/server/session-tools/memory.ts +2 -1
- package/src/lib/server/session-tools/monitor.ts +5 -4
- package/src/lib/server/session-tools/shell.ts +9 -5
- package/src/lib/server/session-tools/table.ts +16 -15
- package/src/lib/server/session-tools/web-utils.ts +4 -4
- package/src/lib/server/session-tools/web.ts +5 -5
- package/src/lib/server/storage.ts +13 -0
- package/src/lib/server/tool-aliases.ts +0 -1
- package/src/lib/server/tool-capability-policy.ts +0 -1
- package/src/lib/server/universal-tool-access.ts +0 -1
- package/src/lib/setup-defaults.ts +36 -9
- package/src/lib/tool-definitions.ts +0 -1
- package/src/stores/slices/agent-slice.ts +1 -1
- package/src/types/index.ts +6 -0
- package/src/components/auth/setup-wizard.tsx +0 -1575
- package/src/lib/server/session-tools/sample-ui.ts +0 -97
package/README.md
CHANGED
|
@@ -129,10 +129,10 @@ npm i -g @swarmclawai/swarmclaw
|
|
|
129
129
|
pnpm add -g @swarmclawai/swarmclaw
|
|
130
130
|
yarn global add @swarmclawai/swarmclaw
|
|
131
131
|
bun add -g @swarmclawai/swarmclaw
|
|
132
|
-
swarmclaw
|
|
132
|
+
swarmclaw
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
`swarmclaw`
|
|
135
|
+
Running `swarmclaw` with no arguments starts the server on `http://localhost:3456`. You can also use `swarmclaw server` explicitly, or pass a subcommand (e.g. `swarmclaw agents list`) to use the CLI.
|
|
136
136
|
Global install runs `postinstall`, which rebuilds `better-sqlite3` and prepares the sandbox browser image when Docker is available.
|
|
137
137
|
If Docker is not installed yet, SwarmClaw keeps running and falls back to host execution for shell, browser, and `sandbox_exec`.
|
|
138
138
|
No Deno install is required for the local `sandbox_exec` path.
|
|
@@ -140,10 +140,10 @@ No Deno install is required for the local `sandbox_exec` path.
|
|
|
140
140
|
### One-off run
|
|
141
141
|
|
|
142
142
|
```bash
|
|
143
|
-
npx @swarmclawai/swarmclaw
|
|
144
|
-
pnpm dlx @swarmclawai/swarmclaw
|
|
145
|
-
yarn dlx @swarmclawai/swarmclaw
|
|
146
|
-
bunx @swarmclawai/swarmclaw
|
|
143
|
+
npx @swarmclawai/swarmclaw
|
|
144
|
+
pnpm dlx @swarmclawai/swarmclaw
|
|
145
|
+
yarn dlx @swarmclawai/swarmclaw
|
|
146
|
+
bunx @swarmclawai/swarmclaw
|
|
147
147
|
```
|
|
148
148
|
|
|
149
149
|
### Install script
|
package/bin/server-cmd.js
CHANGED
|
@@ -137,10 +137,8 @@ function runBuild() {
|
|
|
137
137
|
|
|
138
138
|
// Run Next.js build
|
|
139
139
|
log('Building Next.js application (this may take a minute)...')
|
|
140
|
-
// Use webpack for production build reliability in packaged/fresh-install
|
|
141
|
-
// environments (Turbopack has intermittently failed during prerender).
|
|
142
140
|
const nextCli = path.join(SWARMCLAW_HOME, 'node_modules', 'next', 'dist', 'bin', 'next')
|
|
143
|
-
execFileSync(process.execPath, [nextCli, 'build', '--
|
|
141
|
+
execFileSync(process.execPath, [nextCli, 'build', '--no-turbopack'], {
|
|
144
142
|
cwd: SWARMCLAW_HOME,
|
|
145
143
|
stdio: 'inherit',
|
|
146
144
|
env: {
|
package/bin/swarmclaw.js
CHANGED
|
@@ -110,6 +110,12 @@ async function main() {
|
|
|
110
110
|
const argv = process.argv.slice(2)
|
|
111
111
|
const top = argv[0]
|
|
112
112
|
|
|
113
|
+
// Default to 'server' when invoked with no arguments.
|
|
114
|
+
if (!top) {
|
|
115
|
+
require('./server-cmd.js').main()
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
113
119
|
// Route 'server', 'worker', and 'update' subcommands to CJS scripts (no TS dependency).
|
|
114
120
|
if (top === 'server') {
|
|
115
121
|
require('./server-cmd.js').main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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": {
|
|
@@ -66,6 +66,7 @@
|
|
|
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",
|
|
69
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",
|
|
70
71
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
71
72
|
"postinstall": "node ./scripts/postinstall.mjs"
|
|
@@ -74,6 +75,7 @@
|
|
|
74
75
|
"@huggingface/transformers": "^3.8.1",
|
|
75
76
|
"@langchain/anthropic": "^1.3.18",
|
|
76
77
|
"@langchain/core": "^1.1.31",
|
|
78
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
77
79
|
"@langchain/langgraph": "^1.2.2",
|
|
78
80
|
"@langchain/openai": "^1.2.8",
|
|
79
81
|
"@multiavatar/multiavatar": "^1.0.7",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { validateAccessKey, isFirstTimeSetup, markSetupComplete } from '@/lib/server/storage'
|
|
2
|
+
import { getAccessKey, validateAccessKey, isFirstTimeSetup, markSetupComplete, replaceAccessKey } from '@/lib/server/storage'
|
|
3
3
|
import { AUTH_COOKIE_NAME, getCookieValue } from '@/lib/auth'
|
|
4
4
|
import { isProductionRuntime } from '@/lib/runtime/runtime-env'
|
|
5
5
|
import { hmrSingleton } from '@/lib/shared-utils'
|
|
@@ -51,12 +51,17 @@ function setAuthCookie(response: NextResponse, req: Request, key: string): NextR
|
|
|
51
51
|
return response
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** GET /api/auth — returns setup state and whether the auth cookie is currently valid
|
|
54
|
+
/** GET /api/auth — returns setup state and whether the auth cookie is currently valid.
|
|
55
|
+
* During first-time setup the generated access key is included so the UI can
|
|
56
|
+
* display it with a copy button. Once setup completes the key is never exposed
|
|
57
|
+
* over an unauthenticated endpoint again. */
|
|
55
58
|
export async function GET(req: Request) {
|
|
56
59
|
const cookieKey = getCookieValue(req.headers.get('cookie'), AUTH_COOKIE_NAME)
|
|
60
|
+
const firstTime = isFirstTimeSetup()
|
|
57
61
|
return NextResponse.json({
|
|
58
|
-
firstTime
|
|
62
|
+
firstTime,
|
|
59
63
|
authenticated: !!cookieKey && validateAccessKey(cookieKey),
|
|
64
|
+
...(firstTime ? { generatedKey: getAccessKey() } : {}),
|
|
60
65
|
})
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -73,7 +78,18 @@ export async function POST(req: Request) {
|
|
|
73
78
|
))
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
const { key } = await req.json()
|
|
81
|
+
const { key, override } = await req.json()
|
|
82
|
+
|
|
83
|
+
// During first-time setup, allow the user to replace the generated key with their own
|
|
84
|
+
if (override && isFirstTimeSetup() && typeof key === 'string' && key.trim().length >= 8) {
|
|
85
|
+
replaceAccessKey(key.trim())
|
|
86
|
+
markSetupComplete()
|
|
87
|
+
if (rateLimitEnabled) authRateLimitMap.delete(clientIp)
|
|
88
|
+
const { ensureDaemonStarted } = await import('@/lib/server/runtime/daemon-state')
|
|
89
|
+
ensureDaemonStarted('api/auth:post')
|
|
90
|
+
return setAuthCookie(NextResponse.json({ ok: true }), req, key.trim())
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
if (!key || !validateAccessKey(key)) {
|
|
78
94
|
let remaining = MAX_ATTEMPTS
|
|
79
95
|
if (rateLimitEnabled) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
|
|
4
|
-
import { normalizeOllamaSetupEndpoint } from './route'
|
|
4
|
+
import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './route'
|
|
5
5
|
|
|
6
6
|
test('normalizeOllamaSetupEndpoint strips local /v1 suffixes but preserves cloud endpoints', () => {
|
|
7
7
|
assert.equal(
|
|
@@ -17,3 +17,87 @@ test('normalizeOllamaSetupEndpoint strips local /v1 suffixes but preserves cloud
|
|
|
17
17
|
'https://ollama.com/v1',
|
|
18
18
|
)
|
|
19
19
|
})
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// normalizeOpenClawUrl
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
test('normalizeOpenClawUrl adds http:// to bare host:port', () => {
|
|
26
|
+
const { httpUrl, wsUrl } = normalizeOpenClawUrl('myhost:18789')
|
|
27
|
+
assert.equal(httpUrl, 'http://myhost:18789')
|
|
28
|
+
assert.equal(wsUrl, 'ws://myhost:18789')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('normalizeOpenClawUrl converts ws:// to http:// and vice-versa', () => {
|
|
32
|
+
const { httpUrl, wsUrl } = normalizeOpenClawUrl('ws://192.168.1.5:18789')
|
|
33
|
+
assert.equal(httpUrl, 'http://192.168.1.5:18789')
|
|
34
|
+
assert.equal(wsUrl, 'ws://192.168.1.5:18789')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('normalizeOpenClawUrl converts wss:// to https:// and vice-versa', () => {
|
|
38
|
+
const { httpUrl, wsUrl } = normalizeOpenClawUrl('wss://gateway.example.com')
|
|
39
|
+
assert.equal(httpUrl, 'https://gateway.example.com')
|
|
40
|
+
assert.equal(wsUrl, 'wss://gateway.example.com')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('normalizeOpenClawUrl strips trailing slashes', () => {
|
|
44
|
+
const { httpUrl, wsUrl } = normalizeOpenClawUrl('http://localhost:18789///')
|
|
45
|
+
assert.equal(httpUrl, 'http://localhost:18789')
|
|
46
|
+
assert.equal(wsUrl, 'ws://localhost:18789')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('normalizeOpenClawUrl defaults to localhost when empty', () => {
|
|
50
|
+
const { httpUrl, wsUrl } = normalizeOpenClawUrl('')
|
|
51
|
+
assert.equal(httpUrl, 'http://localhost:18789')
|
|
52
|
+
assert.equal(wsUrl, 'ws://localhost:18789')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('normalizeOpenClawUrl preserves existing http://', () => {
|
|
56
|
+
const { httpUrl, wsUrl } = normalizeOpenClawUrl('http://10.0.0.1:9999')
|
|
57
|
+
assert.equal(httpUrl, 'http://10.0.0.1:9999')
|
|
58
|
+
assert.equal(wsUrl, 'ws://10.0.0.1:9999')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('normalizeOpenClawUrl preserves https://', () => {
|
|
62
|
+
const { httpUrl, wsUrl } = normalizeOpenClawUrl('https://secure.example.com')
|
|
63
|
+
assert.equal(httpUrl, 'https://secure.example.com')
|
|
64
|
+
assert.equal(wsUrl, 'wss://secure.example.com')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// parseErrorMessage
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
function fakeResponse(body: string, status = 400): Response {
|
|
72
|
+
return new Response(body, { status })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('parseErrorMessage extracts JSON .error.message', async () => {
|
|
76
|
+
const res = fakeResponse(JSON.stringify({ error: { message: 'bad key' } }))
|
|
77
|
+
assert.equal(await parseErrorMessage(res, 'fallback'), 'bad key')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('parseErrorMessage extracts JSON .error string', async () => {
|
|
81
|
+
const res = fakeResponse(JSON.stringify({ error: 'rate limited' }))
|
|
82
|
+
assert.equal(await parseErrorMessage(res, 'fallback'), 'rate limited')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('parseErrorMessage extracts JSON .detail string', async () => {
|
|
86
|
+
const res = fakeResponse(JSON.stringify({ detail: 'not found' }))
|
|
87
|
+
assert.equal(await parseErrorMessage(res, 'fallback'), 'not found')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('parseErrorMessage returns raw text for non-JSON', async () => {
|
|
91
|
+
const res = fakeResponse('Service Unavailable')
|
|
92
|
+
assert.equal(await parseErrorMessage(res, 'fallback'), 'Service Unavailable')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('parseErrorMessage returns fallback for empty body', async () => {
|
|
96
|
+
const res = fakeResponse('')
|
|
97
|
+
assert.equal(await parseErrorMessage(res, 'fallback'), 'fallback')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('parseErrorMessage extracts .message from JSON', async () => {
|
|
101
|
+
const res = fakeResponse(JSON.stringify({ message: 'quota exceeded' }))
|
|
102
|
+
assert.equal(await parseErrorMessage(res, 'fallback'), 'quota exceeded')
|
|
103
|
+
})
|
|
@@ -40,7 +40,7 @@ function parseBody(input: unknown): SetupCheckBody {
|
|
|
40
40
|
return input as SetupCheckBody
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
async function parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
|
43
|
+
export async function parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
|
44
44
|
const text = await res.text().catch(() => '')
|
|
45
45
|
if (!text) return fallback
|
|
46
46
|
try {
|
|
@@ -60,24 +60,66 @@ async function checkOpenAiCompatible(
|
|
|
60
60
|
apiKey: string,
|
|
61
61
|
endpointRaw: string,
|
|
62
62
|
defaultEndpoint: string,
|
|
63
|
+
modelHint?: string,
|
|
63
64
|
): Promise<{ ok: boolean; message: string; normalizedEndpoint: string }> {
|
|
64
65
|
const normalizedEndpoint = (endpointRaw || defaultEndpoint).replace(/\/+$/, '')
|
|
65
|
-
|
|
66
|
+
|
|
67
|
+
// First, discover a model to test with (prefer the hint, fall back to the first available model)
|
|
68
|
+
let testModel = modelHint || ''
|
|
69
|
+
if (!testModel) {
|
|
70
|
+
try {
|
|
71
|
+
const modelsRes = await fetch(`${normalizedEndpoint}/models`, {
|
|
72
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
73
|
+
signal: AbortSignal.timeout(8_000),
|
|
74
|
+
cache: 'no-store',
|
|
75
|
+
})
|
|
76
|
+
if (modelsRes.ok) {
|
|
77
|
+
const modelsPayload = await modelsRes.json().catch(() => ({} as any))
|
|
78
|
+
const first = Array.isArray(modelsPayload?.data) ? modelsPayload.data[0] : null
|
|
79
|
+
if (first?.id) testModel = String(first.id)
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Model discovery failed — we'll still try the chat endpoint with the provider's default
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Fall back to a reasonable default per provider
|
|
87
|
+
if (!testModel) {
|
|
88
|
+
const fallbacks: Record<string, string> = {
|
|
89
|
+
OpenAI: 'gpt-4o-mini',
|
|
90
|
+
'Google Gemini': 'gemini-2.0-flash',
|
|
91
|
+
DeepSeek: 'deepseek-chat',
|
|
92
|
+
Groq: 'llama-3.3-70b-versatile',
|
|
93
|
+
'Together AI': 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
|
|
94
|
+
'Mistral AI': 'mistral-small-latest',
|
|
95
|
+
'xAI (Grok)': 'grok-3-mini-fast',
|
|
96
|
+
'Fireworks AI': 'accounts/fireworks/models/llama4-scout-instruct-basic',
|
|
97
|
+
}
|
|
98
|
+
testModel = fallbacks[providerName] || 'gpt-4o-mini'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Test the chat completions endpoint with a minimal request
|
|
102
|
+
const res = await fetch(`${normalizedEndpoint}/chat/completions`, {
|
|
103
|
+
method: 'POST',
|
|
66
104
|
headers: {
|
|
67
105
|
authorization: `Bearer ${apiKey}`,
|
|
106
|
+
'content-type': 'application/json',
|
|
68
107
|
},
|
|
69
|
-
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
model: testModel,
|
|
110
|
+
max_tokens: 8,
|
|
111
|
+
messages: [{ role: 'user', content: 'Reply OK' }],
|
|
112
|
+
}),
|
|
113
|
+
signal: AbortSignal.timeout(15_000),
|
|
70
114
|
cache: 'no-store',
|
|
71
115
|
})
|
|
72
116
|
if (!res.ok) {
|
|
73
117
|
const detail = await parseErrorMessage(res, `${providerName} returned ${res.status}.`)
|
|
74
118
|
return { ok: false, message: detail, normalizedEndpoint }
|
|
75
119
|
}
|
|
76
|
-
const payload = await res.json().catch(() => ({} as any))
|
|
77
|
-
const count = Array.isArray(payload?.data) ? payload.data.length : 0
|
|
78
120
|
return {
|
|
79
121
|
ok: true,
|
|
80
|
-
message:
|
|
122
|
+
message: `Connected to ${providerName}. Chat endpoint verified with ${testModel}.`,
|
|
81
123
|
normalizedEndpoint,
|
|
82
124
|
}
|
|
83
125
|
}
|
|
@@ -119,8 +161,7 @@ async function checkOllama(params: {
|
|
|
119
161
|
apiEndpoint: params.endpointRaw,
|
|
120
162
|
})
|
|
121
163
|
const normalizedEndpoint = normalizeOllamaSetupEndpoint(runtime.endpoint, runtime.useCloud)
|
|
122
|
-
const
|
|
123
|
-
const headers = runtime.apiKey ? { authorization: `Bearer ${runtime.apiKey}` } : undefined
|
|
164
|
+
const headers: Record<string, string> = runtime.apiKey ? { authorization: `Bearer ${runtime.apiKey}` } : {}
|
|
124
165
|
if (runtime.useCloud && !runtime.apiKey) {
|
|
125
166
|
return {
|
|
126
167
|
ok: false,
|
|
@@ -128,40 +169,72 @@ async function checkOllama(params: {
|
|
|
128
169
|
normalizedEndpoint,
|
|
129
170
|
}
|
|
130
171
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
172
|
+
|
|
173
|
+
// Discover a model to test with
|
|
174
|
+
let testModel = params.modelRaw || ''
|
|
175
|
+
let recommendedModel: string | undefined
|
|
176
|
+
if (!testModel) {
|
|
177
|
+
try {
|
|
178
|
+
const tagsPath = runtime.useCloud ? '/v1/models' : '/api/tags'
|
|
179
|
+
const res = await fetch(`${normalizedEndpoint}${tagsPath}`, {
|
|
180
|
+
headers: headers.authorization ? headers : undefined,
|
|
181
|
+
signal: AbortSignal.timeout(8_000),
|
|
182
|
+
cache: 'no-store',
|
|
183
|
+
})
|
|
184
|
+
if (res.ok) {
|
|
185
|
+
const payload = await res.json().catch(() => ({} as any))
|
|
186
|
+
const models = runtime.useCloud
|
|
187
|
+
? (Array.isArray(payload?.data) ? payload.data : [])
|
|
188
|
+
: (Array.isArray(payload?.models) ? payload.models : [])
|
|
189
|
+
const firstModel = runtime.useCloud
|
|
190
|
+
? (typeof models[0]?.id === 'string' ? String(models[0].id) : undefined)
|
|
191
|
+
: (typeof models[0]?.name === 'string' ? String(models[0].name).replace(/:latest$/, '') : undefined)
|
|
192
|
+
if (firstModel) {
|
|
193
|
+
testModel = firstModel
|
|
194
|
+
recommendedModel = firstModel
|
|
195
|
+
}
|
|
196
|
+
if (models.length === 0) {
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
message: runtime.useCloud
|
|
200
|
+
? 'Connected to Ollama Cloud, but no models were returned.'
|
|
201
|
+
: 'Connected to Ollama, but no models are installed yet. Run `ollama pull <model>` to add one.',
|
|
202
|
+
normalizedEndpoint,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// Model discovery failed — try chat anyway
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!testModel) testModel = 'llama3.2'
|
|
212
|
+
|
|
213
|
+
// Test the chat endpoint
|
|
214
|
+
const label = runtime.useCloud ? 'Ollama Cloud' : 'Ollama'
|
|
215
|
+
const chatEndpoint = `${normalizedEndpoint}/v1/chat/completions`
|
|
216
|
+
const chatBody = JSON.stringify({ model: testModel, max_tokens: 8, messages: [{ role: 'user', content: 'Reply OK' }] })
|
|
217
|
+
|
|
218
|
+
const chatRes = await fetch(chatEndpoint, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
221
|
+
body: chatBody,
|
|
222
|
+
signal: AbortSignal.timeout(30_000),
|
|
134
223
|
cache: 'no-store',
|
|
135
224
|
})
|
|
136
|
-
if (!
|
|
137
|
-
const detail = await parseErrorMessage(
|
|
138
|
-
return { ok: false, message: detail, normalizedEndpoint }
|
|
139
|
-
}
|
|
140
|
-
const payload = await res.json().catch(() => ({} as any))
|
|
141
|
-
const models = runtime.useCloud
|
|
142
|
-
? (Array.isArray(payload?.data) ? payload.data : [])
|
|
143
|
-
: (Array.isArray(payload?.models) ? payload.models : [])
|
|
144
|
-
const firstModel = runtime.useCloud
|
|
145
|
-
? (typeof models[0]?.id === 'string' ? String(models[0].id) : undefined)
|
|
146
|
-
: (typeof models[0]?.name === 'string' ? String(models[0].name).replace(/:latest$/, '') : undefined)
|
|
147
|
-
if (models.length === 0) {
|
|
148
|
-
return {
|
|
149
|
-
ok: true,
|
|
150
|
-
message: runtime.useCloud
|
|
151
|
-
? 'Connected to Ollama Cloud, but no models were returned.'
|
|
152
|
-
: 'Connected to Ollama, but no models are installed yet. Run `ollama pull <model>` to add one.',
|
|
153
|
-
normalizedEndpoint,
|
|
154
|
-
}
|
|
225
|
+
if (!chatRes.ok) {
|
|
226
|
+
const detail = await parseErrorMessage(chatRes, `${label} chat returned ${chatRes.status}.`)
|
|
227
|
+
return { ok: false, message: detail, normalizedEndpoint, recommendedModel }
|
|
155
228
|
}
|
|
156
229
|
return {
|
|
157
230
|
ok: true,
|
|
158
|
-
message: `Connected to ${
|
|
231
|
+
message: `Connected to ${label}. Chat verified with ${testModel}.`,
|
|
159
232
|
normalizedEndpoint,
|
|
160
|
-
recommendedModel:
|
|
233
|
+
recommendedModel: recommendedModel || testModel,
|
|
161
234
|
}
|
|
162
235
|
}
|
|
163
236
|
|
|
164
|
-
function normalizeOpenClawUrl(raw: string): { httpUrl: string; wsUrl: string } {
|
|
237
|
+
export function normalizeOpenClawUrl(raw: string): { httpUrl: string; wsUrl: string } {
|
|
165
238
|
let url = (raw || 'http://localhost:18789').replace(/\/+$/, '')
|
|
166
239
|
if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
|
|
167
240
|
const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
|
|
@@ -214,7 +287,7 @@ export async function POST(req: Request) {
|
|
|
214
287
|
case 'openai': {
|
|
215
288
|
if (!apiKey) return NextResponse.json({ ok: false, message: 'OpenAI API key is required.' })
|
|
216
289
|
const info = OPENAI_COMPATIBLE_DEFAULTS.openai
|
|
217
|
-
const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint)
|
|
290
|
+
const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
|
|
218
291
|
return NextResponse.json(result)
|
|
219
292
|
}
|
|
220
293
|
case 'anthropic': {
|
|
@@ -231,7 +304,7 @@ export async function POST(req: Request) {
|
|
|
231
304
|
case 'fireworks': {
|
|
232
305
|
const info = OPENAI_COMPATIBLE_DEFAULTS[provider]
|
|
233
306
|
if (!apiKey) return NextResponse.json({ ok: false, message: `${info.name} API key is required.` })
|
|
234
|
-
const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint)
|
|
307
|
+
const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
|
|
235
308
|
return NextResponse.json(result)
|
|
236
309
|
}
|
|
237
310
|
case 'ollama': {
|
package/src/app/login/page.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useRouter } from 'next/navigation'
|
|
4
3
|
import { AccessKeyGate } from '@/components/auth/access-key-gate'
|
|
5
4
|
|
|
6
5
|
export default function LoginPage() {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
return <AccessKeyGate onAuthenticated={() => {
|
|
7
|
+
// Full navigation so the bootstrap re-checks auth from scratch with the new cookie.
|
|
8
|
+
window.location.replace('/home')
|
|
9
|
+
}} />
|
|
9
10
|
}
|
package/src/app/setup/page.tsx
CHANGED
|
@@ -224,6 +224,7 @@ export function AgentSheet() {
|
|
|
224
224
|
const [proactiveMemory, setProactiveMemory] = useState(false)
|
|
225
225
|
const [autoRecovery, setAutoRecovery] = useState(false)
|
|
226
226
|
const [disabled, setDisabled] = useState(false)
|
|
227
|
+
const [filesystemScope, setFilesystemScope] = useState<'workspace' | 'machine'>('workspace')
|
|
227
228
|
const [voiceId, setVoiceId] = useState('')
|
|
228
229
|
const [heartbeatEnabled, setHeartbeatEnabled] = useState(false)
|
|
229
230
|
const [heartbeatIntervalSec, setHeartbeatIntervalSec] = useState('') // '' = default (30m)
|
|
@@ -422,6 +423,7 @@ export function AgentSheet() {
|
|
|
422
423
|
setProactiveMemory(editing.proactiveMemory || false)
|
|
423
424
|
setAutoRecovery(editing.autoRecovery || false)
|
|
424
425
|
setDisabled(editing.disabled === true)
|
|
426
|
+
setFilesystemScope(editing.filesystemScope === 'machine' ? 'machine' : 'workspace')
|
|
425
427
|
setVoiceId(editing.elevenLabsVoiceId || '')
|
|
426
428
|
setHeartbeatEnabled(editing.heartbeatEnabled || false)
|
|
427
429
|
setHeartbeatIntervalSec(parseDurationToSec(editing.heartbeatInterval, editing.heartbeatIntervalSec))
|
|
@@ -674,6 +676,7 @@ export function AgentSheet() {
|
|
|
674
676
|
proactiveMemory,
|
|
675
677
|
autoRecovery,
|
|
676
678
|
disabled,
|
|
679
|
+
filesystemScope: filesystemScope === 'machine' ? 'machine' as const : undefined,
|
|
677
680
|
elevenLabsVoiceId: voiceId.trim() || null,
|
|
678
681
|
heartbeatEnabled,
|
|
679
682
|
heartbeatInterval: heartbeatIntervalSec ? formatHbDuration(Number(heartbeatIntervalSec)) : null,
|
|
@@ -2239,6 +2242,22 @@ export function AgentSheet() {
|
|
|
2239
2242
|
</div>
|
|
2240
2243
|
)}
|
|
2241
2244
|
|
|
2245
|
+
{/* Filesystem Access */}
|
|
2246
|
+
<div className="mb-8">
|
|
2247
|
+
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Filesystem Access</label>
|
|
2248
|
+
<select
|
|
2249
|
+
value={filesystemScope}
|
|
2250
|
+
onChange={(e) => setFilesystemScope(e.target.value as 'workspace' | 'machine')}
|
|
2251
|
+
className="w-full h-10 px-3 rounded-[10px] bg-white/[0.04] border border-white/[0.06] text-[14px] text-text-2"
|
|
2252
|
+
>
|
|
2253
|
+
<option value="workspace">Workspace only</option>
|
|
2254
|
+
<option value="machine">Full machine</option>
|
|
2255
|
+
</select>
|
|
2256
|
+
{filesystemScope === 'machine' && (
|
|
2257
|
+
<p className="mt-2 text-[12px] text-amber-400/80">Agent can access any file your user account can reach. Sensitive paths (.ssh, .env, .gnupg) are blocked by default.</p>
|
|
2258
|
+
)}
|
|
2259
|
+
</div>
|
|
2260
|
+
|
|
2242
2261
|
{/* Platform — hidden for providers that manage capabilities outside LangGraph */}
|
|
2243
2262
|
{!hasNativeCapabilities && (
|
|
2244
2263
|
<div className="mb-8">
|