@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 +20 -0
- package/package.json +2 -2
- package/src/app/api/agents/agents-route.test.ts +36 -0
- 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/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/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/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 +102 -0
- package/src/lib/server/storage-auth.ts +104 -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/validation/schemas.ts +12 -0
- package/src/types/agent.ts +1 -0
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.
|
|
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.
|
|
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 }),
|
package/src/cli/index.test.js
CHANGED
|
@@ -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:
|
|
136
|
-
maxProtocol:
|
|
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
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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:
|
|
130
|
+
protocol: 4,
|
|
124
131
|
auth: { deviceToken: 'device-token-test' },
|
|
125
132
|
policy: { tickIntervalMs: 15_000 },
|
|
126
133
|
},
|
|
@@ -362,7 +369,7 @@ test('openclaw connector reconnects when tick watchdog detects stale connection'
|
|
|
362
369
|
try {
|
|
363
370
|
await performHandshake(ws, {
|
|
364
371
|
type: 'hello-ok',
|
|
365
|
-
protocol:
|
|
372
|
+
protocol: 4,
|
|
366
373
|
policy: { tickIntervalMs: 200 },
|
|
367
374
|
})
|
|
368
375
|
|
|
@@ -36,7 +36,7 @@ export function buildCredentialEnv(credentialIds: string[]): CredentialEnv {
|
|
|
36
36
|
const env: Record<string, string> = {}
|
|
37
37
|
const secrets: string[] = []
|
|
38
38
|
|
|
39
|
-
const allCredentials = loadCredentials() as Record<string, Credential & {
|
|
39
|
+
const allCredentials = loadCredentials() as Record<string, Credential & { encryptedKey?: string }>
|
|
40
40
|
|
|
41
41
|
for (const credId of credentialIds) {
|
|
42
42
|
const cred = allCredentials[credId]
|
|
@@ -45,8 +45,9 @@ export function buildCredentialEnv(credentialIds: string[]): CredentialEnv {
|
|
|
45
45
|
continue
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
//
|
|
49
|
-
|
|
48
|
+
// Credentials persist ciphertext under `encryptedKey`; older reads of
|
|
49
|
+
// `cred.encrypted` silently skipped injection for execute tool calls.
|
|
50
|
+
const encrypted = cred.encryptedKey
|
|
50
51
|
if (!encrypted || typeof encrypted !== 'string') {
|
|
51
52
|
log.warn(TAG, `Credential has no encrypted value: ${credId}`)
|
|
52
53
|
continue
|
|
@@ -603,6 +603,9 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
603
603
|
const raw = buildCrudPayload(normalized, action, data)
|
|
604
604
|
const defaults = RESOURCE_DEFAULTS[toolKey]
|
|
605
605
|
const parsed = defaults ? defaults(raw) : raw
|
|
606
|
+
if (toolKey === 'manage_tasks' && !Object.prototype.hasOwnProperty.call(raw, 'status')) {
|
|
607
|
+
delete (parsed as Record<string, unknown>).status
|
|
608
|
+
}
|
|
606
609
|
if (parsed && typeof parsed === 'object' && 'id' in parsed) {
|
|
607
610
|
delete (parsed as Record<string, unknown>).id
|
|
608
611
|
}
|
|
@@ -696,6 +699,8 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
696
699
|
now,
|
|
697
700
|
settings: loadSettings(),
|
|
698
701
|
fallbackAgentId: ctx?.agentId || null,
|
|
702
|
+
creatorAgentId: ctx?.agentId || null,
|
|
703
|
+
autoQueueDelegatedTasks: true,
|
|
699
704
|
defaultCwd: cwd,
|
|
700
705
|
deriveTitleFromDescription: true,
|
|
701
706
|
requireMeaningfulTitle: true,
|
|
@@ -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
|
-
|
|
16
|
-
|
|
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)
|
|
29
|
+
if (k && v.length) loaded[k.trim()] = v.join('=').trim()
|
|
20
30
|
})
|
|
31
|
+
return loaded
|
|
21
32
|
}
|
|
22
33
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
63
|
-
if
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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),
|
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?: {
|