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.
- package/CHANGELOG.md +212 -1
- package/README.md +9 -4
- package/apps/desktop/isolation.js +8 -2
- package/apps/desktop/main.js +29 -10
- package/apps/desktop/package.json +4 -1
- package/apps/desktop/ports.js +78 -0
- package/apps/desktop/preload.js +1 -1
- package/apps/desktop/renderer/isolation.js +6 -2
- package/apps/desktop/renderer/tabs.js +4 -1
- package/apps/desktop/renderer.js +5 -5
- package/apps/desktop/resources/component.js +129 -58
- package/apps/desktop/server-env.js +4 -0
- package/bin/cli-subcommand.mjs +13 -5
- package/bin/hj.mjs +6 -2
- package/bin/tosijs-dev.mjs +14 -7
- package/bin/version.mjs +1 -1
- package/dist/api-handlers.d.ts +18 -0
- package/dist/api-schema.d.ts +12 -0
- package/dist/client.d.ts +15 -2
- package/dist/client.js +15 -19
- package/dist/component.d.ts +8 -1
- package/dist/component.esm.js +129 -58
- package/dist/component.js +129 -58
- package/dist/desktop-isolation.d.ts +11 -5
- package/dist/hj-install.d.ts +9 -0
- package/dist/hj.js +34 -151
- package/dist/index.js +923 -641
- package/dist/loader-snippet.d.ts +37 -0
- package/dist/machine-log.d.ts +5 -0
- package/dist/navigation-url.d.ts +38 -0
- package/dist/page-wait.d.ts +50 -0
- package/dist/playwright-server.d.ts +4 -2
- package/dist/ports.d.ts +109 -0
- package/dist/server.d.ts +20 -4
- package/dist/server.js +905 -636
- package/dist/test-in-browser.d.ts +99 -0
- package/dist/test-ports.d.ts +15 -0
- package/dist/test.d.ts +3 -2
- package/dist/test.js +149 -27
- package/dist/transports.d.ts +207 -0
- package/dist/version.d.ts +1 -1
- package/dist/ws-origin.d.ts +4 -3
- package/docs/AGENTIC-IDE.md +2 -2
- package/docs/DECISIONS.md +168 -0
- package/docs/EXECUTIVE-SUMMARY.md +2 -2
- package/docs/README.md +1 -1
- package/docs/ROADMAP-HISTORY.md +533 -0
- package/llms.txt +25 -12
- package/package.json +28 -2
- package/bin/build-bookmarklet.ts +0 -107
- package/bin/server.ts +0 -9
- package/bin/tosijs-dev.ts +0 -74
- package/dist/.metadata_never_index +0 -0
- package/dist/machine-socket.d.ts +0 -71
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,217 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 1.
|
|
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
|
-
-
|
|
335
|
-
|
|
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=
|
|
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
|
-
|
|
52
|
-
|
|
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 === "")
|
package/apps/desktop/main.js
CHANGED
|
@@ -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 ||
|
|
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
|
-
|
|
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.
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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(...) ||
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/apps/desktop/preload.js
CHANGED
|
@@ -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
|
|
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 =
|
|
4
|
-
var SHARED_INTERNAL_PORT =
|
|
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
|
-
|
|
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 {
|
package/apps/desktop/renderer.js
CHANGED
|
@@ -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
|
|
42
|
-
// agent-facing window lists on the public server. To inspect the
|
|
43
|
-
// UI from `hj`, target the internal port: HALTIJA_PORT=
|
|
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. `||
|
|
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
|
|
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}`)
|