@swarmclawai/swarmclaw 1.9.32 → 1.9.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -151,6 +151,16 @@ openclaw skills install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/waydelyle/swarmclaw)
153
153
 
154
+ ## v1.9.33 Highlights
155
+
156
+ Issue and PR validation release for credential durability, delegated task dispatch, connector output hygiene, and OpenClaw gateway protocol compatibility.
157
+
158
+ - **Credential durability.** Execute-tool credential injection now reads the persisted `encryptedKey` field, and `CREDENTIAL_SECRET` now resolves in a stable order: explicit environment value, `DATA_DIR/credential-secret`, legacy env files, then generated fallback.
159
+ - **Delegated task dispatch.** Agent-created tasks delegated to another agent auto-queue when no explicit status is supplied, and failed dead-lettered tasks can be requeued through `POST /api/tasks/:id/retry`.
160
+ - **Connector output hygiene.** Connector replies now reuse the internal metadata scrubber before delivery and persistence, while successful non-connector delivery tool output is no longer overwritten as an unconfirmed send.
161
+ - **Agent and gateway compatibility.** Agent updates preserve workspace filesystem settings, and OpenClaw gateway routes now use protocol version 4.
162
+ - **Regression coverage.** Added tests for credential env injection, secret precedence, delegated queueing, failed-task retry, connector sanitization, agent workspace settings, and OpenClaw gateway protocol exports.
163
+
154
164
  ## v1.9.32 Highlights
155
165
 
156
166
  PR integration release for background model routing, reflection memory controls, and current ClawHub install guidance.
@@ -435,6 +445,16 @@ Operational docs: https://swarmclaw.ai/docs/observability
435
445
 
436
446
  ## Releases
437
447
 
448
+ ### v1.9.33 Highlights
449
+
450
+ Issue and PR validation release for credential durability, delegated task dispatch, connector output hygiene, and OpenClaw gateway protocol compatibility.
451
+
452
+ - **Credential durability.** Execute-tool credential injection now reads the persisted `encryptedKey` field, and `CREDENTIAL_SECRET` now resolves in a stable order: explicit environment value, `DATA_DIR/credential-secret`, legacy env files, then generated fallback.
453
+ - **Delegated task dispatch.** Agent-created tasks delegated to another agent auto-queue when no explicit status is supplied, and failed dead-lettered tasks can be requeued through `POST /api/tasks/:id/retry`.
454
+ - **Connector output hygiene.** Connector replies now reuse the internal metadata scrubber before delivery and persistence, while successful non-connector delivery tool output is no longer overwritten as an unconfirmed send.
455
+ - **Agent and gateway compatibility.** Agent updates preserve workspace filesystem settings, and OpenClaw gateway routes now use protocol version 4.
456
+ - **Regression coverage.** Added tests for credential env injection, secret precedence, delegated queueing, failed-task retry, connector sanitization, agent workspace settings, and OpenClaw gateway protocol exports.
457
+
438
458
  ### v1.9.32 Highlights
439
459
 
440
460
  PR integration release for background model routing, reflection memory controls, and current ClawHub install guidance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.32",
3
+ "version": "1.9.33",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -154,7 +154,7 @@
154
154
  "next": "16.2.4",
155
155
  "next-themes": "^0.4.6",
156
156
  "nodemailer": "^8.0.1",
157
- "openclaw": "^2026.4.22",
157
+ "openclaw": "^2026.5.12",
158
158
  "pdf-parse": "^2.4.5",
159
159
  "qrcode": "^1.5.4",
160
160
  "radix-ui": "^1.4.3",
@@ -206,6 +206,42 @@ test('PUT /api/agents/:id updates planning mode without clobbering other fields'
206
206
  assert.equal(body.proactiveMemory, false)
207
207
  })
208
208
 
209
+ test('PUT /api/agents/:id persists workspace filesystem settings', async () => {
210
+ seedAgent('agent-workspace-settings', {
211
+ name: 'Workspace Agent',
212
+ workspace: null,
213
+ filesystemScope: null,
214
+ fileAccessPolicy: null,
215
+ })
216
+
217
+ const response = await putAgent(new Request('http://local/api/agents/agent-workspace-settings', {
218
+ method: 'PUT',
219
+ headers: { 'content-type': 'application/json' },
220
+ body: JSON.stringify({
221
+ workspace: '/tmp/swarmclaw-agent-workspace',
222
+ filesystemScope: 'workspace',
223
+ fileAccessPolicy: {
224
+ allowedPaths: ['/tmp/swarmclaw-agent-workspace'],
225
+ blockedPaths: ['/tmp/swarmclaw-agent-workspace/private'],
226
+ },
227
+ }),
228
+ }), routeParams('agent-workspace-settings'))
229
+
230
+ assert.equal(response.status, 200)
231
+ const body = await response.json()
232
+ assert.equal(body.workspace, '/tmp/swarmclaw-agent-workspace')
233
+ assert.equal(body.filesystemScope, 'workspace')
234
+ assert.deepEqual(body.fileAccessPolicy, {
235
+ allowedPaths: ['/tmp/swarmclaw-agent-workspace'],
236
+ blockedPaths: ['/tmp/swarmclaw-agent-workspace/private'],
237
+ })
238
+
239
+ const stored = loadAgents()['agent-workspace-settings']
240
+ assert.equal(stored.workspace, '/tmp/swarmclaw-agent-workspace')
241
+ assert.equal(stored.filesystemScope, 'workspace')
242
+ assert.deepEqual(stored.fileAccessPolicy, body.fileAccessPolicy)
243
+ })
244
+
209
245
  test('PUT /api/agents/:id rejects non-string name', async () => {
210
246
  seedAgent('agent-bad-name', { name: 'Good' })
211
247
 
@@ -0,0 +1,12 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { notFound } from '@/lib/server/collection-helpers'
3
+ import { retryTaskFromRoute } from '@/lib/server/tasks/task-route-service'
4
+
5
+ export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
6
+ const { id } = await params
7
+ const result = retryTaskFromRoute(id)
8
+ if (!result.ok && result.status === 404) return notFound()
9
+ return result.ok
10
+ ? NextResponse.json(result.payload)
11
+ : NextResponse.json(result.payload, { status: result.status })
12
+ }
@@ -19,6 +19,7 @@ let getTaskHandoff: typeof import('./[id]/handoff/route')['GET']
19
19
  let postTaskHandoff: typeof import('./[id]/handoff/route')['POST']
20
20
  let getTaskExecutionPolicy: typeof import('./[id]/execution-policy/route')['GET']
21
21
  let postTaskExecutionPolicy: typeof import('./[id]/execution-policy/route')['POST']
22
+ let postTaskRetry: typeof import('./[id]/retry/route')['POST']
22
23
  let getTaskHandoffs: typeof import('./handoffs/route')['GET']
23
24
  let getTasks: typeof import('./route')['GET']
24
25
  let storage: typeof import('@/lib/server/storage')
@@ -57,6 +58,7 @@ before(async () => {
57
58
  const policyRoute = await import('./[id]/execution-policy/route')
58
59
  getTaskExecutionPolicy = policyRoute.GET
59
60
  postTaskExecutionPolicy = policyRoute.POST
61
+ postTaskRetry = (await import('./[id]/retry/route')).POST
60
62
  getTaskHandoffs = (await import('./handoffs/route')).GET
61
63
  getTasks = (await import('./route')).GET
62
64
  })
@@ -255,6 +257,66 @@ test('GET /api/tasks/:id/execution-policy returns policy summary', async () => {
255
257
  assert.equal(body.summary.status, 'waiting')
256
258
  })
257
259
 
260
+ test('POST /api/tasks/:id/retry requeues a dead-lettered failed task', async () => {
261
+ seedTask('task-dead-letter-retry', {
262
+ title: 'Dead Letter Retry',
263
+ status: 'failed',
264
+ attempts: 3,
265
+ maxAttempts: 3,
266
+ retryScheduledAt: Date.now() + 60_000,
267
+ deadLetteredAt: Date.now(),
268
+ checkoutRunId: 'run-failed',
269
+ error: 'Dead-lettered after 3/3 attempts: timeout',
270
+ validation: { ok: false, reasons: ['No result'], checkedAt: Date.now() },
271
+ })
272
+
273
+ const response = await postTaskRetry(
274
+ new Request('http://local/api/tasks/task-dead-letter-retry/retry', { method: 'POST' }),
275
+ routeParams('task-dead-letter-retry'),
276
+ )
277
+
278
+ assert.equal(response.status, 200)
279
+ const body = await response.json() as BoardTask
280
+ assert.equal(body.status, 'queued')
281
+ assert.equal(body.attempts, 0)
282
+ assert.equal(body.retryScheduledAt, null)
283
+ assert.equal(body.deadLetteredAt, null)
284
+ assert.equal(body.checkoutRunId, null)
285
+ assert.equal(body.error, null)
286
+ assert.equal(body.validation, null)
287
+ assert.equal(storage.loadQueue().includes('task-dead-letter-retry'), true)
288
+ assert.equal(body.comments?.some((comment) => comment.text.includes('retry requested')), true)
289
+ })
290
+
291
+ test('POST /api/tasks/:id/retry rejects tasks still blocked by dependencies', async () => {
292
+ seedTask('task-blocked-retry', {
293
+ title: 'Blocked Retry',
294
+ status: 'failed',
295
+ blockedBy: ['retry-dep'],
296
+ deadLetteredAt: Date.now(),
297
+ })
298
+ const tasks = storage.loadTasks()
299
+ tasks['retry-dep'] = {
300
+ id: 'retry-dep',
301
+ title: 'Retry Dependency',
302
+ description: '',
303
+ status: 'running',
304
+ agentId: 'agent-1',
305
+ createdAt: Date.now(),
306
+ updatedAt: Date.now(),
307
+ } as BoardTask
308
+ storage.saveTasks(tasks)
309
+
310
+ const response = await postTaskRetry(
311
+ new Request('http://local/api/tasks/task-blocked-retry/retry', { method: 'POST' }),
312
+ routeParams('task-blocked-retry'),
313
+ )
314
+
315
+ assert.equal(response.status, 409)
316
+ assert.equal(storage.loadTasks()['task-blocked-retry']?.status, 'failed')
317
+ assert.equal(storage.loadQueue().includes('task-blocked-retry'), false)
318
+ })
319
+
258
320
  test('POST /api/tasks/:id/handoff saves markdown and JSON snapshots into the workspace', async () => {
259
321
  seedTask('task-handoff-save', {
260
322
  title: 'Saved Handoff Task',
package/src/cli/index.js CHANGED
@@ -761,6 +761,7 @@ const COMMAND_GROUPS = [
761
761
  cmd('handoffs', 'GET', '/tasks/handoffs', 'List task handoff readiness packets'),
762
762
  cmd('execution-policy', 'GET', '/tasks/:id/execution-policy', 'Get task execution policy state'),
763
763
  cmd('execution-policy-decision', 'POST', '/tasks/:id/execution-policy', 'Approve, request changes, or reset a task policy stage', { expectsJsonBody: true }),
764
+ cmd('retry', 'POST', '/tasks/:id/retry', 'Retry a failed task and requeue it'),
764
765
  cmd('create', 'POST', '/tasks', 'Create task', { expectsJsonBody: true }),
765
766
  cmd('bulk', 'POST', '/tasks/bulk', 'Bulk update tasks (status/agent/project)', { expectsJsonBody: true }),
766
767
  cmd('update', 'PUT', '/tasks/:id', 'Update task', { expectsJsonBody: true }),
@@ -225,6 +225,36 @@ test('tasks execution-policy-decision posts policy decisions', async () => {
225
225
  assert.equal(stderr.toString(), '')
226
226
  })
227
227
 
228
+ test('tasks retry posts to the failed-task retry endpoint', async () => {
229
+ const stdout = makeWritable()
230
+ const stderr = makeWritable()
231
+ const calls = []
232
+
233
+ const fetchImpl = async (url, init) => {
234
+ calls.push({ url: String(url), init })
235
+ return jsonResponse({ id: 'task-1', status: 'queued' })
236
+ }
237
+
238
+ const exitCode = await runCli(
239
+ ['tasks', 'retry', 'task-1', '--json'],
240
+ {
241
+ fetchImpl,
242
+ stdout,
243
+ stderr,
244
+ env: {},
245
+ cwd: process.cwd(),
246
+ }
247
+ )
248
+
249
+ assert.equal(exitCode, 0)
250
+ assert.equal(calls.length, 1)
251
+ assert.match(calls[0].url, /\/api\/tasks\/task-1\/retry$/)
252
+ assert.equal(calls[0].init.method, 'POST')
253
+ assert.equal(calls[0].init.body, undefined)
254
+ assert.match(stdout.toString(), /"queued"/)
255
+ assert.equal(stderr.toString(), '')
256
+ })
257
+
228
258
  test('gateways drain command posts a lifecycle control action', async () => {
229
259
  const stdout = makeWritable()
230
260
  const stderr = makeWritable()
package/src/cli/spec.js CHANGED
@@ -541,6 +541,7 @@ const COMMAND_GROUPS = {
541
541
  handoffs: { description: 'List task handoff readiness packets', method: 'GET', path: '/tasks/handoffs' },
542
542
  'execution-policy': { description: 'Get task execution policy state', method: 'GET', path: '/tasks/:id/execution-policy', params: ['id'] },
543
543
  'execution-policy-decision': { description: 'Approve, request changes, or reset a task policy stage', method: 'POST', path: '/tasks/:id/execution-policy', params: ['id'] },
544
+ retry: { description: 'Retry a failed task and requeue it', method: 'POST', path: '/tasks/:id/retry', params: ['id'] },
544
545
  create: { description: 'Create task', method: 'POST', path: '/tasks' },
545
546
  bulk: { description: 'Bulk update tasks (status/agent/project)', method: 'POST', path: '/tasks/bulk' },
546
547
  update: { description: 'Update task', method: 'PUT', path: '/tasks/:id', params: ['id'] },
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { afterEach, test } from 'node:test'
3
3
 
4
- import { buildOpenClawSessionKey, resolveGatewayAgentId } from './openclaw'
4
+ import { buildOpenClawConnectParams, buildOpenClawSessionKey, resolveGatewayAgentId } from './openclaw'
5
5
  import { loadAgents, saveAgents } from '../server/storage'
6
6
  import type { Agent } from '@/types'
7
7
 
@@ -72,3 +72,10 @@ test('buildOpenClawSessionKey honors explicit OpenClaw session keys when provide
72
72
 
73
73
  assert.equal(sessionKey, 'agent:ops:benchmark:fixed-key')
74
74
  })
75
+
76
+ test('buildOpenClawConnectParams advertises the current gateway protocol', () => {
77
+ const params = buildOpenClawConnectParams('test-token', 'test-nonce')
78
+
79
+ assert.equal(params.minProtocol, 4)
80
+ assert.equal(params.maxProtocol, 4)
81
+ })
@@ -113,6 +113,8 @@ export function getDeviceId(): string {
113
113
 
114
114
  // --- Protocol helpers ---
115
115
 
116
+ export const OPENCLAW_GATEWAY_PROTOCOL_VERSION = 4
117
+
116
118
  /**
117
119
  * Build connect params for the OpenClaw gateway protocol.
118
120
  *
@@ -132,8 +134,8 @@ export function buildOpenClawConnectParams(
132
134
  const scopes = ['operator.admin']
133
135
 
134
136
  const params: Record<string, unknown> = {
135
- minProtocol: 3,
136
- maxProtocol: 3,
137
+ minProtocol: OPENCLAW_GATEWAY_PROTOCOL_VERSION,
138
+ maxProtocol: OPENCLAW_GATEWAY_PROTOCOL_VERSION,
137
139
  auth: token ? { token } : undefined,
138
140
  client: {
139
141
  id: clientId,
@@ -1,5 +1,6 @@
1
1
  import type { MessageToolEvent } from '@/types'
2
2
  import { dedupeConsecutiveToolEvents } from '@/lib/server/chat-execution/chat-execution-tool-events'
3
+ import { stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
3
4
 
4
5
  function parseToolJsonObject(raw: string): Record<string, unknown> | null {
5
6
  const trimmed = raw.trim()
@@ -65,12 +66,23 @@ export function looksLikePositiveConnectorDeliveryText(
65
66
 
66
67
  export function reconcileConnectorDeliveryText(text: string, events: MessageToolEvent[]): string {
67
68
  const trimmed = text.trim()
68
- const connectorEvents = dedupeConsecutiveToolEvents(events).filter((event) => event.name === 'connector_message_tool')
69
+ const dedupedEvents = dedupeConsecutiveToolEvents(events)
70
+ const connectorEvents = dedupedEvents.filter((event) => event.name === 'connector_message_tool')
69
71
  if (!looksLikePositiveConnectorDeliveryText(trimmed, {
70
72
  requireConnectorContext: connectorEvents.length === 0,
71
73
  })) return text
72
74
  if (connectorEvents.some((event) => connectorToolEventSucceeded(event))) return text
73
75
  if (connectorEvents.length === 0) {
76
+ // No connector_message_tool event was recorded for this turn, but the
77
+ // agent may have legitimately delivered the message through another tool
78
+ // — typically `execute` running nodemailer / smtp / curl against an
79
+ // outbound HTTP endpoint, or a future connector that doesn't route
80
+ // through connector_message_tool. If *any* tool ran this turn, the agent
81
+ // did real work; trust them rather than overwriting their response with
82
+ // a false-negative. Only reconcile (with the overwrite below) when there
83
+ // is genuine evidence of a failed send — i.e., a connector_message_tool
84
+ // event exists but didn't succeed.
85
+ if (dedupedEvents.length > 0) return text
74
86
  return `I couldn't confirm that the configured connector actually sent anything. No connector delivery tool call was recorded for this response.`
75
87
  }
76
88
 
@@ -84,3 +96,7 @@ export function reconcileConnectorDeliveryText(text: string, events: MessageTool
84
96
 
85
97
  return `I couldn't send that through the configured connector. ${failureSummary}`.trim()
86
98
  }
99
+
100
+ export function sanitizeConnectorDeliveryText(text: string, events: MessageToolEvent[]): string {
101
+ return reconcileConnectorDeliveryText(stripAllInternalMetadata(text), events).trim()
102
+ }
@@ -12,7 +12,7 @@ import { stripMainLoopMetaForPersistence } from '@/lib/server/agents/main-agent-
12
12
  import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
13
13
  import { pruneStreamingAssistantArtifacts } from '@/lib/chat/chat-streaming-state'
14
14
  import { pruneIncompleteToolEvents } from '@/lib/server/chat-execution/chat-streaming-utils'
15
- import { reconcileConnectorDeliveryText } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
15
+ import { sanitizeConnectorDeliveryText } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
16
16
  import {
17
17
  classifyHeartbeatResponse,
18
18
  estimateConversationTone,
@@ -347,7 +347,7 @@ export async function finalizeChatTurn(params: {
347
347
  // Outbound transforms are non-critical.
348
348
  }
349
349
  }
350
- finalText = reconcileConnectorDeliveryText(finalText, persistedToolEvents)
350
+ finalText = sanitizeConnectorDeliveryText(finalText, persistedToolEvents)
351
351
  finalText = normalizeAssistantArtifactLinks(finalText, session.cwd)
352
352
  finalText = applyExactOutputContract({
353
353
  contract: await resolveExactOutputContractWithTimeout({
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
2
2
  import { describe, it } from 'node:test'
3
3
 
4
4
  import { stripLeakedClassificationJson } from './post-stream-finalization'
5
+ import { sanitizeConnectorDeliveryText } from './chat-execution-connector-delivery'
5
6
 
6
7
  // A fully-valid MessageClassification serialized by the model. Mirrors the
7
8
  // real output we observed during a live delegation turn.
@@ -105,3 +106,26 @@ describe('stripLeakedClassificationJson', () => {
105
106
  assert.equal(cleaned, input)
106
107
  })
107
108
  })
109
+
110
+ describe('sanitizeConnectorDeliveryText', () => {
111
+ it('strips internal metadata before connector delivery reconciliation', () => {
112
+ const input = [
113
+ '{ "isDeliverableTask": true, "confidence": 0.9 }',
114
+ 'I sent the message via the endpoint. Message ID: abc123.',
115
+ ].join('\n')
116
+ const result = sanitizeConnectorDeliveryText(input, [
117
+ {
118
+ name: 'execute',
119
+ input: '{"code":"curl -X POST https://example.invalid/send"}',
120
+ output: 'ok',
121
+ },
122
+ ])
123
+
124
+ assert.equal(result, 'I sent the message via the endpoint. Message ID: abc123.')
125
+ })
126
+
127
+ it('preserves benign user JSON in non-delivery text', () => {
128
+ const input = 'The payload example is { "port": 3000 }.'
129
+ assert.equal(sanitizeConnectorDeliveryText(input, []), input)
130
+ })
131
+ })
@@ -92,7 +92,8 @@ import {
92
92
  resolveSenderPreferencePolicy,
93
93
  } from './contact-preferences'
94
94
  import { prepareConnectorVoiceNotePayload } from './voice-note'
95
- import { reconcileConnectorDeliveryText } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
95
+ import { sanitizeConnectorDeliveryText } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
96
+ import { stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
96
97
  import { pruneIncompleteToolEvents, updateStreamedToolEvents } from '@/lib/server/chat-execution/chat-streaming-utils'
97
98
  import { guardUntrustedText, getUntrustedContentGuardMode } from '@/lib/server/untrusted-content'
98
99
  import {
@@ -432,7 +433,7 @@ export async function deliverQueuedConnectorRunResult(params: {
432
433
  session,
433
434
  toolEvents: params.result.toolEvents || [],
434
435
  })
435
- fullText = reconcileConnectorDeliveryText(fullText, params.result.toolEvents || []).trim()
436
+ fullText = sanitizeConnectorDeliveryText(stripHiddenControlTokens(fullText), params.result.toolEvents || [])
436
437
 
437
438
  if (!fullText && !currentChannelDelivery) {
438
439
  await maybeSendStatusReaction(params.connector, params.msg, 'silent')
@@ -729,7 +730,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
729
730
  history,
730
731
  })
731
732
 
732
- const responseText = stripHiddenControlTokens(result.finalResponse || result.fullText)
733
+ const responseText = stripAllInternalMetadata(stripHiddenControlTokens(result.finalResponse || result.fullText))
733
734
  if (responseText.trim() && !isNoMessage(responseText)) {
734
735
  // Persist agent response to chatroom
735
736
  const agentSource: MessageSource = {
@@ -1332,12 +1333,11 @@ If media sending fails, report the exact error and retry with a corrected path/t
1332
1333
  }
1333
1334
 
1334
1335
  const suppressHiddenResponse = shouldSuppressHiddenControlText(fullText)
1335
- fullText = stripHiddenControlTokens(fullText)
1336
- fullText = reconcileConnectorDeliveryText(fullText, settledConnectorToolEvents).trim()
1336
+ fullText = sanitizeConnectorDeliveryText(stripHiddenControlTokens(fullText), settledConnectorToolEvents)
1337
1337
 
1338
1338
  // If the agent chose NO_MESSAGE, skip saving it to history — the user's message
1339
1339
  // is already recorded, and saving the sentinel would pollute the LLM's context
1340
- if (suppressHiddenResponse || isNoMessage(fullText)) {
1340
+ if (suppressHiddenResponse || isNoMessage(fullText) || !fullText.trim()) {
1341
1341
  if (currentChannelDeliveryRef.current) {
1342
1342
  persistConnectorDeliveryMarker({
1343
1343
  session,
@@ -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,
@@ -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,71 @@ 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 { spawnSync } from 'node:child_process'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
+
10
+ function runStorageAuthImport(options: {
11
+ envLocal?: string
12
+ generatedEnv?: string
13
+ credentialSecretFile?: string
14
+ externalCredentialSecret?: string
15
+ }) {
16
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'storage-auth-import-'))
17
+ const dataDir = path.join(tmpDir, 'data')
18
+ const cwd = path.join(tmpDir, 'cwd')
19
+ fs.mkdirSync(dataDir, { recursive: true })
20
+ fs.mkdirSync(cwd, { recursive: true })
21
+ try {
22
+ if (options.envLocal !== undefined) {
23
+ fs.writeFileSync(path.join(cwd, '.env.local'), options.envLocal, 'utf8')
24
+ }
25
+ if (options.generatedEnv !== undefined) {
26
+ fs.writeFileSync(path.join(dataDir, '.env.generated'), options.generatedEnv, 'utf8')
27
+ }
28
+ if (options.credentialSecretFile !== undefined) {
29
+ fs.writeFileSync(path.join(dataDir, 'credential-secret'), options.credentialSecretFile, { encoding: 'utf8', mode: 0o600 })
30
+ }
31
+ const env: NodeJS.ProcessEnv = {
32
+ ...process.env,
33
+ DATA_DIR: dataDir,
34
+ WORKSPACE_DIR: path.join(tmpDir, 'workspace'),
35
+ SWARMCLAW_DAEMON_AUTOSTART: '0',
36
+ }
37
+ delete env.ACCESS_KEY
38
+ delete env.CREDENTIAL_SECRET
39
+ delete env.SWARMCLAW_BUILD_MODE
40
+ if (options.externalCredentialSecret !== undefined) {
41
+ env.CREDENTIAL_SECRET = options.externalCredentialSecret
42
+ }
43
+ const script = `
44
+ import fs from 'node:fs'
45
+ import path from 'node:path'
46
+ import { pathToFileURL } from 'node:url'
47
+ process.chdir(${JSON.stringify(cwd)})
48
+ await import(pathToFileURL(${JSON.stringify(path.join(repoRoot, 'src/lib/server/storage-auth.ts'))}).href)
49
+ const secretPath = path.join(process.env.DATA_DIR, 'credential-secret')
50
+ const fileSecret = fs.existsSync(secretPath) ? fs.readFileSync(secretPath, 'utf8').trim() : ''
51
+ const mode = fs.existsSync(secretPath) ? (fs.statSync(secretPath).mode & 0o777).toString(8) : ''
52
+ console.log(JSON.stringify({
53
+ credentialSecret: process.env.CREDENTIAL_SECRET || '',
54
+ fileSecret,
55
+ mode,
56
+ }))
57
+ `
58
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
59
+ cwd: repoRoot,
60
+ env,
61
+ encoding: 'utf8',
62
+ timeout: 15_000,
63
+ })
64
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'storage-auth subprocess failed')
65
+ const jsonLine = (result.stdout || '').trim().split('\n').reverse().find((line) => line.trim().startsWith('{'))
66
+ return JSON.parse(jsonLine || '{}') as { credentialSecret: string; fileSecret: string; mode: string }
67
+ } finally {
68
+ fs.rmSync(tmpDir, { recursive: true, force: true })
69
+ }
70
+ }
6
71
 
7
72
  /**
8
73
  * Tests for storage-auth helpers.
@@ -148,3 +213,40 @@ describe('Docker key persistence fallback', () => {
148
213
  assert.equal(vars.ACCESS_KEY, 'original')
149
214
  })
150
215
  })
216
+
217
+ describe('credential secret persistence precedence', () => {
218
+ it('migrates a legacy .env.local credential secret into DATA_DIR', () => {
219
+ const legacySecret = 'a'.repeat(64)
220
+ const output = runStorageAuthImport({
221
+ envLocal: `CREDENTIAL_SECRET=${legacySecret}\n`,
222
+ })
223
+
224
+ assert.equal(output.credentialSecret, legacySecret)
225
+ assert.equal(output.fileSecret, legacySecret)
226
+ assert.equal(output.mode, '600')
227
+ })
228
+
229
+ it('uses the DATA_DIR credential secret before legacy env files', () => {
230
+ const fileSecret = 'b'.repeat(64)
231
+ const legacySecret = 'a'.repeat(64)
232
+ const output = runStorageAuthImport({
233
+ credentialSecretFile: fileSecret,
234
+ envLocal: `CREDENTIAL_SECRET=${legacySecret}\n`,
235
+ })
236
+
237
+ assert.equal(output.credentialSecret, fileSecret)
238
+ assert.equal(output.fileSecret, fileSecret)
239
+ })
240
+
241
+ it('lets an explicitly supplied environment credential secret override the file', () => {
242
+ const externalSecret = 'c'.repeat(64)
243
+ const fileSecret = 'b'.repeat(64)
244
+ const output = runStorageAuthImport({
245
+ externalCredentialSecret: externalSecret,
246
+ credentialSecretFile: fileSecret,
247
+ })
248
+
249
+ assert.equal(output.credentialSecret, externalSecret)
250
+ assert.equal(output.fileSecret, fileSecret)
251
+ })
252
+ })
@@ -11,24 +11,52 @@ const TAG = 'storage-auth'
11
11
  // because DATA_DIR is volume-mounted, unlike process.cwd()/.env.local.
12
12
  const GENERATED_ENV_PATH = path.join(DATA_DIR, '.env.generated')
13
13
 
14
+ // Dedicated single-purpose file for the credential-encryption secret. Lives in
15
+ // DATA_DIR so it survives both Docker volume mounts AND npm-global upgrades
16
+ // (the latter changes process.cwd() per version, which made .env.local-only
17
+ // persistence regenerate the secret every upgrade and orphan every credential
18
+ // encrypted under the old value).
19
+ const CREDENTIAL_SECRET_FILE = path.join(DATA_DIR, 'credential-secret')
20
+
14
21
  // --- .env loading ---
15
- function loadEnvFile(filePath: string): void {
16
- if (!fs.existsSync(filePath)) return
22
+ type LoadedEnvFile = Record<string, string>
23
+
24
+ function loadEnvFile(filePath: string): LoadedEnvFile {
25
+ const loaded: LoadedEnvFile = {}
26
+ if (!fs.existsSync(filePath)) return loaded
17
27
  fs.readFileSync(filePath, 'utf8').split(/\r?\n/).forEach(line => {
18
28
  const [k, ...v] = line.split('=')
19
- if (k && v.length) process.env[k.trim()] = v.join('=').trim()
29
+ if (k && v.length) loaded[k.trim()] = v.join('=').trim()
20
30
  })
31
+ return loaded
21
32
  }
22
33
 
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'))
34
+ function applyLoadedEnv(loaded: LoadedEnvFile, externalKeys: Set<string>, options?: { overwriteLoaded?: boolean }) {
35
+ for (const [key, value] of Object.entries(loaded)) {
36
+ if (externalKeys.has(key)) continue
37
+ if (options?.overwriteLoaded || process.env[key] === undefined || process.env[key] === '') {
38
+ process.env[key] = value
39
+ }
40
+ }
28
41
  }
29
- if (!IS_BUILD_BOOTSTRAP) {
30
- loadEnv()
42
+
43
+ function loadEnv(): { generated: LoadedEnvFile; local: LoadedEnvFile } {
44
+ const externalKeys = new Set(
45
+ Object.entries(process.env)
46
+ .filter(([, value]) => typeof value === 'string' && value.length > 0)
47
+ .map(([key]) => key),
48
+ )
49
+ const generated = loadEnvFile(GENERATED_ENV_PATH)
50
+ const local = loadEnvFile(path.join(process.cwd(), '.env.local'))
51
+
52
+ applyLoadedEnv(generated, externalKeys)
53
+ applyLoadedEnv(local, externalKeys, { overwriteLoaded: true })
54
+ return { generated, local }
31
55
  }
56
+ const externalCredentialSecret = process.env.CREDENTIAL_SECRET?.trim() || ''
57
+ const loadedEnv: { generated: LoadedEnvFile; local: LoadedEnvFile } = !IS_BUILD_BOOTSTRAP
58
+ ? loadEnv()
59
+ : { generated: {}, local: {} }
32
60
 
33
61
  /** Append a key=value to a file only if the key doesn't already exist in it. */
34
62
  function appendEnvKeyIfMissing(envPath: string, key: string, value: string): void {
@@ -59,12 +87,72 @@ function persistEnvKey(key: string, value: string): void {
59
87
  }
60
88
  }
61
89
 
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')
90
+ /** Read CREDENTIAL_SECRET from the dedicated file in DATA_DIR.
91
+ * Returns the trimmed contents, or empty string if absent / unreadable. */
92
+ function readCredentialSecretFile(): string {
93
+ try {
94
+ if (!fs.existsSync(CREDENTIAL_SECRET_FILE)) return ''
95
+ return fs.readFileSync(CREDENTIAL_SECRET_FILE, 'utf-8').trim()
96
+ } catch (err) {
97
+ log.warn(TAG, `Could not read CREDENTIAL_SECRET from ${CREDENTIAL_SECRET_FILE}`, {
98
+ error: err instanceof Error ? err.message : String(err),
99
+ })
100
+ return ''
101
+ }
102
+ }
103
+
104
+ /** Write CREDENTIAL_SECRET to the dedicated file with restrictive permissions. */
105
+ function writeCredentialSecretFile(secret: string): boolean {
106
+ try {
107
+ fs.mkdirSync(path.dirname(CREDENTIAL_SECRET_FILE), { recursive: true })
108
+ fs.writeFileSync(CREDENTIAL_SECRET_FILE, secret, { encoding: 'utf-8', mode: 0o600 })
109
+ return true
110
+ } catch (err) {
111
+ log.warn(TAG, `Could not persist CREDENTIAL_SECRET to ${CREDENTIAL_SECRET_FILE}`, {
112
+ error: err instanceof Error ? err.message : String(err),
113
+ })
114
+ return false
115
+ }
116
+ }
117
+
118
+ // Resolve CREDENTIAL_SECRET in this precedence order:
119
+ // 1. process.env (already set externally, e.g. by orchestrator)
120
+ // 2. DATA_DIR/credential-secret (the stable home — survives upgrades)
121
+ // 3. .env files (legacy — values loaded into process.env by loadEnv() above)
122
+ // 4. Generate new secret + persist to DATA_DIR/credential-secret
123
+ //
124
+ // Step 2 is the key change: previously the secret only lived in a per-version
125
+ // .env.local (cwd changes on npm-global upgrade), so each upgrade
126
+ // silently regenerated it and orphaned every encrypted credential.
127
+ if (!IS_BUILD_BOOTSTRAP) {
128
+ const legacyEnvSecret = loadedEnv.local.CREDENTIAL_SECRET?.trim()
129
+ || loadedEnv.generated.CREDENTIAL_SECRET?.trim()
130
+ || ''
131
+ const fileSecret = readCredentialSecretFile()
132
+ if (externalCredentialSecret) {
133
+ process.env.CREDENTIAL_SECRET = externalCredentialSecret
134
+ if (fileSecret && fileSecret !== externalCredentialSecret) {
135
+ log.warn(TAG, `CREDENTIAL_SECRET is set by the environment and differs from ${CREDENTIAL_SECRET_FILE}; using the environment value.`)
136
+ }
137
+ } else if (fileSecret) {
138
+ process.env.CREDENTIAL_SECRET = fileSecret
139
+ if (legacyEnvSecret && legacyEnvSecret !== fileSecret) {
140
+ // Both persisted locations exist and disagree. Trust DATA_DIR because it
141
+ // survives npm-global upgrades and Docker restarts.
142
+ log.warn(TAG, `CREDENTIAL_SECRET mismatch between legacy env files and ${CREDENTIAL_SECRET_FILE}; using the file value.`)
143
+ }
144
+ } else if (legacyEnvSecret) {
145
+ process.env.CREDENTIAL_SECRET = legacyEnvSecret
146
+ if (writeCredentialSecretFile(legacyEnvSecret)) {
147
+ log.info(TAG, `Migrated CREDENTIAL_SECRET from .env to ${CREDENTIAL_SECRET_FILE}`)
148
+ }
149
+ } else {
150
+ // First-ever launch on this DATA_DIR. Generate.
151
+ const secret = crypto.randomBytes(32).toString('hex')
152
+ process.env.CREDENTIAL_SECRET = secret
153
+ writeCredentialSecretFile(secret)
154
+ log.info(TAG, `Generated CREDENTIAL_SECRET and persisted to ${CREDENTIAL_SECRET_FILE}`)
155
+ }
68
156
  }
69
157
 
70
158
  // 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)
@@ -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?: {