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

971
packages/sdk/src/client.ts Normal file
View File

@@ -0,0 +1,971 @@
// OpenClaw SDK module implements client behavior.
import { randomUUID } from "node:crypto";
import { EventHub } from "./event-hub.js";
import { normalizeGatewayEvent } from "./normalize.js";
import { GatewayClientTransport, isConnectableTransport } from "./transport.js";
import type {
AgentsCreateParams,
AgentsDeleteParams,
AgentsUpdateParams,
AgentRunParams,
ApprovalDecisionParams,
ArtifactQuery,
ArtifactsDownloadResult,
ArtifactsGetResult,
ArtifactsListResult,
EnvironmentSummary,
EnvironmentsListResult,
GatewayEvent,
GatewayRequestOptions,
OpenClawEvent,
OpenClawTransport,
RunCreateParams,
RunResult,
RunTimestamp,
SessionCreateParams,
SessionSendParams,
SessionTarget,
TasksCancelResult,
TasksGetResult,
TasksListParams,
TasksListResult,
ToolsEffectiveParams,
ToolInvokeParams,
ToolInvokeResult,
} from "./types.js";
// High-level OpenClaw SDK client. Namespaces below translate friendly SDK calls
// into current Gateway RPC methods and normalize event streams for consumers.
const MAX_REPLAY_RUNS = 100;
const MAX_REPLAY_EVENTS_PER_RUN = 500;
const MAX_NORMALIZED_REPLAY_EVENTS = 2000;
/** Connection and transport options for the OpenClaw SDK client. */
export type OpenClawOptions = {
gateway?: "auto" | (string & {});
url?: string;
token?: string;
password?: string;
requestTimeoutMs?: number;
transport?: OpenClawTransport;
};
function resolveGatewayUrl(options: OpenClawOptions): string | undefined {
if (options.url) {
return options.url;
}
if (options.gateway && options.gateway !== "auto") {
return options.gateway;
}
return undefined;
}
function runStatusFromWaitPayload(payload: unknown): RunResult["status"] {
// Gateway wait payloads come from several runtime paths. Preserve timeout vs
// cancellation semantics from metadata instead of trusting one status field.
const record =
typeof payload === "object" && payload !== null
? (payload as Record<string, unknown> & { aborted?: unknown; status?: unknown })
: {};
const status = typeof record.status === "string" ? record.status.toLowerCase() : undefined;
const stopReason = typeof record.stopReason === "string" ? record.stopReason.toLowerCase() : "";
const pendingError = record.pendingError === true;
const timeoutPhase =
typeof record.timeoutPhase === "string" ? record.timeoutPhase.toLowerCase() : undefined;
const statusAlreadyTimeoutAttributed = status === "timeout" || status === "timed_out";
const hardTimeout =
!pendingError &&
((stopReason !== "restart" &&
record.providerStarted === true &&
statusAlreadyTimeoutAttributed) ||
timeoutPhase === "preflight" ||
timeoutPhase === "provider" ||
timeoutPhase === "post_turn");
const hasTerminalTimeoutMetadata =
readOptionalTimestamp(record.endedAt) !== undefined ||
(!pendingError && readOptionalString(record.error) !== undefined) ||
stopReason.length > 0 ||
typeof record.livenessState === "string" ||
record.yielded === true;
if (hardTimeout) {
return "timed_out";
}
if (
status === "aborted" ||
status === "cancelled" ||
status === "canceled" ||
status === "killed" ||
stopReason === "aborted" ||
stopReason === "cancelled" ||
stopReason === "canceled" ||
stopReason === "killed" ||
stopReason === "auth-revoked" ||
stopReason === "restart" ||
stopReason === "rpc" ||
stopReason === "user" ||
(record.aborted === true && stopReason === "stop")
) {
return "cancelled";
}
if (status === "ok" || status === "completed" || status === "succeeded") {
return "completed";
}
if (status === "timeout") {
if (
stopReason === "timeout" ||
stopReason === "timed_out" ||
record.aborted === true ||
hasTerminalTimeoutMetadata
) {
return "timed_out";
}
return "accepted";
}
if (status === "timed_out") {
return "timed_out";
}
if (status === "accepted") {
return "accepted";
}
return "failed";
}
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readOptionalTimestamp(value: unknown): RunTimestamp | undefined {
if (typeof value === "string" && value.length > 0) {
return value;
}
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizeTimeoutMs(timeoutMs: number | undefined): number | undefined {
if (timeoutMs === undefined) {
return undefined;
}
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
throw new Error("timeoutMs must be a finite non-negative number");
}
return Math.floor(timeoutMs);
}
function timeoutSecondsFromMs(timeoutMs: number | undefined): number | undefined {
const normalized = normalizeTimeoutMs(timeoutMs);
if (normalized === undefined) {
return undefined;
}
return normalized === 0 ? 0 : Math.ceil(normalized / 1000);
}
function splitModelRef(model: string | undefined): { provider?: string; model?: string } {
if (!model) {
return {};
}
const index = model.indexOf("/");
if (index <= 0 || index === model.length - 1) {
return { model };
}
return {
provider: model.slice(0, index),
model: model.slice(index + 1),
};
}
function assertNoUnsupportedRunOptions(params: AgentRunParams): void {
const unsupported = [
params.workspace ? "workspace" : undefined,
params.runtime ? "runtime" : undefined,
params.environment ? "environment" : undefined,
params.approvals ? "approvals" : undefined,
].filter((value): value is string => Boolean(value));
if (unsupported.length === 0) {
return;
}
throw new Error(
`OpenClaw Gateway does not support per-run SDK option${
unsupported.length === 1 ? "" : "s"
} yet: ${unsupported.join(", ")}`,
);
}
function buildAgentParams(params: AgentRunParams): Record<string, unknown> {
assertNoUnsupportedRunOptions(params);
const modelRef = splitModelRef(params.model);
const timeoutSeconds = timeoutSecondsFromMs(params.timeoutMs);
return {
message: params.input,
...(params.agentId ? { agentId: params.agentId } : {}),
...(modelRef.provider ? { provider: modelRef.provider } : {}),
...(modelRef.model ? { model: modelRef.model } : {}),
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.thinking ? { thinking: params.thinking } : {}),
...(typeof params.deliver === "boolean" ? { deliver: params.deliver } : {}),
...(params.attachments ? { attachments: params.attachments } : {}),
...(timeoutSeconds !== undefined ? { timeout: timeoutSeconds } : {}),
...(params.label ? { label: params.label } : {}),
idempotencyKey: params.idempotencyKey ?? randomUUID(),
};
}
function unsupportedGatewayApi(api: string): never {
throw new Error(`${api} is not supported by the current OpenClaw Gateway yet`);
}
type ChatProjectionState = "delta" | "final";
type ChatProjection = {
state: ChatProjectionState;
payload: Record<string, unknown>;
};
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
function hasArtifactQueryScope(params: unknown): params is ArtifactQuery {
const record = asRecord(params);
return [record.sessionKey, record.runId, record.taskId].some(
(value) => typeof value === "string" && value.trim().length > 0,
);
}
function requireArtifactQueryScope(api: string, params: unknown): ArtifactQuery {
if (!hasArtifactQueryScope(params)) {
throw new Error(`${api} requires one of sessionKey, runId, or taskId`);
}
return params;
}
function hasToolsEffectiveSessionKey(params: unknown): params is ToolsEffectiveParams {
const record = asRecord(params);
return typeof record.sessionKey === "string" && record.sessionKey.trim().length > 0;
}
function requireToolsEffectiveSessionKey(params: unknown): ToolsEffectiveParams {
if (!hasToolsEffectiveSessionKey(params)) {
throw new Error("oc.tools.effective requires sessionKey");
}
return params;
}
function readChatProjection(event: OpenClawEvent): ChatProjection | undefined {
const raw = event.raw;
if (event.type !== "raw" || raw?.event !== "chat") {
return undefined;
}
const payload = asRecord(raw.payload);
return payload.state === "delta" || payload.state === "final"
? { state: payload.state, payload }
: undefined;
}
function readChatProjectionText(payload: Record<string, unknown>): string | undefined {
const message = asRecord(payload.message);
const content = message.content;
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return undefined;
}
const text = content
.map((part) => {
const record = asRecord(part);
return record.type === "text" && typeof record.text === "string" ? record.text : "";
})
.join("");
return text.length > 0 ? text : undefined;
}
function readChatProjectionDeltaText(payload: Record<string, unknown>): string | undefined {
return typeof payload.deltaText === "string" ? payload.deltaText : undefined;
}
function readChatProjectionReplace(payload: Record<string, unknown>): boolean {
return payload.replace === true;
}
function isAssistantRunEvent(event: OpenClawEvent): boolean {
return event.type === "assistant.delta" || event.type === "assistant.message";
}
function isTerminalRunEvent(event: OpenClawEvent): boolean {
return (
event.type === "run.completed" ||
event.type === "run.failed" ||
event.type === "run.cancelled" ||
event.type === "run.timed_out"
);
}
function normalizeChatProjectionEvent(
event: OpenClawEvent,
projection: ChatProjection,
previousText: string | undefined,
): OpenClawEvent {
const text = readChatProjectionText(projection.payload);
const deltaText = readChatProjectionDeltaText(projection.payload);
const hasPreviousText = previousText !== undefined;
const isReplacement = readChatProjectionReplace(projection.payload);
return {
...event,
type: projection.state === "delta" ? "assistant.delta" : "run.completed",
data:
projection.state === "delta"
? text !== undefined
? {
text,
delta: hasPreviousText ? (deltaText ?? text) : text,
...(isReplacement ? { replace: true } : {}),
}
: event.data
: { phase: "end", ...(text !== undefined ? { outputText: text } : {}) },
};
}
/** Root SDK client with namespaces for agents, sessions, runs, and gateway APIs. */
export class OpenClaw {
readonly agents: AgentsNamespace;
readonly sessions: SessionsNamespace;
readonly runs: RunsNamespace;
readonly tasks: TasksNamespace;
readonly models: ModelsNamespace;
readonly tools: ToolsNamespace;
readonly artifacts: ArtifactsNamespace;
readonly approvals: ApprovalsNamespace;
readonly environments: EnvironmentsNamespace;
private readonly transport: OpenClawTransport;
private readonly normalizedEvents = new EventHub<OpenClawEvent>({
replayLimit: MAX_NORMALIZED_REPLAY_EVENTS,
});
private readonly replayByRunId = new Map<string, OpenClawEvent[]>();
private connected = false;
private closed = false;
private eventPumpPromise: Promise<void> | null = null;
private eventPumpReady: Promise<void> | null = null;
private closePromise: Promise<void> | null = null;
constructor(options: OpenClawOptions = {}) {
this.transport =
options.transport ??
new GatewayClientTransport({
url: resolveGatewayUrl(options),
token: options.token,
password: options.password,
requestTimeoutMs: options.requestTimeoutMs,
});
this.agents = new AgentsNamespace(this);
this.sessions = new SessionsNamespace(this);
this.runs = new RunsNamespace(this);
this.tasks = new TasksNamespace(this);
this.models = new ModelsNamespace(this);
this.tools = new ToolsNamespace(this);
this.artifacts = new ArtifactsNamespace(this);
this.approvals = new ApprovalsNamespace(this);
this.environments = new EnvironmentsNamespace(this);
}
async connect(): Promise<void> {
this.assertOpen();
if (this.connected) {
await this.startEventPump();
this.assertOpen();
return;
}
if (isConnectableTransport(this.transport)) {
await this.transport.connect();
}
this.assertOpen();
this.connected = true;
await this.startEventPump();
this.assertOpen();
}
async close(): Promise<void> {
if (this.closePromise) {
return await this.closePromise;
}
if (this.closed) {
return;
}
this.closed = true;
this.closePromise = (async () => {
try {
await this.transport.close?.();
await this.eventPumpPromise?.catch(() => {});
} finally {
this.normalizedEvents.close();
this.eventPumpPromise = null;
this.eventPumpReady = null;
this.connected = false;
}
})();
try {
await this.closePromise;
} finally {
this.closePromise = null;
}
}
async request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T> {
await this.connect();
this.assertOpen();
return await this.transport.request<T>(method, params, options);
}
events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable<OpenClawEvent> {
return this.iterateEvents(filter);
}
runEvents(
runId: string,
filter?: (event: OpenClawEvent) => boolean,
): AsyncIterable<OpenClawEvent> {
return this.iterateRunEvents(runId, filter);
}
rawEvents(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
this.assertOpen();
return this.transport.events(filter);
}
private assertOpen(): void {
if (this.closed) {
throw new Error("OpenClaw SDK client is closed");
}
}
private async *iterateEvents(
filter?: (event: OpenClawEvent) => boolean,
): AsyncIterable<OpenClawEvent> {
await this.connect();
this.assertOpen();
for await (const event of this.normalizedEvents.stream(filter)) {
yield event;
}
}
private async *iterateRunEvents(
runId: string,
filter?: (event: OpenClawEvent) => boolean,
): AsyncIterable<OpenClawEvent> {
await this.connect();
this.assertOpen();
const replayEvents = this.replaySnapshot(runId);
let hasCanonicalAssistantRunEvent = replayEvents.some(isAssistantRunEvent);
let hasTerminalRunEvent = replayEvents.some(isTerminalRunEvent);
let previousChatProjectionText: string | undefined;
const toRunStreamEvent = (event: OpenClawEvent): OpenClawEvent | undefined => {
const chatProjection = readChatProjection(event);
if (chatProjection?.state === "delta") {
if (hasCanonicalAssistantRunEvent) {
return undefined;
}
const runEvent = normalizeChatProjectionEvent(
event,
chatProjection,
previousChatProjectionText,
);
const text = readChatProjectionText(chatProjection.payload);
if (text !== undefined) {
previousChatProjectionText = text;
}
return runEvent;
}
if (chatProjection?.state === "final") {
if (hasTerminalRunEvent) {
return undefined;
}
hasTerminalRunEvent = true;
return normalizeChatProjectionEvent(event, chatProjection, previousChatProjectionText);
}
if (isAssistantRunEvent(event)) {
hasCanonicalAssistantRunEvent = true;
}
if (isTerminalRunEvent(event)) {
hasTerminalRunEvent = true;
}
return event;
};
const matches = (event: OpenClawEvent) => event.runId === runId;
const liveSource = this.normalizedEvents.stream(matches, { replay: true });
const live = liveSource[Symbol.asyncIterator]();
const seen = new Set<string>();
try {
for (const event of replayEvents) {
if (seen.has(event.id)) {
continue;
}
seen.add(event.id);
const runEvent = toRunStreamEvent(event);
if (!runEvent || (filter && !filter(runEvent))) {
continue;
}
yield runEvent;
}
while (true) {
const next = await live.next();
if (next.done) {
break;
}
if (seen.has(next.value.id)) {
continue;
}
seen.add(next.value.id);
const runEvent = toRunStreamEvent(next.value);
if (!runEvent || (filter && !filter(runEvent))) {
continue;
}
yield runEvent;
}
} finally {
await live.return?.();
}
}
private startEventPump(): Promise<void> {
if (this.eventPumpReady) {
return this.eventPumpReady;
}
let markReady = () => {};
let ready = false;
this.eventPumpReady = new Promise<void>((resolve) => {
markReady = () => {
if (ready) {
return;
}
ready = true;
resolve();
};
});
this.eventPumpPromise = (async () => {
let iterator: AsyncIterator<GatewayEvent> | undefined;
let pumpError: unknown;
let hasPumpError = false;
try {
iterator = this.transport.events()[Symbol.asyncIterator]();
while (true) {
const next = iterator.next();
await Promise.resolve();
markReady();
const result = await next;
if (result.done) {
break;
}
const normalized = normalizeGatewayEvent(result.value);
this.recordReplayEvent(normalized);
this.normalizedEvents.publish(normalized);
}
} catch (error) {
pumpError = error;
hasPumpError = true;
} finally {
markReady();
try {
await iterator?.return?.();
} catch (error) {
if (!hasPumpError) {
pumpError = error;
hasPumpError = true;
}
}
}
if (hasPumpError) {
this.normalizedEvents.close(pumpError);
return;
}
this.normalizedEvents.close();
})().catch((error: unknown) => {
markReady();
this.normalizedEvents.close(error);
});
return this.eventPumpReady;
}
private recordReplayEvent(event: OpenClawEvent): void {
if (!event.runId) {
return;
}
let events = this.replayByRunId.get(event.runId);
if (!events) {
if (this.replayByRunId.size >= MAX_REPLAY_RUNS) {
const oldestRunId = this.replayByRunId.keys().next().value;
if (oldestRunId) {
this.replayByRunId.delete(oldestRunId);
}
}
events = [];
this.replayByRunId.set(event.runId, events);
}
events.push(event);
if (events.length > MAX_REPLAY_EVENTS_PER_RUN) {
events.splice(0, events.length - MAX_REPLAY_EVENTS_PER_RUN);
}
}
private replaySnapshot(runId: string): OpenClawEvent[] {
return [...(this.replayByRunId.get(runId) ?? [])];
}
}
/** Agent-scoped helper for runs and identity lookups. */
export class Agent {
constructor(
private readonly client: OpenClaw,
readonly id: string,
) {}
async run(input: string | Omit<AgentRunParams, "agentId">): Promise<Run> {
const params: AgentRunParams =
typeof input === "string" ? { input, agentId: this.id } : { ...input, agentId: this.id };
return await this.client.runs.create(params);
}
async identity(params?: { sessionKey?: string }): Promise<unknown> {
return await this.client.request("agent.identity.get", {
agentId: this.id,
...(params?.sessionKey ? { sessionKey: params.sessionKey } : {}),
});
}
}
/** Run handle for streaming events, waiting, and cancellation. */
export class Run {
constructor(
private readonly client: OpenClaw,
readonly id: string,
readonly sessionKey?: string,
) {}
events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable<OpenClawEvent> {
return this.client.runEvents(this.id, filter);
}
async wait(options?: { timeoutMs?: number }): Promise<RunResult> {
const timeoutMs = normalizeTimeoutMs(options?.timeoutMs);
const raw = await this.client.request(
"agent.wait",
{
runId: this.id,
...(timeoutMs !== undefined ? { timeoutMs } : {}),
},
{ timeoutMs: null },
);
const record = asRecord(raw);
const status = runStatusFromWaitPayload(raw);
const error = readOptionalString(record.error)
? { message: readOptionalString(record.error) ?? "run failed" }
: undefined;
return {
runId: this.id,
status,
sessionKey: readOptionalString(record.sessionKey) ?? this.sessionKey,
sessionId: readOptionalString(record.sessionId),
startedAt: readOptionalTimestamp(record.startedAt),
endedAt: readOptionalTimestamp(record.endedAt),
...(error ? { error } : {}),
raw,
};
}
async cancel(): Promise<unknown> {
return await this.client.request("sessions.abort", {
runId: this.id,
...(this.sessionKey ? { key: this.sessionKey } : {}),
});
}
}
/** Session handle for sending messages and session-scoped mutations. */
export class Session {
constructor(
private readonly client: OpenClaw,
readonly key: string,
readonly info?: unknown,
) {}
async send(input: string | Omit<SessionSendParams, "key">): Promise<Run> {
const params: SessionSendParams =
typeof input === "string" ? { key: this.key, message: input } : { ...input, key: this.key };
const timeoutMs = normalizeTimeoutMs(params.timeoutMs);
if (timeoutMs !== undefined) {
params.timeoutMs = timeoutMs;
}
const raw = await this.client.request("sessions.send", params, {
expectFinal: true,
...(timeoutMs !== undefined ? { timeoutMs: timeoutMs === 0 ? null : timeoutMs } : {}),
});
const record = asRecord(raw);
const runId = readOptionalString(record.runId);
if (!runId) {
throw new Error("sessions.send did not return a runId");
}
return new Run(this.client, runId, this.key);
}
async abort(runId?: string): Promise<unknown> {
return await this.client.request("sessions.abort", {
key: this.key,
...(runId ? { runId } : {}),
});
}
async patch(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("sessions.patch", { ...params, key: this.key });
}
async compact(params?: { maxLines?: number }): Promise<unknown> {
return await this.client.request(
"sessions.compact",
{ key: this.key, ...params },
// The server owns the configurable terminal compaction deadline.
{ timeoutMs: null },
);
}
}
/** Agent management namespace. */
export class AgentsNamespace {
constructor(private readonly client: OpenClaw) {}
async list(params?: Record<string, unknown>): Promise<unknown> {
return await this.client.request("agents.list", params === undefined ? {} : params);
}
async get(id: string): Promise<Agent> {
return new Agent(this.client, id);
}
async create(params: AgentsCreateParams): Promise<unknown> {
return await this.client.request("agents.create", params);
}
async update(params: AgentsUpdateParams): Promise<unknown> {
return await this.client.request("agents.update", params);
}
async delete(params: AgentsDeleteParams): Promise<unknown> {
return await this.client.request("agents.delete", params);
}
}
/** Session management namespace. */
export class SessionsNamespace {
constructor(private readonly client: OpenClaw) {}
async list(params?: Record<string, unknown>): Promise<unknown> {
return await this.client.request("sessions.list", params === undefined ? {} : params);
}
async create(params: SessionCreateParams = {}): Promise<Session> {
const raw = await this.client.request("sessions.create", params);
const record = asRecord(raw);
const key =
readOptionalString(record.key) ?? readOptionalString(record.sessionKey) ?? params.key;
if (!key) {
throw new Error("sessions.create did not return a session key");
}
return new Session(this.client, key, raw);
}
async get(target: SessionTarget | string): Promise<Session> {
const key = typeof target === "string" ? target : target.key;
return new Session(this.client, key);
}
async resolve(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("sessions.resolve", params);
}
async send(input: SessionSendParams): Promise<Run> {
return await new Session(this.client, input.key).send(input);
}
}
/** Run creation and lifecycle namespace. */
export class RunsNamespace {
constructor(private readonly client: OpenClaw) {}
async create(params: RunCreateParams): Promise<Run> {
const timeoutMs = normalizeTimeoutMs(params.timeoutMs);
const normalizedParams = timeoutMs !== undefined ? { ...params, timeoutMs } : params;
const raw = await this.client.request("agent", buildAgentParams(normalizedParams), {
expectFinal: false,
...(timeoutMs !== undefined ? { timeoutMs: timeoutMs === 0 ? null : timeoutMs } : {}),
});
const record = asRecord(raw);
const runId = readOptionalString(record.runId);
if (!runId) {
throw new Error("agent did not return a runId");
}
return new Run(this.client, runId, readOptionalString(record.sessionKey) ?? params.sessionKey);
}
async get(runId: string): Promise<Run> {
return new Run(this.client, runId);
}
events(runId: string): AsyncIterable<OpenClawEvent> {
return new Run(this.client, runId).events();
}
async wait(runId: string, options?: { timeoutMs?: number }): Promise<RunResult> {
return await new Run(this.client, runId).wait(options);
}
async cancel(runId: string, sessionKey?: string): Promise<unknown> {
return await new Run(this.client, runId, sessionKey).cancel();
}
}
class RpcNamespace {
constructor(
protected readonly client: OpenClaw,
private readonly prefix: string,
) {}
protected async call<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T> {
return await this.client.request<T>(`${this.prefix}.${method}`, params, options);
}
}
/** Task query and cancellation namespace. */
export class TasksNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "tasks");
}
async list(params?: TasksListParams): Promise<TasksListResult> {
return await this.call("list", params === undefined ? {} : params);
}
async get(taskId: string): Promise<TasksGetResult> {
return await this.call("get", { taskId });
}
async cancel(taskId: string, options?: { reason?: string }): Promise<TasksCancelResult> {
return await this.call("cancel", {
taskId,
...(options?.reason ? { reason: options.reason } : {}),
});
}
}
/** Model catalog and auth status namespace. */
export class ModelsNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "models");
}
async list(params?: unknown): Promise<unknown> {
return await this.call("list", params === undefined ? {} : params);
}
async status(params?: unknown): Promise<unknown> {
return await this.call("authStatus", params);
}
}
/** Tool catalog, effective tool, and direct invocation namespace. */
export class ToolsNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "tools");
}
async list(params?: unknown): Promise<unknown> {
return await this.call("catalog", params === undefined ? {} : params);
}
async effective(params: ToolsEffectiveParams): Promise<unknown> {
return await this.call("effective", requireToolsEffectiveSessionKey(params));
}
async invoke(name: string, params?: ToolInvokeParams): Promise<ToolInvokeResult> {
return await this.call("invoke", {
name,
...(params?.args ? { args: params.args } : {}),
...(params?.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params?.agentId ? { agentId: params.agentId } : {}),
...(typeof params?.confirm === "boolean" ? { confirm: params.confirm } : {}),
...(params?.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
});
}
}
/** Run/session artifact listing and download namespace. */
export class ArtifactsNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "artifacts");
}
async list(params: ArtifactQuery): Promise<ArtifactsListResult> {
return await this.call("list", requireArtifactQueryScope("oc.artifacts.list", params));
}
async get(id: string, params: ArtifactQuery): Promise<ArtifactsGetResult> {
return await this.call("get", {
...requireArtifactQueryScope("oc.artifacts.get", params),
artifactId: id,
});
}
async download(id: string, params: ArtifactQuery): Promise<ArtifactsDownloadResult> {
return await this.call("download", {
...requireArtifactQueryScope("oc.artifacts.download", params),
artifactId: id,
});
}
}
/** Approval request listing and response namespace. */
export class ApprovalsNamespace {
constructor(private readonly client: OpenClaw) {}
async list(params?: unknown): Promise<unknown> {
return await this.client.request("exec.approval.list", params === undefined ? {} : params);
}
async respond(approvalId: string, params: ApprovalDecisionParams): Promise<unknown> {
return await this.client.request("exec.approval.resolve", {
id: approvalId,
decision: params.decision,
});
}
}
/** Environment discovery namespace. */
export class EnvironmentsNamespace extends RpcNamespace {
constructor(client: OpenClaw) {
super(client, "environments");
}
async list(params?: unknown): Promise<EnvironmentsListResult> {
return await this.call("list", params === undefined ? {} : params);
}
async create(params?: unknown): Promise<unknown> {
void params;
return unsupportedGatewayApi("oc.environments.create");
}
async status(environmentId: string): Promise<EnvironmentSummary> {
return await this.call("status", { environmentId });
}
async delete(environmentId: string): Promise<unknown> {
void environmentId;
return unsupportedGatewayApi("oc.environments.delete");
}
}

View File

@@ -0,0 +1,144 @@
// OpenClaw SDK module implements event hub behavior.
import type { GatewayEvent } from "./types.js";
// Async event hub with bounded replay for SDK event streams.
type Listener<T> = (event: T) => void;
/** Replay settings for EventHub streams. */
export type EventHubOptions = {
replayLimit?: number;
};
/** Per-stream options for including replayed events. */
export type EventStreamOptions = {
replay?: boolean;
};
/** Small publish/subscribe hub used by SDK transports and normalized events. */
export class EventHub<T> {
private readonly replayLimit: number;
private readonly replayEvents: T[] = [];
private closed = false;
private closeError: unknown;
private hasCloseError = false;
private readonly listeners = new Set<Listener<T>>();
private readonly waiters = new Set<() => void>();
constructor(options: EventHubOptions = {}) {
this.replayLimit = options.replayLimit ?? 0;
}
publish(event: T): void {
if (this.closed) {
return;
}
if (this.replayLimit > 0) {
this.replayEvents.push(event);
const overflow = this.replayEvents.length - this.replayLimit;
if (overflow > 0) {
this.replayEvents.splice(0, overflow);
}
}
for (const listener of this.listeners) {
listener(event);
}
}
close(error?: unknown): void {
const hasError = arguments.length > 0;
if (hasError) {
this.closeError = error;
this.hasCloseError = true;
}
this.closed = true;
this.replayEvents.length = 0;
this.listeners.clear();
for (const wake of this.waiters) {
wake();
}
this.waiters.clear();
}
snapshot(filter?: (event: T) => boolean): T[] {
return filter ? this.replayEvents.filter(filter) : [...this.replayEvents];
}
stream(filter?: (event: T) => boolean, options: EventStreamOptions = {}): AsyncIterable<T> {
return {
[Symbol.asyncIterator]: (): AsyncIterator<T> => {
const queue: T[] = options.replay ? this.snapshot(filter) : [];
let stopped = false;
let wake: (() => void) | null = null;
const wakePending = () => {
const pending = wake;
if (!pending) {
return;
}
wake = null;
this.waiters.delete(pending);
pending();
};
const listener = (event: T) => {
if (!filter || filter(event)) {
queue.push(event);
wakePending();
}
};
const cleanup = () => {
if (stopped) {
return;
}
stopped = true;
this.listeners.delete(listener);
wakePending();
};
this.listeners.add(listener);
return {
next: async (): Promise<IteratorResult<T>> => {
while (true) {
if (stopped) {
break;
}
if (queue.length > 0) {
return { done: false, value: queue.shift() as T };
}
if (this.closed) {
break;
}
await new Promise<void>((resolve) => {
const wakeCurrent = () => {
if (wake === wakeCurrent) {
wake = null;
}
this.waiters.delete(wakeCurrent);
resolve();
};
wake = wakeCurrent;
this.waiters.add(wakeCurrent);
});
}
cleanup();
if (this.hasCloseError) {
throw this.closeError;
}
return { done: true, value: undefined as never };
},
return: async (): Promise<IteratorResult<T>> => {
cleanup();
return { done: true, value: undefined as never };
},
};
},
};
}
}
export function isGatewayEvent(value: unknown): value is GatewayEvent {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { event?: unknown }).event === "string"
);
}

View File

@@ -0,0 +1,738 @@
// OpenClaw SDK tests cover index behavior.
import type { AddressInfo } from "node:net";
import net from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer, type RawData, type WebSocket } from "ws";
import { installGatewayTestHooks, startServer } from "../../../src/gateway/test-helpers.js";
import { emitAgentEvent, registerAgentRunContext } from "../../../src/infra/agent-events.js";
import { GatewayClientTransport, OpenClaw } from "./index.js";
type JsonObject = Record<string, unknown>;
type FakeGatewayRequest = {
id: string;
method: string;
params?: unknown;
};
type FakeGateway = {
url: string;
requests: FakeGatewayRequest[];
close: () => Promise<void>;
};
const servers: WebSocketServer[] = [];
function expectJsonObject(value: unknown): JsonObject {
expect(value && typeof value).toBe("object");
return value as JsonObject;
}
function sendJson(socket: WebSocket, payload: JsonObject): void {
socket.send(JSON.stringify(payload));
}
function readRawMessage(raw: RawData): string {
if (typeof raw === "string") {
return raw;
}
if (Buffer.isBuffer(raw)) {
return raw.toString("utf8");
}
if (raw instanceof ArrayBuffer) {
return Buffer.from(raw).toString("utf8");
}
return Buffer.concat(raw).toString("utf8");
}
async function reservePort(): Promise<number> {
const server = net.createServer();
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const { port } = server.address() as AddressInfo;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return port;
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
async function createFakeGateway(port = 0): Promise<FakeGateway> {
const server = new WebSocketServer({ host: "127.0.0.1", port });
servers.push(server);
await new Promise<void>((resolve) => {
server.once("listening", resolve);
});
let seq = 1;
const requests: FakeGatewayRequest[] = [];
const sockets = new Set<WebSocket>();
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
sendJson(socket, {
type: "event",
event: "connect.challenge",
seq: seq++,
payload: { nonce: "sdk-e2e-nonce" },
});
socket.on("message", (raw) => {
const frame = JSON.parse(readRawMessage(raw)) as FakeGatewayRequest;
requests.push(frame);
const reply = (payload: JsonObject): void => {
sendJson(socket, { type: "res", id: frame.id, ok: true, payload });
};
if (frame.method === "connect") {
reply({
type: "hello-ok",
protocol: 1,
server: { version: "sdk-e2e", connId: "conn-sdk-e2e" },
features: {
methods: [
"agent",
"agent.wait",
"agent.identity.get",
"agents.create",
"agents.delete",
"agents.list",
"agents.update",
"connect",
"exec.approval.list",
"exec.approval.resolve",
"models.authStatus",
"models.list",
"sessions.abort",
"sessions.create",
"sessions.compact",
"sessions.list",
"sessions.patch",
"sessions.resolve",
"sessions.send",
"tasks.cancel",
"tasks.get",
"tasks.list",
"tools.catalog",
"tools.effective",
"tools.invoke",
],
events: ["agent", "sessions.changed"],
},
snapshot: {
presence: [],
health: {},
stateVersion: { presence: 0, health: 0 },
uptimeMs: 1,
},
auth: { role: "operator", scopes: [] },
policy: {
maxPayload: 262144,
maxBufferedBytes: 262144,
tickIntervalMs: 30000,
},
});
return;
}
if (frame.method === "agents.list") {
reply({ agents: [{ id: "main" }] });
return;
}
if (frame.method === "agent.identity.get") {
reply({ agentId: "main", ...(frame.params as JsonObject | undefined) });
return;
}
if (
frame.method === "agents.create" ||
frame.method === "agents.update" ||
frame.method === "agents.delete"
) {
reply({ ok: true, method: frame.method, params: frame.params as JsonObject | undefined });
return;
}
if (frame.method === "agent") {
const params = frame.params as { sessionKey?: string } | undefined;
reply({
status: "accepted",
runId: "run-sdk-e2e",
sessionKey: params?.sessionKey,
});
setTimeout(() => {
sendJson(socket, {
type: "event",
event: "agent",
seq: seq++,
payload: {
runId: "run-sdk-e2e",
sessionKey: params?.sessionKey,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "start" },
},
});
sendJson(socket, {
type: "event",
event: "agent",
seq: seq++,
payload: {
runId: "run-sdk-e2e",
sessionKey: params?.sessionKey,
stream: "assistant",
ts: Date.now(),
data: { delta: "hello from fake gateway" },
},
});
sendJson(socket, {
type: "event",
event: "agent",
seq: seq++,
payload: {
runId: "run-sdk-e2e",
sessionKey: params?.sessionKey,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
},
});
}, 50);
return;
}
if (frame.method === "agent.wait") {
reply({
status: "ok",
runId: "run-sdk-e2e",
sessionKey: "main",
startedAt: 123,
endedAt: 456,
});
return;
}
if (frame.method === "sessions.list") {
reply({ sessions: [{ key: "sdk-session" }] });
return;
}
if (frame.method === "sessions.create") {
const params = frame.params as { key?: string } | undefined;
reply({ key: params?.key ?? "sdk-session" });
return;
}
if (frame.method === "sessions.resolve") {
reply({ key: "sdk-session", params: frame.params as JsonObject | undefined });
return;
}
if (frame.method === "sessions.send") {
const params = frame.params as { key?: string } | undefined;
reply({ status: "ok", runId: "run-session-e2e", sessionKey: params?.key });
return;
}
if (frame.method === "sessions.abort") {
reply({
ok: true,
abortedRunId: (frame.params as { runId?: string } | undefined)?.runId ?? "run-sdk-e2e",
status: "aborted",
});
return;
}
if (frame.method === "sessions.patch" || frame.method === "sessions.compact") {
reply({ ok: true, method: frame.method, params: frame.params as JsonObject | undefined });
return;
}
if (frame.method === "tasks.list") {
reply({
tasks: [
{
id: "task-sdk-e2e",
status: "running",
title: "SDK task",
runId: "run-sdk-e2e",
sessionKey: "sdk-session",
},
],
});
return;
}
if (frame.method === "tasks.get") {
reply({
task: {
id: (frame.params as { taskId?: string } | undefined)?.taskId ?? "task-sdk-e2e",
status: "running",
title: "SDK task",
},
});
return;
}
if (frame.method === "tasks.cancel") {
reply({
found: true,
cancelled: true,
task: {
id: (frame.params as { taskId?: string } | undefined)?.taskId ?? "task-sdk-e2e",
status: "cancelled",
},
});
return;
}
if (frame.method === "models.list") {
reply({ models: [{ id: "gpt-5.4" }] });
return;
}
if (frame.method === "models.authStatus") {
reply({ providers: [] });
return;
}
if (frame.method === "tools.catalog") {
reply({ tools: [{ name: "shell" }] });
return;
}
if (frame.method === "tools.effective") {
reply({ tools: [{ name: "shell", enabled: true }] });
return;
}
if (frame.method === "tools.invoke") {
reply({ ok: true, toolName: "shell", output: { ok: true } });
return;
}
if (frame.method === "exec.approval.list") {
reply({ approvals: [] });
return;
}
if (frame.method === "exec.approval.resolve") {
expect(frame.params).toMatchObject({ id: "approval-1", decision: "allow-once" });
reply({ ok: true, params: frame.params as JsonObject | undefined });
return;
}
sendJson(socket, {
type: "res",
id: frame.id,
ok: false,
error: { code: "UNKNOWN_METHOD", message: `unhandled fake Gateway method ${frame.method}` },
});
});
});
const { port: boundPort } = server.address() as AddressInfo;
return {
url: `ws://127.0.0.1:${boundPort}`,
requests,
close: () => {
const index = servers.indexOf(server);
if (index >= 0) {
servers.splice(index, 1);
}
for (const socket of sockets) {
socket.terminate();
}
sockets.clear();
return new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
describe("OpenClaw SDK websocket e2e", () => {
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
for (const client of server.clients) {
client.terminate();
}
server.close(() => resolve());
}),
),
);
});
it("runs an agent and streams normalized events over a Gateway websocket", async () => {
const gateway = await createFakeGateway();
const transport = new GatewayClientTransport({
url: gateway.url,
deviceIdentity: null,
requestTimeoutMs: 2_000,
});
const oc = new OpenClaw({ transport });
try {
const agent = await oc.agents.get("main");
const run = await agent.run({
input: "say hello",
sessionKey: "main",
idempotencyKey: "sdk-e2e",
});
const seenPromise = (async () => {
const seen: string[] = [];
for await (const event of run.events()) {
seen.push(event.type);
if (event.type === "run.completed") {
break;
}
}
return seen;
})();
const [seen, result] = await Promise.all([
withTimeout(seenPromise, 2_000, "timed out waiting for SDK run events"),
run.wait({ timeoutMs: 2_000 }),
]);
expect(run.id).toBe("run-sdk-e2e");
expect(seen).toEqual(["run.started", "assistant.delta", "run.completed"]);
expect(result.runId).toBe("run-sdk-e2e");
expect(result.sessionKey).toBe("main");
expect(result.status).toBe("completed");
expect(result.startedAt).toBe(123);
expect(result.endedAt).toBe(456);
const cancelResult = expectJsonObject(await run.cancel());
expect(cancelResult.abortedRunId).toBe("run-sdk-e2e");
expect(cancelResult.status).toBe("aborted");
} finally {
await oc.close();
await gateway.close();
}
});
it("covers documented namespace helpers over a Gateway websocket", async () => {
const gateway = await createFakeGateway();
const transport = new GatewayClientTransport({
url: gateway.url,
deviceIdentity: null,
requestTimeoutMs: 2_000,
});
const oc = new OpenClaw({ transport });
try {
const agents = expectJsonObject(await oc.agents.list());
expect(agents.agents).toEqual([{ id: "main" }]);
const agent = await oc.agents.get("main");
const identity = expectJsonObject(await agent.identity({ sessionKey: "sdk-session" }));
expect(identity.agentId).toBe("main");
expect(identity.sessionKey).toBe("sdk-session");
const createAgent = expectJsonObject(
await oc.agents.create({ name: "SDK Agent", workspace: "/tmp/sdk-agent" }),
);
expect(createAgent.method).toBe("agents.create");
expect(createAgent.params).toEqual({ name: "SDK Agent", workspace: "/tmp/sdk-agent" });
const updateAgent = expectJsonObject(
await oc.agents.update({ agentId: "sdk-agent", name: "Renamed SDK Agent" }),
);
expect(updateAgent.method).toBe("agents.update");
expect(updateAgent.params).toEqual({ agentId: "sdk-agent", name: "Renamed SDK Agent" });
const deleteAgent = expectJsonObject(await oc.agents.delete({ agentId: "sdk-agent" }));
expect(deleteAgent.method).toBe("agents.delete");
expect(deleteAgent.params).toEqual({ agentId: "sdk-agent" });
const sessions = expectJsonObject(await oc.sessions.list());
expect(sessions.sessions).toEqual([{ key: "sdk-session" }]);
const session = await oc.sessions.create({ key: "sdk-session", agentId: "main" });
expect(session.key).toBe("sdk-session");
const resolvedSession = expectJsonObject(await oc.sessions.resolve({ key: "sdk-session" }));
expect(resolvedSession.key).toBe("sdk-session");
const sessionRun = await session.send("continue");
expect(sessionRun.id).toBe("run-session-e2e");
const abortSession = expectJsonObject(await session.abort(sessionRun.id));
expect(abortSession.abortedRunId).toBe("run-session-e2e");
const patchSession = expectJsonObject(await session.patch({ label: "Renamed" }));
expect(patchSession.method).toBe("sessions.patch");
const compactSession = expectJsonObject(await session.compact({ maxLines: 200 }));
expect(compactSession.method).toBe("sessions.compact");
const tasks = await oc.tasks.list({ status: "running" });
expect(tasks.tasks).toEqual([
{
id: "task-sdk-e2e",
status: "running",
title: "SDK task",
runId: "run-sdk-e2e",
sessionKey: "sdk-session",
},
]);
const task = await oc.tasks.get("task-sdk-e2e");
expect(task.task).toEqual({
id: "task-sdk-e2e",
status: "running",
title: "SDK task",
});
const cancelledTask = await oc.tasks.cancel("task-sdk-e2e");
expect(cancelledTask.cancelled).toBe(true);
const models = expectJsonObject(await oc.models.list());
expect(models.models).toEqual([{ id: "gpt-5.4" }]);
const modelStatus = expectJsonObject(await oc.models.status({ probe: false }));
expect(modelStatus.providers).toEqual([]);
const tools = expectJsonObject(await oc.tools.list());
expect(tools.tools).toEqual([{ name: "shell" }]);
const effectiveTools = expectJsonObject(
await oc.tools.effective({ sessionKey: "sdk-session" }),
);
expect(effectiveTools.tools).toEqual([{ name: "shell", enabled: true }]);
const toolResult = await oc.tools.invoke("shell", {
args: { command: "pwd" },
sessionKey: "sdk-session",
});
expect(toolResult.ok).toBe(true);
expect(toolResult.toolName).toBe("shell");
expect(toolResult.output).toEqual({ ok: true });
const approvals = expectJsonObject(await oc.approvals.list());
expect(approvals.approvals).toEqual([]);
const approvalResult = expectJsonObject(
await oc.approvals.respond("approval-1", { decision: "allow-once" }),
);
expect(approvalResult.ok).toBe(true);
expect(gateway.requests.map((request) => request.method)).toEqual([
"connect",
"agents.list",
"agent.identity.get",
"agents.create",
"agents.update",
"agents.delete",
"sessions.list",
"sessions.create",
"sessions.resolve",
"sessions.send",
"sessions.abort",
"sessions.patch",
"sessions.compact",
"tasks.list",
"tasks.get",
"tasks.cancel",
"models.list",
"models.authStatus",
"tools.catalog",
"tools.effective",
"tools.invoke",
"exec.approval.list",
"exec.approval.resolve",
]);
const requestParams = new Map(
gateway.requests.map((request) => [request.method, request.params]),
);
expect(requestParams.get("agents.list")).toEqual({});
expect(requestParams.get("sessions.list")).toEqual({});
expect(requestParams.get("models.list")).toEqual({});
expect(requestParams.get("tools.catalog")).toEqual({});
expect(requestParams.get("exec.approval.list")).toEqual({});
} finally {
await oc.close();
await gateway.close();
}
}, 10_000);
it("retries after an initial websocket connection failure", async () => {
const port = await reservePort();
const url = `ws://127.0.0.1:${port}`;
const transport = new GatewayClientTransport({
url,
deviceIdentity: null,
connectChallengeTimeoutMs: 200,
preauthHandshakeTimeoutMs: 200,
requestTimeoutMs: 500,
});
const initialConnectError = await transport.connect().catch((error: unknown) => error);
expect(initialConnectError).toBeInstanceOf(Error);
expect(String(initialConnectError)).toMatch(/ECONNREFUSED/);
const gateway = await createFakeGateway(port);
try {
await expect(transport.connect()).resolves.toBeUndefined();
} finally {
await transport.close();
await gateway.close();
}
});
});
describe("OpenClaw SDK real Gateway e2e", () => {
installGatewayTestHooks({ scope: "test" });
it("streams real Gateway agent events", async () => {
const token = "sdk-real-gateway-token";
const started = await startServer(token, { controlUiEnabled: false });
const transport = new GatewayClientTransport({
url: `ws://127.0.0.1:${started.port}`,
token,
deviceIdentity: null,
requestTimeoutMs: 2_000,
});
const oc = new OpenClaw({ transport });
const runId = "sdk-real-gateway-run";
try {
await oc.connect();
registerAgentRunContext(runId, {
sessionKey: "agent:main:dashboard:sdk-real-gateway",
verboseLevel: "off",
});
const run = await oc.runs.get(runId);
const eventsPromise = (async () => {
const seen: string[] = [];
const sessionKeys: Array<string | undefined> = [];
for await (const event of run.events()) {
seen.push(event.type);
sessionKeys.push(event.sessionKey);
if (event.type === "run.completed") {
break;
}
}
return { seen, sessionKeys };
})();
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "start", startedAt: 111 },
});
emitAgentEvent({
runId,
stream: "assistant",
data: { delta: "hello from real gateway" },
});
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "end", endedAt: 222 },
});
const { seen, sessionKeys } = await withTimeout(
eventsPromise,
2_000,
"timed out waiting for real Gateway SDK events",
);
expect(seen).toEqual(["run.started", "assistant.delta", "run.completed"]);
expect(sessionKeys).toEqual([
"agent:main:dashboard:sdk-real-gateway",
"agent:main:dashboard:sdk-real-gateway",
"agent:main:dashboard:sdk-real-gateway",
]);
} finally {
await oc.close();
await started.server.close();
started.envSnapshot.restore();
}
});
});
const liveGatewayUrl = process.env.OPENCLAW_SDK_LIVE_GATEWAY_URL;
const liveGatewayToken = process.env.OPENCLAW_SDK_LIVE_GATEWAY_TOKEN;
const liveGatewayDescribe = liveGatewayUrl && liveGatewayToken ? describe : describe.skip;
function readLiveTextDelta(data: unknown): string {
if (!data || typeof data !== "object") {
return "";
}
const record = data as Record<string, unknown>;
for (const key of ["delta", "text", "content"]) {
const value = record[key];
if (typeof value === "string") {
return value;
}
}
return "";
}
function expectArrayProperty(value: unknown, property: string): void {
expect(value && typeof value).toBe("object");
const record = value as Record<string, unknown>;
expect(Array.isArray(record[property])).toBe(true);
}
liveGatewayDescribe("OpenClaw SDK live Gateway e2e", () => {
it("connects to a configured Gateway, streams a real run, and waits for completion", async () => {
const oc = new OpenClaw({
url: liveGatewayUrl,
token: liveGatewayToken,
requestTimeoutMs: 20_000,
});
try {
await oc.connect();
expectArrayProperty(await oc.agents.list(), "agents");
expectArrayProperty(await oc.models.status({ probe: false }), "providers");
const agent = await oc.agents.get(process.env.OPENCLAW_SDK_LIVE_AGENT_ID ?? "main");
const run = await agent.run({
input: "Reply with exactly: OPENCLAW_SDK_LIVE_OK",
sessionKey: `sdk-live-e2e-${Date.now()}`,
deliver: false,
timeoutMs: 120_000,
label: "SDK live E2E",
});
const eventsPromise = (async () => {
const eventTypes: string[] = [];
let text = "";
for await (const event of run.events()) {
eventTypes.push(event.type);
if (event.type === "assistant.delta" || event.type === "assistant.message") {
text += readLiveTextDelta(event.data);
}
if (
event.type === "run.completed" ||
event.type === "run.failed" ||
event.type === "run.cancelled" ||
event.type === "run.timed_out"
) {
return { eventTypes, terminal: event.type, text };
}
}
return { eventTypes, terminal: undefined, text };
})();
const result = await run.wait({ timeoutMs: 180_000 });
const events = await withTimeout(
eventsPromise,
5_000,
"timed out waiting for live SDK run events",
);
expect(result.status).toBe("completed");
expect(events.terminal).toBe("run.completed");
expect(events.eventTypes).toContain("run.started");
expect(events.text).toContain("OPENCLAW_SDK_LIVE_OK");
} finally {
await oc.close();
}
}, 240_000);
});

File diff suppressed because it is too large Load Diff

63
packages/sdk/src/index.ts Normal file
View File

@@ -0,0 +1,63 @@
// Public OpenClaw SDK entrypoint. Re-export client namespaces, event helpers,
// transport, and stable SDK types from focused modules.
export {
Agent,
AgentsNamespace,
ApprovalsNamespace,
ArtifactsNamespace,
EnvironmentsNamespace,
ModelsNamespace,
OpenClaw,
Run,
RunsNamespace,
Session,
SessionsNamespace,
TasksNamespace,
ToolsNamespace,
type OpenClawOptions,
} from "./client.js";
export { EventHub, isGatewayEvent } from "./event-hub.js";
export { normalizeGatewayEvent } from "./normalize.js";
export { GatewayClientTransport, isConnectableTransport } from "./transport.js";
export type {
AgentsCreateParams,
AgentsDeleteParams,
AgentsUpdateParams,
AgentRunParams,
ApprovalDecisionParams,
ApprovalMode,
ArtifactQuery,
ArtifactSummary,
ArtifactsDownloadResult,
ArtifactsGetResult,
ArtifactsListResult,
ConnectableOpenClawTransport,
EnvironmentSelection,
EnvironmentSummary,
EnvironmentsListResult,
GatewayEvent,
GatewayRequestOptions,
JsonObject,
OpenClawEvent,
OpenClawEventType,
OpenClawTransport,
RunCreateParams,
RunResult,
RunStatus,
RuntimeSelection,
SDKError,
SDKMessage,
SessionCreateParams,
SessionSendParams,
SessionTarget,
TaskStatus,
TaskSummary,
TasksCancelResult,
TasksGetResult,
TasksListParams,
TasksListResult,
ToolsEffectiveParams,
ToolInvokeParams,
ToolInvokeResult,
WorkspaceSelection,
} from "./types.js";

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { normalizeGatewayEvent } from "./normalize.js";
// Terminal tool/item events are emitted with phase:"end" plus the real status
// (running|completed|failed|blocked), so failed/blocked must not collapse to completed.
function agentItemEvent(data: Record<string, unknown>) {
return { event: "agent", payload: { runId: "r1", stream: "item", data } };
}
describe("normalizeGatewayEvent terminal tool item status", () => {
it("classifies a failed terminal tool item as tool.call.failed", () => {
expect(normalizeGatewayEvent(agentItemEvent({ phase: "end", status: "failed" })).type).toBe(
"tool.call.failed",
);
});
it("classifies a blocked terminal tool item as tool.call.failed", () => {
expect(normalizeGatewayEvent(agentItemEvent({ phase: "end", status: "blocked" })).type).toBe(
"tool.call.failed",
);
});
it("still classifies a completed terminal tool item as tool.call.completed", () => {
expect(normalizeGatewayEvent(agentItemEvent({ phase: "end", status: "completed" })).type).toBe(
"tool.call.completed",
);
});
it("still classifies a phase:end tool item without status as tool.call.completed", () => {
expect(normalizeGatewayEvent(agentItemEvent({ phase: "end" })).type).toBe(
"tool.call.completed",
);
});
});

View File

@@ -0,0 +1,195 @@
// OpenClaw SDK helper module supports normalize behavior.
import type { GatewayEvent, JsonObject, OpenClawEvent, OpenClawEventType } from "./types.js";
// Normalize raw Gateway events into stable SDK event types and common metadata.
function asRecord(value: unknown): JsonObject {
return typeof value === "object" && value !== null ? (value as JsonObject) : {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readLowerString(value: unknown): string | undefined {
return readString(value)?.toLowerCase();
}
function hasHardTimeoutMetadata(data: JsonObject, statusAlreadyTimeoutAttributed = false): boolean {
const timeoutPhase = readLowerString(data.timeoutPhase);
return (
(statusAlreadyTimeoutAttributed && data.providerStarted === true) ||
timeoutPhase === "preflight" ||
timeoutPhase === "provider" ||
timeoutPhase === "post_turn"
);
}
function isLifecycleCancellation(data: JsonObject): boolean {
const status = readLowerString(data.status);
const stopReason = readLowerString(data.stopReason);
return (
status === "aborted" ||
status === "cancelled" ||
status === "canceled" ||
status === "killed" ||
stopReason === "aborted" ||
stopReason === "cancelled" ||
stopReason === "canceled" ||
stopReason === "killed" ||
stopReason === "auth-revoked" ||
stopReason === "restart" ||
stopReason === "rpc" ||
stopReason === "user" ||
(data.aborted === true && stopReason === "stop")
);
}
function normalizeLifecycleEndEventType(data: JsonObject): OpenClawEventType {
const status = readLowerString(data.status);
const stopReason = readLowerString(data.stopReason);
const statusAlreadyTimeoutAttributed =
stopReason !== "restart" &&
(status === "timeout" || status === "timed_out" || data.aborted === true);
if (hasHardTimeoutMetadata(data, statusAlreadyTimeoutAttributed)) {
return "run.timed_out";
}
if (isLifecycleCancellation(data)) {
return "run.cancelled";
}
if (
status === "timeout" ||
status === "timed_out" ||
stopReason === "timeout" ||
stopReason === "timed_out"
) {
return "run.timed_out";
}
if (data.aborted === true) {
return "run.timed_out";
}
return "run.completed";
}
function normalizeAgentEventType(payload: JsonObject): OpenClawEventType {
const stream = readString(payload.stream);
const data = asRecord(payload.data);
const phase = readString(data.phase);
const status = readString(data.status);
if (stream === "assistant") {
return data.delta === true || typeof data.delta === "string"
? "assistant.delta"
: "assistant.message";
}
if (stream === "thinking" || stream === "plan") {
return "thinking.delta";
}
if (stream === "lifecycle") {
if (phase === "start") {
return "run.started";
}
if (phase === "end") {
return normalizeLifecycleEndEventType(data);
}
if (phase === "error") {
if (hasHardTimeoutMetadata(data, false)) {
return "run.timed_out";
}
if (isLifecycleCancellation(data)) {
return "run.cancelled";
}
return "run.failed";
}
}
if (stream === "tool" || stream === "item" || stream === "command_output") {
if (phase === "start" || status === "running") {
return "tool.call.started";
}
if (phase === "delta" || phase === "update") {
return "tool.call.delta";
}
// Terminal tool/item events carry phase:"end" together with the real status, so a failed or
// blocked tool must be classified before the end/completed branch — otherwise phase:"end" wins
// and failures are reported as tool.call.completed.
if (status === "failed" || status === "blocked") {
return "tool.call.failed";
}
if (phase === "end" || status === "completed") {
return "tool.call.completed";
}
return "tool.call.delta";
}
if (stream === "approval") {
return phase === "resolved" ? "approval.resolved" : "approval.requested";
}
if (stream === "patch") {
return "artifact.updated";
}
if (stream === "error") {
return "run.failed";
}
return "raw";
}
function normalizeNamedEventType(event: GatewayEvent): OpenClawEventType {
const payload = asRecord(event.payload);
switch (event.event) {
case "agent":
return normalizeAgentEventType(payload);
case "sessions.changed": {
const reason = readString(payload.reason);
if (reason === "create") {
return "session.created";
}
if (reason === "compact") {
return "session.compacted";
}
return "session.updated";
}
case "session.message":
return "assistant.message";
case "session.tool":
return "tool.call.delta";
case "exec.approval.requested":
case "plugin.approval.requested":
return "approval.requested";
case "exec.approval.resolved":
case "plugin.approval.resolved":
return "approval.resolved";
case "task.updated":
case "tasks.changed":
return "task.updated";
default:
return "raw";
}
}
/** Normalize a raw Gateway event into the public SDK event shape. */
export function normalizeGatewayEvent(event: GatewayEvent): OpenClawEvent {
const payload = asRecord(event.payload);
const runId = readString(payload.runId);
const sessionId = readString(payload.sessionId);
const sessionKey = readString(payload.sessionKey);
const taskId = readString(payload.taskId);
const agentId = readString(payload.agentId);
const ts = readNumber(payload.ts) ?? Date.now();
const idParts = [event.seq ?? "local", event.event, runId, sessionKey, ts].filter(Boolean);
return {
version: 1,
id: idParts.join(":"),
ts,
type: normalizeNamedEventType(event),
...(runId ? { runId } : {}),
...(sessionId ? { sessionId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(taskId ? { taskId } : {}),
...(agentId ? { agentId } : {}),
data: payload.data ?? payload,
raw: event,
};
}

View File

@@ -0,0 +1,406 @@
// OpenClaw SDK tests cover package behavior.
import { spawn, spawnSync, type SpawnOptionsWithoutStdio } from "node:child_process";
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveNpmRunner } from "../../../scripts/npm-runner.mjs";
import { createPnpmRunnerSpawnSpec } from "../../../scripts/pnpm-runner.mjs";
import { getWindowsSystem32ExePath } from "../../../src/infra/windows-install-roots.js";
import { createNodeEvalArgs } from "../../../src/test-utils/node-process.js";
type CommandResult = {
stdout: string;
stderr: string;
};
const COMMAND_TIMEOUT_MS = 120_000;
const tempDirs: string[] = [];
const WORKSPACE_PACKAGE_NAMES = [
"@openclaw/gateway-protocol",
"@openclaw/gateway-client",
"@openclaw/sdk",
] as const;
type PackageManifest = {
name: string;
version: string;
dependencies?: Record<string, string>;
[key: string]: unknown;
};
type PackedPackage = {
manifest: PackageManifest;
tarball: string;
};
function runCommand(
command: string,
args: string[],
options: { cwd: string; timeoutMs?: number } & Pick<
SpawnOptionsWithoutStdio,
"env" | "shell" | "windowsVerbatimArguments"
>,
): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const stdout: string[] = [];
const stderr: string[] = [];
const child = spawn(command, args, {
cwd: options.cwd,
detached: process.platform !== "win32",
env: options.env ?? createCommandEnv(),
shell: options.shell,
stdio: ["ignore", "pipe", "pipe"],
windowsVerbatimArguments: options.windowsVerbatimArguments,
});
const timer = setTimeout(() => {
signalCommandProcess(child, "SIGKILL");
reject(
new Error(
`command timed out after ${options.timeoutMs ?? COMMAND_TIMEOUT_MS}ms: ${[
command,
...args,
].join(" ")}`,
),
);
}, options.timeoutMs ?? COMMAND_TIMEOUT_MS);
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => stdout.push(String(chunk)));
child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
child.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.once("exit", (code, signal) => {
clearTimeout(timer);
const result = { stdout: stdout.join(""), stderr: stderr.join("") };
if (code === 0) {
resolve(result);
return;
}
reject(
new Error(
`command failed (${String(code ?? signal)}): ${[command, ...args].join(" ")}\n` +
`--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`,
),
);
});
});
}
function signalCommandProcess(
child: ReturnType<typeof spawn>,
signal: NodeJS.Signals,
runTaskkill: typeof spawnSync = spawnSync,
): void {
if (process.platform === "win32") {
if (typeof child.pid === "number") {
const args = ["/PID", String(child.pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const taskkillPath = getWindowsSystem32ExePath("taskkill.exe");
const result = runTaskkill(taskkillPath, args, { stdio: "ignore", windowsHide: true });
if (!result.error && result.status === 0) {
return;
}
if (signal !== "SIGKILL") {
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
stdio: "ignore",
windowsHide: true,
});
if (!forceResult.error && forceResult.status === 0) {
return;
}
}
}
child.kill(signal);
return;
}
if (typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
return;
}
}
}
child.kill(signal);
}
function createCommandEnv(): NodeJS.ProcessEnv {
return {
...process.env,
CI: process.env.CI ?? "true",
npm_config_audit: "false",
npm_config_fund: "false",
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
};
}
function runPnpmCommand(
args: string[],
options: { cwd: string; timeoutMs?: number },
): Promise<CommandResult> {
const spec = createPnpmRunnerSpawnSpec({
cwd: options.cwd,
env: createCommandEnv(),
pnpmArgs: args,
stdio: ["ignore", "pipe", "pipe"],
});
const cwd = typeof spec.options.cwd === "string" ? spec.options.cwd : options.cwd;
return runCommand(spec.command, spec.args, {
cwd,
env: spec.options.env,
shell: spec.options.shell,
timeoutMs: options.timeoutMs,
windowsVerbatimArguments: spec.options.windowsVerbatimArguments,
});
}
function runNpmCommand(
args: string[],
options: { cwd: string; timeoutMs?: number },
): Promise<CommandResult> {
const env = createCommandEnv();
const runner = resolveNpmRunner({
env,
npmArgs: args,
});
return runCommand(runner.command, runner.args, {
cwd: options.cwd,
env: runner.env ?? env,
shell: runner.shell,
timeoutMs: options.timeoutMs,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
});
}
function normalizeWorkspaceDependencies(
dependencies: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!dependencies) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [name, spec] of Object.entries(dependencies)) {
normalized[name] =
name.startsWith("@openclaw/") && spec === "workspace:*" ? "0.0.0-private" : spec;
}
return normalized;
}
async function readPackageManifest(packageRoot: string): Promise<PackageManifest> {
const packageJson = await fs.readFile(path.join(packageRoot, "package.json"), "utf8");
const manifest = JSON.parse(packageJson) as PackageManifest;
return {
...manifest,
dependencies: normalizeWorkspaceDependencies(manifest.dependencies),
};
}
function tarballFileName(manifest: PackageManifest): string {
return `${manifest.name.replace(/^@/, "").replace("/", "-")}-${manifest.version}.tgz`;
}
async function createPackStagingRoot(
packageRoot: string,
destinationRoot: string,
): Promise<string> {
const manifest = await readPackageManifest(packageRoot);
const packageSlug = manifest.name.replace(/^@/, "").replace("/", "-");
const stagingRoot = path.join(destinationRoot, `pack-${packageSlug}`);
await fs.mkdir(stagingRoot, { recursive: true });
await fs.writeFile(path.join(stagingRoot, "package.json"), JSON.stringify(manifest, null, 2));
const files: string[] = Array.isArray(manifest.files) ? (manifest.files as string[]) : [];
for (const entry of files) {
if (typeof entry !== "string") {
continue;
}
await fs.cp(path.join(packageRoot, entry), path.join(stagingRoot, entry), {
recursive: true,
});
}
return stagingRoot;
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
async function startOpenClawRegistry(packages: PackedPackage[]): Promise<{
registryUrl: string;
close: () => Promise<void>;
}> {
const byName = new Map(packages.map((pkg) => [pkg.manifest.name, pkg]));
const byTarball = new Map(packages.map((pkg) => [path.basename(pkg.tarball), pkg]));
const server = createServer((req, res) => {
const host = req.headers.host ?? "127.0.0.1";
const url = new URL(req.url ?? "/", `http://${host}`);
const decodedPath = decodeURIComponent(url.pathname);
if (decodedPath.startsWith("/tarballs/")) {
const fileName = decodedPath.slice("/tarballs/".length);
const pkg = byTarball.get(fileName);
if (!pkg) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { "content-type": "application/octet-stream" });
createReadStream(pkg.tarball).pipe(res);
return;
}
const packageName = decodedPath.slice(1);
const pkg = byName.get(packageName);
if (!pkg) {
res.writeHead(404).end();
return;
}
const baseUrl = `http://${host}`;
const body = {
name: pkg.manifest.name,
"dist-tags": { latest: pkg.manifest.version },
versions: {
[pkg.manifest.version]: {
...pkg.manifest,
dist: {
tarball: `${baseUrl}/tarballs/${encodeURIComponent(path.basename(pkg.tarball))}`,
},
},
},
};
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
await closeServer(server);
throw new Error("registry server did not bind to a TCP port");
}
return {
registryUrl: `http://127.0.0.1:${address.port}/`,
close: () => closeServer(server),
};
}
describe("OpenClaw SDK package e2e", () => {
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("force-kills Windows package command process trees when graceful taskkill fails", () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
try {
const killMock = vi.fn();
const child = {
pid: 12345,
kill: killMock,
} as unknown as ReturnType<typeof spawn>;
const runTaskkill = vi
.fn()
.mockReturnValueOnce({ status: 1 })
.mockReturnValueOnce({ status: 0 });
signalCommandProcess(child, "SIGTERM", runTaskkill);
const taskkillPath = getWindowsSystem32ExePath("taskkill.exe");
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
stdio: "ignore",
windowsHide: true,
});
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
expect(killMock).not.toHaveBeenCalled();
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
it("packs and imports from an external temp consumer", async () => {
const repoRoot = process.cwd();
const packageRoots = [
path.join(repoRoot, "packages", "gateway-protocol"),
path.join(repoRoot, "packages", "gateway-client"),
path.join(repoRoot, "packages", "sdk"),
];
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sdk-consumer-"));
tempDirs.push(tempDir);
for (const packageName of WORKSPACE_PACKAGE_NAMES) {
await runPnpmCommand(["--filter", packageName, "build"], {
cwd: repoRoot,
timeoutMs: 180_000,
});
}
for (const packageRoot of packageRoots) {
const stagingRoot = await createPackStagingRoot(packageRoot, tempDir);
await runNpmCommand(["pack", "--ignore-scripts", "--pack-destination", tempDir], {
cwd: stagingRoot,
});
}
const packedPackages: PackedPackage[] = [];
for (const packageRoot of packageRoots) {
const manifest = await readPackageManifest(packageRoot);
const tarball = path.join(tempDir, tarballFileName(manifest));
await fs.stat(tarball);
packedPackages.push({ manifest, tarball });
}
const sdkTarball =
packedPackages.find((pkg) => pkg.manifest.name === "@openclaw/sdk")?.tarball ?? "";
expect(sdkTarball).not.toBe("");
const registry = await startOpenClawRegistry(packedPackages);
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ private: true, type: "module" }),
);
await fs.writeFile(path.join(tempDir, ".npmrc"), `@openclaw:registry=${registry.registryUrl}`);
try {
await runNpmCommand(["install", "--ignore-scripts", "--no-audit", "--no-fund", sdkTarball], {
cwd: tempDir,
});
} finally {
await registry.close();
}
const importScript = `
import { GatewayClientTransport, OpenClaw, normalizeGatewayEvent } from "@openclaw/sdk";
if (typeof GatewayClientTransport !== "function") throw new Error("missing transport export");
if (typeof OpenClaw !== "function") throw new Error("missing client export");
const event = normalizeGatewayEvent({
event: "agent",
payload: { runId: "pack-smoke", stream: "lifecycle", data: { phase: "start" } }
});
if (event.type !== "run.started") throw new Error("unexpected event normalization");
`;
await runCommand(process.execPath, createNodeEvalArgs(importScript, { evalFlag: "-e" }), {
cwd: tempDir,
});
});
});

View File

@@ -0,0 +1,98 @@
// OpenClaw SDK tests cover transport behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GatewayClientTransport } from "./transport.js";
type MockGatewayClientInstance = {
opts: {
onConnectError?: (error: Error) => void;
onHelloOk?: (hello: unknown) => void;
};
request: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
stopAndWait: ReturnType<typeof vi.fn>;
};
const gatewayClientMocks = vi.hoisted(() => ({
instances: [] as MockGatewayClientInstance[],
}));
vi.mock("@openclaw/gateway-client", () => ({
GatewayClient: class {
readonly opts: MockGatewayClientInstance["opts"];
readonly request = vi.fn();
readonly start = vi.fn();
readonly stopAndWait = vi.fn(async () => {});
constructor(opts: MockGatewayClientInstance["opts"]) {
this.opts = opts;
gatewayClientMocks.instances.push(this);
}
},
}));
describe("GatewayClientTransport", () => {
beforeEach(() => {
gatewayClientMocks.instances.length = 0;
});
it("rejects a pending connect when the transport closes before hello-ok", async () => {
const transport = new GatewayClientTransport();
const connect = transport.connect();
const connectExpectation = expect(connect).rejects.toThrow(
"gateway transport closed before connect completed",
);
const client = gatewayClientMocks.instances[0];
expect(client?.start).toHaveBeenCalledTimes(1);
await transport.close();
await connectExpectation;
expect(client?.stopAndWait).toHaveBeenCalledTimes(1);
});
it("rejects reconnect attempts after close", async () => {
const transport = new GatewayClientTransport();
await transport.close();
await expect(transport.connect()).rejects.toThrow("gateway transport is closed");
expect(gatewayClientMocks.instances).toHaveLength(0);
});
it("resolves connect when a hello observer throws", async () => {
const onHelloOk = vi.fn(() => {
throw new Error("hello observer failed");
});
const transport = new GatewayClientTransport({ onHelloOk });
const connect = transport.connect();
const client = gatewayClientMocks.instances[0];
expect(() => client?.opts.onHelloOk?.({ sessionId: "session-1" })).toThrow(
"hello observer failed",
);
await expect(connect).resolves.toBeUndefined();
expect(onHelloOk).toHaveBeenCalledWith({ sessionId: "session-1" });
});
it("rejects connect when a connect-error observer throws", async () => {
const onConnectError = vi.fn(() => {
throw new Error("connect observer failed");
});
const transport = new GatewayClientTransport({ onConnectError });
const connect = transport.connect();
const connectExpectation = expect(connect).rejects.toThrow("gateway rejected");
const client = gatewayClientMocks.instances[0];
expect(() => client?.opts.onConnectError?.(new Error("gateway rejected"))).toThrow(
"connect observer failed",
);
await connectExpectation;
expect(onConnectError).toHaveBeenCalledOnce();
expect(client?.stopAndWait).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,181 @@
// OpenClaw SDK module implements transport behavior.
import { GatewayClient } from "@openclaw/gateway-client";
import { EventHub } from "./event-hub.js";
import type {
ConnectableOpenClawTransport,
GatewayEvent,
GatewayRequestOptions,
OpenClawTransport,
} from "./types.js";
// Gateway transport adapter that converts the lower-level GatewayClient into the
// SDK transport interface and replays raw events for late subscribers.
type GatewayClientLike = {
request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T>;
stopAndWait(): Promise<void>;
};
const RAW_EVENT_REPLAY_LIMIT = 1000;
/** Options passed through to the Gateway websocket client. */
export type GatewayClientTransportOptions = {
url?: string;
connectChallengeTimeoutMs?: number;
connectDelayMs?: number;
preauthHandshakeTimeoutMs?: number;
tickWatchMinIntervalMs?: number;
requestTimeoutMs?: number;
token?: string;
bootstrapToken?: string;
deviceToken?: string;
password?: string;
instanceId?: string;
clientName?: string;
clientDisplayName?: string;
clientVersion?: string;
platform?: string;
deviceFamily?: string;
mode?: string;
role?: string;
scopes?: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
pathEnv?: string;
deviceIdentity?: unknown;
minProtocol?: number;
maxProtocol?: number;
tlsFingerprint?: string;
onEvent?: (evt: GatewayEvent) => void;
onHelloOk?: (hello: unknown) => void;
onConnectError?: (err: Error) => void;
onReconnectPaused?: (info: unknown) => void;
onClose?: (code: number, reason: string) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
function toGatewayEvent(event: unknown): GatewayEvent {
const record =
typeof event === "object" && event !== null ? (event as Record<string, unknown>) : {};
const eventName = typeof record.event === "string" ? record.event : "unknown";
return {
event: eventName,
payload: record.payload,
...(typeof record.seq === "number" ? { seq: record.seq } : {}),
...(record.stateVersion ? { stateVersion: record.stateVersion } : {}),
};
}
/** Connectable SDK transport backed by @openclaw/gateway-client. */
export class GatewayClientTransport implements ConnectableOpenClawTransport {
private readonly eventsHub = new EventHub<GatewayEvent>({
replayLimit: RAW_EVENT_REPLAY_LIMIT,
});
private readonly options: GatewayClientTransportOptions;
private client: GatewayClientLike | null = null;
private connectPromise: Promise<void> | null = null;
private rejectPendingConnect: ((error: Error) => void) | null = null;
private closePromise: Promise<void> | null = null;
private closed = false;
constructor(options: GatewayClientTransportOptions = {}) {
this.options = options;
}
connect(): Promise<void> {
if (this.closed) {
return Promise.reject(new Error("gateway transport is closed"));
}
if (this.connectPromise) {
return this.connectPromise;
}
this.connectPromise = new Promise<void>((resolve, reject) => {
this.rejectPendingConnect = reject;
const client = new GatewayClient({
...this.options,
onEvent: (event: unknown) => {
const normalized = toGatewayEvent(event);
this.eventsHub.publish(normalized);
this.options.onEvent?.(normalized);
},
onHelloOk: (_hello: unknown) => {
try {
this.options.onHelloOk?.(_hello);
} finally {
this.rejectPendingConnect = null;
resolve();
}
},
onConnectError: (error: Error) => {
try {
this.options.onConnectError?.(error);
} finally {
if (this.client === client) {
this.client = null;
}
if (this.connectPromise) {
this.connectPromise = null;
}
void client.stopAndWait().catch(() => {});
this.rejectPendingConnect = null;
reject(error);
}
},
onReconnectPaused: this.options.onReconnectPaused,
onClose: this.options.onClose,
onGap: this.options.onGap,
} as never);
this.client = client;
client.start();
});
return this.connectPromise;
}
async request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T> {
await this.connect();
if (!this.client) {
throw new Error("gateway transport is not connected");
}
return await this.client.request<T>(method, params, options);
}
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
return this.eventsHub.stream(filter, { replay: true });
}
async close(): Promise<void> {
if (this.closePromise) {
return await this.closePromise;
}
if (this.closed) {
return;
}
this.closed = true;
this.eventsHub.close();
const client = this.client;
this.client = null;
const rejectPendingConnect = this.rejectPendingConnect;
this.rejectPendingConnect = null;
rejectPendingConnect?.(new Error("gateway transport closed before connect completed"));
this.connectPromise = null;
this.closePromise = client?.stopAndWait() ?? Promise.resolve();
await this.closePromise;
this.closePromise = null;
}
}
/** Narrow an SDK transport to one that supports explicit connect. */
export function isConnectableTransport(
transport: OpenClawTransport,
): transport is ConnectableOpenClawTransport {
return typeof (transport as { connect?: unknown }).connect === "function";
}

357
packages/sdk/src/types.ts Normal file
View File

@@ -0,0 +1,357 @@
// Public SDK data contracts for Gateway transport, runs, sessions, tools,
// artifacts, tasks, environments, and normalized event streams.
export type JsonObject = Record<string, unknown>;
/** Per-request options accepted by SDK transports. */
export type GatewayRequestOptions = {
expectFinal?: boolean;
timeoutMs?: number | null;
};
/** Raw event payload emitted by the Gateway transport. */
export type GatewayEvent = {
event: string;
payload?: unknown;
seq?: number;
stateVersion?: unknown;
};
/** Minimal transport interface consumed by the OpenClaw SDK client. */
export type OpenClawTransport = {
request<T = unknown>(
method: string,
params?: unknown,
options?: GatewayRequestOptions,
): Promise<T>;
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent>;
close?(): Promise<void> | void;
};
/** Transport variant that requires an explicit connection step. */
export type ConnectableOpenClawTransport = OpenClawTransport & {
connect(): Promise<void>;
};
/** Desired runtime/harness selection for future per-run execution routing. */
export type RuntimeSelection =
| "auto"
| { type: "embedded"; id: "openclaw" | "codex" | (string & {}) }
| { type: "cli"; id: "claude-cli" | (string & {}) }
| { type: "acp"; harness: "claude" | "cursor" | "gemini" | "opencode" | (string & {}) }
| { type: "managed"; provider: "local" | "node" | "testbox" | "cloud" | (string & {}) };
/** Desired execution environment selection for future per-run routing. */
export type EnvironmentSelection =
| { type: "local"; cwd?: string }
| { type: "gateway"; url?: string; cwd?: string }
| { type: "node"; nodeId: string; cwd?: string }
| { type: "managed"; provider: string; repo?: string; ref?: string }
| { type: "ephemeral"; provider: string; repo?: string; ref?: string };
export type EnvironmentSummary = {
id: string;
type: "local" | "gateway" | "node" | "managed" | "ephemeral" | (string & {});
label?: string;
status: "available" | "unavailable" | "starting" | "stopping" | "error";
capabilities?: string[];
};
export type EnvironmentsListResult = {
environments: EnvironmentSummary[];
};
export type WorkspaceSelection = {
cwd?: string;
repo?: string;
ref?: string;
};
export type ApprovalMode = "ask" | "never" | "auto" | "trusted";
export type ApprovalDecisionParams = {
decision: "allow-once" | "allow-always" | "deny";
};
/** Terminal and non-terminal status values returned by Run.wait. */
export type RunStatus = "accepted" | "completed" | "failed" | "cancelled" | "timed_out";
export type RunTimestamp = string | number;
export type SDKMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
toolCallId?: string;
};
/** Metadata for an artifact attached to a run, task, or session. */
export type ArtifactSummary = {
id: string;
runId?: string;
taskId?: string;
sessionId?: string;
sessionKey?: string;
type:
| "file"
| "patch"
| "diff"
| "log"
| "media"
| "screenshot"
| "trajectory"
| "pull_request"
| "workspace"
| (string & {});
title?: string;
mimeType?: string;
sizeBytes?: number;
messageSeq?: number;
source?: string;
download?: {
mode: "bytes" | "url" | "unsupported" | (string & {});
};
createdAt?: string;
expiresAt?: string;
};
export type ArtifactQuery =
| { sessionKey: string; runId?: string; taskId?: string; agentId?: string }
| { runId: string; sessionKey?: string; taskId?: string; agentId?: string }
| { taskId: string; sessionKey?: string; runId?: string; agentId?: string };
export type ArtifactsListResult = {
artifacts: ArtifactSummary[];
};
export type ArtifactsGetResult = {
artifact: ArtifactSummary;
};
export type ArtifactsDownloadResult = {
artifact: ArtifactSummary;
encoding?: "base64";
data?: string;
url?: string;
};
export type TaskStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "timed_out";
/** Gateway task summary returned by task list/get calls. */
export type TaskSummary = {
id: string;
taskId?: string;
kind?: string;
runtime?: string;
status: TaskStatus;
title?: string;
agentId?: string;
sessionKey?: string;
childSessionKey?: string;
ownerKey?: string;
runId?: string;
flowId?: string;
parentTaskId?: string;
sourceId?: string;
createdAt?: RunTimestamp;
updatedAt?: RunTimestamp;
startedAt?: RunTimestamp;
endedAt?: RunTimestamp;
progressSummary?: string;
terminalSummary?: string;
error?: string;
};
export type TasksListParams = {
status?: TaskStatus | TaskStatus[];
agentId?: string;
sessionKey?: string;
limit?: number;
cursor?: string;
};
export type TasksListResult = {
tasks: TaskSummary[];
nextCursor?: string;
};
export type TasksGetResult = {
task: TaskSummary;
};
export type TasksCancelResult = {
found: boolean;
cancelled: boolean;
reason?: string;
task?: TaskSummary;
};
export type SDKError = {
code?: string;
message: string;
details?: unknown;
};
/** Parameters for direct tool invocation through the SDK. */
export type ToolsEffectiveParams = {
sessionKey: string;
agentId?: string;
};
export type ToolInvokeParams = {
args?: JsonObject;
sessionKey?: string;
agentId?: string;
confirm?: boolean;
idempotencyKey?: string;
};
export type ToolInvokeResult = {
ok: boolean;
toolName: string;
output?: unknown;
requiresApproval?: boolean;
approvalId?: string;
source?: string;
error?: SDKError;
};
/** Normalized result returned by Run.wait. */
export type RunResult = {
runId: string;
status: RunStatus;
sessionId?: string;
sessionKey?: string;
taskId?: string;
startedAt?: RunTimestamp;
endedAt?: RunTimestamp;
output?: {
text?: string;
messages?: SDKMessage[];
};
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
costUsd?: number;
};
artifacts?: ArtifactSummary[];
error?: SDKError;
raw?: unknown;
};
/** Stable SDK event type taxonomy derived from raw Gateway events. */
export type OpenClawEventType =
| "run.created"
| "run.queued"
| "run.started"
| "run.completed"
| "run.failed"
| "run.cancelled"
| "run.timed_out"
| "assistant.delta"
| "assistant.message"
| "thinking.delta"
| "tool.call.started"
| "tool.call.delta"
| "tool.call.completed"
| "tool.call.failed"
| "approval.requested"
| "approval.resolved"
| "question.requested"
| "question.answered"
| "artifact.created"
| "artifact.updated"
| "session.created"
| "session.updated"
| "session.compacted"
| "task.updated"
| "git.branch"
| "git.diff"
| "git.pr"
| "raw";
/** Normalized SDK event with common run/session/task metadata. */
export type OpenClawEvent<TData = unknown> = {
version: 1;
id: string;
ts: number;
type: OpenClawEventType;
runId?: string;
sessionId?: string;
sessionKey?: string;
taskId?: string;
agentId?: string;
data: TData;
raw?: GatewayEvent;
};
/** Parameters for creating an agent run. */
export type AgentRunParams = {
input: string;
agentId?: string;
model?: string;
thinking?: string;
sessionId?: string;
sessionKey?: string;
deliver?: boolean;
attachments?: unknown[];
timeoutMs?: number;
label?: string;
runtime?: RuntimeSelection;
environment?: EnvironmentSelection;
workspace?: WorkspaceSelection;
approvals?: ApprovalMode;
idempotencyKey?: string;
};
/** Parameters for creating a session. */
export type SessionCreateParams = {
key?: string;
agentId?: string;
label?: string;
model?: string;
parentSessionKey?: string;
task?: string;
message?: string;
};
/** Parameters for sending a message to an existing session. */
export type SessionSendParams = {
key: string;
message: string;
thinking?: string;
attachments?: unknown[];
timeoutMs?: number;
idempotencyKey?: string;
};
export type SessionTarget = {
key: string;
sessionId?: string;
agentId?: string;
label?: string;
};
export type RunCreateParams = AgentRunParams;
export type AgentsCreateParams = {
name: string;
workspace: string;
model?: string;
emoji?: string;
avatar?: string;
};
export type AgentsUpdateParams = {
agentId: string;
name?: string;
workspace?: string;
model?: string;
emoji?: string;
avatar?: string;
};
export type AgentsDeleteParams = {
agentId: string;
deleteFiles?: boolean;
};