@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.
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -1
- package/plugin-main.ts +15 -1
- package/src/channel.ts +4 -0
- package/src/client.ts +53 -6
- package/src/crypto.test.ts +181 -0
- package/src/crypto.ts +203 -0
- package/src/service.ts +301 -11
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stagewhisper/stagewhisper",
|
|
3
|
-
"version": "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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|