haltija 1.12.7 → 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 +117 -1
- package/apps/desktop/machine-channel.js +105 -0
- package/apps/desktop/machine-response.global.js +74 -0
- package/apps/desktop/main.js +43 -19
- package/apps/desktop/preload.js +2 -0
- package/apps/desktop/renderer/machine-relay.js +39 -18
- package/apps/desktop/renderer/machine-response.js +33 -0
- package/apps/desktop/renderer/tabs.js +9 -1
- package/apps/desktop/resources/component.js +1 -1
- package/apps/desktop/server-env.js +4 -0
- package/apps/desktop/terminal.html +46 -34
- package/bin/tosijs-dev.mjs +16 -4
- package/bin/version.mjs +1 -1
- package/dist/component.d.ts +1 -1
- package/dist/component.esm.js +1 -1
- package/dist/component.js +1 -1
- package/dist/desktop-server-env.d.ts +2 -0
- package/dist/hj.js +2 -2
- package/dist/index.js +790 -675
- package/dist/machine-channel.d.ts +21 -2
- package/dist/machine-response.d.ts +30 -0
- package/dist/server.js +788 -673
- package/dist/test.d.ts +29 -0
- package/dist/test.js +26 -1
- package/dist/version.d.ts +1 -1
- package/dist/ws-origin.d.ts +54 -0
- package/docs/AGENTIC-IDE.md +5 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,122 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 1.12.
|
|
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
|
|
106
|
+
|
|
107
|
+
- **Electron discovery assumed macOS, so Linux re-downloaded Electron every run (#43).** The npx-cache
|
|
108
|
+
search looked for `Electron.app` — a macOS bundle — behind a `platform() !== 'win32'` guard, so
|
|
109
|
+
Linux took the branch and matched nothing. A populated cache was never detected: the launcher
|
|
110
|
+
printed "Electron not cached" on every run and fell back to `npx --yes electron`, putting an
|
|
111
|
+
npm-registry round-trip on the browser-readiness path. During a registry degradation on
|
|
112
|
+
2026-09-04 that became a server which never bound its port across three CI lanes.
|
|
113
|
+
|
|
114
|
+
The defect was two expressions of "what an Electron dist looks like", only one of them
|
|
115
|
+
platform-aware. The search now finds dist *directories* and lets `binaryInDist()` — which had it
|
|
116
|
+
right all along — decide the binary name, so there is one authority instead of two to drift.
|
|
117
|
+
Reported by snowfox with the diagnosis and the fix.
|
|
118
|
+
|
|
119
|
+
## 1.12.7
|
|
4
120
|
|
|
5
121
|
### File preview: 3D models, video, audio and fonts
|
|
6
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
|
+
})();
|
package/apps/desktop/main.js
CHANGED
|
@@ -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
|
-
|
|
1287
|
-
|
|
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
|
-
},
|
|
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(
|
|
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
|
|
1390
|
-
machineBuffer =
|
|
1391
|
-
for (const line of
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
}
|
package/apps/desktop/preload.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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)
|
|
@@ -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";
|