@orgloop/agentctl 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,19 @@
1
1
  import type { AgentAdapter, AgentSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
2
+ export interface DeviceIdentity {
3
+ deviceId: string;
4
+ publicKeyPem: string;
5
+ privateKeyPem: string;
6
+ }
7
+ /**
8
+ * Load or create agentctl's device identity. Uses its own key pair
9
+ * (separate from OpenClaw's identity at ~/.openclaw/identity/device.json).
10
+ */
11
+ export declare function loadOrCreateDeviceIdentity(filePath?: string): DeviceIdentity;
2
12
  export interface OpenClawAdapterOpts {
3
13
  baseUrl?: string;
4
14
  authToken?: string;
15
+ /** Device identity for scoped access. Auto-created if not provided. */
16
+ deviceIdentity?: DeviceIdentity | null;
5
17
  /** Override for testing — replaces the real WebSocket RPC call */
6
18
  rpcCall?: RpcCallFn;
7
19
  }
@@ -53,17 +65,63 @@ export interface SessionsPreviewResult {
53
65
  ts: number;
54
66
  previews: SessionsPreviewEntry[];
55
67
  }
68
+ /**
69
+ * Build the device auth payload string for signing.
70
+ * Format: v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
71
+ */
72
+ export declare function buildDeviceAuthPayload(params: {
73
+ deviceId: string;
74
+ clientId: string;
75
+ clientMode: string;
76
+ role: string;
77
+ scopes: string[];
78
+ signedAtMs: number;
79
+ token: string | null;
80
+ nonce: string | null;
81
+ }): string;
82
+ /**
83
+ * Build the full `connect` handshake params sent to the gateway.
84
+ * Includes device auth for scoped access when identity is available.
85
+ * Exported so tests can verify the protocol constants.
86
+ */
87
+ export declare function buildConnectParams(authToken: string, deviceIdentity?: DeviceIdentity | null, nonce?: string | null): {
88
+ minProtocol: number;
89
+ maxProtocol: number;
90
+ client: {
91
+ id: "cli";
92
+ version: string;
93
+ platform: NodeJS.Platform;
94
+ mode: "cli";
95
+ };
96
+ role: "operator";
97
+ scopes: string[];
98
+ auth: {
99
+ token: string | null;
100
+ };
101
+ device: {
102
+ id: string;
103
+ publicKey: string;
104
+ signature: string;
105
+ signedAt: number;
106
+ nonce: string | undefined;
107
+ } | undefined;
108
+ };
56
109
  /**
57
110
  * OpenClaw adapter — reads session data from the OpenClaw gateway via
58
111
  * its WebSocket RPC protocol. Falls back gracefully when the gateway
59
112
  * is unreachable.
113
+ *
114
+ * Uses Ed25519 device auth for scoped access (operator.read).
115
+ * Device identity is auto-created at ~/.agentctl/identity/device.json.
60
116
  */
61
117
  export declare class OpenClawAdapter implements AgentAdapter {
62
118
  readonly id = "openclaw";
63
119
  private readonly baseUrl;
64
120
  private readonly authToken;
121
+ private readonly deviceIdentity;
65
122
  private readonly rpcCall;
66
123
  constructor(opts?: OpenClawAdapterOpts);
124
+ private tryLoadDeviceIdentity;
67
125
  list(opts?: ListOpts): Promise<AgentSession[]>;
68
126
  peek(sessionId: string, opts?: PeekOpts): Promise<string>;
69
127
  status(sessionId: string): Promise<AgentSession>;
@@ -71,18 +129,11 @@ export declare class OpenClawAdapter implements AgentAdapter {
71
129
  stop(_sessionId: string, _opts?: StopOpts): Promise<void>;
72
130
  resume(sessionId: string, _message: string): Promise<void>;
73
131
  events(): AsyncIterable<LifecycleEvent>;
74
- /**
75
- * Map a gateway session row to the standard AgentSession interface.
76
- * OpenClaw sessions with a recent updatedAt are considered "running".
77
- */
78
132
  private mapRowToSession;
79
- /**
80
- * Resolve a sessionId (or prefix) to a gateway session key.
81
- */
82
133
  private resolveKey;
83
134
  /**
84
- * Real WebSocket RPC call — connects, performs handshake, sends one
85
- * request, reads the response, then disconnects.
135
+ * Real WebSocket RPC call — connects, performs handshake with device auth,
136
+ * sends one request, reads the response, then disconnects.
86
137
  */
87
138
  private defaultRpcCall;
88
139
  }
@@ -1,25 +1,206 @@
1
- import { randomUUID } from "node:crypto";
1
+ import crypto, { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import os from "node:os";
5
+ import path from "node:path";
2
6
  const DEFAULT_BASE_URL = "http://127.0.0.1:18789";
7
+ const _require = createRequire(import.meta.url);
8
+ const PKG_VERSION = _require("../../package.json").version;
9
+ // --- Device identity helpers ---
10
+ /** Ed25519 SPKI DER prefix (RFC 8410) — strip to get raw 32-byte public key */
11
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
12
+ function base64UrlEncode(buf) {
13
+ return buf
14
+ .toString("base64")
15
+ .replaceAll("+", "-")
16
+ .replaceAll("/", "_")
17
+ .replace(/=+$/g, "");
18
+ }
19
+ function derivePublicKeyRaw(publicKeyPem) {
20
+ const spki = crypto
21
+ .createPublicKey(publicKeyPem)
22
+ .export({ type: "spki", format: "der" });
23
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
24
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
25
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
26
+ }
27
+ return spki;
28
+ }
29
+ function fingerprintPublicKey(publicKeyPem) {
30
+ const raw = derivePublicKeyRaw(publicKeyPem);
31
+ return crypto.createHash("sha256").update(raw).digest("hex");
32
+ }
33
+ function publicKeyRawBase64Url(publicKeyPem) {
34
+ return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
35
+ }
36
+ function signPayload(privateKeyPem, payload) {
37
+ const key = crypto.createPrivateKey(privateKeyPem);
38
+ return base64UrlEncode(Buffer.from(crypto.sign(null, Buffer.from(payload, "utf8"), key)));
39
+ }
40
+ function generateDeviceIdentity() {
41
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
42
+ const publicKeyPem = publicKey
43
+ .export({ type: "spki", format: "pem" })
44
+ .toString();
45
+ const privateKeyPem = privateKey
46
+ .export({ type: "pkcs8", format: "pem" })
47
+ .toString();
48
+ return {
49
+ deviceId: fingerprintPublicKey(publicKeyPem),
50
+ publicKeyPem,
51
+ privateKeyPem,
52
+ };
53
+ }
54
+ /** Default identity path: ~/.agentctl/identity/device.json */
55
+ function resolveDefaultIdentityPath() {
56
+ return path.join(os.homedir(), ".agentctl", "identity", "device.json");
57
+ }
58
+ /**
59
+ * Load or create agentctl's device identity. Uses its own key pair
60
+ * (separate from OpenClaw's identity at ~/.openclaw/identity/device.json).
61
+ */
62
+ export function loadOrCreateDeviceIdentity(filePath = resolveDefaultIdentityPath()) {
63
+ try {
64
+ if (fs.existsSync(filePath)) {
65
+ const raw = fs.readFileSync(filePath, "utf8");
66
+ const parsed = JSON.parse(raw);
67
+ if (parsed?.version === 1 &&
68
+ typeof parsed.deviceId === "string" &&
69
+ typeof parsed.publicKeyPem === "string" &&
70
+ typeof parsed.privateKeyPem === "string") {
71
+ // Re-derive deviceId from public key in case it drifted
72
+ const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
73
+ return {
74
+ deviceId: derivedId,
75
+ publicKeyPem: parsed.publicKeyPem,
76
+ privateKeyPem: parsed.privateKeyPem,
77
+ };
78
+ }
79
+ }
80
+ }
81
+ catch {
82
+ // Fall through to generate new identity
83
+ }
84
+ const identity = generateDeviceIdentity();
85
+ const dir = path.dirname(filePath);
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ const stored = {
88
+ version: 1,
89
+ deviceId: identity.deviceId,
90
+ publicKeyPem: identity.publicKeyPem,
91
+ privateKeyPem: identity.privateKeyPem,
92
+ createdAtMs: Date.now(),
93
+ };
94
+ fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, {
95
+ mode: 0o600,
96
+ });
97
+ return identity;
98
+ }
99
+ /**
100
+ * Build the device auth payload string for signing.
101
+ * Format: v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
102
+ */
103
+ export function buildDeviceAuthPayload(params) {
104
+ const version = params.nonce ? "v2" : "v1";
105
+ const base = [
106
+ version,
107
+ params.deviceId,
108
+ params.clientId,
109
+ params.clientMode,
110
+ params.role,
111
+ params.scopes.join(","),
112
+ String(params.signedAtMs),
113
+ params.token ?? "",
114
+ ];
115
+ if (version === "v2")
116
+ base.push(params.nonce ?? "");
117
+ return base.join("|");
118
+ }
119
+ /**
120
+ * Build the full `connect` handshake params sent to the gateway.
121
+ * Includes device auth for scoped access when identity is available.
122
+ * Exported so tests can verify the protocol constants.
123
+ */
124
+ export function buildConnectParams(authToken, deviceIdentity, nonce) {
125
+ const scopes = ["operator.read"];
126
+ const signedAtMs = Date.now();
127
+ const device = deviceIdentity
128
+ ? (() => {
129
+ const payload = buildDeviceAuthPayload({
130
+ deviceId: deviceIdentity.deviceId,
131
+ clientId: "cli",
132
+ clientMode: "cli",
133
+ role: "operator",
134
+ scopes,
135
+ signedAtMs,
136
+ token: authToken || null,
137
+ nonce: nonce ?? null,
138
+ });
139
+ return {
140
+ id: deviceIdentity.deviceId,
141
+ publicKey: publicKeyRawBase64Url(deviceIdentity.publicKeyPem),
142
+ signature: signPayload(deviceIdentity.privateKeyPem, payload),
143
+ signedAt: signedAtMs,
144
+ nonce: nonce ?? undefined,
145
+ };
146
+ })()
147
+ : undefined;
148
+ return {
149
+ minProtocol: 1,
150
+ maxProtocol: 3,
151
+ client: {
152
+ id: "cli",
153
+ version: PKG_VERSION,
154
+ platform: process.platform,
155
+ mode: "cli",
156
+ },
157
+ role: "operator",
158
+ scopes,
159
+ auth: { token: authToken || null },
160
+ device,
161
+ };
162
+ }
3
163
  /**
4
164
  * OpenClaw adapter — reads session data from the OpenClaw gateway via
5
165
  * its WebSocket RPC protocol. Falls back gracefully when the gateway
6
166
  * is unreachable.
167
+ *
168
+ * Uses Ed25519 device auth for scoped access (operator.read).
169
+ * Device identity is auto-created at ~/.agentctl/identity/device.json.
7
170
  */
8
171
  export class OpenClawAdapter {
9
172
  id = "openclaw";
10
173
  baseUrl;
11
174
  authToken;
175
+ deviceIdentity;
12
176
  rpcCall;
13
177
  constructor(opts) {
14
178
  this.baseUrl = opts?.baseUrl || DEFAULT_BASE_URL;
15
179
  this.authToken =
16
- opts?.authToken || process.env.OPENCLAW_WEBHOOK_TOKEN || "";
180
+ opts?.authToken ||
181
+ process.env.OPENCLAW_GATEWAY_TOKEN ||
182
+ process.env.OPENCLAW_WEBHOOK_TOKEN ||
183
+ "";
184
+ // Device identity: explicit null disables it (for tests), undefined = auto-create
185
+ this.deviceIdentity =
186
+ opts?.deviceIdentity === null
187
+ ? null
188
+ : (opts?.deviceIdentity ?? this.tryLoadDeviceIdentity());
17
189
  this.rpcCall = opts?.rpcCall || this.defaultRpcCall.bind(this);
18
190
  }
191
+ tryLoadDeviceIdentity() {
192
+ try {
193
+ return loadOrCreateDeviceIdentity();
194
+ }
195
+ catch {
196
+ // Don't break adapter construction if identity can't be created
197
+ return null;
198
+ }
199
+ }
19
200
  async list(opts) {
20
201
  if (!this.authToken) {
21
- console.warn("Warning: OPENCLAW_WEBHOOK_TOKEN is not set — OpenClaw adapter cannot authenticate. " +
22
- "Set this environment variable to connect to the gateway.");
202
+ console.warn("Warning: OPENCLAW_GATEWAY_TOKEN is not set — OpenClaw adapter cannot authenticate. " +
203
+ "Set OPENCLAW_GATEWAY_TOKEN (or OPENCLAW_WEBHOOK_TOKEN) to connect to the gateway.");
23
204
  return [];
24
205
  }
25
206
  let result;
@@ -33,7 +214,7 @@ export class OpenClawAdapter {
33
214
  const msg = err?.message || "unknown error";
34
215
  if (msg.includes("auth") || msg.includes("Auth")) {
35
216
  console.warn(`Warning: OpenClaw gateway authentication failed: ${msg}. ` +
36
- "Check that OPENCLAW_WEBHOOK_TOKEN is valid.");
217
+ "Check that OPENCLAW_GATEWAY_TOKEN (or OPENCLAW_WEBHOOK_TOKEN) is valid.");
37
218
  }
38
219
  else {
39
220
  console.warn(`Warning: OpenClaw gateway unreachable (${msg}). ` +
@@ -81,7 +262,7 @@ export class OpenClawAdapter {
81
262
  }
82
263
  async status(sessionId) {
83
264
  if (!this.authToken) {
84
- throw new Error("OPENCLAW_WEBHOOK_TOKEN is not set — cannot connect to OpenClaw gateway");
265
+ throw new Error("OPENCLAW_GATEWAY_TOKEN is not set — cannot connect to OpenClaw gateway");
85
266
  }
86
267
  let result;
87
268
  try {
@@ -108,14 +289,10 @@ export class OpenClawAdapter {
108
289
  throw new Error("OpenClaw sessions cannot be stopped via agentctl");
109
290
  }
110
291
  async resume(sessionId, _message) {
111
- // OpenClaw sessions receive messages through their configured channels,
112
- // not through a direct CLI interface.
113
292
  throw new Error(`Cannot resume OpenClaw session ${sessionId} — use the gateway UI or configured channel`);
114
293
  }
115
294
  async *events() {
116
- // Poll-based diffing (same pattern as claude-code)
117
295
  let knownSessions = new Map();
118
- // Initial snapshot
119
296
  const initial = await this.list({ all: true });
120
297
  for (const s of initial) {
121
298
  knownSessions.set(s.id, s);
@@ -164,15 +341,10 @@ export class OpenClawAdapter {
164
341
  }
165
342
  }
166
343
  // --- Private helpers ---
167
- /**
168
- * Map a gateway session row to the standard AgentSession interface.
169
- * OpenClaw sessions with a recent updatedAt are considered "running".
170
- */
171
344
  mapRowToSession(row, defaults) {
172
345
  const now = Date.now();
173
346
  const updatedAt = row.updatedAt ?? 0;
174
347
  const ageMs = now - updatedAt;
175
- // Consider "running" if updated in the last 5 minutes
176
348
  const isActive = updatedAt > 0 && ageMs < 5 * 60 * 1000;
177
349
  const model = row.model || defaults.model || undefined;
178
350
  const input = row.inputTokens ?? 0;
@@ -196,12 +368,9 @@ export class OpenClawAdapter {
196
368
  },
197
369
  };
198
370
  }
199
- /**
200
- * Resolve a sessionId (or prefix) to a gateway session key.
201
- */
202
371
  async resolveKey(sessionId) {
203
372
  if (!this.authToken) {
204
- throw new Error("OPENCLAW_WEBHOOK_TOKEN is not set — cannot connect to OpenClaw gateway");
373
+ throw new Error("OPENCLAW_GATEWAY_TOKEN is not set — cannot connect to OpenClaw gateway");
205
374
  }
206
375
  let result;
207
376
  try {
@@ -219,13 +388,11 @@ export class OpenClawAdapter {
219
388
  return row?.key ?? null;
220
389
  }
221
390
  /**
222
- * Real WebSocket RPC call — connects, performs handshake, sends one
223
- * request, reads the response, then disconnects.
391
+ * Real WebSocket RPC call — connects, performs handshake with device auth,
392
+ * sends one request, reads the response, then disconnects.
224
393
  */
225
394
  async defaultRpcCall(method, params) {
226
- // Dynamic import so tests can inject a mock without loading ws
227
395
  const { WebSocket } = await import("ws").catch(() => {
228
- // Fall back to globalThis.WebSocket (available in Node 22+)
229
396
  return { WebSocket: globalThis.WebSocket };
230
397
  });
231
398
  const wsUrl = this.baseUrl.replace(/^http/, "ws");
@@ -244,25 +411,16 @@ export class OpenClawAdapter {
244
411
  try {
245
412
  const raw = typeof event.data === "string" ? event.data : String(event.data);
246
413
  const frame = JSON.parse(raw);
247
- // Step 1: Receive challenge, send connect
414
+ // Step 1: Receive challenge (with nonce), send connect with device auth
248
415
  if (frame.type === "event" && frame.event === "connect.challenge") {
416
+ const nonce = frame.payload && typeof frame.payload.nonce === "string"
417
+ ? frame.payload.nonce
418
+ : null;
249
419
  ws.send(JSON.stringify({
250
420
  type: "req",
251
421
  id: randomUUID(),
252
422
  method: "connect",
253
- params: {
254
- minProtocol: 1,
255
- maxProtocol: 1,
256
- client: {
257
- id: "agentctl",
258
- version: "0.1.0",
259
- platform: process.platform,
260
- mode: "cli",
261
- },
262
- role: "operator",
263
- scopes: ["operator.read"],
264
- auth: { token: this.authToken || null },
265
- },
423
+ params: buildConnectParams(this.authToken, this.deviceIdentity, nonce),
266
424
  }));
267
425
  return;
268
426
  }
@@ -306,7 +464,6 @@ export class OpenClawAdapter {
306
464
  };
307
465
  ws.onclose = () => {
308
466
  clearTimeout(timeout);
309
- // Only reject if we haven't resolved yet
310
467
  };
311
468
  });
312
469
  }
@@ -0,0 +1,143 @@
1
+ import type { AgentAdapter, AgentSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
2
+ export interface PidInfo {
3
+ pid: number;
4
+ cwd: string;
5
+ args: string;
6
+ /** Process start time from `ps -p <pid> -o lstart=`, used to detect PID recycling */
7
+ startTime?: string;
8
+ }
9
+ /** Metadata persisted by launch() so status checks survive wrapper exit */
10
+ export interface LaunchedSessionMeta {
11
+ sessionId: string;
12
+ pid: number;
13
+ /** Process start time from `ps -p <pid> -o lstart=` for PID recycling detection */
14
+ startTime?: string;
15
+ /** The PID of the wrapper (agentctl launch) — may differ from `pid` (opencode process) */
16
+ wrapperPid?: number;
17
+ cwd: string;
18
+ model?: string;
19
+ prompt?: string;
20
+ launchedAt: string;
21
+ }
22
+ /** Shape of an OpenCode session JSON file */
23
+ export interface OpenCodeSessionFile {
24
+ id: string;
25
+ slug?: string;
26
+ version?: string;
27
+ projectID?: string;
28
+ directory?: string;
29
+ title?: string;
30
+ time?: {
31
+ created?: number | string;
32
+ updated?: number | string;
33
+ };
34
+ summary?: {
35
+ additions?: number;
36
+ deletions?: number;
37
+ files?: number;
38
+ };
39
+ }
40
+ /** Shape of an OpenCode message JSON file */
41
+ export interface OpenCodeMessageFile {
42
+ id: string;
43
+ sessionID?: string;
44
+ role?: "user" | "assistant";
45
+ time?: {
46
+ created?: number | string;
47
+ completed?: number | string;
48
+ };
49
+ agent?: string;
50
+ model?: {
51
+ providerID?: string;
52
+ modelID?: string;
53
+ };
54
+ tokens?: {
55
+ input?: number;
56
+ output?: number;
57
+ reasoning?: number;
58
+ };
59
+ cache?: {
60
+ read?: number;
61
+ write?: number;
62
+ };
63
+ cost?: number;
64
+ finish?: string;
65
+ error?: {
66
+ name?: string;
67
+ data?: {
68
+ message?: string;
69
+ };
70
+ };
71
+ modelID?: string;
72
+ providerID?: string;
73
+ }
74
+ export interface OpenCodeAdapterOpts {
75
+ storageDir?: string;
76
+ sessionsMetaDir?: string;
77
+ getPids?: () => Promise<Map<number, PidInfo>>;
78
+ /** Override PID liveness check for testing (default: process.kill(pid, 0)) */
79
+ isProcessAlive?: (pid: number) => boolean;
80
+ }
81
+ /**
82
+ * Compute the project hash matching OpenCode's approach: SHA1 of the directory path.
83
+ */
84
+ export declare function computeProjectHash(directory: string): string;
85
+ /**
86
+ * OpenCode adapter — reads session data from ~/.local/share/opencode/storage/
87
+ * and cross-references with running opencode processes.
88
+ */
89
+ export declare class OpenCodeAdapter implements AgentAdapter {
90
+ readonly id = "opencode";
91
+ private readonly storageDir;
92
+ private readonly sessionDir;
93
+ private readonly messageDir;
94
+ private readonly sessionsMetaDir;
95
+ private readonly getPids;
96
+ private readonly isProcessAlive;
97
+ constructor(opts?: OpenCodeAdapterOpts);
98
+ list(opts?: ListOpts): Promise<AgentSession[]>;
99
+ peek(sessionId: string, opts?: PeekOpts): Promise<string>;
100
+ status(sessionId: string): Promise<AgentSession>;
101
+ launch(opts: LaunchOpts): Promise<AgentSession>;
102
+ stop(sessionId: string, opts?: StopOpts): Promise<void>;
103
+ resume(sessionId: string, message: string): Promise<void>;
104
+ events(): AsyncIterable<LifecycleEvent>;
105
+ /**
106
+ * Read all session JSON files for a project directory.
107
+ */
108
+ private getSessionFilesForProject;
109
+ /**
110
+ * Build an AgentSession from an OpenCode session file.
111
+ */
112
+ private buildSession;
113
+ /**
114
+ * Check whether a session is currently running by cross-referencing PIDs.
115
+ */
116
+ private isSessionRunning;
117
+ /**
118
+ * Check whether a process plausibly belongs to a session by verifying
119
+ * the process started at or after the session's creation time.
120
+ */
121
+ private processStartedAfterSession;
122
+ private findMatchingPid;
123
+ /**
124
+ * Read all messages for a session and aggregate stats.
125
+ */
126
+ private aggregateMessageStats;
127
+ /**
128
+ * Read all message files for a session, sorted by time.
129
+ */
130
+ private readMessages;
131
+ /**
132
+ * Read message content parts from storage/part/<messageId>/
133
+ */
134
+ private readMessageParts;
135
+ /**
136
+ * Resolve a session ID (supports prefix matching).
137
+ */
138
+ private resolveSessionId;
139
+ private findPidForSession;
140
+ writeSessionMeta(meta: Omit<LaunchedSessionMeta, "startTime">): Promise<void>;
141
+ readSessionMeta(sessionId: string): Promise<LaunchedSessionMeta | null>;
142
+ private deleteSessionMeta;
143
+ }