agent-relay 3.1.6 → 3.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
- package/bin/agent-relay-broker-linux-x64 +0 -0
- package/dist/index.cjs +130 -107
- package/package.json +8 -8
- package/packages/acp-bridge/package.json +2 -2
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/dist/cli.js +8 -1
- package/packages/openclaw/dist/cli.js.map +1 -1
- package/packages/openclaw/dist/config.d.ts +6 -2
- package/packages/openclaw/dist/config.d.ts.map +1 -1
- package/packages/openclaw/dist/config.js +95 -7
- package/packages/openclaw/dist/config.js.map +1 -1
- package/packages/openclaw/dist/gateway.d.ts +3 -0
- package/packages/openclaw/dist/gateway.d.ts.map +1 -1
- package/packages/openclaw/dist/gateway.js +114 -16
- package/packages/openclaw/dist/gateway.js.map +1 -1
- package/packages/openclaw/dist/setup.d.ts.map +1 -1
- package/packages/openclaw/dist/setup.js +111 -15
- package/packages/openclaw/dist/setup.js.map +1 -1
- package/packages/openclaw/package.json +2 -2
- package/packages/openclaw/skill/SKILL.md +190 -31
- package/packages/openclaw/src/cli.ts +7 -1
- package/packages/openclaw/src/config.ts +94 -8
- package/packages/openclaw/src/gateway.ts +153 -19
- package/packages/openclaw/src/setup.ts +113 -13
- package/packages/policy/package.json +2 -2
- package/packages/sdk/dist/client.js +6 -8
- package/packages/sdk/dist/client.js.map +1 -1
- package/packages/sdk/dist/workflows/builder.d.ts +3 -1
- package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/builder.js +1 -0
- package/packages/sdk/dist/workflows/builder.js.map +1 -1
- package/packages/sdk/dist/workflows/runner.d.ts +15 -1
- package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/runner.js +146 -117
- package/packages/sdk/dist/workflows/runner.js.map +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/sdk/scripts/bundle-agent-relay.mjs +11 -1
- package/packages/sdk/src/client.ts +6 -8
- package/packages/sdk/src/workflows/builder.ts +4 -1
- package/packages/sdk/src/workflows/runner.ts +173 -119
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, dirname, basename } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { existsSync } from 'node:fs';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
|
|
6
6
|
import type { GatewayConfig } from './types.js';
|
|
7
7
|
|
|
8
8
|
export interface OpenClawDetection {
|
|
9
9
|
/** Whether OpenClaw is installed. */
|
|
10
10
|
installed: boolean;
|
|
11
|
-
/** Path to ~/.openclaw/ */
|
|
11
|
+
/** Path to ~/.openclaw/ (or ~/.clawdbot/ for Clawdbot variant) */
|
|
12
12
|
homeDir: string;
|
|
13
13
|
/** Path to ~/.openclaw/workspace/ */
|
|
14
14
|
workspaceDir: string;
|
|
@@ -16,19 +16,105 @@ export interface OpenClawDetection {
|
|
|
16
16
|
configFile: string | null;
|
|
17
17
|
/** Parsed openclaw.json (if exists). */
|
|
18
18
|
config: Record<string, unknown> | null;
|
|
19
|
+
/** Detected variant: 'clawdbot' or 'openclaw'. */
|
|
20
|
+
variant: 'clawdbot' | 'openclaw';
|
|
21
|
+
/** Config filename (e.g. 'clawdbot.json' or 'openclaw.json'). */
|
|
22
|
+
configFilename: string;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Determine whether a directory has a valid, parseable config file.
|
|
27
|
+
* Uses sync I/O — only called during startup, not on hot path.
|
|
28
|
+
*/
|
|
29
|
+
function hasValidConfig(dir: string, filename: string): boolean {
|
|
30
|
+
const configPath = join(dir, filename);
|
|
31
|
+
if (!existsSync(configPath)) return false;
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
34
|
+
JSON.parse(raw);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Default OpenClaw config directory. Checks env vars and probes for Clawdbot variant. */
|
|
22
42
|
export function openclawHome(): string {
|
|
23
|
-
|
|
43
|
+
if (process.env.OPENCLAW_CONFIG_PATH) {
|
|
44
|
+
// Direct config file path — return its parent directory
|
|
45
|
+
return dirname(process.env.OPENCLAW_CONFIG_PATH);
|
|
46
|
+
}
|
|
47
|
+
if (process.env.OPENCLAW_HOME) {
|
|
48
|
+
return process.env.OPENCLAW_HOME;
|
|
49
|
+
}
|
|
50
|
+
// Probe by valid config file presence (not just directory existence).
|
|
51
|
+
// When both dirs exist, prefer the one with a valid config file.
|
|
52
|
+
const clawdbotHome = join(homedir(), '.clawdbot');
|
|
53
|
+
const openclawHomePath = join(homedir(), '.openclaw');
|
|
54
|
+
const clawdbotValid = hasValidConfig(clawdbotHome, 'clawdbot.json');
|
|
55
|
+
const openclawValid = hasValidConfig(openclawHomePath, 'openclaw.json');
|
|
56
|
+
|
|
57
|
+
if (clawdbotValid && !openclawValid) return clawdbotHome;
|
|
58
|
+
if (openclawValid && !clawdbotValid) return openclawHomePath;
|
|
59
|
+
// Both valid or neither valid — prefer clawdbot if its dir exists (marketplace image)
|
|
60
|
+
if (existsSync(clawdbotHome)) return clawdbotHome;
|
|
61
|
+
return openclawHomePath;
|
|
24
62
|
}
|
|
25
63
|
|
|
26
64
|
/**
|
|
27
65
|
* Detect whether OpenClaw is installed and return paths/config.
|
|
28
66
|
*/
|
|
29
67
|
export async function detectOpenClaw(): Promise<OpenClawDetection> {
|
|
30
|
-
|
|
31
|
-
|
|
68
|
+
// Determine variant and config filename
|
|
69
|
+
let homeDir: string;
|
|
70
|
+
let variant: 'clawdbot' | 'openclaw';
|
|
71
|
+
let configFilename: string;
|
|
72
|
+
|
|
73
|
+
if (process.env.OPENCLAW_CONFIG_PATH) {
|
|
74
|
+
// Direct config file path provided
|
|
75
|
+
homeDir = dirname(process.env.OPENCLAW_CONFIG_PATH);
|
|
76
|
+
const base = basename(process.env.OPENCLAW_CONFIG_PATH);
|
|
77
|
+
configFilename = base;
|
|
78
|
+
variant = base === 'clawdbot.json' ? 'clawdbot' : 'openclaw';
|
|
79
|
+
} else if (process.env.OPENCLAW_HOME) {
|
|
80
|
+
homeDir = process.env.OPENCLAW_HOME;
|
|
81
|
+
// Check if the home dir looks like a Clawdbot installation
|
|
82
|
+
const clawdbotConfig = join(homeDir, 'clawdbot.json');
|
|
83
|
+
if (existsSync(clawdbotConfig)) {
|
|
84
|
+
variant = 'clawdbot';
|
|
85
|
+
configFilename = 'clawdbot.json';
|
|
86
|
+
} else {
|
|
87
|
+
variant = 'openclaw';
|
|
88
|
+
configFilename = 'openclaw.json';
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
// Probe by valid config file, not just directory existence.
|
|
92
|
+
const clawdbotHome = join(homedir(), '.clawdbot');
|
|
93
|
+
const openclawHomePath = join(homedir(), '.openclaw');
|
|
94
|
+
const clawdbotValid = hasValidConfig(clawdbotHome, 'clawdbot.json');
|
|
95
|
+
const openclawValid = hasValidConfig(openclawHomePath, 'openclaw.json');
|
|
96
|
+
|
|
97
|
+
if (clawdbotValid && !openclawValid) {
|
|
98
|
+
homeDir = clawdbotHome;
|
|
99
|
+
variant = 'clawdbot';
|
|
100
|
+
configFilename = 'clawdbot.json';
|
|
101
|
+
} else if (openclawValid && !clawdbotValid) {
|
|
102
|
+
homeDir = openclawHomePath;
|
|
103
|
+
variant = 'openclaw';
|
|
104
|
+
configFilename = 'openclaw.json';
|
|
105
|
+
} else if (existsSync(clawdbotHome)) {
|
|
106
|
+
// Both valid or neither — prefer clawdbot if present (marketplace image)
|
|
107
|
+
homeDir = clawdbotHome;
|
|
108
|
+
variant = 'clawdbot';
|
|
109
|
+
configFilename = 'clawdbot.json';
|
|
110
|
+
} else {
|
|
111
|
+
homeDir = openclawHomePath;
|
|
112
|
+
variant = 'openclaw';
|
|
113
|
+
configFilename = 'openclaw.json';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const configPath = join(homeDir, configFilename);
|
|
32
118
|
const workspaceDir = join(homeDir, 'workspace');
|
|
33
119
|
|
|
34
120
|
const installed = existsSync(homeDir);
|
|
@@ -45,7 +131,7 @@ export async function detectOpenClaw(): Promise<OpenClawDetection> {
|
|
|
45
131
|
}
|
|
46
132
|
}
|
|
47
133
|
|
|
48
|
-
return { installed, homeDir, workspaceDir, configFile, config };
|
|
134
|
+
return { installed, homeDir, workspaceDir, configFile, config, variant, configFilename };
|
|
49
135
|
}
|
|
50
136
|
|
|
51
137
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, createPrivateKey, generateKeyPairSync, sign, type KeyObject } from 'node:crypto';
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, type KeyObject } from 'node:crypto';
|
|
2
2
|
import { chmod, readFile, rename, writeFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -49,13 +49,75 @@ function normalizeChannelName(channel: string): string {
|
|
|
49
49
|
// Ed25519 device identity for OpenClaw gateway WebSocket auth
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
51
|
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Auth profile system — deterministic profile selection for WS auth across
|
|
54
|
+
// OpenClaw/Clawdbot versions. Profiles define key encoding, signature format,
|
|
55
|
+
// and payload canonicalization for the device auth handshake.
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
interface AuthProfile {
|
|
59
|
+
/** Human-readable profile name (logged on each auth attempt). */
|
|
60
|
+
name: string;
|
|
61
|
+
/** Encoding for the public key sent in the connect message. */
|
|
62
|
+
publicKeyFormat: 'raw-base64url' | 'spki-pem';
|
|
63
|
+
/** Encoding for the Ed25519 signature. */
|
|
64
|
+
signatureEncoding: 'base64url' | 'base64';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const AUTH_PROFILES: Record<string, AuthProfile> = {
|
|
68
|
+
default: {
|
|
69
|
+
name: 'default',
|
|
70
|
+
publicKeyFormat: 'raw-base64url',
|
|
71
|
+
signatureEncoding: 'base64url',
|
|
72
|
+
},
|
|
73
|
+
'clawdbot-v1': {
|
|
74
|
+
name: 'clawdbot-v1',
|
|
75
|
+
publicKeyFormat: 'spki-pem',
|
|
76
|
+
signatureEncoding: 'base64',
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the auth profile to use. Selection priority:
|
|
82
|
+
* 1. Explicit env var `OPENCLAW_WS_AUTH_COMPAT` (manual override, highest priority)
|
|
83
|
+
* 2. Variant detection: `~/.clawdbot/` detected → clawdbot-v1
|
|
84
|
+
* 3. Default profile (standard OpenClaw, unchanged)
|
|
85
|
+
*/
|
|
86
|
+
function resolveAuthProfile(): AuthProfile {
|
|
87
|
+
// 1. Manual override (highest priority)
|
|
88
|
+
const envVal = process.env.OPENCLAW_WS_AUTH_COMPAT;
|
|
89
|
+
if (envVal === 'clawdbot' || envVal === 'clawdbot-v1') {
|
|
90
|
+
return AUTH_PROFILES['clawdbot-v1'];
|
|
91
|
+
}
|
|
92
|
+
if (envVal && AUTH_PROFILES[envVal]) {
|
|
93
|
+
return AUTH_PROFILES[envVal];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Variant detection via config path
|
|
97
|
+
const home = process.env.OPENCLAW_HOME || process.env.OPENCLAW_CONFIG_PATH || '';
|
|
98
|
+
if (home.includes('.clawdbot') || home.includes('clawdbot')) {
|
|
99
|
+
return AUTH_PROFILES['clawdbot-v1'];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 3. Default
|
|
103
|
+
return AUTH_PROFILES['default'];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Backward-compat helper — returns 'clawdbot' when using clawdbot profile. */
|
|
107
|
+
type WsAuthCompat = 'clawdbot' | undefined;
|
|
108
|
+
function getWsAuthCompat(): WsAuthCompat {
|
|
109
|
+
const profile = resolveAuthProfile();
|
|
110
|
+
return profile.name === 'clawdbot-v1' ? 'clawdbot' : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
52
113
|
interface DeviceIdentity {
|
|
53
|
-
publicKeyB64: string; // base64url-encoded raw Ed25519 public key
|
|
114
|
+
publicKeyB64: string; // base64url-encoded raw Ed25519 public key (default mode)
|
|
115
|
+
publicKeyPem?: string; // PEM-encoded SPKI public key (clawdbot compat mode)
|
|
54
116
|
privateKeyObj: KeyObject; // Node.js KeyObject for signing
|
|
55
117
|
deviceId: string; // SHA-256 hex of the raw public key
|
|
56
118
|
}
|
|
57
119
|
|
|
58
|
-
function generateDeviceIdentity(): DeviceIdentity {
|
|
120
|
+
function generateDeviceIdentity(compat?: WsAuthCompat): DeviceIdentity {
|
|
59
121
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
60
122
|
|
|
61
123
|
// Extract raw 32-byte public key from SPKI DER (12-byte header for Ed25519)
|
|
@@ -64,11 +126,17 @@ function generateDeviceIdentity(): DeviceIdentity {
|
|
|
64
126
|
const deviceId = createHash('sha256').update(rawPublicBytes).digest('hex');
|
|
65
127
|
const publicKeyB64 = Buffer.from(rawPublicBytes).toString('base64url');
|
|
66
128
|
|
|
67
|
-
|
|
129
|
+
const identity: DeviceIdentity = {
|
|
68
130
|
publicKeyB64,
|
|
69
131
|
privateKeyObj: privateKey,
|
|
70
132
|
deviceId,
|
|
71
133
|
};
|
|
134
|
+
|
|
135
|
+
if (compat === 'clawdbot') {
|
|
136
|
+
identity.publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return identity;
|
|
72
140
|
}
|
|
73
141
|
|
|
74
142
|
/** Path to persisted device identity file. */
|
|
@@ -80,6 +148,10 @@ interface PersistedDevice {
|
|
|
80
148
|
publicKeyB64: string;
|
|
81
149
|
privateKeyPkcs8B64: string; // base64-encoded PKCS#8 DER
|
|
82
150
|
deviceId: string;
|
|
151
|
+
/** PEM-encoded SPKI public key — present when generated with clawdbot compat mode. */
|
|
152
|
+
publicKeyPem?: string;
|
|
153
|
+
/** PEM-encoded PKCS#8 private key — present when generated with clawdbot compat mode. */
|
|
154
|
+
privateKeyPem?: string;
|
|
83
155
|
}
|
|
84
156
|
|
|
85
157
|
/**
|
|
@@ -89,6 +161,7 @@ interface PersistedDevice {
|
|
|
89
161
|
*/
|
|
90
162
|
async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
|
91
163
|
const filePath = deviceIdentityPath();
|
|
164
|
+
const compat = getWsAuthCompat();
|
|
92
165
|
|
|
93
166
|
// Attempt to load existing identity (no existsSync — just try the read)
|
|
94
167
|
try {
|
|
@@ -102,11 +175,31 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
|
|
102
175
|
// Ensure permissions are tight even if file was created with looser perms
|
|
103
176
|
await chmod(filePath, 0o600).catch(() => {});
|
|
104
177
|
console.log(`[openclaw-ws] Loaded persisted device identity (deviceId=${persisted.deviceId.slice(0, 12)}...)`);
|
|
105
|
-
|
|
178
|
+
|
|
179
|
+
const identity: DeviceIdentity = {
|
|
106
180
|
publicKeyB64: persisted.publicKeyB64,
|
|
107
181
|
privateKeyObj,
|
|
108
182
|
deviceId: persisted.deviceId,
|
|
109
183
|
};
|
|
184
|
+
|
|
185
|
+
// If compat mode is clawdbot but the persisted device has no PEM keys,
|
|
186
|
+
// derive them on-the-fly from the existing DER key material.
|
|
187
|
+
if (compat === 'clawdbot') {
|
|
188
|
+
if (persisted.publicKeyPem) {
|
|
189
|
+
identity.publicKeyPem = persisted.publicKeyPem;
|
|
190
|
+
} else {
|
|
191
|
+
// Reconstruct SPKI public key from the stored base64url raw bytes
|
|
192
|
+
const rawPublicBytes = Buffer.from(persisted.publicKeyB64, 'base64url');
|
|
193
|
+
// Ed25519 SPKI DER = 12-byte header + 32-byte raw key
|
|
194
|
+
const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex');
|
|
195
|
+
const spkiDer = Buffer.concat([spkiHeader, rawPublicBytes]);
|
|
196
|
+
const publicKeyObj = createPublicKey({ key: spkiDer, format: 'der', type: 'spki' });
|
|
197
|
+
identity.publicKeyPem = publicKeyObj.export({ type: 'spki', format: 'pem' }) as string;
|
|
198
|
+
console.log('[openclaw-ws] Derived PEM public key from existing DER key for clawdbot compat mode');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return identity;
|
|
110
203
|
} catch (err) {
|
|
111
204
|
// ENOENT is expected on first run; other errors mean corruption
|
|
112
205
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
@@ -115,7 +208,7 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
|
|
115
208
|
}
|
|
116
209
|
|
|
117
210
|
// Generate fresh and persist via atomic write-then-rename
|
|
118
|
-
const identity = generateDeviceIdentity();
|
|
211
|
+
const identity = generateDeviceIdentity(compat);
|
|
119
212
|
const pkcs8Der = identity.privateKeyObj.export({ type: 'pkcs8', format: 'der' });
|
|
120
213
|
const persisted: PersistedDevice = {
|
|
121
214
|
publicKeyB64: identity.publicKeyB64,
|
|
@@ -123,6 +216,11 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
|
|
123
216
|
deviceId: identity.deviceId,
|
|
124
217
|
};
|
|
125
218
|
|
|
219
|
+
if (compat === 'clawdbot' && identity.publicKeyPem) {
|
|
220
|
+
persisted.publicKeyPem = identity.publicKeyPem;
|
|
221
|
+
persisted.privateKeyPem = identity.privateKeyObj.export({ type: 'pkcs8', format: 'pem' }) as string;
|
|
222
|
+
}
|
|
223
|
+
|
|
126
224
|
try {
|
|
127
225
|
const dir = join(openclawHome(), 'workspace', 'relaycast');
|
|
128
226
|
await mkdir(dir, { recursive: true });
|
|
@@ -151,6 +249,8 @@ function signConnectPayload(
|
|
|
151
249
|
nonce: string;
|
|
152
250
|
},
|
|
153
251
|
): string {
|
|
252
|
+
const profile = resolveAuthProfile();
|
|
253
|
+
|
|
154
254
|
// v3 payload format: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
|
|
155
255
|
const payload = [
|
|
156
256
|
'v3',
|
|
@@ -168,9 +268,14 @@ function signConnectPayload(
|
|
|
168
268
|
|
|
169
269
|
const payloadBytes = Buffer.from(payload, 'utf-8');
|
|
170
270
|
|
|
271
|
+
// Diagnostic logging: selected profile + pre-auth fingerprint (no secrets).
|
|
272
|
+
const payloadHash = createHash('sha256').update(payloadBytes).digest('hex').slice(0, 16);
|
|
273
|
+
console.log(`[ws-auth] profile=${profile.name} deviceId=${device.deviceId.slice(0, 16)}... keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding} payloadHash=${payloadHash}`);
|
|
274
|
+
|
|
171
275
|
// Ed25519 sign — no hash algorithm needed (null), it's built into Ed25519
|
|
172
276
|
const signature = sign(null, payloadBytes, device.privateKeyObj);
|
|
173
|
-
|
|
277
|
+
|
|
278
|
+
return Buffer.from(signature).toString(profile.signatureEncoding);
|
|
174
279
|
}
|
|
175
280
|
|
|
176
281
|
|
|
@@ -206,11 +311,13 @@ export class OpenClawGatewayClient {
|
|
|
206
311
|
private static readonly MAX_CONSECUTIVE_FAILURES = 5;
|
|
207
312
|
private static readonly BASE_RECONNECT_MS = 3_000;
|
|
208
313
|
private static readonly MAX_RECONNECT_MS = 30_000;
|
|
314
|
+
/** Slow retry interval after pairing rejection or max failures (60s). */
|
|
315
|
+
private static readonly PAIRING_RETRY_MS = 60_000;
|
|
209
316
|
|
|
210
317
|
constructor(token: string, port: number, device?: DeviceIdentity) {
|
|
211
318
|
this.token = token;
|
|
212
319
|
this.port = port;
|
|
213
|
-
this.device = device ?? generateDeviceIdentity();
|
|
320
|
+
this.device = device ?? generateDeviceIdentity(getWsAuthCompat());
|
|
214
321
|
}
|
|
215
322
|
|
|
216
323
|
/**
|
|
@@ -292,7 +399,8 @@ export class OpenClawGatewayClient {
|
|
|
292
399
|
// Detect pairing rejection: code 1008 (Policy Violation) with pairing reason
|
|
293
400
|
if (code === 1008 && /pairing|not.paired/i.test(reasonStr)) {
|
|
294
401
|
console.error('[openclaw-ws] Connection closed due to pairing policy. Device is not paired.');
|
|
295
|
-
console.error(
|
|
402
|
+
console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`);
|
|
403
|
+
console.error('[openclaw-ws] Run: openclaw devices approve <requestId> (check gateway logs for requestId)');
|
|
296
404
|
this.pairingRejected = true;
|
|
297
405
|
}
|
|
298
406
|
|
|
@@ -310,7 +418,7 @@ export class OpenClawGatewayClient {
|
|
|
310
418
|
this.connectReject = null;
|
|
311
419
|
this.connectResolve = null;
|
|
312
420
|
}
|
|
313
|
-
if (!this.stopped
|
|
421
|
+
if (!this.stopped) {
|
|
314
422
|
this.scheduleReconnect();
|
|
315
423
|
}
|
|
316
424
|
});
|
|
@@ -360,6 +468,12 @@ export class OpenClawGatewayClient {
|
|
|
360
468
|
nonce: payload.nonce,
|
|
361
469
|
});
|
|
362
470
|
|
|
471
|
+
// Select public key format based on resolved auth profile.
|
|
472
|
+
const profile = resolveAuthProfile();
|
|
473
|
+
const publicKeyField = profile.publicKeyFormat === 'spki-pem' && this.device.publicKeyPem
|
|
474
|
+
? this.device.publicKeyPem
|
|
475
|
+
: this.device.publicKeyB64;
|
|
476
|
+
|
|
363
477
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
364
478
|
console.warn('[openclaw-ws] WebSocket not open when trying to send connect');
|
|
365
479
|
return;
|
|
@@ -388,7 +502,7 @@ export class OpenClawGatewayClient {
|
|
|
388
502
|
userAgent: 'relaycast-gateway/1.0.0',
|
|
389
503
|
device: {
|
|
390
504
|
id: this.device.deviceId,
|
|
391
|
-
publicKey:
|
|
505
|
+
publicKey: publicKeyField,
|
|
392
506
|
signature,
|
|
393
507
|
signedAt,
|
|
394
508
|
nonce: payload.nonce,
|
|
@@ -413,8 +527,17 @@ export class OpenClawGatewayClient {
|
|
|
413
527
|
const isPairing = /pairing.required|not.paired/i.test(errStr);
|
|
414
528
|
|
|
415
529
|
if (isPairing) {
|
|
530
|
+
const errObj = msg.error as Record<string, unknown> | undefined;
|
|
531
|
+
const requestId = errObj?.requestId ?? errObj?.request_id ?? '';
|
|
416
532
|
console.error('[openclaw-ws] Pairing rejected — device is not paired with the OpenClaw gateway.');
|
|
417
|
-
|
|
533
|
+
if (requestId) {
|
|
534
|
+
console.error(`[openclaw-ws] Approve this device: openclaw devices approve ${requestId}`);
|
|
535
|
+
}
|
|
536
|
+
console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`);
|
|
537
|
+
const configHint = getWsAuthCompat() === 'clawdbot'
|
|
538
|
+
? '~/.clawdbot/clawdbot.json'
|
|
539
|
+
: '~/.openclaw/openclaw.json';
|
|
540
|
+
console.error(`[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token`);
|
|
418
541
|
this.pairingRejected = true;
|
|
419
542
|
} else {
|
|
420
543
|
console.warn(`[openclaw-ws] Auth rejected: ${errStr}`);
|
|
@@ -494,16 +617,27 @@ export class OpenClawGatewayClient {
|
|
|
494
617
|
}
|
|
495
618
|
|
|
496
619
|
private scheduleReconnect(): void {
|
|
497
|
-
if (this.stopped || this.
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (this.consecutiveFailures >= OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) {
|
|
502
|
-
|
|
503
|
-
|
|
620
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
621
|
+
|
|
622
|
+
// After pairing rejection or max failures, switch to slow periodic retry
|
|
623
|
+
// so the gateway can self-heal once pairing is approved externally.
|
|
624
|
+
if (this.pairingRejected || this.consecutiveFailures >= OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) {
|
|
625
|
+
if (this.consecutiveFailures === OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) {
|
|
626
|
+
console.warn(`[openclaw-ws] ${this.consecutiveFailures} consecutive failures — switching to slow retry (every 60s).`);
|
|
627
|
+
console.warn('[openclaw-ws] Check that the OpenClaw gateway is running and OPENCLAW_GATEWAY_TOKEN is correct.');
|
|
628
|
+
}
|
|
629
|
+
this.consecutiveFailures++;
|
|
630
|
+
console.log(`[openclaw-ws] Slow retry in ${OpenClawGatewayClient.PAIRING_RETRY_MS / 1000}s...`);
|
|
631
|
+
this.reconnectTimer = setTimeout(() => {
|
|
632
|
+
this.reconnectTimer = null;
|
|
633
|
+
this.pairingRejected = false; // Clear flag so connect attempt proceeds
|
|
634
|
+
this.doConnect();
|
|
635
|
+
}, OpenClawGatewayClient.PAIRING_RETRY_MS);
|
|
504
636
|
return;
|
|
505
637
|
}
|
|
506
638
|
|
|
639
|
+
this.consecutiveFailures++;
|
|
640
|
+
|
|
507
641
|
const delay = Math.min(
|
|
508
642
|
OpenClawGatewayClient.BASE_RECONNECT_MS * Math.pow(2, this.consecutiveFailures - 1),
|
|
509
643
|
OpenClawGatewayClient.MAX_RECONNECT_MS,
|
|
@@ -5,6 +5,7 @@ import { existsSync } from 'node:fs';
|
|
|
5
5
|
import { hostname } from 'node:os';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { spawn as spawnProcess, execFileSync } from 'node:child_process';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
8
9
|
|
|
9
10
|
import { RelayCast } from '@relaycast/sdk';
|
|
10
11
|
|
|
@@ -25,6 +26,23 @@ function extractNestedValue(obj: unknown, path: string): unknown {
|
|
|
25
26
|
return current;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Set a deeply nested value in an object by dot-separated path, creating
|
|
31
|
+
* intermediate objects as needed.
|
|
32
|
+
*/
|
|
33
|
+
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
|
|
34
|
+
const keys = path.split('.');
|
|
35
|
+
let current: Record<string, unknown> = obj;
|
|
36
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
37
|
+
const key = keys[i];
|
|
38
|
+
if (current[key] == null || typeof current[key] !== 'object') {
|
|
39
|
+
current[key] = {};
|
|
40
|
+
}
|
|
41
|
+
current = current[key] as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
current[keys[keys.length - 1]] = value;
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
/**
|
|
29
47
|
* Resolve how to invoke mcporter. Prefers a global binary, falls back to npx.
|
|
30
48
|
*/
|
|
@@ -98,14 +116,18 @@ export async function setup(options: SetupOptions): Promise<SetupResult> {
|
|
|
98
116
|
const baseUrl = options.baseUrl ?? 'https://api.relaycast.dev';
|
|
99
117
|
const channels = options.channels ?? ['general'];
|
|
100
118
|
|
|
119
|
+
// CLI name for restart reminder messages (based on detected variant)
|
|
120
|
+
const cliName = detection.variant === 'clawdbot' ? 'clawdbot' : 'openclaw';
|
|
121
|
+
const serviceName = detection.variant === 'clawdbot' ? 'clawdbot' : 'openclaw';
|
|
122
|
+
|
|
101
123
|
if (!detection.installed) {
|
|
102
124
|
// Auto-create ~/.openclaw/ if OpenClaw binary is available but the config dir
|
|
103
125
|
// doesn't exist yet (common in Docker images before onboarding).
|
|
104
126
|
try {
|
|
105
127
|
await mkdir(detection.homeDir, { recursive: true });
|
|
106
128
|
await mkdir(join(detection.homeDir, 'workspace'), { recursive: true });
|
|
107
|
-
// Write a minimal
|
|
108
|
-
const configPath = join(detection.homeDir,
|
|
129
|
+
// Write a minimal config file so MCP servers can be registered
|
|
130
|
+
const configPath = join(detection.homeDir, detection.configFilename);
|
|
109
131
|
if (!existsSync(configPath)) {
|
|
110
132
|
await writeFile(configPath, JSON.stringify({ mcpServers: {} }, null, 2) + '\n', 'utf-8');
|
|
111
133
|
}
|
|
@@ -126,14 +148,48 @@ export async function setup(options: SetupOptions): Promise<SetupResult> {
|
|
|
126
148
|
|
|
127
149
|
// Enable the OpenResponses HTTP API so the inbound gateway can inject
|
|
128
150
|
// messages via POST /v1/responses on the local OpenClaw gateway.
|
|
129
|
-
|
|
130
|
-
|
|
151
|
+
// Try CLI names in order: openclaw, clawdbot, clawdbot-cli.sh.
|
|
152
|
+
// If all CLI calls fail, mutate the config JSON directly.
|
|
153
|
+
let configMutated = false;
|
|
154
|
+
{
|
|
155
|
+
const httpEndpointArgs = [
|
|
131
156
|
'config', 'set',
|
|
132
157
|
'gateway.http.endpoints.responses.enabled', 'true',
|
|
133
|
-
]
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
158
|
+
];
|
|
159
|
+
const cliCandidates = ['openclaw', 'clawdbot', 'clawdbot-cli.sh'];
|
|
160
|
+
let cliSuccess = false;
|
|
161
|
+
|
|
162
|
+
for (const cli of cliCandidates) {
|
|
163
|
+
try {
|
|
164
|
+
execFileSync(cli, httpEndpointArgs, { stdio: 'pipe' });
|
|
165
|
+
cliSuccess = true;
|
|
166
|
+
break;
|
|
167
|
+
} catch {
|
|
168
|
+
// Try next candidate
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!cliSuccess) {
|
|
173
|
+
// Fall back to direct JSON config file mutation
|
|
174
|
+
if (detection.configFile) {
|
|
175
|
+
try {
|
|
176
|
+
const raw = await readFile(detection.configFile, 'utf-8');
|
|
177
|
+
const cfg = JSON.parse(raw) as Record<string, unknown>;
|
|
178
|
+
setNestedValue(cfg, 'gateway.http.endpoints.responses.enabled', true);
|
|
179
|
+
await writeFile(detection.configFile, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
|
|
180
|
+
// Reload config in detection
|
|
181
|
+
detection.config = cfg;
|
|
182
|
+
configMutated = true;
|
|
183
|
+
console.log('[setup] Enabled gateway.http.endpoints.responses.enabled via config file.');
|
|
184
|
+
} catch (writeErr) {
|
|
185
|
+
console.warn('Could not enable OpenResponses API (non-fatal). Enable manually:');
|
|
186
|
+
console.warn(` ${cliName} config set gateway.http.endpoints.responses.enabled true`);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
console.warn('Could not enable OpenResponses API (non-fatal). Enable manually:');
|
|
190
|
+
console.warn(` ${cliName} config set gateway.http.endpoints.responses.enabled true`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
137
193
|
}
|
|
138
194
|
|
|
139
195
|
// Resolve API key: use provided key or create a new workspace
|
|
@@ -220,19 +276,63 @@ export async function setup(options: SetupOptions): Promise<SetupResult> {
|
|
|
220
276
|
);
|
|
221
277
|
}
|
|
222
278
|
|
|
223
|
-
// Extract gateway auth from
|
|
224
|
-
|
|
279
|
+
// Extract gateway auth from config (if available). Auto-generate if missing.
|
|
280
|
+
let openclawGatewayToken: string | undefined =
|
|
225
281
|
process.env.OPENCLAW_GATEWAY_TOKEN ??
|
|
226
282
|
(extractNestedValue(detection.config, 'gateway.auth.token') as string | undefined);
|
|
283
|
+
|
|
227
284
|
const openclawGatewayPortRaw =
|
|
228
285
|
process.env.OPENCLAW_GATEWAY_PORT ??
|
|
229
286
|
(extractNestedValue(detection.config, 'gateway.port') as number | string | undefined);
|
|
230
287
|
const openclawGatewayPort = openclawGatewayPortRaw ? Number(openclawGatewayPortRaw) : undefined;
|
|
231
288
|
|
|
232
289
|
if (!openclawGatewayToken) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
290
|
+
// Generate a random token and persist it to the config file
|
|
291
|
+
const generated = randomBytes(16).toString('hex');
|
|
292
|
+
openclawGatewayToken = generated;
|
|
293
|
+
console.log('[setup] No gateway token found — generating one and writing to config file.');
|
|
294
|
+
|
|
295
|
+
if (detection.configFile) {
|
|
296
|
+
try {
|
|
297
|
+
const raw = await readFile(detection.configFile, 'utf-8');
|
|
298
|
+
const cfg = JSON.parse(raw) as Record<string, unknown>;
|
|
299
|
+
setNestedValue(cfg, 'gateway.auth.token', generated);
|
|
300
|
+
await writeFile(detection.configFile, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
|
|
301
|
+
detection.config = cfg;
|
|
302
|
+
configMutated = true;
|
|
303
|
+
} catch (writeErr) {
|
|
304
|
+
console.warn(`[setup] Could not write generated token to config file: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
console.warn('[setup] No config file available to persist generated token. Set manually:');
|
|
308
|
+
console.warn(`[setup] export OPENCLAW_GATEWAY_TOKEN=${generated}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Print restart reminder if any config mutations were made
|
|
313
|
+
if (configMutated) {
|
|
314
|
+
console.log('');
|
|
315
|
+
console.log('Config changes detected. Restart the gateway to apply:');
|
|
316
|
+
console.log(` systemctl restart ${serviceName}`);
|
|
317
|
+
if (serviceName !== 'openclaw') {
|
|
318
|
+
console.log(` # or: systemctl restart openclaw`);
|
|
319
|
+
}
|
|
320
|
+
console.log(` # or restart manually if not using systemd`);
|
|
321
|
+
console.log('');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Exec policy preflight warning — warn when security is missing OR not 'full'
|
|
325
|
+
{
|
|
326
|
+
const execSecurity = extractNestedValue(detection.config, 'tools.exec.security') as string | undefined;
|
|
327
|
+
if (execSecurity !== 'full') {
|
|
328
|
+
console.warn('');
|
|
329
|
+
console.warn('Warning: Execution policies may be locked down. If the agent can only chat:');
|
|
330
|
+
console.warn(` ${cliName} config set tools.exec.host gateway`);
|
|
331
|
+
console.warn(` ${cliName} config set tools.exec.ask off`);
|
|
332
|
+
console.warn(` ${cliName} config set tools.exec.security full`);
|
|
333
|
+
console.warn(` systemctl restart ${serviceName}`);
|
|
334
|
+
console.warn('');
|
|
335
|
+
}
|
|
236
336
|
}
|
|
237
337
|
|
|
238
338
|
// Save gateway config (.env)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-relay/policy",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.8",
|
|
4
4
|
"description": "Agent policy management with multi-level fallback (repo, local PRPM, cloud workspace)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"test:watch": "vitest"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@agent-relay/config": "3.1.
|
|
25
|
+
"@agent-relay/config": "3.1.8"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^22.19.3",
|
|
@@ -516,6 +516,7 @@ function detectPlatformSuffix() {
|
|
|
516
516
|
const platformMap = {
|
|
517
517
|
darwin: { arm64: 'darwin-arm64', x64: 'darwin-x64' },
|
|
518
518
|
linux: { arm64: 'linux-arm64', x64: 'linux-x64' },
|
|
519
|
+
win32: { x64: 'win32-x64' },
|
|
519
520
|
};
|
|
520
521
|
return platformMap[process.platform]?.[process.arch] ?? null;
|
|
521
522
|
}
|
|
@@ -595,21 +596,18 @@ function resolveDefaultBinaryPath() {
|
|
|
595
596
|
if (fs.existsSync(workspaceRelease)) {
|
|
596
597
|
return workspaceRelease;
|
|
597
598
|
}
|
|
598
|
-
// 2. Check for bundled broker binary in SDK package (npm install)
|
|
599
|
-
//
|
|
600
|
-
//
|
|
599
|
+
// 2. Check for bundled platform-specific broker binary in SDK package (npm install).
|
|
600
|
+
// Only use binaries that match the current platform to avoid running
|
|
601
|
+
// e.g. a macOS binary on Linux (or vice-versa).
|
|
601
602
|
const binDir = path.resolve(moduleDir, '..', 'bin');
|
|
602
603
|
const suffix = detectPlatformSuffix();
|
|
603
604
|
if (suffix) {
|
|
604
|
-
const
|
|
605
|
+
const ext = process.platform === 'win32' ? '.exe' : '';
|
|
606
|
+
const platformBinary = path.join(binDir, `agent-relay-broker-${suffix}${ext}`);
|
|
605
607
|
if (fs.existsSync(platformBinary)) {
|
|
606
608
|
return platformBinary;
|
|
607
609
|
}
|
|
608
610
|
}
|
|
609
|
-
const bundled = path.join(binDir, brokerExe);
|
|
610
|
-
if (fs.existsSync(bundled)) {
|
|
611
|
-
return bundled;
|
|
612
|
-
}
|
|
613
611
|
// 3. Check for standalone broker binary in ~/.agent-relay/bin/ (install.sh)
|
|
614
612
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
615
613
|
const standaloneBroker = path.join(homeDir, '.agent-relay', 'bin', brokerExe);
|