openclaw gateway: auto-approve first pairing and retry

This commit is contained in:
Dotta
2026-03-07 17:46:55 -06:00
parent 3479ea6e80
commit 2223afa0e9
6 changed files with 648 additions and 236 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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>`

View File

@@ -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:

View File

@@ -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();
}
}
}

View File

@@ -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", () => {