@swarmclawai/swarmclaw 1.2.6 → 1.2.8

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 (112) hide show
  1. package/README.md +24 -17
  2. package/next.config.ts +1 -0
  3. package/package.json +3 -2
  4. package/scripts/easy-setup.mjs +1 -1
  5. package/scripts/postinstall.mjs +1 -1
  6. package/skills/swarmclaw.md +115 -0
  7. package/skills/tools/browser.md +131 -0
  8. package/skills/tools/execute.md +98 -0
  9. package/skills/tools/files.md +98 -0
  10. package/skills/tools/memory.md +104 -0
  11. package/skills/tools/platform.md +144 -0
  12. package/skills/tools/skills.md +83 -0
  13. package/src/app/api/chats/[id]/messages/route.ts +23 -19
  14. package/src/app/api/chats/messages-route.test.ts +105 -51
  15. package/src/app/api/mcp-servers/[id]/test/route.ts +3 -2
  16. package/src/app/api/openclaw/deploy/route.ts +2 -0
  17. package/src/app/api/setup/doctor/route.ts +4 -4
  18. package/src/components/agents/agent-chat-list.tsx +23 -1
  19. package/src/components/agents/inspector-panel.tsx +165 -48
  20. package/src/components/chat/chat-area.tsx +38 -9
  21. package/src/components/chat/message-list.tsx +33 -19
  22. package/src/components/gateways/gateway-sheet.tsx +5 -2
  23. package/src/lib/agent-execute-defaults.test.ts +24 -0
  24. package/src/lib/agent-execute-defaults.ts +62 -0
  25. package/src/lib/chat/queued-message-queue.test.ts +134 -1
  26. package/src/lib/chat/queued-message-queue.ts +77 -2
  27. package/src/lib/server/agents/agent-service.ts +5 -0
  28. package/src/lib/server/builtin-extensions.ts +1 -0
  29. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +1 -1
  30. package/src/lib/server/chat-execution/chat-execution-tool-events.test.ts +1 -0
  31. package/src/lib/server/chat-execution/chat-execution-utils.ts +2 -2
  32. package/src/lib/server/chat-execution/chat-turn-preparation.ts +79 -42
  33. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +4 -0
  34. package/src/lib/server/chat-execution/continuation-evaluator.ts +8 -0
  35. package/src/lib/server/chat-execution/memory-mutation-tools.ts +1 -1
  36. package/src/lib/server/chat-execution/message-classifier.ts +11 -1
  37. package/src/lib/server/chat-execution/prompt-builder.test.ts +28 -0
  38. package/src/lib/server/chat-execution/prompt-builder.ts +14 -1
  39. package/src/lib/server/chat-execution/prompt-mode.test.ts +24 -0
  40. package/src/lib/server/chat-execution/prompt-mode.ts +5 -1
  41. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -4
  42. package/src/lib/server/chat-execution/stream-agent-chat.ts +45 -16
  43. package/src/lib/server/chatrooms/chatroom-routing.test.ts +4 -0
  44. package/src/lib/server/connectors/discord.ts +2 -2
  45. package/src/lib/server/connectors/matrix.ts +3 -2
  46. package/src/lib/server/connectors/signal.ts +5 -4
  47. package/src/lib/server/connectors/slack.ts +10 -9
  48. package/src/lib/server/connectors/teams.ts +3 -2
  49. package/src/lib/server/connectors/telegram.ts +4 -4
  50. package/src/lib/server/connectors/whatsapp.ts +2 -2
  51. package/src/lib/server/daemon/controller.ts +7 -0
  52. package/src/lib/server/gateways/gateway-profile-service.ts +19 -1
  53. package/src/lib/server/messages/message-repository.test.ts +70 -0
  54. package/src/lib/server/messages/message-repository.ts +11 -6
  55. package/src/lib/server/openclaw/deploy.ts +32 -2
  56. package/src/lib/server/plugins-advanced.test.ts +1 -2
  57. package/src/lib/server/provider-health.ts +1 -1
  58. package/src/lib/server/runtime/process-manager.ts +13 -9
  59. package/src/lib/server/runtime/session-run-manager/queries.ts +15 -0
  60. package/src/lib/server/runtime/session-run-manager.test.ts +58 -0
  61. package/src/lib/server/sandbox/session-runtime.test.ts +18 -1
  62. package/src/lib/server/sandbox/session-runtime.ts +40 -28
  63. package/src/lib/server/session-tools/autonomy-tools.test.ts +7 -9
  64. package/src/lib/server/session-tools/context.ts +1 -1
  65. package/src/lib/server/session-tools/credential-env.ts +109 -0
  66. package/src/lib/server/session-tools/crud.ts +3 -3
  67. package/src/lib/server/session-tools/edit_file.ts +3 -2
  68. package/src/lib/server/session-tools/execute.test.ts +58 -0
  69. package/src/lib/server/session-tools/execute.ts +334 -0
  70. package/src/lib/server/session-tools/files-tool.ts +635 -0
  71. package/src/lib/server/session-tools/index.ts +14 -4
  72. package/src/lib/server/session-tools/memory-tool.ts +242 -0
  73. package/src/lib/server/session-tools/memory.ts +1 -1
  74. package/src/lib/server/session-tools/openclaw-nodes.ts +3 -2
  75. package/src/lib/server/session-tools/openclaw-workspace.ts +3 -2
  76. package/src/lib/server/session-tools/platform-tool.ts +617 -0
  77. package/src/lib/server/session-tools/session-info.ts +3 -2
  78. package/src/lib/server/session-tools/session-tools-wiring.test.ts +3 -4
  79. package/src/lib/server/session-tools/shell.ts +7 -122
  80. package/src/lib/server/session-tools/skills-tool.ts +396 -0
  81. package/src/lib/server/session-tools/web.ts +2 -2
  82. package/src/lib/server/storage-normalization.ts +2 -0
  83. package/src/lib/server/tool-aliases.ts +2 -1
  84. package/src/lib/server/tool-capability-policy-advanced.test.ts +9 -2
  85. package/src/lib/server/tool-capability-policy.test.ts +2 -1
  86. package/src/lib/server/tool-capability-policy.ts +60 -33
  87. package/src/lib/server/tool-planning.ts +11 -0
  88. package/src/lib/setup-defaults.ts +5 -0
  89. package/src/lib/tool-definitions.ts +1 -0
  90. package/src/lib/validation/schemas.test.ts +16 -0
  91. package/src/lib/validation/schemas.ts +16 -0
  92. package/src/stores/use-chat-store.test.ts +231 -0
  93. package/src/stores/use-chat-store.ts +62 -13
  94. package/src/types/agent.ts +348 -0
  95. package/src/types/app-settings.ts +175 -0
  96. package/src/types/approval.ts +27 -0
  97. package/src/types/connector.ts +187 -0
  98. package/src/types/extension.ts +386 -0
  99. package/src/types/index.ts +16 -3555
  100. package/src/types/message.ts +57 -0
  101. package/src/types/misc.ts +739 -0
  102. package/src/types/mission.ts +185 -0
  103. package/src/types/protocol.ts +422 -0
  104. package/src/types/provider.ts +52 -0
  105. package/src/types/run.ts +183 -0
  106. package/src/types/schedule.ts +59 -0
  107. package/src/types/session.ts +265 -0
  108. package/src/types/skill.ts +157 -0
  109. package/src/types/task.ts +140 -0
  110. package/src/types/working-state.ts +211 -0
  111. package/src/views/settings/section-heartbeat.tsx +2 -2
  112. package/src/lib/server/session-tools/sandbox.ts +0 -281
@@ -107,6 +107,11 @@ function syncSessionMeta(sessionId: string): void {
107
107
  })
108
108
  }
109
109
 
110
+ function notifyMessageTopics(sessionId: string, action: string): void {
111
+ notify('messages', action, sessionId)
112
+ notify(`messages:${sessionId}`, action)
113
+ }
114
+
110
115
  // ---------------------------------------------------------------------------
111
116
  // Lazy migration — copies blob messages → table on first access
112
117
  // ---------------------------------------------------------------------------
@@ -229,7 +234,7 @@ export function appendMessage(sessionId: string, message: Message): number {
229
234
  const seq = nextSeq(sessionId)
230
235
  stmts().insert.run(sessionId, seq, JSON.stringify(message))
231
236
  syncSessionMeta(sessionId)
232
- notify('messages', 'append', sessionId)
237
+ notifyMessageTopics(sessionId, 'append')
233
238
  return seq
234
239
  }, { sessionId })
235
240
  }
@@ -247,7 +252,7 @@ export function appendMessages(sessionId: string, messages: Message[]): void {
247
252
  }
248
253
  })
249
254
  syncSessionMeta(sessionId)
250
- notify('messages', 'append', sessionId)
255
+ notifyMessageTopics(sessionId, 'append')
251
256
  }, { sessionId, count: messages.length })
252
257
  }
253
258
 
@@ -256,7 +261,7 @@ export function replaceMessageAt(sessionId: string, seq: number, message: Messag
256
261
  perf.measureSync('message-repo', 'replaceMessageAt', () => {
257
262
  stmts().update.run(JSON.stringify(message), sessionId, seq)
258
263
  syncSessionMeta(sessionId)
259
- notify('messages', 'update', sessionId)
264
+ notifyMessageTopics(sessionId, 'update')
260
265
  }, { sessionId, seq })
261
266
  }
262
267
 
@@ -265,7 +270,7 @@ export function truncateAfter(sessionId: string, seq: number): void {
265
270
  perf.measureSync('message-repo', 'truncateAfter', () => {
266
271
  stmts().deleteAfter.run(sessionId, seq)
267
272
  syncSessionMeta(sessionId)
268
- notify('messages', 'truncate', sessionId)
273
+ notifyMessageTopics(sessionId, 'truncate')
269
274
  }, { sessionId, seq })
270
275
  }
271
276
 
@@ -274,7 +279,7 @@ export function clearMessages(sessionId: string): void {
274
279
  perf.measureSync('message-repo', 'clearMessages', () => {
275
280
  stmts().deleteAll.run(sessionId)
276
281
  syncSessionMeta(sessionId)
277
- notify('messages', 'clear', sessionId)
282
+ notifyMessageTopics(sessionId, 'clear')
278
283
  }, { sessionId })
279
284
  }
280
285
 
@@ -289,7 +294,7 @@ export function replaceAllMessages(sessionId: string, messages: Message[]): void
289
294
  }
290
295
  })
291
296
  syncSessionMeta(sessionId)
292
- notify('messages', 'replace', sessionId)
297
+ notifyMessageTopics(sessionId, 'replace')
293
298
  }, { sessionId, count: messages.length })
294
299
  }
295
300
 
@@ -12,6 +12,9 @@ import {
12
12
  type ProcessStatus,
13
13
  } from '@/lib/server/runtime/process-manager'
14
14
  import { normalizeOpenClawEndpoint, deriveOpenClawWsUrl } from '@/lib/openclaw/openclaw-endpoint'
15
+ import { createCredentialRecord } from '@/lib/server/credentials/credential-service'
16
+ import { createGatewayProfile } from '@/lib/server/gateways/gateway-profile-service'
17
+ import { loadGatewayProfiles } from '@/lib/server/gateways/gateway-profile-repository'
15
18
  import { probeOpenClawHealth, type OpenClawHealthResult } from './health'
16
19
  import { DATA_DIR } from '../data-dir'
17
20
 
@@ -1046,7 +1049,7 @@ export async function startOpenClawLocalDeploy(input?: {
1046
1049
  port?: number
1047
1050
  token?: string | null
1048
1051
  makePrimary?: boolean
1049
- }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string }> {
1052
+ }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string; gatewayProfileId: string | null }> {
1050
1053
  const state = getRuntimeState()
1051
1054
  const port = sanitizeLocalPort(input?.port, DEFAULT_LOCAL_PORT)
1052
1055
  const requestedLocalId = typeof input?.localId === 'string' && input.localId.trim()
@@ -1145,11 +1148,38 @@ export async function startOpenClawLocalDeploy(input?: {
1145
1148
 
1146
1149
  await waitForLocalRuntime(result.processId)
1147
1150
 
1151
+ // Auto-provision gateway profile so agents can connect immediately
1152
+ const existingGateways = loadGatewayProfiles()
1153
+ const existingProfile = Object.values(existingGateways).find(
1154
+ (gw) => gw && gw.endpoint === endpoint,
1155
+ )
1156
+ let gatewayProfileId: string | null = null
1157
+
1158
+ if (existingProfile) {
1159
+ gatewayProfileId = existingProfile.id
1160
+ } else {
1161
+ const deployName = current.name || 'Local OpenClaw'
1162
+ const cred = createCredentialRecord({
1163
+ provider: 'openclaw',
1164
+ name: `${deployName} token`,
1165
+ apiKey: token,
1166
+ })
1167
+ const profile = createGatewayProfile({
1168
+ name: deployName,
1169
+ endpoint,
1170
+ credentialId: cred.id,
1171
+ isDefault: Object.keys(existingGateways).length === 0,
1172
+ deployment: { method: 'local', port, localId },
1173
+ })
1174
+ gatewayProfileId = profile.id
1175
+ }
1176
+
1148
1177
  const local = getOpenClawLocalDeployStatus(localId)
1149
1178
  return {
1150
1179
  local,
1151
1180
  locals: getOpenClawLocalDeployStatuses(),
1152
1181
  token,
1182
+ gatewayProfileId,
1153
1183
  }
1154
1184
  }
1155
1185
 
@@ -1190,7 +1220,7 @@ export async function restartOpenClawLocalDeploy(input?: {
1190
1220
  port?: number
1191
1221
  token?: string | null
1192
1222
  makePrimary?: boolean
1193
- }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string }> {
1223
+ }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string; gatewayProfileId: string | null }> {
1194
1224
  const state = getRuntimeState()
1195
1225
  const current = findLocalRuntimeState(state, {
1196
1226
  localId: input?.localId ?? null,
@@ -340,8 +340,7 @@ describe('complex expansion scenarios', () => {
340
340
  it('sandbox aliases expand', () => {
341
341
  const result = expandExtensionIds(['sandbox'])
342
342
  assert.ok(result.includes('sandbox'))
343
- assert.ok(result.includes('sandbox_exec'))
344
- assert.ok(result.includes('sandbox_list_runtimes'))
343
+ assert.ok(result.includes('execute'))
345
344
  })
346
345
 
347
346
  it('files expands to include read_file, write_file, etc.', () => {
@@ -42,7 +42,7 @@ function commandExists(binary: string): boolean {
42
42
  pruneTTLCache(cliCheckCache, CLI_CHECK_TTL_MS, CLI_CHECK_CACHE_MAX)
43
43
  const probe = isWindows
44
44
  ? spawnSync('where', [binary], { timeout: 2000, stdio: 'pipe' })
45
- : spawnSync('/bin/zsh', ['-lc', `command -v ${binary} >/dev/null 2>&1`], { timeout: 2000 })
45
+ : spawnSync(process.env.SHELL || '/bin/bash', ['-lc', `command -v ${binary} >/dev/null 2>&1`], { timeout: 2000 })
46
46
  const ok = (probe.status ?? 1) === 0
47
47
  cliCheckCache.set(binary, { at: now, ok })
48
48
  return ok
@@ -1,5 +1,5 @@
1
1
  import { genId } from '@/lib/id'
2
- import { hmrSingleton, sleep } from '@/lib/shared-utils'
2
+ import { hmrSingleton, sleep, errorMessage } from '@/lib/shared-utils'
3
3
  import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
4
4
  import { detectDocker } from '@/lib/server/sandbox/docker-detect'
5
5
  import { log } from '@/lib/server/logger'
@@ -169,7 +169,8 @@ export function buildDockerExecArgs(params: {
169
169
 
170
170
  export function getShellCommand(command: string, processId?: string, sandbox?: SandboxOptions): { shell: string; args: string[]; containerName?: string } {
171
171
  if (!sandbox) {
172
- return { shell: '/bin/zsh', args: ['-lc', command] }
172
+ const shell = process.env.SHELL || '/bin/bash'
173
+ return { shell, args: ['-lc', command] }
173
174
  }
174
175
 
175
176
  if (sandbox.kind === 'persistent') {
@@ -247,9 +248,12 @@ export async function startManagedProcess(opts: StartProcessOptions): Promise<St
247
248
  state.records.set(id, record)
248
249
 
249
250
  const { shell, args } = getShellCommand(opts.command, id, opts.sandbox)
250
- // Strip SwarmClaw-specific env vars so agent child processes don't inherit
251
- // our port binding or internal keys (e.g. npm dev servers defaulting to :3456)
252
- const stripKeys = new Set(['PORT', 'ACCESS_KEY', 'NEXTAUTH_SECRET'])
251
+ // Strip env vars that should not leak into child processes:
252
+ // - PORT, ACCESS_KEY, NEXTAUTH_SECRET: SwarmClaw-specific bindings
253
+ // - npm_config_prefix: injected by npm when running scripts; conflicts with nvm
254
+ // in login shell subprocesses (nvm refuses to initialize when this is set,
255
+ // breaking node PATH resolution for all nvm users)
256
+ const stripKeys = new Set(['PORT', 'ACCESS_KEY', 'NEXTAUTH_SECRET', 'npm_config_prefix'])
253
257
  const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !stripKeys.has(k))) as NodeJS.ProcessEnv
254
258
  const child = spawn(shell, args, {
255
259
  cwd: opts.sandbox ? undefined : opts.cwd,
@@ -405,8 +409,8 @@ export function writeManagedProcessStdin(processId: string, data: string, eof?:
405
409
  if (data) child.stdin.write(data)
406
410
  if (eof) child.stdin.end()
407
411
  return { ok: true }
408
- } catch (err: any) {
409
- return { ok: false, error: err.message || String(err) }
412
+ } catch (err: unknown) {
413
+ return { ok: false, error: errorMessage(err) || String(err) }
410
414
  }
411
415
  }
412
416
 
@@ -418,8 +422,8 @@ export function killManagedProcess(processId: string, signal: NodeJS.Signals = '
418
422
  rec.status = 'killed'
419
423
  child.kill(signal)
420
424
  return { ok: true }
421
- } catch (err: any) {
422
- return { ok: false, error: err.message || String(err) }
425
+ } catch (err: unknown) {
426
+ return { ok: false, error: errorMessage(err) || String(err) }
423
427
  }
424
428
  }
425
429
 
@@ -49,12 +49,27 @@ function toQueuedTurn(entry: SessionRunQueueEntry, index: number): SessionQueued
49
49
  }
50
50
  }
51
51
 
52
+ function toActiveTurn(entry: SessionRunQueueEntry): SessionQueuedTurn {
53
+ return {
54
+ ...toQueuedTurn(entry, 0),
55
+ position: 0,
56
+ }
57
+ }
58
+
59
+ function visibleActiveTurnForSession(sessionId: string): SessionQueuedTurn | null {
60
+ const running = Array.from(state.runningByExecution.values())
61
+ .find((entry) => entry.run.sessionId === sessionId && entry.run.status === 'running')
62
+ if (!running || running.run.internal === true) return null
63
+ return toActiveTurn(running)
64
+ }
65
+
52
66
  export function getSessionQueueSnapshot(sessionId: string): SessionQueueSnapshot {
53
67
  const execution = getSessionExecutionState(sessionId)
54
68
  const visibleQueued = visibleQueuedEntriesForSession(sessionId)
55
69
  return {
56
70
  sessionId,
57
71
  activeRunId: execution.runningRunId || null,
72
+ activeTurn: visibleActiveTurnForSession(sessionId),
58
73
  queueLength: visibleQueued.length,
59
74
  items: visibleQueued.map((entry, index) => toQueuedTurn(entry, index)),
60
75
  }
@@ -521,6 +521,37 @@ describe('session-run-manager', () => {
521
521
  })
522
522
  })
523
523
 
524
+ it('exposes the active user-visible turn separately from queued follow-ups', () => {
525
+ const running = enqueue({
526
+ sessionId: 'sess-active-turn',
527
+ message: 'running now',
528
+ source: 'chat',
529
+ })
530
+ const queued = enqueue({
531
+ sessionId: 'sess-active-turn',
532
+ message: 'queued next',
533
+ source: 'chat',
534
+ })
535
+
536
+ const snapshot = mgr.getSessionQueueSnapshot('sess-active-turn')
537
+ assert.equal(snapshot.activeRunId, running.runId)
538
+ assert.deepEqual(snapshot.activeTurn, {
539
+ runId: running.runId,
540
+ sessionId: 'sess-active-turn',
541
+ missionId: null,
542
+ text: 'running now',
543
+ queuedAt: snapshot.activeTurn?.queuedAt,
544
+ position: 0,
545
+ imagePath: undefined,
546
+ imageUrl: undefined,
547
+ attachedFiles: undefined,
548
+ replyToId: undefined,
549
+ source: 'chat',
550
+ })
551
+ assert.deepEqual(snapshot.items.map((item) => item.runId), [queued.runId])
552
+ assert.equal(snapshot.queueLength, 1)
553
+ })
554
+
524
555
  it('hides internal queued runs from the user-visible queue snapshot', () => {
525
556
  enqueue({ sessionId: 'sess-visible-queue', message: 'running' })
526
557
  enqueue({
@@ -538,9 +569,36 @@ describe('session-run-manager', () => {
538
569
 
539
570
  const snapshot = mgr.getSessionQueueSnapshot('sess-visible-queue')
540
571
  assert.equal(snapshot.queueLength, 1)
572
+ assert.equal(snapshot.activeTurn?.runId, snapshot.activeRunId)
541
573
  assert.deepEqual(snapshot.items.map((item) => item.runId), [visible.runId])
542
574
  })
543
575
 
576
+ it('hides internal active runs from the active-turn snapshot field', () => {
577
+ const { entry, promise } = makeManualQueuedEntry({
578
+ sessionId: 'sess-hidden-active',
579
+ runId: 'run-hidden',
580
+ message: 'heartbeat hidden',
581
+ internal: true,
582
+ source: 'heartbeat',
583
+ })
584
+ entry.run.status = 'running'
585
+ entry.run.startedAt = Date.now()
586
+ const state = getRuntimeState()
587
+ state.runningByExecution.set(entry.executionKey, entry as unknown)
588
+ state.runs.set(entry.run.id, entry.run)
589
+ state.promises.set(entry.run.id, promise)
590
+ pendingPromises.push(promise.catch(() => {}))
591
+
592
+ try {
593
+ const snapshot = mgr.getSessionQueueSnapshot('sess-hidden-active')
594
+ assert.equal(snapshot.activeRunId, 'run-hidden')
595
+ assert.equal(snapshot.activeTurn, null)
596
+ assert.equal(snapshot.queueLength, 0)
597
+ } finally {
598
+ entry.resolve(undefined)
599
+ }
600
+ })
601
+
544
602
  it('reports heartbeat vs non-heartbeat queued runs', () => {
545
603
  enqueue({ sessionId: 'sess-hb-state', message: 'occupier' })
546
604
  enqueue({
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
- import { resolveSandboxRuntimeStatus, resolveSandboxWorkdir } from '@/lib/server/sandbox/session-runtime'
3
+ import { resolveSandboxRuntimeStatus, resolveSandboxSessionContext, resolveSandboxWorkdir } from '@/lib/server/sandbox/session-runtime'
4
4
  import type { Session } from '@/types'
5
5
 
6
6
  test('resolveSandboxRuntimeStatus defaults enabled sandboxes to all sessions', () => {
@@ -49,6 +49,23 @@ test('resolveSandboxRuntimeStatus sandboxes child sessions in non-main mode', ()
49
49
  assert.equal(status.scopeKey, 'agent:agent-1')
50
50
  })
51
51
 
52
+ test('resolveSandboxSessionContext resolves browser-compatible workspace context without starting containers', () => {
53
+ const context = resolveSandboxSessionContext({
54
+ config: { enabled: true, scope: 'agent', workdir: '/workspace' },
55
+ session: {
56
+ id: 'child-session',
57
+ agentId: 'agent-1',
58
+ parentSessionId: 'main-session',
59
+ } as Session,
60
+ workspaceDir: '/tmp/project',
61
+ })
62
+
63
+ assert.ok(context)
64
+ assert.equal(context?.scopeKey, 'agent:agent-1')
65
+ assert.equal(context?.workspaceDir, '/tmp/project')
66
+ assert.equal(context?.containerWorkdir, '/workspace')
67
+ })
68
+
52
69
  test('resolveSandboxWorkdir maps nested host paths into the container workspace', () => {
53
70
  const resolved = resolveSandboxWorkdir({
54
71
  workspaceDir: '/tmp/project',
@@ -236,15 +236,37 @@ export function resolveSandboxWorkdir(params: {
236
236
  }
237
237
  }
238
238
 
239
- export async function ensureSessionSandbox(params: {
239
+ export function resolveSandboxSessionContext(params: {
240
240
  config: AgentSandboxConfig | null | undefined
241
241
  session?: Session | null
242
242
  agentId?: string | null
243
243
  sessionId?: string | null
244
244
  workspaceDir: string
245
- }): Promise<SandboxSessionContext | null> {
245
+ }): SandboxSessionContext | null {
246
246
  const status = resolveSandboxRuntimeStatus(params)
247
247
  if (!status.sandboxed || !status.config || !status.scopeKey) return null
248
+
249
+ return {
250
+ containerName: `${status.config.containerPrefix}${slugifyScopeKey(status.scopeKey)}`.slice(0, 63),
251
+ containerWorkdir: status.config.workdir,
252
+ workspaceDir: params.workspaceDir,
253
+ workspaceAccess: status.config.workspaceAccess,
254
+ mode: status.mode,
255
+ scope: status.scope,
256
+ scopeKey: status.scopeKey,
257
+ config: status.config,
258
+ }
259
+ }
260
+
261
+ export async function ensureSessionSandbox(params: {
262
+ config: AgentSandboxConfig | null | undefined
263
+ session?: Session | null
264
+ agentId?: string | null
265
+ sessionId?: string | null
266
+ workspaceDir: string
267
+ }): Promise<SandboxSessionContext | null> {
268
+ const context = resolveSandboxSessionContext(params)
269
+ if (!context) return null
248
270
  await maybePruneSandboxes(params.config)
249
271
 
250
272
  const docker = detectDocker()
@@ -252,17 +274,16 @@ export async function ensureSessionSandbox(params: {
252
274
  throw new Error('Sandbox is enabled but Docker is not available. Install Docker Desktop or disable the sandbox in agent settings.')
253
275
  }
254
276
 
255
- const containerName = `${status.config.containerPrefix}${slugifyScopeKey(status.scopeKey)}`.slice(0, 63)
256
277
  const configHash = computeSandboxConfigHash({
257
- config: status.config,
278
+ config: context.config,
258
279
  workspaceDir: params.workspaceDir,
259
- scopeKey: status.scopeKey,
280
+ scopeKey: context.scopeKey,
260
281
  })
261
282
 
262
- const current = await inspectDockerContainer(containerName)
263
- const currentHash = current.exists ? await readDockerLabel(containerName, 'swarmclaw.configHash') : null
283
+ const current = await inspectDockerContainer(context.containerName)
284
+ const currentHash = current.exists ? await readDockerLabel(context.containerName, 'swarmclaw.configHash') : null
264
285
  if (current.exists && currentHash && currentHash !== configHash) {
265
- await execDocker(['rm', '-f', containerName], true)
286
+ await execDocker(['rm', '-f', context.containerName], true)
266
287
  }
267
288
 
268
289
  const next = current.exists && currentHash === configHash
@@ -271,37 +292,28 @@ export async function ensureSessionSandbox(params: {
271
292
 
272
293
  if (!next.exists) {
273
294
  await execDocker(buildSandboxCreateArgs({
274
- containerName,
275
- scopeKey: status.scopeKey,
295
+ containerName: context.containerName,
296
+ scopeKey: context.scopeKey,
276
297
  configHash,
277
- config: status.config,
298
+ config: context.config,
278
299
  workspaceDir: params.workspaceDir,
279
300
  }))
280
- await execDocker(['start', containerName])
281
- if (status.config.setupCommand) {
282
- await execDocker(['exec', '-i', containerName, '/bin/sh', '-lc', status.config.setupCommand])
301
+ await execDocker(['start', context.containerName])
302
+ if (context.config.setupCommand) {
303
+ await execDocker(['exec', '-i', context.containerName, '/bin/sh', '-lc', context.config.setupCommand])
283
304
  }
284
305
  } else if (!next.running) {
285
- await execDocker(['start', containerName])
306
+ await execDocker(['start', context.containerName])
286
307
  }
287
308
 
288
309
  await upsertSandboxRegistryEntry({
289
- containerName,
290
- scopeKey: status.scopeKey,
310
+ containerName: context.containerName,
311
+ scopeKey: context.scopeKey,
291
312
  createdAtMs: Date.now(),
292
313
  lastUsedAtMs: Date.now(),
293
- image: status.config.image,
314
+ image: context.config.image,
294
315
  configHash,
295
316
  })
296
317
 
297
- return {
298
- containerName,
299
- containerWorkdir: status.config.workdir,
300
- workspaceDir: params.workspaceDir,
301
- workspaceAccess: status.config.workspaceAccess,
302
- mode: status.mode,
303
- scope: status.scope,
304
- scopeKey: status.scopeKey,
305
- config: status.config,
306
- }
318
+ return context
307
319
  }
@@ -52,14 +52,12 @@ describe('durable wait surface', () => {
52
52
  })
53
53
  })
54
54
 
55
- describe('sandbox surface', () => {
56
- it('sandbox execution functions remain for shell integration', () => {
57
- const src = readToolSource('sandbox')
58
- assert.equal(src.includes('executeSandboxExec'), true)
59
- assert.equal(src.includes('executeListRuntimes'), true)
60
- assert.equal(src.includes('executeHostNode'), true)
61
- // Extension registration removed — sandbox_exec is now provided by shell
62
- assert.equal(src.includes('registerBuiltin'), false)
55
+ describe('execute surface', () => {
56
+ it('keeps just-bash as the sandboxed execution path with explicit host opt-in', () => {
57
+ const src = readToolSource('execute')
58
+ assert.equal(src.includes('just-bash'), true)
59
+ assert.equal(src.includes('normalizeAgentExecuteConfig'), true)
60
+ assert.equal(src.includes('persistent=true'), true)
63
61
  })
64
62
  })
65
63
 
@@ -81,7 +79,7 @@ describe('delegation job handles', () => {
81
79
 
82
80
  it('scheduler and daemon recover the durable autonomy jobs', () => {
83
81
  const schedulerSrc = fs.readFileSync(path.join(serverDir, 'runtime', 'scheduler.ts'), 'utf-8')
84
- const daemonSrc = fs.readFileSync(path.join(serverDir, 'runtime', 'daemon-state.ts'), 'utf-8')
82
+ const daemonSrc = fs.readFileSync(path.join(serverDir, 'runtime', 'daemon-state', 'core.ts'), 'utf-8')
85
83
  assert.equal(schedulerSrc.includes('processDueWatchJobs'), true)
86
84
  assert.equal(daemonSrc.includes('recoverStaleDelegationJobs'), true)
87
85
  })
@@ -190,7 +190,7 @@ export function findBinaryOnPath(binaryName: string): string | null {
190
190
  const { spawnSync } = require('child_process')
191
191
  const probe = isWindows
192
192
  ? spawnSync('where', [binaryName], { encoding: 'utf-8', timeout: 2000, stdio: 'pipe' })
193
- : spawnSync('/bin/zsh', ['-lc', `command -v ${binaryName} 2>/dev/null`], { encoding: 'utf-8', timeout: 2000 })
193
+ : spawnSync(process.env.SHELL || '/bin/bash', ['-lc', `command -v ${binaryName} 2>/dev/null`], { encoding: 'utf-8', timeout: 2000 })
194
194
  const resolved = (probe.stdout || '').trim() || null
195
195
  binaryLookupCache.set(binaryName, { checkedAt: now, path: resolved })
196
196
  return resolved
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Credential injection and secret redaction for agent code execution.
3
+ *
4
+ * Resolves an agent's configured credentials from the credential store,
5
+ * returns them as env vars for injection, and provides a redaction function
6
+ * to scrub secrets from execution output.
7
+ */
8
+
9
+ import { loadCredentials, decryptKey } from '../storage'
10
+ import { log } from '../logger'
11
+ import type { Credential } from '@/types'
12
+
13
+ const TAG = 'credential-env'
14
+
15
+ export interface CredentialEnv {
16
+ /** Environment variables to inject (name → decrypted value) */
17
+ env: Record<string, string>
18
+ /** Raw secret values for redaction */
19
+ secrets: string[]
20
+ }
21
+
22
+ /**
23
+ * Build credential environment variables for an agent execution.
24
+ *
25
+ * Each credential ID in the list is resolved from the credential store,
26
+ * decrypted, and mapped to an env var name derived from the credential's
27
+ * provider and name fields.
28
+ *
29
+ * Env var naming: `<PROVIDER>_API_KEY` for the primary key, or
30
+ * `<PROVIDER>_<NAME>` if a name is explicitly set. All uppercased,
31
+ * non-alphanumeric chars replaced with underscores.
32
+ */
33
+ export function buildCredentialEnv(credentialIds: string[]): CredentialEnv {
34
+ if (!credentialIds.length) return { env: {}, secrets: [] }
35
+
36
+ const env: Record<string, string> = {}
37
+ const secrets: string[] = []
38
+
39
+ const allCredentials = loadCredentials() as Record<string, Credential & { encrypted?: string }>
40
+
41
+ for (const credId of credentialIds) {
42
+ const cred = allCredentials[credId]
43
+ if (!cred) {
44
+ log.warn(TAG, `Credential not found: ${credId}`)
45
+ continue
46
+ }
47
+
48
+ // Decrypt the stored key
49
+ const encrypted = cred.encrypted
50
+ if (!encrypted || typeof encrypted !== 'string') {
51
+ log.warn(TAG, `Credential has no encrypted value: ${credId}`)
52
+ continue
53
+ }
54
+
55
+ let value: string
56
+ try {
57
+ value = decryptKey(encrypted)
58
+ } catch (err: unknown) {
59
+ log.warn(TAG, `Failed to decrypt credential ${credId}`, { error: String(err) })
60
+ continue
61
+ }
62
+
63
+ // Derive env var name
64
+ const envVarName = deriveEnvVarName(cred.provider, cred.name)
65
+ env[envVarName] = value
66
+ secrets.push(value)
67
+ }
68
+
69
+ return { env, secrets }
70
+ }
71
+
72
+ /**
73
+ * Derive an environment variable name from provider and credential name.
74
+ * e.g., provider="openai", name="default" → "OPENAI_API_KEY"
75
+ * e.g., provider="custom", name="my-service-token" → "CUSTOM_MY_SERVICE_TOKEN"
76
+ */
77
+ function deriveEnvVarName(provider: string, name: string): string {
78
+ const sanitize = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_|_$/g, '')
79
+
80
+ const providerKey = sanitize(provider)
81
+ const nameKey = sanitize(name)
82
+
83
+ // If name is generic (default, primary, key, api-key, etc.), use PROVIDER_API_KEY
84
+ const genericNames = new Set(['DEFAULT', 'PRIMARY', 'KEY', 'API_KEY', 'APIKEY', ''])
85
+ if (genericNames.has(nameKey)) {
86
+ return `${providerKey}_API_KEY`
87
+ }
88
+
89
+ return `${providerKey}_${nameKey}`
90
+ }
91
+
92
+ /**
93
+ * Redact secret values from execution output.
94
+ *
95
+ * Scans the text for any injected secret values and replaces them
96
+ * with [REDACTED]. Only redacts secrets longer than 4 characters
97
+ * to avoid false positives on short strings.
98
+ */
99
+ export function redactSecrets(text: string, secrets: string[]): string {
100
+ if (!secrets.length || !text) return text
101
+
102
+ let result = text
103
+ for (const secret of secrets) {
104
+ if (secret.length > 4) {
105
+ result = result.replaceAll(secret, '[REDACTED]')
106
+ }
107
+ }
108
+ return result
109
+ }
@@ -48,7 +48,7 @@ import {
48
48
  import type { ToolBuildContext } from './context'
49
49
  import { normalizeToolInputArgs } from './normalize-tool-args'
50
50
  import type { BoardTask } from '@/types'
51
- import { dedup } from '@/lib/shared-utils'
51
+ import { dedup, errorMessage } from '@/lib/shared-utils'
52
52
  import { isDirectConnectorSession } from '../connectors/session-kind'
53
53
  import { buildManageSkillsDescription, executeManageSkillsAction } from './skills'
54
54
  import { isMainSession } from '@/lib/server/agents/main-agent-loop'
@@ -1149,8 +1149,8 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
1149
1149
  return JSON.stringify({ ok: true, taskId: id, claimedByAgentId: ctx?.agentId })
1150
1150
  }
1151
1151
  return `Unknown action "${action}". Valid: list, get, create, update, delete, claim_task`
1152
- } catch (err: any) {
1153
- return `Error: ${err.message}`
1152
+ } catch (err: unknown) {
1153
+ return `Error: ${errorMessage(err)}`
1154
1154
  }
1155
1155
  },
1156
1156
  {
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from '@/lib/shared-utils'
1
2
  import { z } from 'zod'
2
3
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
4
  import fs from 'fs'
@@ -32,8 +33,8 @@ async function executeEditFile(args: { filePath: string; oldString: string; newS
32
33
  const updated = content.replace(oldString, newString)
33
34
  fs.writeFileSync(resolved, updated, 'utf-8')
34
35
  return `Successfully updated ${filePath} (1 replacement made).`
35
- } catch (err: any) {
36
- return `Error: ${err.message}`
36
+ } catch (err: unknown) {
37
+ return `Error: ${errorMessage(err)}`
37
38
  }
38
39
  }
39
40