haltija 1.12.8 → 1.12.9

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 CHANGED
@@ -1,6 +1,108 @@
1
1
  # Changelog
2
2
 
3
- ## 1.12.8 (unreleased)
3
+ ## 1.12.9 (unreleased)
4
+
5
+ Remediation of a Tier 1 review that returned **BLOCK** on the already-published 1.12.7/1.12.8.
6
+ Full report: `reviews/1.12.9-tier1-v1.12.6-to-v1.12.8.md`.
7
+
8
+ ### Security
9
+
10
+ - **Babylon.js is pinned and integrity-checked (review B1).** The 3D preview added in 1.12.7 loaded
11
+ `cdn.babylonjs.com/babylon.js` — rolling latest, no pin, no SRI — into the one frame holding a
12
+ postMessage relay that reaches `spawn('sh','-c')` and the filesystem. The app strips CSP headers
13
+ globally and the iframe has no sandbox, so a CDN compromise, a bad publish, or a TLS-intercepting
14
+ proxy meant arbitrary code execution on the developer's machine. Shipped, ironically, in the
15
+ release that removed a *network*-reachable RCE. Now pinned to `v9.25.0` with `integrity` +
16
+ `crossorigin`, so substituted bytes cannot execute at all.
17
+ - **The renderer relay now default-denies its sender.** It validated message *shape* only, so any
18
+ frame in the renderer could reach shell and filesystem. It now requires `event.source` to be a
19
+ known terminal iframe. This is what turned "third-party code in a preview pane" into "third-party
20
+ code with a shell", and it would have handed shell access to any plain `<iframe>` added later.
21
+ - **`/ws/terminal` upgrades are restricted (review B3).** It accepted unauthenticated
22
+ cross-origin upgrades and volunteers `{shellId, cwd}` on connect, streaming the developer's
23
+ working directory and absolute file paths to any web page. It now admits: a **loopback hostname**
24
+ origin, an opaque/`file:` origin that presents a per-launch nonce the desktop app gives only to
25
+ its own terminal frame, or a request with **no `Origin` header at all** (a non-browser client — a
26
+ browser always sends one cross-origin).
27
+
28
+ **Stated precisely, because an earlier draft of this bullet was false in three directions and a
29
+ pre-publish review caught it.** What is *not* claimed: `/ws/agent` and `/ws/browser` are
30
+ deliberately **not** gated — the REST surface already answers any origin
31
+ (`Access-Control-Allow-Origin: *`), so any page can already `POST /tree` and `/eval`; gating the
32
+ socket alone would close nothing and would break non-browser agents, which is why an attempt to
33
+ do so was reverted during this release. And the `/ws/terminal` rule is not "local machine only":
34
+ a loopback match is **hostname-only, any port**, so a third-party script on your own
35
+ `localhost:3000` dev server qualifies, and an absent `Origin` is admitted from anywhere on the
36
+ LAN because the server binds `0.0.0.0`. **`--token` is what addresses those**, not this gate.
37
+ - **The channel grant no longer leaks to child processes.** `HALTIJA_MACHINE_CHANNEL=1` was spread
38
+ into every command the terminal or an agent spawned, so a nested `bunx haltija` would start
39
+ treating its own stdin as an authenticated control channel.
40
+
41
+ ### `haltija/test` no longer mutates a browser nobody chose (#42)
42
+
43
+ `resolveTestServerUrl` falls back to `http://localhost:8700` — the *shared* interactive server — and
44
+ the only gate was a stderr warning. This project proved on itself that a warning is not a boundary:
45
+ on 2026-09-03 our own suite adopted another project's server and called `navigate`/`click` against
46
+ its six live tabs. We fixed our lane and left the published library adopting, and **a hazard fixed
47
+ in your own lane but left in the library you ship is not fixed**.
48
+
49
+ Mutating calls (`navigate`, `click`, `type`, `press`, `refresh`) now **throw** when no
50
+ `HALTIJA_URL`/`HALTIJA_PORT` was set, with a message naming the remedy. Read-only calls (`status`,
51
+ `query`, `eval`) are unaffected — reading from a shared server harms nobody, and a guard that blocks
52
+ harmless calls gets disabled wholesale. `HALTIJA_TEST_ALLOW_SHARED=1` opts back in deliberately.
53
+
54
+ **This is a behaviour change in a published API.** Anyone whose suite relied on the 8700 fallback
55
+ for mutating calls will now get a loud error instead of silently driving whichever browser answered
56
+ — which is the point.
57
+
58
+ ### Dependencies
59
+
60
+ - **`tosijs-schema` was pinned to an exact `1.5.1`, four minors behind.** Surfaced by a new Tier 0
61
+ check (from tosijs-ui#61's finding #2: *declared ranges whose window excludes what a consumer
62
+ installs today*). An exact pin in a regular dependency also invites a duplicate install alongside
63
+ a consumer's own copy. Moved to `^1.9.0` after verifying the upgrade rather than assuming it:
64
+ 977 unit + 125 e2e green, and every schema-derived generated artifact (`API.md`, `DOCS.md`,
65
+ `llms.txt`, `bin/hints.json`, `apps/mcp/src/endpoints.json`) rebuilt **byte-identical**.
66
+
67
+ ### Coverage and structure (the two gaps the reviews left open)
68
+
69
+ - **Nothing exercised the terminal's WebSocket**, which is why a regression that broke it was
70
+ invisible to 977 green tests: `/terminal/command` travels over the stdio pipe and never needed
71
+ the socket, so commands kept working. Two guards now: a live upgrade test against a real private
72
+ server (raw HTTP upgrade, so it can set `Origin` — which the browser WebSocket API forbids), and
73
+ a wiring test asserting `main.js` actually *passes* what `buildServerEnv` can grant. The second
74
+ is the one that matters: the function was already tested; the defect was a caller that silently
75
+ dropped an argument. Both verified by mutation.
76
+ - **The base64 Response shim existed twice and had already diverged** (only one copy had
77
+ `dataUrl()`). Now one source, `src/machine-response.ts`, built twice — ESM for the renderer's
78
+ modules, a global for `terminal.html`, which is an inline-script page with no import mechanism.
79
+
80
+ ### Fixes
81
+
82
+ - **Window recreation was dead code (review B2).** The 1.12.7 stdout refactor tested
83
+ `__NEED_WINDOW__` against the *residual line buffer*; the marker always arrives newline-terminated,
84
+ so the check was always false and the correct one below it unreachable. Every `hj` command against
85
+ a windowless-but-alive desktop app hung 8s and failed.
86
+ - **Root cause of the above: the wire protocol was implemented twice.** `main.js` hand-rolled the
87
+ prefixes, the line split and the parse while `src/machine-channel.ts` owned and tested them. It is
88
+ now a generated twin (`apps/desktop/machine-channel.js`), like `server-env.js`.
89
+ - **Timeout layering was inverted**, so a ~30s command (`npm ci`, a slow build) reported "Timed out
90
+ on the machine channel" and discarded the server's real output. Each outer deadline is now
91
+ strictly longer than the one it wraps (server 30s → main 45s → iframe 50s), so a timeout at a
92
+ layer means that layer genuinely lost the answer.
93
+ - **The desktop guard test only recognised template-literal fetches**, so
94
+ `fetch(getServerUrl() + '/files/read')` passed green and 410'd at runtime — the exact silent
95
+ breakage it exists to prevent. It now matches the call and the path independently (verified by
96
+ mutation), skips comments, and states its remaining line-scoped limit rather than implying none.
97
+ - Bun's Unix-socket defect is now **filed upstream** (oven-sh/bun#41381) and mirrored in
98
+ `UPSTREAM.md`, per "we file, we don't fix". The stdio channel stays regardless — it is good on its
99
+ own merits.
100
+ - Docs corrected: `docs/AGENTIC-IDE.md` no longer advertises two routes that now return 410;
101
+ `CLAUDE.md` no longer contradicts itself about whether the tabs were restored; published versions
102
+ are no longer marked `(unreleased)`; and the absolute "no machine-control surface on any
103
+ transport" claim is stated precisely, since an earlier version of it was false.
104
+
105
+ ## 1.12.8
4
106
 
5
107
  - **Electron discovery assumed macOS, so Linux re-downloaded Electron every run (#43).** The npx-cache
6
108
  search looked for `Electron.app` — a macOS bundle — behind a `platform() !== 'win32'` guard, so
@@ -14,7 +116,7 @@
14
116
  right all along — decide the binary name, so there is one authority instead of two to drift.
15
117
  Reported by snowfox with the diagnosis and the fix.
16
118
 
17
- ## 1.12.7 (unreleased)
119
+ ## 1.12.7
18
120
 
19
121
  ### File preview: 3D models, video, audio and fonts
20
122
 
@@ -0,0 +1,105 @@
1
+ /** ⚠️ AUTO-GENERATED FROM src/machine-channel.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/machine-channel.ts
41
+ var exports_machine_channel = {};
42
+ __export(exports_machine_channel, {
43
+ MACHINE_CHANNEL_ENV: () => MACHINE_CHANNEL_ENV,
44
+ MACHINE_REQ_PREFIX: () => MACHINE_REQ_PREFIX,
45
+ MACHINE_RES_PREFIX: () => MACHINE_RES_PREFIX,
46
+ childEnvWithoutChannel: () => childEnvWithoutChannel,
47
+ formatRequest: () => formatRequest,
48
+ formatResponse: () => formatResponse,
49
+ machineChannelEnabled: () => machineChannelEnabled,
50
+ parseRequestLine: () => parseRequestLine,
51
+ parseResponseLine: () => parseResponseLine,
52
+ splitLines: () => splitLines
53
+ });
54
+ module.exports = __toCommonJS(exports_machine_channel);
55
+ var MACHINE_REQ_PREFIX = "HJ_MACHINE_REQ ";
56
+ var MACHINE_RES_PREFIX = "HJ_MACHINE_RES ";
57
+ var MACHINE_CHANNEL_ENV = "HALTIJA_MACHINE_CHANNEL";
58
+ function machineChannelEnabled(env) {
59
+ return env[MACHINE_CHANNEL_ENV] === "1";
60
+ }
61
+ function formatRequest(req) {
62
+ return MACHINE_REQ_PREFIX + JSON.stringify(req) + `
63
+ `;
64
+ }
65
+ function formatResponse(res) {
66
+ return MACHINE_RES_PREFIX + JSON.stringify(res) + `
67
+ `;
68
+ }
69
+ function parseRequestLine(line) {
70
+ if (!line.startsWith(MACHINE_REQ_PREFIX))
71
+ return null;
72
+ try {
73
+ const parsed = JSON.parse(line.slice(MACHINE_REQ_PREFIX.length));
74
+ if (typeof parsed?.id !== "string" || typeof parsed?.path !== "string")
75
+ return null;
76
+ if (!parsed.path.startsWith("/"))
77
+ return null;
78
+ return { method: "GET", ...parsed };
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+ function parseResponseLine(line) {
84
+ if (!line.startsWith(MACHINE_RES_PREFIX))
85
+ return null;
86
+ try {
87
+ const parsed = JSON.parse(line.slice(MACHINE_RES_PREFIX.length));
88
+ if (typeof parsed?.id !== "string" || typeof parsed?.status !== "number")
89
+ return null;
90
+ return { headers: {}, bodyB64: "", ...parsed };
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+ function splitLines(buffer) {
96
+ const parts = buffer.split(`
97
+ `);
98
+ const rest = parts.pop() ?? "";
99
+ return { lines: parts, rest };
100
+ }
101
+ function childEnvWithoutChannel(env = process.env) {
102
+ const copy = { ...env };
103
+ delete copy[MACHINE_CHANNEL_ENV];
104
+ return copy;
105
+ }
@@ -0,0 +1,74 @@
1
+ /** ⚠️ AUTO-GENERATED FROM src/machine-response.ts — DO NOT EDIT. Run: bun run build */
2
+ (() => {
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ function __accessProp(key) {
8
+ return this[key];
9
+ }
10
+ var __toCommonJS = (from) => {
11
+ var entry = (__moduleCache ??= new WeakMap).get(from), desc;
12
+ if (entry)
13
+ return entry;
14
+ entry = __defProp({}, "__esModule", { value: true });
15
+ if (from && typeof from === "object" || typeof from === "function") {
16
+ for (var key of __getOwnPropNames(from))
17
+ if (!__hasOwnProp.call(entry, key))
18
+ __defProp(entry, key, {
19
+ get: __accessProp.bind(from, key),
20
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
21
+ });
22
+ }
23
+ __moduleCache.set(from, entry);
24
+ return entry;
25
+ };
26
+ var __moduleCache;
27
+ var __returnValue = (v) => v;
28
+ function __exportSetter(name, newValue) {
29
+ this[name] = __returnValue.bind(null, newValue);
30
+ }
31
+ var __export = (target, all) => {
32
+ for (var name in all)
33
+ __defProp(target, name, {
34
+ get: all[name],
35
+ enumerable: true,
36
+ configurable: true,
37
+ set: __exportSetter.bind(all, name)
38
+ });
39
+ };
40
+
41
+ // src/machine-response.ts
42
+ var exports_machine_response = {};
43
+ __export(exports_machine_response, {
44
+ machineResponseError: () => machineResponseError,
45
+ makeMachineResponse: () => makeMachineResponse
46
+ });
47
+ function makeMachineResponse(status, bodyB64, headers = {}) {
48
+ let decoded = null;
49
+ const decode = () => {
50
+ if (decoded === null) {
51
+ const bytes = Uint8Array.from(atob(bodyB64 || ""), (c) => c.charCodeAt(0));
52
+ decoded = new TextDecoder().decode(bytes);
53
+ }
54
+ return decoded;
55
+ };
56
+ return {
57
+ ok: status >= 200 && status < 300,
58
+ status,
59
+ headers,
60
+ b64: bodyB64 || "",
61
+ dataUrl: () => `data:${headers["content-type"] || "application/octet-stream"};base64,${bodyB64 || ""}`,
62
+ json: async () => JSON.parse(decode() || "{}"),
63
+ text: async () => decode()
64
+ };
65
+ }
66
+ function machineResponseError(status, message) {
67
+ return makeMachineResponse(status, btoa(JSON.stringify({ success: false, error: message })), {
68
+ "content-type": "application/json"
69
+ });
70
+ }
71
+ if (typeof globalThis !== "undefined" && typeof globalThis.document !== "undefined") {
72
+ globalThis.__hjMachineResponse = { makeMachineResponse, machineResponseError };
73
+ }
74
+ })();
@@ -25,6 +25,11 @@ 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
+ // The wire protocol, generated from src/machine-channel.ts — NOT re-declared here. main.js used to
29
+ // hand-roll the prefixes, the line split and the parse; that copy is where the __NEED_WINDOW__
30
+ // regression came from, and it had no test while the src/ original did.
31
+ const { MACHINE_REQ_PREFIX, MACHINE_RES_PREFIX, formatRequest, parseResponseLine, splitLines } =
32
+ require('./machine-channel.js')
28
33
  // ONE definition of where artifacts live and how long they live. Built from src/artifacts.ts.
29
34
  const { artifactDir, pruneKind } = require('./artifacts.js')
30
35
 
@@ -302,8 +307,14 @@ async function checkAndPromptMcpSetup() {
302
307
  }
303
308
  } else if (result.response === 2) {
304
309
  // Don't ask again - store preference
305
- const store = require('electron-store')
306
310
  try {
311
+ // The require MUST be inside the try. It was outside, so a missing electron-store threw
312
+ // 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.
317
+ const store = require('electron-store')
307
318
  const Store = store.default || store
308
319
  const appStore = new Store()
309
320
  appStore.set('skipMcpSetup', true)
@@ -1283,8 +1294,10 @@ function checkServerRunning() {
1283
1294
  * A Unix socket was the first choice and does not work — Bun 1.4.0 accepts the connection and
1284
1295
  * never answers (verified three ways against a working Node<->Node control). Don't retry it.
1285
1296
  */
1286
- const MACHINE_REQ_PREFIX = 'HJ_MACHINE_REQ '
1287
- const MACHINE_RES_PREFIX = 'HJ_MACHINE_RES '
1297
+ // Minted once per launch and handed to (a) the server via env and (b) the terminal iframe via its
1298
+ // URL. Never sent to a page, so a hostile srcdoc frame cannot obtain it (review M1).
1299
+ const WS_NONCE = require('crypto').randomBytes(24).toString('hex')
1300
+
1288
1301
  const machinePending = new Map()
1289
1302
  let machineBuffer = ''
1290
1303
  let machineSeq = 0
@@ -1319,12 +1332,13 @@ function machineRequest(path, init = {}) {
1319
1332
  success: false, error: `Timed out waiting for ${path} on the machine channel.`,
1320
1333
  })).toString('base64') })
1321
1334
  }
1322
- }, 30000)
1335
+ }, 45000) // > the server's own 30s command timeout, so a slow command returns its real
1336
+ // (possibly truncated) output instead of this layer claiming the channel lost it.
1323
1337
  machinePending.set(id, (res) => { clearTimeout(timer); resolve(res) })
1324
1338
  const frame = { id, method: init.method || 'GET', path, headers: init.headers || {} }
1325
1339
  if (init.bodyB64 !== undefined) frame.bodyB64 = init.bodyB64
1326
1340
  try {
1327
- machineProc.stdin.write(MACHINE_REQ_PREFIX + JSON.stringify(frame) + '\n')
1341
+ machineProc.stdin.write(formatRequest(frame))
1328
1342
  } catch (err) {
1329
1343
  if (machinePending.delete(id)) {
1330
1344
  clearTimeout(timer)
@@ -1336,6 +1350,8 @@ function machineRequest(path, init = {}) {
1336
1350
  })
1337
1351
  }
1338
1352
 
1353
+ ipcMain.handle('ws-nonce', () => WS_NONCE)
1354
+
1339
1355
  ipcMain.handle('machine-request', async (_evt, path, init) => {
1340
1356
  // Only this prefix pair is reachable, so a compromised renderer cannot use the channel as a
1341
1357
  // general proxy to the server (e.g. to bypass a token on some unrelated endpoint).
@@ -1355,6 +1371,10 @@ const env = buildServerEnv(process.env, {
1355
1371
  role: role === 'public' ? 'public' : 'internal',
1356
1372
  isPrivate: IS_PRIVATE,
1357
1373
  portFile,
1374
+ wsNonce: WS_NONCE,
1375
+ // Without this the server has no nonce, so it refuses its OWN terminal frame — which is
1376
+ // precisely what happened: the edit that added it matched the wrong indentation, str.replace
1377
+ // silently did nothing, and the only symptom was a socket that would not open.
1358
1378
  })
1359
1379
  let proc
1360
1380
  if (serverPath && useCompiledBinary) {
@@ -1386,22 +1406,26 @@ const env = buildServerEnv(process.env, {
1386
1406
  // and buffered, or a large /files/tree answer arrives as fragments and never resolves.
1387
1407
  if (role === 'public') {
1388
1408
  machineBuffer += data.toString()
1389
- const parts = machineBuffer.split('\n')
1390
- machineBuffer = parts.pop()
1391
- for (const line of parts) {
1392
- if (!line.startsWith(MACHINE_RES_PREFIX)) {
1393
- if (line.trim()) console.log(`${label} ${line.trim()}`)
1394
- continue
1395
- }
1396
- try {
1397
- const res = JSON.parse(line.slice(MACHINE_RES_PREFIX.length))
1409
+ const split = splitLines(machineBuffer)
1410
+ machineBuffer = split.rest
1411
+ for (const line of split.lines) {
1412
+ // parseResponseLine returns null for anything that is not ours — log output, the banner,
1413
+ // and the other prefixed messages are all expected here.
1414
+ const res = parseResponseLine(line)
1415
+ if (res) {
1398
1416
  const waiting = machinePending.get(res.id)
1399
1417
  if (waiting) { machinePending.delete(res.id); waiting(res) }
1400
- } catch {}
1401
- }
1402
- if (role === 'public' && machineBuffer.includes('__NEED_WINDOW__') && BrowserWindow.getAllWindows().length === 0) {
1403
- console.log('[Haltija Desktop] Server requested window, recreating...')
1404
- createWindow()
1418
+ continue
1419
+ }
1420
+ if (line.trim()) console.log(`${label} ${line.trim()}`)
1421
+ // Tested on the COMPLETE line, never on the residual buffer (review B2). The marker
1422
+ // arrives newline-terminated, so it always lands in `lines` and the buffer is '' — the
1423
+ // old check read `machineBuffer` and was therefore ALWAYS false, making window
1424
+ // recreation dead code and hanging every `hj` command against a windowless app for 8s.
1425
+ if (line.includes('__NEED_WINDOW__') && BrowserWindow.getAllWindows().length === 0) {
1426
+ console.log('[Haltija Desktop] Server requested window, recreating...')
1427
+ createWindow()
1428
+ }
1405
1429
  }
1406
1430
  return
1407
1431
  }
@@ -34,6 +34,8 @@ contextBridge.exposeInMainWorld('haltija', {
34
34
  // Machine control over the server's stdio (#40). /terminal/* and /files/* are refused on the
35
35
  // network listener; this is how the app's own tabs still reach them. Returns {status, bodyB64}.
36
36
  machineRequest: (path, init) => ipcRenderer.invoke('machine-request', path, init),
37
+ // Per-launch secret the terminal frame presents on /ws/terminal (review M1).
38
+ wsNonce: () => ipcRenderer.invoke('ws-nonce'),
37
39
 
38
40
  // Screen capture
39
41
  capturePage: () => ipcRenderer.invoke('capture-page'),
@@ -1,3 +1,4 @@
1
+ import { makeMachineResponse } from './machine-response.js'
1
2
  /**
2
3
  * Relay for the terminal iframe's machine-control calls (#40).
3
4
  *
@@ -11,6 +12,27 @@
11
12
  * so this is an extra message type on a channel that exists, not a new mechanism.
12
13
  */
13
14
 
15
+
16
+ /**
17
+ * Is this the content window of a terminal iframe we created?
18
+ *
19
+ * Identity by object, not by origin string: `terminal.html` is loaded from disk, so its origin is
20
+ * `null`/`file://` — indistinguishable from any other local frame, and trivially claimed. The tab
21
+ * registry holds the actual elements, so comparing `contentWindow` identity is both stronger and
22
+ * simpler than any string check.
23
+ */
24
+ function isKnownTerminalFrame(source) {
25
+ if (!source) return false
26
+ // Straight from the DOM rather than through the tab registry: the class is set where the iframe
27
+ // is created (tabs.js), the registry stores it under `webview` (not `iframe`), and reaching
28
+ // across modules for a shape that can be renamed is how this check would silently start
29
+ // returning false — which fails CLOSED here, but would break every terminal tab.
30
+ for (const frame of document.querySelectorAll('iframe.terminal-frame')) {
31
+ if (frame.contentWindow && frame.contentWindow === source) return true
32
+ }
33
+ return false
34
+ }
35
+
14
36
  const RELAY_REQUEST = 'hj-machine-request'
15
37
  const RELAY_RESPONSE = 'hj-machine-response'
16
38
 
@@ -19,6 +41,22 @@ export function initMachineRelay() {
19
41
  const msg = event.data
20
42
  if (!msg || msg.type !== RELAY_REQUEST || typeof msg.id !== 'string') return
21
43
 
44
+ // DEFAULT-DENY on the sender, not just the message shape (review B1).
45
+ //
46
+ // This validated the shape only, then forwarded to shell + filesystem. Today the sole legitimate
47
+ // sender is a terminal iframe — content tabs are <webview> guests and cannot postMessage the
48
+ // embedder — so it was latent. But it is exactly what turns "third-party code in a preview
49
+ // pane" into "third-party code with a shell", and any plain <iframe> added to the renderer
50
+ // later (a docs pane, a preview, an OAuth page) would inherit shell access by accident rather
51
+ // than by decision.
52
+ //
53
+ // Same discipline as isRefusedMachineControlPath: the sender must be recognised, not merely
54
+ // fail to look wrong.
55
+ if (!isKnownTerminalFrame(event.source)) {
56
+ console.warn('[Haltija] machine-request from an unrecognised frame — refused')
57
+ return
58
+ }
59
+
22
60
  // Answer through the SOURCE window, not a broadcast: several terminal tabs can be open, and
23
61
  // posting to all of them would let one tab observe another's file contents and command output.
24
62
  const reply = (payload) => {
@@ -68,24 +106,7 @@ export function initMachineRelay() {
68
106
  * conversion is mechanical and a missed site is visible rather than subtle.
69
107
  */
70
108
  export async function machineFetch(path, init = {}) {
71
- const respond = (status, bodyB64, headers = {}) => {
72
- let text = null
73
- const decode = () => {
74
- if (text === null) {
75
- const bytes = Uint8Array.from(atob(bodyB64 || ''), (c) => c.charCodeAt(0))
76
- text = new TextDecoder().decode(bytes)
77
- }
78
- return text
79
- }
80
- return {
81
- ok: status >= 200 && status < 300,
82
- status,
83
- headers,
84
- b64: bodyB64 || '',
85
- json: async () => JSON.parse(decode() || '{}'),
86
- text: async () => decode(),
87
- }
88
- }
109
+ const respond = makeMachineResponse // shared with terminal.html via src/machine-response.ts
89
110
 
90
111
  if (!window.haltija?.machineRequest) {
91
112
  return respond(503, btoa(JSON.stringify({
@@ -0,0 +1,33 @@
1
+ /** ⚠️ AUTO-GENERATED FROM src/machine-response.ts — DO NOT EDIT. Run: bun run build */
2
+ // src/machine-response.ts
3
+ function makeMachineResponse(status, bodyB64, headers = {}) {
4
+ let decoded = null;
5
+ const decode = () => {
6
+ if (decoded === null) {
7
+ const bytes = Uint8Array.from(atob(bodyB64 || ""), (c) => c.charCodeAt(0));
8
+ decoded = new TextDecoder().decode(bytes);
9
+ }
10
+ return decoded;
11
+ };
12
+ return {
13
+ ok: status >= 200 && status < 300,
14
+ status,
15
+ headers,
16
+ b64: bodyB64 || "",
17
+ dataUrl: () => `data:${headers["content-type"] || "application/octet-stream"};base64,${bodyB64 || ""}`,
18
+ json: async () => JSON.parse(decode() || "{}"),
19
+ text: async () => decode()
20
+ };
21
+ }
22
+ function machineResponseError(status, message) {
23
+ return makeMachineResponse(status, btoa(JSON.stringify({ success: false, error: message })), {
24
+ "content-type": "application/json"
25
+ });
26
+ }
27
+ if (typeof globalThis !== "undefined" && typeof globalThis.document !== "undefined") {
28
+ globalThis.__hjMachineResponse = { makeMachineResponse, machineResponseError };
29
+ }
30
+ export {
31
+ machineResponseError,
32
+ makeMachineResponse
33
+ };
@@ -240,7 +240,15 @@ export async function createTerminalTab(mode = 'human') {
240
240
  // else talked to our own ephemeral server, which is an isolation violation the private mode
241
241
  // exists to prevent. It only became visible when /terminal/* moved to the stdio pipe (#40) and
242
242
  // the two transports could no longer agree by accident.
243
- iframe.src = `terminal.html?port=${resolvePublicPort()}&mode=${mode}${cwdParam}`
243
+ // The nonce proves this frame belongs to the app when it opens /ws/terminal — a hostile page's
244
+ // sandboxed srcdoc frame sends the same `Origin: null` but cannot obtain this (review M1).
245
+ // Resolved before the frame loads; without it the socket is refused and the terminal is inert,
246
+ // so a failure here is loud rather than subtle.
247
+ Promise.resolve(window.haltija?.wsNonce?.() ?? '').then((nonce) => {
248
+ iframe.src =
249
+ `terminal.html?port=${resolvePublicPort()}&mode=${mode}${cwdParam}` +
250
+ (nonce ? `&nonce=${encodeURIComponent(nonce)}` : '')
251
+ })
244
252
  iframe.className = 'terminal-frame'
245
253
 
246
254
  el.webviewContainer.appendChild(iframe)
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.12.8";
49
+ var VERSION = "1.12.9";
50
50
 
51
51
  // src/ws-url.ts
52
52
  function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
@@ -58,6 +58,10 @@ function buildServerEnv(base, opts) {
58
58
  env.HALTIJA_MACHINE_CHANNEL = "1";
59
59
  else
60
60
  delete env.HALTIJA_MACHINE_CHANNEL;
61
+ if (opts.wsNonce)
62
+ env.HALTIJA_WS_NONCE = opts.wsNonce;
63
+ else
64
+ delete env.HALTIJA_WS_NONCE;
61
65
  if (opts.isPrivate) {
62
66
  env.HALTIJA_PRIVATE = "1";
63
67
  env.HALTIJA_NO_RETIRE = "1";