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`.
|
||||
|
||||
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.
|
||||
- Approve it in OpenClaw, then retry the task.
|
||||
- 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 `devicePrivateKeyPem` to pin a stable signing key
|
||||
- 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
|
||||
|
||||
|
||||
@@ -266,7 +266,9 @@ POST /api/companies/$CLA_COMPANY_ID/invites
|
||||
- pairing mode is explicit:
|
||||
- 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
|
||||
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.
|
||||
- Local docker automation path:
|
||||
- `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
|
||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||
- 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
|
||||
|
||||
Session routing fields:
|
||||
|
||||
@@ -57,6 +57,11 @@ type PendingRequest = {
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type GatewayResponseError = Error & {
|
||||
gatewayCode?: string;
|
||||
gatewayDetails?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type GatewayClientOptions = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
@@ -164,6 +169,10 @@ function normalizeScopes(value: unknown): string[] {
|
||||
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 {
|
||||
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
||||
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());
|
||||
}
|
||||
|
||||
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 {
|
||||
const trimmed = rawToken.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
@@ -691,7 +715,101 @@ class GatewayWsClient {
|
||||
nonEmpty(errorRecord?.message) ??
|
||||
nonEmpty(errorRecord?.code) ??
|
||||
"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,15 +942,55 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agentParams.timeout = waitTimeoutMs;
|
||||
}
|
||||
|
||||
if (ctx.onMeta) {
|
||||
await ctx.onMeta({
|
||||
adapterType: "openclaw_gateway",
|
||||
command: "gateway",
|
||||
commandArgs: ["ws", parsedUrl.toString(), "agent"],
|
||||
context: ctx.context,
|
||||
});
|
||||
}
|
||||
|
||||
const outboundHeaderKeys = Object.keys(headers).sort();
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
|
||||
);
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`,
|
||||
);
|
||||
await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
||||
if (transportHint) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`,
|
||||
);
|
||||
}
|
||||
if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
"[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n",
|
||||
);
|
||||
}
|
||||
|
||||
const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true);
|
||||
let autoPairAttempted = false;
|
||||
let latestResultPayload: unknown = null;
|
||||
|
||||
while (true) {
|
||||
const trackedRunIds = new Set<string>([ctx.runId]);
|
||||
const assistantChunks: string[] = [];
|
||||
let lifecycleError: string | null = null;
|
||||
let latestResultPayload: unknown = 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`);
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -881,40 +1039,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
|
||||
if (ctx.onMeta) {
|
||||
await ctx.onMeta({
|
||||
adapterType: "openclaw_gateway",
|
||||
command: "gateway",
|
||||
commandArgs: ["ws", parsedUrl.toString(), "agent"],
|
||||
context: ctx.context,
|
||||
});
|
||||
}
|
||||
|
||||
const outboundHeaderKeys = Object.keys(headers).sort();
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
|
||||
);
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`,
|
||||
);
|
||||
await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
||||
if (transportHint) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`,
|
||||
);
|
||||
}
|
||||
if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
"[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
||||
deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
||||
if (deviceIdentity) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
@@ -995,7 +1121,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
);
|
||||
|
||||
if (acceptedStatus === "error") {
|
||||
const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
||||
const errorMessage =
|
||||
nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
@@ -1068,7 +1195,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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`);
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -1086,6 +1216,43 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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;
|
||||
@@ -1108,3 +1275,4 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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(() => {
|
||||
// no global mocks
|
||||
});
|
||||
@@ -238,6 +440,43 @@ describe("openclaw gateway adapter execute", () => {
|
||||
expect(result.exitCode).toBe(1);
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user