@swarmclawai/swarmclaw 0.9.5 → 0.9.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.
Files changed (69) hide show
  1. package/README.md +14 -14
  2. package/bin/server-cmd.js +1 -3
  3. package/bin/swarmclaw.js +6 -0
  4. package/package.json +3 -1
  5. package/src/app/api/auth/route.ts +20 -4
  6. package/src/app/api/settings/route.ts +1 -0
  7. package/src/app/api/setup/check-provider/route.test.ts +85 -1
  8. package/src/app/api/setup/check-provider/route.ts +108 -35
  9. package/src/app/api/wallets/[id]/send/route.ts +10 -4
  10. package/src/app/api/wallets/route.ts +5 -2
  11. package/src/app/login/page.tsx +4 -3
  12. package/src/app/settings/page.tsx +9 -0
  13. package/src/app/setup/page.tsx +1 -0
  14. package/src/components/agents/agent-sheet.tsx +19 -0
  15. package/src/components/auth/access-key-gate.tsx +247 -42
  16. package/src/components/auth/setup-wizard/index.tsx +463 -0
  17. package/src/components/auth/setup-wizard/shared.tsx +96 -0
  18. package/src/components/auth/setup-wizard/step-agents.tsx +542 -0
  19. package/src/components/auth/setup-wizard/step-connect.tsx +553 -0
  20. package/src/components/auth/setup-wizard/step-next.tsx +71 -0
  21. package/src/components/auth/setup-wizard/step-profile.tsx +148 -0
  22. package/src/components/auth/setup-wizard/step-progress.tsx +56 -0
  23. package/src/components/auth/setup-wizard/step-providers.tsx +154 -0
  24. package/src/components/auth/setup-wizard/types.test.ts +11 -0
  25. package/src/components/auth/setup-wizard/types.ts +149 -0
  26. package/src/components/auth/setup-wizard/utils.test.ts +159 -0
  27. package/src/components/auth/setup-wizard/utils.ts +167 -0
  28. package/src/components/auth/user-picker.tsx +137 -71
  29. package/src/components/layout/dashboard-shell.tsx +9 -3
  30. package/src/components/wallets/wallet-panel.tsx +12 -1
  31. package/src/components/wallets/wallet-section.tsx +4 -1
  32. package/src/hooks/setup-done-detection.test.ts +27 -0
  33. package/src/hooks/setup-done-detection.ts +13 -0
  34. package/src/hooks/use-app-bootstrap.ts +6 -3
  35. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
  36. package/src/lib/server/agents/main-agent-loop.ts +12 -1
  37. package/src/lib/server/build-llm.ts +1 -1
  38. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +42 -0
  39. package/src/lib/server/chat-execution/stream-agent-chat.ts +30 -1
  40. package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
  41. package/src/lib/server/connectors/contact-boundaries.ts +50 -3
  42. package/src/lib/server/connectors/manager.ts +10 -5
  43. package/src/lib/server/connectors/policy.ts +7 -4
  44. package/src/lib/server/connectors/session.ts +18 -0
  45. package/src/lib/server/ollama-runtime.ts +1 -1
  46. package/src/lib/server/provider-model-discovery.test.ts +242 -0
  47. package/src/lib/server/provider-model-discovery.ts +16 -14
  48. package/src/lib/server/session-tools/context.ts +5 -1
  49. package/src/lib/server/session-tools/crud.ts +1 -1
  50. package/src/lib/server/session-tools/document.ts +5 -4
  51. package/src/lib/server/session-tools/edit_file.ts +3 -3
  52. package/src/lib/server/session-tools/extract.ts +2 -2
  53. package/src/lib/server/session-tools/file.ts +20 -19
  54. package/src/lib/server/session-tools/git.ts +3 -3
  55. package/src/lib/server/session-tools/index.ts +17 -1
  56. package/src/lib/server/session-tools/memory.ts +2 -1
  57. package/src/lib/server/session-tools/monitor.ts +5 -4
  58. package/src/lib/server/session-tools/shell.ts +9 -5
  59. package/src/lib/server/session-tools/table.ts +16 -15
  60. package/src/lib/server/session-tools/web-utils.ts +4 -4
  61. package/src/lib/server/session-tools/web.ts +5 -5
  62. package/src/lib/server/storage.ts +13 -0
  63. package/src/lib/server/wallet/wallet-service.test.ts +25 -1
  64. package/src/lib/server/wallet/wallet-service.ts +13 -0
  65. package/src/lib/setup-defaults.ts +36 -9
  66. package/src/stores/slices/agent-slice.ts +1 -1
  67. package/src/types/index.ts +4 -1
  68. package/src/views/settings/section-wallets.tsx +35 -0
  69. package/src/components/auth/setup-wizard.tsx +0 -1575
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 server
132
+ swarmclaw
133
133
  ```
134
134
 
135
- `swarmclaw` by itself opens the CLI. `swarmclaw server` launches the packaged standalone server on `http://localhost:3456`.
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 server
144
- pnpm dlx @swarmclawai/swarmclaw server
145
- yarn dlx @swarmclawai/swarmclaw server
146
- bunx @swarmclawai/swarmclaw server
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
@@ -155,7 +155,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
155
155
  The installer resolves the latest stable release tag and installs that version by default.
156
156
  It also builds the production bundle so `npm run start` is ready immediately after install.
157
157
  No Deno install is required; local sandbox execution is Docker-first with automatic host Node fallback.
158
- To pin a version: `SWARMCLAW_VERSION=v0.9.4 curl ... | bash`
158
+ To pin a version: `SWARMCLAW_VERSION=v0.9.6 curl ... | bash`
159
159
 
160
160
  Or run locally from the repo (friendly for non-technical users):
161
161
 
@@ -729,15 +729,15 @@ On `v*` tags, GitHub Actions will:
729
729
  2. Create a GitHub Release
730
730
  3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
731
731
 
732
- #### v0.9.5 Release Readiness Notes
732
+ #### v0.9.6 Release Readiness Notes
733
733
 
734
- Before shipping `v0.9.5`, confirm the following user-facing changes are reflected in docs:
734
+ Before shipping `v0.9.6`, confirm the following user-facing changes are reflected in docs:
735
735
 
736
- 1. Plugin/runtime docs mention the new typed lifecycle hooks: `beforePromptBuild`, `beforeToolCall`, `beforeModelResolve`, `llmInput`, `llmOutput`, `toolResultPersist`, `beforeMessageWrite`, plus session and subagent lifecycle hooks.
737
- 2. Connector/memory docs explain that quiet-boundary memories are matched by identifiers and agent aliases, with explicit boundary metadata support, rather than relying on hardcoded person-name fallbacks.
738
- 3. Skills docs still explain that local skills are discoverable by default, pinned skills stay always-on, and `use_skill` is the runtime path for selection/loading/dispatch.
739
- 4. Site and README install/version strings are updated to `v0.9.5`, including release notes index text and any pinned install snippets.
740
- 5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.5`) versus the git tag (`v0.9.5`).
736
+ 1. Wallet docs explain the new global wallet approval override in Settings/Wallets, and note that per-wallet approval toggles remain stored but are ignored when the global switch is off.
737
+ 2. Runtime/autonomy docs mention the continuation hardening for long-running tasks: intent-only kickoff replies now get one bounded followthrough, and chat-originated progress runs can schedule a bounded main-loop continuation without waiting for another user ping.
738
+ 3. Skills/runtime docs still explain that local skills are discoverable by default, pinned skills stay always-on, and `use_skill` is the runtime path for selection/loading/dispatch.
739
+ 4. Site and README install/version strings are updated to `v0.9.6`, including release notes index text and any pinned install snippets.
740
+ 5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.6`) versus the git tag (`v0.9.6`).
741
741
 
742
742
  ## CLI
743
743
 
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', '--webpack'], {
141
+ execFileSync(process.execPath, [nextCli, 'build'], {
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.5",
3
+ "version": "0.9.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": {
@@ -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"
@@ -76,6 +77,7 @@
76
77
  "@langchain/core": "^1.1.31",
77
78
  "@langchain/langgraph": "^1.2.2",
78
79
  "@langchain/openai": "^1.2.8",
80
+ "@modelcontextprotocol/sdk": "^1.27.1",
79
81
  "@multiavatar/multiavatar": "^1.0.7",
80
82
  "@playwright/mcp": "^0.0.68",
81
83
  "@slack/bolt": "^4.6.0",
@@ -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: isFirstTimeSetup(),
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) {
@@ -129,6 +129,7 @@ export async function PUT(req: Request) {
129
129
  settings.taskQualityGateRequireReport = parseBoolSetting(settings.taskQualityGateRequireReport, false)
130
130
  settings.taskManagementEnabled = parseBoolSetting(settings.taskManagementEnabled, true)
131
131
  settings.projectManagementEnabled = parseBoolSetting(settings.projectManagementEnabled, true)
132
+ settings.walletApprovalsEnabled = parseBoolSetting(settings.walletApprovalsEnabled, true)
132
133
  settings.integrityMonitorEnabled = parseBoolSetting(settings.integrityMonitorEnabled, true)
133
134
  settings.daemonAutostartEnabled = parseBoolSetting(settings.daemonAutostartEnabled, true)
134
135
  settings.sessionResetMode = settings.sessionResetMode === 'daily' ? 'daily' : settings.sessionResetMode === 'idle' ? 'idle' : null
@@ -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
- const res = await fetch(`${normalizedEndpoint}/models`, {
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
- signal: AbortSignal.timeout(10_000),
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: count > 0 ? `Connected to ${providerName}. ${count} model(s) available.` : `Connected to ${providerName}.`,
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 tagsPath = runtime.useCloud ? '/v1/models' : '/api/tags'
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
- const res = await fetch(`${normalizedEndpoint}${tagsPath}`, {
132
- headers,
133
- signal: AbortSignal.timeout(8_000),
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 (!res.ok) {
137
- const detail = await parseErrorMessage(res, `Ollama returned ${res.status}.`)
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 ${runtime.useCloud ? 'Ollama Cloud' : 'Ollama'}. ${models.length} model(s) available.`,
231
+ message: `Connected to ${label}. Chat verified with ${testModel}.`,
159
232
  normalizedEndpoint,
160
- recommendedModel: firstModel,
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': {
@@ -1,12 +1,17 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
- import { loadWallets, upsertWalletTransaction } from '@/lib/server/storage'
3
+ import { loadSettings, loadWallets, upsertWalletTransaction } from '@/lib/server/storage'
4
4
  import { notify } from '@/lib/server/ws-hub'
5
5
  import type { AgentWallet, WalletTransaction } from '@/types'
6
6
  import {
7
7
  normalizeAtomicString,
8
8
  } from '@/lib/wallet/wallet'
9
- import { isValidWalletAddress, sendWalletNativeAsset, validateWalletSendLimits } from '@/lib/server/wallet/wallet-service'
9
+ import {
10
+ isValidWalletAddress,
11
+ sendWalletNativeAsset,
12
+ validateWalletSendLimits,
13
+ walletRequiresApproval,
14
+ } from '@/lib/server/wallet/wallet-service'
10
15
  import { errorMessage } from '@/lib/shared-utils'
11
16
  export const dynamic = 'force-dynamic'
12
17
 
@@ -15,6 +20,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
15
20
  const wallets = loadWallets() as Record<string, AgentWallet>
16
21
  const wallet = wallets[id]
17
22
  if (!wallet) return NextResponse.json({ error: 'Wallet not found' }, { status: 404 })
23
+ const settings = loadSettings()
18
24
 
19
25
  const body = await req.json()
20
26
  const toAddress = typeof body.toAddress === 'string' ? body.toAddress.trim() : ''
@@ -32,8 +38,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
32
38
  const txId = genId(8)
33
39
  const now = Date.now()
34
40
 
35
- // If requireApproval, create pending tx and return it
36
- if (wallet.requireApproval) {
41
+ // When approvals are enabled globally and for this wallet, create a pending request instead of sending.
42
+ if (walletRequiresApproval(wallet, settings)) {
37
43
  const pendingTx: WalletTransaction = {
38
44
  id: txId,
39
45
  walletId: id,
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadAgents, loadWallets } from '@/lib/server/storage'
2
+ import { loadAgents, loadSettings, loadWallets } from '@/lib/server/storage'
3
3
  import { createAgentWallet, getAgentActiveWalletId, getWalletPortfolioSnapshot, stripWalletPrivateKey } from '@/lib/server/wallet/wallet-service'
4
4
  import { buildEmptyWalletPortfolio } from '@/lib/server/wallet/wallet-portfolio'
5
5
  import type { AgentWallet, WalletPortfolioSummary } from '@/types'
@@ -62,13 +62,16 @@ export async function GET(req: Request) {
62
62
 
63
63
  export async function POST(req: Request) {
64
64
  const body = await req.json()
65
+ const settings = loadSettings()
65
66
  try {
66
67
  const wallet = createAgentWallet({
67
68
  agentId: body.agentId,
68
69
  chain: body.chain,
69
70
  provider: body.provider,
70
71
  label: body.label,
71
- requireApproval: body.requireApproval,
72
+ requireApproval: typeof body.requireApproval === 'boolean'
73
+ ? body.requireApproval
74
+ : settings.walletApprovalsEnabled !== false,
72
75
  spendingLimitAtomic: body.spendingLimitAtomic ?? body.spendingLimitLamports,
73
76
  dailyLimitAtomic: body.dailyLimitAtomic ?? body.dailyLimitLamports,
74
77
  })
@@ -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
- const router = useRouter()
8
- return <AccessKeyGate onAuthenticated={() => router.replace('/home')} />
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
  }
@@ -11,6 +11,7 @@ import { ThemeSection } from '@/views/settings/section-theme'
11
11
  import { OrchestratorSection } from '@/views/settings/section-orchestrator'
12
12
  import { RuntimeLoopSection } from '@/views/settings/section-runtime-loop'
13
13
  import { CapabilityPolicySection } from '@/views/settings/section-capability-policy'
14
+ import { WalletsSection } from '@/views/settings/section-wallets'
14
15
  import { StorageSection } from '@/views/settings/section-storage'
15
16
  import { VoiceSection } from '@/views/settings/section-voice'
16
17
  import { WebSearchSection } from '@/views/settings/section-web-search'
@@ -147,6 +148,14 @@ export default function SettingsRoute() {
147
148
  keywords: ['storage', 'uploads', 'disk', 'cleanup', 'files'],
148
149
  render: () => <StorageSection {...sectionProps} />,
149
150
  },
151
+ {
152
+ id: 'wallets',
153
+ tabId: 'general',
154
+ title: 'Wallets',
155
+ description: 'Control global wallet approval behavior and auto-execution defaults.',
156
+ keywords: ['wallet', 'wallets', 'approval', 'approvals', 'crypto', 'send'],
157
+ render: () => <WalletsSection {...sectionProps} />,
158
+ },
150
159
  {
151
160
  id: 'theme',
152
161
  tabId: 'appearance',
@@ -10,6 +10,7 @@ export default function SetupPage() {
10
10
  <SetupWizard
11
11
  onComplete={() => {
12
12
  safeStorageSet('sc_setup_done', '1')
13
+ window.dispatchEvent(new Event('sc:setup-complete'))
13
14
  router.replace('/home')
14
15
  }}
15
16
  />
@@ -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">