File size: 6,393 Bytes
e18726e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import { addTimerTimeoutGraceMs } from "@openclaw/normalization-core/number-coercion";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import type {
  MeetingBrowserRequestParams,
  MeetingPlatformAdapter,
} from "./platform-adapter-contract.js";
import type { MeetingBrowserHealth, MeetingTranscriptSnapshot } from "./session-types.js";

type BrowserProxyResult = { result?: unknown };

export type MeetingBrowserNodeInfo = {
  caps?: string[];
  commands?: string[];
  connected?: boolean;
  nodeId?: string;
  displayName?: string;
  remoteIp?: string;
};

type NodeAdapter = Pick<
  MeetingPlatformAdapter<unknown, string, MeetingBrowserHealth, MeetingTranscriptSnapshot>,
  "displayName" | "nodeCommandName" | "nodeConfigPath"
>;

function isMeetingBrowserNode(node: MeetingBrowserNodeInfo, adapter: NodeAdapter) {
  const commands = Array.isArray(node.commands) ? node.commands : [];
  const caps = Array.isArray(node.caps) ? node.caps : [];
  return (
    node.connected === true &&
    commands.includes(adapter.nodeCommandName) &&
    (commands.includes("browser.proxy") || caps.includes("browser"))
  );
}

function matchesRequestedNode(node: MeetingBrowserNodeInfo, requested: string): boolean {
  return [node.nodeId, node.displayName, node.remoteIp].some((value) => value === requested);
}

function formatNodeLabel(node: MeetingBrowserNodeInfo): string {
  const parts = [node.displayName, node.nodeId, node.remoteIp].filter(Boolean);
  return parts.length > 0 ? parts.join(" / ") : "unknown node";
}

function describeNodeUsabilityIssues(node: MeetingBrowserNodeInfo, adapter: NodeAdapter): string[] {
  const commands = Array.isArray(node.commands) ? node.commands : [];
  const caps = Array.isArray(node.caps) ? node.caps : [];
  const issues: string[] = [];
  if (node.connected !== true) {
    issues.push("offline");
  }
  if (!commands.includes(adapter.nodeCommandName)) {
    issues.push(`missing ${adapter.nodeCommandName}`);
  }
  if (!commands.includes("browser.proxy") && !caps.includes("browser")) {
    issues.push("missing browser.proxy/browser capability");
  }
  return issues;
}

async function listMeetingNodes(
  runtime: PluginRuntime,
  adapter: NodeAdapter,
  params?: { connected?: boolean },
): Promise<{ nodes: MeetingBrowserNodeInfo[] }> {
  try {
    return params ? await runtime.nodes.list(params) : await runtime.nodes.list();
  } catch (error) {
    throw new Error(`${adapter.displayName} node inventory unavailable`, { cause: error });
  }
}

export async function resolveMeetingBrowserNodeInfo(params: {
  runtime: PluginRuntime;
  adapter: NodeAdapter;
  requestedNode?: string;
}): Promise<MeetingBrowserNodeInfo> {
  const requested = params.requestedNode?.trim();
  if (requested) {
    const list = await listMeetingNodes(params.runtime, params.adapter);
    const matches = list.nodes.filter((node) => matchesRequestedNode(node, requested));
    if (matches.length > 1) {
      throw new Error(
        `Configured ${params.adapter.displayName} node ${requested} is ambiguous (${matches.length} matches). Pin ${params.adapter.nodeConfigPath} to a unique node id, display name, or remote IP.`,
      );
    }
    const [node] = matches;
    if (!node) {
      throw new Error(
        `Configured ${params.adapter.displayName} node ${requested} was not found. Run \`openclaw nodes status\` and start or approve the Chrome node.`,
      );
    }
    if (isMeetingBrowserNode(node, params.adapter)) {
      return node;
    }
    throw new Error(
      `Configured ${params.adapter.displayName} node ${requested} is not usable (${formatNodeLabel(node)}): ${describeNodeUsabilityIssues(node, params.adapter).join("; ")}. Start or reinstall \`openclaw node run\` on that Chrome host, approve pairing, and allow ${params.adapter.nodeCommandName} plus browser.proxy.`,
    );
  }

  const list = await listMeetingNodes(params.runtime, params.adapter, { connected: true });
  const nodes = list.nodes.filter((node) => isMeetingBrowserNode(node, params.adapter));
  const [node] = nodes;
  if (!node) {
    throw new Error(
      `No connected ${params.adapter.displayName}-capable node with browser proxy. Run \`openclaw node run\` on the Chrome host with browser proxy enabled, approve pairing, and allow ${params.adapter.nodeCommandName} plus browser.proxy.`,
    );
  }
  if (nodes.length === 1) {
    return node;
  }
  throw new Error(
    `Multiple ${params.adapter.displayName}-capable nodes connected. Set ${params.adapter.nodeConfigPath}.`,
  );
}

export async function resolveMeetingBrowserNode(params: {
  runtime: PluginRuntime;
  adapter: NodeAdapter;
  requestedNode?: string;
}): Promise<string> {
  const node = await resolveMeetingBrowserNodeInfo(params);
  if (!node.nodeId) {
    throw new Error(`${params.adapter.displayName} node did not include a node id.`);
  }
  return node.nodeId;
}

function unwrapNodeInvokePayload(raw: unknown, adapter: NodeAdapter): unknown {
  const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
  if (typeof record.payloadJSON === "string" && record.payloadJSON.trim()) {
    try {
      return JSON.parse(record.payloadJSON);
    } catch (error) {
      throw new Error(`${adapter.displayName} browser proxy returned malformed payloadJSON.`, {
        cause: error,
      });
    }
  }
  if ("payload" in record) {
    return record.payload;
  }
  return raw;
}

function parseBrowserProxyResult(raw: unknown, adapter: NodeAdapter): unknown {
  const payload = unwrapNodeInvokePayload(raw, adapter);
  const proxy =
    payload && typeof payload === "object" ? (payload as BrowserProxyResult) : undefined;
  if (!proxy || !("result" in proxy)) {
    throw new Error(`${adapter.displayName} browser proxy returned an invalid result.`);
  }
  return proxy.result;
}

export async function callMeetingBrowserProxyOnNode(
  params: {
    runtime: PluginRuntime;
    adapter: NodeAdapter;
    nodeId: string;
  } & MeetingBrowserRequestParams,
) {
  const raw = await params.runtime.nodes.invoke({
    nodeId: params.nodeId,
    command: "browser.proxy",
    params: {
      method: params.method,
      path: params.path,
      body: params.body,
      timeoutMs: params.timeoutMs,
    },
    timeoutMs: addTimerTimeoutGraceMs(params.timeoutMs) ?? 1,
    scopes: ["operator.admin"],
  });
  return parseBrowserProxyResult(raw, params.adapter);
}