@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.
Files changed (82) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +104 -0
  3. package/consistency.json +101 -0
  4. package/dist/client-ui.d.ts +19 -0
  5. package/dist/client-ui.js +271 -0
  6. package/dist/client.d.ts +16 -0
  7. package/dist/client.js +42 -0
  8. package/dist/consistency.js +12 -0
  9. package/dist/descriptor.js +141 -0
  10. package/dist/dsh.d.ts +25 -0
  11. package/dist/dsh.js +194 -0
  12. package/dist/errors.js +25 -0
  13. package/dist/gateway.js +224 -0
  14. package/dist/guardian.js +113 -0
  15. package/dist/index.d.ts +130 -0
  16. package/dist/index.js +15 -0
  17. package/dist/launcher.js +17 -0
  18. package/dist/library/catalog.js +108 -0
  19. package/dist/library/domain.js +8 -0
  20. package/dist/library/install.js +87 -0
  21. package/dist/library/installed.js +67 -0
  22. package/dist/library/locate.js +79 -0
  23. package/dist/library/routes.js +17 -0
  24. package/dist/library/service.js +76 -0
  25. package/dist/manager.js +605 -0
  26. package/dist/provision/LICENSE +6 -0
  27. package/dist/provision/THIRD_PARTY_NOTICES.md +13 -0
  28. package/dist/provision/archive.d.ts +28 -0
  29. package/dist/provision/archive.js +66 -0
  30. package/dist/provision/check.d.ts +10 -0
  31. package/dist/provision/check.js +51 -0
  32. package/dist/provision/checkpoints.d.ts +12 -0
  33. package/dist/provision/checkpoints.js +23 -0
  34. package/dist/provision/cli.d.ts +1 -0
  35. package/dist/provision/cli.js +169 -0
  36. package/dist/provision/download.d.ts +21 -0
  37. package/dist/provision/download.js +242 -0
  38. package/dist/provision/errors.d.ts +11 -0
  39. package/dist/provision/errors.js +13 -0
  40. package/dist/provision/extract.d.ts +17 -0
  41. package/dist/provision/extract.js +124 -0
  42. package/dist/provision/fsutil.d.ts +8 -0
  43. package/dist/provision/fsutil.js +71 -0
  44. package/dist/provision/index.d.ts +7 -0
  45. package/dist/provision/index.js +6 -0
  46. package/dist/provision/ledger.d.ts +14 -0
  47. package/dist/provision/ledger.js +50 -0
  48. package/dist/provision/manifest.d.ts +11 -0
  49. package/dist/provision/manifest.js +171 -0
  50. package/dist/provision/paths.d.ts +27 -0
  51. package/dist/provision/paths.js +95 -0
  52. package/dist/provision/platform.d.ts +6 -0
  53. package/dist/provision/platform.js +7 -0
  54. package/dist/provision/provision.d.ts +17 -0
  55. package/dist/provision/provision.js +454 -0
  56. package/dist/provision/tar.d.ts +7 -0
  57. package/dist/provision/tar.js +169 -0
  58. package/dist/provision/types.d.ts +174 -0
  59. package/dist/provision/types.js +7 -0
  60. package/dist/provision/zip.d.ts +4 -0
  61. package/dist/provision/zip.js +194 -0
  62. package/dist/router/broker.js +161 -0
  63. package/dist/router/domain.js +18 -0
  64. package/dist/router/receipts-domain.js +8 -0
  65. package/dist/router/receipts.js +156 -0
  66. package/dist/router/routes.js +48 -0
  67. package/dist/router/sources.js +87 -0
  68. package/dist/routes.js +74 -0
  69. package/dist/runtime.js +222 -0
  70. package/dist/store.js +250 -0
  71. package/dist/usage-evidence.js +117 -0
  72. package/docs/APP_PACKAGE.md +38 -0
  73. package/docs/CONTRACT.md +190 -0
  74. package/docs/DSH_INTEGRATION.md +43 -0
  75. package/docs/LIBRARY.md +45 -0
  76. package/docs/LICENSES.json +185 -0
  77. package/docs/PROVENANCE.json +63 -0
  78. package/docs/SECURITY.md +52 -0
  79. package/package.json +112 -0
  80. package/profile/cordis.patch.yml +4 -0
  81. package/schemas/catalog-provider-page.schema.json +376 -0
  82. package/schemas/catalog-source.schema.json +151 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * T6 — usage receipts (design 2026-09-22 §3, STATUS `T6`).
3
+ *
4
+ * The Router knows which provider / model it injected into which application instance; the gateway knows when that
5
+ * instance really served an authorized request. This ledger turns the two into content-free hourly receipts
6
+ *
7
+ * { appId, providerId, model, hour, count } hour = UTC `YYYYMMDDHH`, count = forwarded requests in that hour
8
+ *
9
+ * kept in app-host's own storage domain (`hanamesh_router_receipts`, one global snapshot, ONE `global.set` per
10
+ * publication, debounced) and reported through the usage plugin's record seat as the receipt of that hour's `use`
11
+ * event (see usage-evidence.js). No prompt, response, credential or URL is ever recorded — only the provider id,
12
+ * the model name the Router routed, and a count.
13
+ *
14
+ * inject Router `credentialResolver` resolved a route for an instance → the instance's route is remembered
15
+ * (first api-key / provider env entry in declaration order) and the (app, route, hour) row counts one injection.
16
+ * activity the instance's gateway forwarded an authorized request → the (app, route, hour) row counts one request;
17
+ * an instance whose route is unknown (recovered / attached, no injection this run) counts against a
18
+ * route-less row so the hour's `use` is still reported, just without a receipt.
19
+ * reported once the usage seat accepted (or already had) the hour's `use` event the rows are flagged; later requests in
20
+ * the same hour keep counting locally but are not re-reported (the seat de-duplicates by key).
21
+ *
22
+ * Without a storage domain (library use, tests) the ledger is memory-only and behaves identically.
23
+ */
24
+ import { z } from 'zod';
25
+ import { requireCondition } from '../errors.js';
26
+
27
+ const pad = n => String(n).padStart(2,'0');
28
+ /** UTC hour bucket `YYYYMMDDHH` of an epoch-millisecond instant. */
29
+ export function hourBucket(ms) {
30
+ const d = new Date(ms);
31
+ return `${d.getUTCFullYear()}${pad(d.getUTCMonth()+1)}${pad(d.getUTCDate())}${pad(d.getUTCHours())}`;
32
+ }
33
+ /** Bucket → epoch ms of its first instant (used for retention and for ordering). */
34
+ export function bucketStart(bucket) {
35
+ return Date.UTC(Number(bucket.slice(0,4)),Number(bucket.slice(4,6))-1,Number(bucket.slice(6,8)),Number(bucket.slice(8,10)));
36
+ }
37
+ const PROVIDER = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/, MODEL = /^[A-Za-z0-9][A-Za-z0-9_.:/@+-]{0,127}$/, APP = /^[a-z0-9][a-z0-9-]*$/, BUCKET = /^\d{10}$/;
38
+ const SECRET = /(?:^sk-|^sk_|^bearer[.:-]|ghp_|github_pat_|xox[baprs]-|secret|password|api.?key|private.?key)/i;
39
+ /** A route the Router actually resolved: provider id plus the model it routed (granted, else the app's declared default, else null). */
40
+ export function normalizeRoute(route) {
41
+ if (!route || typeof route !== 'object') return null;
42
+ const providerId = route.providerId, model = route.model ?? null;
43
+ if (typeof providerId !== 'string' || !PROVIDER.test(providerId) || SECRET.test(providerId)) return null;
44
+ if (model !== null && (typeof model !== 'string' || !MODEL.test(model) || SECRET.test(model))) return { providerId, model:null };
45
+ return { providerId, model };
46
+ }
47
+ const row = z.object({ appId:z.string(), providerId:z.string().nullable(), model:z.string().nullable(), hour:z.string(),
48
+ count:z.number().int().nonnegative(), injections:z.number().int().nonnegative(), firstAt:z.number().int().nonnegative(), lastAt:z.number().int().nonnegative(), reported:z.boolean() });
49
+ /** Shape of the persisted global; the storage-domain spec itself lives in `./receipts-domain.js`
50
+ * so the library entry never pulls a DSH-only peer into a plain consumer's import graph. */
51
+ export const receiptsGlobalSchema = z.object({ schema:z.literal(1), items:z.record(z.string(),row) });
52
+ export const receiptsGlobalInitial = { schema:1, items:{} };
53
+ const key = (appId,providerId,model,hour) => `${appId}|${providerId ?? ''}|${model ?? ''}|${hour}`;
54
+ const DAY = 86_400_000;
55
+
56
+ /**
57
+ * @param {{domain?:{global:{get():any,set(value:any):Promise<void>}}, clock?:()=>number, persistDelayMs?:number, maxItems?:number, retentionMs?:number}} [options]
58
+ */
59
+ export function createReceiptLedger({ domain, clock = Date.now, persistDelayMs = 5_000, maxItems = 5_000, retentionMs = 90*DAY } = {}) {
60
+ requireCondition(domain === undefined || (domain && domain.global && typeof domain.global.get === 'function' && typeof domain.global.set === 'function'),
61
+ 'INVALID_DOMAIN','receipts domain must expose global.get/set.');
62
+ requireCondition(Number.isSafeInteger(persistDelayMs) && persistDelayMs >= 0 && Number.isSafeInteger(maxItems) && maxItems > 0,'INVALID_OPTIONS','persistDelayMs / maxItems out of range.');
63
+ const loaded = domain ? domain.global.get() : null;
64
+ /** @type {Record<string, any>} */
65
+ const items = loaded && loaded.schema === 1 && loaded.items && typeof loaded.items === 'object' ? structuredClone(loaded.items) : {};
66
+ /** instanceId → normalized route (or null when the Router resolved nothing for it). */
67
+ const routes = new Map();
68
+ let dirty = false, closed = false, timer = null, writing = Promise.resolve();
69
+
70
+ function prune() {
71
+ const cutoff = clock() - retentionMs;
72
+ for (const [k,item] of Object.entries(items)) if (bucketStart(item.hour) < cutoff) delete items[k];
73
+ const keys = Object.keys(items);
74
+ if (keys.length > maxItems) {
75
+ keys.sort((a,b) => items[a].reported === items[b].reported ? items[a].lastAt - items[b].lastAt : (items[a].reported ? -1 : 1));
76
+ for (const k of keys.slice(0, keys.length - maxItems)) delete items[k];
77
+ }
78
+ }
79
+ /** ONE global.set per publication; every mutation since the last one rides in the same image. */
80
+ function persistNow() {
81
+ if (!dirty || !domain) { dirty = false; return writing; }
82
+ dirty = false; prune();
83
+ const image = { schema:1, items:structuredClone(items) };
84
+ writing = writing.then(() => domain.global.set(image)).catch(() => { dirty = true; });
85
+ return writing;
86
+ }
87
+ function schedule() {
88
+ dirty = true;
89
+ if (!domain || closed) return;
90
+ if (persistDelayMs === 0) { void persistNow(); return; }
91
+ if (timer === null) { timer = setTimeout(() => { timer = null; void persistNow(); }, persistDelayMs); timer.unref?.(); }
92
+ }
93
+ function touch(appId, route, at, field) {
94
+ requireCondition(typeof appId === 'string' && APP.test(appId),'INVALID_APP','appId must be an application id.');
95
+ requireCondition(Number.isSafeInteger(at) && at >= 0,'INVALID_TIME','at must be epoch milliseconds.');
96
+ const hour = hourBucket(at), k = key(appId, route?.providerId ?? null, route?.model ?? null, hour);
97
+ const item = items[k] ?? (items[k] = { appId, providerId:route?.providerId ?? null, model:route?.model ?? null, hour, count:0, injections:0, firstAt:at, lastAt:at, reported:false });
98
+ item[field] += 1; item.firstAt = Math.min(item.firstAt, at); item.lastAt = Math.max(item.lastAt, at);
99
+ schedule();
100
+ return { ...item };
101
+ }
102
+ const api = Object.freeze({
103
+ /** The Router injected credentials into an instance; `routes` in declaration order, the first valid one is the instance's route. */
104
+ inject({ appId, instanceId, routes: resolved = [], at = clock() }) {
105
+ requireCondition(typeof appId === 'string' && APP.test(appId),'INVALID_APP','appId must be an application id.');
106
+ requireCondition(typeof instanceId === 'string' && instanceId.length > 0,'INVALID_INSTANCE','instanceId is required.');
107
+ const route = (Array.isArray(resolved) ? resolved : []).map(normalizeRoute).find(Boolean) ?? null;
108
+ routes.set(instanceId, route);
109
+ return route ? touch(appId, route, at, 'injections') : null;
110
+ },
111
+ /** The instance's gateway forwarded an authorized request. */
112
+ activity({ appId, instanceId, at = clock() }) {
113
+ return touch(appId, routes.get(instanceId) ?? null, at, 'count');
114
+ },
115
+ /** The instance ended; its route binding is dropped (rows stay until reported / pruned). */
116
+ forget(instanceId) { routes.delete(instanceId); },
117
+ routeOf(instanceId) { const r = routes.get(instanceId); return r ? { ...r } : null; },
118
+ /**
119
+ * Hours ready to report as one `use` event each: rows of an (app, hour) that saw real requests and were not reported yet.
120
+ * `before` (a bucket) keeps the current hour out unless `includeCurrent`; `appId` narrows to one app.
121
+ * The receipt is the routed row with the most requests; `occurredAt` is the earliest request of the hour.
122
+ */
123
+ pending({ appId, includeCurrent = false, now = clock() } = {}) {
124
+ const current = hourBucket(now), groups = new Map();
125
+ for (const item of Object.values(items)) {
126
+ if (item.reported || item.count === 0 || (appId !== undefined && item.appId !== appId)) continue;
127
+ if (!includeCurrent && item.hour >= current) continue;
128
+ const g = key(item.appId,'','',item.hour), group = groups.get(g) ?? { appId:item.appId, hour:item.hour, occurredAt:item.firstAt, count:0, receipt:null };
129
+ group.occurredAt = Math.min(group.occurredAt, item.firstAt); group.count += item.count;
130
+ if (item.providerId !== null && (group.receipt === null || item.count > group.receipt.count)) group.receipt = { providerId:item.providerId, model:item.model, count:item.count };
131
+ groups.set(g, group);
132
+ }
133
+ return [...groups.values()].sort((a,b) => a.hour.localeCompare(b.hour) || a.appId.localeCompare(b.appId));
134
+ },
135
+ /** The hour's `use` event reached the seat (recorded or already there): flag every row of that (app, hour). */
136
+ markReported(appId, hour) {
137
+ requireCondition(typeof hour === 'string' && BUCKET.test(hour),'INVALID_HOUR','hour must be a UTC bucket.');
138
+ let n = 0;
139
+ for (const item of Object.values(items)) if (item.appId === appId && item.hour === hour && !item.reported) { item.reported = true; n++; }
140
+ if (n > 0) schedule();
141
+ return n;
142
+ },
143
+ /** Local inspection (router route): the receipt rows, newest hour first, optionally for one app. Never includes credentials. */
144
+ list({ appId } = {}) {
145
+ return Object.values(items).filter(item => appId === undefined || item.appId === appId)
146
+ .sort((a,b) => b.hour.localeCompare(a.hour) || a.appId.localeCompare(b.appId) || String(a.providerId).localeCompare(String(b.providerId)))
147
+ .map(item => ({ appId:item.appId, providerId:item.providerId, model:item.model, hour:item.hour, count:item.count, injections:item.injections,
148
+ firstAt:new Date(item.firstAt).toISOString(), lastAt:new Date(item.lastAt).toISOString(), reported:item.reported }));
149
+ },
150
+ size: () => Object.keys(items).length,
151
+ /** Publish now (tests, orderly shutdown). */
152
+ async persist() { if (timer !== null) { clearTimeout(timer); timer = null; } await persistNow(); },
153
+ async close() { if (closed) return; closed = true; if (timer !== null) { clearTimeout(timer); timer = null; } await persistNow(); routes.clear(); },
154
+ });
155
+ return api;
156
+ }
@@ -0,0 +1,48 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { AppHostError, requireCondition } from '../errors.js';
3
+ import { loopbackOrigin } from '../descriptor.js';
4
+
5
+ export const ROUTER_ROUTES=Object.freeze(['/hanamesh/router/providers','/hanamesh/router/plan','/hanamesh/router/grant',
6
+ '/hanamesh/router/revoke','/hanamesh/router/model','/hanamesh/router/mode','/hanamesh/router/gateway','/hanamesh/router/receipts']);
7
+ async function body(req){
8
+ requireCondition(req.headers['content-type']?.split(';')[0].trim()==='application/json','CONTENT_TYPE_REQUIRED','Expected application/json.',{},415);
9
+ const chunks=[];let total=0;for await(const chunk of req){total+=chunk.length;requireCondition(total<=16_384,'BODY_TOO_LARGE','Request body is too large.',{},413);chunks.push(chunk);}
10
+ try{return JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw new AppHostError('INVALID_JSON','Malformed JSON.',{},400);}
11
+ }
12
+ function send(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store',
13
+ 'x-content-type-options':'nosniff','content-security-policy':"default-src 'none'; frame-ancestors 'none'"});res.end(JSON.stringify(value));}
14
+ const statusFor=code=>({APP_NOT_FOUND:404,ENTRY_UNKNOWN:404,ROUTER_PROVIDER_ABSENT:404,RISK_NOT_ACKNOWLEDGED:403,
15
+ CONSUMER_MISMATCH:403,ENTRY_NOT_ROUTED:409,GATEWAY_OFF:409,GATEWAY_UNREACHABLE:503}[code]??400);
16
+
17
+ export function createRouterHttpHandler(router,{parentOrigin,authenticate,authorize}){
18
+ parentOrigin=loopbackOrigin(parentOrigin);requireCondition(typeof authenticate==='function'&&typeof authorize==='function','AUTH_BINDING_REQUIRED','Router authentication is required.');
19
+ return async(req,res)=>{const traceId=randomUUID();try{
20
+ requireCondition(req.headers.host===new URL(parentOrigin).host,'HOST_DENIED','Unrecognized request host.',{},403);
21
+ requireCondition(req.url?.startsWith('/')&&!req.url.startsWith('//')&&!req.url.includes('\\'),'BAD_TARGET','Invalid request target.');
22
+ const url=new URL(req.url,parentOrigin),path=url.pathname;requireCondition(ROUTER_ROUTES.includes(path),'NOT_FOUND','Unknown router route.',{},404);
23
+ const readOnly=['/hanamesh/router/providers','/hanamesh/router/plan','/hanamesh/router/receipts'].includes(path);
24
+ requireCondition(req.method===(readOnly?'GET':'POST'),'METHOD_NOT_ALLOWED','State changes require POST.',{},405);
25
+ requireCondition(!req.headers.origin||req.headers.origin===parentOrigin,'ORIGIN_DENIED','Unapproved browser origin.',{},403);
26
+ requireCondition(req.headers['sec-fetch-dest']!=='iframe'&&req.headers['sec-fetch-site']!=='cross-site','FRAME_CONTROL_DENIED','An application frame cannot control the router.',{},403);
27
+ requireCondition(req.headers['x-hanamesh-client']==='workspace-v1','CSRF_DENIED','Explicit workspace client header is required.',{},403);
28
+ if(!readOnly)requireCondition(req.headers.origin===parentOrigin,'CSRF_DENIED','Workspace origin is required.',{},403);
29
+ const subject=await authenticate(req);requireCondition(subject&&typeof subject.principalId==='string','UNAUTHENTICATED','Host authentication is required.',{},401);
30
+ const input=readOnly?Object.fromEntries(url.searchParams):await body(req);
31
+ if(path==='/hanamesh/router/providers')requireCondition(Object.keys(input).length===0,'UNKNOWN_FIELDS','No provider parameters are accepted.');
32
+ if(path==='/hanamesh/router/plan')requireCondition(Object.keys(input).every(key=>key==='appId')&&typeof input.appId==='string','UNKNOWN_FIELDS','Plan accepts only appId.');
33
+ if(path==='/hanamesh/router/receipts')requireCondition(Object.keys(input).every(key=>key==='appId'),'UNKNOWN_FIELDS','Receipts accept only an optional appId.');
34
+ requireCondition(await authorize(subject,path,input)===true,'FORBIDDEN','Operation is not authorized.',{},403);
35
+ let result;
36
+ if(path==='/hanamesh/router/providers')result={providers:await router.providers()};
37
+ if(path==='/hanamesh/router/plan')result=await router.plan(input.appId);
38
+ if(path==='/hanamesh/router/receipts')result=await router.receipts(input.appId);
39
+ if(path==='/hanamesh/router/grant')result=await router.grant(input.appId,input.entryId,input.subject,{model:input.model,riskAcknowledged:input.riskAcknowledged});
40
+ if(path==='/hanamesh/router/revoke')result=await router.revoke(input.appId,input.entryId);
41
+ if(path==='/hanamesh/router/model')result=await router.setModel(input.appId,input.entryId,input.model??'');
42
+ if(path==='/hanamesh/router/mode'){await router.setMode(input.appId,input.mode);result=await router.plan(input.appId);}
43
+ if(path==='/hanamesh/router/gateway')result=await router.enableGateway(input.enabled===true);
44
+ send(res,200,{...result,traceId});
45
+ }catch(error){const trusted=error instanceof AppHostError||typeof error?.code==='string',code=trusted?(error.code??error.message):'INTERNAL_ERROR';
46
+ send(res,trusted?(error.status??statusFor(code)):500,{error:{code,message:trusted?error.message:'The operation could not be completed.'},traceId});}
47
+ };
48
+ }
@@ -0,0 +1,87 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ export const KNOWN = Object.freeze({
5
+ DEEPSEEK_API_KEY:'deepseek', OPENAI_API_KEY:'openai', ANTHROPIC_API_KEY:'anthropic', GEMINI_API_KEY:'google',
6
+ });
7
+ export const providerOf = ref => KNOWN[ref] ?? (ref.endsWith('_API_KEY') ? ref.slice(0,-8).toLowerCase().replaceAll('_','-') : undefined);
8
+ const refForProvider = provider => Object.keys(KNOWN).find(ref => KNOWN[ref] === provider)
9
+ ?? `${provider.toUpperCase().replaceAll('-','_')}_API_KEY`;
10
+ const safeJson = async response => {
11
+ try { return await response.json(); } catch { return {}; }
12
+ };
13
+
14
+ export function createProviderSources({ credentials, apps, llm = () => undefined, fetcher = globalThis.fetch.bind(globalThis),
15
+ webOrigin, codingOauth = {}, dshHome = process.env.DSH_HOME }) {
16
+ const mode = codingOauth.mode ?? 'http';
17
+ async function dshProviders() {
18
+ const refs = new Set(Object.keys(KNOWN));
19
+ for (const app of apps.list().apps) for (const deployment of app.deployments ?? []) for (const entry of deployment.credentialEnv ?? [])
20
+ for (const provider of entry.providers ?? []) if (provider !== 'coding-oauth-gateway') refs.add(refForProvider(provider));
21
+ const catalog = new Map((llm()?.listProviders?.() ?? []).map(item => [item.id,item]));
22
+ return await Promise.all([...refs].sort().map(async ref => {
23
+ const info = await credentials.describe(ref);
24
+ const id = providerOf(ref);
25
+ const meta = catalog.get(id);
26
+ let models;
27
+ if (meta && typeof llm()?.listModels === 'function') {
28
+ try { models = (await llm().listModels(id)).map(item => item.id ?? item.model).filter(Boolean); } catch {}
29
+ }
30
+ return { id, source:'dsh-models', kind:'api-key', displayName:meta?.name ?? id, ref,
31
+ ...(info.keyHint ? { keyHint:info.keyHint } : {}), ...(models?.length ? { models } : {}),
32
+ state:info.configured ? 'configured' : 'absent' };
33
+ }));
34
+ }
35
+ async function fileGateway({ reveal = false } = {}) {
36
+ if (!dshHome) return null;
37
+ const path = join(dshHome,'.coding-oauth-gateway.json');
38
+ let fileStat, value;
39
+ try { fileStat = await stat(path); value = JSON.parse(await readFile(path,'utf8')); } catch { return null; }
40
+ if ((fileStat.mode & 0o077) !== 0) return { id:'coding-oauth-gateway', source:'coding-oauth-gateway', kind:'api-key',
41
+ displayName:'Coding OAuth Gateway', state:'unreachable' };
42
+ const base = { id:'coding-oauth-gateway', source:'coding-oauth-gateway', kind:'api-key', displayName:'Coding OAuth Gateway',
43
+ ...(Array.isArray(value.models) ? { models:value.models } : {}), ...(value.keyHint ? { keyHint:value.keyHint } : {}) };
44
+ if (!value.enabled) return { ...base, state:'gateway-off' };
45
+ if (!Number.isSafeInteger(value.port)) return { ...base, state:'unreachable' };
46
+ return { ...base, state:'configured', baseUrl:`http://127.0.0.1:${value.port}/v1`, ...(reveal ? { value:value.apiKey } : {}) };
47
+ }
48
+ async function httpGateway({ reveal = false } = {}) {
49
+ let response;
50
+ try { response = await fetcher(`${webOrigin}/plugins/dsh-grok-build/gateway${reveal?'/reveal':''}`, { method:reveal?'POST':'GET' }); }
51
+ catch { return { id:'coding-oauth-gateway', source:'coding-oauth-gateway', kind:'api-key', displayName:'Coding OAuth Gateway', state:'unreachable' }; }
52
+ if (response.status === 404) return null;
53
+ if (!response.ok) return { id:'coding-oauth-gateway', source:'coding-oauth-gateway', kind:'api-key', displayName:'Coding OAuth Gateway', state:'unreachable' };
54
+ const value = await safeJson(response);
55
+ const base = { id:'coding-oauth-gateway', source:'coding-oauth-gateway', kind:'api-key', displayName:'Coding OAuth Gateway',
56
+ ...(Array.isArray(value.models) ? { models:value.models } : {}), ...(value.keyHint ? { keyHint:value.keyHint } : {}) };
57
+ if (!reveal && !value.enabled) return { ...base, state:'gateway-off' };
58
+ if (!reveal && (!value.running || !Number.isSafeInteger(value.port))) return { ...base, state:'unreachable' };
59
+ const port = value.port ?? (await httpGateway())?.baseUrl?.match(/:(\d+)\/v1$/)?.[1];
60
+ return { ...base, state:'configured', ...(port ? { baseUrl:`http://127.0.0.1:${port}/v1` } : {}), ...(reveal ? { value:value.apiKey } : {}) };
61
+ }
62
+ const gateway = options => mode === 'file' ? fileGateway(options) : httpGateway(options);
63
+ return {
64
+ async list() { const values = await dshProviders(), extra = await gateway(); if (extra) values.push(extra); return values; },
65
+ async enableGateway(enabled) {
66
+ if (mode === 'file') throw Object.assign(new Error('GATEWAY_FILE_MODE'),{code:'GATEWAY_FILE_MODE'});
67
+ const response = await fetcher(`${webOrigin}/plugins/dsh-grok-build/gateway`, { method:'PATCH',
68
+ headers:{'content-type':'application/json'}, body:JSON.stringify({enabled}) });
69
+ if (!response.ok) throw Object.assign(new Error(response.status===404?'ROUTER_PROVIDER_ABSENT':'GATEWAY_UNREACHABLE'),{code:response.status===404?'ROUTER_PROVIDER_ABSENT':'GATEWAY_UNREACHABLE'});
70
+ return await safeJson(response);
71
+ },
72
+ async resolve(subject) {
73
+ if (subject.kind === 'api-key') {
74
+ const resolved = await credentials.resolve(subject.ref);
75
+ return resolved?.value ? { value:resolved.value, provider:{ id:providerOf(subject.ref), source:'dsh-models', ref:subject.ref } } : undefined;
76
+ }
77
+ if (subject.kind === 'provider' && subject.providerId === 'coding-oauth-gateway') {
78
+ const provider = await gateway(), revealed = await gateway({reveal:true});
79
+ if (!provider) throw Object.assign(new Error('ROUTER_PROVIDER_ABSENT'),{code:'ROUTER_PROVIDER_ABSENT'});
80
+ if (provider.state === 'gateway-off') throw Object.assign(new Error('GATEWAY_OFF'),{code:'GATEWAY_OFF'});
81
+ if (provider.state !== 'configured' || !revealed?.value) throw Object.assign(new Error('GATEWAY_UNREACHABLE'),{code:'GATEWAY_UNREACHABLE'});
82
+ return { value:revealed.value, provider:{...provider, ...revealed, value:undefined} };
83
+ }
84
+ return undefined;
85
+ },
86
+ };
87
+ }
package/dist/routes.js ADDED
@@ -0,0 +1,74 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { AppHostError, requireCondition } from './errors.js';
3
+ import { loopbackOrigin } from './descriptor.js';
4
+ function browserInstance(instance) {
5
+ if(!instance)return null;
6
+ const {id,appId,deploymentId,dataId,mode,status,runtimeId,errorCode}=instance;
7
+ return {id,appId,deploymentId,dataId,mode,status,runtimeId,...(errorCode?{errorCode}:{})};
8
+ }
9
+ function visible(value) {
10
+ if(value?.instance)return {...value,instance:browserInstance(value.instance)};
11
+ if(value?.instances)return {...value,instances:value.instances.map(browserInstance)};
12
+ return value;
13
+ }
14
+ async function jsonBody(req) {
15
+ requireCondition(req.headers['content-type']?.split(';')[0].trim()==='application/json','CONTENT_TYPE_REQUIRED','Expected application/json.',{},415);
16
+ const chunks=[];let total=0;
17
+ for await(const chunk of req){total+=chunk.length;requireCondition(total<=16_384,'BODY_TOO_LARGE','Request body is too large.',{},413);chunks.push(chunk);}
18
+ try{return JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw new AppHostError('INVALID_JSON','Malformed JSON.',{},400);}
19
+ }
20
+ function send(res,status,value) {
21
+ res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store',
22
+ 'x-content-type-options':'nosniff','content-security-policy':"default-src 'none'; frame-ancestors 'none'"});
23
+ res.end(JSON.stringify(value));
24
+ }
25
+ /** Node request handler. Authentication/authorization MUST come from the host. */
26
+ export function createHttpHandler(host,{parentOrigin,authenticate,authorize}) {
27
+ parentOrigin=loopbackOrigin(parentOrigin);
28
+ requireCondition(typeof authenticate==='function'&&typeof authorize==='function','AUTH_BINDING_REQUIRED',
29
+ 'Host authentication and per-operation authorization callbacks are mandatory.');
30
+ return async(req,res)=>{
31
+ const traceId=randomUUID();
32
+ try{
33
+ requireCondition(req.headers.host===new URL(parentOrigin).host,'HOST_DENIED','Unrecognized request host.',{},403);
34
+ requireCondition(req.url?.startsWith('/')&&!req.url.startsWith('//')&&!req.url.includes('\\'),'BAD_TARGET','Invalid request target.');
35
+ const url=new URL(req.url,parentOrigin),path=url.pathname;
36
+ const known=['/hanamesh/apps','/apps/open','/apps/resume','/apps/recover','/apps/close','/apps/heartbeat','/apps/stop','/apps/events'];
37
+ requireCondition(known.includes(path),'NOT_FOUND','Unknown app-host route.',{},404);
38
+ const readOnly=['/hanamesh/apps','/apps/events'].includes(path);
39
+ requireCondition(req.method===(readOnly?'GET':'POST'),'METHOD_NOT_ALLOWED','State changes require POST.',{},405);
40
+ requireCondition(!req.headers.origin||req.headers.origin===parentOrigin,'ORIGIN_DENIED','Unapproved browser origin.',{},403);
41
+ requireCondition(req.headers['sec-fetch-dest']!=='iframe','FRAME_CONTROL_DENIED','An application frame cannot control the host.',{},403);
42
+ if(!readOnly){
43
+ requireCondition(req.headers.origin===parentOrigin&&req.headers['x-hanamesh-client']==='workspace-v1',
44
+ 'CSRF_DENIED','Workspace origin and explicit client header are required.',{},403);
45
+ }
46
+ const subject=await authenticate(req);
47
+ requireCondition(subject&&typeof subject.principalId==='string','UNAUTHENTICATED','Host authentication is required.',{},401);
48
+ const input=readOnly?Object.fromEntries(url.searchParams):await jsonBody(req);
49
+ requireCondition(await authorize(subject,path,input)===true,'FORBIDDEN','Operation is not authorized.',{},403);
50
+ let result;
51
+ switch(path){
52
+ case '/hanamesh/apps':requireCondition(!url.search,'UNKNOWN_FIELDS','No list parameters are accepted.');result=host.list(subject.principalId);break;
53
+ case '/apps/open':result=await host.beginOpen(input,subject.principalId);break;
54
+ case '/apps/resume':result=await host.resume(input,subject.principalId,{waitForReady:false});break;
55
+ case '/apps/recover':result=await host.recoverView(input,subject.principalId);break;
56
+ case '/apps/close':result=await host.close(input,subject.principalId);break;
57
+ case '/apps/heartbeat':result=await host.heartbeat(input,subject.principalId);break;
58
+ case '/apps/stop':{
59
+ requireCondition(input&&Object.keys(input).every(k=>['instanceId','confirm'].includes(k))&&
60
+ typeof input.instanceId==='string'&&(input.confirm===undefined||typeof input.confirm==='boolean'),
61
+ 'INVALID_REQUEST','Stop requires an instanceId and an optional boolean confirmation.');
62
+ result={instance:await host.stop(input.instanceId,{confirm:input.confirm??false},subject.principalId)};break;
63
+ }
64
+ case '/apps/events':requireCondition(Object.keys(input).every(k=>k==='after'),'UNKNOWN_FIELDS','Unknown event parameters.');result=host.eventsSince(Number(input.after??0),subject.principalId);break;
65
+ }
66
+ send(res,200,{...visible(result),traceId});
67
+ }catch(error){
68
+ const trusted=error instanceof AppHostError;
69
+ send(res,trusted?error.status:500,{error:{code:trusted?error.code:'INTERNAL_ERROR',
70
+ message:trusted?error.message:'The operation could not be completed.',
71
+ ...(trusted?{details:error.details}:{})},traceId});
72
+ }
73
+ };
74
+ }
@@ -0,0 +1,222 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { createServer, request } from 'node:http';
3
+ import { readdir, readFile, readlink, access } from 'node:fs/promises';
4
+ import { constants } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, isAbsolute, join } from 'node:path';
7
+ import { promisify } from 'node:util';
8
+ import { AppHostError, bounded } from './errors.js';
9
+ const exec = promisify(execFile);
10
+ export async function resolveNodeBinary(nodeBinary) {
11
+ let selected = nodeBinary;
12
+ if (selected === undefined || selected === null || selected === '') {
13
+ if (process.versions.electron !== undefined) {
14
+ throw new AppHostError('NODE_RUNTIME_REQUIRED', 'Electron hosts must configure an external Node.js executable.');
15
+ }
16
+ // A plain Node host may use itself. This fallback is intentionally unreachable in Electron.
17
+ selected = process.execPath;
18
+ }
19
+ if (typeof selected !== 'string' || !isAbsolute(selected)) {
20
+ throw new AppHostError('NODE_RUNTIME_REQUIRED', 'nodeBinary must be an absolute executable path.');
21
+ }
22
+ try { await access(selected, constants.X_OK); }
23
+ catch { throw new AppHostError('NODE_RUNTIME_REQUIRED', 'nodeBinary must be an absolute executable path.'); }
24
+ return selected;
25
+ }
26
+ export function guardianEnvironment(nodeBinary, source = process.env) {
27
+ const env = {};
28
+ for (const key of ['DSH_HOME', 'HOME', 'LANG', 'TMPDIR']) {
29
+ if (typeof source[key] === 'string' && source[key].length) env[key] = source[key];
30
+ }
31
+ if (!env.LANG) env.LANG = 'C.UTF-8';
32
+ env.PATH = `${dirname(nodeBinary)}:/usr/bin:/bin`;
33
+ return env;
34
+ }
35
+ export async function freePort() {
36
+ const server = createServer();
37
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
38
+ const port = server.address().port;
39
+ await new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve()));
40
+ return port;
41
+ }
42
+ export function readEndpoint(url, { timeoutMs = 600, limit = 128 * 1024 } = {}) {
43
+ return new Promise((resolve, reject) => {
44
+ const req = request(url, { method: 'GET', headers: { 'accept-encoding': 'identity' } }, res => {
45
+ const chunks = []; let length = 0;
46
+ res.on('data', data => { length += data.length; if (length > limit) req.destroy(new AppHostError('PROBE_TOO_LARGE', 'Readiness response exceeds limit.')); else chunks.push(data); });
47
+ res.once('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
48
+ res.once('error', reject);
49
+ });
50
+ const timer = setTimeout(() => req.destroy(new AppHostError('PROBE_TIMEOUT', 'Readiness probe timed out.')), timeoutMs);
51
+ req.once('close', () => clearTimeout(timer)); req.once('error', reject); req.end();
52
+ });
53
+ }
54
+
55
+ // Verifies that the actual listening socket belongs to the group created by our
56
+ // exact child handle. A random HTTP 200 (even with the same app marker) is not ready.
57
+ export async function ownsLoopbackPort(pid, port) {
58
+ if (process.platform === 'linux') {
59
+ const table = await readFile('/proc/net/tcp', 'utf8');
60
+ const inodes = new Set(table.trim().split('\n').slice(1).map(line => line.trim().split(/\s+/))
61
+ .filter(cols => cols[1] === `0100007F:${port.toString(16).toUpperCase().padStart(4, '0')}` && cols[3] === '0A')
62
+ .map(cols => cols[9]));
63
+ if (!inodes.size) return false;
64
+ for (const entry of await readdir('/proc')) {
65
+ if (!/^\d+$/.test(entry)) continue;
66
+ try {
67
+ const stat = await readFile(`/proc/${entry}/stat`, 'utf8');
68
+ const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
69
+ if (Number(rest[2]) !== pid) continue; // field 5: pgrp
70
+ for (const fd of await readdir(`/proc/${entry}/fd`)) {
71
+ const target = await readlink(`/proc/${entry}/fd/${fd}`).catch(() => '');
72
+ const match = /^socket:\[(\d+)\]$/.exec(target);
73
+ if (match && inodes.has(match[1])) return true;
74
+ }
75
+ } catch (error) { if (!['ENOENT','ESRCH','EACCES'].includes(error.code)) throw error; }
76
+ }
77
+ return false;
78
+ }
79
+ if (process.platform === 'darwin') {
80
+ let binary;
81
+ for (const path of ['/usr/sbin/lsof','/usr/bin/lsof']) {
82
+ try { await access(path, constants.X_OK); binary = path; break; } catch {}
83
+ }
84
+ if (!binary) throw new AppHostError('PORT_OWNERSHIP_UNAVAILABLE', 'lsof is required to verify socket ownership on macOS.');
85
+ let stdout;
86
+ try { ({ stdout } = await exec(binary, ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-FpgLn'], { timeout: 1_000 })); }
87
+ catch (error) { if (error.code === 1) return false; throw error; }
88
+ const blocks = stdout.split(/(?=^p\d+$)/m);
89
+ return blocks.some(block => block.split('\n').includes(`g${pid}`) && block.includes(`n127.0.0.1:${port}`));
90
+ }
91
+ throw new AppHostError('PLATFORM_UNSUPPORTED', 'Owned process/socket verification currently supports POSIX Linux and macOS only.');
92
+ }
93
+
94
+ // Identity of a live process: a start token (pid reuse guard) and its executable when the
95
+ // platform can say. Unknown fields stay null so callers fail closed; a dead or zombie pid is null.
96
+ let bootId;
97
+ export async function processIdentity(pid) {
98
+ if (!Number.isSafeInteger(pid) || pid <= 1) return null;
99
+ try { process.kill(pid, 0); }
100
+ catch (error) { if (error.code === 'ESRCH') return null; if (error.code !== 'EPERM') throw error; }
101
+ if (process.platform === 'linux') {
102
+ try {
103
+ const stat = await readFile(`/proc/${pid}/stat`, 'utf8');
104
+ const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
105
+ if (rest[0] === 'Z' || rest[0] === 'X') return null;
106
+ bootId ??= (await readFile('/proc/sys/kernel/random/boot_id', 'utf8').catch(() => '')).trim();
107
+ return { pid, start: `${bootId}:${rest[19]}`, command: await readlink(`/proc/${pid}/exe`).catch(() => null) }; // field 22: starttime
108
+ } catch (error) { if (['ENOENT', 'ESRCH'].includes(error.code)) return null; if (error.code !== 'EACCES') throw error; }
109
+ }
110
+ if (process.platform !== 'win32') {
111
+ for (const binary of ['/bin/ps', '/usr/bin/ps']) {
112
+ let stdout;
113
+ try { ({ stdout } = await exec(binary, ['-o', 'stat=', '-o', 'lstart=', '-o', 'comm=', '-p', String(pid)], { timeout: 1_000 })); }
114
+ catch (error) { if (error.code === 'ENOENT') continue; if (typeof error.code === 'number') return null; throw error; }
115
+ const fields = stdout.trim().split(/\s+/);
116
+ if (fields.length < 6 || fields[0].startsWith('Z')) return null;
117
+ return { pid, start: fields.slice(1, 6).join(' '), command: fields.slice(6).join(' ') || null };
118
+ }
119
+ }
120
+ return { pid, start: null, command: null };
121
+ }
122
+
123
+ export function redact(line, secrets = []) {
124
+ for (const secret of secrets) if (typeof secret === 'string' && secret.length >= 4) line = line.split(secret).join('[REDACTED]');
125
+ return line.replace(/(\bBearer\s+)[^\s"',;]+/gi, '$1[REDACTED]')
126
+ .replace(/((?:token|secret|password|api[_-]?key)\s*[=:]\s*)[^\s&"',;]+/gi, '$1[REDACTED]')
127
+ .replace(/("(?:token|secret|password|api[_-]?key)"\s*:\s*")[^"]*/gi, '$1[REDACTED]');
128
+ }
129
+ function lineSink(stream, write, secrets) {
130
+ let buffer = '', discarding = false;
131
+ stream.setEncoding('utf8');
132
+ stream.on('data', chunk => {
133
+ for (const part of chunk.split(/(?<=\n)/)) {
134
+ if (!discarding) buffer += part;
135
+ if (buffer.length > 8_192) { buffer = ''; discarding = true; }
136
+ if (part.endsWith('\n')) {
137
+ write(discarding ? '[oversize log line omitted]' : redact(buffer.trimEnd(), secrets));
138
+ buffer = ''; discarding = false;
139
+ }
140
+ }
141
+ });
142
+ stream.once('end', () => { if (buffer && !discarding) write(redact(buffer, secrets)); });
143
+ }
144
+ export async function spawnOwned(config, { onLog = () => {} } = {}) {
145
+ if (!['linux','darwin'].includes(process.platform)) throw new AppHostError('PLATFORM_UNSUPPORTED', 'Owned runtimes are restricted to POSIX.');
146
+ const nodeBinary = await resolveNodeBinary(config.nodeBinary);
147
+ const launchConfig = { ...config, nodeBinary };
148
+ const guardian = spawn(nodeBinary, [fileURLToPath(new URL('./guardian.js', import.meta.url))], {
149
+ env: guardianEnvironment(nodeBinary),
150
+ stdio: ['ignore','pipe','pipe','ipc'], shell: false,
151
+ });
152
+ const secrets = [...Object.entries(config.env).filter(([key]) => /TOKEN|SECRET|PASSWORD|KEY|AUTH|OAUTH|CREDENTIAL/.test(key)).map(([, value]) => value),
153
+ ...(Array.isArray(config.secrets) ? config.secrets : [])];
154
+ lineSink(guardian.stdout, text => onLog('stdout', text), secrets);
155
+ lineSink(guardian.stderr, text => onLog('stderr', text), secrets);
156
+ let resolveSpawn, rejectSpawn, resolveOwnership, childPid, groupId, guardianBinary, launcherBinary, appExit, cleanupConfirmed = false;
157
+ const ownershipReady = new Promise(resolve => { resolveOwnership = resolve; });
158
+ const spawned = new Promise((resolve, reject) => { resolveSpawn = resolve; rejectSpawn = reject; });
159
+ const exited = new Promise(resolve => {
160
+ guardian.once('exit', (code, signal) => { rejectSpawn(new AppHostError('SPAWN_FAILED','Guardian exited before application launch.')); resolve(appExit ?? { code, signal }); });
161
+ guardian.once('error', error => { rejectSpawn(error); resolve({ code: null, signal: null, error: error.message }); });
162
+ });
163
+ guardian.on('message', msg => {
164
+ if (msg.type === 'stopped') cleanupConfirmed = true;
165
+ if (msg.type === 'ownership-ready') resolveOwnership();
166
+ if (msg.type === 'guardian-ready') guardian.send({ type: 'launch', config: launchConfig }, error => { if (error) rejectSpawn(error); });
167
+ if (msg.type === 'guardian.refused') rejectSpawn(new AppHostError(msg.code ?? 'NODE_RUNTIME_REQUIRED', msg.message ?? 'Guardian refused its runtime.'));
168
+ if (msg.type === 'spawned') {
169
+ childPid = msg.pid; groupId = msg.groupId; guardianBinary = msg.guardianBinary; launcherBinary = msg.launcherBinary;
170
+ resolveSpawn(msg.pid);
171
+ }
172
+ if (msg.type === 'app-exit') appExit = { code: msg.code, signal: msg.signal };
173
+ if (msg.type === 'error') rejectSpawn(new AppHostError(msg.code, msg.message, msg.details ?? {}));
174
+ });
175
+ let stopping;
176
+ const stop = () => {
177
+ if (stopping) return stopping;
178
+ stopping = (async () => {
179
+ if (guardian.exitCode === null && guardian.signalCode === null) {
180
+ if (guardian.connected) guardian.send({ type: 'stop' }, () => {});
181
+ else guardian.kill('SIGTERM'); // exact owned child handle only
182
+ // Do not SIGKILL the guardian: that could orphan the managed application.
183
+ }
184
+ const result = await bounded(exited, config.stopGraceMs + 5_000, 'Owned process cleanup');
185
+ if (!cleanupConfirmed) throw new AppHostError('CLEANUP_UNCONFIRMED', 'Guardian termination was not accompanied by a confirmed cleanup acknowledgement.');
186
+ return result;
187
+ })();
188
+ return stopping;
189
+ };
190
+ try {
191
+ // Ownership recovery may use the declared TERM grace, then 2s KILL confirmation.
192
+ // Keep the existing 5s bootstrap/probe allowance separate from the launch handshake.
193
+ // Racing spawned also observes early errors/exits immediately in either phase.
194
+ await bounded(Promise.race([ownershipReady, spawned]), 5_000 + config.stopGraceMs + 2_000, 'Runtime ownership recovery');
195
+ await bounded(spawned, 5_000, 'Process spawn');
196
+ }
197
+ catch (error) { await stop().catch(() => {}); throw error; }
198
+ return { mode: 'owned', pid: childPid, groupId, guardianPid: guardian.pid, nodeBinary, guardianBinary, launcherBinary, exited, stop,
199
+ isAlive: () => guardian.exitCode === null && guardian.signalCode === null && !appExit };
200
+ }
201
+ export async function waitReady(origin, readiness, { runtime, timeoutMs, signal }) {
202
+ const deadline = Date.now() + timeoutMs;
203
+ let last = 'No matching response.';
204
+ while (Date.now() < deadline) {
205
+ if (signal.aborted) throw new AppHostError('START_CANCELLED','Launch was cancelled.');
206
+ if (runtime?.mode === 'owned' && !runtime.isAlive()) throw new AppHostError('APP_EXITED','Application exited before readiness.');
207
+ try {
208
+ const result = await readEndpoint(new URL(readiness.path, origin), { timeoutMs: Math.min(600, Math.max(25, deadline - Date.now())) });
209
+ const marker = (!readiness.bodyIncludes || result.body.includes(readiness.bodyIncludes)) &&
210
+ (!readiness.header || result.headers[readiness.header.name.toLowerCase()] === readiness.header.value);
211
+ if (result.status === readiness.status && marker) {
212
+ if (runtime?.mode !== 'owned' || await ownsLoopbackPort(runtime.groupId, Number(new URL(origin).port))) return;
213
+ last = 'The endpoint is not owned by the launched process group.';
214
+ } else last = 'Status or application identity marker did not match.';
215
+ } catch (error) {
216
+ if (['PLATFORM_UNSUPPORTED','PORT_OWNERSHIP_UNAVAILABLE'].includes(error.code)) throw error;
217
+ last = 'The readiness endpoint was unavailable.';
218
+ }
219
+ await new Promise(resolve => setTimeout(resolve, 35));
220
+ }
221
+ throw new AppHostError('READY_TIMEOUT', `Application readiness failed: ${last}`, {}, 504);
222
+ }