@swarmclawai/swarmclaw 1.5.3 → 1.5.33
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 +167 -147
- package/bin/swarmclaw.js +35 -1
- package/package.json +2 -2
- package/scripts/run-next-build.mjs +87 -2
- package/skills/swarmclaw/SKILL.md +151 -0
- package/src/app/api/agents/[id]/route.ts +8 -0
- package/src/app/api/agents/agents-route.test.ts +114 -0
- package/src/app/api/agents/route.ts +10 -0
- package/src/app/api/connectors/route.ts +25 -13
- package/src/app/api/credentials/[id]/route.ts +8 -1
- package/src/app/api/schedules/[id]/route.ts +8 -0
- package/src/app/api/secrets/[id]/route.ts +10 -0
- package/src/app/api/setup/check-provider/route.ts +45 -0
- package/src/app/api/setup/doctor/route.ts +5 -0
- package/src/cli/binary.test.js +11 -0
- package/src/cli/index.js +4 -4
- package/src/cli/index.test.js +5 -2
- package/src/cli/index.ts +1 -1
- package/src/components/agents/agent-sheet.tsx +16 -4
- package/src/components/agents/inspector-panel.tsx +5 -0
- package/src/components/auth/setup-wizard/step-agents.tsx +19 -1
- package/src/components/chat/activity-moment.tsx +3 -0
- package/src/components/chat/chat-header.tsx +23 -2
- package/src/components/chat/tool-call-bubble.tsx +20 -0
- package/src/components/layout/sidebar-rail.tsx +29 -0
- package/src/hooks/setup-done-detection.test.ts +4 -2
- package/src/hooks/setup-done-detection.ts +1 -1
- package/src/lib/orchestrator-config.ts +5 -0
- package/src/lib/provider-sets.ts +4 -4
- package/src/lib/providers/acp-client.ts +116 -0
- package/src/lib/providers/cli-utils.test.ts +9 -1
- package/src/lib/providers/cli-utils.ts +89 -4
- package/src/lib/providers/cursor-cli.ts +172 -0
- package/src/lib/providers/goose.ts +149 -0
- package/src/lib/providers/index.ts +29 -1
- package/src/lib/providers/qwen-code-cli.ts +152 -0
- package/src/lib/server/agents/agent-availability.ts +2 -2
- package/src/lib/server/agents/agent-thread-session.ts +8 -0
- package/src/lib/server/agents/task-session.ts +8 -0
- package/src/lib/server/capability-router.ts +8 -2
- package/src/lib/server/chat-execution/chat-execution-utils.ts +13 -0
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +8 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +5 -1
- package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +3 -0
- package/src/lib/server/chat-execution/iteration-timers.test.ts +84 -0
- package/src/lib/server/chat-execution/iteration-timers.ts +18 -1
- package/src/lib/server/chat-execution/prompt-sections.ts +3 -1
- package/src/lib/server/chat-execution/stream-agent-chat.ts +5 -0
- package/src/lib/server/chatrooms/chatroom-helpers.ts +13 -0
- package/src/lib/server/chats/chat-session-service.ts +18 -0
- package/src/lib/server/connectors/session.ts +8 -0
- package/src/lib/server/context-manager.ts +5 -0
- package/src/lib/server/provider-health.ts +13 -3
- package/src/lib/server/provider-model-discovery.test.ts +8 -0
- package/src/lib/server/provider-model-discovery.ts +1 -1
- package/src/lib/server/runtime/daemon-state/core.ts +2 -2
- package/src/lib/server/runtime/queue/core.ts +30 -4
- package/src/lib/server/runtime/session-run-manager/enqueue.ts +1 -1
- package/src/lib/server/session-reset-policy.test.ts +16 -0
- package/src/lib/server/session-reset-policy.ts +9 -1
- package/src/lib/server/session-tools/context.ts +2 -2
- package/src/lib/server/session-tools/delegate.ts +334 -14
- package/src/lib/server/session-tools/index.ts +5 -2
- package/src/lib/server/session-tools/session-info.ts +4 -1
- package/src/lib/server/storage-auth-docker.test.ts +244 -0
- package/src/lib/server/storage-auth.test.ts +150 -0
- package/src/lib/server/storage-auth.ts +57 -22
- package/src/lib/server/storage-normalization.ts +19 -0
- package/src/lib/server/storage.ts +3 -0
- package/src/lib/server/tasks/task-resume.ts +23 -1
- package/src/lib/server/tool-aliases.ts +1 -1
- package/src/lib/server/tool-capability-policy.ts +4 -1
- package/src/lib/setup-defaults.test.ts +6 -0
- package/src/lib/setup-defaults.ts +146 -0
- package/src/lib/tool-definitions.ts +1 -1
- package/src/types/misc.ts +4 -1
- package/src/types/provider.ts +1 -1
- package/src/types/session.ts +9 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it, beforeEach, afterEach } from 'node:test'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* End-to-end simulation of the Docker key persistence fix.
|
|
9
|
+
*
|
|
10
|
+
* Simulates a Docker container lifecycle where:
|
|
11
|
+
* 1. .env.local is NOT writable (baked into the image, not volume-mounted)
|
|
12
|
+
* 2. DATA_DIR IS writable (volume-mounted at ./data:/app/data)
|
|
13
|
+
* 3. Keys should persist across container restarts via DATA_DIR/.env.generated
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Replicate the core logic from storage-auth.ts
|
|
17
|
+
function loadEnvFile(filePath: string, env: Record<string, string>): void {
|
|
18
|
+
if (!fs.existsSync(filePath)) return
|
|
19
|
+
fs.readFileSync(filePath, 'utf8').split(/\r?\n/).forEach(line => {
|
|
20
|
+
const [k, ...v] = line.split('=')
|
|
21
|
+
if (k && v.length) env[k.trim()] = v.join('=').trim()
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function appendEnvKeyIfMissing(envPath: string, key: string, value: string): void {
|
|
26
|
+
const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''
|
|
27
|
+
const keyPattern = new RegExp(`^${key}=`, 'm')
|
|
28
|
+
if (keyPattern.test(existing)) return
|
|
29
|
+
fs.appendFileSync(envPath, `\n${key}=${value}\n`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function persistEnvKey(
|
|
33
|
+
key: string,
|
|
34
|
+
value: string,
|
|
35
|
+
envLocalPath: string,
|
|
36
|
+
generatedEnvPath: string,
|
|
37
|
+
): boolean {
|
|
38
|
+
try {
|
|
39
|
+
appendEnvKeyIfMissing(envLocalPath, key, value)
|
|
40
|
+
return true
|
|
41
|
+
} catch {
|
|
42
|
+
// .env.local not writable
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(path.dirname(generatedEnvPath), { recursive: true })
|
|
46
|
+
appendEnvKeyIfMissing(generatedEnvPath, key, value)
|
|
47
|
+
return true
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Simulate a complete container lifecycle: boot, generate keys, restart, verify keys persist. */
|
|
54
|
+
function simulateContainerBoot(
|
|
55
|
+
envLocalPath: string,
|
|
56
|
+
generatedEnvPath: string,
|
|
57
|
+
): { accessKey: string; credentialSecret: string } {
|
|
58
|
+
const env: Record<string, string> = {}
|
|
59
|
+
|
|
60
|
+
// Step 1: loadEnv — read both locations
|
|
61
|
+
loadEnvFile(envLocalPath, env)
|
|
62
|
+
loadEnvFile(generatedEnvPath, env)
|
|
63
|
+
|
|
64
|
+
// Step 2: auto-generate if missing
|
|
65
|
+
if (!env.CREDENTIAL_SECRET) {
|
|
66
|
+
const secret = 'test-secret-' + Date.now()
|
|
67
|
+
env.CREDENTIAL_SECRET = secret
|
|
68
|
+
persistEnvKey('CREDENTIAL_SECRET', secret, envLocalPath, generatedEnvPath)
|
|
69
|
+
}
|
|
70
|
+
if (!env.ACCESS_KEY) {
|
|
71
|
+
const key = 'test-key-' + Date.now()
|
|
72
|
+
env.ACCESS_KEY = key
|
|
73
|
+
persistEnvKey('ACCESS_KEY', key, envLocalPath, generatedEnvPath)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { accessKey: env.ACCESS_KEY, credentialSecret: env.CREDENTIAL_SECRET }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('Docker container key persistence (end-to-end simulation)', () => {
|
|
80
|
+
let tmpDir: string
|
|
81
|
+
let dataDir: string
|
|
82
|
+
let readOnlyDir: string
|
|
83
|
+
let envLocalPath: string
|
|
84
|
+
let generatedEnvPath: string
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docker-sim-'))
|
|
88
|
+
// Simulate Docker: data dir is writable (volume-mounted)
|
|
89
|
+
dataDir = path.join(tmpDir, 'data')
|
|
90
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
91
|
+
generatedEnvPath = path.join(dataDir, '.env.generated')
|
|
92
|
+
|
|
93
|
+
// Simulate Docker: .env.local dir is NOT writable (inside container image)
|
|
94
|
+
readOnlyDir = path.join(tmpDir, 'readonly')
|
|
95
|
+
fs.mkdirSync(readOnlyDir, { recursive: true })
|
|
96
|
+
envLocalPath = path.join(readOnlyDir, '.env.local')
|
|
97
|
+
// Make the directory read-only to simulate Docker container filesystem
|
|
98
|
+
fs.chmodSync(readOnlyDir, 0o555)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
// Restore permissions before cleanup
|
|
103
|
+
try { fs.chmodSync(readOnlyDir, 0o755) } catch { /* ok */ }
|
|
104
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('first boot generates keys and persists to data dir fallback', () => {
|
|
108
|
+
const boot1 = simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
109
|
+
|
|
110
|
+
assert.ok(boot1.accessKey, 'ACCESS_KEY should be generated')
|
|
111
|
+
assert.ok(boot1.credentialSecret, 'CREDENTIAL_SECRET should be generated')
|
|
112
|
+
assert.ok(fs.existsSync(generatedEnvPath), '.env.generated should exist in data dir')
|
|
113
|
+
|
|
114
|
+
const content = fs.readFileSync(generatedEnvPath, 'utf8')
|
|
115
|
+
assert.ok(content.includes('ACCESS_KEY='), 'ACCESS_KEY should be in .env.generated')
|
|
116
|
+
assert.ok(content.includes('CREDENTIAL_SECRET='), 'CREDENTIAL_SECRET should be in .env.generated')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('keys survive container restart (same data dir)', () => {
|
|
120
|
+
// First boot
|
|
121
|
+
const boot1 = simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
122
|
+
|
|
123
|
+
// Simulate container restart: keys from .env.generated should be reloaded
|
|
124
|
+
const boot2 = simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
125
|
+
|
|
126
|
+
assert.equal(boot2.accessKey, boot1.accessKey, 'ACCESS_KEY should persist across restart')
|
|
127
|
+
assert.equal(boot2.credentialSecret, boot1.credentialSecret, 'CREDENTIAL_SECRET should persist across restart')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('three consecutive restarts all return the same keys', () => {
|
|
131
|
+
const boot1 = simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
132
|
+
const boot2 = simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
133
|
+
const boot3 = simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
134
|
+
|
|
135
|
+
assert.equal(boot1.accessKey, boot2.accessKey)
|
|
136
|
+
assert.equal(boot2.accessKey, boot3.accessKey)
|
|
137
|
+
assert.equal(boot1.credentialSecret, boot2.credentialSecret)
|
|
138
|
+
assert.equal(boot2.credentialSecret, boot3.credentialSecret)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('.env.local is never written when read-only', () => {
|
|
142
|
+
simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
143
|
+
|
|
144
|
+
assert.ok(!fs.existsSync(envLocalPath), '.env.local should NOT be created in read-only dir')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('Local dev key persistence (writable .env.local)', () => {
|
|
149
|
+
let tmpDir: string
|
|
150
|
+
let dataDir: string
|
|
151
|
+
let envLocalPath: string
|
|
152
|
+
let generatedEnvPath: string
|
|
153
|
+
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'local-dev-sim-'))
|
|
156
|
+
dataDir = path.join(tmpDir, 'data')
|
|
157
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
158
|
+
generatedEnvPath = path.join(dataDir, '.env.generated')
|
|
159
|
+
envLocalPath = path.join(tmpDir, '.env.local')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
afterEach(() => {
|
|
163
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('writes keys to .env.local when writable (local dev mode)', () => {
|
|
167
|
+
const boot = simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
168
|
+
|
|
169
|
+
assert.ok(boot.accessKey)
|
|
170
|
+
assert.ok(fs.existsSync(envLocalPath), '.env.local should be created')
|
|
171
|
+
const content = fs.readFileSync(envLocalPath, 'utf8')
|
|
172
|
+
assert.ok(content.includes('ACCESS_KEY='), 'ACCESS_KEY should be in .env.local')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('does not create .env.generated when .env.local is writable', () => {
|
|
176
|
+
simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
177
|
+
|
|
178
|
+
assert.ok(!fs.existsSync(generatedEnvPath), '.env.generated should NOT be created when .env.local works')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('pre-existing .env.local keys are preserved', () => {
|
|
182
|
+
fs.writeFileSync(envLocalPath, 'ACCESS_KEY=preset-key\nCREDENTIAL_SECRET=preset-secret\n')
|
|
183
|
+
|
|
184
|
+
const boot = simulateContainerBoot(envLocalPath, generatedEnvPath)
|
|
185
|
+
|
|
186
|
+
assert.equal(boot.accessKey, 'preset-key', 'pre-existing ACCESS_KEY should be loaded')
|
|
187
|
+
assert.equal(boot.credentialSecret, 'preset-secret', 'pre-existing CREDENTIAL_SECRET should be loaded')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('.env.local takes precedence over .env.generated when both exist', () => {
|
|
191
|
+
// Simulate: .env.generated has old auto-generated keys
|
|
192
|
+
fs.writeFileSync(generatedEnvPath, 'ACCESS_KEY=auto-old\nCREDENTIAL_SECRET=auto-old-secret\n')
|
|
193
|
+
// User creates .env.local with their own custom keys
|
|
194
|
+
fs.writeFileSync(envLocalPath, 'ACCESS_KEY=user-custom\nCREDENTIAL_SECRET=user-custom-secret\n')
|
|
195
|
+
|
|
196
|
+
const env: Record<string, string> = {}
|
|
197
|
+
// Load order must match storage-auth.ts: .env.generated first, then .env.local
|
|
198
|
+
loadEnvFile(generatedEnvPath, env)
|
|
199
|
+
loadEnvFile(envLocalPath, env)
|
|
200
|
+
|
|
201
|
+
assert.equal(env.ACCESS_KEY, 'user-custom', '.env.local should override .env.generated')
|
|
202
|
+
assert.equal(env.CREDENTIAL_SECRET, 'user-custom-secret', '.env.local should override .env.generated')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('Windows line endings in .env files', () => {
|
|
207
|
+
let tmpDir: string
|
|
208
|
+
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crlf-test-'))
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
afterEach(() => {
|
|
214
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('parses .env.local with CRLF endings correctly', () => {
|
|
218
|
+
const envPath = path.join(tmpDir, '.env.local')
|
|
219
|
+
// Write with Windows-style line endings
|
|
220
|
+
fs.writeFileSync(envPath, 'ACCESS_KEY=win-key-123\r\nCREDENTIAL_SECRET=win-secret-456\r\n')
|
|
221
|
+
|
|
222
|
+
const env: Record<string, string> = {}
|
|
223
|
+
loadEnvFile(envPath, env)
|
|
224
|
+
|
|
225
|
+
assert.equal(env.ACCESS_KEY, 'win-key-123', 'No trailing \\r on ACCESS_KEY')
|
|
226
|
+
assert.equal(env.CREDENTIAL_SECRET, 'win-secret-456', 'No trailing \\r on CREDENTIAL_SECRET')
|
|
227
|
+
|
|
228
|
+
// Double-check no \r characters
|
|
229
|
+
assert.ok(!env.ACCESS_KEY.includes('\r'))
|
|
230
|
+
assert.ok(!env.CREDENTIAL_SECRET.includes('\r'))
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('key from CRLF file matches expected value for auth validation', () => {
|
|
234
|
+
const envPath = path.join(tmpDir, '.env.local')
|
|
235
|
+
fs.writeFileSync(envPath, 'ACCESS_KEY=test-auth-key\r\n')
|
|
236
|
+
|
|
237
|
+
const env: Record<string, string> = {}
|
|
238
|
+
loadEnvFile(envPath, env)
|
|
239
|
+
|
|
240
|
+
// Simulate validateAccessKey logic
|
|
241
|
+
const userInput = 'test-auth-key'
|
|
242
|
+
assert.equal(env.ACCESS_KEY, userInput, 'Key from CRLF file should match user input exactly')
|
|
243
|
+
})
|
|
244
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it, beforeEach, afterEach } from 'node:test'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tests for storage-auth helpers.
|
|
9
|
+
*
|
|
10
|
+
* We can't import the module directly (it has side-effects that touch the real
|
|
11
|
+
* filesystem), so we test the key parsing and persistence logic in isolation
|
|
12
|
+
* by reimplementing the pure functions and verifying the patterns they use.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Replicate the env-file parser from storage-auth.ts
|
|
16
|
+
function parseEnvFile(content: string): Record<string, string> {
|
|
17
|
+
const vars: Record<string, string> = {}
|
|
18
|
+
content.split(/\r?\n/).forEach((line) => {
|
|
19
|
+
const [k, ...v] = line.split('=')
|
|
20
|
+
if (k && v.length) vars[k.trim()] = v.join('=').trim()
|
|
21
|
+
})
|
|
22
|
+
return vars
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Replicate appendEnvKeyIfMissing from storage-auth.ts
|
|
26
|
+
function appendEnvKeyIfMissing(envPath: string, key: string, value: string): void {
|
|
27
|
+
const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''
|
|
28
|
+
const keyPattern = new RegExp(`^${key}=`, 'm')
|
|
29
|
+
if (keyPattern.test(existing)) return
|
|
30
|
+
fs.appendFileSync(envPath, `\n${key}=${value}\n`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('env file parsing', () => {
|
|
34
|
+
it('parses Unix line endings', () => {
|
|
35
|
+
const vars = parseEnvFile('ACCESS_KEY=abc123\nCREDENTIAL_SECRET=secret456\n')
|
|
36
|
+
assert.equal(vars.ACCESS_KEY, 'abc123')
|
|
37
|
+
assert.equal(vars.CREDENTIAL_SECRET, 'secret456')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('parses Windows line endings without trailing \\r', () => {
|
|
41
|
+
const vars = parseEnvFile('ACCESS_KEY=abc123\r\nCREDENTIAL_SECRET=secret456\r\n')
|
|
42
|
+
assert.equal(vars.ACCESS_KEY, 'abc123')
|
|
43
|
+
assert.equal(vars.CREDENTIAL_SECRET, 'secret456')
|
|
44
|
+
// Verify no \r is left on the values
|
|
45
|
+
assert.ok(!vars.ACCESS_KEY.includes('\r'), 'ACCESS_KEY should not contain \\r')
|
|
46
|
+
assert.ok(!vars.CREDENTIAL_SECRET.includes('\r'), 'CREDENTIAL_SECRET should not contain \\r')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('handles mixed line endings', () => {
|
|
50
|
+
const vars = parseEnvFile('A=1\r\nB=2\nC=3\r\n')
|
|
51
|
+
assert.equal(vars.A, '1')
|
|
52
|
+
assert.equal(vars.B, '2')
|
|
53
|
+
assert.equal(vars.C, '3')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('preserves values containing equals signs', () => {
|
|
57
|
+
const vars = parseEnvFile('SECRET=abc=def=ghi\n')
|
|
58
|
+
assert.equal(vars.SECRET, 'abc=def=ghi')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('skips empty lines and comment-like lines without =', () => {
|
|
62
|
+
const vars = parseEnvFile('\n# comment line\nKEY=val\n\n')
|
|
63
|
+
assert.equal(Object.keys(vars).length, 1)
|
|
64
|
+
assert.equal(vars.KEY, 'val')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('trims whitespace from keys and values', () => {
|
|
68
|
+
const vars = parseEnvFile(' MY_KEY = my_value \n')
|
|
69
|
+
assert.equal(vars.MY_KEY, 'my_value')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('appendEnvKeyIfMissing', () => {
|
|
74
|
+
let tmpDir: string
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'storage-auth-test-'))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('appends key to empty file', () => {
|
|
85
|
+
const envPath = path.join(tmpDir, '.env.local')
|
|
86
|
+
fs.writeFileSync(envPath, '', 'utf8')
|
|
87
|
+
appendEnvKeyIfMissing(envPath, 'ACCESS_KEY', 'test123')
|
|
88
|
+
const content = fs.readFileSync(envPath, 'utf8')
|
|
89
|
+
assert.ok(content.includes('ACCESS_KEY=test123'))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('creates file if it does not exist', () => {
|
|
93
|
+
const envPath = path.join(tmpDir, '.env.local')
|
|
94
|
+
appendEnvKeyIfMissing(envPath, 'ACCESS_KEY', 'test123')
|
|
95
|
+
assert.ok(fs.existsSync(envPath))
|
|
96
|
+
const content = fs.readFileSync(envPath, 'utf8')
|
|
97
|
+
assert.ok(content.includes('ACCESS_KEY=test123'))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does not duplicate an existing key', () => {
|
|
101
|
+
const envPath = path.join(tmpDir, '.env.local')
|
|
102
|
+
fs.writeFileSync(envPath, 'ACCESS_KEY=original\n', 'utf8')
|
|
103
|
+
appendEnvKeyIfMissing(envPath, 'ACCESS_KEY', 'should-not-appear')
|
|
104
|
+
const content = fs.readFileSync(envPath, 'utf8')
|
|
105
|
+
assert.equal(content, 'ACCESS_KEY=original\n')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('appends a second key without overwriting the first', () => {
|
|
109
|
+
const envPath = path.join(tmpDir, '.env.local')
|
|
110
|
+
fs.writeFileSync(envPath, 'FIRST=1\n', 'utf8')
|
|
111
|
+
appendEnvKeyIfMissing(envPath, 'SECOND', '2')
|
|
112
|
+
const vars = parseEnvFile(fs.readFileSync(envPath, 'utf8'))
|
|
113
|
+
assert.equal(vars.FIRST, '1')
|
|
114
|
+
assert.equal(vars.SECOND, '2')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('Docker key persistence fallback', () => {
|
|
119
|
+
let tmpDir: string
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'storage-auth-docker-test-'))
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('keys written to fallback file survive simulated container restart', () => {
|
|
130
|
+
// Simulate: write keys to a "data dir" fallback (like DATA_DIR/.env.generated)
|
|
131
|
+
const generatedEnvPath = path.join(tmpDir, '.env.generated')
|
|
132
|
+
appendEnvKeyIfMissing(generatedEnvPath, 'ACCESS_KEY', 'docker-key-123')
|
|
133
|
+
appendEnvKeyIfMissing(generatedEnvPath, 'CREDENTIAL_SECRET', 'docker-secret-456')
|
|
134
|
+
|
|
135
|
+
// Simulate restart: re-read the file (as loadEnvFile would)
|
|
136
|
+
const vars = parseEnvFile(fs.readFileSync(generatedEnvPath, 'utf8'))
|
|
137
|
+
assert.equal(vars.ACCESS_KEY, 'docker-key-123')
|
|
138
|
+
assert.equal(vars.CREDENTIAL_SECRET, 'docker-secret-456')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('fallback file does not overwrite keys that already exist', () => {
|
|
142
|
+
const generatedEnvPath = path.join(tmpDir, '.env.generated')
|
|
143
|
+
appendEnvKeyIfMissing(generatedEnvPath, 'ACCESS_KEY', 'original')
|
|
144
|
+
|
|
145
|
+
// Second call should not overwrite
|
|
146
|
+
appendEnvKeyIfMissing(generatedEnvPath, 'ACCESS_KEY', 'new-value')
|
|
147
|
+
const vars = parseEnvFile(fs.readFileSync(generatedEnvPath, 'utf8'))
|
|
148
|
+
assert.equal(vars.ACCESS_KEY, 'original')
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -7,21 +7,30 @@ import { log } from '@/lib/server/logger'
|
|
|
7
7
|
|
|
8
8
|
const TAG = 'storage-auth'
|
|
9
9
|
|
|
10
|
+
// Fallback env file inside the data directory — survives Docker container restarts
|
|
11
|
+
// because DATA_DIR is volume-mounted, unlike process.cwd()/.env.local.
|
|
12
|
+
const GENERATED_ENV_PATH = path.join(DATA_DIR, '.env.generated')
|
|
13
|
+
|
|
10
14
|
// --- .env loading ---
|
|
15
|
+
function loadEnvFile(filePath: string): void {
|
|
16
|
+
if (!fs.existsSync(filePath)) return
|
|
17
|
+
fs.readFileSync(filePath, 'utf8').split(/\r?\n/).forEach(line => {
|
|
18
|
+
const [k, ...v] = line.split('=')
|
|
19
|
+
if (k && v.length) process.env[k.trim()] = v.join('=').trim()
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
function loadEnv() {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (k && v.length) process.env[k.trim()] = v.join('=').trim()
|
|
17
|
-
})
|
|
18
|
-
}
|
|
24
|
+
// Load fallback first so that .env.local values take precedence.
|
|
25
|
+
// .env.generated is auto-created in Docker where .env.local isn't writable.
|
|
26
|
+
loadEnvFile(GENERATED_ENV_PATH)
|
|
27
|
+
loadEnvFile(path.join(process.cwd(), '.env.local'))
|
|
19
28
|
}
|
|
20
29
|
if (!IS_BUILD_BOOTSTRAP) {
|
|
21
30
|
loadEnv()
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
/** Append a key=value to
|
|
33
|
+
/** Append a key=value to a file only if the key doesn't already exist in it. */
|
|
25
34
|
function appendEnvKeyIfMissing(envPath: string, key: string, value: string): void {
|
|
26
35
|
const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''
|
|
27
36
|
const keyPattern = new RegExp(`^${key}=`, 'm')
|
|
@@ -29,23 +38,42 @@ function appendEnvKeyIfMissing(envPath: string, key: string, value: string): voi
|
|
|
29
38
|
fs.appendFileSync(envPath, `\n${key}=${value}\n`)
|
|
30
39
|
}
|
|
31
40
|
|
|
41
|
+
/** Try to persist a key to .env.local, falling back to DATA_DIR/.env.generated. */
|
|
42
|
+
function persistEnvKey(key: string, value: string): void {
|
|
43
|
+
const envLocalPath = path.join(process.cwd(), '.env.local')
|
|
44
|
+
// Try .env.local first (works for local dev, npm run dev)
|
|
45
|
+
try {
|
|
46
|
+
appendEnvKeyIfMissing(envLocalPath, key, value)
|
|
47
|
+
return
|
|
48
|
+
} catch {
|
|
49
|
+
// .env.local not writable — expected in Docker containers
|
|
50
|
+
}
|
|
51
|
+
// Fall back to the data directory (volume-mounted in Docker)
|
|
52
|
+
try {
|
|
53
|
+
fs.mkdirSync(path.dirname(GENERATED_ENV_PATH), { recursive: true })
|
|
54
|
+
appendEnvKeyIfMissing(GENERATED_ENV_PATH, key, value)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
log.warn(TAG, `Could not persist ${key} to disk. It will be regenerated on restart.`, {
|
|
57
|
+
error: err instanceof Error ? err.message : String(err),
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
// Auto-generate CREDENTIAL_SECRET if missing
|
|
33
63
|
if (!IS_BUILD_BOOTSTRAP && !process.env.CREDENTIAL_SECRET) {
|
|
34
64
|
const secret = crypto.randomBytes(32).toString('hex')
|
|
35
|
-
const envPath = path.join(process.cwd(), '.env.local')
|
|
36
|
-
appendEnvKeyIfMissing(envPath, 'CREDENTIAL_SECRET', secret)
|
|
37
65
|
process.env.CREDENTIAL_SECRET = secret
|
|
38
|
-
|
|
66
|
+
persistEnvKey('CREDENTIAL_SECRET', secret)
|
|
67
|
+
log.info(TAG, 'Generated CREDENTIAL_SECRET')
|
|
39
68
|
}
|
|
40
69
|
|
|
41
70
|
// Auto-generate ACCESS_KEY if missing (used for simple auth)
|
|
42
71
|
const SETUP_FLAG = path.join(DATA_DIR, '.setup_pending')
|
|
43
72
|
if (!IS_BUILD_BOOTSTRAP && !process.env.ACCESS_KEY) {
|
|
44
73
|
const key = crypto.randomBytes(16).toString('hex')
|
|
45
|
-
const envPath = path.join(process.cwd(), '.env.local')
|
|
46
|
-
appendEnvKeyIfMissing(envPath, 'ACCESS_KEY', key)
|
|
47
74
|
process.env.ACCESS_KEY = key
|
|
48
|
-
|
|
75
|
+
persistEnvKey('ACCESS_KEY', key)
|
|
76
|
+
try { fs.writeFileSync(SETUP_FLAG, key) } catch { /* non-fatal */ }
|
|
49
77
|
log.info(TAG, `ACCESS KEY: ${key} — Use this key to connect from the browser.`)
|
|
50
78
|
}
|
|
51
79
|
|
|
@@ -65,15 +93,22 @@ export function markSetupComplete(): void {
|
|
|
65
93
|
if (fs.existsSync(SETUP_FLAG)) fs.unlinkSync(SETUP_FLAG)
|
|
66
94
|
}
|
|
67
95
|
|
|
68
|
-
/** Replace the access key in memory and
|
|
96
|
+
/** Replace the access key in memory and on disk (first-time setup override). */
|
|
69
97
|
export function replaceAccessKey(newKey: string): void {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
98
|
+
// Update in both possible locations
|
|
99
|
+
for (const envPath of [path.join(process.cwd(), '.env.local'), GENERATED_ENV_PATH]) {
|
|
100
|
+
try {
|
|
101
|
+
if (fs.existsSync(envPath)) {
|
|
102
|
+
const contents = fs.readFileSync(envPath, 'utf-8')
|
|
103
|
+
if (/^ACCESS_KEY=/m.test(contents)) {
|
|
104
|
+
fs.writeFileSync(envPath, contents.replace(/^ACCESS_KEY=.*$/m, `ACCESS_KEY=${newKey}`))
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
appendEnvKeyIfMissing(envPath, 'ACCESS_KEY', newKey)
|
|
109
|
+
} catch {
|
|
110
|
+
// Not writable — try the other location
|
|
111
|
+
}
|
|
77
112
|
}
|
|
78
113
|
process.env.ACCESS_KEY = newKey
|
|
79
114
|
}
|
|
@@ -649,6 +649,25 @@ function normalizeStoredRecordInner(
|
|
|
649
649
|
if (session.geminiSessionId === undefined) session.geminiSessionId = null
|
|
650
650
|
// Default copilotSessionId for new field
|
|
651
651
|
if (session.copilotSessionId === undefined) session.copilotSessionId = null
|
|
652
|
+
if (session.cursorSessionId === undefined) session.cursorSessionId = null
|
|
653
|
+
if (session.qwenSessionId === undefined) session.qwenSessionId = null
|
|
654
|
+
if (session.acpSessionId === undefined) session.acpSessionId = null
|
|
655
|
+
if (!session.delegateResumeIds || typeof session.delegateResumeIds !== 'object') {
|
|
656
|
+
session.delegateResumeIds = {
|
|
657
|
+
claudeCode: null,
|
|
658
|
+
codex: null,
|
|
659
|
+
opencode: null,
|
|
660
|
+
gemini: null,
|
|
661
|
+
copilot: null,
|
|
662
|
+
cursor: null,
|
|
663
|
+
qwen: null,
|
|
664
|
+
}
|
|
665
|
+
} else {
|
|
666
|
+
const resumeIds = session.delegateResumeIds as Record<string, unknown>
|
|
667
|
+
if (resumeIds.copilot === undefined) resumeIds.copilot = null
|
|
668
|
+
if (resumeIds.cursor === undefined) resumeIds.cursor = null
|
|
669
|
+
if (resumeIds.qwen === undefined) resumeIds.qwen = null
|
|
670
|
+
}
|
|
652
671
|
// Default injectedMemoryIds for proactive recall dedup
|
|
653
672
|
if (!session.injectedMemoryIds || typeof session.injectedMemoryIds !== 'object') {
|
|
654
673
|
session.injectedMemoryIds = {}
|
|
@@ -655,6 +655,9 @@ if (!IS_BUILD_BOOTSTRAP) {
|
|
|
655
655
|
'claude_code',
|
|
656
656
|
'codex_cli',
|
|
657
657
|
'opencode_cli',
|
|
658
|
+
'gemini_cli',
|
|
659
|
+
'cursor_cli',
|
|
660
|
+
'qwen_code_cli',
|
|
658
661
|
]
|
|
659
662
|
const count = (db.prepare('SELECT COUNT(*) as c FROM agents').get() as { c: number }).c
|
|
660
663
|
if (count === 0) {
|
|
@@ -5,6 +5,8 @@ export interface TaskResumeState {
|
|
|
5
5
|
claudeSessionId: string | null
|
|
6
6
|
codexThreadId: string | null
|
|
7
7
|
opencodeSessionId: string | null
|
|
8
|
+
cursorSessionId?: string | null
|
|
9
|
+
qwenSessionId?: string | null
|
|
8
10
|
delegateResumeIds: NonNullable<Session['delegateResumeIds']>
|
|
9
11
|
}
|
|
10
12
|
|
|
@@ -26,6 +28,9 @@ function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']
|
|
|
26
28
|
codex: null,
|
|
27
29
|
opencode: null,
|
|
28
30
|
gemini: null,
|
|
31
|
+
copilot: null,
|
|
32
|
+
cursor: null,
|
|
33
|
+
qwen: null,
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
|
|
@@ -43,6 +48,8 @@ function hasResumeState(state: TaskResumeState | null | undefined): state is Tas
|
|
|
43
48
|
|| state.delegateResumeIds.codex
|
|
44
49
|
|| state.delegateResumeIds.opencode
|
|
45
50
|
|| state.delegateResumeIds.gemini
|
|
51
|
+
|| state.delegateResumeIds.cursor
|
|
52
|
+
|| state.delegateResumeIds.qwen
|
|
46
53
|
)
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -59,16 +66,22 @@ export function extractTaskResumeState(task: Partial<BoardTask> | null | undefin
|
|
|
59
66
|
|| (legacyProvider === 'opencode-cli' ? legacyResumeId : null)
|
|
60
67
|
const geminiSessionId = normalizeResumeHandle(task.geminiResumeId)
|
|
61
68
|
|| (legacyProvider === 'gemini-cli' ? legacyResumeId : null)
|
|
69
|
+
const cursorSessionId = legacyProvider === 'cursor-cli' ? legacyResumeId : null
|
|
70
|
+
const qwenSessionId = legacyProvider === 'qwen-code-cli' ? legacyResumeId : null
|
|
62
71
|
|
|
63
72
|
const resume = {
|
|
64
73
|
claudeSessionId,
|
|
65
74
|
codexThreadId,
|
|
66
75
|
opencodeSessionId,
|
|
76
|
+
cursorSessionId,
|
|
77
|
+
qwenSessionId,
|
|
67
78
|
delegateResumeIds: {
|
|
68
79
|
claudeCode: claudeSessionId,
|
|
69
80
|
codex: codexThreadId,
|
|
70
81
|
opencode: opencodeSessionId,
|
|
71
82
|
gemini: geminiSessionId,
|
|
83
|
+
cursor: cursorSessionId,
|
|
84
|
+
qwen: qwenSessionId,
|
|
72
85
|
},
|
|
73
86
|
} satisfies TaskResumeState
|
|
74
87
|
|
|
@@ -81,6 +94,8 @@ export function extractSessionResumeState(session: Partial<Session> | null | und
|
|
|
81
94
|
const claudeSessionId = normalizeResumeHandle(session.claudeSessionId)
|
|
82
95
|
const codexThreadId = normalizeResumeHandle(session.codexThreadId)
|
|
83
96
|
const opencodeSessionId = normalizeResumeHandle(session.opencodeSessionId)
|
|
97
|
+
const cursorSessionId = normalizeResumeHandle(session.cursorSessionId)
|
|
98
|
+
const qwenSessionId = normalizeResumeHandle(session.qwenSessionId)
|
|
84
99
|
const delegateResumeIds = session.delegateResumeIds && typeof session.delegateResumeIds === 'object'
|
|
85
100
|
? { ...buildEmptyDelegateResumeIds(), ...session.delegateResumeIds }
|
|
86
101
|
: buildEmptyDelegateResumeIds()
|
|
@@ -89,11 +104,16 @@ export function extractSessionResumeState(session: Partial<Session> | null | und
|
|
|
89
104
|
claudeSessionId,
|
|
90
105
|
codexThreadId,
|
|
91
106
|
opencodeSessionId,
|
|
107
|
+
cursorSessionId,
|
|
108
|
+
qwenSessionId,
|
|
92
109
|
delegateResumeIds: {
|
|
93
110
|
claudeCode: normalizeResumeHandle(delegateResumeIds.claudeCode) || claudeSessionId,
|
|
94
111
|
codex: normalizeResumeHandle(delegateResumeIds.codex) || codexThreadId,
|
|
95
112
|
opencode: normalizeResumeHandle(delegateResumeIds.opencode) || opencodeSessionId,
|
|
96
113
|
gemini: normalizeResumeHandle(delegateResumeIds.gemini),
|
|
114
|
+
cursor: normalizeResumeHandle(delegateResumeIds.cursor) || cursorSessionId,
|
|
115
|
+
qwen: normalizeResumeHandle(delegateResumeIds.qwen) || qwenSessionId,
|
|
116
|
+
copilot: normalizeResumeHandle(delegateResumeIds.copilot),
|
|
97
117
|
},
|
|
98
118
|
} satisfies TaskResumeState
|
|
99
119
|
|
|
@@ -140,10 +160,12 @@ export function applyTaskResumeStateToSession(session: Session, resume: TaskResu
|
|
|
140
160
|
if (!hasResumeState(resume)) return false
|
|
141
161
|
|
|
142
162
|
let changed = false
|
|
143
|
-
const directFields: Array<['claudeSessionId' | 'codexThreadId' | 'opencodeSessionId', string | null]> = [
|
|
163
|
+
const directFields: Array<['claudeSessionId' | 'codexThreadId' | 'opencodeSessionId' | 'cursorSessionId' | 'qwenSessionId', string | null]> = [
|
|
144
164
|
['claudeSessionId', resume.claudeSessionId],
|
|
145
165
|
['codexThreadId', resume.codexThreadId],
|
|
146
166
|
['opencodeSessionId', resume.opencodeSessionId],
|
|
167
|
+
['cursorSessionId', resume.cursorSessionId ?? null],
|
|
168
|
+
['qwenSessionId', resume.qwenSessionId ?? null],
|
|
147
169
|
]
|
|
148
170
|
for (const [key, value] of directFields) {
|
|
149
171
|
if (!value || session[key] === value) continue
|
|
@@ -5,7 +5,7 @@ const EXTENSION_ALIAS_GROUPS: string[][] = [
|
|
|
5
5
|
['edit_file'],
|
|
6
6
|
['web', 'web_search', 'web_fetch', 'http_request', 'http'],
|
|
7
7
|
['browser', 'openclaw_browser'],
|
|
8
|
-
['delegate', 'claude_code', 'codex_cli', 'opencode_cli', 'gemini_cli', 'delegate_to_claude_code', 'delegate_to_codex_cli', 'delegate_to_opencode_cli', 'delegate_to_gemini_cli'],
|
|
8
|
+
['delegate', 'claude_code', 'codex_cli', 'opencode_cli', 'gemini_cli', 'copilot_cli', 'cursor_cli', 'qwen_code_cli', 'delegate_to_claude_code', 'delegate_to_codex_cli', 'delegate_to_opencode_cli', 'delegate_to_gemini_cli', 'delegate_to_copilot_cli', 'delegate_to_cursor_cli', 'delegate_to_qwen_code_cli'],
|
|
9
9
|
['manage_platform'],
|
|
10
10
|
['manage_agents'],
|
|
11
11
|
['manage_projects'],
|
|
@@ -53,11 +53,14 @@ const TOOL_DESCRIPTORS: Record<string, ToolDescriptor> = {
|
|
|
53
53
|
web_search: { categories: ['network'], concreteTools: ['web_search'] },
|
|
54
54
|
web_fetch: { categories: ['network'], concreteTools: ['web_fetch'] },
|
|
55
55
|
browser: { categories: ['browser', 'network'], concreteTools: ['browser', 'openclaw_browser'] },
|
|
56
|
-
delegate: { categories: ['delegation', 'execution'], concreteTools: ['delegate', 'delegate_to_claude_code', 'delegate_to_codex_cli', 'delegate_to_opencode_cli', 'delegate_to_gemini_cli'] },
|
|
56
|
+
delegate: { categories: ['delegation', 'execution'], concreteTools: ['delegate', 'delegate_to_claude_code', 'delegate_to_codex_cli', 'delegate_to_opencode_cli', 'delegate_to_gemini_cli', 'delegate_to_copilot_cli', 'delegate_to_cursor_cli', 'delegate_to_qwen_code_cli'] },
|
|
57
57
|
claude_code: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_claude_code'] },
|
|
58
58
|
codex_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_codex_cli'] },
|
|
59
59
|
opencode_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_opencode_cli'] },
|
|
60
60
|
gemini_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_gemini_cli'] },
|
|
61
|
+
copilot_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_copilot_cli'] },
|
|
62
|
+
cursor_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_cursor_cli'] },
|
|
63
|
+
qwen_code_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_qwen_code_cli'] },
|
|
61
64
|
memory: { categories: ['memory'], concreteTools: ['memory', 'memory_tool', 'memory_search', 'memory_get', 'memory_store', 'memory_update', 'context_status', 'context_summarize'] },
|
|
62
65
|
// http_request consolidated into web 'api' action — no separate descriptor
|
|
63
66
|
monitor: { categories: ['execution'], concreteTools: ['monitor', 'monitor_tool'] },
|