haltija 1.12.6 → 1.12.7

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,5 +1,110 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.7 (unreleased)
4
+
5
+ ### File preview: 3D models, video, audio and fonts
6
+
7
+ The terminal's file browser previews a lot more than text and images now.
8
+
9
+ - **3D models** — `.glb`, `.gltf`, `.obj`, `.stl`, `.fbx`, `.ply`, `.splat`/`.spz`, `.bvh`. Babylon.js
10
+ is fetched from the CDN **on demand**, not bundled: a terminal that mostly shows text should not
11
+ carry a 3D engine to start up, and the browser caches it after the first model. Orbit/zoom, and
12
+ the scene is disposed when you open anything else (a WebGL context is a capped resource).
13
+ - **Video and audio** — anything `<video>`/`<audio>` can decode. Deliberately no codec allowlist:
14
+ container support depends on the build, so the file is handed to the element and **its own**
15
+ error is reported if it cannot decode. Claiming support we do not have is the failure worth
16
+ avoiding; a silent black rectangle looks like a corrupt file.
17
+ - **Fonts** — `.ttf`, `.otf`, `.woff`, `.woff2`, `.ttc`: charset plus a pangram at seven sizes. Each
18
+ preview registers a uniquely-named family, so a failed load can never leave the *previous*
19
+ typeface on screen pretending to be this one.
20
+
21
+ Two things that would otherwise have made this quietly useless:
22
+
23
+ - `/files/read` reports anything over 1MB as `tooLarge`, which is most real models and every video.
24
+ Rich previews are decided **before** that guard and the binary guard, or a 3.6MB `.glb` would
25
+ simply say "File too large".
26
+ - Sibling files (`.mtl`, `.bin`, textures) cannot resolve from a blob URL, so OBJ/FBX/glTF may load
27
+ geometry without materials. The preview says so — on failure *and* in the caption on success —
28
+ rather than letting a limitation look like a bad asset.
29
+
30
+ Large files **warn rather than refuse**: over 64MB you get the size, what it will cost, and a
31
+ "Preview anyway" button. These are local files and the user knows what they opened.
32
+
33
+ Not supported, and left out on purpose: `.dae` (COLLADA) and `.usdz` — Babylon has no loader for
34
+ either, and listing them would produce a canvas that fails instead of an honest "not supported".
35
+
36
+ ### Desktop app fixes found while testing the above
37
+
38
+ - **The terminal iframe was always told port 8700.** `window.haltija.port` has never existed —
39
+ preload exposes `serverUrl` — so `window.haltija?.port || 8700` silently resolved to the shared
40
+ default for every launch. It happened to be right whenever the app's own server was on 8700, and
41
+ was an **isolation violation** on a `--private` run: the terminal's WebSocket registered its shell
42
+ on a *different project's* server while everything else used the private one. Symptoms were the
43
+ Claude tab reporting "shell not found" and the file browser stuck on haltija's own directory
44
+ (`/files/tree` falls back to the server's cwd when the shell id resolves to nothing).
45
+ - **The file panel did not follow `cd`.** It only loaded when toggled, so after changing directory
46
+ the tree and the prompt disagreed about where you were. It now refreshes on `cwd-changed`, when
47
+ visible. Pre-existing.
48
+
49
+ ### Security: shell execution and filesystem access are no longer served over HTTP (#40)
50
+
51
+ A default `bunx haltija` answered `POST /terminal/command` → `spawn('sh', ['-c', …])`,
52
+ `POST /terminal/agent-prompt` → `claude --permission-mode dontAsk`, and the whole `/files/*`
53
+ surface, to **any caller on the port**.
54
+
55
+ Verified, not theorised: a cross-origin POST with a `text/plain` body — a CORS-*simple* request, so
56
+ the browser issues **no preflight** — returned 200 and its shell command wrote a file. The `Origin`
57
+ header was present and ignored. So the exposure was not "someone on your LAN"; it was **any web
58
+ page open in your browser** while haltija ran on the default port. We also send
59
+ `Access-Control-Allow-Private-Network: true`, which opts out of the browser mitigation built to
60
+ stop exactly this.
61
+
62
+ **Removed rather than gated.** A token gate would leave `spawn('sh','-c')` reachable and depend on
63
+ the gate staying correct forever. A development tool has no business offering remote code execution
64
+ on a network port at all, so `/terminal/*` and `/files/*` are refused with **410 and an explanation**.
65
+
66
+ Two decisions worth recording:
67
+
68
+ - **The refusal is on the PREFIX, not a list of routes.** There are 21 routes under those two
69
+ prefixes; removing the four obvious ones would have left `/files/tree` (directory listing) and
70
+ `/files/image` (arbitrary file read) serving happily. Default-deny means the route nobody has
71
+ written yet is refused too.
72
+ - **The allowlist is empty.** One route had a legitimate cross-origin caller — the injected widget
73
+ reads the running-agent list — and the first version carved it out as an exception. It moved to
74
+ **`/agents`** instead, so the rule is "nothing under these prefixes is served": a sentence that
75
+ cannot be subtly wrong, and no hole for a future need to widen.
76
+
77
+ **Browser control is unaffected** — `/tree`, `/click`, `/eval`, `/screenshot`, the widget, the
78
+ bookmarklet and the tunnel path all behave exactly as before. Nothing here was public API: these
79
+ routes appear in no `API.md`, `DOCS.md`, `llms.txt` or `api-schema.ts` entry.
80
+
81
+ **Known limitation:** if the app *attaches* to a haltija server that was already running
82
+ (`serverMode: 'auto'`, the default, when something is on 8700), the terminal/agent/file tabs are
83
+ unavailable — the pipe requires being the server's parent. The app says so and names
84
+ `HALTIJA_SERVER_MODE=builtin`. Tracked in `TODO.md` with a clean fix.
85
+
86
+ **The desktop tabs keep working** (when the app started its own server), over a channel a browser
87
+ cannot address: `terminal.html`
88
+ postMessages the renderer, which reaches Electron main over IPC, which talks to the server child
89
+ over its **stdio**. A pipe has no origin, no port and no URL `fetch()` can name.
90
+
91
+ The channel opens only when the spawning parent sets `HALTIJA_MACHINE_CHANNEL=1` — which the app
92
+ does for its own public server. So a plain `bunx haltija` now has **no machine-control surface on
93
+ any transport at all**, which is a stronger position than before this release, when it was on by
94
+ default and reachable from any web page.
95
+
96
+ (A Unix socket was the obvious choice and does not work: Bun 1.4.0 accepts the connection and never
97
+ responds, verified three ways against a working Node↔Node control. Noted so nobody retries it.)
98
+
99
+
100
+ - **Our own integration lane was driving other projects' browsers.** `tests/haltija.test.ts`
101
+ adopted whatever answered on the shared default port 8700 and then called `navigate` and `click`
102
+ against it. On a machine running a second project, that is a stranger's browser. It now runs only
103
+ against a server it was explicitly pointed at (`HALTIJA_PORT` / `HALTIJA_URL`), and skips
104
+ otherwise with instructions. No library or runtime change — test lane only.
105
+ - Filed #42: the same default ships in `haltija/test` for every adopter, which is a
106
+ published-API decision rather than housekeeping.
107
+
3
108
  ## 1.12.6
4
109
 
5
110
  Security updates, three silent-failure fixes in the path from "server tells page how to reach me",
@@ -1270,10 +1270,87 @@ function checkServerRunning() {
1270
1270
  * Stdout/stderr are piped to the desktop app's console with a label, and
1271
1271
  * `__NEED_WINDOW__` from the public server triggers window recreation.
1272
1272
  */
1273
- function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, componentDir, portFile }) {
1273
+ // Registered at MODULE SCOPE, once. It was originally inside spawnHaltijaServer, which runs twice
1274
+ // (public and internal servers) — Electron throws "Attempted to register a second handler" and the
1275
+ // app dies at startup. Found by launching the app; no unit test would have caught it.
1276
+ /**
1277
+ * Machine control over the public server's stdio (#40).
1278
+ *
1279
+ * /terminal/* and /files/* are refused on the network listener, because a text/plain POST is a
1280
+ * CORS-simple request and any web page could reach them. The desktop app's own tabs still need
1281
+ * them, so they travel down a pipe instead: no port, no origin, nothing fetch() can name.
1282
+ *
1283
+ * A Unix socket was the first choice and does not work — Bun 1.4.0 accepts the connection and
1284
+ * never answers (verified three ways against a working Node<->Node control). Don't retry it.
1285
+ */
1286
+ const MACHINE_REQ_PREFIX = 'HJ_MACHINE_REQ '
1287
+ const MACHINE_RES_PREFIX = 'HJ_MACHINE_RES '
1288
+ const machinePending = new Map()
1289
+ let machineBuffer = ''
1290
+ let machineSeq = 0
1291
+ let machineProc = null
1292
+
1293
+ function machineRequest(path, init = {}) {
1294
+ return new Promise((resolve) => {
1295
+ if (!machineProc || !machineProc.stdin || machineProc.stdin.destroyed) {
1296
+ // The likeliest cause by far: serverMode 'auto' found a healthy server on 8700 and ATTACHED
1297
+ // to it rather than starting its own (which is deliberate — it must not kill another
1298
+ // project's channel). But the pipe requires being the server's PARENT, so an adopted server
1299
+ // has no machine control. Name the remedy; "unavailable" alone sends people hunting.
1300
+ const adopted = reusedExternalServer
1301
+ resolve({ status: 503, bodyB64: Buffer.from(JSON.stringify({
1302
+ success: false,
1303
+ error: adopted
1304
+ ? 'Terminal, agent and file features need a server this app started itself, and this '
1305
+ + 'instance ATTACHED to a haltija server that was already running (it will not kill '
1306
+ + 'another project\'s channel). Quit that server, or relaunch with '
1307
+ + 'HALTIJA_SERVER_MODE=builtin to force its own. See issue #40.'
1308
+ : 'The machine-control channel is not open. It exists only for the desktop app\'s own '
1309
+ + 'server (issue #40); terminal and file features are unavailable in this instance.',
1310
+ })).toString('base64') })
1311
+ return
1312
+ }
1313
+ const id = `m${++machineSeq}`
1314
+ // ALWAYS resolve. A tab awaiting an id that never returns looks frozen, which is far worse to
1315
+ // diagnose than an error, so every request carries its own deadline.
1316
+ const timer = setTimeout(() => {
1317
+ if (machinePending.delete(id)) {
1318
+ resolve({ status: 504, bodyB64: Buffer.from(JSON.stringify({
1319
+ success: false, error: `Timed out waiting for ${path} on the machine channel.`,
1320
+ })).toString('base64') })
1321
+ }
1322
+ }, 30000)
1323
+ machinePending.set(id, (res) => { clearTimeout(timer); resolve(res) })
1324
+ const frame = { id, method: init.method || 'GET', path, headers: init.headers || {} }
1325
+ if (init.bodyB64 !== undefined) frame.bodyB64 = init.bodyB64
1326
+ try {
1327
+ machineProc.stdin.write(MACHINE_REQ_PREFIX + JSON.stringify(frame) + '\n')
1328
+ } catch (err) {
1329
+ if (machinePending.delete(id)) {
1330
+ clearTimeout(timer)
1331
+ resolve({ status: 500, bodyB64: Buffer.from(JSON.stringify({
1332
+ success: false, error: String(err && err.message || err),
1333
+ })).toString('base64') })
1334
+ }
1335
+ }
1336
+ })
1337
+ }
1338
+
1339
+ ipcMain.handle('machine-request', async (_evt, path, init) => {
1340
+ // Only this prefix pair is reachable, so a compromised renderer cannot use the channel as a
1341
+ // general proxy to the server (e.g. to bypass a token on some unrelated endpoint).
1342
+ if (typeof path !== 'string' || !(path.startsWith('/terminal/') || path.startsWith('/files/'))) {
1343
+ return { status: 400, bodyB64: Buffer.from(JSON.stringify({
1344
+ success: false, error: 'machine-request only carries /terminal/* and /files/* paths',
1345
+ })).toString('base64') }
1346
+ }
1347
+ return machineRequest(path, init || {})
1348
+ })
1349
+
1350
+ function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, componentDir, portFile }) {
1274
1351
  // Built by src/desktop-server-env.ts, whose contract is unit-tested without launching Electron —
1275
1352
  // getting this wrong is silent (a child that ignores its port, binds the parent's, and dies).
1276
- const env = buildServerEnv(process.env, {
1353
+ const env = buildServerEnv(process.env, {
1277
1354
  port,
1278
1355
  role: role === 'public' ? 'public' : 'internal',
1279
1356
  isPrivate: IS_PRIVATE,
@@ -1282,26 +1359,52 @@ function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, compone
1282
1359
  let proc
1283
1360
  if (serverPath && useCompiledBinary) {
1284
1361
  proc = spawn(serverPath, [], {
1285
- stdio: ['ignore', 'pipe', 'pipe'],
1362
+ stdio: [role === 'public' ? 'pipe' : 'ignore', 'pipe', 'pipe'],
1286
1363
  cwd: componentDir || path.dirname(serverPath),
1287
1364
  env,
1288
1365
  })
1289
1366
  } else if (serverPath) {
1290
1367
  proc = spawn('bun', ['run', serverPath], {
1291
- stdio: ['ignore', 'pipe', 'pipe'],
1368
+ stdio: [role === 'public' ? 'pipe' : 'ignore', 'pipe', 'pipe'],
1292
1369
  env,
1293
1370
  })
1294
1371
  } else {
1295
1372
  proc = spawn('bunx', ['haltija', '--port', port.toString()], {
1296
- stdio: ['ignore', 'pipe', 'pipe'],
1373
+ stdio: [role === 'public' ? 'pipe' : 'ignore', 'pipe', 'pipe'],
1297
1374
  env,
1298
1375
  })
1299
1376
  }
1300
1377
 
1378
+ if (role === 'public') machineProc = proc
1379
+
1301
1380
  const label = `[${role} server]`
1302
1381
 
1303
1382
  proc.stdout.on('data', (data) => {
1304
1383
  try {
1384
+ // Machine-channel responses first, on the RAW chunk. The logging path below trims and
1385
+ // reformats, and a pipe splits frames wherever it likes — so parsing has to be line-based
1386
+ // and buffered, or a large /files/tree answer arrives as fragments and never resolves.
1387
+ if (role === 'public') {
1388
+ 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))
1398
+ const waiting = machinePending.get(res.id)
1399
+ 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()
1405
+ }
1406
+ return
1407
+ }
1305
1408
  const text = data.toString().trim()
1306
1409
  console.log(`${label} ${text}`)
1307
1410
  if (role === 'public' && text.includes('__NEED_WINDOW__') && BrowserWindow.getAllWindows().length === 0) {
@@ -31,6 +31,10 @@ contextBridge.exposeInMainWorld('haltija', {
31
31
  goForward: () => ipcRenderer.send('go-forward'),
32
32
  refresh: () => ipcRenderer.send('refresh'),
33
33
 
34
+ // Machine control over the server's stdio (#40). /terminal/* and /files/* are refused on the
35
+ // network listener; this is how the app's own tabs still reach them. Returns {status, bodyB64}.
36
+ machineRequest: (path, init) => ipcRenderer.invoke('machine-request', path, init),
37
+
34
38
  // Screen capture
35
39
  capturePage: () => ipcRenderer.invoke('capture-page'),
36
40
  captureElement: (selector) => ipcRenderer.invoke('capture-element', selector),
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { tabs, el, getServerUrl } from './state.js'
6
6
  import { escapeHtml, createFloatPanel } from './ui-utils.js'
7
+ import { machineFetch } from './machine-relay.js'
7
8
 
8
9
  let agentStatusWs = null
9
10
  let connectedShells = new Map()
@@ -132,7 +133,7 @@ async function showMemosPanel(target) {
132
133
  if (!panel) return
133
134
 
134
135
  try {
135
- const resp = await fetch(`${getServerUrl()}/terminal/command`, {
136
+ const resp = await machineFetch(`/terminal/command`, {
136
137
  method: 'POST',
137
138
  headers: { 'Content-Type': 'application/json' },
138
139
  body: JSON.stringify({ tool: 'tasks', command: 'board' })
@@ -209,7 +210,7 @@ export async function initAgentStatusBar() {
209
210
  if (selectorEl) selectorEl.style.display = 'none'
210
211
 
211
212
  try {
212
- const response = await fetch(`${getServerUrl()}/terminal/status`)
213
+ const response = await machineFetch(`/terminal/status`)
213
214
  if (response.ok) {
214
215
  const line = await response.text()
215
216
  renderAgentStatusBar(line)
@@ -220,7 +221,7 @@ export async function initAgentStatusBar() {
220
221
 
221
222
  // Seed agent list from existing sessions (may have connected before this WS)
222
223
  try {
223
- const res = await fetch(`${getServerUrl()}/terminal/agents`)
224
+ const res = await fetch(`${getServerUrl()}/agents`)
224
225
  if (res.ok) {
225
226
  const data = await res.json()
226
227
  for (const agent of data.agents || []) {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Relay for the terminal iframe's machine-control calls (#40).
3
+ *
4
+ * `terminal.html` is loaded from disk as an iframe and has NO preload bridge — which is exactly
5
+ * why it originally reached its own machine over HTTP, and why `/terminal/*` and `/files/*` had to
6
+ * be network-reachable at all. Those routes are now refused on the network listener, so the iframe
7
+ * asks its parent instead: the renderer has `window.haltija`, which reaches main, which owns the
8
+ * pipe to the server.
9
+ *
10
+ * The iframe already talks to us this way for `terminal-cwd`, `agent-status` and `shell-renamed`,
11
+ * so this is an extra message type on a channel that exists, not a new mechanism.
12
+ */
13
+
14
+ const RELAY_REQUEST = 'hj-machine-request'
15
+ const RELAY_RESPONSE = 'hj-machine-response'
16
+
17
+ export function initMachineRelay() {
18
+ window.addEventListener('message', async (event) => {
19
+ const msg = event.data
20
+ if (!msg || msg.type !== RELAY_REQUEST || typeof msg.id !== 'string') return
21
+
22
+ // Answer through the SOURCE window, not a broadcast: several terminal tabs can be open, and
23
+ // posting to all of them would let one tab observe another's file contents and command output.
24
+ const reply = (payload) => {
25
+ try {
26
+ event.source?.postMessage({ type: RELAY_RESPONSE, id: msg.id, ...payload }, '*')
27
+ } catch {}
28
+ }
29
+
30
+ if (!window.haltija?.machineRequest) {
31
+ reply({
32
+ status: 503,
33
+ bodyB64: btoa(JSON.stringify({
34
+ success: false,
35
+ error: 'Machine control is unavailable: this window has no preload bridge.',
36
+ })),
37
+ })
38
+ return
39
+ }
40
+
41
+ try {
42
+ const res = await window.haltija.machineRequest(msg.path, msg.init || {})
43
+ // Pass base64 through UNTOUCHED. An earlier version decoded with atob() here, which yields a
44
+ // binary string and silently corrupts anything that is not Latin-1 — /files/image is binary,
45
+ // and a source file with any non-ASCII character would have been mangled on the way to the
46
+ // editor. Decoding belongs where the consumer knows whether it wants text or bytes.
47
+ reply({ status: res.status, bodyB64: res.bodyB64 || '', headers: res.headers || {} })
48
+ } catch (err) {
49
+ // Always reply. A pending id that never returns presents as a frozen tab.
50
+ reply({
51
+ status: 500,
52
+ bodyB64: btoa(JSON.stringify({ success: false, error: String(err?.message || err) })),
53
+ })
54
+ }
55
+ })
56
+ }
57
+
58
+ /**
59
+ * fetch()-shaped access to machine control, for renderer modules (#40).
60
+ *
61
+ * The renderer HAS the preload bridge, so it calls `machineRequest` directly — no postMessage hop;
62
+ * that relay exists only for the terminal iframe, which has no bridge.
63
+ *
64
+ * This exists because converting call sites by enumeration missed six of them: `terminal.html` was
65
+ * done thoroughly while `tabs.js` and `agent-status.js` were forgotten entirely, so `/terminal/*`
66
+ * from the renderer silently started returning 410 — which is how "Pick folder…" stopped working
67
+ * (it sends a `cd` via /terminal/command). Keeping the call shape identical to fetch() means the
68
+ * conversion is mechanical and a missed site is visible rather than subtle.
69
+ */
70
+ 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
+ }
89
+
90
+ if (!window.haltija?.machineRequest) {
91
+ return respond(503, btoa(JSON.stringify({
92
+ success: false,
93
+ error: 'Machine control is unavailable: no preload bridge in this window.',
94
+ })))
95
+ }
96
+
97
+ const frame = { method: init.method || 'GET', headers: init.headers || {} }
98
+ if (init.body !== undefined) {
99
+ frame.bodyB64 = btoa(typeof init.body === 'string' ? init.body : JSON.stringify(init.body))
100
+ }
101
+ try {
102
+ const res = await window.haltija.machineRequest(path, frame)
103
+ return respond(res.status, res.bodyB64 || '', res.headers || {})
104
+ } catch (err) {
105
+ return respond(500, btoa(JSON.stringify({ success: false, error: String(err?.message || err) })))
106
+ }
107
+ }
@@ -6,7 +6,24 @@ import { tabs, activeTabId, setActiveTabId, nextTabId, el, getServerUrl, lastCwd
6
6
  import { showNotification } from './ui-utils.js'
7
7
  import { setupWebviewEvents } from './webview-events.js'
8
8
  import { checkHaltija, updateNavButtons } from './status.js'
9
+
10
+ /**
11
+ * The port of THIS instance's public server.
12
+ *
13
+ * Derived from `serverUrl`, which main sets after port resolution — so a private instance reports
14
+ * its ephemeral port rather than the shared default. Falls back to 8700 only when main told us
15
+ * nothing, which is the "adopted an external server" case where 8700 is genuinely right.
16
+ */
17
+ function resolvePublicPort() {
18
+ try {
19
+ const url = window.haltija?.serverUrl
20
+ if (url) return new URL(url).port || '8700'
21
+ } catch {}
22
+ return '8700'
23
+ }
24
+
9
25
  import { handleAction, getIsRecordingActions, getIsRecordingVideo, getIsSelecting } from './widget-status.js'
26
+ import { machineFetch } from './machine-relay.js'
10
27
 
11
28
  // ==========================================
12
29
  // Tab dropdown menu
@@ -217,7 +234,13 @@ export async function createTerminalTab(mode = 'human') {
217
234
  const iframe = document.createElement('iframe')
218
235
  iframe.id = tabId
219
236
  const cwdParam = initialCwd ? `&cwd=${encodeURIComponent(initialCwd)}` : ''
220
- iframe.src = `terminal.html?port=${window.haltija?.port || 8700}&mode=${mode}${cwdParam}`
237
+ // `window.haltija.port` HAS NEVER EXISTED — preload exposes `serverUrl`, not `port` — so this
238
+ // silently resolved to 8700 for every instance. On a `--private --app` run that is another
239
+ // project's shared server: the terminal's WebSocket registered its shell THERE while everything
240
+ // else talked to our own ephemeral server, which is an isolation violation the private mode
241
+ // exists to prevent. It only became visible when /terminal/* moved to the stdio pipe (#40) and
242
+ // the two transports could no longer agree by accident.
243
+ iframe.src = `terminal.html?port=${resolvePublicPort()}&mode=${mode}${cwdParam}`
221
244
  iframe.className = 'terminal-frame'
222
245
 
223
246
  el.webviewContainer.appendChild(iframe)
@@ -287,7 +310,7 @@ export function activateTab(tabId) {
287
310
  }
288
311
 
289
312
  if (tab.terminalMode === 'agent' && tab.shellId) {
290
- fetch(`${getServerUrl()}/terminal/agent-focus`, {
313
+ machineFetch(`/terminal/agent-focus`, {
291
314
  method: 'POST',
292
315
  headers: { 'Content-Type': 'application/json' },
293
316
  body: JSON.stringify({ shellId: tab.shellId }),
@@ -415,7 +438,7 @@ export function navigate(url, tabId = activeTabId) {
415
438
  export async function changeTerminalDirectory(tab, path) {
416
439
  if (!path) return
417
440
  try {
418
- const resp = await fetch(`${getServerUrl()}/terminal/command`, {
441
+ const resp = await machineFetch(`/terminal/command`, {
419
442
  method: 'POST',
420
443
  headers: { 'Content-Type': 'application/json' },
421
444
  body: JSON.stringify({ command: `cd ${path}`, shellId: tab.shellId }),
@@ -453,7 +476,7 @@ async function checkHjInstalled() {
453
476
  hjCheckDone = true
454
477
 
455
478
  try {
456
- const response = await fetch(`${getServerUrl()}/terminal/hj-status`)
479
+ const response = await machineFetch(`/terminal/hj-status`)
457
480
  if (!response.ok) return
458
481
 
459
482
  const { installed, installCommand, message } = await response.json()
@@ -12,6 +12,7 @@ import {
12
12
  } from './renderer/tabs.js'
13
13
  import { setTabFunctions } from './renderer/webview-events.js'
14
14
  import { checkHaltija } from './renderer/status.js'
15
+ import { initMachineRelay } from './renderer/machine-relay.js'
15
16
  import { initSettingsListeners, hideSettings, hideNewTabDialog } from './renderer/settings.js'
16
17
  import { initAgentStatusBar } from './renderer/agent-status.js'
17
18
  import { initVideoCapture } from './renderer/video-capture.js'
@@ -29,6 +30,7 @@ window._tabs = { getActiveTab, getActiveWebview, createTab, activateTab, closeTa
29
30
  setTabFunctions({ navigate, createTab, activateTab, closeTab })
30
31
 
31
32
  console.log('[Haltija Desktop] Initializing with tabs...')
33
+ initMachineRelay()
32
34
  checkHaltija()
33
35
  createTab()
34
36
 
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.12.6";
49
+ var VERSION = "1.12.7";
50
50
 
51
51
  // src/ws-url.ts
52
52
  function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
@@ -4698,7 +4698,7 @@
4698
4698
  const serverUrl2 = this.serverUrl.replace("ws://", "http://").replace("wss://", "https://").replace("/ws/browser", "");
4699
4699
  let agents = [];
4700
4700
  try {
4701
- const response = await fetch(`${serverUrl2}/terminal/agents`, { headers: serverHeaders() });
4701
+ const response = await fetch(`${serverUrl2}/agents`, { headers: serverHeaders() });
4702
4702
  const data = await response.json();
4703
4703
  agents = data.agents || [];
4704
4704
  } catch (err) {
@@ -54,6 +54,10 @@ function buildServerEnv(base, opts) {
54
54
  env.DEV_CHANNEL_PORT = port;
55
55
  env.HALTIJA_DESKTOP = "1";
56
56
  env.HALTIJA_DESKTOP_PUBLIC = opts.role === "public" ? "1" : "0";
57
+ if (opts.role === "public")
58
+ env.HALTIJA_MACHINE_CHANNEL = "1";
59
+ else
60
+ delete env.HALTIJA_MACHINE_CHANNEL;
57
61
  if (opts.isPrivate) {
58
62
  env.HALTIJA_PRIVATE = "1";
59
63
  env.HALTIJA_NO_RETIRE = "1";