@clawcrony/claw-crony 1.2.3 → 1.3.0

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.
@@ -0,0 +1,7 @@
1
+ import type { GatewayConfig } from "./types.js";
2
+ export declare const EPHEMERAL_INBOUND_TOKEN_LENGTH = 48;
3
+ export declare function isValidEphemeralInboundToken(value: unknown): value is string;
4
+ export declare function issueEphemeralInboundToken(config: GatewayConfig, _matchId: number, _peerAgentId: number, ttlMs?: number): {
5
+ token: string;
6
+ expiresAt: string;
7
+ };
@@ -0,0 +1,17 @@
1
+ import crypto from "node:crypto";
2
+ export const EPHEMERAL_INBOUND_TOKEN_LENGTH = 48;
3
+ const EPHEMERAL_INBOUND_TOKEN_PATTERN = /^[0-9a-f]{48}$/;
4
+ export function isValidEphemeralInboundToken(value) {
5
+ return typeof value === "string" && EPHEMERAL_INBOUND_TOKEN_PATTERN.test(value);
6
+ }
7
+ export function issueEphemeralInboundToken(config, _matchId, _peerAgentId, ttlMs = 5 * 60_000) {
8
+ const token = crypto.randomBytes(EPHEMERAL_INBOUND_TOKEN_LENGTH / 2).toString("hex");
9
+ config.security.validTokens.add(token);
10
+ setTimeout(() => {
11
+ config.security.validTokens.delete(token);
12
+ }, ttlMs).unref?.();
13
+ return {
14
+ token,
15
+ expiresAt: new Date(Date.now() + ttlMs).toISOString(),
16
+ };
17
+ }
@@ -0,0 +1,3 @@
1
+ import type { HandshakePayload, IdentityData } from "./types.js";
2
+ export declare function encryptHandshake(payload: HandshakePayload, recipientPublicKeyPem: string): string;
3
+ export declare function decryptHandshake(ciphertext: string, identity: IdentityData): HandshakePayload;
@@ -0,0 +1,58 @@
1
+ import crypto from "node:crypto";
2
+ function toBase64(value) {
3
+ return Buffer.from(value instanceof Buffer ? value : new Uint8Array(value)).toString("base64");
4
+ }
5
+ function fromBase64(value) {
6
+ return Buffer.from(value, "base64");
7
+ }
8
+ function exportPublicPem(key) {
9
+ return key.export({ format: "pem", type: "spki" }).toString();
10
+ }
11
+ function hkdf(secret, salt, info) {
12
+ return Buffer.from(crypto.hkdfSync("sha256", secret, salt, Buffer.from(info, "utf-8"), 32));
13
+ }
14
+ const HANDSHAKE_INFO = "claw-crony:handshake:v1";
15
+ export function encryptHandshake(payload, recipientPublicKeyPem) {
16
+ const recipientPublicKey = crypto.createPublicKey(recipientPublicKeyPem);
17
+ const ephemeral = crypto.generateKeyPairSync("x25519");
18
+ const sharedSecret = crypto.diffieHellman({
19
+ privateKey: ephemeral.privateKey,
20
+ publicKey: recipientPublicKey,
21
+ });
22
+ const iv = crypto.randomBytes(12);
23
+ const key = hkdf(sharedSecret, iv, HANDSHAKE_INFO);
24
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
25
+ const plaintext = Buffer.from(JSON.stringify(payload), "utf-8");
26
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
27
+ const authTag = cipher.getAuthTag();
28
+ const encrypted = {
29
+ version: 1,
30
+ algorithm: "x25519-aes-256-gcm",
31
+ senderPublicKey: exportPublicPem(ephemeral.publicKey),
32
+ iv: toBase64(iv),
33
+ ciphertext: toBase64(ciphertext),
34
+ authTag: toBase64(authTag),
35
+ };
36
+ return JSON.stringify(encrypted);
37
+ }
38
+ export function decryptHandshake(ciphertext, identity) {
39
+ const envelope = JSON.parse(ciphertext);
40
+ if (envelope.algorithm !== "x25519-aes-256-gcm") {
41
+ throw new Error(`Unsupported handshake algorithm: ${envelope.algorithm}`);
42
+ }
43
+ const privateKey = crypto.createPrivateKey(identity.privateKey);
44
+ const senderPublicKey = crypto.createPublicKey(envelope.senderPublicKey);
45
+ const iv = fromBase64(envelope.iv);
46
+ const sharedSecret = crypto.diffieHellman({
47
+ privateKey,
48
+ publicKey: senderPublicKey,
49
+ });
50
+ const key = hkdf(sharedSecret, iv, HANDSHAKE_INFO);
51
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
52
+ decipher.setAuthTag(fromBase64(envelope.authTag));
53
+ const plaintext = Buffer.concat([
54
+ decipher.update(fromBase64(envelope.ciphertext)),
55
+ decipher.final(),
56
+ ]);
57
+ return JSON.parse(plaintext.toString("utf-8"));
58
+ }
@@ -0,0 +1,44 @@
1
+ export type RequestHistoryType = "match.created" | "match.failed" | "handshake.offer_sent" | "handshake.offer_received" | "handshake.answer_sent" | "handshake.answer_received" | "handshake.failed" | "peer.upserted" | "send.started" | "send.completed" | "send.failed" | "send_file.started" | "send_file.completed" | "send_file.failed" | "task.inbound_completed" | "task.inbound_failed";
2
+ export type RequestHistoryStatus = "started" | "success" | "failure" | "ignored";
3
+ export type RequestHistoryDirection = "inbound" | "outbound" | "local";
4
+ export interface RequestHistoryEntry {
5
+ ts: string;
6
+ type: RequestHistoryType;
7
+ status: RequestHistoryStatus;
8
+ direction?: RequestHistoryDirection;
9
+ matchId?: number;
10
+ messageId?: number;
11
+ peer?: string;
12
+ durationMs?: number;
13
+ detail?: Record<string, unknown>;
14
+ }
15
+ export interface RequestHistoryFilter {
16
+ count?: number;
17
+ type?: string;
18
+ status?: string;
19
+ direction?: string;
20
+ matchId?: number;
21
+ peer?: string;
22
+ }
23
+ export interface RequestHistoryOptions {
24
+ enabled: boolean;
25
+ includeEncryptedPayloads: boolean;
26
+ }
27
+ /**
28
+ * Append-only request history store for operator-facing troubleshooting.
29
+ * Unlike the audit log, this captures Hub match/handshake milestones and
30
+ * gateway calls. Sensitive fields are redacted before persistence.
31
+ */
32
+ export declare class RequestHistoryStore {
33
+ private readonly filePath;
34
+ private readonly options;
35
+ private dirEnsured;
36
+ constructor(filePath: string, options?: Partial<RequestHistoryOptions>);
37
+ record(entry: Omit<RequestHistoryEntry, "ts"> & {
38
+ ts?: string;
39
+ }): void;
40
+ tail(filter?: RequestHistoryFilter): Promise<RequestHistoryEntry[]>;
41
+ close(): void;
42
+ private ensureDir;
43
+ private write;
44
+ }
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ const SECRET_KEY_NAMES = ["token", "secret", "password", "authorization", "ciphertext"];
5
+ function redactValue(key, value, includeEncryptedPayloads) {
6
+ const normalizedKey = key.toLowerCase();
7
+ if (SECRET_KEY_NAMES.some((name) => normalizedKey.includes(name))) {
8
+ if (normalizedKey.includes("ciphertext") && includeEncryptedPayloads) {
9
+ return value;
10
+ }
11
+ return "[redacted]";
12
+ }
13
+ if (Array.isArray(value)) {
14
+ return value.map((entry) => redactUnknown(entry, includeEncryptedPayloads));
15
+ }
16
+ if (value && typeof value === "object") {
17
+ return redactObject(value, includeEncryptedPayloads);
18
+ }
19
+ return value;
20
+ }
21
+ function redactUnknown(value, includeEncryptedPayloads) {
22
+ if (Array.isArray(value)) {
23
+ return value.map((entry) => redactUnknown(entry, includeEncryptedPayloads));
24
+ }
25
+ if (value && typeof value === "object") {
26
+ return redactObject(value, includeEncryptedPayloads);
27
+ }
28
+ return value;
29
+ }
30
+ function redactObject(value, includeEncryptedPayloads) {
31
+ const next = {};
32
+ for (const [key, entry] of Object.entries(value)) {
33
+ next[key] = redactValue(key, entry, includeEncryptedPayloads);
34
+ }
35
+ return next;
36
+ }
37
+ function matchesFilter(entry, filter) {
38
+ if (filter.type && entry.type !== filter.type)
39
+ return false;
40
+ if (filter.status && entry.status !== filter.status)
41
+ return false;
42
+ if (filter.direction && entry.direction !== filter.direction)
43
+ return false;
44
+ if (filter.matchId != null && entry.matchId !== filter.matchId)
45
+ return false;
46
+ if (filter.peer && entry.peer !== filter.peer)
47
+ return false;
48
+ return true;
49
+ }
50
+ /**
51
+ * Append-only request history store for operator-facing troubleshooting.
52
+ * Unlike the audit log, this captures Hub match/handshake milestones and
53
+ * gateway calls. Sensitive fields are redacted before persistence.
54
+ */
55
+ export class RequestHistoryStore {
56
+ filePath;
57
+ options;
58
+ dirEnsured = false;
59
+ constructor(filePath, options = {}) {
60
+ this.filePath = filePath;
61
+ this.options = {
62
+ enabled: options.enabled ?? true,
63
+ includeEncryptedPayloads: options.includeEncryptedPayloads ?? false,
64
+ };
65
+ }
66
+ record(entry) {
67
+ if (!this.options.enabled) {
68
+ return;
69
+ }
70
+ const detail = entry.detail
71
+ ? redactObject(entry.detail, this.options.includeEncryptedPayloads)
72
+ : undefined;
73
+ this.write({
74
+ ...entry,
75
+ ts: entry.ts ?? new Date().toISOString(),
76
+ ...(detail ? { detail } : {}),
77
+ });
78
+ }
79
+ async tail(filter = {}) {
80
+ if (!fs.existsSync(this.filePath))
81
+ return [];
82
+ const count = Math.min(Math.max(1, Math.floor(filter.count ?? 50)), 500);
83
+ const entries = [];
84
+ const input = fs.createReadStream(this.filePath, { encoding: "utf-8" });
85
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
86
+ for await (const line of rl) {
87
+ if (!line.trim())
88
+ continue;
89
+ try {
90
+ const entry = JSON.parse(line);
91
+ if (matchesFilter(entry, filter)) {
92
+ entries.push(entry);
93
+ }
94
+ }
95
+ catch {
96
+ // Skip malformed lines.
97
+ }
98
+ }
99
+ return entries.slice(-count).reverse();
100
+ }
101
+ close() {
102
+ // No persistent handles.
103
+ }
104
+ ensureDir() {
105
+ if (this.dirEnsured)
106
+ return;
107
+ this.dirEnsured = true;
108
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
109
+ }
110
+ write(entry) {
111
+ try {
112
+ this.ensureDir();
113
+ fs.appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
114
+ }
115
+ catch {
116
+ // History is diagnostic only; never crash the gateway.
117
+ }
118
+ }
119
+ }
@@ -1,20 +1,14 @@
1
1
  /**
2
- * Hub Match API client for openclaw-claw-crony.
3
- *
4
- * Provides a typed client for the hub's /api/matches endpoints:
5
- * - POST /api/matches createMatch
6
- * - GET /api/matches/{id} getMatch
7
- * - GET /api/matches/pending getPendingMatches
8
- * - POST /api/matches/{id}/token submitToken
9
- * - POST /api/matches/{id}/complete completeMatch
10
- * - POST /api/matches/{id}/cancel cancelMatch
2
+ * Hub Match API client for claw-crony.
11
3
  */
12
4
  import type { HubRegistrationData } from "./types.js";
13
5
  export interface HubAgentDto {
14
6
  id: number;
15
7
  name: string;
16
- address: string;
17
8
  skills: string[];
9
+ clientId?: string;
10
+ publicKey?: string;
11
+ presenceStatus?: string;
18
12
  }
19
13
  export interface HubMatchResult {
20
14
  id: number;
@@ -22,58 +16,53 @@ export interface HubMatchResult {
22
16
  status: string;
23
17
  requester: HubAgentDto | null;
24
18
  provider: HubAgentDto | null;
25
- yourToken: string | null;
26
- peerToken: string | null;
19
+ yourToken?: string | null;
20
+ peerToken?: string | null;
27
21
  callerRole?: "requester" | "provider" | "observer" | null;
28
22
  requesterTokenSubmitted?: boolean;
29
23
  providerTokenSubmitted?: boolean;
30
24
  readyForComplete?: boolean;
25
+ requesterHandshakeSent?: boolean;
26
+ providerHandshakeSent?: boolean;
27
+ requesterHandshakeConsumed?: boolean;
28
+ providerHandshakeConsumed?: boolean;
29
+ requesterReady?: boolean;
30
+ providerReady?: boolean;
31
+ readyForConnect?: boolean;
32
+ }
33
+ export interface HubHandshakeMessage {
34
+ id: number;
35
+ senderAgentId: number;
36
+ receiverAgentId: number;
37
+ messageType: "offer" | "answer";
38
+ ciphertext: string;
39
+ status: string;
40
+ expiresAt: string;
41
+ createdAt?: string;
42
+ consumedAt?: string | null;
31
43
  }
32
44
  export declare class HubMatchClient {
33
45
  private readonly hubUrl;
34
46
  private readonly registration;
35
47
  constructor(hubUrl: string, registration: HubRegistrationData);
36
48
  get agentId(): number;
37
- get registrationToken(): string;
38
49
  static create(): Promise<HubMatchClient>;
39
50
  private request;
40
- /**
41
- * Create a new match request.
42
- * @param params.skills - Skills to search for in a provider
43
- * @param params.description - Optional description of the match request
44
- * @param params.token - Optional bearer token to include for this agent
45
- */
46
51
  createMatch(params: {
47
52
  skills: string[];
48
53
  description?: string;
49
- token?: string;
50
54
  }): Promise<HubMatchResult>;
51
- /**
52
- * Get a match result by ID.
53
- * @param matchId - The match ID
54
- * @param callerId - Optional agent ID to set callerId query param (affects yourToken)
55
- */
56
55
  getMatch(matchId: number, callerId?: number): Promise<HubMatchResult>;
57
- /**
58
- * Get all pending matches for this agent.
59
- */
60
56
  getPendingMatches(): Promise<HubMatchResult[]>;
61
- /**
62
- * Submit this agent's token for a match.
63
- * @param matchId - The match ID
64
- * @param token - This agent's bearer token
65
- */
66
- submitToken(matchId: number, token: string): Promise<HubMatchResult>;
67
- /**
68
- * Mark a match as completed (both parties have submitted tokens).
69
- * @param matchId - The match ID
70
- * @param token - This agent's bearer token (for authorization)
71
- */
72
- completeMatch(matchId: number, token: string): Promise<HubMatchResult>;
73
- /**
74
- * Cancel a pending or token_exchange match.
75
- * @param matchId - The match ID
76
- * @param token - This agent's bearer token (for authorization)
77
- */
78
- cancelMatch(matchId: number, token: string): Promise<HubMatchResult>;
57
+ updatePresence(presenceStatus: "online" | "offline" | "busy", clientVersion?: string): Promise<HubAgentDto>;
58
+ sendHandshakeMessage(matchId: number, params: {
59
+ messageType: "offer" | "answer";
60
+ ciphertext: string;
61
+ expiresAt: string;
62
+ }): Promise<HubHandshakeMessage>;
63
+ getPendingHandshakeMessages(matchId: number): Promise<HubHandshakeMessage[]>;
64
+ consumeHandshakeMessage(matchId: number, messageId: number): Promise<HubHandshakeMessage>;
65
+ markReady(matchId: number): Promise<HubMatchResult>;
66
+ completeMatch(matchId: number): Promise<HubMatchResult>;
67
+ cancelMatch(matchId: number): Promise<HubMatchResult>;
79
68
  }
@@ -1,18 +1,7 @@
1
1
  /**
2
- * Hub Match API client for openclaw-claw-crony.
3
- *
4
- * Provides a typed client for the hub's /api/matches endpoints:
5
- * - POST /api/matches createMatch
6
- * - GET /api/matches/{id} getMatch
7
- * - GET /api/matches/pending getPendingMatches
8
- * - POST /api/matches/{id}/token submitToken
9
- * - POST /api/matches/{id}/complete completeMatch
10
- * - POST /api/matches/{id}/cancel cancelMatch
2
+ * Hub Match API client for claw-crony.
11
3
  */
12
4
  import { loadRegistration } from "./hub-registration.js";
13
- // ---------------------------------------------------------------------------
14
- // HubMatchClient
15
- // ---------------------------------------------------------------------------
16
5
  export class HubMatchClient {
17
6
  hubUrl;
18
7
  registration;
@@ -23,16 +12,12 @@ export class HubMatchClient {
23
12
  get agentId() {
24
13
  return this.registration.agentId;
25
14
  }
26
- get registrationToken() {
27
- return this.registration.token;
28
- }
29
15
  static async create() {
30
16
  const registration = loadRegistration();
31
17
  if (!registration) {
32
18
  throw new Error("No hub registration found. Run the gateway first to register with the hub.");
33
19
  }
34
- const configUrl = registration.hubUrl;
35
- return new HubMatchClient(configUrl, registration);
20
+ return new HubMatchClient(registration.hubUrl, registration);
36
21
  }
37
22
  async request(path, options = {}) {
38
23
  const url = `${this.hubUrl}${path}`;
@@ -40,7 +25,6 @@ export class HubMatchClient {
40
25
  ...options,
41
26
  headers: {
42
27
  "Content-Type": "application/json",
43
- "Authorization": `Bearer ${this.registration.token}`,
44
28
  ...(options.headers ?? {}),
45
29
  },
46
30
  });
@@ -50,12 +34,6 @@ export class HubMatchClient {
50
34
  }
51
35
  return res.json();
52
36
  }
53
- /**
54
- * Create a new match request.
55
- * @param params.skills - Skills to search for in a provider
56
- * @param params.description - Optional description of the match request
57
- * @param params.token - Optional bearer token to include for this agent
58
- */
59
37
  async createMatch(params) {
60
38
  return this.request("/api/matches", {
61
39
  method: "POST",
@@ -63,45 +41,57 @@ export class HubMatchClient {
63
41
  agentId: this.registration.agentId,
64
42
  requiredSkills: params.skills,
65
43
  description: params.description ?? "",
66
- token: params.token,
67
44
  }),
68
45
  });
69
46
  }
70
- /**
71
- * Get a match result by ID.
72
- * @param matchId - The match ID
73
- * @param callerId - Optional agent ID to set callerId query param (affects yourToken)
74
- */
75
47
  async getMatch(matchId, callerId) {
76
48
  const path = callerId != null ? `/api/matches/${matchId}?callerId=${callerId}` : `/api/matches/${matchId}`;
77
49
  return this.request(path);
78
50
  }
79
- /**
80
- * Get all pending matches for this agent.
81
- */
82
51
  async getPendingMatches() {
83
52
  return this.request(`/api/matches/pending?agentId=${this.registration.agentId}`);
84
53
  }
85
- /**
86
- * Submit this agent's token for a match.
87
- * @param matchId - The match ID
88
- * @param token - This agent's bearer token
89
- */
90
- async submitToken(matchId, token) {
91
- return this.request(`/api/matches/${matchId}/token`, {
54
+ async updatePresence(presenceStatus, clientVersion = "claw-crony/1.3.0") {
55
+ return this.request(`/api/agents/${this.registration.agentId}/presence`, {
56
+ method: "PUT",
57
+ body: JSON.stringify({
58
+ presenceStatus,
59
+ clientVersion,
60
+ }),
61
+ });
62
+ }
63
+ async sendHandshakeMessage(matchId, params) {
64
+ return this.request(`/api/matches/${matchId}/handshake`, {
65
+ method: "POST",
66
+ body: JSON.stringify({
67
+ agentId: this.registration.agentId,
68
+ messageType: params.messageType,
69
+ ciphertext: params.ciphertext,
70
+ expiresAt: params.expiresAt,
71
+ }),
72
+ });
73
+ }
74
+ async getPendingHandshakeMessages(matchId) {
75
+ const result = await this.request(`/api/matches/${matchId}/handshake/pending?agentId=${this.registration.agentId}`);
76
+ return result.messages ?? [];
77
+ }
78
+ async consumeHandshakeMessage(matchId, messageId) {
79
+ return this.request(`/api/matches/${matchId}/handshake/${messageId}/consume`, {
80
+ method: "POST",
81
+ body: JSON.stringify({
82
+ agentId: this.registration.agentId,
83
+ }),
84
+ });
85
+ }
86
+ async markReady(matchId) {
87
+ return this.request(`/api/matches/${matchId}/ready`, {
92
88
  method: "POST",
93
89
  body: JSON.stringify({
94
90
  agentId: this.registration.agentId,
95
- token,
96
91
  }),
97
92
  });
98
93
  }
99
- /**
100
- * Mark a match as completed (both parties have submitted tokens).
101
- * @param matchId - The match ID
102
- * @param token - This agent's bearer token (for authorization)
103
- */
104
- async completeMatch(matchId, token) {
94
+ async completeMatch(matchId) {
105
95
  return this.request(`/api/matches/${matchId}/complete`, {
106
96
  method: "POST",
107
97
  body: JSON.stringify({
@@ -109,12 +99,7 @@ export class HubMatchClient {
109
99
  }),
110
100
  });
111
101
  }
112
- /**
113
- * Cancel a pending or token_exchange match.
114
- * @param matchId - The match ID
115
- * @param token - This agent's bearer token (for authorization)
116
- */
117
- async cancelMatch(matchId, token) {
102
+ async cancelMatch(matchId) {
118
103
  return this.request(`/api/matches/${matchId}/cancel`, {
119
104
  method: "POST",
120
105
  body: JSON.stringify({
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Hub registration module for openclaw-claw-crony.
2
+ * Hub registration module for claw-crony.
3
3
  *
4
- * Handles automatic registration of the gateway with the hub server on first startup,
5
- * token generation, and idempotent re-registration.
4
+ * Registers the local plugin with the hub using client_id + public_key
5
+ * and persists the resulting agent binding locally.
6
6
  */
7
7
  import type { GatewayConfig, HubConfig, HubRegistrationData, OpenClawPluginApi, RegistrationConfig } from "./types.js";
8
8
  export declare function loadRegistration(configDir?: string): HubRegistrationData | null;
@@ -13,11 +13,4 @@ export interface HubRegistration {
13
13
  address: string;
14
14
  name: string;
15
15
  }
16
- /**
17
- * Run the full hub registration flow:
18
- * 1. Load existing registration (if any)
19
- * 2. Validate existing token with hub
20
- * 3. If no valid registration, create new one (handling 409 conflicts)
21
- * 4. Save registration file atomically
22
- */
23
16
  export declare function runHubRegistration(api: OpenClawPluginApi, config: GatewayConfig, hubConfig: HubConfig, registrationConfig: RegistrationConfig): Promise<HubRegistration | null>;