@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.
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -1
- package/plugin-main.ts +16 -3
- 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 +430 -21
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
|
@@ -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
|
-
|
|
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(
|
|
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`);
|
|
@@ -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(
|
|
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(
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|