@vantran-se/openchamber-web 2.0.4 → 2.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
- package/dist/assets/DiffView-uzNpHBlV.js +4 -0
- package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
- package/dist/assets/{GitView-DwQwqDvu.js → GitView-BmibZC7n.js} +4 -4
- package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
- package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
- package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-BNT2yUVu.js} +1 -1
- package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-D3Azksju.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
- package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-D_41H2qC.js} +1 -1
- package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-C0RSqa9W.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
- package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-Bqc-xuDe.js} +4 -4
- package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
- package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
- package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DuC4ecLw.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
- package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
- package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-BJicp0RC.js} +2 -2
- package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-CiuyzfSW.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
- package/dist/assets/de-BMnfXm-G.js +10 -0
- package/dist/assets/es-DKEvCCL6.js +16 -0
- package/dist/assets/fr-D9fgE2xE.js +14 -0
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/{index-OKDYjcMF.js → index-DWB8bR_g.js} +1 -1
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-DOoIsGqs.js} +1 -1
- package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-bt5VHvcd.js} +1 -1
- package/dist/assets/ja-BEXad-KW.js +16 -0
- package/dist/assets/ko-DGrQ0lEe.js +16 -0
- package/dist/assets/{main-CGEvLMd2.js → main-BpLQbZC2.js} +2 -2
- package/dist/assets/main-CI9DtHJv.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
- package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-CIWaR8wG.js} +2 -2
- package/dist/assets/{mobile-C5KRTNOB.js → mobile-CnWKEYIP.js} +2 -2
- package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
- package/dist/assets/pl-BlhkmR5b.js +16 -0
- package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
- package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
- package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-l-MbaNS4.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
- package/dist/assets/tr-CehUK1M9.js +7 -0
- package/dist/assets/uk-Bno5WGNz.js +16 -0
- package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-DDjuwVqi.js} +160 -156
- package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Clg8EO3A.js} +2 -2
- package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-BEo4-ISu.js → web-components-Bc1WPd4i.js} +1 -1
- package/dist/assets/zh-CN-DnAweIWo.js +16 -0
- package/dist/assets/zh-TW-BbBBK25p.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +3 -3
- package/server/index.js +82 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
- package/server/lib/agent-tool/runtime.js +34 -15
- package/server/lib/agent-tool/runtime.test.js +15 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/global-hub.js +23 -1
- package/server/lib/event-stream/global-hub.test.js +35 -0
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +30 -2
- package/server/lib/opencode/lifecycle.test.js +27 -0
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +38 -2
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +113 -0
- package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
- package/dist/assets/FilesView-BeTtbsip.js +0 -17
- package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
- package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
- package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
- package/dist/assets/PluginPane-B39JEUL8.js +0 -3
- package/dist/assets/SettingsView-13B2_G-A.js +0 -29
- package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
- package/dist/assets/de-BtYnUgII.js +0 -10
- package/dist/assets/es-crP5qPye.js +0 -16
- package/dist/assets/fr-CLSRuALk.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-SZ1-ucLV.js +0 -16
- package/dist/assets/ko-HthbT_Ve.js +0 -16
- package/dist/assets/main-BJklC7Cl.js +0 -896
- package/dist/assets/markdownCore-BzQuETm0.js +0 -5
- package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
- package/dist/assets/pl-C8eYfjL1.js +0 -16
- package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
- package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
- package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
- package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
- package/dist/assets/tr-B4IQ5t-T.js +0 -7
- package/dist/assets/uk-DCoXG6ve.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
- package/dist/assets/zh-TW-C6oelpnL.js +0 -16
|
@@ -24,6 +24,8 @@ export function createGlobalMessageStreamHub({
|
|
|
24
24
|
throw new RangeError('Replay limits must be nonnegative safe integers');
|
|
25
25
|
}
|
|
26
26
|
const eventSubscribers = new Set();
|
|
27
|
+
// The event subscribers that also take the events of isolated spaces.
|
|
28
|
+
const spaceSubscribers = new Set();
|
|
27
29
|
const statusSubscribers = new Set();
|
|
28
30
|
const replay = [];
|
|
29
31
|
let replayBytes = 0;
|
|
@@ -69,6 +71,10 @@ export function createGlobalMessageStreamHub({
|
|
|
69
71
|
const eventId = typeof envelope?.eventId === 'string' && envelope.eventId.length > 0
|
|
70
72
|
? envelope.eventId
|
|
71
73
|
: `${replayIdPrefix}${String(++replaySequence).padStart(12, '0')}`;
|
|
74
|
+
// An event of an isolated space carries the space's id; subscribers see it only when they
|
|
75
|
+
// asked for space events, because a consumer that acts on the host's OpenCode by
|
|
76
|
+
// directory must never act on a space's directory.
|
|
77
|
+
const spaceId = typeof envelope?.spaceId === 'string' && envelope.spaceId.length > 0 ? envelope.spaceId : null;
|
|
72
78
|
let serializedFrame;
|
|
73
79
|
let translated;
|
|
74
80
|
return {
|
|
@@ -76,6 +82,7 @@ export function createGlobalMessageStreamHub({
|
|
|
76
82
|
payload,
|
|
77
83
|
directory,
|
|
78
84
|
eventId,
|
|
85
|
+
spaceId,
|
|
79
86
|
serialize() {
|
|
80
87
|
serializedFrame ??= serializeMessageStreamWsEvent(payload, { directory, eventId });
|
|
81
88
|
return serializedFrame;
|
|
@@ -112,6 +119,7 @@ export function createGlobalMessageStreamHub({
|
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
for (const subscriber of Array.from(eventSubscribers)) {
|
|
122
|
+
if (normalized.spaceId !== null && !spaceSubscribers.has(subscriber)) continue;
|
|
115
123
|
notifySubscriber('event', subscriber, normalized);
|
|
116
124
|
}
|
|
117
125
|
};
|
|
@@ -207,12 +215,26 @@ export function createGlobalMessageStreamHub({
|
|
|
207
215
|
hasConnected() {
|
|
208
216
|
return everConnected;
|
|
209
217
|
},
|
|
210
|
-
|
|
218
|
+
/**
|
|
219
|
+
* `spaces: true` also delivers the events of isolated spaces, which carry `spaceId`.
|
|
220
|
+
* Without it a subscriber sees the host's events only, as every consumer did before spaces.
|
|
221
|
+
*/
|
|
222
|
+
subscribeEvent(subscriber, { spaces = false } = {}) {
|
|
211
223
|
eventSubscribers.add(subscriber);
|
|
224
|
+
if (spaces) spaceSubscribers.add(subscriber);
|
|
212
225
|
return () => {
|
|
213
226
|
eventSubscribers.delete(subscriber);
|
|
227
|
+
spaceSubscribers.delete(subscriber);
|
|
214
228
|
};
|
|
215
229
|
},
|
|
230
|
+
/**
|
|
231
|
+
* One event of an isolated space, from that space's own connection, entered here as if it
|
|
232
|
+
* had arrived upstream: numbered, coalesced, replayed and fanned out with the host's, so
|
|
233
|
+
* a client keeps one cursor for everything. `directory` is the space's, `spaceId` marks it.
|
|
234
|
+
*/
|
|
235
|
+
injectEvent({ payload, directory, spaceId }) {
|
|
236
|
+
coalescer.push({ envelope: { directory, spaceId }, payload });
|
|
237
|
+
},
|
|
216
238
|
subscribeStatus(subscriber) {
|
|
217
239
|
statusSubscribers.add(subscriber);
|
|
218
240
|
return () => {
|
|
@@ -412,3 +412,38 @@ describe('createGlobalMessageStreamHub', () => {
|
|
|
412
412
|
}
|
|
413
413
|
});
|
|
414
414
|
});
|
|
415
|
+
|
|
416
|
+
describe('events of isolated spaces in the global hub', () => {
|
|
417
|
+
const makeHub = () => createGlobalMessageStreamHub({
|
|
418
|
+
buildOpenCodeUrl: path => `http://127.0.0.1:4096${path}`,
|
|
419
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
420
|
+
deltaCoalesceWindowMs: 0,
|
|
421
|
+
fetchImpl: async () => createSseResponse({ blocks: [] }),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('delivers an injected space event to subscribers that asked for spaces only, numbered and replayable like the host\'s', () => {
|
|
425
|
+
const hub = makeHub();
|
|
426
|
+
const plain = [];
|
|
427
|
+
const withSpaces = [];
|
|
428
|
+
hub.subscribeEvent(event => plain.push(event));
|
|
429
|
+
hub.subscribeEvent(event => withSpaces.push(event), { spaces: true });
|
|
430
|
+
hub.injectEvent({ payload: { type: 'session.execution.started', data: { sessionID: 's1' } }, directory: '/spaces/a1b2c3d4e5f6/repo', spaceId: 'a1b2c3d4e5f6' });
|
|
431
|
+
expect(plain).toHaveLength(0);
|
|
432
|
+
expect(withSpaces).toHaveLength(1);
|
|
433
|
+
expect(withSpaces[0]).toMatchObject({ spaceId: 'a1b2c3d4e5f6', directory: '/spaces/a1b2c3d4e5f6/repo' });
|
|
434
|
+
expect(withSpaces[0].eventId).toMatch(/^oc-/);
|
|
435
|
+
expect(withSpaces[0].translated()).toEqual([expect.objectContaining({ type: 'session.status' })]);
|
|
436
|
+
expect(JSON.parse(withSpaces[0].serialize())).toMatchObject({ type: 'event', directory: '/spaces/a1b2c3d4e5f6/repo', payload: { type: 'session.execution.started' } });
|
|
437
|
+
hub.injectEvent({ payload: { type: 'session.execution.succeeded', data: { sessionID: 's1' } }, directory: '/spaces/a1b2c3d4e5f6/repo', spaceId: 'a1b2c3d4e5f6' });
|
|
438
|
+
expect(hub.replayAfter(withSpaces[0].eventId).map(entry => entry.eventId)).toEqual([withSpaces[1].eventId]);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('marks a host event with no space, so every subscriber sees it', () => {
|
|
442
|
+
const hub = makeHub();
|
|
443
|
+
const seen = [];
|
|
444
|
+
hub.subscribeEvent(event => seen.push(event.spaceId));
|
|
445
|
+
hub.subscribeEvent(event => seen.push(event.spaceId), { spaces: true });
|
|
446
|
+
hub.injectEvent({ payload: { type: 'session.execution.started', data: { sessionID: 's1' } }, directory: '/home/me', spaceId: null });
|
|
447
|
+
expect(seen).toEqual([null, null]);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -86,6 +86,8 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
86
86
|
}
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
+
// Browser clients take the events of isolated spaces too: each frame carries its directory,
|
|
90
|
+
// and a space's directory is one more project directory to the UI.
|
|
89
91
|
const unsubscribeEvent = globalHub.subscribeEvent((event) => {
|
|
90
92
|
const { payload } = event;
|
|
91
93
|
for (const socket of Array.from(clients)) {
|
|
@@ -111,7 +113,7 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
});
|
|
114
|
-
});
|
|
116
|
+
}, { spaces: true });
|
|
115
117
|
|
|
116
118
|
const unsubscribeStatus = globalHub.subscribeStatus((status) => {
|
|
117
119
|
if (status.type === 'connect') {
|
|
@@ -144,7 +144,7 @@ export function createUpstreamSseReader({
|
|
|
144
144
|
response,
|
|
145
145
|
});
|
|
146
146
|
await cancelResponseBody(response);
|
|
147
|
-
await waitForReconnectDelay(reconnectDelayMs, signal);
|
|
147
|
+
await waitForReconnectDelay(resolveTimeoutMs(reconnectDelayMs, DEFAULT_UPSTREAM_RECONNECT_DELAY_MS), signal);
|
|
148
148
|
continue;
|
|
149
149
|
}
|
|
150
150
|
|
|
@@ -219,7 +219,8 @@ export function createUpstreamSseReader({
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
if (!stopped && !signal?.aborted) {
|
|
222
|
-
|
|
222
|
+
// A function is read before every wait, so a caller can back off between attempts.
|
|
223
|
+
await waitForReconnectDelay(resolveTimeoutMs(reconnectDelayMs, DEFAULT_UPSTREAM_RECONNECT_DELAY_MS), signal);
|
|
223
224
|
}
|
|
224
225
|
}
|
|
225
226
|
})().finally(() => {
|
|
@@ -43,7 +43,7 @@ The following functions are exported and used by the web server:
|
|
|
43
43
|
- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). Inside the index mutation queue, the server verifies that the complete patch exactly matches one current three-context-line hunk for that file and scope, then runs `--check` before applying. Applicability alone cannot prove an unstaged change: old staged or committed hunks can reverse cleanly too. Stale, historical and multi-file patches fail with a refresh error. Temporary patch files are removed on success and failure; hunk content retains CRLF bytes.
|
|
44
44
|
|
|
45
45
|
### Branch Operations
|
|
46
|
-
- `getBranchBase(directory, branch)`: Read a named creation source from reflog. After a rebase, the creation source is no longer a current parent record, so return `null` and let the user choose a base. Explicit per-runtime, directory, and branch choices in the shared UI outrank detection.
|
|
46
|
+
- `getBranchBase(directory, branch)`: Read a named creation source from reflog. After a rebase, the creation source is no longer a current parent record, so return `null` and let the user choose a base. A source that is the branch's own remote copy (`git switch feat` records `Created from refs/remotes/origin/feat`) is not a parent either and also returns `null`. Explicit per-runtime, directory, and branch choices in the shared UI outrank detection.
|
|
47
47
|
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
|
|
48
48
|
- `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream.
|
|
49
49
|
- `createBranch(directory, branchName, options)`: Create and checkout a new branch.
|
|
@@ -3097,6 +3097,21 @@ export function parseBranchCreationSource(reflogText) {
|
|
|
3097
3097
|
return null;
|
|
3098
3098
|
}
|
|
3099
3099
|
|
|
3100
|
+
async function isOwnRemoteCopy(git, source, branchName) {
|
|
3101
|
+
const fullName = await git
|
|
3102
|
+
.raw(['rev-parse', '--symbolic-full-name', source])
|
|
3103
|
+
.then((value) => String(value || '').trim())
|
|
3104
|
+
.catch(() => '');
|
|
3105
|
+
if (!fullName.startsWith('refs/remotes/')) return false;
|
|
3106
|
+
const upstream = await git
|
|
3107
|
+
.raw(['rev-parse', '--symbolic-full-name', `refs/heads/${branchName}@{upstream}`])
|
|
3108
|
+
.then((value) => String(value || '').trim())
|
|
3109
|
+
.catch(() => '');
|
|
3110
|
+
if (upstream && fullName === upstream) return true;
|
|
3111
|
+
// Upstream may be unset; a remote ref with the branch's own name is still its copy.
|
|
3112
|
+
return fullName.slice('refs/remotes/'.length).split('/').slice(1).join('/') === branchName;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3100
3115
|
/**
|
|
3101
3116
|
* Resolve the branch the given branch was created from, from its reflog.
|
|
3102
3117
|
* Returns { base: null } when git has no authoritative record (clone, detached
|
|
@@ -3130,6 +3145,13 @@ export async function getBranchBase(directory, branch) {
|
|
|
3130
3145
|
return { base: null };
|
|
3131
3146
|
}
|
|
3132
3147
|
|
|
3148
|
+
// `git switch feat` from a remote branch records "Created from
|
|
3149
|
+
// refs/remotes/origin/feat": the branch's own remote copy, not a parent.
|
|
3150
|
+
// Comparing against it hides every pushed commit.
|
|
3151
|
+
if (await isOwnRemoteCopy(git, source, branchName)) {
|
|
3152
|
+
return { base: null };
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3133
3155
|
return { base: source };
|
|
3134
3156
|
}
|
|
3135
3157
|
|
|
@@ -2316,6 +2316,14 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
|
|
|
2316
2316
|
}
|
|
2317
2317
|
});
|
|
2318
2318
|
|
|
2319
|
+
it('does not treat a branch checked out from its own remote copy as its base', async () => {
|
|
2320
|
+
const { repository } = createRepositoryWithRemote();
|
|
2321
|
+
runGit(repository, ['checkout', '-b', 'react', '--track', 'origin/react']);
|
|
2322
|
+
expect(await getBranchBase(repository, 'react')).toEqual({ base: null });
|
|
2323
|
+
runGit(repository, ['checkout', '--no-track', '-b', 'loose', 'origin/react']);
|
|
2324
|
+
expect(await getBranchBase(repository, 'loose')).toEqual({ base: 'origin/react' });
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2319
2327
|
it('asks for a new base after restacking and compares against the selected parent', async () => {
|
|
2320
2328
|
const { repository } = createRepositoryWithRemote();
|
|
2321
2329
|
runGit(repository, ['checkout', '-b', 'child', 'origin/react']);
|
|
@@ -7,6 +7,7 @@ OpenChamber-owned install and static serve for iframe guests. This is not the Op
|
|
|
7
7
|
## Routes
|
|
8
8
|
|
|
9
9
|
- `POST /api/guests/:id/storage` accepts the SDK's `GuestStorageRequest` and returns `GuestStorageResult`. It requires an installed, enabled, fully approved guest with either a panel or background entry, but no additional capability. The UI host supplies the frame's own identity. Guest-scoped asset tokens cannot call this route. Data lives beside `extensions.json` in `guest-storage/<id>.json`. Each key is 1 to 128 characters, each JSON value at most 64 KiB UTF-8, the whole namespace at most 2 MiB and 2,000 keys. `storage.js` owns serialized authorization/read/write/removal; writes are atomic and failures preserve prior bytes. `uninstallGuest` removes storage for every install source. Missing keys return `found: false`, including after removal; stored JSON null returns `found: true`.
|
|
10
|
+
- `GET /api/guests` also exposes `statusEntry` and optional `statusTitle` / `statusHeight` for `contributes.statusSection`, the extension's Work Status section. Its HTML and built scripts pass the same install checks, and it may be the package's only frame: the storage route and the asset route's `hasRuntime` accept `entry`, `backgroundEntry`, or `statusEntry`.
|
|
10
11
|
- `GET /api/guests` also exposes `pageEntry` and optional `pageTitle` for `contributes.page`. Full-screen page HTML and its built scripts pass the same install checks as attach HTML. No new serving/auth path is required.
|
|
11
12
|
|
|
12
13
|
- `GET /api/guests` returns installed guests. `attach` is `"panel"` or `"dialog"` when declared, and `attachEntry` is the dialog's separate HTML. `version` is the package semver. `integration` is the public slice: name, description, `auth`, and settings; OAuth URLs and tokens stay on the server. `service` includes `runtime`, optional `permissions`, optional `socketBindings`, and `granted`. `capabilities` carries `{ requested, granted }`, derived through the SDK's `requestedGuestCapabilities`. `actions`, `commands`, `tools`, and `filesystem` retain their parsed declarations; UI owns menu placement, command collisions, and tool precedence. `entry` is the optional visible panel and `backgroundEntry` is independent on-demand execution HTML. Background-only extensions get no rail surface; tools-only extensions with neither entry mount no frame. A guest without every requested grant is inactive until approval. `enabled: false` pauses it. A failed listing is 500, never an empty list.
|
|
@@ -30,13 +31,13 @@ OpenChamber-owned install and static serve for iframe guests. This is not the Op
|
|
|
30
31
|
- `POST /api/guests/updates/check` — `{ force?: boolean }` → `{ updates: Record<guestId, { version }> }` for every git install whose origin now carries a newer `package.json` `version`. The check runs `git fetch --depth 1 origin <ref|HEAD>` inside the installed clone and reads `FETCH_HEAD:package.json` through the SDK manifest parser; the only signal is semver (`updates.js` `compareSemver`, prerelease below release), no commit hashes. Results sit in a memory cache per store file and guest id for `UPDATE_CHECK_TTL_MS` (one hour); `force` asks the network again. That cache is separate from the catalog cache, so a store write does not drop it. One guest's failed fetch never hides another's update. Folder and zip installs are never checked.
|
|
31
32
|
- `POST /api/guests/:id/update` — replace the installed git copy with a fresh clone of its stored origin. Answers `{ guest }` (the refreshed catalog row) or 400 `{ error }` with `not-git` (folder, zip, or no stored origin), `clone-failed`, `invalid-manifest` (also when the clone declares a different panel id), `missing-build`, `host-too-old` (body may include `required`), or `swap-failed`. Unknown id is 404. Grants, tokens, settings, and the paused flag are keyed by guest id and stay as they were; a version that asks for more capabilities shows up as needs-approval in the catalog and goes through the usual review dialog.
|
|
32
33
|
- `DELETE /api/guests/:id` — drops the store row, any service grant, and socket overrides; stops a running service. Path-install does not delete the user's folder. Zip and git copies under `{openchamberDataDir}/extensions/` are deleted. Unknown id is 404.
|
|
33
|
-
- `GET /api/guests/:id/{*filePath}` serves package-local files. Unknown IDs, escapes, missing files, and unknown types return 404. HTML and JS require
|
|
34
|
+
- `GET /api/guests/:id/{*filePath}` serves package-local files. Unknown IDs, escapes, missing files, and unknown types return 404. HTML and JS require `entry`, `backgroundEntry`, or `statusEntry`, expressed by `resolveGuestServedFile`'s `hasRuntime` option. Tools-only extensions still serve their SVG icons but no executable content. Guest HTML propagates the scoped `oc_url_token` onto relative script/link/image URLs. The token is minted for `guest:<id>` and works only under that guest's asset route, never `/api/fs/raw` or event streams. Every response carries `Content-Security-Policy: sandbox allow-scripts`, including direct document navigation. The host serves built files from disk and never compiles guest sources.
|
|
34
35
|
|
|
35
36
|
## Install
|
|
36
37
|
|
|
37
38
|
`contributes.background.entry` is optional package-local `.html`, separate from `panel.entry`. Install validates its HTML and every referenced built script using the same path confinement as visible panels. `backgroundEntry` is exposed on the catalog independently of `entry`; adding it does not create a rail icon or a visible page. Capabilities, services, integrations, and storage work with either execution entry. The SDK refuses visible contributions without `panel.entry` and all runtime contributions when neither entry exists. See `packages/sdk/DOCUMENTATION.md` for on-demand invocation ownership.
|
|
38
39
|
|
|
39
|
-
`html-styles.js` appends the SDK's shared scrollbar stylesheet to served HTML, including requests without an asset token. This applies to existing panel, page, and attach bundles in direct and relay mode; the relay loader retains the inline style in `srcDoc`. Appending preserves the doctype and avoids rewriting tag-like text inside scripts or comments. Authored CSP stays intact and can block inline styling. Package files on disk and non-HTML responses are unchanged. Scrollbar colors follow the SDK theme variables inside the frame.
|
|
40
|
+
`html-styles.js` appends the SDK's shared scrollbar stylesheet and its small inline activity script (`GUEST_SCROLLBAR_SCRIPT`: shows a scrollbar while its element scrolls) to served HTML, including requests without an asset token. An authored CSP that blocks inline scripts leaves hover-only scrollbars. This applies to existing panel, page, and attach bundles in direct and relay mode; the relay loader retains the inline style in `srcDoc`. Appending preserves the doctype and avoids rewriting tag-like text inside scripts or comments. Authored CSP stays intact and can block inline styling. Package files on disk and non-HTML responses are unchanged. Scrollbar colors follow the SDK theme variables inside the frame.
|
|
40
41
|
|
|
41
42
|
Built-ins ship with the app; user extensions are installed separately. Add a folder, local ZIP, HTTPS Git/ZIP URL, or SSH Git address from Settings → Extensions. The ZIP picker and drop zone upload the archive on web and remote desktop instances. A guest with a page must ship built classic-IIFE JavaScript, for example from `bunx openchamber-guest-bundle panel/main.ts panel/main.js`; the sandbox does not support ESM imports. `package.json` needs a semver version. A missing panel SVG or invalid manifest fails installation; page-less tools-only packages skip HTML/script checks. Optional `engines.openchamber` is checked against this host. Provider accounts are connected separately in Settings → Integrations.
|
|
42
43
|
|
|
@@ -63,7 +64,7 @@ A missing folder is omitted from the list. Other guests stay. A corrupt `extensi
|
|
|
63
64
|
- `contributes.attach` is copied as `"panel"` or `"dialog"`. The composer + menu and New Worktree read that field. Omitted guests stay off those menus. The object form's `entry` (dialog only) is checked at install like `panel.entry`: missing HTML is `invalid-manifest`, an unbuilt script it loads is `missing-build`; the catalog exposes it as `attachEntry` and the attach window loads that page instead of `panel.entry`. The optional attach `data` payload lives only in the UI's chip, queued context part, and session metadata snapshot; no server route parses it.
|
|
64
65
|
- `request` never leaves the declared `apiOrigin`. A 401 refreshes once. If that fails, tokens for that guest are dropped. Host Linear does not drop Linear auth on a failed GraphQL query. A paused guest never proxies `request` or `serviceRequest`.
|
|
65
66
|
- A service with `provides: ["browser"]` is also driven by the host (`../browser-control/provider.js`), not only by a panel: `proxyGuestServiceRequest` takes `timeoutMs`, `responseMax`, `idleStopMs`, and `signal` for that. A service that never became ready is `SERVICE_FAILED` (nothing was sent); a request that was sent and got no usable answer is `REQUEST_FAILED` (the service may have acted on it), and callers word their errors accordingly. `idleStopMs` arms a per-guest timer re-armed at the end of every request; when it fires with no request in flight the process is discarded (not a Pause: no epoch bump) and the next request respawns it. A user-facing stop clears the timer. Panel requests pass none of these and keep the SDK limits. The guest routes call `onGuestDeactivated({ guestId, guestName })` after a pause, a removal, or a withdrawn approval so a host role the extension held is handed back and the user told.
|
|
66
|
-
- `surface.js` hosts shared surfaces (`contributes.service.surface`). `/api/guests/:id/surface/ws` (URL-token WebSocket, allowlisted in `ui-auth.js` and `relay/tunnel-host.js`; upgrade refused with 404 unless `isSurfaceGuest`: enabled, fully approved, declares the surface) attaches a viewer to the extension's one session. The session holds the service (`holdGuestService`) and runs one pump: `GET /surface/frame?after=<seq>&wait=25000` through `openGuestServiceRequest`, newest frame kept, delivered per viewer only after that viewer acknowledged the previous one (`frame` text message + one binary message). Control is the session's: `none`, `agent` (set by `noteAgentActivity`, expires after `SURFACE_AGENT_HOLD_MS`), or a viewer id; the first deliberate input (anything but a button-less pointer move) from a viewer while nobody or the agent holds it takes it, `release` or the holder's disconnect gives it back, a second viewer's input is refused with a `control` message. Every change is broadcast (`mine` per connection) and posted to `POST /surface/control`. Input, control notices, resizes, and clipboard reads run through one promise chain per session, in socket order; work queued for an ended session is dropped, and an ended session's close handlers never call the service, so closing a panel after a pause, removal, or withdrawn approval cannot restart the process under the session's old authorization. When the last viewer leaves normally the session stays registered until its queue (including the final `none`) has drained, so a deactivation in that window still finds and cancels it; a viewer that reattaches meanwhile restarts the pump. Every hand-off (release, holder disconnect) bumps `session.handoffs`; an input batch remembers the count it arrived under and, if it no longer holds control, may take control only when nothing was handed off since, so a click queued behind a slow request cannot re-take control after the user let go, and a viewer that left (`gone`) never becomes the controller. `userControls` gates the browser provider; `endForGuest` (pause, removal, withdrawn approval) and a failed frame request end the session with an `ended` reason, and `stop()` closes every viewer on shutdown.
|
|
67
|
+
- `surface.js` hosts shared surfaces (`contributes.service.surface`). `/api/guests/:id/surface/ws` (URL-token WebSocket, allowlisted in `ui-auth.js` and `relay/tunnel-host.js`; upgrade refused with 404 unless `isSurfaceGuest`: enabled, fully approved, declares the surface) attaches a viewer to the extension's one session. The session holds the service (`holdGuestService`) and runs one pump: `GET /surface/frame?after=<seq>&wait=25000` through `openGuestServiceRequest`, newest frame kept, delivered per viewer only after that viewer acknowledged the previous one (`frame` text message + one binary message). Control is the session's: `none`, `agent` (set by `noteAgentActivity`, expires after `SURFACE_AGENT_HOLD_MS`), or a viewer id; the first deliberate input (anything but a button-less pointer move) from a viewer while nobody or the agent holds it takes it, `release` or the holder's disconnect gives it back, a second viewer's input is refused with a `control` message. Every change is broadcast (`mine` per connection) and posted to `POST /surface/control`. Input, control notices, resizes, and clipboard reads run through one promise chain per session, in socket order; work queued for an ended session is dropped, and an ended session's close handlers never call the service, so closing a panel after a pause, removal, or withdrawn approval cannot restart the process under the session's old authorization. When the last viewer leaves normally the session stays registered until its queue (including the final `none`) has drained, so a deactivation in that window still finds and cancels it; a viewer that reattaches meanwhile restarts the pump. Every hand-off (release, holder disconnect) bumps `session.handoffs`; an input batch remembers the count it arrived under and, if it no longer holds control, may take control only when nothing was handed off since, so a click queued behind a slow request cannot re-take control after the user let go, and a viewer that left (`gone`) never becomes the controller. Viewer ids are random UUIDs. Every input batch reaches the service with `x-surface-viewer` and `x-surface-frame-seq`: the service's own sequence number (kept apart from the host's bumped `seq`) of the frame that viewer last acknowledged, read when the batch arrives, not when the queue reaches it. A 409 answer becomes an `INPUT_STALE` error to that viewer. Control notices carry `viewer` for a user controller. `viewerHeaders(guestId, viewerId)` gives `/api/guests/:id/service/request` the same viewer, control, and frame headers when the panel names a live viewer of that extension (`viewerId` in the body, from `surface-viewers.ts` in the UI); an unknown id adds nothing. `userControls` gates the browser provider; `endForGuest` (pause, removal, withdrawn approval) and a failed frame request end the session with an `ended` reason, and `stop()` closes every viewer on shutdown.
|
|
67
68
|
- `serviceRequest` never leaves that guest's loopback port. Auth to the service is `Authorization: Bearer` with a host-minted token. The service env is an allowlist (PATH, HOME, temp, locale, Windows system variables) plus `OPENCHAMBER_SERVICE_*`; host secrets are not inherited. There is no sandbox: an allowed service has the user's full access, and the grant card says so. Ready is `GET /health` → 200. Parallel first requests share one spawn; they must not kill each other mid-start. Host quit stops every service. Disabling a guest stops its service without clearing OAuth or grants.
|
|
68
69
|
- File access is decided in `files.js` on canonical paths, never on the string the guest sent. `matchesFilesystemPattern`, `expandHome`, and `globToRegExp` are pure; `resolveGuestFilePath` and `runGuestFileOperation` take `fsPromises` / `nodePath` so tests can stub the OS. The project directory is whatever `resolveOptionalProjectDirectory` validated for this request, so a guest cannot name a project the UI did not open.
|
|
69
70
|
- Update swap (`updates.js` `updateGuest`): the new clone lands in `{dataDir}/extensions/.tmp-<id>-<random>` and is inspected exactly like an install before anything else moves. Only then: stop the guest's service, rename current → `.old-<id>`, rename temp → final, delete `.old-<id>`. Any failure before the swap deletes the temp dir and leaves the install untouched; a failed second rename moves `.old-<id>` back. The store row is not rewritten (same path, same id), the catalog cache and that guest's update cache entry are dropped.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { hasGuestPage, requestedGuestCapabilities, resolveAttachEntry, resolveAttachMode, resolvePageEntry, toPublicService, toPublicIntegration, hostMeetsOpenChamberEngine, openChamberEngineMinimum } from '@openchamber/sdk';
|
|
4
|
+
import { hasGuestPage, requestedGuestCapabilities, resolveAttachEntry, resolveAttachMode, resolvePageEntry, resolveStatusSectionEntry, toPublicService, toPublicIntegration, hostMeetsOpenChamberEngine, openChamberEngineMinimum } from '@openchamber/sdk';
|
|
5
5
|
import { parseManifestJson } from '@openchamber/sdk/schemas';
|
|
6
6
|
|
|
7
7
|
import { listRelativeGuestScriptHrefs, resolveGuestHtmlRelativePath } from './html-tokens.js';
|
|
@@ -247,6 +247,21 @@ export const inspectGuestPackage = async (packageRoot, { openchamberVersion, ski
|
|
|
247
247
|
const page = parsed.manifest.contributes.page;
|
|
248
248
|
if (page !== true && page?.title) guest.pageTitle = page.title;
|
|
249
249
|
}
|
|
250
|
+
// The Work Status section is checked like any other entry, and may be the
|
|
251
|
+
// package's only frame.
|
|
252
|
+
const statusEntry = resolveStatusSectionEntry(parsed.manifest.contributes);
|
|
253
|
+
if (statusEntry) {
|
|
254
|
+
if (!await resolveGuestAssetPath(packageRoot, statusEntry)) {
|
|
255
|
+
return { ok: false, code: 'invalid-manifest' };
|
|
256
|
+
}
|
|
257
|
+
if (!await guestBuiltScriptsReady(packageRoot, statusEntry)) {
|
|
258
|
+
return { ok: false, code: 'missing-build' };
|
|
259
|
+
}
|
|
260
|
+
guest.statusEntry = statusEntry;
|
|
261
|
+
const section = parsed.manifest.contributes.statusSection;
|
|
262
|
+
if (section !== true && section?.title) guest.statusTitle = section.title;
|
|
263
|
+
if (section !== true && section?.height !== undefined) guest.statusHeight = section.height;
|
|
264
|
+
}
|
|
250
265
|
if (parsed.manifest.contributes.capabilities?.length) {
|
|
251
266
|
guest.capabilities = [...parsed.manifest.contributes.capabilities];
|
|
252
267
|
}
|
|
@@ -324,6 +339,9 @@ export const toPublicGuest = (guest) => {
|
|
|
324
339
|
const attach = resolveAttachMode(guest.attach);
|
|
325
340
|
if (guest.pageEntry) row.pageEntry = guest.pageEntry;
|
|
326
341
|
if (guest.pageTitle) row.pageTitle = guest.pageTitle;
|
|
342
|
+
if (guest.statusEntry) row.statusEntry = guest.statusEntry;
|
|
343
|
+
if (guest.statusTitle) row.statusTitle = guest.statusTitle;
|
|
344
|
+
if (Number.isInteger(guest.statusHeight)) row.statusHeight = guest.statusHeight;
|
|
327
345
|
if (attach) {
|
|
328
346
|
row.attach = attach;
|
|
329
347
|
}
|
|
@@ -257,6 +257,28 @@ describe('page-less packages', () => {
|
|
|
257
257
|
expect(toPublicGuest(inspected.guest)).toMatchObject({ pageEntry: 'panel/page.html', pageTitle: 'Tasks' });
|
|
258
258
|
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
259
259
|
});
|
|
260
|
+
test('a status-section-only package validates its HTML and scripts and publishes title and height without a panel entry', async () => {
|
|
261
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-status-'));
|
|
262
|
+
try {
|
|
263
|
+
await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({ version: '1.0.0', openchamber: {
|
|
264
|
+
apiVersion: 1, contributes: {
|
|
265
|
+
panel: { id: 'git-graph', name: 'Git graph', icon: 'git-commit' },
|
|
266
|
+
statusSection: { entry: 'status/index.html', title: 'Recent commits', height: 160 },
|
|
267
|
+
},
|
|
268
|
+
} }));
|
|
269
|
+
expect(await inspectGuestPackage(dir)).toMatchObject({ ok: false, code: 'invalid-manifest' });
|
|
270
|
+
await fs.mkdir(path.join(dir, 'status'));
|
|
271
|
+
await fs.writeFile(path.join(dir, 'status/index.html'), '<script src="main.js"></script>');
|
|
272
|
+
expect(await inspectGuestPackage(dir)).toMatchObject({ ok: false, code: 'missing-build' });
|
|
273
|
+
await fs.writeFile(path.join(dir, 'status/main.js'), 'console.log("status")');
|
|
274
|
+
const inspected = await inspectGuestPackage(dir);
|
|
275
|
+
expect(inspected.ok).toBe(true);
|
|
276
|
+
const row = toPublicGuest(inspected.guest);
|
|
277
|
+
expect(row).toMatchObject({ statusEntry: 'status/index.html', statusTitle: 'Recent commits', statusHeight: 160 });
|
|
278
|
+
expect(row).not.toHaveProperty('entry');
|
|
279
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
280
|
+
});
|
|
281
|
+
|
|
260
282
|
test('installs a tools-only package without entry, omits entry from the row, and never serves it a frame', async () => {
|
|
261
283
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
262
284
|
const guestRoot = path.join(dir, 'tools-only');
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { GUEST_SCROLLBAR_CSS } from '@openchamber/sdk';
|
|
1
|
+
import { GUEST_SCROLLBAR_CSS, GUEST_SCROLLBAR_SCRIPT } from '@openchamber/sdk';
|
|
2
2
|
|
|
3
3
|
/** Append instead of matching tags inside untrusted comments, scripts, or templates.
|
|
4
|
-
* Browsers accept the style after the document; its doctype and authored CSP stay intact.
|
|
4
|
+
* Browsers accept the style and script after the document; its doctype and authored CSP stay intact.
|
|
5
|
+
* The script only marks scrolling elements so their scrollbars show while scrolling; an authored
|
|
6
|
+
* CSP that blocks inline scripts leaves the hover-only behavior of the stylesheet.
|
|
5
7
|
*/
|
|
6
|
-
export const injectGuestDocumentStyles = (html) => `${html}\n<style data-openchamber-guest-styles>${GUEST_SCROLLBAR_CSS}</style>`;
|
|
8
|
+
export const injectGuestDocumentStyles = (html) => `${html}\n<style data-openchamber-guest-styles>${GUEST_SCROLLBAR_CSS}</style>\n<script data-openchamber-guest-scrollbar>${GUEST_SCROLLBAR_SCRIPT}</script>`;
|
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import express from 'express';
|
|
6
6
|
import request from 'supertest';
|
|
7
|
-
import { GUEST_SCROLLBAR_CSS } from '@openchamber/sdk';
|
|
7
|
+
import { GUEST_SCROLLBAR_CSS, GUEST_SCROLLBAR_SCRIPT } from '@openchamber/sdk';
|
|
8
8
|
import { injectGuestDocumentStyles } from './html-styles.js';
|
|
9
9
|
import { registerGuestRoutes } from './routes.js';
|
|
10
10
|
import { writeExtensionStore } from './persist.js';
|
|
@@ -19,6 +19,7 @@ describe('guest document styles', () => {
|
|
|
19
19
|
expect(decorated.startsWith(html)).toBe(true);
|
|
20
20
|
expect(decorated).toContain(GUEST_SCROLLBAR_CSS);
|
|
21
21
|
expect(decorated).toContain('data-openchamber-guest-styles');
|
|
22
|
+
expect(decorated).toContain(`<script data-openchamber-guest-scrollbar>${GUEST_SCROLLBAR_SCRIPT}</script>`);
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
test('serves scrollbar defaults to existing guests with or without an asset token', async () => {
|
|
@@ -36,6 +37,7 @@ describe('guest document styles', () => {
|
|
|
36
37
|
expect(response.headers['content-security-policy']).toBe('sandbox allow-scripts');
|
|
37
38
|
expect(response.headers['cache-control']).toBe('no-store');
|
|
38
39
|
expect(response.text).toContain(GUEST_SCROLLBAR_CSS);
|
|
40
|
+
expect(response.text).toContain(GUEST_SCROLLBAR_SCRIPT);
|
|
39
41
|
expect(response.text.startsWith('<!doctype html>')).toBe(true);
|
|
40
42
|
expect(response.text).toContain(suffix ? 'main.js?oc_url_token=fixture-scope' : 'src="main.js"');
|
|
41
43
|
}
|
|
@@ -88,6 +88,12 @@ const requestBodySchema = z.object({
|
|
|
88
88
|
body: z.string().max(64_000).optional(),
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
const serviceRequestBodySchema = requestBodySchema.extend({
|
|
92
|
+
// The shared-surface viewer open in the same window, if any. The host
|
|
93
|
+
// resolves it; an id that is not a live viewer of this extension is ignored.
|
|
94
|
+
viewerId: z.string().min(1).max(128).optional(),
|
|
95
|
+
});
|
|
96
|
+
|
|
91
97
|
const fileBodySchema = z.object({
|
|
92
98
|
op: z.enum(['read', 'write', 'list', 'stat']),
|
|
93
99
|
path: z.string().min(1).max(GUEST_FILE_PATH_MAX),
|
|
@@ -196,6 +202,7 @@ export const registerGuestRoutes = (app, {
|
|
|
196
202
|
resolveOptionalProjectDirectory,
|
|
197
203
|
getSmallModelService,
|
|
198
204
|
onGuestDeactivated = async () => false,
|
|
205
|
+
surfaceViewerHeaders = () => null,
|
|
199
206
|
}) => {
|
|
200
207
|
const persistPath = extensionsPersistPath(openchamberDataDir);
|
|
201
208
|
const authPath = guestAuthPersistPath(openchamberDataDir);
|
|
@@ -547,7 +554,7 @@ export const registerGuestRoutes = (app, {
|
|
|
547
554
|
if (!guest?.service) {
|
|
548
555
|
return res.status(404).json({ error: 'not-found' });
|
|
549
556
|
}
|
|
550
|
-
const parsed =
|
|
557
|
+
const parsed = serviceRequestBodySchema.safeParse(req.body);
|
|
551
558
|
if (!parsed.success) {
|
|
552
559
|
return res.status(400).json({ error: 'invalid-request' });
|
|
553
560
|
}
|
|
@@ -562,6 +569,7 @@ export const registerGuestRoutes = (app, {
|
|
|
562
569
|
path: parsed.data.path,
|
|
563
570
|
query: parsed.data.query,
|
|
564
571
|
body: parsed.data.body,
|
|
572
|
+
headers: parsed.data.viewerId ? surfaceViewerHeaders(guest.id, parsed.data.viewerId) ?? undefined : undefined,
|
|
565
573
|
});
|
|
566
574
|
res.json(result);
|
|
567
575
|
} catch (error) {
|
|
@@ -580,7 +588,7 @@ export const registerGuestRoutes = (app, {
|
|
|
580
588
|
try {
|
|
581
589
|
const result = await runGuestStorage(persistPath, req.params.id, parsed.data, async () => {
|
|
582
590
|
const guest = await loadGuest(req.params.id);
|
|
583
|
-
if (!guest || guest.enabled === false || (!guest.entry && !guest.backgroundEntry)) throw new Error('Extension is unavailable.');
|
|
591
|
+
if (!guest || guest.enabled === false || (!guest.entry && !guest.backgroundEntry && !guest.statusEntry)) throw new Error('Extension is unavailable.');
|
|
584
592
|
if (!requestedGuestCapabilities(guest).every((capability) => guest.capabilityGrants.includes(capability))) throw new Error('Extension needs approval.');
|
|
585
593
|
});
|
|
586
594
|
return res.json(result);
|
|
@@ -814,7 +822,7 @@ export const registerGuestRoutes = (app, {
|
|
|
814
822
|
return res.status(404).end();
|
|
815
823
|
}
|
|
816
824
|
const served = await resolveGuestServedFile(guest.packageRoot, relativePath, {
|
|
817
|
-
hasRuntime: Boolean(guest.entry || guest.backgroundEntry),
|
|
825
|
+
hasRuntime: Boolean(guest.entry || guest.backgroundEntry || guest.statusEntry),
|
|
818
826
|
});
|
|
819
827
|
if (!served) {
|
|
820
828
|
return res.status(404).end();
|
|
@@ -552,6 +552,7 @@ const ensureGuestService = async (params) => {
|
|
|
552
552
|
* query?: Record<string, string>,
|
|
553
553
|
* body?: string,
|
|
554
554
|
* accept?: string,
|
|
555
|
+
* headers?: Record<string, string>,
|
|
555
556
|
* timeoutMs?: number,
|
|
556
557
|
* idleStopMs?: number,
|
|
557
558
|
* signal?: AbortSignal,
|
|
@@ -572,6 +573,7 @@ export const openGuestServiceRequest = async ({
|
|
|
572
573
|
query,
|
|
573
574
|
body,
|
|
574
575
|
accept = 'application/json',
|
|
576
|
+
headers: extraHeaders,
|
|
575
577
|
timeoutMs = GUEST_REQUEST_TIMEOUT_MS,
|
|
576
578
|
idleStopMs,
|
|
577
579
|
signal,
|
|
@@ -645,6 +647,9 @@ export const openGuestServiceRequest = async ({
|
|
|
645
647
|
|
|
646
648
|
/** @type {Record<string, string>} */
|
|
647
649
|
const headers = {
|
|
650
|
+
// Host-set context (surface viewer headers) first: nothing in it can
|
|
651
|
+
// replace the bearer or the content type below.
|
|
652
|
+
...extraHeaders,
|
|
648
653
|
Accept: accept,
|
|
649
654
|
[OPENCHAMBER_SERVICE_AUTH_HEADER]: `Bearer ${runtime.token}`,
|
|
650
655
|
};
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* holding it releases it.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
|
|
23
25
|
import { WebSocketServer } from 'ws';
|
|
24
26
|
|
|
25
27
|
import {
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
SURFACE_FRAME_MAX_BYTES,
|
|
31
33
|
SURFACE_FRAME_MIMES,
|
|
32
34
|
SURFACE_FRAME_PATH,
|
|
35
|
+
SURFACE_FRAME_SEQ_HEADER,
|
|
33
36
|
SURFACE_FRAME_WAIT_MS,
|
|
34
37
|
SURFACE_HEIGHT_HEADER,
|
|
35
38
|
SURFACE_INPUT_PATH,
|
|
@@ -37,6 +40,8 @@ import {
|
|
|
37
40
|
SURFACE_SEQ_HEADER,
|
|
38
41
|
SURFACE_TITLE_HEADER,
|
|
39
42
|
SURFACE_TITLE_MAX,
|
|
43
|
+
SURFACE_VIEWER_CONTROLS_HEADER,
|
|
44
|
+
SURFACE_VIEWER_HEADER,
|
|
40
45
|
SURFACE_WIDTH_HEADER,
|
|
41
46
|
isGuestApproved,
|
|
42
47
|
requestedGuestCapabilities,
|
|
@@ -97,7 +102,9 @@ export const createGuestSurfaceRuntime = ({
|
|
|
97
102
|
idleStopMs,
|
|
98
103
|
openServiceRequest = openGuestServiceRequest,
|
|
99
104
|
holdService = holdGuestService,
|
|
100
|
-
|
|
105
|
+
// Unguessable: a page call that names a viewer is told whether that viewer
|
|
106
|
+
// holds control, so the id works as the viewer's lease.
|
|
107
|
+
createViewerId = () => `viewer-${randomUUID()}`,
|
|
101
108
|
setTimer = setTimeout,
|
|
102
109
|
clearTimer = clearTimeout,
|
|
103
110
|
}) => {
|
|
@@ -109,13 +116,15 @@ export const createGuestSurfaceRuntime = ({
|
|
|
109
116
|
* id: string,
|
|
110
117
|
* ws: import('ws').WebSocket,
|
|
111
118
|
* sentSeq: number,
|
|
119
|
+
* sentServiceSeq: number,
|
|
120
|
+
* shownServiceSeq: number,
|
|
112
121
|
* awaitingAck: boolean,
|
|
113
122
|
* }} Viewer
|
|
114
123
|
* @typedef {{
|
|
115
124
|
* guestId: string,
|
|
116
125
|
* guest: object,
|
|
117
126
|
* viewers: Map<string, Viewer>,
|
|
118
|
-
* latest: { seq: number, width: number, height: number, mime: string, title?: string, agentActive: boolean, bytes: Buffer } | null,
|
|
127
|
+
* latest: { seq: number, serviceSeq: number, width: number, height: number, mime: string, title?: string, agentActive: boolean, bytes: Buffer } | null,
|
|
119
128
|
* controller: 'none' | 'agent' | string,
|
|
120
129
|
* agentTimer: ReturnType<typeof setTimeout> | null,
|
|
121
130
|
* pumpAbort: AbortController | null,
|
|
@@ -164,13 +173,13 @@ export const createGuestSurfaceRuntime = ({
|
|
|
164
173
|
return run;
|
|
165
174
|
};
|
|
166
175
|
|
|
167
|
-
const notifyService = async (session, controller) => {
|
|
176
|
+
const notifyService = async (session, controller, viewerId) => {
|
|
168
177
|
if (session.ended) return;
|
|
169
178
|
try {
|
|
170
179
|
const { response, finished } = await serviceRequest(session, {
|
|
171
180
|
method: 'POST',
|
|
172
181
|
path: SURFACE_CONTROL_PATH,
|
|
173
|
-
body: JSON.stringify({ controller }),
|
|
182
|
+
body: JSON.stringify(controller === 'user' && viewerId ? { controller, viewer: viewerId } : { controller }),
|
|
174
183
|
timeoutMs: QUICK_REQUEST_TIMEOUT_MS,
|
|
175
184
|
});
|
|
176
185
|
await response.arrayBuffer().catch(() => undefined);
|
|
@@ -186,7 +195,8 @@ export const createGuestSurfaceRuntime = ({
|
|
|
186
195
|
for (const viewer of session.viewers.values()) {
|
|
187
196
|
send(viewer, { type: 'control', controller: kind, mine: session.controller === viewer.id });
|
|
188
197
|
}
|
|
189
|
-
|
|
198
|
+
const holder = kind === 'user' ? session.controller : undefined;
|
|
199
|
+
void enqueue(session, () => notifyService(session, kind, holder));
|
|
190
200
|
};
|
|
191
201
|
|
|
192
202
|
const clearAgentTimer = (session) => {
|
|
@@ -208,6 +218,7 @@ export const createGuestSurfaceRuntime = ({
|
|
|
208
218
|
if (!frame || viewer.awaitingAck || viewer.sentSeq >= frame.seq || viewer.ws.readyState !== 1) return;
|
|
209
219
|
viewer.awaitingAck = true;
|
|
210
220
|
viewer.sentSeq = frame.seq;
|
|
221
|
+
viewer.sentServiceSeq = frame.serviceSeq;
|
|
211
222
|
const meta = {
|
|
212
223
|
type: 'frame',
|
|
213
224
|
seq: frame.seq,
|
|
@@ -260,6 +271,9 @@ export const createGuestSurfaceRuntime = ({
|
|
|
260
271
|
const title = (response.headers.get(SURFACE_TITLE_HEADER) || '').slice(0, SURFACE_TITLE_MAX);
|
|
261
272
|
const frame = {
|
|
262
273
|
seq,
|
|
274
|
+
// The service's own number, kept when `seq` is bumped below: input is
|
|
275
|
+
// stamped with it, and only the service knows which view it belongs to.
|
|
276
|
+
serviceSeq: seq,
|
|
263
277
|
width,
|
|
264
278
|
height,
|
|
265
279
|
mime,
|
|
@@ -357,7 +371,7 @@ export const createGuestSurfaceRuntime = ({
|
|
|
357
371
|
* control back: the user already let go, and re-taking it from a queued
|
|
358
372
|
* click would lock the agent out with nobody to press the button.
|
|
359
373
|
*/
|
|
360
|
-
const handleInput = async (session, viewer, events, handoff) => {
|
|
374
|
+
const handleInput = async (session, viewer, events, handoff, frameSeq) => {
|
|
361
375
|
if (viewer.gone) return;
|
|
362
376
|
if (session.controller !== viewer.id) {
|
|
363
377
|
if (session.controller !== 'none' && session.controller !== 'agent') {
|
|
@@ -373,10 +387,20 @@ export const createGuestSurfaceRuntime = ({
|
|
|
373
387
|
method: 'POST',
|
|
374
388
|
path: SURFACE_INPUT_PATH,
|
|
375
389
|
body: JSON.stringify({ events }),
|
|
390
|
+
headers: {
|
|
391
|
+
[SURFACE_VIEWER_HEADER]: viewer.id,
|
|
392
|
+
[SURFACE_FRAME_SEQ_HEADER]: String(frameSeq),
|
|
393
|
+
},
|
|
376
394
|
timeoutMs: QUICK_REQUEST_TIMEOUT_MS,
|
|
377
395
|
});
|
|
378
396
|
await response.arrayBuffer().catch(() => undefined);
|
|
379
397
|
finished();
|
|
398
|
+
// 409: made on a picture the service no longer shows. Nothing reached
|
|
399
|
+
// the page, and the next frame shows the viewer what is there now.
|
|
400
|
+
if (response.status === 409) {
|
|
401
|
+
send(viewer, { type: 'error', code: 'INPUT_STALE', message: 'The picture changed before this input arrived; it was not applied.' });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
380
404
|
if (response.status < 200 || response.status >= 300) {
|
|
381
405
|
send(viewer, { type: 'error', code: 'INPUT_REJECTED', message: `The extension refused this input (HTTP ${response.status}).` });
|
|
382
406
|
}
|
|
@@ -445,7 +469,8 @@ export const createGuestSurfaceRuntime = ({
|
|
|
445
469
|
const handleViewerMessage = (session, viewer, raw) => {
|
|
446
470
|
let parsed;
|
|
447
471
|
try {
|
|
448
|
-
|
|
472
|
+
// Node's `ws` delivers a Buffer, Bun's a Uint8Array; both decode here.
|
|
473
|
+
parsed = surfaceViewerMessageSchema.safeParse(JSON.parse(Buffer.from(raw).toString('utf8')));
|
|
449
474
|
} catch {
|
|
450
475
|
parsed = { success: false };
|
|
451
476
|
}
|
|
@@ -458,12 +483,17 @@ export const createGuestSurfaceRuntime = ({
|
|
|
458
483
|
case 'ack':
|
|
459
484
|
if (message.seq === viewer.sentSeq) {
|
|
460
485
|
viewer.awaitingAck = false;
|
|
486
|
+
// Acknowledged after drawing: this is the picture its next input is made on.
|
|
487
|
+
viewer.shownServiceSeq = viewer.sentServiceSeq;
|
|
461
488
|
deliverLatest(session, viewer);
|
|
462
489
|
}
|
|
463
490
|
return;
|
|
464
491
|
case 'input': {
|
|
465
492
|
const handoff = session.handoffs;
|
|
466
|
-
|
|
493
|
+
// Read on arrival, not when the queue reaches the batch: the socket
|
|
494
|
+
// delivers acks and input in the order the viewer drew and acted.
|
|
495
|
+
const frameSeq = viewer.shownServiceSeq;
|
|
496
|
+
void enqueue(session, () => handleInput(session, viewer, message.events, handoff, frameSeq));
|
|
467
497
|
return;
|
|
468
498
|
}
|
|
469
499
|
case 'release':
|
|
@@ -485,7 +515,7 @@ export const createGuestSurfaceRuntime = ({
|
|
|
485
515
|
|
|
486
516
|
const attachViewer = (guestId, guest, ws) => {
|
|
487
517
|
const session = ensureSession(guestId, guest);
|
|
488
|
-
const viewer = { id: createViewerId(), ws, sentSeq: 0, awaitingAck: false, gone: false };
|
|
518
|
+
const viewer = { id: createViewerId(), ws, sentSeq: 0, sentServiceSeq: 0, shownServiceSeq: 0, awaitingAck: false, gone: false };
|
|
489
519
|
session.viewers.set(viewer.id, viewer);
|
|
490
520
|
// Started with the first viewer in place: the pump's loop condition is
|
|
491
521
|
// the viewer count, and it is checked before the first await.
|
|
@@ -609,6 +639,22 @@ export const createGuestSurfaceRuntime = ({
|
|
|
609
639
|
if (session) endSession(session, 'extension-unavailable');
|
|
610
640
|
},
|
|
611
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Headers for a page `serviceRequest` made from the window that holds
|
|
644
|
+
* `viewerId`, or null when that id is not a live viewer of this
|
|
645
|
+
* extension (then the request goes out without any).
|
|
646
|
+
*/
|
|
647
|
+
viewerHeaders(guestId, viewerId) {
|
|
648
|
+
const session = sessions.get(guestId);
|
|
649
|
+
const viewer = session && !session.ended ? session.viewers.get(viewerId) : undefined;
|
|
650
|
+
if (!viewer || viewer.gone) return null;
|
|
651
|
+
return {
|
|
652
|
+
[SURFACE_VIEWER_HEADER]: viewer.id,
|
|
653
|
+
[SURFACE_VIEWER_CONTROLS_HEADER]: session.controller === viewer.id ? '1' : '0',
|
|
654
|
+
[SURFACE_FRAME_SEQ_HEADER]: String(viewer.shownServiceSeq),
|
|
655
|
+
};
|
|
656
|
+
},
|
|
657
|
+
|
|
612
658
|
/** Test seam. */
|
|
613
659
|
sessionOf(guestId) {
|
|
614
660
|
return sessions.get(guestId) ?? null;
|