openclaw-bridge 0.1.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 +119 -0
- package/_inbox/main/bridge-test.md +1 -0
- package/_inbox/pm/bridge-test.md +1 -0
- package/openclaw.plugin.json +71 -0
- package/output/bridge-test.md +1 -0
- package/package.json +29 -0
- package/src/config.ts +63 -0
- package/src/discovery.ts +17 -0
- package/src/file-ops.ts +320 -0
- package/src/heartbeat.ts +165 -0
- package/src/index.ts +646 -0
- package/src/message-relay.ts +155 -0
- package/src/permissions.ts +18 -0
- package/src/registry.ts +107 -0
- package/src/restart.ts +137 -0
- package/src/router.ts +40 -0
- package/src/session.ts +33 -0
- package/src/types.ts +87 -0
- package/tsconfig.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# openclaw-bridge
|
|
2
|
+
|
|
3
|
+
Cross-gateway communication plugin for [OpenClaw](https://github.com/nicepkg/openclaw). Enables independent gateway instances to discover each other, exchange files, relay messages, and hand off conversations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Agent Discovery** — Auto-register and discover online agents via heartbeat
|
|
8
|
+
- **File Transfer** — Send files between agents (local or cross-machine via Hub)
|
|
9
|
+
- **Message Relay** — Send messages to any agent through the Hub and get replies
|
|
10
|
+
- **Session Handoff** — Transfer active conversations between agents seamlessly
|
|
11
|
+
- **Superuser Tools** — Read/write files and restart remote gateways
|
|
12
|
+
- **Auto-Config** — Automatically patches `openclaw.json` with recommended settings on first run
|
|
13
|
+
|
|
14
|
+
## Prerequisites
|
|
15
|
+
|
|
16
|
+
You need [openclaw-bridge-hub](https://www.npmjs.com/package/openclaw-bridge-hub) (v0.2.4+) running on a reachable server first:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# On your server:
|
|
20
|
+
npm install -g openclaw-bridge-hub
|
|
21
|
+
openclaw-bridge-hub init # generates API key — save it!
|
|
22
|
+
openclaw-bridge-hub start # starts on port 3080
|
|
23
|
+
openclaw-bridge-hub install-service # auto-start on boot (Linux)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Save the generated API key — you'll need it for the plugin config below.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
openclaw plugins install openclaw-bridge
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or manually: place this plugin in a directory listed in `plugins.load.paths` of your `openclaw.json`.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Add to `openclaw.json` under `plugins.entries` (replace the API key and server URL with your own):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"plugins": {
|
|
43
|
+
"entries": {
|
|
44
|
+
"openclaw-bridge": {
|
|
45
|
+
"config": {
|
|
46
|
+
"role": "normal",
|
|
47
|
+
"agentId": "my-agent",
|
|
48
|
+
"agentName": "My Agent",
|
|
49
|
+
"registry": {
|
|
50
|
+
"baseUrl": "http://69.5.7.190:3080",
|
|
51
|
+
"apiKey": "your-hub-api-key"
|
|
52
|
+
},
|
|
53
|
+
"fileRelay": {
|
|
54
|
+
"baseUrl": "http://69.5.7.190:3080",
|
|
55
|
+
"apiKey": "your-hub-api-key"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Auto-configured settings
|
|
65
|
+
|
|
66
|
+
On first startup, the plugin automatically adds these if missing:
|
|
67
|
+
|
|
68
|
+
| Setting | Value | Purpose |
|
|
69
|
+
|---------|-------|---------|
|
|
70
|
+
| `messageRelay.url` | Derived from `fileRelay.baseUrl` | WebSocket connection to Hub |
|
|
71
|
+
| `messageRelay.apiKey` | Same as `fileRelay.apiKey` | Hub authentication |
|
|
72
|
+
| `gateway.http.endpoints.chatCompletions.enabled` | `true` | Required for message relay processing |
|
|
73
|
+
| `channels.discord.accounts.*.dmHistoryLimit` | `0` | Fast DM responses (OpenViking handles memory) |
|
|
74
|
+
|
|
75
|
+
### Roles
|
|
76
|
+
|
|
77
|
+
| Role | Capabilities |
|
|
78
|
+
|------|-------------|
|
|
79
|
+
| `normal` | discover, whois, send_file, send_message, handoff |
|
|
80
|
+
| `superuser` | All of normal + read_file, write_file, restart |
|
|
81
|
+
|
|
82
|
+
## Tools
|
|
83
|
+
|
|
84
|
+
| Tool | Description |
|
|
85
|
+
|------|-------------|
|
|
86
|
+
| `bridge_discover` | List all online agents |
|
|
87
|
+
| `bridge_whois` | Get details for a specific agent |
|
|
88
|
+
| `bridge_send_file` | Send a file to another agent's inbox |
|
|
89
|
+
| `bridge_send_message` | Send a message and wait for reply (relay mode) |
|
|
90
|
+
| `bridge_handoff` | Hand off conversation to another agent |
|
|
91
|
+
| `bridge_handoff_end` | End handoff and return to original agent |
|
|
92
|
+
| `bridge_handoff_switch` | Switch handoff to a different agent |
|
|
93
|
+
| `bridge_read_file` | Read a file from any agent's workspace (superuser) |
|
|
94
|
+
| `bridge_write_file` | Write a file to any agent's workspace (superuser) |
|
|
95
|
+
| `bridge_restart` | Restart another gateway (superuser) |
|
|
96
|
+
|
|
97
|
+
## Architecture
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
User ←→ Discord DM ←→ Main Gateway
|
|
101
|
+
↕ (WebSocket)
|
|
102
|
+
openclaw-bridge-hub (port 3080)
|
|
103
|
+
↕ (WebSocket)
|
|
104
|
+
PM Gateway / Bot1-4
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- Each gateway runs one agent with this shared plugin
|
|
108
|
+
- Plugin auto-registers to Hub, heartbeats every 30 seconds
|
|
109
|
+
- Messages and handoffs route through Hub WebSocket (`/ws`)
|
|
110
|
+
- File transfers use local filesystem (same machine) or Hub relay (cross-machine)
|
|
111
|
+
|
|
112
|
+
## Requirements
|
|
113
|
+
|
|
114
|
+
- [openclaw-bridge-hub](https://www.npmjs.com/package/openclaw-bridge-hub) v0.2.4+ running on a reachable server
|
|
115
|
+
- OpenClaw gateway 2026.3.24+
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Hello from main! This is a test file sent via bridge_send_file.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Hello from main! This is a test file sent via bridge_send_file.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "openclaw-bridge",
|
|
3
|
+
"kind": "extension",
|
|
4
|
+
"uiHints": {
|
|
5
|
+
"role": {
|
|
6
|
+
"label": "Role",
|
|
7
|
+
"help": "normal = isolated workspace access only; superuser = cross-gateway access"
|
|
8
|
+
},
|
|
9
|
+
"agentId": {
|
|
10
|
+
"label": "Agent ID",
|
|
11
|
+
"help": "Unique identifier for this agent (e.g., pm, bot1, main)"
|
|
12
|
+
},
|
|
13
|
+
"agentName": {
|
|
14
|
+
"label": "Agent Display Name",
|
|
15
|
+
"help": "Human-readable name (e.g., PM Bot, Director Ma)"
|
|
16
|
+
},
|
|
17
|
+
"registry.baseUrl": {
|
|
18
|
+
"label": "OpenViking URL",
|
|
19
|
+
"placeholder": "http://69.5.7.190:1933",
|
|
20
|
+
"help": "OpenViking server for gateway registry"
|
|
21
|
+
},
|
|
22
|
+
"registry.apiKey": {
|
|
23
|
+
"label": "OpenViking API Key",
|
|
24
|
+
"sensitive": true
|
|
25
|
+
},
|
|
26
|
+
"fileRelay.baseUrl": {
|
|
27
|
+
"label": "FileRelay URL",
|
|
28
|
+
"placeholder": "http://69.5.7.190:3080",
|
|
29
|
+
"help": "FileRelay server for cross-machine file transfer"
|
|
30
|
+
},
|
|
31
|
+
"fileRelay.apiKey": {
|
|
32
|
+
"label": "FileRelay API Key",
|
|
33
|
+
"sensitive": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"configSchema": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"additionalProperties": false,
|
|
39
|
+
"required": ["role", "agentId", "agentName", "registry"],
|
|
40
|
+
"properties": {
|
|
41
|
+
"role": { "type": "string", "enum": ["normal", "superuser"] },
|
|
42
|
+
"agentId": { "type": "string" },
|
|
43
|
+
"agentName": { "type": "string" },
|
|
44
|
+
"registry": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"required": ["baseUrl"],
|
|
47
|
+
"properties": {
|
|
48
|
+
"provider": { "type": "string" },
|
|
49
|
+
"baseUrl": { "type": "string" },
|
|
50
|
+
"apiKey": { "type": "string" }
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"fileRelay": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"properties": {
|
|
56
|
+
"baseUrl": { "type": "string" },
|
|
57
|
+
"apiKey": { "type": "string" }
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"messageRelay": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"url": { "type": "string" },
|
|
64
|
+
"apiKey": { "type": "string" }
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"heartbeatIntervalMs": { "type": "number" },
|
|
68
|
+
"offlineThresholdMs": { "type": "number" }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Hello from main! This is a test file sent via bridge_send_file.
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cross-gateway communication plugin for OpenClaw — agent discovery, file transfer, real-time messaging, and session handoff",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./src/index.ts"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@sinclair/typebox": "^0.34.0",
|
|
14
|
+
"ws": "^8.20.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/ws": "^8.18.1",
|
|
18
|
+
"typescript": "^5.7.0"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"openclaw",
|
|
22
|
+
"bridge",
|
|
23
|
+
"multi-agent",
|
|
24
|
+
"gateway",
|
|
25
|
+
"communication",
|
|
26
|
+
"handoff"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import type { BridgeConfig } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
heartbeatIntervalMs: 30_000,
|
|
6
|
+
offlineThresholdMs: 120_000,
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export function parseConfig(raw: unknown): BridgeConfig {
|
|
10
|
+
if (!raw || typeof raw !== "object") {
|
|
11
|
+
throw new Error("openclaw-bridge: missing plugin config");
|
|
12
|
+
}
|
|
13
|
+
const obj = raw as Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
if (!obj.role || (obj.role !== "normal" && obj.role !== "superuser")) {
|
|
16
|
+
throw new Error('openclaw-bridge: config.role must be "normal" or "superuser"');
|
|
17
|
+
}
|
|
18
|
+
if (!obj.agentId || typeof obj.agentId !== "string") {
|
|
19
|
+
throw new Error("openclaw-bridge: config.agentId is required");
|
|
20
|
+
}
|
|
21
|
+
if (!obj.agentName || typeof obj.agentName !== "string") {
|
|
22
|
+
throw new Error("openclaw-bridge: config.agentName is required");
|
|
23
|
+
}
|
|
24
|
+
const registry = obj.registry as Record<string, unknown> | undefined;
|
|
25
|
+
if (!registry || !registry.baseUrl || typeof registry.baseUrl !== "string") {
|
|
26
|
+
throw new Error("openclaw-bridge: config.registry.baseUrl is required");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
role: obj.role as "normal" | "superuser",
|
|
31
|
+
agentId: obj.agentId as string,
|
|
32
|
+
agentName: obj.agentName as string,
|
|
33
|
+
registry: {
|
|
34
|
+
provider: (registry.provider as string) ?? "openviking",
|
|
35
|
+
baseUrl: registry.baseUrl as string,
|
|
36
|
+
apiKey: registry.apiKey as string | undefined,
|
|
37
|
+
},
|
|
38
|
+
fileRelay: obj.fileRelay
|
|
39
|
+
? {
|
|
40
|
+
baseUrl: (obj.fileRelay as Record<string, unknown>).baseUrl as string,
|
|
41
|
+
apiKey: (obj.fileRelay as Record<string, unknown>).apiKey as string | undefined,
|
|
42
|
+
}
|
|
43
|
+
: undefined,
|
|
44
|
+
messageRelay: obj.messageRelay
|
|
45
|
+
? {
|
|
46
|
+
url: (obj.messageRelay as Record<string, unknown>).url as string,
|
|
47
|
+
apiKey: (obj.messageRelay as Record<string, unknown>).apiKey as string,
|
|
48
|
+
}
|
|
49
|
+
: undefined,
|
|
50
|
+
heartbeatIntervalMs:
|
|
51
|
+
typeof obj.heartbeatIntervalMs === "number"
|
|
52
|
+
? obj.heartbeatIntervalMs
|
|
53
|
+
: DEFAULTS.heartbeatIntervalMs,
|
|
54
|
+
offlineThresholdMs:
|
|
55
|
+
typeof obj.offlineThresholdMs === "number"
|
|
56
|
+
? obj.offlineThresholdMs
|
|
57
|
+
: DEFAULTS.offlineThresholdMs,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getMachineId(): string {
|
|
62
|
+
return hostname();
|
|
63
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RegistryEntry } from "./types.js";
|
|
2
|
+
import type { BridgeRegistry } from "./registry.js";
|
|
3
|
+
|
|
4
|
+
export async function discoverAll(
|
|
5
|
+
registry: BridgeRegistry,
|
|
6
|
+
offlineThresholdMs: number,
|
|
7
|
+
): Promise<RegistryEntry[]> {
|
|
8
|
+
return registry.discover(offlineThresholdMs);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function whois(
|
|
12
|
+
registry: BridgeRegistry,
|
|
13
|
+
agentId: string,
|
|
14
|
+
offlineThresholdMs: number,
|
|
15
|
+
): Promise<RegistryEntry | null> {
|
|
16
|
+
return registry.findAgent(agentId, offlineThresholdMs);
|
|
17
|
+
}
|
package/src/file-ops.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { readFile, writeFile, copyFile, mkdir, access } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, dirname, basename, extname } from "node:path";
|
|
3
|
+
import type { BridgeConfig, RegistryEntry, PluginLogger } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** If dest exists, append _1, _2, etc. before the extension */
|
|
6
|
+
async function deduplicatePath(destPath: string): Promise<string> {
|
|
7
|
+
let candidate = destPath;
|
|
8
|
+
let counter = 0;
|
|
9
|
+
const ext = extname(destPath);
|
|
10
|
+
const base = destPath.slice(0, destPath.length - ext.length);
|
|
11
|
+
while (true) {
|
|
12
|
+
try {
|
|
13
|
+
await access(candidate);
|
|
14
|
+
counter++;
|
|
15
|
+
candidate = `${base}_${counter}${ext}`;
|
|
16
|
+
} catch {
|
|
17
|
+
return candidate; // File doesn't exist, use this path
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class BridgeFileOps {
|
|
23
|
+
private config: BridgeConfig;
|
|
24
|
+
private machineId: string;
|
|
25
|
+
private workspacePath: string;
|
|
26
|
+
private logger: PluginLogger;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
config: BridgeConfig,
|
|
30
|
+
machineId: string,
|
|
31
|
+
workspacePath: string,
|
|
32
|
+
logger: PluginLogger,
|
|
33
|
+
) {
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.machineId = machineId;
|
|
36
|
+
this.workspacePath = workspacePath;
|
|
37
|
+
this.logger = logger;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private isSameMachine(target: RegistryEntry): boolean {
|
|
41
|
+
return target.machineId === this.machineId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private validatePathWithinWorkspace(filePath: string, workspace: string): string {
|
|
45
|
+
const resolved = resolve(workspace, filePath);
|
|
46
|
+
if (!resolved.startsWith(resolve(workspace))) {
|
|
47
|
+
throw new Error(`openclaw-bridge: path escapes workspace: ${filePath}`);
|
|
48
|
+
}
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private fileRelayHeaders(): Record<string, string> {
|
|
53
|
+
const h: Record<string, string> = { "Content-Type": "application/json" };
|
|
54
|
+
if (this.config.fileRelay?.apiKey) h["X-API-Key"] = this.config.fileRelay.apiKey;
|
|
55
|
+
return h;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private fileRelayUrl(path: string): string {
|
|
59
|
+
if (!this.config.fileRelay?.baseUrl) {
|
|
60
|
+
throw new Error("openclaw-bridge: fileRelay.baseUrl not configured");
|
|
61
|
+
}
|
|
62
|
+
return `${this.config.fileRelay.baseUrl.replace(/\/+$/, "")}${path}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async sendFile(
|
|
66
|
+
target: RegistryEntry,
|
|
67
|
+
localRelativePath: string,
|
|
68
|
+
): Promise<{ delivered: boolean; message: string }> {
|
|
69
|
+
const sourcePath = this.validatePathWithinWorkspace(localRelativePath, this.workspacePath);
|
|
70
|
+
|
|
71
|
+
if (this.isSameMachine(target)) {
|
|
72
|
+
const destDir = join(target.workspacePath, "_inbox", this.config.agentId);
|
|
73
|
+
await mkdir(destDir, { recursive: true });
|
|
74
|
+
const originalFilename = localRelativePath.split(/[\\/]/).pop()!;
|
|
75
|
+
const rawDestPath = join(destDir, originalFilename);
|
|
76
|
+
const destPath = await deduplicatePath(rawDestPath);
|
|
77
|
+
await copyFile(sourcePath, destPath);
|
|
78
|
+
const actualFilename = basename(destPath);
|
|
79
|
+
const renamed = actualFilename !== originalFilename;
|
|
80
|
+
this.logger.info(
|
|
81
|
+
`openclaw-bridge: sent ${localRelativePath} to ${target.agentId} (local)${renamed ? ` (renamed to ${actualFilename})` : ""}`,
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
delivered: true,
|
|
85
|
+
message: renamed
|
|
86
|
+
? `File copied to ${target.agentId}/_inbox/ (renamed to ${actualFilename} because a file with the same name already existed)`
|
|
87
|
+
: `File copied to ${target.agentId}/_inbox/`,
|
|
88
|
+
filename: actualFilename,
|
|
89
|
+
renamed,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const content = await readFile(sourcePath);
|
|
94
|
+
const res = await fetch(this.fileRelayUrl("/api/v1/files/upload"), {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: this.fileRelayHeaders(),
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
fromAgent: this.config.agentId,
|
|
99
|
+
toAgent: target.agentId,
|
|
100
|
+
filename: localRelativePath.split(/[\\/]/).pop()!,
|
|
101
|
+
content: content.toString("base64"),
|
|
102
|
+
metadata: {},
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
throw new Error(`openclaw-bridge: FileRelay upload failed: ${res.status}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.logger.info(
|
|
111
|
+
`openclaw-bridge: sent ${localRelativePath} to ${target.agentId} (FileRelay)`,
|
|
112
|
+
);
|
|
113
|
+
return { delivered: false, message: `File uploaded to FileRelay for ${target.agentId}` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async readRemoteFile(
|
|
117
|
+
target: RegistryEntry,
|
|
118
|
+
relativePath: string,
|
|
119
|
+
): Promise<string> {
|
|
120
|
+
if (this.isSameMachine(target)) {
|
|
121
|
+
const fullPath = this.validatePathWithinWorkspace(relativePath, target.workspacePath);
|
|
122
|
+
return await readFile(fullPath, "utf-8");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const enqueueRes = await fetch(this.fileRelayUrl("/api/v1/commands/enqueue"), {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: this.fileRelayHeaders(),
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
fromAgent: this.config.agentId,
|
|
130
|
+
toAgent: target.agentId,
|
|
131
|
+
type: "read_file",
|
|
132
|
+
payload: { path: relativePath },
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (!enqueueRes.ok) {
|
|
137
|
+
throw new Error(`openclaw-bridge: command enqueue failed: ${enqueueRes.status}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const { id: cmdId } = (await enqueueRes.json()) as { id: string };
|
|
141
|
+
|
|
142
|
+
const deadline = Date.now() + 90_000;
|
|
143
|
+
while (Date.now() < deadline) {
|
|
144
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
145
|
+
const resultRes = await fetch(this.fileRelayUrl(`/api/v1/commands/result/${cmdId}`), {
|
|
146
|
+
headers: this.fileRelayHeaders(),
|
|
147
|
+
});
|
|
148
|
+
if (!resultRes.ok) continue;
|
|
149
|
+
const result = (await resultRes.json()) as {
|
|
150
|
+
status: string;
|
|
151
|
+
payload?: { content?: string };
|
|
152
|
+
};
|
|
153
|
+
if (result.status === "ok" && result.payload?.content) {
|
|
154
|
+
return Buffer.from(result.payload.content, "base64").toString("utf-8");
|
|
155
|
+
}
|
|
156
|
+
if (result.status === "error") {
|
|
157
|
+
throw new Error(`openclaw-bridge: remote read failed`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
throw new Error("openclaw-bridge: remote read timed out (90s)");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async writeRemoteFile(
|
|
164
|
+
target: RegistryEntry,
|
|
165
|
+
relativePath: string,
|
|
166
|
+
content: string,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
if (this.isSameMachine(target)) {
|
|
169
|
+
const fullPath = this.validatePathWithinWorkspace(relativePath, target.workspacePath);
|
|
170
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
171
|
+
await writeFile(fullPath, content, "utf-8");
|
|
172
|
+
this.logger.info(
|
|
173
|
+
`openclaw-bridge: wrote ${relativePath} to ${target.agentId} workspace`,
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const res = await fetch(this.fileRelayUrl("/api/v1/files/upload"), {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: this.fileRelayHeaders(),
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
fromAgent: this.config.agentId,
|
|
183
|
+
toAgent: target.agentId,
|
|
184
|
+
filename: relativePath.split(/[\\/]/).pop()!,
|
|
185
|
+
content: Buffer.from(content).toString("base64"),
|
|
186
|
+
metadata: { writeToPath: relativePath },
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (!res.ok) {
|
|
191
|
+
throw new Error(`openclaw-bridge: FileRelay write-upload failed: ${res.status}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async processPendingFiles(): Promise<number> {
|
|
196
|
+
if (!this.config.fileRelay?.baseUrl) return 0;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const res = await fetch(
|
|
200
|
+
this.fileRelayUrl(`/api/v1/files/pending?agent=${this.config.agentId}`),
|
|
201
|
+
{ headers: this.fileRelayHeaders() },
|
|
202
|
+
);
|
|
203
|
+
if (!res.ok) return 0;
|
|
204
|
+
|
|
205
|
+
const data = (await res.json()) as {
|
|
206
|
+
files: Array<{
|
|
207
|
+
id: string;
|
|
208
|
+
fromAgent: string;
|
|
209
|
+
filename: string;
|
|
210
|
+
metadata?: { writeToPath?: string };
|
|
211
|
+
}>;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
let count = 0;
|
|
215
|
+
for (const file of data.files) {
|
|
216
|
+
const dlRes = await fetch(this.fileRelayUrl(`/api/v1/files/download/${file.id}`), {
|
|
217
|
+
headers: this.fileRelayHeaders(),
|
|
218
|
+
});
|
|
219
|
+
if (!dlRes.ok) continue;
|
|
220
|
+
|
|
221
|
+
const dlData = (await dlRes.json()) as { content: string };
|
|
222
|
+
const fileContent = Buffer.from(dlData.content, "base64");
|
|
223
|
+
|
|
224
|
+
let destPath: string;
|
|
225
|
+
if (file.metadata?.writeToPath) {
|
|
226
|
+
destPath = this.validatePathWithinWorkspace(file.metadata.writeToPath, this.workspacePath);
|
|
227
|
+
} else {
|
|
228
|
+
const inboxDir = join(this.workspacePath, "_inbox", file.fromAgent);
|
|
229
|
+
await mkdir(inboxDir, { recursive: true });
|
|
230
|
+
destPath = await deduplicatePath(join(inboxDir, file.filename));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
234
|
+
await writeFile(destPath, fileContent);
|
|
235
|
+
|
|
236
|
+
await fetch(this.fileRelayUrl(`/api/v1/files/ack/${file.id}`), {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: this.fileRelayHeaders(),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
count++;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (count > 0) {
|
|
245
|
+
this.logger.info(`openclaw-bridge: processed ${count} pending file(s) from FileRelay`);
|
|
246
|
+
}
|
|
247
|
+
return count;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
this.logger.warn(`openclaw-bridge: FileRelay file poll failed: ${String(err)}`);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async processPendingCommands(): Promise<number> {
|
|
255
|
+
if (!this.config.fileRelay?.baseUrl) return 0;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const res = await fetch(
|
|
259
|
+
this.fileRelayUrl(`/api/v1/commands/pending?agent=${this.config.agentId}`),
|
|
260
|
+
{ headers: this.fileRelayHeaders() },
|
|
261
|
+
);
|
|
262
|
+
if (!res.ok) return 0;
|
|
263
|
+
|
|
264
|
+
const data = (await res.json()) as {
|
|
265
|
+
commands: Array<{
|
|
266
|
+
id: string;
|
|
267
|
+
fromAgent: string;
|
|
268
|
+
type: string;
|
|
269
|
+
payload: Record<string, unknown>;
|
|
270
|
+
}>;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
let count = 0;
|
|
274
|
+
for (const cmd of data.commands) {
|
|
275
|
+
try {
|
|
276
|
+
if (cmd.type === "read_file") {
|
|
277
|
+
const path = cmd.payload.path as string;
|
|
278
|
+
const fullPath = this.validatePathWithinWorkspace(path, this.workspacePath);
|
|
279
|
+
const content = await readFile(fullPath);
|
|
280
|
+
await fetch(this.fileRelayUrl(`/api/v1/commands/respond/${cmd.id}`), {
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: this.fileRelayHeaders(),
|
|
283
|
+
body: JSON.stringify({
|
|
284
|
+
status: "ok",
|
|
285
|
+
payload: { content: content.toString("base64") },
|
|
286
|
+
}),
|
|
287
|
+
});
|
|
288
|
+
} else if (cmd.type === "restart") {
|
|
289
|
+
// Acknowledge the command first
|
|
290
|
+
await fetch(this.fileRelayUrl(`/api/v1/commands/respond/${cmd.id}`), {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: this.fileRelayHeaders(),
|
|
293
|
+
body: JSON.stringify({ status: "ok", payload: {} }),
|
|
294
|
+
});
|
|
295
|
+
// Schedule self-restart after responding
|
|
296
|
+
setTimeout(() => {
|
|
297
|
+
this.logger.info("openclaw-bridge: executing remote restart command");
|
|
298
|
+
process.exit(0); // PM2 or run.ps1 will restart the process
|
|
299
|
+
}, 1_000);
|
|
300
|
+
}
|
|
301
|
+
count++;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
await fetch(this.fileRelayUrl(`/api/v1/commands/respond/${cmd.id}`), {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: this.fileRelayHeaders(),
|
|
306
|
+
body: JSON.stringify({ status: "error", payload: { error: String(err) } }),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (count > 0) {
|
|
312
|
+
this.logger.info(`openclaw-bridge: processed ${count} pending command(s) from FileRelay`);
|
|
313
|
+
}
|
|
314
|
+
return count;
|
|
315
|
+
} catch (err) {
|
|
316
|
+
this.logger.warn(`openclaw-bridge: FileRelay command poll failed: ${String(err)}`);
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|