@swarmclawai/swarmclaw 1.9.32 → 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.
Files changed (34) hide show
  1. package/README.md +38 -0
  2. package/package.json +2 -2
  3. package/src/app/api/agents/agents-route.test.ts +36 -0
  4. package/src/app/api/extensions/builtins/route.ts +2 -1
  5. package/src/app/api/tasks/[id]/retry/route.ts +12 -0
  6. package/src/app/api/tasks/task-workspace-route.test.ts +62 -0
  7. package/src/cli/index.js +1 -0
  8. package/src/cli/index.test.js +30 -0
  9. package/src/cli/spec.js +1 -0
  10. package/src/components/agents/agent-sheet.tsx +41 -2
  11. package/src/components/chat/chat-tool-toggles.tsx +29 -7
  12. package/src/lib/providers/openclaw.test.ts +8 -1
  13. package/src/lib/providers/openclaw.ts +4 -2
  14. package/src/lib/server/chat-execution/chat-execution-connector-delivery.ts +17 -1
  15. package/src/lib/server/chat-execution/chat-turn-finalization.ts +2 -2
  16. package/src/lib/server/chat-execution/post-stream-finalization.test.ts +24 -0
  17. package/src/lib/server/connectors/connector-inbound.ts +6 -6
  18. package/src/lib/server/connectors/connector-lifecycle.ts +17 -1
  19. package/src/lib/server/connectors/openclaw.test.ts +9 -2
  20. package/src/lib/server/connectors/openclaw.ts +1 -1
  21. package/src/lib/server/session-tools/credential-env.ts +4 -3
  22. package/src/lib/server/session-tools/crud.ts +5 -0
  23. package/src/lib/server/session-tools/discovery-approvals.test.ts +49 -0
  24. package/src/lib/server/session-tools/execute.test.ts +29 -0
  25. package/src/lib/server/session-tools/manage-tasks.test.ts +55 -0
  26. package/src/lib/server/storage-auth.test.ts +204 -0
  27. package/src/lib/server/storage-auth.ts +309 -16
  28. package/src/lib/server/tasks/task-route-service.ts +46 -0
  29. package/src/lib/server/tasks/task-service.test.ts +50 -0
  30. package/src/lib/server/tasks/task-service.ts +16 -3
  31. package/src/lib/server/universal-tool-access.test.ts +16 -0
  32. package/src/lib/server/universal-tool-access.ts +3 -1
  33. package/src/lib/validation/schemas.ts +12 -0
  34. package/src/types/agent.ts +1 -0
@@ -164,11 +164,23 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
164
164
 
165
165
  // Resolve bot token from credential
166
166
  let botToken = ''
167
+ let credentialDecryptError: string | null = null
167
168
  if (connector.credentialId) {
168
169
  const creds = loadCredentials()
169
170
  const cred = creds[connector.credentialId]
170
171
  if (cred?.encryptedKey) {
171
- try { botToken = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
172
+ try {
173
+ botToken = decryptKey(cred.encryptedKey)
174
+ } catch (err: unknown) {
175
+ credentialDecryptError = `Failed to decrypt credential "${connector.credentialId}". CREDENTIAL_SECRET may have changed since this credential was stored; restore the previous credential secret or re-add the key.`
176
+ log.warn(TAG, credentialDecryptError, {
177
+ connectorId,
178
+ connectorName: connector.name,
179
+ platform: connector.platform,
180
+ credentialId: connector.credentialId,
181
+ error: errorMessage(err),
182
+ })
183
+ }
172
184
  }
173
185
  }
174
186
  // Also check config for inline token (some platforms)
@@ -182,6 +194,10 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
182
194
  botToken = swarmdockFallbackPrivateKey
183
195
  }
184
196
 
197
+ if (!botToken && credentialDecryptError) {
198
+ throw new Error(credentialDecryptError)
199
+ }
200
+
185
201
  if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email' && connector.platform !== 'filequeue' && connector.platform !== 'swarmdock') {
186
202
  throw new Error('No bot token configured')
187
203
  }
@@ -66,6 +66,10 @@ function findReqAt(ws: MockWebSocket, method: string, index: number): WsFrame |
66
66
  return matches[index]
67
67
  }
68
68
 
69
+ function frameParams<T extends Record<string, unknown>>(frame: WsFrame): T {
70
+ return (frame.params && typeof frame.params === 'object' ? frame.params : {}) as T
71
+ }
72
+
69
73
  async function waitFor<T>(
70
74
  getValue: () => T | null | undefined,
71
75
  timeoutMs = 2_000,
@@ -114,13 +118,16 @@ async function bootstrapConnector(params?: {
114
118
  async function performHandshake(ws: MockWebSocket, helloPayload?: WsFrame) {
115
119
  ws.emit({ type: 'event', event: 'connect.challenge', payload: { nonce: 'test-nonce' } })
116
120
  const connectReq = await waitFor(() => findReq(ws, 'connect'), 1_500)
121
+ const connectParams = frameParams<{ minProtocol?: unknown; maxProtocol?: unknown }>(connectReq)
122
+ assert.equal(connectParams.minProtocol, 4)
123
+ assert.equal(connectParams.maxProtocol, 4)
117
124
  ws.emit({
118
125
  type: 'res',
119
126
  id: connectReq.id as string,
120
127
  ok: true,
121
128
  payload: helloPayload || {
122
129
  type: 'hello-ok',
123
- protocol: 3,
130
+ protocol: 4,
124
131
  auth: { deviceToken: 'device-token-test' },
125
132
  policy: { tickIntervalMs: 15_000 },
126
133
  },
@@ -362,7 +369,7 @@ test('openclaw connector reconnects when tick watchdog detects stale connection'
362
369
  try {
363
370
  await performHandshake(ws, {
364
371
  type: 'hello-ok',
365
- protocol: 3,
372
+ protocol: 4,
366
373
  policy: { tickIntervalMs: 200 },
367
374
  })
368
375
 
@@ -25,7 +25,7 @@ const TAG = 'openclaw'
25
25
  * - chat traffic is event `chat` and RPC method `chat.send`
26
26
  */
27
27
 
28
- const PROTOCOL_VERSION = 3
28
+ const PROTOCOL_VERSION = 4
29
29
  const RECONNECT_BASE_MS = 2_000
30
30
  const RECONNECT_MAX_MS = 30_000
31
31
  const RPC_TIMEOUT_MS = 25_000
@@ -36,7 +36,7 @@ export function buildCredentialEnv(credentialIds: string[]): CredentialEnv {
36
36
  const env: Record<string, string> = {}
37
37
  const secrets: string[] = []
38
38
 
39
- const allCredentials = loadCredentials() as Record<string, Credential & { encrypted?: string }>
39
+ const allCredentials = loadCredentials() as Record<string, Credential & { encryptedKey?: string }>
40
40
 
41
41
  for (const credId of credentialIds) {
42
42
  const cred = allCredentials[credId]
@@ -45,8 +45,9 @@ export function buildCredentialEnv(credentialIds: string[]): CredentialEnv {
45
45
  continue
46
46
  }
47
47
 
48
- // Decrypt the stored key
49
- const encrypted = cred.encrypted
48
+ // Credentials persist ciphertext under `encryptedKey`; older reads of
49
+ // `cred.encrypted` silently skipped injection for execute tool calls.
50
+ const encrypted = cred.encryptedKey
50
51
  if (!encrypted || typeof encrypted !== 'string') {
51
52
  log.warn(TAG, `Credential has no encrypted value: ${credId}`)
52
53
  continue
@@ -603,6 +603,9 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
603
603
  const raw = buildCrudPayload(normalized, action, data)
604
604
  const defaults = RESOURCE_DEFAULTS[toolKey]
605
605
  const parsed = defaults ? defaults(raw) : raw
606
+ if (toolKey === 'manage_tasks' && !Object.prototype.hasOwnProperty.call(raw, 'status')) {
607
+ delete (parsed as Record<string, unknown>).status
608
+ }
606
609
  if (parsed && typeof parsed === 'object' && 'id' in parsed) {
607
610
  delete (parsed as Record<string, unknown>).id
608
611
  }
@@ -696,6 +699,8 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
696
699
  now,
697
700
  settings: loadSettings(),
698
701
  fallbackAgentId: ctx?.agentId || null,
702
+ creatorAgentId: ctx?.agentId || null,
703
+ autoQueueDelegatedTasks: true,
699
704
  defaultCwd: cwd,
700
705
  deriveTitleFromDescription: true,
701
706
  requireMeaningfulTitle: true,
@@ -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')
@@ -87,6 +87,35 @@ describe('credential-env', () => {
87
87
  const result = buildCredentialEnv(['nonexistent-id'])
88
88
  assert.deepEqual(result, { env: {}, secrets: [] })
89
89
  })
90
+
91
+ it('injects credentials stored under encryptedKey', () => {
92
+ const output = runWithTempDataDir<{
93
+ env: Record<string, string>
94
+ secrets: string[]
95
+ }>(`
96
+ process.env.CREDENTIAL_SECRET = 'a'.repeat(64)
97
+ const storageMod = await import('@/lib/server/storage')
98
+ const credentialEnvMod = await import('@/lib/server/session-tools/credential-env')
99
+ const storage = storageMod.default || storageMod
100
+ const credentialEnv = credentialEnvMod.default || credentialEnvMod
101
+ const encryptedKey = storage.encryptKey('github_pat_execute_tool')
102
+ storage.saveCredentials({
103
+ 'cred-github': {
104
+ id: 'cred-github',
105
+ provider: 'github',
106
+ name: 'token',
107
+ encryptedKey,
108
+ createdAt: Date.now(),
109
+ updatedAt: Date.now(),
110
+ },
111
+ })
112
+ const result = credentialEnv.buildCredentialEnv(['cred-github'])
113
+ console.log(JSON.stringify(result))
114
+ `)
115
+
116
+ assert.equal(output.env.GITHUB_TOKEN, 'github_pat_execute_tool')
117
+ assert.deepEqual(output.secrets, ['github_pat_execute_tool'])
118
+ })
90
119
  })
91
120
  })
92
121
 
@@ -192,6 +192,61 @@ describe('manage_tasks tool', () => {
192
192
  assert.equal(output.stored.workflowStateId, 'in_progress')
193
193
  })
194
194
 
195
+ it('queues agent-delegated tasks when no explicit status is provided', () => {
196
+ const output = runWithTempDataDir(`
197
+ const storageMod = await import('./src/lib/server/storage')
198
+ const crudMod = await import('./src/lib/server/session-tools/crud')
199
+ const storage = storageMod.default || storageMod
200
+ const crud = crudMod.default || crudMod
201
+
202
+ const now = Date.now()
203
+ storage.saveAgents({
204
+ coordinator: {
205
+ id: 'coordinator',
206
+ name: 'Coordinator',
207
+ description: '',
208
+ systemPrompt: '',
209
+ provider: 'openai',
210
+ model: 'gpt-test',
211
+ createdAt: now,
212
+ updatedAt: now,
213
+ },
214
+ worker: {
215
+ id: 'worker',
216
+ name: 'Worker',
217
+ description: '',
218
+ systemPrompt: '',
219
+ provider: 'openai',
220
+ model: 'gpt-test',
221
+ createdAt: now,
222
+ updatedAt: now,
223
+ },
224
+ })
225
+
226
+ const tools = crud.buildCrudTools({
227
+ cwd: process.env.WORKSPACE_DIR,
228
+ ctx: { sessionId: 'session-delegate', agentId: 'coordinator', delegationEnabled: true, delegationTargetMode: 'all', delegationTargetAgentIds: [] },
229
+ hasExtension: (name) => name === 'manage_tasks',
230
+ })
231
+ const tool = tools.find((entry) => entry.name === 'manage_tasks')
232
+ const raw = await tool.invoke({
233
+ action: 'create',
234
+ title: 'Worker follow-up',
235
+ description: 'Please take this delegated implementation task.',
236
+ agentId: 'worker',
237
+ })
238
+ const response = JSON.parse(raw)
239
+ const stored = storage.loadTasks()[response.id]
240
+ const queue = storage.loadQueue()
241
+ console.log(JSON.stringify({ response, stored, queue }))
242
+ `)
243
+
244
+ assert.equal(output.response.status, 'queued')
245
+ assert.equal(output.stored.status, 'queued')
246
+ assert.equal(output.stored.agentId, 'worker')
247
+ assert.deepEqual(output.queue, [output.response.id])
248
+ })
249
+
195
250
  it('keeps an explicit assignee but returns delegation advisory when another teammate is a better fit', () => {
196
251
  const output = runWithTempDataDir(`
197
252
  const storageMod = await import('./src/lib/server/storage')
@@ -3,6 +3,116 @@ 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'
7
+ import { spawnSync } from 'node:child_process'
8
+ import Database from 'better-sqlite3'
9
+
10
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
11
+
12
+ function runStorageAuthImport(options: {
13
+ envLocal?: string
14
+ generatedEnv?: string
15
+ credentialSecretFile?: string
16
+ externalCredentialSecret?: string
17
+ swarmclawHome?: boolean
18
+ buildEnvFiles?: Array<{ relativePath: string; content: string }>
19
+ encryptedCredentialSecrets?: string[]
20
+ }) {
21
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'storage-auth-import-'))
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')
27
+ fs.mkdirSync(dataDir, { recursive: true })
28
+ fs.mkdirSync(cwd, { recursive: true })
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
+ }
37
+ if (options.envLocal !== undefined) {
38
+ fs.writeFileSync(path.join(cwd, '.env.local'), options.envLocal, 'utf8')
39
+ }
40
+ if (options.generatedEnv !== undefined) {
41
+ fs.writeFileSync(path.join(dataDir, '.env.generated'), options.generatedEnv, 'utf8')
42
+ }
43
+ if (options.credentialSecretFile !== undefined) {
44
+ fs.writeFileSync(path.join(dataDir, 'credential-secret'), options.credentialSecretFile, { encoding: 'utf8', mode: 0o600 })
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
+ }
65
+ const env: NodeJS.ProcessEnv = {
66
+ ...process.env,
67
+ DATA_DIR: dataDir,
68
+ WORKSPACE_DIR: path.join(tmpDir, 'workspace'),
69
+ SWARMCLAW_DAEMON_AUTOSTART: '0',
70
+ }
71
+ if (options.swarmclawHome) env.SWARMCLAW_HOME = homeDir
72
+ delete env.ACCESS_KEY
73
+ delete env.CREDENTIAL_SECRET
74
+ delete env.SWARMCLAW_BUILD_MODE
75
+ if (options.externalCredentialSecret !== undefined) {
76
+ env.CREDENTIAL_SECRET = options.externalCredentialSecret
77
+ }
78
+ const script = `
79
+ import fs from 'node:fs'
80
+ import path from 'node:path'
81
+ import { pathToFileURL } from 'node:url'
82
+ process.chdir(${JSON.stringify(cwd)})
83
+ await import(pathToFileURL(${JSON.stringify(path.join(repoRoot, 'src/lib/server/storage-auth.ts'))}).href)
84
+ const secretPath = path.join(process.env.DATA_DIR, 'credential-secret')
85
+ const fileSecret = fs.existsSync(secretPath) ? fs.readFileSync(secretPath, 'utf8').trim() : ''
86
+ const mode = fs.existsSync(secretPath) ? (fs.statSync(secretPath).mode & 0o777).toString(8) : ''
87
+ console.log(JSON.stringify({
88
+ credentialSecret: process.env.CREDENTIAL_SECRET || '',
89
+ fileSecret,
90
+ mode,
91
+ }))
92
+ `
93
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
94
+ cwd: repoRoot,
95
+ env,
96
+ encoding: 'utf8',
97
+ timeout: 15_000,
98
+ })
99
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'storage-auth subprocess failed')
100
+ const jsonLine = (result.stdout || '').trim().split('\n').reverse().find((line) => line.trim().startsWith('{'))
101
+ return JSON.parse(jsonLine || '{}') as { credentialSecret: string; fileSecret: string; mode: string }
102
+ } finally {
103
+ fs.rmSync(tmpDir, { recursive: true, force: true })
104
+ }
105
+ }
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
+ }
6
116
 
7
117
  /**
8
118
  * Tests for storage-auth helpers.
@@ -148,3 +258,97 @@ describe('Docker key persistence fallback', () => {
148
258
  assert.equal(vars.ACCESS_KEY, 'original')
149
259
  })
150
260
  })
261
+
262
+ describe('credential secret persistence precedence', () => {
263
+ it('migrates a legacy .env.local credential secret into DATA_DIR', () => {
264
+ const legacySecret = 'a'.repeat(64)
265
+ const output = runStorageAuthImport({
266
+ envLocal: `CREDENTIAL_SECRET=${legacySecret}\n`,
267
+ })
268
+
269
+ assert.equal(output.credentialSecret, legacySecret)
270
+ assert.equal(output.fileSecret, legacySecret)
271
+ assert.equal(output.mode, '600')
272
+ })
273
+
274
+ it('uses the DATA_DIR credential secret before legacy env files', () => {
275
+ const fileSecret = 'b'.repeat(64)
276
+ const legacySecret = 'a'.repeat(64)
277
+ const output = runStorageAuthImport({
278
+ credentialSecretFile: fileSecret,
279
+ envLocal: `CREDENTIAL_SECRET=${legacySecret}\n`,
280
+ })
281
+
282
+ assert.equal(output.credentialSecret, fileSecret)
283
+ assert.equal(output.fileSecret, fileSecret)
284
+ })
285
+
286
+ it('lets an explicitly supplied environment credential secret override the file', () => {
287
+ const externalSecret = 'c'.repeat(64)
288
+ const fileSecret = 'b'.repeat(64)
289
+ const output = runStorageAuthImport({
290
+ externalCredentialSecret: externalSecret,
291
+ credentialSecretFile: fileSecret,
292
+ })
293
+
294
+ assert.equal(output.credentialSecret, externalSecret)
295
+ assert.equal(output.fileSecret, fileSecret)
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
+ })
354
+ })