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 +105 -0
- package/apps/desktop/main.js +108 -5
- package/apps/desktop/preload.js +4 -0
- package/apps/desktop/renderer/agent-status.js +4 -3
- package/apps/desktop/renderer/machine-relay.js +107 -0
- package/apps/desktop/renderer/tabs.js +27 -4
- package/apps/desktop/renderer.js +2 -0
- package/apps/desktop/resources/component.js +2 -2
- package/apps/desktop/server-env.js +4 -0
- package/apps/desktop/terminal.html +431 -15
- package/bin/version.mjs +1 -1
- package/dist/component.d.ts +1 -1
- package/dist/component.esm.js +2 -2
- package/dist/component.js +2 -2
- package/dist/hj.js +2 -2
- package/dist/index.js +114 -6
- package/dist/machine-channel.d.ts +80 -0
- package/dist/machine-control.d.ts +68 -0
- package/dist/machine-socket.d.ts +71 -0
- package/dist/server.js +114 -6
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
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",
|
package/apps/desktop/main.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/apps/desktop/preload.js
CHANGED
|
@@ -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
|
|
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
|
|
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()}/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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()
|
package/apps/desktop/renderer.js
CHANGED
|
@@ -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.
|
|
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}/
|
|
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";
|