@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import crypto from 'crypto'
|
|
4
|
+
import Database from 'better-sqlite3'
|
|
4
5
|
|
|
5
6
|
import { DATA_DIR, IS_BUILD_BOOTSTRAP } from './data-dir'
|
|
6
7
|
import { log } from '@/lib/server/logger'
|
|
@@ -11,25 +12,217 @@ const TAG = 'storage-auth'
|
|
|
11
12
|
// because DATA_DIR is volume-mounted, unlike process.cwd()/.env.local.
|
|
12
13
|
const GENERATED_ENV_PATH = path.join(DATA_DIR, '.env.generated')
|
|
13
14
|
|
|
15
|
+
// Dedicated single-purpose file for the credential-encryption secret. Lives in
|
|
16
|
+
// DATA_DIR so it survives both Docker volume mounts AND npm-global upgrades
|
|
17
|
+
// (the latter changes process.cwd() per version, which made .env.local-only
|
|
18
|
+
// persistence regenerate the secret every upgrade and orphan every credential
|
|
19
|
+
// encrypted under the old value).
|
|
20
|
+
const CREDENTIAL_SECRET_FILE = path.join(DATA_DIR, 'credential-secret')
|
|
21
|
+
|
|
14
22
|
// --- .env loading ---
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
type LoadedEnvFile = Record<string, string>
|
|
24
|
+
type CredentialSecretCandidate = {
|
|
25
|
+
secret: string
|
|
26
|
+
source: string
|
|
27
|
+
mtimeMs: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadEnvFile(filePath: string): LoadedEnvFile {
|
|
31
|
+
const loaded: LoadedEnvFile = {}
|
|
32
|
+
if (!fs.existsSync(filePath)) return loaded
|
|
17
33
|
fs.readFileSync(filePath, 'utf8').split(/\r?\n/).forEach(line => {
|
|
18
34
|
const [k, ...v] = line.split('=')
|
|
19
|
-
if (k && v.length)
|
|
35
|
+
if (k && v.length) loaded[k.trim()] = v.join('=').trim()
|
|
20
36
|
})
|
|
37
|
+
return loaded
|
|
21
38
|
}
|
|
22
39
|
|
|
23
|
-
function
|
|
24
|
-
|
|
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'))
|
|
40
|
+
function cleanSecret(value: unknown): string {
|
|
41
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
28
42
|
}
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
|
|
44
|
+
function applyLoadedEnv(loaded: LoadedEnvFile, externalKeys: Set<string>, options?: { overwriteLoaded?: boolean }) {
|
|
45
|
+
for (const [key, value] of Object.entries(loaded)) {
|
|
46
|
+
if (externalKeys.has(key)) continue
|
|
47
|
+
if (options?.overwriteLoaded || process.env[key] === undefined || process.env[key] === '') {
|
|
48
|
+
process.env[key] = value
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function loadEnv(): { generated: LoadedEnvFile; local: LoadedEnvFile } {
|
|
54
|
+
const externalKeys = new Set(
|
|
55
|
+
Object.entries(process.env)
|
|
56
|
+
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
|
57
|
+
.map(([key]) => key),
|
|
58
|
+
)
|
|
59
|
+
const generated = loadEnvFile(GENERATED_ENV_PATH)
|
|
60
|
+
const local = loadEnvFile(path.join(process.cwd(), '.env.local'))
|
|
61
|
+
|
|
62
|
+
applyLoadedEnv(generated, externalKeys)
|
|
63
|
+
applyLoadedEnv(local, externalKeys, { overwriteLoaded: true })
|
|
64
|
+
return { generated, local }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function appendCandidate(candidates: CredentialSecretCandidate[], seen: Set<string>, candidate: CredentialSecretCandidate): void {
|
|
68
|
+
const secret = cleanSecret(candidate.secret)
|
|
69
|
+
if (!secret || seen.has(secret)) return
|
|
70
|
+
seen.add(secret)
|
|
71
|
+
candidates.push({ ...candidate, secret })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readEnvCandidate(filePath: string, source: string): CredentialSecretCandidate | null {
|
|
75
|
+
try {
|
|
76
|
+
if (!fs.existsSync(filePath)) return null
|
|
77
|
+
const secret = cleanSecret(loadEnvFile(filePath).CREDENTIAL_SECRET)
|
|
78
|
+
if (!secret) return null
|
|
79
|
+
return {
|
|
80
|
+
secret,
|
|
81
|
+
source,
|
|
82
|
+
mtimeMs: fs.statSync(filePath).mtimeMs,
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
log.debug(TAG, `Could not inspect legacy CREDENTIAL_SECRET candidate at ${filePath}`, {
|
|
86
|
+
error: err instanceof Error ? err.message : String(err),
|
|
87
|
+
})
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findStateHomeCandidates(): string[] {
|
|
93
|
+
const homes: string[] = []
|
|
94
|
+
const configuredHome = cleanSecret(process.env.SWARMCLAW_HOME)
|
|
95
|
+
if (configuredHome) homes.push(path.resolve(configuredHome))
|
|
96
|
+
if (path.basename(DATA_DIR) === 'data') homes.push(path.dirname(DATA_DIR))
|
|
97
|
+
return Array.from(new Set(homes))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collectPreviousBuildSecretCandidates(seen: Set<string>): CredentialSecretCandidate[] {
|
|
101
|
+
const candidates: CredentialSecretCandidate[] = []
|
|
102
|
+
for (const home of findStateHomeCandidates()) {
|
|
103
|
+
const buildsDir = path.join(home, 'builds')
|
|
104
|
+
let entries: fs.Dirent[]
|
|
105
|
+
try {
|
|
106
|
+
entries = fs.readdirSync(buildsDir, { withFileTypes: true })
|
|
107
|
+
} catch {
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!entry.isDirectory() || !entry.name.startsWith('package-')) continue
|
|
113
|
+
const buildRoot = path.join(buildsDir, entry.name)
|
|
114
|
+
const envPaths = [
|
|
115
|
+
path.join(buildRoot, '.env.local'),
|
|
116
|
+
path.join(buildRoot, '.env.local.bak'),
|
|
117
|
+
path.join(buildRoot, '.next', 'standalone', '.env.local'),
|
|
118
|
+
path.join(buildRoot, '.next', 'standalone', '.env.local.bak'),
|
|
119
|
+
]
|
|
120
|
+
for (const envPath of envPaths) {
|
|
121
|
+
const candidate = readEnvCandidate(envPath, `previous build env ${envPath}`)
|
|
122
|
+
if (candidate) appendCandidate(candidates, seen, candidate)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readEncryptedCredentialKeysFromObject(value: unknown): string[] {
|
|
131
|
+
if (!value || typeof value !== 'object') return []
|
|
132
|
+
return Object.values(value as Record<string, unknown>)
|
|
133
|
+
.map((entry) => {
|
|
134
|
+
if (!entry || typeof entry !== 'object') return ''
|
|
135
|
+
return cleanSecret((entry as Record<string, unknown>).encryptedKey)
|
|
136
|
+
})
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readEncryptedCredentialKeys(): string[] {
|
|
141
|
+
const keys: string[] = []
|
|
142
|
+
const jsonPath = path.join(DATA_DIR, 'credentials.json')
|
|
143
|
+
try {
|
|
144
|
+
if (fs.existsSync(jsonPath)) {
|
|
145
|
+
keys.push(...readEncryptedCredentialKeysFromObject(JSON.parse(fs.readFileSync(jsonPath, 'utf8'))))
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
log.debug(TAG, `Could not inspect encrypted credentials in ${jsonPath}`, {
|
|
149
|
+
error: err instanceof Error ? err.message : String(err),
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const dbPath = path.join(DATA_DIR, 'swarmclaw.db')
|
|
154
|
+
try {
|
|
155
|
+
if (fs.existsSync(dbPath)) {
|
|
156
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
|
|
157
|
+
try {
|
|
158
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credentials'").get()
|
|
159
|
+
if (table) {
|
|
160
|
+
const rows = db.prepare('SELECT data FROM credentials LIMIT 500').all() as Array<{ data: string }>
|
|
161
|
+
const fromDb: Record<string, unknown> = {}
|
|
162
|
+
for (const [index, row] of rows.entries()) {
|
|
163
|
+
try {
|
|
164
|
+
fromDb[`row_${index}`] = JSON.parse(row.data)
|
|
165
|
+
} catch {
|
|
166
|
+
// Ignore malformed rows; storage normalization handles them later.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
keys.push(...readEncryptedCredentialKeysFromObject(fromDb))
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
db.close()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
log.debug(TAG, `Could not inspect encrypted credentials in ${dbPath}`, {
|
|
177
|
+
error: err instanceof Error ? err.message : String(err),
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return Array.from(new Set(keys))
|
|
31
182
|
}
|
|
32
183
|
|
|
184
|
+
function canDecryptCredential(encryptedKey: string, secret: string): boolean {
|
|
185
|
+
try {
|
|
186
|
+
const parts = encryptedKey.split(':')
|
|
187
|
+
if (parts.length !== 3) return false
|
|
188
|
+
const [ivHex, tagHex, encrypted] = parts
|
|
189
|
+
const key = Buffer.from(secret, 'hex')
|
|
190
|
+
if (key.length !== 32) return false
|
|
191
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'))
|
|
192
|
+
decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
|
|
193
|
+
decipher.update(encrypted, 'hex', 'utf8')
|
|
194
|
+
decipher.final('utf8')
|
|
195
|
+
return true
|
|
196
|
+
} catch {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function countDecryptableCredentials(secret: string, encryptedKeys: string[]): number {
|
|
202
|
+
if (encryptedKeys.length === 0) return 0
|
|
203
|
+
return encryptedKeys.filter((encryptedKey) => canDecryptCredential(encryptedKey, secret)).length
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function selectCredentialSecretCandidate(
|
|
207
|
+
candidates: CredentialSecretCandidate[],
|
|
208
|
+
encryptedKeys: string[],
|
|
209
|
+
): CredentialSecretCandidate | null {
|
|
210
|
+
if (candidates.length === 0) return null
|
|
211
|
+
if (encryptedKeys.length === 0) return candidates[0]
|
|
212
|
+
|
|
213
|
+
let best: { candidate: CredentialSecretCandidate; count: number } | null = null
|
|
214
|
+
for (const candidate of candidates) {
|
|
215
|
+
const count = countDecryptableCredentials(candidate.secret, encryptedKeys)
|
|
216
|
+
if (count === 0) continue
|
|
217
|
+
if (!best || count > best.count) best = { candidate, count }
|
|
218
|
+
}
|
|
219
|
+
return best?.candidate ?? null
|
|
220
|
+
}
|
|
221
|
+
const externalCredentialSecret = process.env.CREDENTIAL_SECRET?.trim() || ''
|
|
222
|
+
const loadedEnv: { generated: LoadedEnvFile; local: LoadedEnvFile } = !IS_BUILD_BOOTSTRAP
|
|
223
|
+
? loadEnv()
|
|
224
|
+
: { generated: {}, local: {} }
|
|
225
|
+
|
|
33
226
|
/** Append a key=value to a file only if the key doesn't already exist in it. */
|
|
34
227
|
function appendEnvKeyIfMissing(envPath: string, key: string, value: string): void {
|
|
35
228
|
const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''
|
|
@@ -59,12 +252,112 @@ function persistEnvKey(key: string, value: string): void {
|
|
|
59
252
|
}
|
|
60
253
|
}
|
|
61
254
|
|
|
62
|
-
|
|
63
|
-
if
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
255
|
+
/** Read CREDENTIAL_SECRET from the dedicated file in DATA_DIR.
|
|
256
|
+
* Returns the trimmed contents, or empty string if absent / unreadable. */
|
|
257
|
+
function readCredentialSecretFile(): string {
|
|
258
|
+
try {
|
|
259
|
+
if (!fs.existsSync(CREDENTIAL_SECRET_FILE)) return ''
|
|
260
|
+
return fs.readFileSync(CREDENTIAL_SECRET_FILE, 'utf-8').trim()
|
|
261
|
+
} catch (err) {
|
|
262
|
+
log.warn(TAG, `Could not read CREDENTIAL_SECRET from ${CREDENTIAL_SECRET_FILE}`, {
|
|
263
|
+
error: err instanceof Error ? err.message : String(err),
|
|
264
|
+
})
|
|
265
|
+
return ''
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Write CREDENTIAL_SECRET to the dedicated file with restrictive permissions. */
|
|
270
|
+
function writeCredentialSecretFile(secret: string): boolean {
|
|
271
|
+
try {
|
|
272
|
+
fs.mkdirSync(path.dirname(CREDENTIAL_SECRET_FILE), { recursive: true })
|
|
273
|
+
fs.writeFileSync(CREDENTIAL_SECRET_FILE, secret, { encoding: 'utf-8', mode: 0o600 })
|
|
274
|
+
return true
|
|
275
|
+
} catch (err) {
|
|
276
|
+
log.warn(TAG, `Could not persist CREDENTIAL_SECRET to ${CREDENTIAL_SECRET_FILE}`, {
|
|
277
|
+
error: err instanceof Error ? err.message : String(err),
|
|
278
|
+
})
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Resolve CREDENTIAL_SECRET in this precedence order:
|
|
284
|
+
// 1. process.env (already set externally, e.g. by orchestrator)
|
|
285
|
+
// 2. DATA_DIR/credential-secret (the stable home — survives upgrades)
|
|
286
|
+
// 3. .env files (legacy current cwd plus prior npm-global build env files)
|
|
287
|
+
// 4. Generate new secret + persist to DATA_DIR/credential-secret
|
|
288
|
+
//
|
|
289
|
+
// Step 2 is the key change: previously the secret only lived in a per-version
|
|
290
|
+
// .env.local (cwd changes on npm-global upgrade), so each upgrade
|
|
291
|
+
// silently regenerated it and orphaned every encrypted credential. When
|
|
292
|
+
// encrypted credentials already exist, validate candidate legacy secrets by
|
|
293
|
+
// actually decrypting a stored credential before persisting the migration.
|
|
294
|
+
if (!IS_BUILD_BOOTSTRAP) {
|
|
295
|
+
const encryptedCredentialKeys = readEncryptedCredentialKeys()
|
|
296
|
+
const candidateSeen = new Set<string>()
|
|
297
|
+
const legacyCandidates: CredentialSecretCandidate[] = []
|
|
298
|
+
appendCandidate(legacyCandidates, candidateSeen, {
|
|
299
|
+
secret: cleanSecret(loadedEnv.local.CREDENTIAL_SECRET),
|
|
300
|
+
source: `${path.join(process.cwd(), '.env.local')}`,
|
|
301
|
+
mtimeMs: 0,
|
|
302
|
+
})
|
|
303
|
+
appendCandidate(legacyCandidates, candidateSeen, {
|
|
304
|
+
secret: cleanSecret(loadedEnv.generated.CREDENTIAL_SECRET),
|
|
305
|
+
source: GENERATED_ENV_PATH,
|
|
306
|
+
mtimeMs: 0,
|
|
307
|
+
})
|
|
308
|
+
legacyCandidates.push(...collectPreviousBuildSecretCandidates(candidateSeen))
|
|
309
|
+
|
|
310
|
+
const legacyEnvSecret = legacyCandidates[0]?.secret || ''
|
|
311
|
+
const fileSecret = readCredentialSecretFile()
|
|
312
|
+
if (externalCredentialSecret) {
|
|
313
|
+
process.env.CREDENTIAL_SECRET = externalCredentialSecret
|
|
314
|
+
if (fileSecret && fileSecret !== externalCredentialSecret) {
|
|
315
|
+
log.warn(TAG, `CREDENTIAL_SECRET is set by the environment and differs from ${CREDENTIAL_SECRET_FILE}; using the environment value.`)
|
|
316
|
+
}
|
|
317
|
+
} else if (fileSecret) {
|
|
318
|
+
const fileDecryptsCredentials = encryptedCredentialKeys.length === 0
|
|
319
|
+
|| countDecryptableCredentials(fileSecret, encryptedCredentialKeys) > 0
|
|
320
|
+
if (!fileDecryptsCredentials) {
|
|
321
|
+
const recovered = selectCredentialSecretCandidate(
|
|
322
|
+
legacyCandidates.filter((candidate) => candidate.secret !== fileSecret),
|
|
323
|
+
encryptedCredentialKeys,
|
|
324
|
+
)
|
|
325
|
+
if (recovered) {
|
|
326
|
+
process.env.CREDENTIAL_SECRET = recovered.secret
|
|
327
|
+
writeCredentialSecretFile(recovered.secret)
|
|
328
|
+
log.warn(TAG, `Recovered CREDENTIAL_SECRET from ${recovered.source} because ${CREDENTIAL_SECRET_FILE} could not decrypt existing credentials.`)
|
|
329
|
+
} else {
|
|
330
|
+
process.env.CREDENTIAL_SECRET = fileSecret
|
|
331
|
+
log.warn(TAG, `${CREDENTIAL_SECRET_FILE} could not decrypt existing credentials, and no recoverable previous-build CREDENTIAL_SECRET was found.`)
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
process.env.CREDENTIAL_SECRET = fileSecret
|
|
335
|
+
if (legacyEnvSecret && legacyEnvSecret !== fileSecret) {
|
|
336
|
+
// Both persisted locations exist and disagree. Trust DATA_DIR because it
|
|
337
|
+
// survives npm-global upgrades and Docker restarts.
|
|
338
|
+
log.warn(TAG, `CREDENTIAL_SECRET mismatch between legacy env files and ${CREDENTIAL_SECRET_FILE}; using the file value.`)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
const recovered = selectCredentialSecretCandidate(legacyCandidates, encryptedCredentialKeys)
|
|
343
|
+
if (recovered) {
|
|
344
|
+
process.env.CREDENTIAL_SECRET = recovered.secret
|
|
345
|
+
if (writeCredentialSecretFile(recovered.secret)) {
|
|
346
|
+
log.info(TAG, `Migrated CREDENTIAL_SECRET from ${recovered.source} to ${CREDENTIAL_SECRET_FILE}`)
|
|
347
|
+
}
|
|
348
|
+
} else if (legacyEnvSecret) {
|
|
349
|
+
process.env.CREDENTIAL_SECRET = legacyEnvSecret
|
|
350
|
+
if (writeCredentialSecretFile(legacyEnvSecret)) {
|
|
351
|
+
log.info(TAG, `Migrated CREDENTIAL_SECRET from .env to ${CREDENTIAL_SECRET_FILE}`)
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
// First-ever launch on this DATA_DIR. Generate.
|
|
355
|
+
const secret = crypto.randomBytes(32).toString('hex')
|
|
356
|
+
process.env.CREDENTIAL_SECRET = secret
|
|
357
|
+
writeCredentialSecretFile(secret)
|
|
358
|
+
log.info(TAG, `Generated CREDENTIAL_SECRET and persisted to ${CREDENTIAL_SECRET_FILE}`)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
68
361
|
}
|
|
69
362
|
|
|
70
363
|
// Auto-generate ACCESS_KEY if missing (used for simple auth)
|
|
@@ -290,6 +290,52 @@ export function updateTaskFromRoute(id: string, body: Record<string, unknown>):
|
|
|
290
290
|
return serviceOk(tasks[id])
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
export function retryTaskFromRoute(id: string): ServiceResult<BoardTask> {
|
|
294
|
+
const tasks = loadTasks()
|
|
295
|
+
const task = tasks[id]
|
|
296
|
+
if (!task) return serviceFail(404, 'Task not found')
|
|
297
|
+
if (task.status !== 'failed') {
|
|
298
|
+
return serviceFail(409, 'Only failed tasks can be retried.')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const blockers = Array.isArray(task.blockedBy) ? task.blockedBy : []
|
|
302
|
+
const incompleteBlocker = blockers.find((bid: string) => tasks[bid] && tasks[bid].status !== 'completed')
|
|
303
|
+
if (incompleteBlocker) {
|
|
304
|
+
return serviceFail(409, 'Cannot retry: blocked by incomplete tasks')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const now = Date.now()
|
|
308
|
+
if (!task.comments) task.comments = []
|
|
309
|
+
task.comments.push({
|
|
310
|
+
id: genId(),
|
|
311
|
+
author: 'System',
|
|
312
|
+
text: 'Task retry requested by operator.',
|
|
313
|
+
createdAt: now,
|
|
314
|
+
})
|
|
315
|
+
task.status = 'queued'
|
|
316
|
+
task.attempts = 0
|
|
317
|
+
task.deadLetteredAt = null
|
|
318
|
+
task.retryScheduledAt = null
|
|
319
|
+
task.checkoutRunId = null
|
|
320
|
+
task.error = null
|
|
321
|
+
task.validation = null
|
|
322
|
+
task.startedAt = null
|
|
323
|
+
task.completedAt = null
|
|
324
|
+
task.queuedAt = now
|
|
325
|
+
task.updatedAt = now
|
|
326
|
+
task.liveness = computeTaskLiveness(task, tasks, { now })
|
|
327
|
+
|
|
328
|
+
saveTask(id, task)
|
|
329
|
+
enqueueTask(id)
|
|
330
|
+
logActivity({ entityType: 'task', entityId: id, action: 'queued', actor: 'user', summary: `Task retried: "${task.title}"` })
|
|
331
|
+
pushMainLoopEventToMainSessions({
|
|
332
|
+
type: 'task_queued',
|
|
333
|
+
text: `Task retried and queued: "${task.title}" (${id}).`,
|
|
334
|
+
})
|
|
335
|
+
notify('tasks')
|
|
336
|
+
return serviceOk(loadTask(id) || task)
|
|
337
|
+
}
|
|
338
|
+
|
|
293
339
|
export function decideTaskExecutionPolicyFromRoute(
|
|
294
340
|
id: string,
|
|
295
341
|
body: Record<string, unknown>,
|
|
@@ -154,6 +154,56 @@ describe('task-service assignment workflow transitions', () => {
|
|
|
154
154
|
}
|
|
155
155
|
})
|
|
156
156
|
|
|
157
|
+
it('auto-queues agent-created tasks delegated to a different agent', () => {
|
|
158
|
+
const prepared = prepareTaskCreation({
|
|
159
|
+
input: {
|
|
160
|
+
title: 'Build delegated client',
|
|
161
|
+
description: 'Hand this work to the builder.',
|
|
162
|
+
agentId: 'builder',
|
|
163
|
+
},
|
|
164
|
+
tasks: {},
|
|
165
|
+
now: 210,
|
|
166
|
+
creatorAgentId: 'coordinator',
|
|
167
|
+
autoQueueDelegatedTasks: true,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
assert.equal(prepared.ok, true)
|
|
171
|
+
if (!prepared.ok) return
|
|
172
|
+
assert.equal(prepared.task.status, 'queued')
|
|
173
|
+
assert.equal(prepared.task.agentId, 'builder')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does not auto-queue self-assigned or explicitly backlogged tasks', () => {
|
|
177
|
+
const selfAssigned = prepareTaskCreation({
|
|
178
|
+
input: {
|
|
179
|
+
title: 'Self task',
|
|
180
|
+
description: '',
|
|
181
|
+
agentId: 'coordinator',
|
|
182
|
+
},
|
|
183
|
+
tasks: {},
|
|
184
|
+
now: 220,
|
|
185
|
+
creatorAgentId: 'coordinator',
|
|
186
|
+
autoQueueDelegatedTasks: true,
|
|
187
|
+
})
|
|
188
|
+
assert.equal(selfAssigned.ok, true)
|
|
189
|
+
if (selfAssigned.ok) assert.equal(selfAssigned.task.status, 'backlog')
|
|
190
|
+
|
|
191
|
+
const explicitBacklog = prepareTaskCreation({
|
|
192
|
+
input: {
|
|
193
|
+
title: 'Explicit backlog',
|
|
194
|
+
description: '',
|
|
195
|
+
agentId: 'builder',
|
|
196
|
+
status: 'backlog',
|
|
197
|
+
},
|
|
198
|
+
tasks: {},
|
|
199
|
+
now: 230,
|
|
200
|
+
creatorAgentId: 'coordinator',
|
|
201
|
+
autoQueueDelegatedTasks: true,
|
|
202
|
+
})
|
|
203
|
+
assert.equal(explicitBacklog.ok, true)
|
|
204
|
+
if (explicitBacklog.ok) assert.equal(explicitBacklog.task.status, 'backlog')
|
|
205
|
+
})
|
|
206
|
+
|
|
157
207
|
it('leaves already-started workflow states alone', () => {
|
|
158
208
|
const next = resolveAssignmentWorkflowStateTransition({
|
|
159
209
|
previousAgentId: '',
|
|
@@ -171,6 +171,8 @@ export interface PrepareTaskCreationOptions {
|
|
|
171
171
|
now: number
|
|
172
172
|
settings?: AppSettings | Record<string, unknown> | null
|
|
173
173
|
fallbackAgentId?: string | null
|
|
174
|
+
creatorAgentId?: string | null
|
|
175
|
+
autoQueueDelegatedTasks?: boolean
|
|
174
176
|
defaultCwd?: string | null
|
|
175
177
|
deriveTitleFromDescription?: boolean
|
|
176
178
|
requireMeaningfulTitle?: boolean
|
|
@@ -194,11 +196,22 @@ export function prepareTaskCreation(options: PrepareTaskCreationOptions): Prepar
|
|
|
194
196
|
return { ok: false, error: 'Error: manage_tasks create requires a specific title or a meaningful description.' }
|
|
195
197
|
}
|
|
196
198
|
|
|
197
|
-
const normalizedStatus = normalizeTaskStatusInput(options.input.status) || 'backlog'
|
|
198
199
|
const description = typeof options.input.description === 'string' ? options.input.description : ''
|
|
199
200
|
const agentId = typeof options.input.agentId === 'string'
|
|
200
|
-
? options.input.agentId
|
|
201
|
-
: (typeof options.fallbackAgentId === 'string' ? options.fallbackAgentId : '')
|
|
201
|
+
? options.input.agentId.trim()
|
|
202
|
+
: (typeof options.fallbackAgentId === 'string' ? options.fallbackAgentId.trim() : '')
|
|
203
|
+
const explicitStatus = Object.prototype.hasOwnProperty.call(options.input, 'status')
|
|
204
|
+
let normalizedStatus = normalizeTaskStatusInput(options.input.status) || 'backlog'
|
|
205
|
+
const creatorAgentId = typeof options.creatorAgentId === 'string' ? options.creatorAgentId.trim() : ''
|
|
206
|
+
if (
|
|
207
|
+
!explicitStatus
|
|
208
|
+
&& options.autoQueueDelegatedTasks === true
|
|
209
|
+
&& creatorAgentId
|
|
210
|
+
&& agentId
|
|
211
|
+
&& agentId !== creatorAgentId
|
|
212
|
+
) {
|
|
213
|
+
normalizedStatus = 'queued'
|
|
214
|
+
}
|
|
202
215
|
const qualityGate = Object.prototype.hasOwnProperty.call(options.input, 'qualityGate')
|
|
203
216
|
? (options.input.qualityGate
|
|
204
217
|
? normalizeTaskQualityGate(options.input.qualityGate, options.settings || null)
|
|
@@ -24,6 +24,16 @@ function scoped(declared: string[] | null | undefined, universe: Set<string> = U
|
|
|
24
24
|
return Array.from(new Set([...SCOPED_TOOL_BASELINE, ...picks]))
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function scopedWithAttachedExtensions(
|
|
28
|
+
declared: string[] | null | undefined,
|
|
29
|
+
extraExtensions: string[] | null | undefined,
|
|
30
|
+
universe: Set<string> = UNIVERSAL_SAMPLE,
|
|
31
|
+
): string[] {
|
|
32
|
+
const picks = normalize(declared).filter((t) => universe.has(t))
|
|
33
|
+
const attachedExternalExtensions = normalize(extraExtensions).filter((entry) => /\.(?:m?js)$/i.test(entry))
|
|
34
|
+
return Array.from(new Set([...SCOPED_TOOL_BASELINE, ...picks, ...attachedExternalExtensions]))
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
describe('scoped tool access algorithm', () => {
|
|
28
38
|
it('intersects declared tools with the universe and keeps the baseline', () => {
|
|
29
39
|
const out = scoped(['shell', 'files', 'edit_file', 'web'])
|
|
@@ -68,4 +78,10 @@ describe('scoped tool access algorithm', () => {
|
|
|
68
78
|
assert.ok(out.includes('shell'))
|
|
69
79
|
assert.ok(out.includes('files'))
|
|
70
80
|
})
|
|
81
|
+
|
|
82
|
+
it('keeps explicitly attached external extensions in scoped mode', () => {
|
|
83
|
+
const out = scopedWithAttachedExtensions(['shell'], ['freedzhost-critic.js'])
|
|
84
|
+
assert.ok(out.includes('shell'))
|
|
85
|
+
assert.ok(out.includes('freedzhost-critic.js'))
|
|
86
|
+
})
|
|
71
87
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dedup } from '@/lib/shared-utils'
|
|
2
|
+
import { isExternalExtensionId } from '@/lib/capability-selection'
|
|
2
3
|
import { getExtensionManager } from './extensions'
|
|
3
4
|
|
|
4
5
|
const UNIVERSAL_CORE_EXTENSION_IDS = [
|
|
@@ -78,5 +79,6 @@ export function listScopedToolAccessExtensionIds(
|
|
|
78
79
|
const universe = new Set(listUniversalToolAccessExtensionIds(extraExtensions))
|
|
79
80
|
const declared = normalizeExtensionList(declaredTools)
|
|
80
81
|
const scoped = declared.filter((tool) => universe.has(tool))
|
|
81
|
-
|
|
82
|
+
const explicitlyAttachedExternalExtensions = normalizeExtensionList(extraExtensions).filter(isExternalExtensionId)
|
|
83
|
+
return dedup([...SCOPED_TOOL_BASELINE, ...scoped, ...explicitlyAttachedExternalExtensions])
|
|
82
84
|
}
|
|
@@ -140,6 +140,18 @@ export const AgentCreateSchema = z.object({
|
|
|
140
140
|
projectId: z.string().optional(),
|
|
141
141
|
avatarSeed: z.string().optional(),
|
|
142
142
|
avatarUrl: z.string().nullable().optional().default(null),
|
|
143
|
+
/** Per-agent working directory. Used as the cwd for execute tool calls and
|
|
144
|
+
* as the root for workspace-scoped file operations. */
|
|
145
|
+
workspace: z.string().nullable().optional().default(null),
|
|
146
|
+
/** When 'workspace', the structured file tool's reads/writes are confined to
|
|
147
|
+
* the agent's workspace directory; 'machine' allows the whole host. */
|
|
148
|
+
filesystemScope: z.enum(['workspace', 'machine']).nullable().optional().default(null),
|
|
149
|
+
/** Per-agent filesystem allow/block lists enforced by the file tool. Globs
|
|
150
|
+
* are matched against fully-resolved absolute paths. */
|
|
151
|
+
fileAccessPolicy: z.object({
|
|
152
|
+
allowedPaths: z.array(z.string()).optional(),
|
|
153
|
+
blockedPaths: z.array(z.string()).optional(),
|
|
154
|
+
}).nullable().optional().default(null),
|
|
143
155
|
sandboxConfig: AgentSandboxConfigSchema,
|
|
144
156
|
executeConfig: AgentExecuteConfigSchema,
|
|
145
157
|
autoRecovery: z.boolean().optional().default(false),
|
package/src/types/agent.ts
CHANGED
|
@@ -142,6 +142,7 @@ export interface Agent {
|
|
|
142
142
|
*/
|
|
143
143
|
planningMode?: 'off' | 'strict' | null
|
|
144
144
|
/** Controls whether file operations are confined to the workspace or allowed anywhere on the host. Default: 'workspace'. */
|
|
145
|
+
workspace?: string | null
|
|
145
146
|
filesystemScope?: 'workspace' | 'machine' | null
|
|
146
147
|
/** Per-agent filesystem restrictions. Globs matched against resolved paths. */
|
|
147
148
|
fileAccessPolicy?: {
|