@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
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
resolveOpenClawDashboardUrl,
|
|
8
8
|
getOpenClawErrorHint,
|
|
9
9
|
withHttpScheme,
|
|
10
|
+
buildStarterDrafts,
|
|
11
|
+
preferredConfiguredProvider,
|
|
10
12
|
} from './utils'
|
|
13
|
+
import type { ConfiguredProvider } from './types'
|
|
11
14
|
|
|
12
15
|
// ---------------------------------------------------------------------------
|
|
13
16
|
// stepIndex
|
|
@@ -157,3 +160,79 @@ test('withHttpScheme preserves ws://', () => {
|
|
|
157
160
|
test('withHttpScheme preserves wss://', () => {
|
|
158
161
|
assert.equal(withHttpScheme('wss://example.com'), 'wss://example.com')
|
|
159
162
|
})
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// buildStarterDrafts — OpenClaw provider handling
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function makeConfiguredProvider(overrides: Partial<ConfiguredProvider> & { provider: ConfiguredProvider['provider'] }): ConfiguredProvider {
|
|
169
|
+
return {
|
|
170
|
+
id: 'cp-1',
|
|
171
|
+
name: 'Test Provider',
|
|
172
|
+
credentialId: null,
|
|
173
|
+
endpoint: null,
|
|
174
|
+
defaultModel: '',
|
|
175
|
+
gatewayProfileId: null,
|
|
176
|
+
verified: true,
|
|
177
|
+
...overrides,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
test('buildStarterDrafts assigns OpenClaw provider to drafts', () => {
|
|
182
|
+
const cp = makeConfiguredProvider({ provider: 'openclaw', endpoint: 'http://localhost:18789' })
|
|
183
|
+
const drafts = buildStarterDrafts({
|
|
184
|
+
starterKitId: 'personal_assistant',
|
|
185
|
+
intentText: '',
|
|
186
|
+
configuredProviders: [cp],
|
|
187
|
+
})
|
|
188
|
+
assert.ok(drafts.length > 0, 'should produce at least one draft')
|
|
189
|
+
for (const d of drafts) {
|
|
190
|
+
assert.equal(d.provider, 'openclaw')
|
|
191
|
+
assert.equal(d.providerConfigId, cp.id)
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('buildStarterDrafts OpenClaw drafts use empty model (not "default")', () => {
|
|
196
|
+
const cp = makeConfiguredProvider({ provider: 'openclaw', defaultModel: '' })
|
|
197
|
+
const drafts = buildStarterDrafts({
|
|
198
|
+
starterKitId: 'personal_assistant',
|
|
199
|
+
intentText: '',
|
|
200
|
+
configuredProviders: [cp],
|
|
201
|
+
})
|
|
202
|
+
for (const d of drafts) {
|
|
203
|
+
// Model should be empty since the gateway controls the model
|
|
204
|
+
assert.equal(d.model, '')
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('buildStarterDrafts OpenClaw drafts inherit endpoint from provider', () => {
|
|
209
|
+
const cp = makeConfiguredProvider({ provider: 'openclaw', endpoint: 'http://10.0.0.5:18789' })
|
|
210
|
+
const drafts = buildStarterDrafts({
|
|
211
|
+
starterKitId: 'personal_assistant',
|
|
212
|
+
intentText: '',
|
|
213
|
+
configuredProviders: [cp],
|
|
214
|
+
})
|
|
215
|
+
for (const d of drafts) {
|
|
216
|
+
assert.equal(d.apiEndpoint, 'http://10.0.0.5:18789')
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('buildStarterDrafts carries dashboardUrl through from ConfiguredProvider', () => {
|
|
221
|
+
const cp = makeConfiguredProvider({
|
|
222
|
+
provider: 'openclaw',
|
|
223
|
+
endpoint: 'http://localhost:18789',
|
|
224
|
+
dashboardUrl: 'http://localhost:18789?token=my-secret',
|
|
225
|
+
})
|
|
226
|
+
// dashboardUrl lives on the ConfiguredProvider, not the draft — verify it's accessible
|
|
227
|
+
assert.equal(cp.dashboardUrl, 'http://localhost:18789?token=my-secret')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('preferredConfiguredProvider picks openclaw provider for openclaw template', () => {
|
|
231
|
+
const openclawCp = makeConfiguredProvider({ id: 'oc-1', provider: 'openclaw' })
|
|
232
|
+
const openaiCp = makeConfiguredProvider({ id: 'oai-1', provider: 'openai' })
|
|
233
|
+
const result = preferredConfiguredProvider(
|
|
234
|
+
{ id: 'tmpl-1', name: 'Test', description: '', systemPrompt: '', tools: [], recommendedProviders: ['openclaw'] },
|
|
235
|
+
[openaiCp, openclawCp],
|
|
236
|
+
)
|
|
237
|
+
assert.equal(result?.id, 'oc-1')
|
|
238
|
+
})
|
|
@@ -162,6 +162,8 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
162
162
|
const sourceDropdownRef = useRef<HTMLDivElement>(null)
|
|
163
163
|
const [syncingHistory, setSyncingHistory] = useState(false)
|
|
164
164
|
const [syncResult, setSyncResult] = useState('')
|
|
165
|
+
const [openClawDashboardUrl, setOpenClawDashboardUrl] = useState<string | null>(null)
|
|
166
|
+
const [openClawDashboardLoading, setOpenClawDashboardLoading] = useState(false)
|
|
165
167
|
const [renaming, setRenaming] = useState(false)
|
|
166
168
|
const [renameDraft, setRenameDraft] = useState('')
|
|
167
169
|
const [renameSaving, setRenameSaving] = useState(false)
|
|
@@ -494,6 +496,25 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
494
496
|
return () => clearTimeout(timer)
|
|
495
497
|
}, [syncResult])
|
|
496
498
|
|
|
499
|
+
const loadOpenClawDashboardUrl = useCallback(async () => {
|
|
500
|
+
if (!session.agentId || openClawDashboardLoading) return
|
|
501
|
+
setOpenClawDashboardLoading(true)
|
|
502
|
+
try {
|
|
503
|
+
const result = await api<{ url: string }>('GET', `/openclaw/dashboard-url?agentId=${session.agentId}`)
|
|
504
|
+
if (result.url) setOpenClawDashboardUrl(result.url)
|
|
505
|
+
} catch {
|
|
506
|
+
// Fall back to agent endpoint
|
|
507
|
+
const ep = (agent?.apiEndpoint || 'http://localhost:18789').replace(/\/+$/, '')
|
|
508
|
+
setOpenClawDashboardUrl(/^https?:\/\//i.test(ep) ? ep : `http://${ep}`)
|
|
509
|
+
} finally {
|
|
510
|
+
setOpenClawDashboardLoading(false)
|
|
511
|
+
}
|
|
512
|
+
}, [session.agentId, agent?.apiEndpoint, openClawDashboardLoading])
|
|
513
|
+
|
|
514
|
+
useEffect(() => {
|
|
515
|
+
if (isOpenClawAgent && !openClawDashboardUrl) void loadOpenClawDashboardUrl()
|
|
516
|
+
}, [isOpenClawAgent, openClawDashboardUrl, loadOpenClawDashboardUrl])
|
|
517
|
+
|
|
497
518
|
const startRename = () => {
|
|
498
519
|
if (!agent) return
|
|
499
520
|
setRenameDraft(agent.name)
|
|
@@ -751,7 +772,29 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
751
772
|
</HeaderChip>
|
|
752
773
|
)
|
|
753
774
|
})}
|
|
754
|
-
{
|
|
775
|
+
{isOpenClawAgent ? (
|
|
776
|
+
<a
|
|
777
|
+
href={openClawDashboardUrl || '#'}
|
|
778
|
+
target="_blank"
|
|
779
|
+
rel="noopener noreferrer"
|
|
780
|
+
onClick={(e) => {
|
|
781
|
+
if (openClawDashboardUrl) return
|
|
782
|
+
e.preventDefault()
|
|
783
|
+
if (!openClawDashboardLoading) void loadOpenClawDashboardUrl()
|
|
784
|
+
}}
|
|
785
|
+
className="inline-flex max-w-full items-center gap-1.5 rounded-[9px] border border-white/[0.06] bg-white/[0.03] px-2.5 py-1 text-[10px] font-600 text-text-3/70 backdrop-blur-sm transition-colors hover:border-white/[0.1] hover:bg-white/[0.06] hover:text-accent-bright shrink-0"
|
|
786
|
+
>
|
|
787
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
|
788
|
+
<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z" />
|
|
789
|
+
</svg>
|
|
790
|
+
<span className="truncate max-w-[min(42vw,220px)]">OpenClaw Dashboard</span>
|
|
791
|
+
<svg width="10" height="10" viewBox="0 0 14 14" fill="none" className="shrink-0 opacity-50">
|
|
792
|
+
<path d="M5.5 3H3.5C3.22386 3 3 3.22386 3 3.5V10.5C3 10.7761 3.22386 11 3.5 11H10.5C10.7761 11 11 10.7761 11 10.5V8.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
793
|
+
<path d="M8 2H12V6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
|
794
|
+
<path d="M12 2L7 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
795
|
+
</svg>
|
|
796
|
+
</a>
|
|
797
|
+
) : modelName ? (
|
|
755
798
|
<div className="relative shrink-0" ref={modelSwitcherRef}>
|
|
756
799
|
<Tip label={`Switch model (${providerLabel})`}>
|
|
757
800
|
<button
|
|
@@ -803,7 +846,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
803
846
|
</div>
|
|
804
847
|
)}
|
|
805
848
|
</div>
|
|
806
|
-
)}
|
|
849
|
+
) : null}
|
|
807
850
|
{threadContextLabel && (
|
|
808
851
|
<HeaderChip title={threadContextLabel}>
|
|
809
852
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
|
|
4
|
+
// Verify that rpcOnConnectedGateway is exported (needed by setup check-provider route)
|
|
5
|
+
test('rpcOnConnectedGateway is exported from openclaw provider', async () => {
|
|
6
|
+
const mod = await import('./openclaw')
|
|
7
|
+
assert.equal(typeof mod.rpcOnConnectedGateway, 'function')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('wsConnect is exported from openclaw provider', async () => {
|
|
11
|
+
const mod = await import('./openclaw')
|
|
12
|
+
assert.equal(typeof mod.wsConnect, 'function')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('getDeviceId is exported from openclaw provider', async () => {
|
|
16
|
+
const mod = await import('./openclaw')
|
|
17
|
+
assert.equal(typeof mod.getDeviceId, 'function')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('buildOpenClawConnectParams is exported from openclaw provider', async () => {
|
|
21
|
+
const mod = await import('./openclaw')
|
|
22
|
+
assert.equal(typeof mod.buildOpenClawConnectParams, 'function')
|
|
23
|
+
})
|
|
@@ -321,7 +321,7 @@ export function buildOpenClawSessionKey(
|
|
|
321
321
|
return `agent:${agentId}:swarm:${sessionId}`
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
async function rpcOnConnectedGateway(
|
|
324
|
+
export async function rpcOnConnectedGateway(
|
|
325
325
|
ws: InstanceType<typeof WebSocket>,
|
|
326
326
|
method: string,
|
|
327
327
|
params: unknown,
|
|
@@ -90,4 +90,39 @@ describe('data-dir resolution', () => {
|
|
|
90
90
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
91
91
|
}
|
|
92
92
|
})
|
|
93
|
+
|
|
94
|
+
it('derives runtime directories from SWARMCLAW_HOME when set', () => {
|
|
95
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-data-dir-home-'))
|
|
96
|
+
const fakeHome = path.join(tempDir, 'home')
|
|
97
|
+
const swarmclawHome = path.join(tempDir, 'project', '.swarmclaw')
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const env = { ...process.env, HOME: fakeHome, SWARMCLAW_HOME: swarmclawHome }
|
|
101
|
+
delete (env as any).DATA_DIR
|
|
102
|
+
delete (env as any).WORKSPACE_DIR
|
|
103
|
+
delete (env as any).BROWSER_PROFILES_DIR
|
|
104
|
+
|
|
105
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
|
|
106
|
+
const modNs = await import('./src/lib/server/data-dir')
|
|
107
|
+
const mod = modNs.default || modNs['module.exports'] || modNs
|
|
108
|
+
console.log(JSON.stringify({
|
|
109
|
+
dataDir: mod.DATA_DIR,
|
|
110
|
+
workspaceDir: mod.WORKSPACE_DIR,
|
|
111
|
+
browserProfilesDir: mod.BROWSER_PROFILES_DIR,
|
|
112
|
+
}))
|
|
113
|
+
`], {
|
|
114
|
+
cwd: repoRoot,
|
|
115
|
+
env,
|
|
116
|
+
encoding: 'utf-8',
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
120
|
+
const payload = extractLastJson(result.stdout || '')
|
|
121
|
+
assert.equal(payload.dataDir, path.join(swarmclawHome, 'data'))
|
|
122
|
+
assert.equal(payload.workspaceDir, path.join(swarmclawHome, 'workspace'))
|
|
123
|
+
assert.equal(payload.browserProfilesDir, path.join(swarmclawHome, 'browser-profiles'))
|
|
124
|
+
} finally {
|
|
125
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
126
|
+
}
|
|
127
|
+
})
|
|
93
128
|
})
|
|
@@ -13,9 +13,16 @@ function isBuildBootstrapEnv(env: NodeJS.ProcessEnv = process.env, argv: string[
|
|
|
13
13
|
|
|
14
14
|
export const IS_BUILD_BOOTSTRAP = isBuildBootstrapEnv()
|
|
15
15
|
|
|
16
|
+
function resolveSwarmclawHome(): string | null {
|
|
17
|
+
const configured = process.env.SWARMCLAW_HOME?.trim()
|
|
18
|
+
return configured ? path.resolve(configured) : null
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
function resolveDataDir(): string {
|
|
17
22
|
if (process.env.DATA_DIR) return process.env.DATA_DIR
|
|
18
23
|
if (IS_BUILD_BOOTSTRAP) return path.join(os.tmpdir(), 'swarmclaw-build-data')
|
|
24
|
+
const appHome = resolveSwarmclawHome()
|
|
25
|
+
if (appHome) return path.join(appHome, 'data')
|
|
19
26
|
return path.join(process.cwd(), 'data')
|
|
20
27
|
}
|
|
21
28
|
|
|
@@ -41,6 +48,8 @@ function supportsChildWrites(dir: string): boolean {
|
|
|
41
48
|
function resolveWorkspaceDir(): string {
|
|
42
49
|
if (process.env.WORKSPACE_DIR) return process.env.WORKSPACE_DIR
|
|
43
50
|
if (IS_BUILD_BOOTSTRAP) return path.join(DATA_DIR, 'workspace')
|
|
51
|
+
const appHome = resolveSwarmclawHome()
|
|
52
|
+
if (appHome) return path.join(appHome, 'workspace')
|
|
44
53
|
const external = path.join(os.homedir(), '.swarmclaw', 'workspace')
|
|
45
54
|
if (supportsChildWrites(external)) {
|
|
46
55
|
return external
|
|
@@ -53,6 +62,8 @@ export const WORKSPACE_DIR = resolveWorkspaceDir()
|
|
|
53
62
|
function resolveBrowserProfilesDir(): string {
|
|
54
63
|
if (process.env.BROWSER_PROFILES_DIR) return process.env.BROWSER_PROFILES_DIR
|
|
55
64
|
if (IS_BUILD_BOOTSTRAP) return path.join(DATA_DIR, 'browser-profiles')
|
|
65
|
+
const appHome = resolveSwarmclawHome()
|
|
66
|
+
if (appHome) return path.join(appHome, 'browser-profiles')
|
|
56
67
|
const external = path.join(os.homedir(), '.swarmclaw', 'browser-profiles')
|
|
57
68
|
if (supportsChildWrites(external)) {
|
|
58
69
|
return external
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { deriveOpenClawWsUrl, normalizeOpenClawEndpoint } from '@/lib/openclaw/openclaw-endpoint'
|
|
2
2
|
import { wsConnect } from '@/lib/providers/openclaw'
|
|
3
|
-
import { decryptKey, loadCredentials } from '../storage'
|
|
3
|
+
import { decryptKey, loadCredentials, loadGatewayProfiles, saveGatewayProfiles } from '../storage'
|
|
4
|
+
import { notify } from '../ws-hub'
|
|
5
|
+
import type { GatewayProfile } from '@/types'
|
|
4
6
|
|
|
5
7
|
export interface OpenClawHealthInput {
|
|
6
8
|
endpoint?: string | null
|
|
@@ -411,3 +413,30 @@ export async function probeOpenClawHealth(input: OpenClawHealthInput): Promise<O
|
|
|
411
413
|
hint: http.hint,
|
|
412
414
|
}
|
|
413
415
|
}
|
|
416
|
+
|
|
417
|
+
export function persistGatewayHealthResult(
|
|
418
|
+
id: string,
|
|
419
|
+
result: OpenClawHealthResult,
|
|
420
|
+
now = Date.now(),
|
|
421
|
+
): GatewayProfile | null {
|
|
422
|
+
const gateways = loadGatewayProfiles()
|
|
423
|
+
const gateway = gateways[id]
|
|
424
|
+
if (!gateway) return null
|
|
425
|
+
|
|
426
|
+
gateway.status = result.ok ? 'healthy' : (result.authProvided ? 'degraded' : 'offline')
|
|
427
|
+
gateway.lastCheckedAt = now
|
|
428
|
+
gateway.lastError = result.ok ? null : (result.error || result.hint || 'Gateway health check failed.')
|
|
429
|
+
gateway.lastModelCount = Array.isArray(result.models) ? result.models.length : 0
|
|
430
|
+
gateway.deployment = {
|
|
431
|
+
...(gateway.deployment || {}),
|
|
432
|
+
lastVerifiedAt: now,
|
|
433
|
+
lastVerifiedOk: result.ok,
|
|
434
|
+
lastVerifiedMessage: result.ok
|
|
435
|
+
? result.message
|
|
436
|
+
: (result.error || result.hint || 'Gateway health check failed.'),
|
|
437
|
+
}
|
|
438
|
+
gateway.updatedAt = now
|
|
439
|
+
saveGatewayProfiles(gateways)
|
|
440
|
+
notify('gateways')
|
|
441
|
+
return gateway
|
|
442
|
+
}
|
|
@@ -113,10 +113,26 @@ describe('normalizeSendFilePaths', () => {
|
|
|
113
113
|
fs.rmSync(cwd, { recursive: true, force: true })
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
-
it('resolves browser profile screenshot paths
|
|
116
|
+
it('resolves browser profile screenshot paths into the configured browser profiles directory', () => {
|
|
117
117
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'send-file-browser-profile-'))
|
|
118
118
|
const resolved = resolveSendFileSourcePath(cwd, '../../../.swarmclaw/browser-profiles/example/mcp-output/page.png')
|
|
119
|
-
assert.match(resolved, new RegExp(
|
|
119
|
+
assert.match(resolved, new RegExp(`browser-profiles[\\\\/]example[\\\\/]mcp-output[\\\\/]page\\.png$`))
|
|
120
120
|
fs.rmSync(cwd, { recursive: true, force: true })
|
|
121
121
|
})
|
|
122
|
+
|
|
123
|
+
it('resolves browser profile screenshot paths from BROWSER_PROFILES_DIR when set', () => {
|
|
124
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'send-file-browser-profile-env-'))
|
|
125
|
+
const browserProfilesDir = path.join(cwd, '.swarmclaw', 'browser-profiles')
|
|
126
|
+
const previousDir = process.env.BROWSER_PROFILES_DIR
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
process.env.BROWSER_PROFILES_DIR = browserProfilesDir
|
|
130
|
+
const resolved = resolveSendFileSourcePath(cwd, '../../../.swarmclaw/browser-profiles/example/mcp-output/page.png')
|
|
131
|
+
assert.equal(resolved, path.join(browserProfilesDir, 'example', 'mcp-output', 'page.png'))
|
|
132
|
+
} finally {
|
|
133
|
+
if (previousDir === undefined) delete process.env.BROWSER_PROFILES_DIR
|
|
134
|
+
else process.env.BROWSER_PROFILES_DIR = previousDir
|
|
135
|
+
fs.rmSync(cwd, { recursive: true, force: true })
|
|
136
|
+
}
|
|
137
|
+
})
|
|
122
138
|
})
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
3
3
|
import fs from 'fs'
|
|
4
|
-
import os from 'os'
|
|
5
4
|
import path from 'path'
|
|
6
5
|
import { UPLOAD_DIR } from '../storage'
|
|
7
|
-
import { WORKSPACE_DIR } from '../data-dir'
|
|
6
|
+
import { BROWSER_PROFILES_DIR, WORKSPACE_DIR } from '../data-dir'
|
|
8
7
|
import type { ToolBuildContext } from './context'
|
|
9
8
|
import { safePath, truncate, listDirRecursive, MAX_FILE } from './context'
|
|
10
9
|
import type { Plugin, PluginHooks } from '@/types'
|
|
@@ -387,19 +386,24 @@ export function findRecentSendFileFallbackPaths(cwd: string, maxAgeMs = 10 * 60
|
|
|
387
386
|
return dedup(candidates)
|
|
388
387
|
}
|
|
389
388
|
|
|
389
|
+
function getBrowserProfilesDir(): string {
|
|
390
|
+
return process.env.BROWSER_PROFILES_DIR || BROWSER_PROFILES_DIR
|
|
391
|
+
}
|
|
392
|
+
|
|
390
393
|
export function resolveSendFileSourcePath(cwd: string, rawPath: string, scope?: 'workspace' | 'machine'): string {
|
|
391
394
|
const trimmed = rawPath.trim()
|
|
392
395
|
const uploadMatch = trimmed.match(/^(?:sandbox:)?\/api\/uploads\/(.+)$/)
|
|
393
396
|
if (uploadMatch) {
|
|
394
397
|
return path.join(UPLOAD_DIR, path.basename(uploadMatch[1]))
|
|
395
398
|
}
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
399
|
+
const browserProfileMatch = trimmed
|
|
400
|
+
.replaceAll('\\', '/')
|
|
401
|
+
.match(/(?:^|\/)\.swarmclaw\/browser-profiles\/(.+)$/)
|
|
402
|
+
if (browserProfileMatch) {
|
|
403
|
+
return path.join(getBrowserProfilesDir(), browserProfileMatch[1])
|
|
400
404
|
}
|
|
401
405
|
if (trimmed.startsWith('browser-profiles/')) {
|
|
402
|
-
const candidate = path.join(
|
|
406
|
+
const candidate = path.join(getBrowserProfilesDir(), trimmed.slice('browser-profiles/'.length))
|
|
403
407
|
if (fs.existsSync(candidate)) return candidate
|
|
404
408
|
}
|
|
405
409
|
if (trimmed === '/workspace' || trimmed === 'workspace') return cwd
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
2
4
|
import path from 'node:path'
|
|
3
5
|
import test from 'node:test'
|
|
4
|
-
import { discoverSkills } from './skill-discovery'
|
|
6
|
+
import { clearDiscoveredSkillsCache, discoverSkills } from './skill-discovery'
|
|
5
7
|
|
|
6
8
|
test('discoverSkills includes tracked bundled skills from bundled-skills', () => {
|
|
7
9
|
const skills = discoverSkills({ cwd: path.join(process.cwd(), 'src') })
|
|
@@ -14,3 +16,34 @@ test('discoverSkills includes tracked bundled skills from bundled-skills', () =>
|
|
|
14
16
|
true,
|
|
15
17
|
)
|
|
16
18
|
})
|
|
19
|
+
|
|
20
|
+
test('discoverSkills reads workspace skills from SWARMCLAW_HOME when set', () => {
|
|
21
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-skills-home-'))
|
|
22
|
+
const skillDir = path.join(tempHome, 'skills', 'local-skill')
|
|
23
|
+
const previousHome = process.env.SWARMCLAW_HOME
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(skillDir, { recursive: true })
|
|
27
|
+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---
|
|
28
|
+
name: local-skill
|
|
29
|
+
description: A local test skill.
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# Local Skill
|
|
33
|
+
`, 'utf8')
|
|
34
|
+
process.env.SWARMCLAW_HOME = tempHome
|
|
35
|
+
clearDiscoveredSkillsCache()
|
|
36
|
+
|
|
37
|
+
const skills = discoverSkills()
|
|
38
|
+
const localSkill = skills.find((skill) => skill.name === 'local-skill')
|
|
39
|
+
|
|
40
|
+
assert.ok(localSkill)
|
|
41
|
+
assert.equal(localSkill?.source, 'workspace')
|
|
42
|
+
assert.equal(localSkill?.sourcePath, path.join(skillDir, 'SKILL.md'))
|
|
43
|
+
} finally {
|
|
44
|
+
clearDiscoveredSkillsCache()
|
|
45
|
+
if (previousHome === undefined) delete process.env.SWARMCLAW_HOME
|
|
46
|
+
else process.env.SWARMCLAW_HOME = previousHome
|
|
47
|
+
fs.rmSync(tempHome, { recursive: true, force: true })
|
|
48
|
+
}
|
|
49
|
+
})
|
|
@@ -29,7 +29,12 @@ export function clearDiscoveredSkillsCache(): void {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function buildCacheKey(cwd?: string): string {
|
|
32
|
-
return `${cwd || ''}`
|
|
32
|
+
return `${cwd || ''}|${resolveWorkspaceSkillsDir()}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveWorkspaceSkillsDir(): string {
|
|
36
|
+
const swarmclawHome = process.env.SWARMCLAW_HOME || path.join(os.homedir(), '.swarmclaw')
|
|
37
|
+
return path.join(swarmclawHome, 'skills')
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
function scanLayer(
|
|
@@ -81,7 +86,7 @@ function scanLayer(
|
|
|
81
86
|
* Discover skills from three layers:
|
|
82
87
|
* 1. Bundled: `bundled-skills/` (tracked with the app)
|
|
83
88
|
* Legacy fallback: `data/skills/`
|
|
84
|
-
* 2. Workspace:
|
|
89
|
+
* 2. Workspace: `<swarmclaw-home>/skills/` (user-installed)
|
|
85
90
|
* 3. Project: `<cwd>/skills/` (project-local)
|
|
86
91
|
*
|
|
87
92
|
* Results are cached with a 5-second TTL. Later layers override
|
|
@@ -102,8 +107,8 @@ export function discoverSkills(opts?: { cwd?: string }): DiscoveredSkill[] {
|
|
|
102
107
|
...scanLayer(BUNDLED_SKILLS_DIR, 'bundled'),
|
|
103
108
|
]
|
|
104
109
|
|
|
105
|
-
// Layer 2: Workspace skills (
|
|
106
|
-
const workspaceDir =
|
|
110
|
+
// Layer 2: Workspace skills (<swarmclaw-home>/skills/)
|
|
111
|
+
const workspaceDir = resolveWorkspaceSkillsDir()
|
|
107
112
|
const workspace = scanLayer(workspaceDir, 'workspace')
|
|
108
113
|
|
|
109
114
|
// Layer 3: Project-local skills (<cwd>/skills/)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import { DEFAULT_AGENTS, getDefaultModelForProvider } from './setup-defaults'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// OpenClaw default model is empty (not 'default')
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
test('OpenClaw default agent model is empty string', () => {
|
|
10
|
+
assert.equal(DEFAULT_AGENTS.openclaw.model, '')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('getDefaultModelForProvider returns empty for openclaw', () => {
|
|
14
|
+
assert.equal(getDefaultModelForProvider('openclaw'), '')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('OpenClaw default model is falsy so UI does not render a suggested model', () => {
|
|
18
|
+
assert.ok(!DEFAULT_AGENTS.openclaw.model, 'model should be falsy')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Other providers still have their expected models
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
test('getDefaultModelForProvider returns non-empty for openai', () => {
|
|
26
|
+
const model = getDefaultModelForProvider('openai')
|
|
27
|
+
assert.ok(model, 'openai model should be truthy')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('getDefaultModelForProvider returns non-empty for anthropic', () => {
|
|
31
|
+
const model = getDefaultModelForProvider('anthropic')
|
|
32
|
+
assert.ok(model, 'anthropic model should be truthy')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('getDefaultModelForProvider returns non-empty for ollama', () => {
|
|
36
|
+
const model = getDefaultModelForProvider('ollama')
|
|
37
|
+
assert.ok(model, 'ollama model should be truthy')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('custom provider default model is empty (like openclaw)', () => {
|
|
41
|
+
assert.equal(DEFAULT_AGENTS.custom.model, '')
|
|
42
|
+
})
|
|
@@ -629,7 +629,7 @@ export const DEFAULT_AGENTS: Record<SetupProvider, DefaultAgentConfig> = {
|
|
|
629
629
|
name: 'OpenClaw Operator',
|
|
630
630
|
description: 'A manager agent for talking to and coordinating OpenClaw instances.',
|
|
631
631
|
systemPrompt: 'You are an operator focused on reliable execution, clear status updates, and task completion.',
|
|
632
|
-
model: '
|
|
632
|
+
model: '',
|
|
633
633
|
tools: STARTER_AGENT_TOOLS,
|
|
634
634
|
},
|
|
635
635
|
custom: {
|