@rynx-ai/server 0.1.9 → 0.1.10-beta.3
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-ws.d.ts +2 -0
- package/dist/browser-surface-ws.js +1 -0
- package/dist/control-api.d.ts +17 -0
- package/dist/control-api.js +809 -9
- package/dist/control-web/assets/{highlighted-body-OFNGDK62-e-w61YLy.js → highlighted-body-OFNGDK62-D-FhYROw.js} +1 -1
- package/dist/control-web/assets/index-BKzSsGxH.js +671 -0
- package/dist/control-web/assets/index-DykWY_Vk.css +32 -0
- package/dist/control-web/assets/{mermaid-GHXKKRXX-DZn7Hbr2.js → mermaid-GHXKKRXX-DE-Ovg9T.js} +3 -3
- package/dist/control-web/index.html +2 -2
- package/dist/desktop-browser-host.d.ts +20 -3
- package/dist/desktop-browser-host.js +126 -44
- package/dist/direct-runtime-browser-inspect-server.d.ts +72 -0
- package/dist/direct-runtime-browser-inspect-server.js +749 -0
- package/dist/direct-runtime-server.d.ts +6 -0
- package/dist/direct-runtime-server.js +29 -1
- package/dist/emulator-surface-ws.d.ts +2 -0
- package/dist/emulator-surface-ws.js +1 -0
- package/dist/machine-session-service.d.ts +88 -5
- package/dist/machine-session-service.js +283 -20
- package/dist/remote-runtime-dispatcher.d.ts +1 -1
- package/dist/remote-runtime-dispatcher.js +25 -2
- package/dist/remote-runtime-session-projection.js +10 -0
- package/dist/runtime-web-auth.d.ts +5 -0
- package/dist/runtime-web-auth.js +43 -3
- package/dist/server.d.ts +78 -8
- package/dist/server.js +42 -17
- package/dist/session-browser-service.d.ts +30 -0
- package/dist/session-browser-service.js +89 -3
- package/dist/session-browser-surface-coordinator.d.ts +4 -3
- package/dist/session-browser-surface-coordinator.js +374 -268
- package/dist/session-runtime-index.d.ts +6 -0
- package/dist/session-runtime-index.js +10 -0
- package/dist/session-terminal-host.d.ts +6 -1
- package/dist/session-terminal-host.js +18 -0
- package/dist/terminal-ws.d.ts +3 -0
- package/dist/terminal-ws.js +1 -0
- package/package.json +12 -7
- package/dist/control-web/assets/index-BQ7XVBKO.css +0 -32
- package/dist/control-web/assets/index-ClpzuBJx.js +0 -606
|
@@ -46,6 +46,9 @@ export class MachineSessionService {
|
|
|
46
46
|
maxSnapshotItemLimit;
|
|
47
47
|
snapshotPageMaxBytes;
|
|
48
48
|
directoryScanLimit;
|
|
49
|
+
pendingMessageRetryMs;
|
|
50
|
+
pendingDeliveries = new Map();
|
|
51
|
+
cancelledPendingDeliveries = new Set();
|
|
49
52
|
constructor(ports, options = {}) {
|
|
50
53
|
this.ports = ports;
|
|
51
54
|
this.maxListLimit = boundedInteger(options.maxListLimit ?? MAX_LIST_LIMIT, 1, MAX_LIST_LIMIT, "maxListLimit");
|
|
@@ -53,6 +56,8 @@ export class MachineSessionService {
|
|
|
53
56
|
this.maxSnapshotItemLimit = boundedInteger(options.maxSnapshotItemLimit ?? MAX_SNAPSHOT_ITEM_LIMIT, 1, MAX_SNAPSHOT_ITEM_LIMIT, "maxSnapshotItemLimit");
|
|
54
57
|
this.snapshotPageMaxBytes = boundedInteger(options.snapshotPageMaxBytes ?? DEFAULT_SNAPSHOT_PAGE_MAX_BYTES, MIN_PAGE_MAX_BYTES, MAX_PAGE_MAX_BYTES, "snapshotPageMaxBytes");
|
|
55
58
|
this.directoryScanLimit = boundedInteger(options.directoryScanLimit ?? DEFAULT_DIRECTORY_SCAN_LIMIT, 1, MAX_DIRECTORY_SCAN_LIMIT, "directoryScanLimit");
|
|
59
|
+
this.pendingMessageRetryMs = boundedInteger(options.pendingMessageRetryMs ?? 1_000, 10, 60_000, "pendingMessageRetryMs");
|
|
60
|
+
void this.resumePendingDeliveries();
|
|
56
61
|
}
|
|
57
62
|
async list(input = {}) {
|
|
58
63
|
const limit = requestLimit(input.limit, DEFAULT_LIST_LIMIT, this.maxListLimit, "limit");
|
|
@@ -63,14 +68,18 @@ export class MachineSessionService {
|
|
|
63
68
|
const metas = [...allMetas]
|
|
64
69
|
.sort(compareMetaRecency)
|
|
65
70
|
.slice(0, this.directoryScanLimit);
|
|
66
|
-
const logged = await
|
|
71
|
+
const [logged, pendingMessages] = await Promise.all([
|
|
72
|
+
this.ports.log.listSessions({ limit: this.directoryScanLimit + 1 }),
|
|
73
|
+
this.ports.pendingMessages?.list() ?? [],
|
|
74
|
+
]);
|
|
67
75
|
const directoryTruncated = allMetas.length > this.directoryScanLimit || logged.length > this.directoryScanLimit;
|
|
68
76
|
const boundedLogs = logged.slice(0, this.directoryScanLimit);
|
|
69
77
|
const metaById = new Map(metas.map((meta) => [meta.id, meta]));
|
|
70
78
|
const logById = new Map(boundedLogs.map((entry) => [entry.sessionId, entry]));
|
|
79
|
+
const pendingById = new Map(pendingMessages.map((entry) => [entry.sessionId, entry.state]));
|
|
71
80
|
const ids = new Set([...metaById.keys(), ...logById.keys()]);
|
|
72
81
|
const summaries = [...ids]
|
|
73
|
-
.map((id) => sessionSummary(id, metaById.get(id), logById.get(id), this.ports.runtimeState))
|
|
82
|
+
.map((id) => sessionSummary(id, metaById.get(id), logById.get(id), this.ports.runtimeState, pendingById.get(id)))
|
|
74
83
|
.sort(compareSummaryRecency);
|
|
75
84
|
const afterCursor = cursor === undefined
|
|
76
85
|
? summaries
|
|
@@ -186,11 +195,7 @@ export class MachineSessionService {
|
|
|
186
195
|
}
|
|
187
196
|
else {
|
|
188
197
|
if (!isSessionProviderId(input.provider)) {
|
|
189
|
-
throw new MachineSessionServiceInputError("provider must be codex or claude");
|
|
190
|
-
}
|
|
191
|
-
const readiness = await this.runtimeReadiness(input.provider);
|
|
192
|
-
if (!readiness.ready) {
|
|
193
|
-
throw new MachineSessionServiceFailure("failed_precondition", readiness.reason ?? "provider is not ready");
|
|
198
|
+
throw new MachineSessionServiceInputError("provider must be codex, traex, or claude");
|
|
194
199
|
}
|
|
195
200
|
target = { provider: input.provider };
|
|
196
201
|
}
|
|
@@ -209,31 +214,188 @@ export class MachineSessionService {
|
|
|
209
214
|
/** Inject one turn through the target daemon's native single-writer runner. */
|
|
210
215
|
async sendMessage(sessionIdInput, messageInput) {
|
|
211
216
|
const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
|
|
212
|
-
|
|
217
|
+
const request = typeof messageInput === "string"
|
|
218
|
+
? { message: messageInput }
|
|
219
|
+
: messageInput;
|
|
220
|
+
const hasMessage = request.message !== undefined;
|
|
221
|
+
const hasContent = request.content !== undefined;
|
|
222
|
+
if (hasMessage === hasContent) {
|
|
223
|
+
throw new MachineSessionServiceInputError("provide exactly one of message or content");
|
|
224
|
+
}
|
|
225
|
+
if (hasMessage && (typeof request.message !== "string" || request.message.length === 0)) {
|
|
213
226
|
throw new MachineSessionServiceInputError("message must be a non-empty string");
|
|
214
227
|
}
|
|
228
|
+
if (hasContent && (!Array.isArray(request.content) || request.content.length === 0)) {
|
|
229
|
+
throw new MachineSessionServiceInputError("content must be a non-empty array");
|
|
230
|
+
}
|
|
215
231
|
const meta = this.ports.registry.get(sessionId);
|
|
216
232
|
const consoleMeta = meta?.source === "console" ? meta : undefined;
|
|
233
|
+
let prepared;
|
|
234
|
+
const needsPreparedOperation = hasContent || request.clientMessageId !== undefined;
|
|
235
|
+
if (needsPreparedOperation) {
|
|
236
|
+
const resources = this.ports.resources;
|
|
237
|
+
if (!resources) {
|
|
238
|
+
throw new MachineSessionServiceFailure("failed_precondition", "Session resources are unavailable");
|
|
239
|
+
}
|
|
240
|
+
if (hasContent &&
|
|
241
|
+
request.content.some((part) => part.type === "input_image") &&
|
|
242
|
+
!request.clientMessageId) {
|
|
243
|
+
throw new MachineSessionServiceInputError("clientMessageId is required for image input");
|
|
244
|
+
}
|
|
245
|
+
prepared = await resources.prepareInput(sessionId, hasContent
|
|
246
|
+
? request.content
|
|
247
|
+
: [{ type: "input_text", text: request.message }], request.clientMessageId);
|
|
248
|
+
if (prepared.operationState === "injecting" ||
|
|
249
|
+
prepared.operationState === "outcome_unknown") {
|
|
250
|
+
throw new MachineSessionServiceFailure("outcome_unknown", "message delivery outcome is unknown");
|
|
251
|
+
}
|
|
252
|
+
if (prepared.operationState === "injected" ||
|
|
253
|
+
prepared.operationState === "mirrored") {
|
|
254
|
+
return { ok: true, injected: true };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
prepared = {
|
|
259
|
+
input: {
|
|
260
|
+
content: [{ type: "text", text: request.message }],
|
|
261
|
+
...(request.clientMessageId
|
|
262
|
+
? { clientMessageId: request.clientMessageId }
|
|
263
|
+
: {}),
|
|
264
|
+
},
|
|
265
|
+
title: request.message,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
217
268
|
if (consoleMeta && !consoleMeta.title) {
|
|
218
|
-
this.ports.registry.setTitle(sessionId, synthesizeSessionTitle(
|
|
269
|
+
this.ports.registry.setTitle(sessionId, synthesizeSessionTitle(prepared.title));
|
|
270
|
+
}
|
|
271
|
+
const resources = this.ports.resources;
|
|
272
|
+
if (request.clientMessageId) {
|
|
273
|
+
await resources?.markMessageInjecting(sessionId, request.clientMessageId);
|
|
274
|
+
}
|
|
275
|
+
let execution;
|
|
276
|
+
try {
|
|
277
|
+
execution = await this.ensureLiveSession(sessionId, meta);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (request.clientMessageId) {
|
|
281
|
+
await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error));
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
let outcome;
|
|
286
|
+
try {
|
|
287
|
+
outcome = await execution.runner.injectMessage(sessionId, hasMessage && !needsPreparedOperation ? request.message : prepared.input);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (request.clientMessageId) {
|
|
291
|
+
await Promise.resolve(resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error))).catch(() => undefined);
|
|
292
|
+
}
|
|
293
|
+
throw new MachineSessionServiceFailure("outcome_unknown", "live injection outcome is unknown");
|
|
219
294
|
}
|
|
220
|
-
const execution = await this.ensureLiveSession(sessionId, meta);
|
|
221
|
-
const outcome = await execution.runner.injectMessage(sessionId, messageInput);
|
|
222
295
|
if (outcome === "failed") {
|
|
296
|
+
if (request.clientMessageId) {
|
|
297
|
+
await resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, `live injection ${outcome}`);
|
|
298
|
+
}
|
|
223
299
|
throw new MachineSessionServiceFailure("outcome_unknown", `live injection ${outcome}`);
|
|
224
300
|
}
|
|
225
301
|
if (outcome !== "injected") {
|
|
302
|
+
if (request.clientMessageId) {
|
|
303
|
+
await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, `live injection ${outcome}`);
|
|
304
|
+
}
|
|
226
305
|
throw new MachineSessionServiceFailure("failed_precondition", `live injection ${outcome}`);
|
|
227
306
|
}
|
|
307
|
+
if (request.clientMessageId) {
|
|
308
|
+
try {
|
|
309
|
+
await resources?.markMessageInjected(sessionId, request.clientMessageId);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
await Promise.resolve(resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, "message state could not be persisted after injection")).catch(() => undefined);
|
|
313
|
+
throw new MachineSessionServiceFailure("outcome_unknown", "message was injected but its delivery state could not be persisted");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
228
316
|
return { ok: true, injected: true };
|
|
229
317
|
}
|
|
318
|
+
resourcePolicy(sessionIdInput) {
|
|
319
|
+
const sessionId = this.requireResourceSession(sessionIdInput);
|
|
320
|
+
return this.requireResources().policy(sessionId);
|
|
321
|
+
}
|
|
322
|
+
async beginResourceUpload(input) {
|
|
323
|
+
const sessionId = this.requireResourceSession(input.sessionId);
|
|
324
|
+
return this.requireResources().beginUpload({ ...input, sessionId });
|
|
325
|
+
}
|
|
326
|
+
async writeResourceUploadChunk(input) {
|
|
327
|
+
const sessionId = this.requireResourceSession(input.sessionId);
|
|
328
|
+
return this.requireResources().writeUploadChunk({ ...input, sessionId });
|
|
329
|
+
}
|
|
330
|
+
async commitResourceUpload(input) {
|
|
331
|
+
const sessionId = this.requireResourceSession(input.sessionId);
|
|
332
|
+
return this.requireResources().commitUpload({ ...input, sessionId });
|
|
333
|
+
}
|
|
334
|
+
async getResource(input) {
|
|
335
|
+
const sessionId = this.requireResourceSession(input.sessionId);
|
|
336
|
+
return this.requireResources().getResource({ ...input, sessionId });
|
|
337
|
+
}
|
|
338
|
+
async readResource(input) {
|
|
339
|
+
const sessionId = this.requireResourceSession(input.sessionId);
|
|
340
|
+
return this.requireResources().readResource({ ...input, sessionId });
|
|
341
|
+
}
|
|
342
|
+
async deleteResource(input) {
|
|
343
|
+
const sessionId = this.requireResourceSession(input.sessionId);
|
|
344
|
+
return this.requireResources().deleteResource({ ...input, sessionId });
|
|
345
|
+
}
|
|
346
|
+
/** Persist the first task while login/onboarding owns the Provider TUI. The
|
|
347
|
+
* delivery worker starts the pane now, waits for a real native thread, then
|
|
348
|
+
* injects exactly once. `failed` is fenced as outcome_unknown and never retried. */
|
|
349
|
+
async enqueueMessage(sessionIdInput, messageInput) {
|
|
350
|
+
const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
|
|
351
|
+
if (typeof messageInput !== "string" || messageInput.length === 0) {
|
|
352
|
+
throw new MachineSessionServiceInputError("message must be a non-empty string");
|
|
353
|
+
}
|
|
354
|
+
const meta = this.ports.registry.get(sessionId);
|
|
355
|
+
if (!meta)
|
|
356
|
+
throw new MachineSessionServiceFailure("not_found", "session not found");
|
|
357
|
+
const pending = this.ports.pendingMessages;
|
|
358
|
+
if (!pending) {
|
|
359
|
+
throw new MachineSessionServiceFailure("failed_precondition", "Provider setup task queue is unavailable");
|
|
360
|
+
}
|
|
361
|
+
const inserted = await pending.put({
|
|
362
|
+
sessionId,
|
|
363
|
+
message: messageInput,
|
|
364
|
+
createdAt: new Date().toISOString(),
|
|
365
|
+
});
|
|
366
|
+
if (!inserted) {
|
|
367
|
+
throw new MachineSessionServiceFailure("failed_precondition", "This Session already has a task waiting for Provider setup");
|
|
368
|
+
}
|
|
369
|
+
if (meta.source === "console" && !meta.title) {
|
|
370
|
+
this.ports.registry.setTitle(sessionId, synthesizeSessionTitle(messageInput));
|
|
371
|
+
}
|
|
372
|
+
this.schedulePendingDelivery(sessionId);
|
|
373
|
+
// Best effort: delivery also starts/retries the pane, but awaiting this path
|
|
374
|
+
// makes the setup Terminal attachable as soon as the enqueue RPC returns.
|
|
375
|
+
await this.startTerminal(sessionId).catch(() => undefined);
|
|
376
|
+
return { ok: true, queued: true };
|
|
377
|
+
}
|
|
230
378
|
/** Explicitly restore the target daemon's live runner without starting a turn. */
|
|
231
379
|
async startTerminal(sessionIdInput) {
|
|
232
380
|
const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
|
|
233
|
-
await this.
|
|
381
|
+
const request = await this.liveSessionRequest(sessionId);
|
|
382
|
+
const start = request.execution.runner.startLiveSession?.bind(request.execution.runner) ??
|
|
383
|
+
request.execution.runner.ensureLiveSession.bind(request.execution.runner);
|
|
384
|
+
const started = await start(sessionId, request.options);
|
|
385
|
+
if (!started) {
|
|
386
|
+
throw new MachineSessionServiceFailure("failed_precondition", `Provider terminal unavailable (${request.liveRuntime})`, request.execution.runner.lastLiveSessionError?.(sessionId));
|
|
387
|
+
}
|
|
234
388
|
return { ok: true };
|
|
235
389
|
}
|
|
236
390
|
async ensureLiveSession(sessionId, meta = this.ports.registry.get(sessionId)) {
|
|
391
|
+
const request = await this.liveSessionRequest(sessionId, meta);
|
|
392
|
+
const live = await request.execution.runner.ensureLiveSession(sessionId, request.options);
|
|
393
|
+
if (!live) {
|
|
394
|
+
throw new MachineSessionServiceFailure("failed_precondition", `live session unavailable (${request.liveRuntime})`, request.execution.runner.lastLiveSessionError?.(sessionId));
|
|
395
|
+
}
|
|
396
|
+
return request.execution;
|
|
397
|
+
}
|
|
398
|
+
async liveSessionRequest(sessionId, meta = this.ports.registry.get(sessionId)) {
|
|
237
399
|
const execution = this.requireExecution();
|
|
238
400
|
const consoleMeta = meta?.source === "console" ? meta : undefined;
|
|
239
401
|
const bound = consoleMeta ? null : ((await execution.sessionStore?.get(sessionId)) ?? null);
|
|
@@ -245,18 +407,19 @@ export class MachineSessionService {
|
|
|
245
407
|
agentSpec: consoleMeta?.config,
|
|
246
408
|
provider: meta?.provider ?? bound?.runtime,
|
|
247
409
|
});
|
|
248
|
-
if (liveRuntime === "codex") {
|
|
410
|
+
if (liveRuntime === "codex" || liveRuntime === "traex") {
|
|
249
411
|
const codexRecord = await execution.sessionStore?.get(sessionId);
|
|
250
412
|
if (codexRecord?.codexSessionId) {
|
|
251
413
|
ensureCodexResumeRollout({
|
|
252
414
|
sessionId,
|
|
415
|
+
runtime: liveRuntime,
|
|
253
416
|
threadId: codexRecord.codexSessionId,
|
|
254
417
|
cwd: codexRecord.cwd ?? process.cwd(),
|
|
255
418
|
items: await this.ports.log.snapshot(sessionId),
|
|
256
419
|
});
|
|
257
420
|
}
|
|
258
421
|
}
|
|
259
|
-
const
|
|
422
|
+
const options = {
|
|
260
423
|
...(bound?.cwd
|
|
261
424
|
? { cwd: bound.cwd }
|
|
262
425
|
: consoleMeta?.config?.osEnv?.cwd
|
|
@@ -268,11 +431,8 @@ export class MachineSessionService {
|
|
|
268
431
|
: {}),
|
|
269
432
|
...(consoleMeta?.agent ? { agentName: consoleMeta.agent } : {}),
|
|
270
433
|
...(consoleMeta?.config ? { agentSpec: consoleMeta.config } : {}),
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
throw new MachineSessionServiceFailure("failed_precondition", `live session unavailable (${liveRuntime})`, execution.runner.lastLiveSessionError?.(sessionId));
|
|
274
|
-
}
|
|
275
|
-
return execution;
|
|
434
|
+
};
|
|
435
|
+
return { execution, liveRuntime, options };
|
|
276
436
|
}
|
|
277
437
|
async resolveInteraction(sessionIdInput, interactionIdInput, resolution) {
|
|
278
438
|
const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
|
|
@@ -287,8 +447,11 @@ export class MachineSessionService {
|
|
|
287
447
|
catch (error) {
|
|
288
448
|
throw new MachineSessionServiceFailure("failed_precondition", "Session-owned resources could not be fenced before deletion", error instanceof Error ? error.message : String(error));
|
|
289
449
|
}
|
|
450
|
+
this.cancelledPendingDeliveries.add(sessionId);
|
|
290
451
|
this.ports.execution?.runner.stopRunner(sessionId);
|
|
291
452
|
this.ports.events.close?.(sessionId);
|
|
453
|
+
await this.ports.pendingMessages?.delete(sessionId);
|
|
454
|
+
await this.ports.resources?.deleteSession(sessionId);
|
|
292
455
|
this.ports.registry.remove(sessionId);
|
|
293
456
|
await this.ports.log.deleteSession(sessionId);
|
|
294
457
|
this.ports.runtimeState.remove?.(sessionId);
|
|
@@ -306,6 +469,93 @@ export class MachineSessionService {
|
|
|
306
469
|
}
|
|
307
470
|
return this.ports.agents;
|
|
308
471
|
}
|
|
472
|
+
requireResources() {
|
|
473
|
+
if (!this.ports.resources) {
|
|
474
|
+
throw new MachineSessionServiceFailure("failed_precondition", "Session resources are unavailable");
|
|
475
|
+
}
|
|
476
|
+
return this.ports.resources;
|
|
477
|
+
}
|
|
478
|
+
requireResourceSession(sessionIdInput) {
|
|
479
|
+
const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
|
|
480
|
+
if (!this.ports.registry.get(sessionId)) {
|
|
481
|
+
throw new MachineSessionServiceFailure("not_found", "session not found");
|
|
482
|
+
}
|
|
483
|
+
return sessionId;
|
|
484
|
+
}
|
|
485
|
+
async resumePendingDeliveries() {
|
|
486
|
+
const pending = this.ports.pendingMessages;
|
|
487
|
+
if (!pending)
|
|
488
|
+
return;
|
|
489
|
+
try {
|
|
490
|
+
for (const message of await pending.list()) {
|
|
491
|
+
if (message.state === "queued")
|
|
492
|
+
this.schedulePendingDelivery(message.sessionId);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// A later enqueue or daemon restart retries recovery; startup remains usable.
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
schedulePendingDelivery(sessionId) {
|
|
500
|
+
if (this.pendingDeliveries.has(sessionId))
|
|
501
|
+
return;
|
|
502
|
+
this.cancelledPendingDeliveries.delete(sessionId);
|
|
503
|
+
const delivery = this.deliverPendingMessage(sessionId)
|
|
504
|
+
.catch(() => undefined)
|
|
505
|
+
.finally(() => {
|
|
506
|
+
if (this.pendingDeliveries.get(sessionId) === delivery) {
|
|
507
|
+
this.pendingDeliveries.delete(sessionId);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
this.pendingDeliveries.set(sessionId, delivery);
|
|
511
|
+
}
|
|
512
|
+
async deliverPendingMessage(sessionId) {
|
|
513
|
+
const pendingStore = this.ports.pendingMessages;
|
|
514
|
+
if (!pendingStore)
|
|
515
|
+
return;
|
|
516
|
+
for (;;) {
|
|
517
|
+
if (this.cancelledPendingDeliveries.has(sessionId))
|
|
518
|
+
return;
|
|
519
|
+
const pending = await pendingStore.get(sessionId);
|
|
520
|
+
if (!pending || pending.state !== "queued")
|
|
521
|
+
return;
|
|
522
|
+
let injectionAttempted = false;
|
|
523
|
+
try {
|
|
524
|
+
const execution = await this.ensureLiveSession(sessionId);
|
|
525
|
+
if (this.cancelledPendingDeliveries.has(sessionId))
|
|
526
|
+
return;
|
|
527
|
+
const current = await pendingStore.get(sessionId);
|
|
528
|
+
if (!current ||
|
|
529
|
+
current.state !== "queued" ||
|
|
530
|
+
current.createdAt !== pending.createdAt ||
|
|
531
|
+
current.message !== pending.message)
|
|
532
|
+
return;
|
|
533
|
+
injectionAttempted = true;
|
|
534
|
+
const outcome = await execution.runner.injectMessage(sessionId, pending.message);
|
|
535
|
+
if (outcome === "injected") {
|
|
536
|
+
await Promise.resolve(pendingStore.delete(sessionId)).catch(async () => {
|
|
537
|
+
await Promise.resolve(pendingStore.markOutcomeUnknown(sessionId)).catch(() => undefined);
|
|
538
|
+
});
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (outcome === "failed") {
|
|
542
|
+
await pendingStore.markOutcomeUnknown(sessionId);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
if (injectionAttempted) {
|
|
548
|
+
await Promise.resolve(pendingStore.markOutcomeUnknown(sessionId)).catch(() => undefined);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (error instanceof MachineSessionServiceFailure && error.code === "not_found") {
|
|
552
|
+
await pendingStore.delete(sessionId);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
await retryDelay(this.pendingMessageRetryMs);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
309
559
|
runtimeReadiness(runtime, cache) {
|
|
310
560
|
const cached = cache?.get(runtime);
|
|
311
561
|
if (cached)
|
|
@@ -368,6 +618,12 @@ function runtimeSetupReason(runtime, status) {
|
|
|
368
618
|
return status.issues.find((issue) => issue.trim().length > 0) ??
|
|
369
619
|
`${name} needs setup before it can start Sessions.`;
|
|
370
620
|
}
|
|
621
|
+
function retryDelay(ms) {
|
|
622
|
+
return new Promise((resolve) => {
|
|
623
|
+
const timer = setTimeout(resolve, ms);
|
|
624
|
+
timer.unref?.();
|
|
625
|
+
});
|
|
626
|
+
}
|
|
371
627
|
function listPage(sessions, hasMore, directoryTruncated) {
|
|
372
628
|
return {
|
|
373
629
|
sessions,
|
|
@@ -432,7 +688,7 @@ function snapshotPage(sessionId, items, runtime, hasMore) {
|
|
|
432
688
|
...(hasMore && items.length > 0 ? { nextAfterId: items.at(-1).id } : {}),
|
|
433
689
|
};
|
|
434
690
|
}
|
|
435
|
-
function sessionSummary(id, meta, log, runtimeState) {
|
|
691
|
+
function sessionSummary(id, meta, log, runtimeState, pendingState) {
|
|
436
692
|
const createdAt = meta?.createdAt ?? timestampFromEpoch(log?.createdAt, "createdAt");
|
|
437
693
|
const updatedAt = log
|
|
438
694
|
? timestampFromEpoch(log.updatedAt, "updatedAt")
|
|
@@ -443,6 +699,13 @@ function sessionSummary(id, meta, log, runtimeState) {
|
|
|
443
699
|
...(meta?.agent === undefined ? {} : { agent: meta.agent }),
|
|
444
700
|
...(meta?.title === undefined ? {} : { title: normalizeSessionTitle(meta.title) }),
|
|
445
701
|
status: runtimeState.snapshot(id).status,
|
|
702
|
+
...(pendingState === undefined
|
|
703
|
+
? {}
|
|
704
|
+
: {
|
|
705
|
+
setupStatus: pendingState === "queued"
|
|
706
|
+
? "waiting_for_provider"
|
|
707
|
+
: "delivery_unknown",
|
|
708
|
+
}),
|
|
446
709
|
createdAt: validTimestamp(createdAt, "createdAt"),
|
|
447
710
|
source: meta?.source ?? "unknown",
|
|
448
711
|
updatedAt: validTimestamp(updatedAt, "updatedAt"),
|
|
@@ -25,7 +25,7 @@ export interface RemoteRuntimeDispatcherOptions {
|
|
|
25
25
|
/** Optional diagnostic sink. Wire errors deliberately omit internal details. */
|
|
26
26
|
onError?: (error: unknown, request: RemoteRuntimeRpcRequest) => void;
|
|
27
27
|
}
|
|
28
|
-
export type RemoteRuntimeSessionHost = Pick<MachineSessionService, "list" | "snapshot" | "watch" | "interrupt" | "agentOptions" | "launchOptions" | "create" | "sendMessage" | "startTerminal" | "delete" | "resolveInteraction">;
|
|
28
|
+
export type RemoteRuntimeSessionHost = Pick<MachineSessionService, "list" | "snapshot" | "watch" | "interrupt" | "agentOptions" | "launchOptions" | "create" | "sendMessage" | "resourcePolicy" | "beginResourceUpload" | "writeResourceUploadChunk" | "commitResourceUpload" | "getResource" | "readResource" | "deleteResource" | "enqueueMessage" | "startTerminal" | "delete" | "resolveInteraction">;
|
|
29
29
|
export type RemoteRuntimeBrowserHost = Pick<SessionBrowserService, "getState" | "open" | "close" | "createPage" | "closePage" | "activatePage" | "navigatePage" | "goBack" | "goForward" | "reload">;
|
|
30
30
|
export type RemoteRuntimeSessionEmulatorHost = Pick<SessionEmulatorService, "getState" | "listDevices" | "attach" | "detach" | "tap" | "type" | "button" | "rotate" | "launch" | "gesture" | "releaseDevice" | "stopDevice">;
|
|
31
31
|
export interface OpenRemoteRuntimeSessionEvents {
|
|
@@ -103,7 +103,28 @@ export class RemoteRuntimeDispatcher {
|
|
|
103
103
|
case "session.create":
|
|
104
104
|
return await this.withSessions(request, (sessions) => sessions.create(request.params));
|
|
105
105
|
case "session.message.send":
|
|
106
|
-
return await this.withSessions(request, (sessions) =>
|
|
106
|
+
return await this.withSessions(request, (sessions) => {
|
|
107
|
+
const { sessionId, ...input } = request.params;
|
|
108
|
+
return sessions.sendMessage(sessionId, input.content === undefined && input.clientMessageId === undefined
|
|
109
|
+
? input.message
|
|
110
|
+
: input);
|
|
111
|
+
});
|
|
112
|
+
case "session.resource.policy.get":
|
|
113
|
+
return await this.withSessions(request, async (sessions) => sessions.resourcePolicy(request.params.sessionId));
|
|
114
|
+
case "session.resource.upload.begin":
|
|
115
|
+
return await this.withSessions(request, (sessions) => sessions.beginResourceUpload(request.params));
|
|
116
|
+
case "session.resource.upload.chunk":
|
|
117
|
+
return await this.withSessions(request, (sessions) => sessions.writeResourceUploadChunk(request.params));
|
|
118
|
+
case "session.resource.upload.commit":
|
|
119
|
+
return await this.withSessions(request, (sessions) => sessions.commitResourceUpload(request.params));
|
|
120
|
+
case "session.resource.get":
|
|
121
|
+
return await this.withSessions(request, (sessions) => sessions.getResource(request.params));
|
|
122
|
+
case "session.resource.read":
|
|
123
|
+
return await this.withSessions(request, (sessions) => sessions.readResource(request.params));
|
|
124
|
+
case "session.resource.delete":
|
|
125
|
+
return await this.withSessions(request, (sessions) => sessions.deleteResource(request.params));
|
|
126
|
+
case "session.message.enqueue":
|
|
127
|
+
return await this.withSessions(request, (sessions) => sessions.enqueueMessage(request.params.sessionId, request.params.message));
|
|
107
128
|
case "session.terminal.start":
|
|
108
129
|
return await this.withSessions(request, (sessions) => sessions.startTerminal(request.params.sessionId));
|
|
109
130
|
case "session.delete":
|
|
@@ -274,7 +295,9 @@ function mapHostError(request, error) {
|
|
|
274
295
|
? "Remote Session resource was not found"
|
|
275
296
|
: error.code === "failed_precondition"
|
|
276
297
|
? "Remote Session operation is unavailable"
|
|
277
|
-
:
|
|
298
|
+
: error.code === "conflict"
|
|
299
|
+
? "Remote Session resource conflicts with existing state"
|
|
300
|
+
: "Remote Session operation may have been applied";
|
|
278
301
|
return { code: error.code, message };
|
|
279
302
|
}
|
|
280
303
|
if (request.method === "session.interrupt") {
|
|
@@ -94,6 +94,16 @@ function projectRuntimeSnapshot(value) {
|
|
|
94
94
|
/** Project one canonical durable item, preserving every identity and array entry. */
|
|
95
95
|
export function projectRemoteRuntimeSessionItem(item) {
|
|
96
96
|
const projected = structuredClone(item);
|
|
97
|
+
// Older Traex sessions used an empty model string to mean "use the CLI
|
|
98
|
+
// default". Model is optional in the canonical item, but Direct Runtime's
|
|
99
|
+
// bounded wire string is deliberately non-empty; preserve the meaning by
|
|
100
|
+
// projecting that legacy sentinel as an absent optional field.
|
|
101
|
+
if ((projected.type === "message" ||
|
|
102
|
+
projected.type === "function_call" ||
|
|
103
|
+
projected.type === "reasoning") &&
|
|
104
|
+
projected.data.model === "") {
|
|
105
|
+
delete projected.data.model;
|
|
106
|
+
}
|
|
97
107
|
boundHistoricalInteractionRouting(projected);
|
|
98
108
|
const refs = collectItemPayloadRefs(projected);
|
|
99
109
|
boundPayloads(refs);
|
|
@@ -10,6 +10,10 @@ export interface RuntimeWebAuthorizationFailure {
|
|
|
10
10
|
status: 401 | 403;
|
|
11
11
|
error: "loopback_only" | "invalid_host" | "cross_origin" | "invalid_runtime_capability";
|
|
12
12
|
}
|
|
13
|
+
export interface RuntimeWebAccessPolicy {
|
|
14
|
+
mode: "loopback" | "host";
|
|
15
|
+
}
|
|
16
|
+
export declare function runtimeWebAccessPolicyForBindHost(bindHost: string): RuntimeWebAccessPolicy;
|
|
13
17
|
/**
|
|
14
18
|
* Authorize a browser request before any Runtime lease or terminal is opened.
|
|
15
19
|
* Socket address and the original Host header are both checked to resist DNS
|
|
@@ -18,4 +22,5 @@ export interface RuntimeWebAuthorizationFailure {
|
|
|
18
22
|
export declare function authorizeRuntimeWebRequest(request: IncomingMessage, options?: {
|
|
19
23
|
mutation?: boolean;
|
|
20
24
|
capability?: string;
|
|
25
|
+
access?: RuntimeWebAccessPolicy;
|
|
21
26
|
}): RuntimeWebAuthorizationFailure | undefined;
|
package/dist/runtime-web-auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isIP } from "node:net";
|
|
2
3
|
export const RUNTIME_WEB_CAPABILITY_HEADER = "x-rynx-runtime-capability";
|
|
3
4
|
export const RUNTIME_WEB_CAPABILITY_QUERY = "capability";
|
|
4
5
|
/**
|
|
@@ -6,17 +7,26 @@ export const RUNTIME_WEB_CAPABILITY_QUERY = "capability";
|
|
|
6
7
|
* browser WebSocket upgrades. It is not a daemon-management credential.
|
|
7
8
|
*/
|
|
8
9
|
export const RUNTIME_WEB_CAPABILITY = randomBytes(32).toString("base64url");
|
|
10
|
+
export function runtimeWebAccessPolicyForBindHost(bindHost) {
|
|
11
|
+
const normalized = normalizeAddress(bindHost.trim().replace(/^\[|\]$/g, ""))?.toLowerCase();
|
|
12
|
+
return {
|
|
13
|
+
mode: normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"
|
|
14
|
+
? "loopback"
|
|
15
|
+
: "host",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
9
18
|
/**
|
|
10
19
|
* Authorize a browser request before any Runtime lease or terminal is opened.
|
|
11
20
|
* Socket address and the original Host header are both checked to resist DNS
|
|
12
21
|
* rebinding; mutation metadata and the capability provide the CSRF fence.
|
|
13
22
|
*/
|
|
14
23
|
export function authorizeRuntimeWebRequest(request, options = {}) {
|
|
15
|
-
|
|
24
|
+
const access = options.access ?? { mode: "loopback" };
|
|
25
|
+
if (access.mode === "loopback" && !isLoopbackAddress(request.socket.remoteAddress)) {
|
|
16
26
|
return { status: 403, error: "loopback_only" };
|
|
17
27
|
}
|
|
18
28
|
const host = originalHostHeader(request);
|
|
19
|
-
if (!host || !
|
|
29
|
+
if (!host || !isAllowedHostAuthority(request, host, access)) {
|
|
20
30
|
return { status: 403, error: "invalid_host" };
|
|
21
31
|
}
|
|
22
32
|
if (!options.mutation)
|
|
@@ -30,9 +40,12 @@ export function authorizeRuntimeWebRequest(request, options = {}) {
|
|
|
30
40
|
return undefined;
|
|
31
41
|
}
|
|
32
42
|
function isLoopbackAddress(value) {
|
|
33
|
-
const normalized =
|
|
43
|
+
const normalized = normalizeAddress(value);
|
|
34
44
|
return normalized === "127.0.0.1" || normalized === "::1";
|
|
35
45
|
}
|
|
46
|
+
function normalizeAddress(value) {
|
|
47
|
+
return value?.startsWith("::ffff:") ? value.slice("::ffff:".length) : value;
|
|
48
|
+
}
|
|
36
49
|
/** Read the HTTP/1 Host header exactly as received; duplicate Hosts fail closed. */
|
|
37
50
|
function originalHostHeader(request) {
|
|
38
51
|
let host;
|
|
@@ -46,6 +59,17 @@ function originalHostHeader(request) {
|
|
|
46
59
|
}
|
|
47
60
|
return host;
|
|
48
61
|
}
|
|
62
|
+
function isAllowedHostAuthority(request, host, access) {
|
|
63
|
+
if (isLoopbackHostAuthority(host)) {
|
|
64
|
+
return isLoopbackAddress(request.socket.remoteAddress);
|
|
65
|
+
}
|
|
66
|
+
if (access.mode !== "host")
|
|
67
|
+
return false;
|
|
68
|
+
const hostAddress = ipHostAuthorityAddress(host);
|
|
69
|
+
const localAddress = normalizeAddress(request.socket.localAddress);
|
|
70
|
+
return hostAddress !== undefined && localAddress !== undefined &&
|
|
71
|
+
hostAddress.toLowerCase() === localAddress.toLowerCase();
|
|
72
|
+
}
|
|
49
73
|
function isLoopbackHostAuthority(value) {
|
|
50
74
|
if (value.length === 0 || value !== value.trim())
|
|
51
75
|
return false;
|
|
@@ -57,6 +81,22 @@ function isLoopbackHostAuthority(value) {
|
|
|
57
81
|
const port = Number(match[1]);
|
|
58
82
|
return Number.isInteger(port) && port >= 1 && port <= 65_535;
|
|
59
83
|
}
|
|
84
|
+
function ipHostAuthorityAddress(value) {
|
|
85
|
+
if (value.length === 0 || value !== value.trim())
|
|
86
|
+
return undefined;
|
|
87
|
+
const match = /^(?:([0-9.]+)|\[([0-9a-f:.]+)\])(?::(\d{1,5}))?$/i.exec(value);
|
|
88
|
+
if (!match)
|
|
89
|
+
return undefined;
|
|
90
|
+
const address = match[1] ?? match[2];
|
|
91
|
+
if (isIP(address) === 0)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (match[3] !== undefined) {
|
|
94
|
+
const port = Number(match[3]);
|
|
95
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return address;
|
|
99
|
+
}
|
|
60
100
|
function isSameOriginBrowserMutation(request, host) {
|
|
61
101
|
const fetchSite = singleHeader(request.headers["sec-fetch-site"]).trim().toLowerCase();
|
|
62
102
|
if (fetchSite && fetchSite !== "same-origin")
|