openclaw/src/commands/agent-via-gateway.ts
kumarabhirup 45f86187ed
fix: resolve merge-induced type errors and lint issues
- Deduplicate SkillEntry/SkillSnapshot imports in workspace.ts
- Add missing skillFilter/applySkillsPromptLimits in workspace skills
- Add sessionEventLog/sessionSubscriptions to runtime state return type
- Add allowRealIpFallback to GatewayConfig type
- Fix agent.subscribe/unsubscribe validation to use AJV pattern
- Add missing imports (RuntimeEnv, inheritOptionFromParent, etc.)
- Fix spread type in run-main.exit.test.ts
- Type chat.ts appendMessage call correctly
- Rename synology-chat workspace ref from openclaw to ironclaw
- Regenerate pnpm-lock.yaml for merged dependencies

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 18:25:26 -08:00

545 lines
17 KiB
TypeScript

import { listAgentIds } from "../agents/agent-scope.js";
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig, resolveConfigPath, resolveStateDir } from "../config/config.js";
import {
buildGatewayConnectionDetails,
callGateway,
randomIdempotencyKey,
} from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
normalizeMessageChannel,
} from "../utils/message-channel.js";
import { agentCommand } from "./agent.js";
import { resolveSessionKeyForRequest } from "./agent/session.js";
/** Write a single NDJSON line to stdout. */
export function emitNdjsonLine(obj: Record<string, unknown>): void {
process.stdout.write(`${JSON.stringify(obj)}\n`);
}
type AgentGatewayResult = {
payloads?: Array<{
text?: string;
mediaUrl?: string | null;
mediaUrls?: string[];
}>;
meta?: unknown;
};
type GatewayAgentResponse = {
runId?: string;
status?: string;
summary?: string;
result?: AgentGatewayResult;
};
const NO_GATEWAY_TIMEOUT_MS = 2_147_000_000;
export type AgentCliOpts = {
message: string;
agent?: string;
to?: string;
sessionId?: string;
sessionKey?: string;
thinking?: string;
verbose?: string;
json?: boolean;
/** Stream NDJSON events to stdout during the agent run. */
streamJson?: boolean;
timeout?: string;
deliver?: boolean;
channel?: string;
replyTo?: string;
replyChannel?: string;
replyAccount?: string;
bestEffortDeliver?: boolean;
lane?: string;
runId?: string;
extraSystemPrompt?: string;
local?: boolean;
};
function parseTimeoutSeconds(opts: { cfg: ReturnType<typeof loadConfig>; timeout?: string }) {
const raw =
opts.timeout !== undefined
? Number.parseInt(String(opts.timeout), 10)
: (opts.cfg.agents?.defaults?.timeoutSeconds ?? 600);
if (Number.isNaN(raw) || raw < 0) {
throw new Error("--timeout must be a non-negative integer (seconds; 0 means no timeout)");
}
return raw;
}
function formatPayloadForLog(payload: {
text?: string;
mediaUrls?: string[];
mediaUrl?: string | null;
}) {
const lines: string[] = [];
if (payload.text) {
lines.push(payload.text.trimEnd());
}
const mediaUrl =
typeof payload.mediaUrl === "string" && payload.mediaUrl.trim()
? payload.mediaUrl.trim()
: undefined;
const media = payload.mediaUrls ?? (mediaUrl ? [mediaUrl] : []);
for (const url of media) {
lines.push(`MEDIA:${url}`);
}
return lines.join("\n").trimEnd();
}
export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
const body = (opts.message ?? "").trim();
if (!body) {
throw new Error("Message (--message) is required");
}
if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agent) {
throw new Error(
"Pass --to <E.164>, --session-id, --session-key, or --agent to choose a session",
);
}
const cfg = loadConfig();
const agentIdRaw = opts.agent?.trim();
const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
if (agentId) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentId)) {
throw new Error(
`Unknown agent id "${agentIdRaw}". Use "${formatCliCommand("openclaw agents list")}" to see configured agents.`,
);
}
}
const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout });
const gatewayTimeoutMs =
timeoutSeconds === 0
? NO_GATEWAY_TIMEOUT_MS // no timeout (timer-safe max)
: Math.max(10_000, (timeoutSeconds + 30) * 1000);
const sessionKey = resolveSessionKeyForRequest({
cfg,
agentId,
to: opts.to,
sessionId: opts.sessionId,
sessionKey: opts.sessionKey,
}).sessionKey;
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
const workspaceOverride = process.env.OPENCLAW_WORKSPACE?.trim() || undefined;
const response = await withProgress(
{
label: "Waiting for agent reply…",
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway<GatewayAgentResponse>({
method: "agent",
params: {
message: body,
agentId,
to: opts.to,
replyTo: opts.replyTo,
sessionId: opts.sessionId,
sessionKey,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
channel,
replyChannel: opts.replyChannel,
replyAccountId: opts.replyAccount,
timeout: timeoutSeconds,
lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt,
idempotencyKey,
workspace: workspaceOverride,
},
expectFinal: true,
timeoutMs: gatewayTimeoutMs,
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
}),
);
if (opts.json) {
runtime.log(JSON.stringify(response, null, 2));
return response;
}
const result = response?.result;
const payloads = result?.payloads ?? [];
if (payloads.length === 0) {
runtime.log(response?.summary ? String(response.summary) : "No reply from agent.");
return response;
}
for (const payload of payloads) {
const out = formatPayloadForLog(payload);
if (out) {
runtime.log(out);
}
}
return response;
}
/**
* Gateway agent call with live NDJSON event streaming to stdout.
* Reuses callGateway with an onEvent callback to emit each gateway event as an NDJSON line.
*/
async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEnv) {
const body = (opts.message ?? "").trim();
if (!body) {
throw new Error("Message (--message) is required");
}
if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agent) {
throw new Error(
"Pass --to <E.164>, --session-id, --session-key, or --agent to choose a session",
);
}
const cfg = loadConfig();
const agentIdRaw = opts.agent?.trim();
const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
if (agentId) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentId)) {
throw new Error(
`Unknown agent id "${agentIdRaw}". Use "${formatCliCommand("openclaw agents list")}" to see configured agents.`,
);
}
}
const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout });
const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000);
const sessionKey = resolveSessionKeyForRequest({
cfg,
agentId,
to: opts.to,
sessionId: opts.sessionId,
sessionKey: opts.sessionKey,
}).sessionKey;
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
const streamWorkspaceOverride = process.env.OPENCLAW_WORKSPACE?.trim() || undefined;
// Capture the runId from early gateway events so we can abort the
// correct run when the process receives SIGTERM/SIGINT.
let capturedRunId: string | undefined;
const abortController = new AbortController();
const onSignal = () => {
if (!abortController.signal.aborted) {
abortController.abort();
}
};
process.on("SIGTERM", onSignal);
process.on("SIGINT", onSignal);
try {
const response = await callGateway<GatewayAgentResponse>({
method: "agent",
params: {
message: body,
agentId,
to: opts.to,
replyTo: opts.replyTo,
sessionId: opts.sessionId,
sessionKey,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
channel,
replyChannel: opts.replyChannel,
replyAccountId: opts.replyAccount,
timeout: timeoutSeconds,
lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt,
idempotencyKey,
workspace: streamWorkspaceOverride,
},
expectFinal: true,
timeoutMs: gatewayTimeoutMs,
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
// Request tool-events capability so the gateway streams tool start/result
// events alongside assistant text, thinking, and lifecycle events.
caps: ["tool-events"],
signal: abortController.signal,
onEvent: (evt) => {
// Capture runId from the first event that carries one (lifecycle/accepted).
if (!capturedRunId) {
const payload = evt.payload as Record<string, unknown> | undefined;
const rid = payload?.runId;
if (typeof rid === "string" && rid.trim()) {
capturedRunId = rid.trim();
} else if (typeof rid === "number") {
capturedRunId = String(rid);
}
}
// Emit each gateway event as an NDJSON line (chat deltas, agent tool/lifecycle events).
emitNdjsonLine({ event: evt.event, ...(evt.payload as Record<string, unknown>) });
},
onAbort: async (client) => {
// Best-effort: tell the gateway to abort the agent run before we exit.
if (capturedRunId) {
await client.request("chat.abort", { sessionKey, runId: capturedRunId }).catch(() => {});
}
},
});
// Emit the final result as the last NDJSON line.
emitNdjsonLine({ event: "result", ...response });
return response;
} catch (err) {
// Re-throw everything except AbortError (expected on SIGTERM/SIGINT).
if (err instanceof DOMException && err.name === "AbortError") {
emitNdjsonLine({ event: "aborted", reason: "signal" });
return {} as GatewayAgentResponse;
}
throw err;
} finally {
process.removeListener("SIGTERM", onSignal);
process.removeListener("SIGINT", onSignal);
}
}
/**
* Subscribe to a session key's events via the gateway `agent.subscribe` RPC.
* Streams NDJSON to stdout until SIGTERM/SIGINT.
*/
async function agentSubscribeStreamJson(
sessionKey: string,
afterSeq: number,
_runtime: RuntimeEnv,
): Promise<void> {
const cfg = loadConfig();
const isRemoteMode = cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
const remoteUrl =
typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined;
if (isRemoteMode && !remoteUrl) {
const configPath = resolveConfigPath(process.env, resolveStateDir(process.env));
throw new Error(
[
"gateway remote mode misconfigured: gateway.remote.url missing",
`Config: ${configPath}`,
"Fix: set gateway.remote.url, or set gateway.mode=local.",
].join("\n"),
);
}
const connectionDetails = buildGatewayConnectionDetails({ config: cfg });
const useLocalTls =
cfg.gateway?.tls?.enabled === true && !remoteUrl && connectionDetails.url.startsWith("wss://");
const tlsRuntime = useLocalTls ? await loadGatewayTlsRuntime(cfg.gateway?.tls) : undefined;
const tlsFingerprint =
(isRemoteMode && remoteUrl && typeof remote?.tlsFingerprint === "string"
? remote.tlsFingerprint.trim()
: undefined) || (tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined);
const authToken = cfg.gateway?.auth?.token;
const authPassword = cfg.gateway?.auth?.password;
const token =
isRemoteMode && remoteUrl
? typeof remote?.token === "string" && remote.token.trim().length > 0
? remote.token.trim()
: undefined
: process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
(typeof authToken === "string" && authToken.trim().length > 0
? authToken.trim()
: undefined);
const password =
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
(isRemoteMode && remoteUrl
? typeof remote?.password === "string" && remote.password.trim().length > 0
? remote.password.trim()
: undefined
: typeof authPassword === "string" && authPassword.trim().length > 0
? authPassword.trim()
: undefined);
let cursor = Math.max(0, Number.isFinite(afterSeq) ? afterSeq : 0);
const abortController = new AbortController();
let client: GatewayClient | null = null;
const onSignal = () => {
if (!abortController.signal.aborted) {
abortController.abort();
}
};
process.on("SIGTERM", onSignal);
process.on("SIGINT", onSignal);
try {
await new Promise<void>((resolve, reject) => {
let settled = false;
let aborting = false;
const settle = (err?: Error) => {
if (settled) {
return;
}
settled = true;
if (err) {
reject(err);
return;
}
resolve();
};
const onAbort = () => {
if (aborting) {
return;
}
aborting = true;
const unsubscribeAndStop = async () => {
try {
await client?.request("agent.unsubscribe", { sessionKey }).catch(() => {});
} finally {
client?.stop();
emitNdjsonLine({ event: "aborted", reason: "signal" });
settle();
}
};
void unsubscribeAndStop();
};
if (abortController.signal.aborted) {
onAbort();
return;
}
abortController.signal.addEventListener("abort", onAbort, { once: true });
client = new GatewayClient({
url: connectionDetails.url,
token,
password,
tlsFingerprint,
instanceId: randomIdempotencyKey(),
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientVersion: "dev",
platform: process.platform,
mode: GATEWAY_CLIENT_MODES.CLI,
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
caps: ["tool-events"],
deviceIdentity: loadOrCreateDeviceIdentity(),
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
onHelloOk: async () => {
try {
await client?.request("agent.subscribe", { sessionKey, afterSeq: cursor });
} catch (err) {
client?.stop();
const error = err instanceof Error ? err : new Error(String(err));
settle(error);
}
},
onEvent: (evt) => {
if (evt.event !== "agent") {
return;
}
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: undefined;
if (!payload || payload.sessionKey !== sessionKey) {
return;
}
const globalSeq = typeof payload.globalSeq === "number" ? payload.globalSeq : undefined;
if (globalSeq !== undefined && globalSeq > cursor) {
cursor = globalSeq;
}
emitNdjsonLine({ event: evt.event, ...payload });
},
onConnectError: (err) => {
if (aborting || settled) {
return;
}
client?.stop();
settle(err);
},
onClose: (code, reason) => {
if (aborting || settled || abortController.signal.aborted) {
return;
}
// For reconnectable closes, let GatewayClient retry.
if (code === 1000) {
return;
}
client?.stop();
const reasonText = reason?.trim() || "no close reason";
settle(
new Error(
`gateway subscribe closed (${code}): ${reasonText}\n${connectionDetails.message}`,
),
);
},
});
client.start();
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
emitNdjsonLine({ event: "aborted", reason: "signal" });
return;
}
throw err;
} finally {
process.removeListener("SIGTERM", onSignal);
process.removeListener("SIGINT", onSignal);
}
}
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
// Subscribe-only mode: tail events for a session key with replay cursor.
const subscribeKey = (opts as Record<string, unknown>).subscribeSessionKey as string | undefined;
if (subscribeKey && opts.streamJson) {
const rawAfterSeq = (opts as Record<string, unknown>).afterSeq;
const afterSeq = Number.parseInt(typeof rawAfterSeq === "string" ? rawAfterSeq : "0", 10) || 0;
return await agentSubscribeStreamJson(subscribeKey.trim(), afterSeq, runtime);
}
// --message is required for all non-subscribe paths.
if (!opts.message?.trim()) {
throw new Error("Message (--message) is required");
}
const localOpts = {
...opts,
agentId: opts.agent,
replyAccountId: opts.replyAccount,
};
if (opts.local === true) {
return await agentCommand(localOpts, runtime, deps);
}
// Stream NDJSON via the gateway (no embedded fallback — streaming should fail loud).
if (opts.streamJson) {
return await agentViaGatewayStreamJson(opts, runtime);
}
try {
return await agentViaGatewayCommand(opts, runtime);
} catch (err) {
runtime.error?.(`Gateway agent failed; falling back to embedded: ${String(err)}`);
return await agentCommand(localOpts, runtime, deps);
}
}