@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
@@ -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
- function loadEnvFile(filePath: string): void {
16
- if (!fs.existsSync(filePath)) return
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) process.env[k.trim()] = v.join('=').trim()
35
+ if (k && v.length) loaded[k.trim()] = v.join('=').trim()
20
36
  })
37
+ return loaded
21
38
  }
22
39
 
23
- function loadEnv() {
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'))
40
+ function cleanSecret(value: unknown): string {
41
+ return typeof value === 'string' ? value.trim() : ''
28
42
  }
29
- if (!IS_BUILD_BOOTSTRAP) {
30
- loadEnv()
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
- // Auto-generate CREDENTIAL_SECRET if missing
63
- if (!IS_BUILD_BOOTSTRAP && !process.env.CREDENTIAL_SECRET) {
64
- const secret = crypto.randomBytes(32).toString('hex')
65
- process.env.CREDENTIAL_SECRET = secret
66
- persistEnvKey('CREDENTIAL_SECRET', secret)
67
- log.info(TAG, 'Generated CREDENTIAL_SECRET')
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
- return dedup([...SCOPED_TOOL_BASELINE, ...scoped])
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),
@@ -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?: {