@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.
Files changed (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  2. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  3. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-BmibZC7n.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-BNT2yUVu.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-D3Azksju.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-D_41H2qC.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-C0RSqa9W.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-Bqc-xuDe.js} +4 -4
  15. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  16. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DuC4ecLw.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-BJicp0RC.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  27. package/dist/assets/de-BMnfXm-G.js +10 -0
  28. package/dist/assets/es-DKEvCCL6.js +16 -0
  29. package/dist/assets/fr-D9fgE2xE.js +14 -0
  30. package/dist/assets/index-DUke1nme.css +1 -0
  31. package/dist/assets/{index-OKDYjcMF.js → index-DWB8bR_g.js} +1 -1
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-bt5VHvcd.js} +1 -1
  35. package/dist/assets/ja-BEXad-KW.js +16 -0
  36. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main-BpLQbZC2.js} +2 -2
  38. package/dist/assets/main-CI9DtHJv.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-CIWaR8wG.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-CnWKEYIP.js} +2 -2
  43. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  44. package/dist/assets/pl-BlhkmR5b.js +16 -0
  45. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  47. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-l-MbaNS4.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  52. package/dist/assets/tr-CehUK1M9.js +7 -0
  53. package/dist/assets/uk-Bno5WGNz.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-Bc1WPd4i.js} +1 -1
  61. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  62. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +3 -3
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -0,0 +1,582 @@
1
+ // The dispatcher against a stand-in for the server inside a space: an Express app with the real
2
+ // UI auth of the host, so the login, the cookie and the refusal of a bad token are the real ones.
3
+
4
+ import fs from 'node:fs';
5
+ import http from 'node:http';
6
+ import net from 'node:net';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ import express from 'express';
11
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
12
+
13
+ import { SpaceError } from './errors.js';
14
+ import { registerCommonRequestMiddleware } from '../opencode/core-routes.js';
15
+ import {
16
+ classifySpacePath,
17
+ createSpaceDispatcher,
18
+ isDirectoryOfSpace,
19
+ isSpaceDirectory,
20
+ isSpaceRequestPath,
21
+ parseSpaceRoute,
22
+ } from './dispatcher.js';
23
+
24
+ // The UI auth keeps its signing secret in the data directory, and reads that path when it loads.
25
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-dispatcher-test-'));
26
+ process.env.OPENCHAMBER_DATA_DIR = dataDir;
27
+
28
+ const ID = 'a1b2c3d4e5f6';
29
+ const OTHER = '0f0f0f0f0f0f';
30
+ const TOKEN = 'tok_' + 'a'.repeat(40);
31
+
32
+ const listen = (app) => new Promise((resolve) => {
33
+ const server = http.createServer(app);
34
+ server.listen(0, '127.0.0.1', () => resolve(server));
35
+ });
36
+ const close = (server) => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); });
37
+
38
+ /** The stand-in for the server inside: the host's own UI auth in front of a few routes that tell what they saw. */
39
+ const startInside = async ({ password = TOKEN } = {}) => {
40
+ const { createUiAuth } = await import('../ui-auth/ui-auth.js');
41
+ const auth = createUiAuth({ password, readSettingsFromDiskMigrated: async () => ({}) });
42
+ const seen = [];
43
+ const state = { logins: 0, refuseNext: 0, sseClosed: 0, sseRequests: [] };
44
+ const app = express();
45
+ app.post('/auth/session', express.json(), (req, res) => {
46
+ state.logins += 1;
47
+ return auth.handleSessionCreate(req, res);
48
+ });
49
+ app.use('/api', (req, res, next) => {
50
+ if (state.refuseNext > 0) {
51
+ state.refuseNext -= 1;
52
+ res.status(401).json({ error: 'UI authentication required', locked: true });
53
+ return;
54
+ }
55
+ next();
56
+ });
57
+ app.use('/api', auth.requireAuth);
58
+ app.use('/api', (req, _res, next) => {
59
+ seen.push({ method: req.method, url: req.url, headers: req.headers });
60
+ next();
61
+ });
62
+ app.all('/api/echo', (req, res) => {
63
+ let bytes = 0;
64
+ req.on('data', (chunk) => { bytes += chunk.length; });
65
+ req.on('end', () => res.json({ method: req.method, url: req.url, bytes, contentType: req.headers['content-type'] ?? null }));
66
+ });
67
+ app.post('/api/form', (req, res) => {
68
+ const chunks = [];
69
+ req.on('data', (chunk) => chunks.push(chunk));
70
+ req.on('end', () => res.json({ raw: Buffer.concat(chunks).toString('utf8') }));
71
+ });
72
+ app.get('/api/event', (req, res) => {
73
+ state.sseRequests.push(req);
74
+ res.setHeader('content-type', 'text/event-stream');
75
+ res.setHeader('cache-control', 'private, max-age=0');
76
+ res.flushHeaders();
77
+ res.write('data: first\n\n');
78
+ req.on('close', () => { state.sseClosed += 1; });
79
+ });
80
+ app.get('/api/big-event', (_req, res) => {
81
+ res.setHeader('content-type', 'text/event-stream');
82
+ for (let index = 0; index < 4096; index += 1) res.write(`data: ${'x'.repeat(1000)}\n\n`);
83
+ res.end();
84
+ });
85
+ app.get('/api/fs/raw', (req, res) => {
86
+ res.setHeader('content-type', String(req.query.type));
87
+ res.end('file');
88
+ });
89
+ app.get('/api/page', (_req, res) => {
90
+ res.setHeader('content-type', 'text/html; charset=utf-8');
91
+ res.send('<script>alert(1)</script>');
92
+ });
93
+ app.get('/api/cookie', (_req, res) => {
94
+ res.setHeader('set-cookie', 'planted=1; Path=/');
95
+ res.json({ ok: true });
96
+ });
97
+ app.get('/api/session/:id', (req, res) => res.json({ id: req.params.id }));
98
+ app.all('/api/redirect', (_req, res) => {
99
+ res.setHeader('location', '/api/session/host-session');
100
+ res.setHeader('clear-site-data', '"*"');
101
+ res.status(307).end();
102
+ });
103
+ app.get('/api/unchanged', (req, res) => {
104
+ if (req.headers['if-none-match'] === '"v1"') { res.status(304).end(); return; }
105
+ res.setHeader('etag', '"v1"');
106
+ res.json({ version: 1 });
107
+ });
108
+ app.get('/api/wipe', (_req, res) => {
109
+ res.setHeader('clear-site-data', '"*"');
110
+ res.setHeader('refresh', '0; url=/api/session');
111
+ res.setHeader('x-next-cursor', 'abc');
112
+ res.json({ ok: true });
113
+ });
114
+ app.get('/api/untyped', (_req, res) => {
115
+ res.removeHeader('content-type');
116
+ res.end('<script>alert(1)</script>');
117
+ });
118
+ app.all('/api/Config/settings', (_req, res) => res.json({ reached: 'inside' }));
119
+ const server = await listen(app);
120
+ return { server, port: server.address().port, seen, state, stop: () => close(server) };
121
+ };
122
+
123
+ /** A transport over a plain socket to the stand-in, with the calls counted. */
124
+ const transportTo = (currentInside, { ids = [ID], token = TOKEN } = {}) => {
125
+ const calls = { connect: 0, readToken: 0, list: 0 };
126
+ return {
127
+ calls,
128
+ ids,
129
+ listSpaceIds: async () => { calls.list += 1; return ids; },
130
+ connect: async (spaceId) => {
131
+ calls.connect += 1;
132
+ if (!ids.includes(spaceId)) throw new SpaceError('space_not_found', `Space ${spaceId} has no container`);
133
+ return net.connect({ host: '127.0.0.1', port: currentInside().port });
134
+ },
135
+ readToken: async () => { calls.readToken += 1; return token; },
136
+ };
137
+ };
138
+
139
+ /** The host: the dispatcher after a stand-in for the auth gate, and one host route that reads a directory. */
140
+ const startHost = async (dispatcher, { commonMiddleware = false } = {}) => {
141
+ const app = express();
142
+ if (commonMiddleware) {
143
+ registerCommonRequestMiddleware(app, { express, skipBodyParsing: (req) => isSpaceRequestPath(req.path) });
144
+ }
145
+ app.use('/api', (req, _res, next) => { req.authenticatedByHost = true; next(); });
146
+ app.use(dispatcher.middleware);
147
+ app.all('/api/host', (req, res) => res.json({ host: true, directory: req.query.directory ?? null, body: req.body ?? null }));
148
+ app.use((_req, res) => res.status(404).json({ error: 'not found on host' }));
149
+ const server = await listen(app);
150
+ return { server, port: server.address().port, url: (suffix) => `http://127.0.0.1:${server.address().port}${suffix}`, stop: () => close(server) };
151
+ };
152
+
153
+ const logs = [];
154
+ const logger = { warn: (line) => logs.push(line), error: (line) => logs.push(line) };
155
+
156
+ describe('space routes and directories', () => {
157
+ it('parses the prefix and the space id, and nothing shorter', () => {
158
+ expect(parseSpaceRoute(`/api/spaces/${ID}/session`)).toEqual({ spaceId: ID, path: '/api/session' });
159
+ expect(parseSpaceRoute(`/api/spaces/${ID}/fs/raw`)).toEqual({ spaceId: ID, path: '/api/fs/raw' });
160
+ expect(parseSpaceRoute(`/api/spaces/${ID}`)).toBeNull();
161
+ expect(parseSpaceRoute(`/api/spaces/${ID}/`)).toEqual({ spaceId: ID, path: '/api/' });
162
+ expect(parseSpaceRoute('/api/spaces//x')).toBeNull();
163
+ expect(parseSpaceRoute('/api/session')).toBeNull();
164
+ expect(isSpaceRequestPath(`/api/spaces/${ID}/x`)).toBe(true);
165
+ });
166
+
167
+ it('knows which directories belong to spaces, and to which space', () => {
168
+ expect(isSpaceDirectory(`/spaces/${ID}/repo`)).toBe(true);
169
+ expect(isSpaceDirectory('/spaces')).toBe(true);
170
+ expect(isSpaceDirectory('/spaces/')).toBe(true);
171
+ expect(isSpaceDirectory('//spaces//x')).toBe(true);
172
+ expect(isSpaceDirectory('/home/me/spaces/x')).toBe(false);
173
+ expect(isSpaceDirectory('/spacesx')).toBe(false);
174
+ expect(isSpaceDirectory('')).toBe(false);
175
+ expect(isDirectoryOfSpace(`/spaces/${ID}`, ID)).toBe(true);
176
+ expect(isDirectoryOfSpace(`/spaces/${ID}/repo/src`, ID)).toBe(true);
177
+ expect(isDirectoryOfSpace(`/spaces/${OTHER}/repo`, ID)).toBe(false);
178
+ expect(isDirectoryOfSpace(`/spaces/${ID}/../${OTHER}`, ID)).toBe(false);
179
+ expect(isDirectoryOfSpace(`/spaces/${ID}x`, ID)).toBe(false);
180
+ expect(isDirectoryOfSpace('/home/me', ID)).toBe(false);
181
+ });
182
+
183
+ it('sorts the paths inside by rule', () => {
184
+ expect(classifySpacePath('/api/config/settings')).toBe('host_only');
185
+ expect(classifySpacePath('/api/provider')).toBe('host_only');
186
+ expect(classifySpacePath('/api/projects/abc/icon')).toBe('host_only');
187
+ expect(classifySpacePath('/api/fs/serve/index.html')).toBe('refused_across_boundary');
188
+ expect(classifySpacePath('/api/preview/proxy/3000/')).toBe('refused_across_boundary');
189
+ expect(classifySpacePath('/api/git/worktrees')).toBe('refused_across_boundary');
190
+ expect(classifySpacePath('/api/git/integrate/merge')).toBe('refused_across_boundary');
191
+ expect(classifySpacePath('/api/session/ses_1/move')).toBe('refused_across_boundary');
192
+ // Express inside routes without regard to case.
193
+ expect(classifySpacePath('/api/Config/Settings')).toBe('host_only');
194
+ expect(classifySpacePath('/api/FS/serve/x')).toBe('refused_across_boundary');
195
+ expect(classifySpacePath('/api/Session/ses_1/Move')).toBe('refused_across_boundary');
196
+ expect(classifySpacePath('/api/session')).toBeNull();
197
+ expect(classifySpacePath('/api/git/status')).toBeNull();
198
+ expect(classifySpacePath('/api/fs/raw')).toBeNull();
199
+ expect(classifySpacePath('/api/config/agents')).toBeNull();
200
+ });
201
+ });
202
+
203
+ describe('space dispatcher', () => {
204
+ let inside;
205
+ let host;
206
+ let dispatcher;
207
+ let transport;
208
+
209
+ const start = async (options = {}) => {
210
+ inside = await startInside(options.inside);
211
+ transport = transportTo(() => inside, options.transport);
212
+ dispatcher = createSpaceDispatcher({ transport, logger, now: options.now });
213
+ host = await startHost(dispatcher, options.host);
214
+ };
215
+
216
+ afterEach(async () => {
217
+ dispatcher?.close();
218
+ await host?.stop();
219
+ await inside?.stop();
220
+ inside = host = dispatcher = transport = undefined;
221
+ logs.length = 0;
222
+ });
223
+
224
+ afterAll(() => {
225
+ fs.rmSync(dataDir, { recursive: true, force: true });
226
+ });
227
+
228
+ it('forwards a prefixed request with the prefix and the user\'s credentials removed, and the space session added', async () => {
229
+ await start();
230
+ const response = await fetch(host.url(`/api/spaces/${ID}/echo?a=1&oc_url_token=secret-url&b=2&oc_client_token=secret-client`), {
231
+ headers: { cookie: 'oc_ui_session_3000=user-cookie', authorization: 'Bearer user-bearer', 'x-custom': 'kept', origin: 'https://app.example' },
232
+ });
233
+
234
+ expect(response.status).toBe(200);
235
+ expect(await response.json()).toMatchObject({ method: 'GET', url: '/api/echo?a=1&b=2', bytes: 0 });
236
+ expect(inside.seen).toHaveLength(1);
237
+ const [request] = inside.seen;
238
+ expect(request.headers.authorization).toBeUndefined();
239
+ expect(request.headers.origin).toBeUndefined();
240
+ expect(request.headers.cookie).toMatch(/^oc_ui_session_27600=[^;]+$/);
241
+ expect(request.headers.cookie).not.toContain('user-cookie');
242
+ expect(request.headers.host).toBe('127.0.0.1:27600');
243
+ expect(request.headers['x-custom']).toBe('kept');
244
+ expect(request.url).not.toContain('secret');
245
+ expect(inside.state.logins).toBe(1);
246
+ expect(transport.calls.readToken).toBe(1);
247
+ });
248
+
249
+ it('logs in once per space and reuses the session and the connection', async () => {
250
+ await start();
251
+ const answers = await Promise.all(Array.from({ length: 6 }, (_, index) => fetch(host.url(`/api/spaces/${ID}/echo?n=${index}`))));
252
+ for (const answer of answers) expect(answer.status).toBe(200);
253
+ await Promise.all(answers.map((answer) => answer.text()));
254
+ const again = await fetch(host.url(`/api/spaces/${ID}/echo?n=again`));
255
+ expect(again.status).toBe(200);
256
+ await again.text();
257
+
258
+ expect(inside.state.logins).toBe(1);
259
+ expect(transport.calls.readToken).toBe(1);
260
+ // Fewer connections than requests: the agent keeps them.
261
+ expect(transport.calls.connect).toBeLessThan(8);
262
+ });
263
+
264
+ it('streams a request body into the space without buffering it', async () => {
265
+ await start();
266
+ const size = 3 * 1024 * 1024;
267
+ const response = await fetch(host.url(`/api/spaces/${ID}/echo`), {
268
+ method: 'POST',
269
+ headers: { 'content-type': 'application/octet-stream' },
270
+ body: Buffer.alloc(size, 7),
271
+ });
272
+ expect(response.status).toBe(200);
273
+ expect(await response.json()).toMatchObject({ method: 'POST', bytes: size, contentType: 'application/octet-stream' });
274
+ });
275
+
276
+ it('lets a form-encoded body reach the space untouched when the host parsers are told to leave it alone', async () => {
277
+ await start({ host: { commonMiddleware: true } });
278
+ const response = await fetch(host.url(`/api/spaces/${ID}/form`), {
279
+ method: 'POST',
280
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
281
+ body: 'a=1&b=two%20words',
282
+ });
283
+ expect(response.status).toBe(200);
284
+ expect(await response.json()).toEqual({ raw: 'a=1&b=two%20words' });
285
+ });
286
+
287
+ it('streams an event stream as it arrives, and closes the stream inside when the client leaves', async () => {
288
+ await start();
289
+ const controller = new AbortController();
290
+ const response = await fetch(host.url(`/api/spaces/${ID}/event`), { signal: controller.signal });
291
+ expect(response.status).toBe(200);
292
+ expect(response.headers.get('content-type')).toContain('text/event-stream');
293
+ expect(response.headers.get('cache-control')).toBe('no-cache');
294
+ expect(response.headers.get('x-accel-buffering')).toBe('no');
295
+
296
+ const reader = response.body.getReader();
297
+ const decoder = new TextDecoder();
298
+ const first = await reader.read();
299
+ expect(decoder.decode(first.value)).toBe('data: first\n\n');
300
+ // The stream inside is still open: the second event has not been sent yet.
301
+ const [request] = inside.state.sseRequests;
302
+ request.res.write('data: second\n\n');
303
+ const second = await reader.read();
304
+ expect(decoder.decode(second.value)).toBe('data: second\n\n');
305
+
306
+ controller.abort();
307
+ await new Promise((resolve) => { setTimeout(resolve, 100); });
308
+ expect(inside.state.sseClosed).toBe(1);
309
+ });
310
+
311
+ it('carries a large event stream whole', async () => {
312
+ await start();
313
+ const response = await fetch(host.url(`/api/spaces/${ID}/big-event`));
314
+ expect(response.status).toBe(200);
315
+ const text = await response.text();
316
+ expect(text.length).toBe(4096 * ('data: '.length + 1000 + 2));
317
+ expect(text.endsWith('\n\n')).toBe(true);
318
+ });
319
+
320
+ // Guard one.
321
+ it('refuses a prefixed request that names a directory outside the space', async () => {
322
+ await start();
323
+ for (const suffix of [
324
+ '/echo?directory=/home/me/project',
325
+ `/echo?directory=/spaces/${OTHER}/repo`,
326
+ `/echo?directory=/spaces/${ID}/../${OTHER}/repo`,
327
+ `/echo?location%5Bdirectory%5D=/home/me`,
328
+ `/echo?directory=/spaces/${ID}/repo&directory=/home/me`,
329
+ ]) {
330
+ const response = await fetch(host.url(`/api/spaces/${ID}${suffix}`));
331
+ expect(response.status, suffix).toBe(400);
332
+ expect(await response.json()).toMatchObject({ code: 'directory_outside_space' });
333
+ }
334
+ const byHeader = await fetch(host.url(`/api/spaces/${ID}/echo`), { headers: { 'x-opencode-directory': '/home/me' } });
335
+ expect(byHeader.status).toBe(400);
336
+ const encoded = await fetch(host.url(`/api/spaces/${ID}/echo`), { headers: { 'x-opencode-directory': encodeURIComponent('/home/me'), 'x-opencode-directory-encoding': 'uri' } });
337
+ expect(encoded.status).toBe(400);
338
+ const sdkStyleOutside = await fetch(host.url(`/api/spaces/${ID}/echo`), { headers: { 'x-opencode-directory': encodeURIComponent('/home/me') } });
339
+ expect(sdkStyleOutside.status).toBe(400);
340
+ expect(inside.seen).toHaveLength(0);
341
+ // The SDK encodes the header on every request and sends no marker; OpenCode decodes it, so the guard reads it decoded too.
342
+ const sdkStyle = await fetch(host.url(`/api/spaces/${ID}/echo`), { headers: { 'x-opencode-directory': encodeURIComponent(`/spaces/${ID}/repo`) } });
343
+ expect(sdkStyle.status).toBe(200);
344
+ expect(inside.seen).toHaveLength(1);
345
+
346
+ const inside_ok = await fetch(host.url(`/api/spaces/${ID}/echo?directory=${encodeURIComponent(`/spaces/${ID}/repo`)}`), { headers: { 'x-opencode-directory': `/spaces/${ID}/repo/src` } });
347
+ expect(inside_ok.status).toBe(200);
348
+ expect(inside.seen).toHaveLength(2);
349
+ });
350
+
351
+ // Guard two.
352
+ it('refuses an unprefixed request that names a space directory, before any host route runs', async () => {
353
+ await start();
354
+ for (const suffix of [`/api/host?directory=/spaces/${ID}/repo`, '/api/host?directory=/spaces', `/api/host?location%5Bdirectory%5D=/spaces/${ID}`]) {
355
+ const response = await fetch(host.url(suffix), { method: 'POST' });
356
+ expect(response.status, suffix).toBe(400);
357
+ expect(await response.json()).toMatchObject({ code: 'space_directory_needs_prefix' });
358
+ }
359
+ const byHeader = await fetch(host.url('/api/host'), { headers: { 'x-opencode-directory': `/spaces/${ID}/repo` } });
360
+ expect(byHeader.status).toBe(400);
361
+ // An SDK-style header, URI-encoded with no marker, names the same directory to OpenCode.
362
+ const byEncodedHeader = await fetch(host.url('/api/host'), { headers: { 'x-opencode-directory': encodeURIComponent(`/spaces/${ID}/repo`) } });
363
+ expect(byEncodedHeader.status).toBe(400);
364
+
365
+ const ordinary = await fetch(host.url('/api/host?directory=/home/me/spaces/project'));
366
+ expect(ordinary.status).toBe(200);
367
+ expect(await ordinary.json()).toMatchObject({ host: true, directory: '/home/me/spaces/project' });
368
+ expect(inside.seen).toHaveLength(0);
369
+ });
370
+
371
+ it('refuses host-only routes and the routes that do not cross the boundary, without asking the space', async () => {
372
+ await start();
373
+ const cases = [
374
+ ['/config/settings', 'host_only_route'],
375
+ ['/provider', 'host_only_route'],
376
+ ['/github/repos', 'host_only_route'],
377
+ ['/projects/p1/icon', 'host_only_route'],
378
+ ['/fs/serve/index.html', 'refused_across_boundary'],
379
+ ['/preview/proxy/3000/', 'refused_across_boundary'],
380
+ ['/git/worktrees', 'refused_across_boundary'],
381
+ ['/git/integrate/merge', 'refused_across_boundary'],
382
+ ['/session/ses_1/move', 'refused_across_boundary'],
383
+ ['/Config/settings', 'host_only_route'],
384
+ ['/Preview/proxy/3000/', 'refused_across_boundary'],
385
+ ];
386
+ for (const [suffix, code] of cases) {
387
+ const response = await fetch(host.url(`/api/spaces/${ID}${suffix}`), { method: 'POST' });
388
+ expect(response.status, suffix).toBe(403);
389
+ expect(await response.json()).toMatchObject({ code });
390
+ }
391
+ expect(inside.seen).toHaveLength(0);
392
+ expect(transport.calls.connect).toBe(0);
393
+ // Its neighbours are forwarded.
394
+ expect((await fetch(host.url(`/api/spaces/${ID}/session/ses_1`))).status).toBe(200);
395
+ });
396
+
397
+ it('answers 404 for a space the runtime does not list, and finds one that appears', async () => {
398
+ await start();
399
+ for (const spaceId of [OTHER, 'not-an-id', 'A1B2C3D4E5F6']) {
400
+ const response = await fetch(host.url(`/api/spaces/${spaceId}/echo`));
401
+ expect(response.status, spaceId).toBe(404);
402
+ expect(await response.json()).toMatchObject({ code: 'space_not_found' });
403
+ }
404
+ expect(transport.calls.connect).toBe(0);
405
+
406
+ transport.ids.push(OTHER);
407
+ await new Promise((resolve) => { setTimeout(resolve, 600); });
408
+ const found = await fetch(host.url(`/api/spaces/${OTHER}/echo`));
409
+ expect(found.status).toBe(200);
410
+ });
411
+
412
+ it('lets no cookie through from a space, marks every answer nosniff, and serves a page as text', async () => {
413
+ await start();
414
+ const cookie = await fetch(host.url(`/api/spaces/${ID}/cookie`));
415
+ expect(cookie.status).toBe(200);
416
+ expect(cookie.headers.get('set-cookie')).toBeNull();
417
+ expect(cookie.headers.get('x-content-type-options')).toBe('nosniff');
418
+
419
+ const page = await fetch(host.url(`/api/spaces/${ID}/page`));
420
+ expect(page.headers.get('content-type')).toBe('text/plain; charset=utf-8');
421
+ expect(await page.text()).toBe('<script>alert(1)</script>');
422
+ });
423
+
424
+ it('passes a 304 through: a browser revalidating its own copy is not a redirect', async () => {
425
+ await start();
426
+ const first = await fetch(host.url(`/api/spaces/${ID}/unchanged`));
427
+ expect(first.status).toBe(200);
428
+ expect(first.headers.get('etag')).toBe('"v1"');
429
+ const again = await fetch(host.url(`/api/spaces/${ID}/unchanged`), { headers: { 'if-none-match': '"v1"' } });
430
+ expect(again.status).toBe(304);
431
+ expect(await again.text()).toBe('');
432
+ });
433
+
434
+ it('lets no redirect and no origin-acting header out of a space', async () => {
435
+ await start();
436
+ for (const method of ['GET', 'POST', 'DELETE']) {
437
+ const redirected = await fetch(host.url(`/api/spaces/${ID}/redirect`), { method, redirect: 'manual', ...(method === 'POST' ? { body: 'x' } : {}) });
438
+ expect(redirected.status, method).toBe(502);
439
+ expect(redirected.headers.get('location')).toBeNull();
440
+ expect(redirected.headers.get('clear-site-data')).toBeNull();
441
+ expect(await redirected.json()).toMatchObject({ code: 'space_redirected' });
442
+ }
443
+ const wiped = await fetch(host.url(`/api/spaces/${ID}/wipe`));
444
+ expect(wiped.status).toBe(200);
445
+ expect(wiped.headers.get('clear-site-data')).toBeNull();
446
+ expect(wiped.headers.get('refresh')).toBeNull();
447
+ expect(wiped.headers.get('x-next-cursor')).toBe('abc');
448
+ expect(wiped.headers.get('content-type')).toContain('application/json');
449
+
450
+ const untyped = await fetch(host.url(`/api/spaces/${ID}/untyped`));
451
+ expect(untyped.headers.get('content-type')).toBe('text/plain; charset=utf-8');
452
+ expect(await untyped.text()).toBe('<script>alert(1)</script>');
453
+ });
454
+
455
+ it('answers a stopped space with its code while a large body is still on its way in', async () => {
456
+ await start();
457
+ transport.connect = async () => { throw new SpaceError('space_not_running', `Space ${ID} is stopped. Start it, then try again.`); };
458
+ // A client that has sent a third of its body and is still sending when the space refuses.
459
+ const answer = await new Promise((resolve, reject) => {
460
+ const request = http.request({ host: '127.0.0.1', port: host.port, method: 'POST', path: `/api/spaces/${ID}/echo`, headers: { 'content-type': 'application/octet-stream', 'content-length': String(3 * 1024 * 1024) } }, (response) => {
461
+ const chunks = [];
462
+ response.on('data', (chunk) => chunks.push(chunk));
463
+ response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }));
464
+ });
465
+ request.on('error', reject);
466
+ request.write(Buffer.alloc(1024 * 1024, 5));
467
+ });
468
+ expect(answer.status).toBe(503);
469
+ expect(answer.body).toMatchObject({ code: 'space_not_running' });
470
+ });
471
+
472
+ it('serves a raw file from a space as an image or as a download, never as a page', async () => {
473
+ await start();
474
+ const image = await fetch(host.url(`/api/spaces/${ID}/fs/raw?type=${encodeURIComponent('image/png')}`));
475
+ expect(image.headers.get('content-type')).toBe('image/png');
476
+ expect(image.headers.get('content-disposition')).toBeNull();
477
+
478
+ for (const type of ['text/html', 'image/svg+xml', 'application/javascript', 'application/pdf', 'text/plain']) {
479
+ const other = await fetch(host.url(`/api/spaces/${ID}/fs/raw?type=${encodeURIComponent(type)}`));
480
+ expect(other.headers.get('content-type'), type).toBe('application/octet-stream');
481
+ expect(other.headers.get('content-disposition'), type).toBe('attachment');
482
+ expect(other.headers.get('x-content-type-options')).toBe('nosniff');
483
+ }
484
+ });
485
+
486
+ it('logs in again when the server inside refuses the session, and repeats a request that has no body', async () => {
487
+ await start();
488
+ expect((await fetch(host.url(`/api/spaces/${ID}/echo`))).status).toBe(200);
489
+ inside.state.refuseNext = 1;
490
+ const repeated = await fetch(host.url(`/api/spaces/${ID}/echo?second=1`));
491
+ expect(repeated.status).toBe(200);
492
+ expect(inside.state.logins).toBe(2);
493
+ // The token is read again for that login: the space may have been made again.
494
+ expect(transport.calls.readToken).toBe(2);
495
+ });
496
+
497
+ it('does not repeat a request with a body after a refused session, and says so with a code', async () => {
498
+ await start();
499
+ expect((await fetch(host.url(`/api/spaces/${ID}/echo`))).status).toBe(200);
500
+ inside.state.refuseNext = 1;
501
+ const refused = await fetch(host.url(`/api/spaces/${ID}/echo`), { method: 'POST', headers: { 'content-type': 'text/plain' }, body: 'once' });
502
+ expect(refused.status).toBe(503);
503
+ expect(await refused.json()).toMatchObject({ code: 'space_session_expired' });
504
+ // The next request works without another refusal.
505
+ const next = await fetch(host.url(`/api/spaces/${ID}/echo`), { method: 'POST', headers: { 'content-type': 'text/plain' }, body: 'twice' });
506
+ expect(next.status).toBe(200);
507
+ expect(await next.json()).toMatchObject({ bytes: 5 });
508
+ });
509
+
510
+ it('reads the token again when a login with the remembered one is refused', async () => {
511
+ let clock = 1_000_000;
512
+ await start({ now: () => clock });
513
+ expect((await fetch(host.url(`/api/spaces/${ID}/echo`))).status).toBe(200);
514
+ expect(transport.calls.readToken).toBe(1);
515
+ // The server inside comes back with a new token, as after the agent rewrote the file and the
516
+ // server restarted on it; the host still remembers the old one and has no session left.
517
+ const previous = inside;
518
+ const rotated = 'tok_' + 'c'.repeat(40);
519
+ inside = await startInside({ password: rotated });
520
+ await previous.stop();
521
+ transport.readToken = async () => { transport.calls.readToken += 1; return rotated; };
522
+ // The pooled connections to the old server are gone; the remembered token is not.
523
+ await new Promise((resolve) => { setTimeout(resolve, 50); });
524
+ clock += 11 * 60 * 60 * 1000;
525
+
526
+ const response = await fetch(host.url(`/api/spaces/${ID}/echo?rotated=1`));
527
+ expect(response.status).toBe(200);
528
+ expect(transport.calls.readToken).toBe(2);
529
+ expect(inside.state.logins).toBe(2);
530
+ });
531
+
532
+ it('renews the session before it expires', async () => {
533
+ let clock = 1_000_000;
534
+ await start({ now: () => clock });
535
+ expect((await fetch(host.url(`/api/spaces/${ID}/echo`))).status).toBe(200);
536
+ clock += 11 * 60 * 60 * 1000;
537
+ expect((await fetch(host.url(`/api/spaces/${ID}/echo`))).status).toBe(200);
538
+ expect(inside.state.logins).toBe(2);
539
+ });
540
+
541
+ it('turns a refusal of the place into a stable answer', async () => {
542
+ await start();
543
+ transport.connect = async () => { throw new SpaceError('space_not_running', `Space ${ID} is stopped. Start it, then try again.`); };
544
+ const stopped = await fetch(host.url(`/api/spaces/${ID}/echo`));
545
+ expect(stopped.status).toBe(503);
546
+ expect(await stopped.json()).toMatchObject({ code: 'space_not_running', error: expect.stringMatching(/stopped/) });
547
+
548
+ transport.connect = async () => net.connect({ host: '127.0.0.1', port: 1 });
549
+ const unreachable = await fetch(host.url(`/api/spaces/${ID}/echo`));
550
+ expect(unreachable.status).toBe(502);
551
+ expect(await unreachable.json()).toMatchObject({ code: 'space_unreachable' });
552
+ });
553
+
554
+ it('sends a request without a body once more when a pooled stream has died under it', async () => {
555
+ await start();
556
+ expect((await fetch(host.url(`/api/spaces/${ID}/echo`))).status).toBe(200);
557
+ // The stand-in closes its side of the pooled connection, as the server inside does when it is
558
+ // stopped or when its keep-alive runs out; the next request meets the dead stream first.
559
+ inside.server.closeAllConnections();
560
+ await new Promise((resolve) => { setTimeout(resolve, 50); });
561
+ const again = await fetch(host.url(`/api/spaces/${ID}/echo?again=1`));
562
+ expect(again.status).toBe(200);
563
+ expect(await again.json()).toMatchObject({ url: '/api/echo?again=1' });
564
+ });
565
+
566
+ it('keeps the space token out of every answer and every log line when the login fails', async () => {
567
+ await start({ transport: { token: 'wrong_' + 'b'.repeat(40) } });
568
+ const response = await fetch(host.url(`/api/spaces/${ID}/echo`));
569
+ expect(response.status).toBe(502);
570
+ const body = await response.text();
571
+ expect(JSON.parse(body)).toMatchObject({ code: 'space_login_failed' });
572
+ expect(body).not.toContain('wrong_');
573
+ expect(logs.join('\n')).not.toContain('wrong_');
574
+ expect(logs.join('\n')).toContain('space_login_failed');
575
+
576
+ transport.readToken = async () => { throw new SpaceError('space_token_unreadable', 'The token file of the server inside the space is empty. Something inside the space changed it.'); };
577
+ dispatcher.close();
578
+ const unreadable = await fetch(host.url(`/api/spaces/${ID}/echo`));
579
+ expect(unreadable.status).toBe(502);
580
+ expect(await unreadable.json()).toMatchObject({ code: 'space_token_unreadable' });
581
+ });
582
+ });