mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Conversations must use the same tasks, controls, and execution history. > - Users need an ongoing chat with an agent without managing task properties. > - Agents should clarify and plan work, then hand execution to assigned project tasks. > - This pull request combines the reviewed Agent Chat stack for one squash merge. > - The benefit is persistent conversation with normal task governance and shared UI. ## Linked Issues or Issue Description **Subsystem affected** Task lifecycle, agent runtime tools, shared task UI, and browser/paid runner tests. **Problem or motivation** Users need one persistent conversation with each agent. A separate chat store or renderer would duplicate task behavior and bypass existing controls. **Proposed solution** Use a task-backed chat per company, user, and agent. Reuse the task composer and transcript. Clarify and plan in chat, then create assigned project tasks with the relevant plan. Keep Agent Chat behind its own disabled-by-default experimental setting. **Roadmap alignment** This implements the task-backed direction in [CEO Chat](https://github.com/paperclipai/paperclip/blob/master/ROADMAP.md#-ceo-chat). Related proposals: #2504 and #9693. Related request: #7981. The maintainer requested one squash merge of the complete stack. Consolidates the reviewed runtime [#13281](https://github.com/paperclipai/paperclip/pull/13281), backend [#13282](https://github.com/paperclipai/paperclip/pull/13282), and UI [#13283](https://github.com/paperclipai/paperclip/pull/13283) layers with this PR's E2E coverage. All four layers passed CI and received Greptile 5/5 before consolidation. This PR targets master and includes the complete feature. ## What Changed - Add personal canonical chat tasks with ordinary company visibility, immutable identity, idempotent first sends, and an idle waiting state. - Process `/new` in queue order. Preserve history, release a chat pause, and fence old provider context and delayed writes. - Keep chat lifecycle rules across recovery, finalization, assignment, task lists, and rollups. - Support research and plan revision in chat. Hand plans to ordinary assigned project tasks before execution starts. Reject new chat subtasks. - Add repository-aware project creation and discovery tools, including multiple repository IDs and GitHub URLs, authorization, idempotency, and durable project-created cards. - Reuse task UI components for chat, with starred/recent agent navigation and a separate `enableAgentChat` experimental flag. - Add deterministic browser tests and 24 paid chat cells across four Codex/Claude profiles, with validated reports and screenshots. - Integrate current master recovery, controller lease, queued-message, and task UI changes. Gate chat interruption and deferred promotion on ownership/feature policy. Guarantee lease renewal and active controls are stopped even if teardown fails. - Preserve master's migration 0273 and generate chat migration 0274 with idempotent replay for development databases. ## Verification - Prior exact heads of all four PRs passed Linux CI, including build, typecheck, general/serialized tests, and browser E2E. Each had Greptile 5/5 and no unresolved findings. - Integrated local verification passed: full repository typecheck and production build, Storybook build, token gates, 340 focused UI tests, all 20 deterministic chat browser tests, two migration replay tests, 88 focused chat/queue/native/controller tests, and provider/session regressions including real lease expiry. These include the three lifecycle regressions for the final admission/teardown fixes; server typecheck also passes. Current head `1268eda16cc2af892055917e7292f068820be135` has Greptile 5/5 with no unresolved findings and passing security scans. All final-head CI gates passed: build, full Runner verification, typecheck/release registry, canary, all general/serialized test shards, and all browser E2E shards ([CI run](https://github.com/paperclipai/paperclip/actions/runs/34696739927)). Local PostgreSQL startup contention required serialized retries; skipped fixtures do not count as passing coverage. - The earlier paid campaign passed all 24 chat cells and retained 32 screenshots: [report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/index.html?report=agent-chat#suite-agent-chat). It tested `abacbdfd2f660709ec37312cdb758284c8399d04`; it is prior evidence, not a paid run of this integrated head. - Manual check: enable Agent Chat in Experimental settings, open an agent, clarify and revise a plan, then hand off to an assigned project task. Stop a reply, send `/new`, and verify fresh context with retained history. Disable the setting and verify agent shortcuts/new chat turns are blocked. ## Risks - Queue/session integration can affect retries and delayed writes. Tests cover ownership, cancellation, reset boundaries, idle recovery, and ordinary task behavior. - Migration 0274 adds conversation fields and constraints. Replay is idempotent and preserves existing development chat history. - This combines the previously reviewed stack at the maintainer's request. Agent Chat remains off by default and is separate from Conference Room. ## Model Used OpenAI Codex, GPT-6 Astra (`gpt-6-astra`), with reasoning, code execution, browser tools, and parallel review. The exact context-window size is not exposed in this session. Codex and Claude also ran as test subjects in the linked paid campaign. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1527 lines
51 KiB
TypeScript
1527 lines
51 KiB
TypeScript
import type {
|
|
AdapterExecutionContext,
|
|
AdapterExecutionResult,
|
|
AdapterRuntimeServiceReport,
|
|
} from "@paperclipai/adapter-utils";
|
|
import {
|
|
asNumber,
|
|
asString,
|
|
buildPaperclipEnv,
|
|
buildRuntimeToolsEnv,
|
|
parseObject,
|
|
readPaperclipIssueWorkModeFromContext,
|
|
renderPaperclipWakePrompt,
|
|
selectPaperclipTaskMarkdown,
|
|
stringifyPaperclipWakePayload,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import crypto, { randomUUID } from "node:crypto";
|
|
import { WebSocket } from "ws";
|
|
|
|
type SessionKeyStrategy = "fixed" | "issue" | "run";
|
|
|
|
type WakePayload = {
|
|
runId: string;
|
|
agentId: string;
|
|
companyId: string;
|
|
taskId: string | null;
|
|
issueId: string | null;
|
|
wakeReason: string | null;
|
|
wakeCommentId: string | null;
|
|
approvalId: string | null;
|
|
approvalStatus: string | null;
|
|
issueIds: string[];
|
|
};
|
|
|
|
type GatewayDeviceIdentity = {
|
|
deviceId: string;
|
|
publicKeyRawBase64Url: string;
|
|
privateKeyPem: string;
|
|
source: "configured" | "ephemeral";
|
|
};
|
|
|
|
type GatewayRequestFrame = {
|
|
type: "req";
|
|
id: string;
|
|
method: string;
|
|
params?: unknown;
|
|
};
|
|
|
|
type GatewayResponseFrame = {
|
|
type: "res";
|
|
id: string;
|
|
ok: boolean;
|
|
payload?: unknown;
|
|
error?: {
|
|
code?: unknown;
|
|
message?: unknown;
|
|
};
|
|
};
|
|
|
|
type GatewayEventFrame = {
|
|
type: "event";
|
|
event: string;
|
|
payload?: unknown;
|
|
seq?: number;
|
|
};
|
|
|
|
type PendingRequest = {
|
|
resolve: (value: unknown) => void;
|
|
reject: (err: Error) => void;
|
|
expectFinal: boolean;
|
|
timer: ReturnType<typeof setTimeout> | null;
|
|
};
|
|
|
|
type GatewayResponseError = Error & {
|
|
gatewayCode?: string;
|
|
gatewayDetails?: Record<string, unknown>;
|
|
};
|
|
|
|
type GatewayClientOptions = {
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
onEvent: (frame: GatewayEventFrame) => Promise<void> | void;
|
|
onLog: AdapterExecutionContext["onLog"];
|
|
};
|
|
|
|
type GatewayClientRequestOptions = {
|
|
timeoutMs: number;
|
|
expectFinal?: boolean;
|
|
};
|
|
|
|
const PROTOCOL_VERSION = 4;
|
|
const DEFAULT_SCOPES = ["operator.admin"];
|
|
const DEFAULT_CLIENT_ID = "gateway-client";
|
|
const DEFAULT_CLIENT_MODE = "backend";
|
|
const DEFAULT_CLIENT_VERSION = "paperclip";
|
|
const DEFAULT_ROLE = "operator";
|
|
|
|
const SENSITIVE_LOG_KEY_PATTERN =
|
|
/(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i;
|
|
|
|
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function nonEmpty(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function parseOptionalPositiveInteger(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return Math.max(1, Math.floor(value));
|
|
}
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
const parsed = Number.parseInt(value.trim(), 10);
|
|
if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseBoolean(value: unknown, fallback = false): boolean {
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value === "string") {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "true" || normalized === "1") return true;
|
|
if (normalized === "false" || normalized === "0") return false;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy {
|
|
const normalized = asString(value, "issue").trim().toLowerCase();
|
|
if (normalized === "fixed" || normalized === "run") return normalized;
|
|
return "issue";
|
|
}
|
|
|
|
function prefixSessionKeyForAgent(sessionKey: string, agentId: string | null): string {
|
|
if (!agentId || sessionKey.startsWith("agent:")) return sessionKey;
|
|
return `agent:${agentId}:${sessionKey}`;
|
|
}
|
|
|
|
export function resolveSessionKey(input: {
|
|
strategy: SessionKeyStrategy;
|
|
configuredSessionKey: string | null;
|
|
agentId: string | null;
|
|
runId: string;
|
|
issueId: string | null;
|
|
}): string {
|
|
const fallback = input.configuredSessionKey ?? "paperclip";
|
|
if (input.strategy === "run") {
|
|
return prefixSessionKeyForAgent(`paperclip:run:${input.runId}`, input.agentId);
|
|
}
|
|
if (input.strategy === "issue" && input.issueId) {
|
|
return prefixSessionKeyForAgent(`paperclip:issue:${input.issueId}`, input.agentId);
|
|
}
|
|
return prefixSessionKeyForAgent(fallback, input.agentId);
|
|
}
|
|
|
|
function isLoopbackHost(hostname: string): boolean {
|
|
const value = hostname.trim().toLowerCase();
|
|
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
|
}
|
|
|
|
function toStringRecord(value: unknown): Record<string, string> {
|
|
const parsed = parseObject(value);
|
|
const out: Record<string, string> = {};
|
|
for (const [key, entry] of Object.entries(parsed)) {
|
|
if (typeof entry === "string") out[key] = entry;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function toStringArray(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
if (typeof value === "string") {
|
|
return value
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function normalizeScopes(value: unknown): string[] {
|
|
const parsed = toStringArray(value);
|
|
return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES];
|
|
}
|
|
|
|
function uniqueScopes(scopes: string[]): string[] {
|
|
return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean)));
|
|
}
|
|
|
|
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
|
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
function headerMapHasIgnoreCase(headers: Record<string, string>, key: string): boolean {
|
|
return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase());
|
|
}
|
|
|
|
function getGatewayErrorDetails(err: unknown): Record<string, unknown> | null {
|
|
if (!err || typeof err !== "object") return null;
|
|
const candidate = (err as GatewayResponseError).gatewayDetails;
|
|
return asRecord(candidate);
|
|
}
|
|
|
|
function extractPairingRequestId(err: unknown): string | null {
|
|
const details = getGatewayErrorDetails(err);
|
|
const fromDetails = nonEmpty(details?.requestId);
|
|
if (fromDetails) return fromDetails;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function toAuthorizationHeaderValue(rawToken: string): string {
|
|
const trimmed = rawToken.trim();
|
|
if (!trimmed) return trimmed;
|
|
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
|
}
|
|
|
|
function tokenFromAuthHeader(rawHeader: string | null): string | null {
|
|
if (!rawHeader) return null;
|
|
const trimmed = rawHeader.trim();
|
|
if (!trimmed) return null;
|
|
const match = trimmed.match(/^bearer\s+(.+)$/i);
|
|
return match ? nonEmpty(match[1]) : trimmed;
|
|
}
|
|
|
|
function resolveAuthToken(config: Record<string, unknown>, headers: Record<string, string>): string | null {
|
|
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
|
|
if (explicit) return explicit;
|
|
|
|
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
|
|
if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader);
|
|
|
|
const authHeader =
|
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
|
headerMapGetIgnoreCase(headers, "authorization");
|
|
const fromHeader = tokenFromAuthHeader(authHeader);
|
|
if (fromHeader) return fromHeader;
|
|
|
|
// Fallback to environment variable
|
|
return nonEmpty(process.env.OPENCLAW_TOKEN);
|
|
}
|
|
|
|
function isSensitiveLogKey(key: string): boolean {
|
|
return SENSITIVE_LOG_KEY_PATTERN.test(key.trim());
|
|
}
|
|
|
|
function sha256Prefix(value: string): string {
|
|
return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
}
|
|
|
|
function redactSecretForLog(value: string): string {
|
|
return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`;
|
|
}
|
|
|
|
function truncateForLog(value: string, maxChars = 320): string {
|
|
if (value.length <= maxChars) return value;
|
|
return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`;
|
|
}
|
|
|
|
function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown {
|
|
const currentKey = keyPath[keyPath.length - 1] ?? "";
|
|
if (typeof value === "string") {
|
|
if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value);
|
|
return truncateForLog(value);
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean" || value == null) {
|
|
return value;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (depth >= 6) return "[array-truncated]";
|
|
const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1));
|
|
if (value.length > 20) out.push(`[+${value.length - 20} more items]`);
|
|
return out;
|
|
}
|
|
if (typeof value === "object") {
|
|
if (depth >= 6) return "[object-truncated]";
|
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, entry] of entries.slice(0, 80)) {
|
|
out[key] = redactForLog(entry, [...keyPath, key], depth + 1);
|
|
}
|
|
if (entries.length > 80) {
|
|
out.__truncated__ = `+${entries.length - 80} keys`;
|
|
}
|
|
return out;
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function stringifyForLog(value: unknown, maxChars: number): string {
|
|
const text = JSON.stringify(value);
|
|
if (text.length <= maxChars) return text;
|
|
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
|
|
}
|
|
|
|
function buildWakePayload(ctx: AdapterExecutionContext): WakePayload {
|
|
const { runId, agent, context } = ctx;
|
|
return {
|
|
runId,
|
|
agentId: agent.id,
|
|
companyId: agent.companyId,
|
|
taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId),
|
|
issueId: nonEmpty(context.issueId),
|
|
wakeReason: nonEmpty(context.wakeReason),
|
|
wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId),
|
|
approvalId: nonEmpty(context.approvalId),
|
|
approvalStatus: nonEmpty(context.approvalStatus),
|
|
issueIds: Array.isArray(context.issueIds)
|
|
? context.issueIds.filter(
|
|
(value): value is string => typeof value === "string" && value.trim().length > 0,
|
|
)
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function resolvePaperclipApiUrlOverride(value: unknown): string | null {
|
|
const raw = nonEmpty(value);
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = new URL(raw);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const DEFAULT_CLAIMED_API_KEY_PATH = "~/.openclaw/workspace/paperclip-claimed-api-key.json";
|
|
|
|
export function resolveClaimedApiKeyPath(value: unknown): string {
|
|
return nonEmpty(value) ?? DEFAULT_CLAIMED_API_KEY_PATH;
|
|
}
|
|
|
|
function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record<string, string> {
|
|
const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl);
|
|
const paperclipEnv: Record<string, string> = {
|
|
...buildPaperclipEnv(ctx.agent),
|
|
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
|
PAPERCLIP_RUN_ID: ctx.runId,
|
|
};
|
|
|
|
if (paperclipApiUrlOverride) {
|
|
paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride;
|
|
}
|
|
if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId;
|
|
const issueWorkMode = readPaperclipIssueWorkModeFromContext(ctx.context);
|
|
if (issueWorkMode) paperclipEnv.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
|
if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason;
|
|
if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId;
|
|
if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId;
|
|
if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus;
|
|
if (wakePayload.issueIds.length > 0) {
|
|
paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(",");
|
|
}
|
|
|
|
return paperclipEnv;
|
|
}
|
|
|
|
function buildWakeText(
|
|
payload: WakePayload,
|
|
paperclipEnv: Record<string, string>,
|
|
structuredWakePrompt: string,
|
|
claimedApiKeyPath: string,
|
|
conversationTaskMarkdown?: string,
|
|
): string {
|
|
const orderedKeys = [
|
|
"PAPERCLIP_RUN_ID",
|
|
"PAPERCLIP_AGENT_ID",
|
|
"PAPERCLIP_COMPANY_ID",
|
|
"PAPERCLIP_API_URL",
|
|
"PAPERCLIP_TASK_ID",
|
|
"PAPERCLIP_WAKE_REASON",
|
|
"PAPERCLIP_WAKE_COMMENT_ID",
|
|
"PAPERCLIP_APPROVAL_ID",
|
|
"PAPERCLIP_APPROVAL_STATUS",
|
|
"PAPERCLIP_LINKED_ISSUE_IDS",
|
|
];
|
|
|
|
const envLines: string[] = [];
|
|
for (const key of orderedKeys) {
|
|
const value = paperclipEnv[key];
|
|
if (!value) continue;
|
|
envLines.push(`${key}=${value}`);
|
|
}
|
|
|
|
const issueIdHint = payload.taskId ?? payload.issueId ?? "";
|
|
const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? "<set PAPERCLIP_API_URL>";
|
|
|
|
if (conversationTaskMarkdown !== undefined) {
|
|
return [
|
|
"Paperclip conversation turn for a cloud adapter.",
|
|
"Set these values in your run context:",
|
|
...envLines,
|
|
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token saved after claim-api-key).`,
|
|
"Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call and X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutation.",
|
|
"Follow the supplied chat mode directive. Keep this conversation available for the next message.",
|
|
structuredWakePrompt,
|
|
conversationTaskMarkdown,
|
|
].join("\n\n");
|
|
}
|
|
|
|
const lines = [
|
|
"Paperclip wake event for a cloud adapter.",
|
|
"",
|
|
"Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.",
|
|
"",
|
|
"Set these values in your run context:",
|
|
...envLines,
|
|
`PAPERCLIP_API_KEY=<token from ${claimedApiKeyPath}>`,
|
|
"",
|
|
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`,
|
|
"",
|
|
`api_base=${apiBaseHint}`,
|
|
`task_id=${payload.taskId ?? ""}`,
|
|
`issue_id=${payload.issueId ?? ""}`,
|
|
`wake_reason=${payload.wakeReason ?? ""}`,
|
|
`wake_comment_id=${payload.wakeCommentId ?? ""}`,
|
|
`approval_id=${payload.approvalId ?? ""}`,
|
|
`approval_status=${payload.approvalStatus ?? ""}`,
|
|
`linked_issue_ids=${payload.issueIds.join(",")}`,
|
|
"",
|
|
"HTTP rules:",
|
|
"- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.",
|
|
"- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.",
|
|
"- Use only /api endpoints listed below.",
|
|
"- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.",
|
|
"",
|
|
"Workflow:",
|
|
"1) GET /api/agents/me",
|
|
`2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`,
|
|
' Replace {issueId} in every endpoint below with that determined id. Never send the literal text "{issueId}" in a URL.',
|
|
"3) If issueId exists:",
|
|
" - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\",\"in_review\"]}",
|
|
" - GET /api/issues/{issueId}",
|
|
" - GET /api/issues/{issueId}/comments",
|
|
" - Execute the issue instructions exactly. If the issue is actionable, take concrete action in this run; do not stop at a plan unless planning was requested.",
|
|
" - Leave durable progress with a clear next action. Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.",
|
|
" - Create child issues directly when you know what needs to be done; use POST /api/issues/{issueId}/interactions with kind suggest_tasks, ask_user_questions, or request_confirmation when the board/user must choose, answer, or confirm before you can continue.",
|
|
" - For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}; wait for acceptance before creating implementation subtasks.",
|
|
" - If blocked, PATCH /api/issues/{issueId} with {\"status\":\"blocked\",\"comment\":\"what is blocked, who owns the unblock, and the next action\"}.",
|
|
" - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.",
|
|
" - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.",
|
|
"4) If issueId does not exist:",
|
|
" - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,in_review,blocked",
|
|
" - Pick in_progress first, then in_review when you were woken by a comment, then todo, then blocked, then execute step 3.",
|
|
"",
|
|
"Useful endpoints for issue work:",
|
|
"- POST /api/issues/{issueId}/comments",
|
|
"- PATCH /api/issues/{issueId}",
|
|
"- POST /api/companies/{companyId}/issues (when asked to create a new issue)",
|
|
...(structuredWakePrompt
|
|
? [
|
|
"",
|
|
structuredWakePrompt,
|
|
]
|
|
: []),
|
|
"",
|
|
"Complete the workflow in this run.",
|
|
];
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function appendWakeText(baseText: string, wakeText: string): string {
|
|
const trimmedBase = baseText.trim();
|
|
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
|
|
}
|
|
|
|
function joinWakePayloadSections(structuredWakePrompt: string, structuredWakeJson: string): string {
|
|
const sections = [
|
|
structuredWakePrompt.trim(),
|
|
"Structured wake payload JSON:",
|
|
"```json",
|
|
structuredWakeJson,
|
|
"```",
|
|
].filter((entry) => entry.trim().length > 0);
|
|
return sections.join("\n");
|
|
}
|
|
|
|
export function buildAgentParams(input: {
|
|
payloadTemplate: Record<string, unknown>;
|
|
message: string;
|
|
sessionKey: string;
|
|
runId: string;
|
|
configuredAgentId: string | null;
|
|
waitTimeoutMs: number;
|
|
}): Record<string, unknown> {
|
|
const agentParams: Record<string, unknown> = {
|
|
...input.payloadTemplate,
|
|
message: input.message,
|
|
sessionKey: input.sessionKey,
|
|
idempotencyKey: input.runId,
|
|
};
|
|
delete agentParams.text;
|
|
delete agentParams.paperclip;
|
|
|
|
if (input.configuredAgentId && !nonEmpty(agentParams.agentId)) {
|
|
agentParams.agentId = input.configuredAgentId;
|
|
}
|
|
|
|
if (typeof agentParams.timeout !== "number") {
|
|
agentParams.timeout = input.waitTimeoutMs;
|
|
}
|
|
|
|
return agentParams;
|
|
}
|
|
|
|
function normalizeUrl(input: string): URL | null {
|
|
try {
|
|
return new URL(input);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function rawDataToString(data: unknown): string {
|
|
if (typeof data === "string") return data;
|
|
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
|
if (Array.isArray(data)) {
|
|
return Buffer.concat(
|
|
data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))),
|
|
).toString("utf8");
|
|
}
|
|
return String(data ?? "");
|
|
}
|
|
|
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
promise
|
|
.then((value) => {
|
|
clearTimeout(timer);
|
|
resolve(value);
|
|
})
|
|
.catch((err) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
|
const key = crypto.createPublicKey(publicKeyPem);
|
|
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
|
|
if (
|
|
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
) {
|
|
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
}
|
|
return spki;
|
|
}
|
|
|
|
function base64UrlEncode(buf: Buffer): string {
|
|
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
}
|
|
|
|
function signDevicePayload(privateKeyPem: string, payload: string): string {
|
|
const key = crypto.createPrivateKey(privateKeyPem);
|
|
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
|
|
return base64UrlEncode(sig);
|
|
}
|
|
|
|
function buildDeviceAuthPayloadV3(params: {
|
|
deviceId: string;
|
|
clientId: string;
|
|
clientMode: string;
|
|
role: string;
|
|
scopes: string[];
|
|
signedAtMs: number;
|
|
token?: string | null;
|
|
nonce: string;
|
|
platform?: string | null;
|
|
deviceFamily?: string | null;
|
|
}): string {
|
|
const scopes = params.scopes.join(",");
|
|
const token = params.token ?? "";
|
|
const platform = params.platform?.trim() ?? "";
|
|
const deviceFamily = params.deviceFamily?.trim() ?? "";
|
|
return [
|
|
"v3",
|
|
params.deviceId,
|
|
params.clientId,
|
|
params.clientMode,
|
|
params.role,
|
|
scopes,
|
|
String(params.signedAtMs),
|
|
token,
|
|
params.nonce,
|
|
platform,
|
|
deviceFamily,
|
|
].join("|");
|
|
}
|
|
|
|
function resolveDeviceIdentity(config: Record<string, unknown>): GatewayDeviceIdentity {
|
|
const configuredPrivateKey = nonEmpty(config.devicePrivateKeyPem);
|
|
if (configuredPrivateKey) {
|
|
const privateKey = crypto.createPrivateKey(configuredPrivateKey);
|
|
const publicKey = crypto.createPublicKey(privateKey);
|
|
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
return {
|
|
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
|
publicKeyRawBase64Url: base64UrlEncode(raw),
|
|
privateKeyPem: configuredPrivateKey,
|
|
source: "configured",
|
|
};
|
|
}
|
|
|
|
const generated = crypto.generateKeyPairSync("ed25519");
|
|
const publicKeyPem = generated.publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
const privateKeyPem = generated.privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
const raw = derivePublicKeyRaw(publicKeyPem);
|
|
return {
|
|
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
|
publicKeyRawBase64Url: base64UrlEncode(raw),
|
|
privateKeyPem,
|
|
source: "ephemeral",
|
|
};
|
|
}
|
|
|
|
function isResponseFrame(value: unknown): value is GatewayResponseFrame {
|
|
const record = asRecord(value);
|
|
return Boolean(record && record.type === "res" && typeof record.id === "string" && typeof record.ok === "boolean");
|
|
}
|
|
|
|
function isEventFrame(value: unknown): value is GatewayEventFrame {
|
|
const record = asRecord(value);
|
|
return Boolean(record && record.type === "event" && typeof record.event === "string");
|
|
}
|
|
|
|
class GatewayWsClient {
|
|
private ws: WebSocket | null = null;
|
|
private pending = new Map<string, PendingRequest>();
|
|
private challengePromise: Promise<string>;
|
|
private resolveChallenge!: (nonce: string) => void;
|
|
private rejectChallenge!: (err: Error) => void;
|
|
|
|
constructor(private readonly opts: GatewayClientOptions) {
|
|
this.challengePromise = new Promise<string>((resolve, reject) => {
|
|
this.resolveChallenge = resolve;
|
|
this.rejectChallenge = reject;
|
|
});
|
|
this.challengePromise.catch(() => {});
|
|
}
|
|
|
|
async connect(
|
|
buildConnectParams: (nonce: string) => Record<string, unknown>,
|
|
timeoutMs: number,
|
|
): Promise<Record<string, unknown> | null> {
|
|
this.ws = new WebSocket(this.opts.url, {
|
|
headers: this.opts.headers,
|
|
maxPayload: 25 * 1024 * 1024,
|
|
});
|
|
|
|
const ws = this.ws;
|
|
|
|
ws.on("message", (data) => {
|
|
this.handleMessage(rawDataToString(data));
|
|
});
|
|
|
|
ws.on("close", (code, reason) => {
|
|
const reasonText = rawDataToString(reason);
|
|
const err = new Error(`gateway closed (${code}): ${reasonText}`);
|
|
this.failPending(err);
|
|
this.rejectChallenge(err);
|
|
});
|
|
|
|
ws.on("error", (err) => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
void this.opts.onLog("stderr", `[openclaw-gateway] websocket error: ${message}\n`);
|
|
});
|
|
|
|
await withTimeout(
|
|
new Promise<void>((resolve, reject) => {
|
|
const onOpen = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
const onError = (err: Error) => {
|
|
cleanup();
|
|
reject(err);
|
|
};
|
|
const onClose = (code: number, reason: Buffer) => {
|
|
cleanup();
|
|
reject(new Error(`gateway closed before open (${code}): ${rawDataToString(reason)}`));
|
|
};
|
|
const cleanup = () => {
|
|
ws.off("open", onOpen);
|
|
ws.off("error", onError);
|
|
ws.off("close", onClose);
|
|
};
|
|
ws.once("open", onOpen);
|
|
ws.once("error", onError);
|
|
ws.once("close", onClose);
|
|
}),
|
|
timeoutMs,
|
|
"gateway websocket open timeout",
|
|
);
|
|
|
|
const nonce = await withTimeout(this.challengePromise, timeoutMs, "gateway connect challenge timeout");
|
|
const signedConnectParams = buildConnectParams(nonce);
|
|
|
|
const hello = await this.request<Record<string, unknown> | null>("connect", signedConnectParams, {
|
|
timeoutMs,
|
|
});
|
|
|
|
return hello;
|
|
}
|
|
|
|
async request<T>(
|
|
method: string,
|
|
params: unknown,
|
|
opts: GatewayClientRequestOptions,
|
|
): Promise<T> {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
throw new Error("gateway not connected");
|
|
}
|
|
|
|
const id = randomUUID();
|
|
const frame: GatewayRequestFrame = {
|
|
type: "req",
|
|
id,
|
|
method,
|
|
params,
|
|
};
|
|
|
|
const payload = JSON.stringify(frame);
|
|
const requestPromise = new Promise<T>((resolve, reject) => {
|
|
const timer =
|
|
opts.timeoutMs > 0
|
|
? setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`gateway request timeout (${method})`));
|
|
}, opts.timeoutMs)
|
|
: null;
|
|
|
|
this.pending.set(id, {
|
|
resolve: (value) => resolve(value as T),
|
|
reject,
|
|
expectFinal: opts.expectFinal === true,
|
|
timer,
|
|
});
|
|
});
|
|
|
|
this.ws.send(payload);
|
|
return requestPromise;
|
|
}
|
|
|
|
close() {
|
|
if (!this.ws) return;
|
|
this.ws.close(1000, "paperclip-complete");
|
|
this.ws = null;
|
|
}
|
|
|
|
private failPending(err: Error) {
|
|
for (const [, pending] of this.pending) {
|
|
if (pending.timer) clearTimeout(pending.timer);
|
|
pending.reject(err);
|
|
}
|
|
this.pending.clear();
|
|
}
|
|
|
|
private handleMessage(raw: string) {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (isEventFrame(parsed)) {
|
|
if (parsed.event === "connect.challenge") {
|
|
const payload = asRecord(parsed.payload);
|
|
const nonce = nonEmpty(payload?.nonce);
|
|
if (nonce) {
|
|
this.resolveChallenge(nonce);
|
|
return;
|
|
}
|
|
}
|
|
void Promise.resolve(this.opts.onEvent(parsed)).catch(() => {
|
|
// Ignore event callback failures and keep stream active.
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!isResponseFrame(parsed)) return;
|
|
|
|
const pending = this.pending.get(parsed.id);
|
|
if (!pending) return;
|
|
|
|
const payload = asRecord(parsed.payload);
|
|
const status = nonEmpty(payload?.status)?.toLowerCase();
|
|
if (pending.expectFinal && status === "accepted") {
|
|
return;
|
|
}
|
|
|
|
if (pending.timer) clearTimeout(pending.timer);
|
|
this.pending.delete(parsed.id);
|
|
|
|
if (parsed.ok) {
|
|
pending.resolve(parsed.payload ?? null);
|
|
return;
|
|
}
|
|
|
|
const errorRecord = asRecord(parsed.error);
|
|
const message =
|
|
nonEmpty(errorRecord?.message) ??
|
|
nonEmpty(errorRecord?.code) ??
|
|
"gateway request failed";
|
|
const err = new Error(message) as GatewayResponseError;
|
|
const code = nonEmpty(errorRecord?.code);
|
|
const details = asRecord(errorRecord?.details);
|
|
if (code) err.gatewayCode = code;
|
|
if (details) err.gatewayDetails = details;
|
|
pending.reject(err);
|
|
}
|
|
}
|
|
|
|
async function autoApproveDevicePairing(params: {
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
connectTimeoutMs: number;
|
|
clientId: string;
|
|
clientMode: string;
|
|
clientVersion: string;
|
|
role: string;
|
|
scopes: string[];
|
|
authToken: string | null;
|
|
password: string | null;
|
|
requestId: string | null;
|
|
deviceId: string | null;
|
|
onLog: AdapterExecutionContext["onLog"];
|
|
}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> {
|
|
if (!params.authToken && !params.password) {
|
|
return { ok: false, reason: "shared auth token/password is missing" };
|
|
}
|
|
|
|
const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]);
|
|
const client = new GatewayWsClient({
|
|
url: params.url,
|
|
headers: params.headers,
|
|
onEvent: () => {},
|
|
onLog: params.onLog,
|
|
});
|
|
|
|
try {
|
|
await params.onLog(
|
|
"stdout",
|
|
"[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n",
|
|
);
|
|
|
|
await client.connect(
|
|
() => ({
|
|
minProtocol: PROTOCOL_VERSION,
|
|
maxProtocol: PROTOCOL_VERSION,
|
|
client: {
|
|
id: params.clientId,
|
|
version: params.clientVersion,
|
|
platform: process.platform,
|
|
mode: params.clientMode,
|
|
},
|
|
role: params.role,
|
|
scopes: approvalScopes,
|
|
auth: {
|
|
...(params.authToken ? { token: params.authToken } : {}),
|
|
...(params.password ? { password: params.password } : {}),
|
|
},
|
|
}),
|
|
params.connectTimeoutMs,
|
|
);
|
|
|
|
let requestId = params.requestId;
|
|
if (!requestId) {
|
|
const listPayload = await client.request<Record<string, unknown>>("device.pair.list", {}, {
|
|
timeoutMs: params.connectTimeoutMs,
|
|
});
|
|
const pending = Array.isArray(listPayload.pending) ? listPayload.pending : [];
|
|
const pendingRecords = pending
|
|
.map((entry) => asRecord(entry))
|
|
.filter((entry): entry is Record<string, unknown> => Boolean(entry));
|
|
const matching =
|
|
(params.deviceId
|
|
? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId)
|
|
: null) ?? pendingRecords[pendingRecords.length - 1];
|
|
requestId = nonEmpty(matching?.requestId);
|
|
}
|
|
|
|
if (!requestId) {
|
|
return { ok: false, reason: "no pending device pairing request found" };
|
|
}
|
|
|
|
await client.request(
|
|
"device.pair.approve",
|
|
{ requestId },
|
|
{
|
|
timeoutMs: params.connectTimeoutMs,
|
|
},
|
|
);
|
|
|
|
return { ok: true, requestId };
|
|
} catch (err) {
|
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined {
|
|
const record = asRecord(value);
|
|
if (!record) return undefined;
|
|
|
|
const inputTokens = asNumber(record.inputTokens ?? record.input, 0);
|
|
const outputTokens = asNumber(record.outputTokens ?? record.output, 0);
|
|
const cachedInputTokens = asNumber(
|
|
record.cachedInputTokens ?? record.cached_input_tokens ?? record.cacheRead ?? record.cache_read,
|
|
0,
|
|
);
|
|
|
|
if (inputTokens <= 0 && outputTokens <= 0 && cachedInputTokens <= 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
inputTokens,
|
|
outputTokens,
|
|
...(cachedInputTokens > 0 ? { cachedInputTokens } : {}),
|
|
};
|
|
}
|
|
|
|
function extractRuntimeServicesFromMeta(meta: Record<string, unknown> | null): AdapterRuntimeServiceReport[] {
|
|
if (!meta) return [];
|
|
const reports: AdapterRuntimeServiceReport[] = [];
|
|
|
|
const runtimeServices = Array.isArray(meta.runtimeServices)
|
|
? meta.runtimeServices.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
|
|
: [];
|
|
for (const entry of runtimeServices) {
|
|
const serviceName = nonEmpty(entry.serviceName) ?? nonEmpty(entry.name);
|
|
if (!serviceName) continue;
|
|
const rawStatus = nonEmpty(entry.status)?.toLowerCase();
|
|
const status =
|
|
rawStatus === "starting" || rawStatus === "running" || rawStatus === "stopped" || rawStatus === "failed"
|
|
? rawStatus
|
|
: "running";
|
|
const rawLifecycle = nonEmpty(entry.lifecycle)?.toLowerCase();
|
|
const lifecycle = rawLifecycle === "shared" ? "shared" : "ephemeral";
|
|
const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase();
|
|
const scopeType =
|
|
rawScopeType === "project_workspace" ||
|
|
rawScopeType === "execution_workspace" ||
|
|
rawScopeType === "agent"
|
|
? rawScopeType
|
|
: "run";
|
|
const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase();
|
|
const healthStatus =
|
|
rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "unknown"
|
|
? rawHealth
|
|
: status === "running"
|
|
? "healthy"
|
|
: "unknown";
|
|
|
|
reports.push({
|
|
id: nonEmpty(entry.id),
|
|
projectId: nonEmpty(entry.projectId),
|
|
projectWorkspaceId: nonEmpty(entry.projectWorkspaceId),
|
|
issueId: nonEmpty(entry.issueId),
|
|
scopeType,
|
|
scopeId: nonEmpty(entry.scopeId),
|
|
serviceName,
|
|
status,
|
|
lifecycle,
|
|
reuseKey: nonEmpty(entry.reuseKey),
|
|
command: nonEmpty(entry.command),
|
|
cwd: nonEmpty(entry.cwd),
|
|
port: parseOptionalPositiveInteger(entry.port),
|
|
url: nonEmpty(entry.url),
|
|
providerRef: nonEmpty(entry.providerRef) ?? nonEmpty(entry.previewId),
|
|
ownerAgentId: nonEmpty(entry.ownerAgentId),
|
|
stopPolicy: asRecord(entry.stopPolicy),
|
|
healthStatus,
|
|
});
|
|
}
|
|
|
|
const previewUrl = nonEmpty(meta.previewUrl);
|
|
if (previewUrl) {
|
|
reports.push({
|
|
serviceName: "preview",
|
|
status: "running",
|
|
lifecycle: "ephemeral",
|
|
scopeType: "run",
|
|
url: previewUrl,
|
|
providerRef: nonEmpty(meta.previewId) ?? previewUrl,
|
|
healthStatus: "healthy",
|
|
});
|
|
}
|
|
|
|
const previewUrls = Array.isArray(meta.previewUrls)
|
|
? meta.previewUrls.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
: [];
|
|
previewUrls.forEach((url, index) => {
|
|
reports.push({
|
|
serviceName: index === 0 ? "preview" : `preview-${index + 1}`,
|
|
status: "running",
|
|
lifecycle: "ephemeral",
|
|
scopeType: "run",
|
|
url,
|
|
providerRef: `${url}#${index}`,
|
|
healthStatus: "healthy",
|
|
});
|
|
});
|
|
|
|
return reports;
|
|
}
|
|
|
|
function extractResultText(value: unknown): string | null {
|
|
const record = asRecord(value);
|
|
if (!record) return null;
|
|
|
|
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
|
|
const texts = payloads
|
|
.map((entry) => {
|
|
const payload = asRecord(entry);
|
|
return nonEmpty(payload?.text);
|
|
})
|
|
.filter((entry): entry is string => Boolean(entry));
|
|
|
|
if (texts.length > 0) return texts.join("\n\n");
|
|
return nonEmpty(record.text) ?? nonEmpty(record.summary) ?? null;
|
|
}
|
|
|
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
|
const urlValue = asString(ctx.config.url, "").trim();
|
|
if (!urlValue) {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: "OpenClaw gateway adapter missing url",
|
|
errorCode: "openclaw_gateway_url_missing",
|
|
};
|
|
}
|
|
|
|
const parsedUrl = normalizeUrl(urlValue);
|
|
if (!parsedUrl) {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: `Invalid gateway URL: ${urlValue}`,
|
|
errorCode: "openclaw_gateway_url_invalid",
|
|
};
|
|
}
|
|
|
|
if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: `Unsupported gateway URL protocol: ${parsedUrl.protocol}`,
|
|
errorCode: "openclaw_gateway_url_protocol",
|
|
};
|
|
}
|
|
|
|
const timeoutSec = Math.max(0, Math.floor(asNumber(ctx.config.timeoutSec, 120)));
|
|
const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 0;
|
|
const connectTimeoutMs = timeoutMs > 0 ? Math.min(timeoutMs, 15_000) : 10_000;
|
|
const waitTimeoutMs = parseOptionalPositiveInteger(ctx.config.waitTimeoutMs) ?? (timeoutMs > 0 ? timeoutMs : 30_000);
|
|
|
|
const payloadTemplate = parseObject(ctx.config.payloadTemplate);
|
|
const transportHint = nonEmpty(ctx.config.streamTransport) ?? nonEmpty(ctx.config.transport);
|
|
|
|
const headers = toStringRecord(ctx.config.headers);
|
|
const authToken = resolveAuthToken(parseObject(ctx.config), headers);
|
|
const password = nonEmpty(ctx.config.password);
|
|
const deviceToken = nonEmpty(ctx.config.deviceToken);
|
|
|
|
if (authToken && !headerMapHasIgnoreCase(headers, "authorization")) {
|
|
headers.authorization = toAuthorizationHeaderValue(authToken);
|
|
}
|
|
|
|
const clientId = nonEmpty(ctx.config.clientId) ?? DEFAULT_CLIENT_ID;
|
|
const clientMode = nonEmpty(ctx.config.clientMode) ?? DEFAULT_CLIENT_MODE;
|
|
const clientVersion = nonEmpty(ctx.config.clientVersion) ?? DEFAULT_CLIENT_VERSION;
|
|
const role = nonEmpty(ctx.config.role) ?? DEFAULT_ROLE;
|
|
const scopes = normalizeScopes(ctx.config.scopes);
|
|
const deviceFamily = nonEmpty(ctx.config.deviceFamily);
|
|
const disableDeviceAuth = parseBoolean(ctx.config.disableDeviceAuth, false);
|
|
|
|
const wakePayload = buildWakePayload(ctx);
|
|
const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload);
|
|
// No heartbeat prompt template is sent over the gateway, so the wake prompt
|
|
// must carry the execution contract itself.
|
|
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, {
|
|
includeExecutionContract: true,
|
|
conversationMode: ctx.context.conversationMode === true,
|
|
});
|
|
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
|
|
const wakeText = buildWakeText(
|
|
wakePayload,
|
|
paperclipEnv,
|
|
structuredWakeJson
|
|
? joinWakePayloadSections(structuredWakePrompt, structuredWakeJson)
|
|
: structuredWakePrompt,
|
|
resolveClaimedApiKeyPath(ctx.config.claimedApiKeyPath),
|
|
ctx.context.conversationMode === true
|
|
? selectPaperclipTaskMarkdown(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId) })
|
|
: undefined,
|
|
);
|
|
|
|
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
|
const configuredSessionKey = nonEmpty(ctx.config.sessionKey);
|
|
const configuredAgentId = nonEmpty(ctx.config.agentId);
|
|
const sessionKey = resolveSessionKey({
|
|
strategy: sessionKeyStrategy,
|
|
configuredSessionKey,
|
|
agentId: configuredAgentId,
|
|
runId: ctx.runId,
|
|
issueId: wakePayload.issueId,
|
|
});
|
|
|
|
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
|
|
const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
|
|
|
|
const agentParams = buildAgentParams({
|
|
payloadTemplate,
|
|
message,
|
|
sessionKey,
|
|
runId: ctx.runId,
|
|
configuredAgentId,
|
|
waitTimeoutMs,
|
|
});
|
|
|
|
if (ctx.onMeta) {
|
|
await ctx.onMeta({
|
|
adapterType: "openclaw_gateway",
|
|
command: "gateway",
|
|
commandArgs: ["ws", parsedUrl.toString(), "agent"],
|
|
context: ctx.context,
|
|
});
|
|
}
|
|
|
|
const outboundHeaderKeys = Object.keys(headers).sort();
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
|
|
);
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`,
|
|
);
|
|
await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
|
if (transportHint) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`,
|
|
);
|
|
}
|
|
if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
"[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n",
|
|
);
|
|
}
|
|
|
|
const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true);
|
|
let autoPairAttempted = false;
|
|
let latestResultPayload: unknown = null;
|
|
let retryCount = 0;
|
|
let dispatchReported = false;
|
|
const MAX_RETRIES = 2;
|
|
|
|
const reportDispatch = () => {
|
|
if (dispatchReported) return;
|
|
dispatchReported = true;
|
|
ctx.onDispatch?.();
|
|
};
|
|
|
|
while (true) {
|
|
const trackedRunIds = new Set<string>([ctx.runId]);
|
|
const assistantChunks: string[] = [];
|
|
let lifecycleError: string | null = null;
|
|
let deviceIdentity: GatewayDeviceIdentity | null = null;
|
|
|
|
const onEvent = async (frame: GatewayEventFrame) => {
|
|
if (frame.event !== "agent") {
|
|
if (frame.event === "shutdown") {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const payload = asRecord(frame.payload);
|
|
if (!payload) return;
|
|
|
|
const runId = nonEmpty(payload.runId);
|
|
if (!runId || !trackedRunIds.has(runId)) return;
|
|
|
|
const stream = nonEmpty(payload.stream) ?? "unknown";
|
|
const data = asRecord(payload.data) ?? {};
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`,
|
|
);
|
|
|
|
if (stream === "assistant") {
|
|
const delta = nonEmpty(data.delta);
|
|
const text = nonEmpty(data.text);
|
|
if (delta) {
|
|
assistantChunks.push(delta);
|
|
} else if (text) {
|
|
assistantChunks.push(text);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (stream === "error") {
|
|
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
|
return;
|
|
}
|
|
|
|
if (stream === "lifecycle") {
|
|
const phase = nonEmpty(data.phase)?.toLowerCase();
|
|
if (phase === "error" || phase === "failed" || phase === "cancelled") {
|
|
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
|
}
|
|
}
|
|
};
|
|
|
|
const client = new GatewayWsClient({
|
|
url: parsedUrl.toString(),
|
|
headers,
|
|
onEvent,
|
|
onLog: ctx.onLog,
|
|
});
|
|
|
|
try {
|
|
deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
|
if (deviceIdentity) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`,
|
|
);
|
|
} else {
|
|
await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n");
|
|
}
|
|
|
|
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
|
|
|
|
const hello = await client.connect((nonce) => {
|
|
const signedAtMs = Date.now();
|
|
const connectParams: Record<string, unknown> = {
|
|
minProtocol: PROTOCOL_VERSION,
|
|
maxProtocol: PROTOCOL_VERSION,
|
|
client: {
|
|
id: clientId,
|
|
version: clientVersion,
|
|
platform: process.platform,
|
|
...(deviceFamily ? { deviceFamily } : {}),
|
|
mode: clientMode,
|
|
},
|
|
role,
|
|
scopes,
|
|
auth:
|
|
authToken || password || deviceToken
|
|
? {
|
|
...(authToken ? { token: authToken } : {}),
|
|
...(deviceToken ? { deviceToken } : {}),
|
|
...(password ? { password } : {}),
|
|
}
|
|
: undefined,
|
|
};
|
|
|
|
if (deviceIdentity) {
|
|
const payload = buildDeviceAuthPayloadV3({
|
|
deviceId: deviceIdentity.deviceId,
|
|
clientId,
|
|
clientMode,
|
|
role,
|
|
scopes,
|
|
signedAtMs,
|
|
token: authToken,
|
|
nonce,
|
|
platform: process.platform,
|
|
deviceFamily,
|
|
});
|
|
connectParams.device = {
|
|
id: deviceIdentity.deviceId,
|
|
publicKey: deviceIdentity.publicKeyRawBase64Url,
|
|
signature: signDevicePayload(deviceIdentity.privateKeyPem, payload),
|
|
signedAt: signedAtMs,
|
|
nonce,
|
|
};
|
|
}
|
|
return connectParams;
|
|
}, connectTimeoutMs);
|
|
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`,
|
|
);
|
|
|
|
// Keep any server-side continuation lock through retryable websocket
|
|
// setup and backoff. The first agent request is the remote-work boundary:
|
|
// once it is sent, retrying would be unsafe because the gateway may have
|
|
// accepted work even if the response is lost.
|
|
reportDispatch();
|
|
const acceptedPayload = await client.request<Record<string, unknown>>("agent", agentParams, {
|
|
timeoutMs: connectTimeoutMs,
|
|
});
|
|
|
|
latestResultPayload = acceptedPayload;
|
|
|
|
const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? "";
|
|
const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId;
|
|
trackedRunIds.add(acceptedRunId);
|
|
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`,
|
|
);
|
|
|
|
if (acceptedStatus === "error") {
|
|
const errorMessage =
|
|
nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage,
|
|
errorCode: "openclaw_gateway_agent_error",
|
|
resultJson: acceptedPayload,
|
|
};
|
|
}
|
|
|
|
if (acceptedStatus !== "ok") {
|
|
const waitPayload = await client.request<Record<string, unknown>>(
|
|
"agent.wait",
|
|
{ runId: acceptedRunId, timeoutMs: waitTimeoutMs },
|
|
{ timeoutMs: waitTimeoutMs + connectTimeoutMs },
|
|
);
|
|
|
|
latestResultPayload = waitPayload;
|
|
|
|
const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? "";
|
|
if (waitStatus === "timeout") {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: true,
|
|
errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`,
|
|
errorCode: "openclaw_gateway_wait_timeout",
|
|
resultJson: waitPayload,
|
|
};
|
|
}
|
|
|
|
if (waitStatus === "error") {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage:
|
|
nonEmpty(waitPayload?.error) ??
|
|
lifecycleError ??
|
|
"OpenClaw gateway run failed",
|
|
errorCode: "openclaw_gateway_wait_error",
|
|
resultJson: waitPayload,
|
|
};
|
|
}
|
|
|
|
if (waitStatus && waitStatus !== "ok") {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`,
|
|
errorCode: "openclaw_gateway_wait_status_unexpected",
|
|
resultJson: waitPayload,
|
|
};
|
|
}
|
|
}
|
|
|
|
const summaryFromEvents = assistantChunks.join("").trim();
|
|
const summaryFromPayload =
|
|
extractResultText(asRecord(acceptedPayload?.result)) ??
|
|
extractResultText(acceptedPayload) ??
|
|
extractResultText(asRecord(latestResultPayload)) ??
|
|
null;
|
|
const summary = summaryFromEvents || summaryFromPayload || null;
|
|
|
|
const acceptedResult = asRecord(acceptedPayload?.result);
|
|
const latestPayload = asRecord(latestResultPayload);
|
|
const latestResult = asRecord(latestPayload?.result);
|
|
const acceptedMeta = asRecord(acceptedResult?.meta) ?? asRecord(acceptedPayload?.meta);
|
|
const latestMeta = asRecord(latestResult?.meta) ?? asRecord(latestPayload?.meta);
|
|
const mergedMeta = {
|
|
...(acceptedMeta ?? {}),
|
|
...(latestMeta ?? {}),
|
|
};
|
|
const agentMeta =
|
|
asRecord(mergedMeta.agentMeta) ??
|
|
asRecord(acceptedMeta?.agentMeta) ??
|
|
asRecord(latestMeta?.agentMeta);
|
|
const usage = parseUsage(agentMeta?.usage ?? mergedMeta.usage);
|
|
const runtimeServices = extractRuntimeServicesFromMeta(agentMeta ?? mergedMeta);
|
|
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(mergedMeta.provider) ?? "openclaw";
|
|
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(mergedMeta.model) ?? null;
|
|
const costUsd = asNumber(agentMeta?.costUsd ?? mergedMeta.costUsd, 0);
|
|
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`,
|
|
);
|
|
|
|
return {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
provider,
|
|
...(model ? { model } : {}),
|
|
...(usage ? { usage } : {}),
|
|
...(costUsd > 0 ? { costUsd } : {}),
|
|
resultJson: asRecord(latestResultPayload),
|
|
...(runtimeServices.length > 0 ? { runtimeServices } : {}),
|
|
...(summary ? { summary } : {}),
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const lower = message.toLowerCase();
|
|
const timedOut = lower.includes("timeout");
|
|
const pairingRequired = lower.includes("pairing required");
|
|
|
|
if (
|
|
pairingRequired &&
|
|
!disableDeviceAuth &&
|
|
autoPairOnFirstConnect &&
|
|
!autoPairAttempted &&
|
|
(authToken || password)
|
|
) {
|
|
autoPairAttempted = true;
|
|
const pairResult = await autoApproveDevicePairing({
|
|
url: parsedUrl.toString(),
|
|
headers,
|
|
connectTimeoutMs,
|
|
clientId,
|
|
clientMode,
|
|
clientVersion,
|
|
role,
|
|
scopes,
|
|
authToken,
|
|
password,
|
|
requestId: extractPairingRequestId(err),
|
|
deviceId: deviceIdentity?.deviceId ?? null,
|
|
onLog: ctx.onLog,
|
|
});
|
|
if (pairResult.ok) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`,
|
|
);
|
|
continue;
|
|
}
|
|
await ctx.onLog(
|
|
"stderr",
|
|
`[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`,
|
|
);
|
|
}
|
|
|
|
// Retry transient errors (connection refused, reset, socket hang up)
|
|
const isTransient =
|
|
!pairingRequired &&
|
|
(lower.includes("econnrefused") ||
|
|
lower.includes("econnreset") ||
|
|
lower.includes("socket hang up") ||
|
|
(timedOut && !lower.includes("agent.wait")));
|
|
|
|
if (isTransient && !dispatchReported && retryCount < MAX_RETRIES) {
|
|
retryCount++;
|
|
const backoffMs = retryCount * 2000;
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[openclaw-gateway] transient error, retry ${retryCount}/${MAX_RETRIES} after ${backoffMs}ms: ${message}\n`,
|
|
);
|
|
await new Promise((r) => setTimeout(r, backoffMs));
|
|
continue;
|
|
}
|
|
|
|
const detailedMessage = pairingRequired
|
|
? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url <gateway-ws-url> --token <gateway-token>) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.`
|
|
: message;
|
|
|
|
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
|
|
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut,
|
|
errorMessage: detailedMessage,
|
|
errorCode: timedOut
|
|
? "openclaw_gateway_timeout"
|
|
: pairingRequired
|
|
? "openclaw_gateway_pairing_required"
|
|
: "openclaw_gateway_request_failed",
|
|
resultJson: asRecord(latestResultPayload),
|
|
};
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
}
|