@botcord/daemon 0.2.8 → 0.2.9
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/dist/openclaw-discovery.js +49 -5
- package/dist/provision.js +145 -55
- package/package.json +1 -1
- package/src/__tests__/openclaw-discovery.test.ts +48 -0
- package/src/openclaw-discovery.ts +48 -5
- package/src/provision.ts +135 -49
|
@@ -46,13 +46,31 @@ export async function discoverLocalOpenclawGateways(opts = {}) {
|
|
|
46
46
|
}
|
|
47
47
|
export function mergeOpenclawGateways(cfg, found) {
|
|
48
48
|
const existing = cfg.openclawGateways ?? [];
|
|
49
|
-
const
|
|
49
|
+
const byUrl = new Map();
|
|
50
|
+
existing.forEach((g, i) => byUrl.set(normalizeUrlKey(g.url), i));
|
|
50
51
|
const existingNames = new Set(existing.map((g) => g.name));
|
|
52
|
+
const merged = existing.map((g) => ({ ...g }));
|
|
51
53
|
const added = [];
|
|
54
|
+
let mutated = false;
|
|
52
55
|
for (const item of found) {
|
|
53
56
|
const key = normalizeUrlKey(item.url);
|
|
54
|
-
|
|
57
|
+
const idx = byUrl.get(key);
|
|
58
|
+
if (idx !== undefined) {
|
|
59
|
+
// Same URL already configured — only fill in auth that the user is
|
|
60
|
+
// missing, never overwrite an existing token / tokenFile.
|
|
61
|
+
const cur = merged[idx];
|
|
62
|
+
if (!cur.token && !cur.tokenFile) {
|
|
63
|
+
if (item.token) {
|
|
64
|
+
cur.token = item.token;
|
|
65
|
+
mutated = true;
|
|
66
|
+
}
|
|
67
|
+
else if (item.tokenFile) {
|
|
68
|
+
cur.tokenFile = item.tokenFile;
|
|
69
|
+
mutated = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
55
72
|
continue;
|
|
73
|
+
}
|
|
56
74
|
const profile = {
|
|
57
75
|
name: uniqueName(item.name, existingNames),
|
|
58
76
|
url: item.url,
|
|
@@ -61,14 +79,15 @@ export function mergeOpenclawGateways(cfg, found) {
|
|
|
61
79
|
profile.token = item.token;
|
|
62
80
|
else if (item.tokenFile)
|
|
63
81
|
profile.tokenFile = item.tokenFile;
|
|
64
|
-
|
|
82
|
+
byUrl.set(key, merged.length);
|
|
65
83
|
existingNames.add(profile.name);
|
|
84
|
+
merged.push(profile);
|
|
66
85
|
added.push(profile);
|
|
67
86
|
}
|
|
68
|
-
if (added.length === 0)
|
|
87
|
+
if (added.length === 0 && !mutated)
|
|
69
88
|
return { cfg, changed: false, added };
|
|
70
89
|
return {
|
|
71
|
-
cfg: { ...cfg, openclawGateways:
|
|
90
|
+
cfg: { ...cfg, openclawGateways: merged },
|
|
72
91
|
changed: true,
|
|
73
92
|
added,
|
|
74
93
|
};
|
|
@@ -117,9 +136,34 @@ function discoverFromConfigDir(root) {
|
|
|
117
136
|
}
|
|
118
137
|
function parseJsonConfig(raw) {
|
|
119
138
|
const obj = JSON.parse(raw);
|
|
139
|
+
// Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
|
|
140
|
+
// The legacy `acp.url` shape is also supported for explicit user-authored configs.
|
|
141
|
+
const native = pickOpenclawGatewayValues(obj?.gateway);
|
|
142
|
+
if (native)
|
|
143
|
+
return native;
|
|
120
144
|
const acp = obj?.acp ?? obj?.gateway?.acp ?? obj?.gateway ?? obj;
|
|
121
145
|
return pickConfigValues(acp);
|
|
122
146
|
}
|
|
147
|
+
function pickOpenclawGatewayValues(gw) {
|
|
148
|
+
if (!gw || typeof gw !== "object")
|
|
149
|
+
return null;
|
|
150
|
+
const port = typeof gw.port === "number" ? gw.port : undefined;
|
|
151
|
+
if (!port)
|
|
152
|
+
return null;
|
|
153
|
+
// Local discovery always targets the loopback interface, regardless of how
|
|
154
|
+
// the gateway is bound — the daemon is on the same machine.
|
|
155
|
+
const url = `ws://127.0.0.1:${port}`;
|
|
156
|
+
const auth = gw.auth;
|
|
157
|
+
const out = { url };
|
|
158
|
+
if (auth && typeof auth === "object" && auth.mode === "token") {
|
|
159
|
+
if (typeof auth.token === "string" && auth.token.trim())
|
|
160
|
+
out.token = auth.token.trim();
|
|
161
|
+
else if (typeof auth.tokenFile === "string" && auth.tokenFile.trim()) {
|
|
162
|
+
out.tokenFile = auth.tokenFile.trim();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
123
167
|
function parseTomlConfig(raw) {
|
|
124
168
|
let inAcp = false;
|
|
125
169
|
const values = {};
|
package/dist/provision.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* side effects (register agent, write credentials, load route, add/remove
|
|
5
5
|
* gateway channel) and return an ack payload.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { BotCordClient, CONTROL_FRAME_TYPES, defaultCredentialsFile, derivePublicKey, loadStoredCredentials, writeCredentialsFile, } from "@botcord/protocol-core";
|
|
@@ -801,16 +801,22 @@ export function collectRuntimeSnapshot() {
|
|
|
801
801
|
/** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
|
|
802
802
|
export const RUNTIME_ENDPOINTS_CAP = 32;
|
|
803
803
|
/**
|
|
804
|
-
* Default L2 + L3 probe —
|
|
805
|
-
* and
|
|
806
|
-
* enumerate configured agent profiles. Best-effort: a successful WS open with
|
|
807
|
-
* a failed `agents.list` still reports `ok: true` (just without `agents`),
|
|
808
|
-
* matching the RFC's "agents populated only when listing succeeded" rule.
|
|
804
|
+
* Default L2 + L3 probe — speaks OpenClaw's WS frame protocol against the
|
|
805
|
+
* gateway and enumerates agent profiles via `agents.list`.
|
|
809
806
|
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
* `{
|
|
807
|
+
* Wire flow (see `~/claws/openclaw/src/gateway/server/ws-connection/message-handler.ts`
|
|
808
|
+
* and `~/claws/openclaw/src/gateway/protocol/schema/frames.ts`):
|
|
809
|
+
* 1. WS upgrade (no auth required at the HTTP layer).
|
|
810
|
+
* 2. Server emits `{type:"event", event:"connect.challenge", payload:{nonce}}`.
|
|
811
|
+
* 3. Client sends `{type:"req", id, method:"connect", params:{minProtocol, maxProtocol,
|
|
812
|
+
* client:{id:"openclaw-probe", mode:"probe", ...}, auth:{token}}}`.
|
|
813
|
+
* 4. Server responds `{type:"res", id, ok:true, payload:{type:"hello-ok", server:{version}, ...}}`.
|
|
814
|
+
* 5. Client sends `{type:"req", id, method:"agents.list", params:{}}`.
|
|
815
|
+
* 6. Server responds with `{payload: { defaultId, mainKey, scope, agents:[{id, name?, workspace?, model?}] }}`.
|
|
816
|
+
*
|
|
817
|
+
* Best-effort: a successful WS open with a failed handshake / `agents.list`
|
|
818
|
+
* still reports `ok: true` (just without `agents`), matching the RFC's
|
|
819
|
+
* "agents populated only when listing succeeded" rule.
|
|
814
820
|
*/
|
|
815
821
|
async function defaultWsProbe(args) {
|
|
816
822
|
const { default: WebSocket } = await import("ws");
|
|
@@ -818,6 +824,9 @@ async function defaultWsProbe(args) {
|
|
|
818
824
|
let settled = false;
|
|
819
825
|
let ws;
|
|
820
826
|
let timer;
|
|
827
|
+
let serverVersion;
|
|
828
|
+
const CONNECT_ID = "probe-connect";
|
|
829
|
+
let connectSent = false;
|
|
821
830
|
const settle = (v) => {
|
|
822
831
|
if (settled)
|
|
823
832
|
return;
|
|
@@ -834,6 +843,8 @@ async function defaultWsProbe(args) {
|
|
|
834
843
|
};
|
|
835
844
|
try {
|
|
836
845
|
const headers = {};
|
|
846
|
+
// Some deployments gate the WS upgrade on Authorization too; harmless
|
|
847
|
+
// when not enforced — auth is also re-asserted in the connect frame.
|
|
837
848
|
if (args.token)
|
|
838
849
|
headers["Authorization"] = `Bearer ${args.token}`;
|
|
839
850
|
ws = new WebSocket(args.url, { headers });
|
|
@@ -843,65 +854,81 @@ async function defaultWsProbe(args) {
|
|
|
843
854
|
return;
|
|
844
855
|
}
|
|
845
856
|
timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
857
|
+
const sendConnect = () => {
|
|
858
|
+
if (connectSent)
|
|
859
|
+
return;
|
|
860
|
+
connectSent = true;
|
|
861
|
+
const params = {
|
|
862
|
+
minProtocol: 3,
|
|
863
|
+
maxProtocol: 3,
|
|
864
|
+
client: {
|
|
865
|
+
id: "openclaw-probe",
|
|
866
|
+
version: "0.1.0",
|
|
867
|
+
platform: process.platform || "node",
|
|
868
|
+
mode: "probe",
|
|
869
|
+
},
|
|
870
|
+
role: "operator",
|
|
871
|
+
scopes: ["operator.read"],
|
|
872
|
+
};
|
|
873
|
+
if (args.token)
|
|
874
|
+
params.auth = { token: args.token };
|
|
850
875
|
try {
|
|
851
|
-
ws.send(JSON.stringify({
|
|
852
|
-
jsonrpc: "2.0",
|
|
853
|
-
id: requestId,
|
|
854
|
-
method: "agents.list",
|
|
855
|
-
params: {},
|
|
856
|
-
}));
|
|
876
|
+
ws.send(JSON.stringify({ type: "req", id: CONNECT_ID, method: "connect", params }));
|
|
857
877
|
}
|
|
858
878
|
catch (err) {
|
|
859
|
-
settle({ ok: true, error: `
|
|
879
|
+
settle({ ok: true, error: `connect send failed: ${err.message}` });
|
|
860
880
|
}
|
|
881
|
+
};
|
|
882
|
+
ws.on("open", () => {
|
|
883
|
+
// Some servers send `connect.challenge` before the socket is fully
|
|
884
|
+
// wired; if it never arrives we still try a best-effort connect after
|
|
885
|
+
// a short delay so the probe doesn't stall on legacy gateways.
|
|
886
|
+
setTimeout(() => {
|
|
887
|
+
if (!connectSent && !settled)
|
|
888
|
+
sendConnect();
|
|
889
|
+
}, 250);
|
|
861
890
|
});
|
|
862
891
|
ws.on("message", (raw) => {
|
|
892
|
+
let msg;
|
|
863
893
|
try {
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
894
|
+
msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (!msg || typeof msg !== "object")
|
|
900
|
+
return;
|
|
901
|
+
if (msg.type === "event" && msg.event === "connect.challenge") {
|
|
902
|
+
// Nonce only matters for device-pairing flows; token-only auth ignores it.
|
|
903
|
+
sendConnect();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (msg.type !== "res" || typeof msg.id !== "string")
|
|
907
|
+
return;
|
|
908
|
+
if (msg.id === CONNECT_ID) {
|
|
909
|
+
if (!msg.ok) {
|
|
910
|
+
const errMsg = msg.error?.message ? String(msg.error.message) : "connect rejected";
|
|
911
|
+
settle({ ok: true, error: errMsg });
|
|
869
912
|
return;
|
|
870
913
|
}
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
row.workspace = a.workspace;
|
|
881
|
-
if (a.model && typeof a.model === "object") {
|
|
882
|
-
const model = {};
|
|
883
|
-
if (typeof a.model.name === "string")
|
|
884
|
-
model.name = a.model.name;
|
|
885
|
-
if (typeof a.model.provider === "string")
|
|
886
|
-
model.provider = a.model.provider;
|
|
887
|
-
if (model.name || model.provider)
|
|
888
|
-
row.model = model;
|
|
889
|
-
}
|
|
890
|
-
agents.push(row);
|
|
891
|
-
}
|
|
892
|
-
settle({ ok: true, agents });
|
|
893
|
-
}
|
|
894
|
-
catch (err) {
|
|
895
|
-
settle({ ok: true, error: `agents.list parse failed: ${err.message}` });
|
|
914
|
+
const v = msg.payload?.server?.version;
|
|
915
|
+
if (typeof v === "string" && v)
|
|
916
|
+
serverVersion = v;
|
|
917
|
+
// We don't fetch agents.list over the wire: it requires `operator.read`
|
|
918
|
+
// which the gateway only grants to clients that present a paired device
|
|
919
|
+
// identity (see message-handler.ts:478 — self-declared scopes are
|
|
920
|
+
// cleared without device pairing). For local OpenClaw the agent list
|
|
921
|
+
// is sourced directly from disk by `probeOpenclawAgents`.
|
|
922
|
+
settle({ ok: true, version: serverVersion });
|
|
896
923
|
}
|
|
897
924
|
});
|
|
898
925
|
ws.on("error", (err) => {
|
|
899
926
|
settle({ ok: false, error: err.message });
|
|
900
927
|
});
|
|
901
928
|
ws.on("close", () => {
|
|
902
|
-
// If the socket closes before
|
|
903
|
-
// L2 as ok (
|
|
904
|
-
settle({ ok: true });
|
|
929
|
+
// If the socket closes before we got our agents.list response, treat
|
|
930
|
+
// L2 as ok (the upgrade succeeded) and emit no agents.
|
|
931
|
+
settle({ ok: true, version: serverVersion });
|
|
905
932
|
});
|
|
906
933
|
});
|
|
907
934
|
}
|
|
@@ -913,11 +940,74 @@ export async function probeOpenclawAgents(profile, opts = {}) {
|
|
|
913
940
|
...(profile.token ? { token: profile.token } : {}),
|
|
914
941
|
...(profile.tokenFile ? { tokenFile: profile.tokenFile } : {}),
|
|
915
942
|
});
|
|
916
|
-
|
|
943
|
+
const result = await probe({
|
|
917
944
|
url: profile.url,
|
|
918
945
|
token: prepared.resolvedToken,
|
|
919
946
|
timeoutMs: opts.timeoutMs ?? 3000,
|
|
920
947
|
});
|
|
948
|
+
// For loopback gateways the agent roster lives in `~/.openclaw/openclaw.json`
|
|
949
|
+
// and is the source of truth — listing it over the wire would require a
|
|
950
|
+
// paired device identity (operator.read scope). When the WS probe is the
|
|
951
|
+
// default (i.e. no test injection) we enrich the result from disk.
|
|
952
|
+
if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
|
|
953
|
+
const local = readLocalOpenclawAgents();
|
|
954
|
+
if (local && local.length > 0)
|
|
955
|
+
result.agents = local;
|
|
956
|
+
}
|
|
957
|
+
return result;
|
|
958
|
+
}
|
|
959
|
+
function isLoopbackUrl(raw) {
|
|
960
|
+
try {
|
|
961
|
+
const u = new URL(raw);
|
|
962
|
+
return u.hostname === "127.0.0.1" || u.hostname === "::1" || u.hostname === "localhost";
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function readLocalOpenclawAgents() {
|
|
969
|
+
try {
|
|
970
|
+
const file = path.join(homedir(), ".openclaw", "openclaw.json");
|
|
971
|
+
if (!existsSync(file))
|
|
972
|
+
return null;
|
|
973
|
+
const cfg = JSON.parse(readFileSync(file, "utf8"));
|
|
974
|
+
const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
|
|
975
|
+
const defaultId = typeof cfg?.agents?.defaults?.id === "string" ? cfg.agents.defaults.id : "default";
|
|
976
|
+
const seen = new Set();
|
|
977
|
+
const out = [];
|
|
978
|
+
const push = (raw, fallbackId) => {
|
|
979
|
+
const id = typeof raw?.id === "string" && raw.id ? raw.id : fallbackId;
|
|
980
|
+
if (!id || seen.has(id))
|
|
981
|
+
return;
|
|
982
|
+
seen.add(id);
|
|
983
|
+
const row = { id };
|
|
984
|
+
if (typeof raw?.name === "string")
|
|
985
|
+
row.name = raw.name;
|
|
986
|
+
if (typeof raw?.workspace === "string")
|
|
987
|
+
row.workspace = raw.workspace;
|
|
988
|
+
const m = raw?.model;
|
|
989
|
+
if (m && typeof m === "object") {
|
|
990
|
+
const model = {};
|
|
991
|
+
if (typeof m.primary === "string")
|
|
992
|
+
model.name = m.primary;
|
|
993
|
+
else if (typeof m.name === "string")
|
|
994
|
+
model.name = m.name;
|
|
995
|
+
if (typeof m.provider === "string")
|
|
996
|
+
model.provider = m.provider;
|
|
997
|
+
if (model.name || model.provider)
|
|
998
|
+
row.model = model;
|
|
999
|
+
}
|
|
1000
|
+
out.push(row);
|
|
1001
|
+
};
|
|
1002
|
+
// Default agent first so it surfaces at the top of the dropdown.
|
|
1003
|
+
push({ id: defaultId, workspace: cfg?.agents?.defaults?.workspace, model: cfg?.agents?.defaults?.model }, defaultId);
|
|
1004
|
+
for (const entry of list)
|
|
1005
|
+
push(entry);
|
|
1006
|
+
return out;
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
921
1011
|
}
|
|
922
1012
|
/**
|
|
923
1013
|
* Async variant that includes L2 (gateway reachability) and L3 (agent listing)
|
package/package.json
CHANGED
|
@@ -62,6 +62,33 @@ describe("discoverLocalOpenclawGateways", () => {
|
|
|
62
62
|
);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
it("parses OpenClaw's native gateway.port + auth.token shape", async () => {
|
|
66
|
+
const dir = tempDir();
|
|
67
|
+
writeFileSync(
|
|
68
|
+
path.join(dir, "openclaw.json"),
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
gateway: {
|
|
71
|
+
port: 18789,
|
|
72
|
+
bind: "loopback",
|
|
73
|
+
auth: { mode: "token", token: "native-token" },
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const found = await discoverLocalOpenclawGateways({
|
|
79
|
+
searchPaths: [dir],
|
|
80
|
+
defaultPorts: [],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(found).toEqual([
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
url: "ws://127.0.0.1:18789",
|
|
86
|
+
token: "native-token",
|
|
87
|
+
source: "config-file",
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
65
92
|
it("uses OPENCLAW_ACP_URL and token env vars", async () => {
|
|
66
93
|
const found = await discoverLocalOpenclawGateways({
|
|
67
94
|
searchPaths: [],
|
|
@@ -124,6 +151,27 @@ describe("discoverLocalOpenclawGateways", () => {
|
|
|
124
151
|
});
|
|
125
152
|
|
|
126
153
|
describe("mergeOpenclawGateways", () => {
|
|
154
|
+
it("backfills token onto an existing profile that lacks one", () => {
|
|
155
|
+
const cfg = baseConfig();
|
|
156
|
+
cfg.openclawGateways = [
|
|
157
|
+
{ name: "openclaw-127-0-0-1-18789", url: "ws://127.0.0.1:18789" },
|
|
158
|
+
];
|
|
159
|
+
const merged = mergeOpenclawGateways(cfg, [
|
|
160
|
+
{
|
|
161
|
+
name: "openclaw-127-0-0-1-18789",
|
|
162
|
+
url: "ws://127.0.0.1:18789",
|
|
163
|
+
token: "discovered",
|
|
164
|
+
source: "config-file",
|
|
165
|
+
},
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
expect(merged.changed).toBe(true);
|
|
169
|
+
expect(merged.added).toEqual([]);
|
|
170
|
+
expect(merged.cfg.openclawGateways).toEqual([
|
|
171
|
+
{ name: "openclaw-127-0-0-1-18789", url: "ws://127.0.0.1:18789", token: "discovered" },
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
127
175
|
it("appends new URLs and keeps existing profiles untouched", () => {
|
|
128
176
|
const cfg = baseConfig();
|
|
129
177
|
cfg.openclawGateways = [{ name: "local", url: "ws://127.0.0.1:18789/acp", token: "user-token" }];
|
|
@@ -84,27 +84,46 @@ export function mergeOpenclawGateways(
|
|
|
84
84
|
found: DiscoveredOpenclawGateway[],
|
|
85
85
|
): MergeOpenclawGatewayResult {
|
|
86
86
|
const existing = cfg.openclawGateways ?? [];
|
|
87
|
-
const
|
|
87
|
+
const byUrl = new Map<string, number>();
|
|
88
|
+
existing.forEach((g, i) => byUrl.set(normalizeUrlKey(g.url), i));
|
|
88
89
|
const existingNames = new Set(existing.map((g) => g.name));
|
|
90
|
+
const merged = existing.map((g) => ({ ...g }));
|
|
89
91
|
const added: OpenclawGatewayProfile[] = [];
|
|
92
|
+
let mutated = false;
|
|
90
93
|
|
|
91
94
|
for (const item of found) {
|
|
92
95
|
const key = normalizeUrlKey(item.url);
|
|
93
|
-
|
|
96
|
+
const idx = byUrl.get(key);
|
|
97
|
+
if (idx !== undefined) {
|
|
98
|
+
// Same URL already configured — only fill in auth that the user is
|
|
99
|
+
// missing, never overwrite an existing token / tokenFile.
|
|
100
|
+
const cur = merged[idx];
|
|
101
|
+
if (!cur.token && !cur.tokenFile) {
|
|
102
|
+
if (item.token) {
|
|
103
|
+
cur.token = item.token;
|
|
104
|
+
mutated = true;
|
|
105
|
+
} else if (item.tokenFile) {
|
|
106
|
+
cur.tokenFile = item.tokenFile;
|
|
107
|
+
mutated = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
94
112
|
const profile: OpenclawGatewayProfile = {
|
|
95
113
|
name: uniqueName(item.name, existingNames),
|
|
96
114
|
url: item.url,
|
|
97
115
|
};
|
|
98
116
|
if (item.token) profile.token = item.token;
|
|
99
117
|
else if (item.tokenFile) profile.tokenFile = item.tokenFile;
|
|
100
|
-
|
|
118
|
+
byUrl.set(key, merged.length);
|
|
101
119
|
existingNames.add(profile.name);
|
|
120
|
+
merged.push(profile);
|
|
102
121
|
added.push(profile);
|
|
103
122
|
}
|
|
104
123
|
|
|
105
|
-
if (added.length === 0) return { cfg, changed: false, added };
|
|
124
|
+
if (added.length === 0 && !mutated) return { cfg, changed: false, added };
|
|
106
125
|
return {
|
|
107
|
-
cfg: { ...cfg, openclawGateways:
|
|
126
|
+
cfg: { ...cfg, openclawGateways: merged },
|
|
108
127
|
changed: true,
|
|
109
128
|
added,
|
|
110
129
|
};
|
|
@@ -148,10 +167,34 @@ function discoverFromConfigDir(root: string): DiscoveredOpenclawGateway[] {
|
|
|
148
167
|
|
|
149
168
|
function parseJsonConfig(raw: string): { url?: string; token?: string; tokenFile?: string } | null {
|
|
150
169
|
const obj = JSON.parse(raw) as any;
|
|
170
|
+
// Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
|
|
171
|
+
// The legacy `acp.url` shape is also supported for explicit user-authored configs.
|
|
172
|
+
const native = pickOpenclawGatewayValues(obj?.gateway);
|
|
173
|
+
if (native) return native;
|
|
151
174
|
const acp = obj?.acp ?? obj?.gateway?.acp ?? obj?.gateway ?? obj;
|
|
152
175
|
return pickConfigValues(acp);
|
|
153
176
|
}
|
|
154
177
|
|
|
178
|
+
function pickOpenclawGatewayValues(
|
|
179
|
+
gw: any,
|
|
180
|
+
): { url?: string; token?: string; tokenFile?: string } | null {
|
|
181
|
+
if (!gw || typeof gw !== "object") return null;
|
|
182
|
+
const port = typeof gw.port === "number" ? gw.port : undefined;
|
|
183
|
+
if (!port) return null;
|
|
184
|
+
// Local discovery always targets the loopback interface, regardless of how
|
|
185
|
+
// the gateway is bound — the daemon is on the same machine.
|
|
186
|
+
const url = `ws://127.0.0.1:${port}`;
|
|
187
|
+
const auth = gw.auth;
|
|
188
|
+
const out: { url: string; token?: string; tokenFile?: string } = { url };
|
|
189
|
+
if (auth && typeof auth === "object" && auth.mode === "token") {
|
|
190
|
+
if (typeof auth.token === "string" && auth.token.trim()) out.token = auth.token.trim();
|
|
191
|
+
else if (typeof auth.tokenFile === "string" && auth.tokenFile.trim()) {
|
|
192
|
+
out.tokenFile = auth.tokenFile.trim();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
155
198
|
function parseTomlConfig(raw: string): { url?: string; token?: string; tokenFile?: string } | null {
|
|
156
199
|
let inAcp = false;
|
|
157
200
|
const values: Record<string, string> = {};
|
package/src/provision.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* side effects (register agent, write credentials, load route, add/remove
|
|
5
5
|
* gateway channel) and return an ack payload.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import {
|
|
@@ -957,16 +957,22 @@ export type WsEndpointProbeFn = (args: {
|
|
|
957
957
|
}>;
|
|
958
958
|
|
|
959
959
|
/**
|
|
960
|
-
* Default L2 + L3 probe —
|
|
961
|
-
* and
|
|
962
|
-
* enumerate configured agent profiles. Best-effort: a successful WS open with
|
|
963
|
-
* a failed `agents.list` still reports `ok: true` (just without `agents`),
|
|
964
|
-
* matching the RFC's "agents populated only when listing succeeded" rule.
|
|
960
|
+
* Default L2 + L3 probe — speaks OpenClaw's WS frame protocol against the
|
|
961
|
+
* gateway and enumerates agent profiles via `agents.list`.
|
|
965
962
|
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
* `{
|
|
963
|
+
* Wire flow (see `~/claws/openclaw/src/gateway/server/ws-connection/message-handler.ts`
|
|
964
|
+
* and `~/claws/openclaw/src/gateway/protocol/schema/frames.ts`):
|
|
965
|
+
* 1. WS upgrade (no auth required at the HTTP layer).
|
|
966
|
+
* 2. Server emits `{type:"event", event:"connect.challenge", payload:{nonce}}`.
|
|
967
|
+
* 3. Client sends `{type:"req", id, method:"connect", params:{minProtocol, maxProtocol,
|
|
968
|
+
* client:{id:"openclaw-probe", mode:"probe", ...}, auth:{token}}}`.
|
|
969
|
+
* 4. Server responds `{type:"res", id, ok:true, payload:{type:"hello-ok", server:{version}, ...}}`.
|
|
970
|
+
* 5. Client sends `{type:"req", id, method:"agents.list", params:{}}`.
|
|
971
|
+
* 6. Server responds with `{payload: { defaultId, mainKey, scope, agents:[{id, name?, workspace?, model?}] }}`.
|
|
972
|
+
*
|
|
973
|
+
* Best-effort: a successful WS open with a failed handshake / `agents.list`
|
|
974
|
+
* still reports `ok: true` (just without `agents`), matching the RFC's
|
|
975
|
+
* "agents populated only when listing succeeded" rule.
|
|
970
976
|
*/
|
|
971
977
|
async function defaultWsProbe(args: {
|
|
972
978
|
url: string;
|
|
@@ -1000,6 +1006,9 @@ async function defaultWsProbe(args: {
|
|
|
1000
1006
|
let settled = false;
|
|
1001
1007
|
let ws: any;
|
|
1002
1008
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1009
|
+
let serverVersion: string | undefined;
|
|
1010
|
+
const CONNECT_ID = "probe-connect";
|
|
1011
|
+
let connectSent = false;
|
|
1003
1012
|
const settle = (v: ProbeResult): void => {
|
|
1004
1013
|
if (settled) return;
|
|
1005
1014
|
settled = true;
|
|
@@ -1013,6 +1022,8 @@ async function defaultWsProbe(args: {
|
|
|
1013
1022
|
};
|
|
1014
1023
|
try {
|
|
1015
1024
|
const headers: Record<string, string> = {};
|
|
1025
|
+
// Some deployments gate the WS upgrade on Authorization too; harmless
|
|
1026
|
+
// when not enforced — auth is also re-asserted in the connect frame.
|
|
1016
1027
|
if (args.token) headers["Authorization"] = `Bearer ${args.token}`;
|
|
1017
1028
|
ws = new WebSocket(args.url, { headers });
|
|
1018
1029
|
} catch (err) {
|
|
@@ -1020,58 +1031,75 @@ async function defaultWsProbe(args: {
|
|
|
1020
1031
|
return;
|
|
1021
1032
|
}
|
|
1022
1033
|
timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1034
|
+
|
|
1035
|
+
const sendConnect = (): void => {
|
|
1036
|
+
if (connectSent) return;
|
|
1037
|
+
connectSent = true;
|
|
1038
|
+
const params: any = {
|
|
1039
|
+
minProtocol: 3,
|
|
1040
|
+
maxProtocol: 3,
|
|
1041
|
+
client: {
|
|
1042
|
+
id: "openclaw-probe",
|
|
1043
|
+
version: "0.1.0",
|
|
1044
|
+
platform: process.platform || "node",
|
|
1045
|
+
mode: "probe",
|
|
1046
|
+
},
|
|
1047
|
+
role: "operator",
|
|
1048
|
+
scopes: ["operator.read"],
|
|
1049
|
+
};
|
|
1050
|
+
if (args.token) params.auth = { token: args.token };
|
|
1027
1051
|
try {
|
|
1028
|
-
ws.send(
|
|
1029
|
-
JSON.stringify({
|
|
1030
|
-
jsonrpc: "2.0",
|
|
1031
|
-
id: requestId,
|
|
1032
|
-
method: "agents.list",
|
|
1033
|
-
params: {},
|
|
1034
|
-
}),
|
|
1035
|
-
);
|
|
1052
|
+
ws.send(JSON.stringify({ type: "req", id: CONNECT_ID, method: "connect", params }));
|
|
1036
1053
|
} catch (err) {
|
|
1037
|
-
settle({ ok: true, error: `
|
|
1054
|
+
settle({ ok: true, error: `connect send failed: ${(err as Error).message}` });
|
|
1038
1055
|
}
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
ws.on("open", () => {
|
|
1059
|
+
// Some servers send `connect.challenge` before the socket is fully
|
|
1060
|
+
// wired; if it never arrives we still try a best-effort connect after
|
|
1061
|
+
// a short delay so the probe doesn't stall on legacy gateways.
|
|
1062
|
+
setTimeout(() => {
|
|
1063
|
+
if (!connectSent && !settled) sendConnect();
|
|
1064
|
+
}, 250);
|
|
1039
1065
|
});
|
|
1040
1066
|
ws.on("message", (raw: Buffer | string) => {
|
|
1067
|
+
let msg: any;
|
|
1041
1068
|
try {
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1069
|
+
msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
1070
|
+
} catch {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (!msg || typeof msg !== "object") return;
|
|
1074
|
+
if (msg.type === "event" && msg.event === "connect.challenge") {
|
|
1075
|
+
// Nonce only matters for device-pairing flows; token-only auth ignores it.
|
|
1076
|
+
sendConnect();
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (msg.type !== "res" || typeof msg.id !== "string") return;
|
|
1080
|
+
if (msg.id === CONNECT_ID) {
|
|
1081
|
+
if (!msg.ok) {
|
|
1082
|
+
const errMsg = msg.error?.message ? String(msg.error.message) : "connect rejected";
|
|
1083
|
+
settle({ ok: true, error: errMsg });
|
|
1046
1084
|
return;
|
|
1047
1085
|
}
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
const model: { name?: string; provider?: string } = {};
|
|
1057
|
-
if (typeof a.model.name === "string") model.name = a.model.name;
|
|
1058
|
-
if (typeof a.model.provider === "string") model.provider = a.model.provider;
|
|
1059
|
-
if (model.name || model.provider) row.model = model;
|
|
1060
|
-
}
|
|
1061
|
-
agents.push(row);
|
|
1062
|
-
}
|
|
1063
|
-
settle({ ok: true, agents });
|
|
1064
|
-
} catch (err) {
|
|
1065
|
-
settle({ ok: true, error: `agents.list parse failed: ${(err as Error).message}` });
|
|
1086
|
+
const v = msg.payload?.server?.version;
|
|
1087
|
+
if (typeof v === "string" && v) serverVersion = v;
|
|
1088
|
+
// We don't fetch agents.list over the wire: it requires `operator.read`
|
|
1089
|
+
// which the gateway only grants to clients that present a paired device
|
|
1090
|
+
// identity (see message-handler.ts:478 — self-declared scopes are
|
|
1091
|
+
// cleared without device pairing). For local OpenClaw the agent list
|
|
1092
|
+
// is sourced directly from disk by `probeOpenclawAgents`.
|
|
1093
|
+
settle({ ok: true, version: serverVersion });
|
|
1066
1094
|
}
|
|
1067
1095
|
});
|
|
1068
1096
|
ws.on("error", (err: Error) => {
|
|
1069
1097
|
settle({ ok: false, error: err.message });
|
|
1070
1098
|
});
|
|
1071
1099
|
ws.on("close", () => {
|
|
1072
|
-
// If the socket closes before
|
|
1073
|
-
// L2 as ok (
|
|
1074
|
-
settle({ ok: true });
|
|
1100
|
+
// If the socket closes before we got our agents.list response, treat
|
|
1101
|
+
// L2 as ok (the upgrade succeeded) and emit no agents.
|
|
1102
|
+
settle({ ok: true, version: serverVersion });
|
|
1075
1103
|
});
|
|
1076
1104
|
});
|
|
1077
1105
|
}
|
|
@@ -1097,11 +1125,69 @@ export async function probeOpenclawAgents(
|
|
|
1097
1125
|
...(profile.token ? { token: profile.token } : {}),
|
|
1098
1126
|
...(profile.tokenFile ? { tokenFile: profile.tokenFile } : {}),
|
|
1099
1127
|
});
|
|
1100
|
-
|
|
1128
|
+
const result = await probe({
|
|
1101
1129
|
url: profile.url,
|
|
1102
1130
|
token: prepared.resolvedToken,
|
|
1103
1131
|
timeoutMs: opts.timeoutMs ?? 3000,
|
|
1104
1132
|
});
|
|
1133
|
+
// For loopback gateways the agent roster lives in `~/.openclaw/openclaw.json`
|
|
1134
|
+
// and is the source of truth — listing it over the wire would require a
|
|
1135
|
+
// paired device identity (operator.read scope). When the WS probe is the
|
|
1136
|
+
// default (i.e. no test injection) we enrich the result from disk.
|
|
1137
|
+
if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
|
|
1138
|
+
const local = readLocalOpenclawAgents();
|
|
1139
|
+
if (local && local.length > 0) result.agents = local;
|
|
1140
|
+
}
|
|
1141
|
+
return result;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function isLoopbackUrl(raw: string): boolean {
|
|
1145
|
+
try {
|
|
1146
|
+
const u = new URL(raw);
|
|
1147
|
+
return u.hostname === "127.0.0.1" || u.hostname === "::1" || u.hostname === "localhost";
|
|
1148
|
+
} catch {
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function readLocalOpenclawAgents(): Array<{
|
|
1154
|
+
id: string;
|
|
1155
|
+
name?: string;
|
|
1156
|
+
workspace?: string;
|
|
1157
|
+
model?: { name?: string; provider?: string };
|
|
1158
|
+
}> | null {
|
|
1159
|
+
try {
|
|
1160
|
+
const file = path.join(homedir(), ".openclaw", "openclaw.json");
|
|
1161
|
+
if (!existsSync(file)) return null;
|
|
1162
|
+
const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
|
|
1163
|
+
const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
|
|
1164
|
+
const defaultId = typeof cfg?.agents?.defaults?.id === "string" ? cfg.agents.defaults.id : "default";
|
|
1165
|
+
const seen = new Set<string>();
|
|
1166
|
+
const out: Array<{ id: string; name?: string; workspace?: string; model?: { name?: string; provider?: string } }> = [];
|
|
1167
|
+
const push = (raw: any, fallbackId?: string): void => {
|
|
1168
|
+
const id = typeof raw?.id === "string" && raw.id ? raw.id : fallbackId;
|
|
1169
|
+
if (!id || seen.has(id)) return;
|
|
1170
|
+
seen.add(id);
|
|
1171
|
+
const row: { id: string; name?: string; workspace?: string; model?: { name?: string; provider?: string } } = { id };
|
|
1172
|
+
if (typeof raw?.name === "string") row.name = raw.name;
|
|
1173
|
+
if (typeof raw?.workspace === "string") row.workspace = raw.workspace;
|
|
1174
|
+
const m = raw?.model;
|
|
1175
|
+
if (m && typeof m === "object") {
|
|
1176
|
+
const model: { name?: string; provider?: string } = {};
|
|
1177
|
+
if (typeof m.primary === "string") model.name = m.primary;
|
|
1178
|
+
else if (typeof m.name === "string") model.name = m.name;
|
|
1179
|
+
if (typeof m.provider === "string") model.provider = m.provider;
|
|
1180
|
+
if (model.name || model.provider) row.model = model;
|
|
1181
|
+
}
|
|
1182
|
+
out.push(row);
|
|
1183
|
+
};
|
|
1184
|
+
// Default agent first so it surfaces at the top of the dropdown.
|
|
1185
|
+
push({ id: defaultId, workspace: cfg?.agents?.defaults?.workspace, model: cfg?.agents?.defaults?.model }, defaultId);
|
|
1186
|
+
for (const entry of list) push(entry);
|
|
1187
|
+
return out;
|
|
1188
|
+
} catch {
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1105
1191
|
}
|
|
1106
1192
|
|
|
1107
1193
|
/**
|