@hanamesh/dsh-app-host 0.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +31 -0
- package/README.md +104 -0
- package/consistency.json +101 -0
- package/dist/client-ui.d.ts +19 -0
- package/dist/client-ui.js +271 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +42 -0
- package/dist/consistency.js +12 -0
- package/dist/descriptor.js +141 -0
- package/dist/dsh.d.ts +25 -0
- package/dist/dsh.js +194 -0
- package/dist/errors.js +25 -0
- package/dist/gateway.js +224 -0
- package/dist/guardian.js +113 -0
- package/dist/index.d.ts +130 -0
- package/dist/index.js +15 -0
- package/dist/launcher.js +17 -0
- package/dist/library/catalog.js +108 -0
- package/dist/library/domain.js +8 -0
- package/dist/library/install.js +87 -0
- package/dist/library/installed.js +67 -0
- package/dist/library/locate.js +79 -0
- package/dist/library/routes.js +17 -0
- package/dist/library/service.js +76 -0
- package/dist/manager.js +605 -0
- package/dist/provision/LICENSE +6 -0
- package/dist/provision/THIRD_PARTY_NOTICES.md +13 -0
- package/dist/provision/archive.d.ts +28 -0
- package/dist/provision/archive.js +66 -0
- package/dist/provision/check.d.ts +10 -0
- package/dist/provision/check.js +51 -0
- package/dist/provision/checkpoints.d.ts +12 -0
- package/dist/provision/checkpoints.js +23 -0
- package/dist/provision/cli.d.ts +1 -0
- package/dist/provision/cli.js +169 -0
- package/dist/provision/download.d.ts +21 -0
- package/dist/provision/download.js +242 -0
- package/dist/provision/errors.d.ts +11 -0
- package/dist/provision/errors.js +13 -0
- package/dist/provision/extract.d.ts +17 -0
- package/dist/provision/extract.js +124 -0
- package/dist/provision/fsutil.d.ts +8 -0
- package/dist/provision/fsutil.js +71 -0
- package/dist/provision/index.d.ts +7 -0
- package/dist/provision/index.js +6 -0
- package/dist/provision/ledger.d.ts +14 -0
- package/dist/provision/ledger.js +50 -0
- package/dist/provision/manifest.d.ts +11 -0
- package/dist/provision/manifest.js +171 -0
- package/dist/provision/paths.d.ts +27 -0
- package/dist/provision/paths.js +95 -0
- package/dist/provision/platform.d.ts +6 -0
- package/dist/provision/platform.js +7 -0
- package/dist/provision/provision.d.ts +17 -0
- package/dist/provision/provision.js +454 -0
- package/dist/provision/tar.d.ts +7 -0
- package/dist/provision/tar.js +169 -0
- package/dist/provision/types.d.ts +174 -0
- package/dist/provision/types.js +7 -0
- package/dist/provision/zip.d.ts +4 -0
- package/dist/provision/zip.js +194 -0
- package/dist/router/broker.js +161 -0
- package/dist/router/domain.js +18 -0
- package/dist/router/receipts-domain.js +8 -0
- package/dist/router/receipts.js +156 -0
- package/dist/router/routes.js +48 -0
- package/dist/router/sources.js +87 -0
- package/dist/routes.js +74 -0
- package/dist/runtime.js +222 -0
- package/dist/store.js +250 -0
- package/dist/usage-evidence.js +117 -0
- package/docs/APP_PACKAGE.md +38 -0
- package/docs/CONTRACT.md +190 -0
- package/docs/DSH_INTEGRATION.md +43 -0
- package/docs/LIBRARY.md +45 -0
- package/docs/LICENSES.json +185 -0
- package/docs/PROVENANCE.json +63 -0
- package/docs/SECURITY.md +52 -0
- package/package.json +112 -0
- package/profile/cordis.patch.yml +4 -0
- package/schemas/catalog-provider-page.schema.json +376 -0
- package/schemas/catalog-source.schema.json +151 -0
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createServer, request } from 'node:http';
|
|
2
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
3
|
+
import { AppHostError, requireCondition, bounded } from './errors.js';
|
|
4
|
+
import { loopbackOrigin } from './descriptor.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `frame-ancestors` is matched against EVERY ancestor, not just the direct parent. Inside a
|
|
8
|
+
* desktop shell the chain is `<shell origin>` → DSH workspace → app frame, so the shell's own
|
|
9
|
+
* webview origin (e.g. `tauri://localhost`) must be listed too or the app document is served
|
|
10
|
+
* and then refused by the webview — a blank frame (HanaMesh desktop rc.4, 2026-09-21). The
|
|
11
|
+
* value is `parentOrigin` plus the configured extra ancestors, space-separated.
|
|
12
|
+
*/
|
|
13
|
+
export function frameAncestorsValue(parentOrigin, frameAncestors = []) {
|
|
14
|
+
return [parentOrigin, ...frameAncestors.filter(origin => origin !== parentOrigin)].join(' ');
|
|
15
|
+
}
|
|
16
|
+
/** Only exact origins of a scheme a webview can actually host a top-level document on. */
|
|
17
|
+
export function ancestorOrigin(value) {
|
|
18
|
+
let url;
|
|
19
|
+
try { url = new URL(value); } catch { requireCondition(false, 'INVALID_FRAME_ANCESTOR', 'Expected an explicit ancestor origin.'); }
|
|
20
|
+
requireCondition(['http:', 'https:', 'tauri:', 'asset:', 'file:'].includes(url.protocol) && !url.username && !url.password &&
|
|
21
|
+
(url.pathname === '/' || url.pathname === '') && !url.search && !url.hash && (url.protocol === 'file:' || url.hostname),
|
|
22
|
+
'INVALID_FRAME_ANCESTOR', 'Only scheme://host[:port] ancestor origins are accepted.');
|
|
23
|
+
// `URL.origin` is the string "null" for non-special schemes such as tauri:; rebuild it.
|
|
24
|
+
return url.protocol === 'file:' ? 'file://' : `${url.protocol}//${url.host}`;
|
|
25
|
+
}
|
|
26
|
+
export function rewriteCsp(value, ancestors) {
|
|
27
|
+
if (/(?:^|;)\s*frame-ancestors\b/i.test(value))
|
|
28
|
+
return value.replace(/(^|;)(\s*)frame-ancestors\b[^;]*/ig, (_, delimiter, whitespace) => `${delimiter}${whitespace}frame-ancestors ${ancestors}`);
|
|
29
|
+
return `${value}${value.trimEnd().endsWith(';') ? '' : ';'} frame-ancestors ${ancestors}`;
|
|
30
|
+
}
|
|
31
|
+
export function embeddingHeaders(rawHeaders, ancestors) {
|
|
32
|
+
const output = []; let hasCsp = false;
|
|
33
|
+
for (let i = 0; i < rawHeaders.length; i += 2) {
|
|
34
|
+
const key = rawHeaders[i], value = rawHeaders[i + 1];
|
|
35
|
+
if (key.toLowerCase() === 'x-frame-options') continue;
|
|
36
|
+
if (key.toLowerCase() === 'content-security-policy') { output.push(key, rewriteCsp(value, ancestors)); hasCsp = true; }
|
|
37
|
+
else output.push(key, value);
|
|
38
|
+
}
|
|
39
|
+
if (!hasCsp) output.push('Content-Security-Policy', `frame-ancestors ${ancestors}`);
|
|
40
|
+
return output;
|
|
41
|
+
}
|
|
42
|
+
const token = () => randomBytes(32).toString('hex');
|
|
43
|
+
function cookies(header) {
|
|
44
|
+
const map = new Map();
|
|
45
|
+
for (const part of (header ?? '').split(';')) {
|
|
46
|
+
const at = part.indexOf('='); if (at > 0) map.set(part.slice(0,at).trim(), part.slice(at+1).trim());
|
|
47
|
+
}
|
|
48
|
+
return map;
|
|
49
|
+
}
|
|
50
|
+
function fail(res, status, code) {
|
|
51
|
+
res.writeHead(status, { 'content-type':'application/json', 'cache-control':'no-store', 'content-security-policy':"default-src 'none'; frame-ancestors 'none'" });
|
|
52
|
+
res.end(JSON.stringify({ error: { code } }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Fixed upstream only. Bootstrap is our own response, not a rewritten app document. */
|
|
56
|
+
export class FixedGateway {
|
|
57
|
+
constructor({ upstream, parentOrigin, isLeaseActive, cookieAllowlist = [], allowAppAuthorization = false, maxUploadBytes = 32 * 1024 * 1024, frameAncestors = [], onForward = () => {} }) {
|
|
58
|
+
this.upstream = loopbackOrigin(upstream); this.parentOrigin = loopbackOrigin(parentOrigin);
|
|
59
|
+
requireCondition(Array.isArray(frameAncestors) && frameAncestors.length <= 8, 'INVALID_FRAME_ANCESTOR', 'frameAncestors must be a short list of origins.');
|
|
60
|
+
this.ancestors = frameAncestorsValue(this.parentOrigin, frameAncestors.map(ancestorOrigin));
|
|
61
|
+
requireCondition(typeof isLeaseActive === 'function','INVALID_GATEWAY','A live lease authorization check is required.');
|
|
62
|
+
this.isLeaseActive = isLeaseActive; this.cookieAllowlist = new Set(cookieAllowlist);
|
|
63
|
+
this.allowAppAuthorization = allowAppAuthorization; this.maxUploadBytes = maxUploadBytes;
|
|
64
|
+
requireCondition(typeof onForward === 'function','INVALID_GATEWAY','onForward must be a function.');
|
|
65
|
+
this.onForward = onForward;
|
|
66
|
+
this.tickets = new Map(); this.grants = new Map(); this.sockets = new Set(); this.upstreams = new Set();
|
|
67
|
+
this.cookiePrefix = `hm_app_${token().slice(0,12)}_`;
|
|
68
|
+
}
|
|
69
|
+
async start() {
|
|
70
|
+
this.server = createServer((req,res) => this.#handle(req,res));
|
|
71
|
+
this.server.on('connection', socket => { this.sockets.add(socket); socket.once('close', () => this.sockets.delete(socket)); });
|
|
72
|
+
this.server.on('upgrade', (req,socket,head) => this.#upgrade(req,socket,head));
|
|
73
|
+
this.server.on('connect', (_req,socket) => socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\n\r\n'));
|
|
74
|
+
await new Promise((resolve,reject) => { this.server.once('error',reject); this.server.listen(0,'127.0.0.1',resolve); });
|
|
75
|
+
this.origin = `http://127.0.0.1:${this.server.address().port}`;
|
|
76
|
+
return this.origin;
|
|
77
|
+
}
|
|
78
|
+
issue(viewKey) {
|
|
79
|
+
requireCondition(this.isLeaseActive(viewKey),'LEASE_EXPIRED','Cannot grant access to an inactive view.');
|
|
80
|
+
for (const [key,t] of this.tickets) if (t.expiresAt < Date.now() || !this.isLeaseActive(t.viewKey)) this.tickets.delete(key);
|
|
81
|
+
for (const [key,g] of this.grants) if (!this.isLeaseActive(g.viewKey)) this.grants.delete(key);
|
|
82
|
+
requireCondition(this.tickets.size < 512,'TOO_MANY_TICKETS','Too many pending iframe tickets.');
|
|
83
|
+
const ticket = token(); this.tickets.set(ticket,{ viewKey, expiresAt:Date.now()+60_000 });
|
|
84
|
+
return `${this.origin}/__hanamesh_bootstrap/${ticket}`;
|
|
85
|
+
}
|
|
86
|
+
#requestShape(req) {
|
|
87
|
+
if (req.headers.host !== new URL(this.origin).host) return false;
|
|
88
|
+
if (!req.url?.startsWith('/') || req.url.startsWith('//') || req.url.includes('\\') || req.url.length > 8_192) return false;
|
|
89
|
+
try { if (/^[\/\\]{2}|[\x00-\x1f]/.test(decodeURIComponent(req.url))) return false; } catch { return false; }
|
|
90
|
+
if (req.headers.origin && ![this.parentOrigin,this.origin].includes(req.headers.origin)) return false;
|
|
91
|
+
if (req.headers['sec-fetch-dest'] === 'document') return false; // never top-level navigation
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
#authorized(req) {
|
|
95
|
+
if (!this.#requestShape(req)) return false;
|
|
96
|
+
// Cookie capability + live lease. Origin/loopback alone never authenticate.
|
|
97
|
+
for (const [name,value] of cookies(req.headers.cookie)) {
|
|
98
|
+
if (!name.startsWith(this.cookiePrefix)) continue;
|
|
99
|
+
const grant = this.grants.get(value);
|
|
100
|
+
if (grant?.cookieName === name && this.isLeaseActive(grant.viewKey)) return true;
|
|
101
|
+
}
|
|
102
|
+
return this.#embeddedAuthorized(req);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Embedded webviews (Tauri/WKWebView, WebView2 with tracking prevention) never return
|
|
106
|
+
* the bootstrap cookie: the app frame is a third party under the shell's own top-level
|
|
107
|
+
* origin, so `SameSite=Strict`/third-party cookies are dropped and every request after
|
|
108
|
+
* the 303 arrived without a grant → 403 with `frame-ancestors 'none'` → a blank frame
|
|
109
|
+
* (2026-09-21, HanaMesh desktop rc.4 + Vibe). Fall back to Fetch Metadata, which the
|
|
110
|
+
* same webviews do send: a request is accepted without a cookie only when it comes from
|
|
111
|
+
* inside an app document already served by this gateway (`same-origin`), or is the
|
|
112
|
+
* post-bootstrap frame navigation initiated by the registered parent (`same-site` +
|
|
113
|
+
* `iframe` + parent referer), and a bootstrap ticket for a still-live lease has been
|
|
114
|
+
* consumed for this instance. Cross-site and address-bar (`none`) requests stay denied.
|
|
115
|
+
*/
|
|
116
|
+
#embeddedAuthorized(req) {
|
|
117
|
+
if (!this.#liveGrantExists()) return false;
|
|
118
|
+
const site = req.headers['sec-fetch-site'];
|
|
119
|
+
if (site === 'same-origin') return true;
|
|
120
|
+
// WebSocket handshakes: every browser sets `Origin` on them (not spoofable from web content); some
|
|
121
|
+
// WebKit builds omit Fetch Metadata there. The app's own origin is equivalent to same-origin.
|
|
122
|
+
if (req.headers.upgrade?.toLowerCase() === 'websocket' && req.headers.origin === this.origin) return true;
|
|
123
|
+
if (site !== 'same-site' || req.headers['sec-fetch-dest'] !== 'iframe') return false;
|
|
124
|
+
let referer;
|
|
125
|
+
try { referer = new URL(req.headers.referer).origin; } catch { return false; }
|
|
126
|
+
return referer === this.parentOrigin;
|
|
127
|
+
}
|
|
128
|
+
#liveGrantExists() {
|
|
129
|
+
for (const [value,g] of this.grants) { if (this.isLeaseActive(g.viewKey)) return true; this.grants.delete(value); }
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
#headers(req, upgrade = false) {
|
|
133
|
+
const headers = { ...req.headers, host:new URL(this.upstream).host };
|
|
134
|
+
const allowedCookies = [...cookies(req.headers.cookie)].filter(([key]) => this.cookieAllowlist.has(key));
|
|
135
|
+
if (allowedCookies.length) headers.cookie = allowedCookies.map(([k,v]) => `${k}=${v}`).join('; ');
|
|
136
|
+
else delete headers.cookie;
|
|
137
|
+
if (!this.allowAppAuthorization) delete headers.authorization;
|
|
138
|
+
for (const key of Object.keys(headers)) if (/^(?:x-dsh|x-hanamesh|proxy-authorization|proxy-authenticate)/i.test(key)) delete headers[key];
|
|
139
|
+
if (!upgrade) {
|
|
140
|
+
for (const key of String(req.headers.connection ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean)) delete headers[key];
|
|
141
|
+
delete headers.connection; delete headers['proxy-connection']; delete headers.upgrade;
|
|
142
|
+
}
|
|
143
|
+
return headers;
|
|
144
|
+
}
|
|
145
|
+
#bootstrap(req,res) {
|
|
146
|
+
const ticket = req.url.slice('/__hanamesh_bootstrap/'.length);
|
|
147
|
+
const item = this.tickets.get(ticket);
|
|
148
|
+
let referer;
|
|
149
|
+
try { referer = new URL(req.headers.referer).origin; } catch {}
|
|
150
|
+
if (req.method !== 'GET' || req.headers['sec-fetch-dest'] !== 'iframe' || referer !== this.parentOrigin ||
|
|
151
|
+
!item || item.expiresAt <= Date.now() || !this.isLeaseActive(item.viewKey)) return fail(res,403,'GATEWAY_BOOTSTRAP_DENIED');
|
|
152
|
+
this.tickets.delete(ticket);
|
|
153
|
+
const value = token(), suffix = createHash('sha256').update(item.viewKey).digest('hex').slice(0,16);
|
|
154
|
+
const cookieName = this.cookiePrefix + suffix;
|
|
155
|
+
for (const [old,g] of this.grants) if (g.cookieName === cookieName) this.grants.delete(old);
|
|
156
|
+
this.grants.set(value,{ viewKey:item.viewKey, cookieName });
|
|
157
|
+
// No `referrer-policy: no-referrer` here: browsers apply it to the redirect hop, so the
|
|
158
|
+
// `/` navigation that follows arrives without a Referer and the cookie-less embedded path
|
|
159
|
+
// (parent-referer + same-site iframe) can never match (WKWebView, 2026-09-21). The Referer
|
|
160
|
+
// of that hop is the parent document, never this ticket URL, so nothing leaks upstream.
|
|
161
|
+
res.writeHead(303, {
|
|
162
|
+
location:'/', 'set-cookie':`${cookieName}=${value}; Path=/; HttpOnly; SameSite=Strict`,
|
|
163
|
+
'cache-control':'no-store',
|
|
164
|
+
'content-security-policy':`default-src 'none'; frame-ancestors ${this.ancestors}`,
|
|
165
|
+
});
|
|
166
|
+
res.end();
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* rc.27 usage evidence: one call per request this gateway actually forwarded for an authorized view and the
|
|
170
|
+
* app answered with anything but 401/403. Bootstrap tickets and every gateway denial never reach here; the
|
|
171
|
+
* host's own readiness probes go to the upstream directly, never through the gateway. A throwing hook must
|
|
172
|
+
* never break the proxied response.
|
|
173
|
+
*/
|
|
174
|
+
#forwarded() { try { this.onForward(); } catch {} }
|
|
175
|
+
#handle(req,res) {
|
|
176
|
+
if (!this.#requestShape(req)) return fail(res,403,'GATEWAY_REQUEST_DENIED');
|
|
177
|
+
if (req.url.startsWith('/__hanamesh_bootstrap/')) return this.#bootstrap(req,res);
|
|
178
|
+
if (!this.#authorized(req)) return fail(res,403,'GATEWAY_AUTH_REQUIRED');
|
|
179
|
+
if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) return fail(res,405,'METHOD_NOT_ALLOWED');
|
|
180
|
+
if (Number(req.headers['content-length']) > this.maxUploadBytes) return fail(res,413,'UPLOAD_TOO_LARGE');
|
|
181
|
+
const upstream = request(this.upstream + req.url,{ method:req.method,headers:this.#headers(req) }, response => {
|
|
182
|
+
if (response.statusCode !== 401 && response.statusCode !== 403) this.#forwarded();
|
|
183
|
+
res.writeHead(response.statusCode, embeddingHeaders(response.rawHeaders,this.ancestors));
|
|
184
|
+
response.pipe(res, { end:false });
|
|
185
|
+
response.once('error', () => res.destroy());
|
|
186
|
+
response.once('end', () => { if (Object.keys(response.trailers).length) res.addTrailers(response.trailers); res.end(); });
|
|
187
|
+
});
|
|
188
|
+
this.upstreams.add(upstream); upstream.once('close',() => this.upstreams.delete(upstream));
|
|
189
|
+
upstream.setTimeout(120_000,() => upstream.destroy(new Error('Upstream idle timeout')));
|
|
190
|
+
upstream.once('error',() => { if (!res.headersSent) fail(res,502,'GATEWAY_UPSTREAM_FAILED'); else res.destroy(); });
|
|
191
|
+
let size = 0;
|
|
192
|
+
req.on('data', chunk => {
|
|
193
|
+
size += chunk.length;
|
|
194
|
+
if (size > this.maxUploadBytes) { if (!res.headersSent) fail(res,413,'UPLOAD_TOO_LARGE'); upstream.destroy(); }
|
|
195
|
+
});
|
|
196
|
+
req.once('aborted',() => upstream.destroy()); res.once('close',() => upstream.destroy());
|
|
197
|
+
req.pipe(upstream);
|
|
198
|
+
}
|
|
199
|
+
#upgrade(req,socket,head) {
|
|
200
|
+
if (!this.#authorized(req) || req.headers.upgrade?.toLowerCase() !== 'websocket') {
|
|
201
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
|
|
202
|
+
}
|
|
203
|
+
const upstream = request(this.upstream + req.url,{ method:'GET',headers:this.#headers(req,true) });
|
|
204
|
+
this.upstreams.add(upstream); upstream.once('close',() => this.upstreams.delete(upstream));
|
|
205
|
+
const timer = setTimeout(() => { upstream.destroy(); socket.destroy(); },10_000);
|
|
206
|
+
upstream.once('upgrade',(res,peer,upHead) => {
|
|
207
|
+
clearTimeout(timer); this.sockets.add(peer); peer.once('close',() => this.sockets.delete(peer)); this.#forwarded();
|
|
208
|
+
const lines = [`HTTP/1.1 ${res.statusCode} ${res.statusMessage}`];
|
|
209
|
+
for (let i=0;i<res.rawHeaders.length;i+=2) lines.push(`${res.rawHeaders[i]}: ${res.rawHeaders[i+1]}`);
|
|
210
|
+
socket.write(lines.join('\r\n')+'\r\n\r\n'); if (upHead.length) socket.write(upHead); if (head.length) peer.write(head);
|
|
211
|
+
peer.pipe(socket); socket.pipe(peer);
|
|
212
|
+
peer.on('error',() => socket.destroy()); socket.on('error',() => peer.destroy());
|
|
213
|
+
socket.once('close',() => peer.destroy()); peer.once('close',() => socket.destroy());
|
|
214
|
+
});
|
|
215
|
+
upstream.once('response',() => { clearTimeout(timer); upstream.destroy(); socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n'); });
|
|
216
|
+
upstream.once('error',() => { clearTimeout(timer); socket.destroy(); }); upstream.end();
|
|
217
|
+
}
|
|
218
|
+
async close() {
|
|
219
|
+
this.tickets.clear(); this.grants.clear();
|
|
220
|
+
for (const request of this.upstreams) request.destroy();
|
|
221
|
+
for (const socket of this.sockets) socket.destroy();
|
|
222
|
+
if (this.server?.listening) await bounded(new Promise((resolve,reject) => this.server.close(error => error ? reject(error) : resolve())),2_000,'Gateway shutdown');
|
|
223
|
+
}
|
|
224
|
+
}
|
package/dist/guardian.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// One guardian per owned runtime. It never attaches to an arbitrary PID.
|
|
2
|
+
// Losing the parent's IPC channel triggers cleanup, even when the host was SIGKILLed.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { FileLock } from './store.js';
|
|
7
|
+
import { guardianEnvironment, processIdentity } from './runtime.js';
|
|
8
|
+
if (process.versions.electron !== undefined) {
|
|
9
|
+
const refusal = { type:'guardian.refused', code:'NODE_RUNTIME_REQUIRED', message:'Guardian requires a standalone Node.js runtime.' };
|
|
10
|
+
if (process.connected) await new Promise(resolve => process.send(refusal, () => resolve()));
|
|
11
|
+
process.exit(78);
|
|
12
|
+
}
|
|
13
|
+
let child, lock, stopping, config, resolveAppExit;
|
|
14
|
+
const appExited = new Promise(resolve => { resolveAppExit = resolve; });
|
|
15
|
+
// The guardian must outlive a killed host. Its stdout/stderr pipes die with that host;
|
|
16
|
+
// EPIPE is transport loss, not a reason to abandon the exact process group it owns.
|
|
17
|
+
for (const output of [process.stdout,process.stderr]) output.on('error', error => {
|
|
18
|
+
if (error.code !== 'EPIPE') throw error;
|
|
19
|
+
});
|
|
20
|
+
const send = value => { if (process.connected) process.send(value, () => {}); };
|
|
21
|
+
function signalOwned(signal) {
|
|
22
|
+
if (!child?.pid) return;
|
|
23
|
+
try {
|
|
24
|
+
// The process group was created by this exact child handle (detached:true).
|
|
25
|
+
// This is not process-name matching and is never used for attach instances.
|
|
26
|
+
if (process.platform !== 'win32') process.kill(-child.pid, signal);
|
|
27
|
+
else if (child.exitCode === null && child.signalCode === null) child.kill(signal);
|
|
28
|
+
} catch (e) { if (e.code !== 'ESRCH') throw e; }
|
|
29
|
+
}
|
|
30
|
+
function ownedGroupAlive() {
|
|
31
|
+
if (!child?.pid) return false;
|
|
32
|
+
if (process.platform === 'win32') return child.exitCode === null && child.signalCode === null;
|
|
33
|
+
try { process.kill(-child.pid, 0); return true; }
|
|
34
|
+
catch (error) { if (error.code === 'ESRCH') return false; if (error.code === 'EPERM') return true; throw error; }
|
|
35
|
+
}
|
|
36
|
+
async function waitForOwnedGroupExit(timeoutMs) {
|
|
37
|
+
const deadline = Date.now() + timeoutMs;
|
|
38
|
+
while (ownedGroupAlive() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
|
|
39
|
+
return !ownedGroupAlive();
|
|
40
|
+
}
|
|
41
|
+
async function cleanup(reason) {
|
|
42
|
+
if (stopping) return stopping;
|
|
43
|
+
stopping = (async () => {
|
|
44
|
+
let timer, confirmed = !child?.pid;
|
|
45
|
+
if (child?.pid) {
|
|
46
|
+
const ended = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() :
|
|
47
|
+
new Promise(resolve => child.once('close', resolve));
|
|
48
|
+
signalOwned('SIGTERM');
|
|
49
|
+
// The launcher deliberately survives TERM to anchor its owned process group.
|
|
50
|
+
// Its close event therefore cannot be the normal app's graceful-exit signal.
|
|
51
|
+
await Promise.race([ended, appExited, new Promise(resolve => { timer = setTimeout(resolve, config.stopGraceMs); })]);
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
// A descendant can hold the process group after its leader has exited.
|
|
54
|
+
// Kill only the group originally created by this guardian.
|
|
55
|
+
signalOwned('SIGKILL');
|
|
56
|
+
confirmed = process.platform === 'win32' ? await Promise.race([
|
|
57
|
+
ended.then(() => true), new Promise(resolve => { timer = setTimeout(() => resolve(false), 2_000); }),
|
|
58
|
+
]) : await waitForOwnedGroupExit(2_000);
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
if (!confirmed) throw new Error('Owned process group termination was not confirmed; runtime lock retained.');
|
|
62
|
+
if (lock) await lock.release();
|
|
63
|
+
if (process.connected) await new Promise(resolve => process.send({ type:'stopped', reason }, () => resolve()));
|
|
64
|
+
process.exit(0);
|
|
65
|
+
})().catch(error => { send({ type: 'error', code: error.code ?? 'GUARDIAN_FAILURE', message: error.message, details: error.details ?? {} }); process.exit(1); });
|
|
66
|
+
return stopping;
|
|
67
|
+
}
|
|
68
|
+
process.on('disconnect', () => void cleanup('host-disconnected'));
|
|
69
|
+
process.on('SIGTERM', () => void cleanup('guardian-term'));
|
|
70
|
+
process.on('SIGINT', () => void cleanup('guardian-int'));
|
|
71
|
+
process.on('message', async message => {
|
|
72
|
+
if (message?.type === 'stop') { void cleanup('requested'); return; }
|
|
73
|
+
if (message?.type !== 'launch' || config) return;
|
|
74
|
+
config = message.config;
|
|
75
|
+
try {
|
|
76
|
+
if (!process.connected || stopping) return;
|
|
77
|
+
// A force-killed guardian leaves this lock behind. The record names this pid + start token and
|
|
78
|
+
// the children created below, so the next guardian can prove the stale owner dead (or its
|
|
79
|
+
// orphans ours) instead of failing DATA_ROOT_BUSY until a human deletes the file.
|
|
80
|
+
lock = new FileLock(join(config.dataDir, '.runtime.lock'), { reclaimDead: true, orphanGraceMs: config.stopGraceMs });
|
|
81
|
+
await lock.acquire();
|
|
82
|
+
if (!process.connected || stopping) { await lock.release(); return; }
|
|
83
|
+
send({ type:'ownership-ready' });
|
|
84
|
+
child = spawn(config.nodeBinary, [fileURLToPath(new URL('./launcher.js', import.meta.url))], {
|
|
85
|
+
env: guardianEnvironment(config.nodeBinary), shell: false,
|
|
86
|
+
detached: true, stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
87
|
+
});
|
|
88
|
+
child.stdout.pipe(process.stdout); child.stderr.pipe(process.stderr);
|
|
89
|
+
const record = async (pid, role, command, group) => {
|
|
90
|
+
const live = await processIdentity(pid);
|
|
91
|
+
await lock.annotate({ child: { pid, role, command, group, start: live?.start ?? null }, port: config.port ?? null });
|
|
92
|
+
};
|
|
93
|
+
// The launcher (group leader) is recorded before it is told to launch anything.
|
|
94
|
+
child.once('spawn', () => record(child.pid, 'launcher', config.nodeBinary, true).then(() => child.send({type:'launch',config}, error => {
|
|
95
|
+
if(error){send({type:'error',code:'SPAWN_FAILED',message:error.message});void cleanup('ipc-error');}
|
|
96
|
+
}), error => { send({type:'error',code:error.code ?? 'SPAWN_FAILED',message:error.message}); void cleanup('lock-annotate-error'); }));
|
|
97
|
+
child.on('message', message => {
|
|
98
|
+
if(message.type==='spawned')record(message.pid,'app',config.command,false).then(()=>send({type:'spawned',pid:message.pid,groupId:child.pid,
|
|
99
|
+
guardianBinary:process.execPath,launcherBinary:message.launcherBinary}),
|
|
100
|
+
error=>{send({type:'error',code:error.code ?? 'GUARDIAN_FAILURE',message:error.message});void cleanup('lock-annotate-error');});
|
|
101
|
+
if(message.type==='app-exit'){resolveAppExit();send(message);if(!stopping)void cleanup('app-exited');}
|
|
102
|
+
if(message.type==='error'){send(message);void cleanup('spawn-error');}
|
|
103
|
+
});
|
|
104
|
+
child.once('error', error => { send({ type: 'error', code: 'SPAWN_FAILED', message: error.message }); void cleanup('spawn-error'); });
|
|
105
|
+
child.once('exit', (code, signal) => {
|
|
106
|
+
if(!stopping){send({type:'app-exit',code,signal});void cleanup('group-leader-exited');}
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
send({ type: 'error', code: error.code ?? 'SPAWN_FAILED', message: error.message, details: error.details ?? {} });
|
|
110
|
+
void cleanup('setup-error');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
send({ type: 'guardian-ready' });
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
2
|
+
export type InstanceStatus = 'reserved'|'starting'|'ready'|'stopping'|'stopped'|'failed'|'interrupted';
|
|
3
|
+
export type LeaseStatus = 'active'|'closed'|'expired'|'stopped'|'failed';
|
|
4
|
+
export interface Readiness { path:string; status:number; bodyIncludes?:string; header?:{name:string;value:string}; }
|
|
5
|
+
export interface GatewayPolicy { cookieAllowlist?:string[]; allowAppAuthorization?:boolean; }
|
|
6
|
+
interface DeploymentBase { id:string; dataId:string; readiness:Readiness; embedding?:'direct'|'gateway'; startTimeoutMs?:number; stopGraceMs?:number; gateway?:GatewayPolicy; }
|
|
7
|
+
/** rc.4: what an app needs, never where it comes from. `projection:'env'` names a process variable; `projection:'file'` a path relative to the app HOME. */
|
|
8
|
+
export type CredentialEnvEntry =
|
|
9
|
+
{ env:string; kind?:'api-key'|'grant'; providers?:string[]; required?:boolean; purpose?:string; projection?:'env'; sets?:Record<string,string> }
|
|
10
|
+
| { path:string; kind?:'api-key'|'grant'; providers?:string[]; required?:boolean; purpose?:string; projection:'file'; base?:'home'|'dataDir'; format?:string; sets?:Record<string,string> };
|
|
11
|
+
/** rc.8 `sets`: non-secret companion env applied by the broker when this slot is granted (each key must be a declared env slot). Value `{{provider}}` is replaced with the granted credential's provider id. */
|
|
12
|
+
/** `base`: where `path` is rooted — the app HOME (`<dataDir>/home`, default) or the data directory itself (e.g. a `$XXX_HOME={{dataDir}}` app). `format`: opaque id the credential broker maps a grant to (e.g. `codex-cli-auth-json`, `oauth-cli-kit`); the host never interprets it. */
|
|
13
|
+
export interface CredentialResolverInput { appId:string; deploymentId:string; instanceId:string; principalId:string; credentialEnv:CredentialEnvEntry[]; }
|
|
14
|
+
/** File projection policy (rc.6): `if-absent` (default) never clobbers a file the app rotated itself; `overwrite` replaces it (new grant version); `remove` deletes it (revoke / app-owned). The host never reads the file back. Events: `credential.injected` (env names + written files), `credential.file-kept`, `credential.file-removed`, `credential.env-rejected`, `credential.resolver-failed`. rc.7: a stopped instance adopts a changed app definition on open (`instance.definition-adopted`); only a running one refuses with `DEFINITION_CHANGED` (409). */
|
|
15
|
+
export type CredentialFilePolicy = 'if-absent'|'overwrite'|'remove';
|
|
16
|
+
export interface CredentialResolverResult { env?:Record<string,string>; files?:Array<{path:string;content?:string;mode?:number;policy?:CredentialFilePolicy}>; secrets?:string[]; }
|
|
17
|
+
/** Seated by the credential broker (plugin-auth-apikey). Undefined / throw = inject nothing; the launch proceeds. */
|
|
18
|
+
export type CredentialResolver = (input:CredentialResolverInput)=>Awaitable<CredentialResolverResult|undefined>;
|
|
19
|
+
export interface RuntimeManifestItem { id:string; version:string; kind:'tar.gz'|'zip'; installTo:string; platforms:Record<string,{asset:string;sha256:string;size?:number;strip?:number;url?:string}>; }
|
|
20
|
+
export interface RuntimeManifest { schema:1; sources:Array<{id:string;kind:'https'|'file';base:string}>; items:RuntimeManifestItem[]; }
|
|
21
|
+
export interface RuntimeSelection { manifest:RuntimeManifest; item:string; exec:string; }
|
|
22
|
+
interface OwnedDeploymentBase extends DeploymentBase { mode:'owned'; args:string[]; cwd?:string; env?:Record<string,string>; envAllowlist?:string[]; credentialEnv?:CredentialEnvEntry[]; }
|
|
23
|
+
export type OwnedDeployment = OwnedDeploymentBase & ({ command:string; runtime?:never }|{ command?:never; runtime:RuntimeSelection });
|
|
24
|
+
export interface AttachedDeployment extends DeploymentBase { mode:'attach'; url:string; }
|
|
25
|
+
export type Deployment = OwnedDeployment|AttachedDeployment;
|
|
26
|
+
/** rc.27 `packageName`: the npm package shipping this app — the usage-evidence `hanaRef`. Optional; the installed-package scan binds it otherwise (`AppHost.bindPackageName`). */
|
|
27
|
+
export interface AppDefinition { id:string; name:string; singleInstanceOnly?:boolean; packageName?:string; deployments:Deployment[]; }
|
|
28
|
+
export interface InstanceRecord {
|
|
29
|
+
id:string; appId:string; deploymentId:string; dataId:string; principalId:string; dataDir:string; mode:'owned'|'attach';
|
|
30
|
+
definitionHash:string; status:InstanceStatus; runtimeId:string|null; endpoint:string|null; gatewayOrigin:string|null;
|
|
31
|
+
pid:number|null; guardianPid:number|null; createdAt:number; updatedAt:number; errorCode?:string;
|
|
32
|
+
}
|
|
33
|
+
export interface ViewLease { principalId:string; viewId:string; instanceId:string; generation:number; status:LeaseStatus; expiresAt:number; createdAt:number; originalSessionId:string|null; }
|
|
34
|
+
export interface StoredLease extends ViewLease { tokenHash:string; }
|
|
35
|
+
export interface HostEvent { sequence:number; at:number; type:string; instanceId:string; principalId:string; viewId?:string; generation?:number; [key:string]:unknown; }
|
|
36
|
+
/** rc.27: a request the instance's gateway forwarded for an authorized view (never bootstrap, denials, 401/403 or host probes). In-memory only. */
|
|
37
|
+
export interface InstanceActivity { type:'instance.activity'; instanceId:string; appId:string; deploymentId:string; principalId:string; at:number; }
|
|
38
|
+
export interface Snapshot { schema:1; revision:number; sequence:number; instances:InstanceRecord[]; leases:StoredLease[]; events:HostEvent[]; }
|
|
39
|
+
export interface SnapshotStore { init():Promise<void>; load():Promise<Snapshot>; save(snapshot:Snapshot):Promise<void>; close():Promise<void>; }
|
|
40
|
+
export interface OpenRequest { appId:string; deploymentId:string; viewId:string; leaseToken?:string; instanceId?:string; originalSessionId?:string; }
|
|
41
|
+
export interface LeaseRequest { viewId:string; leaseToken:string; }
|
|
42
|
+
export interface RecoverRequest { viewId:string; instanceId:string; confirm:true; }
|
|
43
|
+
export interface OpenReceipt { instance:InstanceRecord; lease:ViewLease; leaseToken:string; uiUrl:string|null; originalSessionId:string|null; }
|
|
44
|
+
export interface AppListing {
|
|
45
|
+
contractVersion:1; apps:Array<{id:string;name:string;singleInstanceOnly:boolean;packageName:string|null;deployments:Array<{id:string;dataId:string;mode:'owned'|'attach';embedding:'direct'|'gateway';credentialEnv:CredentialEnvEntry[]}>}>;
|
|
46
|
+
instances:InstanceRecord[]; views:ViewLease[]; sequence:number;
|
|
47
|
+
}
|
|
48
|
+
export interface EventPage { events:HostEvent[]; sequence:number; resetRequired:boolean; }
|
|
49
|
+
export interface HostOptions {
|
|
50
|
+
store:SnapshotStore; dataRoot:string; parentOrigin:string; leaseTtlMs?:number; sweepIntervalMs?:number;
|
|
51
|
+
nodeBinary?:string; clock?:()=>number; checkpoint?:(point:string,details:Record<string,unknown>)=>Awaitable<void>; credentialResolver?:CredentialResolver|null;
|
|
52
|
+
runtimeLedgerReader?:(root:string)=>Awaitable<{schema:1;items:Record<string,{version:string}>}>;
|
|
53
|
+
}
|
|
54
|
+
export class AppHost {
|
|
55
|
+
constructor(options:HostOptions);
|
|
56
|
+
/** rc.4: seat or clear the credential broker; returns a disposer that clears it only if still the same function. */
|
|
57
|
+
setCredentialResolver(resolver:CredentialResolver|null):()=>void;
|
|
58
|
+
register(definition:AppDefinition):{appId:string;definitionHash:string}; init():Promise<this>;
|
|
59
|
+
/** rc.27: bind the npm package that ships `appId` (from the installed scan); a definition-declared `packageName` wins. */
|
|
60
|
+
bindPackageName(appId:string,packageName:string):void;
|
|
61
|
+
/** Usage-evidence `hanaRef` for an app, or null when unknown (then no evidence is reported for it). */
|
|
62
|
+
packageName(appId:string):string|null;
|
|
63
|
+
/** rc.27: real gateway activity per instance; not persisted, not in `eventsSince`. Returns a disposer. */
|
|
64
|
+
onActivity(listener:(activity:InstanceActivity)=>void):()=>void;
|
|
65
|
+
beginOpen(input:OpenRequest,principalId?:string):Promise<OpenReceipt>;
|
|
66
|
+
open(input:OpenRequest,principalId?:string):Promise<OpenReceipt>; start(input:OpenRequest,principalId?:string):Promise<OpenReceipt>;
|
|
67
|
+
resume(input:LeaseRequest,principalId?:string,options?:{waitForReady?:boolean}):Promise<OpenReceipt>;
|
|
68
|
+
recoverView(input:RecoverRequest,principalId?:string):Promise<OpenReceipt>;
|
|
69
|
+
heartbeat(input:LeaseRequest,principalId?:string):Promise<ViewLease>;
|
|
70
|
+
close(input:LeaseRequest,principalId?:string):Promise<{instanceId:string;alreadyClosed:boolean}>;
|
|
71
|
+
stop(instanceId:string,options?:{confirm?:boolean},principalId?:string):Promise<InstanceRecord>; stopAll():Promise<void>;
|
|
72
|
+
sweepLeases():Promise<void>; instance(id:string,principalId?:string):InstanceRecord|null; instanceList(principalId?:string):InstanceRecord[];
|
|
73
|
+
list(principalId?:string):AppListing; eventsSince(sequence?:number,principalId?:string):EventPage;
|
|
74
|
+
subscribe(listener:(event:HostEvent|{type:'host.error';code:string})=>void):()=>void;
|
|
75
|
+
logTail(id:string,limit?:number,principalId?:string):Array<{at:number;stream:string;text:string}>; dispose():Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
export class AppHostError extends Error { constructor(code:string,message:string,details?:Record<string,unknown>,status?:number); code:string; details:Record<string,unknown>; status:number; }
|
|
78
|
+
export class AtomicFileStore implements SnapshotStore {
|
|
79
|
+
constructor(root:string,options?:{checkpoint?:(point:string,snapshot:Snapshot)=>Awaitable<void>}); readonly path:string;
|
|
80
|
+
init():Promise<void>; load():Promise<Snapshot>; save(snapshot:Snapshot):Promise<void>; close():Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
export interface DshStorageBinding {
|
|
83
|
+
layout:'single'; domain:'hanamesh_app_host'; readSnapshot():Promise<Snapshot|null|undefined>;
|
|
84
|
+
publishSnapshot(snapshot:Snapshot):Promise<void>; acquireExclusive():Promise<void>; releaseExclusive():Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
export class DshDomainSnapshotStore implements SnapshotStore {
|
|
87
|
+
constructor(binding:DshStorageBinding); init():Promise<void>; load():Promise<Snapshot>; save(snapshot:Snapshot):Promise<void>; close():Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
export interface AuthenticatedSubject { principalId:string; [key:string]:unknown; }
|
|
90
|
+
export interface HttpOptions<Request=unknown> {
|
|
91
|
+
parentOrigin:string; authenticate(request:Request):Awaitable<AuthenticatedSubject|null>;
|
|
92
|
+
authorize(subject:AuthenticatedSubject,path:string,input:unknown):Awaitable<boolean>;
|
|
93
|
+
}
|
|
94
|
+
export function createHttpHandler<Request=unknown,Response=unknown>(host:AppHost,options:HttpOptions<Request>):(request:Request,response:Response)=>Promise<void>;
|
|
95
|
+
export class FixedGateway {
|
|
96
|
+
constructor(options:{upstream:string;parentOrigin:string;isLeaseActive:(viewKey:string)=>boolean;cookieAllowlist?:string[];allowAppAuthorization?:boolean;maxUploadBytes?:number;frameAncestors?:string[];
|
|
97
|
+
/** rc.27: called once per forwarded request the upstream answered with anything but 401/403 (and per accepted WebSocket upgrade). */
|
|
98
|
+
onForward?:()=>void});
|
|
99
|
+
origin:string; start():Promise<string>; issue(viewKey:string):string; close():Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
export function rewriteCsp(value:string,parentOrigin:string):string;
|
|
102
|
+
export function embeddingHeaders(rawHeaders:string[],parentOrigin:string):string[];
|
|
103
|
+
export function validateDefinition(input:AppDefinition):AppDefinition;
|
|
104
|
+
/** rc.27 usage evidence: `open` once per instance reaching ready, `use` once per app per UTC hour of real gateway activity, through the optional usage seat. */
|
|
105
|
+
export const SOURCE_PLUGIN:'@hanamesh/dsh-app-host';
|
|
106
|
+
export function hourBucket(ms:number):string;
|
|
107
|
+
/** T6 usage receipt riding on a `use` event: provider × model × forwarded-request count of that UTC hour; never content. */
|
|
108
|
+
export interface UsageReceipt { providerId:string; model:string|null; count:number; }
|
|
109
|
+
export interface UsageRecordInput { hanaRef:string; action:'open'|'use'; occurredAt?:string; idempotencyKey:string; sourcePlugin:string; sourceHanaRef?:string; targetRef?:string; receipt?:UsageReceipt; }
|
|
110
|
+
export interface UsageRecordResult { disposition:'recorded'|'duplicate'|'withheld'|'rejected'; eventId?:string; code?:string; }
|
|
111
|
+
export interface UsageSeat { record(input:UsageRecordInput):Awaitable<UsageRecordResult>; }
|
|
112
|
+
/** T6 receipt ledger (storage domain `hanamesh_router_receipts`): hourly rows per app × Router route, reported through the usage seat when the hour closes. */
|
|
113
|
+
export interface ReceiptRoute { providerId:string; model:string|null; }
|
|
114
|
+
export interface ReceiptRow { appId:string; providerId:string|null; model:string|null; hour:string; count:number; injections:number; firstAt:string; lastAt:string; reported:boolean; }
|
|
115
|
+
export interface ReceiptLedger {
|
|
116
|
+
inject(input:{appId:string;instanceId:string;routes?:readonly {providerId:string;model?:string|null}[];at?:number}):Record<string,unknown>|null;
|
|
117
|
+
activity(input:{appId:string;instanceId:string;at?:number}):Record<string,unknown>;
|
|
118
|
+
forget(instanceId:string):void; routeOf(instanceId:string):ReceiptRoute|null;
|
|
119
|
+
pending(options?:{appId?:string;includeCurrent?:boolean;now?:number}):{appId:string;hour:string;occurredAt:number;count:number;receipt:UsageReceipt|null}[];
|
|
120
|
+
markReported(appId:string,hour:string):number; list(options?:{appId?:string}):ReceiptRow[]; size():number;
|
|
121
|
+
persist():Promise<void>; close():Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
export function createReceiptLedger(options?:{domain?:{global:{get():unknown;set(value:unknown):Promise<void>}};clock?:()=>number;persistDelayMs?:number;maxItems?:number;retentionMs?:number}):ReceiptLedger;
|
|
124
|
+
export function normalizeRoute(route:unknown):ReceiptRoute|null;
|
|
125
|
+
export function createUsageEvidence(options:{host:AppHost;seat:()=>UsageSeat|unknown;receipts?:ReceiptLedger;logger?:{debug?:(...args:unknown[])=>void};clock?:()=>number;sweepIntervalMs?:number}):{
|
|
126
|
+
flush(options?:{appId?:string;includeCurrent?:boolean}):Promise<void>; settle():Promise<void>; receipts:ReceiptLedger; close():void;
|
|
127
|
+
};
|
|
128
|
+
export const name:'hanamesh-app-host';
|
|
129
|
+
export const inject:readonly ['webServer','storageDomain','connection'];
|
|
130
|
+
export function apply(ctx:unknown,config?:import('./dsh.js').DshPluginConfig):Promise<void>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { AppHost } from './manager.js';
|
|
2
|
+
export { AppHostError } from './errors.js';
|
|
3
|
+
export { AtomicFileStore, DshDomainSnapshotStore } from './store.js';
|
|
4
|
+
export { createHttpHandler } from './routes.js';
|
|
5
|
+
export { FixedGateway, rewriteCsp, embeddingHeaders } from './gateway.js';
|
|
6
|
+
export { validateDefinition } from './descriptor.js';
|
|
7
|
+
export { createUsageEvidence, hourBucket, SOURCE_PLUGIN } from './usage-evidence.js';
|
|
8
|
+
export { createReceiptLedger, normalizeRoute } from './router/receipts.js';
|
|
9
|
+
/** Bundle root: core API plus a lazy Cordis plugin face, so client-modules can discover this package. */
|
|
10
|
+
export const name = 'hanamesh-app-host';
|
|
11
|
+
export const inject = ['webServer', 'storageDomain', 'connection'];
|
|
12
|
+
export async function apply(ctx, config = {}) {
|
|
13
|
+
const plugin = await import('./dsh.js');
|
|
14
|
+
return await plugin.apply(ctx, config);
|
|
15
|
+
}
|
package/dist/launcher.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A live group leader prevents process-group-ID reuse during cleanup.
|
|
2
|
+
// It intentionally remains alive until the guardian terminates the owned group.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
const send=value=>{if(process.connected)process.send(value,()=>{});};
|
|
5
|
+
let started=false;
|
|
6
|
+
process.on('SIGTERM',()=>{});
|
|
7
|
+
process.on('SIGINT',()=>{});
|
|
8
|
+
process.on('message',message=>{
|
|
9
|
+
if(started||message?.type!=='launch')return;started=true;
|
|
10
|
+
const c=message.config;
|
|
11
|
+
const app=spawn(c.command,c.args,{cwd:c.cwd,env:c.env,shell:false,detached:false,stdio:['ignore','inherit','inherit']});
|
|
12
|
+
app.once('spawn',()=>send({type:'spawned',pid:app.pid,launcherBinary:process.execPath}));
|
|
13
|
+
app.once('error',e=>send({type:'error',code:'SPAWN_FAILED',message:e.message}));
|
|
14
|
+
app.once('exit',(code,signal)=>send({type:'app-exit',code,signal}));
|
|
15
|
+
});
|
|
16
|
+
// The guardian owns this handle and is responsible for reaping it.
|
|
17
|
+
setInterval(()=>{},60_000);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { AppHostError, requireCondition } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
const MAX_BYTES=2*1024*1024;
|
|
5
|
+
const plain=value=>typeof value==='string'&&value.length>0&&!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value);
|
|
6
|
+
const own=(value,keys)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).every(key=>keys.includes(key));
|
|
7
|
+
|
|
8
|
+
export function validateManifestUrl(value){
|
|
9
|
+
let url;try{url=new URL(value);}catch{throw new AppHostError('INVALID_CATALOG_URL','Catalog manifest URL is invalid.');}
|
|
10
|
+
requireCondition(url.protocol==='https:'&&!url.port&&!url.username&&!url.password&&!url.search&&!url.hash,
|
|
11
|
+
'INVALID_CATALOG_URL','Catalog manifests require standard-port HTTPS without credentials, query, or fragment.');
|
|
12
|
+
return url;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateCatalogManifest(value){
|
|
16
|
+
requireCondition(own(value,['manifestVersion','providerId','name','description','homepage','attribution','transport','query'])&&
|
|
17
|
+
value.manifestVersion==='1.0.0'&&/^[a-z0-9]+(?:[.-][a-z0-9]+)+$/.test(value.providerId)&&plain(value.name)&&
|
|
18
|
+
own(value.attribution,['name','url','notice'])&&plain(value.attribution.name)&&
|
|
19
|
+
own(value.transport,['kind','endpoint','method'])&&value.transport.kind==='https-json'&&value.transport.method==='GET'&&
|
|
20
|
+
own(value.query,['supported','defaultLimit','maxLimit','sorts'])&&Array.isArray(value.query.supported)&&Array.isArray(value.query.sorts)&&
|
|
21
|
+
Number.isInteger(value.query.defaultLimit)&&value.query.defaultLimit>=1&&value.query.defaultLimit<=200&&
|
|
22
|
+
Number.isInteger(value.query.maxLimit)&&value.query.maxLimit>=value.query.defaultLimit&&value.query.maxLimit<=200,
|
|
23
|
+
'INVALID_CATALOG_MANIFEST','Catalog source manifest does not match schema 1.0.0.');
|
|
24
|
+
validateManifestUrl(value.attribution.url);
|
|
25
|
+
const endpoint=validateManifestUrl(value.transport.endpoint);
|
|
26
|
+
requireCondition(endpoint.pathname.endsWith('/v1/plugins'),'INVALID_CATALOG_MANIFEST','Catalog endpoint must end in /v1/plugins.');
|
|
27
|
+
return structuredClone(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validateItem(item){
|
|
31
|
+
// Key set = schemas/catalog-provider-page.schema.json `item` (incl. optional `updatedAt`; the production source emits it — 2026-09-20).
|
|
32
|
+
requireCondition(own(item,['id','name','displayName','summary','description','homepage','latestVersion','license','categories','keywords','capabilities','compatibility','repository','installSource','package','publisher','media','updatedAt'])&&(item.updatedAt===undefined||(typeof item.updatedAt==='string'&&Number.isFinite(Date.parse(item.updatedAt))))&&
|
|
33
|
+
plain(item.id)&&item.id.length<=160&&plain(item.name)&&plain(item.displayName)&&plain(item.summary)&&
|
|
34
|
+
(item.categories===undefined||(Array.isArray(item.categories)&&new Set(item.categories).size===item.categories.length&&item.categories.every(x=>/^[a-z0-9][a-z0-9._:-]{0,63}$/.test(x))))&&
|
|
35
|
+
(item.package===undefined||(own(item.package,['registry','name'])&&item.package.registry==='npm'&&/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(item.package.name))),
|
|
36
|
+
'INVALID_CATALOG_PAGE','Catalog item does not match schema 1.0.0.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateProviderPage(value){
|
|
40
|
+
requireCondition(own(value,['schemaVersion','generatedAt','revision','items','page'])&&value.schemaVersion==='1.0.0'&&
|
|
41
|
+
Array.isArray(value.items)&&value.items.length<=200&&own(value.page,['nextCursor','total']),
|
|
42
|
+
'INVALID_CATALOG_PAGE','Catalog provider page does not match schema 1.0.0.');
|
|
43
|
+
for(const item of value.items)validateItem(item);
|
|
44
|
+
return structuredClone(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isApplication(item){return Boolean(item?.categories?.includes('hanamesh-app')&&item?.package?.registry==='npm');}
|
|
48
|
+
/** rc.28 market: an npm-backed entry that is not a HanaMesh application installs as a plain DSH plugin (`dsh plugin add`). */
|
|
49
|
+
export function isPlugin(item){return Boolean(item?.package?.registry==='npm'&&!isApplication(item));}
|
|
50
|
+
/** `application` | `plugin` | `listing` (repository-only / no npm package: browse only). */
|
|
51
|
+
export function itemKind(item){return isApplication(item)?'application':isPlugin(item)?'plugin':'listing';}
|
|
52
|
+
|
|
53
|
+
/** Exact semver compare without a dependency: numeric core, then a prerelease sorts below its release; unparsable → 0. */
|
|
54
|
+
export function compareVersions(a,b){
|
|
55
|
+
const parse=value=>{const m=/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value??'').trim());return m&&{core:[+m[1],+m[2],+m[3]],pre:m[4]?m[4].split('.'):null};};
|
|
56
|
+
const x=parse(a),y=parse(b);if(!x||!y)return 0;
|
|
57
|
+
for(let i=0;i<3;i++)if(x.core[i]!==y.core[i])return x.core[i]<y.core[i]?-1:1;
|
|
58
|
+
if(!x.pre&&!y.pre)return 0;if(!x.pre)return 1;if(!y.pre)return -1;
|
|
59
|
+
for(let i=0;i<Math.max(x.pre.length,y.pre.length);i++){const p=x.pre[i],q=y.pre[i];if(p===undefined)return -1;if(q===undefined)return 1;const np=/^\d+$/.test(p),nq=/^\d+$/.test(q);
|
|
60
|
+
if(np&&nq){if(+p!==+q)return +p<+q?-1:1;}else if(np!==nq)return np?-1:1;else if(p!==q)return p<q?-1:1;}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
/** `latestVersion` of the catalog entry is strictly newer than the installed version. */
|
|
64
|
+
export function upgradeAvailable(latestVersion,installedVersion){return compareVersions(latestVersion,installedVersion)>0;}
|
|
65
|
+
|
|
66
|
+
async function responseJson(response,label){
|
|
67
|
+
requireCondition(response.ok,'CATALOG_HTTP_ERROR',`${label} returned HTTP ${response.status}.`,{status:response.status},502);
|
|
68
|
+
const contentType=response.headers.get('content-type')?.split(';')[0].trim().toLowerCase()??'';
|
|
69
|
+
requireCondition(/^application\/(?:json|.+\+json)$/.test(contentType)&&!response.headers.get('content-encoding'),
|
|
70
|
+
'CATALOG_RESPONSE_DENIED',`${label} must be unencoded JSON.`,{},502);
|
|
71
|
+
const declared=Number(response.headers.get('content-length')??0);
|
|
72
|
+
requireCondition(!declared||declared<=MAX_BYTES,'CATALOG_RESPONSE_TOO_LARGE',`${label} exceeds 2 MiB.`,{},502);
|
|
73
|
+
const bytes=new Uint8Array(await response.arrayBuffer());
|
|
74
|
+
requireCondition(bytes.byteLength<=MAX_BYTES,'CATALOG_RESPONSE_TOO_LARGE',`${label} exceeds 2 MiB.`,{},502);
|
|
75
|
+
try{return JSON.parse(new TextDecoder().decode(bytes));}catch{throw new AppHostError('INVALID_CATALOG_JSON',`${label} returned malformed JSON.`,{},502);}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function sameOriginFetch(start,fetchImpl,label){
|
|
79
|
+
const origin=start.origin;let current=start;
|
|
80
|
+
for(let redirects=0;redirects<=3;redirects++){
|
|
81
|
+
const response=await fetchImpl(current,{redirect:'manual',headers:{accept:'application/json','accept-encoding':'identity'}});
|
|
82
|
+
if([301,302,303,307,308].includes(response.status)){
|
|
83
|
+
requireCondition(redirects<3,'CATALOG_REDIRECT_DENIED','Catalog redirected more than three times.',{},502);
|
|
84
|
+
const location=response.headers.get('location');requireCondition(location,'CATALOG_REDIRECT_DENIED','Catalog redirect lacks a location.',{},502);
|
|
85
|
+
const next=new URL(location,current);requireCondition(next.origin===origin,'CATALOG_REDIRECT_DENIED','Catalog redirects must remain on the approved origin.',{},502);
|
|
86
|
+
current=next;continue;
|
|
87
|
+
}
|
|
88
|
+
return{response,url:current};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function loadCatalog({fixture,sources=[],fetchImpl=globalThis.fetch,query='',category='',cursor}={}){
|
|
93
|
+
if(fixture){
|
|
94
|
+
requireCondition(typeof fixture==='string','INVALID_FIXTURE','Catalog fixture path is required.');
|
|
95
|
+
let value;try{value=JSON.parse(await readFile(fixture,'utf8'));}catch(error){throw new AppHostError('INVALID_FIXTURE','Catalog fixture could not be read.',{code:error.code});}
|
|
96
|
+
return{source:{kind:'fixture',path:fixture},...validateProviderPage(value)};
|
|
97
|
+
}
|
|
98
|
+
const enabled=sources.filter(source=>source?.enabled!==false);
|
|
99
|
+
requireCondition(enabled.length===1,'CATALOG_SOURCE_REQUIRED','Exactly one catalog source must be enabled.');
|
|
100
|
+
const manifestUrl=validateManifestUrl(enabled[0].manifestUrl);
|
|
101
|
+
const manifestResponse=await sameOriginFetch(manifestUrl,fetchImpl,'Catalog manifest');
|
|
102
|
+
const manifest=validateCatalogManifest(await responseJson(manifestResponse.response,'Catalog manifest'));
|
|
103
|
+
const endpoint=validateManifestUrl(manifest.transport.endpoint);
|
|
104
|
+
requireCondition(endpoint.origin===manifestUrl.origin,'INVALID_CATALOG_MANIFEST','Catalog endpoint must share the manifest origin.');
|
|
105
|
+
if(query)endpoint.searchParams.set('q',String(query).slice(0,200));if(category)endpoint.searchParams.set('category',String(category).slice(0,64));if(cursor)endpoint.searchParams.set('cursor',String(cursor).slice(0,2048));endpoint.searchParams.set('limit','50');
|
|
106
|
+
const pageResponse=await sameOriginFetch(endpoint,fetchImpl,'Catalog page');
|
|
107
|
+
return{source:{kind:'remote',manifestUrl:manifestUrl.href,manifest},...validateProviderPage(await responseJson(pageResponse.response,'Catalog page'))};
|
|
108
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineDomain } from '@deepseek-ai/dsh-storage-domain';
|
|
2
|
+
import { z as zod } from 'zod';
|
|
3
|
+
|
|
4
|
+
const source=zod.object({manifestUrl:zod.string(),enabled:zod.boolean()});
|
|
5
|
+
export const libraryDomainSpec=defineDomain({name:'hanamesh_library',version:1,layout:'single',tables:{},global:{
|
|
6
|
+
schema:zod.object({schema:zod.literal(1),revision:zod.number().int().nonnegative(),sources:zod.array(source)}),
|
|
7
|
+
initial:{schema:1,revision:0,sources:[]},
|
|
8
|
+
}});
|