@rynx-ai/remote-runtime-client 0.1.9
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/dist/browser-surface-client.d.ts +31 -0
- package/dist/browser-surface-client.js +785 -0
- package/dist/client.d.ts +86 -0
- package/dist/client.js +1915 -0
- package/dist/credential.d.ts +35 -0
- package/dist/credential.js +163 -0
- package/dist/errors.d.ts +55 -0
- package/dist/errors.js +112 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/package.json +32 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,1915 @@
|
|
|
1
|
+
import { createHash, createPublicKey, randomBytes, sign as signPayload, verify as verifyPayload, } from "node:crypto";
|
|
2
|
+
import { decodeDirectRuntimeFrame, DIRECT_RUNTIME_BINDING_PROTOCOL, DIRECT_RUNTIME_CLOSE_CODE, DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES, encodeClientAuthenticateCanonical, encodeClientAuthenticateProofPayload, encodeDirectRuntimeFrame, encodeServerAcceptedProofPayload, encodeServerHelloCanonical, encodeServerHelloProofPayload, parsePairingOffer, } from "@rynx-ai/protocol/direct-runtime";
|
|
3
|
+
import { assertRemoteRuntimeRpcMethodSupported, assertRemoteRuntimeSessionEventsSupported, assertRemoteRuntimeSessionTerminalSupported, encodeRemoteRuntimeSessionTerminalClientFrame, encodeRemoteRuntimeSessionEventsFrame, encodeRemoteRuntimeRpcRequest, parseRemoteRuntimeRpcResponseEnvelope, parseRemoteRuntimeRpcResponseForMethod, parseRemoteRuntimeSessionEventsFrame, parseRemoteRuntimeSessionTerminalServerFrame, REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES, } from "@rynx-ai/protocol/remote-runtime-rpc";
|
|
4
|
+
import { REMOTE_RUNTIME_CORE_PROTOCOL, } from "@rynx-ai/protocol/remote-runtime";
|
|
5
|
+
import { encodeRuntimeEmulatorSurfaceClientFrame, parseRuntimeEmulatorSurfaceServerFrame, RUNTIME_EMULATOR_SURFACE_MAX_FRAME_BYTES, RUNTIME_EMULATOR_SURFACE_SEMANTIC_CAPABILITY, } from "@rynx-ai/protocol/runtime-emulator-surface";
|
|
6
|
+
import { DIRECT_RUNTIME_E2EE_MAX_ENVELOPE_BYTES, generateClientKeyExchange, } from "@rynx-ai/remote-runtime-e2ee";
|
|
7
|
+
import WebSocket from "ws";
|
|
8
|
+
import { generateDirectRuntimeClientIdentity, parseDirectRuntimeClientIdentity, parseDirectRuntimeCredential, parseDirectEndpoint, parseEd25519PublicKey, parseX25519PublicKey, } from "./credential.js";
|
|
9
|
+
import { clientError, DirectRuntimeCallTransportError, DirectRuntimeClientCapacityError, DirectRuntimeClientError, DirectRuntimeRpcError, DirectRuntimeSessionEventsError, DirectRuntimeSessionTerminalError, } from "./errors.js";
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
11
|
+
const MIN_TIMEOUT_MS = 100;
|
|
12
|
+
const MAX_TIMEOUT_MS = 120_000;
|
|
13
|
+
const DEFAULT_LIVENESS_PING_INTERVAL_MS = 10_000;
|
|
14
|
+
const DEFAULT_LIVENESS_INACTIVITY_TIMEOUT_MS = 25_000;
|
|
15
|
+
const MAX_LIVENESS_INTERVAL_MS = 5 * 60_000;
|
|
16
|
+
const MAX_PRE_ACTIVATION_FRAMES = 4;
|
|
17
|
+
const MAX_PRE_ACTIVATION_BYTES = 128 * 1024;
|
|
18
|
+
// Match the server's default per-channel admission contract. These are local
|
|
19
|
+
// safety ceilings even when a particular server is configured more loosely.
|
|
20
|
+
const MAX_PENDING_RPCS = 32;
|
|
21
|
+
const MAX_SESSION_SUBSCRIPTIONS = 16;
|
|
22
|
+
const MAX_PENDING_OUTBOUND_FRAMES = 32;
|
|
23
|
+
const MAX_PENDING_OUTBOUND_BYTES = 1024 * 1024;
|
|
24
|
+
// Conservative upper bound for the fixed E2EE JSON fields, tag and sequence.
|
|
25
|
+
const MAX_E2EE_ENVELOPE_OVERHEAD_BYTES = 256;
|
|
26
|
+
const MAX_SUBSCRIPTION_QUEUED_EVENTS = 128;
|
|
27
|
+
const MAX_SUBSCRIPTION_QUEUED_BYTES = 256 * 1024;
|
|
28
|
+
const MAX_TERMINAL_QUEUED_OUTPUT_FRAMES = 128;
|
|
29
|
+
const MAX_TERMINAL_QUEUED_OUTPUT_BYTES = 512 * 1024;
|
|
30
|
+
const establishedTextDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
31
|
+
/** Enroll a client identity, authenticate the issued grant, and probe status. */
|
|
32
|
+
export async function pairDirectRuntime(untrustedOffer, options = {}) {
|
|
33
|
+
const offer = validatedOffer(untrustedOffer, options.allowExpiredOffer);
|
|
34
|
+
const identityInput = options.identity ?? generateDirectRuntimeClientIdentity();
|
|
35
|
+
const identity = parseDirectRuntimeClientIdentity(identityInput, "authentication_failed");
|
|
36
|
+
const timeoutMs = parseTimeout(options.timeoutMs);
|
|
37
|
+
const liveness = parseClientLiveness(options.liveness);
|
|
38
|
+
const authenticated = await authenticate({
|
|
39
|
+
mode: "enroll",
|
|
40
|
+
daemonId: offer.daemonId,
|
|
41
|
+
identityPublicKey: offer.identityPublicKey,
|
|
42
|
+
serverEncryptionPublicKey: offer.serverEncryptionPublicKey,
|
|
43
|
+
directEndpoint: offer.directEndpoint,
|
|
44
|
+
claimId: offer.claimId,
|
|
45
|
+
claimSecret: offer.claimSecret,
|
|
46
|
+
identity: identity.value,
|
|
47
|
+
privateKey: identity.privateKey,
|
|
48
|
+
timeoutMs,
|
|
49
|
+
liveness,
|
|
50
|
+
});
|
|
51
|
+
const connection = new DirectRuntimeConnectionImpl(authenticated, timeoutMs);
|
|
52
|
+
const credential = {
|
|
53
|
+
version: 2,
|
|
54
|
+
daemonId: offer.daemonId,
|
|
55
|
+
identityPublicKey: offer.identityPublicKey,
|
|
56
|
+
serverEncryptionPublicKey: offer.serverEncryptionPublicKey,
|
|
57
|
+
directEndpoint: offer.directEndpoint,
|
|
58
|
+
...identity.value,
|
|
59
|
+
grantId: authenticated.accepted.grantId,
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
const status = await connection.status();
|
|
63
|
+
if (status.daemonId !== offer.daemonId) {
|
|
64
|
+
throw clientError("identity_mismatch", "authenticated status belongs to a different daemon");
|
|
65
|
+
}
|
|
66
|
+
return { credential, status };
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await connection.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Connect once with a persisted grant. No retry or local fallback is attempted. */
|
|
73
|
+
export async function connectDirectRuntime(untrustedCredential, options = {}) {
|
|
74
|
+
const credential = parseDirectRuntimeCredential(untrustedCredential);
|
|
75
|
+
const identity = parseDirectRuntimeClientIdentity(credential, "authentication_failed");
|
|
76
|
+
const timeoutMs = parseTimeout(options.timeoutMs);
|
|
77
|
+
const liveness = parseClientLiveness(options.liveness);
|
|
78
|
+
const authenticated = await authenticate({
|
|
79
|
+
mode: "connect",
|
|
80
|
+
daemonId: credential.daemonId,
|
|
81
|
+
identityPublicKey: credential.identityPublicKey,
|
|
82
|
+
serverEncryptionPublicKey: credential.serverEncryptionPublicKey,
|
|
83
|
+
directEndpoint: credential.directEndpoint,
|
|
84
|
+
grantId: credential.grantId,
|
|
85
|
+
identity: identity.value,
|
|
86
|
+
privateKey: identity.privateKey,
|
|
87
|
+
timeoutMs,
|
|
88
|
+
liveness,
|
|
89
|
+
});
|
|
90
|
+
return new DirectRuntimeConnectionImpl(authenticated, timeoutMs);
|
|
91
|
+
}
|
|
92
|
+
async function authenticate(input) {
|
|
93
|
+
const socket = new DirectSocket(input.directEndpoint, input.timeoutMs, input.liveness);
|
|
94
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
95
|
+
try {
|
|
96
|
+
await socket.open(remaining(deadline));
|
|
97
|
+
const keyExchange = generateClientKeyExchange(input.serverEncryptionPublicKey);
|
|
98
|
+
const channelBinding = keyExchange.channelBinding;
|
|
99
|
+
await socket.startE2EE(keyExchange.channel, encodeDirectRuntimeFrame(keyExchange.clientHello));
|
|
100
|
+
const ready = parseHandshakeFrame(await socket.readFrame(DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES, remaining(deadline)), "e2ee.ready");
|
|
101
|
+
if (ready.type !== "e2ee.ready") {
|
|
102
|
+
throw clientError("protocol_error", "e2ee.ready must follow e2ee.client-hello");
|
|
103
|
+
}
|
|
104
|
+
const hello = parseHandshakeFrame(await socket.readFrame(DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES, remaining(deadline)), "server.hello");
|
|
105
|
+
if (hello.type !== "server.hello") {
|
|
106
|
+
throw clientError("protocol_error", "server.hello must be the first encrypted Direct frame");
|
|
107
|
+
}
|
|
108
|
+
assertHelloIdentity(hello, input);
|
|
109
|
+
const selectedBindingVersion = selectBindingProtocol(hello);
|
|
110
|
+
const selectedCoreVersion = selectCoreProtocol(hello);
|
|
111
|
+
const helloFields = withoutHelloSignature(hello);
|
|
112
|
+
const daemonKey = publicKey(input.identityPublicKey, "identity_mismatch");
|
|
113
|
+
if (!verifyPayload(null, encodeServerHelloProofPayload(channelBinding, helloFields), daemonKey, Buffer.from(hello.signature, "base64url"))) {
|
|
114
|
+
throw clientError("authentication_failed", "server.hello signature is invalid");
|
|
115
|
+
}
|
|
116
|
+
const authenticateFields = clientAuthenticateFields(input, hello, selectedBindingVersion, selectedCoreVersion);
|
|
117
|
+
const helloHash = sha256(encodeServerHelloCanonical(helloFields));
|
|
118
|
+
const authenticate = {
|
|
119
|
+
...authenticateFields,
|
|
120
|
+
signature: signPayload(null, encodeClientAuthenticateProofPayload(channelBinding, helloHash, authenticateFields), input.privateKey).toString("base64url"),
|
|
121
|
+
};
|
|
122
|
+
const authenticateHash = sha256(encodeClientAuthenticateCanonical(authenticateFields));
|
|
123
|
+
await socket.sendText(encodeDirectRuntimeFrame(authenticate));
|
|
124
|
+
const accepted = parseHandshakeFrame(await socket.readFrame(DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES, remaining(deadline)), "server.accepted");
|
|
125
|
+
if (accepted.type !== "server.accepted") {
|
|
126
|
+
throw clientError("protocol_error", "server.accepted must follow client.authenticate");
|
|
127
|
+
}
|
|
128
|
+
assertAcceptedConsistency(accepted, hello, authenticate, input);
|
|
129
|
+
const acceptedFields = withoutAcceptedSignature(accepted);
|
|
130
|
+
if (!verifyPayload(null, encodeServerAcceptedProofPayload(channelBinding, helloHash, authenticateHash, acceptedFields), daemonKey, Buffer.from(accepted.signature, "base64url"))) {
|
|
131
|
+
throw clientError("authentication_failed", "server.accepted signature is invalid");
|
|
132
|
+
}
|
|
133
|
+
socket.markAuthenticated();
|
|
134
|
+
return { socket, hello, accepted };
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
const normalized = normalizeHandshakeError(error);
|
|
138
|
+
socket.abort(normalized);
|
|
139
|
+
throw normalized;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
class DirectRuntimeConnectionImpl {
|
|
143
|
+
channel;
|
|
144
|
+
timeoutMs;
|
|
145
|
+
pending = new Map();
|
|
146
|
+
pendingSubscriptions = new Map();
|
|
147
|
+
subscriptions = new Map();
|
|
148
|
+
subscriptionCancelTimers = new Map();
|
|
149
|
+
pendingTerminal;
|
|
150
|
+
terminal;
|
|
151
|
+
terminalCloseTimer;
|
|
152
|
+
pendingEmulatorSurface;
|
|
153
|
+
emulatorSurface;
|
|
154
|
+
statusSnapshot;
|
|
155
|
+
statusProbe;
|
|
156
|
+
closed = false;
|
|
157
|
+
constructor(channel, timeoutMs) {
|
|
158
|
+
this.channel = channel;
|
|
159
|
+
this.timeoutMs = timeoutMs;
|
|
160
|
+
channel.socket.activate((frame) => this.receive(frame), (error) => this.fail(error));
|
|
161
|
+
}
|
|
162
|
+
status() {
|
|
163
|
+
return this.call("status.get", {});
|
|
164
|
+
}
|
|
165
|
+
async call(method, params, options = {}) {
|
|
166
|
+
if (method !== "status.get") {
|
|
167
|
+
let status;
|
|
168
|
+
try {
|
|
169
|
+
status = await this.capabilityStatus(options);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
throw notStartedClientError(error, `Remote Runtime ${method} capability preflight failed`);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
assertRemoteRuntimeRpcMethodSupported(method, this.channel.accepted.coreProtocolVersion, status.semanticCapabilities);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
throw new DirectRuntimeClientError("incompatible", error instanceof Error ? error.message : `Remote Runtime method ${method} is unavailable`, { cause: error, outcome: "not_started" });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return await this.sendCall(method, params, options);
|
|
182
|
+
}
|
|
183
|
+
async sendCall(method, params, options = {}) {
|
|
184
|
+
if (this.closed) {
|
|
185
|
+
throw new DirectRuntimeClientError("closed", "Direct Runtime connection is closed", {
|
|
186
|
+
outcome: "not_started",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (params === undefined && method !== "status.get") {
|
|
190
|
+
throw new DirectRuntimeClientError("protocol_error", `${method} requires an explicit params object`, { outcome: "not_started" });
|
|
191
|
+
}
|
|
192
|
+
if (this.pending.size >= MAX_PENDING_RPCS) {
|
|
193
|
+
throw new DirectRuntimeClientCapacityError("pending_rpc", MAX_PENDING_RPCS, `Direct Runtime client admits at most ${MAX_PENDING_RPCS} pending RPCs per connection`);
|
|
194
|
+
}
|
|
195
|
+
const id = `rpc_${randomBytes(18).toString("base64url")}`;
|
|
196
|
+
let frame;
|
|
197
|
+
try {
|
|
198
|
+
frame = encodeRemoteRuntimeRpcRequest({
|
|
199
|
+
type: "rpc.request",
|
|
200
|
+
id,
|
|
201
|
+
method,
|
|
202
|
+
params: params ?? {},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
throw new DirectRuntimeClientError("protocol_error", `invalid ${method} RPC params`, {
|
|
207
|
+
cause: error,
|
|
208
|
+
outcome: "not_started",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const timeoutMs = parseTimeout(options.timeoutMs ?? this.timeoutMs);
|
|
212
|
+
return await new Promise((resolve, reject) => {
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
this.pending.delete(id);
|
|
215
|
+
reject(callTransportError(clientError("deadline_exceeded", "Direct Runtime RPC timed out"), method, id));
|
|
216
|
+
}, timeoutMs);
|
|
217
|
+
timer.unref?.();
|
|
218
|
+
this.pending.set(id, { method, resolve, reject, timer });
|
|
219
|
+
void this.channel.socket.sendText(frame).catch((error) => {
|
|
220
|
+
const pending = this.pending.get(id);
|
|
221
|
+
if (!pending)
|
|
222
|
+
return;
|
|
223
|
+
this.pending.delete(id);
|
|
224
|
+
clearTimeout(pending.timer);
|
|
225
|
+
pending.reject(error instanceof DirectRuntimeClientCapacityError
|
|
226
|
+
? error
|
|
227
|
+
: callTransportError(error, pending.method, id));
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
capabilityStatus(options = {}) {
|
|
232
|
+
if (this.statusSnapshot)
|
|
233
|
+
return Promise.resolve(this.statusSnapshot);
|
|
234
|
+
if (!this.statusProbe) {
|
|
235
|
+
this.statusProbe = this.sendCall("status.get", {}, options).finally(() => {
|
|
236
|
+
this.statusProbe = undefined;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return this.statusProbe;
|
|
240
|
+
}
|
|
241
|
+
async subscribeSessionEvents(sessionId, options = {}) {
|
|
242
|
+
if (this.closed) {
|
|
243
|
+
throw new DirectRuntimeClientError("closed", "Direct Runtime connection is closed", {
|
|
244
|
+
outcome: "not_started",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
let status;
|
|
248
|
+
try {
|
|
249
|
+
status = await this.capabilityStatus(options);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
throw notStartedClientError(error, "Remote Runtime Session events capability preflight failed");
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
assertRemoteRuntimeSessionEventsSupported(this.channel.accepted.coreProtocolVersion, status.semanticCapabilities);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
throw new DirectRuntimeClientError("incompatible", error instanceof Error ? error.message : "Remote Runtime Session events are unavailable", { cause: error, outcome: "not_started" });
|
|
259
|
+
}
|
|
260
|
+
if (this.pendingSubscriptions.size + this.subscriptions.size >=
|
|
261
|
+
MAX_SESSION_SUBSCRIPTIONS) {
|
|
262
|
+
throw new DirectRuntimeClientCapacityError("session_subscription", MAX_SESSION_SUBSCRIPTIONS, `Direct Runtime client admits at most ${MAX_SESSION_SUBSCRIPTIONS} opening or active Session subscriptions per connection`);
|
|
263
|
+
}
|
|
264
|
+
const subscriptionId = `sub_${randomBytes(18).toString("base64url")}`;
|
|
265
|
+
let frame;
|
|
266
|
+
try {
|
|
267
|
+
frame = encodeRemoteRuntimeSessionEventsFrame({
|
|
268
|
+
type: "session.events.subscribe",
|
|
269
|
+
subscriptionId,
|
|
270
|
+
sessionId,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
throw new DirectRuntimeClientError("protocol_error", "invalid Remote Session subscription", {
|
|
275
|
+
cause: error,
|
|
276
|
+
outcome: "not_started",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const timeoutMs = parseTimeout(options.timeoutMs ?? this.timeoutMs);
|
|
280
|
+
return await new Promise((resolve, reject) => {
|
|
281
|
+
const timer = setTimeout(() => {
|
|
282
|
+
const pending = this.pendingSubscriptions.get(subscriptionId);
|
|
283
|
+
if (!pending)
|
|
284
|
+
return;
|
|
285
|
+
this.pendingSubscriptions.delete(subscriptionId);
|
|
286
|
+
const timeout = clientError("deadline_exceeded", "Remote Session subscription acceptance timed out");
|
|
287
|
+
pending.reject(timeout);
|
|
288
|
+
this.fail(timeout);
|
|
289
|
+
}, timeoutMs);
|
|
290
|
+
timer.unref?.();
|
|
291
|
+
this.pendingSubscriptions.set(subscriptionId, {
|
|
292
|
+
sessionId,
|
|
293
|
+
timeoutMs,
|
|
294
|
+
resolve,
|
|
295
|
+
reject,
|
|
296
|
+
timer,
|
|
297
|
+
});
|
|
298
|
+
void this.channel.socket.sendText(frame).catch((error) => {
|
|
299
|
+
if (error instanceof DirectRuntimeClientCapacityError) {
|
|
300
|
+
const pending = this.pendingSubscriptions.get(subscriptionId);
|
|
301
|
+
if (!pending)
|
|
302
|
+
return;
|
|
303
|
+
this.pendingSubscriptions.delete(subscriptionId);
|
|
304
|
+
clearTimeout(pending.timer);
|
|
305
|
+
pending.reject(error);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this.fail(error);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async openSessionTerminal(sessionId, options) {
|
|
313
|
+
if (this.closed) {
|
|
314
|
+
throw new DirectRuntimeClientError("closed", "Direct Runtime connection is closed", {
|
|
315
|
+
outcome: "not_started",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (this.pendingTerminal || this.terminal) {
|
|
319
|
+
throw new DirectRuntimeClientCapacityError("session_terminal", 1, "Direct Runtime V1 admits one Session terminal per connection");
|
|
320
|
+
}
|
|
321
|
+
let status;
|
|
322
|
+
try {
|
|
323
|
+
status = await this.capabilityStatus(options);
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
throw notStartedClientError(error, "Remote Runtime Session Terminal capability preflight failed");
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
assertRemoteRuntimeSessionTerminalSupported(this.channel.accepted.coreProtocolVersion, status.semanticCapabilities);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
throw new DirectRuntimeClientError("incompatible", error instanceof Error ? error.message : "Remote Runtime Session Terminal is unavailable", { cause: error, outcome: "not_started" });
|
|
333
|
+
}
|
|
334
|
+
const attachmentId = `term_${randomBytes(18).toString("base64url")}`;
|
|
335
|
+
let frame;
|
|
336
|
+
try {
|
|
337
|
+
frame = encodeRemoteRuntimeSessionTerminalClientFrame({
|
|
338
|
+
type: "session.terminal.open",
|
|
339
|
+
attachmentId,
|
|
340
|
+
sessionId,
|
|
341
|
+
role: options.role,
|
|
342
|
+
cols: options.cols,
|
|
343
|
+
rows: options.rows,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
throw new DirectRuntimeClientError("protocol_error", "invalid Remote Session terminal open", {
|
|
348
|
+
cause: error,
|
|
349
|
+
outcome: "not_started",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const timeoutMs = parseTimeout(options.timeoutMs ?? this.timeoutMs);
|
|
353
|
+
return await new Promise((resolve, reject) => {
|
|
354
|
+
const timer = setTimeout(() => {
|
|
355
|
+
if (this.pendingTerminal?.attachmentId !== attachmentId)
|
|
356
|
+
return;
|
|
357
|
+
this.pendingTerminal = undefined;
|
|
358
|
+
const timeout = clientError("deadline_exceeded", "Remote Session terminal acceptance timed out");
|
|
359
|
+
reject(timeout);
|
|
360
|
+
this.fail(timeout);
|
|
361
|
+
}, timeoutMs);
|
|
362
|
+
timer.unref?.();
|
|
363
|
+
this.pendingTerminal = {
|
|
364
|
+
attachmentId,
|
|
365
|
+
sessionId,
|
|
366
|
+
timeoutMs,
|
|
367
|
+
resolve,
|
|
368
|
+
reject,
|
|
369
|
+
timer,
|
|
370
|
+
};
|
|
371
|
+
void this.channel.socket.sendText(frame).catch((error) => {
|
|
372
|
+
const pending = this.pendingTerminal;
|
|
373
|
+
if (pending?.attachmentId !== attachmentId)
|
|
374
|
+
return;
|
|
375
|
+
this.pendingTerminal = undefined;
|
|
376
|
+
clearTimeout(pending.timer);
|
|
377
|
+
pending.reject(error);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
async openEmulatorSurface(sessionId, bindingId, options = {}) {
|
|
382
|
+
if (this.closed)
|
|
383
|
+
throw clientError("closed", "Direct Runtime connection is closed");
|
|
384
|
+
if (this.pendingEmulatorSurface || this.emulatorSurface) {
|
|
385
|
+
throw new DirectRuntimeClientCapacityError("emulator_surface", 1, "Direct Runtime client admits one Emulator Surface per connection");
|
|
386
|
+
}
|
|
387
|
+
const status = await this.capabilityStatus({ timeoutMs: options.timeoutMs });
|
|
388
|
+
if (!status.semanticCapabilities.includes(RUNTIME_EMULATOR_SURFACE_SEMANTIC_CAPABILITY)) {
|
|
389
|
+
throw new DirectRuntimeClientError("incompatible", `Remote Runtime Emulator Surface requires semantic capability ${RUNTIME_EMULATOR_SURFACE_SEMANTIC_CAPABILITY}`, { outcome: "not_started" });
|
|
390
|
+
}
|
|
391
|
+
// capabilityStatus is asynchronous. Recheck the one-slot admission after
|
|
392
|
+
// it resolves so two concurrent callers cannot both pass the preflight and
|
|
393
|
+
// overwrite the single pending Surface (which would orphan one Promise).
|
|
394
|
+
if (this.pendingEmulatorSurface || this.emulatorSurface) {
|
|
395
|
+
throw new DirectRuntimeClientCapacityError("emulator_surface", 1, "Direct Runtime client admits one Emulator Surface per connection");
|
|
396
|
+
}
|
|
397
|
+
const surfaceId = `emu_${randomBytes(18).toString("base64url")}`;
|
|
398
|
+
const maximumFrameRate = options.maximumFrameRate ?? 3;
|
|
399
|
+
let encoded;
|
|
400
|
+
try {
|
|
401
|
+
encoded = encodeRuntimeEmulatorSurfaceClientFrame({
|
|
402
|
+
type: "emulator.surface.open",
|
|
403
|
+
surfaceId,
|
|
404
|
+
sessionId,
|
|
405
|
+
bindingId,
|
|
406
|
+
maximumFrameRate,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
throw clientError("protocol_error", "invalid Emulator Surface open request", error);
|
|
411
|
+
}
|
|
412
|
+
const timeoutMs = parseTimeout(options.timeoutMs ?? this.timeoutMs);
|
|
413
|
+
return await new Promise((resolve, reject) => {
|
|
414
|
+
const timer = setTimeout(() => {
|
|
415
|
+
if (this.pendingEmulatorSurface?.surfaceId !== surfaceId)
|
|
416
|
+
return;
|
|
417
|
+
this.pendingEmulatorSurface = undefined;
|
|
418
|
+
const error = clientError("deadline_exceeded", "Emulator Surface open timed out");
|
|
419
|
+
reject(error);
|
|
420
|
+
this.fail(error);
|
|
421
|
+
}, timeoutMs);
|
|
422
|
+
timer.unref?.();
|
|
423
|
+
this.pendingEmulatorSurface = {
|
|
424
|
+
surfaceId,
|
|
425
|
+
sessionId,
|
|
426
|
+
bindingId,
|
|
427
|
+
timeoutMs,
|
|
428
|
+
timer,
|
|
429
|
+
resolve,
|
|
430
|
+
reject,
|
|
431
|
+
};
|
|
432
|
+
void this.channel.socket.sendText(encoded).catch((error) => {
|
|
433
|
+
const pending = this.pendingEmulatorSurface;
|
|
434
|
+
if (pending?.surfaceId !== surfaceId)
|
|
435
|
+
return;
|
|
436
|
+
this.pendingEmulatorSurface = undefined;
|
|
437
|
+
clearTimeout(pending.timer);
|
|
438
|
+
pending.reject(error);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
async close() {
|
|
443
|
+
if (this.closed)
|
|
444
|
+
return;
|
|
445
|
+
this.closed = true;
|
|
446
|
+
const closed = clientError("closed", "Direct Runtime connection was closed");
|
|
447
|
+
this.rejectPending(closed);
|
|
448
|
+
this.terminateSubscriptions(closed);
|
|
449
|
+
this.terminateTerminal(closed);
|
|
450
|
+
this.terminateEmulatorSurface(closed);
|
|
451
|
+
await this.channel.socket.close();
|
|
452
|
+
}
|
|
453
|
+
receive(frame) {
|
|
454
|
+
let value;
|
|
455
|
+
try {
|
|
456
|
+
value = decodeEstablishedFrame(frame);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
this.fail(clientError("protocol_error", "invalid Remote Runtime frame", error));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
switch (value.type) {
|
|
463
|
+
case "rpc.result":
|
|
464
|
+
case "rpc.error":
|
|
465
|
+
this.receiveRpc(value);
|
|
466
|
+
return;
|
|
467
|
+
case "session.events.accepted":
|
|
468
|
+
case "session.events.event":
|
|
469
|
+
case "session.events.closed":
|
|
470
|
+
this.receiveSessionFrame(value, frame.byteLength);
|
|
471
|
+
return;
|
|
472
|
+
case "session.terminal.opened":
|
|
473
|
+
case "session.terminal.output":
|
|
474
|
+
case "session.terminal.closed":
|
|
475
|
+
this.receiveTerminalFrame(value);
|
|
476
|
+
return;
|
|
477
|
+
case "emulator.surface.ready":
|
|
478
|
+
case "emulator.surface.frame.chunk":
|
|
479
|
+
case "emulator.surface.closed":
|
|
480
|
+
this.receiveEmulatorSurfaceFrame(value);
|
|
481
|
+
return;
|
|
482
|
+
default:
|
|
483
|
+
this.fail(clientError("protocol_error", "unknown Remote Runtime frame type"));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
receiveRpc(value) {
|
|
487
|
+
let envelope;
|
|
488
|
+
try {
|
|
489
|
+
envelope = parseRemoteRuntimeRpcResponseEnvelope(value);
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
this.fail(clientError("protocol_error", "invalid Remote Runtime RPC response", error));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const pending = this.pending.get(envelope.id);
|
|
496
|
+
if (!pending) {
|
|
497
|
+
this.fail(clientError("protocol_error", "RPC response has no pending request"));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
this.pending.delete(envelope.id);
|
|
501
|
+
clearTimeout(pending.timer);
|
|
502
|
+
let response;
|
|
503
|
+
try {
|
|
504
|
+
response = parseRemoteRuntimeRpcResponseForMethod(envelope, pending.method);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
const invalid = clientError("protocol_error", `invalid ${pending.method} Remote Runtime RPC result`, error);
|
|
508
|
+
pending.reject(callTransportError(invalid, pending.method, envelope.id));
|
|
509
|
+
this.fail(invalid);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (response.type === "rpc.error") {
|
|
513
|
+
const error = new DirectRuntimeRpcError(response.error.code, pending.method, response.id, `Remote Runtime RPC failed: ${response.error.code}: ${response.error.message}`);
|
|
514
|
+
pending.reject(error);
|
|
515
|
+
if (response.error.code === "unauthorized") {
|
|
516
|
+
this.fail(clientError("authentication_failed", "Remote Runtime revoked authorization for the active connection", error));
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
if (pending.method === "status.get") {
|
|
522
|
+
const status = response.result;
|
|
523
|
+
this.assertStatusConsistency(status);
|
|
524
|
+
this.statusSnapshot = {
|
|
525
|
+
...status,
|
|
526
|
+
semanticCapabilities: [...status.semanticCapabilities],
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
pending.resolve(response.result);
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
pending.reject(error);
|
|
533
|
+
this.fail(error);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
receiveSessionFrame(value, frameBytes) {
|
|
537
|
+
let frame;
|
|
538
|
+
try {
|
|
539
|
+
const parsed = parseRemoteRuntimeSessionEventsFrame(value);
|
|
540
|
+
if (parsed.type === "session.events.subscribe" || parsed.type === "session.events.cancel") {
|
|
541
|
+
throw new Error("server sent a client-only Session events frame");
|
|
542
|
+
}
|
|
543
|
+
frame = parsed;
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
this.fail(clientError("protocol_error", "invalid Remote Session events frame", error));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
switch (frame.type) {
|
|
550
|
+
case "session.events.accepted":
|
|
551
|
+
this.acceptSubscription(frame);
|
|
552
|
+
return;
|
|
553
|
+
case "session.events.event": {
|
|
554
|
+
const subscription = this.subscriptions.get(frame.subscriptionId);
|
|
555
|
+
if (!subscription) {
|
|
556
|
+
this.fail(clientError("protocol_error", "Session event has no active subscription"));
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
subscription.push(frame.event, frameBytes);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
case "session.events.closed":
|
|
563
|
+
this.closeSubscriptionFromRemote(frame);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
acceptSubscription(frame) {
|
|
567
|
+
const pending = this.pendingSubscriptions.get(frame.subscriptionId);
|
|
568
|
+
if (!pending) {
|
|
569
|
+
this.fail(clientError("protocol_error", "Session acceptance has no pending subscription"));
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (frame.sessionId !== pending.sessionId) {
|
|
573
|
+
this.fail(clientError("protocol_error", "Session acceptance changed the requested sessionId"));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (frame.daemonInstanceId !== this.channel.hello.daemonInstanceId) {
|
|
577
|
+
this.fail(clientError("protocol_error", "Session acceptance changed daemonInstanceId"));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
this.pendingSubscriptions.delete(frame.subscriptionId);
|
|
581
|
+
clearTimeout(pending.timer);
|
|
582
|
+
const subscription = new SessionEventsSubscriptionImpl(frame.subscriptionId, frame.sessionId, frame.daemonInstanceId, pending.timeoutMs, () => this.cancelSubscription(frame.subscriptionId), () => {
|
|
583
|
+
void this.cancelSubscription(frame.subscriptionId, true).catch(() => undefined);
|
|
584
|
+
});
|
|
585
|
+
this.subscriptions.set(frame.subscriptionId, subscription);
|
|
586
|
+
pending.resolve(subscription);
|
|
587
|
+
}
|
|
588
|
+
closeSubscriptionFromRemote(frame) {
|
|
589
|
+
const pending = this.pendingSubscriptions.get(frame.subscriptionId);
|
|
590
|
+
if (pending) {
|
|
591
|
+
this.pendingSubscriptions.delete(frame.subscriptionId);
|
|
592
|
+
clearTimeout(pending.timer);
|
|
593
|
+
pending.reject(sessionEventsError(frame, pending.sessionId));
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const subscription = this.subscriptions.get(frame.subscriptionId);
|
|
597
|
+
if (!subscription) {
|
|
598
|
+
this.fail(clientError("protocol_error", "Session closure has no active subscription"));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
this.subscriptions.delete(frame.subscriptionId);
|
|
602
|
+
const timer = this.subscriptionCancelTimers.get(frame.subscriptionId);
|
|
603
|
+
if (timer)
|
|
604
|
+
clearTimeout(timer);
|
|
605
|
+
this.subscriptionCancelTimers.delete(frame.subscriptionId);
|
|
606
|
+
subscription.remoteClose(frame);
|
|
607
|
+
}
|
|
608
|
+
receiveTerminalFrame(value) {
|
|
609
|
+
let frame;
|
|
610
|
+
try {
|
|
611
|
+
frame = parseRemoteRuntimeSessionTerminalServerFrame(value);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
this.fail(clientError("protocol_error", "invalid Remote Session terminal frame", error));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
switch (frame.type) {
|
|
618
|
+
case "session.terminal.opened":
|
|
619
|
+
this.acceptTerminal(frame);
|
|
620
|
+
return;
|
|
621
|
+
case "session.terminal.output": {
|
|
622
|
+
const terminal = this.terminal;
|
|
623
|
+
if (!terminal || terminal.attachmentId !== frame.attachmentId) {
|
|
624
|
+
this.fail(clientError("protocol_error", "Terminal output has no active attachment"));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
terminal.push(Uint8Array.from(Buffer.from(frame.data, "base64")));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
case "session.terminal.closed":
|
|
631
|
+
this.closeTerminalFromRemote(frame);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
acceptTerminal(frame) {
|
|
635
|
+
const pending = this.pendingTerminal;
|
|
636
|
+
if (!pending || pending.attachmentId !== frame.attachmentId) {
|
|
637
|
+
this.fail(clientError("protocol_error", "Terminal acceptance has no pending attachment"));
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (frame.sessionId !== pending.sessionId) {
|
|
641
|
+
this.fail(clientError("protocol_error", "Terminal acceptance changed the requested sessionId"));
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
this.pendingTerminal = undefined;
|
|
645
|
+
clearTimeout(pending.timer);
|
|
646
|
+
const terminal = new SessionTerminalImpl(frame, pending.timeoutMs, (data) => this.writeTerminal(frame.attachmentId, data), (cols, rows) => this.resizeTerminal(frame.attachmentId, cols, rows), () => this.closeTerminal(frame.attachmentId), (error) => this.fail(error));
|
|
647
|
+
this.terminal = terminal;
|
|
648
|
+
pending.resolve(terminal);
|
|
649
|
+
}
|
|
650
|
+
closeTerminalFromRemote(frame) {
|
|
651
|
+
const pending = this.pendingTerminal;
|
|
652
|
+
if (pending?.attachmentId === frame.attachmentId) {
|
|
653
|
+
this.pendingTerminal = undefined;
|
|
654
|
+
clearTimeout(pending.timer);
|
|
655
|
+
pending.reject(sessionTerminalError(frame, pending.sessionId));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const terminal = this.terminal;
|
|
659
|
+
if (!terminal || terminal.attachmentId !== frame.attachmentId) {
|
|
660
|
+
this.fail(clientError("protocol_error", "Terminal closure has no active attachment"));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
this.terminal = undefined;
|
|
664
|
+
if (this.terminalCloseTimer)
|
|
665
|
+
clearTimeout(this.terminalCloseTimer);
|
|
666
|
+
this.terminalCloseTimer = undefined;
|
|
667
|
+
terminal.remoteClose(frame);
|
|
668
|
+
}
|
|
669
|
+
receiveEmulatorSurfaceFrame(value) {
|
|
670
|
+
let frame;
|
|
671
|
+
try {
|
|
672
|
+
frame = parseRuntimeEmulatorSurfaceServerFrame(value);
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
this.fail(clientError("protocol_error", "invalid Emulator Surface frame", error));
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (frame.type === "emulator.surface.ready") {
|
|
679
|
+
const pending = this.pendingEmulatorSurface;
|
|
680
|
+
if (!pending || pending.surfaceId !== frame.surfaceId) {
|
|
681
|
+
this.fail(clientError("protocol_error", "Emulator Surface ready has no pending open"));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (pending.sessionId !== frame.sessionId || pending.bindingId !== frame.bindingId) {
|
|
685
|
+
this.fail(clientError("protocol_error", "Emulator Surface ready changed its identity"));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
this.pendingEmulatorSurface = undefined;
|
|
689
|
+
clearTimeout(pending.timer);
|
|
690
|
+
const surface = new EmulatorSurfaceImpl(frame, (sequence) => this.ackEmulatorSurface(frame.surfaceId, sequence), (inputSequence, point) => this.sendEmulatorSurfaceTouch(frame.surfaceId, inputSequence, point), () => this.closeEmulatorSurface(frame.surfaceId), (error) => this.fail(error));
|
|
691
|
+
this.emulatorSurface = surface;
|
|
692
|
+
pending.resolve(surface);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (frame.type === "emulator.surface.frame.chunk") {
|
|
696
|
+
const surface = this.emulatorSurface;
|
|
697
|
+
if (!surface || surface.surfaceId !== frame.surfaceId) {
|
|
698
|
+
this.fail(clientError("protocol_error", "Emulator Surface chunk has no active Surface"));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
surface.push(frame);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const pending = this.pendingEmulatorSurface;
|
|
705
|
+
if (pending?.surfaceId === frame.surfaceId) {
|
|
706
|
+
this.pendingEmulatorSurface = undefined;
|
|
707
|
+
clearTimeout(pending.timer);
|
|
708
|
+
pending.reject(emulatorSurfaceError(frame));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const surface = this.emulatorSurface;
|
|
712
|
+
if (!surface || surface.surfaceId !== frame.surfaceId) {
|
|
713
|
+
this.fail(clientError("protocol_error", "Emulator Surface close has no active Surface"));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
this.emulatorSurface = undefined;
|
|
717
|
+
surface.remoteClose(frame);
|
|
718
|
+
}
|
|
719
|
+
async ackEmulatorSurface(surfaceId, frameSequence) {
|
|
720
|
+
if (this.emulatorSurface?.surfaceId !== surfaceId)
|
|
721
|
+
return;
|
|
722
|
+
await this.channel.socket.sendText(encodeRuntimeEmulatorSurfaceClientFrame({
|
|
723
|
+
type: "emulator.surface.frame.ack",
|
|
724
|
+
surfaceId,
|
|
725
|
+
frameSequence,
|
|
726
|
+
}));
|
|
727
|
+
}
|
|
728
|
+
async closeEmulatorSurface(surfaceId) {
|
|
729
|
+
const surface = this.emulatorSurface;
|
|
730
|
+
if (!surface || surface.surfaceId !== surfaceId)
|
|
731
|
+
return;
|
|
732
|
+
await this.channel.socket.sendText(encodeRuntimeEmulatorSurfaceClientFrame({
|
|
733
|
+
type: "emulator.surface.close",
|
|
734
|
+
surfaceId,
|
|
735
|
+
}));
|
|
736
|
+
await surface.closed;
|
|
737
|
+
}
|
|
738
|
+
async sendEmulatorSurfaceTouch(surfaceId, inputSequence, point) {
|
|
739
|
+
if (this.emulatorSurface?.surfaceId !== surfaceId) {
|
|
740
|
+
throw clientError("closed", "Emulator Surface is closed");
|
|
741
|
+
}
|
|
742
|
+
await this.channel.socket.sendText(encodeRuntimeEmulatorSurfaceClientFrame({
|
|
743
|
+
type: "emulator.surface.touch",
|
|
744
|
+
surfaceId,
|
|
745
|
+
inputSequence,
|
|
746
|
+
...point,
|
|
747
|
+
}));
|
|
748
|
+
}
|
|
749
|
+
async writeTerminal(attachmentId, data) {
|
|
750
|
+
if (data.byteLength === 0)
|
|
751
|
+
return;
|
|
752
|
+
for (let offset = 0; offset < data.byteLength; offset += REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES) {
|
|
753
|
+
const chunk = data.subarray(offset, Math.min(offset + REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES, data.byteLength));
|
|
754
|
+
const frame = encodeRemoteRuntimeSessionTerminalClientFrame({
|
|
755
|
+
type: "session.terminal.input",
|
|
756
|
+
attachmentId,
|
|
757
|
+
data: Buffer.from(chunk).toString("base64"),
|
|
758
|
+
});
|
|
759
|
+
await this.channel.socket.sendText(frame);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async resizeTerminal(attachmentId, cols, rows) {
|
|
763
|
+
const frame = encodeRemoteRuntimeSessionTerminalClientFrame({
|
|
764
|
+
type: "session.terminal.resize",
|
|
765
|
+
attachmentId,
|
|
766
|
+
cols,
|
|
767
|
+
rows,
|
|
768
|
+
});
|
|
769
|
+
await this.channel.socket.sendText(frame);
|
|
770
|
+
}
|
|
771
|
+
async closeTerminal(attachmentId) {
|
|
772
|
+
const terminal = this.terminal;
|
|
773
|
+
if (!terminal || terminal.attachmentId !== attachmentId)
|
|
774
|
+
return;
|
|
775
|
+
if (terminal.beginClose()) {
|
|
776
|
+
const frame = encodeRemoteRuntimeSessionTerminalClientFrame({
|
|
777
|
+
type: "session.terminal.close",
|
|
778
|
+
attachmentId,
|
|
779
|
+
});
|
|
780
|
+
this.terminalCloseTimer = setTimeout(() => {
|
|
781
|
+
if (this.terminal?.attachmentId !== attachmentId)
|
|
782
|
+
return;
|
|
783
|
+
this.fail(clientError("deadline_exceeded", "Remote Session terminal close acknowledgement timed out"));
|
|
784
|
+
}, terminal.timeoutMs);
|
|
785
|
+
this.terminalCloseTimer.unref?.();
|
|
786
|
+
try {
|
|
787
|
+
await this.channel.socket.sendText(frame);
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
this.fail(error);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
await terminal.waitClosed();
|
|
794
|
+
}
|
|
795
|
+
async cancelSubscription(subscriptionId, force = false) {
|
|
796
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
797
|
+
if (!subscription)
|
|
798
|
+
return;
|
|
799
|
+
if (force || subscription.beginCancel()) {
|
|
800
|
+
let frame;
|
|
801
|
+
try {
|
|
802
|
+
frame = encodeRemoteRuntimeSessionEventsFrame({
|
|
803
|
+
type: "session.events.cancel",
|
|
804
|
+
subscriptionId,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
this.fail(clientError("protocol_error", "invalid Remote Session cancellation", error));
|
|
809
|
+
return await subscription.waitClosed();
|
|
810
|
+
}
|
|
811
|
+
const timer = setTimeout(() => {
|
|
812
|
+
if (!this.subscriptions.has(subscriptionId))
|
|
813
|
+
return;
|
|
814
|
+
this.fail(clientError("deadline_exceeded", "Remote Session cancellation acknowledgement timed out"));
|
|
815
|
+
}, subscription.timeoutMs);
|
|
816
|
+
timer.unref?.();
|
|
817
|
+
this.subscriptionCancelTimers.set(subscriptionId, timer);
|
|
818
|
+
void this.channel.socket.sendText(frame).catch((error) => this.fail(error));
|
|
819
|
+
}
|
|
820
|
+
await subscription.waitClosed();
|
|
821
|
+
}
|
|
822
|
+
assertStatusConsistency(status) {
|
|
823
|
+
const { hello, accepted } = this.channel;
|
|
824
|
+
if (status.daemonId !== hello.daemonId) {
|
|
825
|
+
throw clientError("identity_mismatch", "status daemonId differs from the pinned daemon");
|
|
826
|
+
}
|
|
827
|
+
if (status.daemonInstanceId !== hello.daemonInstanceId) {
|
|
828
|
+
throw clientError("protocol_error", "status daemonInstanceId differs from the handshake");
|
|
829
|
+
}
|
|
830
|
+
if (status.coreProtocolVersion !== hello.coreProtocolVersion ||
|
|
831
|
+
status.minimumCompatibleCoreVersion !== hello.minimumCompatibleCoreVersion ||
|
|
832
|
+
accepted.coreProtocolVersion < status.minimumCompatibleCoreVersion ||
|
|
833
|
+
accepted.coreProtocolVersion > status.coreProtocolVersion) {
|
|
834
|
+
throw clientError("protocol_error", "status Core protocol range differs from the handshake");
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
fail(error) {
|
|
838
|
+
if (this.closed)
|
|
839
|
+
return;
|
|
840
|
+
this.closed = true;
|
|
841
|
+
const normalized = normalizeEstablishedError(error);
|
|
842
|
+
this.rejectPending(normalized);
|
|
843
|
+
this.terminateSubscriptions(normalized);
|
|
844
|
+
this.terminateTerminal(normalized);
|
|
845
|
+
this.terminateEmulatorSurface(normalized);
|
|
846
|
+
this.channel.socket.abort(normalized);
|
|
847
|
+
}
|
|
848
|
+
rejectPending(error) {
|
|
849
|
+
for (const [id, pending] of this.pending) {
|
|
850
|
+
clearTimeout(pending.timer);
|
|
851
|
+
pending.reject(callTransportError(error, pending.method, id));
|
|
852
|
+
}
|
|
853
|
+
this.pending.clear();
|
|
854
|
+
}
|
|
855
|
+
terminateSubscriptions(error) {
|
|
856
|
+
for (const pending of this.pendingSubscriptions.values()) {
|
|
857
|
+
clearTimeout(pending.timer);
|
|
858
|
+
pending.reject(error);
|
|
859
|
+
}
|
|
860
|
+
this.pendingSubscriptions.clear();
|
|
861
|
+
for (const timer of this.subscriptionCancelTimers.values())
|
|
862
|
+
clearTimeout(timer);
|
|
863
|
+
this.subscriptionCancelTimers.clear();
|
|
864
|
+
for (const subscription of this.subscriptions.values())
|
|
865
|
+
subscription.terminate(error);
|
|
866
|
+
this.subscriptions.clear();
|
|
867
|
+
}
|
|
868
|
+
terminateTerminal(error) {
|
|
869
|
+
const pending = this.pendingTerminal;
|
|
870
|
+
this.pendingTerminal = undefined;
|
|
871
|
+
if (pending) {
|
|
872
|
+
clearTimeout(pending.timer);
|
|
873
|
+
pending.reject(error);
|
|
874
|
+
}
|
|
875
|
+
if (this.terminalCloseTimer)
|
|
876
|
+
clearTimeout(this.terminalCloseTimer);
|
|
877
|
+
this.terminalCloseTimer = undefined;
|
|
878
|
+
this.terminal?.terminate(error);
|
|
879
|
+
this.terminal = undefined;
|
|
880
|
+
}
|
|
881
|
+
terminateEmulatorSurface(error) {
|
|
882
|
+
const pending = this.pendingEmulatorSurface;
|
|
883
|
+
this.pendingEmulatorSurface = undefined;
|
|
884
|
+
if (pending) {
|
|
885
|
+
clearTimeout(pending.timer);
|
|
886
|
+
pending.reject(error);
|
|
887
|
+
}
|
|
888
|
+
this.emulatorSurface?.terminate(error);
|
|
889
|
+
this.emulatorSurface = undefined;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
class EmulatorSurfaceImpl {
|
|
893
|
+
ready;
|
|
894
|
+
acknowledge;
|
|
895
|
+
sendInput;
|
|
896
|
+
requestClose;
|
|
897
|
+
reportFatal;
|
|
898
|
+
assembling;
|
|
899
|
+
queued;
|
|
900
|
+
waiter;
|
|
901
|
+
ended = false;
|
|
902
|
+
terminalError;
|
|
903
|
+
closing = false;
|
|
904
|
+
inputSequence = 0;
|
|
905
|
+
inputTail = Promise.resolve();
|
|
906
|
+
closedPromise;
|
|
907
|
+
resolveClosed;
|
|
908
|
+
rejectClosed;
|
|
909
|
+
constructor(ready, acknowledge, sendInput, requestClose, reportFatal) {
|
|
910
|
+
this.ready = ready;
|
|
911
|
+
this.acknowledge = acknowledge;
|
|
912
|
+
this.sendInput = sendInput;
|
|
913
|
+
this.requestClose = requestClose;
|
|
914
|
+
this.reportFatal = reportFatal;
|
|
915
|
+
this.closedPromise = new Promise((resolve, reject) => {
|
|
916
|
+
this.resolveClosed = resolve;
|
|
917
|
+
this.rejectClosed = reject;
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
get surfaceId() { return this.ready.surfaceId; }
|
|
921
|
+
get sessionId() { return this.ready.sessionId; }
|
|
922
|
+
get bindingId() { return this.ready.bindingId; }
|
|
923
|
+
get closed() { return this.closedPromise; }
|
|
924
|
+
[Symbol.asyncIterator]() { return this; }
|
|
925
|
+
next() {
|
|
926
|
+
const queued = this.queued;
|
|
927
|
+
if (queued) {
|
|
928
|
+
this.queued = undefined;
|
|
929
|
+
this.delivered(queued);
|
|
930
|
+
return Promise.resolve({ done: false, value: queued });
|
|
931
|
+
}
|
|
932
|
+
if (this.terminalError !== undefined)
|
|
933
|
+
return Promise.reject(this.terminalError);
|
|
934
|
+
if (this.ended)
|
|
935
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
936
|
+
if (this.waiter)
|
|
937
|
+
return Promise.reject(clientError("protocol_error", "concurrent Emulator Surface reads are not allowed"));
|
|
938
|
+
return new Promise((resolve, reject) => { this.waiter = { resolve, reject }; });
|
|
939
|
+
}
|
|
940
|
+
async return() {
|
|
941
|
+
await this.close();
|
|
942
|
+
return { done: true, value: undefined };
|
|
943
|
+
}
|
|
944
|
+
async close() {
|
|
945
|
+
if (this.ended || this.closing)
|
|
946
|
+
return;
|
|
947
|
+
this.closing = true;
|
|
948
|
+
await this.requestClose();
|
|
949
|
+
}
|
|
950
|
+
sendTouch(point) {
|
|
951
|
+
if (this.ended || this.closing)
|
|
952
|
+
return Promise.reject(clientError("closed", "Emulator Surface is closed"));
|
|
953
|
+
const inputSequence = this.inputSequence;
|
|
954
|
+
this.inputSequence += 1;
|
|
955
|
+
const sending = this.inputTail.then(() => this.sendInput(inputSequence, point));
|
|
956
|
+
this.inputTail = sending.catch(() => undefined);
|
|
957
|
+
return sending;
|
|
958
|
+
}
|
|
959
|
+
push(chunk) {
|
|
960
|
+
if (this.ended || this.closing)
|
|
961
|
+
return;
|
|
962
|
+
let assembling = this.assembling;
|
|
963
|
+
if (!assembling) {
|
|
964
|
+
if (chunk.chunkIndex !== 0)
|
|
965
|
+
return this.fatal("Emulator Surface frame did not start at chunk zero");
|
|
966
|
+
assembling = {
|
|
967
|
+
sequence: chunk.frameSequence,
|
|
968
|
+
timestampMilliseconds: chunk.timestampMilliseconds,
|
|
969
|
+
chunkCount: chunk.chunkCount,
|
|
970
|
+
nextChunkIndex: 0,
|
|
971
|
+
chunks: [],
|
|
972
|
+
bytes: 0,
|
|
973
|
+
};
|
|
974
|
+
this.assembling = assembling;
|
|
975
|
+
}
|
|
976
|
+
if (chunk.frameSequence !== assembling.sequence ||
|
|
977
|
+
chunk.timestampMilliseconds !== assembling.timestampMilliseconds ||
|
|
978
|
+
chunk.chunkCount !== assembling.chunkCount ||
|
|
979
|
+
chunk.chunkIndex !== assembling.nextChunkIndex)
|
|
980
|
+
return this.fatal("Emulator Surface chunks are out of order");
|
|
981
|
+
const bytes = Uint8Array.from(Buffer.from(chunk.data, "base64"));
|
|
982
|
+
assembling.bytes += bytes.byteLength;
|
|
983
|
+
if (assembling.bytes > RUNTIME_EMULATOR_SURFACE_MAX_FRAME_BYTES) {
|
|
984
|
+
return this.fatal("Emulator Surface frame exceeds the client bound");
|
|
985
|
+
}
|
|
986
|
+
assembling.chunks.push(bytes);
|
|
987
|
+
assembling.nextChunkIndex += 1;
|
|
988
|
+
if (assembling.nextChunkIndex !== assembling.chunkCount)
|
|
989
|
+
return;
|
|
990
|
+
const encodedBytes = new Uint8Array(assembling.bytes);
|
|
991
|
+
let offset = 0;
|
|
992
|
+
for (const part of assembling.chunks) {
|
|
993
|
+
encodedBytes.set(part, offset);
|
|
994
|
+
offset += part.byteLength;
|
|
995
|
+
}
|
|
996
|
+
const frame = {
|
|
997
|
+
surfaceId: this.surfaceId,
|
|
998
|
+
frameSequence: assembling.sequence,
|
|
999
|
+
timestampMilliseconds: assembling.timestampMilliseconds,
|
|
1000
|
+
format: this.ready.format,
|
|
1001
|
+
encodedBytes,
|
|
1002
|
+
};
|
|
1003
|
+
this.assembling = undefined;
|
|
1004
|
+
if (this.waiter) {
|
|
1005
|
+
const waiter = this.waiter;
|
|
1006
|
+
this.waiter = undefined;
|
|
1007
|
+
this.delivered(frame);
|
|
1008
|
+
waiter.resolve({ done: false, value: frame });
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (this.queued)
|
|
1012
|
+
return this.fatal("Emulator Surface consumer exceeded the one-frame queue");
|
|
1013
|
+
this.queued = frame;
|
|
1014
|
+
}
|
|
1015
|
+
remoteClose(frame) {
|
|
1016
|
+
if (this.ended)
|
|
1017
|
+
return;
|
|
1018
|
+
this.resolveClosed(frame);
|
|
1019
|
+
if (frame.reason === "client_closed")
|
|
1020
|
+
this.finish();
|
|
1021
|
+
else
|
|
1022
|
+
this.finish(emulatorSurfaceError(frame));
|
|
1023
|
+
}
|
|
1024
|
+
terminate(error) {
|
|
1025
|
+
if (this.ended)
|
|
1026
|
+
return;
|
|
1027
|
+
this.rejectClosed(error);
|
|
1028
|
+
this.finish(error);
|
|
1029
|
+
}
|
|
1030
|
+
delivered(frame) {
|
|
1031
|
+
void this.acknowledge(frame.frameSequence).catch((error) => this.reportFatal(error));
|
|
1032
|
+
}
|
|
1033
|
+
fatal(message) {
|
|
1034
|
+
const error = clientError("protocol_error", message);
|
|
1035
|
+
this.finish(error);
|
|
1036
|
+
this.reportFatal(error);
|
|
1037
|
+
}
|
|
1038
|
+
finish(error) {
|
|
1039
|
+
if (this.ended)
|
|
1040
|
+
return;
|
|
1041
|
+
this.ended = true;
|
|
1042
|
+
this.terminalError = error;
|
|
1043
|
+
this.assembling = undefined;
|
|
1044
|
+
this.queued = undefined;
|
|
1045
|
+
const waiter = this.waiter;
|
|
1046
|
+
this.waiter = undefined;
|
|
1047
|
+
if (waiter) {
|
|
1048
|
+
if (error)
|
|
1049
|
+
waiter.reject(error);
|
|
1050
|
+
else
|
|
1051
|
+
waiter.resolve({ done: true, value: undefined });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
class SessionEventsSubscriptionImpl {
|
|
1056
|
+
subscriptionId;
|
|
1057
|
+
sessionId;
|
|
1058
|
+
daemonInstanceId;
|
|
1059
|
+
timeoutMs;
|
|
1060
|
+
requestCancel;
|
|
1061
|
+
reportBackpressure;
|
|
1062
|
+
queue = [];
|
|
1063
|
+
queuedBytes = 0;
|
|
1064
|
+
waiter;
|
|
1065
|
+
terminalError;
|
|
1066
|
+
ended = false;
|
|
1067
|
+
cancelling = false;
|
|
1068
|
+
closedPromise;
|
|
1069
|
+
resolveClosed;
|
|
1070
|
+
constructor(subscriptionId, sessionId, daemonInstanceId, timeoutMs, requestCancel, reportBackpressure) {
|
|
1071
|
+
this.subscriptionId = subscriptionId;
|
|
1072
|
+
this.sessionId = sessionId;
|
|
1073
|
+
this.daemonInstanceId = daemonInstanceId;
|
|
1074
|
+
this.timeoutMs = timeoutMs;
|
|
1075
|
+
this.requestCancel = requestCancel;
|
|
1076
|
+
this.reportBackpressure = reportBackpressure;
|
|
1077
|
+
this.closedPromise = new Promise((resolve) => {
|
|
1078
|
+
this.resolveClosed = resolve;
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
[Symbol.asyncIterator]() {
|
|
1082
|
+
return this;
|
|
1083
|
+
}
|
|
1084
|
+
next() {
|
|
1085
|
+
const queued = this.queue.shift();
|
|
1086
|
+
if (queued) {
|
|
1087
|
+
this.queuedBytes -= queued.bytes;
|
|
1088
|
+
return Promise.resolve({ done: false, value: queued.event });
|
|
1089
|
+
}
|
|
1090
|
+
if (this.terminalError !== undefined)
|
|
1091
|
+
return Promise.reject(this.terminalError);
|
|
1092
|
+
if (this.ended)
|
|
1093
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
1094
|
+
if (this.waiter) {
|
|
1095
|
+
return Promise.reject(clientError("protocol_error", "concurrent reads from one Session subscription are not allowed"));
|
|
1096
|
+
}
|
|
1097
|
+
return new Promise((resolve, reject) => {
|
|
1098
|
+
this.waiter = { resolve, reject };
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
async return() {
|
|
1102
|
+
await this.close();
|
|
1103
|
+
return { done: true, value: undefined };
|
|
1104
|
+
}
|
|
1105
|
+
async close() {
|
|
1106
|
+
if (this.ended)
|
|
1107
|
+
return;
|
|
1108
|
+
await this.requestCancel();
|
|
1109
|
+
}
|
|
1110
|
+
push(event, frameBytes) {
|
|
1111
|
+
if (this.ended || this.cancelling)
|
|
1112
|
+
return;
|
|
1113
|
+
if (this.waiter) {
|
|
1114
|
+
const waiter = this.waiter;
|
|
1115
|
+
this.waiter = undefined;
|
|
1116
|
+
waiter.resolve({ done: false, value: event });
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
if (this.queue.length >= MAX_SUBSCRIPTION_QUEUED_EVENTS ||
|
|
1120
|
+
this.queuedBytes + frameBytes > MAX_SUBSCRIPTION_QUEUED_BYTES) {
|
|
1121
|
+
this.cancelling = true;
|
|
1122
|
+
this.finish(new DirectRuntimeSessionEventsError("backpressure", this.subscriptionId, this.sessionId, "Remote Session event queue exceeded its bounded client capacity"), true);
|
|
1123
|
+
this.reportBackpressure();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
this.queue.push({ event, bytes: frameBytes });
|
|
1127
|
+
this.queuedBytes += frameBytes;
|
|
1128
|
+
}
|
|
1129
|
+
beginCancel() {
|
|
1130
|
+
if (this.ended || this.cancelling)
|
|
1131
|
+
return false;
|
|
1132
|
+
this.cancelling = true;
|
|
1133
|
+
this.clearQueue();
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
1136
|
+
remoteClose(frame) {
|
|
1137
|
+
if (this.ended)
|
|
1138
|
+
return;
|
|
1139
|
+
if (frame.reason === "cancelled" || frame.reason === "session_closed") {
|
|
1140
|
+
this.finish();
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
this.finish(sessionEventsError(frame, this.sessionId));
|
|
1144
|
+
}
|
|
1145
|
+
terminate(error) {
|
|
1146
|
+
this.finish(error, true);
|
|
1147
|
+
}
|
|
1148
|
+
waitClosed() {
|
|
1149
|
+
return this.closedPromise;
|
|
1150
|
+
}
|
|
1151
|
+
finish(error, discardQueue = false) {
|
|
1152
|
+
if (this.ended)
|
|
1153
|
+
return;
|
|
1154
|
+
this.ended = true;
|
|
1155
|
+
this.terminalError = error;
|
|
1156
|
+
if (discardQueue)
|
|
1157
|
+
this.clearQueue();
|
|
1158
|
+
const waiter = this.waiter;
|
|
1159
|
+
this.waiter = undefined;
|
|
1160
|
+
if (waiter) {
|
|
1161
|
+
if (error === undefined)
|
|
1162
|
+
waiter.resolve({ done: true, value: undefined });
|
|
1163
|
+
else
|
|
1164
|
+
waiter.reject(error);
|
|
1165
|
+
}
|
|
1166
|
+
this.resolveClosed();
|
|
1167
|
+
}
|
|
1168
|
+
clearQueue() {
|
|
1169
|
+
this.queue.length = 0;
|
|
1170
|
+
this.queuedBytes = 0;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
function sessionEventsError(frame, sessionId) {
|
|
1174
|
+
const suffix = frame.message === undefined ? "" : `: ${frame.message}`;
|
|
1175
|
+
return new DirectRuntimeSessionEventsError(frame.reason, frame.subscriptionId, sessionId, `Remote Session events subscription closed (${frame.reason})${suffix}`);
|
|
1176
|
+
}
|
|
1177
|
+
class SessionTerminalImpl {
|
|
1178
|
+
timeoutMs;
|
|
1179
|
+
sendInput;
|
|
1180
|
+
sendResize;
|
|
1181
|
+
requestClose;
|
|
1182
|
+
reportBackpressure;
|
|
1183
|
+
queue = [];
|
|
1184
|
+
queuedBytes = 0;
|
|
1185
|
+
waiter;
|
|
1186
|
+
terminalError;
|
|
1187
|
+
ended = false;
|
|
1188
|
+
closing = false;
|
|
1189
|
+
resolveClosed;
|
|
1190
|
+
rejectClosed;
|
|
1191
|
+
closed;
|
|
1192
|
+
attachmentId;
|
|
1193
|
+
sessionId;
|
|
1194
|
+
role;
|
|
1195
|
+
constructor(opened, timeoutMs, sendInput, sendResize, requestClose, reportBackpressure) {
|
|
1196
|
+
this.timeoutMs = timeoutMs;
|
|
1197
|
+
this.sendInput = sendInput;
|
|
1198
|
+
this.sendResize = sendResize;
|
|
1199
|
+
this.requestClose = requestClose;
|
|
1200
|
+
this.reportBackpressure = reportBackpressure;
|
|
1201
|
+
this.attachmentId = opened.attachmentId;
|
|
1202
|
+
this.sessionId = opened.sessionId;
|
|
1203
|
+
this.role = opened.role;
|
|
1204
|
+
let resolveClosed;
|
|
1205
|
+
let rejectClosed;
|
|
1206
|
+
this.closed = new Promise((resolve, reject) => {
|
|
1207
|
+
resolveClosed = resolve;
|
|
1208
|
+
rejectClosed = reject;
|
|
1209
|
+
});
|
|
1210
|
+
this.resolveClosed = resolveClosed;
|
|
1211
|
+
this.rejectClosed = rejectClosed;
|
|
1212
|
+
void this.closed.catch(() => undefined);
|
|
1213
|
+
}
|
|
1214
|
+
[Symbol.asyncIterator]() {
|
|
1215
|
+
return this;
|
|
1216
|
+
}
|
|
1217
|
+
next() {
|
|
1218
|
+
const queued = this.queue.shift();
|
|
1219
|
+
if (queued) {
|
|
1220
|
+
this.queuedBytes -= queued.bytes;
|
|
1221
|
+
return Promise.resolve({ done: false, value: queued.data });
|
|
1222
|
+
}
|
|
1223
|
+
if (this.terminalError !== undefined)
|
|
1224
|
+
return Promise.reject(this.terminalError);
|
|
1225
|
+
if (this.ended)
|
|
1226
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
1227
|
+
if (this.waiter) {
|
|
1228
|
+
return Promise.reject(clientError("protocol_error", "concurrent reads from one Session terminal are not allowed"));
|
|
1229
|
+
}
|
|
1230
|
+
return new Promise((resolve, reject) => {
|
|
1231
|
+
this.waiter = { resolve, reject };
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
async return() {
|
|
1235
|
+
await this.close();
|
|
1236
|
+
return { done: true, value: undefined };
|
|
1237
|
+
}
|
|
1238
|
+
async write(data) {
|
|
1239
|
+
if (this.ended || this.closing || this.role !== "owner" || data.byteLength === 0)
|
|
1240
|
+
return;
|
|
1241
|
+
await this.sendInput(Uint8Array.from(data));
|
|
1242
|
+
}
|
|
1243
|
+
async resize(cols, rows) {
|
|
1244
|
+
if (this.ended || this.closing)
|
|
1245
|
+
return;
|
|
1246
|
+
await this.sendResize(cols, rows);
|
|
1247
|
+
}
|
|
1248
|
+
async close() {
|
|
1249
|
+
if (this.ended)
|
|
1250
|
+
return;
|
|
1251
|
+
await this.requestClose();
|
|
1252
|
+
}
|
|
1253
|
+
push(data) {
|
|
1254
|
+
if (this.ended || this.closing || data.byteLength === 0)
|
|
1255
|
+
return;
|
|
1256
|
+
if (this.waiter) {
|
|
1257
|
+
const waiter = this.waiter;
|
|
1258
|
+
this.waiter = undefined;
|
|
1259
|
+
waiter.resolve({ done: false, value: data });
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (this.queue.length >= MAX_TERMINAL_QUEUED_OUTPUT_FRAMES ||
|
|
1263
|
+
this.queuedBytes + data.byteLength > MAX_TERMINAL_QUEUED_OUTPUT_BYTES) {
|
|
1264
|
+
this.reportBackpressure(clientError("unreachable", "Remote Session terminal output exceeded its bounded client queue"));
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
const copy = Uint8Array.from(data);
|
|
1268
|
+
this.queue.push({ data: copy, bytes: copy.byteLength });
|
|
1269
|
+
this.queuedBytes += copy.byteLength;
|
|
1270
|
+
}
|
|
1271
|
+
beginClose() {
|
|
1272
|
+
if (this.ended || this.closing)
|
|
1273
|
+
return false;
|
|
1274
|
+
this.closing = true;
|
|
1275
|
+
this.clearQueue();
|
|
1276
|
+
return true;
|
|
1277
|
+
}
|
|
1278
|
+
remoteClose(frame) {
|
|
1279
|
+
if (this.ended)
|
|
1280
|
+
return;
|
|
1281
|
+
const error = frame.reason === "client_closed" || frame.reason === "terminal_exited"
|
|
1282
|
+
? undefined
|
|
1283
|
+
: sessionTerminalError(frame, this.sessionId);
|
|
1284
|
+
this.finish(frame, error, frame.reason !== "terminal_exited");
|
|
1285
|
+
}
|
|
1286
|
+
terminate(error) {
|
|
1287
|
+
if (this.ended)
|
|
1288
|
+
return;
|
|
1289
|
+
this.finish(undefined, error, true);
|
|
1290
|
+
}
|
|
1291
|
+
waitClosed() {
|
|
1292
|
+
return this.closed.then(() => undefined);
|
|
1293
|
+
}
|
|
1294
|
+
finish(frame, error, discardQueue = false) {
|
|
1295
|
+
if (this.ended)
|
|
1296
|
+
return;
|
|
1297
|
+
this.ended = true;
|
|
1298
|
+
this.terminalError = error;
|
|
1299
|
+
if (discardQueue)
|
|
1300
|
+
this.clearQueue();
|
|
1301
|
+
const waiter = this.waiter;
|
|
1302
|
+
this.waiter = undefined;
|
|
1303
|
+
if (waiter) {
|
|
1304
|
+
if (error === undefined)
|
|
1305
|
+
waiter.resolve({ done: true, value: undefined });
|
|
1306
|
+
else
|
|
1307
|
+
waiter.reject(error);
|
|
1308
|
+
}
|
|
1309
|
+
if (frame)
|
|
1310
|
+
this.resolveClosed(frame);
|
|
1311
|
+
else
|
|
1312
|
+
this.rejectClosed(error ?? clientError("closed", "Remote Session terminal closed"));
|
|
1313
|
+
}
|
|
1314
|
+
clearQueue() {
|
|
1315
|
+
this.queue.length = 0;
|
|
1316
|
+
this.queuedBytes = 0;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
function sessionTerminalError(frame, sessionId) {
|
|
1320
|
+
const suffix = frame.message === undefined ? "" : `: ${frame.message}`;
|
|
1321
|
+
return new DirectRuntimeSessionTerminalError(frame.reason, frame.attachmentId, sessionId, `Remote Session terminal closed (${frame.reason})${suffix}`);
|
|
1322
|
+
}
|
|
1323
|
+
function emulatorSurfaceError(frame) {
|
|
1324
|
+
const suffix = frame.message === undefined ? "" : `: ${frame.message}`;
|
|
1325
|
+
const code = frame.reason === "unauthorized"
|
|
1326
|
+
? "authentication_failed"
|
|
1327
|
+
: frame.reason === "unsupported"
|
|
1328
|
+
? "incompatible"
|
|
1329
|
+
: frame.reason === "capacity"
|
|
1330
|
+
? "unreachable"
|
|
1331
|
+
: "closed";
|
|
1332
|
+
return clientError(code, `Remote Emulator Surface closed (${frame.reason})${suffix}`);
|
|
1333
|
+
}
|
|
1334
|
+
function decodeEstablishedFrame(frame) {
|
|
1335
|
+
let value;
|
|
1336
|
+
try {
|
|
1337
|
+
value = JSON.parse(establishedTextDecoder.decode(frame));
|
|
1338
|
+
}
|
|
1339
|
+
catch (error) {
|
|
1340
|
+
throw clientError("protocol_error", "Remote Runtime frame is not valid UTF-8 JSON", error);
|
|
1341
|
+
}
|
|
1342
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1343
|
+
throw clientError("protocol_error", "Remote Runtime frame must be a JSON object");
|
|
1344
|
+
}
|
|
1345
|
+
const record = value;
|
|
1346
|
+
if (typeof record.type !== "string") {
|
|
1347
|
+
throw clientError("protocol_error", "Remote Runtime frame type must be a string");
|
|
1348
|
+
}
|
|
1349
|
+
return record;
|
|
1350
|
+
}
|
|
1351
|
+
function callTransportError(error, method, requestId) {
|
|
1352
|
+
if (error instanceof DirectRuntimeCallTransportError &&
|
|
1353
|
+
error.method === method &&
|
|
1354
|
+
error.requestId === requestId) {
|
|
1355
|
+
return error;
|
|
1356
|
+
}
|
|
1357
|
+
const normalized = normalizeEstablishedError(error);
|
|
1358
|
+
return new DirectRuntimeCallTransportError(normalized.code, method, requestId, "unknown", normalized.message, {
|
|
1359
|
+
cause: normalized,
|
|
1360
|
+
...(normalized.webSocketCloseCode === undefined
|
|
1361
|
+
? {}
|
|
1362
|
+
: { webSocketCloseCode: normalized.webSocketCloseCode }),
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
class DirectSocket {
|
|
1366
|
+
liveness;
|
|
1367
|
+
websocket;
|
|
1368
|
+
opened;
|
|
1369
|
+
openResolve;
|
|
1370
|
+
openReject;
|
|
1371
|
+
e2eeChannel;
|
|
1372
|
+
awaitingPlaintextReady = false;
|
|
1373
|
+
livenessTimer;
|
|
1374
|
+
lastInboundActivityAt = 0;
|
|
1375
|
+
terminal;
|
|
1376
|
+
authenticated = false;
|
|
1377
|
+
localClose = false;
|
|
1378
|
+
queue = [];
|
|
1379
|
+
queuedBytes = 0;
|
|
1380
|
+
readWaiter;
|
|
1381
|
+
frameHandler;
|
|
1382
|
+
terminalHandler;
|
|
1383
|
+
pendingOutboundFrames = 0;
|
|
1384
|
+
pendingOutboundBytes = 0;
|
|
1385
|
+
constructor(endpoint, timeoutMs, liveness) {
|
|
1386
|
+
this.liveness = liveness;
|
|
1387
|
+
this.opened = new Promise((resolve, reject) => {
|
|
1388
|
+
this.openResolve = resolve;
|
|
1389
|
+
this.openReject = reject;
|
|
1390
|
+
});
|
|
1391
|
+
try {
|
|
1392
|
+
this.websocket = new WebSocket(endpoint, {
|
|
1393
|
+
rejectUnauthorized: true,
|
|
1394
|
+
followRedirects: false,
|
|
1395
|
+
handshakeTimeout: timeoutMs,
|
|
1396
|
+
perMessageDeflate: false,
|
|
1397
|
+
maxPayload: DIRECT_RUNTIME_E2EE_MAX_ENVELOPE_BYTES,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
catch (error) {
|
|
1401
|
+
throw mapWebSocketError(error);
|
|
1402
|
+
}
|
|
1403
|
+
this.websocket.on("open", () => {
|
|
1404
|
+
this.noteInboundActivity();
|
|
1405
|
+
this.startLiveness();
|
|
1406
|
+
this.openResolve();
|
|
1407
|
+
});
|
|
1408
|
+
this.websocket.on("message", (data, isBinary) => {
|
|
1409
|
+
this.noteInboundActivity();
|
|
1410
|
+
if (isBinary) {
|
|
1411
|
+
this.fail(clientError("protocol_error", "binary WebSocket frames are not allowed"));
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
const wireFrame = rawDataBytes(data);
|
|
1415
|
+
if (wireFrame.byteLength > DIRECT_RUNTIME_E2EE_MAX_ENVELOPE_BYTES) {
|
|
1416
|
+
this.fail(clientError("protocol_error", "WebSocket frame exceeds the E2EE limit"));
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
const channel = this.e2eeChannel;
|
|
1420
|
+
if (!channel) {
|
|
1421
|
+
this.fail(clientError("protocol_error", "server traffic preceded E2EE setup"));
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (this.awaitingPlaintextReady) {
|
|
1425
|
+
this.awaitingPlaintextReady = false;
|
|
1426
|
+
this.deliver(wireFrame);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
try {
|
|
1430
|
+
const plaintext = channel.open(decodeWireText(wireFrame));
|
|
1431
|
+
const frame = Uint8Array.from(Buffer.from(plaintext, "utf8"));
|
|
1432
|
+
if (frame.byteLength > REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES) {
|
|
1433
|
+
throw clientError("protocol_error", "E2EE plaintext exceeds the RPC limit");
|
|
1434
|
+
}
|
|
1435
|
+
this.deliver(frame);
|
|
1436
|
+
}
|
|
1437
|
+
catch (error) {
|
|
1438
|
+
this.fail(clientError("protocol_error", "invalid Direct Runtime E2EE frame", error));
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
this.websocket.on("ping", () => this.noteInboundActivity());
|
|
1442
|
+
this.websocket.on("pong", () => this.noteInboundActivity());
|
|
1443
|
+
this.websocket.on("unexpected-response", (_request, response) => {
|
|
1444
|
+
response.resume();
|
|
1445
|
+
this.fail(clientError(response.statusCode === 401 || response.statusCode === 403
|
|
1446
|
+
? "authentication_failed"
|
|
1447
|
+
: response.statusCode === 429 || response.statusCode === 503
|
|
1448
|
+
? "unreachable"
|
|
1449
|
+
: "protocol_error", `Direct Runtime WebSocket upgrade failed with HTTP ${response.statusCode ?? "unknown"}`));
|
|
1450
|
+
});
|
|
1451
|
+
this.websocket.on("error", (error) => {
|
|
1452
|
+
this.fail(this.authenticated ? closedError(error) : mapWebSocketError(error));
|
|
1453
|
+
});
|
|
1454
|
+
this.websocket.on("close", (code, reason) => {
|
|
1455
|
+
const suffix = reason.length === 0 ? "" : `: ${reason.toString("utf8").slice(0, 256)}`;
|
|
1456
|
+
this.fail(webSocketCloseError(code, suffix, this.localClose));
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
async open(timeoutMs) {
|
|
1460
|
+
return await withTimeout(this.opened, timeoutMs, () => clientError("unreachable", "Direct Runtime WebSocket connection timed out"));
|
|
1461
|
+
}
|
|
1462
|
+
async startE2EE(channel, clientHello) {
|
|
1463
|
+
if (this.e2eeChannel) {
|
|
1464
|
+
throw clientError("protocol_error", "Direct Runtime E2EE is already initialized");
|
|
1465
|
+
}
|
|
1466
|
+
this.e2eeChannel = channel;
|
|
1467
|
+
this.awaitingPlaintextReady = true;
|
|
1468
|
+
await this.sendWireText(clientHello);
|
|
1469
|
+
}
|
|
1470
|
+
async readFrame(maxBytes, timeoutMs) {
|
|
1471
|
+
if (this.readWaiter) {
|
|
1472
|
+
throw clientError("protocol_error", "concurrent handshake reads are not allowed");
|
|
1473
|
+
}
|
|
1474
|
+
const queued = this.shiftQueuedFrame();
|
|
1475
|
+
if (queued !== undefined)
|
|
1476
|
+
return boundedFrame(queued, maxBytes);
|
|
1477
|
+
if (this.terminal)
|
|
1478
|
+
throw this.terminal;
|
|
1479
|
+
return await new Promise((resolve, reject) => {
|
|
1480
|
+
const timer = setTimeout(() => {
|
|
1481
|
+
if (this.readWaiter?.timer !== timer)
|
|
1482
|
+
return;
|
|
1483
|
+
this.readWaiter = undefined;
|
|
1484
|
+
reject(clientError("unreachable", "Direct Runtime handshake timed out"));
|
|
1485
|
+
}, timeoutMs);
|
|
1486
|
+
timer.unref?.();
|
|
1487
|
+
this.readWaiter = {
|
|
1488
|
+
maxBytes,
|
|
1489
|
+
timer,
|
|
1490
|
+
resolve,
|
|
1491
|
+
reject,
|
|
1492
|
+
};
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
async sendText(frame) {
|
|
1496
|
+
const channel = this.e2eeChannel;
|
|
1497
|
+
if (!channel) {
|
|
1498
|
+
throw clientError("protocol_error", "Direct Runtime E2EE is not initialized");
|
|
1499
|
+
}
|
|
1500
|
+
let encrypted;
|
|
1501
|
+
const plaintextBytes = Buffer.byteLength(frame, "utf8");
|
|
1502
|
+
this.assertOutboundCapacity(Math.ceil(plaintextBytes * 4 / 3) + MAX_E2EE_ENVELOPE_OVERHEAD_BYTES);
|
|
1503
|
+
try {
|
|
1504
|
+
encrypted = channel.seal(frame);
|
|
1505
|
+
}
|
|
1506
|
+
catch (error) {
|
|
1507
|
+
throw clientError("protocol_error", "could not encrypt Direct Runtime frame", error);
|
|
1508
|
+
}
|
|
1509
|
+
await this.sendWireText(encrypted);
|
|
1510
|
+
}
|
|
1511
|
+
async sendWireText(frame) {
|
|
1512
|
+
if (this.terminal || this.websocket.readyState !== WebSocket.OPEN) {
|
|
1513
|
+
throw this.terminal ?? clientError("closed", "Direct Runtime connection is not open");
|
|
1514
|
+
}
|
|
1515
|
+
const frameBytes = Buffer.byteLength(frame, "utf8");
|
|
1516
|
+
this.assertOutboundCapacity(frameBytes);
|
|
1517
|
+
this.pendingOutboundFrames += 1;
|
|
1518
|
+
this.pendingOutboundBytes += frameBytes;
|
|
1519
|
+
await new Promise((resolve, reject) => {
|
|
1520
|
+
const complete = (error) => {
|
|
1521
|
+
this.pendingOutboundFrames -= 1;
|
|
1522
|
+
this.pendingOutboundBytes -= frameBytes;
|
|
1523
|
+
if (error)
|
|
1524
|
+
reject(this.authenticated ? closedError(error) : mapWebSocketError(error));
|
|
1525
|
+
else
|
|
1526
|
+
resolve();
|
|
1527
|
+
};
|
|
1528
|
+
try {
|
|
1529
|
+
this.websocket.send(frame, { binary: false, compress: false }, complete);
|
|
1530
|
+
}
|
|
1531
|
+
catch (error) {
|
|
1532
|
+
this.pendingOutboundFrames -= 1;
|
|
1533
|
+
this.pendingOutboundBytes -= frameBytes;
|
|
1534
|
+
reject(this.authenticated ? closedError(error) : mapWebSocketError(error));
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
assertOutboundCapacity(frameBytes) {
|
|
1539
|
+
if (this.pendingOutboundFrames >= MAX_PENDING_OUTBOUND_FRAMES) {
|
|
1540
|
+
throw new DirectRuntimeClientCapacityError("outbound_frames", MAX_PENDING_OUTBOUND_FRAMES, `Direct Runtime client admits at most ${MAX_PENDING_OUTBOUND_FRAMES} pending WebSocket sends`);
|
|
1541
|
+
}
|
|
1542
|
+
if (this.pendingOutboundBytes + frameBytes > MAX_PENDING_OUTBOUND_BYTES ||
|
|
1543
|
+
this.websocket.bufferedAmount + frameBytes > MAX_PENDING_OUTBOUND_BYTES) {
|
|
1544
|
+
throw new DirectRuntimeClientCapacityError("outbound_bytes", MAX_PENDING_OUTBOUND_BYTES, `Direct Runtime client admits at most ${MAX_PENDING_OUTBOUND_BYTES} outbound WebSocket bytes`);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
markAuthenticated() {
|
|
1548
|
+
this.authenticated = true;
|
|
1549
|
+
}
|
|
1550
|
+
activate(onFrame, onTerminal) {
|
|
1551
|
+
if (this.frameHandler || this.readWaiter) {
|
|
1552
|
+
throw clientError("protocol_error", "Direct Runtime socket is already active");
|
|
1553
|
+
}
|
|
1554
|
+
this.frameHandler = onFrame;
|
|
1555
|
+
this.terminalHandler = onTerminal;
|
|
1556
|
+
while (this.queue.length > 0 && !this.terminal) {
|
|
1557
|
+
onFrame(this.shiftQueuedFrame());
|
|
1558
|
+
}
|
|
1559
|
+
if (this.terminal)
|
|
1560
|
+
onTerminal(this.terminal);
|
|
1561
|
+
}
|
|
1562
|
+
abort(error) {
|
|
1563
|
+
this.fail(error);
|
|
1564
|
+
if (this.websocket.readyState !== WebSocket.CLOSED)
|
|
1565
|
+
this.websocket.terminate();
|
|
1566
|
+
}
|
|
1567
|
+
async close() {
|
|
1568
|
+
this.stopLiveness();
|
|
1569
|
+
if (this.websocket.readyState === WebSocket.CLOSED)
|
|
1570
|
+
return;
|
|
1571
|
+
this.localClose = true;
|
|
1572
|
+
await new Promise((resolve) => {
|
|
1573
|
+
const timer = setTimeout(() => {
|
|
1574
|
+
if (this.websocket.readyState !== WebSocket.CLOSED)
|
|
1575
|
+
this.websocket.terminate();
|
|
1576
|
+
resolve();
|
|
1577
|
+
}, 1_000);
|
|
1578
|
+
timer.unref?.();
|
|
1579
|
+
this.websocket.once("close", () => {
|
|
1580
|
+
clearTimeout(timer);
|
|
1581
|
+
resolve();
|
|
1582
|
+
});
|
|
1583
|
+
if (this.websocket.readyState === WebSocket.CONNECTING)
|
|
1584
|
+
this.websocket.terminate();
|
|
1585
|
+
else
|
|
1586
|
+
this.websocket.close(1000, "client closed");
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
deliver(frame) {
|
|
1590
|
+
if (this.terminal)
|
|
1591
|
+
return;
|
|
1592
|
+
if (this.frameHandler) {
|
|
1593
|
+
try {
|
|
1594
|
+
this.frameHandler(frame);
|
|
1595
|
+
}
|
|
1596
|
+
catch (error) {
|
|
1597
|
+
this.fail(clientError("protocol_error", "RPC frame handler failed", error));
|
|
1598
|
+
}
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
if (this.readWaiter) {
|
|
1602
|
+
const waiter = this.readWaiter;
|
|
1603
|
+
this.readWaiter = undefined;
|
|
1604
|
+
clearTimeout(waiter.timer);
|
|
1605
|
+
try {
|
|
1606
|
+
waiter.resolve(boundedFrame(frame, waiter.maxBytes));
|
|
1607
|
+
}
|
|
1608
|
+
catch (error) {
|
|
1609
|
+
waiter.reject(error);
|
|
1610
|
+
}
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (this.queue.length >= MAX_PRE_ACTIVATION_FRAMES ||
|
|
1614
|
+
this.queuedBytes + frame.byteLength > MAX_PRE_ACTIVATION_BYTES) {
|
|
1615
|
+
this.fail(clientError("protocol_error", "too many Direct Runtime frames arrived before the client was ready"));
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
this.queue.push(frame);
|
|
1619
|
+
this.queuedBytes += frame.byteLength;
|
|
1620
|
+
}
|
|
1621
|
+
fail(error) {
|
|
1622
|
+
if (this.terminal)
|
|
1623
|
+
return;
|
|
1624
|
+
this.stopLiveness();
|
|
1625
|
+
this.terminal = error;
|
|
1626
|
+
this.openReject(error);
|
|
1627
|
+
if (this.readWaiter) {
|
|
1628
|
+
clearTimeout(this.readWaiter.timer);
|
|
1629
|
+
this.readWaiter.reject(error);
|
|
1630
|
+
this.readWaiter = undefined;
|
|
1631
|
+
}
|
|
1632
|
+
this.terminalHandler?.(error);
|
|
1633
|
+
}
|
|
1634
|
+
noteInboundActivity() {
|
|
1635
|
+
this.lastInboundActivityAt = Date.now();
|
|
1636
|
+
}
|
|
1637
|
+
startLiveness() {
|
|
1638
|
+
if (this.livenessTimer)
|
|
1639
|
+
return;
|
|
1640
|
+
this.livenessTimer = setInterval(() => {
|
|
1641
|
+
if (this.terminal)
|
|
1642
|
+
return;
|
|
1643
|
+
if (Date.now() - this.lastInboundActivityAt > this.liveness.inactivityTimeoutMs) {
|
|
1644
|
+
const error = clientError(this.authenticated ? "closed" : "unreachable", "Direct Runtime stopped responding; the WebSocket was reset");
|
|
1645
|
+
this.fail(error);
|
|
1646
|
+
if (this.websocket.readyState !== WebSocket.CLOSED)
|
|
1647
|
+
this.websocket.terminate();
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (this.websocket.readyState !== WebSocket.OPEN)
|
|
1651
|
+
return;
|
|
1652
|
+
try {
|
|
1653
|
+
this.websocket.ping();
|
|
1654
|
+
}
|
|
1655
|
+
catch {
|
|
1656
|
+
// A concurrent error/close event owns terminal error normalization.
|
|
1657
|
+
}
|
|
1658
|
+
}, this.liveness.pingIntervalMs);
|
|
1659
|
+
this.livenessTimer.unref?.();
|
|
1660
|
+
}
|
|
1661
|
+
stopLiveness() {
|
|
1662
|
+
if (!this.livenessTimer)
|
|
1663
|
+
return;
|
|
1664
|
+
clearInterval(this.livenessTimer);
|
|
1665
|
+
this.livenessTimer = undefined;
|
|
1666
|
+
}
|
|
1667
|
+
shiftQueuedFrame() {
|
|
1668
|
+
const frame = this.queue.shift();
|
|
1669
|
+
if (frame !== undefined)
|
|
1670
|
+
this.queuedBytes -= frame.byteLength;
|
|
1671
|
+
return frame;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
function validatedOffer(value, allowExpired) {
|
|
1675
|
+
let offer;
|
|
1676
|
+
try {
|
|
1677
|
+
offer = parsePairingOffer(value, { allowExpired });
|
|
1678
|
+
}
|
|
1679
|
+
catch (error) {
|
|
1680
|
+
throw clientError("invalid_offer", "Direct Runtime pairing offer is invalid", error);
|
|
1681
|
+
}
|
|
1682
|
+
parseEd25519PublicKey(offer.identityPublicKey, "identityPublicKey", "invalid_offer");
|
|
1683
|
+
parseX25519PublicKey(offer.serverEncryptionPublicKey, "serverEncryptionPublicKey", "invalid_offer");
|
|
1684
|
+
parseDirectEndpoint(offer.directEndpoint, "invalid_offer");
|
|
1685
|
+
return offer;
|
|
1686
|
+
}
|
|
1687
|
+
function parseHandshakeFrame(frame, expected) {
|
|
1688
|
+
try {
|
|
1689
|
+
return decodeDirectRuntimeFrame(frame);
|
|
1690
|
+
}
|
|
1691
|
+
catch (error) {
|
|
1692
|
+
throw clientError("protocol_error", `invalid ${expected} frame`, error);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
function assertHelloIdentity(hello, input) {
|
|
1696
|
+
if (hello.role !== "control") {
|
|
1697
|
+
throw clientError("protocol_error", "server.hello role is not control");
|
|
1698
|
+
}
|
|
1699
|
+
if (hello.daemonId !== input.daemonId) {
|
|
1700
|
+
throw clientError("identity_mismatch", "server.hello daemonId differs from the pin");
|
|
1701
|
+
}
|
|
1702
|
+
if (hello.identityPublicKey !== input.identityPublicKey) {
|
|
1703
|
+
throw clientError("identity_mismatch", "server.hello identity key differs from the pin");
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
function selectBindingProtocol(hello) {
|
|
1707
|
+
if (hello.minimumCompatibleBindingVersion > DIRECT_RUNTIME_BINDING_PROTOCOL.currentVersion ||
|
|
1708
|
+
DIRECT_RUNTIME_BINDING_PROTOCOL.minimumCompatibleVersion > hello.bindingProtocolVersion) {
|
|
1709
|
+
throw clientError("incompatible", "Direct Runtime binding protocol ranges do not overlap");
|
|
1710
|
+
}
|
|
1711
|
+
return Math.min(hello.bindingProtocolVersion, DIRECT_RUNTIME_BINDING_PROTOCOL.currentVersion);
|
|
1712
|
+
}
|
|
1713
|
+
function selectCoreProtocol(hello) {
|
|
1714
|
+
if (hello.minimumCompatibleCoreVersion > REMOTE_RUNTIME_CORE_PROTOCOL.currentVersion ||
|
|
1715
|
+
REMOTE_RUNTIME_CORE_PROTOCOL.minimumCompatibleVersion > hello.coreProtocolVersion) {
|
|
1716
|
+
throw clientError("incompatible", "Remote Runtime Core protocol ranges do not overlap");
|
|
1717
|
+
}
|
|
1718
|
+
return Math.min(hello.coreProtocolVersion, REMOTE_RUNTIME_CORE_PROTOCOL.currentVersion);
|
|
1719
|
+
}
|
|
1720
|
+
function clientAuthenticateFields(input, hello, selectedBindingVersion, selectedCoreVersion) {
|
|
1721
|
+
const base = {
|
|
1722
|
+
type: "client.authenticate",
|
|
1723
|
+
role: "control",
|
|
1724
|
+
daemonId: hello.daemonId,
|
|
1725
|
+
daemonInstanceId: hello.daemonInstanceId,
|
|
1726
|
+
bindingProtocolVersion: selectedBindingVersion,
|
|
1727
|
+
coreProtocolVersion: selectedCoreVersion,
|
|
1728
|
+
clientId: input.identity.clientId,
|
|
1729
|
+
clientPublicKey: input.identity.clientPublicKey,
|
|
1730
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
1731
|
+
};
|
|
1732
|
+
return input.mode === "enroll"
|
|
1733
|
+
? {
|
|
1734
|
+
...base,
|
|
1735
|
+
mode: "enroll",
|
|
1736
|
+
claimId: input.claimId,
|
|
1737
|
+
claimSecret: input.claimSecret,
|
|
1738
|
+
}
|
|
1739
|
+
: { ...base, mode: "connect", grantId: input.grantId };
|
|
1740
|
+
}
|
|
1741
|
+
function assertAcceptedConsistency(accepted, hello, authenticate, input) {
|
|
1742
|
+
if (accepted.role !== "control" || authenticate.role !== "control") {
|
|
1743
|
+
throw clientError("protocol_error", "Direct Runtime control role changed during authentication");
|
|
1744
|
+
}
|
|
1745
|
+
if (accepted.daemonId !== hello.daemonId || accepted.daemonId !== input.daemonId) {
|
|
1746
|
+
throw clientError("identity_mismatch", "server.accepted daemonId differs from the pin");
|
|
1747
|
+
}
|
|
1748
|
+
if (accepted.daemonInstanceId !== hello.daemonInstanceId) {
|
|
1749
|
+
throw clientError("protocol_error", "daemonInstanceId changed during authentication");
|
|
1750
|
+
}
|
|
1751
|
+
if (accepted.bindingProtocolVersion !== authenticate.bindingProtocolVersion ||
|
|
1752
|
+
accepted.coreProtocolVersion !== authenticate.coreProtocolVersion) {
|
|
1753
|
+
throw clientError("incompatible", "server accepted different protocol versions");
|
|
1754
|
+
}
|
|
1755
|
+
if (accepted.clientId !== authenticate.clientId) {
|
|
1756
|
+
throw clientError("authentication_failed", "server accepted a different client identity");
|
|
1757
|
+
}
|
|
1758
|
+
if (input.mode === "connect" && accepted.grantId !== input.grantId) {
|
|
1759
|
+
throw clientError("authentication_failed", "server accepted a different client grant");
|
|
1760
|
+
}
|
|
1761
|
+
if (accepted.authorities.length !== 1 || accepted.authorities[0] !== "daemon.full") {
|
|
1762
|
+
throw clientError("authentication_failed", "server did not grant daemon.full authority");
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
function withoutHelloSignature(hello) {
|
|
1766
|
+
const { signature: _signature, ...fields } = hello;
|
|
1767
|
+
return fields;
|
|
1768
|
+
}
|
|
1769
|
+
function withoutAcceptedSignature(accepted) {
|
|
1770
|
+
const { signature: _signature, ...fields } = accepted;
|
|
1771
|
+
return fields;
|
|
1772
|
+
}
|
|
1773
|
+
function publicKey(value, code) {
|
|
1774
|
+
parseEd25519PublicKey(value, "identityPublicKey", code);
|
|
1775
|
+
return createPublicKey({
|
|
1776
|
+
key: Buffer.from(value, "base64url"),
|
|
1777
|
+
format: "der",
|
|
1778
|
+
type: "spki",
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
function sha256(value) {
|
|
1782
|
+
return new Uint8Array(createHash("sha256").update(value).digest());
|
|
1783
|
+
}
|
|
1784
|
+
function parseTimeout(value) {
|
|
1785
|
+
const timeout = value ?? DEFAULT_TIMEOUT_MS;
|
|
1786
|
+
if (!Number.isSafeInteger(timeout) ||
|
|
1787
|
+
timeout < MIN_TIMEOUT_MS ||
|
|
1788
|
+
timeout > MAX_TIMEOUT_MS) {
|
|
1789
|
+
throw clientError("protocol_error", `timeoutMs must be an integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS}`);
|
|
1790
|
+
}
|
|
1791
|
+
return timeout;
|
|
1792
|
+
}
|
|
1793
|
+
function parseClientLiveness(input) {
|
|
1794
|
+
const pingIntervalMs = parseLivenessInterval(input?.pingIntervalMs, DEFAULT_LIVENESS_PING_INTERVAL_MS, "liveness.pingIntervalMs");
|
|
1795
|
+
const inactivityTimeoutMs = parseLivenessInterval(input?.inactivityTimeoutMs, DEFAULT_LIVENESS_INACTIVITY_TIMEOUT_MS, "liveness.inactivityTimeoutMs");
|
|
1796
|
+
if (inactivityTimeoutMs <= pingIntervalMs) {
|
|
1797
|
+
throw clientError("protocol_error", "liveness.inactivityTimeoutMs must be greater than liveness.pingIntervalMs");
|
|
1798
|
+
}
|
|
1799
|
+
return { pingIntervalMs, inactivityTimeoutMs };
|
|
1800
|
+
}
|
|
1801
|
+
function parseLivenessInterval(value, fallback, field) {
|
|
1802
|
+
const interval = value ?? fallback;
|
|
1803
|
+
if (!Number.isSafeInteger(interval) ||
|
|
1804
|
+
interval <= 0 ||
|
|
1805
|
+
interval > MAX_LIVENESS_INTERVAL_MS) {
|
|
1806
|
+
throw clientError("protocol_error", `${field} must be a positive integer no greater than ${MAX_LIVENESS_INTERVAL_MS}`);
|
|
1807
|
+
}
|
|
1808
|
+
return interval;
|
|
1809
|
+
}
|
|
1810
|
+
function remaining(deadline) {
|
|
1811
|
+
return Math.max(1, deadline - Date.now());
|
|
1812
|
+
}
|
|
1813
|
+
function boundedFrame(frame, maxBytes) {
|
|
1814
|
+
if (frame.byteLength > maxBytes) {
|
|
1815
|
+
throw clientError("protocol_error", `Direct Runtime frame exceeds ${maxBytes} bytes`);
|
|
1816
|
+
}
|
|
1817
|
+
return frame;
|
|
1818
|
+
}
|
|
1819
|
+
function rawDataBytes(data) {
|
|
1820
|
+
if (Array.isArray(data))
|
|
1821
|
+
return new Uint8Array(Buffer.concat(data));
|
|
1822
|
+
if (data instanceof ArrayBuffer)
|
|
1823
|
+
return new Uint8Array(data.slice(0));
|
|
1824
|
+
return new Uint8Array(data);
|
|
1825
|
+
}
|
|
1826
|
+
function decodeWireText(bytes) {
|
|
1827
|
+
try {
|
|
1828
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1829
|
+
}
|
|
1830
|
+
catch (error) {
|
|
1831
|
+
throw clientError("protocol_error", "WebSocket frame must be valid UTF-8", error);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
function normalizeHandshakeError(error) {
|
|
1835
|
+
if (error instanceof DirectRuntimeClientError)
|
|
1836
|
+
return error;
|
|
1837
|
+
return clientError("protocol_error", "Direct Runtime handshake failed", error);
|
|
1838
|
+
}
|
|
1839
|
+
function notStartedClientError(error, fallbackMessage) {
|
|
1840
|
+
if (error instanceof DirectRuntimeClientError) {
|
|
1841
|
+
return new DirectRuntimeClientError(error.code, error.message, {
|
|
1842
|
+
cause: error,
|
|
1843
|
+
outcome: "not_started",
|
|
1844
|
+
...(error.webSocketCloseCode === undefined
|
|
1845
|
+
? {}
|
|
1846
|
+
: { webSocketCloseCode: error.webSocketCloseCode }),
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
return new DirectRuntimeClientError("protocol_error", fallbackMessage, {
|
|
1850
|
+
cause: error,
|
|
1851
|
+
outcome: "not_started",
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
function normalizeEstablishedError(error) {
|
|
1855
|
+
if (error instanceof DirectRuntimeClientError)
|
|
1856
|
+
return error;
|
|
1857
|
+
return closedError(error);
|
|
1858
|
+
}
|
|
1859
|
+
function closedError(cause) {
|
|
1860
|
+
return clientError("closed", "Direct Runtime connection was interrupted", cause);
|
|
1861
|
+
}
|
|
1862
|
+
function webSocketCloseError(closeCode, reasonSuffix, localClose) {
|
|
1863
|
+
if (localClose) {
|
|
1864
|
+
return clientError("closed", "Direct Runtime connection was closed", undefined, closeCode);
|
|
1865
|
+
}
|
|
1866
|
+
const code = closeCode === DIRECT_RUNTIME_CLOSE_CODE.unauthorized
|
|
1867
|
+
? "authentication_failed"
|
|
1868
|
+
: closeCode === DIRECT_RUNTIME_CLOSE_CODE.badProtocol
|
|
1869
|
+
? "protocol_error"
|
|
1870
|
+
: closeCode === DIRECT_RUNTIME_CLOSE_CODE.authenticationTimeout ||
|
|
1871
|
+
closeCode === DIRECT_RUNTIME_CLOSE_CODE.capacity
|
|
1872
|
+
? "unreachable"
|
|
1873
|
+
: "closed";
|
|
1874
|
+
return clientError(code, `Direct Runtime connection closed (${closeCode})${reasonSuffix}`, undefined, closeCode);
|
|
1875
|
+
}
|
|
1876
|
+
function mapWebSocketError(error) {
|
|
1877
|
+
const code = errorCode(error);
|
|
1878
|
+
if (code.startsWith("ERR_TLS_") ||
|
|
1879
|
+
code.startsWith("ERR_SSL_") ||
|
|
1880
|
+
code.startsWith("CERT_") ||
|
|
1881
|
+
[
|
|
1882
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
1883
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
1884
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
1885
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
1886
|
+
].includes(code)) {
|
|
1887
|
+
return clientError("tls_error", "Direct Runtime TLS verification failed", error);
|
|
1888
|
+
}
|
|
1889
|
+
if (code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH") {
|
|
1890
|
+
return clientError("protocol_error", "Direct Runtime WebSocket frame is too large", error);
|
|
1891
|
+
}
|
|
1892
|
+
return clientError("unreachable", "Direct Runtime endpoint is unreachable", error);
|
|
1893
|
+
}
|
|
1894
|
+
function errorCode(error) {
|
|
1895
|
+
if (typeof error === "object" &&
|
|
1896
|
+
error !== null &&
|
|
1897
|
+
"code" in error &&
|
|
1898
|
+
typeof error.code === "string") {
|
|
1899
|
+
return error.code;
|
|
1900
|
+
}
|
|
1901
|
+
return "";
|
|
1902
|
+
}
|
|
1903
|
+
async function withTimeout(promise, timeoutMs, timeoutError) {
|
|
1904
|
+
return await new Promise((resolve, reject) => {
|
|
1905
|
+
const timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
|
1906
|
+
timer.unref?.();
|
|
1907
|
+
promise.then((value) => {
|
|
1908
|
+
clearTimeout(timer);
|
|
1909
|
+
resolve(value);
|
|
1910
|
+
}, (error) => {
|
|
1911
|
+
clearTimeout(timer);
|
|
1912
|
+
reject(error);
|
|
1913
|
+
});
|
|
1914
|
+
});
|
|
1915
|
+
}
|