@swarmclawai/swarmclaw 0.4.5 → 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 +8 -2
- package/bin/server-cmd.js +28 -19
- package/next.config.ts +5 -0
- package/package.json +2 -1
- package/src/app/api/agents/[id]/route.ts +23 -5
- package/src/app/api/agents/trash/route.ts +44 -0
- package/src/app/api/connectors/[id]/route.ts +7 -4
- package/src/app/api/connectors/[id]/webhook/route.ts +6 -2
- 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 +4 -3
- package/src/app/api/openclaw/discover/route.ts +3 -2
- 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 +3 -2
- package/src/app/api/projects/[id]/route.ts +1 -1
- package/src/app/api/projects/route.ts +1 -1
- package/src/app/api/secrets/[id]/route.ts +1 -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 +18 -1
- package/src/app/api/sessions/[id]/route.ts +12 -3
- package/src/app/api/sessions/route.ts +6 -2
- package/src/app/globals.css +14 -0
- package/src/app/layout.tsx +5 -20
- package/src/cli/index.js +33 -1
- package/src/cli/spec.js +40 -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 +82 -3
- package/src/components/agents/agent-sheet.tsx +31 -0
- 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 +14 -2
- package/src/components/chat/chat-header.tsx +168 -4
- 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 +89 -16
- package/src/components/chat/tool-request-banner.tsx +20 -2
- package/src/components/chat/trace-block.tsx +103 -0
- package/src/components/projects/project-list.tsx +1 -0
- package/src/components/settings/gateway-connection-panel.tsx +278 -0
- package/src/components/shared/avatar.tsx +13 -2
- package/src/components/shared/settings/settings-page.tsx +1 -0
- package/src/components/tasks/task-board.tsx +1 -1
- package/src/components/tasks/task-sheet.tsx +12 -12
- package/src/hooks/use-continuous-speech.ts +42 -5
- package/src/hooks/use-openclaw-gateway.ts +63 -0
- package/src/lib/notification-sounds.ts +58 -0
- package/src/lib/personality-parser.ts +97 -0
- package/src/lib/providers/openclaw.ts +17 -2
- package/src/lib/runtime-loop.ts +2 -2
- package/src/lib/server/chat-execution.ts +44 -2
- package/src/lib/server/connectors/bluebubbles.test.ts +17 -8
- package/src/lib/server/connectors/bluebubbles.ts +5 -2
- package/src/lib/server/connectors/googlechat.ts +14 -10
- package/src/lib/server/connectors/manager.ts +37 -15
- package/src/lib/server/connectors/openclaw.ts +1 -0
- package/src/lib/server/daemon-state.ts +11 -0
- package/src/lib/server/main-agent-loop.ts +2 -3
- package/src/lib/server/main-session.ts +21 -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 +11 -10
- package/src/lib/server/queue.ts +2 -1
- package/src/lib/server/session-tools/connector.ts +4 -4
- package/src/lib/server/session-tools/delegate.ts +19 -3
- package/src/lib/server/session-tools/file.ts +20 -20
- package/src/lib/server/session-tools/index.ts +2 -2
- package/src/lib/server/session-tools/openclaw-nodes.ts +6 -6
- package/src/lib/server/session-tools/sandbox.ts +2 -2
- package/src/lib/server/session-tools/search-providers.ts +13 -6
- 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 +8 -8
- package/src/lib/server/storage.ts +62 -11
- package/src/lib/server/stream-agent-chat.ts +24 -4
- package/src/lib/server/suggestions.ts +20 -0
- package/src/lib/server/ws-hub.ts +14 -0
- package/src/stores/use-app-store.ts +53 -1
- package/src/stores/use-approval-store.ts +78 -0
- package/src/stores/use-chat-store.ts +153 -5
- package/src/types/index.ts +127 -1
- package/tsconfig.json +13 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ExecApprovalConfig, PermissionPreset } from '@/types'
|
|
2
|
+
import { ensureGatewayConnected } from './openclaw-gateway'
|
|
3
|
+
import { setExecConfig, getExecConfig } from './openclaw-exec-config'
|
|
4
|
+
|
|
5
|
+
export interface PresetConfig {
|
|
6
|
+
security: ExecApprovalConfig['security']
|
|
7
|
+
askMode: ExecApprovalConfig['askMode']
|
|
8
|
+
toolGroups: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const PRESET_CONFIGS: Record<PermissionPreset, PresetConfig> = {
|
|
12
|
+
conservative: {
|
|
13
|
+
security: 'deny',
|
|
14
|
+
askMode: 'off',
|
|
15
|
+
toolGroups: [],
|
|
16
|
+
},
|
|
17
|
+
collaborative: {
|
|
18
|
+
security: 'allowlist',
|
|
19
|
+
askMode: 'on-miss',
|
|
20
|
+
toolGroups: ['group:web', 'group:fs'],
|
|
21
|
+
},
|
|
22
|
+
autonomous: {
|
|
23
|
+
security: 'full',
|
|
24
|
+
askMode: 'off',
|
|
25
|
+
toolGroups: ['group:runtime', 'group:web', 'group:fs'],
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Derive which preset matches the current config, or 'custom' if none match */
|
|
30
|
+
export function resolvePresetFromConfig(config: ExecApprovalConfig): PermissionPreset | 'custom' {
|
|
31
|
+
for (const [preset, pc] of Object.entries(PRESET_CONFIGS) as [PermissionPreset, PresetConfig][]) {
|
|
32
|
+
if (config.security === pc.security && config.askMode === pc.askMode) {
|
|
33
|
+
return preset
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return 'custom'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Apply a permission preset to an agent via gateway RPC */
|
|
40
|
+
export async function applyPreset(agentId: string, preset: PermissionPreset): Promise<void> {
|
|
41
|
+
const pc = PRESET_CONFIGS[preset]
|
|
42
|
+
if (!pc) throw new Error(`Unknown preset: ${preset}`)
|
|
43
|
+
|
|
44
|
+
const gw = await ensureGatewayConnected()
|
|
45
|
+
if (!gw) throw new Error('Gateway not connected')
|
|
46
|
+
|
|
47
|
+
// Update exec approval config
|
|
48
|
+
const snap = await getExecConfig(agentId)
|
|
49
|
+
await setExecConfig(agentId, {
|
|
50
|
+
security: pc.security,
|
|
51
|
+
askMode: pc.askMode,
|
|
52
|
+
patterns: pc.security === 'allowlist' ? snap.file.patterns : [],
|
|
53
|
+
}, snap.hash)
|
|
54
|
+
|
|
55
|
+
// Sync tool groups if gateway supports it
|
|
56
|
+
try {
|
|
57
|
+
await gw.rpc('config.set', {
|
|
58
|
+
key: `agents.${agentId}.toolGroups`,
|
|
59
|
+
value: pc.toolGroups,
|
|
60
|
+
})
|
|
61
|
+
} catch {
|
|
62
|
+
// Not all gateways support tool group config — ignore
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -252,12 +252,12 @@ export function pushSchedulesToOpenClaw(): { written: number } {
|
|
|
252
252
|
const cronDir = path.join(config.workspacePath, 'cron')
|
|
253
253
|
ensureDir(cronDir)
|
|
254
254
|
|
|
255
|
-
const schedules = loadSchedules()
|
|
255
|
+
const schedules = loadSchedules() as Record<string, Schedule>
|
|
256
256
|
const cronSchedules = Object.values(schedules).filter(
|
|
257
|
-
(s
|
|
257
|
+
(s) => s.scheduleType === 'cron' && s.status === 'active',
|
|
258
258
|
)
|
|
259
259
|
|
|
260
|
-
const jobs = cronSchedules.map((s
|
|
260
|
+
const jobs = cronSchedules.map((s) => ({
|
|
261
261
|
name: s.name,
|
|
262
262
|
cron: s.cron,
|
|
263
263
|
agentId: s.agentId,
|
|
@@ -277,8 +277,8 @@ export function pullSchedulesFromOpenClaw(): { imported: number } {
|
|
|
277
277
|
const raw = JSON.parse(fs.readFileSync(jobsPath, 'utf8'))
|
|
278
278
|
if (!Array.isArray(raw)) return { imported: 0 }
|
|
279
279
|
|
|
280
|
-
const schedules = loadSchedules()
|
|
281
|
-
const existingNames = new Set(Object.values(schedules).map((s
|
|
280
|
+
const schedules = loadSchedules() as Record<string, Schedule>
|
|
281
|
+
const existingNames = new Set(Object.values(schedules).map((s) => `${s.name}|${s.cron}`))
|
|
282
282
|
let imported = 0
|
|
283
283
|
|
|
284
284
|
for (const job of raw) {
|
|
@@ -307,11 +307,12 @@ export function pullSchedulesFromOpenClaw(): { imported: number } {
|
|
|
307
307
|
|
|
308
308
|
// --- Secret/Credential Sync (Feature 7) ---
|
|
309
309
|
|
|
310
|
-
export function pullCredentialsFromOpenClaw(): { imported: number } {
|
|
310
|
+
export async function pullCredentialsFromOpenClaw(): Promise<{ imported: number }> {
|
|
311
311
|
const config = loadSyncConfig()
|
|
312
312
|
const modelsPath = path.join(config.workspacePath, 'agents', 'main', 'agent', 'models.json')
|
|
313
313
|
if (!fs.existsSync(modelsPath)) return { imported: 0 }
|
|
314
314
|
|
|
315
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
315
316
|
let raw: any
|
|
316
317
|
try {
|
|
317
318
|
raw = JSON.parse(fs.readFileSync(modelsPath, 'utf8'))
|
|
@@ -319,9 +320,9 @@ export function pullCredentialsFromOpenClaw(): { imported: number } {
|
|
|
319
320
|
return { imported: 0 }
|
|
320
321
|
}
|
|
321
322
|
|
|
322
|
-
const { loadCredentials: loadCreds, saveCredentials } =
|
|
323
|
+
const { loadCredentials: loadCreds, saveCredentials } = await import('./storage')
|
|
323
324
|
const creds = loadCreds()
|
|
324
|
-
const existingProviders = new Set(Object.values(creds).map((c:
|
|
325
|
+
const existingProviders = new Set(Object.values(creds).map((c: Record<string, unknown>) => c.provider))
|
|
325
326
|
let imported = 0
|
|
326
327
|
|
|
327
328
|
// Extract API keys from models.json entries
|
|
@@ -352,7 +353,7 @@ export function pushCredentialsToOpenClaw(): { written: boolean } {
|
|
|
352
353
|
const creds = loadCredentials()
|
|
353
354
|
|
|
354
355
|
const profiles: Record<string, string> = {}
|
|
355
|
-
for (const cred of Object.values(creds) as
|
|
356
|
+
for (const cred of Object.values(creds) as Array<Record<string, string>>) {
|
|
356
357
|
if (!cred.encryptedKey || !cred.provider) continue
|
|
357
358
|
try {
|
|
358
359
|
profiles[cred.provider] = decryptKey(cred.encryptedKey)
|
|
@@ -483,7 +484,7 @@ export async function runSync(params: {
|
|
|
483
484
|
results.push({ type, action: 'pull', result: pullSchedulesFromOpenClaw() })
|
|
484
485
|
break
|
|
485
486
|
case 'credentials':
|
|
486
|
-
results.push({ type, action: 'pull', result: pullCredentialsFromOpenClaw() })
|
|
487
|
+
results.push({ type, action: 'pull', result: await pullCredentialsFromOpenClaw() })
|
|
487
488
|
break
|
|
488
489
|
case 'plugins':
|
|
489
490
|
results.push({ type, action: 'pull', result: syncPluginsFromOpenClaw() })
|
package/src/lib/server/queue.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { pushMainLoopEventToMainSessions } from './main-agent-loop'
|
|
|
9
9
|
import { executeSessionChatTurn } from './chat-execution'
|
|
10
10
|
import { extractTaskResult, formatResultBody } from './task-result'
|
|
11
11
|
import { getCheckpointSaver } from './langgraph-checkpoint'
|
|
12
|
+
import { isProtectedMainSession } from './main-session'
|
|
12
13
|
import type { Agent, BoardTask, Message } from '@/types'
|
|
13
14
|
|
|
14
15
|
let processing = false
|
|
@@ -82,7 +83,7 @@ function pushQueueUnique(queue: string[], id: string): void {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
function isMainSession(session: SessionLike | null | undefined): boolean {
|
|
85
|
-
return session
|
|
86
|
+
return isProtectedMainSession(session)
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
function resolveTaskOwnerUser(task: ScheduleTaskMeta, sessions: Record<string, SessionLike>): string | null {
|