Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,779 @@
#!/usr/bin/env bun
// Manual ACP thread smoke for plain-language routing.
// Keep this script available for regression/debug validation. Do not delete.
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
type ThreadBindingRecord = {
accountId?: string;
channelId?: string;
threadId?: string;
targetKind?: string;
targetSessionKey?: string;
agentId?: string;
boundBy?: string;
boundAt?: number;
};
type ThreadBindingsPayload = {
version?: number;
bindings?: Record<string, ThreadBindingRecord>;
};
type DiscordMessage = {
id: string;
content?: string;
timestamp?: string;
author?: {
id?: string;
username?: string;
bot?: boolean;
};
};
type DiscordUser = {
id: string;
username: string;
bot?: boolean;
};
type DriverMode = "token" | "webhook";
type Args = {
channelId: string;
driverMode: DriverMode;
driverToken: string;
driverTokenPrefix: string;
botToken: string;
botTokenPrefix: string;
targetAgent: string;
timeoutMs: number;
pollMs: number;
mentionUserId?: string;
instruction?: string;
threadBindingsPath: string;
json: boolean;
};
type SuccessResult = {
ok: true;
smokeId: string;
ackToken: string;
sentMessageId: string;
binding: {
threadId: string;
targetSessionKey: string;
targetKind: string;
agentId: string;
boundAt: number;
accountId?: string;
channelId?: string;
};
ackMessage: {
id: string;
authorId?: string;
authorUsername?: string;
timestamp?: string;
content?: string;
};
};
type FailureResult = {
ok: false;
smokeId: string;
stage: "validation" | "send-message" | "wait-binding" | "wait-ack" | "discord-api" | "unexpected";
error: string;
diagnostics?: {
parentChannelRecent?: Array<{
id: string;
author?: string;
bot?: boolean;
content?: string;
}>;
bindingCandidates?: Array<{
threadId: string;
targetSessionKey: string;
targetKind?: string;
agentId?: string;
boundAt?: number;
}>;
};
};
const DISCORD_API_BASE = "https://discord.com/api/v10";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseNumber(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function resolveStateDir(): string {
const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
if (override) {
return override.startsWith("~")
? path.resolve(process.env.HOME || "", override.slice(1))
: path.resolve(override);
}
const home = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || "";
return path.join(home, ".openclaw");
}
function resolveArg(flag: string): string | undefined {
const argv = process.argv.slice(2);
const eq = argv.find((entry) => entry.startsWith(`${flag}=`));
if (eq) {
return eq.slice(flag.length + 1);
}
const idx = argv.indexOf(flag);
if (idx >= 0 && idx + 1 < argv.length) {
return argv[idx + 1];
}
return undefined;
}
function hasFlag(flag: string): boolean {
return process.argv.slice(2).includes(flag);
}
function usage(): string {
return (
"Usage: bun scripts/dev/discord-acp-plain-language-smoke.ts " +
"--channel <discord-channel-id> [--token <driver-token> | --driver webhook --bot-token <bot-token>] [options]\n\n" +
"Manual live smoke only (not CI). Sends a plain-language instruction in Discord and verifies:\n" +
"1) OpenClaw spawned an ACP thread binding\n" +
"2) agent replied in that bound thread with the expected ACK token\n\n" +
"Options:\n" +
" --channel <id> Parent Discord channel id (required)\n" +
" --driver <token|webhook> Driver transport mode (default: token)\n" +
" --token <token> Driver Discord token (required for driver=token)\n" +
" --token-prefix <prefix> Auth prefix for --token (default: Bot)\n" +
" --bot-token <token> Bot token for webhook driver mode\n" +
" --bot-token-prefix <prefix> Auth prefix for --bot-token (default: Bot)\n" +
" --agent <id> Expected ACP agent id (default: codex)\n" +
" --mention <user-id> Mention this user in the instruction (optional)\n" +
" --instruction <text> Custom instruction template (optional)\n" +
" --timeout-ms <n> Total timeout in ms (default: 240000)\n" +
" --poll-ms <n> Poll interval in ms (default: 1500)\n" +
" --thread-bindings-path <p> Override thread-bindings json path\n" +
" --json Emit JSON output\n" +
"\n" +
"Environment fallbacks:\n" +
" OPENCLAW_DISCORD_SMOKE_CHANNEL_ID\n" +
" OPENCLAW_DISCORD_SMOKE_DRIVER\n" +
" OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN\n" +
" OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX\n" +
" OPENCLAW_DISCORD_SMOKE_BOT_TOKEN\n" +
" OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX\n" +
" OPENCLAW_DISCORD_SMOKE_AGENT\n" +
" OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID\n" +
" OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS\n" +
" OPENCLAW_DISCORD_SMOKE_POLL_MS\n" +
" OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH"
);
}
function parseArgs(): Args {
const channelId =
resolveArg("--channel") ||
process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID ||
process.env.CLAWDBOT_DISCORD_SMOKE_CHANNEL_ID ||
"";
const driverModeRaw =
resolveArg("--driver") ||
process.env.OPENCLAW_DISCORD_SMOKE_DRIVER ||
process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER ||
"token";
const normalizedDriverMode = driverModeRaw.trim().toLowerCase();
const driverMode: DriverMode =
normalizedDriverMode === "webhook"
? "webhook"
: normalizedDriverMode === "token"
? "token"
: "token";
const driverToken =
resolveArg("--token") ||
process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN ||
process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER_TOKEN ||
"";
const driverTokenPrefix =
resolveArg("--token-prefix") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX || "Bot";
const botToken =
resolveArg("--bot-token") ||
process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN ||
process.env.CLAWDBOT_DISCORD_SMOKE_BOT_TOKEN ||
process.env.DISCORD_BOT_TOKEN ||
"";
const botTokenPrefix =
resolveArg("--bot-token-prefix") ||
process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX ||
"Bot";
const targetAgent =
resolveArg("--agent") ||
process.env.OPENCLAW_DISCORD_SMOKE_AGENT ||
process.env.CLAWDBOT_DISCORD_SMOKE_AGENT ||
"codex";
const mentionUserId =
resolveArg("--mention") ||
process.env.OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID ||
process.env.CLAWDBOT_DISCORD_SMOKE_MENTION_USER_ID ||
undefined;
const instruction =
resolveArg("--instruction") ||
process.env.OPENCLAW_DISCORD_SMOKE_INSTRUCTION ||
process.env.CLAWDBOT_DISCORD_SMOKE_INSTRUCTION ||
undefined;
const timeoutMs = parseNumber(
resolveArg("--timeout-ms") || process.env.OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS,
240_000,
);
const pollMs = parseNumber(
resolveArg("--poll-ms") || process.env.OPENCLAW_DISCORD_SMOKE_POLL_MS,
1_500,
);
const defaultBindingsPath = path.join(resolveStateDir(), "discord", "thread-bindings.json");
const threadBindingsPath =
resolveArg("--thread-bindings-path") ||
process.env.OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH ||
defaultBindingsPath;
const json = hasFlag("--json");
if (!channelId) {
throw new Error(usage());
}
if (driverMode === "token" && !driverToken) {
throw new Error(usage());
}
if (driverMode === "webhook" && !botToken) {
throw new Error(usage());
}
return {
channelId,
driverMode,
driverToken,
driverTokenPrefix,
botToken,
botTokenPrefix,
targetAgent,
timeoutMs,
pollMs,
mentionUserId,
instruction,
threadBindingsPath,
json,
};
}
function resolveAuthorizationHeader(params: { token: string; tokenPrefix: string }): string {
const token = params.token.trim();
if (!token) {
throw new Error("Missing Discord driver token.");
}
if (token.includes(" ")) {
return token;
}
return `${params.tokenPrefix.trim() || "Bot"} ${token}`;
}
async function discordApi<T>(params: {
method: "GET" | "POST";
path: string;
authHeader: string;
body?: unknown;
retries?: number;
}): Promise<T> {
const retries = params.retries ?? 6;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const response = await fetch(`${DISCORD_API_BASE}${params.path}`, {
method: params.method,
headers: {
Authorization: params.authHeader,
"Content-Type": "application/json",
},
body: params.body === undefined ? undefined : JSON.stringify(params.body),
});
if (response.status === 429) {
const body = (await response.json().catch(() => ({}))) as { retry_after?: number };
const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1;
await sleep(Math.ceil(waitSeconds * 1000));
continue;
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`Discord API ${params.method} ${params.path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`,
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
throw new Error(`Discord API ${params.method} ${params.path} exceeded retry budget.`);
}
async function discordWebhookApi<T>(params: {
method: "POST" | "DELETE";
webhookId: string;
webhookToken: string;
body?: unknown;
query?: string;
retries?: number;
}): Promise<T> {
const retries = params.retries ?? 6;
const suffix = params.query ? `?${params.query}` : "";
const path = `/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}${suffix}`;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const response = await fetch(`${DISCORD_API_BASE}${path}`, {
method: params.method,
headers: {
"Content-Type": "application/json",
},
body: params.body === undefined ? undefined : JSON.stringify(params.body),
});
if (response.status === 429) {
const body = (await response.json().catch(() => ({}))) as { retry_after?: number };
const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1;
await sleep(Math.ceil(waitSeconds * 1000));
continue;
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`Discord webhook API ${params.method} ${path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`,
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
throw new Error(`Discord webhook API ${params.method} ${path} exceeded retry budget.`);
}
async function readThreadBindings(filePath: string): Promise<ThreadBindingRecord[]> {
const raw = await fs.readFile(filePath, "utf8");
const payload = JSON.parse(raw) as ThreadBindingsPayload;
const entries = Object.values(payload.bindings ?? {});
return entries.filter((entry) => Boolean(entry?.threadId && entry?.targetSessionKey));
}
function normalizeBoundAt(record: ThreadBindingRecord): number {
if (typeof record.boundAt === "number" && Number.isFinite(record.boundAt)) {
return record.boundAt;
}
return 0;
}
function resolveCandidateBindings(params: {
entries: ThreadBindingRecord[];
minBoundAt: number;
targetAgent: string;
}): ThreadBindingRecord[] {
const normalizedTargetAgent = params.targetAgent.trim().toLowerCase();
return params.entries
.filter((entry) => {
const targetKind = String(entry.targetKind || "")
.trim()
.toLowerCase();
if (targetKind !== "acp") {
return false;
}
if (normalizeBoundAt(entry) < params.minBoundAt) {
return false;
}
const agentId = String(entry.agentId || "")
.trim()
.toLowerCase();
if (normalizedTargetAgent && agentId && agentId !== normalizedTargetAgent) {
return false;
}
return true;
})
.toSorted((a, b) => normalizeBoundAt(b) - normalizeBoundAt(a));
}
function buildInstruction(params: {
smokeId: string;
ackToken: string;
targetAgent: string;
mentionUserId?: string;
template?: string;
}): string {
const mentionPrefix = params.mentionUserId?.trim() ? `<@${params.mentionUserId.trim()}> ` : "";
if (params.template?.trim()) {
return mentionPrefix + params.template.trim();
}
return (
mentionPrefix +
`Manual smoke ${params.smokeId}: Please spawn a ${params.targetAgent} ACP coding agent in a thread for this request, keep it persistent, and in that thread reply with exactly "${params.ackToken}" and nothing else.`
);
}
function toRecentMessageRow(message: DiscordMessage) {
return {
id: message.id,
author: message.author?.username || message.author?.id || "unknown",
bot: Boolean(message.author?.bot),
content: (message.content || "").slice(0, 500),
};
}
function printOutput(params: { json: boolean; payload: SuccessResult | FailureResult }) {
if (params.json) {
// eslint-disable-next-line no-console
console.log(JSON.stringify(params.payload, null, 2));
return;
}
if (params.payload.ok) {
const success = params.payload;
// eslint-disable-next-line no-console
console.log("PASS");
// eslint-disable-next-line no-console
console.log(`smokeId: ${success.smokeId}`);
// eslint-disable-next-line no-console
console.log(`sentMessageId: ${success.sentMessageId}`);
// eslint-disable-next-line no-console
console.log(`threadId: ${success.binding.threadId}`);
// eslint-disable-next-line no-console
console.log(`sessionKey: ${success.binding.targetSessionKey}`);
// eslint-disable-next-line no-console
console.log(`ackMessageId: ${success.ackMessage.id}`);
// eslint-disable-next-line no-console
console.log(
`ackAuthor: ${success.ackMessage.authorUsername || success.ackMessage.authorId || "unknown"}`,
);
return;
}
const failure = params.payload;
// eslint-disable-next-line no-console
console.error("FAIL");
// eslint-disable-next-line no-console
console.error(`stage: ${failure.stage}`);
// eslint-disable-next-line no-console
console.error(`smokeId: ${failure.smokeId}`);
// eslint-disable-next-line no-console
console.error(`error: ${failure.error}`);
if (failure.diagnostics?.bindingCandidates?.length) {
// eslint-disable-next-line no-console
console.error("binding candidates:");
for (const candidate of failure.diagnostics.bindingCandidates) {
// eslint-disable-next-line no-console
console.error(
` thread=${candidate.threadId} kind=${candidate.targetKind || "?"} agent=${candidate.agentId || "?"} boundAt=${candidate.boundAt || 0} session=${candidate.targetSessionKey}`,
);
}
}
if (failure.diagnostics?.parentChannelRecent?.length) {
// eslint-disable-next-line no-console
console.error("recent parent channel messages:");
for (const row of failure.diagnostics.parentChannelRecent) {
// eslint-disable-next-line no-console
console.error(` ${row.id} ${row.author}${row.bot ? " [bot]" : ""}: ${row.content || ""}`);
}
}
}
async function run(): Promise<SuccessResult | FailureResult> {
let args: Args;
try {
args = parseArgs();
} catch (err) {
return {
ok: false,
stage: "validation",
smokeId: "n/a",
error: err instanceof Error ? err.message : String(err),
};
}
const smokeId = `acp-smoke-${Date.now()}-${randomUUID().slice(0, 8)}`;
const ackToken = `ACP_SMOKE_ACK_${smokeId}`;
const instruction = buildInstruction({
smokeId,
ackToken,
targetAgent: args.targetAgent,
mentionUserId: args.mentionUserId,
template: args.instruction,
});
let readAuthHeader = "";
let sentMessageId = "";
let setupStage: "discord-api" | "send-message" = "discord-api";
let senderAuthorId: string | undefined;
let webhookForCleanup:
| {
id: string;
token: string;
}
| undefined;
try {
if (args.driverMode === "token") {
const authHeader = resolveAuthorizationHeader({
token: args.driverToken,
tokenPrefix: args.driverTokenPrefix,
});
readAuthHeader = authHeader;
const driverUser = await discordApi<DiscordUser>({
method: "GET",
path: "/users/@me",
authHeader,
});
senderAuthorId = driverUser.id;
setupStage = "send-message";
const sent = await discordApi<DiscordMessage>({
method: "POST",
path: `/channels/${encodeURIComponent(args.channelId)}/messages`,
authHeader,
body: {
content: instruction,
allowed_mentions: args.mentionUserId
? { parse: [], users: [args.mentionUserId] }
: { parse: [] },
},
});
sentMessageId = sent.id;
} else {
const botAuthHeader = resolveAuthorizationHeader({
token: args.botToken,
tokenPrefix: args.botTokenPrefix,
});
readAuthHeader = botAuthHeader;
await discordApi<DiscordUser>({
method: "GET",
path: "/users/@me",
authHeader: botAuthHeader,
});
setupStage = "send-message";
const webhook = await discordApi<{ id: string; token?: string | null }>({
method: "POST",
path: `/channels/${encodeURIComponent(args.channelId)}/webhooks`,
authHeader: botAuthHeader,
body: {
name: `openclaw-acp-smoke-${smokeId.slice(-8)}`,
},
});
if (!webhook.id || !webhook.token) {
return {
ok: false,
stage: "send-message",
smokeId,
error:
"Discord webhook creation succeeded but no webhook token was returned; cannot post smoke message.",
};
}
webhookForCleanup = { id: webhook.id, token: webhook.token };
const sent = await discordWebhookApi<DiscordMessage>({
method: "POST",
webhookId: webhook.id,
webhookToken: webhook.token,
query: "wait=true",
body: {
content: instruction,
allowed_mentions: args.mentionUserId
? { parse: [], users: [args.mentionUserId] }
: { parse: [] },
},
});
sentMessageId = sent.id;
senderAuthorId = sent.author?.id;
}
} catch (err) {
return {
ok: false,
stage: setupStage,
smokeId,
error: err instanceof Error ? err.message : String(err),
};
}
const startedAt = Date.now();
const deadline = startedAt + args.timeoutMs;
let winningBinding: ThreadBindingRecord | undefined;
let latestCandidates: ThreadBindingRecord[] = [];
try {
while (Date.now() < deadline && !winningBinding) {
try {
const entries = await readThreadBindings(args.threadBindingsPath);
latestCandidates = resolveCandidateBindings({
entries,
minBoundAt: startedAt - 3_000,
targetAgent: args.targetAgent,
});
winningBinding = latestCandidates[0];
} catch {
// Keep polling; file may not exist yet or may be mid-write.
}
if (!winningBinding) {
await sleep(args.pollMs);
}
}
if (!winningBinding?.threadId || !winningBinding?.targetSessionKey) {
let parentRecent: DiscordMessage[] = [];
try {
parentRecent = await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`,
authHeader: readAuthHeader,
});
} catch {
// Best effort diagnostics only.
}
return {
ok: false,
stage: "wait-binding",
smokeId,
error: `Timed out waiting for new ACP thread binding (path: ${args.threadBindingsPath}).`,
diagnostics: {
bindingCandidates: latestCandidates.slice(0, 6).map((entry) => ({
threadId: entry.threadId || "",
targetSessionKey: entry.targetSessionKey || "",
targetKind: entry.targetKind,
agentId: entry.agentId,
boundAt: entry.boundAt,
})),
parentChannelRecent: parentRecent.map(toRecentMessageRow),
},
};
}
const threadId = winningBinding.threadId;
let ackMessage: DiscordMessage | undefined;
while (Date.now() < deadline && !ackMessage) {
try {
const threadMessages = await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(threadId)}/messages?limit=50`,
authHeader: readAuthHeader,
});
ackMessage = threadMessages.find((message) => {
const content = message.content || "";
if (!content.includes(ackToken)) {
return false;
}
const authorId = message.author?.id || "";
return !senderAuthorId || authorId !== senderAuthorId;
});
} catch {
// Keep polling; thread can appear before read permissions settle.
}
if (!ackMessage) {
await sleep(args.pollMs);
}
}
if (!ackMessage) {
let parentRecent: DiscordMessage[] = [];
try {
parentRecent = await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`,
authHeader: readAuthHeader,
});
} catch {
// Best effort diagnostics only.
}
return {
ok: false,
stage: "wait-ack",
smokeId,
error: `Thread bound (${threadId}) but timed out waiting for ACK token "${ackToken}" from OpenClaw.`,
diagnostics: {
bindingCandidates: [
{
threadId: winningBinding.threadId || "",
targetSessionKey: winningBinding.targetSessionKey || "",
targetKind: winningBinding.targetKind,
agentId: winningBinding.agentId,
boundAt: winningBinding.boundAt,
},
],
parentChannelRecent: parentRecent.map(toRecentMessageRow),
},
};
}
return {
ok: true,
smokeId,
ackToken,
sentMessageId,
binding: {
threadId,
targetSessionKey: winningBinding.targetSessionKey,
targetKind: String(winningBinding.targetKind || "acp"),
agentId: String(winningBinding.agentId || args.targetAgent),
boundAt: normalizeBoundAt(winningBinding),
accountId: winningBinding.accountId,
channelId: winningBinding.channelId,
},
ackMessage: {
id: ackMessage.id,
authorId: ackMessage.author?.id,
authorUsername: ackMessage.author?.username,
timestamp: ackMessage.timestamp,
content: ackMessage.content,
},
};
} finally {
if (webhookForCleanup) {
await discordWebhookApi<void>({
method: "DELETE",
webhookId: webhookForCleanup.id,
webhookToken: webhookForCleanup.token,
}).catch(() => {
// Best-effort cleanup only.
});
}
}
}
if (hasFlag("--help") || hasFlag("-h")) {
// eslint-disable-next-line no-console
console.log(usage());
process.exit(0);
}
const result = await run().catch(
(err): FailureResult => ({
ok: false,
stage: "unexpected",
smokeId: "n/a",
error: err instanceof Error ? err.message : String(err),
}),
);
printOutput({
json: hasFlag("--json"),
payload: result,
});
process.exit(result.ok ? 0 : 1);

View File

@@ -0,0 +1,75 @@
import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts";
const { get: getArg } = createArgReader();
const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL;
const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN;
if (!urlRaw || !token) {
// eslint-disable-next-line no-console
console.error(
"Usage: bun scripts/dev/gateway-smoke.ts --url <wss://host[:port]> --token <gateway.auth.token>\n" +
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
);
process.exit(1);
}
async function main() {
const url = resolveGatewayUrl(urlRaw);
const { request, waitOpen, close } = createGatewayWsClient({
url: url.toString(),
onEvent: (evt) => {
// Ignore noisy connect handshakes.
if (evt.event === "connect.challenge") {
return;
}
},
});
await waitOpen();
// Match iOS "operator" session defaults: token auth, no device identity.
const connectRes = await request("connect", {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "openclaw-ios",
displayName: "openclaw gateway smoke test",
version: "dev",
platform: "dev",
mode: "ui",
instanceId: "openclaw-dev-smoke",
},
locale: "en-US",
userAgent: "gateway-smoke",
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [],
auth: { token },
});
if (!connectRes.ok) {
// eslint-disable-next-line no-console
console.error("connect failed:", connectRes.error);
process.exit(2);
}
const healthRes = await request("health");
if (!healthRes.ok) {
// eslint-disable-next-line no-console
console.error("health failed:", healthRes.error);
process.exit(3);
}
const historyRes = await request("chat.history", { sessionKey: "main" }, 15000);
if (!historyRes.ok) {
// eslint-disable-next-line no-console
console.error("chat.history failed:", historyRes.error);
process.exit(4);
}
// eslint-disable-next-line no-console
console.log("ok: connected + health + chat.history");
close();
}
await main();

View File

@@ -0,0 +1,132 @@
import { randomUUID } from "node:crypto";
import WebSocket from "ws";
export type GatewayReqFrame = { type: "req"; id: string; method: string; params?: unknown };
export type GatewayResFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: unknown;
};
export type GatewayEventFrame = { type: "event"; event: string; seq?: number; payload?: unknown };
export type GatewayFrame =
| GatewayReqFrame
| GatewayResFrame
| GatewayEventFrame
| { type: string; [key: string]: unknown };
export function createArgReader(argv = process.argv.slice(2)) {
const get = (flag: string) => {
const idx = argv.indexOf(flag);
if (idx !== -1 && idx + 1 < argv.length) {
return argv[idx + 1];
}
return undefined;
};
const has = (flag: string) => argv.includes(flag);
return { argv, get, has };
}
export function resolveGatewayUrl(urlRaw: string): URL {
const url = new URL(urlRaw.includes("://") ? urlRaw : `wss://${urlRaw}`);
if (!url.port) {
url.port = url.protocol === "wss:" ? "443" : "80";
}
return url;
}
function toText(data: WebSocket.RawData): string {
if (typeof data === "string") {
return data;
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8");
}
return Buffer.from(data as Buffer).toString("utf8");
}
export function createGatewayWsClient(params: {
url: string;
handshakeTimeoutMs?: number;
openTimeoutMs?: number;
onEvent?: (evt: GatewayEventFrame) => void;
}) {
const ws = new WebSocket(params.url, { handshakeTimeout: params.handshakeTimeoutMs ?? 8000 });
const pending = new Map<
string,
{
resolve: (res: GatewayResFrame) => void;
reject: (err: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
>();
const request = (method: string, paramsObj?: unknown, timeoutMs = 12_000) =>
new Promise<GatewayResFrame>((resolve, reject) => {
const id = randomUUID();
const frame: GatewayReqFrame = { type: "req", id, method, params: paramsObj };
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(`timeout waiting for ${method}`));
}, timeoutMs);
pending.set(id, { resolve, reject, timeout });
ws.send(JSON.stringify(frame));
});
const waitOpen = () =>
new Promise<void>((resolve, reject) => {
const t = setTimeout(
() => reject(new Error("ws open timeout")),
params.openTimeoutMs ?? 8000,
);
ws.once("open", () => {
clearTimeout(t);
resolve();
});
ws.once("error", (err) => {
clearTimeout(t);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
ws.on("message", (data) => {
const text = toText(data);
let frame: GatewayFrame | null = null;
try {
frame = JSON.parse(text) as GatewayFrame;
} catch {
return;
}
if (!frame || typeof frame !== "object" || !("type" in frame)) {
return;
}
if (frame.type === "res") {
const res = frame as GatewayResFrame;
const waiter = pending.get(res.id);
if (waiter) {
pending.delete(res.id);
clearTimeout(waiter.timeout);
waiter.resolve(res);
}
return;
}
if (frame.type === "event") {
const evt = frame as GatewayEventFrame;
params.onEvent?.(evt);
}
});
const close = () => {
for (const waiter of pending.values()) {
clearTimeout(waiter.timeout);
}
pending.clear();
ws.close();
};
return { ws, request, waitOpen, close };
}

View File

@@ -0,0 +1,283 @@
import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts";
type NodeListPayload = {
ts?: number;
nodes?: Array<{
nodeId: string;
displayName?: string;
platform?: string;
connected?: boolean;
paired?: boolean;
commands?: string[];
permissions?: unknown;
}>;
};
type NodeListNode = NonNullable<NodeListPayload["nodes"]>[number];
const { get: getArg, has: hasFlag } = createArgReader();
const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL;
const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN;
const nodeHint = getArg("--node");
const dangerous = hasFlag("--dangerous") || process.env.OPENCLAW_RUN_DANGEROUS === "1";
const jsonOut = hasFlag("--json");
if (!urlRaw || !token) {
// eslint-disable-next-line no-console
console.error(
"Usage: bun scripts/dev/ios-node-e2e.ts --url <wss://host[:port]> --token <gateway.auth.token> [--node <id|name-substring>] [--dangerous] [--json]\n" +
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
);
process.exit(1);
}
const url = resolveGatewayUrl(urlRaw);
const isoNow = () => new Date().toISOString();
const isoMinusMs = (ms: number) => new Date(Date.now() - ms).toISOString();
type TestCase = {
id: string;
command: string;
params?: unknown;
timeoutMs?: number;
dangerous?: boolean;
};
function formatErr(err: unknown): string {
if (!err) {
return "error";
}
if (typeof err === "string") {
return err;
}
if (err instanceof Error) {
return err.message || String(err);
}
try {
return JSON.stringify(err);
} catch {
return Object.prototype.toString.call(err);
}
}
function pickIosNode(list: NodeListPayload, hint?: string): NodeListNode | null {
const nodes = (list.nodes ?? []).filter((n) => n && n.connected);
const ios = nodes.filter((n) => (n.platform ?? "").toLowerCase().includes("ios"));
if (ios.length === 0) {
return null;
}
if (!hint) {
return ios[0] ?? null;
}
const h = hint.toLowerCase();
return (
ios.find((n) => n.nodeId.toLowerCase() === h) ??
ios.find((n) => (n.displayName ?? "").toLowerCase().includes(h)) ??
ios.find((n) => n.nodeId.toLowerCase().includes(h)) ??
ios[0] ??
null
);
}
async function main() {
const { request, waitOpen, close } = createGatewayWsClient({ url: url.toString() });
await waitOpen();
const connectRes = await request("connect", {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "cli",
displayName: "openclaw ios node e2e",
version: "dev",
platform: "dev",
mode: "cli",
instanceId: "openclaw-dev-ios-node-e2e",
},
locale: "en-US",
userAgent: "ios-node-e2e",
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [],
auth: { token },
});
if (!connectRes.ok) {
// eslint-disable-next-line no-console
console.error("connect failed:", connectRes.error);
close();
process.exit(2);
}
const healthRes = await request("health");
if (!healthRes.ok) {
// eslint-disable-next-line no-console
console.error("health failed:", healthRes.error);
close();
process.exit(3);
}
const nodesRes = await request("node.list");
if (!nodesRes.ok) {
// eslint-disable-next-line no-console
console.error("node.list failed:", nodesRes.error);
close();
process.exit(4);
}
const listPayload = (nodesRes.payload ?? {}) as NodeListPayload;
let node = pickIosNode(listPayload, nodeHint);
if (!node) {
const waitSeconds = Number.parseInt(getArg("--wait-seconds") ?? "25", 10);
const deadline = Date.now() + Math.max(1, waitSeconds) * 1000;
while (!node && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1000));
const res = await request("node.list").catch(() => null);
if (!res?.ok) {
continue;
}
node = pickIosNode((res.payload ?? {}) as NodeListPayload, nodeHint);
}
}
if (!node) {
// eslint-disable-next-line no-console
console.error("No connected iOS nodes found. (Is the iOS app connected to the gateway?)");
close();
process.exit(5);
}
const tests: TestCase[] = [
{ id: "device.info", command: "device.info" },
{ id: "device.status", command: "device.status" },
{
id: "system.notify",
command: "system.notify",
params: { title: "OpenClaw E2E", body: `ios-node-e2e @ ${isoNow()}`, delivery: "system" },
},
{
id: "contacts.search",
command: "contacts.search",
params: { query: null, limit: 5 },
},
{
id: "calendar.events",
command: "calendar.events",
params: { startISO: isoMinusMs(6 * 60 * 60 * 1000), endISO: isoNow(), limit: 10 },
},
{
id: "reminders.list",
command: "reminders.list",
params: { status: "incomplete", limit: 10 },
},
{
id: "motion.pedometer",
command: "motion.pedometer",
params: { startISO: isoMinusMs(60 * 60 * 1000), endISO: isoNow() },
},
{
id: "photos.latest",
command: "photos.latest",
params: { limit: 1, maxWidth: 512, quality: 0.7 },
},
{
id: "camera.snap",
command: "camera.snap",
params: { facing: "back", maxWidth: 768, quality: 0.7, format: "jpeg" },
dangerous: true,
timeoutMs: 20_000,
},
{
id: "screen.record",
command: "screen.record",
params: { durationMs: 2_000, fps: 15, includeAudio: false },
dangerous: true,
timeoutMs: 30_000,
},
];
const run = tests.filter((t) => dangerous || !t.dangerous);
const results: Array<{
id: string;
ok: boolean;
error?: unknown;
payload?: unknown;
}> = [];
for (const t of run) {
const invokeRes = await request(
"node.invoke",
{
nodeId: node.nodeId,
command: t.command,
params: t.params,
timeoutMs: t.timeoutMs ?? 12_000,
idempotencyKey: randomUUID(),
},
(t.timeoutMs ?? 12_000) + 2_000,
).catch((err) => {
results.push({ id: t.id, ok: false, error: formatErr(err) });
return null;
});
if (!invokeRes) {
continue;
}
if (!invokeRes.ok) {
results.push({ id: t.id, ok: false, error: invokeRes.error });
continue;
}
results.push({ id: t.id, ok: true, payload: invokeRes.payload });
}
if (jsonOut) {
// eslint-disable-next-line no-console
console.log(
JSON.stringify(
{
gateway: url.toString(),
node: {
nodeId: node.nodeId,
displayName: node.displayName,
platform: node.platform,
},
dangerous,
results,
},
null,
2,
),
);
} else {
const pad = (s: string, n: number) => (s.length >= n ? s : s + " ".repeat(n - s.length));
const rows = results.map((r) => ({
cmd: r.id,
ok: r.ok ? "ok" : "fail",
note: r.ok ? "" : formatErr(r.error ?? "error"),
}));
const width = Math.min(64, Math.max(12, ...rows.map((r) => r.cmd.length)));
// eslint-disable-next-line no-console
console.log(`node: ${node.displayName ?? node.nodeId} (${node.platform ?? "unknown"})`);
// eslint-disable-next-line no-console
console.log(`dangerous: ${dangerous ? "on" : "off"}`);
// eslint-disable-next-line no-console
console.log("");
for (const r of rows) {
// eslint-disable-next-line no-console
console.log(`${pad(r.cmd, width)} ${pad(r.ok, 4)} ${r.note}`);
}
}
const failed = results.filter((r) => !r.ok);
close();
if (failed.length > 0) {
process.exit(10);
}
}
await main();

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
DEVICE_UDID="${1:-00008130-000630CE0146001C}"
BUNDLE_ID="${2:-ai.openclaw.ios.dev.mariano.test}"
DEST="${3:-/tmp/openclaw-gateway.log}"
xcrun devicectl device copy from \
--device "$DEVICE_UDID" \
--domain-type appDataContainer \
--domain-identifier "$BUNDLE_ID" \
--source Documents/openclaw-gateway.log \
--destination "$DEST" >/dev/null
echo "Pulled to: $DEST"
tail -n 200 "$DEST"

View File

@@ -0,0 +1,62 @@
import { loadConfig } from "../../src/config/config.js";
import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js";
import { loadOpenClawPlugins } from "../../src/plugins/loader.js";
import { sendMessageTelegram } from "../../src/telegram/send.js";
const args = process.argv.slice(2);
const getArg = (flag: string, short?: string) => {
const idx = args.indexOf(flag);
if (idx !== -1 && idx + 1 < args.length) {
return args[idx + 1];
}
if (short) {
const sidx = args.indexOf(short);
if (sidx !== -1 && sidx + 1 < args.length) {
return args[sidx + 1];
}
}
return undefined;
};
const chatId = getArg("--chat", "-c");
const accountId = getArg("--account", "-a");
if (!chatId) {
// eslint-disable-next-line no-console
console.error(
"Usage: bun scripts/dev/test-device-pair-telegram.ts --chat <telegram-chat-id> [--account <accountId>]",
);
process.exit(1);
}
const cfg = loadConfig();
loadOpenClawPlugins({ config: cfg });
const match = matchPluginCommand("/pair");
if (!match) {
// eslint-disable-next-line no-console
console.error("/pair plugin command not registered.");
process.exit(1);
}
const result = await executePluginCommand({
command: match.command,
args: match.args,
senderId: chatId,
channel: "telegram",
channelId: "telegram",
isAuthorizedSender: true,
commandBody: "/pair",
config: cfg,
from: `telegram:${chatId}`,
to: `telegram:${chatId}`,
accountId: accountId,
});
if (result.text) {
await sendMessageTelegram(chatId, result.text, {
accountId: accountId,
});
}
// eslint-disable-next-line no-console
console.log("Sent split /pair messages to", chatId, accountId ? `(${accountId})` : "");