@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.
Files changed (78) hide show
  1. package/README.md +167 -147
  2. package/bin/swarmclaw.js +35 -1
  3. package/package.json +2 -2
  4. package/scripts/run-next-build.mjs +87 -2
  5. package/skills/swarmclaw/SKILL.md +151 -0
  6. package/src/app/api/agents/[id]/route.ts +8 -0
  7. package/src/app/api/agents/agents-route.test.ts +114 -0
  8. package/src/app/api/agents/route.ts +10 -0
  9. package/src/app/api/connectors/route.ts +25 -13
  10. package/src/app/api/credentials/[id]/route.ts +8 -1
  11. package/src/app/api/schedules/[id]/route.ts +8 -0
  12. package/src/app/api/secrets/[id]/route.ts +10 -0
  13. package/src/app/api/setup/check-provider/route.ts +45 -0
  14. package/src/app/api/setup/doctor/route.ts +5 -0
  15. package/src/cli/binary.test.js +11 -0
  16. package/src/cli/index.js +4 -4
  17. package/src/cli/index.test.js +5 -2
  18. package/src/cli/index.ts +1 -1
  19. package/src/components/agents/agent-sheet.tsx +16 -4
  20. package/src/components/agents/inspector-panel.tsx +5 -0
  21. package/src/components/auth/setup-wizard/step-agents.tsx +19 -1
  22. package/src/components/chat/activity-moment.tsx +3 -0
  23. package/src/components/chat/chat-header.tsx +23 -2
  24. package/src/components/chat/tool-call-bubble.tsx +20 -0
  25. package/src/components/layout/sidebar-rail.tsx +29 -0
  26. package/src/hooks/setup-done-detection.test.ts +4 -2
  27. package/src/hooks/setup-done-detection.ts +1 -1
  28. package/src/lib/orchestrator-config.ts +5 -0
  29. package/src/lib/provider-sets.ts +4 -4
  30. package/src/lib/providers/acp-client.ts +116 -0
  31. package/src/lib/providers/cli-utils.test.ts +9 -1
  32. package/src/lib/providers/cli-utils.ts +89 -4
  33. package/src/lib/providers/cursor-cli.ts +172 -0
  34. package/src/lib/providers/goose.ts +149 -0
  35. package/src/lib/providers/index.ts +29 -1
  36. package/src/lib/providers/qwen-code-cli.ts +152 -0
  37. package/src/lib/server/agents/agent-availability.ts +2 -2
  38. package/src/lib/server/agents/agent-thread-session.ts +8 -0
  39. package/src/lib/server/agents/task-session.ts +8 -0
  40. package/src/lib/server/capability-router.ts +8 -2
  41. package/src/lib/server/chat-execution/chat-execution-utils.ts +13 -0
  42. package/src/lib/server/chat-execution/chat-turn-finalization.ts +8 -0
  43. package/src/lib/server/chat-execution/chat-turn-preparation.ts +5 -1
  44. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +3 -0
  45. package/src/lib/server/chat-execution/iteration-timers.test.ts +84 -0
  46. package/src/lib/server/chat-execution/iteration-timers.ts +18 -1
  47. package/src/lib/server/chat-execution/prompt-sections.ts +3 -1
  48. package/src/lib/server/chat-execution/stream-agent-chat.ts +5 -0
  49. package/src/lib/server/chatrooms/chatroom-helpers.ts +13 -0
  50. package/src/lib/server/chats/chat-session-service.ts +18 -0
  51. package/src/lib/server/connectors/session.ts +8 -0
  52. package/src/lib/server/context-manager.ts +5 -0
  53. package/src/lib/server/provider-health.ts +13 -3
  54. package/src/lib/server/provider-model-discovery.test.ts +8 -0
  55. package/src/lib/server/provider-model-discovery.ts +1 -1
  56. package/src/lib/server/runtime/daemon-state/core.ts +2 -2
  57. package/src/lib/server/runtime/queue/core.ts +30 -4
  58. package/src/lib/server/runtime/session-run-manager/enqueue.ts +1 -1
  59. package/src/lib/server/session-reset-policy.test.ts +16 -0
  60. package/src/lib/server/session-reset-policy.ts +9 -1
  61. package/src/lib/server/session-tools/context.ts +2 -2
  62. package/src/lib/server/session-tools/delegate.ts +334 -14
  63. package/src/lib/server/session-tools/index.ts +5 -2
  64. package/src/lib/server/session-tools/session-info.ts +4 -1
  65. package/src/lib/server/storage-auth-docker.test.ts +244 -0
  66. package/src/lib/server/storage-auth.test.ts +150 -0
  67. package/src/lib/server/storage-auth.ts +57 -22
  68. package/src/lib/server/storage-normalization.ts +19 -0
  69. package/src/lib/server/storage.ts +3 -0
  70. package/src/lib/server/tasks/task-resume.ts +23 -1
  71. package/src/lib/server/tool-aliases.ts +1 -1
  72. package/src/lib/server/tool-capability-policy.ts +4 -1
  73. package/src/lib/setup-defaults.test.ts +6 -0
  74. package/src/lib/setup-defaults.ts +146 -0
  75. package/src/lib/tool-definitions.ts +1 -1
  76. package/src/types/misc.ts +4 -1
  77. package/src/types/provider.ts +1 -1
  78. 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
- const envPath = path.join(process.cwd(), '.env.local')
13
- if (fs.existsSync(envPath)) {
14
- fs.readFileSync(envPath, 'utf8').split('\n').forEach(line => {
15
- const [k, ...v] = line.split('=')
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 .env.local only if the key doesn't already exist in the file. */
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
- log.info(TAG, 'Generated CREDENTIAL_SECRET in .env.local')
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
- fs.writeFileSync(SETUP_FLAG, key)
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 in .env.local (first-time setup override). */
96
+ /** Replace the access key in memory and on disk (first-time setup override). */
69
97
  export function replaceAccessKey(newKey: string): void {
70
- const envPath = path.join(process.cwd(), '.env.local')
71
- if (fs.existsSync(envPath)) {
72
- const contents = fs.readFileSync(envPath, 'utf-8')
73
- const updated = contents.replace(/^ACCESS_KEY=.*$/m, `ACCESS_KEY=${newKey}`)
74
- fs.writeFileSync(envPath, updated)
75
- } else {
76
- fs.appendFileSync(envPath, `\nACCESS_KEY=${newKey}\n`)
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'] },