openclaw gateway: auto-approve first pairing and retry
This commit is contained in:
@@ -40,7 +40,8 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT
|
|||||||
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
|
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
|
||||||
|
|
||||||
Pairing handshake note:
|
Pairing handshake note:
|
||||||
- The first gateway run may return `pairing required` once for a new device key.
|
- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
|
||||||
|
- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key.
|
||||||
- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself.
|
- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself.
|
||||||
- Approve it in OpenClaw, then retry the task.
|
- Approve it in OpenClaw, then retry the task.
|
||||||
- For local docker smoke, you can approve from host:
|
- For local docker smoke, you can approve from host:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ By default the adapter sends a signed `device` payload in `connect` params.
|
|||||||
- set `disableDeviceAuth=true` to omit device signing
|
- set `disableDeviceAuth=true` to omit device signing
|
||||||
- set `devicePrivateKeyPem` to pin a stable signing key
|
- set `devicePrivateKeyPem` to pin a stable signing key
|
||||||
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
|
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
|
||||||
|
- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once.
|
||||||
|
|
||||||
## Session Strategy
|
## Session Strategy
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,9 @@ POST /api/companies/$CLA_COMPANY_ID/invites
|
|||||||
- pairing mode is explicit:
|
- pairing mode is explicit:
|
||||||
- default path: `adapterConfig.disableDeviceAuth` is false/absent and stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs
|
- default path: `adapterConfig.disableDeviceAuth` is false/absent and stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs
|
||||||
- fallback path: `disableDeviceAuth=true` only for environments that cannot support pairing
|
- fallback path: `disableDeviceAuth=true` only for environments that cannot support pairing
|
||||||
5. Trigger one connectivity run. If it returns `pairing required`, approve the pending device request in OpenClaw and retry once.
|
5. Trigger one connectivity run. Adapter behavior on first pairing gate:
|
||||||
|
- default: auto-attempt `device.pair.list` + `device.pair.approve` over shared auth, then retry once
|
||||||
|
- if auto-pair fails, run returns `pairing required`; approve manually in OpenClaw and retry once
|
||||||
- Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates.
|
- Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates.
|
||||||
- Local docker automation path:
|
- Local docker automation path:
|
||||||
- `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token <gateway-token>`
|
- `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token <gateway-token>`
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Request behavior fields:
|
|||||||
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||||
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
||||||
|
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
|
||||||
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
|
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
|
||||||
|
|
||||||
Session routing fields:
|
Session routing fields:
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ type PendingRequest = {
|
|||||||
timer: ReturnType<typeof setTimeout> | null;
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GatewayResponseError = Error & {
|
||||||
|
gatewayCode?: string;
|
||||||
|
gatewayDetails?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
type GatewayClientOptions = {
|
type GatewayClientOptions = {
|
||||||
url: string;
|
url: string;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -164,6 +169,10 @@ function normalizeScopes(value: unknown): string[] {
|
|||||||
return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES];
|
return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueScopes(scopes: string[]): string[] {
|
||||||
|
return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
||||||
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
@@ -173,6 +182,21 @@ function headerMapHasIgnoreCase(headers: Record<string, string>, key: string): b
|
|||||||
return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase());
|
return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGatewayErrorDetails(err: unknown): Record<string, unknown> | null {
|
||||||
|
if (!err || typeof err !== "object") return null;
|
||||||
|
const candidate = (err as GatewayResponseError).gatewayDetails;
|
||||||
|
return asRecord(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPairingRequestId(err: unknown): string | null {
|
||||||
|
const details = getGatewayErrorDetails(err);
|
||||||
|
const fromDetails = nonEmpty(details?.requestId);
|
||||||
|
if (fromDetails) return fromDetails;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function toAuthorizationHeaderValue(rawToken: string): string {
|
function toAuthorizationHeaderValue(rawToken: string): string {
|
||||||
const trimmed = rawToken.trim();
|
const trimmed = rawToken.trim();
|
||||||
if (!trimmed) return trimmed;
|
if (!trimmed) return trimmed;
|
||||||
@@ -691,7 +715,101 @@ class GatewayWsClient {
|
|||||||
nonEmpty(errorRecord?.message) ??
|
nonEmpty(errorRecord?.message) ??
|
||||||
nonEmpty(errorRecord?.code) ??
|
nonEmpty(errorRecord?.code) ??
|
||||||
"gateway request failed";
|
"gateway request failed";
|
||||||
pending.reject(new Error(message));
|
const err = new Error(message) as GatewayResponseError;
|
||||||
|
const code = nonEmpty(errorRecord?.code);
|
||||||
|
const details = asRecord(errorRecord?.details);
|
||||||
|
if (code) err.gatewayCode = code;
|
||||||
|
if (details) err.gatewayDetails = details;
|
||||||
|
pending.reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoApproveDevicePairing(params: {
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
connectTimeoutMs: number;
|
||||||
|
clientId: string;
|
||||||
|
clientMode: string;
|
||||||
|
clientVersion: string;
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
authToken: string | null;
|
||||||
|
password: string | null;
|
||||||
|
requestId: string | null;
|
||||||
|
deviceId: string | null;
|
||||||
|
onLog: AdapterExecutionContext["onLog"];
|
||||||
|
}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> {
|
||||||
|
if (!params.authToken && !params.password) {
|
||||||
|
return { ok: false, reason: "shared auth token/password is missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]);
|
||||||
|
const client = new GatewayWsClient({
|
||||||
|
url: params.url,
|
||||||
|
headers: params.headers,
|
||||||
|
onEvent: () => {},
|
||||||
|
onLog: params.onLog,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await params.onLog(
|
||||||
|
"stdout",
|
||||||
|
"[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.connect(
|
||||||
|
() => ({
|
||||||
|
minProtocol: PROTOCOL_VERSION,
|
||||||
|
maxProtocol: PROTOCOL_VERSION,
|
||||||
|
client: {
|
||||||
|
id: params.clientId,
|
||||||
|
version: params.clientVersion,
|
||||||
|
platform: process.platform,
|
||||||
|
mode: params.clientMode,
|
||||||
|
},
|
||||||
|
role: params.role,
|
||||||
|
scopes: approvalScopes,
|
||||||
|
auth: {
|
||||||
|
...(params.authToken ? { token: params.authToken } : {}),
|
||||||
|
...(params.password ? { password: params.password } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
params.connectTimeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
let requestId = params.requestId;
|
||||||
|
if (!requestId) {
|
||||||
|
const listPayload = await client.request<Record<string, unknown>>("device.pair.list", {}, {
|
||||||
|
timeoutMs: params.connectTimeoutMs,
|
||||||
|
});
|
||||||
|
const pending = Array.isArray(listPayload.pending) ? listPayload.pending : [];
|
||||||
|
const pendingRecords = pending
|
||||||
|
.map((entry) => asRecord(entry))
|
||||||
|
.filter((entry): entry is Record<string, unknown> => Boolean(entry));
|
||||||
|
const matching =
|
||||||
|
(params.deviceId
|
||||||
|
? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId)
|
||||||
|
: null) ?? pendingRecords[pendingRecords.length - 1];
|
||||||
|
requestId = nonEmpty(matching?.requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
return { ok: false, reason: "no pending device pairing request found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.request(
|
||||||
|
"device.pair.approve",
|
||||||
|
{ requestId },
|
||||||
|
{
|
||||||
|
timeoutMs: params.connectTimeoutMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ok: true, requestId };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,63 +942,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
agentParams.timeout = waitTimeoutMs;
|
agentParams.timeout = waitTimeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackedRunIds = new Set<string>([ctx.runId]);
|
|
||||||
const assistantChunks: string[] = [];
|
|
||||||
let lifecycleError: string | null = null;
|
|
||||||
let latestResultPayload: unknown = null;
|
|
||||||
|
|
||||||
const onEvent = async (frame: GatewayEventFrame) => {
|
|
||||||
if (frame.event !== "agent") {
|
|
||||||
if (frame.event === "shutdown") {
|
|
||||||
await ctx.onLog("stdout", `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = asRecord(frame.payload);
|
|
||||||
if (!payload) return;
|
|
||||||
|
|
||||||
const runId = nonEmpty(payload.runId);
|
|
||||||
if (!runId || !trackedRunIds.has(runId)) return;
|
|
||||||
|
|
||||||
const stream = nonEmpty(payload.stream) ?? "unknown";
|
|
||||||
const data = asRecord(payload.data) ?? {};
|
|
||||||
await ctx.onLog(
|
|
||||||
"stdout",
|
|
||||||
`[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (stream === "assistant") {
|
|
||||||
const delta = nonEmpty(data.delta);
|
|
||||||
const text = nonEmpty(data.text);
|
|
||||||
if (delta) {
|
|
||||||
assistantChunks.push(delta);
|
|
||||||
} else if (text) {
|
|
||||||
assistantChunks.push(text);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream === "error") {
|
|
||||||
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream === "lifecycle") {
|
|
||||||
const phase = nonEmpty(data.phase)?.toLowerCase();
|
|
||||||
if (phase === "error" || phase === "failed" || phase === "cancelled") {
|
|
||||||
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const client = new GatewayWsClient({
|
|
||||||
url: parsedUrl.toString(),
|
|
||||||
headers,
|
|
||||||
onEvent,
|
|
||||||
onLog: ctx.onLog,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ctx.onMeta) {
|
if (ctx.onMeta) {
|
||||||
await ctx.onMeta({
|
await ctx.onMeta({
|
||||||
adapterType: "openclaw_gateway",
|
adapterType: "openclaw_gateway",
|
||||||
@@ -913,198 +974,305 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true);
|
||||||
const deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
let autoPairAttempted = false;
|
||||||
if (deviceIdentity) {
|
let latestResultPayload: unknown = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const trackedRunIds = new Set<string>([ctx.runId]);
|
||||||
|
const assistantChunks: string[] = [];
|
||||||
|
let lifecycleError: string | null = null;
|
||||||
|
let deviceIdentity: GatewayDeviceIdentity | null = null;
|
||||||
|
|
||||||
|
const onEvent = async (frame: GatewayEventFrame) => {
|
||||||
|
if (frame.event !== "agent") {
|
||||||
|
if (frame.event === "shutdown") {
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(frame.payload);
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
|
const runId = nonEmpty(payload.runId);
|
||||||
|
if (!runId || !trackedRunIds.has(runId)) return;
|
||||||
|
|
||||||
|
const stream = nonEmpty(payload.stream) ?? "unknown";
|
||||||
|
const data = asRecord(payload.data) ?? {};
|
||||||
await ctx.onLog(
|
await ctx.onLog(
|
||||||
"stdout",
|
"stdout",
|
||||||
`[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`,
|
`[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
|
if (stream === "assistant") {
|
||||||
|
const delta = nonEmpty(data.delta);
|
||||||
const hello = await client.connect((nonce) => {
|
const text = nonEmpty(data.text);
|
||||||
const signedAtMs = Date.now();
|
if (delta) {
|
||||||
const connectParams: Record<string, unknown> = {
|
assistantChunks.push(delta);
|
||||||
minProtocol: PROTOCOL_VERSION,
|
} else if (text) {
|
||||||
maxProtocol: PROTOCOL_VERSION,
|
assistantChunks.push(text);
|
||||||
client: {
|
}
|
||||||
id: clientId,
|
return;
|
||||||
version: clientVersion,
|
|
||||||
platform: process.platform,
|
|
||||||
...(deviceFamily ? { deviceFamily } : {}),
|
|
||||||
mode: clientMode,
|
|
||||||
},
|
|
||||||
role,
|
|
||||||
scopes,
|
|
||||||
auth:
|
|
||||||
authToken || password || deviceToken
|
|
||||||
? {
|
|
||||||
...(authToken ? { token: authToken } : {}),
|
|
||||||
...(deviceToken ? { deviceToken } : {}),
|
|
||||||
...(password ? { password } : {}),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (deviceIdentity) {
|
|
||||||
const payload = buildDeviceAuthPayloadV3({
|
|
||||||
deviceId: deviceIdentity.deviceId,
|
|
||||||
clientId,
|
|
||||||
clientMode,
|
|
||||||
role,
|
|
||||||
scopes,
|
|
||||||
signedAtMs,
|
|
||||||
token: authToken,
|
|
||||||
nonce,
|
|
||||||
platform: process.platform,
|
|
||||||
deviceFamily,
|
|
||||||
});
|
|
||||||
connectParams.device = {
|
|
||||||
id: deviceIdentity.deviceId,
|
|
||||||
publicKey: deviceIdentity.publicKeyRawBase64Url,
|
|
||||||
signature: signDevicePayload(deviceIdentity.privateKeyPem, payload),
|
|
||||||
signedAt: signedAtMs,
|
|
||||||
nonce,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return connectParams;
|
|
||||||
}, connectTimeoutMs);
|
|
||||||
|
|
||||||
await ctx.onLog(
|
if (stream === "error") {
|
||||||
"stdout",
|
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
||||||
`[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`,
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
const acceptedPayload = await client.request<Record<string, unknown>>("agent", agentParams, {
|
if (stream === "lifecycle") {
|
||||||
timeoutMs: connectTimeoutMs,
|
const phase = nonEmpty(data.phase)?.toLowerCase();
|
||||||
|
if (phase === "error" || phase === "failed" || phase === "cancelled") {
|
||||||
|
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new GatewayWsClient({
|
||||||
|
url: parsedUrl.toString(),
|
||||||
|
headers,
|
||||||
|
onEvent,
|
||||||
|
onLog: ctx.onLog,
|
||||||
});
|
});
|
||||||
|
|
||||||
latestResultPayload = acceptedPayload;
|
try {
|
||||||
|
deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
||||||
|
if (deviceIdentity) {
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n");
|
||||||
|
}
|
||||||
|
|
||||||
const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? "";
|
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
|
||||||
const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId;
|
|
||||||
trackedRunIds.add(acceptedRunId);
|
|
||||||
|
|
||||||
await ctx.onLog(
|
const hello = await client.connect((nonce) => {
|
||||||
"stdout",
|
const signedAtMs = Date.now();
|
||||||
`[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`,
|
const connectParams: Record<string, unknown> = {
|
||||||
);
|
minProtocol: PROTOCOL_VERSION,
|
||||||
|
maxProtocol: PROTOCOL_VERSION,
|
||||||
|
client: {
|
||||||
|
id: clientId,
|
||||||
|
version: clientVersion,
|
||||||
|
platform: process.platform,
|
||||||
|
...(deviceFamily ? { deviceFamily } : {}),
|
||||||
|
mode: clientMode,
|
||||||
|
},
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
auth:
|
||||||
|
authToken || password || deviceToken
|
||||||
|
? {
|
||||||
|
...(authToken ? { token: authToken } : {}),
|
||||||
|
...(deviceToken ? { deviceToken } : {}),
|
||||||
|
...(password ? { password } : {}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deviceIdentity) {
|
||||||
|
const payload = buildDeviceAuthPayloadV3({
|
||||||
|
deviceId: deviceIdentity.deviceId,
|
||||||
|
clientId,
|
||||||
|
clientMode,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
signedAtMs,
|
||||||
|
token: authToken,
|
||||||
|
nonce,
|
||||||
|
platform: process.platform,
|
||||||
|
deviceFamily,
|
||||||
|
});
|
||||||
|
connectParams.device = {
|
||||||
|
id: deviceIdentity.deviceId,
|
||||||
|
publicKey: deviceIdentity.publicKeyRawBase64Url,
|
||||||
|
signature: signDevicePayload(deviceIdentity.privateKeyPem, payload),
|
||||||
|
signedAt: signedAtMs,
|
||||||
|
nonce,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return connectParams;
|
||||||
|
}, connectTimeoutMs);
|
||||||
|
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptedPayload = await client.request<Record<string, unknown>>("agent", agentParams, {
|
||||||
|
timeoutMs: connectTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
latestResultPayload = acceptedPayload;
|
||||||
|
|
||||||
|
const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? "";
|
||||||
|
const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId;
|
||||||
|
trackedRunIds.add(acceptedRunId);
|
||||||
|
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (acceptedStatus === "error") {
|
||||||
|
const errorMessage =
|
||||||
|
nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage,
|
||||||
|
errorCode: "openclaw_gateway_agent_error",
|
||||||
|
resultJson: acceptedPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acceptedStatus !== "ok") {
|
||||||
|
const waitPayload = await client.request<Record<string, unknown>>(
|
||||||
|
"agent.wait",
|
||||||
|
{ runId: acceptedRunId, timeoutMs: waitTimeoutMs },
|
||||||
|
{ timeoutMs: waitTimeoutMs + connectTimeoutMs },
|
||||||
|
);
|
||||||
|
|
||||||
|
latestResultPayload = waitPayload;
|
||||||
|
|
||||||
|
const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? "";
|
||||||
|
if (waitStatus === "timeout") {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
signal: null,
|
||||||
|
timedOut: true,
|
||||||
|
errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`,
|
||||||
|
errorCode: "openclaw_gateway_wait_timeout",
|
||||||
|
resultJson: waitPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waitStatus === "error") {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage:
|
||||||
|
nonEmpty(waitPayload?.error) ??
|
||||||
|
lifecycleError ??
|
||||||
|
"OpenClaw gateway run failed",
|
||||||
|
errorCode: "openclaw_gateway_wait_error",
|
||||||
|
resultJson: waitPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waitStatus && waitStatus !== "ok") {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`,
|
||||||
|
errorCode: "openclaw_gateway_wait_status_unexpected",
|
||||||
|
resultJson: waitPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryFromEvents = assistantChunks.join("").trim();
|
||||||
|
const summaryFromPayload =
|
||||||
|
extractResultText(asRecord(acceptedPayload?.result)) ??
|
||||||
|
extractResultText(acceptedPayload) ??
|
||||||
|
extractResultText(asRecord(latestResultPayload)) ??
|
||||||
|
null;
|
||||||
|
const summary = summaryFromEvents || summaryFromPayload || null;
|
||||||
|
|
||||||
|
const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta);
|
||||||
|
const agentMeta = asRecord(meta?.agentMeta);
|
||||||
|
const usage = parseUsage(agentMeta?.usage ?? meta?.usage);
|
||||||
|
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw";
|
||||||
|
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
|
||||||
|
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
|
||||||
|
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
provider,
|
||||||
|
...(model ? { model } : {}),
|
||||||
|
...(usage ? { usage } : {}),
|
||||||
|
...(costUsd > 0 ? { costUsd } : {}),
|
||||||
|
resultJson: asRecord(latestResultPayload),
|
||||||
|
...(summary ? { summary } : {}),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
const timedOut = lower.includes("timeout");
|
||||||
|
const pairingRequired = lower.includes("pairing required");
|
||||||
|
|
||||||
|
if (
|
||||||
|
pairingRequired &&
|
||||||
|
!disableDeviceAuth &&
|
||||||
|
autoPairOnFirstConnect &&
|
||||||
|
!autoPairAttempted &&
|
||||||
|
(authToken || password)
|
||||||
|
) {
|
||||||
|
autoPairAttempted = true;
|
||||||
|
const pairResult = await autoApproveDevicePairing({
|
||||||
|
url: parsedUrl.toString(),
|
||||||
|
headers,
|
||||||
|
connectTimeoutMs,
|
||||||
|
clientId,
|
||||||
|
clientMode,
|
||||||
|
clientVersion,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
authToken,
|
||||||
|
password,
|
||||||
|
requestId: extractPairingRequestId(err),
|
||||||
|
deviceId: deviceIdentity?.deviceId ?? null,
|
||||||
|
onLog: ctx.onLog,
|
||||||
|
});
|
||||||
|
if (pairResult.ok) {
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await ctx.onLog(
|
||||||
|
"stderr",
|
||||||
|
`[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailedMessage = pairingRequired
|
||||||
|
? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url <gateway-ws-url> --token <gateway-token>) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.`
|
||||||
|
: message;
|
||||||
|
|
||||||
|
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
|
||||||
|
|
||||||
if (acceptedStatus === "error") {
|
|
||||||
const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
|
||||||
return {
|
return {
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
signal: null,
|
signal: null,
|
||||||
timedOut: false,
|
timedOut,
|
||||||
errorMessage,
|
errorMessage: detailedMessage,
|
||||||
errorCode: "openclaw_gateway_agent_error",
|
errorCode: timedOut
|
||||||
resultJson: acceptedPayload,
|
? "openclaw_gateway_timeout"
|
||||||
|
: pairingRequired
|
||||||
|
? "openclaw_gateway_pairing_required"
|
||||||
|
: "openclaw_gateway_request_failed",
|
||||||
|
resultJson: asRecord(latestResultPayload),
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (acceptedStatus !== "ok") {
|
|
||||||
const waitPayload = await client.request<Record<string, unknown>>(
|
|
||||||
"agent.wait",
|
|
||||||
{ runId: acceptedRunId, timeoutMs: waitTimeoutMs },
|
|
||||||
{ timeoutMs: waitTimeoutMs + connectTimeoutMs },
|
|
||||||
);
|
|
||||||
|
|
||||||
latestResultPayload = waitPayload;
|
|
||||||
|
|
||||||
const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? "";
|
|
||||||
if (waitStatus === "timeout") {
|
|
||||||
return {
|
|
||||||
exitCode: 1,
|
|
||||||
signal: null,
|
|
||||||
timedOut: true,
|
|
||||||
errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`,
|
|
||||||
errorCode: "openclaw_gateway_wait_timeout",
|
|
||||||
resultJson: waitPayload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (waitStatus === "error") {
|
|
||||||
return {
|
|
||||||
exitCode: 1,
|
|
||||||
signal: null,
|
|
||||||
timedOut: false,
|
|
||||||
errorMessage:
|
|
||||||
nonEmpty(waitPayload?.error) ??
|
|
||||||
lifecycleError ??
|
|
||||||
"OpenClaw gateway run failed",
|
|
||||||
errorCode: "openclaw_gateway_wait_error",
|
|
||||||
resultJson: waitPayload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (waitStatus && waitStatus !== "ok") {
|
|
||||||
return {
|
|
||||||
exitCode: 1,
|
|
||||||
signal: null,
|
|
||||||
timedOut: false,
|
|
||||||
errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`,
|
|
||||||
errorCode: "openclaw_gateway_wait_status_unexpected",
|
|
||||||
resultJson: waitPayload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const summaryFromEvents = assistantChunks.join("").trim();
|
|
||||||
const summaryFromPayload =
|
|
||||||
extractResultText(asRecord(acceptedPayload?.result)) ??
|
|
||||||
extractResultText(acceptedPayload) ??
|
|
||||||
extractResultText(asRecord(latestResultPayload)) ??
|
|
||||||
null;
|
|
||||||
const summary = summaryFromEvents || summaryFromPayload || null;
|
|
||||||
|
|
||||||
const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta);
|
|
||||||
const agentMeta = asRecord(meta?.agentMeta);
|
|
||||||
const usage = parseUsage(agentMeta?.usage ?? meta?.usage);
|
|
||||||
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw";
|
|
||||||
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
|
|
||||||
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
|
|
||||||
|
|
||||||
await ctx.onLog("stdout", `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
timedOut: false,
|
|
||||||
provider,
|
|
||||||
...(model ? { model } : {}),
|
|
||||||
...(usage ? { usage } : {}),
|
|
||||||
...(costUsd > 0 ? { costUsd } : {}),
|
|
||||||
resultJson: asRecord(latestResultPayload),
|
|
||||||
...(summary ? { summary } : {}),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
const lower = message.toLowerCase();
|
|
||||||
const timedOut = lower.includes("timeout");
|
|
||||||
const pairingRequired = lower.includes("pairing required");
|
|
||||||
const detailedMessage = pairingRequired
|
|
||||||
? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url <gateway-ws-url> --token <gateway-token>) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.`
|
|
||||||
: message;
|
|
||||||
|
|
||||||
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
exitCode: 1,
|
|
||||||
signal: null,
|
|
||||||
timedOut,
|
|
||||||
errorMessage: detailedMessage,
|
|
||||||
errorCode: timedOut
|
|
||||||
? "openclaw_gateway_timeout"
|
|
||||||
: pairingRequired
|
|
||||||
? "openclaw_gateway_pairing_required"
|
|
||||||
: "openclaw_gateway_request_failed",
|
|
||||||
resultJson: asRecord(latestResultPayload),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
client.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,208 @@ async function createMockGatewayServer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createMockGatewayServerWithPairing() {
|
||||||
|
const server = createServer();
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
let agentPayload: Record<string, unknown> | null = null;
|
||||||
|
let approved = false;
|
||||||
|
let pendingRequestId = "req-1";
|
||||||
|
let lastSeenDeviceId: string | null = null;
|
||||||
|
|
||||||
|
wss.on("connection", (socket) => {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "event",
|
||||||
|
event: "connect.challenge",
|
||||||
|
payload: { nonce: "nonce-123" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on("message", (raw) => {
|
||||||
|
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
||||||
|
const frame = JSON.parse(text) as {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (frame.type !== "req") return;
|
||||||
|
|
||||||
|
if (frame.method === "connect") {
|
||||||
|
const device = frame.params?.device as Record<string, unknown> | undefined;
|
||||||
|
const deviceId = typeof device?.id === "string" ? device.id : null;
|
||||||
|
if (deviceId) {
|
||||||
|
lastSeenDeviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceId && !approved) {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "NOT_PAIRED",
|
||||||
|
message: "pairing required",
|
||||||
|
details: {
|
||||||
|
code: "PAIRING_REQUIRED",
|
||||||
|
requestId: pendingRequestId,
|
||||||
|
reason: "not-paired",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
socket.close(1008, "pairing required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
type: "hello-ok",
|
||||||
|
protocol: 3,
|
||||||
|
server: { version: "test", connId: "conn-1" },
|
||||||
|
features: {
|
||||||
|
methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"],
|
||||||
|
events: ["agent"],
|
||||||
|
},
|
||||||
|
snapshot: { version: 1, ts: Date.now() },
|
||||||
|
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.method === "device.pair.list") {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
pending: approved
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
requestId: pendingRequestId,
|
||||||
|
deviceId: lastSeenDeviceId ?? "device-unknown",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.method === "device.pair.approve") {
|
||||||
|
const requestId = frame.params?.requestId;
|
||||||
|
if (requestId !== pendingRequestId) {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "INVALID_REQUEST", message: "unknown requestId" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
approved = true;
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
requestId: pendingRequestId,
|
||||||
|
device: {
|
||||||
|
deviceId: lastSeenDeviceId ?? "device-unknown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.method === "agent") {
|
||||||
|
agentPayload = frame.params ?? null;
|
||||||
|
const runId =
|
||||||
|
typeof frame.params?.idempotencyKey === "string"
|
||||||
|
? frame.params.idempotencyKey
|
||||||
|
: "run-123";
|
||||||
|
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
runId,
|
||||||
|
status: "accepted",
|
||||||
|
acceptedAt: Date.now(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "event",
|
||||||
|
event: "agent",
|
||||||
|
payload: {
|
||||||
|
runId,
|
||||||
|
seq: 1,
|
||||||
|
stream: "assistant",
|
||||||
|
ts: Date.now(),
|
||||||
|
data: { delta: "ok" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.method === "agent.wait") {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
runId: frame.params?.runId,
|
||||||
|
status: "ok",
|
||||||
|
startedAt: 1,
|
||||||
|
endedAt: 2,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Failed to resolve test server address");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `ws://127.0.0.1:${address.port}`,
|
||||||
|
getAgentPayload: () => agentPayload,
|
||||||
|
close: async () => {
|
||||||
|
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// no global mocks
|
// no global mocks
|
||||||
});
|
});
|
||||||
@@ -238,6 +440,43 @@ describe("openclaw gateway adapter execute", () => {
|
|||||||
expect(result.exitCode).toBe(1);
|
expect(result.exitCode).toBe(1);
|
||||||
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-approves pairing once and retries the run", async () => {
|
||||||
|
const gateway = await createMockGatewayServerWithPairing();
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execute(
|
||||||
|
buildContext(
|
||||||
|
{
|
||||||
|
url: gateway.url,
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-token": "gateway-token",
|
||||||
|
},
|
||||||
|
payloadTemplate: {
|
||||||
|
message: "wake now",
|
||||||
|
},
|
||||||
|
waitTimeoutMs: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onLog: async (_stream, chunk) => {
|
||||||
|
logs.push(chunk);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.summary).toContain("ok");
|
||||||
|
expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true);
|
||||||
|
expect(gateway.getAgentPayload()).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
await gateway.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("openclaw gateway testEnvironment", () => {
|
describe("openclaw gateway testEnvironment", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user