@rynx-ai/daemon 0.1.11-beta.30 → 0.1.11-beta.31
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/bundled-plugins/plugins/lark/package.json +1 -1
- package/dist/daemon-server.js +19 -5
- package/dist/db.js +22 -0
- package/dist/headless-browser-host.js +283 -34
- package/dist/plugin-host-rpc.d.ts +18 -1
- package/dist/plugin-host-rpc.js +129 -5
- package/dist/session-portal-grant-store.d.ts +27 -1
- package/dist/session-portal-grant-store.js +92 -17
- package/dist/session-resource-store.d.ts +4 -0
- package/dist/session-resource-store.js +22 -0
- package/dist/session-timeline-projection-store.d.ts +24 -0
- package/dist/session-timeline-projection-store.js +53 -0
- package/package.json +9 -9
package/dist/daemon-server.js
CHANGED
|
@@ -17,8 +17,8 @@ import { REMOTE_RUNTIME_CORE_PROTOCOL } from "@rynx-ai/protocol/remote-runtime";
|
|
|
17
17
|
import { RUNTIME_BROWSER_INSPECT_MAX_MESSAGE_BYTES, RUNTIME_BROWSER_INSPECT_SEMANTIC_CAPABILITY, } from "@rynx-ai/protocol/runtime-browser-inspect";
|
|
18
18
|
import { RUNTIME_BROWSER_SURFACE_SEMANTIC_CAPABILITY } from "@rynx-ai/protocol/runtime-browser-surface";
|
|
19
19
|
import { RUNTIME_EMULATOR_SURFACE_SEMANTIC_CAPABILITY } from "@rynx-ai/protocol/runtime-emulator-surface";
|
|
20
|
-
import { createDaemonRuntimeHost, createDesktopFirstSessionBrowserHost, createRunnerSessionTerminalHost, createSessionRuntimeServices, DesktopBrowserHostAbsentError, DesktopBrowserHostRegistry, DirectRuntimeConnectionHub, DirectRuntimeBrowserInspectHostError, MachineSessionService, MachineSessionServiceFailure, RemoteRuntimeDispatcher, SessionBrowserService, SessionBrowserServiceError, SessionBrowserSurfaceCoordinator, SessionEmulatorService, startServer, startDirectRuntimeServer, } from "@rynx-ai/server";
|
|
21
|
-
import { authorizeSessionPortalGrant, redeemSessionPortalTicket, } from "./session-portal-grant-store.js";
|
|
20
|
+
import { createDaemonRuntimeHost, createDesktopFirstSessionBrowserHost, createRunnerSessionTerminalHost, createSessionRuntimeServices, DesktopBrowserHostAbsentError, DesktopBrowserHostRegistry, DirectRuntimeConnectionHub, DirectRuntimeBrowserInspectHostError, MachineSessionService, MachineSessionServiceFailure, RemoteRuntimeDispatcher, SessionPendingInputIndex, SessionBrowserService, SessionBrowserServiceError, SessionBrowserSurfaceCoordinator, SessionEmulatorService, startServer, startDirectRuntimeServer, } from "@rynx-ai/server";
|
|
21
|
+
import { authorizeSessionPortalGrant, inspectSessionPortalGrant, onSessionPortalGrantRevoked, redeemSessionPortalTicket, revokeEmbeddedWritableSessionPortalGrants, } from "./session-portal-grant-store.js";
|
|
22
22
|
import { APP_BROWSER_HOST_BOOTSTRAP_TYPE, AppBrowserHostSupervisor, parseAppBrowserHostLaunchConfig, } from "./app-browser-host-supervisor.js";
|
|
23
23
|
import { buildControlDeps, listControlAgentModels } from "./control-deps.js";
|
|
24
24
|
import { createBrowserArtifactManagementService } from "./browser-artifact-management.js";
|
|
@@ -311,7 +311,9 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
|
|
|
311
311
|
"semantic.session.interaction.v1",
|
|
312
312
|
"semantic.project.manage.v1",
|
|
313
313
|
"semantic.provider-cli.status.v1",
|
|
314
|
-
...(runtimeBrowserSupported
|
|
314
|
+
...(runtimeBrowserSupported
|
|
315
|
+
? ["semantic.browser.v1", "semantic.browser.request-headers.v1"]
|
|
316
|
+
: []),
|
|
315
317
|
...(runtimeBrowserSupported && directHostingSupported
|
|
316
318
|
? [
|
|
317
319
|
RUNTIME_BROWSER_SURFACE_SEMANTIC_CAPABILITY,
|
|
@@ -387,6 +389,7 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
|
|
|
387
389
|
};
|
|
388
390
|
};
|
|
389
391
|
let machineSessions;
|
|
392
|
+
let sessionBrowsers;
|
|
390
393
|
const pluginRuntime = {
|
|
391
394
|
bindHostServices: (services) => pluginSupervisor.bindHostServices({
|
|
392
395
|
...services,
|
|
@@ -396,6 +399,7 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
|
|
|
396
399
|
publicKey: installationIdentity.publicKey,
|
|
397
400
|
sign: installationIdentity.sign,
|
|
398
401
|
},
|
|
402
|
+
sessionBrowserRequestHeaders: sessionBrowsers,
|
|
399
403
|
sessionLaunch: {
|
|
400
404
|
resolve: async (selection) => {
|
|
401
405
|
const workspace = await resolveSessionWorkspace(runtimeProjects, selection.projectId, selection.managedCwd, selection.workspace);
|
|
@@ -434,6 +438,7 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
|
|
|
434
438
|
const directConnections = new DirectRuntimeConnectionHub();
|
|
435
439
|
const sessionLog = new SqliteSessionLogStore();
|
|
436
440
|
const sessionResources = new SqliteSessionResourceStore();
|
|
441
|
+
const sessionPendingInputs = new SessionPendingInputIndex();
|
|
437
442
|
const sessionForks = new SqliteSessionForkStore();
|
|
438
443
|
const runtimeProjects = new SqliteRuntimeProjectStore();
|
|
439
444
|
const providerClis = new ProviderCliService();
|
|
@@ -446,6 +451,11 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
|
|
|
446
451
|
const sessionRuntimeServices = createSessionRuntimeServices({
|
|
447
452
|
config: appConfig,
|
|
448
453
|
sessionLog,
|
|
454
|
+
pendingInputs: sessionPendingInputs,
|
|
455
|
+
onPendingInputPersisted: (input) => sessionResources.markMessageMirrored(input.sessionId, input.clientMessageId, {
|
|
456
|
+
responseId: input.responseId,
|
|
457
|
+
messageItemId: input.messageItemId,
|
|
458
|
+
}),
|
|
449
459
|
sessionContextProvider: browserRunnerSessionContexts,
|
|
450
460
|
admissionOpen,
|
|
451
461
|
admissionReserve: reserveAdmission,
|
|
@@ -455,7 +465,7 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
|
|
|
455
465
|
const sessionBrowserHost = appBrowserHostSupervisor
|
|
456
466
|
? createSupervisedAppSessionBrowserHost(durableDesktopBrowserHost, appBrowserHostSupervisor)
|
|
457
467
|
: createDesktopFirstSessionBrowserHost(durableDesktopBrowserHost, createHeadlessBrowserHost());
|
|
458
|
-
|
|
468
|
+
sessionBrowsers = new SessionBrowserService({
|
|
459
469
|
host: sessionBrowserHost,
|
|
460
470
|
sessions: {
|
|
461
471
|
exists: async (sessionId) => Boolean(sessionRegistry.get(sessionId) ??
|
|
@@ -556,6 +566,7 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
|
|
|
556
566
|
list: () => control.listAgents(),
|
|
557
567
|
},
|
|
558
568
|
pendingMessages: sessionPendingMessageStore,
|
|
569
|
+
pendingInputs: sessionPendingInputs,
|
|
559
570
|
resources: sessionResources,
|
|
560
571
|
forks: sessionForks,
|
|
561
572
|
execution: {
|
|
@@ -769,6 +780,7 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
|
|
|
769
780
|
status: async () => asDaemonChromeInspectionStatus(await initializedChromeInspectionManager.status()),
|
|
770
781
|
configure: async (input) => asDaemonChromeInspectionStatus(await initializedChromeInspectionManager.configure(input)),
|
|
771
782
|
};
|
|
783
|
+
revokeEmbeddedWritableSessionPortalGrants();
|
|
772
784
|
server = await startServer({
|
|
773
785
|
config: appConfig,
|
|
774
786
|
control,
|
|
@@ -801,8 +813,10 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
|
|
|
801
813
|
daemonInstanceId,
|
|
802
814
|
},
|
|
803
815
|
sessionPortalAuthorization: {
|
|
804
|
-
redeem: redeemSessionPortalTicket,
|
|
816
|
+
redeem: (ticket, embedOrigin) => redeemSessionPortalTicket(ticket, new Date(), embedOrigin),
|
|
805
817
|
authorize: authorizeSessionPortalGrant,
|
|
818
|
+
inspect: inspectSessionPortalGrant,
|
|
819
|
+
subscribeRevocations: onSessionPortalGrantRevoked,
|
|
806
820
|
ownsSession: sessionBelongsToPlugin,
|
|
807
821
|
},
|
|
808
822
|
});
|
package/dist/db.js
CHANGED
|
@@ -282,6 +282,17 @@ function migrate(conn) {
|
|
|
282
282
|
);
|
|
283
283
|
CREATE INDEX IF NOT EXISTS idx_session_portal_grants_expires
|
|
284
284
|
ON session_portal_grants(expires_at);
|
|
285
|
+
CREATE TABLE IF NOT EXISTS session_timeline_projections (
|
|
286
|
+
plugin_id TEXT NOT NULL,
|
|
287
|
+
session_id TEXT NOT NULL,
|
|
288
|
+
projection_id TEXT NOT NULL,
|
|
289
|
+
request_hash TEXT NOT NULL,
|
|
290
|
+
response_id TEXT NOT NULL,
|
|
291
|
+
item_id TEXT NOT NULL UNIQUE,
|
|
292
|
+
state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'completed')),
|
|
293
|
+
created_at TEXT NOT NULL,
|
|
294
|
+
PRIMARY KEY(plugin_id, session_id, projection_id)
|
|
295
|
+
);
|
|
285
296
|
CREATE TABLE IF NOT EXISTS runtime_pairing_claims (
|
|
286
297
|
claim_id TEXT PRIMARY KEY,
|
|
287
298
|
secret_hash TEXT NOT NULL,
|
|
@@ -359,6 +370,17 @@ function migrate(conn) {
|
|
|
359
370
|
addColumnIfMissing(conn, "session_message_operations", "response_id", "TEXT");
|
|
360
371
|
addColumnIfMissing(conn, "session_message_operations", "message_item_id", "TEXT");
|
|
361
372
|
addColumnIfMissing(conn, "session_message_operations", "execution_snapshot", "TEXT");
|
|
373
|
+
addColumnIfMissing(conn, "session_portal_tickets", "grant_handle", "TEXT");
|
|
374
|
+
addColumnIfMissing(conn, "session_portal_tickets", "embed_origin", "TEXT");
|
|
375
|
+
addColumnIfMissing(conn, "session_portal_grants", "grant_handle", "TEXT");
|
|
376
|
+
addColumnIfMissing(conn, "session_portal_grants", "embed_origin", "TEXT");
|
|
377
|
+
addColumnIfMissing(conn, "session_timeline_projections", "state", "TEXT NOT NULL DEFAULT 'pending'");
|
|
378
|
+
conn.exec(`
|
|
379
|
+
CREATE INDEX IF NOT EXISTS idx_session_portal_tickets_grant_handle
|
|
380
|
+
ON session_portal_tickets(plugin_id, grant_handle);
|
|
381
|
+
CREATE INDEX IF NOT EXISTS idx_session_portal_grants_grant_handle
|
|
382
|
+
ON session_portal_grants(plugin_id, grant_handle);
|
|
383
|
+
`);
|
|
362
384
|
}
|
|
363
385
|
/** Rebuild the original image-only table without losing committed resources. */
|
|
364
386
|
function migrateSessionResourceFiles(conn) {
|
|
@@ -34,6 +34,14 @@ const DEVTOOLS_BROWSER_PATH = /^\/devtools\/browser\/[A-Za-z0-9._~-]+$/;
|
|
|
34
34
|
const DEVTOOLS_TARGET_ID_PATTERN = /^[A-Za-z0-9._~-]+$/;
|
|
35
35
|
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
36
36
|
const CONTROL_CHARACTER_GLOBAL_PATTERN = /[\u0000-\u001f\u007f]/g;
|
|
37
|
+
const NETWORK_AUTO_ATTACH_FILTER = [
|
|
38
|
+
{ type: "page" },
|
|
39
|
+
{ type: "iframe" },
|
|
40
|
+
{ type: "worker" },
|
|
41
|
+
{ type: "service_worker" },
|
|
42
|
+
{ type: "shared_worker" },
|
|
43
|
+
{ exclude: true },
|
|
44
|
+
];
|
|
37
45
|
export function createHeadlessBrowserHost(options = {}) {
|
|
38
46
|
return new HeadlessBrowserHost(options);
|
|
39
47
|
}
|
|
@@ -95,7 +103,7 @@ class HeadlessBrowserHost {
|
|
|
95
103
|
throw error;
|
|
96
104
|
}
|
|
97
105
|
const ownerToken = randomBytes(32).toString("hex");
|
|
98
|
-
const args = chromeArguments(profileDir, ownerToken
|
|
106
|
+
const args = chromeArguments(profileDir, ownerToken);
|
|
99
107
|
let child;
|
|
100
108
|
try {
|
|
101
109
|
child = spawn(build.executablePath, args, {
|
|
@@ -198,6 +206,7 @@ class HeadlessBrowserHandle {
|
|
|
198
206
|
closingTargetIds = new Set();
|
|
199
207
|
pageSessions = new Map();
|
|
200
208
|
targetBySession = new Map();
|
|
209
|
+
networkTargetTypeBySession = new Map();
|
|
201
210
|
surfaceSources = new Map();
|
|
202
211
|
loadingTargets = new Set();
|
|
203
212
|
activeTargetId = null;
|
|
@@ -205,8 +214,11 @@ class HeadlessBrowserHandle {
|
|
|
205
214
|
closing = false;
|
|
206
215
|
closePromise;
|
|
207
216
|
operations = Promise.resolve();
|
|
217
|
+
networkOperations = Promise.resolve();
|
|
218
|
+
requestHeaders;
|
|
208
219
|
constructor(host) {
|
|
209
220
|
this.host = host;
|
|
221
|
+
this.requestHeaders = cloneRequestHeadersConfig(host.input.requestHeaders ?? { enabled: false, headers: [] });
|
|
210
222
|
this.events = this.eventQueue;
|
|
211
223
|
this.host.connection.setEventHandler((event) => this.onCdpEvent(event));
|
|
212
224
|
this.host.connection.setDisconnectHandler((reason) => this.markUnavailable(reason));
|
|
@@ -223,21 +235,22 @@ class HeadlessBrowserHandle {
|
|
|
223
235
|
}
|
|
224
236
|
async initialize() {
|
|
225
237
|
await this.host.connection.command("Target.setDiscoverTargets", { discover: true });
|
|
226
|
-
await this.
|
|
238
|
+
await this.host.connection.command("Target.setAutoAttach", {
|
|
239
|
+
autoAttach: true,
|
|
240
|
+
waitForDebuggerOnStart: true,
|
|
241
|
+
flatten: true,
|
|
242
|
+
filter: NETWORK_AUTO_ATTACH_FILTER,
|
|
243
|
+
});
|
|
244
|
+
await this.createFreshStartupPage();
|
|
227
245
|
await this.refreshTargets();
|
|
228
246
|
const requestedUrl = this.host.input.url;
|
|
229
247
|
if (requestedUrl) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
await this.host.connection.command("Target.activateTarget", {
|
|
237
|
-
targetId: requestedTarget.targetId,
|
|
238
|
-
});
|
|
239
|
-
this.activeTargetId = requestedTarget.targetId;
|
|
240
|
-
}
|
|
248
|
+
const targetId = this.activeTargetId;
|
|
249
|
+
if (!targetId)
|
|
250
|
+
throw new Error("Headless Browser startup Page is unavailable");
|
|
251
|
+
const pageSessionId = await this.pageSession(targetId);
|
|
252
|
+
await this.host.connection.command("Page.navigate", { url: requestedUrl }, pageSessionId);
|
|
253
|
+
await this.resetPageNavigationHistory(targetId);
|
|
241
254
|
}
|
|
242
255
|
}
|
|
243
256
|
async getCdpEndpoint() {
|
|
@@ -336,10 +349,17 @@ class HeadlessBrowserHandle {
|
|
|
336
349
|
if (this.targets.size >= RUNTIME_BROWSER_MAX_PAGES) {
|
|
337
350
|
throw new SessionBrowserHostError("capacity", "Headless Browser Page capacity was reached");
|
|
338
351
|
}
|
|
339
|
-
const result = await this.host.connection.command("Target.createTarget", {
|
|
352
|
+
const result = await this.host.connection.command("Target.createTarget", {
|
|
353
|
+
url: "about:blank",
|
|
354
|
+
});
|
|
340
355
|
const targetId = requiredCdpId(result.targetId, "Target.createTarget targetId");
|
|
341
356
|
this.activeTargetId = targetId;
|
|
342
357
|
await this.refreshTargets();
|
|
358
|
+
if (url !== "about:blank") {
|
|
359
|
+
const pageSessionId = await this.pageSession(targetId);
|
|
360
|
+
await this.host.connection.command("Page.navigate", { url }, pageSessionId);
|
|
361
|
+
await this.resetPageNavigationHistory(targetId);
|
|
362
|
+
}
|
|
343
363
|
this.eventQueue.changed(this.browserGeneration);
|
|
344
364
|
}
|
|
345
365
|
catch (error) {
|
|
@@ -395,6 +415,18 @@ class HeadlessBrowserHandle {
|
|
|
395
415
|
reload(hostPageId) {
|
|
396
416
|
return this.pageCommand(hostPageId, "Page.reload", {});
|
|
397
417
|
}
|
|
418
|
+
setRequestHeaders(config) {
|
|
419
|
+
return this.exclusive(async () => {
|
|
420
|
+
this.ensureAvailable();
|
|
421
|
+
await this.networkExclusive(async () => {
|
|
422
|
+
// Every target is permanently Fetch-intercepted before it is resumed.
|
|
423
|
+
// Swapping this immutable snapshot is therefore the single commit
|
|
424
|
+
// point for all current and future targets; already-paused/in-flight
|
|
425
|
+
// requests keep the snapshot they started with.
|
|
426
|
+
this.requestHeaders = cloneRequestHeadersConfig(config);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
398
430
|
close() {
|
|
399
431
|
if (this.closePromise)
|
|
400
432
|
return this.closePromise;
|
|
@@ -434,6 +466,24 @@ class HeadlessBrowserHandle {
|
|
|
434
466
|
}
|
|
435
467
|
});
|
|
436
468
|
}
|
|
469
|
+
async resetPageNavigationHistory(targetId) {
|
|
470
|
+
let lastError;
|
|
471
|
+
for (let attempt = 0; attempt < PAGE_SNAPSHOT_TRANSITION_ATTEMPTS; attempt += 1) {
|
|
472
|
+
const sessionId = await this.pageSession(targetId);
|
|
473
|
+
try {
|
|
474
|
+
await this.host.connection.command("Page.resetNavigationHistory", {}, sessionId);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
if (!isMissingTargetError(error) && !isTransientPageTransitionError(error))
|
|
479
|
+
throw error;
|
|
480
|
+
lastError = error;
|
|
481
|
+
this.dropAttachedSession(sessionId);
|
|
482
|
+
await delay(PAGE_SNAPSHOT_TRANSITION_POLL_MS);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
throw lastError ?? new Error("Headless Browser could not reset Page navigation history");
|
|
486
|
+
}
|
|
437
487
|
pageCommand(hostPageId, method, params) {
|
|
438
488
|
return this.exclusive(async () => {
|
|
439
489
|
this.ensureAvailable();
|
|
@@ -491,13 +541,23 @@ class HeadlessBrowserHandle {
|
|
|
491
541
|
throw this.mapOperationError(error);
|
|
492
542
|
}
|
|
493
543
|
}
|
|
494
|
-
async
|
|
544
|
+
async createFreshStartupPage() {
|
|
495
545
|
const initialPages = await this.readPageTargets();
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
546
|
+
for (const page of initialPages) {
|
|
547
|
+
try {
|
|
548
|
+
await this.host.connection.command("Target.closeTarget", { targetId: page.targetId });
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
if (!isMissingPageError(error))
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const created = await this.host.connection.command("Target.createTarget", {
|
|
556
|
+
url: "about:blank",
|
|
557
|
+
});
|
|
499
558
|
const freshTargetId = requiredCdpId(created.targetId, "Target.createTarget targetId");
|
|
500
559
|
this.activeTargetId = freshTargetId;
|
|
560
|
+
await this.host.connection.command("Target.activateTarget", { targetId: freshTargetId });
|
|
501
561
|
for (let attempt = 0; attempt < STARTUP_PAGE_CLEANUP_ATTEMPTS; attempt += 1) {
|
|
502
562
|
const pages = await this.readPageTargets();
|
|
503
563
|
if (!pages.some((page) => page.targetId === freshTargetId)) {
|
|
@@ -581,11 +641,21 @@ class HeadlessBrowserHandle {
|
|
|
581
641
|
canGoForward: false,
|
|
582
642
|
};
|
|
583
643
|
}
|
|
584
|
-
async pageSession(targetId) {
|
|
644
|
+
async pageSession(targetId, retryMissing = true) {
|
|
585
645
|
this.requireTarget(targetId);
|
|
586
646
|
const existing = this.pageSessions.get(targetId);
|
|
587
|
-
if (existing)
|
|
588
|
-
|
|
647
|
+
if (existing) {
|
|
648
|
+
try {
|
|
649
|
+
return await existing;
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
if (!retryMissing || !isMissingPageError(error))
|
|
653
|
+
throw error;
|
|
654
|
+
if (this.pageSessions.get(targetId) === existing)
|
|
655
|
+
this.pageSessions.delete(targetId);
|
|
656
|
+
return this.pageSession(targetId, false);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
589
659
|
const pending = (async () => {
|
|
590
660
|
const result = await this.host.connection.command("Target.attachToTarget", {
|
|
591
661
|
targetId,
|
|
@@ -593,7 +663,8 @@ class HeadlessBrowserHandle {
|
|
|
593
663
|
});
|
|
594
664
|
const sessionId = requiredCdpId(result.sessionId, "Target.attachToTarget sessionId");
|
|
595
665
|
this.targetBySession.set(sessionId, targetId);
|
|
596
|
-
|
|
666
|
+
this.networkTargetTypeBySession.set(sessionId, "page");
|
|
667
|
+
await this.networkExclusive(() => this.initializeAttachedTarget(sessionId, this.requireTarget(targetId), false));
|
|
597
668
|
return sessionId;
|
|
598
669
|
})();
|
|
599
670
|
this.pageSessions.set(targetId, pending);
|
|
@@ -608,6 +679,67 @@ class HeadlessBrowserHandle {
|
|
|
608
679
|
onCdpEvent(event) {
|
|
609
680
|
if (this.closing || this.unavailableReason)
|
|
610
681
|
return;
|
|
682
|
+
if (event.method === "Fetch.requestPaused" && event.sessionId) {
|
|
683
|
+
void this.continuePausedRequest(event.sessionId, event.params).catch((error) => {
|
|
684
|
+
if (isMissingFetchRequestError(error))
|
|
685
|
+
return;
|
|
686
|
+
if (isMissingTargetError(error) || isTransientPageTransitionError(error)) {
|
|
687
|
+
this.dropAttachedSession(event.sessionId);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
this.markUnavailable("Headless Browser could not continue an intercepted request");
|
|
691
|
+
});
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (event.method === "Target.attachedToTarget") {
|
|
695
|
+
const sessionId = optionalCdpId(event.params.sessionId);
|
|
696
|
+
const target = parseTargetInfo(event.params.targetInfo);
|
|
697
|
+
if (!sessionId)
|
|
698
|
+
return;
|
|
699
|
+
const waitingForDebugger = event.params.waitingForDebugger === true;
|
|
700
|
+
this.targetBySession.set(sessionId, target.targetId);
|
|
701
|
+
this.networkTargetTypeBySession.set(sessionId, target.type);
|
|
702
|
+
if (target.type === "page") {
|
|
703
|
+
this.targets.set(target.targetId, target);
|
|
704
|
+
let pending;
|
|
705
|
+
pending = this.networkExclusive(() => this.initializeAttachedTarget(sessionId, target, waitingForDebugger))
|
|
706
|
+
.then(async () => {
|
|
707
|
+
const selected = this.pageSessions.get(target.targetId);
|
|
708
|
+
if (selected && selected !== pending) {
|
|
709
|
+
let selectedSessionId;
|
|
710
|
+
try {
|
|
711
|
+
selectedSessionId = await selected;
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
// The next Page operation will establish a replacement.
|
|
715
|
+
}
|
|
716
|
+
if (selectedSessionId !== sessionId &&
|
|
717
|
+
this.targetBySession.get(sessionId) === target.targetId) {
|
|
718
|
+
await this.networkExclusive(() => this.detachAuxiliaryPageSession(sessionId));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return sessionId;
|
|
722
|
+
})
|
|
723
|
+
.catch((error) => {
|
|
724
|
+
if (this.pageSessions.get(target.targetId) === pending) {
|
|
725
|
+
this.pageSessions.delete(target.targetId);
|
|
726
|
+
}
|
|
727
|
+
throw error;
|
|
728
|
+
});
|
|
729
|
+
void pending.catch(() => undefined);
|
|
730
|
+
// Chrome may deliver a waitForDebugger auto-attach session while a
|
|
731
|
+
// manual Target.attachToTarget for the same Page is still pending.
|
|
732
|
+
// Every physical session must be initialized and resumed even when a
|
|
733
|
+
// different session already owns semantic Page commands.
|
|
734
|
+
if (!this.pageSessions.has(target.targetId)) {
|
|
735
|
+
this.pageSessions.set(target.targetId, pending);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
void this.networkExclusive(() => this.initializeAttachedTarget(sessionId, target, waitingForDebugger)).catch(() => undefined);
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
611
743
|
if (event.method === "Target.targetCreated" || event.method === "Target.targetInfoChanged") {
|
|
612
744
|
const target = parseTargetInfo(event.params.targetInfo);
|
|
613
745
|
if (target.type !== "page")
|
|
@@ -640,7 +772,7 @@ class HeadlessBrowserHandle {
|
|
|
640
772
|
if (event.method === "Target.detachedFromTarget") {
|
|
641
773
|
const sessionId = optionalCdpId(event.params.sessionId);
|
|
642
774
|
if (sessionId)
|
|
643
|
-
this.
|
|
775
|
+
this.dropAttachedSession(sessionId);
|
|
644
776
|
return;
|
|
645
777
|
}
|
|
646
778
|
const targetId = event.sessionId ? this.targetBySession.get(event.sessionId) : undefined;
|
|
@@ -683,22 +815,106 @@ class HeadlessBrowserHandle {
|
|
|
683
815
|
this.loadingTargets.delete(targetId);
|
|
684
816
|
const session = this.pageSessions.get(targetId);
|
|
685
817
|
this.pageSessions.delete(targetId);
|
|
818
|
+
for (const [sessionId, attachedTargetId] of this.targetBySession) {
|
|
819
|
+
if (attachedTargetId === targetId)
|
|
820
|
+
this.dropAttachedSession(sessionId);
|
|
821
|
+
}
|
|
822
|
+
// A manual attach can resolve after targetDestroyed. Clean the late
|
|
823
|
+
// session as well instead of retaining it in the Network update set.
|
|
686
824
|
if (session)
|
|
687
|
-
void session.then((id) => this.
|
|
825
|
+
void session.then((id) => this.dropAttachedSession(id), () => undefined);
|
|
688
826
|
}
|
|
689
827
|
dropPageSession(sessionId) {
|
|
690
828
|
const targetId = this.targetBySession.get(sessionId);
|
|
829
|
+
this.targetBySession.delete(sessionId);
|
|
830
|
+
this.networkTargetTypeBySession.delete(sessionId);
|
|
691
831
|
if (!targetId)
|
|
692
832
|
return;
|
|
693
|
-
this.
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
833
|
+
const selected = this.pageSessions.get(targetId);
|
|
834
|
+
if (selected) {
|
|
835
|
+
void selected.then((selectedSessionId) => {
|
|
836
|
+
if (selectedSessionId === sessionId &&
|
|
837
|
+
this.pageSessions.get(targetId) === selected) {
|
|
838
|
+
this.pageSessions.delete(targetId);
|
|
839
|
+
const surface = this.surfaceSources.get(targetId);
|
|
840
|
+
if (surface) {
|
|
841
|
+
this.surfaceSources.delete(targetId);
|
|
842
|
+
surface.closeAfterSourceLoss();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}, () => undefined);
|
|
699
846
|
}
|
|
700
847
|
this.eventQueue.changed(this.browserGeneration);
|
|
701
848
|
}
|
|
849
|
+
dropAttachedSession(sessionId) {
|
|
850
|
+
if (this.networkTargetTypeBySession.get(sessionId) === "page") {
|
|
851
|
+
this.dropPageSession(sessionId);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
this.networkTargetTypeBySession.delete(sessionId);
|
|
855
|
+
this.targetBySession.delete(sessionId);
|
|
856
|
+
}
|
|
857
|
+
async initializeAttachedTarget(sessionId, target, waitingForDebugger) {
|
|
858
|
+
try {
|
|
859
|
+
await this.host.connection.command("Network.enable", {}, sessionId);
|
|
860
|
+
await this.host.connection.command("Fetch.enable", {
|
|
861
|
+
patterns: [{ urlPattern: "*", requestStage: "Request" }],
|
|
862
|
+
}, sessionId);
|
|
863
|
+
if (target.type === "page" || target.type === "iframe") {
|
|
864
|
+
await this.host.connection.command("Target.setAutoAttach", {
|
|
865
|
+
autoAttach: true,
|
|
866
|
+
waitForDebuggerOnStart: true,
|
|
867
|
+
flatten: true,
|
|
868
|
+
filter: NETWORK_AUTO_ATTACH_FILTER,
|
|
869
|
+
}, sessionId);
|
|
870
|
+
}
|
|
871
|
+
if (target.type === "page") {
|
|
872
|
+
await this.host.connection.command("Page.enable", {}, sessionId);
|
|
873
|
+
}
|
|
874
|
+
if (waitingForDebugger) {
|
|
875
|
+
await this.host.connection.command("Runtime.runIfWaitingForDebugger", {}, sessionId);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
if (isMissingTargetError(error) || isTransientPageTransitionError(error)) {
|
|
880
|
+
this.dropAttachedSession(sessionId);
|
|
881
|
+
throw missingPage(error);
|
|
882
|
+
}
|
|
883
|
+
this.markUnavailable("Headless Browser could not initialize a network target");
|
|
884
|
+
throw this.mapOperationError(error);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
async detachAuxiliaryPageSession(sessionId) {
|
|
888
|
+
try {
|
|
889
|
+
await this.host.connection.command("Target.detachFromTarget", { sessionId });
|
|
890
|
+
}
|
|
891
|
+
catch (error) {
|
|
892
|
+
if (!isMissingTargetError(error) && !isTransientPageTransitionError(error)) {
|
|
893
|
+
this.markUnavailable("Headless Browser could not fence a duplicate Page session");
|
|
894
|
+
throw this.mapOperationError(error);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
finally {
|
|
898
|
+
this.dropAttachedSession(sessionId);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
async continuePausedRequest(sessionId, params) {
|
|
902
|
+
const requestId = requiredCdpId(params.requestId, "Fetch.requestPaused requestId");
|
|
903
|
+
const config = this.requestHeaders;
|
|
904
|
+
const effective = config.enabled && config.headers.length > 0;
|
|
905
|
+
const request = effective && isRecord(params.request) ? params.request : {};
|
|
906
|
+
// This is a product-level baseline, not a security boundary: a caller
|
|
907
|
+
// holding the privileged native CDP endpoint can alter interception.
|
|
908
|
+
await this.host.connection.command("Fetch.continueRequest", {
|
|
909
|
+
requestId,
|
|
910
|
+
// Fetch treats `headers` as a full override. Omitting it is the only
|
|
911
|
+
// true pass-through path for disabled/empty policy and preserves
|
|
912
|
+
// Browser-managed Cookie, auth, redirect, and duplicate headers.
|
|
913
|
+
...(effective
|
|
914
|
+
? { headers: mergePausedRequestHeaders(pausedRequestHeaders(request.headers), config) }
|
|
915
|
+
: {}),
|
|
916
|
+
}, sessionId);
|
|
917
|
+
}
|
|
702
918
|
ensureAvailable() {
|
|
703
919
|
if (this.unavailableReason || this.closing || !this.host.connection.connected) {
|
|
704
920
|
throw new SessionBrowserHostError("unavailable", "Headless Browser is unavailable");
|
|
@@ -729,6 +945,11 @@ class HeadlessBrowserHandle {
|
|
|
729
945
|
this.operations = result.then(() => undefined, () => undefined);
|
|
730
946
|
return result;
|
|
731
947
|
}
|
|
948
|
+
networkExclusive(operation) {
|
|
949
|
+
const result = this.networkOperations.then(operation, operation);
|
|
950
|
+
this.networkOperations = result.then(() => undefined, () => undefined);
|
|
951
|
+
return result;
|
|
952
|
+
}
|
|
732
953
|
}
|
|
733
954
|
class HeadlessBrowserSurfaceSource {
|
|
734
955
|
source;
|
|
@@ -1476,7 +1697,7 @@ class CdpConnection {
|
|
|
1476
1697
|
this.pending.delete(id);
|
|
1477
1698
|
rejectCommand(new CdpCommandTimeoutError(`Native CDP command ${method} timed out`));
|
|
1478
1699
|
}, this.commandTimeoutMs);
|
|
1479
|
-
this.pending.set(id, { resolve: resolveCommand, reject: rejectCommand, timer });
|
|
1700
|
+
this.pending.set(id, { method, resolve: resolveCommand, reject: rejectCommand, timer });
|
|
1480
1701
|
try {
|
|
1481
1702
|
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
1482
1703
|
}
|
|
@@ -1518,7 +1739,9 @@ class CdpConnection {
|
|
|
1518
1739
|
this.pending.delete(value.id);
|
|
1519
1740
|
clearTimeout(pending.timer);
|
|
1520
1741
|
if (isRecord(value.error)) {
|
|
1521
|
-
pending.reject(new CdpProtocolError(typeof value.error.code === "number" ? value.error.code : -1,
|
|
1742
|
+
pending.reject(new CdpProtocolError(typeof value.error.code === "number" ? value.error.code : -1, `Native CDP command ${pending.method} failed: ${typeof value.error.message === "string"
|
|
1743
|
+
? value.error.message
|
|
1744
|
+
: "Native CDP command failed"}`));
|
|
1522
1745
|
}
|
|
1523
1746
|
else {
|
|
1524
1747
|
pending.resolve(value.result ?? {});
|
|
@@ -1887,7 +2110,7 @@ function readSmallRegularJson(path, maxBytes, label) {
|
|
|
1887
2110
|
closeSync(descriptor);
|
|
1888
2111
|
}
|
|
1889
2112
|
}
|
|
1890
|
-
function chromeArguments(profileDir, ownerToken
|
|
2113
|
+
function chromeArguments(profileDir, ownerToken) {
|
|
1891
2114
|
return [
|
|
1892
2115
|
"--headless=new",
|
|
1893
2116
|
"--use-mock-keychain",
|
|
@@ -1905,7 +2128,29 @@ function chromeArguments(profileDir, ownerToken, url) {
|
|
|
1905
2128
|
"--metrics-recording-only",
|
|
1906
2129
|
"--no-report-upload",
|
|
1907
2130
|
"--window-size=1280,800",
|
|
1908
|
-
|
|
2131
|
+
"--no-startup-window",
|
|
2132
|
+
];
|
|
2133
|
+
}
|
|
2134
|
+
function cloneRequestHeadersConfig(config) {
|
|
2135
|
+
return {
|
|
2136
|
+
enabled: config.enabled,
|
|
2137
|
+
headers: config.headers.map((header) => ({ ...header })),
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
function pausedRequestHeaders(value) {
|
|
2141
|
+
if (!isRecord(value))
|
|
2142
|
+
return [];
|
|
2143
|
+
return Object.entries(value).flatMap(([name, headerValue]) => typeof headerValue === "string" ? [{ name, value: headerValue }] : []);
|
|
2144
|
+
}
|
|
2145
|
+
function mergePausedRequestHeaders(original, config) {
|
|
2146
|
+
if (!config.enabled)
|
|
2147
|
+
return original.map((header) => ({ ...header }));
|
|
2148
|
+
const overriddenNames = new Set(config.headers.map((header) => header.name.toLowerCase()));
|
|
2149
|
+
return [
|
|
2150
|
+
...original
|
|
2151
|
+
.filter((header) => !overriddenNames.has(header.name.toLowerCase()))
|
|
2152
|
+
.map((header) => ({ ...header })),
|
|
2153
|
+
...config.headers.map((header) => ({ ...header })),
|
|
1909
2154
|
];
|
|
1910
2155
|
}
|
|
1911
2156
|
function validateActiveExecutable(build) {
|
|
@@ -2118,6 +2363,10 @@ function isTransientPageTransitionError(error) {
|
|
|
2118
2363
|
/cannot find (?:an )?execution context/i.test(error.message) ||
|
|
2119
2364
|
/cannot find context with specified id/i.test(error.message));
|
|
2120
2365
|
}
|
|
2366
|
+
function isMissingFetchRequestError(error) {
|
|
2367
|
+
return error instanceof CdpProtocolError && (/invalid (?:interception|request)id/i.test(error.message) ||
|
|
2368
|
+
/no resource with given identifier/i.test(error.message));
|
|
2369
|
+
}
|
|
2121
2370
|
function isMissingPageError(error) {
|
|
2122
2371
|
return (error instanceof SessionBrowserHostError && error.code === "page_not_found") || isMissingTargetError(error);
|
|
2123
2372
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type AdmissionReservation, type AgentSpec, type AgentCapabilities, type AgentRuntimeId, type AgentSessionStore, type ResolvedExecutionSnapshot, type RuntimeCollaborationMode, type SessionRegistry, type SessionWorkspaceSnapshot, type SteerSignal } from "@rynx-ai/core";
|
|
2
2
|
import { type PluginAgentSummary, type PluginHostEvent, type PluginSessionActivitySnapshot, type PluginResolvedSessionExecution, type PluginSessionExecutionSnapshot, type PluginSessionInteractionResolveDisposition } from "@rynx-ai/plugin-sdk";
|
|
3
3
|
import type { SessionInteractionResolution } from "@rynx-ai/protocol";
|
|
4
|
-
import type
|
|
4
|
+
import { type RuntimeBrowserRequestHeadersGetParams, type RuntimeBrowserRequestHeadersPolicy, type RuntimeBrowserRequestHeadersSetParams } from "@rynx-ai/protocol/runtime-browser";
|
|
5
|
+
import { type MachineSessionMessageInput, type StartedMachineSessionRun } from "@rynx-ai/server";
|
|
5
6
|
import { PluginRunnerError } from "@rynx-ai/plugin-runner";
|
|
6
7
|
/** Leaves ample room for the RPC envelope below the runner's 1 MiB line cap. */
|
|
7
8
|
export declare const MAX_PLUGIN_HOST_EVENT_BYTES: number;
|
|
@@ -48,6 +49,10 @@ export interface PluginHostServices {
|
|
|
48
49
|
sessionFiles: {
|
|
49
50
|
resolveResourcePath(sessionId: string, resourceId: string): Promise<string>;
|
|
50
51
|
};
|
|
52
|
+
sessionBrowserRequestHeaders?: {
|
|
53
|
+
getRequestHeaders(input: RuntimeBrowserRequestHeadersGetParams): Promise<RuntimeBrowserRequestHeadersPolicy>;
|
|
54
|
+
setRequestHeaders(input: RuntimeBrowserRequestHeadersSetParams): Promise<RuntimeBrowserRequestHeadersPolicy>;
|
|
55
|
+
};
|
|
51
56
|
capabilities: AgentCapabilities;
|
|
52
57
|
sessionStore: AgentSessionStore;
|
|
53
58
|
sessionRegistry: SessionRegistry;
|
|
@@ -65,6 +70,15 @@ export interface PluginHostServices {
|
|
|
65
70
|
sessionActivity(sessionId: string): PluginSessionActivitySnapshot | Promise<PluginSessionActivitySnapshot>;
|
|
66
71
|
interruptSession?(sessionId: string): Promise<boolean>;
|
|
67
72
|
terminateSession?(sessionId: string): Promise<boolean>;
|
|
73
|
+
sessionTimeline?: {
|
|
74
|
+
hasItem(sessionId: string, itemId: string): Promise<boolean>;
|
|
75
|
+
projectText(input: {
|
|
76
|
+
sessionId: string;
|
|
77
|
+
responseId: string;
|
|
78
|
+
itemId: string;
|
|
79
|
+
text: string;
|
|
80
|
+
}): Promise<void>;
|
|
81
|
+
};
|
|
68
82
|
resolveSessionInteraction?(sessionId: string, interactionId: string, resolution: SessionInteractionResolution): Promise<{
|
|
69
83
|
disposition: PluginSessionInteractionResolveDisposition;
|
|
70
84
|
}>;
|
|
@@ -150,9 +164,12 @@ export declare class PluginHostRpc {
|
|
|
150
164
|
private reserveAdmission;
|
|
151
165
|
private withAdmission;
|
|
152
166
|
private setSessionTitle;
|
|
167
|
+
private getSessionBrowserRequestHeaders;
|
|
168
|
+
private setSessionBrowserRequestHeaders;
|
|
153
169
|
private resolveAgentExecution;
|
|
154
170
|
private issueControlLink;
|
|
155
171
|
private issueSessionPortalLink;
|
|
172
|
+
private projectTimelineText;
|
|
156
173
|
private ownedSession;
|
|
157
174
|
private isOwned;
|
|
158
175
|
}
|
package/dist/plugin-host-rpc.js
CHANGED
|
@@ -3,10 +3,13 @@ import { realpath, stat } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AgentRuntimeError, agentSpecSchema, resolvedExecutionSnapshotSchema, sessionWorkspaceSnapshotSchema, } from "@rynx-ai/core";
|
|
5
5
|
import { PLUGIN_HOST_MAX_ACTIVE_RUNS, } from "@rynx-ai/plugin-sdk";
|
|
6
|
+
import { parseRuntimeBrowserRequestHeadersGetParams, parseRuntimeBrowserRequestHeadersSetParams, } from "@rynx-ai/protocol/runtime-browser";
|
|
7
|
+
import { SessionBrowserServiceError, } from "@rynx-ai/server";
|
|
6
8
|
import { PluginRunnerError } from "@rynx-ai/plugin-runner";
|
|
7
9
|
import { ensurePluginSessionLaunch, replayPluginSessionLaunch, SessionLaunchOperationConflictError, SessionLaunchOperationTargetDeletedError, } from "./session-launch-operations.js";
|
|
8
10
|
import { createControlLink } from "./control-link-store.js";
|
|
9
|
-
import { issueSessionPortalTicket } from "./session-portal-grant-store.js";
|
|
11
|
+
import { issueSessionPortalTicket, revokeSessionPortalGrant, } from "./session-portal-grant-store.js";
|
|
12
|
+
import { claimSessionTimelineProjection, completeSessionTimelineProjection, } from "./session-timeline-projection-store.js";
|
|
10
13
|
const MAX_MESSAGE_LENGTH = 1_000_000;
|
|
11
14
|
/** Leaves ample room for the RPC envelope below the runner's 1 MiB line cap. */
|
|
12
15
|
export const MAX_PLUGIN_HOST_EVENT_BYTES = 512 * 1024;
|
|
@@ -119,6 +122,18 @@ export class PluginHostRpc {
|
|
|
119
122
|
capabilities: {
|
|
120
123
|
sessionRunContent: true,
|
|
121
124
|
sessionRunFiles: true,
|
|
125
|
+
...(this.services.sessionTimeline
|
|
126
|
+
? { sessionTimelineProjectText: true }
|
|
127
|
+
: {}),
|
|
128
|
+
...(this.services.sessionBrowserRequestHeaders
|
|
129
|
+
? { sessionBrowserRequestHeaders: true }
|
|
130
|
+
: {}),
|
|
131
|
+
...(this.services.sessionPortal
|
|
132
|
+
? {
|
|
133
|
+
sessionPortalEmbedOrigin: true,
|
|
134
|
+
sessionPortalGrantRevocation: true,
|
|
135
|
+
}
|
|
136
|
+
: {}),
|
|
122
137
|
},
|
|
123
138
|
...(this.services.sessionPortal
|
|
124
139
|
? { sessionPortal: { ...this.services.sessionPortal } }
|
|
@@ -176,18 +191,31 @@ export class PluginHostRpc {
|
|
|
176
191
|
return this.services.sessionRegistry.list().filter((session) => this.isOwned(session));
|
|
177
192
|
case "session.meta.setTitle":
|
|
178
193
|
return this.setSessionTitle(params);
|
|
194
|
+
case "session.browser.requestHeaders.get":
|
|
195
|
+
return await this.getSessionBrowserRequestHeaders(params);
|
|
196
|
+
case "session.browser.requestHeaders.set":
|
|
197
|
+
return await this.setSessionBrowserRequestHeaders(params);
|
|
179
198
|
case "control.link.issue":
|
|
180
199
|
return this.issueControlLink(params);
|
|
181
200
|
case "session.portal.plugin.issue": {
|
|
182
|
-
const
|
|
183
|
-
return this.issueSessionPortalLink("plugin_sessions",
|
|
201
|
+
const input = record(params);
|
|
202
|
+
return this.issueSessionPortalLink("plugin_sessions", requiredSessionPortalAccess(input), undefined, optionalSessionPortalEmbedOrigin(input.embedOrigin), optionalString(input.grantHandle, "grantHandle", 128));
|
|
184
203
|
}
|
|
185
204
|
case "session.portal.session.issue": {
|
|
186
205
|
const input = record(params);
|
|
187
206
|
const sessionId = requiredSessionId(input);
|
|
188
207
|
this.ownedSession(sessionId);
|
|
189
|
-
return this.issueSessionPortalLink("single_session", requiredSessionPortalAccess(input), sessionId);
|
|
208
|
+
return this.issueSessionPortalLink("single_session", requiredSessionPortalAccess(input), sessionId, optionalSessionPortalEmbedOrigin(input.embedOrigin), optionalString(input.grantHandle, "grantHandle", 128));
|
|
209
|
+
}
|
|
210
|
+
case "session.portal.grant.revoke": {
|
|
211
|
+
const grantHandle = requiredString(record(params).grantHandle, "grantHandle", 128);
|
|
212
|
+
return revokeSessionPortalGrant({
|
|
213
|
+
pluginId: this.principalId,
|
|
214
|
+
grantHandle,
|
|
215
|
+
});
|
|
190
216
|
}
|
|
217
|
+
case "session.timeline.projectText":
|
|
218
|
+
return await this.projectTimelineText(params);
|
|
191
219
|
default:
|
|
192
220
|
throw new PluginHostCallError("METHOD_NOT_FOUND", `unknown host method: ${method}`);
|
|
193
221
|
}
|
|
@@ -843,6 +871,36 @@ export class PluginHostRpc {
|
|
|
843
871
|
this.services.sessionRegistry.setTitle(sessionId, title);
|
|
844
872
|
return { updated: true };
|
|
845
873
|
}
|
|
874
|
+
async getSessionBrowserRequestHeaders(params) {
|
|
875
|
+
let input;
|
|
876
|
+
try {
|
|
877
|
+
input = parseRuntimeBrowserRequestHeadersGetParams(params);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
throw new PluginHostCallError("INVALID_PARAMS", "Session Browser request headers parameters are invalid");
|
|
881
|
+
}
|
|
882
|
+
this.ownedSession(input.sessionId);
|
|
883
|
+
const service = this.services.sessionBrowserRequestHeaders;
|
|
884
|
+
if (!service) {
|
|
885
|
+
throw new PluginHostCallError("METHOD_NOT_FOUND", "Session Browser request headers are unsupported by this Host");
|
|
886
|
+
}
|
|
887
|
+
return await service.getRequestHeaders(input);
|
|
888
|
+
}
|
|
889
|
+
async setSessionBrowserRequestHeaders(params) {
|
|
890
|
+
let input;
|
|
891
|
+
try {
|
|
892
|
+
input = parseRuntimeBrowserRequestHeadersSetParams(params);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
throw new PluginHostCallError("INVALID_PARAMS", "Session Browser request headers parameters are invalid");
|
|
896
|
+
}
|
|
897
|
+
this.ownedSession(input.sessionId);
|
|
898
|
+
const service = this.services.sessionBrowserRequestHeaders;
|
|
899
|
+
if (!service) {
|
|
900
|
+
throw new PluginHostCallError("METHOD_NOT_FOUND", "Session Browser request headers are unsupported by this Host");
|
|
901
|
+
}
|
|
902
|
+
return await service.setRequestHeaders(input);
|
|
903
|
+
}
|
|
846
904
|
resolveAgentExecution(params) {
|
|
847
905
|
const input = record(params);
|
|
848
906
|
const defaultAgent = optionalAgentId(input.defaultAgent, "defaultAgent");
|
|
@@ -862,7 +920,7 @@ export class PluginHostRpc {
|
|
|
862
920
|
...(ttlSeconds === undefined ? {} : { ttlMs: ttlSeconds * 1_000 }),
|
|
863
921
|
});
|
|
864
922
|
}
|
|
865
|
-
issueSessionPortalLink(scope, access, sessionId) {
|
|
923
|
+
issueSessionPortalLink(scope, access, sessionId, embedOrigin, grantHandle) {
|
|
866
924
|
if (!this.services.sessionPortal) {
|
|
867
925
|
throw new PluginHostCallError("HOST_CALL_FAILED", "the Session Portal is not configured");
|
|
868
926
|
}
|
|
@@ -871,8 +929,47 @@ export class PluginHostRpc {
|
|
|
871
929
|
scope,
|
|
872
930
|
access,
|
|
873
931
|
...(sessionId ? { sessionId } : {}),
|
|
932
|
+
...(embedOrigin ? { embedOrigin } : {}),
|
|
933
|
+
...(grantHandle ? { grantHandle } : {}),
|
|
874
934
|
});
|
|
875
935
|
}
|
|
936
|
+
async projectTimelineText(params) {
|
|
937
|
+
const sessionTimeline = this.services.sessionTimeline;
|
|
938
|
+
if (!sessionTimeline) {
|
|
939
|
+
throw new PluginHostCallError("HOST_CALL_FAILED", "Session timeline projection is unavailable");
|
|
940
|
+
}
|
|
941
|
+
const input = record(params);
|
|
942
|
+
const sessionId = requiredSessionId(input);
|
|
943
|
+
this.ownedSession(sessionId);
|
|
944
|
+
const projection = claimSessionTimelineProjection({
|
|
945
|
+
pluginId: this.principalId,
|
|
946
|
+
sessionId,
|
|
947
|
+
projectionId: requiredString(input.projectionId, "projectionId", 256),
|
|
948
|
+
...(input.responseId === undefined
|
|
949
|
+
? {}
|
|
950
|
+
: { responseId: requiredString(input.responseId, "responseId", 256) }),
|
|
951
|
+
text: requiredText(input.text, "text", 64 * 1024),
|
|
952
|
+
});
|
|
953
|
+
if (!(await sessionTimeline.hasItem(sessionId, projection.itemId))) {
|
|
954
|
+
await sessionTimeline.projectText({
|
|
955
|
+
sessionId,
|
|
956
|
+
responseId: projection.responseId,
|
|
957
|
+
itemId: projection.itemId,
|
|
958
|
+
text: projection.text,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
completeSessionTimelineProjection({
|
|
962
|
+
pluginId: this.principalId,
|
|
963
|
+
sessionId,
|
|
964
|
+
projectionId: projection.projectionId,
|
|
965
|
+
});
|
|
966
|
+
return {
|
|
967
|
+
projectionId: projection.projectionId,
|
|
968
|
+
responseId: projection.responseId,
|
|
969
|
+
itemId: projection.itemId,
|
|
970
|
+
disposition: projection.disposition,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
876
973
|
ownedSession(sessionId) {
|
|
877
974
|
const session = this.services.sessionRegistry.get(sessionId);
|
|
878
975
|
if (!session || !this.isOwned(session)) {
|
|
@@ -933,6 +1030,18 @@ export class PluginHostCallError extends PluginRunnerError {
|
|
|
933
1030
|
function toHostCallError(error) {
|
|
934
1031
|
if (error instanceof PluginHostCallError)
|
|
935
1032
|
return error;
|
|
1033
|
+
if (error instanceof SessionBrowserServiceError) {
|
|
1034
|
+
if (error.code === "revision_mismatch") {
|
|
1035
|
+
return new PluginHostCallError("REVISION_CONFLICT", "Session Browser request headers changed since they were read");
|
|
1036
|
+
}
|
|
1037
|
+
if (error.code === "invalid_request") {
|
|
1038
|
+
return new PluginHostCallError("INVALID_PARAMS", "Session Browser request headers parameters are invalid");
|
|
1039
|
+
}
|
|
1040
|
+
if (error.code === "not_found") {
|
|
1041
|
+
return new PluginHostCallError("SESSION_NOT_FOUND", "plugin session not found");
|
|
1042
|
+
}
|
|
1043
|
+
return new PluginHostCallError("HOST_CALL_FAILED", "Session Browser request headers operation failed");
|
|
1044
|
+
}
|
|
936
1045
|
if (error instanceof AgentRuntimeError && error.code === "live_unavailable") {
|
|
937
1046
|
// ConversationRuntime emits this code only before injectMessage is called:
|
|
938
1047
|
// the native live Session (including its exact Skill environment) could
|
|
@@ -1109,6 +1218,21 @@ function requiredSessionPortalAccess(params) {
|
|
|
1109
1218
|
return access;
|
|
1110
1219
|
throw new PluginHostCallError("INVALID_PARAMS", "access must be read_only or read_write");
|
|
1111
1220
|
}
|
|
1221
|
+
function optionalSessionPortalEmbedOrigin(value) {
|
|
1222
|
+
if (value === undefined)
|
|
1223
|
+
return undefined;
|
|
1224
|
+
const candidate = requiredString(value, "embedOrigin", 512);
|
|
1225
|
+
try {
|
|
1226
|
+
const parsed = new URL(candidate);
|
|
1227
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
1228
|
+
parsed.origin !== candidate)
|
|
1229
|
+
throw new Error("invalid origin");
|
|
1230
|
+
return parsed.origin;
|
|
1231
|
+
}
|
|
1232
|
+
catch {
|
|
1233
|
+
throw new PluginHostCallError("INVALID_PARAMS", "embedOrigin must be an exact http(s) origin");
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1112
1236
|
function sessionInteractionResolution(value) {
|
|
1113
1237
|
const input = record(value);
|
|
1114
1238
|
const keys = Object.keys(input);
|
|
@@ -5,6 +5,7 @@ export interface SessionPortalAuthorization {
|
|
|
5
5
|
pluginId: string;
|
|
6
6
|
scope: SessionPortalScope;
|
|
7
7
|
access: SessionPortalAccess;
|
|
8
|
+
embedOrigin?: string;
|
|
8
9
|
sessionId?: string;
|
|
9
10
|
expiresAt: string;
|
|
10
11
|
}
|
|
@@ -16,14 +17,39 @@ export declare function issueSessionPortalTicket(input: {
|
|
|
16
17
|
scope: SessionPortalScope;
|
|
17
18
|
access: SessionPortalAccess;
|
|
18
19
|
sessionId?: string;
|
|
20
|
+
embedOrigin?: string;
|
|
21
|
+
grantHandle?: string;
|
|
19
22
|
now?: Date;
|
|
20
23
|
}): {
|
|
21
24
|
path: string;
|
|
22
25
|
expiresAt: string;
|
|
26
|
+
grantHandle: string;
|
|
23
27
|
};
|
|
24
28
|
/** Atomically consume a one-use ticket and mint a fixed-lifetime browser grant. */
|
|
25
|
-
export declare function redeemSessionPortalTicket(ticket: string, now?: Date): (SessionPortalAuthorization & {
|
|
29
|
+
export declare function redeemSessionPortalTicket(ticket: string, now?: Date, embedOrigin?: string): (SessionPortalAuthorization & {
|
|
26
30
|
secret: string;
|
|
27
31
|
}) | undefined;
|
|
32
|
+
/** Read the public, still-active half of a grant without accepting its secret.
|
|
33
|
+
* Used only to bind an embedded SPA bootstrap and transport lifecycle to the
|
|
34
|
+
* exact grant that was issued; it does not authorize an API call. */
|
|
35
|
+
export declare function inspectSessionPortalGrant(portalId: string, now?: Date): SessionPortalAuthorization | undefined;
|
|
28
36
|
export declare function authorizeSessionPortalGrant(portalId: string, secret: string, now?: Date): SessionPortalAuthorization | undefined;
|
|
37
|
+
/** Revoke one issued Portal link, whether its ticket is still unused or it has
|
|
38
|
+
* already become a browser grant. The opaque handle carries no product meaning. */
|
|
39
|
+
export declare function revokeSessionPortalGrant(input: {
|
|
40
|
+
pluginId: string;
|
|
41
|
+
grantHandle: string;
|
|
42
|
+
now?: Date;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
revoked: boolean;
|
|
45
|
+
}>;
|
|
46
|
+
/** Subscribe to committed grant revocations. The notification is emitted only
|
|
47
|
+
* after SQLite commits, so a listener may synchronously tear down every
|
|
48
|
+
* transport admitted under the revoked portal id. */
|
|
49
|
+
export declare function onSessionPortalGrantRevoked(listener: (portalIds: readonly string[]) => void | Promise<void>): () => void;
|
|
50
|
+
/** Embedded writable grants are process-generation capabilities. A daemon
|
|
51
|
+
* restart has no surviving transport to drain and Plugin recovery may fail, so
|
|
52
|
+
* fail closed before the Portal listener is reopened; ordinary non-embedded
|
|
53
|
+
* Portal links retain their historical fixed-lifetime behavior. */
|
|
54
|
+
export declare function revokeEmbeddedWritableSessionPortalGrants(now?: Date): void;
|
|
29
55
|
export declare function pruneSessionPortalAuthorization(now?: Date): void;
|
|
@@ -3,6 +3,7 @@ import { db } from "./db.js";
|
|
|
3
3
|
import { transaction } from "./sqlite.js";
|
|
4
4
|
const TICKET_TTL_MS = 5 * 60 * 1_000;
|
|
5
5
|
const GRANT_TTL_MS = 30 * 60 * 1_000;
|
|
6
|
+
const revocationListeners = new Set();
|
|
6
7
|
/** Issue a browser handoff into the native Session workbench. The Host knows
|
|
7
8
|
* only the generic Session scope and access level; product/user policy stays in
|
|
8
9
|
* the plugin that asks for the ticket. */
|
|
@@ -13,24 +14,34 @@ export function issueSessionPortalTicket(input) {
|
|
|
13
14
|
const now = input.now ?? new Date();
|
|
14
15
|
const expiresAt = new Date(now.getTime() + TICKET_TTL_MS).toISOString();
|
|
15
16
|
const ticket = randomToken();
|
|
17
|
+
const grantHandle = input.grantHandle ?? randomUUID();
|
|
16
18
|
pruneSessionPortalAuthorization(now);
|
|
17
19
|
db().prepare(`INSERT INTO session_portal_tickets
|
|
18
|
-
(ticket_hash, plugin_id, scope, access, session_id, created_at, expires_at, used_at
|
|
19
|
-
|
|
20
|
+
(ticket_hash, plugin_id, scope, access, session_id, created_at, expires_at, used_at,
|
|
21
|
+
grant_handle, embed_origin)
|
|
22
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(hash(ticket), input.pluginId, input.scope, input.access, input.sessionId ?? null, now.toISOString(), expiresAt, grantHandle, input.embedOrigin ?? null);
|
|
23
|
+
const redeem = new URL("/session-portal/redeem", "http://session-portal.invalid");
|
|
24
|
+
if (input.embedOrigin)
|
|
25
|
+
redeem.searchParams.set("embed_origin", input.embedOrigin);
|
|
20
26
|
return {
|
|
21
|
-
path:
|
|
27
|
+
path: `${redeem.pathname}${redeem.search}#ticket=${encodeURIComponent(ticket)}`,
|
|
22
28
|
expiresAt,
|
|
29
|
+
grantHandle,
|
|
23
30
|
};
|
|
24
31
|
}
|
|
25
32
|
/** Atomically consume a one-use ticket and mint a fixed-lifetime browser grant. */
|
|
26
|
-
export function redeemSessionPortalTicket(ticket, now = new Date()) {
|
|
33
|
+
export function redeemSessionPortalTicket(ticket, now = new Date(), embedOrigin) {
|
|
27
34
|
const connection = db();
|
|
28
35
|
return transaction(connection, () => {
|
|
29
36
|
const ticketHash = hash(ticket);
|
|
30
|
-
const row = connection.prepare(`SELECT plugin_id, scope, access, session_id, expires_at, used_at
|
|
37
|
+
const row = connection.prepare(`SELECT plugin_id, scope, access, session_id, expires_at, used_at,
|
|
38
|
+
grant_handle, embed_origin
|
|
31
39
|
FROM session_portal_tickets
|
|
32
40
|
WHERE ticket_hash = ?`).get(ticketHash);
|
|
33
|
-
if (!row ||
|
|
41
|
+
if (!row ||
|
|
42
|
+
row.used_at !== null ||
|
|
43
|
+
row.expires_at <= now.toISOString() ||
|
|
44
|
+
(row.embed_origin ?? "") !== (embedOrigin ?? ""))
|
|
34
45
|
return undefined;
|
|
35
46
|
const consumed = connection.prepare(`UPDATE session_portal_tickets SET used_at = ?
|
|
36
47
|
WHERE ticket_hash = ? AND used_at IS NULL AND expires_at > ?`).run(now.toISOString(), ticketHash, now.toISOString());
|
|
@@ -40,21 +51,36 @@ export function redeemSessionPortalTicket(ticket, now = new Date()) {
|
|
|
40
51
|
const secret = randomToken();
|
|
41
52
|
const expiresAt = new Date(now.getTime() + GRANT_TTL_MS).toISOString();
|
|
42
53
|
connection.prepare(`INSERT INTO session_portal_grants
|
|
43
|
-
(portal_id, secret_hash, plugin_id, scope, access, session_id, created_at, expires_at,
|
|
44
|
-
|
|
54
|
+
(portal_id, secret_hash, plugin_id, scope, access, session_id, created_at, expires_at,
|
|
55
|
+
revoked_at, grant_handle, embed_origin)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(portalId, hash(secret), row.plugin_id, row.scope, row.access, row.session_id, now.toISOString(), expiresAt, row.grant_handle, row.embed_origin);
|
|
45
57
|
return {
|
|
46
58
|
portalId,
|
|
47
59
|
secret,
|
|
48
60
|
pluginId: row.plugin_id,
|
|
49
61
|
scope: row.scope,
|
|
50
62
|
access: row.access,
|
|
63
|
+
...(row.embed_origin ? { embedOrigin: row.embed_origin } : {}),
|
|
51
64
|
...(row.session_id ? { sessionId: row.session_id } : {}),
|
|
52
65
|
expiresAt,
|
|
53
66
|
};
|
|
54
67
|
})();
|
|
55
68
|
}
|
|
69
|
+
/** Read the public, still-active half of a grant without accepting its secret.
|
|
70
|
+
* Used only to bind an embedded SPA bootstrap and transport lifecycle to the
|
|
71
|
+
* exact grant that was issued; it does not authorize an API call. */
|
|
72
|
+
export function inspectSessionPortalGrant(portalId, now = new Date()) {
|
|
73
|
+
const row = db().prepare(`SELECT portal_id, secret_hash, plugin_id, scope, access, session_id, expires_at, revoked_at,
|
|
74
|
+
grant_handle, embed_origin
|
|
75
|
+
FROM session_portal_grants
|
|
76
|
+
WHERE portal_id = ?`).get(portalId);
|
|
77
|
+
if (!row || row.revoked_at !== null || row.expires_at <= now.toISOString())
|
|
78
|
+
return undefined;
|
|
79
|
+
return publicGrant(row);
|
|
80
|
+
}
|
|
56
81
|
export function authorizeSessionPortalGrant(portalId, secret, now = new Date()) {
|
|
57
|
-
const row = db().prepare(`SELECT portal_id, secret_hash, plugin_id, scope, access, session_id, expires_at, revoked_at
|
|
82
|
+
const row = db().prepare(`SELECT portal_id, secret_hash, plugin_id, scope, access, session_id, expires_at, revoked_at,
|
|
83
|
+
grant_handle, embed_origin
|
|
58
84
|
FROM session_portal_grants
|
|
59
85
|
WHERE portal_id = ?`).get(portalId);
|
|
60
86
|
if (!row || row.revoked_at !== null || row.expires_at <= now.toISOString())
|
|
@@ -63,14 +89,52 @@ export function authorizeSessionPortalGrant(portalId, secret, now = new Date())
|
|
|
63
89
|
const expected = Buffer.from(row.secret_hash, "hex");
|
|
64
90
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
65
91
|
return undefined;
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
92
|
+
return publicGrant(row);
|
|
93
|
+
}
|
|
94
|
+
/** Revoke one issued Portal link, whether its ticket is still unused or it has
|
|
95
|
+
* already become a browser grant. The opaque handle carries no product meaning. */
|
|
96
|
+
export async function revokeSessionPortalGrant(input) {
|
|
97
|
+
const now = input.now ?? new Date();
|
|
98
|
+
const connection = db();
|
|
99
|
+
const result = transaction(connection, () => {
|
|
100
|
+
const portalIds = connection.prepare(`SELECT portal_id FROM session_portal_grants
|
|
101
|
+
WHERE plugin_id = ? AND grant_handle = ? AND revoked_at IS NULL`).all(input.pluginId, input.grantHandle);
|
|
102
|
+
const tickets = connection.prepare(`UPDATE session_portal_tickets SET used_at = ?
|
|
103
|
+
WHERE plugin_id = ? AND grant_handle = ? AND used_at IS NULL`).run(now.toISOString(), input.pluginId, input.grantHandle);
|
|
104
|
+
const grants = connection.prepare(`UPDATE session_portal_grants SET revoked_at = ?
|
|
105
|
+
WHERE plugin_id = ? AND grant_handle = ? AND revoked_at IS NULL`).run(now.toISOString(), input.pluginId, input.grantHandle);
|
|
106
|
+
return {
|
|
107
|
+
revoked: tickets.changes > 0 || grants.changes > 0,
|
|
108
|
+
portalIds: portalIds.map((row) => row.portal_id),
|
|
109
|
+
};
|
|
110
|
+
})();
|
|
111
|
+
if (result.portalIds.length > 0) {
|
|
112
|
+
// Revocation is a barrier, not merely a database flag. Host RPC returns
|
|
113
|
+
// only after every Portal transport admitted under the committed grant has
|
|
114
|
+
// been aborted and drained.
|
|
115
|
+
await Promise.all([...revocationListeners].map((listener) => listener(result.portalIds)));
|
|
116
|
+
}
|
|
117
|
+
return { revoked: result.revoked };
|
|
118
|
+
}
|
|
119
|
+
/** Subscribe to committed grant revocations. The notification is emitted only
|
|
120
|
+
* after SQLite commits, so a listener may synchronously tear down every
|
|
121
|
+
* transport admitted under the revoked portal id. */
|
|
122
|
+
export function onSessionPortalGrantRevoked(listener) {
|
|
123
|
+
revocationListeners.add(listener);
|
|
124
|
+
return () => revocationListeners.delete(listener);
|
|
125
|
+
}
|
|
126
|
+
/** Embedded writable grants are process-generation capabilities. A daemon
|
|
127
|
+
* restart has no surviving transport to drain and Plugin recovery may fail, so
|
|
128
|
+
* fail closed before the Portal listener is reopened; ordinary non-embedded
|
|
129
|
+
* Portal links retain their historical fixed-lifetime behavior. */
|
|
130
|
+
export function revokeEmbeddedWritableSessionPortalGrants(now = new Date()) {
|
|
131
|
+
const connection = db();
|
|
132
|
+
transaction(connection, () => {
|
|
133
|
+
connection.prepare(`UPDATE session_portal_tickets SET used_at=?
|
|
134
|
+
WHERE access='read_write' AND embed_origin IS NOT NULL AND used_at IS NULL`).run(now.toISOString());
|
|
135
|
+
connection.prepare(`UPDATE session_portal_grants SET revoked_at=?
|
|
136
|
+
WHERE access='read_write' AND embed_origin IS NOT NULL AND revoked_at IS NULL`).run(now.toISOString());
|
|
137
|
+
})();
|
|
74
138
|
}
|
|
75
139
|
export function pruneSessionPortalAuthorization(now = new Date()) {
|
|
76
140
|
const connection = db();
|
|
@@ -83,3 +147,14 @@ function randomToken() {
|
|
|
83
147
|
function hash(value) {
|
|
84
148
|
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
85
149
|
}
|
|
150
|
+
function publicGrant(row) {
|
|
151
|
+
return {
|
|
152
|
+
portalId: row.portal_id,
|
|
153
|
+
pluginId: row.plugin_id,
|
|
154
|
+
scope: row.scope,
|
|
155
|
+
access: row.access,
|
|
156
|
+
...(row.embed_origin ? { embedOrigin: row.embed_origin } : {}),
|
|
157
|
+
...(row.session_id ? { sessionId: row.session_id } : {}),
|
|
158
|
+
expiresAt: row.expires_at,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -28,6 +28,10 @@ export declare class SqliteSessionResourceStore implements MachineSessionResourc
|
|
|
28
28
|
markMessageInjected(sessionId: string, clientMessageId: string, identity?: {
|
|
29
29
|
responseId: string;
|
|
30
30
|
}): void;
|
|
31
|
+
markMessageMirrored(sessionId: string, clientMessageId: string, identity: {
|
|
32
|
+
responseId: string;
|
|
33
|
+
messageItemId: string;
|
|
34
|
+
}): void;
|
|
31
35
|
markMessageOutcomeUnknown(sessionId: string, clientMessageId: string, reason: string): void;
|
|
32
36
|
markMessageFailedNotStarted(sessionId: string, clientMessageId: string, reason: string): void;
|
|
33
37
|
deleteSession(sessionId: string): void;
|
|
@@ -347,6 +347,28 @@ export class SqliteSessionResourceStore {
|
|
|
347
347
|
response_id = COALESCE(response_id, ?),
|
|
348
348
|
updated_at = ?
|
|
349
349
|
WHERE session_id = ? AND client_message_id = ? AND state = 'injecting'`).run(identity?.responseId ?? null, Date.now(), sessionId, clientMessageId);
|
|
350
|
+
if (result.changes !== 1 && current.state !== "mirrored") {
|
|
351
|
+
throw conflict("Message operation state changed");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
markMessageMirrored(sessionId, clientMessageId, identity) {
|
|
355
|
+
const current = db().prepare(`SELECT state, response_id, message_item_id
|
|
356
|
+
FROM session_message_operations
|
|
357
|
+
WHERE session_id = ? AND client_message_id = ?`).get(sessionId, clientMessageId);
|
|
358
|
+
if (!current ||
|
|
359
|
+
(current.response_id && current.response_id !== identity.responseId) ||
|
|
360
|
+
(current.message_item_id && current.message_item_id !== identity.messageItemId)) {
|
|
361
|
+
throw conflict("Message operation identity changed");
|
|
362
|
+
}
|
|
363
|
+
if (current.state === "mirrored")
|
|
364
|
+
return;
|
|
365
|
+
const result = db().prepare(`UPDATE session_message_operations
|
|
366
|
+
SET state = 'mirrored', error = NULL,
|
|
367
|
+
response_id = COALESCE(response_id, ?),
|
|
368
|
+
message_item_id = COALESCE(message_item_id, ?),
|
|
369
|
+
updated_at = ?
|
|
370
|
+
WHERE session_id = ? AND client_message_id = ?
|
|
371
|
+
AND state IN ('injecting', 'injected', 'outcome_unknown')`).run(identity.responseId, identity.messageItemId, Date.now(), sessionId, clientMessageId);
|
|
350
372
|
if (result.changes !== 1)
|
|
351
373
|
throw conflict("Message operation state changed");
|
|
352
374
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface SessionTimelineProjection {
|
|
2
|
+
pluginId: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
projectionId: string;
|
|
5
|
+
responseId: string;
|
|
6
|
+
itemId: string;
|
|
7
|
+
text: string;
|
|
8
|
+
state: "pending" | "completed";
|
|
9
|
+
disposition: "created" | "replayed";
|
|
10
|
+
}
|
|
11
|
+
/** Durable identity for a narrow text projection. The text itself deliberately
|
|
12
|
+
* stays in the canonical Session item; this row only fences retries. */
|
|
13
|
+
export declare function claimSessionTimelineProjection(input: {
|
|
14
|
+
pluginId: string;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
projectionId: string;
|
|
17
|
+
responseId?: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}): SessionTimelineProjection;
|
|
20
|
+
export declare function completeSessionTimelineProjection(input: {
|
|
21
|
+
pluginId: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
projectionId: string;
|
|
24
|
+
}): void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { transaction } from "./sqlite.js";
|
|
4
|
+
/** Durable identity for a narrow text projection. The text itself deliberately
|
|
5
|
+
* stays in the canonical Session item; this row only fences retries. */
|
|
6
|
+
export function claimSessionTimelineProjection(input) {
|
|
7
|
+
const connection = db();
|
|
8
|
+
const requestHash = createHash("sha256")
|
|
9
|
+
.update(JSON.stringify({ responseId: input.responseId ?? null, text: input.text }), "utf8")
|
|
10
|
+
.digest("hex");
|
|
11
|
+
return transaction(connection, () => {
|
|
12
|
+
const existing = connection.prepare(`SELECT plugin_id, session_id, projection_id, request_hash, response_id, item_id, state
|
|
13
|
+
FROM session_timeline_projections
|
|
14
|
+
WHERE plugin_id = ? AND session_id = ? AND projection_id = ?`).get(input.pluginId, input.sessionId, input.projectionId);
|
|
15
|
+
if (existing) {
|
|
16
|
+
if (existing.request_hash !== requestHash) {
|
|
17
|
+
throw new Error("projection id was reused with different text");
|
|
18
|
+
}
|
|
19
|
+
return present(existing, input.text, "replayed");
|
|
20
|
+
}
|
|
21
|
+
const responseId = input.responseId?.trim() || `projection_response_${input.projectionId}`;
|
|
22
|
+
const itemId = `msg_projection_${randomUUID()}`;
|
|
23
|
+
connection.prepare(`INSERT INTO session_timeline_projections
|
|
24
|
+
(plugin_id, session_id, projection_id, request_hash, response_id, item_id, state, created_at)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`).run(input.pluginId, input.sessionId, input.projectionId, requestHash, responseId, itemId, new Date().toISOString());
|
|
26
|
+
return {
|
|
27
|
+
pluginId: input.pluginId,
|
|
28
|
+
sessionId: input.sessionId,
|
|
29
|
+
projectionId: input.projectionId,
|
|
30
|
+
responseId,
|
|
31
|
+
itemId,
|
|
32
|
+
text: input.text,
|
|
33
|
+
state: "pending",
|
|
34
|
+
disposition: "created",
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
}
|
|
38
|
+
export function completeSessionTimelineProjection(input) {
|
|
39
|
+
db().prepare(`UPDATE session_timeline_projections SET state = 'completed'
|
|
40
|
+
WHERE plugin_id = ? AND session_id = ? AND projection_id = ?`).run(input.pluginId, input.sessionId, input.projectionId);
|
|
41
|
+
}
|
|
42
|
+
function present(row, text, disposition) {
|
|
43
|
+
return {
|
|
44
|
+
pluginId: row.plugin_id,
|
|
45
|
+
sessionId: row.session_id,
|
|
46
|
+
projectionId: row.projection_id,
|
|
47
|
+
responseId: row.response_id,
|
|
48
|
+
itemId: row.item_id,
|
|
49
|
+
text,
|
|
50
|
+
state: row.state,
|
|
51
|
+
disposition,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rynx-ai/daemon",
|
|
3
|
-
"version": "0.1.11-beta.
|
|
3
|
+
"version": "0.1.11-beta.31",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/rynx-ai/rynx.git",
|
|
@@ -50,17 +50,17 @@
|
|
|
50
50
|
"pm2": "^6.0.0",
|
|
51
51
|
"tar": "^7.5.19",
|
|
52
52
|
"ws": "^8.21.0",
|
|
53
|
-
"@rynx-ai/
|
|
54
|
-
"@rynx-ai/
|
|
55
|
-
"@rynx-ai/
|
|
56
|
-
"@rynx-ai/protocol": "0.1.11-beta.
|
|
57
|
-
"@rynx-ai/remote-runtime-client": "0.1.11-beta.
|
|
58
|
-
"@rynx-ai/
|
|
59
|
-
"@rynx-ai/
|
|
53
|
+
"@rynx-ai/plugin-runner": "0.1.11-beta.31",
|
|
54
|
+
"@rynx-ai/emulator": "0.1.11-beta.31",
|
|
55
|
+
"@rynx-ai/plugin-sdk": "0.1.11-beta.31",
|
|
56
|
+
"@rynx-ai/protocol": "0.1.11-beta.31",
|
|
57
|
+
"@rynx-ai/remote-runtime-client": "0.1.11-beta.31",
|
|
58
|
+
"@rynx-ai/server": "0.1.11-beta.31",
|
|
59
|
+
"@rynx-ai/core": "0.1.11-beta.31"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/ws": "^8.18.1",
|
|
63
|
-
"@rynx-ai/plugin-channel-lark": "0.1.11-beta.
|
|
63
|
+
"@rynx-ai/plugin-channel-lark": "0.1.11-beta.31"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|
|
66
66
|
"build": "rm -rf dist bundled-plugins && tsc -p tsconfig.json && chmod +x dist/index-daemon.js && node ../../scripts/stage-bundled-plugins.mjs",
|