openclaw-bridge 0.3.2 → 0.4.1
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 +43 -16
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +843 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +64 -0
- package/dist/discovery.d.ts +4 -0
- package/dist/discovery.js +6 -0
- package/dist/file-ops.d.ts +22 -0
- package/dist/file-ops.js +253 -0
- package/dist/heartbeat.d.ts +21 -0
- package/dist/heartbeat.js +152 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +624 -0
- package/dist/manager/hub-client.d.ts +18 -0
- package/dist/manager/hub-client.js +89 -0
- package/dist/manager/local-manager.d.ts +12 -0
- package/dist/manager/local-manager.js +117 -0
- package/dist/manager/pm2-bridge.d.ts +17 -0
- package/dist/manager/pm2-bridge.js +113 -0
- package/dist/message-relay.d.ts +32 -0
- package/dist/message-relay.js +229 -0
- package/dist/permissions.d.ts +3 -0
- package/dist/permissions.js +14 -0
- package/dist/registry.d.ts +13 -0
- package/dist/registry.js +103 -0
- package/dist/restart.d.ts +15 -0
- package/dist/restart.js +107 -0
- package/dist/router.d.ts +11 -0
- package/dist/router.js +18 -0
- package/dist/session.d.ts +11 -0
- package/dist/session.js +21 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.js +1 -0
- package/openclaw.plugin.json +6 -92
- package/package.json +15 -5
- package/src/cli.ts +0 -842
- package/src/config.ts +0 -72
- package/src/discovery.ts +0 -17
- package/src/file-ops.ts +0 -320
- package/src/heartbeat.ts +0 -196
- package/src/index.ts +0 -681
- package/src/manager/hub-client.ts +0 -114
- package/src/manager/local-manager.ts +0 -121
- package/src/manager/pm2-bridge.ts +0 -125
- package/src/message-relay.ts +0 -184
- package/src/permissions.ts +0 -18
- package/src/registry.ts +0 -107
- package/src/restart.ts +0 -137
- package/src/router.ts +0 -40
- package/src/session.ts +0 -33
- package/src/types.ts +0 -100
- package/tsconfig.json +0 -14
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
export class ManagerHubClient {
|
|
4
|
+
hubUrl;
|
|
5
|
+
apiKey;
|
|
6
|
+
managerPass;
|
|
7
|
+
machineId;
|
|
8
|
+
ws = null;
|
|
9
|
+
_connected = false;
|
|
10
|
+
reconnectDelay = 1000;
|
|
11
|
+
logger;
|
|
12
|
+
onCommand = null;
|
|
13
|
+
constructor(hubUrl, apiKey, managerPass, logger) {
|
|
14
|
+
this.hubUrl = hubUrl;
|
|
15
|
+
this.apiKey = apiKey;
|
|
16
|
+
this.managerPass = managerPass;
|
|
17
|
+
this.machineId = hostname();
|
|
18
|
+
this.logger = logger;
|
|
19
|
+
}
|
|
20
|
+
get connected() {
|
|
21
|
+
return this._connected;
|
|
22
|
+
}
|
|
23
|
+
async connect() {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const wsUrl = this.hubUrl.replace(/^http/, "ws") + "/ws/manager";
|
|
26
|
+
this.ws = new WebSocket(wsUrl);
|
|
27
|
+
this.ws.on("open", () => {
|
|
28
|
+
this.ws.send(JSON.stringify({
|
|
29
|
+
type: "auth",
|
|
30
|
+
role: "manager",
|
|
31
|
+
machineId: this.machineId,
|
|
32
|
+
apiKey: this.apiKey,
|
|
33
|
+
managerPass: this.managerPass,
|
|
34
|
+
}));
|
|
35
|
+
});
|
|
36
|
+
this.ws.on("message", (data) => {
|
|
37
|
+
const msg = JSON.parse(data.toString());
|
|
38
|
+
if (msg.type === "auth_ok") {
|
|
39
|
+
this._connected = true;
|
|
40
|
+
this.reconnectDelay = 1000;
|
|
41
|
+
this.logger.info(`[local-manager] Hub connected as ${this.machineId}`);
|
|
42
|
+
resolve();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (msg.type === "auth_error") {
|
|
46
|
+
reject(new Error(msg.reason));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (msg.type === "manager_command" && this.onCommand) {
|
|
50
|
+
this.onCommand(msg);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
this.ws.on("close", () => {
|
|
54
|
+
this._connected = false;
|
|
55
|
+
this.logger.warn(`[local-manager] Hub disconnected, reconnecting in ${this.reconnectDelay}ms...`);
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
this.connect().catch(() => {
|
|
58
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000);
|
|
59
|
+
});
|
|
60
|
+
}, this.reconnectDelay);
|
|
61
|
+
});
|
|
62
|
+
this.ws.on("error", (err) => {
|
|
63
|
+
this.logger.error(`[local-manager] Hub error: ${err.message}`);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
sendStatus(agents, logs) {
|
|
68
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
69
|
+
this.ws.send(JSON.stringify({
|
|
70
|
+
type: "manager_status",
|
|
71
|
+
machineId: this.machineId,
|
|
72
|
+
agents,
|
|
73
|
+
logs,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
sendResult(action, target, success) {
|
|
78
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
79
|
+
this.ws.send(JSON.stringify({ type: "manager_result", action, target, success }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
disconnect() {
|
|
83
|
+
if (this.ws) {
|
|
84
|
+
this.ws.close();
|
|
85
|
+
this.ws = null;
|
|
86
|
+
this._connected = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PluginLogger, LocalManagerConfig } from "../types.js";
|
|
2
|
+
export declare class LocalManager {
|
|
3
|
+
private config;
|
|
4
|
+
private apiKey;
|
|
5
|
+
private logger;
|
|
6
|
+
private hubClient;
|
|
7
|
+
private statusTimer;
|
|
8
|
+
private hasLock;
|
|
9
|
+
constructor(config: LocalManagerConfig, apiKey: string, logger: PluginLogger);
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
stop(): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { ManagerHubClient } from "./hub-client.js";
|
|
5
|
+
import { listProcesses, getProcessLogs, restartProcess, stopProcess, startProcess, stopAll, } from "./pm2-bridge.js";
|
|
6
|
+
const LOCK_FILE = join(tmpdir(), "openclaw-local-manager.lock");
|
|
7
|
+
function acquireLock() {
|
|
8
|
+
if (existsSync(LOCK_FILE)) {
|
|
9
|
+
try {
|
|
10
|
+
const pid = parseInt(readFileSync(LOCK_FILE, "utf-8").trim(), 10);
|
|
11
|
+
process.kill(pid, 0);
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Process is dead, steal the lock
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
writeFileSync(LOCK_FILE, String(process.pid));
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
function releaseLock() {
|
|
22
|
+
try {
|
|
23
|
+
unlinkSync(LOCK_FILE);
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
}
|
|
27
|
+
export class LocalManager {
|
|
28
|
+
config;
|
|
29
|
+
apiKey;
|
|
30
|
+
logger;
|
|
31
|
+
hubClient = null;
|
|
32
|
+
statusTimer = null;
|
|
33
|
+
hasLock = false;
|
|
34
|
+
constructor(config, apiKey, logger) {
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.apiKey = apiKey;
|
|
37
|
+
this.logger = logger;
|
|
38
|
+
}
|
|
39
|
+
async start() {
|
|
40
|
+
if (!this.config.enabled)
|
|
41
|
+
return;
|
|
42
|
+
this.hasLock = acquireLock();
|
|
43
|
+
if (!this.hasLock) {
|
|
44
|
+
this.logger.info("[local-manager] Another instance is already managing this machine, skipping");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.hubClient = new ManagerHubClient(this.config.hubUrl, this.apiKey, this.config.managerPass, this.logger);
|
|
48
|
+
this.hubClient.onCommand = async (msg) => {
|
|
49
|
+
const processes = await listProcesses();
|
|
50
|
+
let target = msg.target;
|
|
51
|
+
if (msg.action !== "restart-all" && msg.action !== "stop-all") {
|
|
52
|
+
const match = processes.find((p) => p.agentId === msg.target || p.name === msg.target);
|
|
53
|
+
if (match) {
|
|
54
|
+
target = match.name;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
this.logger.error(`[local-manager] No PM2 process found for: ${msg.target}`);
|
|
58
|
+
this.hubClient.sendResult(msg.action, msg.target, false);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
this.logger.info(`[local-manager] ${msg.action} ${target}`);
|
|
63
|
+
try {
|
|
64
|
+
if (msg.action === "restart")
|
|
65
|
+
await restartProcess(target);
|
|
66
|
+
else if (msg.action === "stop")
|
|
67
|
+
await stopProcess(target);
|
|
68
|
+
else if (msg.action === "start")
|
|
69
|
+
await startProcess(target);
|
|
70
|
+
else if (msg.action === "restart-all")
|
|
71
|
+
await stopAll();
|
|
72
|
+
else if (msg.action === "stop-all")
|
|
73
|
+
await stopAll();
|
|
74
|
+
this.hubClient.sendResult(msg.action, msg.target, true);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
this.logger.error(`[local-manager] Failed: ${err.message}`);
|
|
78
|
+
this.hubClient.sendResult(msg.action, msg.target, false);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
await this.hubClient.connect();
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
this.logger.warn(`[local-manager] Hub connect failed: ${err.message}. Will retry.`);
|
|
86
|
+
}
|
|
87
|
+
this.statusTimer = setInterval(async () => {
|
|
88
|
+
try {
|
|
89
|
+
const processes = await listProcesses();
|
|
90
|
+
const logs = {};
|
|
91
|
+
for (const proc of processes) {
|
|
92
|
+
logs[proc.name] = await getProcessLogs(proc.name);
|
|
93
|
+
}
|
|
94
|
+
if (this.hubClient?.connected) {
|
|
95
|
+
this.hubClient.sendStatus(processes, logs);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
this.logger.warn(`[local-manager] Status tick failed: ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
}, 30_000);
|
|
102
|
+
this.logger.info("[local-manager] Started (reporting every 30s)");
|
|
103
|
+
}
|
|
104
|
+
async stop() {
|
|
105
|
+
if (this.statusTimer) {
|
|
106
|
+
clearInterval(this.statusTimer);
|
|
107
|
+
this.statusTimer = null;
|
|
108
|
+
}
|
|
109
|
+
if (this.hubClient) {
|
|
110
|
+
this.hubClient.disconnect();
|
|
111
|
+
this.hubClient = null;
|
|
112
|
+
}
|
|
113
|
+
if (this.hasLock)
|
|
114
|
+
releaseLock();
|
|
115
|
+
this.logger.info("[local-manager] Stopped");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface PM2Process {
|
|
2
|
+
name: string;
|
|
3
|
+
agentId: string;
|
|
4
|
+
pid: number;
|
|
5
|
+
status: string;
|
|
6
|
+
memory: number;
|
|
7
|
+
cpu: number;
|
|
8
|
+
restarts: number;
|
|
9
|
+
uptime: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function listProcesses(): Promise<PM2Process[]>;
|
|
12
|
+
export declare function getProcessLogs(name: string): Promise<string>;
|
|
13
|
+
export declare function restartProcess(name: string): Promise<void>;
|
|
14
|
+
export declare function stopProcess(name: string): Promise<void>;
|
|
15
|
+
export declare function startProcess(name: string): Promise<void>;
|
|
16
|
+
export declare function stopAll(): Promise<void>;
|
|
17
|
+
export declare function startAll(ecosystemPath: string): Promise<void>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
const _exec = promisify(exec);
|
|
5
|
+
const execAsync = async (cmd, opts) => {
|
|
6
|
+
const result = await _exec(cmd, { windowsHide: true, encoding: "utf-8", ...opts });
|
|
7
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
8
|
+
};
|
|
9
|
+
const IS_WIN = platform() === "win32";
|
|
10
|
+
async function killPort(port) {
|
|
11
|
+
if (!port)
|
|
12
|
+
return;
|
|
13
|
+
try {
|
|
14
|
+
if (IS_WIN) {
|
|
15
|
+
const { stdout } = await execAsync(`netstat -ano | findstr :${port} | findstr LISTENING`);
|
|
16
|
+
const pids = new Set();
|
|
17
|
+
stdout.split("\n").forEach((line) => {
|
|
18
|
+
const pid = line.trim().split(/\s+/).pop();
|
|
19
|
+
if (pid && /^\d+$/.test(pid) && pid !== "0")
|
|
20
|
+
pids.add(pid);
|
|
21
|
+
});
|
|
22
|
+
for (const pid of pids) {
|
|
23
|
+
await execAsync(`taskkill /F /PID ${pid}`).catch(() => { });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
await execAsync(`lsof -ti:${port} | xargs kill -9`).catch(() => { });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// No process on port
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function getProcessPort(name) {
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await execAsync("pm2 jlist");
|
|
37
|
+
const apps = JSON.parse(stdout);
|
|
38
|
+
const app = apps.find((a) => a.name === name);
|
|
39
|
+
if (app?.pm2_env?.env?.OPENCLAW_GATEWAY_PORT) {
|
|
40
|
+
return parseInt(app.pm2_env.env.OPENCLAW_GATEWAY_PORT, 10);
|
|
41
|
+
}
|
|
42
|
+
if (app?.pm2_env?.pm_exec_path) {
|
|
43
|
+
const { readFileSync } = await import("node:fs");
|
|
44
|
+
const dir = app.pm2_env.pm_exec_path.replace(/[/\\]run\.sh$/, "");
|
|
45
|
+
const script = readFileSync(dir + "/run.sh", "utf-8");
|
|
46
|
+
const match = script.match(/OPENCLAW_GATEWAY_PORT="?(\d+)/);
|
|
47
|
+
if (match)
|
|
48
|
+
return parseInt(match[1], 10);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
export async function listProcesses() {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execAsync("pm2 jlist");
|
|
57
|
+
const apps = JSON.parse(stdout);
|
|
58
|
+
return apps.map((p) => ({
|
|
59
|
+
name: p.name,
|
|
60
|
+
agentId: p.name.replace(/^gw-/, ""),
|
|
61
|
+
pid: p.pid,
|
|
62
|
+
status: p.pm2_env?.status || "unknown",
|
|
63
|
+
memory: p.monit?.memory || 0,
|
|
64
|
+
cpu: p.monit?.cpu || 0,
|
|
65
|
+
restarts: p.pm2_env?.restart_time || 0,
|
|
66
|
+
uptime: p.pm2_env?.pm_uptime ? Date.now() - p.pm2_env.pm_uptime : 0,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function getProcessLogs(name) {
|
|
74
|
+
try {
|
|
75
|
+
const { stdout } = await execAsync(`pm2 logs ${name} --nostream --lines 100`, { timeout: 10_000 });
|
|
76
|
+
return stdout;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return "(logs unavailable)";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function restartProcess(name) {
|
|
83
|
+
await execAsync("pm2 stop " + name).catch(() => { });
|
|
84
|
+
const port = await getProcessPort(name);
|
|
85
|
+
if (port)
|
|
86
|
+
await killPort(port);
|
|
87
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
88
|
+
await execAsync("pm2 restart " + name);
|
|
89
|
+
}
|
|
90
|
+
export async function stopProcess(name) {
|
|
91
|
+
await execAsync("pm2 stop " + name);
|
|
92
|
+
const port = await getProcessPort(name);
|
|
93
|
+
if (port)
|
|
94
|
+
await killPort(port);
|
|
95
|
+
}
|
|
96
|
+
export async function startProcess(name) {
|
|
97
|
+
const port = await getProcessPort(name);
|
|
98
|
+
if (port)
|
|
99
|
+
await killPort(port);
|
|
100
|
+
await execAsync("pm2 restart " + name);
|
|
101
|
+
}
|
|
102
|
+
export async function stopAll() {
|
|
103
|
+
const procs = await listProcesses();
|
|
104
|
+
await execAsync("pm2 stop all");
|
|
105
|
+
for (const proc of procs) {
|
|
106
|
+
const port = await getProcessPort(proc.name);
|
|
107
|
+
if (port)
|
|
108
|
+
await killPort(port);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export async function startAll(ecosystemPath) {
|
|
112
|
+
await execAsync("pm2 start " + ecosystemPath);
|
|
113
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PluginLogger, MessageRelayConfig } from './types.js';
|
|
2
|
+
type MessageHandler = (msg: any) => void;
|
|
3
|
+
export declare class MessageRelayClient {
|
|
4
|
+
private ws;
|
|
5
|
+
private agentId;
|
|
6
|
+
private config;
|
|
7
|
+
private logger;
|
|
8
|
+
private handlers;
|
|
9
|
+
private reconnectDelay;
|
|
10
|
+
private maxReconnectDelay;
|
|
11
|
+
private shouldReconnect;
|
|
12
|
+
private authenticated;
|
|
13
|
+
private reconnectTimer;
|
|
14
|
+
private pendingCallbacks;
|
|
15
|
+
private machineId;
|
|
16
|
+
private originalAgentId;
|
|
17
|
+
private originalAgentName;
|
|
18
|
+
private conflictRetries;
|
|
19
|
+
private maxConflictRetries;
|
|
20
|
+
private onConflictRename;
|
|
21
|
+
constructor(agentId: string, config: MessageRelayConfig, logger: PluginLogger, machineId: string);
|
|
22
|
+
setAgentName(name: string): void;
|
|
23
|
+
setOnConflictRename(cb: (newAgentId: string, newAgentName: string) => void): void;
|
|
24
|
+
connect(): Promise<void>;
|
|
25
|
+
private scheduleReconnect;
|
|
26
|
+
on(type: string, handler: MessageHandler): void;
|
|
27
|
+
send(msg: any): void;
|
|
28
|
+
sendAndWait(msg: any, timeoutMs?: number): Promise<any>;
|
|
29
|
+
disconnect(): Promise<void>;
|
|
30
|
+
get isConnected(): boolean;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
export class MessageRelayClient {
|
|
3
|
+
ws = null;
|
|
4
|
+
agentId;
|
|
5
|
+
config;
|
|
6
|
+
logger;
|
|
7
|
+
handlers = new Map();
|
|
8
|
+
reconnectDelay = 1000;
|
|
9
|
+
maxReconnectDelay = 30000;
|
|
10
|
+
shouldReconnect = true;
|
|
11
|
+
authenticated = false;
|
|
12
|
+
reconnectTimer = null;
|
|
13
|
+
pendingCallbacks = new Map();
|
|
14
|
+
machineId;
|
|
15
|
+
originalAgentId;
|
|
16
|
+
originalAgentName = '';
|
|
17
|
+
conflictRetries = 0;
|
|
18
|
+
maxConflictRetries = 5;
|
|
19
|
+
onConflictRename = null;
|
|
20
|
+
constructor(agentId, config, logger, machineId) {
|
|
21
|
+
this.agentId = agentId;
|
|
22
|
+
this.originalAgentId = agentId;
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.logger = logger;
|
|
25
|
+
this.machineId = machineId;
|
|
26
|
+
}
|
|
27
|
+
setAgentName(name) {
|
|
28
|
+
if (!this.originalAgentName)
|
|
29
|
+
this.originalAgentName = name;
|
|
30
|
+
}
|
|
31
|
+
setOnConflictRename(cb) {
|
|
32
|
+
this.onConflictRename = cb;
|
|
33
|
+
}
|
|
34
|
+
async connect() {
|
|
35
|
+
// Clean up any existing connection
|
|
36
|
+
if (this.ws) {
|
|
37
|
+
try {
|
|
38
|
+
this.ws.removeAllListeners();
|
|
39
|
+
this.ws.close();
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
this.ws = null;
|
|
43
|
+
}
|
|
44
|
+
this.authenticated = false;
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
let settled = false;
|
|
47
|
+
const settle = (fn, arg) => {
|
|
48
|
+
if (!settled) {
|
|
49
|
+
settled = true;
|
|
50
|
+
fn(arg);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
this.ws = new WebSocket(this.config.url);
|
|
55
|
+
// Auth timeout — if no auth_ok within 10s, fail
|
|
56
|
+
const authTimer = setTimeout(() => {
|
|
57
|
+
settle(reject, new Error('Auth timeout'));
|
|
58
|
+
if (this.ws) {
|
|
59
|
+
try {
|
|
60
|
+
this.ws.close();
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
}
|
|
64
|
+
}, 10_000);
|
|
65
|
+
this.ws.on('open', () => {
|
|
66
|
+
this.logger.info('Connected to Message Relay Hub');
|
|
67
|
+
this.ws.send(JSON.stringify({
|
|
68
|
+
type: 'auth',
|
|
69
|
+
agentId: this.agentId,
|
|
70
|
+
apiKey: this.config.apiKey,
|
|
71
|
+
machineId: this.machineId,
|
|
72
|
+
}));
|
|
73
|
+
});
|
|
74
|
+
this.ws.on('message', (data) => {
|
|
75
|
+
let msg;
|
|
76
|
+
try {
|
|
77
|
+
msg = JSON.parse(data.toString());
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (msg.type === 'auth_ok') {
|
|
83
|
+
clearTimeout(authTimer);
|
|
84
|
+
this.authenticated = true;
|
|
85
|
+
this.reconnectDelay = 1000; // Reset on success
|
|
86
|
+
this.logger.info('Authenticated with Message Relay Hub');
|
|
87
|
+
settle(resolve);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (msg.type === 'auth_error') {
|
|
91
|
+
clearTimeout(authTimer);
|
|
92
|
+
this.logger.error(`Auth failed: ${msg.reason}`);
|
|
93
|
+
settle(reject, new Error(msg.reason));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (msg.type === 'auth_conflict') {
|
|
97
|
+
clearTimeout(authTimer);
|
|
98
|
+
this.conflictRetries++;
|
|
99
|
+
if (this.conflictRetries > this.maxConflictRetries) {
|
|
100
|
+
this.logger.error(`agentId conflict: max retries (${this.maxConflictRetries}) exhausted, giving up`);
|
|
101
|
+
this.shouldReconnect = false;
|
|
102
|
+
settle(reject, new Error('agentId conflict: max retries exhausted'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let newAgentId;
|
|
106
|
+
if (this.conflictRetries === 1) {
|
|
107
|
+
newAgentId = msg.suggestedId;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const base = `${this.originalAgentId}@${this.machineId}`;
|
|
111
|
+
newAgentId = `${base}-${this.conflictRetries - 1}`;
|
|
112
|
+
}
|
|
113
|
+
const newAgentName = this.originalAgentName
|
|
114
|
+
? `${this.originalAgentName} (${this.machineId})`
|
|
115
|
+
: newAgentId;
|
|
116
|
+
this.logger.info(`agentId "${this.agentId}" conflicts with machine "${msg.existingMachine}", renaming to "${newAgentId}"`);
|
|
117
|
+
this.agentId = newAgentId;
|
|
118
|
+
if (this.onConflictRename) {
|
|
119
|
+
this.onConflictRename(newAgentId, newAgentName);
|
|
120
|
+
}
|
|
121
|
+
settle(reject, new Error('auth_conflict - reconnecting with new name'));
|
|
122
|
+
if (this.ws) {
|
|
123
|
+
try {
|
|
124
|
+
this.ws.close();
|
|
125
|
+
}
|
|
126
|
+
catch { }
|
|
127
|
+
}
|
|
128
|
+
this.reconnectDelay = 100;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Check for pending request callbacks (replyTo or sessionId)
|
|
132
|
+
const callbackKey = msg.replyTo || msg.sessionId;
|
|
133
|
+
if (callbackKey && this.pendingCallbacks.has(callbackKey)) {
|
|
134
|
+
const cb = this.pendingCallbacks.get(callbackKey);
|
|
135
|
+
clearTimeout(cb.timer);
|
|
136
|
+
this.pendingCallbacks.delete(callbackKey);
|
|
137
|
+
cb.resolve(msg);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Dispatch to type-based handlers
|
|
141
|
+
const handlers = this.handlers.get(msg.type) || [];
|
|
142
|
+
for (const handler of handlers) {
|
|
143
|
+
try {
|
|
144
|
+
handler(msg);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
this.logger.error(`Handler error for ${msg.type}: ${e.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
this.ws.on('close', (code) => {
|
|
152
|
+
clearTimeout(authTimer);
|
|
153
|
+
this.authenticated = false;
|
|
154
|
+
this.logger.info(`Disconnected from Hub (code: ${code})`);
|
|
155
|
+
settle(reject, new Error('Connection closed'));
|
|
156
|
+
if (this.shouldReconnect) {
|
|
157
|
+
this.scheduleReconnect();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
this.ws.on('error', (err) => {
|
|
161
|
+
this.logger.error(`WebSocket error: ${err.message}`);
|
|
162
|
+
// Don't settle here — let 'close' event handle it
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
settle(reject, err);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
scheduleReconnect() {
|
|
171
|
+
// Prevent multiple reconnect timers
|
|
172
|
+
if (this.reconnectTimer)
|
|
173
|
+
return;
|
|
174
|
+
this.logger.info(`Reconnecting in ${this.reconnectDelay}ms...`);
|
|
175
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
176
|
+
this.reconnectTimer = null;
|
|
177
|
+
try {
|
|
178
|
+
await this.connect();
|
|
179
|
+
this.logger.info('Reconnected to Message Relay Hub');
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
183
|
+
// connect() failure triggers 'close' which calls scheduleReconnect again
|
|
184
|
+
}
|
|
185
|
+
}, this.reconnectDelay);
|
|
186
|
+
}
|
|
187
|
+
on(type, handler) {
|
|
188
|
+
const existing = this.handlers.get(type) || [];
|
|
189
|
+
existing.push(handler);
|
|
190
|
+
this.handlers.set(type, existing);
|
|
191
|
+
}
|
|
192
|
+
send(msg) {
|
|
193
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.authenticated) {
|
|
194
|
+
this.ws.send(JSON.stringify(msg));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async sendAndWait(msg, timeoutMs = 60_000) {
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
const id = msg.id || msg.sessionId;
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
this.pendingCallbacks.delete(id);
|
|
202
|
+
reject(new Error(`Timeout waiting for reply to ${id}`));
|
|
203
|
+
}, timeoutMs);
|
|
204
|
+
this.pendingCallbacks.set(id, { resolve, reject, timer });
|
|
205
|
+
this.send(msg);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async disconnect() {
|
|
209
|
+
this.shouldReconnect = false;
|
|
210
|
+
if (this.reconnectTimer) {
|
|
211
|
+
clearTimeout(this.reconnectTimer);
|
|
212
|
+
this.reconnectTimer = null;
|
|
213
|
+
}
|
|
214
|
+
for (const [id, cb] of this.pendingCallbacks.entries()) {
|
|
215
|
+
clearTimeout(cb.timer);
|
|
216
|
+
cb.reject(new Error('Disconnecting'));
|
|
217
|
+
}
|
|
218
|
+
this.pendingCallbacks.clear();
|
|
219
|
+
if (this.ws) {
|
|
220
|
+
this.ws.removeAllListeners();
|
|
221
|
+
this.ws.close(1000, 'plugin deactivated');
|
|
222
|
+
this.ws = null;
|
|
223
|
+
}
|
|
224
|
+
this.authenticated = false;
|
|
225
|
+
}
|
|
226
|
+
get isConnected() {
|
|
227
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN && this.authenticated;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const PUBLIC_ACTIONS = new Set(["discover", "whois", "send_file"]);
|
|
2
|
+
const SUPERUSER_ACTIONS = new Set(["read_file", "write_file", "restart"]);
|
|
3
|
+
export function checkPermission(action, config) {
|
|
4
|
+
if (PUBLIC_ACTIONS.has(action))
|
|
5
|
+
return true;
|
|
6
|
+
if (SUPERUSER_ACTIONS.has(action))
|
|
7
|
+
return config.role === "superuser";
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
export function assertPermission(action, config) {
|
|
11
|
+
if (!checkPermission(action, config)) {
|
|
12
|
+
throw new Error(`openclaw-bridge: permission denied — "${action}" requires superuser role, current role is "${config.role}"`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BridgeConfig, RegistryEntry, PluginLogger } from "./types.js";
|
|
2
|
+
export declare class BridgeRegistry {
|
|
3
|
+
private baseUrl;
|
|
4
|
+
private apiKey;
|
|
5
|
+
private logger;
|
|
6
|
+
constructor(config: BridgeConfig, logger: PluginLogger);
|
|
7
|
+
private headers;
|
|
8
|
+
register(entry: RegistryEntry): Promise<void>;
|
|
9
|
+
update(entry: RegistryEntry): Promise<void>;
|
|
10
|
+
deregister(agentId: string): Promise<void>;
|
|
11
|
+
discover(offlineThresholdMs: number): Promise<RegistryEntry[]>;
|
|
12
|
+
findAgent(agentId: string, offlineThresholdMs: number): Promise<RegistryEntry | null>;
|
|
13
|
+
}
|