@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
package/dist/store.js ADDED
@@ -0,0 +1,250 @@
1
+ import { constants } from 'node:fs';
2
+ import { mkdir, readFile, open, rename, unlink, lstat, realpath } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { AppHostError, SerialQueue, copy, requireCondition } from './errors.js';
6
+ import { processIdentity } from './runtime.js';
7
+
8
+ export function emptySnapshot() {
9
+ return { schema: 1, revision: 0, sequence: 0, instances: [], leases: [], events: [] };
10
+ }
11
+ export function validateSnapshot(s) {
12
+ requireCondition(s && s.schema === 1 && Number.isSafeInteger(s.revision) && s.revision >= 0 &&
13
+ Number.isSafeInteger(s.sequence) && s.sequence >= 0 && Array.isArray(s.instances) &&
14
+ Array.isArray(s.leases) && Array.isArray(s.events), 'CORRUPT_STATE', 'Unsupported or damaged app-host snapshot.');
15
+ const ids = new Set(), views = new Set();
16
+ for (const i of s.instances) {
17
+ requireCondition(i && typeof i.id === 'string' && !ids.has(i.id) &&
18
+ typeof i.appId === 'string' && typeof i.deploymentId === 'string' && typeof i.dataId === 'string' &&
19
+ typeof i.principalId === 'string' && typeof i.dataDir === 'string' &&
20
+ ['reserved','starting','ready','stopping','stopped','failed','interrupted'].includes(i.status) &&
21
+ ['owned','attach'].includes(i.mode), 'CORRUPT_STATE', 'Invalid instance record.');
22
+ ids.add(i.id);
23
+ }
24
+ for (const l of s.leases) {
25
+ const key = JSON.stringify([l?.principalId, l?.viewId]);
26
+ requireCondition(l && ids.has(l.instanceId) && s.instances.some(i => i.id === l.instanceId && i.principalId === l.principalId) && !views.has(key) &&
27
+ typeof l.viewId === 'string' && typeof l.principalId === 'string' &&
28
+ typeof l.tokenHash === 'string' && /^[a-f0-9]{64}$/.test(l.tokenHash) &&
29
+ Number.isSafeInteger(l.generation) && l.generation > 0 && Number.isFinite(l.expiresAt) &&
30
+ ['active','closed','expired','stopped','failed'].includes(l.status), 'CORRUPT_STATE', 'Invalid or ownerless lease.');
31
+ views.add(key);
32
+ }
33
+ let last = 0;
34
+ for (const e of s.events) {
35
+ requireCondition(Number.isSafeInteger(e.sequence) && e.sequence > last && e.sequence <= s.sequence &&
36
+ typeof e.principalId === 'string', 'CORRUPT_STATE', 'Invalid event journal.');
37
+ last = e.sequence;
38
+ }
39
+ return s;
40
+ }
41
+
42
+ // Only use under an explicitly selected local profile root. Reject symlink components.
43
+ export async function secureDirectory(path) {
44
+ requireCondition(isAbsolute(path), 'INVALID_ROOT', 'An absolute data directory is required.');
45
+ const parts = resolve(path).split('/').filter(Boolean);
46
+ let current = '/';
47
+ for (const part of parts) {
48
+ current = join(current, part);
49
+ await mkdir(current, { mode: 0o700 }).catch(e => { if (e.code !== 'EEXIST') throw e; });
50
+ const stat = await lstat(current);
51
+ requireCondition(stat.isDirectory() && !stat.isSymbolicLink(), 'UNSAFE_DATA_PATH', 'Symlinked/non-directory data path is not allowed.');
52
+ }
53
+ return await realpath(path);
54
+ }
55
+
56
+ // File mutex with dead-owner reclamation. The record carries the owner pid + process start
57
+ // token (a reused pid fails closed) and, for runtime locks, the children the guardian created
58
+ // (pid + start token + executable). A malformed lock is never auto-deleted. A live owner, or a
59
+ // surviving child that is not provably ours, keeps DATA_ROOT_BUSY and names the live pid.
60
+ // Only provably-ours orphans (same pid, same start token, same executable when known) are
61
+ // terminated before the lock is taken; a stale record whose processes are gone is reclaimed.
62
+ const sameExecutable = async (recorded, live) => {
63
+ if (typeof recorded !== 'string' || typeof live !== 'string') return recorded == null || live == null;
64
+ if (recorded === live) return true;
65
+ const [a, b] = await Promise.all([realpath(recorded).catch(() => recorded), realpath(live).catch(() => live)]);
66
+ return a === b;
67
+ };
68
+ const busy = (message, details) => new AppHostError('DATA_ROOT_BUSY', message, details);
69
+ export class FileLock {
70
+ #queue = new SerialQueue();
71
+ constructor(path, { reclaimDead = false, orphanGraceMs = 2_000, identity = processIdentity } = {}) {
72
+ this.path = path; this.reclaimDead = reclaimDead; this.orphanGraceMs = orphanGraceMs; this.identity = identity;
73
+ this.id = randomUUID(); this.held = false;
74
+ }
75
+ async #identity(pid) {
76
+ // A failed probe is "alive, unknown": it can never prove a process dead or ours.
77
+ try { return await this.identity(pid); } catch { return { pid, start: null, command: null }; }
78
+ }
79
+ async #liveOwner(record) {
80
+ if (!Number.isSafeInteger(record.pid) || record.pid <= 1) return { pid: record.pid ?? null, start: null };
81
+ const live = await this.#identity(record.pid);
82
+ if (!live) return null;
83
+ if (typeof record.start === 'string' && typeof live.start === 'string' && live.start !== record.start) return null; // pid reused
84
+ return live;
85
+ }
86
+ async #classifyChildren(record) {
87
+ const ours = [], unknown = [];
88
+ for (const child of Array.isArray(record.children) ? record.children : []) {
89
+ if (!Number.isSafeInteger(child?.pid) || child.pid <= 1) continue;
90
+ const live = await this.#identity(child.pid);
91
+ if (!live) continue; // gone
92
+ if (typeof child.start === 'string' && typeof live.start === 'string') {
93
+ if (live.start !== child.start) continue; // pid reused by an unrelated process
94
+ if (await sameExecutable(child.command, live.command)) { ours.push({ ...child, live }); continue; }
95
+ }
96
+ unknown.push({ pid: child.pid, role: child.role ?? null, command: live.command ?? null });
97
+ }
98
+ return { ours, unknown };
99
+ }
100
+ async #terminateOwned(children, ownerPid) {
101
+ const signal = (child, name) => {
102
+ for (const target of child.group ? [-child.pid, child.pid] : [child.pid]) {
103
+ try { process.kill(target, name); } catch (error) {
104
+ if (error.code !== 'ESRCH') throw busy('An orphaned owned process refused the signal; the data root stays locked.',
105
+ { pid: child.pid, ownerPid, reason: 'signal-refused', cause: error.code ?? null });
106
+ }
107
+ }
108
+ };
109
+ // Unknown start (a failed probe) counts as alive: never take the lock over a process we cannot see.
110
+ const alive = async child => { const live = await this.#identity(child.pid); return live !== null && (live.start === null || live.start === child.start); };
111
+ const settle = async deadline => {
112
+ let pending = children;
113
+ while (pending.length && Date.now() < deadline) {
114
+ const still = [];
115
+ for (const child of pending) if (await alive(child)) still.push(child);
116
+ pending = still;
117
+ if (pending.length) await new Promise(resolve => setTimeout(resolve, 25));
118
+ }
119
+ return pending;
120
+ };
121
+ for (const child of children) signal(child, 'SIGTERM');
122
+ let pending = await settle(Date.now() + this.orphanGraceMs);
123
+ for (const child of pending) signal(child, 'SIGKILL');
124
+ pending = await settle(Date.now() + 2_000);
125
+ if (pending.length) throw busy('An orphaned owned process survived termination; the data root stays locked.',
126
+ { pid: pending[0].pid, pids: pending.map(c => c.pid), ownerPid, reason: 'orphan-survived' });
127
+ }
128
+ async acquire() {
129
+ const self = await this.#identity(process.pid);
130
+ for (let attempt = 0; attempt < 2; attempt++) {
131
+ let fd;
132
+ try {
133
+ fd = await open(this.path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
134
+ await fd.writeFile(JSON.stringify({ pid: process.pid, start: self?.start ?? null, id: this.id, children: [] })); await fd.sync();
135
+ this.held = true; return;
136
+ } catch (e) {
137
+ if (e.code !== 'EEXIST') throw e;
138
+ let previous;
139
+ try { previous = JSON.parse(await readFile(this.path, 'utf8')); } catch {
140
+ throw new AppHostError('LOCK_RECOVERY_REQUIRED', 'Malformed lock; refusing automatic recovery.');
141
+ }
142
+ const owner = await this.#liveOwner(previous);
143
+ if (owner) throw busy('The data root is locked by a live owner; no process was signalled.',
144
+ { pid: owner.pid, start: owner.start ?? null, reason: 'owner-alive', port: previous.port ?? null });
145
+ if (!this.reclaimDead) throw busy('The data root is locked by another owner; no process was signalled.',
146
+ { pid: previous.pid ?? null, reason: 'owner-dead-no-reclaim' });
147
+ const { ours, unknown } = await this.#classifyChildren(previous);
148
+ if (unknown.length) throw busy('The lock owner is gone but a recorded child is still running and cannot be proven ours; no process was signalled.',
149
+ { pid: unknown[0].pid, pids: unknown.map(c => c.pid), ownerPid: previous.pid ?? null, reason: 'orphan-unproven', children: unknown });
150
+ if (ours.length) await this.#terminateOwned(ours, previous.pid ?? null);
151
+ // No live owner may legitimately replace a dead owner's lock without taking it.
152
+ // Serialize stale recovery by a second exclusive marker; never delete that of a contender.
153
+ let recovery;
154
+ try {
155
+ recovery = await open(`${this.path}.recovery`, 'wx', 0o600);
156
+ const current = JSON.parse(await readFile(this.path, 'utf8'));
157
+ if (current.id === previous.id) await unlink(this.path);
158
+ } catch (err) {
159
+ throw new AppHostError('LOCK_RECOVERY_REQUIRED', 'Concurrent or interrupted lock recovery; retry after inspection.');
160
+ } finally {
161
+ if (recovery) { await recovery.close(); await unlink(`${this.path}.recovery`).catch(() => {}); }
162
+ }
163
+ } finally { if (fd) await fd.close(); }
164
+ }
165
+ throw busy('Could not acquire the data root.', { reason: 'retry-exhausted' });
166
+ }
167
+ /** Record the processes this owner created (guardian children, port) so a later reclaimer can judge them. */
168
+ annotate(patch) {
169
+ return this.#queue.run(async () => {
170
+ requireCondition(this.held, 'LOCK_NOT_HELD', 'Cannot annotate a lock that is not held.');
171
+ const current = JSON.parse(await readFile(this.path, 'utf8'));
172
+ requireCondition(current.id === this.id, 'LOCK_OWNERSHIP_LOST', 'Lock changed owner; refusing to rewrite it.');
173
+ const next = { ...current, ...patch, pid: current.pid, start: current.start, id: current.id };
174
+ if (patch.child) { next.children = [...(Array.isArray(current.children) ? current.children : []), patch.child]; delete next.child; }
175
+ const temp = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
176
+ const fd = await open(temp, 'wx', 0o600);
177
+ try { await fd.writeFile(JSON.stringify(next)); await fd.sync(); } finally { await fd.close(); }
178
+ try { await rename(temp, this.path); } catch (error) { await unlink(temp).catch(() => {}); throw error; }
179
+ return next;
180
+ });
181
+ }
182
+ async release() {
183
+ await this.#queue.drained();
184
+ if (!this.held) return;
185
+ const current = JSON.parse(await readFile(this.path, 'utf8'));
186
+ requireCondition(current.id === this.id, 'LOCK_OWNERSHIP_LOST', 'Lock changed owner; refusing to remove it.');
187
+ await unlink(this.path); this.held = false;
188
+ }
189
+ }
190
+
191
+ /** Reference/local backend. This is NOT an implementation of a guessed DSH API. */
192
+ export class AtomicFileStore {
193
+ #queue = new SerialQueue();
194
+ constructor(root, { checkpoint = async () => {} } = {}) {
195
+ this.root = root; this.path = join(root, 'app-host.json'); this.checkpoint = checkpoint;
196
+ }
197
+ async init() {
198
+ await secureDirectory(this.root);
199
+ this.lock = new FileLock(join(this.root, '.app-host.lock'), { reclaimDead: true });
200
+ await this.lock.acquire(); this.opened = true;
201
+ }
202
+ async load() {
203
+ requireCondition(this.opened, 'STORE_NOT_OPEN', 'Storage must be initialized.');
204
+ try {
205
+ const fd = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW);
206
+ try { return copy(validateSnapshot(JSON.parse(await fd.readFile('utf8')))); } finally { await fd.close(); }
207
+ } catch (e) { if (e.code === 'ENOENT') return emptySnapshot(); throw e; }
208
+ }
209
+ save(snapshot) {
210
+ const image = copy(validateSnapshot(snapshot));
211
+ return this.#queue.run(async () => {
212
+ requireCondition(this.opened, 'STORE_NOT_OPEN', 'Storage is closed.');
213
+ const temp = join(this.root, `.app-host.${process.pid}.${randomUUID()}.tmp`);
214
+ let fd, renamed = false;
215
+ try {
216
+ fd = await open(temp, 'wx', 0o600);
217
+ await fd.writeFile(JSON.stringify(image)); await fd.sync();
218
+ await this.checkpoint('snapshot-temp-fsynced', copy(image));
219
+ await fd.close(); fd = undefined;
220
+ await rename(temp, this.path); renamed = true;
221
+ const directory = await open(dirname(this.path), constants.O_RDONLY);
222
+ try { await directory.sync(); } finally { await directory.close(); }
223
+ await this.checkpoint('snapshot-published', copy(image));
224
+ } catch (e) {
225
+ // Post-rename failure is an ambiguous commit, not a guaranteed rollback.
226
+ if (renamed) throw new AppHostError('COMMIT_UNCERTAIN', 'Snapshot rename completed but durability/acknowledgement failed; restart before further writes.');
227
+ throw e;
228
+ } finally {
229
+ if (fd) await fd.close().catch(() => {});
230
+ await unlink(temp).catch(e => { if (e.code !== 'ENOENT') throw e; });
231
+ }
232
+ });
233
+ }
234
+ async close() { await this.#queue.drained(); if (this.opened) { await this.lock.release(); this.opened = false; } }
235
+ }
236
+
237
+ /** Explicit bridge port; a verified profile must supply the actual DSH public-API binding. */
238
+ export class DshDomainSnapshotStore {
239
+ constructor(binding) {
240
+ requireCondition(binding?.layout === 'single' && binding?.domain === 'hanamesh_app_host' &&
241
+ typeof binding.readSnapshot === 'function' && typeof binding.publishSnapshot === 'function' &&
242
+ typeof binding.acquireExclusive === 'function' && typeof binding.releaseExclusive === 'function',
243
+ 'DSH_BINDING_REQUIRED', 'A verified single-image DSH storage-domain binding is required.');
244
+ this.binding = binding;
245
+ }
246
+ async init() { await this.binding.acquireExclusive(); }
247
+ async load() { const s = await this.binding.readSnapshot(); return s == null ? emptySnapshot() : copy(validateSnapshot(s)); }
248
+ async save(s) { await this.binding.publishSnapshot(copy(validateSnapshot(s))); }
249
+ async close() { await this.binding.releaseExclusive(); }
250
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * rc.27 — application usage evidence (user decision 2026-09-21, STATUS `P2-USE-EVENTS`); T6 — usage receipts.
3
+ *
4
+ * Applications' `open` / `use` events are reported by app-host through the usage plugin's record seat
5
+ * (`ctx.hanameshUsage.record`). The three HanaMesh plugins never import each other: the seat arrives as an
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.
24
+ */
25
+ import { requireCondition } from './errors.js';
26
+ import { createReceiptLedger, hourBucket } from './router/receipts.js';
27
+
28
+ export { hourBucket };
29
+ export const SOURCE_PLUGIN = '@hanamesh/dsh-app-host';
30
+ const MAX_KEY = 160;
31
+ /** Duck-type the optional sibling service: anything exposing `record(input)` is used, nothing else is assumed. */
32
+ export const duckSeat = value => value !== null && typeof value === 'object' && typeof value.record === 'function' ? value : null;
33
+ const TERMINAL = ['instance.stopped','instance.failed','instance.interrupted'];
34
+ const LIVE = ['starting','ready','stopping'];
35
+
36
+ /**
37
+ * @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
+ */
41
+ export function createUsageEvidence({ host, seat, receipts, logger, clock = Date.now, sweepIntervalMs = 60_000 }) {
42
+ requireCondition(host && typeof host.subscribe === 'function' && typeof host.onActivity === 'function' && typeof host.packageName === 'function',
43
+ 'INVALID_HOST','createUsageEvidence needs an AppHost with subscribe/onActivity/packageName.');
44
+ requireCondition(typeof seat === 'function','INVALID_SEAT','seat must be a function returning the optional usage service.');
45
+ 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
+ const ledger = receipts ?? createReceiptLedger({ clock });
48
+ const inflight = new Set();
49
+ let closed = false;
50
+
51
+ /** @returns {Promise<{disposition:string, code?:string}|null>} null when nothing was attempted */
52
+ function emit(action, appId, key, at, extra = {}) {
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
+ };
88
+
89
+ const unsubscribeEvents = host.subscribe(event => {
90
+ if (event.type === 'instance.ready') {
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
+ }
99
+ });
100
+ const unsubscribeActivity = host.onActivity(activity => {
101
+ ledger.activity({ appId:activity.appId, instanceId:activity.instanceId, at:activity.at });
102
+ void flush({ appId:activity.appId });
103
+ });
104
+ let timer = null;
105
+ if (sweepIntervalMs > 0) { timer = setInterval(() => { void flush(); }, sweepIntervalMs); timer.unref?.(); }
106
+ // Hours left open by a previous run (persisted ledger) are reported once the seat is reachable.
107
+ queueMicrotask(() => { void flush(); });
108
+
109
+ return Object.freeze({
110
+ /** Report closed hours now (tests / shutdown); `includeCurrent` also reports the running hour. */
111
+ flush,
112
+ /** Resolves once every record() call issued so far has settled (tests / orderly shutdown). */
113
+ settle: async () => { await Promise.allSettled([...inflight]); },
114
+ receipts: ledger,
115
+ close() { void flush(); closed = true; if (timer !== null) clearInterval(timer); unsubscribeEvents(); unsubscribeActivity(); },
116
+ });
117
+ }
@@ -0,0 +1,38 @@
1
+ # HanaMesh 应用 npm 包规范 v1
2
+
3
+ 应用是 DSH bundle,而不是 app-host 的私有配置片段:这样 DSH 自己的 profile/lockfile 是唯一包安装事实,Cordis 可以在同一次启动里加载 bundle、注册静态 `app.json`,应用库也能用标准 npm 版本与完整性语义安装和卸载。app-host 只消费注册后的定义与 lib-provision 账本,不扫描任意脚本或猜测入口。
4
+
5
+ ## 必需结构
6
+
7
+ 包名必须是 `@hanamesh/app-<name>`,`type` 必须是 `module`,稳定版本发布到 `latest`。同一版本的任意字节不得改变;内容变化必须升版本。`app.json` 的 `id` 是持久数据与 runtime 根身份,一经发布永不改变。
8
+
9
+ ```json
10
+ {
11
+ "name": "@hanamesh/app-example",
12
+ "version": "1.0.0",
13
+ "type": "module",
14
+ "files": ["app.json", "dsh.js", "cordis.patch.yml", "README.md", "LICENSE"],
15
+ "peerDependencies": { "@hanamesh/dsh-app-host": "0.1.0-rc.16" },
16
+ "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
17
+ "hanamesh": { "app": "./app.json", "contractVersion": 1 }
18
+ }
19
+ ```
20
+
21
+ 生产包不得有 `dependencies` 或 `optionalDependencies`;应用运行时由 `app.json.deployments[].runtime` 的固定 manifest 和 sha256 管理,不能藏在 npm 生命周期脚本里。包必须精确携带上述五个文件,不纳入构建缓存、源码树或生成日志。
22
+
23
+ `app.json` 必须通过 app-host 导出的 `validateDefinition`;runtime 部署必须省略 `command`。`cordis.patch.yml` 只允许一次 `insert`,其 `id` 与 `name` 都指向本包。`dsh.js` 不超过 30 行,只读取同包 `app.json`、调用 `ctx.hanameshApps.register(definition)`,不得下载、安装、启动进程或接受动态 config。
24
+
25
+ 最小入口:
26
+
27
+ ```js
28
+ import { readFile } from 'node:fs/promises';
29
+ export const name = '@hanamesh/app-example';
30
+ export const inject = ['hanameshApps'];
31
+ export async function apply(ctx, config = {}) {
32
+ if (Object.keys(config).length) throw new Error('Application bundle does not accept config.');
33
+ const definition = JSON.parse(await readFile(new URL('./app.json', import.meta.url), 'utf8'));
34
+ ctx.hanameshApps.register(definition);
35
+ }
36
+ ```
37
+
38
+ 预发布版本只用于隔离验收且不得成为 `latest`;应用库生产路径只接受 registry `latest` 返回的稳定精确版本。发布者必须同时保留 npm 包完整性、runtime 三方来源与 sha256、以及与 app-host 精确 peer 版本对应的可复核构建记录。
@@ -0,0 +1,190 @@
1
+ # 公开契约 v1(候选,0.1.0-rc.41;rc.41 卸载 runtime 时清理生成的 Python 字节码缓存、未知残留仍 fail-closed;rc.40 市场只读查询待启动实例,ready 后一次 resume;前台即时 heartbeat 与失效帧显式清理;rc.39 Market seat 内打开应用时将原有应用帧固定铺满原生窗口并覆盖 Settings,同一回执/租约/关闭动作;rc.38 投射时仅提升 shell.overlay 层级以盖过原生 Settings;rc.35 市场开关按钮即时反馈、owned 应用已退出时提前结束 TERM 等待并确认精确进程组终止;rc.34 内置市场消费 heartbeat/视图清理;rc.33 owned 回收与启动握手分阶段限时;rc.32 客户端 `market` 席位与 dshmarket 互斥、壳内重启改走 postMessage 桥(见「市场席位」);rc.28 HanaMesh 市场:插件与应用同路径装卸、已装含插件、可升级/需重启、`installedPlugins` 与 `plugins/*` 路由、`LIB-PROVISION-INPUT` 400(见「市场」);rc.27 应用使用证据经 usage 插件 record 座位上报(见「应用使用证据」);rc.26 应用库默认目录源 + 纯 DSH 位置推断(见「应用库配置」);rc.15 目录条目校验补 updatedAt;rc.16 应用库默认 category=hanamesh-app、q/cursor 透传;rc.13/rc.14 与 rc.12 契约相同:rc.13 补许可证/repository/精确 peer,rc.14 去掉客户端对 hanameshCore 的读取)
2
+
3
+ 冷恢复中断实例时,将对应旧活跃租约失效并在同一镜像记 `view.expired(reason=host-restart)`;旧视图仍可按原身份恢复并轮换凭据,新视图关闭不受旧租约占用。只有确认进程组退出后才持久发布 `stopped`。
4
+
5
+ ## 身份与所有权
6
+
7
+ `appId` 是已注册应用;`deploymentId + dataId` 是部署与数据身份;`instance.id` 是持久槽位;`runtimeId` 是每次真实运行的新身份;`principalId + viewId + generation` 是视图租约。`originalSessionId` 只保存原应用的 opaque 标识,本宿主**没有**因此实现原应用文档/会话的自动恢复。端口、PID、gateway URL 都是易变诊断信息,不能拿来恢复身份。
8
+
9
+ 单实例范围是 **principal + app + deployment + data**,不是跨用户全局共享进程。一个 AtomicFileStore 根只允许一个活跃 host writer;该进程内并发启动合并,另有每数据根 runtime lock 防止第二宿主同时占用(rc.25 起该锁记录 owner/子进程的 pid + 启动令牌,guardian 被强杀后按 `SECURITY.md`「死主接管」规则安全回收;活着的 owner 或证明不了归属的孤儿仍 `DATA_ROOT_BUSY` 并在 `details.pid` 给出活 pid)。多实例每次新 view 默认新槽位,明确传入 owned `instanceId` 才共享已有槽位。
10
+
11
+ ## 注册描述符(仅受信宿主)
12
+
13
+ ```js
14
+ host.register({
15
+ id: 'notes', name: 'Notes', singleInstanceOnly: true,
16
+ deployments: [{
17
+ id: 'local', dataId: 'notes-v1', mode: 'owned',
18
+ command: '/absolute/path/to/node',
19
+ args: ['/absolute/path/to/app.mjs', '{{port}}', '{{dataDir}}'],
20
+ env: {}, envAllowlist: [], embedding: 'gateway',
21
+ readiness: { path: '/health', status: 200, bodyIncludes: 'NOTES_APP' },
22
+ startTimeoutMs: 10000, stopGraceMs: 1000,
23
+ gateway: { cookieAllowlist: ['notes_session'], allowAppAuthorization: false }
24
+ }]
25
+ });
26
+ ```
27
+
28
+ 绝对 command、argv 数组、显式 envAllowlist;无 shell 拼接。模板只接受 `dataDir/port/instanceId/runtimeId/appId/deploymentId/dataId`,必须绑定 dataDir 与 port。部署不得 daemonize/setsid 脱离所拥有的进程组。dataDir、HOME、TMPDIR 与 XDG 由宿主计算;与既有持久记录不匹配时要求显式迁移,不猜测改绑。
29
+
30
+ attach 描述符只有 `url: http://127.0.0.1:<固定端口>` 与身份探针,不允许 command/args/env。attach 就绪是端点+标记匹配,不冒称对外部进程的所有权。
31
+
32
+ ### 便携 runtime(rc.12)
33
+
34
+ 应用包可以用 `runtime: { manifest, item, exec }` 代替绝对 `command`。`item` 必须精确命中 `manifest.items[].id`;`installTo` 与 `exec` 都只能是无空段、`.`、`..`、反斜杠、NUL 或绝对盘符的相对 POSIX 路径。存在 `runtime` 时禁止再提供 `command`。
35
+
36
+ 宿主只从 `<dataRoot>/runtimes/<appId>/.provision/ledger.json` 认领安装结果,且账本中的 item 版本必须与描述符 manifest 完全一致。最终命令固定为 `<dataRoot>/runtimes/<appId>/<installTo>/<exec>`;文件不存在或没有可执行位统一失败为 `RUNTIME_MISSING`,绝不回退到 PATH、系统 Python、`process.execPath` 或应用提供的任意路径。`args`、`env`、凭据投射、readiness 与 K5 guardian 生命周期规则不变。
37
+
38
+ ### 凭据声明与注入(rc.4,[凭据决策](../../../../Docs/Projects/hanamesh/decisions/2026-09-14-credentials-oauth-and-apikey.md) §3.3)
39
+
40
+ owned 部署可带 `credentialEnv: CredentialEnvEntry[]`:app 声明**需要什么**,从不声明值从哪来。
41
+
42
+ ```js
43
+ credentialEnv: [
44
+ { env: 'OPENAI_API_KEY', kind: 'api-key', providers: ['openai'], required: false, purpose: 'LLM' },
45
+ { env: 'LANGCHAIN_PROVIDER' }, // 派生值,由 broker 按所选引用填
46
+ { path: '.codex/auth.json', kind: 'grant', projection: 'file', providers: ['openai-chatgpt'] },
47
+ ]
48
+ ```
49
+
50
+ - `projection:'env'`(默认):`env` 是 POSIX 标识符,不得是宿主控制变量(`DSH_*`、`NODE_OPTIONS`、`LD_*`、`DYLD_*`、`PATH`、`HOME`、`TMPDIR`、`XDG_*`),不得与静态 `env` 重名。
51
+ - `projection:'file'`:`path` 相对 **`base`**——`'home'`(默认,`<dataDir>/home`)或 `'dataDir'`(给 `$XXX_HOME={{dataDir}}` 这类 app,例如 Vibe 的 `auth/openai-codex.json`);不含 `.`/`..` 段、不以 `/` 开头;写入 0600,父目录 0700。`format` 是给 broker 看的 opaque id(如 `codex-cli-auth-json`、`oauth-cli-kit`),宿主不解释。(rc.5 增:`base`/`format`)
52
+ - 值来自 **credential broker**:`new AppHost({ credentialResolver })` 或运行中 `host.setCredentialResolver(fn)`(返回 disposer;`plugin-auth-apikey` 在 DSH 里 `ctx.hanameshApps.setCredentialResolver(...)`)。每次 owned 启动前调用 `resolver({ appId, deploymentId, instanceId, principalId, credentialEnv })`,期望 `{ env?, files?, secrets? }`。
53
+ - **只接受声明过的名字/路径**:其余丢弃并记事件 `credential.env-rejected {names}`;注入成功记 `credential.injected {names}`(只有名字,永无值);resolver 抛错记 `credential.resolver-failed {code}` 且**不阻止启动**(FR-02);无 resolver 时行为与 rc.3 相同。
54
+ - **`sets`(rc.8)**:槽位可声明 `sets: { ENV: 'value' }`。每个键必须是同一 deployment 已声明的 env 槽位;模板由 Router 展开。宿主只校验形状,不解释、不注入。
55
+ - **文件生命周期(rc.6)**:resolver 的每个 `files[]` 项带 `policy`:`if-absent`(默认)——目标已存在就不动(app 自己旋转过的 token 保留),记事件 `credential.file-kept`;`overwrite`——新 grant 版本,替换;`remove`——撤销 / 切 app-owned 时删除,记 `credential.file-removed`。宿主**从不读回**投射文件;「投射已完成」由 `credential.injected` 事件(含 `file:<path>`)告知 broker(`host.subscribe`)。
56
+ - 注入值与 `files` 内容进入日志脱敏集合;静态 `env` 与宿主变量仍优先于注入值(不能用凭据覆盖 `HOME` 等)。
57
+ - `list()` 的 `apps[].deployments[].credentialEnv` 原样带出,供客户端渲染「缺凭据」;是否已授权由 broker 的 `plan` 回答,本宿主不存任何凭据。
58
+
59
+ ### 定义变更(rc.7)
60
+
61
+ app 升级会改描述符。宿主只在数据绑定(dataDir/mode,`BINDING_PATH_INVALID`)上严格;**未运行的实例在下次 open 时直接采用新定义**,记事件 `instance.definition-adopted {previous,current}`(指纹前 12 位);运行中的实例只能在停止后采用(`DEFINITION_CHANGED` 409)。
62
+
63
+ ## Host service
64
+
65
+ `register / beginOpen / open / start / resume / recoverView / heartbeat / close / stop / stopAll / instance / instanceList / list / logTail / eventsSince / subscribe / dispose`。`.d.ts` 是可编译的精确字段定义。宿主方法省略 principal 时是受信 `host` 主体,浏览器路由必须使用认证系统给出的主体,不能从请求体读 principal。
66
+
67
+ `beginOpen` 在 durable reservation 和 bounded spawn 后返回回执(owned 所有权回收先按既有 5s bootstrap + stopGraceMs + 2s kill确认限时;仅 acquire 成功后启动后续独立 5s launcher/app握手,错误/guardian退出在两阶段立即失败),此时可 `close` 取消尚未就绪的启动;`open/start` 等待就绪,供受信调用者使用。回执包含持久 instance、view lease、`leaseToken`、`uiUrl`(未就绪时 null)。原始 token 不写入 sidecar,仅存哈希;只读列表与事件流不返回 token。
68
+
69
+ Open 请求为 `{appId,deploymentId,viewId,leaseToken?,instanceId?,originalSessionId?}`。已知 view 必须带其当前 token;重复 Open/Renew 保持 generation 和引用数,不重复 spawn。inactive/expired lease 显式 resume 时旋转 token/generation。Open 回执丢失不应生成另一个 view 来碰运气。
70
+
71
+ ### 精确恢复
72
+
73
+ 1. 工作台保存稳定 viewId 和 instanceId,凭据保存在其受保护的缓存;缓存不是实例权威。
74
+ 2. 正常恢复:`resume({viewId,leaseToken})` 从持久绑定中找 app/deployment/data/instance,不“找第一个 ready”。
75
+ 3. token 丢失:已认证 owner 调用 `recoverView({viewId,instanceId,confirm:true})`,必须先向用户确认撤销旧凭据;再用返回 token resume。恢复凭据本身不增加/续租、不启动应用;跨主体或不匹配 instance 拒绝。
76
+ 4. 单次 Open 响应丢失可能留下一个可过期的预留;恢复操作解决“记录存在但浏览器没有 token”的场景,不能把传输超时当作未写入。
77
+
78
+ ### Stop 与通知
79
+
80
+ `stop(id)` 有活跃视图时返回 `INSTANCE_IN_USE`,details.views 包含 viewId/generation/expiresAt。`stop(id,{confirm:true})` 先记录 stopping、清理自身网关/进程,再一次镜像发布 stopped、撤销租约和各 view 的 `view.stopped` 通知。`instance.stop-requested` 不是停止完成。
81
+
82
+ close 幂等,只关闭给定代的 owned view;最后一个活跃租约释放后停实例。并发新 Open 与最后 close 竞争时,重新检查占用,不误停新视图。Stop/cancel 期间的新 Open 返回可解释错误,不偷偷创建替代实例。attach Stop 只撤销附着和视图,不对外部服务发信号。
83
+
84
+ `eventsSince(after)` 返回 durable sequence、events、resetRequired;通知日志最多1024条,过旧游标应重新 list 后按稳定身份恢复。`subscribe` 仅面向受信宿主进程,浏览器不能订阅所有主体。已停止事件与视图状态原子发布。
85
+
86
+ ## HTTP 控制面(必须安装认证/授权回调)
87
+
88
+ | 路径 | 方法 | 请求 |
89
+ |---|---|---|
90
+ | `/hanamesh/apps` | GET | 无查询参数;列表绝不产生引用 |
91
+ | `/apps/open` | POST | Open 请求;返回非阻塞回执 |
92
+ | `/apps/resume` | POST | `{viewId,leaseToken}`;精确恢复,可能仍 starting |
93
+ | `/apps/recover` | POST | `{viewId,instanceId,confirm:true}`;认证 owner 的显式凭据找回 |
94
+ | `/apps/heartbeat` | POST | `{viewId,leaseToken}` |
95
+ | `/apps/close` | POST | `{viewId,leaseToken}` |
96
+ | `/apps/stop` | POST | `{instanceId,confirm?:boolean}` |
97
+ | `/apps/events?after=0` | GET | 通知游标 |
98
+
99
+ 请求必须命中具名 host,写入必须有准确 Origin、JSON 和 `X-HanaMesh-Client: workspace-v1`,禁止 iframe 控制请求。认证回调先获得主体,授权回调逐请求判断,缺失任意回调拒绝启动。响应隐藏 PID、dataDir、命令和环境,带 traceId。400/401/403/404/405/409/413/415/503/504 分别按实际错误返回;失败并不代表一定没有持久预留,应使用 view 身份检查。
100
+
101
+ 市场客户端待启动时用 GET `/hanamesh/apps` 只读查询精确实例状态,按原回执 `expiresAt` 对 pending lease 续租;看到 ready 才单次 POST `/apps/resume` 取得 URL。已展示视图在页面回到前台时立即尝试续租,终态 `LEASE_EXPIRED` 或 `INSTANCE_NOT_READY` 清除失效 iframe,由用户从市场显式重新打开。关闭/隐藏继续释放租约并停止最后一个自有视图对应进程;不延长 TTL,不静默恢复。
102
+
103
+ 文档原表将 open/close 列在“只读路由”下,但它们会修改租约;本候选使用 POST 保持“GET 无状态变更”边界。原型客户端的 method/descriptor 兼容性待比对,不能无版本地替换原接口。
104
+
105
+ ## 工作台 SDK
106
+
107
+ ```js
108
+ import { WorkspaceAppClient } from '@hanamesh/dsh-app-host/client';
109
+ const apps = new WorkspaceAppClient({ origin: location.origin });
110
+ const receipt = await apps.open({appId:'notes',deploymentId:'local',viewId:'stable-view-123'});
111
+ // 回执必须尽早保留,发生超时仍能显式 close 或恢复。
112
+ const ready = await apps.waitUntilReady(receipt);
113
+ iframe.src = ready.uiUrl;
114
+ // 按已配置 TTL 的约 1/3 周期 heartbeat({viewId,leaseToken})。
115
+ // 内置市场视图已消费此契约:按回执 expiresAt 串行续租;关闭/隐藏/卸载取消续租并显式 close。
116
+ // 关闭或停止必须用该 lease;不依赖 unload 做最终回收。
117
+ ```
118
+
119
+ SDK 只用于工作台顶层页面,不给应用 iframe。采用 same-origin credentials;认证由真实宿主处理,没有内置默认口令。polling 有界,原 view 的失败状态不会触发匿名重启。取消 waitUntilReady 只取消等待,不伪称撤销已经成功持久化的 Open;显式 close 才释放租约。
120
+
121
+ ## 存储及迁移
122
+
123
+ 完整快照 schema1:instances + leases + durable events + revision/sequence 一次发布。AtomicFileStore 是独立本地参考后端,不能冒称 dsh-storage-domain。目录锁、600 文件、symlink 拒绝、临时写 fsync → rename → 目录 fsync;后 rename 故障标记 COMMIT_UNCERTAIN,禁止继续猜测写入结果。重启清除旧 ready 端点,只保留持久身份。
124
+
125
+ DSH 绑定必须提供同一个 `single` domain 的一次完整镜像 publish 与 writer 排他权。两次 API 写入不等于事务。没有 Session.append 自定义事件。
126
+
127
+ 候选容量:256个持久实例、2048个视图历史、1024条通知、每实例128条内存日志。达到容量拒绝新建而不偷偷删除未审计历史;尚无自动归档/迁移工具。默认租约90秒、扫描15秒;`sweepIntervalMs:0` 仅用于手动驱动/测试,生产必须启用扫描或可靠外部调用。
128
+
129
+ ## 应用库配置(rc.26)
130
+
131
+ `library.sources` **未配置**(undefined)→ 默认 `[{manifestUrl:"https://market.hanamesh.com/catalog-source.json",enabled:true}]`;显式 `[]` → 无来源(`CATALOG_SOURCE_REQUIRED`,由用户在「应用库来源」里添加)。默认只在存储为空的首启播种;已保存的来源永不被默认覆盖。
132
+
133
+ `library.profileDir` / `profileName` / `dshBin` 与 `nodeBinary`:显式配置永远优先。缺省时按下列规则推断,推不出即留空(安装不可用 `LIBRARY_INSTALL_UNAVAILABLE`,浏览与已安装扫描仍照常):
134
+
135
+ | 键 | 推断来源 | 拒绝条件 |
136
+ |---|---|---|
137
+ | `profileDir` | 本包真实安装根(`import.meta.url` 向上到 `@hanamesh/dsh-app-host` 包根,`realpath`)之上第一个 `node_modules/@hanamesh/dsh-app-host` 解析到该根、且 `package.json` 的 `dependencies` 或 `dsh.profile.bundles` 提到 hanamesh 的祖先目录 | pnpm `.pnpm` 存储目录(无 profile 清单)、非 hanamesh profile、源码 checkout |
138
+ | `profileName` | `basename(profileDir)` | 无 `profileDir` |
139
+ | `dshBin` | `process.argv[1]`,仅当以 `/@deepseek-ai/dsh/lib/bin.js` 结尾且为绝对路径;否则再尝试从 profile 解析 `@deepseek-ai/dsh/lib/bin.js` | 任何其它脚本 |
140
+ | `nodeBinary`(库操作用) | `process.execPath`,仅当 `process.versions.electron === undefined` | Electron(K5 fail-closed `NODE_RUNTIME_REQUIRED`) |
141
+
142
+ 推断结果以一行结构化日志 `hanamesh-app-host library: inferred {…}` 打出(只含路径与来源 URL,不含凭据)。不从 cwd、`PATH` 或个人 `~/.dsh` 猜。
143
+
144
+ ## 市场(rc.28,设计定案 2026-09-22 §4)
145
+
146
+ `/hanamesh/library*` 路由全部保留,语义只增不改:
147
+
148
+ | 路由 | 方法 | 输入 | 输出 |
149
+ |---|---|---|---|
150
+ | `/hanamesh/library` | GET | `q`、`category`(缺省 `hanamesh-app`;`category=` 空 = 全部)、`cursor` | `{source, items[], categories[], installed[], plugins[], restartRequired, page}`;`items[]` 每条目在目录字段之外加 `kind`(`application` = `hanamesh-app` + npm 包;`plugin` = 其它 npm 包;`listing` = 无 npm 包,仅收录)、`application`(兼容)、`installed`(应用行或插件行,或 `null`)、`upgradeAvailable`(目录 `latestVersion` 严格新于已装 `version`;已装行处于 `uninstalled-not-unloaded` 时恒 false);`categories` = 本页条目出现过的类别(目录契约没有类别清单端点) |
151
+ | `/hanamesh/library/installedPlugins` | GET(POST → 405) | 无参数 | `{plugins[], apps[], restartRequired}`;`plugins[]`:`{packageName, version, kind:'plugin', bundle, active, state, errorCode?}`,`state` ∈ `installed` / `installed-not-loaded`(启动后新增)/ `uninstalled-not-unloaded`(启动后移除,进程仍加载)/ `invalid`(依赖里有、`node_modules` 里读不到);`apps[]` = 原已安装应用扫描行(`registered` / `installed-not-loaded` / `runtime-missing` / `invalid`) |
152
+ | `/hanamesh/library/install` | POST | `{itemId, packageName?}` | 202 `{operationId,status:'started'}`;`packageName` 给出时用它精确查目录(`q=<包名>&category=`)再按 `itemId` 匹配;应用与插件同一路径 `dsh plugin add --save-exact <包>@<registry latest>`,应用另外供给 runtime;也是升级路径 |
153
+ | `/hanamesh/library/plugins/install` | POST | `{packageName}` | 202;包必须在启用目录里能搜到(否则 404 `CATALOG_ITEM_MISSING`);目录条目是应用则走应用路径 |
154
+ | `/hanamesh/library/plugins/uninstall` | POST | `{packageName}` | 202;`dsh plugin remove <包>`;若该包是已装应用则转应用卸载(runtime 清理 + 运行中 `INSTANCE_IN_USE` 409) |
155
+ | `/hanamesh/library/provision` | POST | `{appId, packageName, runtimeItem}` 三者必填 | 202;缺字段/非法 → 400 `INVALID_INPUT`(`LIB-PROVISION-INPUT`) |
156
+ | `/hanamesh/library/uninstall` | POST | `{appId, packageName, runtimeItem?}` | 202;缺字段/非法 → 400 `INVALID_INPUT` |
157
+
158
+ 事件流 `library.install-*` 增加 `kind`;`install-done.result` 对插件为 `{status:'restart-required', kind:'plugin', packageName, version}`。受保护包(`hanamesh-core`、`hanamesh-usage`、`@hanamesh/dsh-app-host` 及旧写法 `@hanamesh/dsh-core`、`@hanamesh/dsh-usage`)任何路径都 `PACKAGE_DENIED`:`plugins/*` 在启动操作前同步答 403,目录条目路径在操作内以 `-failed` 事件给出。「需重启」判定只来自本进程启动时的 profile 依赖快照(钉版本内核不暴露已加载插件集合);快照不可读时一切已装计为已加载。客户端在壳内(`window.self !== window.top`)给 `hanamesh://restart` 深链,官方 DSH 直开时只提示文字;本包不重启 DSH。
159
+
160
+ ## 市场席位(rc.32,设计定案 2026-09-22 §4,T5)
161
+
162
+ 客户端模块(`./client-ui`,浏览器包)在 DSH 客户端的 cordis 上下文上提供服务 **`market`**:
163
+
164
+ | 成员 | 形状 | 谁调用 |
165
+ |---|---|---|
166
+ | `render(options?)` | `options.preferredSubsectionId?: string` → React 元素 | 壳面板 `dsh-tauri-panel-extension`「扩展管理」的「市场」标签体;今天只传 `'installed'` |
167
+ | `setSettingsVisible(visible)` | `boolean` → `void` | 面板接管市场时 `false`、dispose 时 `true` |
168
+
169
+ 席位名与形状取自钉死的 `dsh-tauri-panel-extension@1.0.0`(随 `hanamesh-desktop-tauri` 发布,`src-tauri/resources/node_modules/`):它 `ctx.reflect.get('market')` 取值,`typeof value.render !== 'function'` 时当作没有;不是猜的名字。`render()` 返回的就是「HanaMesh 市场」页本身(内嵌形态:常显、无「关闭」按钮、`preferredSubsectionId==='installed'` 时「已安装」排在目录之前);`setSettingsVisible(false)` 收起侧栏「市场」入口与覆盖页,`true` 恢复。无壳(官方 DSH 浏览器直开)时没有面板读这个席位,`provide` 不生效属正常,市场仍走侧栏入口。
170
+
171
+ Market seat 的「打开」保留原 receipt/heartbeat/close owner;应用 iframe 与「关闭视图」固定铺满原生窗口,覆盖 Settings 窄栏。关闭或 seat 卸载时视图清空,原 owner 只释放一次精确租约。无 seat 的侧栏市场仍走原来的 shell.overlay 视图。
172
+
173
+ **与 dshmarket 互斥(同名席位只能其一)**:cordis 的 `provide` 对同一名字的第二次注册直接抛 `service "market" has been registered at <…>`,抢座会把对方打挂。因此占座前先做两项判定——`ctx.reflect.get('market', false)`(非严格:尚未 active 的提供者也算占座)与一次 `GET /hanamesh/library/installedPlugins`(`plugins[]` 里有 `dshmarket` 即视为在场,无论是否已加载)。任一命中就**不 provide**,改为:宿主日志一条 warn,市场页顶部一条 `role="status"` 提示(`data-hanamesh-market-conflict="dshmarket"`),写明两者只能其一;其它功能(侧栏入口、覆盖页、安装/卸载/重启提示)全部照常,不报错。库路由答不上来(网络/宿主异常)时 fail open,照常占座——席位不承载任何持久事实。X01 边界 `market-seat-exclusive`。
174
+
175
+ **壳内重启(rc.32 改)**:`hanamesh://restart` 在壳 iframe 内改走壳已有的入站 postMessage 桥 —— `window.parent.postMessage({type:'hanamesh://restart'}, '*')`,与壳 → iframe 的 `hanamesh://bound-refresh` 同一命名空间;壳按 `event.source`/`event.origin` 校验来源后调用自己的重启。原因:WKWebView 不会把 iframe 内发起的自定义 scheme 导航交给系统,`location.assign('hanamesh://restart')` 在壳内不可达。`hanamesh://restart` 这个 URL 仍是系统级入口(外部浏览器 / `open hanamesh://restart`),由壳 `deep_link.rs` 的 `restart` 分支处理。官方 DSH 直开浏览器时仍只提示「请手动重启 DSH」。
176
+
177
+ ## 应用使用证据(rc.27,用户 2026-09-21 定,STATUS `P2-USE-EVENTS`)
178
+
179
+ 应用的 `open` / `use` 使用证据由 **app-host** 经 usage 插件的 record 座位上报;app-host 自己不存事件、不签名、不上传。三插件互不 import:`ctx.inject(['hanameshUsage'], …)` 以**可选**服务取得 `ctx.hanameshUsage`,只鸭子类型地要求 `record(input): Promise<{disposition:'recorded'|'duplicate'|'withheld'|'rejected', eventId?, code?}>`。座位缺席、形状不符、抛错或返回 `rejected`,一律**只打 debug 日志**,不抛、不阻止应用启动或网关转发;`withheld`(用户未同意)与 `duplicate`(重复)是正常结果,同样只 debug。
180
+
181
+ | 事件 | 触发点 | `idempotencyKey` | `occurredAt` |
182
+ |---|---|---|---|
183
+ | `open` | 实例到达 `ready`(视图真被服务)——每次成功启动一次;同一实例的第二个 view 不重复 | `open:<appId>:<instanceId>` | 就绪时刻 |
184
+ | `use` | 该实例的网关**真的转发**了一个已授权请求且应用回应不是 401/403(含成功的 WebSocket 升级);每应用每 UTC 小时最多一次 | `use:<appId>:<YYYYMMDDHH>` | 该小时桶内第一个被转发请求的时刻 |
185
+
186
+ 不计入 `use` 的:引导票据 `/__hanamesh_bootstrap/*`、网关拒绝(403 `GATEWAY_*`)、应用自己的 401/403 回应、宿主的就绪探针(探针直连上游,不经网关)、`embedding:'direct'` 部署(没有网关,没有活动信号)。每实例只记一个「上次小时桶」,实例 `stopped`/`failed`/`interrupted` 即丢弃,map 以活实例数为界。
187
+
188
+ `hanaRef` = 应用的 **npm 包名**(与 usage `validHana` 同形:`@scope/name` 或 `name`,≤128 字符),`sourcePlugin` = `@hanamesh/dsh-app-host`。包名来源按序:① 描述符可选字段 `packageName`(rc.27 新增,`validateDefinition` 校验形状;不带则完全向后兼容);② 宿主启动时的已安装扫描(`<profile>/node_modules/<pkg>/app.json` → `id`)经 `host.bindPackageName(appId, packageName)` 绑定——应用包只注册纯 `app.json`,扫描先于应用包注册发生,所以绑定不要求 appId 已注册;③ 都没有 → 该应用不产生证据(debug 日志),启动照常。`list().apps[].packageName` 带出当前解析结果(无则 `null`)。
189
+
190
+ 新的宿主面:`host.bindPackageName(appId,name)`、`host.packageName(appId)`、`host.onActivity(listener)`(内存内活动通知 `{type:'instance.activity',instanceId,appId,deploymentId,principalId,at}`,不落 `eventsSince`);`FixedGateway({ onForward })`;`createUsageEvidence({ host, seat:()=>ctx.hanameshUsage, logger })` 是把二者接到 record 座位的适配器(`dsh.js` 已装好;`close()` 随插件卸载)。