@wenathlan/devthink 2.0.13 → 2.0.15
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/CHANGELOG.md +27 -0
- package/accounts.ts +2087 -0
- package/agent.ts +2331 -0
- package/antigravity.ts +2139 -0
- package/apifreeze.ts +4146 -0
- package/auth.ts +642 -0
- package/background.ts +16837 -0
- package/bridge.ts +2080 -0
- package/build.ts +54 -0
- package/bun.ts +26 -0
- package/capture.ts +1614 -0
- package/checksecrets.ts +72 -0
- package/cjs.ts +9 -0
- package/cli.ts +3500 -0
- package/commands.ts +1101 -0
- package/companion.ts +450 -0
- package/compatibility.ts +37 -0
- package/config.ts +3137 -0
- package/constants.ts +943 -0
- package/core.ts +1473 -0
- package/crossbrowser.ts +1359 -0
- package/dashdone.ts +605 -0
- package/data.ts +943 -0
- package/database.ts +133 -0
- package/debug.ts +850 -0
- package/deno.ts +27 -0
- package/devthink.ts +707 -0
- package/dist/accounts.d.ts +409 -0
- package/dist/agent.d.ts +837 -0
- package/dist/antigravity.d.ts +380 -0
- package/dist/apifreeze.d.ts +113 -0
- package/dist/auth.d.ts +224 -0
- package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
- package/dist/capture.d.ts +595 -0
- package/dist/cjs.d.ts +6 -0
- package/{cli.js → dist/cli.js} +2 -2
- package/dist/commands.d.ts +310 -0
- package/dist/compatibility.d.ts +17 -0
- package/dist/config.d.ts +1853 -0
- package/dist/constants.d.ts +637 -0
- package/dist/core.d.ts +361 -0
- package/dist/data.d.ts +330 -0
- package/dist/database.d.ts +24 -0
- package/dist/debug.d.ts +270 -0
- package/dist/engine.d.ts +74 -0
- package/dist/environments.d.ts +244 -0
- package/dist/evidence.d.ts +277 -0
- package/dist/export.d.ts +311 -0
- package/dist/fingerprint.d.ts +594 -0
- package/dist/flow.d.ts +300 -0
- package/dist/gates.d.ts +103 -0
- package/dist/identity.d.ts +51 -0
- package/dist/ids.d.ts +7 -0
- package/dist/llm.d.ts +652 -0
- package/dist/models.d.ts +1807 -0
- package/dist/net.d.ts +325 -0
- package/dist/neutral.d.ts +41 -0
- package/dist/oauth.d.ts +703 -0
- package/dist/page.d.ts +1166 -0
- package/dist/perf.d.ts +481 -0
- package/dist/plan.d.ts +134 -0
- package/dist/project.d.ts +395 -0
- package/dist/providers.d.ts +37 -0
- package/dist/quota.d.ts +453 -0
- package/dist/recovery.d.ts +621 -0
- package/dist/request.d.ts +1492 -0
- package/dist/run.d.ts +779 -0
- package/dist/runtime.d.ts +212 -0
- package/dist/search.d.ts +375 -0
- package/dist/security.d.ts +534 -0
- package/dist/serve.d.ts +136 -0
- package/dist/session.d.ts +296 -0
- package/dist/storage.d.ts +53 -0
- package/dist/streaming.d.ts +1119 -0
- package/dist/swarm.d.ts +328 -0
- package/dist/tools.d.ts +170 -0
- package/dist/types.d.ts +8357 -0
- package/dist/utils.d.ts +82 -0
- package/dist/version.d.ts +7 -0
- package/dist/versionregistry.d.ts +406 -0
- package/dist/views.d.ts +579 -0
- package/dist/workbenchsession.d.ts +69 -0
- package/dist/workflow.d.ts +649 -0
- package/docs/readme.md +92 -0
- package/docs/schemas/antigravity.json +192 -0
- package/engine.ts +1524 -0
- package/environments.ts +1005 -0
- package/evidence.ts +643 -0
- package/export.ts +889 -0
- package/fingerprint.ts +1458 -0
- package/flow.ts +1190 -0
- package/gates.ts +307 -0
- package/gateway.ts +34 -0
- package/hardening.ts +191 -0
- package/headless.ts +369 -0
- package/http.ts +2110 -0
- package/identity.ts +246 -0
- package/ids.ts +26 -0
- package/index.ts +369 -0
- package/llm.ts +2682 -0
- package/mcp.ts +1835 -0
- package/{memory.js → memory.ts} +3834 -2310
- package/models.ts +3805 -0
- package/modes.ts +43 -0
- package/net.ts +1214 -0
- package/neutral.ts +362 -0
- package/node.ts +26 -0
- package/oauth.ts +2216 -0
- package/offscreen.ts +170 -0
- package/pack.ts +956 -0
- package/package.json +175 -82
- package/page.ts +7824 -0
- package/pagebridge.ts +1116 -0
- package/perf.ts +957 -0
- package/plan.ts +672 -0
- package/plugin.ts +76 -0
- package/{policy.js → policy.ts} +5073 -4679
- package/prisma.config.ts +27 -0
- package/progress.ts +1308 -0
- package/project.ts +1291 -0
- package/protocol.ts +4187 -0
- package/providers.ts +246 -0
- package/quota.ts +1887 -0
- package/recovery.ts +2433 -0
- package/request.ts +4482 -0
- package/run.ts +2135 -0
- package/runtime.ts +526 -0
- package/search.ts +1760 -0
- package/security.ts +1673 -0
- package/serve.ts +419 -0
- package/server.ts +5538 -0
- package/session.ts +1162 -0
- package/storage.ts +182 -0
- package/streaming.ts +4101 -0
- package/swarm.ts +884 -0
- package/sync.ts +56 -0
- package/tests/agentcert.test.ts +128 -0
- package/tests/agentfleet.test.ts +214 -0
- package/tests/agentreview.test.ts +241 -0
- package/tests/agentstream.test.ts +335 -0
- package/tests/agentwork.test.ts +356 -0
- package/tests/apifreeze.test.ts +290 -0
- package/tests/apimap.test.ts +112 -0
- package/tests/approvalgate.test.ts +175 -0
- package/tests/artifactmanifest.test.ts +204 -0
- package/tests/attentionfeed.test.ts +163 -0
- package/tests/backgroundruns.test.ts +100 -0
- package/tests/batchscheduling.test.ts +131 -0
- package/tests/blackboard.test.ts +238 -0
- package/tests/bridge.test.ts +1088 -0
- package/tests/browsercoverage.test.ts +134 -0
- package/tests/browserpolyfills.test.ts +124 -0
- package/tests/build.mjs +1260 -0
- package/tests/clientauth.test.ts +223 -0
- package/tests/clitools.test.ts +578 -0
- package/tests/confirmgates.test.ts +190 -0
- package/tests/containerpack.test.ts +143 -0
- package/tests/controlflow.test.ts +2120 -0
- package/tests/coordination.test.ts +345 -0
- package/tests/copyscreen.test.ts +139 -0
- package/tests/core.test.ts +417 -0
- package/tests/costcert.test.ts +95 -0
- package/tests/crossbrowser.test.ts +115 -0
- package/tests/cspaudit.test.ts +79 -0
- package/tests/dashdone.test.ts +615 -0
- package/tests/datagrid.test.ts +137 -0
- package/tests/doccheck.test.ts +107 -0
- package/tests/dombatch.test.ts +109 -0
- package/tests/emulation.test.ts +282 -0
- package/tests/environments.test.ts +259 -0
- package/tests/evidenceviews.test.ts +88 -0
- package/tests/exporttools.test.ts +185 -0
- package/tests/firefoxprep.test.ts +134 -0
- package/tests/flowlibrary.test.ts +242 -0
- package/tests/flowrun.test.ts +303 -0
- package/tests/forensics.test.ts +439 -0
- package/tests/gateway.test.ts +939 -0
- package/tests/headlesslib.test.ts +171 -0
- package/tests/httpstream.test.ts +277 -0
- package/tests/immutablelog.test.ts +157 -0
- package/tests/inboundguard.test.ts +177 -0
- package/tests/lazyload.test.ts +72 -0
- package/tests/librarymodes.test.ts +623 -0
- package/tests/llm.test.ts +696 -0
- package/tests/manifest.test.ts +226 -0
- package/tests/maskinputs.test.ts +150 -0
- package/tests/matrixverify.test.ts +111 -0
- package/tests/mavenpack.test.ts +75 -0
- package/tests/mcpserver.test.ts +704 -0
- package/tests/memory.test.ts +4155 -0
- package/tests/memorycare.test.ts +230 -0
- package/tests/migrateplan.test.ts +349 -0
- package/tests/minimization.test.ts +414 -0
- package/tests/modelroute.test.ts +112 -0
- package/tests/multiagent.test.ts +440 -0
- package/tests/nativebridge.test.ts +807 -0
- package/tests/navigation.test.ts +284 -0
- package/tests/nugetpack.test.ts +83 -0
- package/tests/orchestration.test.ts +449 -0
- package/tests/originpolicy.test.ts +405 -0
- package/tests/outputcompare.test.ts +97 -0
- package/tests/package.test.ts +91 -0
- package/tests/pagebridge.test.ts +5909 -0
- package/tests/pentest.test.ts +75 -0
- package/tests/perfrecords.test.ts +257 -0
- package/tests/permdiff.test.ts +85 -0
- package/tests/phishguard.test.ts +128 -0
- package/tests/pickerviews.test.ts +155 -0
- package/tests/pipelines.test.ts +381 -0
- package/tests/planlint.test.ts +240 -0
- package/tests/planreview.test.ts +383 -0
- package/tests/platformtargets.test.ts +56 -0
- package/tests/policy.test.ts +8284 -0
- package/tests/portability.test.ts +96 -0
- package/tests/progress.test.ts +1036 -0
- package/tests/promptlibrary.test.ts +116 -0
- package/tests/protocol.test.ts +4793 -0
- package/tests/publishingpipeline.test.ts +314 -0
- package/tests/quickactions.test.ts +164 -0
- package/tests/readiness.test.ts +78 -0
- package/tests/recipes.test.ts +163 -0
- package/tests/redactshots.test.ts +216 -0
- package/tests/registernvidiakeys.ts +51 -0
- package/tests/relayserve.test.ts +304 -0
- package/tests/releasenotes.test.ts +276 -0
- package/tests/resilience.test.ts +467 -0
- package/tests/resourceaware.test.ts +187 -0
- package/tests/runbudget.test.ts +125 -0
- package/tests/runhistory.test.ts +299 -0
- package/tests/runreplay.test.ts +113 -0
- package/tests/runresume.test.ts +184 -0
- package/tests/runstate.test.ts +288 -0
- package/tests/runtimeadapters.test.ts +251 -0
- package/tests/safariskeleton.test.ts +115 -0
- package/tests/sandboxframe.test.ts +196 -0
- package/tests/sbom.test.ts +105 -0
- package/tests/secretvault.test.ts +188 -0
- package/tests/selcache.test.ts +111 -0
- package/tests/sessioninterface.test.ts +791 -0
- package/tests/sessions.test.ts +591 -0
- package/tests/siteprefs.test.ts +228 -0
- package/tests/snapshotdelta.test.ts +165 -0
- package/tests/statusviews.test.ts +237 -0
- package/tests/stepmeter.test.ts +200 -0
- package/tests/streamparse.test.ts +163 -0
- package/tests/stylepruning.test.ts +90 -0
- package/tests/surfaces.test.ts +282 -0
- package/tests/sweep.test.ts +78 -0
- package/tests/syncbridge.test.ts +164 -0
- package/tests/taskqueue.test.ts +206 -0
- package/tests/toolcalls.test.ts +336 -0
- package/tests/toolcatalog.test.ts +201 -0
- package/tests/torture-agent.test.ts +706 -0
- package/tests/torture-auth.test.ts +461 -0
- package/tests/torture-commands.test.ts +895 -0
- package/tests/torture-data.test.ts +923 -0
- package/tests/torture-environments.test.ts +285 -0
- package/tests/torture-export.test.ts +263 -0
- package/tests/torture-http.test.ts +1379 -0
- package/tests/torture-llm.test.ts +808 -0
- package/tests/torture-memory.test.ts +1084 -0
- package/tests/torture-plan.test.ts +829 -0
- package/tests/torture-run.test.ts +626 -0
- package/tests/torture-session.test.ts +928 -0
- package/tests/torture-swarm.test.ts +1245 -0
- package/tests/torture-tools.test.ts +964 -0
- package/tests/torture-workflow.test.ts +2354 -0
- package/tests/tourviews.test.ts +52 -0
- package/tests/transparency.test.ts +147 -0
- package/tests/trigger.test.ts +941 -0
- package/tests/v1sunset.test.ts +428 -0
- package/tests/virtlist.test.ts +94 -0
- package/tests/vision.test.ts +540 -0
- package/tests/vsixpack.test.ts +145 -0
- package/tests/webapi.test.ts +523 -0
- package/tests/workflow.test.ts +1785 -0
- package/tests/workfloweditor.test.ts +759 -0
- package/tests/xpipack.test.ts +128 -0
- package/tools.ts +734 -0
- package/types.ts +9908 -0
- package/umd.ts +9 -0
- package/utils.ts +249 -0
- package/version.ts +8 -0
- package/versionregistry.ts +1310 -0
- package/views.ts +2153 -0
- package/vitest.config.ts +12 -0
- package/web/App.tsx +78 -0
- package/web/README.md +47 -0
- package/web/capacitor.config.ts +46 -0
- package/web/const.ts +19 -0
- package/web/control.shell.tsx +66 -0
- package/web/dashboardpage.ts +554 -0
- package/web/db.ts +493 -0
- package/web/design.html +541 -0
- package/web/gatewayview/Gateway.tsx +345 -0
- package/web/gatewayview/config.ts +1042 -0
- package/web/gatewayview/definition.ts +174 -0
- package/web/icons.ts +15 -0
- package/{manifest.json → web/manifest.json} +1 -1
- package/web/optionspage.ts +691 -0
- package/web/popup.ts +1651 -0
- package/web/schema.prisma +176 -0
- package/web/sidepanel.ts +7941 -0
- package/web/sitemanifest.json +13 -0
- package/web/terminal-ui.tsx +622 -0
- package/web/transparencypage.ts +541 -0
- package/web/vite.config.ts +39 -0
- package/web/workspace.ts +23 -0
- package/workbenchmemory.ts +104 -0
- package/workbenchsession.ts +304 -0
- package/workflow.ts +4495 -0
- package/bridge.d.ts.map +0 -1
- package/bridge.js +0 -1877
- package/bun.d.ts.map +0 -1
- package/bun.js +0 -52329
- package/caps/background.json +0 -712
- package/caps/cli.json +0 -360
- package/caps/library.json +0 -3611
- package/caps/mcp.json +0 -126
- package/caps/pagebridge.json +0 -377
- package/caps/popup.json +0 -51
- package/caps/sidepanel.json +0 -522
- package/checksums.txt +0 -142
- package/cli.d.ts.map +0 -1
- package/companion.d.ts.map +0 -1
- package/companion.js +0 -389
- package/crossbrowser.d.ts.map +0 -1
- package/crossbrowser.js +0 -1191
- package/dashdone.d.ts.map +0 -1
- package/dashdone.js +0 -368
- package/deno.d.ts.map +0 -1
- package/deno.js +0 -52325
- package/devthink.umd.js +0 -52346
- package/fixtures/bun.ts +0 -7
- package/fixtures/cjs.js +0 -6
- package/fixtures/deno.ts +0 -6
- package/fixtures/example-org-pagestate.json +0 -22
- package/fixtures/importers/automa-workflow.json +0 -18
- package/fixtures/importers/selenium-side.json +0 -20
- package/fixtures/importers/tabular-plan.csv +0 -7
- package/fixtures/importers/uivision-macro.json +0 -10
- package/fixtures/importers/v1-plan.json +0 -13
- package/fixtures/mcp-client.mjs +0 -14
- package/fixtures/pages/checkout.html +0 -192
- package/fixtures/pages/dashboard.html +0 -110
- package/fixtures/pages/feed.html +0 -113
- package/fixtures/pages/product-grid.html +0 -150
- package/fixtures/plans/form-inventory-plan.json +0 -11
- package/fixtures/plans/release-notes-plan.json +0 -11
- package/fixtures/recipes/agentscompete.json +0 -11
- package/fixtures/recipes/agentsforms.json +0 -11
- package/fixtures/recipes/agentsmonitor.json +0 -11
- package/fixtures/recipes/agentsreview.json +0 -29
- package/fixtures/recipes/agentswarm.json +0 -11
- package/fixtures/recipes/fillcalendar.json +0 -12
- package/fixtures/recipes/fillcheckout.json +0 -13
- package/fixtures/recipes/filldependentdropdowns.json +0 -12
- package/fixtures/recipes/fillfromcsv.json +0 -13
- package/fixtures/recipes/fillloginform.json +0 -12
- package/fixtures/recipes/fillmultistepwizard.json +0 -13
- package/fixtures/recipes/fillregistration.json +0 -12
- package/fixtures/recipes/fillsearchfilters.json +0 -12
- package/fixtures/recipes/filltypeahead.json +0 -11
- package/fixtures/recipes/monitoralerts.json +0 -12
- package/fixtures/recipes/monitoravailability.json +0 -11
- package/fixtures/recipes/monitornetworkquiet.json +0 -12
- package/fixtures/recipes/monitorpagechanges.json +0 -11
- package/fixtures/recipes/monitorpricechange.json +0 -11
- package/fixtures/recipes/monitorschedule.json +0 -12
- package/fixtures/recipes/scrapedynamicprices.json +0 -13
- package/fixtures/recipes/scrapeimagegallery.json +0 -12
- package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
- package/fixtures/recipes/scrapenestedlists.json +0 -12
- package/fixtures/recipes/scrapeproductgrid.json +0 -12
- package/fixtures/recipes/scrapereviews.json +0 -12
- package/fixtures/recipes/scrapesearchresults.json +0 -12
- package/fixtures/recipes/scrapetablepagination.json +0 -12
- package/fixtures/recipes/testaccessibility.json +0 -34
- package/fixtures/recipes/testconsoleerrors.json +0 -12
- package/fixtures/recipes/testdeeplinks.json +0 -12
- package/fixtures/recipes/testformvalidation.json +0 -13
- package/fixtures/recipes/testlayoutshift.json +0 -12
- package/fixtures/recipes/testlinkcheck.json +0 -12
- package/fixtures/recipes/testloadtiming.json +0 -12
- package/fixtures/recipes/testvisualdiff.json +0 -12
- package/fixtures/workflows/changelog-digest-workflow.json +0 -13
- package/fixtures/workflows/form-survey-workflow.json +0 -13
- package/gallery.json +0 -706
- package/hardening.d.ts.map +0 -1
- package/hardening.js +0 -188
- package/headless.d.ts.map +0 -1
- package/headless.js +0 -11522
- package/http.d.ts.map +0 -1
- package/http.js +0 -2165
- package/index.cjs +0 -54682
- package/index.d.ts.map +0 -1
- package/index.js +0 -52310
- package/index.neutral.js +0 -46343
- package/mcp.d.ts.map +0 -1
- package/mcp.js +0 -20515
- package/memory.d.ts.map +0 -1
- package/nativehost.template.json +0 -10
- package/node.cjs +0 -54686
- package/node.d.ts.map +0 -1
- package/pack.d.ts.map +0 -1
- package/pack.js +0 -805
- package/policy.d.ts.map +0 -1
- package/progress.d.ts.map +0 -1
- package/progress.js +0 -645
- package/protocol.d.ts.map +0 -1
- package/protocol.js +0 -8334
- package/schemas/audit.schema.json +0 -226
- package/schemas/capability.schema.json +0 -113
- package/schemas/envelope.schema.json +0 -162
- package/schemas/memory.schema.json +0 -51
- package/schemas/observation.schema.json +0 -113
- package/schemas/plan.schema.json +0 -208
- package/schemas/progress.schema.json +0 -126
- package/schemas/proposal.schema.json +0 -68
- package/schemas/session.schema.json +0 -82
- package/schemas/tool.schema.json +0 -94
- package/server.d.ts.map +0 -1
- package/server.js +0 -28877
- package/umd-example.html +0 -18
- package/umd.d.ts.map +0 -1
- /package/{bun.d.ts → dist/bun.d.ts} +0 -0
- /package/{cli.d.ts → dist/cli.d.ts} +0 -0
- /package/{companion.d.ts → dist/companion.d.ts} +0 -0
- /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
- /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
- /package/{deno.d.ts → dist/deno.d.ts} +0 -0
- /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
- /package/{headless.d.ts → dist/headless.d.ts} +0 -0
- /package/{http.d.ts → dist/http.d.ts} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
- /package/{memory.d.ts → dist/memory.d.ts} +0 -0
- /package/{node.d.ts → dist/node.d.ts} +0 -0
- /package/{pack.d.ts → dist/pack.d.ts} +0 -0
- /package/{policy.d.ts → dist/policy.d.ts} +0 -0
- /package/{progress.d.ts → dist/progress.d.ts} +0 -0
- /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
- /package/{server.d.ts → dist/server.d.ts} +0 -0
- /package/{umd.d.ts → dist/umd.d.ts} +0 -0
package/bridge.ts
ADDED
|
@@ -0,0 +1,2080 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge module of the 1.1.90 consolidation: every correlated variation of the bridge logic interned in this one file, so the module family carries one surface without duplicate variations.
|
|
3
|
+
* The correlation is the bridge chain from the wire contract outward to the native host: servercontract holds the wire contract the static site and the extension speak (the versioned envelope with its stable operation ids, the capability negotiation, the session and event operation bodies with their schema validation and the payload minimization that keeps page content on the device without the explicit consent flag); socketrelay holds the site relay client (the wss connection through an injected socket seam, the frame authentication, the reconnect backoff with token rotation, the multiplexed streams, the offline queue with operation id deduplication, the heartbeat frames and the idle expiry); chatbridge holds the chat surface of the site (the chat rows, the plan review cards, the review decisions and the pairing panel, render and collect only, never execute); wsbridge holds the localhost native relay (the loopback bind check, the per session token with its hash only records, the one extension connection rule and the envelope translation that reuses the servercontract shapes); and nativehost holds the native host itself (the manifest installer and uninstaller, the runtime port lifecycle with its attach, crash and reattach states, the companion handshake with its one major version window, the surface catalog behind consent, the correlation ids, the heartbeats, the origin and session checks, the secret exclusion and the graceful degradation).
|
|
4
|
+
* No relay url, host, vendor endpoint, port, window or cap is ever hardcoded anywhere in the family: the relay url stays the user's setting, the wsbridge binds localhost only, the raw session token never enters a log or audit entry, and no bridge path ever bypasses the human review.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* ── Merged from servercontract.ts ── */
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
agentplan,
|
|
11
|
+
bridgereviewcard,
|
|
12
|
+
bridgereviewdecision,
|
|
13
|
+
servercontractcapabilities,
|
|
14
|
+
serverenvelope,
|
|
15
|
+
servereventtype,
|
|
16
|
+
serveroperation,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import { servercontractversion } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** Re-exports the servercontract version of the types module so the contract surface reads from one module. */
|
|
21
|
+
export { servercontractversion };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Servercontract of the 1.1.82 site integration family.
|
|
25
|
+
* Every wire rule the static devthink.pro site and the extension speak lives in this one pure module: the message envelope with the negotiated version, one wire operation and the stable operation id every reply quotes for correlation; the capability negotiation that intersects the operations and event types of both handshake sides; the sessioncreate, sessionjoin, eventpost and eventstream operation bodies with their schema validation that rejects every malformed envelope; the chat, plan proposal, plan review and progress event payloads; and the data minimization that keeps every payload to plan text and statuses so page content never crosses the bridge without the explicit consent flag. The relay server url is always a user setting with no default — this module never names a relay, a host or a vendor endpoint, and the frame codec is pure json so the socket seam in socketrelay stays the only impure boundary.
|
|
26
|
+
* Example: `const envelope = composeenvelope({ op: "eventpost", opid: stableopid("ext", 1), at: now, sessionid, token, body: eventpostbody({ event: chateventpayload({ text, from }) }) }); negotiatecapabilities(contractcapabilities(), sitecapabilities).ok;`
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The wire operations the servercontract defines, in the stable order the capability intersection keeps. */
|
|
30
|
+
export const serveroperations: serveroperation[] = ["sessioncreate", "sessionjoin", "eventpost", "eventstream"];
|
|
31
|
+
|
|
32
|
+
/** The event types the bridge carries, in the stable order the capability intersection keeps. */
|
|
33
|
+
export const servereventtypes: servereventtype[] = ["chat", "planproposal", "planreview", "progress"];
|
|
34
|
+
|
|
35
|
+
/** The field keys page content hides behind: a payload key of this shape never crosses the bridge unless the explicit page consent flag is set, so plan text and statuses stay the default payload. */
|
|
36
|
+
export const pagecontentkeys: string[] = [
|
|
37
|
+
"html",
|
|
38
|
+
"content",
|
|
39
|
+
"body",
|
|
40
|
+
"textpreview",
|
|
41
|
+
"preview",
|
|
42
|
+
"dom",
|
|
43
|
+
"markup",
|
|
44
|
+
"screenshot",
|
|
45
|
+
"pagetext",
|
|
46
|
+
"outerhtml",
|
|
47
|
+
"innertext",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** The capabilities this side of the servercontract declares: every operation, every event type, heartbeat frames and stream multiplexing over one socket. */
|
|
51
|
+
export function contractcapabilities(version: number = servercontractversion): servercontractcapabilities {
|
|
52
|
+
return {
|
|
53
|
+
version,
|
|
54
|
+
operations: [...serveroperations],
|
|
55
|
+
events: [...servereventtypes],
|
|
56
|
+
heartbeat: true,
|
|
57
|
+
multiplex: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Builds the stable operation id one reply correlates: the sender seed and the per sender sequence compose an id that stays unique inside one session without any randomness on the wire. */
|
|
62
|
+
export function stableopid(seed: string, sequence: number): string {
|
|
63
|
+
const trimmed = seed.trim();
|
|
64
|
+
if (trimmed === "")
|
|
65
|
+
throw new Error("The operation id names its sender seed; an empty seed never correlates a reply.");
|
|
66
|
+
if (!Number.isInteger(sequence) || sequence < 1)
|
|
67
|
+
throw new Error("The operation id sequence stays a positive whole number per sender.");
|
|
68
|
+
return `op-${trimmed}-${sequence}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Negotiates the shared contract of the two handshake sides: the version is the newest both sides speak, the operations and event types are the intersection in the stable contract order, and a handshake with no shared version, no shared operation or no shared event type refuses instead of degrading silently. */
|
|
72
|
+
export function negotiatecapabilities(
|
|
73
|
+
ours: servercontractcapabilities,
|
|
74
|
+
theirs: servercontractcapabilities,
|
|
75
|
+
): {
|
|
76
|
+
ok: boolean;
|
|
77
|
+
version: number;
|
|
78
|
+
operations: serveroperation[];
|
|
79
|
+
events: servereventtype[];
|
|
80
|
+
heartbeat: boolean;
|
|
81
|
+
multiplex: boolean;
|
|
82
|
+
reason?: string;
|
|
83
|
+
} {
|
|
84
|
+
if (!Number.isInteger(ours.version) || ours.version < 1 || !Number.isInteger(theirs.version) || theirs.version < 1)
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
version: 0,
|
|
88
|
+
operations: [],
|
|
89
|
+
events: [],
|
|
90
|
+
heartbeat: false,
|
|
91
|
+
multiplex: false,
|
|
92
|
+
reason: "The servercontract version stays a positive whole number on both sides of the handshake.",
|
|
93
|
+
};
|
|
94
|
+
const version = Math.min(ours.version, theirs.version);
|
|
95
|
+
const operations = serveroperations.filter(
|
|
96
|
+
(operation) => ours.operations.includes(operation) && theirs.operations.includes(operation),
|
|
97
|
+
);
|
|
98
|
+
const events = servereventtypes.filter((event) => ours.events.includes(event) && theirs.events.includes(event));
|
|
99
|
+
if (version < 1)
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
version: 0,
|
|
103
|
+
operations: [],
|
|
104
|
+
events: [],
|
|
105
|
+
heartbeat: false,
|
|
106
|
+
multiplex: false,
|
|
107
|
+
reason: "The two handshake sides share no servercontract version.",
|
|
108
|
+
};
|
|
109
|
+
if (operations.length === 0)
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
version,
|
|
113
|
+
operations: [],
|
|
114
|
+
events: [],
|
|
115
|
+
heartbeat: false,
|
|
116
|
+
multiplex: false,
|
|
117
|
+
reason: "The two handshake sides share no wire operation.",
|
|
118
|
+
};
|
|
119
|
+
if (events.length === 0)
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
version,
|
|
123
|
+
operations: [],
|
|
124
|
+
events: [],
|
|
125
|
+
heartbeat: false,
|
|
126
|
+
multiplex: false,
|
|
127
|
+
reason: "The two handshake sides share no event type.",
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
version,
|
|
132
|
+
operations,
|
|
133
|
+
events,
|
|
134
|
+
heartbeat: ours.heartbeat === true && theirs.heartbeat === true,
|
|
135
|
+
multiplex: ours.multiplex === true && theirs.multiplex === true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Composes one servercontract envelope: the version defaults to this build's contract version, the operation id stays exactly the caller's stable id and the body rides as reviewed json. */
|
|
140
|
+
export function composeenvelope(input: {
|
|
141
|
+
op: serveroperation;
|
|
142
|
+
opid: string;
|
|
143
|
+
at: number;
|
|
144
|
+
version?: number;
|
|
145
|
+
sessionid?: string;
|
|
146
|
+
token?: string;
|
|
147
|
+
body?: Record<string, unknown>;
|
|
148
|
+
}): serverenvelope {
|
|
149
|
+
const opid = input.opid.trim();
|
|
150
|
+
if (opid === "")
|
|
151
|
+
throw new Error("The envelope carries its stable operation id; an empty id never correlates a reply.");
|
|
152
|
+
if (!serveroperations.includes(input.op))
|
|
153
|
+
throw new Error(`The envelope operation ${input.op} sits outside the servercontract operations.`);
|
|
154
|
+
if (!Number.isFinite(input.at)) throw new Error("The envelope carries its timestamp.");
|
|
155
|
+
return {
|
|
156
|
+
version: input.version ?? servercontractversion,
|
|
157
|
+
op: input.op,
|
|
158
|
+
opid,
|
|
159
|
+
at: input.at,
|
|
160
|
+
...(input.sessionid !== undefined && input.sessionid.trim() !== "" ? { sessionid: input.sessionid.trim() } : {}),
|
|
161
|
+
...(input.token !== undefined && input.token.trim() !== "" ? { token: input.token.trim() } : {}),
|
|
162
|
+
...(input.body !== undefined ? { body: input.body } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Parses one unknown value into a servercontract envelope with full schema validation: the value must be an object, the version a positive whole number, the operation one of the four wire operations, the operation id a non-empty string, the session id and token non-empty strings when present, the timestamp a finite number and the body a json object when present; every failure rejects the envelope with the field that named it. */
|
|
167
|
+
export function parseenvelope(value: unknown): serverenvelope {
|
|
168
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
169
|
+
throw new Error("The servercontract envelope must be a json object.");
|
|
170
|
+
const entry = value as Record<string, unknown>;
|
|
171
|
+
if (!Number.isInteger(entry.version) || (entry.version as number) < 1)
|
|
172
|
+
throw new Error("The envelope version must be a positive whole number.");
|
|
173
|
+
if (typeof entry.op !== "string" || !serveroperations.includes(entry.op as serveroperation))
|
|
174
|
+
throw new Error(`The envelope operation ${String(entry.op)} sits outside the servercontract operations.`);
|
|
175
|
+
if (typeof entry.opid !== "string" || entry.opid.trim() === "")
|
|
176
|
+
throw new Error("The envelope operation id must be a non-empty string.");
|
|
177
|
+
if (entry.sessionid !== undefined && (typeof entry.sessionid !== "string" || entry.sessionid.trim() === ""))
|
|
178
|
+
throw new Error("The envelope session id must be a non-empty string when present.");
|
|
179
|
+
if (entry.token !== undefined && (typeof entry.token !== "string" || entry.token.trim() === ""))
|
|
180
|
+
throw new Error("The envelope frame token must be a non-empty string when present.");
|
|
181
|
+
if (!Number.isFinite(entry.at)) throw new Error("The envelope timestamp must be a finite number.");
|
|
182
|
+
if (entry.body !== undefined && (!entry.body || typeof entry.body !== "object" || Array.isArray(entry.body)))
|
|
183
|
+
throw new Error("The envelope body must be a json object when present.");
|
|
184
|
+
return {
|
|
185
|
+
version: entry.version as number,
|
|
186
|
+
op: entry.op as serveroperation,
|
|
187
|
+
opid: entry.opid.trim(),
|
|
188
|
+
at: entry.at as number,
|
|
189
|
+
...(entry.sessionid !== undefined ? { sessionid: (entry.sessionid as string).trim() } : {}),
|
|
190
|
+
...(entry.token !== undefined ? { token: (entry.token as string).trim() } : {}),
|
|
191
|
+
...(entry.body !== undefined ? { body: entry.body as Record<string, unknown> } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Reads one envelope acceptance without throwing: the accepted envelope returns with ok true while every schema failure returns the rejection reason the wire logs correlate. */
|
|
196
|
+
export function envelopeacceptance(value: unknown): { ok: boolean; envelope?: serverenvelope; reason?: string } {
|
|
197
|
+
try {
|
|
198
|
+
return { ok: true, envelope: parseenvelope(value) };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Builds the sessioncreate body: the member role, the member id and the capabilities the joining side declares, so the relay answers the session id and the negotiated contract. */
|
|
205
|
+
export function sessioncreatebody(input: {
|
|
206
|
+
role: "extension" | "site";
|
|
207
|
+
memberid: string;
|
|
208
|
+
capabilities: servercontractcapabilities;
|
|
209
|
+
pairingcode?: string;
|
|
210
|
+
}): Record<string, unknown> {
|
|
211
|
+
const memberid = input.memberid.trim();
|
|
212
|
+
if (memberid === "") throw new Error("The sessioncreate body names its member id.");
|
|
213
|
+
if (input.role !== "extension" && input.role !== "site")
|
|
214
|
+
throw new Error("The sessioncreate role is the extension or the site side.");
|
|
215
|
+
return {
|
|
216
|
+
role: input.role,
|
|
217
|
+
memberid,
|
|
218
|
+
capabilities: input.capabilities,
|
|
219
|
+
...(input.pairingcode !== undefined && input.pairingcode.trim() !== ""
|
|
220
|
+
? { pairingcode: input.pairingcode.trim() }
|
|
221
|
+
: {}),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Builds the sessionjoin body: the session the member joins (empty when the pairing code alone identifies the session), the member role and id, the declared capabilities and the pairing code the site side exchanges for its session token. */
|
|
226
|
+
export function sessionjoinbody(input: {
|
|
227
|
+
sessionid?: string;
|
|
228
|
+
role: "extension" | "site";
|
|
229
|
+
memberid: string;
|
|
230
|
+
capabilities: servercontractcapabilities;
|
|
231
|
+
pairingcode?: string;
|
|
232
|
+
}): Record<string, unknown> {
|
|
233
|
+
const sessionid = (input.sessionid ?? "").trim();
|
|
234
|
+
const pairingcode = (input.pairingcode ?? "").trim();
|
|
235
|
+
if (sessionid === "" && pairingcode === "")
|
|
236
|
+
throw new Error(
|
|
237
|
+
"The sessionjoin body names its session id or rides its pairing code; one of the two identifies the join.",
|
|
238
|
+
);
|
|
239
|
+
const memberid = input.memberid.trim();
|
|
240
|
+
if (memberid === "") throw new Error("The sessionjoin body names its member id.");
|
|
241
|
+
if (input.role !== "extension" && input.role !== "site")
|
|
242
|
+
throw new Error("The sessionjoin role is the extension or the site side.");
|
|
243
|
+
return {
|
|
244
|
+
...(sessionid !== "" ? { sessionid } : {}),
|
|
245
|
+
role: input.role,
|
|
246
|
+
memberid,
|
|
247
|
+
capabilities: input.capabilities,
|
|
248
|
+
...(pairingcode !== "" ? { pairingcode } : {}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Builds the eventpost body: the event type, the multiplexed stream name and the minimized payload; the event type must be one of the four wire event types and the stream a non-empty name. */
|
|
253
|
+
export function eventpostbody(input: {
|
|
254
|
+
event: { kind: servereventtype; stream: string; payload: Record<string, unknown> };
|
|
255
|
+
}): Record<string, unknown> {
|
|
256
|
+
if (!servereventtypes.includes(input.event.kind))
|
|
257
|
+
throw new Error(`The event type ${input.event.kind} sits outside the servercontract event types.`);
|
|
258
|
+
const stream = input.event.stream.trim();
|
|
259
|
+
if (stream === "") throw new Error("The eventpost body names its multiplexed stream.");
|
|
260
|
+
if (!input.event.payload || typeof input.event.payload !== "object" || Array.isArray(input.event.payload))
|
|
261
|
+
throw new Error("The eventpost payload must be a json object.");
|
|
262
|
+
return { kind: input.event.kind, stream, payload: input.event.payload };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Builds the eventstream body: the multiplexed streams one side subscribes to and the optional timestamp the subscription resumes after. */
|
|
266
|
+
export function eventstreambody(input: { streams: string[]; since?: number }): Record<string, unknown> {
|
|
267
|
+
const streams = input.streams.map((stream) => stream.trim()).filter((stream) => stream !== "");
|
|
268
|
+
if (streams.length === 0) throw new Error("The eventstream body names at least one multiplexed stream.");
|
|
269
|
+
if (input.since !== undefined && !Number.isFinite(input.since))
|
|
270
|
+
throw new Error("The eventstream resume timestamp stays a finite number.");
|
|
271
|
+
return { streams, ...(input.since !== undefined ? { since: input.since } : {}) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Builds the chat event payload: the task text the human typed and the sender name; the chat text is the reviewed task text itself, never captured page content. */
|
|
275
|
+
export function chateventpayload(input: { text: string; from: string }): Record<string, unknown> {
|
|
276
|
+
const text = input.text.trim();
|
|
277
|
+
if (text === "") throw new Error("The chat event carries its task text.");
|
|
278
|
+
const from = input.from.trim();
|
|
279
|
+
if (from === "") throw new Error("The chat event names its sender.");
|
|
280
|
+
return { text, from };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Builds the plan proposal event payload from the review card: the proposal id, the plan title and the step digest with kinds, origins and sensitive flags — plan text and statuses only, so no page content rides the proposal. */
|
|
284
|
+
export function planproposaleventpayload(card: bridgereviewcard): Record<string, unknown> {
|
|
285
|
+
const proposalid = card.proposalid.trim();
|
|
286
|
+
if (proposalid === "") throw new Error("The plan proposal names its proposal id.");
|
|
287
|
+
const title = card.title.trim();
|
|
288
|
+
if (title === "") throw new Error("The plan proposal names its plan title.");
|
|
289
|
+
if (card.steps.length === 0) throw new Error("The plan proposal carries at least one step of its digest.");
|
|
290
|
+
return {
|
|
291
|
+
proposalid,
|
|
292
|
+
title,
|
|
293
|
+
steps: card.steps.map((step) => ({ id: step.id, kind: step.kind, origin: step.origin, sensitive: step.sensitive })),
|
|
294
|
+
...(card.timeoutat !== undefined ? { timeoutat: card.timeoutat } : {}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Builds the plan review event payload from the returned decision: the proposal id, the decision, the reviewer and the optional note. */
|
|
299
|
+
export function planrevieweventpayload(decision: bridgereviewdecision): Record<string, unknown> {
|
|
300
|
+
const proposalid = decision.proposalid.trim();
|
|
301
|
+
if (proposalid === "") throw new Error("The plan review names its proposal id.");
|
|
302
|
+
if (decision.decision !== "approved" && decision.decision !== "changes" && decision.decision !== "refused")
|
|
303
|
+
throw new Error("The plan review decision is approved, changes or refused.");
|
|
304
|
+
const by = decision.by.trim();
|
|
305
|
+
if (by === "") throw new Error("The plan review names its reviewer.");
|
|
306
|
+
return {
|
|
307
|
+
proposalid,
|
|
308
|
+
decision: decision.decision,
|
|
309
|
+
by,
|
|
310
|
+
...(decision.note !== undefined && decision.note.trim() !== "" ? { note: decision.note.trim() } : {}),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Builds the progress event payload: the step id and its status word — statuses only, so the run progress never carries observation bytes. */
|
|
315
|
+
export function progresseventpayload(input: { stepid: string; status: string }): Record<string, unknown> {
|
|
316
|
+
const stepid = input.stepid.trim();
|
|
317
|
+
if (stepid === "") throw new Error("The progress event names its step id.");
|
|
318
|
+
const status = input.status.trim();
|
|
319
|
+
if (status === "") throw new Error("The progress event names its status word.");
|
|
320
|
+
return { stepid, status };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Builds the heartbeat event the bridge socket keeps alive with: a progress event on the heartbeat stream, so the four wire operations and event types stay complete with no extra kind. */
|
|
324
|
+
export function heartbeatevent(now: number): {
|
|
325
|
+
kind: servereventtype;
|
|
326
|
+
stream: string;
|
|
327
|
+
payload: Record<string, unknown>;
|
|
328
|
+
} {
|
|
329
|
+
if (!Number.isFinite(now)) throw new Error("The heartbeat event carries its timestamp.");
|
|
330
|
+
return {
|
|
331
|
+
kind: "progress",
|
|
332
|
+
stream: "heartbeat",
|
|
333
|
+
payload: progresseventpayload({ stepid: "heartbeat", status: "alive" }),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Minimizes one payload for the bridge: the chat task text rides as the human typed it while every other event keeps plan text and statuses only — a page content key never crosses the bridge unless the explicit page consent flag is set, and the strip report names every field the minimization held. */
|
|
338
|
+
export function bridgepayload(
|
|
339
|
+
kind: servereventtype,
|
|
340
|
+
payload: Record<string, unknown>,
|
|
341
|
+
pageconsent?: boolean,
|
|
342
|
+
): { payload: Record<string, unknown>; held: string[] } {
|
|
343
|
+
if (kind === "chat") return { payload, held: [] };
|
|
344
|
+
const held: string[] = [];
|
|
345
|
+
const minimized: Record<string, unknown> = {};
|
|
346
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
347
|
+
if (pagecontentkeys.includes(key) && pageconsent !== true) {
|
|
348
|
+
held.push(key);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
minimized[key] = value;
|
|
352
|
+
}
|
|
353
|
+
return { payload: minimized, held };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Reduces one approved plan to its bridge digest: the objective text and every step with its kind and status word — the plan text and statuses the review needs, never the observation bytes the steps read. */
|
|
357
|
+
export function minimalplandigest(
|
|
358
|
+
plan: agentplan,
|
|
359
|
+
statuses: Record<string, string> = {},
|
|
360
|
+
): { title: string; steps: Array<{ id: string; kind: string; status: string }> } {
|
|
361
|
+
const objective = plan.objective.trim();
|
|
362
|
+
if (objective === "") throw new Error("The plan digest names its objective.");
|
|
363
|
+
return {
|
|
364
|
+
title: objective,
|
|
365
|
+
steps: plan.steps.map((step) => ({ id: step.id, kind: step.kind, status: statuses[step.id] ?? "pending" })),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Serializes one envelope to its wire frame: plain json text, one frame per message, no binary codec. */
|
|
370
|
+
export function frameof(envelope: serverenvelope): string {
|
|
371
|
+
return JSON.stringify(envelope);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Parses one wire frame back into a validated envelope: the frame must be json text that passes the envelope schema validation. */
|
|
375
|
+
export function parsewireframe(text: string): serverenvelope {
|
|
376
|
+
let parsed: unknown;
|
|
377
|
+
try {
|
|
378
|
+
parsed = JSON.parse(text);
|
|
379
|
+
} catch {
|
|
380
|
+
throw new Error("The wire frame must be json text.");
|
|
381
|
+
}
|
|
382
|
+
return parseenvelope(parsed);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Builds the site manifest the static site declares: the name, the servercontract version the site speaks and the capabilities it offers, so the extension reads the contract version before the first frame. */
|
|
386
|
+
export function sitemanifestof(version: number = servercontractversion): {
|
|
387
|
+
name: string;
|
|
388
|
+
servercontract: number;
|
|
389
|
+
capabilities: servercontractcapabilities;
|
|
390
|
+
} {
|
|
391
|
+
return { name: "devthink site", servercontract: version, capabilities: contractcapabilities(version) };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/* ── Merged from socketrelay.ts ── */
|
|
395
|
+
|
|
396
|
+
import type { bridgeeventrecord, bridgequeuerecord } from "./types.js";
|
|
397
|
+
import { reconnectwaits } from "./net.js";
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Socket relay client of the 1.1.82 site integration family.
|
|
401
|
+
* Every correlated rule of the bridge socket lives in this one module: the connection to the user configured relay url over wss through an injected socket seam (the extension executor wires the browser websocket, tests wire plain fixtures or the local testrelay); the frame authentication that stamps the sharedauth session token on every outgoing frame and verifies the session and token of every incoming frame; the reconnect with exponential backoff that reuses the reviewed backoff family of the socketbus module and asks the injected rotation seam for a fresh token on every reconnect; the multiplexing of the chat, review, progress and heartbeat streams over one socket; the per session rate window that caps the frames one window accepts; the offline queue that buffers frames while the socket is down and replays them with operation id deduplication on reconnect; and the heartbeat frames and the idle expiry that close a quiet session inside the user configured window. The relay url is always the caller's user setting — this module never names a relay, a host or a vendor endpoint, and no url, token or page content enters any log line it builds.
|
|
402
|
+
* Example: `const opened = await connectrelay({ state: client, open: socketseam, sleep, now }); const posted = postevent(opened.state, opened.socket, { kind: "chat", stream: "chat", payload: { text, from } }, now);`
|
|
403
|
+
*/
|
|
404
|
+
|
|
405
|
+
/** The socket seam the impure boundary injects: one url resolves to an open socket that sends text frames, closes with a code and reports its frames, closes and errors through callbacks; the extension executor wires the browser websocket, tests wire plain fixtures. */
|
|
406
|
+
export interface relaysocket {
|
|
407
|
+
send(frame: string): void;
|
|
408
|
+
close(code?: number): void;
|
|
409
|
+
onmessage?: (frame: string) => void;
|
|
410
|
+
onclose?: (code: number) => void;
|
|
411
|
+
onerror?: (error: string) => void;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The socket open seam signature: one reviewed relay url resolves to an open socket or throws the connection error; the impure open lives behind this seam. */
|
|
415
|
+
export type socketopen = (url: string) => Promise<relaysocket>;
|
|
416
|
+
|
|
417
|
+
/** The token rotation seam signature: the reconnect asks this seam for the fresh session token the sharedauth family rotated; an absent seam keeps the current token. */
|
|
418
|
+
export type tokenrotation = () => Promise<string>;
|
|
419
|
+
|
|
420
|
+
/** The sleep seam signature of the reconnect backoff: tests inject a fake clock, the executor wires the platform timer. */
|
|
421
|
+
export type relaysleep = (milliseconds: number) => Promise<void>;
|
|
422
|
+
|
|
423
|
+
/** The client state of one bridge socket: the user configured url, its origin, the session and frame token, the member identity, the lifecycle state, the frame counters, the multiplexed stream queues, the correlation acks, the offline queue, the rate window and the heartbeat clock. */
|
|
424
|
+
export interface relayclientstate {
|
|
425
|
+
url: string;
|
|
426
|
+
origin: string;
|
|
427
|
+
sessionid: string;
|
|
428
|
+
token: string;
|
|
429
|
+
memberid: string;
|
|
430
|
+
role: "extension" | "site";
|
|
431
|
+
state: "idle" | "connecting" | "authenticating" | "connected" | "reconnecting" | "closed";
|
|
432
|
+
attempts: number;
|
|
433
|
+
rotations: number;
|
|
434
|
+
sent: number;
|
|
435
|
+
received: number;
|
|
436
|
+
lastframeat: number;
|
|
437
|
+
lastheartbeatat: number;
|
|
438
|
+
streams: Record<string, bridgeeventrecord[]>;
|
|
439
|
+
acks: serverenvelope[];
|
|
440
|
+
queue: bridgequeuerecord[];
|
|
441
|
+
capabilities: servercontractcapabilities;
|
|
442
|
+
rate: { used: number; windowstartedat: number };
|
|
443
|
+
sequence: number;
|
|
444
|
+
lasterror?: string;
|
|
445
|
+
openedat?: number;
|
|
446
|
+
closedat?: number;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Resolves the origin of one relay url: a wss url maps onto its https origin and a ws url onto its http origin so the origin scoping of the sharedauth tokens covers both; an unparsable url resolves to the empty origin the gates refuse. */
|
|
450
|
+
export function relayorigin(url: string): string {
|
|
451
|
+
try {
|
|
452
|
+
const parsed = new URL(url.trim());
|
|
453
|
+
if (parsed.protocol !== "wss:" && parsed.protocol !== "ws:") return "";
|
|
454
|
+
const protocol = parsed.protocol === "wss:" ? "https:" : "http:";
|
|
455
|
+
return `${protocol}//${parsed.host}`;
|
|
456
|
+
} catch {
|
|
457
|
+
return "";
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Creates the client state of one bridge socket: the url is the caller's user setting (an empty url leaves the bridge disabled), the session and token start empty until the pairing exchange fills them and every counter starts at zero. */
|
|
462
|
+
export function newrelayclient(input: {
|
|
463
|
+
url: string;
|
|
464
|
+
memberid: string;
|
|
465
|
+
role: "extension" | "site";
|
|
466
|
+
capabilities: servercontractcapabilities;
|
|
467
|
+
sessionid?: string;
|
|
468
|
+
token?: string;
|
|
469
|
+
now: number;
|
|
470
|
+
}): relayclientstate {
|
|
471
|
+
const memberid = input.memberid.trim();
|
|
472
|
+
if (memberid === "") throw new Error("The bridge client names its member id.");
|
|
473
|
+
const url = input.url.trim();
|
|
474
|
+
if (url !== "" && relayorigin(url) === "") throw new Error("The relay url must be a wss or ws url the user chose.");
|
|
475
|
+
return {
|
|
476
|
+
url,
|
|
477
|
+
origin: relayorigin(url),
|
|
478
|
+
sessionid: (input.sessionid ?? "").trim(),
|
|
479
|
+
token: (input.token ?? "").trim(),
|
|
480
|
+
memberid,
|
|
481
|
+
role: input.role,
|
|
482
|
+
state: url === "" ? "idle" : "connecting",
|
|
483
|
+
attempts: 0,
|
|
484
|
+
rotations: 0,
|
|
485
|
+
sent: 0,
|
|
486
|
+
received: 0,
|
|
487
|
+
lastframeat: input.now,
|
|
488
|
+
lastheartbeatat: input.now,
|
|
489
|
+
streams: {},
|
|
490
|
+
acks: [],
|
|
491
|
+
queue: [],
|
|
492
|
+
capabilities: input.capabilities,
|
|
493
|
+
rate: { used: 0, windowstartedat: input.now },
|
|
494
|
+
sequence: 0,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Verifies the frame authentication of one incoming envelope: the frame carries the session id and the token of the live client state, so a frame of another session or another token never passes. */
|
|
499
|
+
export function verifyframeauth(envelope: serverenvelope, state: relayclientstate): boolean {
|
|
500
|
+
if (state.sessionid === "" || state.token === "") return false;
|
|
501
|
+
return envelope.sessionid === state.sessionid && envelope.token === state.token;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Stamps the frame authentication on one outgoing envelope: the session id and the session token ride every frame the client sends. */
|
|
505
|
+
export function frameauth(envelope: serverenvelope, state: relayclientstate): serverenvelope {
|
|
506
|
+
return { ...envelope, sessionid: state.sessionid, token: state.token };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Reads the per session rate window: the window resets once it ages out, the used count grows with every accepted frame and a cap the user chose refuses the frame that crosses it while an absent cap keeps the bridge unbounded as the documented user choice. */
|
|
510
|
+
export function bridgeframerate(input: {
|
|
511
|
+
rate: { used: number; windowstartedat: number };
|
|
512
|
+
cap?: number;
|
|
513
|
+
window?: number;
|
|
514
|
+
now: number;
|
|
515
|
+
}): { allowed: boolean; used: number; windowstartedat: number; resetsat?: number } {
|
|
516
|
+
const window =
|
|
517
|
+
input.window !== undefined && Number.isFinite(input.window) && input.window > 0 ? input.window : undefined;
|
|
518
|
+
if (window === undefined)
|
|
519
|
+
return { allowed: true, used: input.rate.used, windowstartedat: input.rate.windowstartedat };
|
|
520
|
+
if (input.now - input.rate.windowstartedat >= window)
|
|
521
|
+
return {
|
|
522
|
+
allowed: input.cap === undefined || input.cap >= 1,
|
|
523
|
+
used: input.cap === undefined ? input.rate.used : 1,
|
|
524
|
+
windowstartedat: input.now,
|
|
525
|
+
resetsat: input.now + window,
|
|
526
|
+
};
|
|
527
|
+
const used = input.rate.used + 1;
|
|
528
|
+
if (input.cap !== undefined && Number.isFinite(input.cap) && input.cap >= 1 && used > input.cap)
|
|
529
|
+
return {
|
|
530
|
+
allowed: false,
|
|
531
|
+
used: input.rate.used,
|
|
532
|
+
windowstartedat: input.rate.windowstartedat,
|
|
533
|
+
resetsat: input.rate.windowstartedat + window,
|
|
534
|
+
};
|
|
535
|
+
return {
|
|
536
|
+
allowed: true,
|
|
537
|
+
used,
|
|
538
|
+
windowstartedat: input.rate.windowstartedat,
|
|
539
|
+
resetsat: input.rate.windowstartedat + window,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Sends one envelope through the open socket: the frame authentication stamps the session and token, the sent counter and the frame clock advance, and the wire frame is the plain json text of the servercontract codec. */
|
|
544
|
+
export function sendframe(
|
|
545
|
+
state: relayclientstate,
|
|
546
|
+
socket: relaysocket,
|
|
547
|
+
envelope: serverenvelope,
|
|
548
|
+
now: number,
|
|
549
|
+
): { state: relayclientstate; sent: boolean; reason?: string } {
|
|
550
|
+
if (state.state !== "connected")
|
|
551
|
+
return { state, sent: false, reason: "The bridge socket stays down; the frame waits in the offline queue." };
|
|
552
|
+
const stamped = frameauth(envelope, state);
|
|
553
|
+
socket.send(frameof(stamped));
|
|
554
|
+
return { state: { ...state, sent: state.sent + 1, lastframeat: now }, sent: true };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Receives one wire frame: the frame parses through the servercontract schema validation, the frame authentication must match the live session and token, the multiplexed streams collect every eventpost by its stream name while the correlation acks collect the operation replies, and a rejected frame returns its reason without touching the stream state. */
|
|
558
|
+
export function receiveframe(
|
|
559
|
+
state: relayclientstate,
|
|
560
|
+
frame: string,
|
|
561
|
+
now: number,
|
|
562
|
+
): { state: relayclientstate; envelope?: serverenvelope; rejection?: string } {
|
|
563
|
+
let envelope: serverenvelope;
|
|
564
|
+
try {
|
|
565
|
+
envelope = parsewireframe(frame);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
return { state, rejection: error instanceof Error ? error.message : String(error) };
|
|
568
|
+
}
|
|
569
|
+
if (!verifyframeauth(envelope, state))
|
|
570
|
+
return {
|
|
571
|
+
state,
|
|
572
|
+
rejection: `The frame ${envelope.opid} failed the frame authentication of the session ${state.sessionid}.`,
|
|
573
|
+
};
|
|
574
|
+
if (envelope.op === "eventpost" && envelope.body !== undefined) {
|
|
575
|
+
const stream = typeof envelope.body.stream === "string" ? envelope.body.stream.trim() : "";
|
|
576
|
+
const kind = typeof envelope.body.kind === "string" ? envelope.body.kind : "";
|
|
577
|
+
if (stream !== "") {
|
|
578
|
+
const record: bridgeeventrecord = {
|
|
579
|
+
id: envelope.opid,
|
|
580
|
+
sessionid: state.sessionid,
|
|
581
|
+
kind: kind as bridgeeventrecord["kind"],
|
|
582
|
+
opid: envelope.opid,
|
|
583
|
+
stream,
|
|
584
|
+
payload: (envelope.body.payload as Record<string, unknown>) ?? {},
|
|
585
|
+
at: envelope.at,
|
|
586
|
+
delivered: true,
|
|
587
|
+
};
|
|
588
|
+
const next: relayclientstate = {
|
|
589
|
+
...state,
|
|
590
|
+
received: state.received + 1,
|
|
591
|
+
lastframeat: now,
|
|
592
|
+
acks: [...state.acks, envelope],
|
|
593
|
+
streams: { ...state.streams, [stream]: [...(state.streams[stream] ?? []), record] },
|
|
594
|
+
};
|
|
595
|
+
return { state: next, envelope };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
state: { ...state, received: state.received + 1, lastframeat: now, acks: [...state.acks, envelope] },
|
|
600
|
+
envelope,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Buffers one frame body into the offline queue while the socket is down: the stable operation id deduplicates the buffer, so the same review request queued twice replays once. */
|
|
605
|
+
export function enqueuebridge(
|
|
606
|
+
state: relayclientstate,
|
|
607
|
+
input: { op: serveroperation; opid: string; body: Record<string, unknown>; now: number },
|
|
608
|
+
): relayclientstate {
|
|
609
|
+
if (state.queue.some((record) => record.opid === input.opid)) return state;
|
|
610
|
+
const record: bridgequeuerecord = {
|
|
611
|
+
opid: input.opid,
|
|
612
|
+
op: input.op,
|
|
613
|
+
sessionid: state.sessionid,
|
|
614
|
+
body: input.body,
|
|
615
|
+
queuedat: input.now,
|
|
616
|
+
attempts: 0,
|
|
617
|
+
};
|
|
618
|
+
return { ...state, queue: [...state.queue, record] };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Replays the offline queue over the reconnected socket: every unsent frame sends once with its stable operation id, the replay deduplicates by operation id, the attempts grow and the send time records; the queue keeps the sent records for the audit trail. */
|
|
622
|
+
export function replaybridgequeue(
|
|
623
|
+
state: relayclientstate,
|
|
624
|
+
socket: relaysocket,
|
|
625
|
+
now: number,
|
|
626
|
+
): { state: relayclientstate; replayed: number; deduped: number } {
|
|
627
|
+
let replayed = 0;
|
|
628
|
+
let deduped = 0;
|
|
629
|
+
const seen = new Set<string>();
|
|
630
|
+
const queue: bridgequeuerecord[] = [];
|
|
631
|
+
let sent = state.sent;
|
|
632
|
+
for (const record of state.queue) {
|
|
633
|
+
if (seen.has(record.opid)) {
|
|
634
|
+
deduped += 1;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
seen.add(record.opid);
|
|
638
|
+
if (record.sentat !== undefined || state.state !== "connected") {
|
|
639
|
+
queue.push(record);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
socket.send(
|
|
643
|
+
frameof(frameauth(composeenvelope({ op: record.op, opid: record.opid, at: now, body: record.body }), state)),
|
|
644
|
+
);
|
|
645
|
+
queue.push({ ...record, attempts: record.attempts + 1, sentat: now });
|
|
646
|
+
replayed += 1;
|
|
647
|
+
sent += 1;
|
|
648
|
+
}
|
|
649
|
+
return { state: { ...state, queue, sent, lastframeat: now }, replayed, deduped };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Posts one bridge event: a connected socket sends the eventpost frame inside the rate window while a down socket buffers the frame into the offline queue, so no event drops and no frame crosses an exhausted window. */
|
|
653
|
+
export function postevent(
|
|
654
|
+
state: relayclientstate,
|
|
655
|
+
socket: relaysocket | undefined,
|
|
656
|
+
event: { kind: bridgeeventrecord["kind"]; stream: string; payload: Record<string, unknown> },
|
|
657
|
+
now: number,
|
|
658
|
+
rate?: { cap?: number; window?: number },
|
|
659
|
+
): { state: relayclientstate; sent: boolean; queued: boolean; reason?: string } {
|
|
660
|
+
const opid = `op-${state.memberid}-${state.sequence + 1}`;
|
|
661
|
+
const body = eventpostbody({ event });
|
|
662
|
+
if (state.state !== "connected" || socket === undefined) {
|
|
663
|
+
return {
|
|
664
|
+
state: enqueuebridge({ ...state, sequence: state.sequence + 1 }, { op: "eventpost", opid, body, now }),
|
|
665
|
+
sent: false,
|
|
666
|
+
queued: true,
|
|
667
|
+
reason: "The bridge socket stays down; the event waits in the offline queue.",
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
const window = bridgeframerate({
|
|
671
|
+
rate: state.rate,
|
|
672
|
+
...(rate?.cap !== undefined ? { cap: rate.cap } : {}),
|
|
673
|
+
...(rate?.window !== undefined ? { window: rate.window } : {}),
|
|
674
|
+
now,
|
|
675
|
+
});
|
|
676
|
+
if (!window.allowed) {
|
|
677
|
+
return {
|
|
678
|
+
state: enqueuebridge(
|
|
679
|
+
{
|
|
680
|
+
...state,
|
|
681
|
+
sequence: state.sequence + 1,
|
|
682
|
+
rate: { used: window.used, windowstartedat: window.windowstartedat },
|
|
683
|
+
},
|
|
684
|
+
{ op: "eventpost", opid, body, now },
|
|
685
|
+
),
|
|
686
|
+
sent: false,
|
|
687
|
+
queued: true,
|
|
688
|
+
reason: `The bridge rate window holds its ${rate?.cap} frame cap; the event waits for the window reset.`,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
const sent = sendframe(
|
|
692
|
+
{ ...state, sequence: state.sequence + 1, rate: { used: window.used, windowstartedat: window.windowstartedat } },
|
|
693
|
+
socket,
|
|
694
|
+
composeenvelope({ op: "eventpost", opid, at: now, body }),
|
|
695
|
+
now,
|
|
696
|
+
);
|
|
697
|
+
return { state: sent.state, sent: true, queued: false };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Sends one heartbeat frame when the user configured interval elapsed: the heartbeat is a progress event on the heartbeat stream, so the socket stays alive inside the four wire operations without a new kind. */
|
|
701
|
+
export function heartbeattick(
|
|
702
|
+
state: relayclientstate,
|
|
703
|
+
socket: relaysocket | undefined,
|
|
704
|
+
now: number,
|
|
705
|
+
interval?: number,
|
|
706
|
+
): { state: relayclientstate; beat: boolean } {
|
|
707
|
+
if (state.state !== "connected" || socket === undefined) return { state, beat: false };
|
|
708
|
+
if (interval === undefined || !Number.isFinite(interval) || interval <= 0) return { state, beat: false };
|
|
709
|
+
if (now - state.lastheartbeatat < interval) return { state, beat: false };
|
|
710
|
+
const event = heartbeatevent(now);
|
|
711
|
+
const opid = `op-${state.memberid}-heartbeat-${state.sent + 1}`;
|
|
712
|
+
const sent = sendframe(
|
|
713
|
+
state,
|
|
714
|
+
socket,
|
|
715
|
+
composeenvelope({ op: "eventpost", opid, at: now, body: eventpostbody({ event }) }),
|
|
716
|
+
now,
|
|
717
|
+
);
|
|
718
|
+
return { state: { ...sent.state, lastheartbeatat: now }, beat: true };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Reads whether the bridge session expired inside the user configured idle window: a session quiet past the window expires while an absent window never expires a session because the bound stays a user choice. */
|
|
722
|
+
export function idleexpired(state: relayclientstate, now: number, idlewindow?: number): boolean {
|
|
723
|
+
if (idlewindow === undefined || !Number.isFinite(idlewindow) || idlewindow <= 0) return false;
|
|
724
|
+
return now - state.lastframeat >= idlewindow;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Closes the bridge socket state: the close records its time and reason, the lifecycle lands on closed and every counter stays for the audit trail. */
|
|
728
|
+
export function disconnectrelay(state: relayclientstate, now: number, reason?: string): relayclientstate {
|
|
729
|
+
return { ...state, state: "closed", closedat: now, ...(reason !== undefined ? { lasterror: reason } : {}) };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Reads the popup bridge status of one client state: disabled when no relay url is configured, connected while the socket is live, paired when the pairing exchange holds a session token and offline while a configured bridge has no live socket. */
|
|
733
|
+
export function bridgestatusview(state: relayclientstate): {
|
|
734
|
+
status: "connected" | "paired" | "offline" | "disabled";
|
|
735
|
+
paired: boolean;
|
|
736
|
+
queued: number;
|
|
737
|
+
} {
|
|
738
|
+
if (state.url === "")
|
|
739
|
+
return {
|
|
740
|
+
status: "disabled",
|
|
741
|
+
paired: false,
|
|
742
|
+
queued: state.queue.filter((record) => record.sentat === undefined).length,
|
|
743
|
+
};
|
|
744
|
+
const paired = state.sessionid !== "" && state.token !== "";
|
|
745
|
+
if (state.state === "connected")
|
|
746
|
+
return { status: "connected", paired, queued: state.queue.filter((record) => record.sentat === undefined).length };
|
|
747
|
+
if (paired)
|
|
748
|
+
return {
|
|
749
|
+
status: "paired",
|
|
750
|
+
paired: true,
|
|
751
|
+
queued: state.queue.filter((record) => record.sentat === undefined).length,
|
|
752
|
+
};
|
|
753
|
+
return {
|
|
754
|
+
status: "offline",
|
|
755
|
+
paired: false,
|
|
756
|
+
queued: state.queue.filter((record) => record.sentat === undefined).length,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Connects the bridge socket through the injected socket seam: the open resolves to a live socket, the client sends its first frame — a sessioncreate with the pairing code when no session exists yet, a sessionjoin with the session id on every reconnect — with the session token as the frame authentication and waits for the reply that quotes the operation id, the negotiated capabilities land on the state and a reply that carries a fresh token rotates the frame authentication. A failed open waits the exponential backoff of the reviewed socketbus family — the rotation seam supplies a fresh token for every retry — until the attempt budget exhausts and the state closes with the last error class.
|
|
762
|
+
*/
|
|
763
|
+
export async function connectrelay(input: {
|
|
764
|
+
state: relayclientstate;
|
|
765
|
+
open: socketopen;
|
|
766
|
+
pairingcode?: string;
|
|
767
|
+
attempts?: number;
|
|
768
|
+
backoffbase?: number;
|
|
769
|
+
backoffceiling?: number;
|
|
770
|
+
rotate?: tokenrotation;
|
|
771
|
+
sleep?: relaysleep;
|
|
772
|
+
now?: () => number;
|
|
773
|
+
}): Promise<{ state: relayclientstate; socket?: relaysocket; error?: string }> {
|
|
774
|
+
const sleep: relaysleep =
|
|
775
|
+
input.sleep ??
|
|
776
|
+
((milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds))));
|
|
777
|
+
const now = input.now ?? Date.now;
|
|
778
|
+
if (input.state.url.trim() === "")
|
|
779
|
+
return {
|
|
780
|
+
state: {
|
|
781
|
+
...input.state,
|
|
782
|
+
state: "idle",
|
|
783
|
+
lasterror:
|
|
784
|
+
"The bridge stays disabled because no relay url is configured; the url is always the user's setting.",
|
|
785
|
+
},
|
|
786
|
+
error: "The bridge stays disabled because no relay url is configured; the url is always the user's setting.",
|
|
787
|
+
};
|
|
788
|
+
const budget = Math.max(1, Math.floor(input.attempts ?? 1));
|
|
789
|
+
const waits = reconnectwaits(budget - 1, input.backoffbase ?? 0, input.backoffceiling);
|
|
790
|
+
let state: relayclientstate = { ...input.state, state: "connecting" };
|
|
791
|
+
let lasterror = "";
|
|
792
|
+
for (let attempt = 0; attempt < budget; attempt += 1) {
|
|
793
|
+
let socket: relaysocket;
|
|
794
|
+
try {
|
|
795
|
+
socket = await input.open(state.url);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
lasterror = error instanceof Error ? error.message : String(error);
|
|
798
|
+
if (attempt < budget - 1) {
|
|
799
|
+
const wait = waits[attempt] ?? 0;
|
|
800
|
+
if (wait > 0) await sleep(wait);
|
|
801
|
+
state = { ...state, attempts: state.attempts + 1, state: "reconnecting" };
|
|
802
|
+
if (input.rotate !== undefined) {
|
|
803
|
+
try {
|
|
804
|
+
state = { ...state, token: await input.rotate(), rotations: state.rotations + 1 };
|
|
805
|
+
} catch {
|
|
806
|
+
/* the rotation seam failure keeps the current token for the next attempt */
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
state = { ...state, state: "authenticating" };
|
|
813
|
+
const opid = `op-${state.memberid}-join-${state.attempts + 1}`;
|
|
814
|
+
const firstjoin = state.sessionid === "";
|
|
815
|
+
const reply = await new Promise<{ frame?: string; error?: string }>((resolve) => {
|
|
816
|
+
socket.onmessage = (frame) => resolve({ frame });
|
|
817
|
+
socket.onclose = (code) =>
|
|
818
|
+
resolve({
|
|
819
|
+
error: `The relay socket closed with code ${code} before the ${firstjoin ? "session create" : "join"} reply.`,
|
|
820
|
+
});
|
|
821
|
+
socket.onerror = (error) => resolve({ error });
|
|
822
|
+
const createsession = firstjoin && state.role === "extension";
|
|
823
|
+
const body = createsession
|
|
824
|
+
? {
|
|
825
|
+
role: state.role,
|
|
826
|
+
memberid: state.memberid,
|
|
827
|
+
capabilities: state.capabilities,
|
|
828
|
+
...(input.pairingcode !== undefined && input.pairingcode.trim() !== ""
|
|
829
|
+
? { pairingcode: input.pairingcode.trim() }
|
|
830
|
+
: {}),
|
|
831
|
+
}
|
|
832
|
+
: firstjoin
|
|
833
|
+
? {
|
|
834
|
+
role: state.role,
|
|
835
|
+
memberid: state.memberid,
|
|
836
|
+
capabilities: state.capabilities,
|
|
837
|
+
...(input.pairingcode !== undefined && input.pairingcode.trim() !== ""
|
|
838
|
+
? { pairingcode: input.pairingcode.trim() }
|
|
839
|
+
: {}),
|
|
840
|
+
}
|
|
841
|
+
: {
|
|
842
|
+
sessionid: state.sessionid,
|
|
843
|
+
role: state.role,
|
|
844
|
+
memberid: state.memberid,
|
|
845
|
+
capabilities: state.capabilities,
|
|
846
|
+
};
|
|
847
|
+
socket.send(
|
|
848
|
+
frameof(
|
|
849
|
+
frameauth(composeenvelope({ op: createsession ? "sessioncreate" : "sessionjoin", opid, at: now(), body }), {
|
|
850
|
+
...state,
|
|
851
|
+
token: state.token,
|
|
852
|
+
}),
|
|
853
|
+
),
|
|
854
|
+
);
|
|
855
|
+
});
|
|
856
|
+
if (reply.frame === undefined) {
|
|
857
|
+
lasterror = reply.error ?? "The relay socket closed before the join reply.";
|
|
858
|
+
try {
|
|
859
|
+
socket.close(1000);
|
|
860
|
+
} catch {
|
|
861
|
+
/* an already closed socket needs no close */
|
|
862
|
+
}
|
|
863
|
+
if (attempt < budget - 1) {
|
|
864
|
+
const wait = waits[attempt] ?? 0;
|
|
865
|
+
if (wait > 0) await sleep(wait);
|
|
866
|
+
state = { ...state, attempts: state.attempts + 1, state: "reconnecting" };
|
|
867
|
+
if (input.rotate !== undefined) {
|
|
868
|
+
try {
|
|
869
|
+
state = { ...state, token: await input.rotate(), rotations: state.rotations + 1 };
|
|
870
|
+
} catch {
|
|
871
|
+
/* the rotation seam failure keeps the current token for the next attempt */
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
let envelope: serverenvelope;
|
|
878
|
+
try {
|
|
879
|
+
envelope = parsewireframe(reply.frame);
|
|
880
|
+
} catch (error) {
|
|
881
|
+
lasterror = error instanceof Error ? error.message : String(error);
|
|
882
|
+
try {
|
|
883
|
+
socket.close(1000);
|
|
884
|
+
} catch {
|
|
885
|
+
/* an already closed socket needs no close */
|
|
886
|
+
}
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (envelope.opid !== opid) {
|
|
890
|
+
lasterror = `The join reply ${envelope.opid} does not correlate the join request ${opid}.`;
|
|
891
|
+
try {
|
|
892
|
+
socket.close(1000);
|
|
893
|
+
} catch {
|
|
894
|
+
/* an already closed socket needs no close */
|
|
895
|
+
}
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (envelope.body !== undefined && typeof envelope.body.error === "string" && envelope.body.error.trim() !== "") {
|
|
899
|
+
lasterror = envelope.body.error;
|
|
900
|
+
try {
|
|
901
|
+
socket.close(1000);
|
|
902
|
+
} catch {
|
|
903
|
+
/* an already closed socket needs no close */
|
|
904
|
+
}
|
|
905
|
+
if (attempt < budget - 1) {
|
|
906
|
+
const wait = waits[attempt] ?? 0;
|
|
907
|
+
if (wait > 0) await sleep(wait);
|
|
908
|
+
state = { ...state, attempts: state.attempts + 1, state: "reconnecting" };
|
|
909
|
+
}
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const rotatedtoken =
|
|
913
|
+
envelope.body !== undefined && typeof envelope.body.token === "string" ? envelope.body.token.trim() : "";
|
|
914
|
+
const sessionid =
|
|
915
|
+
envelope.body !== undefined && typeof envelope.body.sessionid === "string"
|
|
916
|
+
? envelope.body.sessionid.trim()
|
|
917
|
+
: state.sessionid;
|
|
918
|
+
state = {
|
|
919
|
+
...state,
|
|
920
|
+
state: "connected",
|
|
921
|
+
sessionid: sessionid !== "" ? sessionid : state.sessionid,
|
|
922
|
+
token: rotatedtoken !== "" ? rotatedtoken : state.token,
|
|
923
|
+
rotations: rotatedtoken !== "" ? state.rotations + 1 : state.rotations,
|
|
924
|
+
openedat: now(),
|
|
925
|
+
lastframeat: now(),
|
|
926
|
+
lastheartbeatat: now(),
|
|
927
|
+
acks: [...state.acks, envelope],
|
|
928
|
+
};
|
|
929
|
+
return { state, socket };
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
state: { ...state, state: "closed", attempts: state.attempts, closedat: now(), lasterror: lasterror },
|
|
933
|
+
error: lasterror,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/* ── Merged from chatbridge.ts ── */
|
|
938
|
+
|
|
939
|
+
import { pairingcountdown } from "./auth.js";
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Chatbridge of the 1.1.82 site integration family.
|
|
943
|
+
* Every render and decision rule of the site conversation surface lives in this one pure module: the chat rows the widget renders from the bridge events, the plan review cards the plan proposal events become, the review decisions the cards collect and return to the extension review gate, and the pairing panel the options countdown renders. The widget renders and collects only — it never executes an action, never holds a socket and never touches a page: the extension stays the only executor, a sensitive step approves only through the extension approval flow, and every payload the widget builds rides the data minimization of the servercontract so plan text and statuses cross while page content stays on the device without the explicit consent flag.
|
|
944
|
+
* Example: `const cards = reviewcardsof(events); const decision = carddecision(cards[0], "approved", "site user", now); const outcome = reviewgateoutcome(decision);`
|
|
945
|
+
*/
|
|
946
|
+
|
|
947
|
+
/** Reads the chat rows the widget renders: every chat event becomes one row with its sender, its task text, the mine flag of the member that typed it and its timestamp; non chat events never render as chat. */
|
|
948
|
+
export function chatrowsof(
|
|
949
|
+
events: bridgeeventrecord[],
|
|
950
|
+
memberid: string,
|
|
951
|
+
): Array<{ from: string; text: string; mine: boolean; at: number }> {
|
|
952
|
+
return events
|
|
953
|
+
.filter((event) => event.kind === "chat")
|
|
954
|
+
.map((event) => ({
|
|
955
|
+
from: typeof event.payload.from === "string" ? event.payload.from : "",
|
|
956
|
+
text: typeof event.payload.text === "string" ? event.payload.text : "",
|
|
957
|
+
mine: typeof event.payload.from === "string" && event.payload.from === memberid,
|
|
958
|
+
at: event.at,
|
|
959
|
+
}));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Reads the plan review cards the widget renders: every plan proposal event becomes one card with its proposal id, its plan title and its step digest; the card renders the review surface only and collects the decision. */
|
|
963
|
+
export function reviewcardsof(events: bridgeeventrecord[]): bridgereviewcard[] {
|
|
964
|
+
return events
|
|
965
|
+
.filter((event) => event.kind === "planproposal")
|
|
966
|
+
.map((event) => {
|
|
967
|
+
const steps = Array.isArray(event.payload.steps)
|
|
968
|
+
? (event.payload.steps as Array<Record<string, unknown>>)
|
|
969
|
+
.filter((step) => typeof step.id === "string" && typeof step.kind === "string")
|
|
970
|
+
.map((step) => ({
|
|
971
|
+
id: String(step.id),
|
|
972
|
+
kind: String(step.kind),
|
|
973
|
+
origin: typeof step.origin === "string" ? step.origin : "",
|
|
974
|
+
sensitive: step.sensitive === true,
|
|
975
|
+
}))
|
|
976
|
+
: [];
|
|
977
|
+
return {
|
|
978
|
+
proposalid: typeof event.payload.proposalid === "string" ? event.payload.proposalid : "",
|
|
979
|
+
title: typeof event.payload.title === "string" ? event.payload.title : "",
|
|
980
|
+
steps,
|
|
981
|
+
...(typeof event.payload.timeoutat === "number" ? { timeoutat: event.payload.timeoutat } : {}),
|
|
982
|
+
at: event.at,
|
|
983
|
+
};
|
|
984
|
+
})
|
|
985
|
+
.filter((card) => card.proposalid !== "" && card.title !== "");
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** Collects one review decision from a rendered card: the decision carries the proposal id, the verdict, the reviewer and the optional note; the card never executes anything and the decision never names an action, only the review verdict. */
|
|
989
|
+
export function carddecision(
|
|
990
|
+
card: bridgereviewcard,
|
|
991
|
+
decision: bridgereviewdecision["decision"],
|
|
992
|
+
by: string,
|
|
993
|
+
now: number,
|
|
994
|
+
note?: string,
|
|
995
|
+
): bridgereviewdecision {
|
|
996
|
+
if (card.proposalid.trim() === "") throw new Error("The review decision names its proposal.");
|
|
997
|
+
if (decision !== "approved" && decision !== "changes" && decision !== "refused")
|
|
998
|
+
throw new Error("The review decision is approved, changes or refused.");
|
|
999
|
+
const reviewer = by.trim();
|
|
1000
|
+
if (reviewer === "") throw new Error("The review decision names its reviewer.");
|
|
1001
|
+
return {
|
|
1002
|
+
proposalid: card.proposalid,
|
|
1003
|
+
decision,
|
|
1004
|
+
by: reviewer,
|
|
1005
|
+
...(note !== undefined && note.trim() !== "" ? { note: note.trim() } : {}),
|
|
1006
|
+
at: now,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Builds the wire payload of one review decision: the plan review event payload the servercontract validates, so the decision crosses the bridge as plan text only. */
|
|
1011
|
+
export function decisionpayload(decision: bridgereviewdecision): Record<string, unknown> {
|
|
1012
|
+
return planrevieweventpayload(decision);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Reads the extension review gate outcome of one bridge decision: an approved decision resolves the gate as approved while a changes or refused decision returns the proposal to the review surface — the outcome is a resolution request only, the extension approval flow stays the only executor of any step. */
|
|
1016
|
+
export function reviewgateoutcome(decision: bridgereviewdecision): { state: "approved" | "refused"; reason: string } {
|
|
1017
|
+
if (decision.decision === "approved")
|
|
1018
|
+
return {
|
|
1019
|
+
state: "approved",
|
|
1020
|
+
reason: `The site review approved the plan proposal ${decision.proposalid}${decision.note !== undefined ? ` with the note ${decision.note}` : ""}; the extension review gate resolves as approved and the extension stays the only executor.`,
|
|
1021
|
+
};
|
|
1022
|
+
return {
|
|
1023
|
+
state: "refused",
|
|
1024
|
+
reason: `The site review returned ${decision.decision} for the plan proposal ${decision.proposalid}${decision.note !== undefined ? ` with the note ${decision.note}` : ""}; the proposal returns to the review surface and no step executes.`,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** Builds the task text event the site sends through the relay: the chat event payload of the servercontract, so the human typed task text crosses as reviewed text while page content never rides it. */
|
|
1029
|
+
export function chateventof(
|
|
1030
|
+
text: string,
|
|
1031
|
+
from: string,
|
|
1032
|
+
): { kind: servereventtype; stream: string; payload: Record<string, unknown> } {
|
|
1033
|
+
return { kind: "chat", stream: "chat", payload: chateventpayload({ text, from }) };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Builds the plan proposal event the extension posts: the review card payload of the servercontract, so the plan text and the step statuses cross while the observation bytes stay on the device. */
|
|
1037
|
+
export function planproposaleventof(card: bridgereviewcard): {
|
|
1038
|
+
kind: servereventtype;
|
|
1039
|
+
stream: string;
|
|
1040
|
+
payload: Record<string, unknown>;
|
|
1041
|
+
} {
|
|
1042
|
+
return { kind: "planproposal", stream: "review", payload: planproposaleventpayload(card) };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** Reads the whole widget view in one pass: the status word, the chat rows, the review cards and the pending decision count, so the static site renders one frame of the conversation surface from one record list. */
|
|
1046
|
+
export function widgetview(input: { status: string; events: bridgeeventrecord[]; memberid: string }): {
|
|
1047
|
+
status: string;
|
|
1048
|
+
rows: Array<{ from: string; text: string; mine: boolean; at: number }>;
|
|
1049
|
+
cards: bridgereviewcard[];
|
|
1050
|
+
pending: number;
|
|
1051
|
+
} {
|
|
1052
|
+
const cards = reviewcardsof(input.events);
|
|
1053
|
+
const answered = new Set(
|
|
1054
|
+
input.events
|
|
1055
|
+
.filter((event) => event.kind === "planreview")
|
|
1056
|
+
.map((event) => (typeof event.payload.proposalid === "string" ? event.payload.proposalid : ""))
|
|
1057
|
+
.filter((id) => id !== ""),
|
|
1058
|
+
);
|
|
1059
|
+
return {
|
|
1060
|
+
status: input.status,
|
|
1061
|
+
rows: chatrowsof(input.events, input.memberid),
|
|
1062
|
+
cards,
|
|
1063
|
+
pending: cards.filter((card) => !answered.has(card.proposalid)).length,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** Reads the pairing panel the options page and the site render beside the code: the countdown display model of the sharedauth family, so both surfaces show the same expiry window. */
|
|
1068
|
+
export function pairingpanel(
|
|
1069
|
+
record: Parameters<typeof pairingcountdown>[0],
|
|
1070
|
+
now: number,
|
|
1071
|
+
): { code: string; secondsleft: number; expired: boolean; label: string } {
|
|
1072
|
+
const countdown = pairingcountdown(record, now);
|
|
1073
|
+
return {
|
|
1074
|
+
code: record.code.code,
|
|
1075
|
+
secondsleft: countdown.secondsleft,
|
|
1076
|
+
expired: countdown.expired,
|
|
1077
|
+
label: countdown.label,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** Reads the site manifest the static site declares: the servercontract version and the capabilities the site speaks, fetched from the manifest link of the page before the first frame. */
|
|
1082
|
+
export function sitemanifest(): ReturnType<typeof sitemanifestof> {
|
|
1083
|
+
return sitemanifestof();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Reads the popup status label of one bridge status view: the connected, paired, offline and disabled words the popup icon renders. */
|
|
1087
|
+
export function bridgestatuslabel(view: { status: string; paired: boolean; queued: number }): string {
|
|
1088
|
+
if (view.status === "connected")
|
|
1089
|
+
return `Bridge: connected${view.paired ? " and paired" : ""}${view.queued > 0 ? ` · ${view.queued} queued frame${view.queued === 1 ? "" : "s"}` : ""}.`;
|
|
1090
|
+
if (view.status === "paired")
|
|
1091
|
+
return `Bridge: paired, socket offline${view.queued > 0 ? ` · ${view.queued} queued frame${view.queued === 1 ? "" : "s"}` : ""}.`;
|
|
1092
|
+
if (view.status === "offline")
|
|
1093
|
+
return `Bridge: offline${view.queued > 0 ? ` · ${view.queued} queued frame${view.queued === 1 ? "" : "s"}` : ""}.`;
|
|
1094
|
+
return "Bridge: disabled (no relay url set).";
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/* ── Merged from wsbridge.ts ── */
|
|
1098
|
+
|
|
1099
|
+
import type { nativeframe, wsbridgesession } from "./types.js";
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Wsbridge of the 1.1.85 native host bridge family.
|
|
1103
|
+
* Every localhost relay concern of the native transport lives in this one pure module: the bind check that keeps the listener on localhost and refuses every other address, the session start that binds a random free port (the zero port the socket seam reports back), the per session token with its hash only record (the raw token never enters a log or an audit entry), the frame authentication that validates every incoming frame against the session token, the advertisement frame that tells the extension the port and the token over the native port, the idle expiry that closes a quiet session after the user configured window, the one extension connection rule that refuses a second extension side, and the envelope translation that reuses the servercontract envelopes of the 1.1.82 family so the wsbridge frames carry the same negotiated version, operation id and correlation shape the relay family speaks. The module stays pure: the socket, the random port and the token source reach it through injected seams only, the bind address and the idle window stay user choices with no code default, and no relay url, vendor endpoint or machine outside localhost ever appears here — a connection from another machine refuses at the bind itself.
|
|
1104
|
+
* Example: `const bind = wsbridgebindcheck("127.0.0.1"); const session = wsbridgesessionstart({ port: 0, now, token, hashof }); const ad = wsbridgeadvertiseframe(session, token, "run-ext-1");`
|
|
1105
|
+
*/
|
|
1106
|
+
|
|
1107
|
+
/** The localhost addresses the wsbridge may bind: the loopback v4, its name and the loopback v6; every other address refuses because the bridge never leaves the machine. */
|
|
1108
|
+
export const wsbridgelocalhosts: string[] = ["127.0.0.1", "localhost", "::1", "[::1]"];
|
|
1109
|
+
|
|
1110
|
+
/** The stream name the wsbridge events multiplex over: the native transport frames ride the servercontract eventpost stream of this name. */
|
|
1111
|
+
export const wsbridgestreamname = "nativetransport";
|
|
1112
|
+
|
|
1113
|
+
/** Checks one wsbridge bind address: the listener binds a localhost address only, a zero port asks the socket seam for a random free port and every other address refuses because the wsbridge never accepts a connection from another machine. */
|
|
1114
|
+
export function wsbridgebindcheck(input: { bind?: string; port?: number }): {
|
|
1115
|
+
ok: boolean;
|
|
1116
|
+
bind: string;
|
|
1117
|
+
port: number;
|
|
1118
|
+
reason?: string;
|
|
1119
|
+
} {
|
|
1120
|
+
const bind = (input.bind ?? "").trim() === "" ? "127.0.0.1" : (input.bind ?? "").trim();
|
|
1121
|
+
if (!wsbridgelocalhosts.includes(bind))
|
|
1122
|
+
return {
|
|
1123
|
+
ok: false,
|
|
1124
|
+
bind,
|
|
1125
|
+
port: 0,
|
|
1126
|
+
reason: `The wsbridge binds a localhost address only; the ${bind} address accepts connections from other machines and the bridge never leaves the machine.`,
|
|
1127
|
+
};
|
|
1128
|
+
const port = input.port ?? 0;
|
|
1129
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
1130
|
+
return {
|
|
1131
|
+
ok: false,
|
|
1132
|
+
bind,
|
|
1133
|
+
port: 0,
|
|
1134
|
+
reason: `The wsbridge port stays a whole number between zero and 65535; the zero port asks the socket for a random free port.`,
|
|
1135
|
+
};
|
|
1136
|
+
return { ok: true, bind, port };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** Starts one wsbridge session: the socket seam reports the bound port (the zero request becomes the random free port it chose), the token hash records instead of the raw token and the idle window the user configured computes the expiry; an absent window never expires the session. */
|
|
1140
|
+
export function wsbridgesessionstart(input: {
|
|
1141
|
+
port: number;
|
|
1142
|
+
now: number;
|
|
1143
|
+
token: string;
|
|
1144
|
+
hashof: (text: string) => string;
|
|
1145
|
+
idlewindow?: number;
|
|
1146
|
+
id?: string;
|
|
1147
|
+
}): wsbridgesession {
|
|
1148
|
+
if (!Number.isInteger(input.port) || input.port < 0 || input.port > 65535)
|
|
1149
|
+
throw new Error("The wsbridge session port stays a whole number between zero and 65535.");
|
|
1150
|
+
const token = input.token.trim();
|
|
1151
|
+
if (token === "")
|
|
1152
|
+
throw new Error("The wsbridge session carries its per session token; an empty token authenticates nothing.");
|
|
1153
|
+
if (input.idlewindow !== undefined && (!Number.isFinite(input.idlewindow) || input.idlewindow <= 0))
|
|
1154
|
+
throw new Error("The wsbridge idle window stays a positive millisecond count the user chose.");
|
|
1155
|
+
return {
|
|
1156
|
+
id: input.id ?? `wsbridge-${input.now}`,
|
|
1157
|
+
port: input.port,
|
|
1158
|
+
tokenhash: input.hashof(token),
|
|
1159
|
+
boundat: input.now,
|
|
1160
|
+
...(input.idlewindow !== undefined
|
|
1161
|
+
? { idlewindow: input.idlewindow, expiresat: input.now + input.idlewindow }
|
|
1162
|
+
: {}),
|
|
1163
|
+
extensionconnected: false,
|
|
1164
|
+
connections: 0,
|
|
1165
|
+
received: 0,
|
|
1166
|
+
sent: 0,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** Records one authenticated connection on the session: the frame counters and the idle clock move, and the idle expiry slides its window from the fresh frame time. */
|
|
1171
|
+
export function wsbridgeconnection(session: wsbridgesession, now: number): wsbridgesession {
|
|
1172
|
+
const idlewindow = session.idlewindow;
|
|
1173
|
+
return {
|
|
1174
|
+
...session,
|
|
1175
|
+
connections: session.connections + 1,
|
|
1176
|
+
lastframeat: now,
|
|
1177
|
+
...(idlewindow !== undefined ? { expiresat: now + idlewindow } : {}),
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Connects the extension side of one session: exactly one extension connection holds a session, so a second extension side refuses and the first keeps its slot. */
|
|
1182
|
+
export function wsbridgeextensionconnect(
|
|
1183
|
+
session: wsbridgesession,
|
|
1184
|
+
now: number,
|
|
1185
|
+
): { session?: wsbridgesession; refused?: string } {
|
|
1186
|
+
if (session.extensionconnected)
|
|
1187
|
+
return {
|
|
1188
|
+
refused: `The wsbridge session holds one extension connection at a time; the extension side of the session ${session.id} stays taken.`,
|
|
1189
|
+
};
|
|
1190
|
+
const connected = wsbridgeconnection(session, now);
|
|
1191
|
+
return { session: { ...connected, extensionconnected: true } };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** Checks the per session token of one incoming frame: the frame token must match the session token exactly, and a frame without its token refuses before any body reads. */
|
|
1195
|
+
export function wsbridgeframeauth(
|
|
1196
|
+
session: wsbridgesession,
|
|
1197
|
+
token: string,
|
|
1198
|
+
hashof: (text: string) => string,
|
|
1199
|
+
): { ok: boolean; reason?: string } {
|
|
1200
|
+
const trimmed = (token ?? "").trim();
|
|
1201
|
+
if (trimmed === "")
|
|
1202
|
+
return {
|
|
1203
|
+
ok: false,
|
|
1204
|
+
reason: "The wsbridge frame carries its session token; an anonymous frame refuses at the door.",
|
|
1205
|
+
};
|
|
1206
|
+
if (hashof(trimmed) !== session.tokenhash)
|
|
1207
|
+
return {
|
|
1208
|
+
ok: false,
|
|
1209
|
+
reason: `The wsbridge frame token fails the session ${session.id} authentication; a wrong token never reaches the body.`,
|
|
1210
|
+
};
|
|
1211
|
+
return { ok: true };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Sweeps the idle expiry of one session: a quiet session past its expiry closes while a session without an idle window never expires, and the last frame time slides the window so an active session survives the sweep. */
|
|
1215
|
+
export function wsbridgeidlesweep(
|
|
1216
|
+
session: wsbridgesession,
|
|
1217
|
+
now: number,
|
|
1218
|
+
): { expired: boolean; session: wsbridgesession; reason?: string } {
|
|
1219
|
+
if (session.idlewindow === undefined || session.expiresat === undefined) return { expired: false, session };
|
|
1220
|
+
if (session.lastframeat !== undefined && session.lastframeat > session.expiresat - session.idlewindow) {
|
|
1221
|
+
const slid = { ...session, expiresat: session.lastframeat + session.idlewindow };
|
|
1222
|
+
if (now < slid.expiresat) return { expired: false, session: slid };
|
|
1223
|
+
}
|
|
1224
|
+
if (now >= session.expiresat)
|
|
1225
|
+
return {
|
|
1226
|
+
expired: true,
|
|
1227
|
+
session,
|
|
1228
|
+
reason: `The wsbridge session ${session.id} sat quiet past its ${session.idlewindow} millisecond idle window; the session expired and the token stopped verifying.`,
|
|
1229
|
+
};
|
|
1230
|
+
return { expired: false, session };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Wraps one native frame into the servercontract envelope the wsbridge carries: the eventpost operation with the negotiated contract version, the stable operation id and the native frame as the multiplexed stream payload, so the wsbridge frames and the relay frames speak the same envelope shape. */
|
|
1234
|
+
export function wsbridgeenvelopeof(input: {
|
|
1235
|
+
opid: string;
|
|
1236
|
+
at: number;
|
|
1237
|
+
frame: nativeframe;
|
|
1238
|
+
sessionid?: string;
|
|
1239
|
+
token?: string;
|
|
1240
|
+
version?: number;
|
|
1241
|
+
}): ReturnType<typeof composeenvelope> {
|
|
1242
|
+
return composeenvelope({
|
|
1243
|
+
op: "eventpost",
|
|
1244
|
+
opid: input.opid,
|
|
1245
|
+
at: input.at,
|
|
1246
|
+
version: input.version ?? servercontractversion,
|
|
1247
|
+
...(input.sessionid !== undefined && input.sessionid !== "" ? { sessionid: input.sessionid } : {}),
|
|
1248
|
+
...(input.token !== undefined && input.token !== "" ? { token: input.token } : {}),
|
|
1249
|
+
body: { kind: "progress", stream: wsbridgestreamname, payload: { frame: input.frame } },
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Reads the native frame back out of one servercontract envelope: only the eventpost operation of the wsbridge stream unwraps, and every other envelope refuses so a foreign frame never reaches the native transport. */
|
|
1254
|
+
export function wsbridgeframeof(envelope: { op: string; body?: Record<string, unknown> }): {
|
|
1255
|
+
frame?: nativeframe;
|
|
1256
|
+
reason?: string;
|
|
1257
|
+
} {
|
|
1258
|
+
if (envelope.op !== "eventpost")
|
|
1259
|
+
return {
|
|
1260
|
+
reason: `The wsbridge envelope carries the eventpost operation; a ${envelope.op} envelope never reaches the native transport.`,
|
|
1261
|
+
};
|
|
1262
|
+
const body = envelope.body ?? {};
|
|
1263
|
+
if (body.stream !== wsbridgestreamname)
|
|
1264
|
+
return {
|
|
1265
|
+
reason: `The wsbridge envelope rides the ${wsbridgestreamname} stream; a ${String(body.stream)} envelope stays outside the native transport.`,
|
|
1266
|
+
};
|
|
1267
|
+
const frame = body.payload;
|
|
1268
|
+
if (frame === undefined || frame === null || typeof frame !== "object" || Array.isArray(frame))
|
|
1269
|
+
return { reason: "The wsbridge envelope payload carries the native frame object." };
|
|
1270
|
+
const candidate = (frame as Record<string, unknown>).frame;
|
|
1271
|
+
if (candidate === undefined || candidate === null || typeof candidate !== "object" || Array.isArray(candidate))
|
|
1272
|
+
return { reason: "The wsbridge envelope payload wraps its native frame under the frame key." };
|
|
1273
|
+
const typed = candidate as Record<string, unknown>;
|
|
1274
|
+
if (typeof typed.kind !== "string" || typeof typed.correlationid !== "string")
|
|
1275
|
+
return { reason: "The wsbridge native frame carries its kind and its correlation id." };
|
|
1276
|
+
return {
|
|
1277
|
+
frame: {
|
|
1278
|
+
kind: typed.kind as nativeframe["kind"],
|
|
1279
|
+
correlationid: typed.correlationid,
|
|
1280
|
+
...(typeof typed.sessionid === "string" ? { sessionid: typed.sessionid } : {}),
|
|
1281
|
+
...(typed.body !== undefined &&
|
|
1282
|
+
typed.body !== null &&
|
|
1283
|
+
typeof typed.body === "object" &&
|
|
1284
|
+
!Array.isArray(typed.body)
|
|
1285
|
+
? { body: typed.body as Record<string, unknown> }
|
|
1286
|
+
: {}),
|
|
1287
|
+
},
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** Builds the advertisement frame the wsbridge sends the extension over the native port: the bound port and the per session token ride the native frame body, the only place the raw token ever appears — the logs, the audit entries and the session records keep its hash only. */
|
|
1292
|
+
export function wsbridgeadvertiseframe(session: wsbridgesession, token: string, correlationid: string): nativeframe {
|
|
1293
|
+
const trimmed = token.trim();
|
|
1294
|
+
if (trimmed === "")
|
|
1295
|
+
throw new Error("The wsbridge advertisement carries the per session token; an empty token advertises nothing.");
|
|
1296
|
+
return {
|
|
1297
|
+
kind: "advertisement",
|
|
1298
|
+
correlationid,
|
|
1299
|
+
body: {
|
|
1300
|
+
port: session.port,
|
|
1301
|
+
token: trimmed,
|
|
1302
|
+
...(session.idlewindow !== undefined ? { idlewindow: session.idlewindow } : {}),
|
|
1303
|
+
},
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** Reads the session report the settings panel and the diagnostics command show: the port, the connection counts and the expiry state ride the report while the raw token and its hash stay out, so a logged report never carries session material. */
|
|
1308
|
+
export function wsbridgereport(
|
|
1309
|
+
session: wsbridgesession,
|
|
1310
|
+
now: number,
|
|
1311
|
+
): {
|
|
1312
|
+
id: string;
|
|
1313
|
+
port: number;
|
|
1314
|
+
connections: number;
|
|
1315
|
+
extensionconnected: boolean;
|
|
1316
|
+
expired: boolean;
|
|
1317
|
+
received: number;
|
|
1318
|
+
sent: number;
|
|
1319
|
+
} {
|
|
1320
|
+
const sweep = wsbridgeidlesweep(session, now);
|
|
1321
|
+
return {
|
|
1322
|
+
id: session.id,
|
|
1323
|
+
port: session.port,
|
|
1324
|
+
connections: session.connections,
|
|
1325
|
+
extensionconnected: session.extensionconnected,
|
|
1326
|
+
expired: sweep.expired,
|
|
1327
|
+
received: session.received,
|
|
1328
|
+
sent: session.sent,
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Counts one frame on the session of the direction it travelled: the counters and the idle clock move together so the report and the expiry read the same state. */
|
|
1333
|
+
export function wsbridgeframecounted(
|
|
1334
|
+
session: wsbridgesession,
|
|
1335
|
+
direction: "received" | "sent",
|
|
1336
|
+
now: number,
|
|
1337
|
+
): wsbridgesession {
|
|
1338
|
+
const idlewindow = session.idlewindow;
|
|
1339
|
+
return {
|
|
1340
|
+
...session,
|
|
1341
|
+
received: session.received + (direction === "received" ? 1 : 0),
|
|
1342
|
+
sent: session.sent + (direction === "sent" ? 1 : 0),
|
|
1343
|
+
lastframeat: now,
|
|
1344
|
+
...(idlewindow !== undefined ? { expiresat: now + idlewindow } : {}),
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/* ── Merged from nativehost.ts ── */
|
|
1349
|
+
|
|
1350
|
+
import {
|
|
1351
|
+
protocolversion,
|
|
1352
|
+
type companionhandshake,
|
|
1353
|
+
type nativecallclass,
|
|
1354
|
+
type nativecallrecord,
|
|
1355
|
+
type nativeerror,
|
|
1356
|
+
type nativehoststate,
|
|
1357
|
+
type nativesurfacekind,
|
|
1358
|
+
type runsettings,
|
|
1359
|
+
} from "./types.js";
|
|
1360
|
+
import { notificationframe } from "./serve.js";
|
|
1361
|
+
import type { jsonrpcframe } from "./types.js";
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Nativehost of the 1.1.85 native host bridge family.
|
|
1365
|
+
* Every native messaging concern lives in this one pure module: the host manifest template with its generated extension id placeholder, the installer that writes the host manifest into the user profile directory and refuses system wide installs without the explicit flag, the uninstaller that removes the host manifest and its preferences, the runtime native messaging port with its attach, detach, crash and reattach states, the companion handshake with the build and protocol version negotiation and the one major version upgrade window, the capability negotiation that lists the host features and enumerates the native surfaces on connect, the os dialog and notification surfaces behind their own consent grants, the correlation ids one run stamps on every frame, the heartbeat frames that detect host process liveness, the origin and session checks that validate every incoming native frame, the secret exclusion that keeps key vault material out of every frame, the structured errors with their retry hints, the graceful degradation that keeps runs alive when the host is absent or outdated, the per session rate cap accounting, the native call records the audit trail keeps with class and outcome, the native call events that stream to subscribed mcp clients and the diagnostics report of port state, versions and last errors. The module stays pure: the filesystem, the process spawning and the native messaging port itself reach it through injected seams only, every path, window and bound stays a user choice with no code default, the transport stays deny by default until the user installs the host, and no vendor endpoint, no download url and no binary blob ever appears here — the companionbin recipe builds from source with plain node because nothing native runs until the user builds and installs it.
|
|
1366
|
+
* Example: `const manifest = nativehostmanifesttemplate({ hostname: "com.example.devthink", companionpath: "/home/user/devthink/dist/companion.js" }); const installed = await installnativehost({ profiledir, hostname: "com.example.devthink", extensionid, companionpath, consent: true, now, io });`
|
|
1367
|
+
*/
|
|
1368
|
+
|
|
1369
|
+
/** The native bridge protocol major version this build speaks: the handshake reports it, the upgrade path keeps compatibility for exactly one major version, and a host that speaks another major version degrades instead of breaking the run. */
|
|
1370
|
+
export const nativebridgeprotocolmajor = 1;
|
|
1371
|
+
|
|
1372
|
+
/** The installer version the installer stamps into the install state; the upgrade path compares it so a manifest written by an older installer reports before it overwrites. */
|
|
1373
|
+
export const nativehostinstallerversion = protocolversion;
|
|
1374
|
+
|
|
1375
|
+
/** The generated extension id placeholder the host manifest template carries: the installer replaces the token with the extension id of the installed build, so the template never pins an identity the user did not generate. */
|
|
1376
|
+
export const nativehostidplaceholder = "__generated_extension_id__";
|
|
1377
|
+
|
|
1378
|
+
/** The native messaging directory name of the chromium user profile: the installer writes the host manifest beside it, never into a system directory without the explicit flag. */
|
|
1379
|
+
export const nativemessagingdirname = "NativeMessagingHosts";
|
|
1380
|
+
|
|
1381
|
+
/** The native surface catalog of the companion process: every surface with its call class and its consent question, so the capability negotiation enumerates what the host offers and the consent gate asks per surface. */
|
|
1382
|
+
export interface nativesurfacedef {
|
|
1383
|
+
surface: nativesurfacekind;
|
|
1384
|
+
callclass: nativecallclass;
|
|
1385
|
+
description: string;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** The native surfaces the companion process exposes behind consent: the os dialog surface opens a desktop dialog (a sensitive class call because it addresses the user directly) and the notification surface posts a desktop notification (an interaction class call). */
|
|
1389
|
+
export function nativesurfacecatalog(): nativesurfacedef[] {
|
|
1390
|
+
return [
|
|
1391
|
+
{
|
|
1392
|
+
surface: "osdialog",
|
|
1393
|
+
callclass: "sensitive",
|
|
1394
|
+
description:
|
|
1395
|
+
"Opens one os dialog on the user desktop; the surface addresses the user directly, so the call class stays sensitive and the surface asks for its own consent grant.",
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
surface: "notification",
|
|
1399
|
+
callclass: "interaction",
|
|
1400
|
+
description:
|
|
1401
|
+
"Posts one os notification on the user desktop; the surface writes outside the browser, so the call class stays interaction and the surface asks for its own consent grant.",
|
|
1402
|
+
},
|
|
1403
|
+
];
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** The capabilities this side of the native transport declares: the native bridge protocol major version, the surfaces of the catalog and the heartbeat support. */
|
|
1407
|
+
export function nativehostcapabilities(): { protocol: number; surfaces: nativesurfacekind[]; heartbeat: boolean } {
|
|
1408
|
+
return {
|
|
1409
|
+
protocol: nativebridgeprotocolmajor,
|
|
1410
|
+
surfaces: nativesurfacecatalog().map((entry) => entry.surface),
|
|
1411
|
+
heartbeat: true,
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** Builds the host manifest template: the native messaging host manifest of the companion process with its name, description, stdio type, the companion path the user chose and the allowed origins list that carries the generated extension id placeholder until the installer fills it. */
|
|
1416
|
+
export function nativehostmanifesttemplate(input: { hostname: string; companionpath: string; extensionid?: string }): {
|
|
1417
|
+
manifest: { name: string; description: string; path: string; type: "stdio"; allowed_origins: string[] };
|
|
1418
|
+
text: string;
|
|
1419
|
+
} {
|
|
1420
|
+
const hostname = input.hostname.trim();
|
|
1421
|
+
if (hostname === "") throw new Error("The native host manifest names its host; an empty host name never registers.");
|
|
1422
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(hostname))
|
|
1423
|
+
throw new Error(
|
|
1424
|
+
`The native host name ${hostname} stays a plain dns style token; a name with spaces or capitals never registers.`,
|
|
1425
|
+
);
|
|
1426
|
+
const companionpath = input.companionpath.trim();
|
|
1427
|
+
if (companionpath === "")
|
|
1428
|
+
throw new Error(
|
|
1429
|
+
"The native host manifest carries the companion path the user chose; an empty path never launches a host.",
|
|
1430
|
+
);
|
|
1431
|
+
const extensionid = (input.extensionid ?? "").trim();
|
|
1432
|
+
const scheme = "chrome-extension:";
|
|
1433
|
+
const origin = extensionid === "" ? `${scheme}//${nativehostidplaceholder}/` : `${scheme}//${extensionid}/`;
|
|
1434
|
+
const manifest = {
|
|
1435
|
+
name: hostname,
|
|
1436
|
+
description:
|
|
1437
|
+
"The devthink companion process: an optional native host that speaks length prefixed json over stdio and never runs until the user installs it.",
|
|
1438
|
+
path: companionpath,
|
|
1439
|
+
type: "stdio" as const,
|
|
1440
|
+
allowed_origins: [origin],
|
|
1441
|
+
};
|
|
1442
|
+
return { manifest, text: `${JSON.stringify(manifest, null, 2)}\n` };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** Trims the trailing path separators of one directory string through a plain character walk, because a trailing slash regex would itself read as polynomial on adversarial paths. */
|
|
1446
|
+
function trailslashes(value: string): string {
|
|
1447
|
+
let end = value.length;
|
|
1448
|
+
while (end > 0) {
|
|
1449
|
+
const last = value.charCodeAt(end - 1);
|
|
1450
|
+
if (last !== 0x2f /* / */ && last !== 0x5c /* \ */) break;
|
|
1451
|
+
end -= 1;
|
|
1452
|
+
}
|
|
1453
|
+
return value.slice(0, end);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/** Computes the destination of one host manifest: the user profile directory carries the manifest under its NativeMessagingHosts folder while a system wide install needs the explicit flag and computes the platform system directory; a missing profile directory with no system wide flag refuses because the installer never guesses a target. */
|
|
1457
|
+
export function hostmanifestdestination(input: {
|
|
1458
|
+
hostname: string;
|
|
1459
|
+
profiledir?: string;
|
|
1460
|
+
systemwide?: boolean;
|
|
1461
|
+
platform?: "linux" | "macos" | "windows";
|
|
1462
|
+
}): { path: string; systemwide: boolean; refused?: string } {
|
|
1463
|
+
const hostname = input.hostname.trim();
|
|
1464
|
+
if (hostname === "")
|
|
1465
|
+
return {
|
|
1466
|
+
path: "",
|
|
1467
|
+
systemwide: false,
|
|
1468
|
+
refused: "The host manifest destination names its host; an empty host name never writes.",
|
|
1469
|
+
};
|
|
1470
|
+
const systemwide = input.systemwide === true;
|
|
1471
|
+
const profiledir = (input.profiledir ?? "").trim();
|
|
1472
|
+
if (!systemwide && profiledir === "")
|
|
1473
|
+
return {
|
|
1474
|
+
path: "",
|
|
1475
|
+
systemwide: false,
|
|
1476
|
+
refused:
|
|
1477
|
+
"The installer writes the host manifest into the user profile directory; a system wide install needs the explicit flag and the user never gets one silently.",
|
|
1478
|
+
};
|
|
1479
|
+
if (systemwide) {
|
|
1480
|
+
const platform = input.platform ?? "linux";
|
|
1481
|
+
const base =
|
|
1482
|
+
platform === "linux"
|
|
1483
|
+
? "/etc/opt/chrome/native-messaging-hosts"
|
|
1484
|
+
: platform === "macos"
|
|
1485
|
+
? "/Library/Google/Chrome/NativeMessagingHosts"
|
|
1486
|
+
: "C:\\Program Files\\Google\\Chrome\\Application\\native-messaging-hosts";
|
|
1487
|
+
return { path: `${base}/${hostname}.json`, systemwide: true };
|
|
1488
|
+
}
|
|
1489
|
+
return { path: `${trailslashes(profiledir)}/${nativemessagingdirname}/${hostname}.json`, systemwide: false };
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/** Writes the host manifest into the user profile directory behind the install consent: the consent stamp must exist before any host registration, the extension id fills the placeholder of the template, a system wide install refuses without its explicit flag and the returned install state records the installer version for the upgrade path. */
|
|
1493
|
+
export async function installnativehost(input: {
|
|
1494
|
+
profiledir?: string;
|
|
1495
|
+
hostname: string;
|
|
1496
|
+
extensionid: string;
|
|
1497
|
+
companionpath: string;
|
|
1498
|
+
consent: boolean;
|
|
1499
|
+
now: number;
|
|
1500
|
+
systemwide?: boolean;
|
|
1501
|
+
platform?: "linux" | "macos" | "windows";
|
|
1502
|
+
io: { writefile: (path: string, text: string) => Promise<void>; mkdir: (dir: string) => Promise<void> };
|
|
1503
|
+
}): Promise<{ state: nativehoststate; manifestpath: string; refused?: string }> {
|
|
1504
|
+
if (!input.consent)
|
|
1505
|
+
return {
|
|
1506
|
+
state: nativedefaultstate(),
|
|
1507
|
+
manifestpath: "",
|
|
1508
|
+
refused:
|
|
1509
|
+
"The install consent gate explains the scope before any host registration; the installer writes no manifest without the recorded consent.",
|
|
1510
|
+
};
|
|
1511
|
+
const extensionid = input.extensionid.trim();
|
|
1512
|
+
if (extensionid === "")
|
|
1513
|
+
return {
|
|
1514
|
+
state: nativedefaultstate(),
|
|
1515
|
+
manifestpath: "",
|
|
1516
|
+
refused: "The host manifest allows the origins of one extension id; an empty id never registers a host.",
|
|
1517
|
+
};
|
|
1518
|
+
if (input.systemwide === true && (input.profiledir ?? "").trim() !== "")
|
|
1519
|
+
return {
|
|
1520
|
+
state: nativedefaultstate(),
|
|
1521
|
+
manifestpath: "",
|
|
1522
|
+
refused:
|
|
1523
|
+
"The install targets either the user profile directory or, with the explicit flag, the system directory; never both at once.",
|
|
1524
|
+
};
|
|
1525
|
+
const destination = hostmanifestdestination({
|
|
1526
|
+
hostname: input.hostname,
|
|
1527
|
+
...(input.profiledir !== undefined ? { profiledir: input.profiledir } : {}),
|
|
1528
|
+
...(input.systemwide !== undefined ? { systemwide: input.systemwide } : {}),
|
|
1529
|
+
...(input.platform !== undefined ? { platform: input.platform } : {}),
|
|
1530
|
+
});
|
|
1531
|
+
if (destination.refused !== undefined)
|
|
1532
|
+
return { state: nativedefaultstate(), manifestpath: "", refused: destination.refused };
|
|
1533
|
+
const template = nativehostmanifesttemplate({
|
|
1534
|
+
hostname: input.hostname,
|
|
1535
|
+
companionpath: input.companionpath,
|
|
1536
|
+
extensionid,
|
|
1537
|
+
});
|
|
1538
|
+
const dir = destination.path.slice(0, destination.path.lastIndexOf("/"));
|
|
1539
|
+
await input.io.mkdir(dir);
|
|
1540
|
+
await input.io.writefile(destination.path, template.text);
|
|
1541
|
+
const state: nativehoststate = {
|
|
1542
|
+
installed: true,
|
|
1543
|
+
hostname: template.manifest.name,
|
|
1544
|
+
extensionid,
|
|
1545
|
+
installerversion: nativehostinstallerversion,
|
|
1546
|
+
port: "detached",
|
|
1547
|
+
installedat: input.now,
|
|
1548
|
+
updatedat: input.now,
|
|
1549
|
+
lasterrors: [],
|
|
1550
|
+
};
|
|
1551
|
+
return { state, manifestpath: destination.path };
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** Removes the host manifest and its preferences: the uninstaller deletes the manifest of the user profile directory (or, with the explicit flag, the system directory), clears the recorded install state and returns the deny by default posture the extension keeps afterwards. */
|
|
1555
|
+
export async function uninstallnativehost(input: {
|
|
1556
|
+
profiledir?: string;
|
|
1557
|
+
hostname: string;
|
|
1558
|
+
now: number;
|
|
1559
|
+
systemwide?: boolean;
|
|
1560
|
+
platform?: "linux" | "macos" | "windows";
|
|
1561
|
+
io: { removefile: (path: string) => Promise<void>; exists: (path: string) => Promise<boolean> };
|
|
1562
|
+
}): Promise<{ state: nativehoststate; manifestpath: string; removed: boolean; refused?: string }> {
|
|
1563
|
+
const destination = hostmanifestdestination({
|
|
1564
|
+
hostname: input.hostname,
|
|
1565
|
+
...(input.profiledir !== undefined ? { profiledir: input.profiledir } : {}),
|
|
1566
|
+
...(input.systemwide !== undefined ? { systemwide: input.systemwide } : {}),
|
|
1567
|
+
...(input.platform !== undefined ? { platform: input.platform } : {}),
|
|
1568
|
+
});
|
|
1569
|
+
if (destination.refused !== undefined)
|
|
1570
|
+
return { state: nativedefaultstate(), manifestpath: "", removed: false, refused: destination.refused };
|
|
1571
|
+
const present = await input.io.exists(destination.path);
|
|
1572
|
+
if (present) await input.io.removefile(destination.path);
|
|
1573
|
+
return { state: nativedefaultstate(), manifestpath: destination.path, removed: present };
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** The deny by default state of the native transport: no host installed, no port attached and no versions recorded, so the transport stays disabled until the user installs the host. */
|
|
1577
|
+
export function nativedefaultstate(): nativehoststate {
|
|
1578
|
+
return { installed: false, port: "detached", lasterrors: [] };
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/** Reads the native transport choices of one settings record: the consent stamps, the class and surface grants, the idle window, the heartbeat interval and the rate cap with its window — every bound the user set with no code default beside it. */
|
|
1582
|
+
export function nativechoices(settings: runsettings | undefined): {
|
|
1583
|
+
nativeinstallconsent?: boolean;
|
|
1584
|
+
nativetransportconsent?: boolean;
|
|
1585
|
+
nativecallclassconsents?: nativecallclass[];
|
|
1586
|
+
nativesurfaceconsents?: nativesurfacekind[];
|
|
1587
|
+
nativeidlewindow?: number;
|
|
1588
|
+
nativeheartbeatinterval?: number;
|
|
1589
|
+
nativecallratelimit?: number;
|
|
1590
|
+
nativecallratewindow?: number;
|
|
1591
|
+
} {
|
|
1592
|
+
const current = settings ?? {};
|
|
1593
|
+
return {
|
|
1594
|
+
...(current.nativeinstallconsent !== undefined ? { nativeinstallconsent: current.nativeinstallconsent } : {}),
|
|
1595
|
+
...(current.nativetransportconsent !== undefined ? { nativetransportconsent: current.nativetransportconsent } : {}),
|
|
1596
|
+
...(current.nativecallclassconsents !== undefined
|
|
1597
|
+
? { nativecallclassconsents: current.nativecallclassconsents }
|
|
1598
|
+
: {}),
|
|
1599
|
+
...(current.nativesurfaceconsents !== undefined ? { nativesurfaceconsents: current.nativesurfaceconsents } : {}),
|
|
1600
|
+
...(current.nativeidlewindow !== undefined ? { nativeidlewindow: current.nativeidlewindow } : {}),
|
|
1601
|
+
...(current.nativeheartbeatinterval !== undefined
|
|
1602
|
+
? { nativeheartbeatinterval: current.nativeheartbeatinterval }
|
|
1603
|
+
: {}),
|
|
1604
|
+
...(current.nativecallratelimit !== undefined ? { nativecallratelimit: current.nativecallratelimit } : {}),
|
|
1605
|
+
...(current.nativecallratewindow !== undefined ? { nativecallratewindow: current.nativecallratewindow } : {}),
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/** True when the native transport may attach at all: the host must sit installed and the user must hold the transport consent, because the deny by default posture keeps the bridge disabled until both hold. */
|
|
1610
|
+
export function nativetransportenabled(state: nativehoststate | undefined, settings: runsettings | undefined): boolean {
|
|
1611
|
+
return (state?.installed ?? false) && settings?.nativetransportconsent === true;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** Builds the correlation id one run stamps on every native frame: the sender seed and the per run sequence compose an id that stays unique across a whole run without any randomness on the wire. */
|
|
1615
|
+
export function nativecorrelationid(seed: string, sequence: number): string {
|
|
1616
|
+
const trimmed = seed.trim();
|
|
1617
|
+
if (trimmed === "")
|
|
1618
|
+
throw new Error("The correlation id names its sender seed; an empty seed never correlates a frame.");
|
|
1619
|
+
if (!Number.isInteger(sequence) || sequence < 1)
|
|
1620
|
+
throw new Error("The correlation id sequence stays a positive whole number per run.");
|
|
1621
|
+
return `run-${trimmed}-${sequence}`;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/** Builds one native frame of the transport: the kind, the correlation id of the run and the body ride the frame the servercontract envelopes carry. */
|
|
1625
|
+
export function nativeframeof(
|
|
1626
|
+
kind: nativeframe["kind"],
|
|
1627
|
+
correlationid: string,
|
|
1628
|
+
body?: Record<string, unknown>,
|
|
1629
|
+
sessionid?: string,
|
|
1630
|
+
): nativeframe {
|
|
1631
|
+
const trimmed = correlationid.trim();
|
|
1632
|
+
if (trimmed === "")
|
|
1633
|
+
throw new Error("The native frame carries its correlation id; an empty id never correlates a frame.");
|
|
1634
|
+
return {
|
|
1635
|
+
kind,
|
|
1636
|
+
correlationid: trimmed,
|
|
1637
|
+
...(sessionid !== undefined && sessionid !== "" ? { sessionid } : {}),
|
|
1638
|
+
...(body !== undefined ? { body } : {}),
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/** Builds the handshake request frame the extension sends on attach: the protocol major version of this build rides the body so the companion answers with its own build and protocol version. */
|
|
1643
|
+
export function companionhandshakeframe(correlationid: string): nativeframe {
|
|
1644
|
+
return nativeframeof("handshake", correlationid, { protocol: nativebridgeprotocolmajor });
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/** Parses the handshake answer of the companion process: the build version, the protocol major version, the surfaces and the wsbridge port validate their shapes, and a malformed handshake answers the structured error instead of a bare throw. */
|
|
1648
|
+
export function parsecompanionhandshake(
|
|
1649
|
+
frame: nativeframe,
|
|
1650
|
+
now: number,
|
|
1651
|
+
): { handshake?: companionhandshake; error?: nativeerror } {
|
|
1652
|
+
if (frame.kind !== "handshake")
|
|
1653
|
+
return {
|
|
1654
|
+
error: nativeerrorof(
|
|
1655
|
+
"handshake",
|
|
1656
|
+
`The handshake answer carries the handshake kind; a ${frame.kind} frame never completes a handshake.`,
|
|
1657
|
+
"reconnect",
|
|
1658
|
+
now,
|
|
1659
|
+
),
|
|
1660
|
+
};
|
|
1661
|
+
const body = frame.body ?? {};
|
|
1662
|
+
const build = typeof body.build === "string" ? body.build.trim() : "";
|
|
1663
|
+
if (build === "")
|
|
1664
|
+
return {
|
|
1665
|
+
error: nativeerrorof(
|
|
1666
|
+
"handshake",
|
|
1667
|
+
"The handshake answer carries the companion build version; an empty version never attaches.",
|
|
1668
|
+
"reconnect",
|
|
1669
|
+
now,
|
|
1670
|
+
),
|
|
1671
|
+
};
|
|
1672
|
+
const protocol = typeof body.protocol === "number" ? body.protocol : Number.parseInt(String(body.protocol ?? ""), 10);
|
|
1673
|
+
if (!Number.isInteger(protocol) || protocol < 1)
|
|
1674
|
+
return {
|
|
1675
|
+
error: nativeerrorof(
|
|
1676
|
+
"handshake",
|
|
1677
|
+
"The handshake answer carries its native bridge protocol version as a positive whole number.",
|
|
1678
|
+
"reconnect",
|
|
1679
|
+
now,
|
|
1680
|
+
),
|
|
1681
|
+
};
|
|
1682
|
+
const surfaces = Array.isArray(body.surfaces)
|
|
1683
|
+
? body.surfaces.filter(
|
|
1684
|
+
(surface): surface is nativesurfacekind => surface === "osdialog" || surface === "notification",
|
|
1685
|
+
)
|
|
1686
|
+
: [];
|
|
1687
|
+
const wsbridgeport = typeof body.wsbridgeport === "number" ? body.wsbridgeport : 0;
|
|
1688
|
+
if (!Number.isInteger(wsbridgeport) || wsbridgeport < 0)
|
|
1689
|
+
return {
|
|
1690
|
+
error: nativeerrorof(
|
|
1691
|
+
"handshake",
|
|
1692
|
+
"The handshake answer carries the wsbridge port as a whole number; zero keeps the bridge down.",
|
|
1693
|
+
"reconnect",
|
|
1694
|
+
now,
|
|
1695
|
+
),
|
|
1696
|
+
};
|
|
1697
|
+
return { handshake: { build, protocol: String(protocol), surfaces, wsbridgeport } };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/** Reads the major version of one protocol version string: the digits before the first separator stay the major version the upgrade window compares. */
|
|
1701
|
+
export function nativemajorversion(protocol: string | number): number {
|
|
1702
|
+
const value = typeof protocol === "number" ? String(protocol) : protocol.trim();
|
|
1703
|
+
const major = Number.parseInt(value.split(/[.\s]/)[0] ?? "", 10);
|
|
1704
|
+
return Number.isInteger(major) && major >= 1 ? major : 0;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/** Checks the protocol compatibility of the upgrade path: a host keeps compatibility for exactly one major version, so a companion of the same major version attaches whatever its build says while a host of another major version degrades instead of breaking the run. */
|
|
1708
|
+
export function nativeprotocolcompatible(
|
|
1709
|
+
ours: string | number,
|
|
1710
|
+
theirs: string | number,
|
|
1711
|
+
): { ok: boolean; ours: number; theirs: number } {
|
|
1712
|
+
const ourmajor = nativemajorversion(ours);
|
|
1713
|
+
const theirmajor = nativemajorversion(theirs);
|
|
1714
|
+
return { ok: ourmajor === theirmajor && ourmajor >= 1, ours: ourmajor, theirs: theirmajor };
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/** Negotiates the native capabilities on connect: the protocol version takes the upgrade window, the surfaces intersect in the stable catalog order and the heartbeat support needs both sides, so the negotiation lists exactly the host features the run may use. */
|
|
1718
|
+
export function negotiatenativecapabilities(input: {
|
|
1719
|
+
ours?: { protocol: number; surfaces: nativesurfacekind[]; heartbeat: boolean };
|
|
1720
|
+
theirs: { protocol: string | number; surfaces: nativesurfacekind[]; heartbeat?: boolean };
|
|
1721
|
+
}): { ok: boolean; protocol: number; surfaces: nativesurfacekind[]; heartbeat: boolean; reason?: string } {
|
|
1722
|
+
const ours = input.ours ?? nativehostcapabilities();
|
|
1723
|
+
const compat = nativeprotocolcompatible(ours.protocol, input.theirs.protocol);
|
|
1724
|
+
if (!compat.ok)
|
|
1725
|
+
return {
|
|
1726
|
+
ok: false,
|
|
1727
|
+
protocol: 0,
|
|
1728
|
+
surfaces: [],
|
|
1729
|
+
heartbeat: false,
|
|
1730
|
+
reason: `The companion speaks the native bridge protocol major version ${compat.theirs} while this build speaks ${compat.ours}; the upgrade path keeps compatibility for one major version and the run degrades with the transport off.`,
|
|
1731
|
+
};
|
|
1732
|
+
const order = nativesurfacecatalog().map((entry) => entry.surface);
|
|
1733
|
+
const surfaces = order.filter((surface) => input.theirs.surfaces.includes(surface));
|
|
1734
|
+
return { ok: true, protocol: compat.ours, surfaces, heartbeat: ours.heartbeat && input.theirs.heartbeat === true };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** Attaches the runtime native messaging port to a healthy host: the handshake answer fills the companion version, the protocol version and the wsbridge port while the port state moves to attached. */
|
|
1738
|
+
export function attachnativehost(state: nativehoststate, handshake: companionhandshake, now: number): nativehoststate {
|
|
1739
|
+
return {
|
|
1740
|
+
...state,
|
|
1741
|
+
installed: true,
|
|
1742
|
+
port: "attached",
|
|
1743
|
+
companionversion: handshake.build,
|
|
1744
|
+
companionprotocol: handshake.protocol,
|
|
1745
|
+
wsbridgeport: handshake.wsbridgeport,
|
|
1746
|
+
attachedat: now,
|
|
1747
|
+
updatedat: now,
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/** Marks the native port crashed: the host process died under the extension, the state keeps the install facts and the run stays alive with the transport off until the reattach. */
|
|
1752
|
+
export function crashnativehost(state: nativehoststate, now: number): nativehoststate {
|
|
1753
|
+
return {
|
|
1754
|
+
...state,
|
|
1755
|
+
port: "crashed",
|
|
1756
|
+
updatedat: now,
|
|
1757
|
+
lasterrors: [
|
|
1758
|
+
nativeerrorof(
|
|
1759
|
+
"port",
|
|
1760
|
+
"The companion process crashed under the native port; the run stays alive and the extension reattaches on the next host start.",
|
|
1761
|
+
"reconnect",
|
|
1762
|
+
now,
|
|
1763
|
+
),
|
|
1764
|
+
...(state.lasterrors ?? []),
|
|
1765
|
+
].slice(0, 10),
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/** Reattaches the native port after a crash or a restart: the fresh handshake answer fills the versions and the port while the state keeps the crash history for the diagnostics report. */
|
|
1770
|
+
export function reattachnativehost(
|
|
1771
|
+
state: nativehoststate,
|
|
1772
|
+
handshake: companionhandshake,
|
|
1773
|
+
now: number,
|
|
1774
|
+
): nativehoststate {
|
|
1775
|
+
return { ...attachnativehost(state, handshake, now), updatedat: now };
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/** Detaches the native port on demand: the user closed the transport, the port state returns to detached and the install facts stay for the next attach. */
|
|
1779
|
+
export function detachnativehost(state: nativehoststate, now: number): nativehoststate {
|
|
1780
|
+
return { ...state, port: "detached", updatedat: now };
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/** Builds one heartbeat frame of the liveness check: the correlation id of the run and the timestamp ride the body so both sides detect a silent peer. */
|
|
1784
|
+
export function nativeheartbeatframe(correlationid: string, now: number): nativeframe {
|
|
1785
|
+
return nativeframeof("heartbeat", correlationid, { at: now });
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/** Checks the host process liveness from the heartbeat timestamps: a last heartbeat older than the user configured interval marks the host silent while an absent interval never expires the check because the cadence stays a user choice. */
|
|
1789
|
+
export function nativeportliveness(input: { lastheartbeatat?: number; now: number; interval?: number }): {
|
|
1790
|
+
alive: boolean;
|
|
1791
|
+
reason?: string;
|
|
1792
|
+
} {
|
|
1793
|
+
if (input.interval === undefined)
|
|
1794
|
+
return {
|
|
1795
|
+
alive: true,
|
|
1796
|
+
reason:
|
|
1797
|
+
"The heartbeat interval stays unset; the liveness check never expires a host without the user configured cadence.",
|
|
1798
|
+
};
|
|
1799
|
+
if (input.lastheartbeatat === undefined)
|
|
1800
|
+
return {
|
|
1801
|
+
alive: false,
|
|
1802
|
+
reason: "The native port heard no heartbeat yet; the host stays silent under the configured interval.",
|
|
1803
|
+
};
|
|
1804
|
+
if (input.now - input.lastheartbeatat > input.interval)
|
|
1805
|
+
return {
|
|
1806
|
+
alive: false,
|
|
1807
|
+
reason: `The last heartbeat sits ${input.now - input.lastheartbeatat} milliseconds back, past the ${input.interval} millisecond interval; the host process stays silent and the extension reattaches on its restart.`,
|
|
1808
|
+
};
|
|
1809
|
+
return { alive: true };
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** Validates one incoming native frame with the origin and session checks: the correlation id stays present, the frame session matches the session the transport paired with and an anonymous frame refuses, so a frame of another session never reaches the engine. */
|
|
1813
|
+
export function nativeframecheck(frame: nativeframe, sessionid: string): { allowed: boolean; reason?: string } {
|
|
1814
|
+
if (frame.correlationid.trim() === "")
|
|
1815
|
+
return {
|
|
1816
|
+
allowed: false,
|
|
1817
|
+
reason: "The native frame carries its correlation id; an anonymous frame never reaches the engine.",
|
|
1818
|
+
};
|
|
1819
|
+
if (sessionid.trim() === "")
|
|
1820
|
+
return {
|
|
1821
|
+
allowed: false,
|
|
1822
|
+
reason:
|
|
1823
|
+
"The native transport pairs its frames with the session id; a transport without a session never accepts a frame.",
|
|
1824
|
+
};
|
|
1825
|
+
if (frame.sessionid !== undefined && frame.sessionid !== sessionid)
|
|
1826
|
+
return {
|
|
1827
|
+
allowed: false,
|
|
1828
|
+
reason: `The native frame session ${frame.sessionid} sits outside the paired session ${sessionid}; the origin and session checks refuse the frame.`,
|
|
1829
|
+
};
|
|
1830
|
+
return { allowed: true };
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/** The field keys key vault material hides behind: a native frame body of this shape never crosses the port because the native host never receives key vault material. */
|
|
1834
|
+
export const nativesecretkeys: string[] = [
|
|
1835
|
+
"apikey",
|
|
1836
|
+
"api_key",
|
|
1837
|
+
"key",
|
|
1838
|
+
"token",
|
|
1839
|
+
"secret",
|
|
1840
|
+
"password",
|
|
1841
|
+
"authorization",
|
|
1842
|
+
"credential",
|
|
1843
|
+
"privatekey",
|
|
1844
|
+
];
|
|
1845
|
+
|
|
1846
|
+
/** Checks one native frame body for key vault material: a body key of the secret shape refuses the whole frame, so the native host never receives key material and the wsbridge token never rides a call frame. */
|
|
1847
|
+
export function nativesecretexclusion(frame: nativeframe): { ok: boolean; held: string[] } {
|
|
1848
|
+
const body = frame.body ?? {};
|
|
1849
|
+
const held = Object.keys(body).filter((key) =>
|
|
1850
|
+
nativesecretkeys.includes(key.toLowerCase().replaceAll(" ", "").replaceAll("-", "")),
|
|
1851
|
+
);
|
|
1852
|
+
return { ok: held.length === 0, held };
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/** Redacts one native frame for the logs and the audit entries: the secret shaped keys and the token field drop, so a recorded frame carries its shape and its outcome but never its key material. */
|
|
1856
|
+
export function redactnativeframe(frame: nativeframe): nativeframe {
|
|
1857
|
+
const body = frame.body ?? {};
|
|
1858
|
+
const redacted: Record<string, unknown> = {};
|
|
1859
|
+
for (const [key, value] of Object.entries(body))
|
|
1860
|
+
if (!nativesecretkeys.includes(key.toLowerCase().replaceAll(" ", "").replaceAll("-", ""))) redacted[key] = value;
|
|
1861
|
+
return { ...frame, body: redacted };
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/** Builds one structured native error with its retry hint: the family names where the failure belongs and the retry hint maps the failure to its next action. */
|
|
1865
|
+
export function nativeerrorof(
|
|
1866
|
+
family: nativeerror["family"],
|
|
1867
|
+
message: string,
|
|
1868
|
+
retry: nativeerror["retry"],
|
|
1869
|
+
now: number,
|
|
1870
|
+
): nativeerror {
|
|
1871
|
+
return { family, message, retry, at: now };
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/** Maps one native failure to its retry hint: a handshake or port failure reconnects, an installer failure reinstalls and the rest never retry, so the caller reads the next action from the error itself. */
|
|
1875
|
+
export function nativefailureof(error: nativeerror): { retryhint: "retry" | "wait" | "none"; reason: string } {
|
|
1876
|
+
const retryhint = error.retry === "reconnect" ? "retry" : error.retry === "reinstall" ? "wait" : "none";
|
|
1877
|
+
return { retryhint, reason: `${error.family} failure: ${error.message}` };
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/** Reads the graceful degradation posture of the native transport: an absent host (nothing installed) or an outdated host (another protocol major version) keeps the run alive with the transport off, because the engine degrades instead of failing the run. */
|
|
1881
|
+
export function nativedegradationof(input: {
|
|
1882
|
+
state: nativehoststate | undefined;
|
|
1883
|
+
negotiated?: { ok: boolean; reason?: string };
|
|
1884
|
+
}): { degraded: boolean; reason: string } {
|
|
1885
|
+
if ((input.state?.installed ?? false) === false)
|
|
1886
|
+
return {
|
|
1887
|
+
degraded: true,
|
|
1888
|
+
reason:
|
|
1889
|
+
"The native host stays absent: nothing installed, the transport disabled and the run alive with every step inside the browser.",
|
|
1890
|
+
};
|
|
1891
|
+
if (input.negotiated !== undefined && !input.negotiated.ok)
|
|
1892
|
+
return {
|
|
1893
|
+
degraded: true,
|
|
1894
|
+
reason:
|
|
1895
|
+
input.negotiated.reason ??
|
|
1896
|
+
"The native host speaks another native bridge protocol major version; the run stays alive with the transport off.",
|
|
1897
|
+
};
|
|
1898
|
+
if (input.state?.port === "crashed")
|
|
1899
|
+
return {
|
|
1900
|
+
degraded: true,
|
|
1901
|
+
reason:
|
|
1902
|
+
"The native host crashed under the port; the run stays alive and waits for the reattach on the host restart.",
|
|
1903
|
+
};
|
|
1904
|
+
return {
|
|
1905
|
+
degraded: false,
|
|
1906
|
+
reason:
|
|
1907
|
+
"The native host sits attached with a negotiated capability set; the transport carries the consented call classes.",
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/** Checks the per session rate cap of the native transport: the calls of the window count against the user configured cap, an absent cap keeps the transport unbounded and the window slides from the newest call. */
|
|
1912
|
+
export function nativeratecheck(input: { calls: nativecallrecord[]; cap?: number; window?: number; now: number }): {
|
|
1913
|
+
allowed: boolean;
|
|
1914
|
+
used: number;
|
|
1915
|
+
cap?: number;
|
|
1916
|
+
reason?: string;
|
|
1917
|
+
} {
|
|
1918
|
+
if (input.cap === undefined) return { allowed: true, used: input.calls.length };
|
|
1919
|
+
if (!Number.isFinite(input.cap) || input.cap < 1)
|
|
1920
|
+
return {
|
|
1921
|
+
allowed: false,
|
|
1922
|
+
used: input.calls.length,
|
|
1923
|
+
cap: input.cap,
|
|
1924
|
+
reason: `The native transport rate cap of ${String(input.cap)} stays a finite positive count.`,
|
|
1925
|
+
};
|
|
1926
|
+
const window = input.window ?? Number.POSITIVE_INFINITY;
|
|
1927
|
+
const used = input.calls.filter(
|
|
1928
|
+
(record) => window === Number.POSITIVE_INFINITY || input.now - record.at <= window,
|
|
1929
|
+
).length;
|
|
1930
|
+
if (used >= input.cap)
|
|
1931
|
+
return {
|
|
1932
|
+
allowed: false,
|
|
1933
|
+
used,
|
|
1934
|
+
cap: input.cap,
|
|
1935
|
+
reason: `The native transport hit its ${input.cap} call cap with ${used} calls inside the window; the cap stays the user's choice and the session waits.`,
|
|
1936
|
+
};
|
|
1937
|
+
return { allowed: true, used, cap: input.cap };
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/** Builds one native call record of the audit trail: the correlation id of the run, the surface, the call class and the outcome with its refusal reason — the record never carries payload bytes. */
|
|
1941
|
+
export function nativecallrecordof(input: {
|
|
1942
|
+
id: string;
|
|
1943
|
+
correlationid: string;
|
|
1944
|
+
surface: string;
|
|
1945
|
+
callclass: nativecallclass;
|
|
1946
|
+
outcome: nativecallrecord["outcome"];
|
|
1947
|
+
reason?: string;
|
|
1948
|
+
now: number;
|
|
1949
|
+
}): nativecallrecord {
|
|
1950
|
+
return {
|
|
1951
|
+
id: input.id,
|
|
1952
|
+
correlationid: input.correlationid,
|
|
1953
|
+
surface: input.surface,
|
|
1954
|
+
callclass: input.callclass,
|
|
1955
|
+
outcome: input.outcome,
|
|
1956
|
+
...(input.reason !== undefined && input.reason !== "" ? { reason: input.reason } : {}),
|
|
1957
|
+
at: input.now,
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/** Stores one native call record at the head of the audit list; a repeated record id replaces the earlier entry so the trail keeps one record per call. */
|
|
1962
|
+
export function recordnativecall(records: nativecallrecord[], record: nativecallrecord): nativecallrecord[] {
|
|
1963
|
+
return [record, ...records.filter((candidate) => candidate.id !== record.id)];
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/** Builds the native call event that streams to subscribed mcp clients: the surface, the class and the outcome ride one notification frame without any payload, so a subscribed client follows the native transport without seeing call bodies. */
|
|
1967
|
+
export function nativecallevent(record: nativecallrecord): jsonrpcframe {
|
|
1968
|
+
return notificationframe("native/call", {
|
|
1969
|
+
surface: record.surface,
|
|
1970
|
+
callclass: record.callclass,
|
|
1971
|
+
outcome: record.outcome,
|
|
1972
|
+
correlationid: record.correlationid,
|
|
1973
|
+
at: record.at,
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/** Reads the consent grant of one native surface: the surface needs its own grant stamp because a class grant never widens into a desktop surface, and an unknown surface never grants. */
|
|
1978
|
+
export function nativesurfacegrant(
|
|
1979
|
+
surface: string,
|
|
1980
|
+
settings: runsettings | undefined,
|
|
1981
|
+
): { allowed: boolean; reason: string } {
|
|
1982
|
+
const known = nativesurfacecatalog().find((entry) => entry.surface === surface);
|
|
1983
|
+
if (known === undefined)
|
|
1984
|
+
return {
|
|
1985
|
+
allowed: false,
|
|
1986
|
+
reason: `The native surface ${surface} sits outside the surface catalog; an unknown surface never grants.`,
|
|
1987
|
+
};
|
|
1988
|
+
if ((settings?.nativesurfaceconsents ?? []).includes(known.surface))
|
|
1989
|
+
return {
|
|
1990
|
+
allowed: true,
|
|
1991
|
+
reason: `The user granted the ${known.surface} surface its own consent stamp; the surface runs behind the class gate too.`,
|
|
1992
|
+
};
|
|
1993
|
+
return {
|
|
1994
|
+
allowed: false,
|
|
1995
|
+
reason: `The native surface ${known.surface} holds no consent grant; a desktop surface never opens from a class grant alone.`,
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/** Reads the call class consent of one native call: read, interaction and sensitive stay separate grants so every class asks on its own. */
|
|
2000
|
+
export function nativeclassgrant(
|
|
2001
|
+
callclass: nativecallclass,
|
|
2002
|
+
settings: runsettings | undefined,
|
|
2003
|
+
): { allowed: boolean; reason: string } {
|
|
2004
|
+
if ((settings?.nativecallclassconsents ?? []).includes(callclass))
|
|
2005
|
+
return { allowed: true, reason: `The user consented to the ${callclass} call class over the native transport.` };
|
|
2006
|
+
return {
|
|
2007
|
+
allowed: false,
|
|
2008
|
+
reason: `The ${callclass} call class holds no consent grant over the native transport; the gate asks per class and never widens a read grant.`,
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/** Shapes one native surface result for the plan engine: the surface, the call class and the structured details compose the step outcome the plan engine records, with the secret exclusion applied to the details. */
|
|
2013
|
+
export function nativesurfaceresult(input: {
|
|
2014
|
+
surface: string;
|
|
2015
|
+
callclass: nativecallclass;
|
|
2016
|
+
details: Record<string, unknown>;
|
|
2017
|
+
now: number;
|
|
2018
|
+
}): { surface: string; callclass: nativecallclass; details: Record<string, unknown>; at: number } {
|
|
2019
|
+
const catalog = nativesurfacecatalog().find((entry) => entry.surface === input.surface);
|
|
2020
|
+
const callclass = catalog?.callclass ?? input.callclass;
|
|
2021
|
+
const held = Object.keys(input.details).filter((key) =>
|
|
2022
|
+
nativesecretkeys.includes(key.toLowerCase().replaceAll(" ", "").replaceAll("-", "")),
|
|
2023
|
+
);
|
|
2024
|
+
const details = { ...input.details };
|
|
2025
|
+
for (const key of held) delete details[key];
|
|
2026
|
+
return { surface: input.surface, callclass, details, at: input.now };
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/** Builds the diagnostics report of the native transport: the port state, the install versions, the installer version, the wsbridge port, the degradation posture, the consent grants and the last errors one line each, so the settings panel and the cli native command read the same report. */
|
|
2030
|
+
export function nativediagnostics(input: {
|
|
2031
|
+
state: nativehoststate | undefined;
|
|
2032
|
+
settings?: runsettings | undefined;
|
|
2033
|
+
sessions?: wsbridgesession[] | undefined;
|
|
2034
|
+
negotiated?: { ok: boolean; reason?: string } | undefined;
|
|
2035
|
+
now: number;
|
|
2036
|
+
}): {
|
|
2037
|
+
installed: boolean;
|
|
2038
|
+
port: string;
|
|
2039
|
+
hostname?: string;
|
|
2040
|
+
extensionid?: string;
|
|
2041
|
+
installerversion?: string;
|
|
2042
|
+
companionversion?: string;
|
|
2043
|
+
companionprotocol?: string;
|
|
2044
|
+
wsbridgeport?: number;
|
|
2045
|
+
nativebridgeprotocol: number;
|
|
2046
|
+
degraded: { degraded: boolean; reason: string };
|
|
2047
|
+
transportconsent: boolean;
|
|
2048
|
+
classconsents: nativecallclass[];
|
|
2049
|
+
surfaceconsents: nativesurfacekind[];
|
|
2050
|
+
sessions: Array<{ port: number; connections: number; expired: boolean }>;
|
|
2051
|
+
lasterrors: nativeerror[];
|
|
2052
|
+
} {
|
|
2053
|
+
const state = input.state ?? nativedefaultstate();
|
|
2054
|
+
const settings = input.settings;
|
|
2055
|
+
const sessions = (input.sessions ?? []).map((session) => ({
|
|
2056
|
+
port: session.port,
|
|
2057
|
+
connections: session.connections,
|
|
2058
|
+
expired: session.expiresat !== undefined && session.expiresat <= input.now,
|
|
2059
|
+
}));
|
|
2060
|
+
return {
|
|
2061
|
+
installed: state.installed,
|
|
2062
|
+
port: state.port,
|
|
2063
|
+
...(state.hostname !== undefined ? { hostname: state.hostname } : {}),
|
|
2064
|
+
...(state.extensionid !== undefined ? { extensionid: state.extensionid } : {}),
|
|
2065
|
+
...(state.installerversion !== undefined ? { installerversion: state.installerversion } : {}),
|
|
2066
|
+
...(state.companionversion !== undefined ? { companionversion: state.companionversion } : {}),
|
|
2067
|
+
...(state.companionprotocol !== undefined ? { companionprotocol: state.companionprotocol } : {}),
|
|
2068
|
+
...(state.wsbridgeport !== undefined ? { wsbridgeport: state.wsbridgeport } : {}),
|
|
2069
|
+
nativebridgeprotocol: nativebridgeprotocolmajor,
|
|
2070
|
+
degraded: nativedegradationof({
|
|
2071
|
+
state,
|
|
2072
|
+
...(input.negotiated !== undefined ? { negotiated: input.negotiated } : {}),
|
|
2073
|
+
}),
|
|
2074
|
+
transportconsent: settings?.nativetransportconsent === true,
|
|
2075
|
+
classconsents: settings?.nativecallclassconsents ?? [],
|
|
2076
|
+
surfaceconsents: settings?.nativesurfaceconsents ?? [],
|
|
2077
|
+
sessions,
|
|
2078
|
+
lasterrors: state.lasterrors ?? [],
|
|
2079
|
+
};
|
|
2080
|
+
}
|