@stagewhisper/stagewhisper 0.49.0 → 0.51.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.
@@ -2,7 +2,7 @@
2
2
  "id": "stagewhisper",
3
3
  "name": "StageWhisper",
4
4
  "description": "Turn live call moments into assistant tasks via StageWhisper",
5
- "version": "0.49.0",
5
+ "version": "0.51.0",
6
6
  "channels": [
7
7
  "stagewhisper"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stagewhisper/stagewhisper",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin that connects StageWhisper live calls to your AI assistant",
6
6
  "license": "MIT",
@@ -35,5 +35,10 @@
35
35
  },
36
36
  "peerDependencies": {
37
37
  "openclaw": ">=2026.3.0"
38
+ },
39
+ "dependencies": {
40
+ "@noble/ciphers": "^1.3.0",
41
+ "@noble/curves": "^1.8.0",
42
+ "@noble/hashes": "^1.7.0"
38
43
  }
39
44
  }
package/plugin-main.ts CHANGED
@@ -64,11 +64,16 @@ export default definePluginEntry({
64
64
  enableResponses: boolean;
65
65
  }) => {
66
66
  const { StageWhisperClient } = await import("./src/client.js");
67
+ const { IdentityKeypair } = await import("./src/crypto.js");
67
68
  const client = new StageWhisperClient(opts.apiUrl, "", "");
68
69
  try {
70
+ const pluginKeypair = IdentityKeypair.generate();
71
+ const pluginPubB64 = pluginKeypair.publicKeyBase64();
72
+
69
73
  const result = await client.completePairing(
70
74
  opts.code,
71
75
  opts.label ?? "OpenClaw",
76
+ pluginPubB64,
72
77
  );
73
78
 
74
79
  const cfg = await api.runtime.config.loadConfig();
@@ -81,18 +86,27 @@ export default definePluginEntry({
81
86
  swConfig["integrationId"] = result.integration_id;
82
87
  swConfig["relayToken"] = result.relay_token;
83
88
  swConfig["label"] = result.label;
89
+ swConfig["pluginSecretKey"] = Buffer.from(pluginKeypair.secretBytes()).toString("base64");
90
+ if (result.byo_public_key) {
91
+ swConfig["desktopPublicKey"] = result.byo_public_key;
92
+ }
84
93
  swEntry["config"] = swConfig;
85
94
  entries["stagewhisper"] = swEntry;
86
95
  plugins["entries"] = entries;
87
96
  (cfg as Record<string, unknown>)["plugins"] = plugins;
88
97
 
89
98
  const channels = ((cfg as Record<string, unknown>)["channels"] ?? {}) as Record<string, Record<string, unknown>>;
90
- channels["stagewhisper"] = {
99
+ const channelEntry: Record<string, unknown> = {
91
100
  apiBaseUrl: opts.apiUrl,
92
101
  integrationId: result.integration_id,
93
102
  relayToken: result.relay_token,
94
103
  label: result.label,
104
+ pluginSecretKey: Buffer.from(pluginKeypair.secretBytes()).toString("base64"),
95
105
  };
106
+ if (result.byo_public_key) {
107
+ channelEntry["desktopPublicKey"] = result.byo_public_key;
108
+ }
109
+ channels["stagewhisper"] = channelEntry;
96
110
  (cfg as Record<string, unknown>)["channels"] = channels;
97
111
 
98
112
  if (opts.enableResponses) {
package/src/channel.ts CHANGED
@@ -11,6 +11,8 @@ export type StageWhisperAccount = {
11
11
  integrationId: string;
12
12
  relayToken: string;
13
13
  label: string;
14
+ pluginSecretKeyB64?: string;
15
+ desktopPublicKeyB64?: string;
14
16
  };
15
17
 
16
18
  function getChannelSection(cfg: OpenClawConfig): Record<string, unknown> {
@@ -63,6 +65,8 @@ export function resolveAccount(
63
65
  integrationId,
64
66
  relayToken,
65
67
  label,
68
+ pluginSecretKeyB64: (section["pluginSecretKey"] as string) ?? (swConfig?.["pluginSecretKey"] as string) ?? undefined,
69
+ desktopPublicKeyB64: (section["desktopPublicKey"] as string) ?? (swConfig?.["desktopPublicKey"] as string) ?? undefined,
66
70
  };
67
71
  }
68
72
 
package/src/client.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { IdentityKeypair } from "./crypto.js";
2
+
1
3
  export type TaskPayload = {
2
4
  id: string;
3
5
  session_id: string;
@@ -7,12 +9,17 @@ export type TaskPayload = {
7
9
  status: string;
8
10
  evidence_payload: Record<string, unknown> | null;
9
11
  created_at: string;
12
+ is_byo_encrypted?: boolean;
13
+ encrypted_title?: string;
14
+ encrypted_request?: string;
15
+ encrypted_evidence?: string;
10
16
  };
11
17
 
12
18
  export type PairCompleteResponse = {
13
19
  integration_id: string;
14
20
  relay_token: string;
15
21
  label: string;
22
+ byo_public_key?: string;
16
23
  };
17
24
 
18
25
  export type HeartbeatResponse = {
@@ -23,6 +30,8 @@ export class StageWhisperClient {
23
30
  private baseUrl: string;
24
31
  private integrationId: string;
25
32
  private relayToken: string;
33
+ private pluginKeypair_: IdentityKeypair | null = null;
34
+ private desktopPublicKey_: Uint8Array | null = null;
26
35
 
27
36
  constructor(baseUrl: string, integrationId: string, relayToken: string) {
28
37
  this.baseUrl = baseUrl.replace(/\/+$/, "");
@@ -30,6 +39,30 @@ export class StageWhisperClient {
30
39
  this.relayToken = relayToken;
31
40
  }
32
41
 
42
+ setPluginKeypair(kp: IdentityKeypair): void {
43
+ this.pluginKeypair_ = kp;
44
+ }
45
+
46
+ setDesktopPublicKey(key: Uint8Array): void {
47
+ this.desktopPublicKey_ = key;
48
+ }
49
+
50
+ getPluginKeypair(): IdentityKeypair | null {
51
+ return this.pluginKeypair_;
52
+ }
53
+
54
+ getDesktopPublicKey(): Uint8Array | null {
55
+ return this.desktopPublicKey_;
56
+ }
57
+
58
+ get pluginKeypair(): IdentityKeypair | null {
59
+ return this.pluginKeypair_;
60
+ }
61
+
62
+ get desktopPublicKey(): Uint8Array | null {
63
+ return this.desktopPublicKey_;
64
+ }
65
+
33
66
  private headers(): Record<string, string> {
34
67
  return {
35
68
  Authorization: `Bearer ${this.relayToken}`,
@@ -37,14 +70,22 @@ export class StageWhisperClient {
37
70
  };
38
71
  }
39
72
 
40
- async completePairing(pairingCode: string, hostLabel: string): Promise<PairCompleteResponse> {
73
+ async completePairing(
74
+ pairingCode: string,
75
+ hostLabel: string,
76
+ pluginPublicKey?: string,
77
+ ): Promise<PairCompleteResponse> {
78
+ const body: Record<string, unknown> = {
79
+ pairing_code: pairingCode,
80
+ host_label: hostLabel,
81
+ };
82
+ if (pluginPublicKey) {
83
+ body.plugin_public_key = pluginPublicKey;
84
+ }
41
85
  const res = await fetch(`${this.baseUrl}/api/v1/openclaw/pair/complete`, {
42
86
  method: "POST",
43
87
  headers: { "Content-Type": "application/json" },
44
- body: JSON.stringify({
45
- pairing_code: pairingCode,
46
- host_label: hostLabel,
47
- }),
88
+ body: JSON.stringify(body),
48
89
  });
49
90
  if (!res.ok) {
50
91
  const text = await res.text();
@@ -68,9 +109,15 @@ export class StageWhisperClient {
68
109
  }
69
110
  }
70
111
 
71
- async postReply(taskId: string, content: string, remoteMessageId?: string): Promise<void> {
112
+ async postReply(
113
+ taskId: string,
114
+ content: string,
115
+ remoteMessageId?: string,
116
+ encryptedSummary?: string,
117
+ ): Promise<void> {
72
118
  const body: Record<string, unknown> = { content };
73
119
  if (remoteMessageId) body.remote_message_id = remoteMessageId;
120
+ if (encryptedSummary) body.encrypted_summary = encryptedSummary;
74
121
 
75
122
  const res = await fetch(`${this.baseUrl}/api/v1/openclaw/tasks/${taskId}/reply`, {
76
123
  method: "POST",
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ IdentityKeypair,
4
+ seal,
5
+ open,
6
+ sealJson,
7
+ openJson,
8
+ openChecked,
9
+ ReplayGuard,
10
+ ENVELOPE_VERSION,
11
+ type BYOEnvelope,
12
+ } from "./crypto";
13
+
14
+ function makeTestKey(): { key: Uint8Array; alice: IdentityKeypair; bob: IdentityKeypair } {
15
+ const alice = IdentityKeypair.generate();
16
+ const bob = IdentityKeypair.generate();
17
+ const key = alice.deriveEnvelopeKey(bob.publicKey);
18
+ return { key, alice, bob };
19
+ }
20
+
21
+ describe("crypto envelope", () => {
22
+ it("seal and open roundtrip", () => {
23
+ const { key } = makeTestKey();
24
+ const plaintext = new TextEncoder().encode("hello world");
25
+
26
+ const envelope = seal(key, "desktop", "session-1", "corr-1", "reasoning_input", plaintext);
27
+
28
+ expect(envelope.version).toBe(ENVELOPE_VERSION);
29
+ expect(envelope.sender_role).toBe("desktop");
30
+
31
+ const decrypted = open(key, envelope);
32
+ expect(decrypted).toEqual(plaintext);
33
+ });
34
+
35
+ it("wrong key fails", () => {
36
+ const { key: key1 } = makeTestKey();
37
+ const { key: key2 } = makeTestKey();
38
+
39
+ const envelope = seal(key1, "plugin", "s", "c", "reasoning_output", new TextEncoder().encode("secret"));
40
+
41
+ expect(() => open(key2, envelope)).toThrow();
42
+ });
43
+
44
+ it("tampered ciphertext fails", () => {
45
+ const { key } = makeTestKey();
46
+ const envelope = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("data"));
47
+
48
+ const bytes = Uint8Array.from(atob(envelope.ciphertext), (c) => c.codePointAt(0)!);
49
+ bytes[0] ^= 0xff;
50
+ const tampered: BYOEnvelope = {
51
+ ...envelope,
52
+ ciphertext: btoa(Array.from(bytes, (b) => String.fromCodePoint(b)).join("")),
53
+ };
54
+
55
+ expect(() => open(key, tampered)).toThrow();
56
+ });
57
+
58
+ it("tampered session_id fails", () => {
59
+ const { key } = makeTestKey();
60
+ const envelope = seal(key, "desktop", "session-1", "corr-1", "reasoning_input", new TextEncoder().encode("data"));
61
+
62
+ const tampered: BYOEnvelope = { ...envelope, session_id: "TAMPERED" };
63
+ expect(() => open(key, tampered)).toThrow();
64
+ });
65
+
66
+ it("tampered sender_role fails", () => {
67
+ const { key } = makeTestKey();
68
+ const envelope = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("data"));
69
+
70
+ const tampered: BYOEnvelope = { ...envelope, sender_role: "plugin" };
71
+ expect(() => open(key, tampered)).toThrow();
72
+ });
73
+
74
+ it("tampered content_type fails", () => {
75
+ const { key } = makeTestKey();
76
+ const envelope = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("data"));
77
+
78
+ const tampered: BYOEnvelope = { ...envelope, content_type: "task_content" };
79
+ expect(() => open(key, tampered)).toThrow();
80
+ });
81
+
82
+ it("unique message_ids", () => {
83
+ const { key } = makeTestKey();
84
+ const e1 = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("a"));
85
+ const e2 = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("a"));
86
+ expect(e1.message_id).not.toBe(e2.message_id);
87
+ });
88
+
89
+ it("unique nonces", () => {
90
+ const { key } = makeTestKey();
91
+ const e1 = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("a"));
92
+ const e2 = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("a"));
93
+ expect(e1.nonce).not.toBe(e2.nonce);
94
+ });
95
+
96
+ it("json roundtrip", () => {
97
+ const { key } = makeTestKey();
98
+ const payload = {
99
+ system_instruction: "You are an analyst",
100
+ user_prompt: "Analyze the transcript",
101
+ response_schema: { type: "object" },
102
+ };
103
+
104
+ const envelope = sealJson(key, "desktop", "session-1", "corr-1", "reasoning_input", payload);
105
+ const decrypted = openJson(key, envelope);
106
+ expect(decrypted).toEqual(payload);
107
+ });
108
+
109
+ it("serialization roundtrip", () => {
110
+ const { key } = makeTestKey();
111
+ const envelope = seal(key, "desktop", "session-1", "corr-1", "reasoning_input", new TextEncoder().encode("test payload"));
112
+
113
+ const json = JSON.stringify(envelope);
114
+ const restored: BYOEnvelope = JSON.parse(json);
115
+
116
+ const decrypted = open(key, restored);
117
+ expect(new TextDecoder().decode(decrypted)).toBe("test payload");
118
+ });
119
+
120
+ it("unsupported version rejected", () => {
121
+ const { key } = makeTestKey();
122
+ const envelope = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("data"));
123
+ const bad: BYOEnvelope = { ...envelope, version: "static_v99" };
124
+
125
+ expect(() => open(key, bad)).toThrow(/Unsupported envelope version/);
126
+ });
127
+ });
128
+
129
+ describe("replay guard", () => {
130
+ it("detects duplicate message", () => {
131
+ const { key } = makeTestKey();
132
+ const envelope = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("data"));
133
+
134
+ const guard = new ReplayGuard();
135
+ expect(() => openChecked(key, envelope, guard)).not.toThrow();
136
+ expect(() => openChecked(key, envelope, guard)).toThrow(/Replay detected/);
137
+ });
138
+
139
+ it("allows different messages", () => {
140
+ const { key } = makeTestKey();
141
+ const e1 = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("a"));
142
+ const e2 = seal(key, "desktop", "s", "c", "reasoning_input", new TextEncoder().encode("b"));
143
+
144
+ const guard = new ReplayGuard();
145
+ expect(() => openChecked(key, e1, guard)).not.toThrow();
146
+ expect(() => openChecked(key, e2, guard)).not.toThrow();
147
+ });
148
+ });
149
+
150
+ describe("identity keypair", () => {
151
+ it("generate and DH agreement", () => {
152
+ const alice = IdentityKeypair.generate();
153
+ const bob = IdentityKeypair.generate();
154
+
155
+ const sharedA = alice.diffieHellman(bob.publicKey);
156
+ const sharedB = bob.diffieHellman(alice.publicKey);
157
+ expect(sharedA).toEqual(sharedB);
158
+ });
159
+
160
+ it("derived envelope keys match", () => {
161
+ const alice = IdentityKeypair.generate();
162
+ const bob = IdentityKeypair.generate();
163
+
164
+ const keyA = alice.deriveEnvelopeKey(bob.publicKey);
165
+ const keyB = bob.deriveEnvelopeKey(alice.publicKey);
166
+ expect(keyA).toEqual(keyB);
167
+ });
168
+
169
+ it("base64 roundtrip", () => {
170
+ const kp = IdentityKeypair.generate();
171
+ const b64 = kp.publicKeyBase64();
172
+ const restored = IdentityKeypair.publicKeyFromBase64(b64);
173
+ expect(restored).toEqual(kp.publicKey);
174
+ });
175
+
176
+ it("from secret bytes roundtrip", () => {
177
+ const original = IdentityKeypair.generate();
178
+ const restored = IdentityKeypair.fromSecretBytes(original.secretBytes());
179
+ expect(restored.publicKey).toEqual(original.publicKey);
180
+ });
181
+ });
package/src/crypto.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { x25519 } from "@noble/curves/ed25519";
2
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha";
3
+ import { hkdf } from "@noble/hashes/hkdf";
4
+ import { sha256 } from "@noble/hashes/sha256";
5
+ import { randomBytes } from "@noble/ciphers/webcrypto";
6
+
7
+ export const ENVELOPE_VERSION = "static_v1";
8
+ const ENVELOPE_HKDF_INFO = "stagewhisper-byo-envelope-v1";
9
+ const REPLAY_GUARD_MAX_IDS = 10_000;
10
+
11
+ export type SenderRole = "desktop" | "plugin";
12
+
13
+ export type ContentType =
14
+ | "reasoning_input"
15
+ | "reasoning_output"
16
+ | "tool_intent"
17
+ | "tool_result"
18
+ | "task_content"
19
+ | "task_reply";
20
+
21
+ export interface BYOEnvelope {
22
+ version: string;
23
+ sender_role: SenderRole;
24
+ message_id: string;
25
+ session_id: string;
26
+ correlation_id: string;
27
+ content_type: ContentType;
28
+ nonce: string;
29
+ ciphertext: string;
30
+ }
31
+
32
+ export class IdentityKeypair {
33
+ private secretKey: Uint8Array;
34
+ readonly publicKey: Uint8Array;
35
+
36
+ private constructor(secretKey: Uint8Array) {
37
+ this.secretKey = secretKey;
38
+ this.publicKey = x25519.getPublicKey(secretKey);
39
+ }
40
+
41
+ static generate(): IdentityKeypair {
42
+ const secretKey = randomBytes(32);
43
+ return new IdentityKeypair(secretKey);
44
+ }
45
+
46
+ static fromSecretBytes(bytes: Uint8Array): IdentityKeypair {
47
+ if (bytes.length !== 32) throw new Error(`Secret key must be 32 bytes, got ${bytes.length}`);
48
+ return new IdentityKeypair(bytes);
49
+ }
50
+
51
+ secretBytes(): Uint8Array {
52
+ return new Uint8Array(this.secretKey);
53
+ }
54
+
55
+ publicKeyBase64(): string {
56
+ return toBase64(this.publicKey);
57
+ }
58
+
59
+ static publicKeyFromBase64(b64: string): Uint8Array {
60
+ const bytes = fromBase64(b64);
61
+ if (bytes.length !== 32) throw new Error(`Public key must be 32 bytes, got ${bytes.length}`);
62
+ return bytes;
63
+ }
64
+
65
+ diffieHellman(peerPublicKey: Uint8Array): Uint8Array {
66
+ return x25519.getSharedSecret(this.secretKey, peerPublicKey);
67
+ }
68
+
69
+ deriveEnvelopeKey(peerPublicKey: Uint8Array): Uint8Array {
70
+ const shared = this.diffieHellman(peerPublicKey);
71
+ return hkdf(sha256, shared, undefined, ENVELOPE_HKDF_INFO, 32);
72
+ }
73
+ }
74
+
75
+ function buildAad(envelope: Omit<BYOEnvelope, "nonce" | "ciphertext">): Uint8Array {
76
+ const aadObj = {
77
+ content_type: envelope.content_type,
78
+ correlation_id: envelope.correlation_id,
79
+ message_id: envelope.message_id,
80
+ sender_role: envelope.sender_role,
81
+ session_id: envelope.session_id,
82
+ version: envelope.version,
83
+ };
84
+ return new TextEncoder().encode(JSON.stringify(aadObj));
85
+ }
86
+
87
+ function generateUUID(): string {
88
+ return crypto.randomUUID();
89
+ }
90
+
91
+ export function seal(
92
+ key: Uint8Array,
93
+ senderRole: SenderRole,
94
+ sessionId: string,
95
+ correlationId: string,
96
+ contentType: ContentType,
97
+ plaintext: Uint8Array,
98
+ ): BYOEnvelope {
99
+ const messageId = generateUUID();
100
+ const nonce = randomBytes(24);
101
+
102
+ const metadata = {
103
+ version: ENVELOPE_VERSION,
104
+ sender_role: senderRole,
105
+ message_id: messageId,
106
+ session_id: sessionId,
107
+ correlation_id: correlationId,
108
+ content_type: contentType,
109
+ };
110
+
111
+ const aad = buildAad(metadata);
112
+ const cipher = xchacha20poly1305(key, nonce, aad);
113
+ const ciphertext = cipher.encrypt(plaintext);
114
+
115
+ return {
116
+ ...metadata,
117
+ nonce: toBase64(nonce),
118
+ ciphertext: toBase64(ciphertext),
119
+ };
120
+ }
121
+
122
+ export function open(key: Uint8Array, envelope: BYOEnvelope): Uint8Array {
123
+ if (envelope.version !== ENVELOPE_VERSION) {
124
+ throw new Error(`Unsupported envelope version: ${envelope.version}`);
125
+ }
126
+
127
+ const nonce = fromBase64(envelope.nonce);
128
+ if (nonce.length !== 24) {
129
+ throw new Error(`Nonce must be 24 bytes, got ${nonce.length}`);
130
+ }
131
+
132
+ const ciphertext = fromBase64(envelope.ciphertext);
133
+ const aad = buildAad(envelope);
134
+ const cipher = xchacha20poly1305(key, nonce, aad);
135
+
136
+ return cipher.decrypt(ciphertext);
137
+ }
138
+
139
+ export function sealJson<T>(
140
+ key: Uint8Array,
141
+ senderRole: SenderRole,
142
+ sessionId: string,
143
+ correlationId: string,
144
+ contentType: ContentType,
145
+ value: T,
146
+ ): BYOEnvelope {
147
+ const plaintext = new TextEncoder().encode(JSON.stringify(value));
148
+ return seal(key, senderRole, sessionId, correlationId, contentType, plaintext);
149
+ }
150
+
151
+ export function openJson<T>(key: Uint8Array, envelope: BYOEnvelope): T {
152
+ const plaintext = open(key, envelope);
153
+ return JSON.parse(new TextDecoder().decode(plaintext));
154
+ }
155
+
156
+ export class ReplayGuard {
157
+ private seen = new Set<string>();
158
+ private order: string[] = [];
159
+
160
+ check(messageId: string): void {
161
+ if (this.seen.has(messageId)) {
162
+ throw new Error(`Replay detected: duplicate message_id ${messageId}`);
163
+ }
164
+ while (this.seen.size >= REPLAY_GUARD_MAX_IDS) {
165
+ const oldest = this.order.shift();
166
+ if (oldest !== undefined) {
167
+ this.seen.delete(oldest);
168
+ } else {
169
+ break;
170
+ }
171
+ }
172
+ this.seen.add(messageId);
173
+ this.order.push(messageId);
174
+ }
175
+ }
176
+
177
+ export function openChecked(
178
+ key: Uint8Array,
179
+ envelope: BYOEnvelope,
180
+ replayGuard: ReplayGuard,
181
+ ): Uint8Array {
182
+ replayGuard.check(envelope.message_id);
183
+ return open(key, envelope);
184
+ }
185
+
186
+ export function openJsonChecked<T>(
187
+ key: Uint8Array,
188
+ envelope: BYOEnvelope,
189
+ replayGuard: ReplayGuard,
190
+ ): T {
191
+ replayGuard.check(envelope.message_id);
192
+ return openJson<T>(key, envelope);
193
+ }
194
+
195
+ function toBase64(bytes: Uint8Array): string {
196
+ const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join("");
197
+ return btoa(binString);
198
+ }
199
+
200
+ function fromBase64(b64: string): Uint8Array {
201
+ const binString = atob(b64);
202
+ return Uint8Array.from(binString, (c) => c.codePointAt(0)!);
203
+ }
package/src/service.ts CHANGED
@@ -8,6 +8,7 @@ import { resolveAccount } from "./channel.js";
8
8
  import { createHealthTracker } from "./health.js";
9
9
  import { executeReasoningJob, probeOpenResponses, type ReasoningJobEnvelope } from "./reasoning.js";
10
10
  import { isResponsesEndpointEnabled } from "./openresponses.js";
11
+ import { IdentityKeypair, seal, open, type BYOEnvelope } from "./crypto.js";
11
12
 
12
13
  const HEARTBEAT_INTERVAL_MS = 30_000;
13
14
  const RECONNECT_BASE_MS = 1_000;
@@ -58,6 +59,8 @@ export function createRelayService(api: OpenClawPluginApi) {
58
59
  integrationId: (pluginCfg["integrationId"] as string) ?? "",
59
60
  relayToken: (pluginCfg["relayToken"] as string) ?? "",
60
61
  label: (pluginCfg["label"] as string) ?? "StageWhisper",
62
+ pluginSecretKeyB64: (pluginCfg["pluginSecretKey"] as string) ?? undefined,
63
+ desktopPublicKeyB64: (pluginCfg["desktopPublicKey"] as string) ?? undefined,
61
64
  };
62
65
  }
63
66
  }
@@ -188,6 +191,60 @@ export function createRelayService(api: OpenClawPluginApi) {
188
191
  return null;
189
192
  }
190
193
 
194
+ function decryptTaskFields(task: TaskPayload, client: StageWhisperClient): TaskPayload {
195
+ const keypair = client.pluginKeypair;
196
+ const desktopPub = client.desktopPublicKey;
197
+ if (!keypair || !desktopPub) return task;
198
+
199
+ const envelopeKey = keypair.deriveEnvelopeKey(desktopPub);
200
+ const decrypted = { ...task };
201
+ if (task.encrypted_title) {
202
+ try {
203
+ const envelope: BYOEnvelope = JSON.parse(task.encrypted_title);
204
+ const data = open(envelopeKey, envelope);
205
+ decrypted.title = new TextDecoder().decode(data);
206
+ } catch {
207
+ api.logger.warn(`Failed to decrypt task title for ${task.id}`);
208
+ }
209
+ }
210
+ if (task.encrypted_request) {
211
+ try {
212
+ const envelope: BYOEnvelope = JSON.parse(task.encrypted_request);
213
+ const data = open(envelopeKey, envelope);
214
+ decrypted.request_text = new TextDecoder().decode(data);
215
+ } catch {
216
+ api.logger.warn(`Failed to decrypt task request for ${task.id}`);
217
+ }
218
+ }
219
+ if (task.encrypted_evidence) {
220
+ try {
221
+ const envelope: BYOEnvelope = JSON.parse(task.encrypted_evidence);
222
+ const data = open(envelopeKey, envelope);
223
+ decrypted.evidence_payload = JSON.parse(new TextDecoder().decode(data));
224
+ } catch {
225
+ api.logger.warn(`Failed to decrypt task evidence for ${task.id}`);
226
+ }
227
+ }
228
+ return decrypted;
229
+ }
230
+
231
+ function encryptReply(content: string, client: StageWhisperClient, sessionId: string, taskId: string): string | undefined {
232
+ const keypair = client.pluginKeypair;
233
+ const desktopPub = client.desktopPublicKey;
234
+ if (!keypair || !desktopPub) return undefined;
235
+
236
+ const envelopeKey = keypair.deriveEnvelopeKey(desktopPub);
237
+ const envelope = seal(
238
+ envelopeKey,
239
+ "plugin",
240
+ sessionId,
241
+ taskId,
242
+ "task_reply",
243
+ new TextEncoder().encode(content),
244
+ );
245
+ return JSON.stringify(envelope);
246
+ }
247
+
191
248
  async function handleNormalTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
192
249
  let deliveredMarked = false;
193
250
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -205,7 +262,10 @@ export function createRelayService(api: OpenClawPluginApi) {
205
262
  return;
206
263
  }
207
264
 
208
- const messageContent = buildTaskMessage(task);
265
+ const isByo = !!task.is_byo_encrypted;
266
+ const effectiveTask = isByo ? decryptTaskFields(task, client) : task;
267
+
268
+ const messageContent = buildTaskMessage(effectiveTask);
209
269
  const peerId = `sw-session-${task.session_id}`;
210
270
  const sessionKey = buildAgentSessionKey({
211
271
  agentId: "default",
@@ -234,7 +294,8 @@ export function createRelayService(api: OpenClawPluginApi) {
234
294
  if (waitResult.status === "ok") {
235
295
  const reply = await extractReplyForTask(sessionKey, task.id);
236
296
  if (reply) {
237
- await client.postReply(task.id, reply);
297
+ const encSummary = isByo ? encryptReply(reply, client, task.session_id, task.id) : undefined;
298
+ await client.postReply(task.id, isByo ? "[BYO Encrypted]" : reply, undefined, encSummary);
238
299
  api.logger.info(`Task ${task.id} completed with reply`);
239
300
  } else {
240
301
  api.logger.warn(`Task ${task.id} completed but no reply found`);
@@ -305,7 +366,7 @@ export function createRelayService(api: OpenClawPluginApi) {
305
366
  async function handleCapabilityProbe(
306
367
  job: ReasoningJobEnvelope,
307
368
  client: StageWhisperClient,
308
- ): Promise<void> {
369
+ ): Promise<boolean> {
309
370
  const correlationId = job.correlation_id;
310
371
  const displayModel = health.get().displayModel ?? null;
311
372
  const prompt =
@@ -318,6 +379,8 @@ export function createRelayService(api: OpenClawPluginApi) {
318
379
  peer: { kind: "direct", id: `sw-probe-${job.job_id}` },
319
380
  });
320
381
 
382
+ let callbackDelivered = false;
383
+
321
384
  try {
322
385
  const result = await api.runtime.subagent.run({
323
386
  sessionKey,
@@ -348,6 +411,7 @@ export function createRelayService(api: OpenClawPluginApi) {
348
411
  },
349
412
  correlationId,
350
413
  );
414
+ callbackDelivered = true;
351
415
  api.logger.info(`Capability probe ${job.job_id} completed`);
352
416
  } else {
353
417
  await client.postReasoningResult(
@@ -364,6 +428,7 @@ export function createRelayService(api: OpenClawPluginApi) {
364
428
  },
365
429
  correlationId,
366
430
  );
431
+ callbackDelivered = true;
367
432
  }
368
433
  } catch (err) {
369
434
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -383,10 +448,206 @@ export function createRelayService(api: OpenClawPluginApi) {
383
448
  },
384
449
  correlationId,
385
450
  );
451
+ callbackDelivered = true;
386
452
  } catch (postErr) {
387
453
  api.logger.error(`Failed to report probe failure: ${postErr}`);
388
454
  }
389
455
  }
456
+
457
+ return callbackDelivered;
458
+ }
459
+
460
+ async function handleBYOEncryptedJob(
461
+ job: ReasoningJobEnvelope,
462
+ client: StageWhisperClient,
463
+ ): Promise<void> {
464
+ const correlationId = job.correlation_id;
465
+ api.logger.info(
466
+ `Received BYO encrypted reasoning job: ${job.job_id}`,
467
+ );
468
+
469
+ const rawPayload = job.payload as Record<string, unknown>;
470
+ const envelope = rawPayload.envelope as Record<string, unknown> | undefined;
471
+ if (!envelope || !envelope.ciphertext) {
472
+ api.logger.error(`BYO job ${job.job_id} missing encrypted envelope`);
473
+ try {
474
+ await client.postReasoningResult(
475
+ job.job_id,
476
+ {
477
+ job_id: job.job_id,
478
+ status: "failed",
479
+ provider_run_id: null,
480
+ model_ref: null,
481
+ usage: null,
482
+ output: null,
483
+ error_code: "missing_envelope",
484
+ error_message: "No encrypted envelope in BYO reasoning job",
485
+ byo_encrypted: true,
486
+ },
487
+ correlationId,
488
+ );
489
+ } catch (postErr) {
490
+ api.logger.error(`Failed to report BYO failure: ${postErr}`);
491
+ }
492
+ return;
493
+ }
494
+
495
+ const pluginKeypair = client.getPluginKeypair?.();
496
+ const desktopPublicKey = client.getDesktopPublicKey?.();
497
+
498
+ if (!pluginKeypair || !desktopPublicKey) {
499
+ api.logger.error(`BYO job ${job.job_id}: missing crypto keys`);
500
+ try {
501
+ await client.postReasoningResult(
502
+ job.job_id,
503
+ {
504
+ job_id: job.job_id,
505
+ status: "failed",
506
+ provider_run_id: null,
507
+ model_ref: null,
508
+ usage: null,
509
+ output: null,
510
+ error_code: "missing_keys",
511
+ error_message: "Plugin or desktop keys not configured for BYO mode",
512
+ byo_encrypted: true,
513
+ },
514
+ correlationId,
515
+ );
516
+ } catch (postErr) {
517
+ api.logger.error(`Failed to report BYO key failure: ${postErr}`);
518
+ }
519
+ return;
520
+ }
521
+
522
+ let decryptedPayload: Record<string, unknown>;
523
+ const envelopeKey = pluginKeypair.deriveEnvelopeKey(desktopPublicKey);
524
+ try {
525
+ const byoEnvelope: BYOEnvelope = {
526
+ version: (envelope.version as string) ?? "static_v1",
527
+ sender_role: (envelope.sender_role as "desktop" | "plugin") ?? "desktop",
528
+ message_id: (envelope.message_id as string) ?? "",
529
+ session_id: (envelope.session_id as string) ?? "",
530
+ correlation_id: (envelope.correlation_id as string) ?? "",
531
+ content_type: (envelope.content_type as BYOEnvelope["content_type"]) ?? "reasoning_input",
532
+ nonce: envelope.nonce as string,
533
+ ciphertext: envelope.ciphertext as string,
534
+ };
535
+
536
+ const plaintext = open(envelopeKey, byoEnvelope);
537
+ decryptedPayload = JSON.parse(new TextDecoder().decode(plaintext));
538
+ } catch (err) {
539
+ api.logger.error(`BYO job ${job.job_id} decryption failed: ${err}`);
540
+ try {
541
+ await client.postReasoningResult(
542
+ job.job_id,
543
+ {
544
+ job_id: job.job_id,
545
+ status: "failed",
546
+ provider_run_id: null,
547
+ model_ref: null,
548
+ usage: null,
549
+ output: null,
550
+ error_code: "decryption_error",
551
+ error_message: "Failed to decrypt BYO envelope",
552
+ byo_encrypted: true,
553
+ },
554
+ correlationId,
555
+ );
556
+ } catch (postErr) {
557
+ api.logger.error(`Failed to report BYO decryption failure: ${postErr}`);
558
+ }
559
+ return;
560
+ }
561
+
562
+ const plainJob: ReasoningJobEnvelope = {
563
+ ...job,
564
+ payload: decryptedPayload,
565
+ response_schema: decryptedPayload.response_schema as Record<string, unknown> ?? job.response_schema,
566
+ };
567
+
568
+ const displayModel = health.get().displayModel ?? null;
569
+ let result;
570
+ try {
571
+ result = await executeReasoningJob(api, plainJob, displayModel);
572
+ } catch (err) {
573
+ const errMsg = err instanceof Error ? err.message : String(err);
574
+ health.recordFailure(errMsg);
575
+ api.logger.error(`BYO reasoning job ${job.job_id} failed: ${errMsg}`);
576
+ try {
577
+ await client.postReasoningResult(
578
+ job.job_id,
579
+ {
580
+ job_id: job.job_id,
581
+ status: "failed",
582
+ provider_run_id: null,
583
+ model_ref: displayModel,
584
+ usage: null,
585
+ output: null,
586
+ error_code: "execution_error",
587
+ error_message: errMsg,
588
+ byo_encrypted: true,
589
+ },
590
+ correlationId,
591
+ );
592
+ } catch (postErr) {
593
+ api.logger.error(`Failed to report BYO reasoning failure: ${postErr}`);
594
+ }
595
+ return;
596
+ }
597
+
598
+ if (result.status === "completed") {
599
+ health.recordSuccess();
600
+ } else {
601
+ health.recordFailure(result.error_message ?? `reasoning ${result.status}`);
602
+ }
603
+
604
+ let encryptedResult: Record<string, unknown>;
605
+ try {
606
+ const resultJson = JSON.stringify(result.output ?? {});
607
+ const resultBytes = new TextEncoder().encode(resultJson);
608
+
609
+ const sessionId = (envelope.session_id as string) ?? "";
610
+ const resultCorrelationId = (envelope.correlation_id as string) ?? "";
611
+
612
+ const resultEnvelope = seal(
613
+ envelopeKey,
614
+ "plugin",
615
+ sessionId,
616
+ resultCorrelationId,
617
+ "reasoning_output",
618
+ resultBytes,
619
+ );
620
+
621
+ encryptedResult = {
622
+ byo_encrypted: true,
623
+ envelope: resultEnvelope,
624
+ job_id: job.job_id,
625
+ status: result.status,
626
+ usage: result.usage,
627
+ model_ref: result.model_ref,
628
+ };
629
+ } catch (err) {
630
+ api.logger.error(`BYO job ${job.job_id} result encryption failed: ${err}`);
631
+ encryptedResult = {
632
+ job_id: job.job_id,
633
+ status: "failed",
634
+ error_code: "encryption_error",
635
+ error_message: "Failed to encrypt result",
636
+ byo_encrypted: true,
637
+ };
638
+ }
639
+
640
+ try {
641
+ await client.postReasoningResult(
642
+ job.job_id,
643
+ encryptedResult,
644
+ correlationId,
645
+ );
646
+ completedReasoningJobs.set(job.job_id, Date.now());
647
+ api.logger.info(`BYO reasoning job ${job.job_id} completed (status: ${result.status})`);
648
+ } catch (postErr) {
649
+ api.logger.error(`Failed to post BYO reasoning result for ${job.job_id}: ${postErr}`);
650
+ }
390
651
  }
391
652
 
392
653
  async function handleReasoningJob(
@@ -410,8 +671,10 @@ export function createRelayService(api: OpenClawPluginApi) {
410
671
 
411
672
  if (job.purpose === "capability_probe") {
412
673
  try {
413
- await handleCapabilityProbe(job, client);
414
- completedReasoningJobs.set(job.job_id, Date.now());
674
+ const delivered = await handleCapabilityProbe(job, client);
675
+ if (delivered) {
676
+ completedReasoningJobs.set(job.job_id, Date.now());
677
+ }
415
678
  } finally {
416
679
  processingReasoningJobs.delete(job.job_id);
417
680
  }
@@ -419,6 +682,15 @@ export function createRelayService(api: OpenClawPluginApi) {
419
682
  }
420
683
 
421
684
  try {
685
+ const isBYOEncrypted =
686
+ job.payload &&
687
+ (job.payload as Record<string, unknown>).byo_encrypted === true;
688
+
689
+ if (isBYOEncrypted) {
690
+ await handleBYOEncryptedJob(job, client);
691
+ return;
692
+ }
693
+
422
694
  const displayModel = health.get().displayModel ?? null;
423
695
 
424
696
  let result;
@@ -479,12 +751,34 @@ export function createRelayService(api: OpenClawPluginApi) {
479
751
  }
480
752
  }
481
753
 
482
- async function connectStream(account: StageWhisperAccount): Promise<void> {
754
+ function createClient(account: StageWhisperAccount): StageWhisperClient {
483
755
  const client = new StageWhisperClient(
484
756
  account.apiBaseUrl,
485
757
  account.integrationId,
486
758
  account.relayToken,
487
759
  );
760
+ if (account.pluginSecretKeyB64) {
761
+ try {
762
+ const secretBytes = Uint8Array.from(atob(account.pluginSecretKeyB64), c => c.charCodeAt(0));
763
+ client.setPluginKeypair(IdentityKeypair.fromSecretBytes(secretBytes));
764
+ } catch (err) {
765
+ api.logger.warn(`Failed to load plugin keypair from config: ${err}`);
766
+ }
767
+ }
768
+ if (account.desktopPublicKeyB64) {
769
+ try {
770
+ client.setDesktopPublicKey(
771
+ IdentityKeypair.publicKeyFromBase64(account.desktopPublicKeyB64),
772
+ );
773
+ } catch (err) {
774
+ api.logger.warn(`Failed to load desktop public key from config: ${err}`);
775
+ }
776
+ }
777
+ return client;
778
+ }
779
+
780
+ async function connectStream(account: StageWhisperAccount): Promise<void> {
781
+ const client = createClient(account);
488
782
 
489
783
  abortController = new AbortController();
490
784
  const url = client.streamUrl();
@@ -583,11 +877,7 @@ export function createRelayService(api: OpenClawPluginApi) {
583
877
  }
584
878
 
585
879
  function startHeartbeat(account: StageWhisperAccount): void {
586
- const client = new StageWhisperClient(
587
- account.apiBaseUrl,
588
- account.integrationId,
589
- account.relayToken,
590
- );
880
+ const client = createClient(account);
591
881
 
592
882
  heartbeatTimer = setInterval(async () => {
593
883
  try {