@swarmclawai/swarmclaw 0.7.3 → 0.7.4

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 (147) hide show
  1. package/README.md +47 -40
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +17 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +3 -1
  12. package/src/app/api/agents/route.ts +23 -1
  13. package/src/app/api/auth/route.ts +1 -1
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/route.ts +12 -0
  19. package/src/app/api/chats/heartbeat/route.ts +2 -1
  20. package/src/app/api/chats/route.ts +7 -1
  21. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  22. package/src/app/api/external-agents/[id]/route.ts +31 -0
  23. package/src/app/api/external-agents/register/route.ts +3 -0
  24. package/src/app/api/external-agents/route.ts +66 -0
  25. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  26. package/src/app/api/gateways/[id]/route.ts +79 -0
  27. package/src/app/api/gateways/route.ts +57 -0
  28. package/src/app/api/openclaw/gateway/route.ts +10 -7
  29. package/src/app/api/openclaw/skills/route.ts +1 -1
  30. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  31. package/src/app/api/schedules/[id]/route.ts +38 -9
  32. package/src/app/api/schedules/route.ts +51 -28
  33. package/src/app/api/settings/route.ts +6 -10
  34. package/src/app/api/setup/doctor/route.ts +6 -4
  35. package/src/app/api/tasks/[id]/route.ts +2 -1
  36. package/src/app/api/tasks/bulk/route.ts +2 -2
  37. package/src/app/page.tsx +126 -15
  38. package/src/cli/binary.test.js +142 -0
  39. package/src/cli/index.js +34 -11
  40. package/src/cli/index.test.js +195 -0
  41. package/src/cli/index.ts +20 -4
  42. package/src/cli/server-cmd.test.js +59 -0
  43. package/src/cli/spec.js +20 -2
  44. package/src/components/agents/agent-sheet.tsx +249 -7
  45. package/src/components/agents/inspector-panel.tsx +3 -2
  46. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  47. package/src/components/auth/setup-wizard.tsx +970 -275
  48. package/src/components/chat/chat-area.tsx +41 -14
  49. package/src/components/chat/chat-card.tsx +2 -1
  50. package/src/components/chat/chat-header.tsx +8 -13
  51. package/src/components/chat/chat-list.tsx +58 -20
  52. package/src/components/chat/message-list.tsx +142 -18
  53. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  54. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  55. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  56. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +157 -86
  59. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  60. package/src/components/gateways/gateway-sheet.tsx +567 -0
  61. package/src/components/input/chat-input.tsx +135 -86
  62. package/src/components/layout/app-layout.tsx +2 -0
  63. package/src/components/memory/memory-browser.tsx +71 -6
  64. package/src/components/memory/memory-card.tsx +18 -0
  65. package/src/components/memory/memory-detail.tsx +58 -31
  66. package/src/components/memory/memory-sheet.tsx +32 -4
  67. package/src/components/projects/project-detail.tsx +7 -2
  68. package/src/components/providers/provider-list.tsx +158 -2
  69. package/src/components/providers/provider-sheet.tsx +81 -70
  70. package/src/components/shared/bottom-sheet.tsx +31 -15
  71. package/src/components/shared/confirm-dialog.tsx +45 -30
  72. package/src/components/shared/model-combobox.tsx +90 -8
  73. package/src/components/shared/settings/section-heartbeat.tsx +11 -6
  74. package/src/components/shared/settings/section-orchestrator.tsx +3 -0
  75. package/src/components/shared/settings/settings-page.tsx +5 -3
  76. package/src/components/tasks/approvals-panel.tsx +7 -1
  77. package/src/components/ui/dialog.tsx +2 -2
  78. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  79. package/src/lib/heartbeat-defaults.ts +48 -0
  80. package/src/lib/memory-presentation.ts +59 -0
  81. package/src/lib/provider-model-discovery-client.ts +29 -0
  82. package/src/lib/providers/index.ts +12 -5
  83. package/src/lib/runtime-loop.ts +105 -3
  84. package/src/lib/safe-storage.ts +6 -1
  85. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  86. package/src/lib/server/agent-runtime-config.ts +277 -0
  87. package/src/lib/server/approvals-auto-approve.test.ts +59 -0
  88. package/src/lib/server/build-llm.test.ts +13 -5
  89. package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
  90. package/src/lib/server/chat-execution.ts +159 -71
  91. package/src/lib/server/chatroom-helpers.test.ts +7 -0
  92. package/src/lib/server/chatroom-helpers.ts +99 -6
  93. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  94. package/src/lib/server/connectors/manager.ts +89 -61
  95. package/src/lib/server/connectors/slack.ts +1 -1
  96. package/src/lib/server/daemon-state.ts +3 -2
  97. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  98. package/src/lib/server/eval/agent-regression.ts +1742 -0
  99. package/src/lib/server/eval/runner.ts +11 -1
  100. package/src/lib/server/eval/store.ts +2 -1
  101. package/src/lib/server/heartbeat-service.ts +10 -4
  102. package/src/lib/server/main-agent-loop.ts +13 -6
  103. package/src/lib/server/openclaw-exec-config.ts +4 -2
  104. package/src/lib/server/openclaw-gateway.ts +123 -36
  105. package/src/lib/server/orchestrator-lg.ts +1 -2
  106. package/src/lib/server/orchestrator.ts +3 -2
  107. package/src/lib/server/plugins.test.ts +9 -1
  108. package/src/lib/server/plugins.ts +12 -2
  109. package/src/lib/server/provider-model-discovery.ts +481 -0
  110. package/src/lib/server/queue.ts +1 -1
  111. package/src/lib/server/runtime-settings.test.ts +119 -0
  112. package/src/lib/server/runtime-settings.ts +12 -92
  113. package/src/lib/server/schedule-normalization.ts +187 -0
  114. package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
  115. package/src/lib/server/session-tools/crud.ts +27 -3
  116. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  117. package/src/lib/server/session-tools/discovery.ts +18 -8
  118. package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
  119. package/src/lib/server/session-tools/file.ts +8 -2
  120. package/src/lib/server/session-tools/http.ts +9 -3
  121. package/src/lib/server/session-tools/index.ts +31 -1
  122. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  123. package/src/lib/server/session-tools/monitor.ts +14 -7
  124. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  125. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  126. package/src/lib/server/session-tools/platform.ts +1 -1
  127. package/src/lib/server/session-tools/plugin-creator.ts +9 -2
  128. package/src/lib/server/session-tools/sandbox.ts +51 -92
  129. package/src/lib/server/session-tools/session-info.ts +22 -1
  130. package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
  131. package/src/lib/server/session-tools/shell.ts +2 -2
  132. package/src/lib/server/session-tools/subagent.ts +3 -1
  133. package/src/lib/server/session-tools/web.ts +73 -30
  134. package/src/lib/server/storage.ts +29 -3
  135. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  136. package/src/lib/server/stream-agent-chat.ts +139 -4
  137. package/src/lib/server/structured-extract.ts +1 -1
  138. package/src/lib/server/task-mention.ts +0 -1
  139. package/src/lib/server/tool-aliases.ts +37 -6
  140. package/src/lib/server/tool-capability-policy.ts +1 -1
  141. package/src/lib/setup-defaults.ts +352 -11
  142. package/src/lib/tool-definitions.ts +3 -4
  143. package/src/lib/validation/schemas.ts +55 -1
  144. package/src/stores/use-app-store.ts +43 -1
  145. package/src/stores/use-chatroom-store.ts +153 -26
  146. package/src/types/index.ts +189 -6
  147. package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
@@ -1,15 +1,6 @@
1
1
  import type { LoopMode } from '@/types'
2
2
  import {
3
- DEFAULT_AGENT_LOOP_RECURSION_LIMIT,
4
- DEFAULT_CLAUDE_CODE_TIMEOUT_SEC,
5
- DEFAULT_CLI_PROCESS_TIMEOUT_SEC,
6
- DEFAULT_DELEGATION_MAX_DEPTH,
7
- DEFAULT_LEGACY_ORCHESTRATOR_MAX_TURNS,
8
- DEFAULT_LOOP_MODE,
9
- DEFAULT_ONGOING_LOOP_MAX_ITERATIONS,
10
- DEFAULT_ONGOING_LOOP_MAX_RUNTIME_MINUTES,
11
- DEFAULT_ORCHESTRATOR_LOOP_RECURSION_LIMIT,
12
- DEFAULT_SHELL_COMMAND_TIMEOUT_SEC,
3
+ normalizeRuntimeSettingFields,
13
4
  } from '@/lib/runtime-loop'
14
5
  import { loadSettings } from './storage'
15
6
 
@@ -26,92 +17,21 @@ export interface RuntimeSettings {
26
17
  cliProcessTimeoutMs: number
27
18
  }
28
19
 
29
- function parseIntSetting(value: unknown, fallback: number, min: number, max: number): number {
30
- const parsed = typeof value === 'number'
31
- ? value
32
- : typeof value === 'string'
33
- ? Number.parseInt(value, 10)
34
- : Number.NaN
35
- if (!Number.isFinite(parsed)) return fallback
36
- const int = Math.trunc(parsed)
37
- return Math.max(min, Math.min(max, int))
38
- }
39
-
40
- function parseLoopMode(value: unknown): LoopMode {
41
- return value === 'ongoing' ? 'ongoing' : DEFAULT_LOOP_MODE
42
- }
43
-
44
20
  export function loadRuntimeSettings(): RuntimeSettings {
45
21
  const settings = loadSettings()
46
- const loopMode = parseLoopMode(settings.loopMode)
47
-
48
- const agentLoopRecursionLimit = parseIntSetting(
49
- settings.agentLoopRecursionLimit,
50
- DEFAULT_AGENT_LOOP_RECURSION_LIMIT,
51
- 1,
52
- 200,
53
- )
54
- const orchestratorLoopRecursionLimit = parseIntSetting(
55
- settings.orchestratorLoopRecursionLimit,
56
- DEFAULT_ORCHESTRATOR_LOOP_RECURSION_LIMIT,
57
- 1,
58
- 300,
59
- )
60
- const legacyOrchestratorMaxTurns = parseIntSetting(
61
- settings.legacyOrchestratorMaxTurns,
62
- DEFAULT_LEGACY_ORCHESTRATOR_MAX_TURNS,
63
- 1,
64
- 300,
65
- )
66
- const delegationMaxDepth = parseIntSetting(
67
- settings.delegationMaxDepth,
68
- DEFAULT_DELEGATION_MAX_DEPTH,
69
- 1,
70
- 12,
71
- )
72
- const ongoingLoopMaxIterations = parseIntSetting(
73
- settings.ongoingLoopMaxIterations,
74
- DEFAULT_ONGOING_LOOP_MAX_ITERATIONS,
75
- 10,
76
- 5000,
77
- )
78
- const ongoingLoopMaxRuntimeMinutes = parseIntSetting(
79
- settings.ongoingLoopMaxRuntimeMinutes,
80
- DEFAULT_ONGOING_LOOP_MAX_RUNTIME_MINUTES,
81
- 0,
82
- 1440,
83
- )
84
-
85
- const shellCommandTimeoutSec = parseIntSetting(
86
- settings.shellCommandTimeoutSec,
87
- DEFAULT_SHELL_COMMAND_TIMEOUT_SEC,
88
- 1,
89
- 600,
90
- )
91
- const claudeCodeTimeoutSec = parseIntSetting(
92
- settings.claudeCodeTimeoutSec,
93
- DEFAULT_CLAUDE_CODE_TIMEOUT_SEC,
94
- 5,
95
- 7200,
96
- )
97
- const cliProcessTimeoutSec = parseIntSetting(
98
- settings.cliProcessTimeoutSec,
99
- DEFAULT_CLI_PROCESS_TIMEOUT_SEC,
100
- 10,
101
- 7200,
102
- )
22
+ const normalized = normalizeRuntimeSettingFields(settings)
103
23
 
104
24
  return {
105
- loopMode,
106
- agentLoopRecursionLimit,
107
- orchestratorLoopRecursionLimit,
108
- legacyOrchestratorMaxTurns,
109
- delegationMaxDepth,
110
- ongoingLoopMaxIterations,
111
- ongoingLoopMaxRuntimeMs: ongoingLoopMaxRuntimeMinutes > 0 ? ongoingLoopMaxRuntimeMinutes * 60_000 : null,
112
- shellCommandTimeoutMs: shellCommandTimeoutSec * 1000,
113
- claudeCodeTimeoutMs: claudeCodeTimeoutSec * 1000,
114
- cliProcessTimeoutMs: cliProcessTimeoutSec * 1000,
25
+ loopMode: normalized.loopMode as LoopMode,
26
+ agentLoopRecursionLimit: normalized.agentLoopRecursionLimit,
27
+ orchestratorLoopRecursionLimit: normalized.orchestratorLoopRecursionLimit,
28
+ legacyOrchestratorMaxTurns: normalized.legacyOrchestratorMaxTurns,
29
+ delegationMaxDepth: normalized.delegationMaxDepth,
30
+ ongoingLoopMaxIterations: normalized.ongoingLoopMaxIterations,
31
+ ongoingLoopMaxRuntimeMs: normalized.ongoingLoopMaxRuntimeMinutes > 0 ? normalized.ongoingLoopMaxRuntimeMinutes * 60_000 : null,
32
+ shellCommandTimeoutMs: normalized.shellCommandTimeoutSec * 1000,
33
+ claudeCodeTimeoutMs: normalized.claudeCodeTimeoutSec * 1000,
34
+ cliProcessTimeoutMs: normalized.cliProcessTimeoutSec * 1000,
115
35
  }
116
36
  }
117
37
 
@@ -0,0 +1,187 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { WORKSPACE_DIR } from './data-dir'
4
+
5
+ type SchedulePayload = Record<string, unknown>
6
+
7
+ export interface NormalizeScheduleOptions {
8
+ cwd?: string | null
9
+ now?: number
10
+ }
11
+
12
+ export type NormalizeScheduleResult =
13
+ | { ok: true; value: SchedulePayload }
14
+ | { ok: false; error: string }
15
+
16
+ const SCRIPT_FILE_EXT = /\.(py|js|mjs|cjs|ts|tsx|sh|bash|zsh|rb|php|pl)$/i
17
+ const DIRECT_SCRIPT_RUNNERS = new Set(['python', 'python3', 'python3.11', 'node', 'bash', 'sh', 'zsh', 'ruby', 'tsx', 'ts-node'])
18
+ const VALID_STATUSES = new Set(['active', 'paused', 'completed', 'failed'])
19
+
20
+ function trimString(value: unknown): string {
21
+ return typeof value === 'string' ? value.trim() : ''
22
+ }
23
+
24
+ function normalizeScheduleType(value: unknown): 'cron' | 'interval' | 'once' {
25
+ if (value === 'cron' || value === 'interval' || value === 'once') return value
26
+ return 'interval'
27
+ }
28
+
29
+ function normalizePositiveInt(value: unknown): number | null {
30
+ const parsed = typeof value === 'number'
31
+ ? value
32
+ : typeof value === 'string'
33
+ ? Number.parseInt(value, 10)
34
+ : Number.NaN
35
+ if (!Number.isFinite(parsed)) return null
36
+ const intValue = Math.trunc(parsed)
37
+ return intValue > 0 ? intValue : null
38
+ }
39
+
40
+ function isWithinDirectory(parent: string, child: string): boolean {
41
+ const relative = path.relative(path.resolve(parent), path.resolve(child))
42
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
43
+ }
44
+
45
+ function resolveRelativePath(baseDir: string, candidate: string): string | null {
46
+ const trimmed = trimString(candidate)
47
+ if (!trimmed) return null
48
+ if (path.isAbsolute(trimmed)) {
49
+ const resolvedAbsolute = path.resolve(trimmed)
50
+ return isWithinDirectory(baseDir, resolvedAbsolute) ? resolvedAbsolute : null
51
+ }
52
+ const resolved = path.resolve(baseDir, trimmed)
53
+ return isWithinDirectory(baseDir, resolved) ? resolved : null
54
+ }
55
+
56
+ function tokenizeCommand(command: string): string[] {
57
+ return String(command || '').match(/(?:[^\s"'`]+|"[^"]*"|'[^']*')+/g) || []
58
+ }
59
+
60
+ function unquoteToken(token: string): string {
61
+ if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith('\'') && token.endsWith('\''))) {
62
+ return token.slice(1, -1)
63
+ }
64
+ return token
65
+ }
66
+
67
+ function looksLikeScriptPath(token: string): boolean {
68
+ return SCRIPT_FILE_EXT.test(token) || token.includes('/') || token.includes(path.sep)
69
+ }
70
+
71
+ function extractScriptPathFromCommand(command: string): string | null {
72
+ const tokens = tokenizeCommand(command).map(unquoteToken).filter(Boolean)
73
+ if (!tokens.length) return null
74
+
75
+ const commandName = path.basename(tokens[0] || '').toLowerCase()
76
+ let startIndex = 1
77
+ if (commandName === 'npx' && tokens[1]) {
78
+ const nestedRunner = path.basename(tokens[1]).toLowerCase()
79
+ if (nestedRunner === 'tsx' || nestedRunner === 'ts-node') startIndex = 2
80
+ } else if (commandName === 'deno' && tokens[1] === 'run') {
81
+ startIndex = 2
82
+ } else if (!DIRECT_SCRIPT_RUNNERS.has(commandName)) {
83
+ startIndex = 0
84
+ }
85
+
86
+ for (let index = startIndex; index < tokens.length; index += 1) {
87
+ const candidate = tokens[index]
88
+ if (!candidate || candidate.startsWith('-')) continue
89
+ if (!looksLikeScriptPath(candidate)) continue
90
+ return candidate
91
+ }
92
+
93
+ return null
94
+ }
95
+
96
+ function deriveTaskPrompt(payload: SchedulePayload): string {
97
+ const explicitTaskPrompt = trimString(payload.taskPrompt)
98
+ if (explicitTaskPrompt) return explicitTaskPrompt
99
+
100
+ const command = trimString(payload.command)
101
+ if (command) {
102
+ return `Execute the command \`${command}\` from this schedule's working directory and report the result, including any errors.`
103
+ }
104
+
105
+ const filePath = trimString(payload.path)
106
+ if (!filePath) return ''
107
+
108
+ const action = trimString(payload.action).toLowerCase()
109
+ if (action === 'run_script') {
110
+ return `Run the script at \`${filePath}\` from this schedule's working directory and report the result, including any errors.`
111
+ }
112
+
113
+ return `Use the file at \`${filePath}\` to complete this scheduled task and report the result.`
114
+ }
115
+
116
+ function validateScheduleArtifacts(payload: SchedulePayload, baseDir: string): string | null {
117
+ const action = trimString(payload.action).toLowerCase()
118
+ const filePath = trimString(payload.path)
119
+ const command = trimString(payload.command)
120
+
121
+ if (action === 'run_script' && !filePath) {
122
+ return 'run_script schedules require a path.'
123
+ }
124
+
125
+ if (filePath) {
126
+ const resolved = resolveRelativePath(baseDir, filePath)
127
+ if (!resolved) return `schedule path must stay inside ${baseDir}: ${filePath}`
128
+ if (!fs.existsSync(resolved)) return `schedule path not found: ${filePath}`
129
+ }
130
+
131
+ if (!command) return null
132
+ const commandScriptPath = extractScriptPathFromCommand(command)
133
+ if (!commandScriptPath) return null
134
+ const resolved = resolveRelativePath(baseDir, commandScriptPath)
135
+ if (!resolved) return `schedule command references a path outside ${baseDir}: ${commandScriptPath}`
136
+ if (!fs.existsSync(resolved)) return `schedule command references a missing file: ${commandScriptPath}`
137
+ return null
138
+ }
139
+
140
+ export function normalizeSchedulePayload(payload: SchedulePayload, opts: NormalizeScheduleOptions = {}): NormalizeScheduleResult {
141
+ const now = typeof opts.now === 'number' ? opts.now : Date.now()
142
+ const baseDir = path.resolve(trimString(opts.cwd) || WORKSPACE_DIR)
143
+ const normalized: SchedulePayload = {
144
+ ...payload,
145
+ scheduleType: normalizeScheduleType(payload.scheduleType),
146
+ }
147
+ const action = trimString(normalized.action)
148
+ const command = trimString(normalized.command)
149
+ const filePath = trimString(normalized.path)
150
+ if (action) normalized.action = action
151
+ if (command) normalized.command = command
152
+ if (filePath) normalized.path = filePath
153
+
154
+ const status = trimString(normalized.status).toLowerCase()
155
+ normalized.status = VALID_STATUSES.has(status) ? status : 'active'
156
+
157
+ const agentId = trimString(normalized.agentId)
158
+ if (!agentId) {
159
+ return { ok: false, error: 'Error: schedules require a target agentId.' }
160
+ }
161
+ normalized.agentId = agentId
162
+
163
+ const taskPrompt = deriveTaskPrompt(normalized)
164
+ if (!taskPrompt) {
165
+ return { ok: false, error: 'Error: schedules require a taskPrompt, command, or action/path payload.' }
166
+ }
167
+ normalized.taskPrompt = taskPrompt
168
+
169
+ const validationError = validateScheduleArtifacts(normalized, baseDir)
170
+ if (validationError) return { ok: false, error: `Error: ${validationError}` }
171
+
172
+ if (normalized.nextRunAt == null) {
173
+ if (normalized.scheduleType === 'once') {
174
+ const runAt = normalizePositiveInt(normalized.runAt)
175
+ if (runAt != null) normalized.nextRunAt = runAt
176
+ } else if (normalized.scheduleType === 'interval') {
177
+ const intervalMs = normalizePositiveInt(normalized.intervalMs)
178
+ if (intervalMs != null) normalized.nextRunAt = now + intervalMs
179
+ }
180
+ }
181
+
182
+ return { ok: true, value: normalized }
183
+ }
184
+
185
+ export function extractScheduleCommandScriptPath(command: string): string | null {
186
+ return extractScriptPathFromCommand(command)
187
+ }
@@ -22,6 +22,18 @@ describe('browser workflow surface', () => {
22
22
  assert.equal(src.includes(`'${action}'`), true, `web.ts should expose ${action}`)
23
23
  }
24
24
  })
25
+
26
+ it('supports the shorthand form-map path for fill_form', () => {
27
+ const src = readToolSource('web.ts')
28
+ assert.equal(src.includes('params.form'), true)
29
+ assert.equal(src.includes('fields is required for fill_form.'), true)
30
+ })
31
+
32
+ it('flags pages that require human-provided input', () => {
33
+ const src = readToolSource('web.ts')
34
+ assert.equal(src.includes("type: 'human_input_required'"), true)
35
+ assert.equal(src.includes('Ask the human instead of guessing'), true)
36
+ })
25
37
  })
26
38
 
27
39
  describe('durable wait surface', () => {
@@ -40,6 +52,17 @@ describe('durable wait surface', () => {
40
52
  })
41
53
  })
42
54
 
55
+ describe('sandbox surface', () => {
56
+ it('advertises a Deno-only sandbox and steers simple APIs to http_request', () => {
57
+ const src = readToolSource('sandbox.ts')
58
+ assert.equal(src.includes("enum: ['javascript', 'typescript']"), true)
59
+ assert.equal(src.includes('http_request'), true)
60
+ assert.equal(src.includes('plugin_creator'), true)
61
+ assert.equal(src.includes('manage_schedules'), true)
62
+ assert.equal(src.includes('openclaw_sandbox'), false)
63
+ })
64
+ })
65
+
43
66
  describe('delegation job handles', () => {
44
67
  it('exposes subagent control actions', () => {
45
68
  const src = readToolSource('subagent.ts')
@@ -30,6 +30,7 @@ import {
30
30
  validateManagedAgentAssignment,
31
31
  } from '@/lib/server/agent-assignment'
32
32
  import { normalizeTaskQualityGate } from '@/lib/server/task-quality-gate'
33
+ import { normalizeSchedulePayload } from '@/lib/server/schedule-normalization'
33
34
  import type { ToolBuildContext } from './context'
34
35
  import { safePath, findBinaryOnPath } from './context'
35
36
  import { normalizeToolInputArgs } from './normalize-tool-args'
@@ -261,6 +262,9 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
261
262
  if (!hasPlugin(toolKey)) continue
262
263
 
263
264
  let description = `Manage SwarmClaw ${res.label}. ${res.readOnly ? 'List and get only.' : 'List, get, create, update, or delete.'} Returns JSON.`
265
+ if (toolKey.startsWith('manage_') && toolKey !== 'manage_platform') {
266
+ description += `\n\nUse this direct tool name exactly as shown (\`${toolKey}\`). Do not swap it for \`manage_platform\` unless that umbrella tool is separately enabled in the current session.`
267
+ }
264
268
  if (toolKey === 'manage_tasks') {
265
269
  if (assignScope === 'self') {
266
270
  description += `\n\nYou may create tasks for yourself ("${ctx?.agentId || 'unknown'}") or leave them unassigned to track multi-step work. You cannot assign tasks to other agents unless a user enables "Assign to Other Agents" in your agent settings. Valid manual statuses: backlog, queued, completed, failed, archived. "running" is runtime-only and set automatically when execution starts.`
@@ -271,9 +275,9 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
271
275
  description += `\n\nAgents may self-edit their own soul. To update your soul, use action="update", id="${ctx?.agentId || 'your-agent-id'}", and include data with the "soul" field. Set "platformAssignScope":"all" to let an agent delegate work across the fleet; use "self" for solo execution.`
272
276
  } else if (toolKey === 'manage_schedules') {
273
277
  if (assignScope === 'self') {
274
- description += `\n\nSet "agentId" to assign a schedule to yourself ("${ctx?.agentId || 'unknown'}") or leave it null. You can only assign schedules to yourself. Schedule types: interval (set intervalMs), cron (set cron), once (set runAt). Set taskPrompt for what the agent should do. Before create, call list/get to avoid duplicate schedules. If an equivalent active/paused schedule already exists, create returns that existing schedule (deduplicated=true).`
278
+ description += `\n\nOmit "agentId" to assign a schedule to yourself ("${ctx?.agentId || 'unknown'}"), or set it explicitly to yourself. You can only assign schedules to yourself. Schedule types: interval (set intervalMs), cron (set cron), once (set runAt). Provide either taskPrompt, command, or action+path. Before create, call list/get to avoid duplicate schedules. If an equivalent active/paused schedule already exists, create returns that existing schedule (deduplicated=true).`
275
279
  } else {
276
- description += `\n\nSet "agentId" to assign a schedule to an agent (including yourself: "${ctx?.agentId || 'unknown'}"). Schedule types: interval (set intervalMs), cron (set cron), once (set runAt). Set taskPrompt for what the agent should do. Before create, call list/get to avoid duplicate schedules. If an equivalent active/paused schedule already exists, create returns that existing schedule (deduplicated=true).` + agentSummary
280
+ description += `\n\nOmit "agentId" to assign a schedule to yourself ("${ctx?.agentId || 'unknown'}"), or set "agentId" to another agent when needed. Schedule types: interval (set intervalMs), cron (set cron), once (set runAt). Provide either taskPrompt, command, or action+path. Before create, call list/get to avoid duplicate schedules. If an equivalent active/paused schedule already exists, create returns that existing schedule (deduplicated=true).` + agentSummary
277
281
  }
278
282
  } else if (toolKey === 'manage_webhooks') {
279
283
  description += '\n\nUse `source`, `events`, `agentId`, and `secret` when creating webhooks. Inbound calls should POST to `/api/webhooks/{id}` with header `x-webhook-secret` when a secret is configured.'
@@ -350,7 +354,9 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
350
354
  const resolution = resolveManagedAgentAssignment(
351
355
  parsed as Record<string, unknown>,
352
356
  agents,
353
- toolKey === 'manage_tasks' ? (parsed.agentId || ctx?.agentId || null) : null,
357
+ toolKey === 'manage_tasks' || toolKey === 'manage_schedules'
358
+ ? (parsed.agentId || ctx?.agentId || null)
359
+ : null,
354
360
  { allowDescription: toolKey === 'manage_tasks' },
355
361
  )
356
362
  const assignmentError = validateManagedAgentAssignment({
@@ -369,6 +375,12 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
369
375
  parsed.agentId = resolution.agentId
370
376
  }
371
377
  if (toolKey === 'manage_schedules') {
378
+ const normalizedSchedule = normalizeSchedulePayload(parsed as Record<string, unknown>, {
379
+ cwd,
380
+ now,
381
+ })
382
+ if (!normalizedSchedule.ok) return normalizedSchedule.error
383
+ Object.assign(parsed, normalizedSchedule.value)
372
384
  const duplicate = findDuplicateSchedule(all as Record<string, ScheduleLike>, {
373
385
  agentId: parsed.agentId || null,
374
386
  taskPrompt: parsed.taskPrompt || '',
@@ -552,6 +564,18 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
552
564
  }
553
565
  }
554
566
  all[id] = { ...all[id], ...parsed, updatedAt: Date.now() }
567
+ if (toolKey === 'manage_schedules') {
568
+ const normalizedSchedule = normalizeSchedulePayload(all[id] as Record<string, unknown>, {
569
+ cwd,
570
+ now: Date.now(),
571
+ })
572
+ if (!normalizedSchedule.ok) return normalizedSchedule.error
573
+ all[id] = {
574
+ ...all[id],
575
+ ...normalizedSchedule.value,
576
+ updatedAt: Date.now(),
577
+ }
578
+ }
555
579
  if (toolKey === 'manage_secrets') {
556
580
  if (!canAccessSecret(all[id])) return 'Error: you do not have access to this secret.'
557
581
  const nextScope = parsed.scope === 'agent'
@@ -0,0 +1,170 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-discovery-approval-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ },
20
+ encoding: 'utf-8',
21
+ })
22
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
23
+ const lines = (result.stdout || '')
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean)
28
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
29
+ return JSON.parse(jsonLine || '{}')
30
+ } finally {
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ describe('discovery approval flows', () => {
36
+ it('request_tool_access creates a real approval and grants the tool when auto-approved', () => {
37
+ const output = runWithTempDataDir(`
38
+ const storageMod = await import('./src/lib/server/storage.ts')
39
+ const toolsMod = await import('./src/lib/server/session-tools/index.ts')
40
+ const storage = storageMod.default || storageMod
41
+ const toolsApi = toolsMod.default || toolsMod
42
+
43
+ storage.saveSettings({ approvalsEnabled: false })
44
+
45
+ const now = Date.now()
46
+ storage.saveSessions({
47
+ session_tools: {
48
+ id: 'session_tools',
49
+ name: 'Tool Access Test',
50
+ cwd: process.env.WORKSPACE_DIR,
51
+ user: 'tester',
52
+ provider: 'openai',
53
+ model: 'gpt-test',
54
+ claudeSessionId: null,
55
+ messages: [],
56
+ createdAt: now,
57
+ lastActiveAt: now,
58
+ sessionType: 'human',
59
+ agentId: 'default',
60
+ plugins: [],
61
+ },
62
+ })
63
+
64
+ const built = await toolsApi.buildSessionTools(process.env.WORKSPACE_DIR, [], {
65
+ sessionId: 'session_tools',
66
+ agentId: 'default',
67
+ platformAssignScope: 'self',
68
+ })
69
+ const tool = built.tools.find((entry) => entry.name === 'request_tool_access')
70
+ const raw = await tool.invoke({ toolId: 'shell', reason: 'Need terminal access.' })
71
+ const approvals = storage.loadApprovals()
72
+ const session = storage.loadSessions().session_tools
73
+ console.log(JSON.stringify({
74
+ raw,
75
+ approvalCount: Object.keys(approvals).length,
76
+ plugins: session.plugins || [],
77
+ }))
78
+ `)
79
+
80
+ assert.match(String(output.raw), /auto-approved|granted/i)
81
+ assert.equal(output.approvalCount, 1)
82
+ assert.equal(output.plugins.includes('shell'), true)
83
+ })
84
+
85
+ it('manage_capabilities request_access accepts query aliases for pluginId', () => {
86
+ const output = runWithTempDataDir(`
87
+ const storageMod = await import('./src/lib/server/storage.ts')
88
+ const toolsMod = await import('./src/lib/server/session-tools/index.ts')
89
+ const storage = storageMod.default || storageMod
90
+ const toolsApi = toolsMod.default || toolsMod
91
+
92
+ storage.saveSettings({ approvalsEnabled: false })
93
+
94
+ const now = Date.now()
95
+ storage.saveSessions({
96
+ session_caps: {
97
+ id: 'session_caps',
98
+ name: 'Capabilities Test',
99
+ cwd: process.env.WORKSPACE_DIR,
100
+ user: 'tester',
101
+ provider: 'openai',
102
+ model: 'gpt-test',
103
+ claudeSessionId: null,
104
+ messages: [],
105
+ createdAt: now,
106
+ lastActiveAt: now,
107
+ sessionType: 'human',
108
+ agentId: 'default',
109
+ plugins: [],
110
+ },
111
+ })
112
+
113
+ const built = await toolsApi.buildSessionTools(process.env.WORKSPACE_DIR, [], {
114
+ sessionId: 'session_caps',
115
+ agentId: 'default',
116
+ platformAssignScope: 'self',
117
+ })
118
+ const tool = built.tools.find((entry) => entry.name === 'manage_capabilities')
119
+ const raw = await tool.invoke({ action: 'request_access', query: 'shell', reason: 'Need terminal access.' })
120
+ const session = storage.loadSessions().session_caps
121
+ console.log(JSON.stringify({
122
+ raw,
123
+ plugins: session.plugins || [],
124
+ }))
125
+ `)
126
+
127
+ assert.match(String(output.raw), /auto-approved|granted/i)
128
+ assert.equal(output.plugins.includes('shell'), true)
129
+ })
130
+
131
+ it('granting manage_schedules does not surface the manage_platform umbrella tool', () => {
132
+ const output = runWithTempDataDir(`
133
+ const storageMod = await import('./src/lib/server/storage.ts')
134
+ const toolsMod = await import('./src/lib/server/session-tools/index.ts')
135
+ const storage = storageMod.default || storageMod
136
+ const toolsApi = toolsMod.default || toolsMod
137
+
138
+ const now = Date.now()
139
+ storage.saveSessions({
140
+ session_sched: {
141
+ id: 'session_sched',
142
+ name: 'Schedule Tool Isolation',
143
+ cwd: process.env.WORKSPACE_DIR,
144
+ user: 'tester',
145
+ provider: 'openai',
146
+ model: 'gpt-test',
147
+ claudeSessionId: null,
148
+ messages: [],
149
+ createdAt: now,
150
+ lastActiveAt: now,
151
+ sessionType: 'human',
152
+ agentId: 'default',
153
+ plugins: ['manage_schedules'],
154
+ },
155
+ })
156
+
157
+ const built = await toolsApi.buildSessionTools(process.env.WORKSPACE_DIR, ['manage_schedules'], {
158
+ sessionId: 'session_sched',
159
+ agentId: 'default',
160
+ platformAssignScope: 'self',
161
+ })
162
+ console.log(JSON.stringify({
163
+ toolNames: built.tools.map((entry) => entry.name).sort(),
164
+ }))
165
+ `)
166
+
167
+ assert.equal(output.toolNames.includes('manage_schedules'), true)
168
+ assert.equal(output.toolNames.includes('manage_platform'), false)
169
+ })
170
+ })
@@ -15,15 +15,24 @@ async function executeDiscoveryAction(args: Record<string, unknown>, bctx?: Tool
15
15
  const normalized = normalizeToolInputArgs(args)
16
16
  const action = normalized.action
17
17
  const approved = normalized.approved
18
- const pluginId = typeof normalized.pluginId === 'string'
18
+ const explicitPluginId = typeof normalized.pluginId === 'string'
19
19
  ? normalized.pluginId.trim()
20
20
  : typeof normalized.plugin_id === 'string'
21
21
  ? normalized.plugin_id.trim()
22
- : undefined
22
+ : typeof normalized.toolId === 'string'
23
+ ? normalized.toolId.trim()
24
+ : typeof normalized.tool_id === 'string'
25
+ ? normalized.tool_id.trim()
26
+ : typeof normalized.tool === 'string'
27
+ ? normalized.tool.trim()
28
+ : typeof normalized.name === 'string'
29
+ ? normalized.name.trim()
30
+ : undefined
23
31
  const url = typeof normalized.url === 'string' ? normalized.url.trim() : undefined
24
32
  const reason = normalized.reason as string | undefined
25
33
  const manager = getPluginManager()
26
34
  const q = typeof normalized.query === 'string' ? normalized.query : ''
35
+ const pluginId = explicitPluginId || (action === 'request_access' ? q.trim() : '')
27
36
 
28
37
  console.log('[discovery] Executing action:', action, { query: q, pluginId })
29
38
 
@@ -88,7 +97,8 @@ async function executeDiscoveryAction(args: Record<string, unknown>, bctx?: Tool
88
97
  if (bctx?.ctx?.sessionId) {
89
98
  const allSessions = loadSessions()
90
99
  const currentSession = allSessions[bctx.ctx.sessionId]
91
- if (currentSession && pluginIdMatches(currentSession.tools, pluginId)) {
100
+ const grantedTools = currentSession?.plugins || currentSession?.tools || []
101
+ if (currentSession && pluginIdMatches(grantedTools, pluginId)) {
92
102
  return JSON.stringify({
93
103
  alreadyGranted: true,
94
104
  pluginId,
@@ -176,13 +186,13 @@ const DiscoveryPlugin: Plugin = {
176
186
  tools: [
177
187
  {
178
188
  name: 'manage_capabilities',
179
- description: 'Search for available plugins locally or in external marketplaces.',
189
+ description: 'Discover currently available tools, search marketplaces, or request access to a direct tool/plugin name with action="request_access" (for example "shell", "manage_schedules", or "delegate").',
180
190
  parameters: {
181
191
  type: 'object',
182
192
  properties: {
183
193
  action: { type: 'string', enum: ['discover', 'search_marketplace', 'request_access', 'install_request'] },
184
- query: { type: 'string', description: 'Search term for marketplace' },
185
- pluginId: { type: 'string', description: 'The ID or filename of the plugin' },
194
+ query: { type: 'string', description: 'Search term for marketplace, or the direct tool/plugin name for request_access' },
195
+ pluginId: { type: 'string', description: 'The exact tool/plugin name to request, such as "shell" or "manage_schedules"' },
186
196
  url: { type: 'string', description: 'URL for new plugin install request' },
187
197
  reason: { type: 'string', description: 'Why you need this capability' }
188
198
  },
@@ -205,8 +215,8 @@ export function buildDiscoveryTools(bctx: ToolBuildContext): StructuredToolInter
205
215
  description: DiscoveryPlugin.tools![0].description,
206
216
  schema: z.object({
207
217
  action: z.enum(['discover', 'search_marketplace', 'request_access', 'install_request']).describe('The discovery action to perform'),
208
- query: z.string().optional().describe('The search query for marketplace actions'),
209
- pluginId: z.string().optional(),
218
+ query: z.string().optional().describe('The marketplace query, or the direct tool/plugin name to request access to'),
219
+ pluginId: z.string().optional().describe('The exact tool/plugin name to request, such as "shell" or "manage_schedules"'),
210
220
  url: z.string().optional(),
211
221
  reason: z.string().describe('Why you need to perform this discovery action')
212
222
  })
@@ -38,6 +38,11 @@ describe('normalizeFileArgs', () => {
38
38
  assert.equal(out.dirPath, 'docs')
39
39
  })
40
40
 
41
+ it('defaults empty file payloads to a workspace listing instead of an unknown action', () => {
42
+ const out = normalizeFileArgs({})
43
+ assert.equal(out.action, 'list')
44
+ })
45
+
41
46
  it('infers write from bulk file entries with text content', () => {
42
47
  const out = normalizeFileArgs({
43
48
  files: [