haltija 1.12.9 → 1.13.0-beta.2

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 (54) hide show
  1. package/CHANGELOG.md +212 -1
  2. package/README.md +9 -4
  3. package/apps/desktop/isolation.js +8 -2
  4. package/apps/desktop/main.js +29 -10
  5. package/apps/desktop/package.json +4 -1
  6. package/apps/desktop/ports.js +78 -0
  7. package/apps/desktop/preload.js +1 -1
  8. package/apps/desktop/renderer/isolation.js +6 -2
  9. package/apps/desktop/renderer/tabs.js +4 -1
  10. package/apps/desktop/renderer.js +5 -5
  11. package/apps/desktop/resources/component.js +129 -58
  12. package/apps/desktop/server-env.js +4 -0
  13. package/bin/cli-subcommand.mjs +13 -5
  14. package/bin/hj.mjs +6 -2
  15. package/bin/tosijs-dev.mjs +14 -7
  16. package/bin/version.mjs +1 -1
  17. package/dist/api-handlers.d.ts +18 -0
  18. package/dist/api-schema.d.ts +12 -0
  19. package/dist/client.d.ts +15 -2
  20. package/dist/client.js +15 -19
  21. package/dist/component.d.ts +8 -1
  22. package/dist/component.esm.js +129 -58
  23. package/dist/component.js +129 -58
  24. package/dist/desktop-isolation.d.ts +11 -5
  25. package/dist/hj-install.d.ts +9 -0
  26. package/dist/hj.js +34 -151
  27. package/dist/index.js +923 -641
  28. package/dist/loader-snippet.d.ts +37 -0
  29. package/dist/machine-log.d.ts +5 -0
  30. package/dist/navigation-url.d.ts +38 -0
  31. package/dist/page-wait.d.ts +50 -0
  32. package/dist/playwright-server.d.ts +4 -2
  33. package/dist/ports.d.ts +109 -0
  34. package/dist/server.d.ts +20 -4
  35. package/dist/server.js +905 -636
  36. package/dist/test-in-browser.d.ts +99 -0
  37. package/dist/test-ports.d.ts +15 -0
  38. package/dist/test.d.ts +3 -2
  39. package/dist/test.js +149 -27
  40. package/dist/transports.d.ts +207 -0
  41. package/dist/version.d.ts +1 -1
  42. package/dist/ws-origin.d.ts +4 -3
  43. package/docs/AGENTIC-IDE.md +2 -2
  44. package/docs/DECISIONS.md +168 -0
  45. package/docs/EXECUTIVE-SUMMARY.md +2 -2
  46. package/docs/README.md +1 -1
  47. package/docs/ROADMAP-HISTORY.md +533 -0
  48. package/llms.txt +25 -12
  49. package/package.json +28 -2
  50. package/bin/build-bookmarklet.ts +0 -107
  51. package/bin/server.ts +0 -9
  52. package/bin/tosijs-dev.ts +0 -74
  53. package/dist/.metadata_never_index +0 -0
  54. package/dist/machine-socket.d.ts +0 -71
package/CHANGELOG.md CHANGED
@@ -1,6 +1,217 @@
1
1
  # Changelog
2
2
 
3
- ## 1.12.9 (unreleased)
3
+ ## 1.13.0-beta.2 (2026-09-29)
4
+
5
+ ### Changed
6
+
7
+ - **Navigating or reloading now blocks until the page's widget is back** (see Fixed). This
8
+ applies everywhere: `hj navigate` / `hj refresh`, REST `/navigate` / `/refresh`, the MCP
9
+ bridge, `DevChannelClient` and `haltija/test`, and the test runner's `navigate` step.
10
+ - Usually it takes tens of milliseconds. On a page that doesn't load the widget itself (a
11
+ bookmarklet tab), nothing comes back, so each call waits out `--timeout` (default 10 s, max
12
+ 50 s) and then returns with a warning. Pass `--no-wait` (`wait: false`, or `timeout: 0`)
13
+ there.
14
+ - Focus follows the navigated tab, so the next untargeted command reaches the page just
15
+ loaded, unless focus was deliberately changed during the load.
16
+ - `DevChannelClient.navigate()` / `refresh()` (and the `haltija/test` wrappers) now return
17
+ `{ reconnected, warning?, candidateWindowId? }` instead of nothing. `refresh()` with no
18
+ argument is a hard reload, matching the endpoint. It always was one, by accident; see Fixed.
19
+ - **The widget writes to the page's `window.name`** when it navigates, to carry the tab's
20
+ identity across. It writes `haltija-handoff:<id>|<the page's own name>` just before an http(s)
21
+ navigation that loads a new page. The next page's widget restores the page's own value.
22
+ - If the page stays (a 204, a download, a cancelled navigation), the widget restores it once
23
+ the caller's wait is over.
24
+ - If the next page doesn't load the widget, the marker is left there.
25
+ - The next page's own scripts can see the marker until the widget loads and removes it. That
26
+ matters for an app that keeps state in `window.name`.
27
+
28
+ ### Fixed
29
+
30
+ - **`hj navigate` and `hj refresh` wait for the new page** (#54, reported by Snowfox). Both replied
31
+ as soon as the navigation started, before the old page had unloaded. A command sent straight
32
+ after was answered by the page being left, failed with "No browser connected" during the
33
+ handover, or was lost and timed out after 5 s. Snowfox saw it as a page that "stopped loading"
34
+ and worked around it with a 2.5 s pause.
35
+ - They now return once the tab is back from the new page (`data.reconnected: true`).
36
+ - A tab keeps its identity across origins: the widget hands its window id to the next page
37
+ through `window.name`, since the sessionStorage it normally uses is per-origin. The tab is
38
+ therefore recognised by its id, never by guessing which new window it might be. A tab that
39
+ merely appears on the destination origin during a slow load is not mistaken for it.
40
+ - Where `window.name` does not survive, the call returns within about 2 s with
41
+ `reconnected: false`, a `warning`, and the new window as `candidateWindowId`, and never claims
42
+ that window. In the test runner, a `navigate` step fails in this case. How often that happens
43
+ depends on the browser (measured):
44
+ - Chromium, which includes `--headless` and the desktop app's engine, keeps `window.name`
45
+ across origins and sites, including redirects. It is cleared only when the browsing-context
46
+ group changes, which COOP (sent by some OAuth providers) causes.
47
+ - Firefox and WebKit clear it on any cross-origin navigation, including a move to another
48
+ localhost port.
49
+ - In the desktop app a tab's id is stable anyway.
50
+ - The URL is resolved and normalised once, and that URL is both what is loaded and what the
51
+ server waits for. `/docs` used to go to `https:///docs`, and `#x` became a web search in the
52
+ desktop app. A `#hash`-only navigation, which returns at once, was misjudged when written
53
+ differently (`http://localhost:3000#x`, an uppercase host). A bare host (`localhost:3000`)
54
+ still reaches the desktop app raw, so it keeps falling back to http.
55
+ - Warnings are joined to any already attached, rather than replacing them.
56
+ - The test runner's `navigate` step uses the same wait and tab choice. It had failed
57
+ cross-origin navigations and spent a fixed 400 ms per navigation.
58
+ - **`DevChannelClient.refresh(false)` was always a hard reload:** it sent `hard` to an endpoint
59
+ that reads `soft`.
60
+
61
+ ## 1.13.0-beta.1 (2026-09-26)
62
+
63
+ Beta (`npm i haltija@beta`); `latest` stays 1.12.9. Breaking changes are listed first.
64
+
65
+ ### Known issue: any web page can call the REST API (#44)
66
+
67
+ The REST API answers any origin (`Access-Control-Allow-Origin: *`) and needs no token by default,
68
+ so a page you visit can call `/eval` in your connected tabs, or `/shutdown`, and read the results.
69
+ This predates 1.13. What 1.13 changes: with HTTPS on by default, https pages in **Safari** can now
70
+ reach it once the certificate is trusted, where mixed-content blocking used to stop them. Until an
71
+ origin gate lands (planned before 1.13.0), start the server with `--token <secret>` (or set
72
+ `HALTIJA_TOKEN`) on any machine where that matters, and don't leave a server running you aren't
73
+ using.
74
+
75
+ ### Changed (breaking)
76
+
77
+ - **A server now opens BOTH transports by default** (#32a). `DEV_CHANNEL_MODE` defaults to `both`
78
+ instead of `http`, so HTTP 8700 *and* HTTPS 8701 come up whichever project starts the channel.
79
+
80
+ #32 reported this as a missing-certificate problem — `foresight/node_modules/haltija/certs`
81
+ didn't exist, so that instance came up HTTP-only. The causation runs the other way: certs are
82
+ only generated when HTTPS is *wanted*, and the mode defaulted to `http`. The certs were absent
83
+ **because** the transport was, so shipping certs in the package would have fixed nothing.
84
+
85
+ The default is a cross-project decision, which is what makes it worth changing rather than
86
+ documenting. The channel is shared, so an HTTP-only default is not a choice a project makes for
87
+ itself — it decides, for every neighbour on the machine, whether `https://` pages can connect at
88
+ all. In #32 that cost tosijs-3d its entire channel while every indicator said healthy: `hj where`
89
+ reported "haltija 1.12.2, 1 tab", the dev server logged "dev-channel ready", and every command
90
+ routed into an unrelated project's page. An unrecognised `DEV_CHANNEL_MODE` now falls back to
91
+ `both` rather than `http`, so a typo cannot produce the half-open channel either.
92
+
93
+ **`--http` now actually does something.** It was listed in `--help` but never parsed — it
94
+ "worked" only because the `else` branch made HTTP the default. It is a real flag now, and
95
+ choosing it (or `--https`) explicitly on a shared server prints what it costs your neighbours
96
+ (#32d). The default is never warned about; a warning that fires routinely is one nobody reads.
97
+
98
+ - **The TLS certificate moved to `~/.haltija/certs`** — machine-level, not per-install. This is a
99
+ consequence of the above: per-install certs were nearly invisible while almost nobody had any,
100
+ but with HTTPS on by default they mean the browser's "I trust this" memory is invalidated every
101
+ time a different install wins the race to bind 8701, and again after every `npm install` that
102
+ wipes `node_modules`. The user would be asked to trust localhost repeatedly with no way to make
103
+ it stop. An existing per-install certificate is **adopted** (copied forward) rather than
104
+ regenerated, so trust you have already granted survives the move. The private key is written
105
+ `0600` into a `0700` directory. Override with `HALTIJA_CERTS_DIR`.
106
+
107
+ If neither `mkcert` nor `openssl` is present, HTTPS is skipped with an explanation and HTTP still
108
+ serves — a missing tool is not a broken haltija.
109
+
110
+ - **The desktop app's internal chrome-widget port moved from 8701 to 8710** (#32a). `8701` was
111
+ *also* the default for the public HTTPS listener — one number with two independent literals, in
112
+ `src/server.ts` and `apps/desktop/main.js`, neither file aware of the other. They cannot both bind.
113
+
114
+ This was live, not latent. Verified on a developer machine mid-fix: one process (a project's
115
+ `haltija --both`) held **8700 and 8701 together**, because both transports share a process. Launch
116
+ the desktop app in `serverMode: 'builtin'` against that and `killZombieServer()` POSTs `/shutdown`
117
+ to 8701 to claim it for a private widget — shutting down that project's **HTTP** channel as
118
+ collateral. It also blocked #32's headline request outright: "both transports by default" cannot
119
+ ship while the desktop app claims one of them.
120
+
121
+ **The internal port moved rather than HTTPS**, because HTTPS 8701 has callers we do not ship —
122
+ the injected loader and bookmarklet hardcode `https://localhost:8701` (see #38 for the last time
123
+ that string drifted), adopters' dev servers import from it, and self-signed-cert trust is
124
+ per-origin, so moving it would silently un-trust the cert and present as a dead channel. The
125
+ internal port's only client is the app's own renderer, updated in the same binary.
126
+
127
+ **Who is affected:** anyone who targeted the chrome widget as `HALTIJA_PORT=8701 hj tree` should
128
+ now use `8710`. A desktop app older than 1.13.0 still holds 8701, so a new `--both` server may
129
+ fail to bind HTTPS until that app restarts. Setting `HALTIJA_INTERNAL_PORT=8701` by hand now
130
+ prints what will break instead of surfacing as a bind failure in a process you forgot was
131
+ involved.
132
+
133
+ Every well-known port and its role now lives in **`src/ports.ts`** (8700 public HTTP / 8701 public
134
+ HTTPS / 8710 internal HTTP / 8711 reserved), compiled to a CJS twin for Electron main the same way
135
+ `server-env.js` is. `src/ports.test.ts` asserts the public and internal blocks stay disjoint — and
136
+ asserts the *roles*, not the literals, since two literals agreeing is precisely what the bug was.
137
+ Verified against a real `--private --app` launch: chrome widget connected, ports released on
138
+ teardown, and the unrelated `--both` server on 8700/8701 untouched throughout.
139
+
140
+ - **Only the server holding 8700 takes the well-known HTTPS port 8701.** A server started with
141
+ `--port`/`--name`, or one that fell back off a busy 8700 onto an ephemeral port, now opens HTTPS
142
+ on an ephemeral port shown in its banner and `/status`, unless given `--https-port`. Before
143
+ this, with `both` as the default, the first 1.13 server started in any project could capture
144
+ every project's https pages while the real channel and `hj` saw 0 tabs.
145
+
146
+ - **The startup banner and the `server` export prefer HTTP.** They preferred HTTPS, which with
147
+ `both` as the default pointed `curl` at a self-signed certificate and made the DEV MODE
148
+ one-liner fail silently on http pages. `server` is the HTTP listener whenever HTTP is up; use
149
+ `httpsServer` for the HTTPS one.
150
+
151
+ - **A server run from a source checkout installs `hj` by copy, never by symlink,** and only a
152
+ bundle whose stamped version matches the server's. The old symlink fallback pinned
153
+ `~/.local/bin/hj` to that checkout permanently, because a symlinked `hj` is treated as a
154
+ deliberate choice and never repaired.
155
+
156
+ ### Added
157
+
158
+ - **`testInBrowser`** — jest-shaped tests whose assertions run on the host and whose probes run in a
159
+ real browser (#51). The body stays on the host with its real `expect`; only the probe crosses.
160
+ Coordinates (`clickAt`, `dragFrom`) are first-class alongside selectors, because SVG-based UI has
161
+ no selectable element to name. Design credit tosijs-3d; corpus-liveness requirement from
162
+ tosijs-3d-ensemble; the numbers that made the case from tosijs-ui.
163
+
164
+ - **The embed snippet falls back to the other transport** (#32c). It tries the transport matching
165
+ the page, then the other; an https page drops to http only on `localhost`. If neither works it
166
+ logs `haltija: no channel reachable` in the page console, naming the HTTPS address to accept,
167
+ instead of failing silently. The snippet now has one source (`src/loader-snippet.ts`) shared by
168
+ the `/docs` copy and its Playwright suite, and `bun run test:engines` runs that suite in
169
+ Chromium, Firefox and WebKit.
170
+
171
+ ### Fixed
172
+
173
+ - **Whether an https page can reach `http://localhost` depends on the browser, and the docs now
174
+ say so.** Chromium and Firefox allow it; **Safari (WebKit) blocks it as mixed content**. The docs
175
+ had said it was always blocked. Earlier in this release they were changed to say it was never
176
+ blocked, based on a Chromium-only measurement. Both were wrong. The effect is that the fallback
177
+ above helps Chromium and Firefox, but https pages in Safari still need the HTTPS transport. That
178
+ is the strongest reason for `both` being the default. #33's original "mixed content" diagnosis
179
+ was correct for Safari.
180
+
181
+ - **`haltija/server`'s types no longer require `@types/bun`.** Its `.d.ts` exported Bun's own
182
+ server type, so a TypeScript project importing it failed to compile with "Cannot find name
183
+ 'Bun'". `server`, `httpServer` and `httpsServer` are now typed as a small `ListeningServer`
184
+ interface (`port`, `hostname`, `url`, `stop()`); the runtime values are unchanged. Found by the
185
+ new publish workflow's consumer smoke test.
186
+
187
+ - **The machine-level certificate is renewed before it expires**, and one that can't be read is
188
+ replaced. It used to be regenerated by every `npm install`; kept in `~/.haltija/certs` it would
189
+ have lapsed after the openssl fallback's 365 days, while `/status` still reported HTTPS up.
190
+ Writing, adopting or renewing it now leaves a receipt in `~/.haltija/machine-actions.log` and a
191
+ line on stderr. A replaced pair is kept as `*.bak`, since `HALTIJA_CERTS_DIR` may point at a
192
+ directory you own. A key that doesn't match its cert is replaced too, so a pair left
193
+ mismatched by two servers renewing at once heals on the next start. A failed renewal keeps
194
+ using the current cert while it is still valid.
195
+
196
+ - **A bad certificate costs HTTPS only.** A mismatched or corrupt pair in `~/.haltija/certs` used
197
+ to stop every haltija server on the machine from starting, HTTP included. `/status` (and so
198
+ `hj where`) now names the real reason HTTPS is down; it used to blame "port N is held by
199
+ another process" for certificate problems.
200
+
201
+ - **`testInBrowser`: `clickAt` and `dragFrom` send pointer events** as well as mouse events, with
202
+ `buttons` set, and moves go to the element under the pointer. Pointer-driven controls (3D camera
203
+ orbits) ignored them before, and the drag still reported success. `clickAt` also accepts a real
204
+ point above the fold on a scrolled page, which it rejected as "outside the document", and
205
+ `click`/`type`/`press` now fail on a `{ success: false }` reply from a bridge instead of passing.
206
+
207
+ - **The test suites no longer touch the real 8701 or `~/.haltija/certs`.** The Playwright helper
208
+ opted out of HTTPS with a variable nothing had read since v0.1.7, which stopped mattering only
209
+ while `http` was the default. CI now fails if a suite writes anything under `~/.haltija`.
210
+
211
+ - **The README no longer says "localhost only by default".** The server listens on all
212
+ interfaces, and without `--token` any page or anything on the network can call its REST API.
213
+
214
+ ## 1.12.9
4
215
 
5
216
  Remediation of a Tier 1 review that returned **BLOCK** on the already-published 1.12.7/1.12.8.
6
217
  Full report: `reviews/1.12.9-tier1-v1.12.6-to-v1.12.8.md`.
package/README.md CHANGED
@@ -331,8 +331,13 @@ app also exists but isn't required for either.
331
331
 
332
332
  - Widget is always visible (no silent snooping)
333
333
  - User can pause or kill connection anytime
334
- - Localhost only by default
335
- - HTTPS mode with auto-generated certs
334
+ - **Not localhost-only.** The server listens on all interfaces (HTTP 8700 and HTTPS 8701 by
335
+ default), and without `--token` any page you visit, or anything on your network, can call its
336
+ REST API — including `/eval` in your connected tabs. Use `--token <secret>` (or
337
+ `HALTIJA_TOKEN`) on any network you don't trust, and don't leave a server running you aren't
338
+ using.
339
+ - Machine-level HTTPS certificate in `~/.haltija/certs`, auto-generated (mkcert if installed,
340
+ otherwise openssl) and renewed before it expires
336
341
 
337
342
  ---
338
343
 
@@ -419,10 +424,10 @@ HALTIJA_NAME=dashboard hj tree # any shell can address it by name
419
424
 
420
425
  What this means for you, depending on how you used 1.3.0-beta.7:
421
426
 
422
- - **`bunx haltija` desktop app** — works the same; no migration needed. The outer "chrome" widget that lets the app inspect itself now lives on a separate internal port (8701) so it never appears in agent-facing window listings.
427
+ - **`bunx haltija` desktop app** — works the same; no migration needed. The outer "chrome" widget that lets the app inspect itself now lives on a separate internal port (8701 then, 8710 since 1.13.0) so it never appears in agent-facing window listings.
423
428
  - **`HALTIJA_SESSION` / `--session` / `--secure` / the click-to-copy session badge** — gone. If you were setting `HALTIJA_SESSION` in your shell, replace it with `HALTIJA_NAME` (and start the server with `--name <foo>`) or `HALTIJA_PORT`.
424
429
  - **`inject(url, { session })`** — the `session` option is removed. If you need auth, use `inject(url, { token })` (matches `haltija --token <secret>`); for embedding without auth, just `inject(url)` or `inject(url, { mode: 'headless' })`.
425
- - **`hj-chrome` exclusion logic** — gone from the public REST API. To inspect the desktop app's outer UI from `hj`, target the internal port directly: `HALTIJA_PORT=8701 hj tree`.
430
+ - **`hj-chrome` exclusion logic** — gone from the public REST API. To inspect the desktop app's outer UI from `hj`, target the internal port directly: `HALTIJA_PORT=8710 hj tree`. *(That port was 8701 in 1.4.0–1.12.x; it moved in 1.13.0 because 8701 is the public HTTPS listener — see [#32](https://github.com/tonioloewald/haltija/issues/32).)*
426
431
 
427
432
  Net code change: ~830 lines removed, ~150 added back for the simpler model. Test count went up (we now have integration coverage for the token stub, named instances, and auto-port fallback that the previous betas lacked).
428
433
 
@@ -48,8 +48,14 @@ __export(exports_desktop_isolation, {
48
48
  resolveServerUrl: () => resolveServerUrl
49
49
  });
50
50
  module.exports = __toCommonJS(exports_desktop_isolation);
51
- var SHARED_PUBLIC_URL = "http://localhost:8700";
52
- var SHARED_INTERNAL_PORT = 8701;
51
+
52
+ // src/ports.ts
53
+ var DEFAULT_HTTP_PORT = 8700;
54
+ var DEFAULT_INTERNAL_PORT = 8710;
55
+
56
+ // src/desktop-isolation.ts
57
+ var SHARED_PUBLIC_URL = `http://localhost:${DEFAULT_HTTP_PORT}`;
58
+ var SHARED_INTERNAL_PORT = DEFAULT_INTERNAL_PORT;
53
59
  function resolveInternalPort(env) {
54
60
  const raw = env.HALTIJA_INTERNAL_PORT;
55
61
  if (raw === undefined || raw === "")
@@ -25,6 +25,7 @@ const { spawn } = require('child_process')
25
25
  const http = require('http')
26
26
  const { attachNetwork, detachNetwork, getNetworkLog, getNetworkStats, clearNetwork, isMonitoring } = require('./cdp-network.js')
27
27
  const { buildServerEnv } = require('./server-env.js')
28
+ const { DEFAULT_HTTP_PORT, DEFAULT_INTERNAL_PORT, internalPortConflict } = require('./ports.js')
28
29
  // The wire protocol, generated from src/machine-channel.ts — NOT re-declared here. main.js used to
29
30
  // hand-roll the prefixes, the line split and the parse; that copy is where the __NEED_WINDOW__
30
31
  // regression came from, and it had no test while the src/ original did.
@@ -88,15 +89,30 @@ if (IS_PRIVATE) {
88
89
  }
89
90
 
90
91
  // Haltija server config
91
- let HALTIJA_PORT = IS_PRIVATE ? 0 : parseInt(process.env.HALTIJA_PORT || '8700')
92
+ let HALTIJA_PORT = IS_PRIVATE ? 0 : parseInt(process.env.HALTIJA_PORT || String(DEFAULT_HTTP_PORT))
92
93
  let HALTIJA_SERVER = `http://localhost:${HALTIJA_PORT}`
93
94
 
94
95
  // Internal port for the chrome widget (the haltija UI inspecting itself).
95
96
  // Lives on a separate server so it never appears in agent-facing window lists
96
97
  // — agents see only content tabs unless they explicitly target this port.
97
- let HALTIJA_INTERNAL_PORT = IS_PRIVATE ? 0 : parseInt(process.env.HALTIJA_INTERNAL_PORT || '8701')
98
+ //
99
+ // The default comes from src/ports.ts, NOT a literal here. It used to be `8701`, which is also
100
+ // src/server.ts's default for the public HTTPS listener — two independent literals for one number,
101
+ // which cannot both bind (#32a). In 'builtin' mode that made `killZombieServer()` shut down a
102
+ // `haltija --both` server on 8701 and take its HTTP transport down with it.
103
+ let HALTIJA_INTERNAL_PORT = IS_PRIVATE
104
+ ? 0
105
+ : parseInt(process.env.HALTIJA_INTERNAL_PORT || String(DEFAULT_INTERNAL_PORT))
98
106
  let HALTIJA_INTERNAL_SERVER = `http://localhost:${HALTIJA_INTERNAL_PORT}`
99
107
 
108
+ // Someone who pins the old number by hand re-creates the collision, so say what will break rather
109
+ // than letting them discover it as a bind failure in a process they forgot was involved (#32b:
110
+ // a half-open channel and a healthy one must not look alike).
111
+ {
112
+ const conflict = internalPortConflict(HALTIJA_INTERNAL_PORT)
113
+ if (conflict) console.error(`[Haltija Desktop] ${conflict}`)
114
+ }
115
+
100
116
  // Unique app instance ID - used to create stable window IDs across navigations
101
117
  // Combined with webContents.id to create globally unique tab identifiers
102
118
  const APP_INSTANCE_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
@@ -310,10 +326,12 @@ async function checkAndPromptMcpSetup() {
310
326
  try {
311
327
  // The require MUST be inside the try. It was outside, so a missing electron-store threw
312
328
  // before the catch could run and the fallback below was unreachable — while its comment
313
- // claimed otherwise. electron-store is declared in NO manifest (not the root's, not
314
- // apps/desktop's), so for anyone installing from npm it is always missing and this path
315
- // always threw. Found by a release-doctor check for shipped imports the manifest never
316
- // declares. `hasSkippedMcpSetup()` below already had it right; the two disagreed.
329
+ // claimed otherwise. It was then declared in no manifest, so for anyone installing from npm
330
+ // it was always missing and this path always threw. It is now an optional peer of the root
331
+ // package and an optionalDependency here, and npm still does not install it for npm users,
332
+ // so the fallback below remains the path that normally runs. Found by a release-doctor
333
+ // check for shipped imports the manifest never declares. `hasSkippedMcpSetup()` below
334
+ // already had it right; the two disagreed.
317
335
  const store = require('electron-store')
318
336
  const Store = store.default || store
319
337
  const appStore = new Store()
@@ -1548,10 +1566,10 @@ async function startEmbeddedServer() {
1548
1566
  HALTIJA_INTERNAL_SERVER = `http://localhost:${intPort}`
1549
1567
  } else {
1550
1568
  // The internal server never reported. Leaving the port at 0 makes the preload's
1551
- // `parseInt(...) || 8701` fall back to the SHARED internal port — a private app's chrome
1569
+ // `parseInt(...) || DEFAULT_INTERNAL_PORT` fall back to the SHARED internal port — a private app's chrome
1552
1570
  // widget would attach to another project's server. Better to have no chrome widget than to
1553
1571
  // silently join the channel this mode exists to stay out of.
1554
- console.error('[Haltija Desktop] Private internal server did not report a port — chrome widget disabled (refusing to fall back to the shared 8701)')
1572
+ console.error('[Haltija Desktop] Private internal server did not report a port — chrome widget disabled (refusing to fall back to the shared internal port)')
1555
1573
  HALTIJA_INTERNAL_PORT = 0
1556
1574
  HALTIJA_INTERNAL_SERVER = ''
1557
1575
  }
@@ -1732,7 +1750,8 @@ async function ensureServer() {
1732
1750
  *
1733
1751
  * The renderer learns them from `process.env` via the preload, but private mode only reassigns the
1734
1752
  * module-level `let`s — which does not touch `process.env`. Without this, a private app's chrome
1735
- * widget attached to the SHARED 8701 and its first tab loaded the shared 8700. Must run on EVERY
1753
+ * widget attached to the SHARED internal server (8701 at the time; 8710 since #32a) and its first
1754
+ * tab loaded the shared 8700. Must run on EVERY
1736
1755
  * path that reaches `createWindow()`, including the startup-failure one, or the leak returns
1737
1756
  * through the error path. 0 / '' mean "no server", and the renderer treats them as such.
1738
1757
  */
@@ -1819,7 +1838,7 @@ if (!gotTheLock) {
1819
1838
  } catch (err) {
1820
1839
  console.error('[Haltija Desktop] Fatal error during startup:', err)
1821
1840
  // Publish here too. This path still creates a window, so without it the preload would see
1822
- // NEITHER variable and fall back to the shared 8701/8700 — the isolation leak, via the
1841
+ // NEITHER variable and fall back to the shared internal port/8700 — the isolation leak, via the
1823
1842
  // error path. On a failed startup the values are 0/'' , which is the safe state.
1824
1843
  publishResolvedAddresses()
1825
1844
  // Still try to create a window so user sees something
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.12.6",
3
+ "version": "1.13.0-beta.2",
4
4
  "private": true,
5
5
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
6
6
  "homepage": "https://github.com/tonioloewald/haltija",
@@ -112,5 +112,8 @@
112
112
  "asarUnpack": [
113
113
  "resources/*"
114
114
  ]
115
+ },
116
+ "optionalDependencies": {
117
+ "electron-store": "^10.0.0"
115
118
  }
116
119
  }
@@ -0,0 +1,78 @@
1
+ /** ⚠️ AUTO-GENERATED FROM src/ports.ts — DO NOT EDIT. Run: bun run build */
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ function __accessProp(key) {
7
+ return this[key];
8
+ }
9
+ var __toCommonJS = (from) => {
10
+ var entry = (__moduleCache ??= new WeakMap).get(from), desc;
11
+ if (entry)
12
+ return entry;
13
+ entry = __defProp({}, "__esModule", { value: true });
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (var key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(entry, key))
17
+ __defProp(entry, key, {
18
+ get: __accessProp.bind(from, key),
19
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
20
+ });
21
+ }
22
+ __moduleCache.set(from, entry);
23
+ return entry;
24
+ };
25
+ var __moduleCache;
26
+ var __returnValue = (v) => v;
27
+ function __exportSetter(name, newValue) {
28
+ this[name] = __returnValue.bind(null, newValue);
29
+ }
30
+ var __export = (target, all) => {
31
+ for (var name in all)
32
+ __defProp(target, name, {
33
+ get: all[name],
34
+ enumerable: true,
35
+ configurable: true,
36
+ set: __exportSetter.bind(all, name)
37
+ });
38
+ };
39
+
40
+ // src/ports.ts
41
+ var exports_ports = {};
42
+ __export(exports_ports, {
43
+ DEFAULT_HTTPS_PORT: () => DEFAULT_HTTPS_PORT,
44
+ DEFAULT_HTTP_PORT: () => DEFAULT_HTTP_PORT,
45
+ DEFAULT_INTERNAL_PORT: () => DEFAULT_INTERNAL_PORT,
46
+ INTERNAL_PORTS: () => INTERNAL_PORTS,
47
+ LEGACY_INTERNAL_PORT: () => LEGACY_INTERNAL_PORT,
48
+ PUBLIC_PORTS: () => PUBLIC_PORTS,
49
+ RESERVED_INTERNAL_HTTPS_PORT: () => RESERVED_INTERNAL_HTTPS_PORT,
50
+ assertNoPortRoleConflict: () => assertNoPortRoleConflict,
51
+ internalPortConflict: () => internalPortConflict
52
+ });
53
+ module.exports = __toCommonJS(exports_ports);
54
+ var DEFAULT_HTTP_PORT = 8700;
55
+ var DEFAULT_HTTPS_PORT = 8701;
56
+ var DEFAULT_INTERNAL_PORT = 8710;
57
+ var RESERVED_INTERNAL_HTTPS_PORT = 8711;
58
+ var LEGACY_INTERNAL_PORT = 8701;
59
+ var PUBLIC_PORTS = [DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT];
60
+ var INTERNAL_PORTS = [
61
+ DEFAULT_INTERNAL_PORT,
62
+ RESERVED_INTERNAL_HTTPS_PORT
63
+ ];
64
+ function internalPortConflict(port) {
65
+ if (port === DEFAULT_HTTPS_PORT) {
66
+ return `HALTIJA_INTERNAL_PORT=${port} is the public HTTPS port. The desktop app's internal ` + `chrome-widget server would contend with the channel that serves https:// pages — and in ` + `'builtin' server mode it shuts down whatever it finds there, taking a --both server's HTTP ` + `listener with it. Default is ${DEFAULT_INTERNAL_PORT}. See ` + `https://github.com/tonioloewald/haltija/issues/32`;
67
+ }
68
+ if (port === DEFAULT_HTTP_PORT) {
69
+ return `HALTIJA_INTERNAL_PORT=${port} is the public HTTP port — the internal server would contend ` + `with the channel agents drive. Default is ${DEFAULT_INTERNAL_PORT}.`;
70
+ }
71
+ return null;
72
+ }
73
+ function assertNoPortRoleConflict() {
74
+ const overlap = INTERNAL_PORTS.filter((p) => PUBLIC_PORTS.includes(p));
75
+ if (overlap.length > 0) {
76
+ throw new Error(`Port role conflict: ${overlap.join(", ")} assigned to both the public channel and the ` + `desktop app's internals. That is issue #32a — one number, two defaults, neither aware of ` + `the other. Pick a free number in the internal block rather than reusing a public one.`);
77
+ }
78
+ }
@@ -17,7 +17,7 @@ contextBridge.exposeInMainWorld('haltija', {
17
17
  webviewPreloadPath: webviewPreloadPath,
18
18
  // Internal-server port (chrome widget connects here, hidden from public agent traffic).
19
19
  // Set by main AFTER port resolution, so a --private instance reports its EPHEMERAL port rather
20
- // than the shared 8701. 0 means "none" and must stay 0. See src/desktop-isolation.ts.
20
+ // than the shared internal port. 0 means "none" and must stay 0. See src/desktop-isolation.ts.
21
21
  internalPort: resolveInternalPort(process.env),
22
22
  // This instance's public server, or NULL if main didn't set one. Not a default: `|| 8700` here
23
23
  // made state.js's "did main tell me?" check always true, so it overwrote the user's saved
@@ -1,7 +1,11 @@
1
1
  /** ⚠️ AUTO-GENERATED FROM src/desktop-isolation.ts — DO NOT EDIT. Run: bun run build */
2
+ // src/ports.ts
3
+ var DEFAULT_HTTP_PORT = 8700;
4
+ var DEFAULT_INTERNAL_PORT = 8710;
5
+
2
6
  // src/desktop-isolation.ts
3
- var SHARED_PUBLIC_URL = "http://localhost:8700";
4
- var SHARED_INTERNAL_PORT = 8701;
7
+ var SHARED_PUBLIC_URL = `http://localhost:${DEFAULT_HTTP_PORT}`;
8
+ var SHARED_INTERNAL_PORT = DEFAULT_INTERNAL_PORT;
5
9
  function resolveInternalPort(env) {
6
10
  const raw = env.HALTIJA_INTERNAL_PORT;
7
11
  if (raw === undefined || raw === "")
@@ -403,7 +403,10 @@ export function navigate(url, tabId = activeTabId) {
403
403
  let addedHttps = false
404
404
 
405
405
  if (url && !url.match(/^(https?|blob|data|file|about|javascript):\/?\/?/i)) {
406
- if (url.includes('.') || url === 'localhost' || url.startsWith('localhost:')) {
406
+ // A host, not a search: dotted names, localhost with a port or path, and any `name:port`. The
407
+ // widget hands bare hosts over raw so this fallback to http applies; `localhost/docs` and
408
+ // `devbox:3000` used to become a web search (#54 review).
409
+ if (url.includes('.') || /^localhost([:/]|$)/i.test(url) || /^[a-z][\w-]*:\d+([/?#]|$)/i.test(url)) {
407
410
  addedHttps = true
408
411
  url = 'https://' + url
409
412
  } else {
@@ -38,17 +38,17 @@ createTab()
38
38
  setInterval(checkHaltija, 5000)
39
39
 
40
40
  // Inject outer widget (headless) into the renderer for persistence + self-inspection.
41
- // Connects to the *internal* server (default port 8701) so it doesn't appear in
42
- // agent-facing window lists on the public server. To inspect the outer Haltija
43
- // UI from `hj`, target the internal port: HALTIJA_PORT=8701 hj tree
41
+ // Connects to the *internal* server (default port 8710 — see src/ports.ts) so it
42
+ // doesn't appear in agent-facing window lists on the public server. To inspect the
43
+ // outer Haltija UI from `hj`, target the internal port: HALTIJA_PORT=8710 hj tree
44
44
  ;(async function injectOuterWidget() {
45
45
  // 0 / NaN means "this instance has NO internal server" — main.js sets it that way on purpose for
46
- // a private run whose internal server never reported. `|| 8701` undid that one layer above the
46
+ // a private run whose internal server never reported. `|| <shared internal port>` undid that one layer above the
47
47
  // guard and attached the chrome widget to ANOTHER project's shared internal server, with the same
48
48
  // windowId ('hj-chrome'), so the two collided on that channel. Third instance of this leak.
49
49
  const internalPort = Number(window.haltija?.internalPort)
50
50
  if (!Number.isFinite(internalPort) || internalPort <= 0) {
51
- console.warn('[Haltija Desktop] No internal server for this instance — chrome widget disabled (refusing to attach to the shared 8701)')
51
+ console.warn('[Haltija Desktop] No internal server for this instance — chrome widget disabled (refusing to attach to the shared internal port)')
52
52
  return
53
53
  }
54
54
  const internalUrl = getServerUrl().replace(/:\d+/, `:${internalPort}`)