@swarmclawai/swarmclaw 1.9.32 → 1.9.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +38 -0
  2. package/package.json +2 -2
  3. package/src/app/api/agents/agents-route.test.ts +36 -0
  4. package/src/app/api/extensions/builtins/route.ts +2 -1
  5. package/src/app/api/tasks/[id]/retry/route.ts +12 -0
  6. package/src/app/api/tasks/task-workspace-route.test.ts +62 -0
  7. package/src/cli/index.js +1 -0
  8. package/src/cli/index.test.js +30 -0
  9. package/src/cli/spec.js +1 -0
  10. package/src/components/agents/agent-sheet.tsx +41 -2
  11. package/src/components/chat/chat-tool-toggles.tsx +29 -7
  12. package/src/lib/providers/openclaw.test.ts +8 -1
  13. package/src/lib/providers/openclaw.ts +4 -2
  14. package/src/lib/server/chat-execution/chat-execution-connector-delivery.ts +17 -1
  15. package/src/lib/server/chat-execution/chat-turn-finalization.ts +2 -2
  16. package/src/lib/server/chat-execution/post-stream-finalization.test.ts +24 -0
  17. package/src/lib/server/connectors/connector-inbound.ts +6 -6
  18. package/src/lib/server/connectors/connector-lifecycle.ts +17 -1
  19. package/src/lib/server/connectors/openclaw.test.ts +9 -2
  20. package/src/lib/server/connectors/openclaw.ts +1 -1
  21. package/src/lib/server/session-tools/credential-env.ts +4 -3
  22. package/src/lib/server/session-tools/crud.ts +5 -0
  23. package/src/lib/server/session-tools/discovery-approvals.test.ts +49 -0
  24. package/src/lib/server/session-tools/execute.test.ts +29 -0
  25. package/src/lib/server/session-tools/manage-tasks.test.ts +55 -0
  26. package/src/lib/server/storage-auth.test.ts +204 -0
  27. package/src/lib/server/storage-auth.ts +309 -16
  28. package/src/lib/server/tasks/task-route-service.ts +46 -0
  29. package/src/lib/server/tasks/task-service.test.ts +50 -0
  30. package/src/lib/server/tasks/task-service.ts +16 -3
  31. package/src/lib/server/universal-tool-access.test.ts +16 -0
  32. package/src/lib/server/universal-tool-access.ts +3 -1
  33. package/src/lib/validation/schemas.ts +12 -0
  34. package/src/types/agent.ts +1 -0
package/README.md CHANGED
@@ -151,6 +151,25 @@ openclaw skills install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/waydelyle/swarmclaw)
153
153
 
154
+ ## v1.9.34 Highlights
155
+
156
+ Credential recovery and external extension access release for npm-global upgrades and scoped agent tool configuration.
157
+
158
+ - **Credential secret recovery.** Startup now checks prior npm-global build env files before accepting a fresh per-version `CREDENTIAL_SECRET`, and validates candidate secrets against existing encrypted credentials before persisting `DATA_DIR/credential-secret`.
159
+ - **Clear connector failures.** Connector startup now logs and surfaces credential decrypt failures directly instead of falling through to a misleading "No bot token configured" error.
160
+ - **External extension tools.** Scoped agents now keep explicitly attached external `*.js` and `*.mjs` extensions, and the agent/chat tool controls persist enabled external tools through the `extensions` field.
161
+ - **Regression coverage.** Added tests for previous-build credential recovery, non-decrypting secret replacement, scoped external extension access, and extension access persistence.
162
+
163
+ ## v1.9.33 Highlights
164
+
165
+ Issue and PR validation release for credential durability, delegated task dispatch, connector output hygiene, and OpenClaw gateway protocol compatibility.
166
+
167
+ - **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.
168
+ - **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`.
169
+ - **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.
170
+ - **Agent and gateway compatibility.** Agent updates preserve workspace filesystem settings, and OpenClaw gateway routes now use protocol version 4.
171
+ - **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.
172
+
154
173
  ## v1.9.32 Highlights
155
174
 
156
175
  PR integration release for background model routing, reflection memory controls, and current ClawHub install guidance.
@@ -435,6 +454,25 @@ Operational docs: https://swarmclaw.ai/docs/observability
435
454
 
436
455
  ## Releases
437
456
 
457
+ ### v1.9.34 Highlights
458
+
459
+ Credential recovery and external extension access release for npm-global upgrades and scoped agent tool configuration.
460
+
461
+ - **Credential secret recovery.** Startup now checks prior npm-global build env files before accepting a fresh per-version `CREDENTIAL_SECRET`, and validates candidate secrets against existing encrypted credentials before persisting `DATA_DIR/credential-secret`.
462
+ - **Clear connector failures.** Connector startup now logs and surfaces credential decrypt failures directly instead of falling through to a misleading "No bot token configured" error.
463
+ - **External extension tools.** Scoped agents now keep explicitly attached external `*.js` and `*.mjs` extensions, and the agent/chat tool controls persist enabled external tools through the `extensions` field.
464
+ - **Regression coverage.** Added tests for previous-build credential recovery, non-decrypting secret replacement, scoped external extension access, and extension access persistence.
465
+
466
+ ### v1.9.33 Highlights
467
+
468
+ Issue and PR validation release for credential durability, delegated task dispatch, connector output hygiene, and OpenClaw gateway protocol compatibility.
469
+
470
+ - **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.
471
+ - **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`.
472
+ - **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.
473
+ - **Agent and gateway compatibility.** Agent updates preserve workspace filesystem settings, and OpenClaw gateway routes now use protocol version 4.
474
+ - **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.
475
+
438
476
  ### v1.9.32 Highlights
439
477
 
440
478
  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.34",
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
 
@@ -20,7 +20,7 @@ export async function GET() {
20
20
 
21
21
  // For external extensions that are enabled, also collect their concrete tool names
22
22
  // so the UI can show those tools in the toggles
23
- const externalTools: Array<{ extensionId: string; toolName: string; label: string; description: string }> = []
23
+ const externalTools: Array<{ extensionId: string; extensionName: string; toolName: string; label: string; description: string }> = []
24
24
  for (const meta of all) {
25
25
  if (meta.isBuiltin || !meta.enabled) continue
26
26
  try {
@@ -28,6 +28,7 @@ export async function GET() {
28
28
  for (const entry of tools) {
29
29
  externalTools.push({
30
30
  extensionId: entry.extensionId,
31
+ extensionName: meta.name || meta.filename,
31
32
  toolName: entry.tool.name,
32
33
  label: entry.tool.name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
33
34
  description: entry.tool.description || '',
@@ -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'] },
@@ -57,6 +57,14 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
57
57
  const CONNECTION_TEST_TIMEOUT_MS = 40_000
58
58
  type AgentProviderId = string
59
59
 
60
+ interface ExtensionToolInfo {
61
+ extensionId: string
62
+ extensionName?: string
63
+ toolName: string
64
+ label: string
65
+ description: string
66
+ }
67
+
60
68
  function SectionCard({
61
69
  title,
62
70
  description,
@@ -223,6 +231,7 @@ export function AgentSheet() {
223
231
  const [toolAccessMode, setToolAccessMode] = useState<'universal' | 'scoped'>('scoped')
224
232
  const [extensions, setExtensions] = useState<string[]>([])
225
233
  const [enabledExtensionIds, setEnabledExtensionIds] = useState<Set<string> | null>(null)
234
+ const [externalTools, setExternalTools] = useState<ExtensionToolInfo[]>([])
226
235
  const [skills, setSkills] = useState<string[]>([])
227
236
  const [skillIds, setSkillIds] = useState<string[]>([])
228
237
  const [mcpServerIds, setMcpServerIds] = useState<string[]>([])
@@ -423,8 +432,11 @@ export function AgentSheet() {
423
432
  loadProjects()
424
433
  loadClaudeSkills()
425
434
  // Fetch enabled extension IDs so we can filter tool toggles
426
- api<{ enabledExtensionIds: string[] }>('GET', '/extensions/builtins')
427
- .then((res) => { if (res?.enabledExtensionIds) setEnabledExtensionIds(new Set(res.enabledExtensionIds)) })
435
+ api<{ enabledExtensionIds: string[]; externalTools?: ExtensionToolInfo[] }>('GET', '/extensions/builtins')
436
+ .then((res) => {
437
+ if (res?.enabledExtensionIds) setEnabledExtensionIds(new Set(res.enabledExtensionIds))
438
+ if (Array.isArray(res?.externalTools)) setExternalTools(res.externalTools)
439
+ })
428
440
  .catch(() => {})
429
441
  setTestStatus('idle')
430
442
  setTestMessage('')
@@ -2642,6 +2654,33 @@ export function AgentSheet() {
2642
2654
  </div>
2643
2655
  )}
2644
2656
 
2657
+ {!hasNativeCapabilities && externalTools.length > 0 && (
2658
+ <div className="mb-8">
2659
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Extension Tools</label>
2660
+ <p className="text-[12px] text-text-3/60 mb-3">Attach enabled external extension tools to this agent.</p>
2661
+ <div className="space-y-3">
2662
+ {externalTools.map((t) => {
2663
+ const attached = extensions.includes(t.extensionId)
2664
+ const description = t.extensionName
2665
+ ? `${t.description || 'External extension tool'} (${t.extensionName})`
2666
+ : (t.description || 'External extension tool')
2667
+ return (
2668
+ <label key={`${t.extensionId}:${t.toolName}`} className="flex items-center gap-3 cursor-pointer">
2669
+ <div
2670
+ onClick={() => setExtensions((prev) => prev.includes(t.extensionId) ? prev.filter((x) => x !== t.extensionId) : [...prev, t.extensionId])}
2671
+ className={`w-11 h-6 rounded-full transition-all duration-200 relative shrink-0 ${attached ? 'bg-accent-bright cursor-pointer' : 'bg-white/[0.08] cursor-pointer'}`}
2672
+ >
2673
+ <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 ${attached ? 'left-[22px]' : 'left-0.5'}`} />
2674
+ </div>
2675
+ <span className="font-display text-[14px] font-600 text-text-2">{t.label}</span>
2676
+ <span className="text-[12px] text-text-3">{description}</span>
2677
+ </label>
2678
+ )
2679
+ })}
2680
+ </div>
2681
+ </div>
2682
+ )}
2683
+
2645
2684
  {/* Native capability provider note — not shown for OpenClaw (covered in connection status) */}
2646
2685
  {hasNativeCapabilities && !openclawEnabled && (
2647
2686
  <div className="mb-8 p-4 rounded-[14px] bg-white/[0.02] border border-white/[0.06]">
@@ -7,7 +7,7 @@ import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
7
7
  import type { ToolDefinition } from '@/lib/tool-definitions'
8
8
  import type { Session } from '@/types'
9
9
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
10
- import { getEnabledToolIds, getEnabledExtensionIds } from '@/lib/capability-selection'
10
+ import { getEnabledToolIds, getEnabledExtensionIds, isExternalExtensionId } from '@/lib/capability-selection'
11
11
 
12
12
  interface Props {
13
13
  session: Session
@@ -15,6 +15,7 @@ interface Props {
15
15
 
16
16
  interface ExtensionToolInfo {
17
17
  extensionId: string
18
+ extensionName?: string
18
19
  toolName: string
19
20
  label: string
20
21
  description: string
@@ -55,7 +56,20 @@ export function ChatToolToggles({ session }: Props) {
55
56
  return () => document.removeEventListener('mousedown', handler)
56
57
  }, [open])
57
58
 
58
- const toggleTool = async (toolId: string) => {
59
+ const toggleTool = async (tool: ToolDefinition) => {
60
+ if (tool.extensionId && isExternalExtensionId(tool.extensionId)) {
61
+ const updatedExtensions = sessionExtensions.includes(tool.extensionId)
62
+ ? sessionExtensions.filter((extensionId) => extensionId !== tool.extensionId)
63
+ : [...sessionExtensions, tool.extensionId]
64
+ await api('PUT', `/chats/${session.id}`, {
65
+ tools: sessionTools,
66
+ extensions: updatedExtensions,
67
+ })
68
+ await refreshSession(session.id)
69
+ return
70
+ }
71
+
72
+ const toolId = tool.id
59
73
  const updated = sessionTools.includes(toolId)
60
74
  ? sessionTools.filter((t) => t !== toolId)
61
75
  : [...sessionTools, toolId]
@@ -78,9 +92,9 @@ export function ChatToolToggles({ session }: Props) {
78
92
 
79
93
  // Convert external extension tools into ToolDefinition-like items for display
80
94
  const extensionToolDefs: ToolDefinition[] = externalTools.map((et) => ({
81
- id: et.toolName,
95
+ id: `${et.extensionId}:${et.toolName}`,
82
96
  label: et.label,
83
- description: et.description,
97
+ description: et.extensionName ? `${et.description || 'External extension tool'} (${et.extensionName})` : et.description,
84
98
  extensionId: et.extensionId,
85
99
  }))
86
100
 
@@ -92,7 +106,11 @@ export function ChatToolToggles({ session }: Props) {
92
106
 
93
107
  const allVisibleTools = groups.flatMap((g) => g.tools)
94
108
  const totalCount = allVisibleTools.length
95
- const enabledCount = sessionTools.filter((id) => allVisibleTools.some((t) => t.id === id)).length
109
+ const enabledCount = allVisibleTools.filter((tool) => (
110
+ tool.extensionId && isExternalExtensionId(tool.extensionId)
111
+ ? sessionExtensions.includes(tool.extensionId)
112
+ : sessionTools.includes(tool.id)
113
+ )).length
96
114
 
97
115
  return (
98
116
  <div className="relative" ref={ref}>
@@ -120,13 +138,17 @@ export function ChatToolToggles({ session }: Props) {
120
138
  <p className="text-[10px] font-600 text-text-3/60 uppercase tracking-wider mb-2">{group.label}</p>
121
139
  {group.tools.map((tool) => {
122
140
  const extDisabled = !isExtensionEnabled(tool)
123
- const enabled = !extDisabled && sessionTools.includes(tool.id)
141
+ const enabled = !extDisabled && (
142
+ tool.extensionId && isExternalExtensionId(tool.extensionId)
143
+ ? sessionExtensions.includes(tool.extensionId)
144
+ : sessionTools.includes(tool.id)
145
+ )
124
146
  return (
125
147
  <Tooltip key={tool.id}>
126
148
  <TooltipTrigger asChild>
127
149
  <label className={`flex items-center gap-2.5 py-1.5 ${extDisabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`}>
128
150
  <div
129
- onClick={() => !extDisabled && toggleTool(tool.id)}
151
+ onClick={() => !extDisabled && toggleTool(tool)}
130
152
  className={`w-8 h-[18px] rounded-full transition-all duration-200 relative shrink-0
131
153
  ${extDisabled ? 'bg-white/[0.04] cursor-not-allowed' : enabled ? 'bg-accent-bright cursor-pointer' : 'bg-white/[0.12] cursor-pointer'}`}
132
154
  >
@@ -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,