Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,241 @@
// Gateway Smoke script supports OpenClaw repository automation.
import { fileURLToPath } from "node:url";
import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
} from "../../packages/gateway-protocol/src/version.js";
import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts";
function writeStdoutLine(message: string): void {
process.stdout.write(`${message}\n`);
}
function writeStderrLine(message: string): void {
process.stderr.write(`${message}\n`);
}
function writeUsage(): void {
writeStderrLine(usage());
}
type GatewaySmokeClient = ReturnType<typeof createGatewayWsClient>;
type GatewaySmokeCliOptions = {
help: boolean;
token?: string;
urlRaw?: string;
};
type GatewaySmokeDeps = {
createClient?: typeof createGatewayWsClient;
stderr?: (message: string) => void;
stdout?: (message: string) => void;
};
class GatewaySmokeArgError extends Error {}
const BOOLEAN_FLAGS = new Set(["--help", "-h"]);
const VALUE_FLAGS = new Set(["--url", "--token"]);
function usage(): string {
return [
"Usage: bun scripts/dev/gateway-smoke.ts --url <wss://host[:port]> --token <gateway.auth.token>",
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
"",
"Options:",
" --url <url> Gateway websocket URL",
" --token <token> Gateway auth token",
" -h, --help Show this help",
].join("\n");
}
function validateArgs(argv: readonly string[]): void {
const seen = new Set<string>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
if (seen.has(arg)) {
throw new GatewaySmokeArgError(`${arg} was provided more than once`);
}
seen.add(arg);
continue;
}
if (VALUE_FLAGS.has(arg)) {
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new GatewaySmokeArgError(`${arg} requires a value`);
}
if (seen.has(arg)) {
throw new GatewaySmokeArgError(`${arg} was provided more than once`);
}
seen.add(arg);
index += 1;
continue;
}
throw new GatewaySmokeArgError(`Unknown argument: ${arg}`);
}
}
function parseGatewaySmokeCli(
argv = process.argv.slice(2),
env: NodeJS.ProcessEnv = process.env,
): GatewaySmokeCliOptions {
validateArgs(argv);
const { get: getArg, has } = createArgReader([...argv]);
return {
help: has("--help") || has("-h"),
token: getArg("--token") ?? env.OPENCLAW_GATEWAY_TOKEN,
urlRaw: getArg("--url") ?? env.OPENCLAW_GATEWAY_URL,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function hasHealthSummaryPayload(response: unknown): boolean {
if (!isRecord(response) || !isRecord(response.payload)) {
return false;
}
const { payload } = response;
return (
payload.ok === true &&
typeof payload.ts === "number" &&
typeof payload.durationMs === "number" &&
typeof payload.defaultAgentId === "string" &&
payload.defaultAgentId.trim() !== "" &&
Array.isArray(payload.agents) &&
isRecord(payload.channels) &&
Array.isArray(payload.channelOrder) &&
isRecord(payload.sessions)
);
}
function hasStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function connectHelloScopes(response: unknown): string[] | null {
if (!isRecord(response) || !isRecord(response.payload)) {
return null;
}
const { payload } = response;
if (
payload.type !== "hello-ok" ||
typeof payload.protocol !== "number" ||
!isRecord(payload.features) ||
!hasStringArray(payload.features.methods) ||
!payload.features.methods.includes("health") ||
!isRecord(payload.auth) ||
payload.auth.role !== "operator" ||
!hasStringArray(payload.auth.scopes)
) {
return null;
}
return payload.auth.scopes;
}
function hasConnectHelloPayload(response: unknown): boolean {
return connectHelloScopes(response) !== null;
}
function hasUnpairedOperatorScopes(response: unknown): boolean {
const scopes = connectHelloScopes(response);
if (!scopes) {
return false;
}
return scopes.length > 0;
}
export async function runGatewaySmoke(
input: { token: string; urlRaw: string },
deps: GatewaySmokeDeps = {},
): Promise<number> {
const url = resolveGatewayUrl(input.urlRaw);
const createClient = deps.createClient ?? createGatewayWsClient;
const stderr = deps.stderr ?? writeStderrLine;
const stdout = deps.stdout ?? writeStdoutLine;
const client: GatewaySmokeClient = createClient({
url: url.toString(),
onEvent: (evt) => {
// Ignore noisy connect handshakes.
void evt;
},
});
const { request, waitOpen, close } = client;
try {
await waitOpen();
// Match iOS "operator" session defaults: token auth, no device identity.
const connectRes = await request("connect", {
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
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: input.token },
});
if (!connectRes.ok) {
stderr(`connect failed: ${String(connectRes.error)}`);
return 2;
}
if (!hasConnectHelloPayload(connectRes)) {
stderr("connect failed: missing hello-ok payload");
return 2;
}
if (hasUnpairedOperatorScopes(connectRes)) {
stderr("connect failed: unpaired iOS smoke unexpectedly received operator scopes");
return 2;
}
const healthRes = await request("health");
if (!healthRes.ok) {
stderr(`health failed: ${String(healthRes.error)}`);
return 3;
}
if (!hasHealthSummaryPayload(healthRes)) {
stderr("health failed: missing health summary payload");
return 3;
}
stdout("ok: connected + health");
return 0;
} finally {
close();
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
let cli: GatewaySmokeCliOptions;
try {
cli = parseGatewaySmokeCli();
} catch (error) {
writeStderrLine(error instanceof Error ? error.message : String(error));
process.exit(1);
}
if (cli.help) {
writeStdoutLine(usage());
} else if (!cli.urlRaw || !cli.token) {
writeUsage();
process.exitCode = 1;
} else {
process.exitCode = await runGatewaySmoke({ token: cli.token, urlRaw: cli.urlRaw });
}
}
export const testing = {
parseGatewaySmokeCli,
usage,
};

View File

@@ -0,0 +1,2 @@
// Gateway Ws Client script supports OpenClaw repository automation.
export * from "../lib/gateway-ws-client.ts";

406
scripts/dev/ios-node-e2e.ts Normal file
View File

@@ -0,0 +1,406 @@
// Ios Node E2E script supports OpenClaw repository automation.
import { randomUUID } from "node:crypto";
import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
} from "../../packages/gateway-protocol/src/version.js";
function writeStdoutLine(message = ""): void {
process.stdout.write(`${message}\n`);
}
function writeStdoutJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function writeStderrLine(message: string): void {
process.stderr.write(`${message}\n`);
}
function usage(): string {
return [
"Usage: bun scripts/dev/ios-node-e2e.ts --url <wss://host[:port]> --token <gateway.auth.token> [options]",
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
"",
"Options:",
" --node <id|name-substring> Select a connected iOS node",
" --wait-seconds <n> Seconds to wait for an iOS node (default: 25)",
" --dangerous Include camera/screen commands",
" --json Print JSON results",
" -h, --help Show this help",
].join("\n");
}
const argv = process.argv.slice(2);
const getArg = (flag: string) => {
const index = argv.indexOf(flag);
return index === -1 ? undefined : argv[index + 1];
};
const hasFlag = (flag: string) => argv.includes(flag);
const BOOLEAN_FLAGS = new Set(["--dangerous", "--help", "-h", "--json"]);
const VALUE_FLAGS = new Set(["--node", "--token", "--url", "--wait-seconds"]);
function isMissingOptionValue(value: string | undefined): boolean {
return !value || BOOLEAN_FLAGS.has(value) || VALUE_FLAGS.has(value) || value.startsWith("--");
}
function failCli(message: string): never {
writeStderrLine(message);
process.exit(1);
}
function validateArgs(): void {
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
const value = argv[index + 1];
if (isMissingOptionValue(value)) {
failCli(`${arg} requires a value`);
}
index += 1;
continue;
}
failCli(`Unknown argument: ${arg}`);
}
}
validateArgs();
if (hasFlag("--help") || hasFlag("-h")) {
writeStdoutLine(usage());
process.exit(0);
}
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 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) {
writeStderrLine(usage());
process.exit(1);
}
const waitSeconds = parseWaitSeconds(getArg("--wait-seconds"));
const { createGatewayWsClient, resolveGatewayUrl } = await import("./gateway-ws-client.ts");
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 parseWaitSeconds(raw: string | undefined): number {
const value = raw ?? "25";
const text = value.trim();
if (!/^[1-9]\d*$/u.test(text)) {
writeStderrLine(`--wait-seconds must be a positive integer; got: ${value}`);
process.exit(1);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed)) {
writeStderrLine(`--wait-seconds must be a safe positive integer; got: ${value}`);
process.exit(1);
}
return parsed;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function payloadShapeError(command: string, payload: unknown): string | null {
if (payload == null) {
return `${command} returned no payload`;
}
if (Array.isArray(payload)) {
return `${command} returned an array payload`;
}
if (!isRecord(payload)) {
return `${command} returned a ${typeof payload} payload`;
}
if (Object.keys(payload).length === 0) {
return `${command} returned an empty object payload`;
}
if (command === "device.info") {
const hasSystemName =
typeof payload.systemName === "string" && payload.systemName.trim().length > 0;
const hasSystemVersion =
typeof payload.systemVersion === "string" && payload.systemVersion.trim().length > 0;
if (!hasSystemName || !hasSystemVersion) {
return "device.info payload missing systemName/systemVersion";
}
}
return null;
}
function commandPayloadFromInvokePayload(payload: unknown): unknown {
if (!isRecord(payload)) {
return payload;
}
if (typeof payload.payloadJSON === "string") {
try {
return JSON.parse(payload.payloadJSON);
} catch {
return undefined;
}
}
if ("payload" in payload && ("ok" in payload || "nodeId" in payload || "command" in payload)) {
return commandPayloadFromInvokePayload(payload.payload);
}
return payload;
}
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: MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
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) {
writeStderrLine(`connect failed: ${String(connectRes.error)}`);
close();
process.exit(2);
}
const healthRes = await request("health");
if (!healthRes.ok) {
writeStderrLine(`health failed: ${String(healthRes.error)}`);
close();
process.exit(3);
}
const nodesRes = await request("node.list");
if (!nodesRes.ok) {
writeStderrLine(`node.list failed: ${String(nodesRes.error)}`);
close();
process.exit(4);
}
const listPayload = (nodesRes.payload ?? {}) as NodeListPayload;
let node = pickIosNode(listPayload, nodeHint);
if (!node) {
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) {
writeStderrLine("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: unknown) => {
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;
}
const commandPayload = commandPayloadFromInvokePayload(invokeRes.payload);
const payloadError = payloadShapeError(t.command, commandPayload);
if (payloadError) {
results.push({ id: t.id, ok: false, error: payloadError, payload: invokeRes.payload });
continue;
}
results.push({ id: t.id, ok: true, payload: invokeRes.payload });
}
if (jsonOut) {
writeStdoutJson({
gateway: url.toString(),
node: {
nodeId: node.nodeId,
displayName: node.displayName,
platform: node.platform,
},
dangerous,
results,
});
} 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)));
writeStdoutLine(`node: ${node.displayName ?? node.nodeId} (${node.platform ?? "unknown"})`);
writeStdoutLine(`dangerous: ${dangerous ? "on" : "off"}`);
writeStdoutLine();
for (const r of rows) {
writeStdoutLine(`${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,36 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 <device-udid> <bundle-id> [dest]" >&2
echo " OPENCLAW_IOS_DEVICE_UDID=... OPENCLAW_IOS_BUNDLE_ID=... $0" >&2
}
DEVICE_UDID="${1:-${OPENCLAW_IOS_DEVICE_UDID:-}}"
BUNDLE_ID="${2:-${OPENCLAW_IOS_BUNDLE_ID:-}}"
DEST="${3:-${OPENCLAW_IOS_GATEWAY_LOG_DEST:-}}"
if [[ -z "$DEVICE_UDID" || -z "$BUNDLE_ID" ]]; then
usage
exit 2
fi
if [[ -z "$DEST" ]]; then
dest_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-ios-gateway.XXXXXX")"
DEST="$dest_dir/openclaw-gateway.log"
fi
xcrun devicectl device copy from \
--device "$DEVICE_UDID" \
--domain-type appDataContainer \
--domain-identifier "$BUNDLE_ID" \
--source Documents/openclaw-gateway.log \
--destination "$DEST" >/dev/null
if [[ ! -s "$DEST" ]]; then
echo "Gateway log pull produced an empty file: $DEST" >&2
exit 1
fi
echo "Pulled to: $DEST"
tail -n 200 "$DEST"

View File

@@ -0,0 +1,852 @@
// Realtime Talk Live Smoke script supports OpenClaw repository automation.
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { readBoundedResponseText } from "../lib/bounded-response.ts";
import {
parseStrictIntegerOption,
previewForDevToolLog,
redactJsonValueForDevToolLog,
} from "../lib/dev-tooling-safety.ts";
const OPENAI_REALTIME_MODEL =
process.env.OPENCLAW_REALTIME_OPENAI_MODEL?.trim() || "gpt-realtime-2";
const OPENAI_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_OPENAI_VOICE?.trim() || "alloy";
const DEFAULT_OPENAI_HTTP_TIMEOUT_MS = 30_000;
const OPENAI_HTTP_RESPONSE_MAX_BYTES = 256 * 1024;
const GOOGLE_REALTIME_MODEL =
process.env.OPENCLAW_REALTIME_GOOGLE_MODEL?.trim() ||
"gemini-2.5-flash-native-audio-preview-12-2025";
const GOOGLE_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_GOOGLE_VOICE?.trim() || "Kore";
const GOOGLE_LIVE_WS_URL =
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained";
type RealtimeSmokeCliOptions = {
help: boolean;
};
// Keep live stacks behind their owning smoke paths so help and safety helpers stay lightweight.
type Browser = import("playwright").Browser;
type ViteDevServer = Awaited<ReturnType<(typeof import("vite"))["createServer"]>>;
type SmokeResult = {
name: string;
ok: boolean;
details?: Record<string, unknown>;
};
type TimeoutOptions<T> = {
label: string;
timeoutMs: number;
run: (signal: AbortSignal) => Promise<T>;
};
type OpenAIHttpOptions = {
fetchImpl?: typeof fetch;
timeoutMs?: number;
};
type OpenAIRealtimeBrowserResponseReader = (
response: Response,
label: string,
maxBytes: number,
) => Promise<string>;
type OpenAIWebRtcSmokeGlobal = typeof globalThis & {
openclawReadBoundedRealtimeResponseText?: OpenAIRealtimeBrowserResponseReader;
};
class CliArgumentError extends Error {
override name = "CliArgumentError";
}
function usage(): string {
return [
"Usage: node --import tsx scripts/dev/realtime-talk-live-smoke.ts [options]",
"",
"Options:",
" -h, --help Show this help",
"",
"Environment:",
" OPENAI_API_KEY",
" GEMINI_API_KEY or GOOGLE_API_KEY",
].join("\n");
}
function parseRealtimeSmokeArgs(argv = process.argv.slice(2)): RealtimeSmokeCliOptions {
for (const arg of argv) {
if (arg === "--help" || arg === "-h") {
continue;
}
throw new CliArgumentError(`Unknown argument: ${arg}`);
}
return { help: argv.includes("--help") || argv.includes("-h") };
}
function getEnv(name: string): string | undefined {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
function shortError(error: unknown): string {
return previewForDevToolLog(error instanceof Error ? error.message : String(error), 800);
}
async function readBoundedText(
response: Response,
label: string,
maxBytes = OPENAI_HTTP_RESPONSE_MAX_BYTES,
signal?: AbortSignal,
): Promise<string> {
return await readBoundedResponseText(response, label, maxBytes, {
createTooLargeError: (message) => new Error(message),
signal,
});
}
async function readBoundedJsonResponse(
response: Response,
label: string,
signal?: AbortSignal,
): Promise<Record<string, unknown>> {
const text = await readBoundedText(response, label, OPENAI_HTTP_RESPONSE_MAX_BYTES, signal);
return JSON.parse(text) as Record<string, unknown>;
}
function resolveOpenAIHttpTimeoutMs(
raw = process.env.OPENCLAW_REALTIME_OPENAI_HTTP_TIMEOUT_MS,
): number {
return parseStrictIntegerOption({
fallback: DEFAULT_OPENAI_HTTP_TIMEOUT_MS,
label: "OPENCLAW_REALTIME_OPENAI_HTTP_TIMEOUT_MS",
min: 1,
raw,
});
}
async function withTimeout<T>(options: TimeoutOptions<T>): Promise<T> {
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<T>((_resolve, reject) => {
timeout = setTimeout(() => {
const error = new Error(`${options.label} exceeded timeout of ${options.timeoutMs}ms`);
reject(error);
controller.abort(error);
}, options.timeoutMs);
});
try {
return await Promise.race([options.run(controller.signal), timeoutPromise]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
function printResult(result: SmokeResult): void {
console.log(
`${result.name}: ${result.ok ? "ok" : "failed"}`,
redactJsonValueForDevToolLog(result.details ?? {}),
);
}
function compareStrings(left: string | undefined, right: string | undefined): number {
return (left ?? "").localeCompare(right ?? "");
}
async function readOpenAIRealtimeBrowserResponseText(
response: Response,
label: string,
maxBytes: number,
): Promise<string> {
const responseBodyTooLargeError = (errorLabel: string, errorMaxBytes: number): Error =>
new Error(`${errorLabel} response body exceeded ${errorMaxBytes} bytes`);
const rawContentLength = response.headers.get("content-length");
if (rawContentLength && /^\d+$/u.test(rawContentLength)) {
const contentLength = Number(rawContentLength);
if (!Number.isSafeInteger(contentLength) || contentLength > maxBytes) {
await response.body?.cancel().catch(() => undefined);
throw responseBodyTooLargeError(label, maxBytes);
}
}
if (!response.body) {
return "";
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
let totalBytes = 0;
let canceled = false;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
const tail = decoder.decode();
if (tail) {
chunks.push(tail);
}
break;
}
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
canceled = true;
await reader.cancel().catch(() => undefined);
throw responseBodyTooLargeError(label, maxBytes);
}
chunks.push(decoder.decode(value, { stream: true }));
}
} finally {
if (!canceled) {
reader.releaseLock();
}
}
return chunks.join("");
}
function openAIRealtimeBrowserResponseReaderInitScript(): string {
return `globalThis.openclawReadBoundedRealtimeResponseText = ${readOpenAIRealtimeBrowserResponseText.toString()};`;
}
async function createOpenAIClientSecret(
apiKey: string,
options: OpenAIHttpOptions = {},
): Promise<string> {
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? resolveOpenAIHttpTimeoutMs();
const payload = await withTimeout({
label: "OpenAI Realtime client secret request",
timeoutMs,
run: async (signal) => {
const response = await fetchImpl("https://api.openai.com/v1/realtime/client_secrets", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
session: {
type: "realtime",
model: OPENAI_REALTIME_MODEL,
audio: {
output: { voice: OPENAI_REALTIME_VOICE },
},
},
}),
signal,
});
if (!response.ok) {
throw new Error(
`OpenAI Realtime client secret failed (${response.status}): ${previewForDevToolLog(
await readBoundedText(
response,
"OpenAI Realtime client secret error",
OPENAI_HTTP_RESPONSE_MAX_BYTES,
signal,
),
600,
)}`,
);
}
return await readBoundedJsonResponse(response, "OpenAI Realtime client secret", signal);
},
});
const nested =
payload.client_secret && typeof payload.client_secret === "object"
? (payload.client_secret as Record<string, unknown>)
: undefined;
const value = typeof payload.value === "string" ? payload.value : undefined;
const nestedValue = typeof nested?.value === "string" ? nested.value : undefined;
const secret = value ?? nestedValue;
if (!secret) {
throw new Error("OpenAI Realtime client secret response did not include a value");
}
return secret;
}
async function smokeOpenAIBackendBridge(apiKey: string): Promise<SmokeResult> {
const { buildOpenAIRealtimeVoiceProvider } =
await import("../../extensions/openai/realtime-voice-provider.ts");
const provider = buildOpenAIRealtimeVoiceProvider();
const events: string[] = [];
const bridge = provider.createBridge({
providerConfig: {
apiKey,
model: OPENAI_REALTIME_MODEL,
voice: OPENAI_REALTIME_VOICE,
},
instructions: "OpenClaw backend realtime live smoke. Do not speak yet.",
onAudio: () => {},
onClearAudio: () => {},
onEvent: (event) => {
events.push(`${event.direction}:${event.type}`);
},
});
try {
await bridge.connect();
return {
name: "openai-backend-bridge",
ok: bridge.isConnected(),
details: {
model: OPENAI_REALTIME_MODEL,
connected: bridge.isConnected(),
events: events.slice(0, 10),
},
};
} catch (error) {
return {
name: "openai-backend-bridge",
ok: false,
details: { model: OPENAI_REALTIME_MODEL, error: shortError(error) },
};
} finally {
bridge.close();
}
}
async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<SmokeResult> {
try {
const openAIHttpTimeoutMs = resolveOpenAIHttpTimeoutMs();
const clientSecret = await createOpenAIClientSecret(apiKey, { timeoutMs: openAIHttpTimeoutMs });
const context = await browser.newContext({
permissions: ["microphone"],
});
try {
const page = await context.newPage();
await page.evaluate("globalThis.__name = (fn) => fn");
await page.evaluate(openAIRealtimeBrowserResponseReaderInitScript());
const result = await page.evaluate(
async ({ clientSecret: secret, sdpAnswerMaxBytes, timeoutMs }) => {
const readBoundedTextLocal = (globalThis as OpenAIWebRtcSmokeGlobal)
.openclawReadBoundedRealtimeResponseText;
if (!readBoundedTextLocal) {
throw new Error("OpenAI Realtime bounded response reader was not installed");
}
const withBrowserTimeout = async <T>(
label: string,
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> => {
const controller = new AbortController();
let timeout: number | undefined;
const timeoutPromise = new Promise<T>((_resolve, reject) => {
timeout = window.setTimeout(() => {
const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`);
reject(error);
controller.abort(error);
}, timeoutMs);
});
try {
return await Promise.race([run(controller.signal), timeoutPromise]);
} finally {
if (timeout !== undefined) {
window.clearTimeout(timeout);
}
}
};
let media: MediaStream | undefined;
let peer: RTCPeerConnection | undefined;
try {
if (navigator.mediaDevices?.getUserMedia) {
media = await navigator.mediaDevices.getUserMedia({ audio: true });
} else {
const audioContext = new AudioContext();
const destination = audioContext.createMediaStreamDestination();
const oscillator = audioContext.createOscillator();
oscillator.connect(destination);
oscillator.start();
media = destination.stream;
}
peer = new RTCPeerConnection();
for (const track of media.getAudioTracks()) {
peer.addTrack(track, media);
}
const channel = peer.createDataChannel("oai-events");
const connectionState = new Promise<string>((resolve) => {
const timeout = window.setTimeout(
() => resolve(peer?.connectionState ?? "timeout"),
12_000,
);
peer?.addEventListener("connectionstatechange", () => {
if (peer?.connectionState === "connected" || peer?.connectionState === "failed") {
window.clearTimeout(timeout);
resolve(peer.connectionState);
}
});
channel.addEventListener("open", () => {
window.clearTimeout(timeout);
resolve(peer?.connectionState || "data-channel-open");
});
});
const offer = await peer.createOffer();
await peer.setLocalDescription(offer);
const offerSdp = offer.sdp;
if (!offerSdp) {
throw new Error("OpenAI Realtime SDP offer did not include SDP");
}
const answer = await withBrowserTimeout(
"OpenAI Realtime SDP offer request",
async (signal) => {
const response = await fetch("https://api.openai.com/v1/realtime/calls", {
method: "POST",
body: offerSdp,
headers: {
Authorization: `Bearer ${secret}`,
"Content-Type": "application/sdp",
},
signal,
});
if (!response.ok) {
throw new Error(`OpenAI Realtime SDP offer failed (${response.status})`);
}
return await readBoundedTextLocal(
response,
"OpenAI Realtime SDP answer",
sdpAnswerMaxBytes,
);
},
);
await peer.setRemoteDescription({ type: "answer", sdp: answer });
const state = await connectionState;
return {
answerHasAudio: answer.includes("m=audio"),
remoteDescriptionApplied: peer.remoteDescription?.type === "answer",
connectionState: state,
};
} finally {
peer?.close();
media?.getTracks().forEach((track) => track.stop());
}
},
{
clientSecret,
sdpAnswerMaxBytes: OPENAI_HTTP_RESPONSE_MAX_BYTES,
timeoutMs: openAIHttpTimeoutMs,
},
);
return {
name: "openai-webrtc-browser",
ok: result.answerHasAudio && result.remoteDescriptionApplied,
details: {
model: OPENAI_REALTIME_MODEL,
answerHasAudio: result.answerHasAudio,
remoteDescriptionApplied: result.remoteDescriptionApplied,
connectionState: result.connectionState,
},
};
} finally {
await context.close();
}
} catch (error) {
return { name: "openai-webrtc-browser", ok: false, details: { error: shortError(error) } };
}
}
async function createGoogleLiveToken(apiKey: string): Promise<string> {
const { GoogleGenAI, Modality } = await import("@google/genai");
const ai = new GoogleGenAI({
apiKey,
httpOptions: { apiVersion: "v1alpha" },
});
const now = Date.now();
const token = await ai.authTokens.create({
config: {
uses: 1,
expireTime: new Date(now + 30 * 60 * 1000).toISOString(),
newSessionExpireTime: new Date(now + 60 * 1000).toISOString(),
liveConnectConstraints: {
model: GOOGLE_REALTIME_MODEL,
config: {
responseModalities: [Modality.AUDIO],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: GOOGLE_REALTIME_VOICE },
},
},
systemInstruction: "OpenClaw browser Talk live smoke.",
inputAudioTranscription: {},
outputAudioTranscription: {},
},
},
},
});
const name = token.name?.trim();
if (!name) {
throw new Error("Google Live auth token response did not include a token name");
}
return name;
}
async function smokeGoogleLiveBrowserWs(browser: Browser, apiKey: string): Promise<SmokeResult> {
try {
const token = await createGoogleLiveToken(apiKey);
const page = await browser.newPage();
await page.evaluate("globalThis.__name = (fn) => fn");
const result = await page.evaluate(
async ({ model, tokenName, websocketUrl }) => {
const debug: {
opened: boolean;
messages: string[];
close?: { code: number; reason: string };
error: boolean;
} = { opened: false, messages: [], error: false };
const dataToText = async (data: unknown): Promise<string> => {
if (typeof data === "string") {
return data;
}
if (data instanceof Blob) {
return await data.text();
}
if (data instanceof ArrayBuffer) {
return new TextDecoder().decode(data);
}
return String(data);
};
const url = new URL(websocketUrl);
url.searchParams.set("access_token", tokenName);
const ws = new WebSocket(url.toString());
const done = new Promise<Record<string, unknown>>((resolve, reject) => {
const timeout = window.setTimeout(
() => reject(new Error(`Google Live setup timed out: ${JSON.stringify(debug)}`)),
15_000,
);
ws.addEventListener("open", () => {
debug.opened = true;
ws.send(
JSON.stringify({
setup: {
model: model.startsWith("models/") ? model : `models/${model}`,
generationConfig: { responseModalities: ["AUDIO"] },
inputAudioTranscription: {},
outputAudioTranscription: {},
},
}),
);
});
ws.addEventListener("message", (event) => {
void (async () => {
const text = await dataToText(event.data);
debug.messages.push(text.slice(0, 300));
const message = JSON.parse(text) as { setupComplete?: unknown };
if (!message.setupComplete) {
return;
}
window.clearTimeout(timeout);
resolve({ setupComplete: true, readyState: ws.readyState });
})().catch((error: unknown) => {
window.clearTimeout(timeout);
reject(toLintErrorObject(error, "Non-Error rejection"));
});
});
ws.addEventListener("error", () => {
debug.error = true;
window.clearTimeout(timeout);
reject(new Error("Google Live browser WebSocket errored"));
});
ws.addEventListener("close", (event) => {
debug.close = { code: event.code, reason: event.reason };
if (event.code !== 1000) {
window.clearTimeout(timeout);
reject(new Error(`Google Live browser WebSocket closed: ${JSON.stringify(debug)}`));
}
});
});
const value = await done;
ws.close(1000);
return value;
},
{
model: GOOGLE_REALTIME_MODEL,
tokenName: token,
websocketUrl: GOOGLE_LIVE_WS_URL,
},
);
await page.close();
return {
name: "google-live-browser-ws",
ok: result.setupComplete === true,
details: { model: GOOGLE_REALTIME_MODEL, setupComplete: result.setupComplete === true },
};
} catch (error) {
return { name: "google-live-browser-ws", ok: false, details: { error: shortError(error) } };
}
}
async function smokeGatewayRelayBrowser(browser: Browser): Promise<SmokeResult> {
let server: ViteDevServer | undefined;
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-realtime-talk-"));
try {
const { createServer } = await import("vite");
const repoRoot = process.cwd().replaceAll("\\", "/");
const relayModulePath = JSON.stringify(
`/@fs/${repoRoot}/ui/src/ui/chat/realtime-talk-gateway-relay.ts`,
);
await writeFile(
path.join(dir, "index.html"),
'<!doctype html><meta charset="utf-8"><script type="module" src="/main.ts"></script>',
);
await writeFile(
path.join(dir, "main.ts"),
`
const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath});
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const listeners = new Set();
const requests = [];
const statuses = [];
const transcripts = [];
function emit(event) {
for (const listener of [...listeners]) {
listener(event);
}
}
function base64ZeroPcm(bytes) {
let text = "";
for (let index = 0; index < bytes; index += 1) {
text += String.fromCharCode(0);
}
return btoa(text);
}
const client = {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async request(method, params) {
requests.push({ method, params });
if (method === "talk.client.toolCall") {
const runId = params.idempotencyKey || "run-smoke";
window.setTimeout(() => {
emit({ event: "chat", payload: { runId, state: "final", message: { text: "relay consult ok" } } });
}, 50);
return { runId };
}
return { ok: true };
},
};
try {
const transport = new GatewayRelayRealtimeTalkTransport(
{
provider: "smoke",
transport: "gateway-relay",
relaySessionId: "relay-live-smoke",
audio: {
inputEncoding: "pcm16",
inputSampleRateHz: 24000,
outputEncoding: "pcm16",
outputSampleRateHz: 24000,
},
},
{
client,
sessionKey: "main",
callbacks: {
onStatus: (status, detail) => statuses.push({ status, detail }),
onTranscript: (entry) => transcripts.push(entry),
},
},
);
await transport.start();
emit({ event: "talk.event", payload: { relaySessionId: "relay-live-smoke", type: "ready" } });
emit({
event: "talk.event",
payload: { relaySessionId: "relay-live-smoke", type: "transcript", role: "user", text: "relay user", final: true },
});
emit({
event: "talk.event",
payload: { relaySessionId: "relay-live-smoke", type: "transcript", role: "assistant", text: "relay assistant", final: false },
});
emit({
event: "talk.event",
payload: { relaySessionId: "relay-live-smoke", type: "audio", audioBase64: base64ZeroPcm(480) },
});
const processor = transport.inputProcessor;
processor?.onaudioprocess?.({
inputBuffer: { getChannelData: () => new Float32Array(160).fill(0.01) },
});
emit({ event: "talk.event", payload: { relaySessionId: "relay-live-smoke", type: "mark" } });
emit({
event: "talk.event",
payload: {
relaySessionId: "relay-live-smoke",
type: "toolCall",
callId: "call-smoke",
name: "openclaw_agent_consult",
args: { question: "confirm relay consult path" },
},
});
await delay(400);
transport.stop();
await delay(100);
window.relaySmokeResult = { requests, statuses, transcripts };
window.relaySmokeDone = true;
} catch (error) {
window.relaySmokeResult = { error: error instanceof Error ? error.message : String(error), requests, statuses, transcripts };
window.relaySmokeDone = true;
}
`,
);
server = await createServer({
root: dir,
logLevel: "silent",
server: { host: "127.0.0.1", port: 0 },
});
await server.listen();
const address = server.httpServer?.address();
if (!address || typeof address === "string") {
throw new Error("Vite did not expose a local port");
}
const url = `http://127.0.0.1:${address.port}/`;
const context = await browser.newContext({ permissions: ["microphone"] });
await context.grantPermissions(["microphone"], { origin: url });
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(
() => (globalThis as Record<string, unknown>).relaySmokeDone === true,
undefined,
{
timeout: 15_000,
},
);
const result = (await page.evaluate(
() => (globalThis as Record<string, unknown>).relaySmokeResult,
)) as {
error?: string;
requests?: Array<{ method?: string }>;
statuses?: Array<{ status?: string }>;
transcripts?: Array<{ role?: string; text?: string }>;
};
await context.close();
if (result.error) {
throw new Error(result.error);
}
const methods = new Set((result.requests ?? []).map((request) => request.method));
const statusNames = new Set((result.statuses ?? []).map((entry) => entry.status));
const transcriptTexts = new Set((result.transcripts ?? []).map((entry) => entry.text));
const expectedMethods = [
"talk.client.toolCall",
"talk.session.appendAudio",
"talk.session.submitToolResult",
"talk.session.close",
];
const ok =
expectedMethods.every((method) => methods.has(method)) &&
statusNames.has("listening") &&
statusNames.has("thinking") &&
transcriptTexts.has("relay user") &&
transcriptTexts.has("relay assistant");
return {
name: "gateway-relay-browser-adapter",
ok,
details: {
methods: [...methods].toSorted(compareStrings),
statuses: [...statusNames].toSorted(compareStrings),
transcripts: [...transcriptTexts].toSorted(compareStrings),
},
};
} catch (error) {
return {
name: "gateway-relay-browser-adapter",
ok: false,
details: { error: shortError(error) },
};
} finally {
await server?.close();
await rm(dir, { recursive: true, force: true });
}
}
async function main(argv = process.argv.slice(2)): Promise<void> {
const cli = parseRealtimeSmokeArgs(argv);
if (cli.help) {
console.log(usage());
return;
}
const { chromium } = await import("playwright");
const openAIKey = getEnv("OPENAI_API_KEY");
const googleKey = getEnv("GEMINI_API_KEY") ?? getEnv("GOOGLE_API_KEY");
const browser = await chromium.launch({
headless: true,
args: [
"--autoplay-policy=no-user-gesture-required",
"--no-sandbox",
"--use-fake-device-for-media-stream",
"--use-fake-ui-for-media-stream",
],
});
const results: SmokeResult[] = [];
try {
if (!openAIKey) {
results.push({
name: "openai-backend-bridge",
ok: false,
details: { error: "OPENAI_API_KEY missing" },
});
results.push({
name: "openai-webrtc-browser",
ok: false,
details: { error: "OPENAI_API_KEY missing" },
});
} else {
results.push(await smokeOpenAIBackendBridge(openAIKey));
results.push(await smokeOpenAIWebRtc(browser, openAIKey));
}
if (!googleKey) {
results.push({
name: "google-live-browser-ws",
ok: false,
details: { error: "GEMINI_API_KEY or GOOGLE_API_KEY missing" },
});
} else {
results.push(await smokeGoogleLiveBrowserWs(browser, googleKey));
}
results.push(await smokeGatewayRelayBrowser(browser));
} finally {
await browser.close();
}
for (const result of results) {
printResult(result);
}
if (results.some((result) => !result.ok)) {
process.exitCode = 1;
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main().catch((error: unknown) => {
console.error(error instanceof CliArgumentError ? error.message : shortError(error));
process.exitCode = 1;
});
}
export const testing = {
OPENAI_HTTP_RESPONSE_MAX_BYTES,
createOpenAIClientSecret,
parseRealtimeSmokeArgs,
readOpenAIRealtimeBrowserResponseText,
readBoundedText,
resolveOpenAIHttpTimeoutMs,
usage,
};
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -0,0 +1,207 @@
// Test Device Pair Telegram script supports OpenClaw repository automation.
import { pathToFileURL } from "node:url";
import { getRuntimeConfig } from "../../src/config/config.js";
import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js";
import { loadOpenClawPlugins } from "../../src/plugins/loader.js";
type SendMessageTelegram = (
chatId: string,
text: string,
options: {
accountId?: string;
cfg?: ReturnType<typeof getRuntimeConfig>;
},
) => Promise<{ chatId?: string; messageId?: string }>;
type DevicePairTelegramDeps = {
executePluginCommand: typeof executePluginCommand;
getRuntimeConfig: typeof getRuntimeConfig;
loadOpenClawPlugins: typeof loadOpenClawPlugins;
matchPluginCommand: typeof matchPluginCommand;
sendMessageTelegram: SendMessageTelegram;
};
type DevicePairTelegramResult = {
accountId?: string;
chatId: string;
messageId?: string;
sent: boolean;
};
class UsageError extends Error {
readonly exitCode = 1;
}
class CliArgumentError extends UsageError {}
type DevicePairTelegramArgs = {
accountId?: string;
chatId?: string;
help: boolean;
};
const BOOLEAN_FLAGS = new Set(["--help", "-h"]);
const VALUE_FLAGS = new Set(["--account", "-a", "--chat", "-c"]);
function isMissingOptionValue(value: string | undefined): boolean {
return !value || BOOLEAN_FLAGS.has(value) || VALUE_FLAGS.has(value) || value.startsWith("--");
}
function writeStdoutLine(...parts: string[]): void {
process.stdout.write(`${parts.join(" ")}\n`);
}
function writeStderrLine(message: string): void {
process.stderr.write(`${message}\n`);
}
function readArg(args: string[], flag: string, short?: string): string | undefined {
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;
}
function usage(): string {
return [
"Usage: bun scripts/dev/test-device-pair-telegram.ts --chat <telegram-chat-id> [--account <accountId>]",
"",
"Options:",
" --chat, -c <id> Telegram chat id",
" --account, -a <id> Telegram account id",
" -h, --help Show this help",
].join("\n");
}
function validateArgs(args: readonly string[]): void {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
const value = args[index + 1];
if (isMissingOptionValue(value)) {
throw new CliArgumentError(`${arg} requires a value`);
}
index += 1;
continue;
}
throw new CliArgumentError(`Unknown argument: ${arg}`);
}
}
function parseDevicePairTelegramArgs(args: readonly string[]): DevicePairTelegramArgs {
validateArgs(args);
return {
accountId: readArg([...args], "--account", "-a"),
chatId: readArg([...args], "--chat", "-c"),
help: args.includes("--help") || args.includes("-h"),
};
}
async function loadTelegramRuntimeSendMessage(): Promise<SendMessageTelegram> {
const specifier = "../../extensions/telegram/runtime-api.js";
const runtime = (await import(specifier)) as { sendMessageTelegram?: SendMessageTelegram };
if (typeof runtime.sendMessageTelegram !== "function") {
throw new Error("Telegram runtime-api.js did not export sendMessageTelegram");
}
return runtime.sendMessageTelegram;
}
function createDefaultDeps(): DevicePairTelegramDeps {
return {
executePluginCommand,
getRuntimeConfig,
loadOpenClawPlugins,
matchPluginCommand,
sendMessageTelegram: async (...args) => {
const sendMessageTelegram = await loadTelegramRuntimeSendMessage();
return await sendMessageTelegram(...args);
},
};
}
async function runDevicePairTelegram(
args = process.argv.slice(2),
deps: DevicePairTelegramDeps = createDefaultDeps(),
): Promise<DevicePairTelegramResult> {
const { accountId, chatId, help } = parseDevicePairTelegramArgs(args);
if (help) {
throw new UsageError(usage());
}
if (!chatId) {
throw new UsageError(usage());
}
const cfg = deps.getRuntimeConfig();
deps.loadOpenClawPlugins({ config: cfg });
const match = deps.matchPluginCommand("/pair", { channel: "telegram" });
if (!match) {
throw new Error("/pair plugin command not registered.");
}
const result = await deps.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,
});
if (!result.text) {
return { accountId, chatId, sent: false };
}
const sent = await deps.sendMessageTelegram(chatId, result.text, {
accountId,
cfg,
});
return {
accountId,
chatId: sent.chatId ?? chatId,
messageId: sent.messageId,
sent: true,
};
}
async function main(): Promise<void> {
try {
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
writeStdoutLine(usage());
return;
}
const result = await runDevicePairTelegram(args);
writeStdoutLine(
"Sent split /pair messages to",
result.chatId,
result.accountId ? `(${result.accountId})` : "",
result.messageId ? `message=${result.messageId}` : "",
);
} catch (error) {
writeStderrLine(error instanceof Error ? error.message : String(error));
process.exitCode = error instanceof UsageError ? error.exitCode : 1;
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main();
}
export { parseDevicePairTelegramArgs, runDevicePairTelegram };

View File

@@ -0,0 +1,528 @@
// Tui Pty Test Watch script supports OpenClaw repository automation.
import { spawn, spawnSync } from "node:child_process";
import { mkdir, open, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { sleep as delay } from "../lib/sleep.mjs";
import { resolveWindowsTaskkillPath } from "../lib/windows-taskkill.mjs";
type Options = {
altScreen: boolean;
help: boolean;
mirrorPath: string;
mode: "fake" | "local" | "all";
vitestArgs: string[];
};
const DEFAULT_MIRROR_PATH = path.join(process.cwd(), ".artifacts", "tui-pty-mirror", "latest.ansi");
const require = createRequire(import.meta.url);
const MODE_TEST_FILES = {
fake: ["src/tui/tui-pty-harness.e2e.test.ts"],
local: ["src/tui/tui-pty-local.e2e.test.ts"],
all: ["src/tui/tui-pty-harness.e2e.test.ts", "src/tui/tui-pty-local.e2e.test.ts"],
} as const;
const MIRROR_TERMINAL_QUERIES = ["\x1b[?u", "\x1b[16t"];
const DEFAULT_PTY_COLS = 100;
const DEFAULT_PTY_ROWS = 30;
const CHILD_SIGTERM_GRACE_MS = 500;
const CHILD_SIGKILL_GRACE_MS = 5_000;
const MIRROR_READ_CHUNK_BYTES = 1024 * 1024;
const CHILD_OUTPUT_TAIL_BYTES = 128 * 1024;
const BOOLEAN_OPTIONS = new Set(["--help", "-h", "--no-alt-screen"]);
const VALUE_OPTIONS = new Set(["--mode", "--mirror-path"]);
class CliArgumentError extends Error {
override name = "CliArgumentError";
}
type KillableChild = {
pid?: number;
kill(signal: NodeJS.Signals): boolean;
};
type ChildStopper = {
cancel: () => void;
stop: () => void;
};
type SignalChild = (child: KillableChild, signal: NodeJS.Signals) => void;
type RunTaskkill = (
command: string,
args: string[],
options: { stdio: "ignore" },
) => { error?: unknown; status?: number | null } | undefined;
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
(timer as { unref?: () => void }).unref?.();
}
function readOption(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
if (idx < 0) {
return undefined;
}
const value = args[idx + 1];
if (!value || value.startsWith("-")) {
throw new CliArgumentError(`${name} requires a value`);
}
return value.trim();
}
function readMode(args: string[]): Options["mode"] {
const mode = readOption(args, "--mode") ?? "fake";
if (mode === "fake" || mode === "local" || mode === "all") {
return mode;
}
throw new CliArgumentError(`--mode must be fake, local, or all; got ${JSON.stringify(mode)}`);
}
function usage(): string {
return [
"Usage: node --import tsx scripts/dev/tui-pty-test-watch.ts [options] [-- vitest args...]",
"",
"Options:",
" --mode <fake|local|all> Select TUI PTY test group (default: fake)",
" --mirror-path <path> Write/read mirrored ANSI output at this path",
" --no-alt-screen Print without switching to the terminal alt screen",
" -h, --help Show this help",
].join("\n");
}
function validateOwnArgs(args: string[]): void {
for (let idx = 0; idx < args.length; idx += 1) {
const arg = args[idx] ?? "";
if (BOOLEAN_OPTIONS.has(arg)) {
continue;
}
if (VALUE_OPTIONS.has(arg)) {
idx += 1;
continue;
}
throw new CliArgumentError(`Unknown argument: ${arg}`);
}
}
function parseOptions(args = process.argv.slice(2)): Options {
const separator = args.indexOf("--");
const ownArgs = separator >= 0 ? args.slice(0, separator) : args;
const vitestArgs = separator >= 0 ? args.slice(separator + 1) : [];
validateOwnArgs(ownArgs);
const mirrorPathOption = readOption(ownArgs, "--mirror-path");
return {
altScreen: !ownArgs.includes("--no-alt-screen"),
help: ownArgs.includes("--help") || ownArgs.includes("-h"),
mirrorPath:
mirrorPathOption !== undefined ? path.resolve(mirrorPathOption) : DEFAULT_MIRROR_PATH,
mode: readMode(ownArgs),
vitestArgs,
};
}
function shouldUseAltScreen(options: Options) {
return options.altScreen && process.stdout.isTTY;
}
function resolveVitestCliEntry(): string {
const vitestPackageJson = require.resolve("vitest/package.json");
return path.join(path.dirname(vitestPackageJson), "vitest.mjs");
}
function currentTerminalDimension(value: number | undefined, fallback: number): string {
return String(value && value > 0 ? value : fallback);
}
function signalWindowsProcessTree(
pid: number,
signal: NodeJS.Signals,
runTaskkill: RunTaskkill = spawnSync,
): boolean {
const args = ["/PID", String(pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const result = runTaskkill(resolveWindowsTaskkillPath(), args, { stdio: "ignore" });
return !result?.error && result?.status === 0;
}
function signalWindowsProcessTreeOrForce(
pid: number,
signal: NodeJS.Signals,
runTaskkill: RunTaskkill = spawnSync,
): boolean {
if (signalWindowsProcessTree(pid, signal, runTaskkill)) {
return true;
}
return signal !== "SIGKILL" && signalWindowsProcessTree(pid, "SIGKILL", runTaskkill);
}
function signalChildProcessTree(
child: KillableChild,
signal: NodeJS.Signals,
{
platform = process.platform,
runTaskkill = spawnSync,
useProcessGroup = platform !== "win32",
}: {
platform?: NodeJS.Platform;
runTaskkill?: RunTaskkill;
useProcessGroup?: boolean;
} = {},
): void {
if (useProcessGroup && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Non-detached fallback or already-exited group; direct child signaling is
// still useful on platforms without process groups.
}
}
if (platform === "win32" && typeof child.pid === "number") {
if (signalWindowsProcessTreeOrForce(child.pid, signal, runTaskkill)) {
return;
}
}
child.kill(signal);
}
function createChildStopper(
child: KillableChild,
options: {
signalChild?: SignalChild;
sigtermGraceMs?: number;
sigkillGraceMs?: number;
} = {},
): ChildStopper {
const signalChild = options.signalChild ?? signalChildProcessTree;
const sigtermGraceMs = options.sigtermGraceMs ?? CHILD_SIGTERM_GRACE_MS;
const sigkillGraceMs = options.sigkillGraceMs ?? CHILD_SIGKILL_GRACE_MS;
let stopping = false;
let termTimer: ReturnType<typeof setTimeout> | undefined;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const cancel = () => {
if (termTimer) {
clearTimeout(termTimer);
termTimer = undefined;
}
if (killTimer) {
clearTimeout(killTimer);
killTimer = undefined;
}
};
const stop = () => {
if (stopping) {
return;
}
stopping = true;
signalChild(child, "SIGINT");
termTimer = setTimeout(() => {
signalChild(child, "SIGTERM");
killTimer = setTimeout(() => {
signalChild(child, "SIGKILL");
}, sigkillGraceMs);
unrefTimer(killTimer);
}, sigtermGraceMs);
unrefTimer(termTimer);
};
return { cancel, stop };
}
async function createMirrorFile(mirrorPath: string): Promise<void> {
await mkdir(path.dirname(mirrorPath), { recursive: true });
await writeFile(mirrorPath, "", "utf8");
}
async function readNewMirrorData(
mirrorPath: string,
offset: number,
maxChunkBytes = MIRROR_READ_CHUNK_BYTES,
) {
const file = await open(mirrorPath, "r");
try {
const stats = await file.stat();
const readOffset = stats.size < offset ? 0 : offset;
const availableBytes = stats.size - readOffset;
if (availableBytes <= 0) {
return { chunk: Buffer.alloc(0), offset: readOffset };
}
const bytesToRead = Math.min(availableBytes, maxChunkBytes);
const buffer = Buffer.alloc(bytesToRead);
const { bytesRead } = await file.read(buffer, 0, bytesToRead, readOffset);
return { chunk: buffer.subarray(0, bytesRead), offset: readOffset + bytesRead };
} finally {
await file.close();
}
}
function appendBufferTail(current: Buffer, chunk: Buffer, maxBytes = CHILD_OUTPUT_TAIL_BYTES) {
if (chunk.byteLength >= maxBytes) {
return chunk.subarray(chunk.byteLength - maxBytes);
}
if (current.byteLength + chunk.byteLength <= maxBytes) {
return current.byteLength === 0 ? Buffer.from(chunk) : Buffer.concat([current, chunk]);
}
const keepBytes = maxBytes - chunk.byteLength;
return Buffer.concat([current.subarray(current.byteLength - keepBytes), chunk]);
}
async function drainNewMirrorData(
mirrorPath: string,
offset: number,
onChunk: (chunk: Buffer) => void,
maxChunkBytes = MIRROR_READ_CHUNK_BYTES,
) {
let nextOffset = offset;
for (;;) {
const result = await readNewMirrorData(mirrorPath, nextOffset, maxChunkBytes);
nextOffset = result.offset;
if (result.chunk.byteLength === 0) {
return nextOffset;
}
onChunk(result.chunk);
}
}
async function main(): Promise<void> {
const options = parseOptions();
if (options.help) {
process.stdout.write(`${usage()}\n`);
return;
}
const useAltScreen = shouldUseAltScreen(options);
await createMirrorFile(options.mirrorPath);
const child = spawn(
process.execPath,
[
"--no-maglev",
resolveVitestCliEntry(),
"run",
"--config",
"test/vitest/vitest.tui-pty.config.ts",
...MODE_TEST_FILES[options.mode],
"--reporter=dot",
...options.vitestArgs,
],
{
cwd: process.cwd(),
detached: process.platform !== "win32",
env: {
...process.env,
OPENCLAW_TUI_PTY_MIRROR_PATH: options.mirrorPath,
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: options.mode === "fake" ? "0" : "1",
OPENCLAW_TUI_PTY_COLS: currentTerminalDimension(process.stdout.columns, DEFAULT_PTY_COLS),
OPENCLAW_TUI_PTY_ROWS: currentTerminalDimension(process.stdout.rows, DEFAULT_PTY_ROWS),
OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE: process.env.OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE ?? "4",
OPENCLAW_TUI_PTY_TYPE_DELAY_MS: process.env.OPENCLAW_TUI_PTY_TYPE_DELAY_MS ?? "25",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let childStdout = Buffer.alloc(0);
let childStderr = Buffer.alloc(0);
let restored = false;
let mirrorOffset = 0;
let mirrorFilterPending = "";
let sawMirrorOutput = false;
const startedAt = Date.now();
const filterMirrorTerminalQueries = (chunk: Buffer) => {
const input = mirrorFilterPending + chunk.toString("utf8");
let output = "";
mirrorFilterPending = "";
for (let idx = 0; idx < input.length; idx += 1) {
const rest = input.slice(idx);
const fullMatch = MIRROR_TERMINAL_QUERIES.find((query) => rest.startsWith(query));
if (fullMatch) {
idx += fullMatch.length - 1;
continue;
}
const partialMatch = MIRROR_TERMINAL_QUERIES.find((query) => query.startsWith(rest));
if (partialMatch) {
mirrorFilterPending = rest;
break;
}
output += input[idx];
}
return output;
};
const writeMirrorChunk = (chunk: Buffer) => {
const filteredChunk = filterMirrorTerminalQueries(chunk);
if (filteredChunk.length === 0) {
return;
}
if (!sawMirrorOutput && useAltScreen) {
process.stdout.write("\x1b[2J\x1b[H");
}
sawMirrorOutput = true;
process.stdout.write(filteredChunk);
};
const restoreScreen = () => {
if (restored) {
return;
}
restored = true;
if (useAltScreen) {
process.stdout.write("\x1b[?1049l");
}
};
const childStopper = createChildStopper(child);
const stopChild = childStopper.stop;
const ignoredInput = (chunk: Buffer) => {
if (chunk.includes(0x03)) {
stopChild();
}
};
const hadRawMode = process.stdin.isTTY && process.stdin.isRaw;
if (useAltScreen && process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on("data", ignoredInput);
}
const restoreInput = () => {
if (!process.stdin.isTTY) {
return;
}
process.stdin.off("data", ignoredInput);
process.stdin.setRawMode(hadRawMode);
if (!hadRawMode) {
process.stdin.pause();
}
};
const drainParentInput = async () => {
if (!useAltScreen || !process.stdin.isTTY) {
return;
}
await delay(100);
};
const renderWaitingStatus = () => {
if (!useAltScreen || sawMirrorOutput) {
return;
}
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000);
process.stdout.write(
[
"\x1b[2J\x1b[H",
"openclaw TUI PTY tests",
"",
`Mode: ${options.mode}`,
`Waiting for the first TUI frame... ${elapsedSeconds}s`,
`Mirror: ${options.mirrorPath}`,
"",
"Vitest output is buffered and will print after the mirrored TUI run exits.",
].join("\n"),
);
};
if (useAltScreen) {
process.stdout.write("\x1b[?1049h\x1b[?25l");
renderWaitingStatus();
}
child.stdout?.on("data", (chunk: Buffer) => {
childStdout = appendBufferTail(childStdout, chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
childStderr = appendBufferTail(childStderr, chunk);
});
type ChildExit = { code: number | null; signal: NodeJS.Signals | null };
let childExit: ChildExit | null = null;
const childFinished = new Promise<ChildExit>((resolve) => {
child.once("exit", (code, signal) => {
childExit = { code, signal };
childStopper.cancel();
resolve(childExit);
});
});
const parentSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
for (const signal of parentSignals) {
process.once(signal, stopChild);
}
try {
for (;;) {
if (childExit) {
break;
}
const result = await readNewMirrorData(options.mirrorPath, mirrorOffset);
mirrorOffset = result.offset;
if (result.chunk.byteLength > 0) {
writeMirrorChunk(result.chunk);
} else {
renderWaitingStatus();
}
await delay(sawMirrorOutput ? 25 : 250);
}
mirrorOffset = await drainNewMirrorData(options.mirrorPath, mirrorOffset, writeMirrorChunk);
} finally {
if (!childExit) {
stopChild();
}
for (const signal of parentSignals) {
process.off(signal, stopChild);
}
await drainParentInput();
restoreInput();
if (useAltScreen) {
process.stdout.write("\x1b[?2026l\x1b[?2004l\x1b[>4;0m\x1b[?25h");
}
restoreScreen();
}
if (!childExit) {
childExit = await childFinished;
}
if (childStdout.byteLength > 0) {
process.stdout.write(childStdout);
}
if (childStderr.byteLength > 0) {
process.stderr.write(childStderr);
}
if (childExit.signal) {
throw new Error(`TUI PTY tests exited with signal ${childExit.signal}`);
}
if (childExit.code !== 0) {
process.exitCode = childExit.code ?? 1;
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((error: unknown) => {
if (error instanceof CliArgumentError) {
process.stderr.write(`${error.message}\n`);
process.exit(1);
}
process.stderr.write(
`${error instanceof Error ? error.stack || error.message : String(error)}\n`,
);
process.exit(1);
});
}
export const testing = {
appendBufferTail,
createChildStopper,
drainNewMirrorData,
parseOptions,
readNewMirrorData,
signalChildProcessTree,
usage,
};