@soyeht/soyeht 0.1.2 → 0.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.
package/src/service.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { readFile, writeFile, rm } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
@@ -9,9 +10,13 @@ import {
9
10
  } from "./security.js";
10
11
  import { loadOrGenerateIdentity, loadPeers, loadSessions, saveSession } from "./identity.js";
11
12
  import { zeroBuffer } from "./ratchet.js";
12
- import { computeFingerprint, type X25519KeyPair } from "./crypto.js";
13
+ import { base64UrlEncode, computeFingerprint, ed25519Sign, type X25519KeyPair } from "./crypto.js";
13
14
  import type { IdentityBundle, PeerIdentity } from "./identity.js";
14
15
  import type { RatchetState } from "./ratchet.js";
16
+ import { createOutboundQueue, type OutboundQueue } from "./outbound-queue.js";
17
+ import { renderQrTerminal } from "./qr.js";
18
+ import { resolveSoyehtAccount } from "./config.js";
19
+ import { buildPairingQrTranscript, buildPairingQrTranscriptV2 } from "./pairing.js";
15
20
 
16
21
  const HEARTBEAT_INTERVAL_MS = 60_000; // 60s
17
22
 
@@ -27,6 +32,7 @@ export type SecurityV2Deps = {
27
32
  pendingHandshakes: Map<string, PendingHandshake>;
28
33
  nonceCache: NonceCache;
29
34
  rateLimiter: RateLimiter;
35
+ outboundQueue: OutboundQueue;
30
36
  ready: boolean;
31
37
  stateDir?: string;
32
38
  };
@@ -58,10 +64,99 @@ export function createSecurityV2Deps(): SecurityV2Deps {
58
64
  pendingHandshakes: new Map(),
59
65
  nonceCache: createNonceCache(),
60
66
  rateLimiter: createRateLimiter(),
67
+ outboundQueue: createOutboundQueue(),
61
68
  ready: false,
62
69
  };
63
70
  }
64
71
 
72
+ // ---------------------------------------------------------------------------
73
+ // Auto-pairing QR display (terminal)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const AUTO_PAIRING_TTL_MS = 90_000;
77
+
78
+ async function showPairingQr(api: OpenClawPluginApi, v2deps: SecurityV2Deps): Promise<void> {
79
+ const identity = v2deps.identity;
80
+ if (!identity) return;
81
+
82
+ const accountId = "default";
83
+
84
+ // Clear any stale pairing sessions for this account
85
+ for (const [token, session] of v2deps.pairingSessions) {
86
+ if (session.accountId === accountId) {
87
+ v2deps.pairingSessions.delete(token);
88
+ }
89
+ }
90
+
91
+ const pairingToken = base64UrlEncode(randomBytes(32));
92
+ const expiresAt = Date.now() + AUTO_PAIRING_TTL_MS;
93
+ const fingerprint = computeFingerprint(identity);
94
+
95
+ // Resolve gatewayUrl from config or runtime
96
+ const cfg = await api.runtime.config.loadConfig();
97
+ const account = resolveSoyehtAccount(cfg, accountId);
98
+ let gatewayUrl = account.gatewayUrl;
99
+ if (!gatewayUrl) {
100
+ const runtime = api.runtime as Record<string, unknown>;
101
+ if (typeof runtime["gatewayUrl"] === "string" && runtime["gatewayUrl"]) {
102
+ gatewayUrl = runtime["gatewayUrl"];
103
+ } else if (typeof runtime["baseUrl"] === "string" && runtime["baseUrl"]) {
104
+ gatewayUrl = runtime["baseUrl"];
105
+ }
106
+ }
107
+
108
+ const basePayload = {
109
+ accountId,
110
+ pairingToken,
111
+ expiresAt,
112
+ allowOverwrite: false,
113
+ pluginIdentityKey: identity.signKey.publicKeyB64,
114
+ pluginDhKey: identity.dhKey.publicKeyB64,
115
+ fingerprint,
116
+ };
117
+
118
+ let qrPayload: Record<string, unknown>;
119
+
120
+ if (gatewayUrl) {
121
+ const transcript = buildPairingQrTranscriptV2({ gatewayUrl, ...basePayload });
122
+ const signature = base64UrlEncode(ed25519Sign(identity.signKey.privateKey, transcript));
123
+ qrPayload = {
124
+ version: 2,
125
+ type: "soyeht_pairing_qr",
126
+ gatewayUrl,
127
+ ...basePayload,
128
+ signature,
129
+ };
130
+ } else {
131
+ const transcript = buildPairingQrTranscript(basePayload);
132
+ const signature = base64UrlEncode(ed25519Sign(identity.signKey.privateKey, transcript));
133
+ qrPayload = {
134
+ version: 1,
135
+ type: "soyeht_pairing_qr",
136
+ ...basePayload,
137
+ signature,
138
+ };
139
+ }
140
+
141
+ v2deps.pairingSessions.set(pairingToken, {
142
+ token: pairingToken,
143
+ accountId,
144
+ expiresAt,
145
+ allowOverwrite: false,
146
+ });
147
+
148
+ const qrText = JSON.stringify(qrPayload);
149
+ const rendered = renderQrTerminal(qrText);
150
+
151
+ if (rendered) {
152
+ api.logger.info("[soyeht] Scan this QR code with the Soyeht app to pair:\n\n" + rendered);
153
+ api.logger.info(`[soyeht] Fingerprint: ${fingerprint}`);
154
+ api.logger.info(`[soyeht] QR expires in ${AUTO_PAIRING_TTL_MS / 1000}s — restart plugin to generate a new one`);
155
+ } else {
156
+ api.logger.warn("[soyeht] QR code too large for terminal rendering. Use RPC soyeht.security.pairing.start instead.");
157
+ }
158
+ }
159
+
65
160
  // ---------------------------------------------------------------------------
66
161
  // Service
67
162
  // ---------------------------------------------------------------------------
@@ -107,6 +202,15 @@ export function createSoyehtService(
107
202
  }
108
203
  if (v2deps) v2deps.ready = true;
109
204
 
205
+ // Auto-generate and display pairing QR if no peers are paired
206
+ if (v2deps?.identity && v2deps.peers.size === 0) {
207
+ try {
208
+ await showPairingQr(api, v2deps);
209
+ } catch (err) {
210
+ api.logger.error("[soyeht] Failed to auto-generate pairing QR", { err });
211
+ }
212
+ }
213
+
110
214
  api.logger.info("[soyeht] Service started");
111
215
 
112
216
  heartbeatTimer = setInterval(async () => {
@@ -115,6 +219,7 @@ export function createSoyehtService(
115
219
  const now = Date.now();
116
220
  v2deps.nonceCache.prune();
117
221
  v2deps.rateLimiter.prune();
222
+ v2deps.outboundQueue.prune();
118
223
  for (const [token, session] of v2deps.pairingSessions) {
119
224
  if (session.expiresAt <= now) {
120
225
  v2deps.pairingSessions.delete(token);
@@ -168,6 +273,7 @@ export function createSoyehtService(
168
273
  v2deps.peers.clear();
169
274
  v2deps.pairingSessions.clear();
170
275
  v2deps.pendingHandshakes.clear();
276
+ v2deps.outboundQueue.clear();
171
277
  v2deps.ready = false;
172
278
  }
173
279
 
package/src/types.ts CHANGED
@@ -8,6 +8,7 @@ export type SoyehtAccountConfig = {
8
8
  enabled?: boolean;
9
9
  backendBaseUrl?: string;
10
10
  pluginAuthToken?: string;
11
+ gatewayUrl?: string;
11
12
  allowProactive?: boolean;
12
13
  audio?: {
13
14
  transcribeInbound?: boolean;
@@ -37,6 +38,7 @@ export const SoyehtAccountConfigSchema: JsonSchema = {
37
38
  enabled: { type: "boolean" },
38
39
  backendBaseUrl: { type: "string" },
39
40
  pluginAuthToken: { type: "string" },
41
+ gatewayUrl: { type: "string" },
40
42
  allowProactive: { type: "boolean" },
41
43
  audio: {
42
44
  type: "object",
@@ -367,6 +369,51 @@ export const PairingQrPayloadSchema: JsonSchema = {
367
369
  },
368
370
  };
369
371
 
372
+ export type PairingQrPayloadV2 = {
373
+ version: 2;
374
+ type: "soyeht_pairing_qr";
375
+ gatewayUrl: string;
376
+ accountId: string;
377
+ pairingToken: string;
378
+ expiresAt: number;
379
+ allowOverwrite: boolean;
380
+ pluginIdentityKey: string;
381
+ pluginDhKey: string;
382
+ fingerprint: string;
383
+ signature: string;
384
+ };
385
+
386
+ export const PairingQrPayloadV2Schema: JsonSchema = {
387
+ type: "object",
388
+ additionalProperties: false,
389
+ required: [
390
+ "version",
391
+ "type",
392
+ "gatewayUrl",
393
+ "accountId",
394
+ "pairingToken",
395
+ "expiresAt",
396
+ "allowOverwrite",
397
+ "pluginIdentityKey",
398
+ "pluginDhKey",
399
+ "fingerprint",
400
+ "signature",
401
+ ],
402
+ properties: {
403
+ version: { const: 2 },
404
+ type: { const: "soyeht_pairing_qr" },
405
+ gatewayUrl: { type: "string", minLength: 1 },
406
+ accountId: { type: "string" },
407
+ pairingToken: { type: "string" },
408
+ expiresAt: { type: "number" },
409
+ allowOverwrite: { type: "boolean" },
410
+ pluginIdentityKey: { type: "string" },
411
+ pluginDhKey: { type: "string" },
412
+ fingerprint: { type: "string" },
413
+ signature: { type: "string" },
414
+ },
415
+ };
416
+
370
417
  export type HandshakeFinishV2 = {
371
418
  version: 2;
372
419
  accountId: string;
@@ -397,6 +444,11 @@ export const SOYEHT_CAPABILITIES = {
397
444
  voiceContractVersion: 1,
398
445
  pipeline: "stt->llm->tts" as const,
399
446
  supportedContentTypes: ["text", "audio", "file"] as const,
447
+ transport: {
448
+ direct: true,
449
+ backend: true,
450
+ defaultMode: "direct" as const,
451
+ },
400
452
  security: {
401
453
  version: 2,
402
454
  pairingMode: "qr_token" as const,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PLUGIN_VERSION = "0.1.2";
1
+ export const PLUGIN_VERSION = "0.2.1";