@stagewhisper/stagewhisper 0.47.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.47.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.47.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
@@ -38,8 +38,6 @@ export default definePluginEntry({
38
38
  register(api) {
39
39
  api.registerChannel({ plugin: stagewhisperPlugin });
40
40
 
41
- ensureResponsesEndpoint(api);
42
-
43
41
  api.registerCli(
44
42
  ({ program }) => {
45
43
  const sw = program
@@ -66,11 +64,16 @@ export default definePluginEntry({
66
64
  enableResponses: boolean;
67
65
  }) => {
68
66
  const { StageWhisperClient } = await import("./src/client.js");
67
+ const { IdentityKeypair } = await import("./src/crypto.js");
69
68
  const client = new StageWhisperClient(opts.apiUrl, "", "");
70
69
  try {
70
+ const pluginKeypair = IdentityKeypair.generate();
71
+ const pluginPubB64 = pluginKeypair.publicKeyBase64();
72
+
71
73
  const result = await client.completePairing(
72
74
  opts.code,
73
75
  opts.label ?? "OpenClaw",
76
+ pluginPubB64,
74
77
  );
75
78
 
76
79
  const cfg = await api.runtime.config.loadConfig();
@@ -83,18 +86,27 @@ export default definePluginEntry({
83
86
  swConfig["integrationId"] = result.integration_id;
84
87
  swConfig["relayToken"] = result.relay_token;
85
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
+ }
86
93
  swEntry["config"] = swConfig;
87
94
  entries["stagewhisper"] = swEntry;
88
95
  plugins["entries"] = entries;
89
96
  (cfg as Record<string, unknown>)["plugins"] = plugins;
90
97
 
91
98
  const channels = ((cfg as Record<string, unknown>)["channels"] ?? {}) as Record<string, Record<string, unknown>>;
92
- channels["stagewhisper"] = {
99
+ const channelEntry: Record<string, unknown> = {
93
100
  apiBaseUrl: opts.apiUrl,
94
101
  integrationId: result.integration_id,
95
102
  relayToken: result.relay_token,
96
103
  label: result.label,
104
+ pluginSecretKey: Buffer.from(pluginKeypair.secretBytes()).toString("base64"),
97
105
  };
106
+ if (result.byo_public_key) {
107
+ channelEntry["desktopPublicKey"] = result.byo_public_key;
108
+ }
109
+ channels["stagewhisper"] = channelEntry;
98
110
  (cfg as Record<string, unknown>)["channels"] = channels;
99
111
 
100
112
  if (opts.enableResponses) {
@@ -336,6 +348,7 @@ export default definePluginEntry({
336
348
 
337
349
  if (api.registrationMode !== "full") return;
338
350
 
351
+ ensureResponsesEndpoint(api);
339
352
  setRuntime(api.runtime);
340
353
  const service = createRelayService(api);
341
354
  api.registerService(service);
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`);
@@ -302,12 +363,301 @@ export function createRelayService(api: OpenClawPluginApi) {
302
363
  }
303
364
  }
304
365
 
366
+ async function handleCapabilityProbe(
367
+ job: ReasoningJobEnvelope,
368
+ client: StageWhisperClient,
369
+ ): Promise<boolean> {
370
+ const correlationId = job.correlation_id;
371
+ const displayModel = health.get().displayModel ?? null;
372
+ const prompt =
373
+ ((job.payload as Record<string, unknown>)?.prompt as string) ??
374
+ "Briefly describe your capabilities, personality, tools, expertise, goals, and constraints. Plain text, no JSON.";
375
+
376
+ const sessionKey = buildAgentSessionKey({
377
+ agentId: "default",
378
+ channel: "stagewhisper",
379
+ peer: { kind: "direct", id: `sw-probe-${job.job_id}` },
380
+ });
381
+
382
+ let callbackDelivered = false;
383
+
384
+ try {
385
+ const result = await api.runtime.subagent.run({
386
+ sessionKey,
387
+ message: prompt,
388
+ deliver: false,
389
+ idempotencyKey: `sw-probe-${job.job_id}`,
390
+ });
391
+
392
+ const waitResult = await api.runtime.subagent.waitForRun({
393
+ runId: result.runId,
394
+ timeoutMs: 35_000,
395
+ });
396
+
397
+ if (waitResult.status === "ok") {
398
+ const reply = await extractReplyWithRetry(sessionKey);
399
+
400
+ await client.postReasoningResult(
401
+ job.job_id,
402
+ {
403
+ job_id: job.job_id,
404
+ status: "completed",
405
+ provider_run_id: result.runId,
406
+ model_ref: displayModel,
407
+ usage: null,
408
+ output: { raw_description: (reply ?? "").slice(0, 2000) },
409
+ error_code: null,
410
+ error_message: null,
411
+ },
412
+ correlationId,
413
+ );
414
+ callbackDelivered = true;
415
+ api.logger.info(`Capability probe ${job.job_id} completed`);
416
+ } else {
417
+ await client.postReasoningResult(
418
+ job.job_id,
419
+ {
420
+ job_id: job.job_id,
421
+ status: "failed",
422
+ provider_run_id: result.runId,
423
+ model_ref: displayModel,
424
+ usage: null,
425
+ output: null,
426
+ error_code: "agent_error",
427
+ error_message: waitResult.error ?? "Agent run failed",
428
+ },
429
+ correlationId,
430
+ );
431
+ callbackDelivered = true;
432
+ }
433
+ } catch (err) {
434
+ const errMsg = err instanceof Error ? err.message : String(err);
435
+ api.logger.error(`Capability probe ${job.job_id} failed: ${errMsg}`);
436
+ try {
437
+ await client.postReasoningResult(
438
+ job.job_id,
439
+ {
440
+ job_id: job.job_id,
441
+ status: "failed",
442
+ provider_run_id: null,
443
+ model_ref: displayModel,
444
+ usage: null,
445
+ output: null,
446
+ error_code: "execution_error",
447
+ error_message: errMsg,
448
+ },
449
+ correlationId,
450
+ );
451
+ callbackDelivered = true;
452
+ } catch (postErr) {
453
+ api.logger.error(`Failed to report probe failure: ${postErr}`);
454
+ }
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
+ }
651
+ }
652
+
305
653
  async function handleReasoningJob(
306
654
  job: ReasoningJobEnvelope,
307
655
  client: StageWhisperClient,
308
656
  ): Promise<void> {
309
657
  const correlationId = job.correlation_id;
310
- api.logger.info(`Received reasoning job: ${job.job_id} (purpose: ${job.purpose}, correlation: ${correlationId ?? "none"})`);
658
+ api.logger.info(
659
+ `Received reasoning job: ${job.job_id} (purpose: ${job.purpose}, correlation: ${correlationId ?? "none"})`,
660
+ );
311
661
 
312
662
  if (completedReasoningJobs.has(job.job_id)) {
313
663
  api.logger.info(`Skipping completed reasoning job: ${job.job_id}`);
@@ -319,7 +669,28 @@ export function createRelayService(api: OpenClawPluginApi) {
319
669
  }
320
670
  processingReasoningJobs.add(job.job_id);
321
671
 
672
+ if (job.purpose === "capability_probe") {
673
+ try {
674
+ const delivered = await handleCapabilityProbe(job, client);
675
+ if (delivered) {
676
+ completedReasoningJobs.set(job.job_id, Date.now());
677
+ }
678
+ } finally {
679
+ processingReasoningJobs.delete(job.job_id);
680
+ }
681
+ return;
682
+ }
683
+
322
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
+
323
694
  const displayModel = health.get().displayModel ?? null;
324
695
 
325
696
  let result;
@@ -331,16 +702,20 @@ export function createRelayService(api: OpenClawPluginApi) {
331
702
  api.logger.error(`Reasoning job ${job.job_id} failed: ${errMsg}`);
332
703
 
333
704
  try {
334
- await client.postReasoningResult(job.job_id, {
335
- job_id: job.job_id,
336
- status: "failed",
337
- provider_run_id: null,
338
- model_ref: displayModel,
339
- usage: null,
340
- output: null,
341
- error_code: "execution_error",
342
- error_message: errMsg,
343
- }, correlationId);
705
+ await client.postReasoningResult(
706
+ job.job_id,
707
+ {
708
+ job_id: job.job_id,
709
+ status: "failed",
710
+ provider_run_id: null,
711
+ model_ref: displayModel,
712
+ usage: null,
713
+ output: null,
714
+ error_code: "execution_error",
715
+ error_message: errMsg,
716
+ },
717
+ correlationId,
718
+ );
344
719
  } catch (postErr) {
345
720
  api.logger.error(`Failed to report reasoning failure: ${postErr}`);
346
721
  }
@@ -358,7 +733,11 @@ export function createRelayService(api: OpenClawPluginApi) {
358
733
  }
359
734
 
360
735
  try {
361
- await client.postReasoningResult(job.job_id, result as unknown as Record<string, unknown>, correlationId);
736
+ await client.postReasoningResult(
737
+ job.job_id,
738
+ result as unknown as Record<string, unknown>,
739
+ correlationId,
740
+ );
362
741
  completedReasoningJobs.set(job.job_id, Date.now());
363
742
  if (completedReasoningJobs.size > COMPLETED_JOB_MAX_SIZE) {
364
743
  evictStaleCompletedJobs();
@@ -372,12 +751,34 @@ export function createRelayService(api: OpenClawPluginApi) {
372
751
  }
373
752
  }
374
753
 
375
- async function connectStream(account: StageWhisperAccount): Promise<void> {
754
+ function createClient(account: StageWhisperAccount): StageWhisperClient {
376
755
  const client = new StageWhisperClient(
377
756
  account.apiBaseUrl,
378
757
  account.integrationId,
379
758
  account.relayToken,
380
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);
381
782
 
382
783
  abortController = new AbortController();
383
784
  const url = client.streamUrl();
@@ -401,6 +802,18 @@ export function createRelayService(api: OpenClawPluginApi) {
401
802
  health.setConnected();
402
803
  api.logger.info("Connected to StageWhisper relay stream");
403
804
 
805
+ if (health.get().status !== "healthy") {
806
+ api.logger.info("Re-probing /v1/responses after reconnect...");
807
+ const probe = await probeOpenResponses(api);
808
+ if (probe.ok) {
809
+ health.recordSuccess();
810
+ if (probe.model) health.setModel(probe.model);
811
+ api.logger.info(`Local AI verified on reconnect — model: ${probe.model ?? "unknown"}`);
812
+ } else {
813
+ api.logger.warn(`Reconnect probe failed: ${probe.error}`);
814
+ }
815
+ }
816
+
404
817
  const reader = res.body.getReader();
405
818
  const decoder = new TextDecoder();
406
819
  let buffer = "";
@@ -464,11 +877,7 @@ export function createRelayService(api: OpenClawPluginApi) {
464
877
  }
465
878
 
466
879
  function startHeartbeat(account: StageWhisperAccount): void {
467
- const client = new StageWhisperClient(
468
- account.apiBaseUrl,
469
- account.integrationId,
470
- account.relayToken,
471
- );
880
+ const client = createClient(account);
472
881
 
473
882
  heartbeatTimer = setInterval(async () => {
474
883
  try {
@@ -501,7 +910,7 @@ export function createRelayService(api: OpenClawPluginApi) {
501
910
  if (!isResponsesEndpointEnabled(api)) {
502
911
  api.logger.warn(
503
912
  "gateway.http.endpoints.responses.enabled is not true — reasoning jobs will fail with 404. " +
504
- "Enable it in config and restart the gateway, or re-pair with: openclaw stagewhisper pair --code <CODE> --enable-responses",
913
+ "Enable it in config and restart the gateway, or re-pair with: openclaw stagewhisper pair --code <CODE> --enable-responses",
505
914
  );
506
915
  }
507
916