@swarmclawai/swarmclaw 1.9.33 → 1.9.34

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 CHANGED
@@ -151,6 +151,15 @@ openclaw skills install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/waydelyle/swarmclaw)
153
153
 
154
+ ## v1.9.34 Highlights
155
+
156
+ Credential recovery and external extension access release for npm-global upgrades and scoped agent tool configuration.
157
+
158
+ - **Credential secret recovery.** Startup now checks prior npm-global build env files before accepting a fresh per-version `CREDENTIAL_SECRET`, and validates candidate secrets against existing encrypted credentials before persisting `DATA_DIR/credential-secret`.
159
+ - **Clear connector failures.** Connector startup now logs and surfaces credential decrypt failures directly instead of falling through to a misleading "No bot token configured" error.
160
+ - **External extension tools.** Scoped agents now keep explicitly attached external `*.js` and `*.mjs` extensions, and the agent/chat tool controls persist enabled external tools through the `extensions` field.
161
+ - **Regression coverage.** Added tests for previous-build credential recovery, non-decrypting secret replacement, scoped external extension access, and extension access persistence.
162
+
154
163
  ## v1.9.33 Highlights
155
164
 
156
165
  Issue and PR validation release for credential durability, delegated task dispatch, connector output hygiene, and OpenClaw gateway protocol compatibility.
@@ -445,6 +454,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
445
454
 
446
455
  ## Releases
447
456
 
457
+ ### v1.9.34 Highlights
458
+
459
+ Credential recovery and external extension access release for npm-global upgrades and scoped agent tool configuration.
460
+
461
+ - **Credential secret recovery.** Startup now checks prior npm-global build env files before accepting a fresh per-version `CREDENTIAL_SECRET`, and validates candidate secrets against existing encrypted credentials before persisting `DATA_DIR/credential-secret`.
462
+ - **Clear connector failures.** Connector startup now logs and surfaces credential decrypt failures directly instead of falling through to a misleading "No bot token configured" error.
463
+ - **External extension tools.** Scoped agents now keep explicitly attached external `*.js` and `*.mjs` extensions, and the agent/chat tool controls persist enabled external tools through the `extensions` field.
464
+ - **Regression coverage.** Added tests for previous-build credential recovery, non-decrypting secret replacement, scoped external extension access, and extension access persistence.
465
+
448
466
  ### v1.9.33 Highlights
449
467
 
450
468
  Issue and PR validation release for credential durability, delegated task dispatch, connector output hygiene, and OpenClaw gateway protocol compatibility.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.33",
3
+ "version": "1.9.34",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@ export async function GET() {
20
20
 
21
21
  // For external extensions that are enabled, also collect their concrete tool names
22
22
  // so the UI can show those tools in the toggles
23
- const externalTools: Array<{ extensionId: string; toolName: string; label: string; description: string }> = []
23
+ const externalTools: Array<{ extensionId: string; extensionName: string; toolName: string; label: string; description: string }> = []
24
24
  for (const meta of all) {
25
25
  if (meta.isBuiltin || !meta.enabled) continue
26
26
  try {
@@ -28,6 +28,7 @@ export async function GET() {
28
28
  for (const entry of tools) {
29
29
  externalTools.push({
30
30
  extensionId: entry.extensionId,
31
+ extensionName: meta.name || meta.filename,
31
32
  toolName: entry.tool.name,
32
33
  label: entry.tool.name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
33
34
  description: entry.tool.description || '',
@@ -57,6 +57,14 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
57
57
  const CONNECTION_TEST_TIMEOUT_MS = 40_000
58
58
  type AgentProviderId = string
59
59
 
60
+ interface ExtensionToolInfo {
61
+ extensionId: string
62
+ extensionName?: string
63
+ toolName: string
64
+ label: string
65
+ description: string
66
+ }
67
+
60
68
  function SectionCard({
61
69
  title,
62
70
  description,
@@ -223,6 +231,7 @@ export function AgentSheet() {
223
231
  const [toolAccessMode, setToolAccessMode] = useState<'universal' | 'scoped'>('scoped')
224
232
  const [extensions, setExtensions] = useState<string[]>([])
225
233
  const [enabledExtensionIds, setEnabledExtensionIds] = useState<Set<string> | null>(null)
234
+ const [externalTools, setExternalTools] = useState<ExtensionToolInfo[]>([])
226
235
  const [skills, setSkills] = useState<string[]>([])
227
236
  const [skillIds, setSkillIds] = useState<string[]>([])
228
237
  const [mcpServerIds, setMcpServerIds] = useState<string[]>([])
@@ -423,8 +432,11 @@ export function AgentSheet() {
423
432
  loadProjects()
424
433
  loadClaudeSkills()
425
434
  // Fetch enabled extension IDs so we can filter tool toggles
426
- api<{ enabledExtensionIds: string[] }>('GET', '/extensions/builtins')
427
- .then((res) => { if (res?.enabledExtensionIds) setEnabledExtensionIds(new Set(res.enabledExtensionIds)) })
435
+ api<{ enabledExtensionIds: string[]; externalTools?: ExtensionToolInfo[] }>('GET', '/extensions/builtins')
436
+ .then((res) => {
437
+ if (res?.enabledExtensionIds) setEnabledExtensionIds(new Set(res.enabledExtensionIds))
438
+ if (Array.isArray(res?.externalTools)) setExternalTools(res.externalTools)
439
+ })
428
440
  .catch(() => {})
429
441
  setTestStatus('idle')
430
442
  setTestMessage('')
@@ -2642,6 +2654,33 @@ export function AgentSheet() {
2642
2654
  </div>
2643
2655
  )}
2644
2656
 
2657
+ {!hasNativeCapabilities && externalTools.length > 0 && (
2658
+ <div className="mb-8">
2659
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Extension Tools</label>
2660
+ <p className="text-[12px] text-text-3/60 mb-3">Attach enabled external extension tools to this agent.</p>
2661
+ <div className="space-y-3">
2662
+ {externalTools.map((t) => {
2663
+ const attached = extensions.includes(t.extensionId)
2664
+ const description = t.extensionName
2665
+ ? `${t.description || 'External extension tool'} (${t.extensionName})`
2666
+ : (t.description || 'External extension tool')
2667
+ return (
2668
+ <label key={`${t.extensionId}:${t.toolName}`} className="flex items-center gap-3 cursor-pointer">
2669
+ <div
2670
+ onClick={() => setExtensions((prev) => prev.includes(t.extensionId) ? prev.filter((x) => x !== t.extensionId) : [...prev, t.extensionId])}
2671
+ className={`w-11 h-6 rounded-full transition-all duration-200 relative shrink-0 ${attached ? 'bg-accent-bright cursor-pointer' : 'bg-white/[0.08] cursor-pointer'}`}
2672
+ >
2673
+ <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${attached ? 'left-[22px]' : 'left-0.5'}`} />
2674
+ </div>
2675
+ <span className="font-display text-[14px] font-600 text-text-2">{t.label}</span>
2676
+ <span className="text-[12px] text-text-3">{description}</span>
2677
+ </label>
2678
+ )
2679
+ })}
2680
+ </div>
2681
+ </div>
2682
+ )}
2683
+
2645
2684
  {/* Native capability provider note — not shown for OpenClaw (covered in connection status) */}
2646
2685
  {hasNativeCapabilities && !openclawEnabled && (
2647
2686
  <div className="mb-8 p-4 rounded-[14px] bg-white/[0.02] border border-white/[0.06]">
@@ -7,7 +7,7 @@ import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
7
7
  import type { ToolDefinition } from '@/lib/tool-definitions'
8
8
  import type { Session } from '@/types'
9
9
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
10
- import { getEnabledToolIds, getEnabledExtensionIds } from '@/lib/capability-selection'
10
+ import { getEnabledToolIds, getEnabledExtensionIds, isExternalExtensionId } from '@/lib/capability-selection'
11
11
 
12
12
  interface Props {
13
13
  session: Session
@@ -15,6 +15,7 @@ interface Props {
15
15
 
16
16
  interface ExtensionToolInfo {
17
17
  extensionId: string
18
+ extensionName?: string
18
19
  toolName: string
19
20
  label: string
20
21
  description: string
@@ -55,7 +56,20 @@ export function ChatToolToggles({ session }: Props) {
55
56
  return () => document.removeEventListener('mousedown', handler)
56
57
  }, [open])
57
58
 
58
- const toggleTool = async (toolId: string) => {
59
+ const toggleTool = async (tool: ToolDefinition) => {
60
+ if (tool.extensionId && isExternalExtensionId(tool.extensionId)) {
61
+ const updatedExtensions = sessionExtensions.includes(tool.extensionId)
62
+ ? sessionExtensions.filter((extensionId) => extensionId !== tool.extensionId)
63
+ : [...sessionExtensions, tool.extensionId]
64
+ await api('PUT', `/chats/${session.id}`, {
65
+ tools: sessionTools,
66
+ extensions: updatedExtensions,
67
+ })
68
+ await refreshSession(session.id)
69
+ return
70
+ }
71
+
72
+ const toolId = tool.id
59
73
  const updated = sessionTools.includes(toolId)
60
74
  ? sessionTools.filter((t) => t !== toolId)
61
75
  : [...sessionTools, toolId]
@@ -78,9 +92,9 @@ export function ChatToolToggles({ session }: Props) {
78
92
 
79
93
  // Convert external extension tools into ToolDefinition-like items for display
80
94
  const extensionToolDefs: ToolDefinition[] = externalTools.map((et) => ({
81
- id: et.toolName,
95
+ id: `${et.extensionId}:${et.toolName}`,
82
96
  label: et.label,
83
- description: et.description,
97
+ description: et.extensionName ? `${et.description || 'External extension tool'} (${et.extensionName})` : et.description,
84
98
  extensionId: et.extensionId,
85
99
  }))
86
100
 
@@ -92,7 +106,11 @@ export function ChatToolToggles({ session }: Props) {
92
106
 
93
107
  const allVisibleTools = groups.flatMap((g) => g.tools)
94
108
  const totalCount = allVisibleTools.length
95
- const enabledCount = sessionTools.filter((id) => allVisibleTools.some((t) => t.id === id)).length
109
+ const enabledCount = allVisibleTools.filter((tool) => (
110
+ tool.extensionId && isExternalExtensionId(tool.extensionId)
111
+ ? sessionExtensions.includes(tool.extensionId)
112
+ : sessionTools.includes(tool.id)
113
+ )).length
96
114
 
97
115
  return (
98
116
  <div className="relative" ref={ref}>
@@ -120,13 +138,17 @@ export function ChatToolToggles({ session }: Props) {
120
138
  <p className="text-[10px] font-600 text-text-3/60 uppercase tracking-wider mb-2">{group.label}</p>
121
139
  {group.tools.map((tool) => {
122
140
  const extDisabled = !isExtensionEnabled(tool)
123
- const enabled = !extDisabled && sessionTools.includes(tool.id)
141
+ const enabled = !extDisabled && (
142
+ tool.extensionId && isExternalExtensionId(tool.extensionId)
143
+ ? sessionExtensions.includes(tool.extensionId)
144
+ : sessionTools.includes(tool.id)
145
+ )
124
146
  return (
125
147
  <Tooltip key={tool.id}>
126
148
  <TooltipTrigger asChild>
127
149
  <label className={`flex items-center gap-2.5 py-1.5 ${extDisabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`}>
128
150
  <div
129
- onClick={() => !extDisabled && toggleTool(tool.id)}
151
+ onClick={() => !extDisabled && toggleTool(tool)}
130
152
  className={`w-8 h-[18px] rounded-full transition-all duration-200 relative shrink-0
131
153
  ${extDisabled ? 'bg-white/[0.04] cursor-not-allowed' : enabled ? 'bg-accent-bright cursor-pointer' : 'bg-white/[0.12] cursor-pointer'}`}
132
154
  >
@@ -164,11 +164,23 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
164
164
 
165
165
  // Resolve bot token from credential
166
166
  let botToken = ''
167
+ let credentialDecryptError: string | null = null
167
168
  if (connector.credentialId) {
168
169
  const creds = loadCredentials()
169
170
  const cred = creds[connector.credentialId]
170
171
  if (cred?.encryptedKey) {
171
- try { botToken = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
172
+ try {
173
+ botToken = decryptKey(cred.encryptedKey)
174
+ } catch (err: unknown) {
175
+ credentialDecryptError = `Failed to decrypt credential "${connector.credentialId}". CREDENTIAL_SECRET may have changed since this credential was stored; restore the previous credential secret or re-add the key.`
176
+ log.warn(TAG, credentialDecryptError, {
177
+ connectorId,
178
+ connectorName: connector.name,
179
+ platform: connector.platform,
180
+ credentialId: connector.credentialId,
181
+ error: errorMessage(err),
182
+ })
183
+ }
172
184
  }
173
185
  }
174
186
  // Also check config for inline token (some platforms)
@@ -182,6 +194,10 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
182
194
  botToken = swarmdockFallbackPrivateKey
183
195
  }
184
196
 
197
+ if (!botToken && credentialDecryptError) {
198
+ throw new Error(credentialDecryptError)
199
+ }
200
+
185
201
  if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email' && connector.platform !== 'filequeue' && connector.platform !== 'swarmdock') {
186
202
  throw new Error('No bot token configured')
187
203
  }
@@ -85,6 +85,55 @@ describe('discovery tool access flows', () => {
85
85
  assert.equal(output.extensions.includes('shell'), false)
86
86
  })
87
87
 
88
+ it('request_tool_access grants external extensions into the session extensions field', () => {
89
+ const output = runWithTempDataDir(`
90
+ const storageMod = await import('./src/lib/server/storage')
91
+ const toolsMod = await import('./src/lib/server/session-tools/index')
92
+ const storage = storageMod.default || storageMod
93
+ const toolsApi = toolsMod.default || toolsMod
94
+
95
+ const now = Date.now()
96
+ storage.saveSessions({
97
+ session_external_extension: {
98
+ id: 'session_external_extension',
99
+ name: 'External Extension Access Test',
100
+ cwd: process.env.WORKSPACE_DIR,
101
+ user: 'tester',
102
+ provider: 'openai',
103
+ model: 'gpt-test',
104
+ claudeSessionId: null,
105
+ messages: [],
106
+ createdAt: now,
107
+ lastActiveAt: now,
108
+ sessionType: 'human',
109
+ agentId: 'default',
110
+ tools: [],
111
+ extensions: [],
112
+ },
113
+ })
114
+
115
+ const built = await toolsApi.buildSessionTools(process.env.WORKSPACE_DIR, [], {
116
+ sessionId: 'session_external_extension',
117
+ agentId: 'default',
118
+ delegationEnabled: false,
119
+ delegationTargetMode: 'all',
120
+ delegationTargetAgentIds: [],
121
+ })
122
+ const tool = built.tools.find((entry) => entry.name === 'request_tool_access')
123
+ const raw = await tool.invoke({ toolId: 'freedzhost-critic.js', reason: 'Need the installed critic extension.' })
124
+ const session = storage.loadSessions().session_external_extension
125
+ console.log(JSON.stringify({
126
+ raw,
127
+ tools: session.tools || [],
128
+ extensions: session.extensions || [],
129
+ }))
130
+ `)
131
+
132
+ assert.match(String(output.raw), /tool_access_granted|granted immediately/i)
133
+ assert.equal(output.tools.includes('freedzhost-critic.js'), false)
134
+ assert.equal(output.extensions.includes('freedzhost-critic.js'), true)
135
+ })
136
+
88
137
  it('manage_capabilities request_access grants tools immediately without approval state', () => {
89
138
  const output = runWithTempDataDir(`
90
139
  const storageMod = await import('./src/lib/server/storage')
@@ -3,7 +3,9 @@ import { describe, it, beforeEach, afterEach } from 'node:test'
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import os from 'node:os'
6
+ import crypto from 'node:crypto'
6
7
  import { spawnSync } from 'node:child_process'
8
+ import Database from 'better-sqlite3'
7
9
 
8
10
  const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
11
 
@@ -12,13 +14,26 @@ function runStorageAuthImport(options: {
12
14
  generatedEnv?: string
13
15
  credentialSecretFile?: string
14
16
  externalCredentialSecret?: string
17
+ swarmclawHome?: boolean
18
+ buildEnvFiles?: Array<{ relativePath: string; content: string }>
19
+ encryptedCredentialSecrets?: string[]
15
20
  }) {
16
21
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'storage-auth-import-'))
17
- const dataDir = path.join(tmpDir, 'data')
18
- const cwd = path.join(tmpDir, 'cwd')
22
+ const homeDir = path.join(tmpDir, 'home')
23
+ const dataDir = options.swarmclawHome ? path.join(homeDir, 'data') : path.join(tmpDir, 'data')
24
+ const cwd = options.swarmclawHome
25
+ ? path.join(homeDir, 'builds', 'package-current', '.next', 'standalone')
26
+ : path.join(tmpDir, 'cwd')
19
27
  fs.mkdirSync(dataDir, { recursive: true })
20
28
  fs.mkdirSync(cwd, { recursive: true })
21
29
  try {
30
+ for (const [index, entry] of (options.buildEnvFiles ?? []).entries()) {
31
+ const target = path.join(homeDir, entry.relativePath)
32
+ fs.mkdirSync(path.dirname(target), { recursive: true })
33
+ fs.writeFileSync(target, entry.content, 'utf8')
34
+ const time = new Date(Date.now() - (options.buildEnvFiles!.length - index) * 1000)
35
+ fs.utimesSync(target, time, time)
36
+ }
22
37
  if (options.envLocal !== undefined) {
23
38
  fs.writeFileSync(path.join(cwd, '.env.local'), options.envLocal, 'utf8')
24
39
  }
@@ -28,12 +43,32 @@ function runStorageAuthImport(options: {
28
43
  if (options.credentialSecretFile !== undefined) {
29
44
  fs.writeFileSync(path.join(dataDir, 'credential-secret'), options.credentialSecretFile, { encoding: 'utf8', mode: 0o600 })
30
45
  }
46
+ if (options.encryptedCredentialSecrets?.length) {
47
+ const db = new Database(path.join(dataDir, 'swarmclaw.db'))
48
+ try {
49
+ db.exec('CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY, data TEXT NOT NULL)')
50
+ for (const [index, secret] of options.encryptedCredentialSecrets.entries()) {
51
+ const id = `cred_${index}`
52
+ const encryptedKey = encryptForSecret(secret, `token-${index}`)
53
+ db.prepare('INSERT OR REPLACE INTO credentials (id, data) VALUES (?, ?)').run(id, JSON.stringify({
54
+ id,
55
+ provider: 'slack',
56
+ name: `Credential ${index}`,
57
+ encryptedKey,
58
+ createdAt: Date.now(),
59
+ }))
60
+ }
61
+ } finally {
62
+ db.close()
63
+ }
64
+ }
31
65
  const env: NodeJS.ProcessEnv = {
32
66
  ...process.env,
33
67
  DATA_DIR: dataDir,
34
68
  WORKSPACE_DIR: path.join(tmpDir, 'workspace'),
35
69
  SWARMCLAW_DAEMON_AUTOSTART: '0',
36
70
  }
71
+ if (options.swarmclawHome) env.SWARMCLAW_HOME = homeDir
37
72
  delete env.ACCESS_KEY
38
73
  delete env.CREDENTIAL_SECRET
39
74
  delete env.SWARMCLAW_BUILD_MODE
@@ -69,6 +104,16 @@ function runStorageAuthImport(options: {
69
104
  }
70
105
  }
71
106
 
107
+ function encryptForSecret(secret: string, plaintext: string): string {
108
+ const key = Buffer.from(secret, 'hex')
109
+ const iv = crypto.randomBytes(12)
110
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
111
+ let encrypted = cipher.update(plaintext, 'utf8', 'hex')
112
+ encrypted += cipher.final('hex')
113
+ const tag = cipher.getAuthTag().toString('hex')
114
+ return `${iv.toString('hex')}:${tag}:${encrypted}`
115
+ }
116
+
72
117
  /**
73
118
  * Tests for storage-auth helpers.
74
119
  *
@@ -249,4 +294,61 @@ describe('credential secret persistence precedence', () => {
249
294
  assert.equal(output.credentialSecret, externalSecret)
250
295
  assert.equal(output.fileSecret, fileSecret)
251
296
  })
297
+
298
+ it('recovers the previous npm-global build credential secret before persisting a fresh current cwd secret', () => {
299
+ const previousSecret = 'd'.repeat(64)
300
+ const freshCurrentSecret = 'e'.repeat(64)
301
+ const output = runStorageAuthImport({
302
+ swarmclawHome: true,
303
+ envLocal: `CREDENTIAL_SECRET=${freshCurrentSecret}\n`,
304
+ encryptedCredentialSecrets: [previousSecret],
305
+ buildEnvFiles: [
306
+ {
307
+ relativePath: 'builds/package-1.9.32/.next/standalone/.env.local',
308
+ content: `CREDENTIAL_SECRET=${previousSecret}\n`,
309
+ },
310
+ ],
311
+ })
312
+
313
+ assert.equal(output.credentialSecret, previousSecret)
314
+ assert.equal(output.fileSecret, previousSecret)
315
+ })
316
+
317
+ it('replaces a non-decrypting DATA_DIR credential secret when a previous build secret decrypts existing credentials', () => {
318
+ const previousSecret = 'f'.repeat(64)
319
+ const wrongFileSecret = '1'.repeat(64)
320
+ const output = runStorageAuthImport({
321
+ swarmclawHome: true,
322
+ credentialSecretFile: wrongFileSecret,
323
+ encryptedCredentialSecrets: [previousSecret],
324
+ buildEnvFiles: [
325
+ {
326
+ relativePath: 'builds/package-1.9.31/.next/standalone/.env.local.bak',
327
+ content: `CREDENTIAL_SECRET=${previousSecret}\n`,
328
+ },
329
+ ],
330
+ })
331
+
332
+ assert.equal(output.credentialSecret, previousSecret)
333
+ assert.equal(output.fileSecret, previousSecret)
334
+ })
335
+
336
+ it('keeps a DATA_DIR credential secret when it can decrypt existing credentials', () => {
337
+ const workingFileSecret = '2'.repeat(64)
338
+ const previousSecret = '3'.repeat(64)
339
+ const output = runStorageAuthImport({
340
+ swarmclawHome: true,
341
+ credentialSecretFile: workingFileSecret,
342
+ encryptedCredentialSecrets: [workingFileSecret],
343
+ buildEnvFiles: [
344
+ {
345
+ relativePath: 'builds/package-1.9.31/.next/standalone/.env.local',
346
+ content: `CREDENTIAL_SECRET=${previousSecret}\n`,
347
+ },
348
+ ],
349
+ })
350
+
351
+ assert.equal(output.credentialSecret, workingFileSecret)
352
+ assert.equal(output.fileSecret, workingFileSecret)
353
+ })
252
354
  })
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
3
  import crypto from 'crypto'
4
+ import Database from 'better-sqlite3'
4
5
 
5
6
  import { DATA_DIR, IS_BUILD_BOOTSTRAP } from './data-dir'
6
7
  import { log } from '@/lib/server/logger'
@@ -20,6 +21,11 @@ const CREDENTIAL_SECRET_FILE = path.join(DATA_DIR, 'credential-secret')
20
21
 
21
22
  // --- .env loading ---
22
23
  type LoadedEnvFile = Record<string, string>
24
+ type CredentialSecretCandidate = {
25
+ secret: string
26
+ source: string
27
+ mtimeMs: number
28
+ }
23
29
 
24
30
  function loadEnvFile(filePath: string): LoadedEnvFile {
25
31
  const loaded: LoadedEnvFile = {}
@@ -31,6 +37,10 @@ function loadEnvFile(filePath: string): LoadedEnvFile {
31
37
  return loaded
32
38
  }
33
39
 
40
+ function cleanSecret(value: unknown): string {
41
+ return typeof value === 'string' ? value.trim() : ''
42
+ }
43
+
34
44
  function applyLoadedEnv(loaded: LoadedEnvFile, externalKeys: Set<string>, options?: { overwriteLoaded?: boolean }) {
35
45
  for (const [key, value] of Object.entries(loaded)) {
36
46
  if (externalKeys.has(key)) continue
@@ -53,6 +63,161 @@ function loadEnv(): { generated: LoadedEnvFile; local: LoadedEnvFile } {
53
63
  applyLoadedEnv(local, externalKeys, { overwriteLoaded: true })
54
64
  return { generated, local }
55
65
  }
66
+
67
+ function appendCandidate(candidates: CredentialSecretCandidate[], seen: Set<string>, candidate: CredentialSecretCandidate): void {
68
+ const secret = cleanSecret(candidate.secret)
69
+ if (!secret || seen.has(secret)) return
70
+ seen.add(secret)
71
+ candidates.push({ ...candidate, secret })
72
+ }
73
+
74
+ function readEnvCandidate(filePath: string, source: string): CredentialSecretCandidate | null {
75
+ try {
76
+ if (!fs.existsSync(filePath)) return null
77
+ const secret = cleanSecret(loadEnvFile(filePath).CREDENTIAL_SECRET)
78
+ if (!secret) return null
79
+ return {
80
+ secret,
81
+ source,
82
+ mtimeMs: fs.statSync(filePath).mtimeMs,
83
+ }
84
+ } catch (err) {
85
+ log.debug(TAG, `Could not inspect legacy CREDENTIAL_SECRET candidate at ${filePath}`, {
86
+ error: err instanceof Error ? err.message : String(err),
87
+ })
88
+ return null
89
+ }
90
+ }
91
+
92
+ function findStateHomeCandidates(): string[] {
93
+ const homes: string[] = []
94
+ const configuredHome = cleanSecret(process.env.SWARMCLAW_HOME)
95
+ if (configuredHome) homes.push(path.resolve(configuredHome))
96
+ if (path.basename(DATA_DIR) === 'data') homes.push(path.dirname(DATA_DIR))
97
+ return Array.from(new Set(homes))
98
+ }
99
+
100
+ function collectPreviousBuildSecretCandidates(seen: Set<string>): CredentialSecretCandidate[] {
101
+ const candidates: CredentialSecretCandidate[] = []
102
+ for (const home of findStateHomeCandidates()) {
103
+ const buildsDir = path.join(home, 'builds')
104
+ let entries: fs.Dirent[]
105
+ try {
106
+ entries = fs.readdirSync(buildsDir, { withFileTypes: true })
107
+ } catch {
108
+ continue
109
+ }
110
+
111
+ for (const entry of entries) {
112
+ if (!entry.isDirectory() || !entry.name.startsWith('package-')) continue
113
+ const buildRoot = path.join(buildsDir, entry.name)
114
+ const envPaths = [
115
+ path.join(buildRoot, '.env.local'),
116
+ path.join(buildRoot, '.env.local.bak'),
117
+ path.join(buildRoot, '.next', 'standalone', '.env.local'),
118
+ path.join(buildRoot, '.next', 'standalone', '.env.local.bak'),
119
+ ]
120
+ for (const envPath of envPaths) {
121
+ const candidate = readEnvCandidate(envPath, `previous build env ${envPath}`)
122
+ if (candidate) appendCandidate(candidates, seen, candidate)
123
+ }
124
+ }
125
+ }
126
+
127
+ return candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
128
+ }
129
+
130
+ function readEncryptedCredentialKeysFromObject(value: unknown): string[] {
131
+ if (!value || typeof value !== 'object') return []
132
+ return Object.values(value as Record<string, unknown>)
133
+ .map((entry) => {
134
+ if (!entry || typeof entry !== 'object') return ''
135
+ return cleanSecret((entry as Record<string, unknown>).encryptedKey)
136
+ })
137
+ .filter(Boolean)
138
+ }
139
+
140
+ function readEncryptedCredentialKeys(): string[] {
141
+ const keys: string[] = []
142
+ const jsonPath = path.join(DATA_DIR, 'credentials.json')
143
+ try {
144
+ if (fs.existsSync(jsonPath)) {
145
+ keys.push(...readEncryptedCredentialKeysFromObject(JSON.parse(fs.readFileSync(jsonPath, 'utf8'))))
146
+ }
147
+ } catch (err) {
148
+ log.debug(TAG, `Could not inspect encrypted credentials in ${jsonPath}`, {
149
+ error: err instanceof Error ? err.message : String(err),
150
+ })
151
+ }
152
+
153
+ const dbPath = path.join(DATA_DIR, 'swarmclaw.db')
154
+ try {
155
+ if (fs.existsSync(dbPath)) {
156
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true })
157
+ try {
158
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credentials'").get()
159
+ if (table) {
160
+ const rows = db.prepare('SELECT data FROM credentials LIMIT 500').all() as Array<{ data: string }>
161
+ const fromDb: Record<string, unknown> = {}
162
+ for (const [index, row] of rows.entries()) {
163
+ try {
164
+ fromDb[`row_${index}`] = JSON.parse(row.data)
165
+ } catch {
166
+ // Ignore malformed rows; storage normalization handles them later.
167
+ }
168
+ }
169
+ keys.push(...readEncryptedCredentialKeysFromObject(fromDb))
170
+ }
171
+ } finally {
172
+ db.close()
173
+ }
174
+ }
175
+ } catch (err) {
176
+ log.debug(TAG, `Could not inspect encrypted credentials in ${dbPath}`, {
177
+ error: err instanceof Error ? err.message : String(err),
178
+ })
179
+ }
180
+
181
+ return Array.from(new Set(keys))
182
+ }
183
+
184
+ function canDecryptCredential(encryptedKey: string, secret: string): boolean {
185
+ try {
186
+ const parts = encryptedKey.split(':')
187
+ if (parts.length !== 3) return false
188
+ const [ivHex, tagHex, encrypted] = parts
189
+ const key = Buffer.from(secret, 'hex')
190
+ if (key.length !== 32) return false
191
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'))
192
+ decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
193
+ decipher.update(encrypted, 'hex', 'utf8')
194
+ decipher.final('utf8')
195
+ return true
196
+ } catch {
197
+ return false
198
+ }
199
+ }
200
+
201
+ function countDecryptableCredentials(secret: string, encryptedKeys: string[]): number {
202
+ if (encryptedKeys.length === 0) return 0
203
+ return encryptedKeys.filter((encryptedKey) => canDecryptCredential(encryptedKey, secret)).length
204
+ }
205
+
206
+ function selectCredentialSecretCandidate(
207
+ candidates: CredentialSecretCandidate[],
208
+ encryptedKeys: string[],
209
+ ): CredentialSecretCandidate | null {
210
+ if (candidates.length === 0) return null
211
+ if (encryptedKeys.length === 0) return candidates[0]
212
+
213
+ let best: { candidate: CredentialSecretCandidate; count: number } | null = null
214
+ for (const candidate of candidates) {
215
+ const count = countDecryptableCredentials(candidate.secret, encryptedKeys)
216
+ if (count === 0) continue
217
+ if (!best || count > best.count) best = { candidate, count }
218
+ }
219
+ return best?.candidate ?? null
220
+ }
56
221
  const externalCredentialSecret = process.env.CREDENTIAL_SECRET?.trim() || ''
57
222
  const loadedEnv: { generated: LoadedEnvFile; local: LoadedEnvFile } = !IS_BUILD_BOOTSTRAP
58
223
  ? loadEnv()
@@ -118,16 +283,31 @@ function writeCredentialSecretFile(secret: string): boolean {
118
283
  // Resolve CREDENTIAL_SECRET in this precedence order:
119
284
  // 1. process.env (already set externally, e.g. by orchestrator)
120
285
  // 2. DATA_DIR/credential-secret (the stable home — survives upgrades)
121
- // 3. .env files (legacy values loaded into process.env by loadEnv() above)
286
+ // 3. .env files (legacy current cwd plus prior npm-global build env files)
122
287
  // 4. Generate new secret + persist to DATA_DIR/credential-secret
123
288
  //
124
289
  // Step 2 is the key change: previously the secret only lived in a per-version
125
290
  // .env.local (cwd changes on npm-global upgrade), so each upgrade
126
- // silently regenerated it and orphaned every encrypted credential.
291
+ // silently regenerated it and orphaned every encrypted credential. When
292
+ // encrypted credentials already exist, validate candidate legacy secrets by
293
+ // actually decrypting a stored credential before persisting the migration.
127
294
  if (!IS_BUILD_BOOTSTRAP) {
128
- const legacyEnvSecret = loadedEnv.local.CREDENTIAL_SECRET?.trim()
129
- || loadedEnv.generated.CREDENTIAL_SECRET?.trim()
130
- || ''
295
+ const encryptedCredentialKeys = readEncryptedCredentialKeys()
296
+ const candidateSeen = new Set<string>()
297
+ const legacyCandidates: CredentialSecretCandidate[] = []
298
+ appendCandidate(legacyCandidates, candidateSeen, {
299
+ secret: cleanSecret(loadedEnv.local.CREDENTIAL_SECRET),
300
+ source: `${path.join(process.cwd(), '.env.local')}`,
301
+ mtimeMs: 0,
302
+ })
303
+ appendCandidate(legacyCandidates, candidateSeen, {
304
+ secret: cleanSecret(loadedEnv.generated.CREDENTIAL_SECRET),
305
+ source: GENERATED_ENV_PATH,
306
+ mtimeMs: 0,
307
+ })
308
+ legacyCandidates.push(...collectPreviousBuildSecretCandidates(candidateSeen))
309
+
310
+ const legacyEnvSecret = legacyCandidates[0]?.secret || ''
131
311
  const fileSecret = readCredentialSecretFile()
132
312
  if (externalCredentialSecret) {
133
313
  process.env.CREDENTIAL_SECRET = externalCredentialSecret
@@ -135,23 +315,48 @@ if (!IS_BUILD_BOOTSTRAP) {
135
315
  log.warn(TAG, `CREDENTIAL_SECRET is set by the environment and differs from ${CREDENTIAL_SECRET_FILE}; using the environment value.`)
136
316
  }
137
317
  } else if (fileSecret) {
138
- process.env.CREDENTIAL_SECRET = fileSecret
139
- if (legacyEnvSecret && legacyEnvSecret !== fileSecret) {
140
- // Both persisted locations exist and disagree. Trust DATA_DIR because it
141
- // survives npm-global upgrades and Docker restarts.
142
- log.warn(TAG, `CREDENTIAL_SECRET mismatch between legacy env files and ${CREDENTIAL_SECRET_FILE}; using the file value.`)
143
- }
144
- } else if (legacyEnvSecret) {
145
- process.env.CREDENTIAL_SECRET = legacyEnvSecret
146
- if (writeCredentialSecretFile(legacyEnvSecret)) {
147
- log.info(TAG, `Migrated CREDENTIAL_SECRET from .env to ${CREDENTIAL_SECRET_FILE}`)
318
+ const fileDecryptsCredentials = encryptedCredentialKeys.length === 0
319
+ || countDecryptableCredentials(fileSecret, encryptedCredentialKeys) > 0
320
+ if (!fileDecryptsCredentials) {
321
+ const recovered = selectCredentialSecretCandidate(
322
+ legacyCandidates.filter((candidate) => candidate.secret !== fileSecret),
323
+ encryptedCredentialKeys,
324
+ )
325
+ if (recovered) {
326
+ process.env.CREDENTIAL_SECRET = recovered.secret
327
+ writeCredentialSecretFile(recovered.secret)
328
+ log.warn(TAG, `Recovered CREDENTIAL_SECRET from ${recovered.source} because ${CREDENTIAL_SECRET_FILE} could not decrypt existing credentials.`)
329
+ } else {
330
+ process.env.CREDENTIAL_SECRET = fileSecret
331
+ log.warn(TAG, `${CREDENTIAL_SECRET_FILE} could not decrypt existing credentials, and no recoverable previous-build CREDENTIAL_SECRET was found.`)
332
+ }
333
+ } else {
334
+ process.env.CREDENTIAL_SECRET = fileSecret
335
+ if (legacyEnvSecret && legacyEnvSecret !== fileSecret) {
336
+ // Both persisted locations exist and disagree. Trust DATA_DIR because it
337
+ // survives npm-global upgrades and Docker restarts.
338
+ log.warn(TAG, `CREDENTIAL_SECRET mismatch between legacy env files and ${CREDENTIAL_SECRET_FILE}; using the file value.`)
339
+ }
148
340
  }
149
341
  } else {
150
- // First-ever launch on this DATA_DIR. Generate.
151
- const secret = crypto.randomBytes(32).toString('hex')
152
- process.env.CREDENTIAL_SECRET = secret
153
- writeCredentialSecretFile(secret)
154
- log.info(TAG, `Generated CREDENTIAL_SECRET and persisted to ${CREDENTIAL_SECRET_FILE}`)
342
+ const recovered = selectCredentialSecretCandidate(legacyCandidates, encryptedCredentialKeys)
343
+ if (recovered) {
344
+ process.env.CREDENTIAL_SECRET = recovered.secret
345
+ if (writeCredentialSecretFile(recovered.secret)) {
346
+ log.info(TAG, `Migrated CREDENTIAL_SECRET from ${recovered.source} to ${CREDENTIAL_SECRET_FILE}`)
347
+ }
348
+ } else if (legacyEnvSecret) {
349
+ process.env.CREDENTIAL_SECRET = legacyEnvSecret
350
+ if (writeCredentialSecretFile(legacyEnvSecret)) {
351
+ log.info(TAG, `Migrated CREDENTIAL_SECRET from .env to ${CREDENTIAL_SECRET_FILE}`)
352
+ }
353
+ } else {
354
+ // First-ever launch on this DATA_DIR. Generate.
355
+ const secret = crypto.randomBytes(32).toString('hex')
356
+ process.env.CREDENTIAL_SECRET = secret
357
+ writeCredentialSecretFile(secret)
358
+ log.info(TAG, `Generated CREDENTIAL_SECRET and persisted to ${CREDENTIAL_SECRET_FILE}`)
359
+ }
155
360
  }
156
361
  }
157
362
 
@@ -24,6 +24,16 @@ function scoped(declared: string[] | null | undefined, universe: Set<string> = U
24
24
  return Array.from(new Set([...SCOPED_TOOL_BASELINE, ...picks]))
25
25
  }
26
26
 
27
+ function scopedWithAttachedExtensions(
28
+ declared: string[] | null | undefined,
29
+ extraExtensions: string[] | null | undefined,
30
+ universe: Set<string> = UNIVERSAL_SAMPLE,
31
+ ): string[] {
32
+ const picks = normalize(declared).filter((t) => universe.has(t))
33
+ const attachedExternalExtensions = normalize(extraExtensions).filter((entry) => /\.(?:m?js)$/i.test(entry))
34
+ return Array.from(new Set([...SCOPED_TOOL_BASELINE, ...picks, ...attachedExternalExtensions]))
35
+ }
36
+
27
37
  describe('scoped tool access algorithm', () => {
28
38
  it('intersects declared tools with the universe and keeps the baseline', () => {
29
39
  const out = scoped(['shell', 'files', 'edit_file', 'web'])
@@ -68,4 +78,10 @@ describe('scoped tool access algorithm', () => {
68
78
  assert.ok(out.includes('shell'))
69
79
  assert.ok(out.includes('files'))
70
80
  })
81
+
82
+ it('keeps explicitly attached external extensions in scoped mode', () => {
83
+ const out = scopedWithAttachedExtensions(['shell'], ['freedzhost-critic.js'])
84
+ assert.ok(out.includes('shell'))
85
+ assert.ok(out.includes('freedzhost-critic.js'))
86
+ })
71
87
  })
@@ -1,4 +1,5 @@
1
1
  import { dedup } from '@/lib/shared-utils'
2
+ import { isExternalExtensionId } from '@/lib/capability-selection'
2
3
  import { getExtensionManager } from './extensions'
3
4
 
4
5
  const UNIVERSAL_CORE_EXTENSION_IDS = [
@@ -78,5 +79,6 @@ export function listScopedToolAccessExtensionIds(
78
79
  const universe = new Set(listUniversalToolAccessExtensionIds(extraExtensions))
79
80
  const declared = normalizeExtensionList(declaredTools)
80
81
  const scoped = declared.filter((tool) => universe.has(tool))
81
- return dedup([...SCOPED_TOOL_BASELINE, ...scoped])
82
+ const explicitlyAttachedExternalExtensions = normalizeExtensionList(extraExtensions).filter(isExternalExtensionId)
83
+ return dedup([...SCOPED_TOOL_BASELINE, ...scoped, ...explicitlyAttachedExternalExtensions])
82
84
  }