@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.
- package/README.md +38 -0
- package/package.json +2 -2
- package/src/app/api/agents/agents-route.test.ts +36 -0
- package/src/app/api/extensions/builtins/route.ts +2 -1
- package/src/app/api/tasks/[id]/retry/route.ts +12 -0
- package/src/app/api/tasks/task-workspace-route.test.ts +62 -0
- package/src/cli/index.js +1 -0
- package/src/cli/index.test.js +30 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-sheet.tsx +41 -2
- package/src/components/chat/chat-tool-toggles.tsx +29 -7
- package/src/lib/providers/openclaw.test.ts +8 -1
- package/src/lib/providers/openclaw.ts +4 -2
- package/src/lib/server/chat-execution/chat-execution-connector-delivery.ts +17 -1
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +2 -2
- package/src/lib/server/chat-execution/post-stream-finalization.test.ts +24 -0
- package/src/lib/server/connectors/connector-inbound.ts +6 -6
- package/src/lib/server/connectors/connector-lifecycle.ts +17 -1
- package/src/lib/server/connectors/openclaw.test.ts +9 -2
- package/src/lib/server/connectors/openclaw.ts +1 -1
- package/src/lib/server/session-tools/credential-env.ts +4 -3
- package/src/lib/server/session-tools/crud.ts +5 -0
- package/src/lib/server/session-tools/discovery-approvals.test.ts +49 -0
- package/src/lib/server/session-tools/execute.test.ts +29 -0
- package/src/lib/server/session-tools/manage-tasks.test.ts +55 -0
- package/src/lib/server/storage-auth.test.ts +204 -0
- package/src/lib/server/storage-auth.ts +309 -16
- package/src/lib/server/tasks/task-route-service.ts +46 -0
- package/src/lib/server/tasks/task-service.test.ts +50 -0
- package/src/lib/server/tasks/task-service.ts +16 -3
- package/src/lib/server/universal-tool-access.test.ts +16 -0
- package/src/lib/server/universal-tool-access.ts +3 -1
- package/src/lib/validation/schemas.ts +12 -0
- 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 {
|
|
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:
|
|
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:
|
|
372
|
+
protocol: 4,
|
|
366
373
|
policy: { tickIntervalMs: 200 },
|
|
367
374
|
})
|
|
368
375
|
|
|
@@ -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 & {
|
|
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
|
-
//
|
|
49
|
-
|
|
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
|
+
})
|