@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.
- package/README.md +24 -17
- package/next.config.ts +1 -0
- package/package.json +3 -2
- package/scripts/easy-setup.mjs +1 -1
- package/scripts/postinstall.mjs +1 -1
- package/skills/swarmclaw.md +115 -0
- package/skills/tools/browser.md +131 -0
- package/skills/tools/execute.md +98 -0
- package/skills/tools/files.md +98 -0
- package/skills/tools/memory.md +104 -0
- package/skills/tools/platform.md +144 -0
- package/skills/tools/skills.md +83 -0
- package/src/app/api/chats/[id]/messages/route.ts +23 -19
- package/src/app/api/chats/messages-route.test.ts +105 -51
- package/src/app/api/mcp-servers/[id]/test/route.ts +3 -2
- package/src/app/api/openclaw/deploy/route.ts +2 -0
- package/src/app/api/setup/doctor/route.ts +4 -4
- package/src/components/agents/agent-chat-list.tsx +23 -1
- package/src/components/agents/inspector-panel.tsx +165 -48
- package/src/components/chat/chat-area.tsx +38 -9
- package/src/components/chat/message-list.tsx +33 -19
- package/src/components/gateways/gateway-sheet.tsx +5 -2
- package/src/lib/agent-execute-defaults.test.ts +24 -0
- package/src/lib/agent-execute-defaults.ts +62 -0
- package/src/lib/chat/queued-message-queue.test.ts +134 -1
- package/src/lib/chat/queued-message-queue.ts +77 -2
- package/src/lib/server/agents/agent-service.ts +5 -0
- package/src/lib/server/builtin-extensions.ts +1 -0
- package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +1 -1
- package/src/lib/server/chat-execution/chat-execution-tool-events.test.ts +1 -0
- package/src/lib/server/chat-execution/chat-execution-utils.ts +2 -2
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +79 -42
- package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +4 -0
- package/src/lib/server/chat-execution/continuation-evaluator.ts +8 -0
- package/src/lib/server/chat-execution/memory-mutation-tools.ts +1 -1
- package/src/lib/server/chat-execution/message-classifier.ts +11 -1
- package/src/lib/server/chat-execution/prompt-builder.test.ts +28 -0
- package/src/lib/server/chat-execution/prompt-builder.ts +14 -1
- package/src/lib/server/chat-execution/prompt-mode.test.ts +24 -0
- package/src/lib/server/chat-execution/prompt-mode.ts +5 -1
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -4
- package/src/lib/server/chat-execution/stream-agent-chat.ts +45 -16
- package/src/lib/server/chatrooms/chatroom-routing.test.ts +4 -0
- package/src/lib/server/connectors/discord.ts +2 -2
- package/src/lib/server/connectors/matrix.ts +3 -2
- package/src/lib/server/connectors/signal.ts +5 -4
- package/src/lib/server/connectors/slack.ts +10 -9
- package/src/lib/server/connectors/teams.ts +3 -2
- package/src/lib/server/connectors/telegram.ts +4 -4
- package/src/lib/server/connectors/whatsapp.ts +2 -2
- package/src/lib/server/daemon/controller.ts +7 -0
- package/src/lib/server/gateways/gateway-profile-service.ts +19 -1
- package/src/lib/server/messages/message-repository.test.ts +70 -0
- package/src/lib/server/messages/message-repository.ts +11 -6
- package/src/lib/server/openclaw/deploy.ts +32 -2
- package/src/lib/server/plugins-advanced.test.ts +1 -2
- package/src/lib/server/provider-health.ts +1 -1
- package/src/lib/server/runtime/process-manager.ts +13 -9
- package/src/lib/server/runtime/session-run-manager/queries.ts +15 -0
- package/src/lib/server/runtime/session-run-manager.test.ts +58 -0
- package/src/lib/server/sandbox/session-runtime.test.ts +18 -1
- package/src/lib/server/sandbox/session-runtime.ts +40 -28
- package/src/lib/server/session-tools/autonomy-tools.test.ts +7 -9
- package/src/lib/server/session-tools/context.ts +1 -1
- package/src/lib/server/session-tools/credential-env.ts +109 -0
- package/src/lib/server/session-tools/crud.ts +3 -3
- package/src/lib/server/session-tools/edit_file.ts +3 -2
- package/src/lib/server/session-tools/execute.test.ts +58 -0
- package/src/lib/server/session-tools/execute.ts +334 -0
- package/src/lib/server/session-tools/files-tool.ts +635 -0
- package/src/lib/server/session-tools/index.ts +14 -4
- package/src/lib/server/session-tools/memory-tool.ts +242 -0
- package/src/lib/server/session-tools/memory.ts +1 -1
- package/src/lib/server/session-tools/openclaw-nodes.ts +3 -2
- package/src/lib/server/session-tools/openclaw-workspace.ts +3 -2
- package/src/lib/server/session-tools/platform-tool.ts +617 -0
- package/src/lib/server/session-tools/session-info.ts +3 -2
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +3 -4
- package/src/lib/server/session-tools/shell.ts +7 -122
- package/src/lib/server/session-tools/skills-tool.ts +396 -0
- package/src/lib/server/session-tools/web.ts +2 -2
- package/src/lib/server/storage-normalization.ts +2 -0
- package/src/lib/server/tool-aliases.ts +2 -1
- package/src/lib/server/tool-capability-policy-advanced.test.ts +9 -2
- package/src/lib/server/tool-capability-policy.test.ts +2 -1
- package/src/lib/server/tool-capability-policy.ts +60 -33
- package/src/lib/server/tool-planning.ts +11 -0
- package/src/lib/setup-defaults.ts +5 -0
- package/src/lib/tool-definitions.ts +1 -0
- package/src/lib/validation/schemas.test.ts +16 -0
- package/src/lib/validation/schemas.ts +16 -0
- package/src/stores/use-chat-store.test.ts +231 -0
- package/src/stores/use-chat-store.ts +62 -13
- package/src/types/agent.ts +348 -0
- package/src/types/app-settings.ts +175 -0
- package/src/types/approval.ts +27 -0
- package/src/types/connector.ts +187 -0
- package/src/types/extension.ts +386 -0
- package/src/types/index.ts +16 -3555
- package/src/types/message.ts +57 -0
- package/src/types/misc.ts +739 -0
- package/src/types/mission.ts +185 -0
- package/src/types/protocol.ts +422 -0
- package/src/types/provider.ts +52 -0
- package/src/types/run.ts +183 -0
- package/src/types/schedule.ts +59 -0
- package/src/types/session.ts +265 -0
- package/src/types/skill.ts +157 -0
- package/src/types/task.ts +140 -0
- package/src/types/working-state.ts +211 -0
- package/src/views/settings/section-heartbeat.tsx +2 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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/
|
|
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
|
-
|
|
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
|
|
251
|
-
//
|
|
252
|
-
|
|
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:
|
|
409
|
-
return { ok: false, error: 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:
|
|
422
|
-
return { ok: false, error: 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
|
|
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
|
-
}):
|
|
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:
|
|
278
|
+
config: context.config,
|
|
258
279
|
workspaceDir: params.workspaceDir,
|
|
259
|
-
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:
|
|
295
|
+
containerName: context.containerName,
|
|
296
|
+
scopeKey: context.scopeKey,
|
|
276
297
|
configHash,
|
|
277
|
-
config:
|
|
298
|
+
config: context.config,
|
|
278
299
|
workspaceDir: params.workspaceDir,
|
|
279
300
|
}))
|
|
280
|
-
await execDocker(['start', containerName])
|
|
281
|
-
if (
|
|
282
|
-
await execDocker(['exec', '-i', containerName, '/bin/sh', '-lc',
|
|
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:
|
|
310
|
+
containerName: context.containerName,
|
|
311
|
+
scopeKey: context.scopeKey,
|
|
291
312
|
createdAtMs: Date.now(),
|
|
292
313
|
lastUsedAtMs: Date.now(),
|
|
293
|
-
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('
|
|
56
|
-
it('
|
|
57
|
-
const src = readToolSource('
|
|
58
|
-
assert.equal(src.includes('
|
|
59
|
-
assert.equal(src.includes('
|
|
60
|
-
assert.equal(src.includes('
|
|
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/
|
|
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:
|
|
1153
|
-
return `Error: ${err
|
|
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:
|
|
36
|
-
return `Error: ${err
|
|
36
|
+
} catch (err: unknown) {
|
|
37
|
+
return `Error: ${errorMessage(err)}`
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|