@bugmole/cli 0.4.0

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 (308) hide show
  1. package/.bugmole.env.example +20 -0
  2. package/LICENSE +7 -0
  3. package/README.md +293 -0
  4. package/TESTING.md +117 -0
  5. package/bugmole.config.yaml +39 -0
  6. package/package.json +85 -0
  7. package/scripts/billing/paypal-setup.mjs +121 -0
  8. package/scripts/bugmole-continue.ts +318 -0
  9. package/scripts/bugmole-init.ts +188 -0
  10. package/scripts/bugmole.cjs +17 -0
  11. package/scripts/bugmole.test.ts +344 -0
  12. package/scripts/bugmole.ts +657 -0
  13. package/scripts/ensure-maestro.cjs +79 -0
  14. package/scripts/ios-tunnel-keeper.sh +45 -0
  15. package/scripts/ios-wda-keeper.sh +66 -0
  16. package/scripts/sync-plan-catalog.d.mts +3 -0
  17. package/scripts/sync-plan-catalog.mjs +16 -0
  18. package/scripts/ui-parity-diff.py +65 -0
  19. package/scripts/ui-parity-requirements.txt +1 -0
  20. package/scripts/verify-manage-to-plans.mts +194 -0
  21. package/spec/app-ui-audit.schema.json +176 -0
  22. package/spec/blockers.yaml +79 -0
  23. package/spec/bugs.index.json +42 -0
  24. package/spec/design-dna.schema.json +38 -0
  25. package/spec/domain_rules.yaml +24 -0
  26. package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
  27. package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
  28. package/spec/journey_graph.yaml +126 -0
  29. package/spec/journeys.graph.json +2618 -0
  30. package/spec/plans/dashboard-smoke.flow.yaml +20 -0
  31. package/spec/plans/example.flow.yaml +99 -0
  32. package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
  33. package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
  34. package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
  35. package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
  36. package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
  37. package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
  38. package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
  39. package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
  40. package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
  41. package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
  42. package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
  43. package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
  44. package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
  45. package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
  46. package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
  47. package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
  48. package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
  49. package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
  50. package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
  51. package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
  52. package/spec/plans/login.flow.yaml +22 -0
  53. package/spec/plans/owner-operations.flow.yaml +20 -0
  54. package/spec/project_config.yaml +55 -0
  55. package/spec/roles.yaml +30 -0
  56. package/spec/schema.md +394 -0
  57. package/spec/test-case-results.schema.json +62 -0
  58. package/spec/test-cases.schema.json +85 -0
  59. package/spec/ui-parity-audit.schema.json +194 -0
  60. package/spec/ui-reverse-engineering.schema.json +94 -0
  61. package/src/billing/plan-catalog.test.ts +46 -0
  62. package/src/billing/plan-catalog.ts +199 -0
  63. package/src/integrations/aws-sigv4.test.ts +42 -0
  64. package/src/integrations/aws-sigv4.ts +72 -0
  65. package/src/integrations/device-farm.ts +155 -0
  66. package/src/integrations/github-app.test.ts +57 -0
  67. package/src/integrations/github-app.ts +143 -0
  68. package/src/integrations/gitlab.ts +81 -0
  69. package/src/integrations/temp-email.test.ts +123 -0
  70. package/src/integrations/temp-email.ts +175 -0
  71. package/src/integrations/testflight-feedback.test.ts +51 -0
  72. package/src/integrations/testflight-feedback.ts +173 -0
  73. package/src/integrations/webdriver-client.ts +131 -0
  74. package/src/mcp/server.test.ts +1220 -0
  75. package/src/mcp/server.ts +3064 -0
  76. package/src/mcp/write-test-cases.test.ts +287 -0
  77. package/src/registry/api-key-client.ts +39 -0
  78. package/src/registry/control-plane-client.ts +212 -0
  79. package/src/registry/migrations/0001_registry.sql +47 -0
  80. package/src/registry/migrations/0002_device_authorizations.sql +23 -0
  81. package/src/registry/migrations/0003_project_environments.sql +25 -0
  82. package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
  83. package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
  84. package/src/registry/migrations/0006_workspaces.sql +36 -0
  85. package/src/registry/migrations/0007_project_apps.sql +26 -0
  86. package/src/registry/migrations/0008_agent_tasks.sql +30 -0
  87. package/src/registry/migrations/0009_journey_revisions.sql +17 -0
  88. package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
  89. package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
  90. package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
  91. package/src/registry/migrations/0013_device_sessions.sql +22 -0
  92. package/src/registry/migrations/0014_agent_task_step.sql +1 -0
  93. package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
  94. package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
  95. package/src/registry/migrations/0017_run_targets.sql +7 -0
  96. package/src/registry/migrations/0018_run_fix_from.sql +4 -0
  97. package/src/registry/migrations/0019_workspace_flags.sql +9 -0
  98. package/src/registry/migrations/0020_orgs.sql +40 -0
  99. package/src/registry/migrations/0021_billing_core.sql +58 -0
  100. package/src/registry/migrations/0022_cloud_runners.sql +19 -0
  101. package/src/registry/migrations/0023_signup.sql +4 -0
  102. package/src/registry/migrations/0024_billing.sql +67 -0
  103. package/src/registry/migrations/0025_notifications.sql +47 -0
  104. package/src/registry/migrations/0026_repo_bindings.sql +28 -0
  105. package/src/registry/migrations/0027_feedback.sql +29 -0
  106. package/src/registry/migrations/0028_devices.sql +48 -0
  107. package/src/registry/migrations/0029_sso.sql +31 -0
  108. package/src/registry/migrations/0030_workspace_domains.sql +18 -0
  109. package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
  110. package/src/registry/migrations/0032_personas.sql +15 -0
  111. package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
  112. package/src/registry/task-scheduling.test.ts +100 -0
  113. package/src/registry/task-scheduling.ts +80 -0
  114. package/src/registry-worker/ai/platform-model.ts +77 -0
  115. package/src/registry-worker/ai/routes.test.ts +88 -0
  116. package/src/registry-worker/ai/routes.ts +90 -0
  117. package/src/registry-worker/artifacts.test.ts +98 -0
  118. package/src/registry-worker/artifacts.ts +85 -0
  119. package/src/registry-worker/billing/billing-core.test.ts +175 -0
  120. package/src/registry-worker/billing/checkout-routes.ts +209 -0
  121. package/src/registry-worker/billing/enforcement.ts +69 -0
  122. package/src/registry-worker/billing/entitlements.ts +108 -0
  123. package/src/registry-worker/billing/ledger.ts +186 -0
  124. package/src/registry-worker/billing/paypal/api.ts +259 -0
  125. package/src/registry-worker/billing/paypal/client.ts +91 -0
  126. package/src/registry-worker/billing/paypal/provider.ts +143 -0
  127. package/src/registry-worker/billing/paypal.test.ts +466 -0
  128. package/src/registry-worker/billing/provider.ts +114 -0
  129. package/src/registry-worker/billing/routes.ts +66 -0
  130. package/src/registry-worker/billing/subscriptions.ts +780 -0
  131. package/src/registry-worker/billing/thresholds.ts +107 -0
  132. package/src/registry-worker/core.ts +308 -0
  133. package/src/registry-worker/devices/devices.test.ts +185 -0
  134. package/src/registry-worker/devices/policy.ts +71 -0
  135. package/src/registry-worker/devices/routes.ts +453 -0
  136. package/src/registry-worker/domains/domains.test.ts +210 -0
  137. package/src/registry-worker/domains/routes.ts +139 -0
  138. package/src/registry-worker/domains.ts +88 -0
  139. package/src/registry-worker/email/sender.ts +75 -0
  140. package/src/registry-worker/env.d.ts +14716 -0
  141. package/src/registry-worker/features.ts +20 -0
  142. package/src/registry-worker/feedback/feedback.test.ts +230 -0
  143. package/src/registry-worker/feedback/format.ts +148 -0
  144. package/src/registry-worker/feedback/routes.ts +386 -0
  145. package/src/registry-worker/flags.ts +39 -0
  146. package/src/registry-worker/github/checks.test.ts +177 -0
  147. package/src/registry-worker/github/checks.ts +374 -0
  148. package/src/registry-worker/gitlab/checks.test.ts +141 -0
  149. package/src/registry-worker/gitlab/checks.ts +349 -0
  150. package/src/registry-worker/hooks.ts +54 -0
  151. package/src/registry-worker/index.ts +2077 -0
  152. package/src/registry-worker/jobs/index.ts +29 -0
  153. package/src/registry-worker/jobs/retention.ts +68 -0
  154. package/src/registry-worker/mcp/mcp.test.ts +355 -0
  155. package/src/registry-worker/mcp/routes.ts +215 -0
  156. package/src/registry-worker/mcp/token.ts +126 -0
  157. package/src/registry-worker/mcp/tools.ts +563 -0
  158. package/src/registry-worker/notifications/alerts.ts +212 -0
  159. package/src/registry-worker/notifications/notifications.test.ts +298 -0
  160. package/src/registry-worker/notifications/outbox.ts +83 -0
  161. package/src/registry-worker/notifications/routes.ts +280 -0
  162. package/src/registry-worker/notifications/secrets.ts +49 -0
  163. package/src/registry-worker/notifications/slack.ts +96 -0
  164. package/src/registry-worker/notifications/teams.ts +46 -0
  165. package/src/registry-worker/org/audit.ts +116 -0
  166. package/src/registry-worker/org/routes.test.ts +163 -0
  167. package/src/registry-worker/org/routes.ts +302 -0
  168. package/src/registry-worker/personas/personas.test.ts +78 -0
  169. package/src/registry-worker/personas/routes.ts +100 -0
  170. package/src/registry-worker/repo-triggers.ts +20 -0
  171. package/src/registry-worker/routes/index.ts +74 -0
  172. package/src/registry-worker/run-events.ts +24 -0
  173. package/src/registry-worker/runner/dispatch.ts +219 -0
  174. package/src/registry-worker/runner/jobs.ts +43 -0
  175. package/src/registry-worker/runner/metering.ts +82 -0
  176. package/src/registry-worker/runner/policy.ts +59 -0
  177. package/src/registry-worker/runner/routes.ts +171 -0
  178. package/src/registry-worker/runner/runner.test.ts +358 -0
  179. package/src/registry-worker/runner/tokens.ts +93 -0
  180. package/src/registry-worker/runs.test.ts +60 -0
  181. package/src/registry-worker/signup/policy.ts +57 -0
  182. package/src/registry-worker/signup/routes.ts +106 -0
  183. package/src/registry-worker/signup/signup.test.ts +81 -0
  184. package/src/registry-worker/sso/aegis.ts +141 -0
  185. package/src/registry-worker/sso/membership.ts +157 -0
  186. package/src/registry-worker/sso/routes.ts +458 -0
  187. package/src/registry-worker/sso/sso.test.ts +344 -0
  188. package/src/registry-worker/testing/d1-shim.ts +180 -0
  189. package/src/registry-worker/testing/harness.ts +137 -0
  190. package/src/runner-worker/index.ts +108 -0
  191. package/src/runtime/ai-analysis.ts +97 -0
  192. package/src/runtime/ai-exploration.test.ts +32 -0
  193. package/src/runtime/ai-exploration.ts +69 -0
  194. package/src/runtime/ai-repair.ts +74 -0
  195. package/src/runtime/ai-work.test.ts +99 -0
  196. package/src/runtime/android-screen-record.test.ts +75 -0
  197. package/src/runtime/android-screen-record.ts +192 -0
  198. package/src/runtime/app-understanding.test.ts +123 -0
  199. package/src/runtime/app-understanding.ts +201 -0
  200. package/src/runtime/appium-driver.test.ts +179 -0
  201. package/src/runtime/appium-driver.ts +295 -0
  202. package/src/runtime/blocker-resolution.test.ts +113 -0
  203. package/src/runtime/blocker-resolution.ts +111 -0
  204. package/src/runtime/browser-matrix.integration.test.ts +212 -0
  205. package/src/runtime/browser-matrix.test.ts +143 -0
  206. package/src/runtime/browser-matrix.ts +200 -0
  207. package/src/runtime/canonical-flow.test.ts +52 -0
  208. package/src/runtime/config-validate.ts +185 -0
  209. package/src/runtime/continuous-execution.ts +291 -0
  210. package/src/runtime/cursor-applescript.ts +573 -0
  211. package/src/runtime/cursor-cli-driver.test.ts +78 -0
  212. package/src/runtime/cursor-cli-driver.ts +156 -0
  213. package/src/runtime/cursor-driver-example.ts +117 -0
  214. package/src/runtime/cursor-driver-index.ts +65 -0
  215. package/src/runtime/cursor-driver-init.ts +277 -0
  216. package/src/runtime/cursor-driver-run.test.ts +15 -0
  217. package/src/runtime/cursor-driver-run.ts +323 -0
  218. package/src/runtime/cursor-driver.ts +332 -0
  219. package/src/runtime/cursor-llm-example.ts +90 -0
  220. package/src/runtime/cursor-llm.ts +206 -0
  221. package/src/runtime/cursor-mcp-monitor.ts +386 -0
  222. package/src/runtime/device-clouds/browserstack.ts +73 -0
  223. package/src/runtime/device-clouds/device-farm.ts +52 -0
  224. package/src/runtime/device-clouds/index.ts +92 -0
  225. package/src/runtime/device-clouds/kobiton.ts +70 -0
  226. package/src/runtime/device-clouds/targets.ts +44 -0
  227. package/src/runtime/device-clouds/types.ts +62 -0
  228. package/src/runtime/diff-proposal.ts +84 -0
  229. package/src/runtime/discovery-task.test.ts +29 -0
  230. package/src/runtime/discovery-task.ts +284 -0
  231. package/src/runtime/driver-recovery.ts +69 -0
  232. package/src/runtime/driver.ts +79 -0
  233. package/src/runtime/environment.test.ts +104 -0
  234. package/src/runtime/environment.ts +137 -0
  235. package/src/runtime/executor.test.ts +509 -0
  236. package/src/runtime/executor.ts +921 -0
  237. package/src/runtime/explorer.test.ts +101 -0
  238. package/src/runtime/explorer.ts +1013 -0
  239. package/src/runtime/failure-analysis.test.ts +111 -0
  240. package/src/runtime/failure-analysis.ts +272 -0
  241. package/src/runtime/fixtures/fake-maestro.sh +36 -0
  242. package/src/runtime/flow-language.test.ts +268 -0
  243. package/src/runtime/flow-language.ts +414 -0
  244. package/src/runtime/init-wizard.ts +354 -0
  245. package/src/runtime/ios-screen-record.test.ts +68 -0
  246. package/src/runtime/ios-screen-record.ts +155 -0
  247. package/src/runtime/journey-editor.ts +452 -0
  248. package/src/runtime/journey-evidence.test.ts +161 -0
  249. package/src/runtime/journey-evidence.ts +180 -0
  250. package/src/runtime/journey-graph.test.ts +257 -0
  251. package/src/runtime/journey-graph.ts +170 -0
  252. package/src/runtime/legacy-names.ts +32 -0
  253. package/src/runtime/llm-example.ts +105 -0
  254. package/src/runtime/llm.ts +527 -0
  255. package/src/runtime/local-browser.test.ts +45 -0
  256. package/src/runtime/local-browser.ts +48 -0
  257. package/src/runtime/local-registry-stub.test.ts +325 -0
  258. package/src/runtime/local-registry-stub.ts +803 -0
  259. package/src/runtime/maestro-driver.test.ts +84 -0
  260. package/src/runtime/maestro-driver.ts +209 -0
  261. package/src/runtime/mole-voice.ts +21 -0
  262. package/src/runtime/nav-crawl.test.ts +100 -0
  263. package/src/runtime/nav-crawl.ts +153 -0
  264. package/src/runtime/pipeline.test.ts +405 -0
  265. package/src/runtime/pipeline.ts +833 -0
  266. package/src/runtime/planner.test.ts +37 -0
  267. package/src/runtime/planner.ts +274 -0
  268. package/src/runtime/platform-ai.ts +76 -0
  269. package/src/runtime/playwright-driver.test.ts +93 -0
  270. package/src/runtime/playwright-driver.ts +620 -0
  271. package/src/runtime/project-spec.ts +140 -0
  272. package/src/runtime/record-run-verdicts.ts +68 -0
  273. package/src/runtime/reporter.test.ts +56 -0
  274. package/src/runtime/reporter.ts +158 -0
  275. package/src/runtime/reset.test.ts +44 -0
  276. package/src/runtime/reset.ts +61 -0
  277. package/src/runtime/reviewer.test.ts +73 -0
  278. package/src/runtime/reviewer.ts +158 -0
  279. package/src/runtime/run-job.ts +136 -0
  280. package/src/runtime/run-once.test.ts +207 -0
  281. package/src/runtime/run-once.ts +168 -0
  282. package/src/runtime/run.ts +132 -0
  283. package/src/runtime/screen-recording.ts +34 -0
  284. package/src/runtime/serve-gateway.test.ts +74 -0
  285. package/src/runtime/serve-gateway.ts +164 -0
  286. package/src/runtime/serve-worker.test.ts +23 -0
  287. package/src/runtime/serve-worker.ts +278 -0
  288. package/src/runtime/site-discovery.test.ts +168 -0
  289. package/src/runtime/site-discovery.ts +308 -0
  290. package/src/runtime/target-runner.ts +144 -0
  291. package/src/runtime/test-case-verdicts.test.ts +94 -0
  292. package/src/runtime/test-case-verdicts.ts +120 -0
  293. package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
  294. package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
  295. package/src/runtime/ui-reverse-engineering/types.ts +197 -0
  296. package/src/runtime/web-suite.test.ts +97 -0
  297. package/src/runtime/web-suite.ts +176 -0
  298. package/src/storage/create-object-store.ts +144 -0
  299. package/src/storage/keys.ts +34 -0
  300. package/src/storage/local-artifact-server.test.ts +314 -0
  301. package/src/storage/local-artifact-server.ts +357 -0
  302. package/src/storage/object-store.test.ts +28 -0
  303. package/src/storage/object-store.ts +101 -0
  304. package/src/storage/registry-object-store.ts +88 -0
  305. package/src/storage/remote-object-store.ts +104 -0
  306. package/src/storage/storage-directory.test.ts +43 -0
  307. package/src/storage/storage-directory.ts +24 -0
  308. package/tsconfig.json +24 -0
@@ -0,0 +1,314 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import WebSocket from "ws";
7
+ import { startLocalArtifactServer } from "./local-artifact-server.js";
8
+ import { LocalObjectStore } from "./object-store.js";
9
+
10
+ test("local artifact bridge serves only authorized project objects", async () => {
11
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-artifacts-"));
12
+ const store = new LocalObjectStore(directory);
13
+ await store.put("projects/demo/runs/run-1/results.json", '{"ok":true}', "application/json");
14
+ await store.put("projects/other/runs/run-2/results.json", '{"secret":true}', "application/json");
15
+ const bridge = await startLocalArtifactServer({
16
+ directory,
17
+ projectId: "demo",
18
+ port: 0,
19
+ dashboardOrigin: "https://app.bugmole.com",
20
+ });
21
+
22
+ try {
23
+ const artifactUrl = `${bridge.origin}/artifact?key=${encodeURIComponent("projects/demo/runs/run-1/results.json")}`;
24
+ const response = await fetch(artifactUrl, {
25
+ headers: {
26
+ Authorization: `Bearer ${bridge.token}`,
27
+ Origin: "https://app.bugmole.com",
28
+ },
29
+ });
30
+ assert.equal(response.status, 200);
31
+ assert.equal(response.headers.get("content-type"), "application/json");
32
+ assert.equal(await response.text(), '{"ok":true}');
33
+
34
+ const otherProject = await fetch(
35
+ `${bridge.origin}/artifact?key=${encodeURIComponent("projects/other/runs/run-2/results.json")}`,
36
+ { headers: { Authorization: `Bearer ${bridge.token}` } },
37
+ );
38
+ assert.equal(otherProject.status, 403);
39
+
40
+ const traversal = await fetch(
41
+ `${bridge.origin}/artifact?key=${encodeURIComponent("projects/demo/../other/runs/run-2/results.json")}`,
42
+ { headers: { Authorization: `Bearer ${bridge.token}` } },
43
+ );
44
+ assert.equal(traversal.status, 400);
45
+ } finally {
46
+ await bridge.close();
47
+ await fs.rm(directory, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ test("local artifact bridge rejects missing credentials and origins", async () => {
52
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-artifacts-"));
53
+ const bridge = await startLocalArtifactServer({
54
+ directory,
55
+ projectId: "demo",
56
+ port: 0,
57
+ dashboardOrigin: "https://app.bugmole.com",
58
+ });
59
+ try {
60
+ const missingToken = await fetch(`${bridge.origin}/artifacts`);
61
+ assert.equal(missingToken.status, 401);
62
+ const wrongOrigin = await fetch(`${bridge.origin}/health`, {
63
+ headers: { Origin: "https://evil.example" },
64
+ });
65
+ assert.equal(wrongOrigin.status, 403);
66
+ } finally {
67
+ await bridge.close();
68
+ await fs.rm(directory, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ test("local artifact bridges auto-separate projects when the default port is occupied", async () => {
73
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-artifacts-"));
74
+ const first = await startLocalArtifactServer({ directory, projectId: "first" });
75
+ const second = await startLocalArtifactServer({ directory, projectId: "second" });
76
+ try {
77
+ assert.notEqual(first.origin, second.origin);
78
+ const firstHealth = await fetch(`${first.origin}/health`);
79
+ const secondHealth = await fetch(`${second.origin}/health`);
80
+ assert.equal((await firstHealth.json() as { projectId: string }).projectId, "first");
81
+ assert.equal((await secondHealth.json() as { projectId: string }).projectId, "second");
82
+ } finally {
83
+ await second.close();
84
+ await first.close();
85
+ await fs.rm(directory, { recursive: true, force: true });
86
+ }
87
+ });
88
+
89
+ test("local artifact capability expires", async () => {
90
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-artifacts-"));
91
+ const bridge = await startLocalArtifactServer({
92
+ directory,
93
+ projectId: "demo",
94
+ port: 0,
95
+ tokenTtlMs: 1,
96
+ });
97
+ try {
98
+ await new Promise((resolve) => setTimeout(resolve, 5));
99
+ const response = await fetch(`${bridge.origin}/artifacts`, {
100
+ headers: { Authorization: `Bearer ${bridge.token}` },
101
+ });
102
+ assert.equal(response.status, 401);
103
+ } finally {
104
+ await bridge.close();
105
+ await fs.rm(directory, { recursive: true, force: true });
106
+ }
107
+ });
108
+
109
+ test("local presence renews an expired capability for the live bridge", async () => {
110
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-artifacts-"));
111
+ const bridge = await startLocalArtifactServer({
112
+ directory,
113
+ projectId: "demo",
114
+ port: 0,
115
+ tokenTtlMs: 100,
116
+ });
117
+ try {
118
+ await new Promise((resolve) => setTimeout(resolve, 150));
119
+ const presenceResponse = await fetch(`${bridge.origin}/presence`);
120
+ assert.equal(presenceResponse.status, 200);
121
+ const presence = await presenceResponse.json() as { artifactToken?: string };
122
+ assert.ok(presence.artifactToken);
123
+ assert.notEqual(presence.artifactToken, bridge.token);
124
+ const response = await fetch(`${bridge.origin}/artifacts`, {
125
+ headers: { Authorization: `Bearer ${presence.artifactToken}` },
126
+ });
127
+ assert.equal(response.status, 200);
128
+ } finally {
129
+ await bridge.close();
130
+ await fs.rm(directory, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ test("local presence is idempotent while the capability is valid", async () => {
135
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-artifacts-"));
136
+ const bridge = await startLocalArtifactServer({
137
+ directory,
138
+ projectId: "demo",
139
+ aegisEmail: "owner@example.com",
140
+ port: 0,
141
+ });
142
+ try {
143
+ const first = await fetch(`${bridge.origin}/presence`, {
144
+ headers: { Origin: "https://app.bugmole.com" },
145
+ });
146
+ assert.equal(first.status, 200);
147
+ const presence = await first.json() as {
148
+ sessionId?: string;
149
+ deviceId?: string;
150
+ email?: string;
151
+ artifactToken?: string;
152
+ };
153
+ assert.ok(presence.sessionId);
154
+ assert.equal(presence.deviceId, "demo");
155
+ assert.equal(presence.email, "owner@example.com");
156
+ assert.equal(presence.artifactToken, bridge.token);
157
+
158
+ const second = await fetch(`${bridge.origin}/presence`);
159
+ assert.equal(second.status, 200);
160
+ const repeated = await second.json() as { sessionId?: string; artifactToken?: string };
161
+ assert.equal(repeated.sessionId, presence.sessionId);
162
+ assert.equal(repeated.artifactToken, presence.artifactToken);
163
+ } finally {
164
+ await bridge.close();
165
+ await fs.rm(directory, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ test("local artifact bridge writes only project-scoped flow objects", async () => {
170
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-artifacts-"));
171
+ const bridge = await startLocalArtifactServer({ directory, projectId: "demo", port: 0 });
172
+ try {
173
+ const allowed = await fetch(`${bridge.origin}/artifact?key=projects%2Fdemo%2Fjourneys%2Fgraph.json`, {
174
+ method: "PUT",
175
+ headers: { Authorization: `Bearer ${bridge.token}`, "content-type": "application/json" },
176
+ body: '{"version":2}',
177
+ });
178
+ assert.equal(allowed.status, 200);
179
+ const denied = await fetch(`${bridge.origin}/artifact?key=projects%2Fother%2Fjourneys%2Fgraph.json`, {
180
+ method: "PUT",
181
+ headers: { Authorization: `Bearer ${bridge.token}` },
182
+ body: "{}",
183
+ });
184
+ assert.equal(denied.status, 403);
185
+ } finally {
186
+ await bridge.close();
187
+ await fs.rm(directory, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ test("local artifact bridge emits project-scoped progress and audit changes over WebSocket", async () => {
192
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-artifacts-"));
193
+ const store = new LocalObjectStore(directory);
194
+ const bridge = await startLocalArtifactServer({
195
+ directory,
196
+ projectId: "demo",
197
+ port: 0,
198
+ dashboardOrigin: "https://app.bugmole.com",
199
+ });
200
+ const socket = new WebSocket(
201
+ `${bridge.origin.replace(/^http/, "ws")}/artifact-events?project=demo&token=${encodeURIComponent(bridge.token)}`,
202
+ { headers: { Origin: "https://app.bugmole.com" } },
203
+ );
204
+ try {
205
+ await new Promise<void>((resolve, reject) => {
206
+ socket.once("open", () => resolve());
207
+ socket.once("error", reject);
208
+ });
209
+ const messages = new Promise<Array<Record<string, string>>>((resolve, reject) => {
210
+ const received: Array<Record<string, string>> = [];
211
+ const timer = setTimeout(() => reject(new Error("Timed out waiting for artifact changes")), 1000);
212
+ socket.on("message", (data) => {
213
+ received.push(JSON.parse(data.toString()) as Record<string, string>);
214
+ if (received.length === 5) {
215
+ clearTimeout(timer);
216
+ resolve(received);
217
+ }
218
+ });
219
+ });
220
+ await store.put("projects/demo/ui-audits/audit-1.json", '{"auditId":"audit-1"}', "application/json");
221
+ await store.put(
222
+ "projects/demo/ui-parity-audits/parity-1.json",
223
+ '{"auditId":"parity-1","issues":[]}',
224
+ "application/json",
225
+ );
226
+ await store.put("projects/demo/progress/progress-1.json", '{"status":"working"}', "application/json");
227
+ // A live exploration run writes journeys/graph.json incrementally; the
228
+ // dashboard's step graph relies on seeing each write, not just the
229
+ // final one, so it can grow node-by-node instead of appearing all at
230
+ // once.
231
+ await store.put("projects/demo/journeys/graph.json", '{"version":2,"journeys":[]}', "application/json");
232
+ // A resolved exploration blocker; the Blockers panel's "gates N steps"
233
+ // count relies on seeing this the same way it sees UI/parity audits.
234
+ await store.put(
235
+ "projects/demo/blocker-resolutions/abc123.json",
236
+ '{"fingerprint":"abc123","category":"supabase_schema_missing","reason":"setup","nodeId":"home"}',
237
+ "application/json",
238
+ );
239
+ const received = await messages;
240
+ assert.deepEqual(
241
+ received.map((message) => message.type).sort(),
242
+ ["audit-change", "audit-change", "audit-change", "journey-change", "progress-change"],
243
+ );
244
+ for (const message of received) {
245
+ assert.equal(message.projectId, "demo");
246
+ }
247
+ await new Promise<void>((resolve) => {
248
+ socket.once("close", () => resolve());
249
+ socket.close();
250
+ });
251
+ const reconnected = new WebSocket(
252
+ `${bridge.origin.replace(/^http/, "ws")}/artifact-events?project=demo&token=${encodeURIComponent(bridge.token)}`,
253
+ { headers: { Origin: "https://app.bugmole.com" } },
254
+ );
255
+ await new Promise<void>((resolve, reject) => {
256
+ reconnected.once("open", () => resolve());
257
+ reconnected.once("error", reject);
258
+ });
259
+ const reconnectMessage = new Promise<Record<string, string>>((resolve, reject) => {
260
+ const timer = setTimeout(() => reject(new Error("Timed out waiting after reconnect")), 1000);
261
+ reconnected.once("message", (data) => {
262
+ clearTimeout(timer);
263
+ resolve(JSON.parse(data.toString()) as Record<string, string>);
264
+ });
265
+ });
266
+ await store.put("projects/demo/progress/progress-2.json", '{"status":"completed"}', "application/json");
267
+ assert.equal((await reconnectMessage).type, "progress-change");
268
+ reconnected.terminate();
269
+ } finally {
270
+ socket.terminate();
271
+ await bridge.close();
272
+ await fs.rm(directory, { recursive: true, force: true });
273
+ }
274
+ });
275
+
276
+ test("serve bridge announces running and clean shutdown lifecycle", async () => {
277
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-artifacts-"));
278
+ const bridge = await startLocalArtifactServer({
279
+ directory,
280
+ projectId: "demo",
281
+ port: 0,
282
+ service: "serve",
283
+ });
284
+ const socket = new WebSocket(
285
+ `${bridge.origin.replace(/^http/, "ws")}/artifact-events?project=demo&token=${encodeURIComponent(bridge.token)}`,
286
+ );
287
+ try {
288
+ const lifecycle = new Promise<Record<string, string>>((resolve, reject) => {
289
+ socket.once("error", reject);
290
+ socket.once("message", (data) => resolve(JSON.parse(data.toString()) as Record<string, string>));
291
+ });
292
+ await new Promise<void>((resolve, reject) => {
293
+ socket.once("open", () => resolve());
294
+ socket.once("error", reject);
295
+ });
296
+ assert.deepEqual(await lifecycle, { type: "serve-lifecycle", projectId: "demo", state: "running" });
297
+
298
+ const stopping = new Promise<Record<string, string>>((resolve, reject) => {
299
+ socket.once("error", reject);
300
+ socket.once("message", (data) => resolve(JSON.parse(data.toString()) as Record<string, string>));
301
+ });
302
+ const closePromise = bridge.close("shutdown");
303
+ assert.deepEqual(await stopping, {
304
+ type: "serve-lifecycle",
305
+ projectId: "demo",
306
+ state: "stopping",
307
+ reason: "shutdown",
308
+ });
309
+ await closePromise;
310
+ } finally {
311
+ socket.terminate();
312
+ await fs.rm(directory, { recursive: true, force: true });
313
+ }
314
+ });
@@ -0,0 +1,357 @@
1
+ import http from "node:http";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { watch } from "node:fs";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { WebSocketServer, type WebSocket } from "ws";
7
+ import { createObjectStore, type ObjectStoreConfig } from "./create-object-store.js";
8
+ import { normalizeKey, type ObjectStore } from "./object-store.js";
9
+
10
+ export type LocalArtifactServerOptions = {
11
+ directory: string;
12
+ projectId: string;
13
+ aegisEmail?: string;
14
+ port?: number;
15
+ dashboardOrigin?: string;
16
+ tokenTtlMs?: number;
17
+ service?: "serve" | "artifacts";
18
+ };
19
+
20
+ export type LocalArtifactServer = {
21
+ origin: string;
22
+ token: string;
23
+ expiresAt: string;
24
+ close(reason?: "shutdown"): Promise<void>;
25
+ };
26
+
27
+ function tokenMatches(request: http.IncomingMessage, expected: string, expiresAt: number): boolean {
28
+ if (Date.now() >= expiresAt) return false;
29
+ const value = request.headers.authorization?.replace(/^Bearer\s+/i, "");
30
+ return tokenValueMatches(value, expected);
31
+ }
32
+
33
+ function tokenValueMatches(
34
+ value: string | null | undefined,
35
+ expected: string,
36
+ expiresAt?: number,
37
+ ): boolean {
38
+ if (expiresAt !== undefined && Date.now() >= expiresAt) return false;
39
+ if (!value) return false;
40
+ const actual = Buffer.from(value);
41
+ const wanted = Buffer.from(expected);
42
+ return actual.length === wanted.length && timingSafeEqual(actual, wanted);
43
+ }
44
+
45
+ function writeJson(response: http.ServerResponse, status: number, body: unknown): void {
46
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
47
+ response.end(JSON.stringify(body));
48
+ }
49
+
50
+ function contentType(key: string): string {
51
+ if (key.endsWith(".json")) return "application/json";
52
+ if (key.endsWith(".html")) return "text/html; charset=utf-8";
53
+ if (key.endsWith(".md")) return "text/markdown; charset=utf-8";
54
+ if (key.endsWith(".txt") || key.endsWith(".log")) return "text/plain; charset=utf-8";
55
+ if (key.endsWith(".png")) return "image/png";
56
+ if (key.endsWith(".jpg") || key.endsWith(".jpeg")) return "image/jpeg";
57
+ if (key.endsWith(".webp")) return "image/webp";
58
+ if (key.endsWith(".trace") || key.endsWith(".zip")) return "application/octet-stream";
59
+ return "application/octet-stream";
60
+ }
61
+
62
+ export async function startLocalArtifactServer(
63
+ options: LocalArtifactServerOptions,
64
+ ): Promise<LocalArtifactServer> {
65
+ const store = createObjectStore({
66
+ provider: "local",
67
+ localDirectory: options.directory,
68
+ } satisfies ObjectStoreConfig);
69
+ let token = randomBytes(32).toString("base64url");
70
+ let presenceSessionId = randomBytes(24).toString("base64url");
71
+ let tokenExpiresAt = Date.now() + (options.tokenTtlMs ?? 30 * 60 * 1000);
72
+ const allowedOrigin = options.dashboardOrigin ?? "https://app.bugmole.com";
73
+ const projectPrefix = `projects/${encodeURIComponent(options.projectId)}/`;
74
+ const auditPrefix = `${projectPrefix}ui-audits/`;
75
+ const parityAuditPrefix = `${projectPrefix}ui-parity-audits/`;
76
+ const blockerResolutionsPrefix = `${projectPrefix}blocker-resolutions/`;
77
+ const progressPrefix = `${projectPrefix}progress/`;
78
+ const journeysPrefix = `${projectPrefix}journeys/`;
79
+ const projectDirectory = path.join(options.directory, projectPrefix);
80
+ const clients = new Set<WebSocket>();
81
+ const webSocketServer = new WebSocketServer({ noServer: true });
82
+ const lastEmittedSignatures = new Map<string, string>();
83
+ const pendingNotifications = new Map<string, NodeJS.Timeout>();
84
+ const service = options.service ?? "artifacts";
85
+ await fs.mkdir(projectDirectory, { recursive: true });
86
+ const artifactWatcher = watch(projectDirectory, { recursive: true }, (_eventType, filename) => {
87
+ if (!filename) return;
88
+ const relativeName = filename.toString().replaceAll("\\", "/");
89
+ const key = normalizeKey(`${projectPrefix}${relativeName}`);
90
+ const type = key.startsWith(auditPrefix) || key.startsWith(parityAuditPrefix) || key.startsWith(blockerResolutionsPrefix)
91
+ ? "audit-change"
92
+ : key.startsWith(journeysPrefix)
93
+ // A live exploration run writes journeys/graph.json incrementally
94
+ // (one node/edge at a time); broadcasting each write is what lets
95
+ // the dashboard's step graph grow node-by-node instead of only
96
+ // appearing once exploration finishes.
97
+ ? "journey-change"
98
+ : key.startsWith(progressPrefix)
99
+ ? "progress-change"
100
+ : null;
101
+ if (!type || !key.endsWith(".json")) return;
102
+ const previousTimer = pendingNotifications.get(key);
103
+ if (previousTimer) clearTimeout(previousTimer);
104
+ pendingNotifications.set(
105
+ key,
106
+ setTimeout(() => {
107
+ pendingNotifications.delete(key);
108
+ const filePath = path.join(options.directory, key);
109
+ void fs.stat(filePath, { bigint: true }).then(
110
+ (stat) => {
111
+ const signature = `${stat.size}:${stat.mtimeNs}`;
112
+ if (lastEmittedSignatures.get(key) === signature) return;
113
+ lastEmittedSignatures.set(key, signature);
114
+ for (const client of clients) {
115
+ if (client.readyState === client.OPEN) {
116
+ client.send(JSON.stringify({ type, projectId: options.projectId, key }));
117
+ }
118
+ }
119
+ },
120
+ () => {
121
+ if (lastEmittedSignatures.get(key) === "missing") return;
122
+ lastEmittedSignatures.set(key, "missing");
123
+ for (const client of clients) {
124
+ if (client.readyState === client.OPEN) {
125
+ client.send(JSON.stringify({ type, projectId: options.projectId, key }));
126
+ }
127
+ }
128
+ }
129
+ );
130
+ }, 25),
131
+ );
132
+ });
133
+ let expirationTimer: NodeJS.Timeout;
134
+ const scheduleExpiration = () => {
135
+ clearTimeout(expirationTimer);
136
+ expirationTimer = setTimeout(() => {
137
+ for (const client of clients) client.terminate();
138
+ }, Math.max(0, tokenExpiresAt - Date.now()));
139
+ };
140
+ scheduleExpiration();
141
+ const server = http.createServer(async (request, response) => {
142
+ const origin = request.headers.origin;
143
+ if (origin && origin !== allowedOrigin) {
144
+ writeJson(response, 403, { error: "Origin is not allowed" });
145
+ return;
146
+ }
147
+ if (origin) {
148
+ // Echo the configured origin, not the request's. The guard above
149
+ // already rejects anything that isn't exactly allowedOrigin, so the
150
+ // two are equal here — but reflecting a request header into a CORS
151
+ // response is the shape of a real vulnerability, and writing the
152
+ // literal keeps it obvious to a reader (and a scanner) that no
153
+ // caller-controlled value reaches this header.
154
+ response.setHeader("Access-Control-Allow-Origin", allowedOrigin);
155
+ response.setHeader("Vary", "Origin");
156
+ response.setHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
157
+ response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
158
+ response.setHeader("Access-Control-Allow-Private-Network", "true");
159
+ }
160
+ if (request.method === "OPTIONS") {
161
+ response.writeHead(204);
162
+ response.end();
163
+ return;
164
+ }
165
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
166
+ if (request.method !== "GET" && request.method !== "PUT") {
167
+ writeJson(response, 405, { error: "Method not allowed" });
168
+ return;
169
+ }
170
+ if (url.pathname === "/health") {
171
+ writeJson(response, 200, { ok: true, provider: "local", projectId: options.projectId, service });
172
+ return;
173
+ }
174
+ if (url.pathname === "/presence") {
175
+ // Presence is both discovery and renewal. Keep it idempotent while the
176
+ // current capability is valid so login-page polling cannot invalidate a
177
+ // token already handed to the dashboard.
178
+ if (Date.now() >= tokenExpiresAt) {
179
+ token = randomBytes(32).toString("base64url");
180
+ presenceSessionId = randomBytes(24).toString("base64url");
181
+ tokenExpiresAt = Date.now() + (options.tokenTtlMs ?? 30 * 60 * 1000);
182
+ scheduleExpiration();
183
+ }
184
+ writeJson(response, 200, {
185
+ sessionId: presenceSessionId,
186
+ projectId: options.projectId,
187
+ deviceId: options.projectId,
188
+ email: options.aegisEmail,
189
+ artifactOrigin: `http://127.0.0.1:${(server.address() as { port: number }).port}`,
190
+ artifactToken: token,
191
+ expiresAt: new Date(tokenExpiresAt).toISOString(),
192
+ });
193
+ return;
194
+ }
195
+ if (!tokenMatches(request, token, tokenExpiresAt)) {
196
+ writeJson(response, 401, { error: "Artifact bridge authorization required" });
197
+ return;
198
+ }
199
+ if (url.pathname === "/artifacts" && request.method === "GET") {
200
+ const prefix = url.searchParams.get("prefix") ?? projectPrefix;
201
+ if (!prefix.startsWith(projectPrefix)) {
202
+ writeJson(response, 403, { error: "Project scope is not allowed" });
203
+ return;
204
+ }
205
+ const objects = await store.list(normalizeKey(prefix));
206
+ writeJson(response, 200, { objects });
207
+ return;
208
+ }
209
+ if (url.pathname === "/artifact" && request.method === "PUT") {
210
+ const rawKey = url.searchParams.get("key");
211
+ if (!rawKey) {
212
+ writeJson(response, 422, { error: "key is required" });
213
+ return;
214
+ }
215
+ let key: string;
216
+ try {
217
+ key = normalizeKey(rawKey);
218
+ } catch {
219
+ writeJson(response, 400, { error: "Invalid storage key" });
220
+ return;
221
+ }
222
+ if (!key.startsWith(projectPrefix)) {
223
+ writeJson(response, 403, { error: "Project scope is not allowed" });
224
+ return;
225
+ }
226
+ const chunks: Buffer[] = [];
227
+ for await (const chunk of request) chunks.push(Buffer.from(chunk));
228
+ const payload = Buffer.concat(chunks);
229
+ const maxBytes = Number(process.env.BUGMOLE_MAX_ARTIFACT_BYTES || 100_000_000);
230
+ if (payload.length > maxBytes) {
231
+ writeJson(response, 413, { error: "Artifact is too large" });
232
+ return;
233
+ }
234
+ await store.put(key, payload, request.headers["content-type"] ?? "application/octet-stream");
235
+ writeJson(response, 200, { saved: true, key });
236
+ return;
237
+ }
238
+ if (url.pathname === "/artifact" && request.method === "GET") {
239
+ const rawKey = url.searchParams.get("key");
240
+ if (!rawKey) {
241
+ writeJson(response, 422, { error: "key is required" });
242
+ return;
243
+ }
244
+ let key: string;
245
+ try {
246
+ key = normalizeKey(rawKey);
247
+ } catch {
248
+ writeJson(response, 400, { error: "Invalid storage key" });
249
+ return;
250
+ }
251
+ if (!key.startsWith(projectPrefix)) {
252
+ writeJson(response, 403, { error: "Project scope is not allowed" });
253
+ return;
254
+ }
255
+ try {
256
+ const body = await store.get(key);
257
+ response.writeHead(200, {
258
+ "Cache-Control": "no-store",
259
+ "Content-Type": contentType(key),
260
+ "X-Content-Type-Options": "nosniff",
261
+ });
262
+ response.end(Buffer.from(body));
263
+ } catch {
264
+ writeJson(response, 404, { error: "Artifact not found" });
265
+ }
266
+ return;
267
+ }
268
+ writeJson(response, 404, { error: "Not found" });
269
+ });
270
+ server.on("upgrade", (request, socket, head) => {
271
+ const origin = request.headers.origin;
272
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
273
+ const tokenValue = url.searchParams.get("token");
274
+ const projectId = url.searchParams.get("project");
275
+ if (
276
+ origin &&
277
+ origin !== allowedOrigin ||
278
+ url.pathname !== "/artifact-events" ||
279
+ projectId !== options.projectId ||
280
+ Date.now() >= tokenExpiresAt ||
281
+ !tokenValueMatches(tokenValue, token)
282
+ ) {
283
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
284
+ socket.destroy();
285
+ return;
286
+ }
287
+ webSocketServer.handleUpgrade(request, socket, head, (client) => {
288
+ webSocketServer.emit("connection", client, request);
289
+ });
290
+ });
291
+ webSocketServer.on("connection", (client) => {
292
+ clients.add(client);
293
+ if (service === "serve" && client.readyState === client.OPEN) {
294
+ client.send(JSON.stringify({ type: "serve-lifecycle", projectId: options.projectId, state: "running" }));
295
+ }
296
+ client.on("close", () => clients.delete(client));
297
+ client.on("error", () => clients.delete(client));
298
+ });
299
+
300
+ const requestedPort = options.port;
301
+ const listen = (port: number): Promise<void> =>
302
+ new Promise<void>((resolve, reject) => {
303
+ const errorHandler = (error: NodeJS.ErrnoException) => {
304
+ server.removeListener("error", errorHandler);
305
+ reject(error);
306
+ };
307
+ server.once("error", errorHandler);
308
+ server.listen(port, "127.0.0.1", () => {
309
+ server.removeListener("error", errorHandler);
310
+ resolve();
311
+ });
312
+ });
313
+ try {
314
+ // Keep the historical 3190 default for the first bridge, but let a
315
+ // second project use an ephemeral loopback port instead of failing or
316
+ // accidentally sharing another project's capability.
317
+ await listen(requestedPort ?? 3190);
318
+ } catch (error) {
319
+ if (requestedPort !== undefined || (error as NodeJS.ErrnoException).code !== "EADDRINUSE") {
320
+ throw error;
321
+ }
322
+ await listen(0);
323
+ }
324
+ const address = server.address();
325
+ if (!address || typeof address === "string") throw new Error("Local artifact bridge did not bind");
326
+ return {
327
+ origin: `http://127.0.0.1:${address.port}`,
328
+ token,
329
+ expiresAt: new Date(tokenExpiresAt).toISOString(),
330
+ close: async (reason) => {
331
+ clearTimeout(expirationTimer);
332
+ artifactWatcher.close();
333
+ for (const timer of pendingNotifications.values()) clearTimeout(timer);
334
+ pendingNotifications.clear();
335
+ if (service === "serve" && reason === "shutdown") {
336
+ for (const client of clients) {
337
+ if (client.readyState === client.OPEN) {
338
+ client.send(JSON.stringify({
339
+ type: "serve-lifecycle",
340
+ projectId: options.projectId,
341
+ state: "stopping",
342
+ reason: "shutdown",
343
+ }));
344
+ }
345
+ }
346
+ await new Promise((resolve) => setTimeout(resolve, 0));
347
+ }
348
+ for (const client of clients) client.terminate();
349
+ await new Promise<void>((resolve, reject) => {
350
+ webSocketServer.close((error) => (error ? reject(error) : resolve()));
351
+ });
352
+ await new Promise<void>((resolve, reject) => {
353
+ server.close((error) => (error ? reject(error) : resolve()));
354
+ });
355
+ },
356
+ };
357
+ }