@hanamesh/dsh-app-host 0.2.0-rc.2 → 0.6.14
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 +4 -7
- package/README.md +140 -13
- package/consistency.json +41 -6
- package/dist/catalog/fixtures/consumer.json +35 -0
- package/dist/catalog/fixtures/provider.json +107 -0
- package/dist/catalog/suite.d.ts +4 -0
- package/dist/catalog/suite.js +50 -0
- package/dist/client-ui.d.ts +21 -3
- package/dist/client-ui.js +961 -94
- package/dist/client.js +2 -2
- package/dist/control-origin.js +9 -0
- package/dist/descriptor.js +6 -0
- package/dist/dsh-compat.js +6 -0
- package/dist/dsh.d.ts +21 -4
- package/dist/dsh.js +149 -58
- package/dist/gateway.js +140 -11
- package/dist/git-process.js +64 -0
- package/dist/git-worker.js +50 -0
- package/dist/index.d.ts +9 -7
- package/dist/install-target/fixtures/catalog.json +58 -0
- package/dist/install-target/fixtures/consumer-cases.json +148 -0
- package/dist/install-target/fixtures/install-failure-cases.json +58 -0
- package/dist/install-target/fixtures/provider-cases.json +618 -0
- package/dist/install-target/fixtures/reference-consumer.js +37 -0
- package/dist/install-target/fixtures/source-profile.js +123 -0
- package/dist/install-target/harness.js +43 -0
- package/dist/install-target/run.js +28 -0
- package/dist/install-target/suite.d.ts +19 -0
- package/dist/install-target/suite.js +328 -0
- package/dist/installation-status.d.ts +21 -0
- package/dist/installation-status.js +2 -0
- package/dist/library/catalog.js +163 -19
- package/dist/library/client-identity.js +31 -0
- package/dist/library/desktop-cli.js +7 -0
- package/dist/library/domain.js +3 -1
- package/dist/library/handoff.js +246 -0
- package/dist/library/install.js +138 -32
- package/dist/library/installation-cli.js +48 -0
- package/dist/library/installation-command-worker.js +27 -0
- package/dist/library/installation-status-http.js +26 -0
- package/dist/library/installation-status.js +91 -0
- package/dist/library/installation-worker.js +44 -0
- package/dist/library/locate.js +70 -9
- package/dist/library/node-binding.js +11 -0
- package/dist/library/recovery.js +66 -0
- package/dist/library/routes.js +90 -14
- package/dist/library/service.js +123 -27
- package/dist/library/suite.js +130 -0
- package/dist/manager.js +24 -8
- package/dist/node-runtime/domain.js +6 -0
- package/dist/node-runtime/releases.json +59 -0
- package/dist/node-runtime/service.js +101 -0
- package/dist/owned-probe-diagnostic.js +53 -0
- package/dist/router/receipts.js +8 -11
- package/dist/router/routes.js +7 -7
- package/dist/routes.js +9 -8
- package/dist/runtime.js +11 -1
- package/dist/usage-evidence.js +18 -86
- package/dist/workspace-origin.js +17 -0
- package/docs/APP_PACKAGE.md +4 -0
- package/docs/CONTRACT.md +75 -11
- package/docs/DSH_COMPATIBILITY.md +94 -0
- package/docs/DSH_INTEGRATION.md +41 -0
- package/docs/INSTALLATION_STATUS.md +96 -0
- package/docs/INSTALL_TARGET.md +130 -0
- package/docs/LIBRARY.md +44 -3
- package/docs/LICENSES.json +189 -61
- package/docs/PROVENANCE.json +53 -12
- package/docs/SECURITY.md +31 -0
- package/node_modules/@hanamesh/lib-provision/LICENSE +15 -0
- package/node_modules/@hanamesh/lib-provision/README.md +134 -0
- package/node_modules/@hanamesh/lib-provision/THIRD_PARTY_NOTICES.md +17 -0
- package/node_modules/@hanamesh/lib-provision/bin/hanamesh-provision.js +13 -0
- package/node_modules/@hanamesh/lib-provision/consistency.json +20 -0
- package/node_modules/@hanamesh/lib-provision/contract/CHANGELOG.md +5 -0
- package/node_modules/@hanamesh/lib-provision/contract/conformance.mjs +80 -0
- package/node_modules/@hanamesh/lib-provision/contract/fixtures/consumer.json +9 -0
- package/node_modules/@hanamesh/lib-provision/contract/fixtures/provider.json +11 -0
- package/node_modules/@hanamesh/lib-provision/contract/lib-provision.schema.json +47 -0
- package/node_modules/@hanamesh/lib-provision/package.json +50 -0
- package/package.json +81 -33
- package/schemas/catalog-provider-page.schema.json +884 -7
- package/schemas/catalog-source.schema.json +9 -7
- package/schemas/install-target.schema.json +298 -0
- package/dist/provision/LICENSE +0 -6
- package/dist/provision/THIRD_PARTY_NOTICES.md +0 -13
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.js +0 -0
package/dist/manager.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join, resolve, dirname } from 'node:path';
|
|
|
3
3
|
import { access, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
5
|
import { AppHostError, requireCondition, SerialQueue, copy } from './errors.js';
|
|
6
|
-
import { validateDefinition, identifier, fingerprint, expand,
|
|
6
|
+
import { validateDefinition, identifier, fingerprint, expand, workspaceOrigin, packageName as validPackageName } from './descriptor.js';
|
|
7
7
|
import { secureDirectory } from './store.js';
|
|
8
8
|
import { freePort, spawnOwned, waitReady } from './runtime.js';
|
|
9
9
|
import { FixedGateway, ancestorOrigin } from './gateway.js';
|
|
@@ -24,19 +24,21 @@ export class AppHost {
|
|
|
24
24
|
#queue = new SerialQueue(); #definitions = new Map(); #controls = new Map(); #listeners = new Set();
|
|
25
25
|
#packageNames = new Map(); #activityListeners = new Set();
|
|
26
26
|
#logs = new Map(); #state; #initialized = false; #closing = false; #poisoned = false; #timer; #disposing;
|
|
27
|
-
#credentialResolver = null; #nodeBinary; #runtimeLedgerReader;
|
|
27
|
+
#credentialResolver = null; #nodeBinary; #nodeBinaryProvider; #runtimeLedgerReader;
|
|
28
28
|
constructor({ store, dataRoot, parentOrigin, frameAncestors = [], leaseTtlMs = 90_000, sweepIntervalMs = 15_000,
|
|
29
|
-
checkpoint = async () => {}, clock = () => Date.now(), credentialResolver = null, nodeBinary,
|
|
30
|
-
runtimeLedgerReader = async root => (await import('
|
|
29
|
+
checkpoint = async () => {}, clock = () => Date.now(), credentialResolver = null, nodeBinary, nodeBinaryProvider,
|
|
30
|
+
runtimeLedgerReader = async root => (await import('@hanamesh/lib-provision')).ledger(root) }) {
|
|
31
31
|
requireCondition(store && ['init','load','save','close'].every(k => typeof store[k] === 'function'),
|
|
32
32
|
'INVALID_STORE','A durable snapshot store is required.');
|
|
33
33
|
requireCondition(typeof dataRoot === 'string' && resolve(dataRoot) === dataRoot,'INVALID_ROOT','dataRoot must be an absolute canonical path.');
|
|
34
34
|
requireCondition(Number.isSafeInteger(leaseTtlMs) && leaseTtlMs >= 100 && leaseTtlMs <= 3_600_000 &&
|
|
35
35
|
Number.isSafeInteger(sweepIntervalMs) && sweepIntervalMs >= 0 && sweepIntervalMs <= 3_600_000,
|
|
36
36
|
'INVALID_TIMEOUT','Invalid lease or sweep timing.');
|
|
37
|
-
this.store = store; this.dataRoot = dataRoot; this.parentOrigin =
|
|
37
|
+
this.store = store; this.dataRoot = dataRoot; this.parentOrigin = workspaceOrigin(parentOrigin); this.frameAncestors = frameAncestors.map(ancestorOrigin);
|
|
38
38
|
this.leaseTtlMs = leaseTtlMs; this.sweepIntervalMs = sweepIntervalMs; this.checkpoint = checkpoint; this.clock = clock;
|
|
39
39
|
this.#nodeBinary = nodeBinary;
|
|
40
|
+
requireCondition(nodeBinaryProvider===undefined||typeof nodeBinaryProvider==='function','INVALID_RUNTIME','nodeBinaryProvider must be a function.');
|
|
41
|
+
this.#nodeBinaryProvider=nodeBinaryProvider;
|
|
40
42
|
requireCondition(typeof runtimeLedgerReader === 'function','INVALID_RUNTIME','runtimeLedgerReader must be a function.');
|
|
41
43
|
this.#runtimeLedgerReader = runtimeLedgerReader;
|
|
42
44
|
this.setCredentialResolver(credentialResolver);
|
|
@@ -168,6 +170,7 @@ export class AppHost {
|
|
|
168
170
|
try { await this.store.save(next); }
|
|
169
171
|
catch (error) { if (error.code === 'COMMIT_UNCERTAIN') this.#poisoned = true; throw error; }
|
|
170
172
|
this.#state = next;
|
|
173
|
+
for (const control of this.#controls.values()) control.gateway?.revokeInactive();
|
|
171
174
|
for (const event of next.events) if (event.sequence > oldSequence) this.#notify(event);
|
|
172
175
|
}
|
|
173
176
|
#findLease(state,principalId,viewId) { return state.leases.find(l => l.principalId === principalId && l.viewId === viewId); }
|
|
@@ -307,7 +310,7 @@ export class AppHost {
|
|
|
307
310
|
return prepared;
|
|
308
311
|
}
|
|
309
312
|
async #openResult(prepared, input, principalId, requireReady) {
|
|
310
|
-
return await this.#queue.run(() => {
|
|
313
|
+
return await this.#queue.run(async () => {
|
|
311
314
|
this.#assertOpen();
|
|
312
315
|
const lease = this.#findLease(this.#state,principalId,input.viewId);
|
|
313
316
|
this.#checkToken(lease,prepared.token);
|
|
@@ -317,10 +320,23 @@ export class AppHost {
|
|
|
317
320
|
if (requireReady) requireCondition(instance.status === 'ready','INSTANCE_NOT_READY','Instance is not ready.');
|
|
318
321
|
return { instance:publicInstance(instance),lease:publicLease(lease),leaseToken:prepared.token,
|
|
319
322
|
uiUrl:instance.status !== 'ready' ? null : (prepared.control.gateway ?
|
|
320
|
-
prepared.control.gateway.
|
|
323
|
+
await prepared.control.gateway.issueAsync(leaseKey(principalId,input.viewId,lease.generation)) : instance.endpoint),
|
|
321
324
|
originalSessionId:lease.originalSessionId };
|
|
322
325
|
});
|
|
323
326
|
}
|
|
327
|
+
async authorizeBootstrap(input, principalId='host') {
|
|
328
|
+
return await this.#queue.run(() => {
|
|
329
|
+
this.#assertOpen();
|
|
330
|
+
requireCondition(input && Object.keys(input).every(k=>['viewId','leaseToken','gatewayOrigin','challenge'].includes(k)) &&
|
|
331
|
+
typeof input.gatewayOrigin === 'string' && typeof input.challenge === 'string' && /^[a-f0-9]{64}$/.test(input.challenge),
|
|
332
|
+
'INVALID_REQUEST','Expected a bound bootstrap challenge.');
|
|
333
|
+
const lease=this.#findLease(this.#state,principalId,input.viewId);this.#checkToken(lease,input.leaseToken);
|
|
334
|
+
requireCondition(active(lease,this.clock()),'LEASE_EXPIRED','Inactive view.',{},403);
|
|
335
|
+
const control=this.#controls.get(lease.instanceId);
|
|
336
|
+
requireCondition(control?.gateway,'INSTANCE_NOT_READY','Gateway is not ready.');
|
|
337
|
+
return control.gateway.authorizeChallenge(leaseKey(principalId,input.viewId,lease.generation),input.gatewayOrigin,input.challenge);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
324
340
|
async beginOpen(input,principalId='host') {
|
|
325
341
|
const prepared = await this.#prepareOpen(input,principalId);
|
|
326
342
|
return await this.#openResult(prepared,input,principalId,false);
|
|
@@ -385,7 +401,7 @@ export class AppHost {
|
|
|
385
401
|
XDG_STATE_HOME:join(instance.dataDir,'state'),XDG_DATA_HOME:instance.dataDir,
|
|
386
402
|
};
|
|
387
403
|
control.origin=`http://127.0.0.1:${port}`;
|
|
388
|
-
control.runner=await spawnOwned({ nodeBinary:this.#nodeBinary,command,args:deployment.args.map(value => expand(value,values)),
|
|
404
|
+
control.runner=await spawnOwned({ nodeBinary:this.#nodeBinaryProvider?this.#nodeBinaryProvider():this.#nodeBinary,command,args:deployment.args.map(value => expand(value,values)),
|
|
389
405
|
cwd:deployment.cwd ?? instance.dataDir,dataDir:instance.dataDir,port,env,stopGraceMs:deployment.stopGraceMs,secrets:credentials.secrets },{
|
|
390
406
|
onLog:(stream,text) => {
|
|
391
407
|
const records=this.#logs.get(instance.id) ?? [];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import {defineDomain} from '@deepseek-ai/dsh-storage-domain';
|
|
2
|
+
import {z} from 'zod';
|
|
3
|
+
export const nodeRuntimeDomainSpec=defineDomain({name:'hanamesh_node_runtime',version:1,layout:'single',tables:{},global:{
|
|
4
|
+
schema:z.object({schema:z.literal(1),revision:z.number().int().nonnegative(),settings:z.record(z.string(),z.unknown()).nullable(),active:z.record(z.string(),z.unknown()).nullable()}),
|
|
5
|
+
initial:{schema:1,revision:0,settings:null,active:null},
|
|
6
|
+
}});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"keyringCommit": "481637f813e912c4aa3622d7964ab426c97b8e8d",
|
|
3
|
+
"archives": [
|
|
4
|
+
{
|
|
5
|
+
"version": "24.13.1",
|
|
6
|
+
"platform": "darwin-arm64",
|
|
7
|
+
"asset": "node-v24.13.1-darwin-arm64.tar.gz",
|
|
8
|
+
"sha256": "8c039d59f2fec6195e4281ad5b0d02b9a940897b4df7b849c6fb48be6787bba6",
|
|
9
|
+
"bytes": 51007946,
|
|
10
|
+
"unpackedBytes": 189519272,
|
|
11
|
+
"files": 4559,
|
|
12
|
+
"url": "https://nodejs.org/dist/v24.13.1/node-v24.13.1-darwin-arm64.tar.gz",
|
|
13
|
+
"licenseSha256": "9ef8d3fa97d90d93543ee9e2b71394de59c09d8057625fd355d45303243527a3",
|
|
14
|
+
"signatureVerified": true,
|
|
15
|
+
"keyringCommit": "481637f813e912c4aa3622d7964ab426c97b8e8d"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "24.13.1",
|
|
19
|
+
"platform": "darwin-x64",
|
|
20
|
+
"asset": "node-v24.13.1-darwin-x64.tar.gz",
|
|
21
|
+
"sha256": "527f0578d9812e7dfa225121bda0b1546a6a0e4b5f556295fc8299c272de5fbf",
|
|
22
|
+
"bytes": 52182519,
|
|
23
|
+
"unpackedBytes": 191849205,
|
|
24
|
+
"files": 4559,
|
|
25
|
+
"url": "https://nodejs.org/dist/v24.13.1/node-v24.13.1-darwin-x64.tar.gz",
|
|
26
|
+
"licenseSha256": "9ef8d3fa97d90d93543ee9e2b71394de59c09d8057625fd355d45303243527a3",
|
|
27
|
+
"signatureVerified": true,
|
|
28
|
+
"keyringCommit": "481637f813e912c4aa3622d7964ab426c97b8e8d"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"version": "24.22.0",
|
|
32
|
+
"platform": "darwin-arm64",
|
|
33
|
+
"asset": "node-v24.22.0-darwin-arm64.tar.gz",
|
|
34
|
+
"sha256": "6a9724d9b50a5f22b4816cded441b5a8d9ddae546191a5e5b7efa5a5e2ee26d5",
|
|
35
|
+
"bytes": 53049576,
|
|
36
|
+
"unpackedBytes": 197365224,
|
|
37
|
+
"files": 4797,
|
|
38
|
+
"url": "https://nodejs.org/dist/v24.22.0/node-v24.22.0-darwin-arm64.tar.gz",
|
|
39
|
+
"licenseSha256": "5888dbb9a1d2b18f2c3e6c5f6af1b39de658372b402a0577b002777f14c62ace",
|
|
40
|
+
"signatureVerified": true,
|
|
41
|
+
"keyringCommit": "481637f813e912c4aa3622d7964ab426c97b8e8d"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"version": "24.22.0",
|
|
45
|
+
"platform": "darwin-x64",
|
|
46
|
+
"asset": "node-v24.22.0-darwin-x64.tar.gz",
|
|
47
|
+
"sha256": "495e3f273c645f3c4677d438304cdfc701729ea5dec5728f5c8e76a2dece4b91",
|
|
48
|
+
"bytes": 54356645,
|
|
49
|
+
"unpackedBytes": 200534581,
|
|
50
|
+
"files": 4797,
|
|
51
|
+
"url": "https://nodejs.org/dist/v24.22.0/node-v24.22.0-darwin-x64.tar.gz",
|
|
52
|
+
"licenseSha256": "5888dbb9a1d2b18f2c3e6c5f6af1b39de658372b402a0577b002777f14c62ace",
|
|
53
|
+
"signatureVerified": true,
|
|
54
|
+
"keyringCommit": "481637f813e912c4aa3622d7964ab426c97b8e8d"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"verification": "official signed SHASUMS gpgv; SHA match; no runtime execution",
|
|
58
|
+
"defaultVersion": "24.22.0"
|
|
59
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Plugin-owned standalone runtime. Nothing downloads during activation, planning or settings saves. */
|
|
2
|
+
import {readFile,statfs,lstat,access} from 'node:fs/promises';
|
|
3
|
+
import {constants} from 'node:fs';
|
|
4
|
+
import {execFile} from 'node:child_process';
|
|
5
|
+
import {promisify} from 'node:util';
|
|
6
|
+
import {release} from 'node:os';
|
|
7
|
+
import {join,dirname,isAbsolute,resolve} from 'node:path';
|
|
8
|
+
import {randomUUID} from 'node:crypto';
|
|
9
|
+
import * as provisionApi from '@hanamesh/lib-provision';
|
|
10
|
+
import {AppHostError,requireCondition,copy} from '../errors.js';
|
|
11
|
+
import {secureDirectory,FileLock} from '../store.js';
|
|
12
|
+
const run=promisify(execFile);
|
|
13
|
+
const catalog=JSON.parse(await readFile(new URL('./releases.json',import.meta.url),'utf8'));
|
|
14
|
+
export const NODE_SETTING_NAMES=Object.freeze({mode:'安装环境管理方式',version:'独立 Node 固定版本',sourceBase:'Node 下载来源',storageRoot:'独立 Node 存放位置',externalBinary:'已有独立 Node 路径(高级)'});
|
|
15
|
+
const fields=Object.keys(NODE_SETTING_NAMES);
|
|
16
|
+
const plain=x=>x&&typeof x==='object'&&!Array.isArray(x);
|
|
17
|
+
const exact=(input,keys)=>requireCondition(plain(input)&&Object.keys(input).every(k=>keys.includes(k)),'NODE_INPUT_INVALID','安装环境的请求包含不支持的字段。');
|
|
18
|
+
const checkScript="console.log(JSON.stringify({version:process.versions.node,platform:process.platform,arch:process.arch,electron:process.versions.electron??null}))";
|
|
19
|
+
export async function probeStandaloneNode(binary,{platform,version,signal}={}){
|
|
20
|
+
requireCondition(typeof binary==='string'&&isAbsolute(binary),'NODE_BINARY_INVALID','独立 Node 路径必须是绝对路径。');
|
|
21
|
+
await access(binary,constants.X_OK);
|
|
22
|
+
const {stdout}=await run(binary,['-e',checkScript],{env:{PATH:''},maxBuffer:4096,signal});
|
|
23
|
+
let actual;try{actual=JSON.parse(stdout);}catch{throw new AppHostError('NODE_NOT_STANDALONE','所选文件没有返回独立 Node 的检查结果;Electron 包装脚本不能代替独立 Node。');}
|
|
24
|
+
requireCondition(actual.electron===null&&/^24\./.test(actual.version)&&(Number(actual.version.split('.')[1])>13||Number(actual.version.split('.')[1])===13&&Number(actual.version.split('.')[2])>=1)&&(!version||actual.version===version)&&`${actual.platform}-${actual.arch}`===platform,'NODE_NOT_STANDALONE','需要当前平台的独立 Node.js 24(24.13.1 或更新);所选文件的版本、平台或 Electron 检查未通过。');
|
|
25
|
+
return actual;
|
|
26
|
+
}
|
|
27
|
+
async function safeAncestors(path){
|
|
28
|
+
let p=path;
|
|
29
|
+
while(true){try{const info=await lstat(p);requireCondition(info.isDirectory()&&!info.isSymbolicLink(),'NODE_STORAGE_UNSAFE','存放位置不能经过符号链接或普通文件。');}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
30
|
+
const parent=dirname(p);if(p===parent)break;p=parent;}
|
|
31
|
+
}
|
|
32
|
+
async function diskBytes(path){let p=path;while(true){try{const info=await statfs(p,{bigint:true});return Number(info.bavail*info.bsize);}catch(e){if(e.code!=='ENOENT'||p===dirname(p))throw e;p=dirname(p);}}}
|
|
33
|
+
const causeText=e=>String(e?.message??e).slice(0,1500);
|
|
34
|
+
/** Ports are public lib-provision and storage-domain; test substitutes never enter the normal plugin package. */
|
|
35
|
+
export function createNodeRuntime({domain,dataRoot,config={},initialBinary,platform=`${process.platform}-${process.arch}`,kernelRelease=release(),provisionApi:api=provisionApi,probe=probeStandaloneNode,freeBytes=diskBytes,busy=()=>false,checkpoint=async()=>{},lockFactory=path=>new FileLock(path,{reclaimDead:true})}){
|
|
36
|
+
const defaultSettings={mode:initialBinary?'external':'managed',version:catalog.defaultVersion,sourceBase:'https://nodejs.org/dist',storageRoot:join(dataRoot,'managed-node'),externalBinary:initialBinary??''};
|
|
37
|
+
let state,settings,executable,operation=null,plan=null,closed=false,saving=false,pending=Promise.resolve(),controller;
|
|
38
|
+
const supported=['darwin-arm64','darwin-x64'].includes(platform)&&Number(kernelRelease.split('.')[0])>=22&&(Number(kernelRelease.split('.')[0])>22||Number(kernelRelease.split('.')[1])>=6);
|
|
39
|
+
const available=()=>{requireCondition(!closed,'NODE_CLOSED','安装环境页面已关闭。');};
|
|
40
|
+
const idle=()=>{available();requireCondition(!saving&&!controller&&!['preparing','committing'].includes(operation?.state)&&!busy(),'NODE_IN_USE','安装环境正在准备或有插件操作运行,请完成或取消后再修改。');};
|
|
41
|
+
function validate(next){
|
|
42
|
+
exact(next,fields);requireCondition(fields.every(k=>typeof next[k]==='string')&&['managed','external'].includes(next.mode),'NODE_SETTINGS_INVALID','请填写完整的安装环境设置。');
|
|
43
|
+
requireCondition(catalog.archives.some(a=>a.version===next.version),'NODE_VERSION_UNSUPPORTED','请选择已校验的固定 Node 版本,不能填写 latest。');
|
|
44
|
+
let source;try{source=new URL(next.sourceBase);}catch{}
|
|
45
|
+
requireCondition(source?.protocol==='https:'&&!source.username&&!source.password&&!source.search&&!source.hash,'NODE_SOURCE_INVALID','下载来源必须是没有账号、密码、查询参数的 HTTPS 地址。');
|
|
46
|
+
requireCondition(isAbsolute(next.storageRoot)&&resolve(next.storageRoot)===next.storageRoot&&next.storageRoot!=='/'&&!next.storageRoot.includes('\0'),'NODE_STORAGE_INVALID','请选择绝对的专用存放目录。');
|
|
47
|
+
requireCondition(next.mode!=='external'||isAbsolute(next.externalBinary),'NODE_BINARY_INVALID','已有独立 Node 路径必须是绝对路径。');
|
|
48
|
+
return {...next,sourceBase:source.href.replace(/\/$/,'')};
|
|
49
|
+
}
|
|
50
|
+
const externalCheck=()=>requireCondition(['darwin-arm64','darwin-x64','linux-arm64','linux-x64'].includes(platform),'NODE_PLATFORM_UNSUPPORTED','当前平台尚未完成独立 Node 的进程守护支持;Windows 不支持本安装流程。');
|
|
51
|
+
const supportedCheck=()=>requireCondition(supported,'NODE_PLATFORM_UNSUPPORTED','一键准备当前仅支持 macOS 13.5 及以上的 arm64 / x64;Windows 安装与进程守护尚未接通。');
|
|
52
|
+
async function publish(next){const whole={...next,revision:state.revision+1};await domain.global.set(whole);state=whole;}
|
|
53
|
+
function manifest(p){return {schema:1,sources:[{id:'confirmed-source',kind:'https',base:new URL(`v${p.version}/`,p.sourceBase+'/').href}],items:[{id:'node',version:p.version,kind:'tar.gz',installTo:'runtime',platforms:{[platform]:{asset:p.asset,sha256:p.sha256,size:p.bytes,strip:1}},verify:{exec:'bin/node',args:['-e',checkScript],expect:JSON.stringify({version:p.version,platform:'darwin',arch:platform.slice(7),electron:null})}}]};}
|
|
54
|
+
async function verifyActive(active,signal,heldLock){
|
|
55
|
+
requireCondition(active&&active.platform===platform&&catalog.archives.some(a=>a.version===active.version&&a.platform===platform&&a.sha256===active.sha256),'NODE_LEDGER_MISMATCH','运行时记录与已校验的官方发行记录不一致。');
|
|
56
|
+
await safeAncestors(active.root);const lock=heldLock??lockFactory(join(active.root,'.apphost-node.lock'));if(!heldLock)await lock.acquire();
|
|
57
|
+
try{const verification=await api.verify({root:active.root}),ledger=await api.ledger(active.root),entry=ledger.items.node;
|
|
58
|
+
requireCondition(verification.ok&&verification.items.some(i=>i.item==='node'&&['ok','repaired'].includes(i.state))&&entry?.sha256===active.sha256&&entry.version===active.version&&entry.platform===platform&&entry.installTo==='runtime','NODE_LEDGER_MISMATCH','独立 Node 文件、SHA 或管理账本不一致,未启用。');
|
|
59
|
+
const binary=join(active.root,'runtime/bin/node');await probe(binary,{platform,version:active.version,signal});return binary;
|
|
60
|
+
}finally{if(!heldLock)await lock.release();}
|
|
61
|
+
}
|
|
62
|
+
const service={
|
|
63
|
+
get executable(){return executable;},
|
|
64
|
+
async init(){state=await domain.global.get();settings=validate({...defaultSettings,...Object.fromEntries(Object.entries(config).filter(([,v])=>v!==undefined)),...(state.settings??{})});
|
|
65
|
+
try{if(settings.mode==='external'){externalCheck();await probe(settings.externalBinary,{platform});executable=settings.externalBinary;}
|
|
66
|
+
else if(state.active){supportedCheck();executable=await verifyActive(state.active);}
|
|
67
|
+
}catch(e){operation={id:null,state:'failed',phase:'restore',error:{message:'安装环境尚未就绪:'+causeText(e),code:e.code??'NODE_CHECK_FAILED'}};}
|
|
68
|
+
},
|
|
69
|
+
status(){return copy({settings,names:NODE_SETTING_NAMES,versions:[...new Set(catalog.archives.map(a=>a.version))],platform,supported,ready:!!executable,executable:executable??null,active:state?.active??null,operation,limitations:'一键准备支持 macOS 13.5+ arm64 / x64;Windows 尚不支持。下载仅在确认后开始;不重试、不切换来源。'});},
|
|
70
|
+
async save(input){exact(input,['settings']);idle();const next=validate(input.settings);saving=true;plan=null;
|
|
71
|
+
try{await safeAncestors(next.storageRoot);let external;
|
|
72
|
+
if(next.mode==='external'){externalCheck();await probe(next.externalBinary,{platform});external=next.externalBinary;}
|
|
73
|
+
await publish({...state,settings:next,active:next.mode==='external'?null:state.active});settings=next;if(next.mode==='external')executable=external;else if(state.active===null)executable=undefined;
|
|
74
|
+
operation=null;return service.status();
|
|
75
|
+
}finally{saving=false;}
|
|
76
|
+
},
|
|
77
|
+
async plan(input){exact(input,[]);idle();supportedCheck();requireCondition(settings.mode==='managed','NODE_MANAGED_REQUIRED','请先选择并保存「插件管理」方式。');
|
|
78
|
+
const revision=state.revision,snapshot=copy(settings),asset=catalog.archives.find(a=>a.version===snapshot.version&&a.platform===platform);
|
|
79
|
+
await safeAncestors(snapshot.storageRoot);const free=await freeBytes(snapshot.storageRoot),requiredBytes=asset.bytes+2*asset.unpackedBytes;
|
|
80
|
+
requireCondition(free>=requiredBytes,'NODE_DISK_SPACE',`准备环境需要至少 ${requiredBytes} 字节可用空间,当前只有 ${free} 字节。`);idle();requireCondition(revision===state.revision,'NODE_PLAN_STALE','设置已经改变,请重新查看准备信息。');
|
|
81
|
+
plan={...asset,sourceBase:snapshot.sourceBase,source:new URL(`v${snapshot.version}/${asset.asset}`,snapshot.sourceBase+'/').href,root:join(snapshot.storageRoot,'apphost-node',snapshot.version,platform),requiredBytes,freeBytes:free,planId:randomUUID(),revision,license:'Node.js MIT;归档内 LICENSE 保留其第三方声明',minimumOS:'macOS 13.5'};return copy(plan);
|
|
82
|
+
},
|
|
83
|
+
prepare(input){exact(input,['planId','confirm']);idle();supportedCheck();requireCondition(input.confirm===true,'NODE_CONFIRM_REQUIRED','请先核对来源、平台、版本与体积,再确认下载。');
|
|
84
|
+
requireCondition(plan&&input.planId===plan.planId&&plan.revision===state.revision,'NODE_PLAN_STALE','这份确认信息已经失效,请重新查看准备信息。');
|
|
85
|
+
const accepted=plan;plan=null;controller=new AbortController();const signal=controller.signal;operation={id:randomUUID(),state:'preparing',phase:'starting',bytes:0,total:accepted.bytes};
|
|
86
|
+
pending=(async()=>{let lock;try{
|
|
87
|
+
requireCondition(await freeBytes(settings.storageRoot)>=accepted.requiredBytes,'NODE_DISK_SPACE','当前磁盘空间不足,尚未开始下载。');
|
|
88
|
+
await secureDirectory(accepted.root);lock=lockFactory(join(accepted.root,'.apphost-node.lock'));await lock.acquire();signal.throwIfAborted();
|
|
89
|
+
await api.provision(manifest(accepted),{root:accepted.root,platform,force:true,sourceOrder:['confirmed-source'],signal,onProgress:event=>{operation={...operation,phase:event.phase,...(event.bytes!==undefined?{bytes:event.bytes}:{}),...(event.total!==undefined?{total:event.total}:{})};}});
|
|
90
|
+
signal.throwIfAborted();
|
|
91
|
+
const active={root:accepted.root,version:accepted.version,platform,sha256:accepted.sha256},binary=await verifyActive(active,signal,lock);signal.throwIfAborted();
|
|
92
|
+
await checkpoint('node-verified-before-binding');signal.throwIfAborted();operation={...operation,state:'committing',phase:'bind'};await publish({...state,settings,active});await lock.release();lock=null;executable=binary;operation={...operation,state:'ready',phase:'ready'};
|
|
93
|
+
}catch(e){operation={...operation,state:signal.aborted?'cancelled':'failed',error:{code:signal.aborted?'NODE_PREPARATION_CANCELLED':e.code??'NODE_PREPARATION_FAILED',message:signal.aborted?'已取消准备;尚未启用新的安装环境。':`准备安装环境未完成(${operation.phase}):${causeText(e)}`}};
|
|
94
|
+
}finally{try{if(lock)await lock.release();}catch(e){operation={...operation,state:'failed',error:{code:'NODE_CLEANUP_FAILED',message:(operation.error?.message??'准备安装环境未完成')+';运行时锁清理失败:'+causeText(e),original:operation.error??null}};}finally{controller=null;}}})() ;return {operation:copy(operation)};
|
|
95
|
+
},
|
|
96
|
+
cancel(input){exact(input,['operationId']);available();requireCondition(operation?.state!=='committing','NODE_COMMIT_IN_PROGRESS','已完成校验,正在保存并启用,不能再取消;请查看最终结果。');requireCondition(controller&&operation.state==='preparing'&&input.operationId===operation.id,'NODE_OPERATION_STALE','这项准备操作已经结束或不是当前操作。');controller.abort();return {operation:copy(operation)};},
|
|
97
|
+
settled:()=>pending,
|
|
98
|
+
async close(){closed=true;controller?.abort();await pending;},
|
|
99
|
+
};
|
|
100
|
+
return service;
|
|
101
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { appendFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Explicit card-run opt-in. No environment, credentials, argv of other processes,
|
|
6
|
+
// or lsof output is recorded. Writes never join the readiness promise.
|
|
7
|
+
export function probeDiagnosticSink(directory) {
|
|
8
|
+
return event => { void appendFile(join(directory, 'owned-lsof.jsonl'), JSON.stringify(event)+'\n').catch(()=>{}); };
|
|
9
|
+
}
|
|
10
|
+
export function observeOwnedProbe(child, { port, args, source, emit }) {
|
|
11
|
+
const start = performance.now();
|
|
12
|
+
const startedAt = new Date().toISOString();
|
|
13
|
+
const base = { source, pid: child.pid ?? null, port, address: '127.0.0.1', args, startedAt, timeoutMs: 1000 };
|
|
14
|
+
let observer, identityObserver, finished = false, pgid = null;
|
|
15
|
+
const write = event => { try { emit({...base,...event}); } catch {} };
|
|
16
|
+
write({phase:'start', pgid:null, pgidSource:'pending exact-pid ps'});
|
|
17
|
+
if (child.pid) {
|
|
18
|
+
identityObserver = execFile('/bin/ps', ['-p',String(child.pid),'-o','pid=,pgid='],
|
|
19
|
+
{timeout:150,maxBuffer:4096}, (error,stdout)=>{
|
|
20
|
+
const fields=stdout.trim().split(/\s+/);
|
|
21
|
+
pgid = Number(fields[0])===child.pid?Number(fields[1]):null;
|
|
22
|
+
write({phase:'identity',pgid,
|
|
23
|
+
pgidSource:'exact-pid ps',observerExit:error?.code??0,observerSignal:error?.signal??null,
|
|
24
|
+
blindSpot:Number(fields[0])===child.pid?null:'Probe exited or ps could not observe its group.'});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const timer = setTimeout(()=>{
|
|
28
|
+
if (finished || child.exitCode !== null || child.signalCode !== null || !child.pid) return;
|
|
29
|
+
const targetPid = child.pid;
|
|
30
|
+
// ps selects only the child handle while it is alive. No command/env fields.
|
|
31
|
+
observer = execFile('/bin/ps', ['-p',String(targetPid),'-o','pid=,pgid=,stat=,wchan='],
|
|
32
|
+
{timeout:150,maxBuffer:4096}, (error, stdout)=>{
|
|
33
|
+
const fields = stdout.trim().split(/\s+/);
|
|
34
|
+
const matched = Number(fields[0]) === targetPid;
|
|
35
|
+
write({phase:'wait-state',targetPid, observedAt:new Date().toISOString(),
|
|
36
|
+
pgid:matched?Number(fields[1]):null, state:matched?fields[2]:null, wait:matched?fields.slice(3).join(' '):null,
|
|
37
|
+
observerExit:error?.code??0, observerSignal:error?.signal??null,
|
|
38
|
+
observerKilled:error?.killed??false, sourceStage:'slow-window exact-pid ps',
|
|
39
|
+
blindSpot:'No stack collected: stack sampling may suspend target or outlive the original 1000ms budget; ps wait availability depends on platform permissions.'});
|
|
40
|
+
});
|
|
41
|
+
},250);
|
|
42
|
+
timer.unref();
|
|
43
|
+
child.once('error', error=>write({phase:'error',errorCode:error.code??null}));
|
|
44
|
+
child.once('exit',(exit,signal)=>{
|
|
45
|
+
finished=true; clearTimeout(timer);
|
|
46
|
+
for (const ownedObserver of [observer,identityObserver]) {
|
|
47
|
+
if (ownedObserver && ownedObserver.exitCode===null && ownedObserver.signalCode===null) ownedObserver.kill('SIGTERM');
|
|
48
|
+
}
|
|
49
|
+
write({phase:'end',endedAt:new Date().toISOString(),elapsedMs:performance.now()-start,
|
|
50
|
+
pgid,exit,signal,killed:child.killed,sourceStage:'owned execFile exit'});
|
|
51
|
+
});
|
|
52
|
+
child.once('close',()=>{clearTimeout(timer);});
|
|
53
|
+
}
|
package/dist/router/receipts.js
CHANGED
|
@@ -6,18 +6,15 @@
|
|
|
6
6
|
*
|
|
7
7
|
* { appId, providerId, model, hour, count } hour = UTC `YYYYMMDDHH`, count = forwarded requests in that hour
|
|
8
8
|
*
|
|
9
|
-
* kept in app-host's own storage domain (`hanamesh_router_receipts`, one global snapshot,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* kept in app-host's own storage domain (`hanamesh_router_receipts`, one global snapshot,
|
|
10
|
+
* ONE debounced `global.set` per publication). These are transport diagnostics only;
|
|
11
|
+
* ready/HTTP/WS counts and restoring old rows never prove presentation or business
|
|
12
|
+
* success and are never automatically sent to the Usage record service.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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).
|
|
14
|
+
* `inject` remembers the first resolved route and increments injections; `activity`
|
|
15
|
+
* counts forwarded requests. Unknown routes remain null. The legacy `reported`
|
|
16
|
+
* field and explicit markReported() operation stay available for existing ledger
|
|
17
|
+
* consumers, without asserting Usage acceptance, delivery or business success.
|
|
21
18
|
*
|
|
22
19
|
* Without a storage domain (library use, tests) the ledger is memory-only and behaves identically.
|
|
23
20
|
*/
|
package/dist/router/routes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { AppHostError, requireCondition } from '../errors.js';
|
|
3
|
-
import {
|
|
3
|
+
import { controlOrigins } from '../control-origin.js';
|
|
4
4
|
|
|
5
5
|
export const ROUTER_ROUTES=Object.freeze(['/hanamesh/router/providers','/hanamesh/router/plan','/hanamesh/router/grant',
|
|
6
6
|
'/hanamesh/router/revoke','/hanamesh/router/model','/hanamesh/router/mode','/hanamesh/router/gateway','/hanamesh/router/receipts']);
|
|
@@ -14,18 +14,18 @@ function send(res,status,value){res.writeHead(status,{'content-type':'applicatio
|
|
|
14
14
|
const statusFor=code=>({APP_NOT_FOUND:404,ENTRY_UNKNOWN:404,ROUTER_PROVIDER_ABSENT:404,RISK_NOT_ACKNOWLEDGED:403,
|
|
15
15
|
CONSUMER_MISMATCH:403,ENTRY_NOT_ROUTED:409,GATEWAY_OFF:409,GATEWAY_UNREACHABLE:503}[code]??400);
|
|
16
16
|
|
|
17
|
-
export function createRouterHttpHandler(router,{parentOrigin,authenticate,authorize}){
|
|
18
|
-
|
|
17
|
+
export function createRouterHttpHandler(router,{parentOrigin,transportOrigin,authenticate,authorize}){
|
|
18
|
+
const origins=controlOrigins(parentOrigin,transportOrigin);parentOrigin=origins.parent;requireCondition(typeof authenticate==='function'&&typeof authorize==='function','AUTH_BINDING_REQUIRED','Router authentication is required.');
|
|
19
19
|
return async(req,res)=>{const traceId=randomUUID();try{
|
|
20
|
-
requireCondition(req.headers.host===new URL(
|
|
20
|
+
requireCondition(req.headers.host===new URL(origins.transport).host,'HOST_DENIED','Unrecognized request host.',{},403);
|
|
21
21
|
requireCondition(req.url?.startsWith('/')&&!req.url.startsWith('//')&&!req.url.includes('\\'),'BAD_TARGET','Invalid request target.');
|
|
22
|
-
const url=new URL(req.url,
|
|
22
|
+
const url=new URL(req.url,origins.transport),path=url.pathname;requireCondition(ROUTER_ROUTES.includes(path),'NOT_FOUND','Unknown router route.',{},404);
|
|
23
23
|
const readOnly=['/hanamesh/router/providers','/hanamesh/router/plan','/hanamesh/router/receipts'].includes(path);
|
|
24
24
|
requireCondition(req.method===(readOnly?'GET':'POST'),'METHOD_NOT_ALLOWED','State changes require POST.',{},405);
|
|
25
|
-
requireCondition(!req.headers.origin||req.headers.origin
|
|
25
|
+
requireCondition(!req.headers.origin||origins.acceptsOrigin(req.headers.origin),'ORIGIN_DENIED','Unapproved browser origin.',{},403);
|
|
26
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
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
|
|
28
|
+
if(!readOnly)requireCondition(origins.acceptsOrigin(req.headers.origin),'CSRF_DENIED','Workspace origin is required.',{},403);
|
|
29
29
|
const subject=await authenticate(req);requireCondition(subject&&typeof subject.principalId==='string','UNAUTHENTICATED','Host authentication is required.',{},401);
|
|
30
30
|
const input=readOnly?Object.fromEntries(url.searchParams):await body(req);
|
|
31
31
|
if(path==='/hanamesh/router/providers')requireCondition(Object.keys(input).length===0,'UNKNOWN_FIELDS','No provider parameters are accepted.');
|
package/dist/routes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { AppHostError, requireCondition } from './errors.js';
|
|
3
|
-
import {
|
|
3
|
+
import { controlOrigins } from './control-origin.js';
|
|
4
4
|
function browserInstance(instance) {
|
|
5
5
|
if(!instance)return null;
|
|
6
6
|
const {id,appId,deploymentId,dataId,mode,status,runtimeId,errorCode}=instance;
|
|
@@ -23,24 +23,24 @@ function send(res,status,value) {
|
|
|
23
23
|
res.end(JSON.stringify(value));
|
|
24
24
|
}
|
|
25
25
|
/** Node request handler. Authentication/authorization MUST come from the host. */
|
|
26
|
-
export function createHttpHandler(host,{parentOrigin,authenticate,authorize}) {
|
|
27
|
-
|
|
26
|
+
export function createHttpHandler(host,{parentOrigin,transportOrigin,authenticate,authorize}) {
|
|
27
|
+
const origins=controlOrigins(parentOrigin,transportOrigin);parentOrigin=origins.parent;
|
|
28
28
|
requireCondition(typeof authenticate==='function'&&typeof authorize==='function','AUTH_BINDING_REQUIRED',
|
|
29
29
|
'Host authentication and per-operation authorization callbacks are mandatory.');
|
|
30
30
|
return async(req,res)=>{
|
|
31
31
|
const traceId=randomUUID();
|
|
32
32
|
try{
|
|
33
|
-
requireCondition(req.headers.host===new URL(
|
|
33
|
+
requireCondition(req.headers.host===new URL(origins.transport).host,'HOST_DENIED','Unrecognized request host.',{},403);
|
|
34
34
|
requireCondition(req.url?.startsWith('/')&&!req.url.startsWith('//')&&!req.url.includes('\\'),'BAD_TARGET','Invalid request target.');
|
|
35
|
-
const url=new URL(req.url,
|
|
36
|
-
const known=['/hanamesh/apps','/apps/open','/apps/resume','/apps/recover','/apps/close','/apps/heartbeat','/apps/stop','/apps/events'];
|
|
35
|
+
const url=new URL(req.url,origins.transport),path=url.pathname;
|
|
36
|
+
const known=['/hanamesh/apps','/apps/open','/apps/resume','/apps/recover','/apps/close','/apps/heartbeat','/apps/stop','/apps/events','/apps/bootstrap-authorize'];
|
|
37
37
|
requireCondition(known.includes(path),'NOT_FOUND','Unknown app-host route.',{},404);
|
|
38
38
|
const readOnly=['/hanamesh/apps','/apps/events'].includes(path);
|
|
39
39
|
requireCondition(req.method===(readOnly?'GET':'POST'),'METHOD_NOT_ALLOWED','State changes require POST.',{},405);
|
|
40
|
-
requireCondition(!req.headers.origin||req.headers.origin
|
|
40
|
+
requireCondition(!req.headers.origin||origins.acceptsOrigin(req.headers.origin),'ORIGIN_DENIED','Unapproved browser origin.',{},403);
|
|
41
41
|
requireCondition(req.headers['sec-fetch-dest']!=='iframe','FRAME_CONTROL_DENIED','An application frame cannot control the host.',{},403);
|
|
42
42
|
if(!readOnly){
|
|
43
|
-
requireCondition(req.headers.origin
|
|
43
|
+
requireCondition(origins.acceptsOrigin(req.headers.origin)&&req.headers['x-hanamesh-client']==='workspace-v1',
|
|
44
44
|
'CSRF_DENIED','Workspace origin and explicit client header are required.',{},403);
|
|
45
45
|
}
|
|
46
46
|
const subject=await authenticate(req);
|
|
@@ -50,6 +50,7 @@ export function createHttpHandler(host,{parentOrigin,authenticate,authorize}) {
|
|
|
50
50
|
let result;
|
|
51
51
|
switch(path){
|
|
52
52
|
case '/hanamesh/apps':requireCondition(!url.search,'UNKNOWN_FIELDS','No list parameters are accepted.');result=host.list(subject.principalId);break;
|
|
53
|
+
case '/apps/bootstrap-authorize':result=await host.authorizeBootstrap(input,subject.principalId);break;
|
|
53
54
|
case '/apps/open':result=await host.beginOpen(input,subject.principalId);break;
|
|
54
55
|
case '/apps/resume':result=await host.resume(input,subject.principalId,{waitForReady:false});break;
|
|
55
56
|
case '/apps/recover':result=await host.recoverView(input,subject.principalId);break;
|
package/dist/runtime.js
CHANGED
|
@@ -7,6 +7,7 @@ import { dirname, isAbsolute, join } from 'node:path';
|
|
|
7
7
|
import { promisify } from 'node:util';
|
|
8
8
|
import { AppHostError, bounded } from './errors.js';
|
|
9
9
|
const exec = promisify(execFile);
|
|
10
|
+
import { observeOwnedProbe, probeDiagnosticSink } from './owned-probe-diagnostic.js';
|
|
10
11
|
export async function resolveNodeBinary(nodeBinary) {
|
|
11
12
|
let selected = nodeBinary;
|
|
12
13
|
if (selected === undefined || selected === null || selected === '') {
|
|
@@ -83,7 +84,16 @@ export async function ownsLoopbackPort(pid, port) {
|
|
|
83
84
|
}
|
|
84
85
|
if (!binary) throw new AppHostError('PORT_OWNERSHIP_UNAVAILABLE', 'lsof is required to verify socket ownership on macOS.');
|
|
85
86
|
let stdout;
|
|
86
|
-
try {
|
|
87
|
+
try {
|
|
88
|
+
// Select the already-required owned group before Darwin inspects file descriptors of unrelated processes.
|
|
89
|
+
const args = ['-nP', '-a', '-g', String(pid), `-iTCP:${port}`, '-sTCP:LISTEN', '-FpgLn'];
|
|
90
|
+
const pending = exec(binary, args, { timeout: 1_000 });
|
|
91
|
+
if (process.env.HANAMESH_APPHOST_RUN_DIR) {
|
|
92
|
+
try { observeOwnedProbe(pending.child, {port, args, source:'readiness/socket-ownership',
|
|
93
|
+
emit:probeDiagnosticSink(process.env.HANAMESH_APPHOST_RUN_DIR)}); } catch {}
|
|
94
|
+
}
|
|
95
|
+
({ stdout } = await pending);
|
|
96
|
+
}
|
|
87
97
|
catch (error) { if (error.code === 1) return false; throw error; }
|
|
88
98
|
const blocks = stdout.split(/(?=^p\d+$)/m);
|
|
89
99
|
return blocks.some(block => block.split('\n').includes(`g${pid}`) && block.includes(`n127.0.0.1:${port}`));
|
package/dist/usage-evidence.js
CHANGED
|
@@ -1,117 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* OPTIONAL Cordis service, read duck-typed each time it is needed. Absent or incompatible seat → no events,
|
|
7
|
-
* never a throw, never a blocked launch or proxied request.
|
|
8
|
-
*
|
|
9
|
-
* open once per successful app open — the instance reaching `ready` (the served view).
|
|
10
|
-
* idempotencyKey `open:<appId>:<instanceId>`, occurredAt = the ready time.
|
|
11
|
-
* use real activity only — the instance's gateway forwarded an authorized request (bootstrap, denials,
|
|
12
|
-
* 401/403 answers and the host's own readiness probes never count). At most one per app per UTC hour:
|
|
13
|
-
* idempotencyKey `use:<appId>:<YYYYMMDDHH>`, occurredAt = the first forwarded request of that bucket.
|
|
14
|
-
* T6: the event carries `targetRef = appId` and, when the Router routed a provider into the app, the
|
|
15
|
-
* hour's receipt `{providerId, model, count}` from the receipt ledger (router/receipts.js). Because the
|
|
16
|
-
* count is only final when the hour is over, the `use` event is handed to the seat when the UTC hour
|
|
17
|
-
* closes (next activity in a later hour, the periodic sweep, plugin shutdown) or as soon as the app's
|
|
18
|
-
* last live instance ends. Hours that were still open when the host died are reported on the next start
|
|
19
|
-
* (the ledger is persisted); the seat de-duplicates by key, so nothing is counted twice.
|
|
20
|
-
*
|
|
21
|
-
* `hanaRef` is the app's npm package name (`host.packageName(appId)`: definition `packageName`, else the
|
|
22
|
-
* installed-package scan binding). No package name → no evidence for that app (debug log only).
|
|
23
|
-
* `withheld` (consent off) and `duplicate` (repeat) are normal dispositions and are logged at debug only.
|
|
2
|
+
* AppHost's runtime/transport observations are Router diagnostics, not original
|
|
3
|
+
* application presentation or successful-business facts (C-USAGE-LOCAL edition 1).
|
|
4
|
+
* No verified public source for those facts is connected here. Keep Usage unknown
|
|
5
|
+
* and leave the application available; never manufacture record input or time.
|
|
24
6
|
*/
|
|
25
7
|
import { requireCondition } from './errors.js';
|
|
26
8
|
import { createReceiptLedger, hourBucket } from './router/receipts.js';
|
|
27
9
|
|
|
28
10
|
export { hourBucket };
|
|
29
11
|
export const SOURCE_PLUGIN = '@hanamesh/dsh-app-host';
|
|
30
|
-
|
|
31
|
-
/** Duck-type the optional sibling service: anything exposing `record(input)` is used, nothing else is assumed. */
|
|
12
|
+
/** Shape inspection only; presence of a record method does not prove a source fact. */
|
|
32
13
|
export const duckSeat = value => value !== null && typeof value === 'object' && typeof value.record === 'function' ? value : null;
|
|
33
14
|
const TERMINAL = ['instance.stopped','instance.failed','instance.interrupted'];
|
|
34
|
-
const LIVE = ['starting','ready','stopping'];
|
|
35
15
|
|
|
36
16
|
/**
|
|
17
|
+
* Preserve the existing adapter lifecycle and independent Router ledger. `seat`
|
|
18
|
+
* and `sweepIntervalMs` remain accepted for compatibility, but no record call or
|
|
19
|
+
* hourly reporting timer is created without a verified original fact source.
|
|
37
20
|
* @param {{host:import('./manager.js').AppHost, seat:()=>unknown, receipts?:ReturnType<typeof createReceiptLedger>, logger?:{debug?:Function}, clock?:()=>number, sweepIntervalMs?:number}} options
|
|
38
|
-
* `seat()` is read on every emission (the service may appear or disappear while the host runs).
|
|
39
|
-
* `receipts` is the Router's receipt ledger; without one an in-memory ledger gives the same behaviour.
|
|
40
21
|
*/
|
|
41
22
|
export function createUsageEvidence({ host, seat, receipts, logger, clock = Date.now, sweepIntervalMs = 60_000 }) {
|
|
42
23
|
requireCondition(host && typeof host.subscribe === 'function' && typeof host.onActivity === 'function' && typeof host.packageName === 'function',
|
|
43
24
|
'INVALID_HOST','createUsageEvidence needs an AppHost with subscribe/onActivity/packageName.');
|
|
44
25
|
requireCondition(typeof seat === 'function','INVALID_SEAT','seat must be a function returning the optional usage service.');
|
|
45
26
|
requireCondition(Number.isSafeInteger(sweepIntervalMs) && sweepIntervalMs >= 0,'INVALID_OPTIONS','sweepIntervalMs must be a non-negative integer.');
|
|
46
|
-
const debug = (...args) => { try { logger?.debug?.(...args); } catch {} };
|
|
47
27
|
const ledger = receipts ?? createReceiptLedger({ clock });
|
|
48
|
-
const inflight = new Set();
|
|
49
28
|
let closed = false;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (closed) return Promise.resolve(null);
|
|
54
|
-
const api = duckSeat(seat());
|
|
55
|
-
if (!api) { debug('hanamesh-app-host usage: seat absent, %s not recorded', key); return Promise.resolve(null); }
|
|
56
|
-
const hanaRef = host.packageName(appId);
|
|
57
|
-
if (!hanaRef) { debug('hanamesh-app-host usage: no package name for app %s, %s not recorded', appId, key); return Promise.resolve(null); }
|
|
58
|
-
if (key.length > MAX_KEY) { debug('hanamesh-app-host usage: key too long, %s not recorded', key.slice(0,40)); return Promise.resolve(null); }
|
|
59
|
-
const input = { hanaRef, action, occurredAt:new Date(at).toISOString(), idempotencyKey:key, sourcePlugin:SOURCE_PLUGIN, ...extra };
|
|
60
|
-
let task;
|
|
61
|
-
try { task = Promise.resolve(api.record(input)); }
|
|
62
|
-
catch (error) { debug('hanamesh-app-host usage: seat threw for %s (%s)', key, error?.code ?? error?.message); return Promise.resolve(null); }
|
|
63
|
-
const settled = task.then(result => {
|
|
64
|
-
const disposition = result?.disposition ?? 'unknown';
|
|
65
|
-
debug('hanamesh-app-host usage: %s %s → %s%s', action, key, disposition, result?.code ? ` (${result.code})` : '');
|
|
66
|
-
return result && typeof result === 'object' ? result : null;
|
|
67
|
-
}, error => { debug('hanamesh-app-host usage: seat rejected %s (%s)', key, error?.code ?? error?.message); return null; });
|
|
68
|
-
inflight.add(settled);
|
|
69
|
-
settled.finally(() => inflight.delete(settled));
|
|
70
|
-
return settled;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Report every closed (or, with includeCurrent, every) unreported hour as one `use` event with its receipt. */
|
|
74
|
-
function flush({ appId, includeCurrent = false } = {}) {
|
|
75
|
-
if (closed) return Promise.resolve();
|
|
76
|
-
const tasks = [];
|
|
77
|
-
for (const hour of ledger.pending({ appId, includeCurrent, now:clock() })) {
|
|
78
|
-
const extra = { targetRef:hour.appId, ...(hour.receipt ? { receipt:hour.receipt } : {}) };
|
|
79
|
-
tasks.push(emit('use', hour.appId, `use:${hour.appId}:${hour.hour}`, hour.occurredAt, extra).then(result => {
|
|
80
|
-
if (result && (result.disposition === 'recorded' || result.disposition === 'duplicate')) ledger.markReported(hour.appId, hour.hour);
|
|
81
|
-
}));
|
|
82
|
-
}
|
|
83
|
-
return Promise.all(tasks).then(() => {});
|
|
84
|
-
}
|
|
85
|
-
const liveInstances = appId => {
|
|
86
|
-
try { return host.instanceList().filter(i => i.appId === appId && LIVE.includes(i.status)).length; } catch { return 0; }
|
|
87
|
-
};
|
|
29
|
+
// One capability diagnostic at registration; no app content, identity, token,
|
|
30
|
+
// timestamp or service response is logged. Logger failures never block apps.
|
|
31
|
+
try { logger?.debug?.('USAGE_FACT_UNAVAILABLE: no verified public presentation/success source; runtime readiness, transport and hourly receipts are diagnostics only.'); } catch {}
|
|
88
32
|
|
|
89
33
|
const unsubscribeEvents = host.subscribe(event => {
|
|
90
|
-
if (event.type
|
|
91
|
-
const instance = host.instance(event.instanceId, event.principalId);
|
|
92
|
-
if (instance) void emit('open', instance.appId, `open:${instance.appId}:${event.instanceId}`, event.at);
|
|
93
|
-
} else if (TERMINAL.includes(event.type)) {
|
|
94
|
-
ledger.forget(event.instanceId);
|
|
95
|
-
const instance = host.instance(event.instanceId, event.principalId);
|
|
96
|
-
// The app's last live instance ended: this hour's count is final for now, report it right away.
|
|
97
|
-
if (instance && liveInstances(instance.appId) === 0) void flush({ appId:instance.appId, includeCurrent:true });
|
|
98
|
-
}
|
|
34
|
+
if (TERMINAL.includes(event.type)) ledger.forget(event.instanceId);
|
|
99
35
|
});
|
|
100
36
|
const unsubscribeActivity = host.onActivity(activity => {
|
|
101
37
|
ledger.activity({ appId:activity.appId, instanceId:activity.instanceId, at:activity.at });
|
|
102
|
-
void flush({ appId:activity.appId });
|
|
103
38
|
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
queueMicrotask(() => { void flush(); });
|
|
108
|
-
|
|
39
|
+
// Persist diagnostics only. In particular, do not send or mark pending rows
|
|
40
|
+
// reported when an hour closes, on startup, stop, flush or disposal.
|
|
41
|
+
const flush = () => closed ? Promise.resolve() : ledger.persist();
|
|
109
42
|
return Object.freeze({
|
|
110
|
-
/** Report closed hours now (tests / shutdown); `includeCurrent` also reports the running hour. */
|
|
111
43
|
flush,
|
|
112
|
-
/**
|
|
113
|
-
settle: async () => { await
|
|
44
|
+
/** Settles this ledger's writes; it is not a Usage processing/delivery receipt. */
|
|
45
|
+
settle: async () => { await ledger.persist(); },
|
|
114
46
|
receipts: ledger,
|
|
115
|
-
close() { void flush(); closed = true;
|
|
47
|
+
close() { if (closed) return; void flush(); closed = true; unsubscribeEvents(); unsubscribeActivity(); },
|
|
116
48
|
});
|
|
117
49
|
}
|