@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.
Files changed (120) hide show
  1. package/LICENSE +4 -7
  2. package/README.md +140 -13
  3. package/consistency.json +41 -6
  4. package/dist/catalog/fixtures/consumer.json +35 -0
  5. package/dist/catalog/fixtures/provider.json +107 -0
  6. package/dist/catalog/suite.d.ts +4 -0
  7. package/dist/catalog/suite.js +50 -0
  8. package/dist/client-ui.d.ts +21 -3
  9. package/dist/client-ui.js +961 -94
  10. package/dist/client.js +2 -2
  11. package/dist/control-origin.js +9 -0
  12. package/dist/descriptor.js +6 -0
  13. package/dist/dsh-compat.js +6 -0
  14. package/dist/dsh.d.ts +21 -4
  15. package/dist/dsh.js +149 -58
  16. package/dist/gateway.js +140 -11
  17. package/dist/git-process.js +64 -0
  18. package/dist/git-worker.js +50 -0
  19. package/dist/index.d.ts +9 -7
  20. package/dist/install-target/fixtures/catalog.json +58 -0
  21. package/dist/install-target/fixtures/consumer-cases.json +148 -0
  22. package/dist/install-target/fixtures/install-failure-cases.json +58 -0
  23. package/dist/install-target/fixtures/provider-cases.json +618 -0
  24. package/dist/install-target/fixtures/reference-consumer.js +37 -0
  25. package/dist/install-target/fixtures/source-profile.js +123 -0
  26. package/dist/install-target/harness.js +43 -0
  27. package/dist/install-target/run.js +28 -0
  28. package/dist/install-target/suite.d.ts +19 -0
  29. package/dist/install-target/suite.js +328 -0
  30. package/dist/installation-status.d.ts +21 -0
  31. package/dist/installation-status.js +2 -0
  32. package/dist/library/catalog.js +163 -19
  33. package/dist/library/client-identity.js +31 -0
  34. package/dist/library/desktop-cli.js +7 -0
  35. package/dist/library/domain.js +3 -1
  36. package/dist/library/handoff.js +246 -0
  37. package/dist/library/install.js +138 -32
  38. package/dist/library/installation-cli.js +48 -0
  39. package/dist/library/installation-command-worker.js +27 -0
  40. package/dist/library/installation-status-http.js +26 -0
  41. package/dist/library/installation-status.js +91 -0
  42. package/dist/library/installation-worker.js +44 -0
  43. package/dist/library/locate.js +70 -9
  44. package/dist/library/node-binding.js +11 -0
  45. package/dist/library/recovery.js +66 -0
  46. package/dist/library/routes.js +90 -14
  47. package/dist/library/service.js +123 -27
  48. package/dist/library/suite.js +130 -0
  49. package/dist/manager.js +24 -8
  50. package/dist/node-runtime/domain.js +6 -0
  51. package/dist/node-runtime/releases.json +59 -0
  52. package/dist/node-runtime/service.js +101 -0
  53. package/dist/owned-probe-diagnostic.js +53 -0
  54. package/dist/router/receipts.js +8 -11
  55. package/dist/router/routes.js +7 -7
  56. package/dist/routes.js +9 -8
  57. package/dist/runtime.js +11 -1
  58. package/dist/usage-evidence.js +18 -86
  59. package/dist/workspace-origin.js +17 -0
  60. package/docs/APP_PACKAGE.md +4 -0
  61. package/docs/CONTRACT.md +75 -11
  62. package/docs/DSH_COMPATIBILITY.md +94 -0
  63. package/docs/DSH_INTEGRATION.md +41 -0
  64. package/docs/INSTALLATION_STATUS.md +96 -0
  65. package/docs/INSTALL_TARGET.md +130 -0
  66. package/docs/LIBRARY.md +44 -3
  67. package/docs/LICENSES.json +189 -61
  68. package/docs/PROVENANCE.json +53 -12
  69. package/docs/SECURITY.md +31 -0
  70. package/node_modules/@hanamesh/lib-provision/LICENSE +15 -0
  71. package/node_modules/@hanamesh/lib-provision/README.md +134 -0
  72. package/node_modules/@hanamesh/lib-provision/THIRD_PARTY_NOTICES.md +17 -0
  73. package/node_modules/@hanamesh/lib-provision/bin/hanamesh-provision.js +13 -0
  74. package/node_modules/@hanamesh/lib-provision/consistency.json +20 -0
  75. package/node_modules/@hanamesh/lib-provision/contract/CHANGELOG.md +5 -0
  76. package/node_modules/@hanamesh/lib-provision/contract/conformance.mjs +80 -0
  77. package/node_modules/@hanamesh/lib-provision/contract/fixtures/consumer.json +9 -0
  78. package/node_modules/@hanamesh/lib-provision/contract/fixtures/provider.json +11 -0
  79. package/node_modules/@hanamesh/lib-provision/contract/lib-provision.schema.json +47 -0
  80. package/node_modules/@hanamesh/lib-provision/package.json +50 -0
  81. package/package.json +81 -33
  82. package/schemas/catalog-provider-page.schema.json +884 -7
  83. package/schemas/catalog-source.schema.json +9 -7
  84. package/schemas/install-target.schema.json +298 -0
  85. package/dist/provision/LICENSE +0 -6
  86. package/dist/provision/THIRD_PARTY_NOTICES.md +0 -13
  87. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.d.ts +0 -0
  88. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.js +0 -0
  89. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.d.ts +0 -0
  90. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.js +0 -0
  91. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.d.ts +0 -0
  92. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.js +0 -0
  93. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.d.ts +0 -0
  94. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.js +0 -0
  95. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.d.ts +0 -0
  96. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.js +0 -0
  97. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.d.ts +0 -0
  98. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.js +0 -0
  99. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.d.ts +0 -0
  100. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.js +0 -0
  101. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.d.ts +0 -0
  102. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.js +0 -0
  103. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.d.ts +0 -0
  104. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.js +0 -0
  105. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.d.ts +0 -0
  106. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.js +0 -0
  107. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.d.ts +0 -0
  108. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.js +0 -0
  109. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.d.ts +0 -0
  110. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.js +0 -0
  111. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.d.ts +0 -0
  112. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.js +0 -0
  113. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.d.ts +0 -0
  114. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.js +0 -0
  115. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.d.ts +0 -0
  116. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.js +0 -0
  117. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.d.ts +0 -0
  118. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.js +0 -0
  119. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.d.ts +0 -0
  120. /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.js +0 -0
package/dist/gateway.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createServer, request } from 'node:http';
2
2
  import { randomBytes, createHash } from 'node:crypto';
3
3
  import { AppHostError, requireCondition, bounded } from './errors.js';
4
- import { loopbackOrigin } from './descriptor.js';
4
+ import { documentOrigin, DESKTOP_ORIGIN } from './workspace-origin.js';
5
+ import { loopbackOrigin, workspaceOrigin } from './descriptor.js';
5
6
 
6
7
  /**
7
8
  * `frame-ancestors` is matched against EVERY ancestor, not just the direct parent. Inside a
@@ -15,6 +16,7 @@ export function frameAncestorsValue(parentOrigin, frameAncestors = []) {
15
16
  }
16
17
  /** Only exact origins of a scheme a webview can actually host a top-level document on. */
17
18
  export function ancestorOrigin(value) {
19
+ if (value === DESKTOP_ORIGIN) return value;
18
20
  let url;
19
21
  try { url = new URL(value); } catch { requireCondition(false, 'INVALID_FRAME_ANCESTOR', 'Expected an explicit ancestor origin.'); }
20
22
  requireCondition(['http:', 'https:', 'tauri:', 'asset:', 'file:'].includes(url.protocol) && !url.username && !url.password &&
@@ -40,6 +42,8 @@ export function embeddingHeaders(rawHeaders, ancestors) {
40
42
  return output;
41
43
  }
42
44
  const token = () => randomBytes(32).toString('hex');
45
+ // Keep retired names reserved for the process lifetime; 192-bit labels fit DNS's 63-byte limit.
46
+ const desktopNames = new Set();
43
47
  function cookies(header) {
44
48
  const map = new Map();
45
49
  for (const part of (header ?? '').split(';')) {
@@ -55,7 +59,7 @@ function fail(res, status, code) {
55
59
  /** Fixed upstream only. Bootstrap is our own response, not a rewritten app document. */
56
60
  export class FixedGateway {
57
61
  constructor({ upstream, parentOrigin, isLeaseActive, cookieAllowlist = [], allowAppAuthorization = false, maxUploadBytes = 32 * 1024 * 1024, frameAncestors = [], onForward = () => {} }) {
58
- this.upstream = loopbackOrigin(upstream); this.parentOrigin = loopbackOrigin(parentOrigin);
62
+ this.upstream = loopbackOrigin(upstream); this.parentOrigin = workspaceOrigin(parentOrigin);
59
63
  requireCondition(Array.isArray(frameAncestors) && frameAncestors.length <= 8, 'INVALID_FRAME_ANCESTOR', 'frameAncestors must be a short list of origins.');
60
64
  this.ancestors = frameAncestorsValue(this.parentOrigin, frameAncestors.map(ancestorOrigin));
61
65
  requireCondition(typeof isLeaseActive === 'function','INVALID_GATEWAY','A live lease authorization check is required.');
@@ -63,6 +67,7 @@ export class FixedGateway {
63
67
  this.allowAppAuthorization = allowAppAuthorization; this.maxUploadBytes = maxUploadBytes;
64
68
  requireCondition(typeof onForward === 'function','INVALID_GATEWAY','onForward must be a function.');
65
69
  this.onForward = onForward;
70
+ this.children = new Map(); this.pendingChildren = new Map(); this.scopeKey = null; this.closed = false; this.authorizations = new Map();
66
71
  this.tickets = new Map(); this.grants = new Map(); this.sockets = new Set(); this.upstreams = new Set();
67
72
  this.cookiePrefix = `hm_app_${token().slice(0,12)}_`;
68
73
  }
@@ -72,7 +77,14 @@ export class FixedGateway {
72
77
  this.server.on('upgrade', (req,socket,head) => this.#upgrade(req,socket,head));
73
78
  this.server.on('connect', (_req,socket) => socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\n\r\n'));
74
79
  await new Promise((resolve,reject) => { this.server.once('error',reject); this.server.listen(0,'127.0.0.1',resolve); });
75
- this.origin = `http://127.0.0.1:${this.server.address().port}`;
80
+ const port = this.server.address().port;
81
+ if (this.parentOrigin === DESKTOP_ORIGIN && this.scopeKey) {
82
+ const name = token().slice(0,48);
83
+ if (desktopNames.has(name)) { await this.close(); throw new AppHostError('GATEWAY_ORIGIN_REUSED','A retired origin name cannot be reassigned.'); }
84
+ desktopNames.add(name);
85
+ this.origin = `http://control-${name}.localhost:${port}`;
86
+ this.dataOrigin = `http://data-${name}.localhost:${port}`;
87
+ } else this.origin = `http://127.0.0.1:${port}`;
76
88
  return this.origin;
77
89
  }
78
90
  issue(viewKey) {
@@ -83,16 +95,115 @@ export class FixedGateway {
83
95
  const ticket = token(); this.tickets.set(ticket,{ viewKey, expiresAt:Date.now()+60_000 });
84
96
  return `${this.origin}/__hanamesh_bootstrap/${ticket}`;
85
97
  }
98
+ async issueAsync(viewKey) {
99
+ if (this.parentOrigin !== DESKTOP_ORIGIN) return this.issue(viewKey);
100
+ requireCondition(!this.closed && this.isLeaseActive(viewKey),'LEASE_EXPIRED','Inactive view.');
101
+ this.revokeInactive();
102
+ let child = this.children.get(viewKey);
103
+ if (!child) {
104
+ let pending = this.pendingChildren.get(viewKey);
105
+ if (!pending) {
106
+ requireCondition(this.children.size + this.pendingChildren.size < 512,'TOO_MANY_TICKETS','Too many views.');
107
+ pending = (async () => {
108
+ const scoped = new FixedGateway({upstream:this.upstream,parentOrigin:this.parentOrigin,frameAncestors:this.ancestors.split(' ').slice(1),
109
+ isLeaseActive:key => key === viewKey && this.isLeaseActive(key),cookieAllowlist:[...this.cookieAllowlist],
110
+ allowAppAuthorization:this.allowAppAuthorization,maxUploadBytes:this.maxUploadBytes,onForward:this.onForward});
111
+ scoped.scopeKey = viewKey;
112
+ await scoped.start();
113
+ if (this.closed || !this.isLeaseActive(viewKey)) { await scoped.close(); throw new AppHostError('LEASE_EXPIRED','Inactive view.'); }
114
+ this.children.set(viewKey,scoped);
115
+ scoped.expiryTimer = setInterval(() => { if (!scoped.isLeaseActive(viewKey)) void scoped.close(); },50);
116
+ scoped.expiryTimer.unref();
117
+ return scoped;
118
+ })();
119
+ this.pendingChildren.set(viewKey,pending);
120
+ }
121
+ try { child = await pending; }
122
+ finally { if (this.pendingChildren.get(viewKey) === pending) this.pendingChildren.delete(viewKey); }
123
+ }
124
+ return child.issue(viewKey);
125
+ }
126
+ authorizeChallenge(viewKey, gatewayOrigin, challenge) {
127
+ const child = this.children.get(viewKey), item = child?.tickets.get(challenge);
128
+ requireCondition(child && !child.closed && child.origin === gatewayOrigin && item && item.viewKey === viewKey &&
129
+ item.loaded && !item.authorized && item.expiresAt > Date.now() && child.isLeaseActive(viewKey),
130
+ 'GATEWAY_CHALLENGE_DENIED','Invalid or inactive challenge.',{},403);
131
+ const authorization = token(); item.authorized = true;
132
+ child.authorizations.set(authorization,{challenge,viewKey,expiresAt:item.expiresAt});
133
+ return {authorization};
134
+ }
135
+ revokeInactive() {
136
+ for (const [key,child] of this.children) if (!this.isLeaseActive(key) || child.closed) {
137
+ this.children.delete(key); void child.close();
138
+ }
139
+ }
140
+ #desktopBootstrap(req,res) {
141
+ const challenge = req.url.slice('/__hanamesh_bootstrap/'.length), item = this.tickets.get(challenge);
142
+ if (!this.scopeKey || req.method !== 'GET' || req.headers['sec-fetch-dest'] !== 'iframe' ||
143
+ !item || item.loaded || item.expiresAt <= Date.now() || !this.isLeaseActive(item.viewKey)) return fail(res,403,'GATEWAY_BOOTSTRAP_DENIED');
144
+ item.loaded = true;
145
+ const nonce = token();
146
+ const body = `<!doctype html><meta charset="utf-8"><title>应用连接中</title><p>应用连接中…</p><script nonce="${nonce}">
147
+ const challenge=${JSON.stringify(challenge)}, gatewayOrigin=${JSON.stringify(this.origin)};
148
+ let proved=false, redeemed=false;
149
+ addEventListener('message',async event=>{
150
+ const exact=event.origin==='dsh-app://app'&&event.source===parent;
151
+ console.info('HM_BOOTSTRAP_PROOF',JSON.stringify({origin:event.origin,parent:event.source===parent,accepted:exact}));
152
+ if(!exact||event.data?.challenge!==challenge)return;
153
+ if(event.data.type==='hm-bootstrap-parent'&&!proved){proved=true;parent.postMessage({type:'hm-bootstrap-proof',challenge},'dsh-app://app');}
154
+ if(event.data.type==='hm-bootstrap-authorize'&&proved&&!redeemed&&typeof event.data.authorization==='string'){
155
+ redeemed=true;
156
+ const response=await fetch('/__hanamesh_exchange',{method:'POST',credentials:'omit',headers:{'content-type':'application/json'},
157
+ body:JSON.stringify({challenge,authorization:event.data.authorization})});
158
+ if(response.ok)location.replace(${JSON.stringify(this.dataOrigin+'/')});else document.querySelector('p').textContent='应用授权失败';
159
+ }
160
+ });
161
+ parent.postMessage({type:'hm-bootstrap-ready',challenge},'dsh-app://app');
162
+ </script>`;
163
+ res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store','referrer-policy':'origin',
164
+ 'content-security-policy':`default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${this.ancestors}`});
165
+ res.end(body);
166
+ }
167
+ async #exchange(req,res) {
168
+ if (!this.scopeKey || req.method !== 'POST' || req.headers.origin !== this.origin || req.headers['sec-fetch-site'] !== 'same-origin' ||
169
+ req.headers['content-type'] !== 'application/json') return fail(res,403,'GATEWAY_EXCHANGE_DENIED');
170
+ let body='';
171
+ for await (const chunk of req) { body+=chunk; if(body.length>1024)return fail(res,413,'BODY_TOO_LARGE'); }
172
+ let input; try { input=JSON.parse(body); } catch { return fail(res,400,'INVALID_JSON'); }
173
+ const grant=this.authorizations.get(input.authorization);
174
+ if(!grant || Object.keys(input).some(k=>!['challenge','authorization'].includes(k)) || grant.challenge!==input.challenge ||
175
+ grant.expiresAt<=Date.now() || !this.isLeaseActive(grant.viewKey))return fail(res,403,'GATEWAY_EXCHANGE_DENIED');
176
+ this.authorizations.delete(input.authorization);this.tickets.delete(grant.challenge);
177
+ this.grants.clear();this.grants.set(token(),{viewKey:grant.viewKey,initialNavigation:true,navigated:false});
178
+ res.writeHead(204,{'cache-control':'no-store'});res.end();
179
+ }
180
+ #desktopAuthorized(req) {
181
+ if (!this.scopeKey || this.closed || req.headers.host !== new URL(this.dataOrigin).host || !this.isLeaseActive(this.scopeKey)) return false;
182
+ const grant = [...this.grants.values()].find(g => g.viewKey === this.scopeKey);
183
+ if (!grant) return false;
184
+ let referer;try {referer=documentOrigin(req.headers.referer);}catch{}
185
+ // One natural control-to-data navigation, tied to the redeemed complete live view binding.
186
+ if (grant.initialNavigation && req.method === 'GET' && req.url === '/' && !req.headers.origin &&
187
+ req.headers['sec-fetch-dest'] === 'iframe' && req.headers['sec-fetch-mode'] === 'navigate' &&
188
+ req.headers['sec-fetch-site'] === 'cross-site' && referer === this.origin) {
189
+ grant.initialNavigation = false; grant.navigated = true; return true;
190
+ }
191
+ if (!grant.navigated) return false;
192
+ if (req.headers.upgrade?.toLowerCase() === 'websocket') return req.headers.origin === this.dataOrigin;
193
+ return req.headers['sec-fetch-site'] === 'same-origin' &&
194
+ (req.headers.origin ? req.headers.origin === this.dataOrigin : referer === this.dataOrigin);
195
+ }
86
196
  #requestShape(req) {
87
- if (req.headers.host !== new URL(this.origin).host) return false;
197
+ if (![this.origin,this.dataOrigin].filter(Boolean).some(origin => req.headers.host === new URL(origin).host)) return false;
88
198
  if (!req.url?.startsWith('/') || req.url.startsWith('//') || req.url.includes('\\') || req.url.length > 8_192) return false;
89
199
  try { if (/^[\/\\]{2}|[\x00-\x1f]/.test(decodeURIComponent(req.url))) return false; } catch { return false; }
90
- if (req.headers.origin && ![this.parentOrigin,this.origin].includes(req.headers.origin)) return false;
200
+ if (req.headers.origin && ![this.parentOrigin,this.origin,this.dataOrigin].includes(req.headers.origin)) return false;
91
201
  if (req.headers['sec-fetch-dest'] === 'document') return false; // never top-level navigation
92
202
  return true;
93
203
  }
94
204
  #authorized(req) {
95
205
  if (!this.#requestShape(req)) return false;
206
+ if (this.parentOrigin === DESKTOP_ORIGIN) return this.#desktopAuthorized(req);
96
207
  // Cookie capability + live lease. Origin/loopback alone never authenticate.
97
208
  for (const [name,value] of cookies(req.headers.cookie)) {
98
209
  if (!name.startsWith(this.cookiePrefix)) continue;
@@ -122,7 +233,7 @@ export class FixedGateway {
122
233
  if (req.headers.upgrade?.toLowerCase() === 'websocket' && req.headers.origin === this.origin) return true;
123
234
  if (site !== 'same-site' || req.headers['sec-fetch-dest'] !== 'iframe') return false;
124
235
  let referer;
125
- try { referer = new URL(req.headers.referer).origin; } catch { return false; }
236
+ try { referer = documentOrigin(req.headers.referer); } catch { return false; }
126
237
  return referer === this.parentOrigin;
127
238
  }
128
239
  #liveGrantExists() {
@@ -143,10 +254,11 @@ export class FixedGateway {
143
254
  return headers;
144
255
  }
145
256
  #bootstrap(req,res) {
257
+ if (this.parentOrigin === DESKTOP_ORIGIN) return this.#desktopBootstrap(req,res);
146
258
  const ticket = req.url.slice('/__hanamesh_bootstrap/'.length);
147
259
  const item = this.tickets.get(ticket);
148
260
  let referer;
149
- try { referer = new URL(req.headers.referer).origin; } catch {}
261
+ try { referer = documentOrigin(req.headers.referer); } catch {}
150
262
  if (req.method !== 'GET' || req.headers['sec-fetch-dest'] !== 'iframe' || referer !== this.parentOrigin ||
151
263
  !item || item.expiresAt <= Date.now() || !this.isLeaseActive(item.viewKey)) return fail(res,403,'GATEWAY_BOOTSTRAP_DENIED');
152
264
  this.tickets.delete(ticket);
@@ -174,13 +286,25 @@ export class FixedGateway {
174
286
  #forwarded() { try { this.onForward(); } catch {} }
175
287
  #handle(req,res) {
176
288
  if (!this.#requestShape(req)) return fail(res,403,'GATEWAY_REQUEST_DENIED');
177
- if (req.url.startsWith('/__hanamesh_bootstrap/')) return this.#bootstrap(req,res);
289
+ if (this.parentOrigin === DESKTOP_ORIGIN && req.headers.host === new URL(this.origin).host && req.url === '/__hanamesh_exchange') {
290
+ void this.#exchange(req,res).catch(()=>{if(!res.headersSent)fail(res,400,'GATEWAY_EXCHANGE_DENIED');else res.destroy();});return;
291
+ }
292
+ if (this.parentOrigin === DESKTOP_ORIGIN) {
293
+ if (req.headers.host === new URL(this.origin).host) {
294
+ if (req.url.startsWith('/__hanamesh_bootstrap/')) return this.#bootstrap(req,res);
295
+ return fail(res,403,'GATEWAY_CONTROL_ONLY');
296
+ }
297
+ if (req.url.startsWith('/__hanamesh_bootstrap/') || req.url === '/__hanamesh_exchange') return fail(res,403,'GATEWAY_DATA_ONLY');
298
+ } else if (req.url.startsWith('/__hanamesh_bootstrap/')) return this.#bootstrap(req,res);
178
299
  if (!this.#authorized(req)) return fail(res,403,'GATEWAY_AUTH_REQUIRED');
179
300
  if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) return fail(res,405,'METHOD_NOT_ALLOWED');
180
301
  if (Number(req.headers['content-length']) > this.maxUploadBytes) return fail(res,413,'UPLOAD_TOO_LARGE');
181
302
  const upstream = request(this.upstream + req.url,{ method:req.method,headers:this.#headers(req) }, response => {
182
303
  if (response.statusCode !== 401 && response.statusCode !== 403) this.#forwarded();
183
- res.writeHead(response.statusCode, embeddingHeaders(response.rawHeaders,this.ancestors));
304
+ // A nested app frame has both the workspace and its own registered data origin as ancestors.
305
+ // Only the data response admits that exact live view origin; control/bootstrap stays separate.
306
+ const ancestors = this.dataOrigin ? `${this.ancestors} ${this.dataOrigin}` : this.ancestors;
307
+ res.writeHead(response.statusCode, embeddingHeaders(response.rawHeaders,ancestors));
184
308
  response.pipe(res, { end:false });
185
309
  response.once('error', () => res.destroy());
186
310
  response.once('end', () => { if (Object.keys(response.trailers).length) res.addTrailers(response.trailers); res.end(); });
@@ -215,8 +339,13 @@ export class FixedGateway {
215
339
  upstream.once('response',() => { clearTimeout(timer); upstream.destroy(); socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n'); });
216
340
  upstream.once('error',() => { clearTimeout(timer); socket.destroy(); }); upstream.end();
217
341
  }
218
- async close() {
219
- this.tickets.clear(); this.grants.clear();
342
+ close() { return this.closeTask ??= this.#close(); }
343
+ async #close() {
344
+ this.closed = true; clearInterval(this.expiryTimer);
345
+ this.tickets.clear(); this.grants.clear(); this.authorizations.clear();
346
+ await Promise.allSettled([...this.pendingChildren.values()]);
347
+ const children=[...this.children.values()];this.children.clear();
348
+ await Promise.all(children.map(child=>child.close()));
220
349
  for (const request of this.upstreams) request.destroy();
221
350
  for (const socket of this.sockets) socket.destroy();
222
351
  if (this.server?.listening) await bounded(new Promise((resolve,reject) => this.server.close(error => error ? reject(error) : resolve())),2_000,'Gateway shutdown');
@@ -0,0 +1,64 @@
1
+ // Module-owned process boundary. No user Git config or credential store is modified.
2
+ import {fork} from 'node:child_process';
3
+ import {openSync,readSync,closeSync,rmSync} from 'node:fs';
4
+ import {join,isAbsolute} from 'node:path';
5
+ const policy=Object.freeze({GIT_CONFIG_NOSYSTEM:'1',GIT_TERMINAL_PROMPT:'0',GIT_CONFIG_COUNT:'2',
6
+ GIT_CONFIG_KEY_0:'credential.helper',GIT_CONFIG_VALUE_0:'',
7
+ GIT_CONFIG_KEY_1:'credential.helper',GIT_CONFIG_VALUE_1:'!gh auth git-credential'});
8
+ export function safeGitEnv(base=process.env){
9
+ const env={...base};
10
+ for(const key of Object.keys(env))if(/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+|PARAMETERS)$/.test(key)||/^GIT_TRACE/.test(key)||['GIT_ASKPASS','SSH_ASKPASS'].includes(key))delete env[key];
11
+ return {...env,...policy,npm_config_manage_package_manager_versions:'false',pnpm_config_manage_package_manager_versions:'false'};
12
+ }
13
+ export function hasSafeGitEnv(env=process.env){return Object.entries(policy).every(([key,value])=>env[key]===value)&&!env.GIT_CONFIG_PARAMETERS;}
14
+ export function requireGuardedGitEnv(env=process.env){
15
+ if(!hasSafeGitEnv(env)||!env.GIT_TRACE2_EVENT)throw Object.assign(new Error('Run this installation/check through node scripts/safe-git.mjs <command>.'),{code:'GIT_GUARD_REQUIRED'});
16
+ }
17
+ const forbidden=/(?:^|[\s/"'])git-credential-osxkeychain(?:$|[\s"'])|(?:^|[\s"'])credential-osxkeychain(?:$|[\s"'])/u;
18
+ /** Internal worker port: the parent and its IPC worker can independently remove this one fresh trace. */
19
+ export function observeGitTrace(directory){
20
+ const trace=join(directory,'events.jsonl'),fd=openSync(trace,'r'),buffer=Buffer.alloc(64*1024);
21
+ let failure,position=0,pending='',closed=false;
22
+ return {check(final=false){
23
+ if(failure)throw failure;
24
+ let count;while((count=readSync(fd,buffer,0,buffer.length,position))>0){position+=count;pending+=buffer.toString('utf8',0,count);}
25
+ const lines=pending.split('\n');pending=lines.pop();if(final&&pending)lines.push(pending);
26
+ for(const line of lines){
27
+ let event;try{event=JSON.parse(line);}catch{throw Object.assign(new Error('Invalid Git credential trace.'),{code:'GIT_TRACE_INVALID'});}
28
+ if(['child_start','exec'].includes(event.event)&&forbidden.test((event.argv??[]).join(' '))){failure=Object.assign(new Error('Forbidden macOS keychain helper was invoked; operation failed.'),{code:'GIT_KEYCHAIN_FORBIDDEN'});throw failure;}
29
+ }
30
+ },close(){if(closed)return;closed=true;try{closeSync(fd);}finally{rmSync(directory,{recursive:true,force:true});}}};
31
+ }
32
+ export function guardNodeBinary({workerNode,versions=process.versions,execPath=process.execPath}={}){
33
+ if(workerNode!==undefined)return typeof workerNode==='string'&&isAbsolute(workerNode)?workerNode:undefined;
34
+ return versions.electron===undefined&&isAbsolute(execPath)?execPath:undefined;
35
+ }
36
+ /** The detached worker owns the command group. IPC disconnect also cleans it if its caller dies abruptly. */
37
+ export async function spawnGitSafe(command,args,options={}){
38
+ let directory,worker,deadline,abortFallback,message,stdout='',stderr='';
39
+ const {workerNode,signal,timeout,encoding='utf8',...childOptions}=options;
40
+ const nodeRuntime=guardNodeBinary({workerNode});
41
+ if(!nodeRuntime)throw Object.assign(new Error('An independent Node runtime is required; Electron cannot run the Git supervisor.'),{code:'GIT_GUARD_NODE_REQUIRED'});
42
+ const abort=()=>{if(worker?.connected)worker.send({abort:true});abortFallback??=setTimeout(()=>worker?.kill('SIGTERM'),1000);};
43
+ try{
44
+ if(signal?.aborted)throw Object.assign(new Error('Guarded command aborted.'),{name:'AbortError',code:'ABORT_ERR'});
45
+ const stdio=options.stdio==='inherit'?['ignore','inherit','inherit','ipc']:['ignore','pipe','pipe','ipc'];
46
+ worker=fork(new URL('./git-worker.js',import.meta.url),[],{cwd:options.cwd,env:safeGitEnv(options.env??process.env),execArgv:[],execPath:nodeRuntime,detached:process.platform!=='win32',stdio});
47
+ worker.stdout?.on('data',chunk=>{stdout=(stdout+chunk.toString(encoding)).slice(-4*1024*1024);});
48
+ worker.stderr?.on('data',chunk=>{stderr=(stderr+chunk.toString(encoding)).slice(-4*1024*1024);});
49
+ signal?.addEventListener('abort',abort,{once:true});
50
+ if(timeout)deadline=setTimeout(()=>{if(worker.connected)worker.send({timeout:true});},timeout);
51
+ const completed=new Promise((resolve,reject)=>{
52
+ worker.once('error',reject);worker.on('message',value=>{if(value.directory)directory=value.directory;else message=value;});worker.once('close',()=>resolve());
53
+ });
54
+ worker.send({command,args,options:{...childOptions,env:safeGitEnv(options.env??process.env)}});
55
+ await completed;
56
+ if(message?.error)throw Object.assign(new Error('Guarded command failed.'),message.error);
57
+ if(!message?.result)throw Object.assign(new Error('Git monitor exited without a result.'),{code:'GIT_GUARD_FAILED'});
58
+ return {...message.result,stdout,stderr};
59
+ }finally{
60
+ clearTimeout(deadline);clearTimeout(abortFallback);signal?.removeEventListener('abort',abort);
61
+ if(worker?.connected)worker.disconnect();
62
+ if(directory)rmSync(directory,{recursive:true,force:true});
63
+ }
64
+ }
@@ -0,0 +1,50 @@
1
+ // Internal IPC supervisor. The parent never detaches a package manager directly.
2
+ import {spawn,spawnSync} from 'node:child_process';
3
+ import {existsSync,mkdtempSync,writeFileSync,rmSync,realpathSync} from 'node:fs';
4
+ import {tmpdir} from 'node:os';
5
+ import {dirname,join,basename} from 'node:path';
6
+ import {observeGitTrace} from './git-process.js';
7
+ let monitor,directory,timer,done=false,started=false;
8
+ function finish(payload){
9
+ if(done)return;done=true;clearInterval(timer);
10
+ try{monitor?.check(true);}catch(error){payload={error:{code:error.code??'GIT_TRACE_INVALID'}};}
11
+ try{if(monitor)monitor.close();else if(directory)rmSync(directory,{recursive:true,force:true});}catch{payload={error:{code:'GIT_TRACE_CLEANUP_FAILED'}};}
12
+ const end=()=>{
13
+ if(process.platform==='win32'){spawnSync('taskkill',['/pid',String(process.pid),'/T','/F'],{stdio:'ignore'});process.exit(1);}
14
+ else process.kill(-process.pid,'SIGKILL');
15
+ };
16
+ if(process.connected)process.send(payload,end);else end();
17
+ }
18
+ function abort(code='ABORT_ERR'){finish({error:{code,...(code==='ABORT_ERR'?{name:'AbortError'}:{})}});}
19
+ process.once('disconnect',()=>abort());
20
+ process.once('SIGTERM',()=>abort());process.once('SIGINT',()=>abort());
21
+ if(!process.connected)process.exit(1);
22
+ process.on('message',request=>{
23
+ if(request.abort)return abort();if(request.timeout)return abort('ETIMEDOUT');
24
+ if(started)return;started=true;
25
+ try{
26
+ let {command,args,options}=request;
27
+ // No caller-owned trace exists before this IPC worker can clean on disconnect.
28
+ directory=mkdtempSync(join(realpathSync(options.env.TMPDIR??tmpdir()),'hm-git-guard-'));
29
+ writeFileSync(join(directory,'events.jsonl'),'',{mode:0o600});monitor=observeGitTrace(directory);
30
+ options.env={...options.env,GIT_TRACE2_EVENT:join(directory,'events.jsonl'),GIT_TRACE_REDACT:'1'};
31
+ if(!process.connected)return abort();
32
+ process.send({directory},error=>{if(error)abort();});
33
+ // Native Windows .cmd shims cannot be executed by spawn without a shell.
34
+ // Resolve the normal npm/global pnpm JS entry and keep argument arrays intact.
35
+ const manager=basename(command).replace(/\.cmd$/i,'').toLowerCase();
36
+ if(process.platform==='win32'&&['npm','npx','pnpm'].includes(manager)){
37
+ const pkg=manager==='pnpm'?'pnpm':'npm',entry=manager==='pnpm'?'pnpm.cjs':manager+'-cli.js';
38
+ const supplied=options.env.npm_execpath;
39
+ const candidates=[...(supplied&&basename(supplied)===entry?[supplied]:[]),...[dirname(process.execPath),...(options.env.PATH??options.env.Path??'').split(';')].map(base=>join(base,'node_modules',pkg,'bin',entry))];
40
+ const cli=candidates.find(path=>existsSync(path));if(!cli)throw Object.assign(new Error('Package manager JS entry unavailable.'),{code:'PACKAGE_MANAGER_UNAVAILABLE'});
41
+ command=process.execPath;args=[cli,...args];
42
+ }
43
+ const {input,...spawnOptions}=options;
44
+ const child=spawn(command,args,{...spawnOptions,stdio:[input===undefined?'ignore':'pipe','inherit','inherit'],detached:false});
45
+ if(input!==undefined){child.stdin.on('error',()=>{});child.stdin.end(input);}
46
+ child.once('error',error=>finish({error:{code:error.code??'SPAWN_FAILED'}}));
47
+ child.once('close',(code,signal)=>finish({result:{code,status:code,signal}}));
48
+ timer=setInterval(()=>{try{monitor.check();}catch(error){finish({error:{code:error.code??'GIT_TRACE_INVALID'}});}},25);
49
+ }catch(error){finish({error:{code:error.code??'GIT_GUARD_FAILED'}});}
50
+ });
package/dist/index.d.ts CHANGED
@@ -47,8 +47,8 @@ export interface AppListing {
47
47
  }
48
48
  export interface EventPage { events:HostEvent[]; sequence:number; resetRequired:boolean; }
49
49
  export interface HostOptions {
50
- store:SnapshotStore; dataRoot:string; parentOrigin:string; leaseTtlMs?:number; sweepIntervalMs?:number;
51
- nodeBinary?:string; clock?:()=>number; checkpoint?:(point:string,details:Record<string,unknown>)=>Awaitable<void>; credentialResolver?:CredentialResolver|null;
50
+ store:SnapshotStore; dataRoot:string; parentOrigin:string; frameAncestors?:string[]; leaseTtlMs?:number; sweepIntervalMs?:number;
51
+ nodeBinary?:string; nodeBinaryProvider?:()=>string|undefined; clock?:()=>number; checkpoint?:(point:string,details:Record<string,unknown>)=>Awaitable<void>; credentialResolver?:CredentialResolver|null;
52
52
  runtimeLedgerReader?:(root:string)=>Awaitable<{schema:1;items:Record<string,{version:string}>}>;
53
53
  }
54
54
  export class AppHost {
@@ -66,6 +66,7 @@ export class AppHost {
66
66
  open(input:OpenRequest,principalId?:string):Promise<OpenReceipt>; start(input:OpenRequest,principalId?:string):Promise<OpenReceipt>;
67
67
  resume(input:LeaseRequest,principalId?:string,options?:{waitForReady?:boolean}):Promise<OpenReceipt>;
68
68
  recoverView(input:RecoverRequest,principalId?:string):Promise<OpenReceipt>;
69
+ authorizeBootstrap(input:LeaseRequest & {gatewayOrigin:string;challenge:string},principalId?:string):Promise<{authorization:string}>;
69
70
  heartbeat(input:LeaseRequest,principalId?:string):Promise<ViewLease>;
70
71
  close(input:LeaseRequest,principalId?:string):Promise<{instanceId:string;alreadyClosed:boolean}>;
71
72
  stop(instanceId:string,options?:{confirm?:boolean},principalId?:string):Promise<InstanceRecord>; stopAll():Promise<void>;
@@ -88,7 +89,7 @@ export class DshDomainSnapshotStore implements SnapshotStore {
88
89
  }
89
90
  export interface AuthenticatedSubject { principalId:string; [key:string]:unknown; }
90
91
  export interface HttpOptions<Request=unknown> {
91
- parentOrigin:string; authenticate(request:Request):Awaitable<AuthenticatedSubject|null>;
92
+ parentOrigin:string; transportOrigin?:string; authenticate(request:Request):Awaitable<AuthenticatedSubject|null>;
92
93
  authorize(subject:AuthenticatedSubject,path:string,input:unknown):Awaitable<boolean>;
93
94
  }
94
95
  export function createHttpHandler<Request=unknown,Response=unknown>(host:AppHost,options:HttpOptions<Request>):(request:Request,response:Response)=>Promise<void>;
@@ -96,20 +97,20 @@ export class FixedGateway {
96
97
  constructor(options:{upstream:string;parentOrigin:string;isLeaseActive:(viewKey:string)=>boolean;cookieAllowlist?:string[];allowAppAuthorization?:boolean;maxUploadBytes?:number;frameAncestors?:string[];
97
98
  /** rc.27: called once per forwarded request the upstream answered with anything but 401/403 (and per accepted WebSocket upgrade). */
98
99
  onForward?:()=>void});
99
- origin:string; start():Promise<string>; issue(viewKey:string):string; close():Promise<void>;
100
+ origin:string; dataOrigin?:string; start():Promise<string>; issue(viewKey:string):string; issueAsync(viewKey:string):Promise<string>; authorizeChallenge(viewKey:string,gatewayOrigin:string,challenge:string):{authorization:string}; revokeInactive():void; close():Promise<void>;
100
101
  }
101
102
  export function rewriteCsp(value:string,parentOrigin:string):string;
102
103
  export function embeddingHeaders(rawHeaders:string[],parentOrigin:string):string[];
103
104
  export function validateDefinition(input:AppDefinition):AppDefinition;
104
- /** rc.27 usage evidence: `open` once per instance reaching ready, `use` once per app per UTC hour of real gateway activity, through the optional usage seat. */
105
+ /** Canonical collecting plugin identity; runtime/transport observations never form business Usage facts. */
105
106
  export const SOURCE_PLUGIN:'@hanamesh/dsh-app-host';
106
107
  export function hourBucket(ms:number):string;
107
- /** T6 usage receipt riding on a `use` event: provider × model × forwarded-request count of that UTC hour; never content. */
108
+ /** Independent Router diagnostic receipt: routed provider/model and forwarded-request count; not a successful-business fact. */
108
109
  export interface UsageReceipt { providerId:string; model:string|null; count:number; }
109
110
  export interface UsageRecordInput { hanaRef:string; action:'open'|'use'; occurredAt?:string; idempotencyKey:string; sourcePlugin:string; sourceHanaRef?:string; targetRef?:string; receipt?:UsageReceipt; }
110
111
  export interface UsageRecordResult { disposition:'recorded'|'duplicate'|'withheld'|'rejected'; eventId?:string; code?:string; }
111
112
  export interface UsageSeat { record(input:UsageRecordInput):Awaitable<UsageRecordResult>; }
112
- /** T6 receipt ledger (storage domain `hanamesh_router_receipts`): hourly rows per app × Router route, reported through the usage seat when the hour closes. */
113
+ /** Router diagnostic ledger (hanamesh_router_receipts). Pending/reported are legacy ledger state, not Usage confirmation. */
113
114
  export interface ReceiptRoute { providerId:string; model:string|null; }
114
115
  export interface ReceiptRow { appId:string; providerId:string|null; model:string|null; hour:string; count:number; injections:number; firstAt:string; lastAt:string; reported:boolean; }
115
116
  export interface ReceiptLedger {
@@ -122,6 +123,7 @@ export interface ReceiptLedger {
122
123
  }
123
124
  export function createReceiptLedger(options?:{domain?:{global:{get():unknown;set(value:unknown):Promise<void>}};clock?:()=>number;persistDelayMs?:number;maxItems?:number;retentionMs?:number}):ReceiptLedger;
124
125
  export function normalizeRoute(route:unknown):ReceiptRoute|null;
126
+ /** No verified original fact source is connected: logs USAGE_FACT_UNAVAILABLE and persists Router diagnostics only. Legacy scheduling options remain accepted; no reporting timer runs. flush/settle do not observe Usage completion. */
125
127
  export function createUsageEvidence(options:{host:AppHost;seat:()=>UsageSeat|unknown;receipts?:ReceiptLedger;logger?:{debug?:(...args:unknown[])=>void};clock?:()=>number;sweepIntervalMs?:number}):{
126
128
  flush(options?:{appId?:string;includeCurrent?:boolean}):Promise<void>; settle():Promise<void>; receipts:ReceiptLedger; close():void;
127
129
  };
@@ -0,0 +1,58 @@
1
+ {
2
+ "schemaVersion": "1.0.0",
3
+ "revision": "install-target-contract-fixture-1",
4
+ "items": [
5
+ {
6
+ "id": "fixture.install-target.plugin",
7
+ "name": "@hanamesh-fixture/install-target-plugin",
8
+ "displayName": "测试目录 · 安装目标插件",
9
+ "summary": "Install-target contract fixture (test catalog, not a real product).",
10
+ "latestVersion": "1.2.3",
11
+ "categories": [
12
+ "utility"
13
+ ],
14
+ "package": {
15
+ "registry": "npm",
16
+ "name": "@hanamesh-fixture/install-target-plugin"
17
+ },
18
+ "publisher": {
19
+ "name": "HanaMesh fixture"
20
+ }
21
+ },
22
+ {
23
+ "id": "fixture.install-target.app",
24
+ "name": "@hanamesh-fixture/install-target-app",
25
+ "displayName": "测试目录 · 安装目标应用",
26
+ "summary": "Install-target contract fixture application (test catalog).",
27
+ "latestVersion": "0.4.0",
28
+ "categories": [
29
+ "hanamesh-app"
30
+ ],
31
+ "package": {
32
+ "registry": "npm",
33
+ "name": "@hanamesh-fixture/install-target-app"
34
+ },
35
+ "publisher": {
36
+ "name": "HanaMesh fixture"
37
+ }
38
+ },
39
+ {
40
+ "id": "fixture.install-target.repository-only",
41
+ "name": "install-target-repository-only",
42
+ "displayName": "测试目录 · 仅源码",
43
+ "summary": "Repository-only listing; the market cannot install it.",
44
+ "categories": [
45
+ "utility"
46
+ ],
47
+ "repository": {
48
+ "url": "https://example.com/repository"
49
+ },
50
+ "publisher": {
51
+ "name": "HanaMesh fixture"
52
+ }
53
+ }
54
+ ],
55
+ "page": {
56
+ "total": 3
57
+ }
58
+ }
@@ -0,0 +1,148 @@
1
+ {
2
+ "contract": "hanamesh.install-target",
3
+ "contractVersion": "1",
4
+ "workspace": "http://127.0.0.1:43123/workspace/fixture-session?token=fixture-token#previous",
5
+ "targets": [
6
+ {
7
+ "id": "CN01",
8
+ "note": "both identifiers (current Desktop link shape)",
9
+ "target": {
10
+ "itemId": "fixture.install-target.plugin",
11
+ "packageName": "@hanamesh-fixture/install-target-plugin"
12
+ },
13
+ "expect": "navigate",
14
+ "resolves": "fixture.install-target.plugin"
15
+ },
16
+ {
17
+ "id": "CN02",
18
+ "note": "scoped application",
19
+ "target": {
20
+ "itemId": "fixture.install-target.app",
21
+ "packageName": "@hanamesh-fixture/install-target-app"
22
+ },
23
+ "expect": "navigate",
24
+ "resolves": "fixture.install-target.app"
25
+ },
26
+ {
27
+ "id": "CN03",
28
+ "note": "package name only; a consumer may require both identifiers",
29
+ "target": {
30
+ "packageName": "@hanamesh-fixture/install-target-plugin"
31
+ },
32
+ "expect": "navigate",
33
+ "optional": true,
34
+ "resolves": "fixture.install-target.plugin"
35
+ },
36
+ {
37
+ "id": "CN04",
38
+ "note": "item id only; a consumer may require both identifiers",
39
+ "target": {
40
+ "itemId": "fixture.install-target.plugin"
41
+ },
42
+ "expect": "navigate",
43
+ "optional": true,
44
+ "resolves": "fixture.install-target.plugin"
45
+ },
46
+ {
47
+ "id": "CR01",
48
+ "note": "caller-supplied version",
49
+ "target": {
50
+ "itemId": "fixture.install-target.plugin",
51
+ "packageName": "@hanamesh-fixture/install-target-plugin",
52
+ "version": "9.0.0"
53
+ },
54
+ "expect": "refuse"
55
+ },
56
+ {
57
+ "id": "CR02",
58
+ "note": "caller-supplied download location",
59
+ "target": {
60
+ "itemId": "fixture.install-target.plugin",
61
+ "packageName": "@hanamesh-fixture/install-target-plugin",
62
+ "url": "https://evil.example/pkg.tgz"
63
+ },
64
+ "expect": "refuse"
65
+ },
66
+ {
67
+ "id": "CR03",
68
+ "note": "package name grammar",
69
+ "target": {
70
+ "itemId": "fixture.install-target.plugin",
71
+ "packageName": "../escape"
72
+ },
73
+ "expect": "refuse"
74
+ },
75
+ {
76
+ "id": "CR04",
77
+ "note": "item id longer than 160",
78
+ "target": {
79
+ "itemId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
80
+ "packageName": "@hanamesh-fixture/install-target-plugin"
81
+ },
82
+ "expect": "refuse"
83
+ },
84
+ {
85
+ "id": "CR05",
86
+ "note": "control character",
87
+ "target": {
88
+ "itemId": "fixture\u0007id",
89
+ "packageName": "@hanamesh-fixture/install-target-plugin"
90
+ },
91
+ "expect": "refuse"
92
+ },
93
+ {
94
+ "id": "CR06",
95
+ "note": "no identifier",
96
+ "target": {},
97
+ "expect": "refuse"
98
+ }
99
+ ],
100
+ "handshake": [
101
+ {
102
+ "id": "CH01",
103
+ "note": "provider implementing v1",
104
+ "provider": {
105
+ "contract": "hanamesh.install-target",
106
+ "contractVersion": "1",
107
+ "supported": [
108
+ "1"
109
+ ]
110
+ },
111
+ "expect": "accept"
112
+ },
113
+ {
114
+ "id": "CH02",
115
+ "note": "provider implementing only a future version",
116
+ "provider": {
117
+ "contract": "hanamesh.install-target",
118
+ "contractVersion": "2",
119
+ "supported": [
120
+ "2"
121
+ ]
122
+ },
123
+ "expect": "refuse"
124
+ },
125
+ {
126
+ "id": "CH03",
127
+ "note": "provider without a declaration (0.2.0-rc.7 and older)",
128
+ "provider": null,
129
+ "expect": "refuse"
130
+ },
131
+ {
132
+ "id": "CH04",
133
+ "note": "declaration for a different contract",
134
+ "provider": {
135
+ "contract": "hanamesh.something-else",
136
+ "contractVersion": "1",
137
+ "supported": [
138
+ "1"
139
+ ]
140
+ },
141
+ "expect": "refuse"
142
+ }
143
+ ],
144
+ "workspaces": [
145
+ "http://127.0.0.1:43123/workspace/fixture-session?token=fixture-token#previous",
146
+ "http://localhost:43123/workspace/fixture-session?token=fixture-token#previous"
147
+ ]
148
+ }