@jini-ai/platform 0.5.9 → 0.5.10

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 (79) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/asset-cache.js +26 -25
  3. package/dist/blob-storage.d.ts +7 -2
  4. package/dist/blob-storage.js +99 -59
  5. package/dist/child-environment.d.ts +36 -0
  6. package/dist/child-environment.js +68 -0
  7. package/dist/download.js +192 -148
  8. package/dist/fs/atomic-write.js +105 -93
  9. package/dist/fs/boot-identity.d.ts +16 -0
  10. package/dist/fs/boot-identity.js +38 -17
  11. package/dist/fs/directory-home.js +6 -0
  12. package/dist/fs/directory-registry.d.ts +1 -1
  13. package/dist/fs/directory-registry.js +23 -16
  14. package/dist/fs/directory-running-lock.d.ts +1 -1
  15. package/dist/fs/directory-running-lock.js +18 -7
  16. package/dist/fs/directory-sync.d.ts +1 -1
  17. package/dist/fs/directory-sync.js +64 -51
  18. package/dist/fs/directory-user-config.js +108 -67
  19. package/dist/fs/directory-workspace.d.ts +11 -0
  20. package/dist/fs/directory-workspace.js +16 -11
  21. package/dist/fs/durable-json.js +49 -37
  22. package/dist/fs/file-lock.js +214 -149
  23. package/dist/fs/guarded-reader.js +47 -35
  24. package/dist/fs/user-data-directories.d.ts +1 -1
  25. package/dist/fs/user-data-directories.js +25 -9
  26. package/dist/fs/verified-directory-move.d.ts +13 -11
  27. package/dist/fs/verified-directory-move.js +105 -68
  28. package/dist/fs/verified-file-relocation.d.ts +1 -1
  29. package/dist/fs/verified-file-relocation.js +111 -94
  30. package/dist/http/guarded/client.d.ts +3 -4
  31. package/dist/http/guarded/client.js +50 -60
  32. package/dist/http/guarded/fetch-adapter.d.ts +2 -3
  33. package/dist/http/guarded/fetch-adapter.js +26 -15
  34. package/dist/mail/builtin-providers.d.ts +39 -0
  35. package/dist/mail/builtin-providers.js +60 -0
  36. package/dist/mail/feedback.d.ts +84 -0
  37. package/dist/mail/feedback.js +90 -0
  38. package/dist/mail/index.d.ts +11 -0
  39. package/dist/mail/index.js +6 -0
  40. package/dist/mail/policy.d.ts +77 -0
  41. package/dist/mail/policy.js +155 -0
  42. package/dist/mail/ports.d.ts +56 -7
  43. package/dist/mail/provider.d.ts +117 -0
  44. package/dist/mail/provider.js +74 -0
  45. package/dist/mail/rate-limit.d.ts +33 -0
  46. package/dist/mail/rate-limit.js +35 -0
  47. package/dist/mail/sender-identity.d.ts +68 -0
  48. package/dist/mail/sender-identity.js +50 -0
  49. package/dist/mail/types.d.ts +9 -0
  50. package/dist/net/address.d.ts +9 -23
  51. package/dist/net/address.js +28 -133
  52. package/dist/net/endpoint-policy.d.ts +26 -2
  53. package/dist/net/endpoint-policy.js +159 -56
  54. package/dist/object-store/index.d.ts +2 -0
  55. package/dist/object-store/index.js +2 -0
  56. package/dist/object-store/local-fs.d.ts +17 -0
  57. package/dist/object-store/local-fs.js +146 -0
  58. package/dist/process.js +2 -0
  59. package/dist/proxy-env.js +131 -135
  60. package/dist/secrets/credential-sets/contracts.d.ts +1 -0
  61. package/dist/secrets/credential-sets/contracts.js +1 -0
  62. package/dist/secrets/credential-sets/index.d.ts +1 -0
  63. package/dist/secrets/credential-sets/index.js +1 -0
  64. package/dist/secrets/credential-sets/provider-matches.d.ts +61 -0
  65. package/dist/secrets/credential-sets/provider-matches.js +61 -0
  66. package/dist/secrets/credential-sets/types.d.ts +7 -0
  67. package/dist/secrets/credential-sets/vault.meta.js +58 -20
  68. package/dist/secrets/credential-sets/vault.repo.memory.d.ts +7 -0
  69. package/dist/secrets/credential-sets/vault.repo.memory.js +43 -36
  70. package/dist/secrets/credential-sets/vault.service.d.ts +9 -0
  71. package/dist/secrets/credential-sets/vault.service.js +131 -61
  72. package/dist/secrets/credential-sets/vault.types.d.ts +11 -3
  73. package/dist/secrets/credential-sets/vault.types.js +22 -8
  74. package/dist/secrets/keyring.env.js +14 -10
  75. package/dist/toolchain.js +119 -91
  76. package/dist/web/html-document.js +73 -69
  77. package/dist/web/html-to-markdown.js +127 -102
  78. package/dist/web/web-page.js +160 -80
  79. package/package.json +11 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @jini-ai/platform
2
2
 
3
+ ## 0.5.10 — 2026-10-22
4
+
5
+ - 2026-10-22: Mail: sender-identity resolver, payload hash covers the full delivery, rate-limit fixes, console preview; coverage.
6
+
7
+ - `./mail`: email provider contract v1. `MailProviderModule` (id, credentialKeys, capabilities incl. rate limit, optional Svix-style feedback parser, `domainRequirements`/`domainStatus` → shared `DomainRequirement`) on the shared `createProviderRegistry`; `smtp` + `console` built-ins; explicit `mail.provider.{transactional,bulk}` setting with `initialMailProviderSetting` (keeps the effective provider on first boot; console/none = `unconfigured`) and `computeMailStatus`. `createMailDeliveryPolicy`: attachments/bulk-unsubscribe (RFC 8058) refusals, suppression by lane (hard bounce = all, complaint = bulk), test redirect/capture mode, per-provider token bucket with reserved transactional capacity, atomic reserve → send → mark ledger with an `unknown` outcome. `createMailFeedbackIngest`: body cap, unverified rate limit, rotating secrets, (provider, event) inbox dedup, one suppression writer. Breaking (unimplemented ports only): `MailSuppressionRepoPort` scopes are `all|bulk` keyed by lane; `MailSendDedupRepoPort` deprecated for `MailSendLedgerPort`. Additive: `MailerSendResult` failure `unknown`/`retryAfterMs`, feedback `eventId`/`recipient`.
8
+
3
9
  ## 0.5.9 — 2026-10-16
4
10
 
5
11
  - `fs`: best-effort directory fsync, so Windows site creation stops failing with EPERM (87720117).
@@ -47,6 +53,7 @@
47
53
 
48
54
  ## Unreleased
49
55
 
56
+ - New `./object-store` (node): `createLocalFsObjectStore({ rootDir })` and `createLocalFsObjectStoreProvider()` — the default `local` provider for `@jini-ai/core/object-store`. Path formula `join(rootDir, key)`, byte-identical to cms `LocalFsBlobStore`; atomic `ifAbsent` via `wx`; keys escaping the root are refused.
50
57
  ### Compilation fixes
51
58
 
52
59
  - Omit an absent guarded HTTP abort signal to satisfy the canonical request contract.
@@ -158,9 +158,6 @@ export function createAssetCache(opts, options = {}) {
158
158
  const suppliedDispatcher = options.dispatcher ?? opts.dispatcher;
159
159
  const dispatcher = suppliedDispatcher ?? createAssetCacheDispatcher({}, opts.lookupImpl ? { lookupImpl: opts.lookupImpl } : {});
160
160
  const inflight = new Map();
161
- function keyFor(rawUrl) {
162
- return createHash('sha256').update(rawUrl).digest('hex');
163
- }
164
161
  async function readFromDisk(key) {
165
162
  const blobPath = path.join(cacheDir, key);
166
163
  const metaPath = `${blobPath}.json`;
@@ -197,26 +194,18 @@ export function createAssetCache(opts, options = {}) {
197
194
  return guessed;
198
195
  throw new AssetCacheError(415, `unsupported content-type: ${header || 'unknown'}`);
199
196
  }
200
- /** Drain the body, aborting the moment the accumulated size exceeds
201
- * `maxBytes`. A response without a trustworthy Content-Length must never be
202
- * fully buffered first — that would turn this caller-supplied proxy into a
203
- * memory-exhaustion path. */
204
- async function readBodyCapped(response, controller) {
205
- const body = response.body;
206
- if (!body) {
207
- const ab = await response.arrayBuffer();
208
- if (ab.byteLength > maxBytes)
209
- throw new AssetCacheError(413, 'asset exceeds size limit');
210
- return Buffer.from(ab);
211
- }
212
- const reader = body.getReader();
197
+ function assertWithinLimit(total) {
198
+ if (total > maxBytes)
199
+ throw new AssetCacheError(413, 'asset exceeds size limit');
200
+ }
201
+ /** Pull chunks until done, failing as soon as the running total passes
202
+ * `maxBytes`. Always releases the reader, even on the size-limit throw. */
203
+ async function drainCapped(reader, controller) {
213
204
  const chunks = [];
214
205
  let total = 0;
215
206
  try {
216
- for (;;) {
217
- const { done, value } = await reader.read();
218
- if (done)
219
- break;
207
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
208
+ const value = next.value;
220
209
  if (!value)
221
210
  continue;
222
211
  total += value.byteLength;
@@ -237,6 +226,18 @@ export function createAssetCache(opts, options = {}) {
237
226
  }
238
227
  return Buffer.concat(chunks);
239
228
  }
229
+ /** Drain the body, aborting the moment the accumulated size exceeds
230
+ * `maxBytes`. A response without a trustworthy Content-Length must never be
231
+ * fully buffered first — that would turn this caller-supplied proxy into a
232
+ * memory-exhaustion path. */
233
+ async function readBodyCapped(response, controller) {
234
+ const body = response.body;
235
+ if (body)
236
+ return drainCapped(body.getReader(), controller);
237
+ const ab = await response.arrayBuffer();
238
+ assertWithinLimit(ab.byteLength);
239
+ return Buffer.from(ab);
240
+ }
240
241
  async function fetchAndStore(rawUrl, key) {
241
242
  const url = assertSafePublicUrl(rawUrl);
242
243
  const controller = new AbortController();
@@ -259,9 +260,8 @@ export function createAssetCache(opts, options = {}) {
259
260
  throw new AssetCacheError(502, `upstream responded ${response.status}`);
260
261
  }
261
262
  const declaredLength = Number(response.headers.get('content-length') ?? '');
262
- if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
263
- throw new AssetCacheError(413, 'asset exceeds size limit');
264
- }
263
+ if (Number.isFinite(declaredLength))
264
+ assertWithinLimit(declaredLength);
265
265
  const contentType = resolveContentType(response.headers.get('content-type'), url);
266
266
  const buf = await readBodyCapped(response, controller);
267
267
  const result = { buf, contentType };
@@ -281,14 +281,15 @@ export function createAssetCache(opts, options = {}) {
281
281
  if (!isCacheableExternalUrl(rawUrl)) {
282
282
  throw new AssetCacheError(400, 'url is not a cacheable external media url');
283
283
  }
284
- const key = keyFor(rawUrl.trim());
284
+ const url = rawUrl.trim();
285
+ const key = assetCacheKey(url);
285
286
  const cached = await readFromDisk(key);
286
287
  if (cached)
287
288
  return cached;
288
289
  const existing = inflight.get(key);
289
290
  if (existing)
290
291
  return existing;
291
- const pending = fetchAndStore(rawUrl.trim(), key).finally(() => {
292
+ const pending = fetchAndStore(url, key).finally(() => {
292
293
  inflight.delete(key);
293
294
  });
294
295
  inflight.set(key, pending);
@@ -81,8 +81,13 @@ export declare class S3BlobStorage implements BlobStorage {
81
81
  deleteFile(namespace: string, relpath: string): Promise<void>;
82
82
  statFile(namespace: string, relpath: string): Promise<BlobFileMeta | null>;
83
83
  listFiles(namespace: string): Promise<BlobFileMeta[]>;
84
- /** Builds the canonical S3 key the impl uses. Exposed for tests so the prefix/namespace/relpath join is stable. */
85
- keyFor(namespace: string, relpath: string): string;
84
+ /**
85
+ * Builds the canonical S3 key. Exposed for tests so the prefix/namespace/relpath join is stable.
86
+ * Listing explicitly opts into an empty prefix relpath; file operations never do.
87
+ */
88
+ keyFor(namespace: string, relpath: string, { allowPrefix }?: {
89
+ allowPrefix?: boolean;
90
+ }): string;
86
91
  private endpointBase;
87
92
  private signedRequest;
88
93
  }
@@ -12,6 +12,10 @@
12
12
  * workspace id, a project id, whatever the host application scopes storage by) — this
13
13
  * module carries no domain meaning for what a namespace *is*, only that paths are
14
14
  * grouped and traversal-guarded underneath one.
15
+ *
16
+ * Superseded (2026-10-21) by the one object-storage contract, `@jini-ai/core/object-store`
17
+ * (`ObjectStore`, keyed providers, default `local`). New code uses `ObjectStore` (local default:
18
+ * `@jini-ai/platform/object-store`); this module stays for its existing adapter consumers.
15
19
  */
16
20
  import path from 'node:path';
17
21
  import { promises as fsp } from 'node:fs';
@@ -54,32 +58,21 @@ export class LocalBlobStorage {
54
58
  return { path: normalizeRel(relpath), size: stat.size, mtimeMs: stat.mtimeMs };
55
59
  }
56
60
  async listFiles(namespace) {
61
+ validateNamespace(namespace);
57
62
  const root = path.join(this.root, namespace);
58
63
  const out = [];
59
64
  const queue = [root];
60
65
  while (queue.length > 0) {
61
66
  const dir = queue.pop();
62
- let entries;
63
- try {
64
- entries = await fsp.readdir(dir, { withFileTypes: true });
65
- }
66
- catch (err) {
67
- if (err.code === 'ENOENT')
67
+ const entries = await readListedDirectory(dir);
68
+ // A missing namespace lists as empty; a subdirectory deleted mid-walk only drops its own entries.
69
+ if (!entries) {
70
+ if (dir === root)
68
71
  return [];
69
- throw new StorageError('IO', `list failed: ${err.message}`);
70
- }
71
- for (const entry of entries) {
72
- const abs = path.join(dir, entry.name);
73
- if (entry.isDirectory()) {
74
- queue.push(abs);
75
- continue;
76
- }
77
- if (!entry.isFile())
78
- continue;
79
- const stat = await fsp.stat(abs);
80
- const rel = path.relative(root, abs).split(path.sep).join('/');
81
- out.push({ path: rel, size: stat.size, mtimeMs: stat.mtimeMs });
72
+ continue;
82
73
  }
74
+ for (const entry of entries)
75
+ await collectListedEntry({ root, dir, entry, queue, out });
83
76
  }
84
77
  return out;
85
78
  }
@@ -105,15 +98,8 @@ export class LocalBlobStorage {
105
98
  }
106
99
  }
107
100
  resolvePath(namespace, relpath) {
108
- if (!namespace || namespace.includes('/') || namespace.includes('\\') || namespace.includes('\0') || namespace.includes('..')) {
109
- throw new StorageError('TRAVERSAL', `invalid namespace ${namespace}`);
110
- }
111
- const normalized = normalizeRel(relpath);
112
- if (!normalized)
113
- throw new StorageError('TRAVERSAL', 'empty relpath');
114
- if (normalized.split('/').some((seg) => seg === '..' || seg === '.')) {
115
- throw new StorageError('TRAVERSAL', `unsafe relpath ${relpath}`);
116
- }
101
+ validateNamespace(namespace);
102
+ const normalized = validateRelpath(relpath);
117
103
  return path.join(this.root, namespace, ...normalized.split('/'));
118
104
  }
119
105
  }
@@ -171,55 +157,36 @@ export class S3BlobStorage {
171
157
  return {
172
158
  path: normalizeRel(relpath),
173
159
  size: Number.isFinite(contentLength) ? contentLength : 0,
174
- mtimeMs: lastModified ? Date.parse(lastModified) : Date.now(),
160
+ mtimeMs: modificationTime(lastModified),
175
161
  };
176
162
  }
177
163
  async listFiles(namespace) {
178
- const namespacePrefix = this.keyFor(namespace, '');
164
+ const namespacePrefix = `${this.keyFor(namespace, '', { allowPrefix: true })}/`;
179
165
  const out = [];
180
166
  let continuationToken;
181
167
  // Cap iterations so a hostile bucket can't loop forever.
182
168
  for (let pages = 0; pages < 1000; pages++) {
183
- const params = [
184
- ['list-type', '2'],
185
- ['prefix', namespacePrefix],
186
- ];
187
- if (continuationToken)
188
- params.push(['continuation-token', continuationToken]);
189
- params.sort((a, b) => a[0].localeCompare(b[0]));
190
- const query = params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
169
+ const query = listObjectsV2Query({ prefix: namespacePrefix, continuationToken });
191
170
  const res = await this.signedRequest({ method: 'GET', key: '', extraQuery: query });
192
171
  if (!res.ok) {
193
172
  throw new StorageError('IO', `S3 LIST ${namespacePrefix} → ${res.status} ${res.statusText}: ${await safeText(res)}`);
194
173
  }
195
174
  const xml = await res.text();
196
175
  const { entries, isTruncated, nextToken } = parseListBucketV2Xml(xml);
197
- for (const e of entries) {
198
- // Strip the per-bucket namespacePrefix to surface namespace-relative paths.
199
- // `namespacePrefix` is `keyFor(namespace, '')`, which always includes at least the
200
- // (non-empty, `keyFor`-validated) namespace segment and never a trailing slash — so it's
201
- // always non-empty and the +1 separator always applies.
202
- const relStart = namespacePrefix.length + 1;
203
- const rel = e.key.slice(relStart).replace(/^\/+/, '');
204
- if (!rel)
205
- continue; // skip the prefix marker itself
206
- out.push({ path: rel, size: e.size, mtimeMs: e.lastModifiedMs });
207
- }
176
+ out.push(...namespaceEntries(entries, namespacePrefix));
208
177
  if (!isTruncated || !nextToken)
209
178
  break;
210
179
  continuationToken = nextToken;
211
180
  }
212
181
  return out;
213
182
  }
214
- /** Builds the canonical S3 key the impl uses. Exposed for tests so the prefix/namespace/relpath join is stable. */
215
- keyFor(namespace, relpath) {
216
- if (!namespace || namespace.includes('/') || namespace.includes('\\') || namespace.includes('..')) {
217
- throw new StorageError('TRAVERSAL', `invalid namespace ${namespace}`);
218
- }
219
- const normalized = relpath ? normalizeRel(relpath) : '';
220
- if (normalized.split('/').some((seg) => seg === '..' || seg === '.')) {
221
- throw new StorageError('TRAVERSAL', `unsafe relpath ${relpath}`);
222
- }
183
+ /**
184
+ * Builds the canonical S3 key. Exposed for tests so the prefix/namespace/relpath join is stable.
185
+ * Listing explicitly opts into an empty prefix relpath; file operations never do.
186
+ */
187
+ keyFor(namespace, relpath, { allowPrefix = false } = {}) {
188
+ validateNamespace(namespace);
189
+ const normalized = validateRelpath(relpath, { allowPrefix });
223
190
  const segments = [this.options.prefix?.replace(/^\/+|\/+$/g, ''), namespace, normalized].filter((s) => typeof s === 'string' && s.length > 0);
224
191
  return segments.join('/');
225
192
  }
@@ -266,6 +233,17 @@ export class S3BlobStorage {
266
233
  return this.fetchFn(url, init);
267
234
  }
268
235
  }
236
+ /** Builds the ListObjectsV2 query string with keys sorted, as SigV4's canonical query requires. */
237
+ function listObjectsV2Query({ prefix, continuationToken }, _options = {}) {
238
+ const params = [
239
+ ['list-type', '2'],
240
+ ['prefix', prefix],
241
+ ];
242
+ if (continuationToken)
243
+ params.push(['continuation-token', continuationToken]);
244
+ params.sort((a, b) => a[0].localeCompare(b[0]));
245
+ return params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
246
+ }
269
247
  function parseListBucketV2Xml(xml) {
270
248
  const entries = [];
271
249
  // Lightweight XML scrape — accepts S3 / S3-compat shapes:
@@ -286,7 +264,7 @@ function parseListBucketV2Xml(xml) {
286
264
  entries.push({
287
265
  key,
288
266
  size: Number.isFinite(size) ? size : 0,
289
- lastModifiedMs: lastModifiedRaw ? Date.parse(lastModifiedRaw) : Date.now(),
267
+ lastModifiedMs: modificationTime(lastModifiedRaw),
290
268
  });
291
269
  }
292
270
  const isTruncated = (pluckTag(xml, 'IsTruncated') ?? 'false').toLowerCase() === 'true';
@@ -312,4 +290,66 @@ function normalizeRel(relpath) {
312
290
  .replace(/[\\]+/g, '/')
313
291
  .replace(/\/+/g, '/');
314
292
  }
293
+ /** Directory entries for a listing walk; null when the directory no longer exists. */
294
+ async function readListedDirectory(dir) {
295
+ try {
296
+ return await fsp.readdir(dir, { withFileTypes: true });
297
+ }
298
+ catch (err) {
299
+ if (err.code === 'ENOENT')
300
+ return null;
301
+ throw new StorageError('IO', `list failed: ${err.message}`);
302
+ }
303
+ }
304
+ /** Queues a subdirectory or records a regular file; other entry types are skipped. */
305
+ async function collectListedEntry({ root, dir, entry, queue, out }) {
306
+ const abs = path.join(dir, entry.name);
307
+ if (entry.isDirectory()) {
308
+ queue.push(abs);
309
+ return;
310
+ }
311
+ if (!entry.isFile())
312
+ return;
313
+ const stat = await fsp.stat(abs);
314
+ const rel = path.relative(root, abs).split(path.sep).join('/');
315
+ out.push({ path: rel, size: stat.size, mtimeMs: stat.mtimeMs });
316
+ }
317
+ /** Strips the per-bucket namespacePrefix to surface namespace-relative paths. */
318
+ function namespaceEntries(entries, namespacePrefix) {
319
+ const out = [];
320
+ for (const e of entries) {
321
+ // It includes the non-empty, validated namespace and its trailing separator,
322
+ // so sibling namespaces (ns10 vs ns1) cannot leak into this listing.
323
+ if (!e.key.startsWith(namespacePrefix))
324
+ continue;
325
+ const rel = e.key.slice(namespacePrefix.length).replace(/^\/+/, '');
326
+ if (!rel)
327
+ continue; // skip the prefix marker itself
328
+ out.push({ path: rel, size: e.size, mtimeMs: e.lastModifiedMs });
329
+ }
330
+ return out;
331
+ }
332
+ /** One namespace guard for both backends and every operation, including listing. */
333
+ function validateNamespace(namespace) {
334
+ if (!namespace || namespace === '.' || /[/\\\0]/.test(namespace) || namespace.includes('..')) {
335
+ throw new StorageError('TRAVERSAL', `invalid namespace ${namespace}`);
336
+ }
337
+ }
338
+ /** Shared key validation; only an explicit empty string may denote an S3 listing prefix. */
339
+ function validateRelpath(relpath, { allowPrefix = false } = {}) {
340
+ if (allowPrefix && relpath === '')
341
+ return '';
342
+ const normalized = normalizeRel(relpath);
343
+ if (!normalized)
344
+ throw new StorageError('TRAVERSAL', 'empty relpath');
345
+ if (normalized.includes('\0') || normalized.split('/').some(seg => seg === '..' || seg === '.')) {
346
+ throw new StorageError('TRAVERSAL', `unsafe relpath ${relpath}`);
347
+ }
348
+ return normalized;
349
+ }
350
+ /** Missing or malformed remote timestamps use the same finite clock fallback. */
351
+ function modificationTime(value) {
352
+ const parsed = value ? Date.parse(value) : Number.NaN;
353
+ return Number.isFinite(parsed) ? parsed : Date.now();
354
+ }
315
355
  //# sourceMappingURL=blob-storage.js.map
@@ -15,4 +15,40 @@ export declare function omitEnvironmentKeys({ env, keys }: {
15
15
  env: Record<string, string | undefined>;
16
16
  omitted: string[];
17
17
  };
18
+ /** Runs one bounded command and resolves its stdout; `execFile` semantics. */
19
+ export type LoginShellCommandRunner = (required: {
20
+ file: string;
21
+ args: readonly string[];
22
+ env: Record<string, string>;
23
+ cwd: string | undefined;
24
+ }, optional?: {}) => Promise<{
25
+ stdout: string;
26
+ }>;
27
+ /** Host process facts and the command runner the default login-environment reader uses. */
28
+ export interface LoginShellPorts {
29
+ readonly platform: NodeJS.Platform;
30
+ /** The account's login shell; `null` where the OS has none (Windows). */
31
+ readonly userShell: () => string | null;
32
+ readonly runCommand: LoginShellCommandRunner;
33
+ }
34
+ /** The real host: this process's platform, the account's login shell and `execFile` (10 s, 1 MiB). */
35
+ export declare function nodeLoginShellPorts(): LoginShellPorts;
36
+ /** Read the operator's login environment from a clean baseline, never the daemon's secret bag.
37
+ * Only shell bootstrap keys reach the shell. Host-owned names are removed again after profiles
38
+ * load, before a CLI or its MCP children can inherit them. Profile failures reject the launch.
39
+ * `ports` replaces the host platform, login-shell lookup and command runner the default `read` uses.
40
+ * @complexity O(e * p) for e environment entries and p small host-selected exclusions; one bounded process.
41
+ */
42
+ export declare function readLoginShellEnvironment(required: {
43
+ baseEnv: Readonly<Record<string, string | undefined>>;
44
+ excludedPrefixes: readonly string[];
45
+ excludedKeys: readonly string[];
46
+ excludedSuffixes?: readonly string[];
47
+ }, optional?: {
48
+ read?: (input: {
49
+ shell: string;
50
+ env: Record<string, string>;
51
+ }, options?: {}) => Promise<Record<string, string>>;
52
+ ports?: LoginShellPorts;
53
+ }): Promise<Record<string, string>>;
18
54
  //# sourceMappingURL=child-environment.d.ts.map
@@ -1,3 +1,6 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { userInfo } from 'node:os';
3
+ import { promisify } from 'node:util';
1
4
  /** Environment isolation for child processes; hosts name the settings that belong to one run. */
2
5
  /**
3
6
  * Copy an environment without host-selected keys. Reports names only, never values; no I/O.
@@ -23,4 +26,69 @@ export function omitEnvironmentKeys({ env, keys }, { caseInsensitive = false } =
23
26
  }
24
27
  return { env: child, omitted: [...omitted] };
25
28
  }
29
+ const LOGIN_SHELL_BOOTSTRAP_KEYS = ['PATH', 'HOME', 'USERPROFILE', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'TEMP', 'TMP',
30
+ 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'SSH_AUTH_SOCK', 'SystemRoot', 'windir', 'ComSpec', 'PATHEXT'];
31
+ // Registry-backed machine/user environment, without inheriting the service's private values.
32
+ const WINDOWS_ENVIRONMENT_SCRIPT = "$e=@{}; foreach($s in @('Machine','User')) { [Environment]::GetEnvironmentVariables($s).GetEnumerator() | ForEach-Object { $e[$_.Key]=$_.Value } }; $e | ConvertTo-Json -Compress";
33
+ /** The real host: this process's platform, the account's login shell and `execFile` (10 s, 1 MiB). */
34
+ export function nodeLoginShellPorts() {
35
+ const exec = promisify(execFile);
36
+ return {
37
+ platform: process.platform,
38
+ userShell: () => userInfo().shell,
39
+ runCommand: ({ file, args, env, cwd }) => exec(file, [...args], { env, timeout: 10_000, maxBuffer: 1024 * 1024, cwd }),
40
+ };
41
+ }
42
+ /** Parse `env -0` output, keeping only well-formed variable names.
43
+ * @complexity O(n) in the output length. */
44
+ function parseNullSeparatedEnvironment({ stdout }) {
45
+ const entries = stdout.split('\0').flatMap(pair => {
46
+ const at = pair.indexOf('=');
47
+ return at > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(pair.slice(0, at)) ? [[pair.slice(0, at), pair.slice(at + 1)]] : [];
48
+ });
49
+ return Object.fromEntries(entries);
50
+ }
51
+ /** The default reader: the Windows registry environment, or the login shell's `env -0`. */
52
+ async function readHostLoginEnvironment({ shell, env }, ports) {
53
+ if (ports.platform === 'win32') {
54
+ const { stdout } = await ports.runCommand({ file: 'powershell.exe', args: ['-NoLogo', '-NonInteractive', '-Command', WINDOWS_ENVIRONMENT_SCRIPT],
55
+ env, cwd: env.USERPROFILE });
56
+ return { ...env, ...JSON.parse(stdout) };
57
+ }
58
+ const { stdout } = await ports.runCommand({ file: shell, args: ['-ilc', 'exec /usr/bin/env -0'], env, cwd: env.HOME });
59
+ return parseNullSeparatedEnvironment({ stdout });
60
+ }
61
+ /** True when a host-owned name, prefix or suffix claims this key (case-insensitively). */
62
+ function isExcludedEnvironmentKey({ key, required }) {
63
+ const upper = key.toUpperCase();
64
+ return required.excludedKeys.some(name => name.toUpperCase() === upper) ||
65
+ required.excludedPrefixes.some(prefix => upper.startsWith(prefix.toUpperCase())) ||
66
+ (required.excludedSuffixes ?? []).some(suffix => upper.endsWith(suffix.toUpperCase()));
67
+ }
68
+ /** Read the operator's login environment from a clean baseline, never the daemon's secret bag.
69
+ * Only shell bootstrap keys reach the shell. Host-owned names are removed again after profiles
70
+ * load, before a CLI or its MCP children can inherit them. Profile failures reject the launch.
71
+ * `ports` replaces the host platform, login-shell lookup and command runner the default `read` uses.
72
+ * @complexity O(e * p) for e environment entries and p small host-selected exclusions; one bounded process.
73
+ */
74
+ export async function readLoginShellEnvironment(required, optional = {}) {
75
+ const baseline = {};
76
+ for (const key of LOGIN_SHELL_BOOTSTRAP_KEYS) {
77
+ const value = required.baseEnv[key];
78
+ if (value !== undefined)
79
+ baseline[key] = value;
80
+ }
81
+ const ports = optional.ports ?? nodeLoginShellPorts();
82
+ const shell = ports.userShell() || baseline.SHELL || '/bin/sh';
83
+ const read = optional.read ?? ((input) => readHostLoginEnvironment(input, ports));
84
+ let env;
85
+ try {
86
+ env = await read({ shell, env: baseline }, {});
87
+ }
88
+ catch {
89
+ throw new Error('Login shell environment unavailable');
90
+ }
91
+ return Object.fromEntries(Object.entries(env).filter(([key, value]) => typeof value === 'string' &&
92
+ !isExcludedEnvironmentKey({ key, required })));
93
+ }
26
94
  //# sourceMappingURL=child-environment.js.map