@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 +18 -0
- package/package.json +1 -1
- package/src/app/api/extensions/builtins/route.ts +2 -1
- package/src/components/agents/agent-sheet.tsx +41 -2
- package/src/components/chat/chat-tool-toggles.tsx +29 -7
- package/src/lib/server/connectors/connector-lifecycle.ts +17 -1
- package/src/lib/server/session-tools/discovery-approvals.test.ts +49 -0
- package/src/lib/server/storage-auth.test.ts +104 -2
- package/src/lib/server/storage-auth.ts +225 -20
- package/src/lib/server/universal-tool-access.test.ts +16 -0
- package/src/lib/server/universal-tool-access.ts +3 -1
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.
|
|
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) => {
|
|
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 (
|
|
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 =
|
|
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 &&
|
|
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
|
|
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 {
|
|
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
|
|
18
|
-
const
|
|
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
|
|
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
|
|
129
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
82
|
+
const explicitlyAttachedExternalExtensions = normalizeExtensionList(extraExtensions).filter(isExternalExtensionId)
|
|
83
|
+
return dedup([...SCOPED_TOOL_BASELINE, ...scoped, ...explicitlyAttachedExternalExtensions])
|
|
82
84
|
}
|