@swarmclawai/swarmclaw 0.4.0 → 0.5.0
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 +21 -4
- package/bin/server-cmd.js +28 -19
- package/next.config.ts +13 -0
- package/package.json +3 -1
- package/src/app/api/agents/[id]/route.ts +39 -22
- package/src/app/api/agents/[id]/thread/route.ts +2 -2
- package/src/app/api/agents/route.ts +3 -2
- package/src/app/api/agents/trash/route.ts +44 -0
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/connectors/[id]/route.ts +17 -7
- package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
- package/src/app/api/connectors/route.ts +6 -3
- package/src/app/api/credentials/[id]/route.ts +2 -1
- package/src/app/api/credentials/route.ts +2 -2
- package/src/app/api/documents/route.ts +2 -2
- package/src/app/api/files/serve/route.ts +8 -0
- package/src/app/api/knowledge/[id]/route.ts +5 -4
- package/src/app/api/knowledge/upload/route.ts +2 -2
- package/src/app/api/mcp-servers/[id]/route.ts +11 -14
- package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
- package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
- package/src/app/api/mcp-servers/route.ts +2 -2
- package/src/app/api/memory/[id]/route.ts +9 -8
- package/src/app/api/memory/route.ts +2 -2
- package/src/app/api/memory-images/[filename]/route.ts +2 -1
- package/src/app/api/openclaw/agent-files/route.ts +57 -0
- package/src/app/api/openclaw/approvals/route.ts +46 -0
- package/src/app/api/openclaw/config-sync/route.ts +33 -0
- package/src/app/api/openclaw/cron/route.ts +52 -0
- package/src/app/api/openclaw/directory/route.ts +27 -0
- package/src/app/api/openclaw/discover/route.ts +62 -0
- package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
- package/src/app/api/openclaw/exec-config/route.ts +41 -0
- package/src/app/api/openclaw/gateway/route.ts +72 -0
- package/src/app/api/openclaw/history/route.ts +109 -0
- package/src/app/api/openclaw/media/route.ts +53 -0
- package/src/app/api/openclaw/models/route.ts +12 -0
- package/src/app/api/openclaw/permissions/route.ts +39 -0
- package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
- package/src/app/api/openclaw/skills/install/route.ts +32 -0
- package/src/app/api/openclaw/skills/remove/route.ts +24 -0
- package/src/app/api/openclaw/skills/route.ts +82 -0
- package/src/app/api/openclaw/sync/route.ts +31 -0
- package/src/app/api/orchestrator/run/route.ts +2 -2
- package/src/app/api/projects/[id]/route.ts +55 -0
- package/src/app/api/projects/route.ts +27 -0
- package/src/app/api/providers/[id]/models/route.ts +2 -1
- package/src/app/api/providers/[id]/route.ts +13 -15
- package/src/app/api/providers/route.ts +2 -2
- package/src/app/api/schedules/[id]/route.ts +16 -18
- package/src/app/api/schedules/[id]/run/route.ts +4 -3
- package/src/app/api/schedules/route.ts +2 -2
- package/src/app/api/secrets/[id]/route.ts +16 -17
- package/src/app/api/secrets/route.ts +2 -2
- package/src/app/api/sessions/[id]/clear/route.ts +2 -1
- package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
- package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
- package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
- package/src/app/api/sessions/[id]/fork/route.ts +44 -0
- package/src/app/api/sessions/[id]/messages/route.ts +20 -2
- package/src/app/api/sessions/[id]/retry/route.ts +2 -1
- package/src/app/api/sessions/[id]/route.ts +14 -4
- package/src/app/api/sessions/route.ts +8 -4
- package/src/app/api/skills/[id]/route.ts +23 -21
- package/src/app/api/skills/import/route.ts +2 -2
- package/src/app/api/skills/route.ts +2 -2
- package/src/app/api/tasks/[id]/approve/route.ts +2 -1
- package/src/app/api/tasks/[id]/route.ts +6 -5
- package/src/app/api/tasks/route.ts +2 -2
- package/src/app/api/tts/stream/route.ts +48 -0
- package/src/app/api/upload/route.ts +2 -2
- package/src/app/api/uploads/[filename]/route.ts +4 -1
- package/src/app/api/webhooks/[id]/route.ts +29 -31
- package/src/app/api/webhooks/route.ts +2 -2
- package/src/app/globals.css +14 -0
- package/src/app/layout.tsx +5 -20
- package/src/app/page.tsx +3 -24
- package/src/cli/index.js +60 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/spec.js +42 -0
- package/src/components/agents/agent-avatar.tsx +45 -0
- package/src/components/agents/agent-card.tsx +19 -5
- package/src/components/agents/agent-chat-list.tsx +31 -24
- package/src/components/agents/agent-files-editor.tsx +185 -0
- package/src/components/agents/agent-list.tsx +84 -3
- package/src/components/agents/agent-sheet.tsx +147 -14
- package/src/components/agents/cron-job-form.tsx +137 -0
- package/src/components/agents/exec-config-panel.tsx +147 -0
- package/src/components/agents/inspector-panel.tsx +310 -0
- package/src/components/agents/openclaw-skills-panel.tsx +230 -0
- package/src/components/agents/permission-preset-selector.tsx +79 -0
- package/src/components/agents/personality-builder.tsx +111 -0
- package/src/components/agents/sandbox-env-panel.tsx +72 -0
- package/src/components/agents/skill-install-dialog.tsx +102 -0
- package/src/components/agents/trash-list.tsx +109 -0
- package/src/components/chat/chat-area.tsx +41 -6
- package/src/components/chat/chat-header.tsx +305 -29
- package/src/components/chat/chat-preview-panel.tsx +113 -0
- package/src/components/chat/exec-approval-card.tsx +89 -0
- package/src/components/chat/message-bubble.tsx +218 -36
- package/src/components/chat/message-list.tsx +135 -31
- package/src/components/chat/streaming-bubble.tsx +59 -10
- package/src/components/chat/suggestions-bar.tsx +74 -0
- package/src/components/chat/thinking-indicator.tsx +20 -6
- package/src/components/chat/tool-call-bubble.tsx +98 -19
- package/src/components/chat/tool-request-banner.tsx +20 -2
- package/src/components/chat/trace-block.tsx +103 -0
- package/src/components/chat/voice-overlay.tsx +80 -0
- package/src/components/connectors/connector-list.tsx +6 -2
- package/src/components/connectors/connector-sheet.tsx +31 -7
- package/src/components/layout/app-layout.tsx +47 -25
- package/src/components/projects/project-list.tsx +123 -0
- package/src/components/projects/project-sheet.tsx +135 -0
- package/src/components/schedules/schedule-list.tsx +3 -1
- package/src/components/sessions/new-session-sheet.tsx +6 -6
- package/src/components/sessions/session-card.tsx +1 -1
- package/src/components/sessions/session-list.tsx +7 -7
- package/src/components/settings/gateway-connection-panel.tsx +278 -0
- package/src/components/shared/avatar.tsx +13 -2
- package/src/components/shared/connector-platform-icon.tsx +4 -0
- package/src/components/shared/settings/section-heartbeat.tsx +1 -1
- package/src/components/shared/settings/section-orchestrator.tsx +1 -2
- package/src/components/shared/settings/section-web-search.tsx +56 -0
- package/src/components/shared/settings/settings-page.tsx +74 -0
- package/src/components/skills/skill-list.tsx +2 -1
- package/src/components/tasks/task-board.tsx +1 -1
- package/src/components/tasks/task-list.tsx +5 -2
- package/src/components/tasks/task-sheet.tsx +12 -12
- package/src/hooks/use-continuous-speech.ts +181 -0
- package/src/hooks/use-openclaw-gateway.ts +63 -0
- package/src/hooks/use-view-router.ts +52 -0
- package/src/hooks/use-voice-conversation.ts +80 -0
- package/src/lib/id.ts +6 -0
- package/src/lib/notification-sounds.ts +58 -0
- package/src/lib/personality-parser.ts +97 -0
- package/src/lib/projects.ts +13 -0
- package/src/lib/provider-sets.ts +5 -0
- package/src/lib/providers/anthropic.ts +14 -1
- package/src/lib/providers/index.ts +6 -0
- package/src/lib/providers/ollama.ts +9 -1
- package/src/lib/providers/openai.ts +9 -1
- package/src/lib/providers/openclaw.ts +28 -2
- package/src/lib/runtime-loop.ts +2 -2
- package/src/lib/server/api-routes.test.ts +5 -6
- package/src/lib/server/build-llm.ts +17 -4
- package/src/lib/server/chat-execution.ts +82 -6
- package/src/lib/server/collection-helpers.ts +54 -0
- package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
- package/src/lib/server/connectors/bluebubbles.ts +360 -0
- package/src/lib/server/connectors/connector-routing.test.ts +1 -1
- package/src/lib/server/connectors/googlechat.ts +51 -8
- package/src/lib/server/connectors/manager.ts +424 -13
- package/src/lib/server/connectors/media.ts +2 -2
- package/src/lib/server/connectors/openclaw.ts +65 -0
- package/src/lib/server/connectors/pairing.test.ts +99 -0
- package/src/lib/server/connectors/pairing.ts +256 -0
- package/src/lib/server/connectors/signal.ts +1 -0
- package/src/lib/server/connectors/teams.ts +5 -5
- package/src/lib/server/connectors/types.ts +10 -0
- package/src/lib/server/daemon-state.ts +11 -0
- package/src/lib/server/execution-log.ts +3 -3
- package/src/lib/server/heartbeat-service.ts +1 -1
- package/src/lib/server/knowledge-db.test.ts +2 -33
- package/src/lib/server/main-agent-loop.ts +8 -9
- package/src/lib/server/main-session.ts +21 -0
- package/src/lib/server/memory-db.ts +6 -6
- package/src/lib/server/openclaw-approvals.ts +105 -0
- package/src/lib/server/openclaw-config-sync.ts +107 -0
- package/src/lib/server/openclaw-exec-config.ts +52 -0
- package/src/lib/server/openclaw-gateway.ts +291 -0
- package/src/lib/server/openclaw-history-merge.ts +36 -0
- package/src/lib/server/openclaw-models.ts +56 -0
- package/src/lib/server/openclaw-permission-presets.ts +64 -0
- package/src/lib/server/openclaw-sync.ts +497 -0
- package/src/lib/server/orchestrator-lg.ts +30 -9
- package/src/lib/server/orchestrator.ts +4 -4
- package/src/lib/server/process-manager.ts +2 -2
- package/src/lib/server/queue.ts +24 -11
- package/src/lib/server/scheduler.ts +2 -2
- package/src/lib/server/session-mailbox.ts +2 -2
- package/src/lib/server/session-run-manager.ts +2 -2
- package/src/lib/server/session-tools/connector.ts +53 -6
- package/src/lib/server/session-tools/crud.ts +3 -3
- package/src/lib/server/session-tools/delegate.ts +22 -6
- package/src/lib/server/session-tools/file.ts +192 -19
- package/src/lib/server/session-tools/index.ts +4 -2
- package/src/lib/server/session-tools/memory.ts +2 -2
- package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
- package/src/lib/server/session-tools/sandbox.ts +33 -0
- package/src/lib/server/session-tools/search-providers.ts +277 -0
- package/src/lib/server/session-tools/session-info.ts +2 -2
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
- package/src/lib/server/session-tools/shell.ts +1 -1
- package/src/lib/server/session-tools/web.ts +53 -72
- package/src/lib/server/storage.ts +74 -11
- package/src/lib/server/stream-agent-chat.ts +53 -4
- package/src/lib/server/suggestions.ts +20 -0
- package/src/lib/server/task-result.test.ts +44 -0
- package/src/lib/server/task-result.ts +14 -0
- package/src/lib/server/ws-hub.ts +14 -0
- package/src/lib/tool-definitions.ts +5 -3
- package/src/lib/tts-stream.ts +130 -0
- package/src/lib/view-routes.ts +28 -0
- package/src/proxy.ts +3 -0
- package/src/stores/use-app-store.ts +80 -1
- package/src/stores/use-approval-store.ts +78 -0
- package/src/stores/use-chat-store.ts +162 -6
- package/src/types/index.ts +154 -3
- package/tsconfig.json +13 -4
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
import { resolveOpenClawWorkspace } from './openclaw-sync'
|
|
5
|
+
|
|
6
|
+
const APPROVAL_TIMEOUT_MS = 30_000
|
|
7
|
+
|
|
8
|
+
interface ApprovalRequest {
|
|
9
|
+
toolName: string
|
|
10
|
+
args: Record<string, unknown>
|
|
11
|
+
socketPath?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ApprovalResponse {
|
|
15
|
+
approved: boolean
|
|
16
|
+
reason?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveSocketPath(): string | null {
|
|
20
|
+
try {
|
|
21
|
+
const workspace = resolveOpenClawWorkspace()
|
|
22
|
+
const socketPath = path.join(workspace, 'exec-approvals.sock')
|
|
23
|
+
if (fs.existsSync(socketPath)) return socketPath
|
|
24
|
+
} catch { /* workspace not found */ }
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveApprovalToken(): string | null {
|
|
29
|
+
try {
|
|
30
|
+
const workspace = resolveOpenClawWorkspace()
|
|
31
|
+
const tokenPath = path.join(workspace, 'exec-approvals.json')
|
|
32
|
+
if (!fs.existsSync(tokenPath)) return null
|
|
33
|
+
const raw = JSON.parse(fs.readFileSync(tokenPath, 'utf8'))
|
|
34
|
+
return typeof raw?.token === 'string' ? raw.token : null
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Forward a tool approval request to OpenClaw's exec-approvals Unix socket.
|
|
42
|
+
* Returns the approval decision, or null if the socket is unavailable.
|
|
43
|
+
*/
|
|
44
|
+
export async function forwardApprovalToOpenClaw(request: ApprovalRequest): Promise<ApprovalResponse | null> {
|
|
45
|
+
const socketPath = request.socketPath || resolveSocketPath()
|
|
46
|
+
if (!socketPath) return null
|
|
47
|
+
|
|
48
|
+
const token = resolveApprovalToken()
|
|
49
|
+
|
|
50
|
+
return new Promise<ApprovalResponse | null>((resolve) => {
|
|
51
|
+
const socket = net.createConnection({ path: socketPath }, () => {
|
|
52
|
+
const payload = JSON.stringify({
|
|
53
|
+
type: 'approval_request',
|
|
54
|
+
toolName: request.toolName,
|
|
55
|
+
args: request.args,
|
|
56
|
+
token,
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
})
|
|
59
|
+
socket.write(payload + '\n')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
let data = ''
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
socket.destroy()
|
|
65
|
+
resolve(null) // Timeout — fall through to SwarmClaw UI
|
|
66
|
+
}, APPROVAL_TIMEOUT_MS)
|
|
67
|
+
|
|
68
|
+
socket.on('data', (chunk) => {
|
|
69
|
+
data += chunk.toString()
|
|
70
|
+
// Try to parse complete JSON response
|
|
71
|
+
try {
|
|
72
|
+
const response = JSON.parse(data.trim())
|
|
73
|
+
clearTimeout(timer)
|
|
74
|
+
socket.destroy()
|
|
75
|
+
resolve({
|
|
76
|
+
approved: response.approved === true,
|
|
77
|
+
reason: typeof response.reason === 'string' ? response.reason : undefined,
|
|
78
|
+
})
|
|
79
|
+
} catch {
|
|
80
|
+
// Incomplete data, wait for more
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
socket.on('error', () => {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
resolve(null) // Socket error — fall through
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
socket.on('close', () => {
|
|
90
|
+
clearTimeout(timer)
|
|
91
|
+
// If we haven't resolved yet, try to parse what we have
|
|
92
|
+
if (data.trim()) {
|
|
93
|
+
try {
|
|
94
|
+
const response = JSON.parse(data.trim())
|
|
95
|
+
resolve({
|
|
96
|
+
approved: response.approved === true,
|
|
97
|
+
reason: typeof response.reason === 'string' ? response.reason : undefined,
|
|
98
|
+
})
|
|
99
|
+
return
|
|
100
|
+
} catch { /* fall through */ }
|
|
101
|
+
}
|
|
102
|
+
resolve(null)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ensureGatewayConnected } from './openclaw-gateway'
|
|
2
|
+
|
|
3
|
+
export interface ConfigIssue {
|
|
4
|
+
id: string
|
|
5
|
+
severity: 'warning' | 'error'
|
|
6
|
+
title: string
|
|
7
|
+
description: string
|
|
8
|
+
repairAction?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Fetch gateway config and detect common issues */
|
|
12
|
+
export async function detectConfigIssues(): Promise<ConfigIssue[]> {
|
|
13
|
+
const gw = await ensureGatewayConnected()
|
|
14
|
+
if (!gw) return [{ id: 'no-connection', severity: 'error', title: 'Not Connected', description: 'Gateway is not connected.' }]
|
|
15
|
+
|
|
16
|
+
let config: Record<string, unknown>
|
|
17
|
+
try {
|
|
18
|
+
config = (await gw.rpc('config.get')) as Record<string, unknown> ?? {}
|
|
19
|
+
} catch {
|
|
20
|
+
return [{ id: 'config-fetch-failed', severity: 'error', title: 'Config Fetch Failed', description: 'Could not retrieve gateway configuration.' }]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const issues: ConfigIssue[] = []
|
|
24
|
+
|
|
25
|
+
// Check sandbox env allowlist
|
|
26
|
+
const agentsDefaults = config.agents as Record<string, unknown> | undefined
|
|
27
|
+
const sandbox = (agentsDefaults?.defaults as Record<string, unknown>)?.sandbox as Record<string, unknown> | undefined
|
|
28
|
+
const docker = sandbox?.docker as Record<string, unknown> | undefined
|
|
29
|
+
const envArr = docker?.env as string[] | undefined
|
|
30
|
+
if (!envArr || envArr.length === 0) {
|
|
31
|
+
issues.push({
|
|
32
|
+
id: 'empty-sandbox-env',
|
|
33
|
+
severity: 'warning',
|
|
34
|
+
title: 'Empty Sandbox Env',
|
|
35
|
+
description: 'No environment variables are allowed in the sandbox. Agents may lack API access.',
|
|
36
|
+
repairAction: 'sandbox-env-defaults',
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check model defaults
|
|
41
|
+
const models = config.models as Record<string, unknown> | undefined
|
|
42
|
+
const defaultModel = models?.default as string | undefined
|
|
43
|
+
if (!defaultModel) {
|
|
44
|
+
issues.push({
|
|
45
|
+
id: 'no-default-model',
|
|
46
|
+
severity: 'warning',
|
|
47
|
+
title: 'No Default Model',
|
|
48
|
+
description: 'No default model is configured. Agents will need explicit model assignment.',
|
|
49
|
+
repairAction: 'set-default-model',
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check reload mode
|
|
54
|
+
const reloadMode = config.reloadMode as string | undefined
|
|
55
|
+
if (reloadMode === 'full') {
|
|
56
|
+
issues.push({
|
|
57
|
+
id: 'full-reload-mode',
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
title: 'Full Reload Mode',
|
|
60
|
+
description: 'Gateway is in full reload mode. This restarts all agents on config change, which may disrupt running sessions.',
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return issues
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Attempt to repair a specific config issue with hash-based retry */
|
|
68
|
+
export async function repairConfigIssue(issueId: string): Promise<{ ok: boolean; error?: string }> {
|
|
69
|
+
const gw = await ensureGatewayConnected()
|
|
70
|
+
if (!gw) return { ok: false, error: 'Gateway not connected' }
|
|
71
|
+
|
|
72
|
+
const MAX_RETRIES = 3
|
|
73
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
const config = (await gw.rpc('config.get')) as Record<string, unknown> & { _hash?: string } ?? {}
|
|
76
|
+
const baseHash = config._hash as string | undefined
|
|
77
|
+
|
|
78
|
+
switch (issueId) {
|
|
79
|
+
case 'sandbox-env-defaults': {
|
|
80
|
+
// Set common env vars as defaults
|
|
81
|
+
const defaultEnvVars = ['${OPENAI_API_KEY}', '${ANTHROPIC_API_KEY}']
|
|
82
|
+
await gw.rpc('config.set', {
|
|
83
|
+
path: 'agents.defaults.sandbox.docker.env',
|
|
84
|
+
value: defaultEnvVars,
|
|
85
|
+
...(baseHash ? { baseHash } : {}),
|
|
86
|
+
})
|
|
87
|
+
return { ok: true }
|
|
88
|
+
}
|
|
89
|
+
case 'set-default-model': {
|
|
90
|
+
await gw.rpc('config.set', {
|
|
91
|
+
path: 'models.default',
|
|
92
|
+
value: 'claude-sonnet-4-20250514',
|
|
93
|
+
...(baseHash ? { baseHash } : {}),
|
|
94
|
+
})
|
|
95
|
+
return { ok: true }
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
return { ok: false, error: `Unknown issue: ${issueId}` }
|
|
99
|
+
}
|
|
100
|
+
} catch (err: unknown) {
|
|
101
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
102
|
+
if (msg.includes('conflict') && attempt < MAX_RETRIES - 1) continue
|
|
103
|
+
return { ok: false, error: msg }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { ok: false, error: 'Max retries exceeded' }
|
|
107
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExecApprovalConfig, ExecApprovalSnapshot } from '@/types'
|
|
2
|
+
import { ensureGatewayConnected } from './openclaw-gateway'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONFIG: ExecApprovalConfig = {
|
|
5
|
+
security: 'deny',
|
|
6
|
+
askMode: 'off',
|
|
7
|
+
patterns: [],
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Fetch exec approval config from gateway for a given agent */
|
|
11
|
+
export async function getExecConfig(agentId: string): Promise<ExecApprovalSnapshot> {
|
|
12
|
+
const gw = await ensureGatewayConnected()
|
|
13
|
+
if (!gw) throw new Error('Gateway not connected')
|
|
14
|
+
|
|
15
|
+
const result = await gw.rpc('exec.approvals.get', { agentId }) as ExecApprovalSnapshot | undefined
|
|
16
|
+
if (!result) {
|
|
17
|
+
return { path: '', exists: false, hash: '', file: { ...DEFAULT_CONFIG } }
|
|
18
|
+
}
|
|
19
|
+
return result
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Save exec approval config with hash-based conflict retry (up to 3 attempts) */
|
|
23
|
+
export async function setExecConfig(
|
|
24
|
+
agentId: string,
|
|
25
|
+
config: ExecApprovalConfig,
|
|
26
|
+
baseHash: string,
|
|
27
|
+
): Promise<{ ok: boolean; hash: string }> {
|
|
28
|
+
const gw = await ensureGatewayConnected()
|
|
29
|
+
if (!gw) throw new Error('Gateway not connected')
|
|
30
|
+
|
|
31
|
+
let currentHash = baseHash
|
|
32
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
const result = await gw.rpc('exec.approvals.set', {
|
|
35
|
+
agentId,
|
|
36
|
+
file: config,
|
|
37
|
+
baseHash: currentHash,
|
|
38
|
+
}) as { hash?: string } | undefined
|
|
39
|
+
return { ok: true, hash: result?.hash ?? '' }
|
|
40
|
+
} catch (err: unknown) {
|
|
41
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
42
|
+
if (msg.includes('conflict') && attempt < 2) {
|
|
43
|
+
// Re-fetch to get fresh hash
|
|
44
|
+
const fresh = await getExecConfig(agentId)
|
|
45
|
+
currentHash = fresh.hash
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
throw err
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw new Error('Failed after 3 conflict retries')
|
|
52
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { WebSocket } from 'ws'
|
|
2
|
+
import { randomUUID } from 'crypto'
|
|
3
|
+
import { wsConnect, buildOpenClawConnectParams } from '../providers/openclaw'
|
|
4
|
+
import { loadAgents, loadCredentials, decryptKey } from './storage'
|
|
5
|
+
import { notify, notifyWithPayload } from './ws-hub'
|
|
6
|
+
|
|
7
|
+
// --- Types ---
|
|
8
|
+
|
|
9
|
+
interface PendingRpc {
|
|
10
|
+
resolve: (value: unknown) => void
|
|
11
|
+
reject: (err: Error) => void
|
|
12
|
+
timer: ReturnType<typeof setTimeout>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type EventHandler = (payload: unknown) => void
|
|
16
|
+
|
|
17
|
+
// --- Singleton (HMR-safe) ---
|
|
18
|
+
|
|
19
|
+
const GK = '__swarmclaw_ocgateway__' as const
|
|
20
|
+
|
|
21
|
+
interface GatewayState {
|
|
22
|
+
instance: OpenClawGateway | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getState(): GatewayState {
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
const g = globalThis as any
|
|
28
|
+
if (!g[GK]) g[GK] = { instance: null }
|
|
29
|
+
return g[GK] as GatewayState
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- Helper: resolve gateway config from first OpenClaw agent ---
|
|
33
|
+
|
|
34
|
+
interface GatewayConfig {
|
|
35
|
+
wsUrl: string
|
|
36
|
+
token: string | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeWsUrl(raw: string): string {
|
|
40
|
+
let url = raw.replace(/\/+$/, '')
|
|
41
|
+
if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
|
|
42
|
+
url = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
|
|
43
|
+
return url.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveGatewayConfig(): GatewayConfig | null {
|
|
47
|
+
const agents = loadAgents({ includeTrashed: true })
|
|
48
|
+
const creds = loadCredentials()
|
|
49
|
+
for (const agent of Object.values(agents)) {
|
|
50
|
+
if (agent?.provider !== 'openclaw') continue
|
|
51
|
+
const wsUrl = agent.apiEndpoint
|
|
52
|
+
? normalizeWsUrl(agent.apiEndpoint)
|
|
53
|
+
: 'ws://127.0.0.1:18789'
|
|
54
|
+
let token: string | undefined
|
|
55
|
+
if (agent.credentialId) {
|
|
56
|
+
const cred = creds[agent.credentialId]
|
|
57
|
+
if (cred?.encryptedKey) {
|
|
58
|
+
try { token = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { wsUrl, token }
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function hasOpenClawAgents(): boolean {
|
|
67
|
+
const agents = loadAgents({ includeTrashed: true })
|
|
68
|
+
return Object.values(agents).some((a) => a?.provider === 'openclaw' && !a.trashedAt)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- Gateway Client ---
|
|
72
|
+
|
|
73
|
+
export class OpenClawGateway {
|
|
74
|
+
private ws: WebSocket | null = null
|
|
75
|
+
private pending = new Map<string, PendingRpc>()
|
|
76
|
+
private eventListeners = new Map<string, Set<EventHandler>>()
|
|
77
|
+
private _connected = false
|
|
78
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
79
|
+
private reconnectDelay = 800
|
|
80
|
+
private shouldReconnect = false
|
|
81
|
+
private wsUrl = ''
|
|
82
|
+
private token: string | undefined
|
|
83
|
+
|
|
84
|
+
get connected(): boolean { return this._connected }
|
|
85
|
+
|
|
86
|
+
async connect(wsUrl: string, token: string | undefined): Promise<boolean> {
|
|
87
|
+
this.wsUrl = wsUrl
|
|
88
|
+
this.token = token
|
|
89
|
+
this.shouldReconnect = true
|
|
90
|
+
return this.doConnect()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async doConnect(): Promise<boolean> {
|
|
94
|
+
if (this._connected && this.ws?.readyState === WebSocket.OPEN) return true
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const result = await wsConnect(this.wsUrl, this.token, true, 15_000)
|
|
98
|
+
if (!result.ok || !result.ws) {
|
|
99
|
+
console.error('[openclaw-gateway] Connect failed:', result.message)
|
|
100
|
+
this.scheduleReconnect()
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.ws = result.ws
|
|
105
|
+
this._connected = true
|
|
106
|
+
this.reconnectDelay = 800
|
|
107
|
+
console.log('[openclaw-gateway] Connected to gateway')
|
|
108
|
+
|
|
109
|
+
this.ws.on('message', (data) => {
|
|
110
|
+
try {
|
|
111
|
+
const msg = JSON.parse(data.toString())
|
|
112
|
+
this.handleMessage(msg)
|
|
113
|
+
} catch { /* ignore malformed */ }
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
this.ws.on('close', () => {
|
|
117
|
+
this._connected = false
|
|
118
|
+
this.ws = null
|
|
119
|
+
this.rejectAllPending('Gateway connection closed')
|
|
120
|
+
if (this.shouldReconnect) this.scheduleReconnect()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
this.ws.on('error', () => {
|
|
124
|
+
// onclose fires after this
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return true
|
|
128
|
+
} catch (err: unknown) {
|
|
129
|
+
console.error('[openclaw-gateway] Connect error:', err instanceof Error ? err.message : String(err))
|
|
130
|
+
this.scheduleReconnect()
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
disconnect() {
|
|
136
|
+
this.shouldReconnect = false
|
|
137
|
+
if (this.reconnectTimer) {
|
|
138
|
+
clearTimeout(this.reconnectTimer)
|
|
139
|
+
this.reconnectTimer = null
|
|
140
|
+
}
|
|
141
|
+
this.rejectAllPending('Disconnecting')
|
|
142
|
+
if (this.ws) {
|
|
143
|
+
try { this.ws.close() } catch { /* ignore */ }
|
|
144
|
+
this.ws = null
|
|
145
|
+
}
|
|
146
|
+
this._connected = false
|
|
147
|
+
console.log('[openclaw-gateway] Disconnected')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private scheduleReconnect() {
|
|
151
|
+
if (this.reconnectTimer || !this.shouldReconnect) return
|
|
152
|
+
this.reconnectTimer = setTimeout(() => {
|
|
153
|
+
this.reconnectTimer = null
|
|
154
|
+
if (!this.shouldReconnect) return
|
|
155
|
+
this.doConnect().catch(() => {})
|
|
156
|
+
}, this.reconnectDelay)
|
|
157
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 15_000)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private rejectAllPending(reason: string) {
|
|
161
|
+
for (const [id, p] of this.pending) {
|
|
162
|
+
clearTimeout(p.timer)
|
|
163
|
+
p.reject(new Error(reason))
|
|
164
|
+
}
|
|
165
|
+
this.pending.clear()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- RPC ---
|
|
169
|
+
|
|
170
|
+
rpc(method: string, params?: unknown, timeoutMs = 30_000): Promise<unknown> {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
173
|
+
return reject(new Error('Gateway not connected'))
|
|
174
|
+
}
|
|
175
|
+
const id = randomUUID()
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
this.pending.delete(id)
|
|
178
|
+
reject(new Error(`RPC ${method} timed out`))
|
|
179
|
+
}, timeoutMs)
|
|
180
|
+
|
|
181
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
182
|
+
this.ws.send(JSON.stringify({ type: 'req', id, method, params }))
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Events ---
|
|
187
|
+
|
|
188
|
+
on(event: string, handler: EventHandler) {
|
|
189
|
+
let set = this.eventListeners.get(event)
|
|
190
|
+
if (!set) {
|
|
191
|
+
set = new Set()
|
|
192
|
+
this.eventListeners.set(event, set)
|
|
193
|
+
}
|
|
194
|
+
set.add(handler)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
off(event: string, handler: EventHandler) {
|
|
198
|
+
const set = this.eventListeners.get(event)
|
|
199
|
+
if (!set) return
|
|
200
|
+
set.delete(handler)
|
|
201
|
+
if (set.size === 0) this.eventListeners.delete(event)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private handleMessage(msg: Record<string, unknown>) {
|
|
205
|
+
// RPC response
|
|
206
|
+
if (msg.type === 'res' && typeof msg.id === 'string') {
|
|
207
|
+
const p = this.pending.get(msg.id)
|
|
208
|
+
if (p) {
|
|
209
|
+
this.pending.delete(msg.id)
|
|
210
|
+
clearTimeout(p.timer)
|
|
211
|
+
if (msg.ok) {
|
|
212
|
+
p.resolve(msg.payload)
|
|
213
|
+
} else {
|
|
214
|
+
const errMsg = (msg.error as Record<string, unknown>)?.message
|
|
215
|
+
p.reject(new Error(typeof errMsg === 'string' ? errMsg : 'RPC failed'))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Event dispatch
|
|
222
|
+
if (msg.type === 'event' || msg.event) {
|
|
223
|
+
const eventName = (msg.event || msg.type) as string
|
|
224
|
+
const payload = msg.payload ?? msg.data ?? msg
|
|
225
|
+
|
|
226
|
+
// Dispatch to registered listeners
|
|
227
|
+
const handlers = this.eventListeners.get(eventName)
|
|
228
|
+
if (handlers) {
|
|
229
|
+
for (const h of handlers) {
|
|
230
|
+
try { h(payload) } catch { /* ignore handler errors */ }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Push to browser clients via ws-hub
|
|
235
|
+
if (eventName.startsWith('exec.approval')) {
|
|
236
|
+
notifyWithPayload('openclaw:approvals', { event: eventName, payload })
|
|
237
|
+
} else if (eventName.startsWith('agent')) {
|
|
238
|
+
notify('openclaw:agents')
|
|
239
|
+
} else if (eventName.startsWith('skill')) {
|
|
240
|
+
notify('openclaw:skills')
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Singleton access ---
|
|
247
|
+
|
|
248
|
+
export function getGateway(): OpenClawGateway | null {
|
|
249
|
+
return getState().instance
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function ensureGatewayConnected(): Promise<OpenClawGateway | null> {
|
|
253
|
+
const state = getState()
|
|
254
|
+
if (state.instance?.connected) return state.instance
|
|
255
|
+
|
|
256
|
+
const config = resolveGatewayConfig()
|
|
257
|
+
if (!config) return null
|
|
258
|
+
|
|
259
|
+
if (!state.instance) {
|
|
260
|
+
state.instance = new OpenClawGateway()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const ok = await state.instance.connect(config.wsUrl, config.token)
|
|
264
|
+
return ok ? state.instance : null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function disconnectGateway() {
|
|
268
|
+
const state = getState()
|
|
269
|
+
if (state.instance) {
|
|
270
|
+
state.instance.disconnect()
|
|
271
|
+
state.instance = null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Manual connect with explicit URL/token (used by gateway connection panel) */
|
|
276
|
+
export async function manualConnect(url?: string, token?: string): Promise<boolean> {
|
|
277
|
+
const state = getState()
|
|
278
|
+
if (state.instance?.connected) {
|
|
279
|
+
state.instance.disconnect()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const config = resolveGatewayConfig()
|
|
283
|
+
const wsUrl = url ? normalizeWsUrl(url) : config?.wsUrl ?? 'ws://127.0.0.1:18789'
|
|
284
|
+
const resolvedToken = token ?? config?.token
|
|
285
|
+
|
|
286
|
+
if (!state.instance) {
|
|
287
|
+
state.instance = new OpenClawGateway()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return state.instance.connect(wsUrl, resolvedToken)
|
|
291
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Message, GatewaySessionPreview } from '@/types'
|
|
2
|
+
|
|
3
|
+
/** Merge gateway history messages into local messages, deduplicating by timestamp */
|
|
4
|
+
export function mergeHistoryMessages(
|
|
5
|
+
localMessages: Message[],
|
|
6
|
+
preview: GatewaySessionPreview,
|
|
7
|
+
): Message[] {
|
|
8
|
+
const localTimestamps = new Set(localMessages.map((m) => m.time))
|
|
9
|
+
|
|
10
|
+
const newMessages: Message[] = []
|
|
11
|
+
for (const gm of preview.messages) {
|
|
12
|
+
// Skip if we already have a message at this timestamp
|
|
13
|
+
if (localTimestamps.has(gm.ts)) continue
|
|
14
|
+
|
|
15
|
+
const role = gm.role === 'user' ? 'user' as const : 'assistant' as const
|
|
16
|
+
newMessages.push({
|
|
17
|
+
role,
|
|
18
|
+
text: gm.content,
|
|
19
|
+
time: gm.ts,
|
|
20
|
+
kind: 'chat',
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (newMessages.length === 0) return localMessages
|
|
25
|
+
|
|
26
|
+
// Merge and sort by timestamp
|
|
27
|
+
const merged = [...localMessages, ...newMessages]
|
|
28
|
+
merged.sort((a, b) => a.time - b.time)
|
|
29
|
+
|
|
30
|
+
return merged
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Validate a session key matches expected format */
|
|
34
|
+
export function isValidSessionKey(key: string): boolean {
|
|
35
|
+
return typeof key === 'string' && key.length > 0 && key.length < 256
|
|
36
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ensureGatewayConnected } from './openclaw-gateway'
|
|
2
|
+
|
|
3
|
+
interface ModelPolicy {
|
|
4
|
+
defaultModel?: string
|
|
5
|
+
allowedModels?: string[]
|
|
6
|
+
fetchedAt: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let cachedPolicy: ModelPolicy | null = null
|
|
10
|
+
const CACHE_TTL = 60_000 // 60 seconds
|
|
11
|
+
|
|
12
|
+
export async function fetchGatewayModelPolicy(): Promise<ModelPolicy | null> {
|
|
13
|
+
if (cachedPolicy && Date.now() - cachedPolicy.fetchedAt < CACHE_TTL) {
|
|
14
|
+
return cachedPolicy
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const gw = await ensureGatewayConnected()
|
|
18
|
+
if (!gw) return cachedPolicy ?? null
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const result = await gw.rpc('config.get') as Record<string, unknown> | undefined
|
|
22
|
+
if (!result) return null
|
|
23
|
+
|
|
24
|
+
const agentDefaults = (result.agents as Record<string, unknown>)?.defaults as Record<string, unknown> | undefined
|
|
25
|
+
const defaultModel = typeof agentDefaults?.model === 'string' ? agentDefaults.model : undefined
|
|
26
|
+
const rawModels = agentDefaults?.models
|
|
27
|
+
|
|
28
|
+
let allowedModels: string[] | undefined
|
|
29
|
+
if (Array.isArray(rawModels)) {
|
|
30
|
+
allowedModels = rawModels.filter((m): m is string => typeof m === 'string')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
cachedPolicy = {
|
|
34
|
+
defaultModel,
|
|
35
|
+
allowedModels,
|
|
36
|
+
fetchedAt: Date.now(),
|
|
37
|
+
}
|
|
38
|
+
return cachedPolicy
|
|
39
|
+
} catch {
|
|
40
|
+
return cachedPolicy ?? null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildAllowedModelKeys(policy: ModelPolicy | null): string[] | null {
|
|
45
|
+
if (!policy) return null
|
|
46
|
+
const models = new Set<string>()
|
|
47
|
+
if (policy.defaultModel) models.add(policy.defaultModel)
|
|
48
|
+
if (policy.allowedModels) {
|
|
49
|
+
for (const m of policy.allowedModels) models.add(m)
|
|
50
|
+
}
|
|
51
|
+
return models.size > 0 ? Array.from(models) : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function invalidateModelPolicyCache() {
|
|
55
|
+
cachedPolicy = null
|
|
56
|
+
}
|