@jini-ai/platform 0.5.9 → 0.6.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.
- package/CHANGELOG.md +22 -1
- package/dist/asset-cache.js +26 -25
- package/dist/blob-storage.d.ts +7 -2
- package/dist/blob-storage.js +99 -59
- package/dist/child-environment.d.ts +36 -0
- package/dist/child-environment.js +68 -0
- package/dist/download.js +192 -148
- package/dist/fs/atomic-write.js +105 -93
- package/dist/fs/boot-identity.d.ts +16 -0
- package/dist/fs/boot-identity.js +38 -17
- package/dist/fs/directory-home.js +6 -0
- package/dist/fs/directory-registry.d.ts +1 -1
- package/dist/fs/directory-registry.js +23 -16
- package/dist/fs/directory-running-lock.d.ts +1 -1
- package/dist/fs/directory-running-lock.js +18 -7
- package/dist/fs/directory-sync.d.ts +1 -1
- package/dist/fs/directory-sync.js +64 -51
- package/dist/fs/directory-user-config.js +108 -67
- package/dist/fs/directory-workspace.d.ts +11 -0
- package/dist/fs/directory-workspace.js +16 -11
- package/dist/fs/durable-json.js +49 -37
- package/dist/fs/file-lock.js +214 -149
- package/dist/fs/guarded-reader.js +47 -35
- package/dist/fs/user-data-directories.d.ts +1 -1
- package/dist/fs/user-data-directories.js +25 -9
- package/dist/fs/verified-directory-move.d.ts +13 -11
- package/dist/fs/verified-directory-move.js +105 -68
- package/dist/fs/verified-file-relocation.d.ts +1 -1
- package/dist/fs/verified-file-relocation.js +111 -94
- package/dist/http/guarded/client.d.ts +3 -4
- package/dist/http/guarded/client.js +50 -60
- package/dist/http/guarded/fetch-adapter.d.ts +2 -3
- package/dist/http/guarded/fetch-adapter.js +26 -15
- package/dist/mail/builtin-providers.d.ts +39 -0
- package/dist/mail/builtin-providers.js +60 -0
- package/dist/mail/feedback.d.ts +84 -0
- package/dist/mail/feedback.js +90 -0
- package/dist/mail/index.d.ts +11 -0
- package/dist/mail/index.js +6 -0
- package/dist/mail/policy.d.ts +77 -0
- package/dist/mail/policy.js +155 -0
- package/dist/mail/ports.d.ts +56 -7
- package/dist/mail/provider.d.ts +117 -0
- package/dist/mail/provider.js +74 -0
- package/dist/mail/rate-limit.d.ts +33 -0
- package/dist/mail/rate-limit.js +35 -0
- package/dist/mail/sender-identity.d.ts +68 -0
- package/dist/mail/sender-identity.js +50 -0
- package/dist/mail/types.d.ts +9 -0
- package/dist/net/address.d.ts +9 -23
- package/dist/net/address.js +28 -133
- package/dist/net/endpoint-policy.d.ts +26 -2
- package/dist/net/endpoint-policy.js +159 -56
- package/dist/object-store/index.d.ts +2 -0
- package/dist/object-store/index.js +2 -0
- package/dist/object-store/local-fs.d.ts +17 -0
- package/dist/object-store/local-fs.js +146 -0
- package/dist/process.js +2 -0
- package/dist/proxy-env.js +131 -135
- package/dist/secrets/credential-sets/contracts.d.ts +1 -0
- package/dist/secrets/credential-sets/contracts.js +1 -0
- package/dist/secrets/credential-sets/index.d.ts +1 -0
- package/dist/secrets/credential-sets/index.js +1 -0
- package/dist/secrets/credential-sets/provider-matches.d.ts +61 -0
- package/dist/secrets/credential-sets/provider-matches.js +61 -0
- package/dist/secrets/credential-sets/types.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.meta.js +58 -20
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.repo.memory.js +43 -36
- package/dist/secrets/credential-sets/vault.service.d.ts +9 -0
- package/dist/secrets/credential-sets/vault.service.js +131 -61
- package/dist/secrets/credential-sets/vault.types.d.ts +11 -3
- package/dist/secrets/credential-sets/vault.types.js +22 -8
- package/dist/secrets/keyring.env.js +14 -10
- package/dist/toolchain.js +119 -91
- package/dist/web/html-document.js +73 -69
- package/dist/web/html-to-markdown.js +127 -102
- package/dist/web/web-page.js +160 -80
- package/package.json +12 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @jini-ai/platform
|
|
2
2
|
|
|
3
|
+
## 0.6.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e16a907: Preserve page-reader emphasis, raw Markdown text and inline-code whitespace; cap HTML before synchronous conversion.
|
|
8
|
+
|
|
9
|
+
The library projection retains its definition-list, fenced-code language, q/kbd, explicit hard-break and compact GFM table serialization. Ordinary text remains unescaped.
|
|
10
|
+
|
|
11
|
+
- b63cb5f: Add an explicit child-environment omission helper that reports dropped key names without exposing values, including case-insensitive matching for Windows hosts.
|
|
12
|
+
- Updated dependencies [22f0619]
|
|
13
|
+
- Updated dependencies [82ed2d3]
|
|
14
|
+
- Updated dependencies [5a3de86]
|
|
15
|
+
- @jini-ai/core@0.6.10
|
|
16
|
+
|
|
17
|
+
## 0.5.10 — 2026-10-22
|
|
18
|
+
|
|
19
|
+
- 2026-10-22: Mail: sender-identity resolver, payload hash covers the full delivery, rate-limit fixes, console preview; coverage.
|
|
20
|
+
|
|
21
|
+
- `./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`.
|
|
22
|
+
|
|
3
23
|
## 0.5.9 — 2026-10-16
|
|
4
24
|
|
|
5
25
|
- `fs`: best-effort directory fsync, so Windows site creation stops failing with EPERM (87720117).
|
|
@@ -47,6 +67,8 @@
|
|
|
47
67
|
|
|
48
68
|
## Unreleased
|
|
49
69
|
|
|
70
|
+
- 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.
|
|
71
|
+
|
|
50
72
|
### Compilation fixes
|
|
51
73
|
|
|
52
74
|
- Omit an absent guarded HTTP abort signal to satisfy the canonical request contract.
|
|
@@ -59,7 +81,6 @@
|
|
|
59
81
|
- Remove `./trash` and the trash root namespace; import `@jini-ai/cms/trash`.
|
|
60
82
|
- Move both capabilities' behavior tests with their source; platform retains only generic OS/transport capabilities.
|
|
61
83
|
|
|
62
|
-
|
|
63
84
|
### Shared platform primitives
|
|
64
85
|
|
|
65
86
|
- BREAKING: HTTP primitive types now come from `@jini-ai/core/primitives`; guarded HTTP exports
|
package/dist/asset-cache.js
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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)
|
|
263
|
-
|
|
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
|
|
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(
|
|
292
|
+
const pending = fetchAndStore(url, key).finally(() => {
|
|
292
293
|
inflight.delete(key);
|
|
293
294
|
});
|
|
294
295
|
inflight.set(key, pending);
|
package/dist/blob-storage.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
85
|
-
|
|
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
|
}
|
package/dist/blob-storage.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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:
|
|
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
|