@jini-ai/platform 0.6.15 → 0.6.16

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/README.md CHANGED
@@ -14,12 +14,9 @@ that were split out of OD's monolithic daemon (see the archived provenance ledge
14
14
  npm install @jini-ai/platform
15
15
  ```
16
16
 
17
- `undici@^7.25.0` is an optional peer for the SSRF-safe asset cache's default dispatcher.
18
- Install it when calling `createAssetCacheDispatcher` or creating a cache without a host-supplied
19
- guarded dispatcher. Root imports and `@jini-ai/platform/asset-cache` imports do not load undici;
20
- the synchronous factory loads it on demand and reports a missing-peer error if it is absent.
21
- The root declarations retain undici types, so TypeScript consumers of those declarations also
22
- need the peer available. The dispatcher validates DNS at connection time, and cache `close()`
17
+ `undici@^7.25.0` is a regular dependency, installed with this package for the SSRF-safe asset
18
+ cache's default dispatcher. The synchronous factory constructs it on demand and propagates
19
+ construction failures. The dispatcher validates DNS at connection time, and cache `close()`
23
20
  closes only its default dispatcher; a host-supplied dispatcher remains owned by the host.
24
21
  Interactive terminal sessions (`createTerminalService`)
25
22
  need a `PtySpawn` implementation supplied by the caller — this package defines the `PtyProcess`/
@@ -1,7 +1,7 @@
1
1
  import type { LookupFunction } from 'node:net';
2
2
  import type { Agent } from 'undici';
3
- /** Load the optional vendor only for a default dispatcher; the cache owns lookup validation.
4
- * @throws When the optional peer cannot be loaded or its Agent cannot be constructed.
3
+ /** Construct the dispatcher with platform's required undici dependency; the cache owns lookup validation.
4
+ * @throws When the Agent cannot be constructed.
5
5
  */
6
6
  export declare function createUndiciAssetCacheDispatcher({ lookup }: {
7
7
  lookup: LookupFunction;
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from 'node:module';
2
2
  const require = createRequire(import.meta.url);
3
- /** Load the optional vendor only for a default dispatcher; the cache owns lookup validation.
4
- * @throws When the optional peer cannot be loaded or its Agent cannot be constructed.
3
+ /** Construct the dispatcher with platform's required undici dependency; the cache owns lookup validation.
4
+ * @throws When the Agent cannot be constructed.
5
5
  */
6
6
  export function createUndiciAssetCacheDispatcher({ lookup }, _options = {}) {
7
7
  const { Agent } = require('undici');
@@ -52,9 +52,7 @@ export interface AssetCache {
52
52
  /** Closes only the default adapter; an injected dispatcher remains owned by its host. */
53
53
  close?(): Promise<void>;
54
54
  }
55
- /** Default adapter: validate DNS at connection time, so the checked address is the connected one.
56
- * @throws {AssetCacheError} When the host has not installed the optional undici peer.
57
- */
55
+ /** Default adapter: validate DNS at connection time, so the checked address is the connected one. */
58
56
  export declare function createAssetCacheDispatcher(_required: Record<string, never>, { lookupImpl }?: {
59
57
  lookupImpl?: DnsLookupCb;
60
58
  }): Agent;
@@ -130,21 +130,9 @@ export function createValidatingLookup(lookupImpl = dnsLookupCb) {
130
130
  });
131
131
  };
132
132
  }
133
- /** Default adapter: validate DNS at connection time, so the checked address is the connected one.
134
- * @throws {AssetCacheError} When the host has not installed the optional undici peer.
135
- */
133
+ /** Default adapter: validate DNS at connection time, so the checked address is the connected one. */
136
134
  export function createAssetCacheDispatcher(_required, { lookupImpl } = {}) {
137
- // Keep the factory synchronous while root imports and host-supplied dispatchers need no undici.
138
- try {
139
- return createUndiciAssetCacheDispatcher({ lookup: createValidatingLookup(lookupImpl) });
140
- }
141
- catch (error) {
142
- if (error instanceof Error && error.code === 'MODULE_NOT_FOUND'
143
- && error.message.split('\n')[0] === "Cannot find module 'undici'") {
144
- throw new AssetCacheError(500, 'Asset cache dispatcher requires the optional peer "undici"; install undici@^7.25.0 or supply a guarded dispatcher.');
145
- }
146
- throw error;
147
- }
135
+ return createUndiciAssetCacheDispatcher({ lookup: createValidatingLookup(lookupImpl) });
148
136
  }
149
137
  /** Build a disk-backed cache for external sandboxed-content media. Concurrent
150
138
  * requests for the same URL share one in-flight fetch; completed fetches
@@ -37,7 +37,7 @@
37
37
  * module has zero imports of its own — no `node:*` builtin, no `undici` — unlike the package's root
38
38
  * barrel (`index.ts`), which re-exports sibling modules (`process.ts`, `fs.ts`, `terminal.ts`,
39
39
  * `asset-cache.ts`, …) that import `node:child_process`, `node:fs`, etc.; the asset-cache
40
- * dispatcher loads the optional `undici` peer only when its synchronous factory is called.
40
+ * dispatcher constructs its required `undici` dependency only when its synchronous factory is called.
41
41
  * Importing from the root barrel in browser-bundled code is silently fine in a *production* build
42
42
  * through Vite/Rollup — tree-shaking (this package sets `sideEffects: false`) proves the Node-only
43
43
  * re-exports are unused and drops them before the `node:*` imports are ever resolved — but it is
@@ -37,7 +37,7 @@
37
37
  * module has zero imports of its own — no `node:*` builtin, no `undici` — unlike the package's root
38
38
  * barrel (`index.ts`), which re-exports sibling modules (`process.ts`, `fs.ts`, `terminal.ts`,
39
39
  * `asset-cache.ts`, …) that import `node:child_process`, `node:fs`, etc.; the asset-cache
40
- * dispatcher loads the optional `undici` peer only when its synchronous factory is called.
40
+ * dispatcher constructs its required `undici` dependency only when its synchronous factory is called.
41
41
  * Importing from the root barrel in browser-bundled code is silently fine in a *production* build
42
42
  * through Vite/Rollup — tree-shaking (this package sets `sideEffects: false`) proves the Node-only
43
43
  * re-exports are unused and drops them before the `node:*` imports are ever resolved — but it is
@@ -1,5 +1,14 @@
1
1
  import type { DurableJsonFile } from "./durable-json.js";
2
2
  import { type UserDirectoryOptions } from "./user-data-directories.js";
3
+ /** An expected settings refusal whose message is safe to show to the caller.
4
+ * Filesystem and persistence failures retain their original errors for private diagnostics.
5
+ */
6
+ export declare class DirectoryUserConfigRefusal extends Error {
7
+ readonly name = "DirectoryUserConfigRefusal";
8
+ constructor({ message }: {
9
+ message: string;
10
+ }, _options?: {});
11
+ }
3
12
  export interface DirectoryUserConfig {
4
13
  sitesHome: string;
5
14
  siteRoots: string[];
@@ -3,6 +3,13 @@ import { createHash } from "node:crypto";
3
3
  import { resolveDirectoryHome } from "./directory-home.js";
4
4
  import { resolveUserDataDirectories } from "./user-data-directories.js";
5
5
  import { detectDirectorySync } from "./directory-sync.js";
6
+ /** An expected settings refusal whose message is safe to show to the caller.
7
+ * Filesystem and persistence failures retain their original errors for private diagnostics.
8
+ */
9
+ export class DirectoryUserConfigRefusal extends Error {
10
+ name = "DirectoryUserConfigRefusal";
11
+ constructor({ message }, _options = {}) { super(message); }
12
+ }
6
13
  const SAFE_SITE_ID = /^[a-zA-Z0-9_-]+$/;
7
14
  const invalidMarker = (markerPath) => new Error(`Interrupted database move has an invalid recovery marker: ${markerPath}. Restore the pre-move backup before starting.`);
8
15
  function assertDatabaseFilename(filename) {
@@ -78,14 +85,14 @@ export function createDirectoryUserConfig({ homeDir, defaultFolder, envName, pol
78
85
  continue;
79
86
  const expanded = expand(directory);
80
87
  if (exists(expanded) && !isDirectory(expanded))
81
- throw new Error(`Sites folder is not a directory: ${directory}`);
88
+ throw new DirectoryUserConfigRefusal({ message: `Sites folder is not a directory: ${directory}` });
82
89
  }
83
90
  }
84
91
  function assertNotOverridden(checked) {
85
92
  if (checked.sitesHome !== undefined && env[envName] !== undefined)
86
- throw new Error(`${envName} overrides this setting. Unset it before changing the directory home.`);
93
+ throw new DirectoryUserConfigRefusal({ message: `${envName} overrides this setting. Unset it before changing the directory home.` });
87
94
  if (checked.siteRoots !== undefined && env[policy.rootsEnvName] !== undefined)
88
- throw new Error(`${policy.rootsEnvName} overrides this setting. Unset it before changing site roots.`);
95
+ throw new DirectoryUserConfigRefusal({ message: `${policy.rootsEnvName} overrides this setting. Unset it before changing site roots.` });
89
96
  }
90
97
  function merged(previous, checked, incremental) {
91
98
  const next = { ...previous, ...checked,
@@ -213,13 +220,13 @@ const SETTING_RULES = [
213
220
  */
214
221
  export function validateDirectoryUserConfig({ value }, _options = {}) {
215
222
  if (!value || typeof value !== "object" || Array.isArray(value))
216
- throw new Error("User config must be an object.");
223
+ throw new DirectoryUserConfigRefusal({ message: "User config must be an object." });
217
224
  const row = value;
218
225
  if (Object.keys(row).some((key) => !SETTING_RULES.some(([known]) => known === key)))
219
- throw new Error("Unknown user config setting.");
226
+ throw new DirectoryUserConfigRefusal({ message: "Unknown user config setting." });
220
227
  for (const [key, valid, message] of SETTING_RULES)
221
228
  if (row[key] !== undefined && !valid(row[key]))
222
- throw new Error(message);
229
+ throw new DirectoryUserConfigRefusal({ message });
223
230
  return row;
224
231
  }
225
232
  //# sourceMappingURL=directory-user-config.js.map
@@ -35,6 +35,7 @@ export interface HeldFileLockSync {
35
35
  export type FileLockSyncFilesystem = Pick<typeof fs, "mkdirSync" | "openSync" | "writeSync" | "fstatSync" | "readFileSync" | "statSync" | "closeSync" | "rmSync">;
36
36
  export type FileLockAsyncFilesystem = Pick<typeof asyncFs, "mkdir" | "open" | "stat" | "rm">;
37
37
  export interface FileLockOptions {
38
+ /** Infinity follows an operation with its own deadline; dead local owners remain reclaimable. */
38
39
  timeoutMs?: number;
39
40
  /** Infinity disables age stealing; dead local owners remain reclaimable. */
40
41
  staleMs?: number;
@@ -78,7 +78,7 @@ export function isLockStale(required) {
78
78
  return (required.ownerAlive === false || required.nowMs - required.mtimeMs > required.staleMs) && required.observedInode === required.currentInode && required.observedDevice === required.currentDevice;
79
79
  }
80
80
  function intervalsValid({ timeoutMs, staleMs, pollMs }) {
81
- const validTimeout = Number.isSafeInteger(timeoutMs) && timeoutMs >= 0;
81
+ const validTimeout = (Number.isSafeInteger(timeoutMs) && timeoutMs >= 0) || timeoutMs === Infinity;
82
82
  const validStale = (Number.isSafeInteger(staleMs) || staleMs === Infinity) && staleMs > 0;
83
83
  const validPoll = Number.isSafeInteger(pollMs) && pollMs > 0;
84
84
  return validTimeout && validStale && validPoll;
@@ -14,6 +14,27 @@ export type AddedReaderOptions = {
14
14
  descriptorRead?: DescriptorReadPolicy;
15
15
  };
16
16
  export type GuardedReaderFilesystem = Pick<typeof nodeFs, "existsSync" | "lstatSync" | "readdirSync" | "realpathSync" | "statSync" | "openSync" | "readSync" | "closeSync" | "createReadStream"> & DescriptorCapabilities;
17
+ /** Opt-in listing pages; omitted options preserve the legacy recursive reader contract.
18
+ * Cursors are offsets in a sorted snapshot; keep path/depth fixed and restart after tree changes.
19
+ */
20
+ export interface GuardedListingOptions {
21
+ relativePath?: string;
22
+ depth?: number;
23
+ limit?: number;
24
+ cursor?: string;
25
+ maxOutputBytes?: number;
26
+ }
27
+ export interface GuardedListingResult {
28
+ files: string[];
29
+ truncated: boolean;
30
+ remaining?: number;
31
+ nextCursor?: string;
32
+ message?: string;
33
+ }
34
+ /** Adds optional paging without changing existing guarded-reader implementations. */
35
+ export interface PagedGuardedFileReader extends GuardedFileReader {
36
+ list(required: Record<string, never>, optional?: GuardedListingOptions): GuardedListingResult;
37
+ }
17
38
  /** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
18
39
  export declare function createNodeGuardedReaderFilesystem(_required: Record<string, never>): GuardedReaderFilesystem;
19
40
  /** Contains both requested and resolved paths, including symlink aliases and nonexistent descendants.
@@ -28,5 +49,5 @@ export declare function createGuardedFileReader({ rootPath, denyRules, limits, f
28
49
  filesystem: GuardedReaderFilesystem;
29
50
  }, { excludedListingDirs, descriptorRead }?: {
30
51
  excludedListingDirs?: ReadonlySet<string>;
31
- } & AddedReaderOptions): GuardedFileReader;
52
+ } & AddedReaderOptions): PagedGuardedFileReader;
32
53
  //# sourceMappingURL=guarded-reader.d.ts.map
@@ -157,6 +157,10 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
157
157
  if (stat.isDirectory()) {
158
158
  if (isDeniedFsPathSegment(name) || EXCLUDED_LISTING_DIR_NAMES.has(name))
159
159
  return;
160
+ if (state.paged && depth === state.maxDepth) {
161
+ found.push(relative(base, full).split(sep).join("/") + "/");
162
+ return;
163
+ }
160
164
  walkFsDir(full, depth + 1, base, found, state);
161
165
  return;
162
166
  }
@@ -169,14 +173,17 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
169
173
  // Results, entries, and depth are independent limits; keep the decision shared by entry and
170
174
  // loop checks so adding one limit cannot leave an unbounded arm.
171
175
  function walkBoundReached(found, state, depth) {
172
- return depth > MAX_WALK_DEPTH || found.length >= MAX_LISTED_FILES || state.remaining <= 0;
176
+ return depth > state.maxDepth || found.length >= state.maxFiles || state.remaining <= 0;
173
177
  }
174
178
  function walkFsDir(dir, depth, base, found, state) {
175
179
  if (walkBoundReached(found, state, depth)) {
176
180
  state.truncated = true;
177
181
  return;
178
182
  }
179
- for (const name of readdirSync(dir)) {
183
+ const names = readdirSync(dir);
184
+ if (state.paged)
185
+ names.sort();
186
+ for (const name of names) {
180
187
  // Recheck per entry: this loop spends traversal budget and adds results. Remaining
181
188
  // entries mean a bound must set truncated rather than present a short list as complete.
182
189
  if (walkBoundReached(found, state, depth)) {
@@ -187,26 +194,36 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
187
194
  visitFsDirEntry(dir, name, depth, base, found, state);
188
195
  }
189
196
  }
197
+ /** Walk with existing containment/deny rules, then bound the serialized page independently.
198
+ * @complexity At most maxWalkEntries probes; sorting uses O(n log n) time and O(n) paths.
199
+ * Directory reads remain synchronous; traversal exhaustion is reported as an unknown remainder.
200
+ */
190
201
  function listFsFiles(required) {
202
+ const paged = [required.depth, required.limit, required.cursor, required.maxOutputBytes].some(value => value !== undefined);
203
+ const page = listingPageOptions(required, { maxDepth: MAX_WALK_DEPTH, maxFiles: MAX_LISTED_FILES });
191
204
  const { rootPath } = required;
192
205
  const relativePath = required.relativePath ?? "";
206
+ const pathLabel = relativePath || ".";
193
207
  const startDir = relativePath.length === 0 ? resolve(rootPath) : resolveFsFilePath({ rootPath, relativePath });
194
208
  // Freshly provisioned roots/subdirectories may not exist yet; an empty listing is valid.
195
209
  if (!existsSync(startDir))
196
210
  return { files: [], truncated: false };
197
- const startStat = statOrFsPathError(startDir, relativePath || ".");
211
+ const startStat = statOrFsPathError(startDir, pathLabel);
198
212
  if (!startStat)
199
213
  return { files: [], truncated: false };
200
214
  if (!startStat.isDirectory()) {
201
- throw new FsFilePathError({ message: `path '${relativePath || "."}' is not a directory` });
215
+ throw new FsFilePathError({ message: `path '${pathLabel}' is not a directory` });
202
216
  }
203
217
  const base = realpathSync(startDir);
204
218
  const found = [];
205
- const state = { remaining: MAX_WALK_ENTRIES, truncated: false };
219
+ const state = { remaining: MAX_WALK_ENTRIES, truncated: false,
220
+ maxDepth: paged ? page.depth - 1 : MAX_WALK_DEPTH,
221
+ maxFiles: paged ? MAX_WALK_ENTRIES : MAX_LISTED_FILES, paged };
206
222
  walkFsDir(base, 0, base, found, state);
207
223
  // Paths are relative to the requested walk base, so a subdirectory listing can be consumed
208
224
  // without stripping that subdirectory's prefix a second time.
209
- return { files: found.sort(), truncated: state.truncated };
225
+ const listing = { files: found.sort(), truncated: state.truncated };
226
+ return paged ? paginateListing(listing, page) : listing;
210
227
  }
211
228
  // Circular-link ELOOP and other filesystem exceptions must become the reader's refusal class,
212
229
  // not escape raw while an ordinary missing path is represented by undefined.
@@ -353,6 +370,53 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
353
370
  isDeniedPathSegment: ({ segmentName }) => isDeniedFsPathSegment(segmentName),
354
371
  };
355
372
  }
373
+ /** Validate page controls even for raw callers bypassing a tool schema; no filesystem effects. */
374
+ function listingPageOptions(input, { maxDepth, maxFiles }) {
375
+ const depth = input.depth ?? 1, limit = input.limit ?? Math.min(100, maxFiles), maxOutputBytes = input.maxOutputBytes ?? 8000;
376
+ for (const [name, value, max] of [["depth", depth, maxDepth], ["limit", limit, maxFiles], ["maxOutputBytes", maxOutputBytes, Number.MAX_SAFE_INTEGER]]) {
377
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
378
+ throw new FsFilePathError({ message: `${name} must be an integer between 1 and ${max}` });
379
+ }
380
+ if (maxOutputBytes < 512)
381
+ throw new FsFilePathError({ message: "maxOutputBytes must be at least 512 to fit listing metadata" });
382
+ return { depth, limit, maxOutputBytes, offset: listingOffset(input) };
383
+ }
384
+ /** Parse only cursors the pager can issue; refuse unsafe numeric offsets before traversal. */
385
+ function listingOffset({ cursor = "0" }, _options = {}) {
386
+ if (typeof cursor !== "string" || !/^(0|[1-9]\d*)$/.test(cursor) || !Number.isSafeInteger(Number(cursor)))
387
+ throw new FsFilePathError({ message: "cursor must be a non-negative integer string returned as nextCursor; keep path/depth fixed" });
388
+ return Number(cursor);
389
+ }
390
+ /** Bound JSON bytes as well as entry count: long or escaped filenames must not flood model input.
391
+ * Rescanning keeps cursors stateless; the existing traversal budget bounds work on every page.
392
+ * @throws FsFilePathError if one path cannot fit; narrowing path shortens relative entries.
393
+ */
394
+ function paginateListing(listing, { limit, maxOutputBytes, offset }) {
395
+ const files = [];
396
+ // Reserve space for truncation guidance, cursor and the tool's root/path envelope.
397
+ let bytes = 512;
398
+ for (const file of listing.files.slice(offset, offset + limit)) {
399
+ const size = Buffer.byteLength(JSON.stringify(file)) + 1;
400
+ if (bytes + size > maxOutputBytes)
401
+ break;
402
+ files.push(file);
403
+ bytes += size;
404
+ }
405
+ const end = offset + files.length, remaining = Math.max(0, listing.files.length - end);
406
+ if (remaining > 0 && files.length === 0)
407
+ throw new FsFilePathError({ message: "A listing entry exceeds the output budget; pass a narrower path to shorten the relative filename" });
408
+ return listingPageResult({ files, end, remaining, walkTruncated: listing.truncated });
409
+ }
410
+ /** Never claim an exact count when the traversal budget left directories unscanned. */
411
+ function listingPageResult({ files, end, remaining, walkTruncated }, _options = {}) {
412
+ if (remaining === 0 && !walkTruncated)
413
+ return { files, truncated: false };
414
+ const count = walkTruncated ? "unknown" : String(remaining);
415
+ return { files, truncated: true,
416
+ ...(!walkTruncated ? { remaining } : {}),
417
+ ...(remaining > 0 ? { nextCursor: String(end) } : {}),
418
+ message: `truncated: ${count} more entries; pass path/depth/cursor to see more. Keep path/depth fixed when paging; restart if files change.${walkTruncated ? " Traversal limit reached; narrow path to inspect unscanned directories." : ""}` };
419
+ }
356
420
  /** Validate opt-in capability support without weakening legacy filesystem adapters. */
357
421
  function descriptorOpenFlags({ filesystem, policy }) {
358
422
  if (!filesystem.fstatSync || !filesystem.constants)
@@ -9,6 +9,7 @@ export * from "./directory-home.js";
9
9
  export * from "./directory-registry.js";
10
10
  export * from "./directory-running-lock.js";
11
11
  export * from "./directory-workspace.js";
12
+ export { DirectoryUserConfigRefusal, validateDirectoryUserConfig } from "./directory-user-config.js";
12
13
  export * from "./workspace-directory.js";
13
14
  export * from "./verified-directory-move.js";
14
15
  //# sourceMappingURL=index.d.ts.map
package/dist/fs/index.js CHANGED
@@ -10,6 +10,7 @@ export * from "./directory-home.js";
10
10
  export * from "./directory-registry.js";
11
11
  export * from "./directory-running-lock.js";
12
12
  export * from "./directory-workspace.js";
13
+ export { DirectoryUserConfigRefusal, validateDirectoryUserConfig } from "./directory-user-config.js";
13
14
  export * from "./workspace-directory.js";
14
15
  export * from "./verified-directory-move.js";
15
16
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,8 @@
1
1
  import type { DirectoryRecord } from "./directory-registry.js";
2
2
  /** Shared selection policy for CLI/dev hosts. A registry ID disambiguates adopted paths;
3
3
  * a basename selects the home first. No initialized home site preserves the supplied fallback.
4
+ * Hosts may probe availability to resolve an ambiguous name to its sole available directory.
5
+ * Offline records and explicit ID selection are preserved; probe failures propagate.
4
6
  * @complexity O(n) registered/child directories and validation probes.
5
7
  */
6
8
  export declare function selectWorkspaceDirectory({ home, entries, fallback, selected, roots, listDirectories, isInitialized }: {
@@ -15,5 +17,9 @@ export declare function selectWorkspaceDirectory({ home, entries, fallback, sele
15
17
  isInitialized: (required: {
16
18
  path: string;
17
19
  }) => boolean;
18
- }, _options?: {}): string;
20
+ }, { isAvailable }?: {
21
+ isAvailable?: (required: {
22
+ path: string;
23
+ }) => boolean;
24
+ }): string;
19
25
  //# sourceMappingURL=workspace-directory.d.ts.map
@@ -1,9 +1,11 @@
1
1
  import path from "node:path";
2
2
  /** Shared selection policy for CLI/dev hosts. A registry ID disambiguates adopted paths;
3
3
  * a basename selects the home first. No initialized home site preserves the supplied fallback.
4
+ * Hosts may probe availability to resolve an ambiguous name to its sole available directory.
5
+ * Offline records and explicit ID selection are preserved; probe failures propagate.
4
6
  * @complexity O(n) registered/child directories and validation probes.
5
7
  */
6
- export function selectWorkspaceDirectory({ home, entries, fallback, selected, roots = [home], listDirectories, isInitialized }, _options = {}) {
8
+ export function selectWorkspaceDirectory({ home, entries, fallback, selected, roots = [home], listDirectories, isInitialized }, { isAvailable } = {}) {
7
9
  if (selected !== undefined) {
8
10
  const byId = entries.find((entry) => entry.id === selected);
9
11
  if (byId)
@@ -13,8 +15,13 @@ export function selectWorkspaceDirectory({ home, entries, fallback, selected, ro
13
15
  return inHome;
14
16
  const matches = [...new Set([...entries.filter((entry) => path.basename(entry.path) === selected).map((entry) => entry.path),
15
17
  ...roots.filter((root) => root !== home).map((root) => path.join(root, selected)).filter((dir) => isInitialized({ path: dir }))])];
16
- if (matches.length > 1)
18
+ if (matches.length > 1) {
19
+ // A stale scratch catalog row must not block the sole live site or choose by row order.
20
+ const available = matches.filter(dir => isAvailable?.({ path: dir }) ?? true);
21
+ if (available.length === 1)
22
+ return available[0];
17
23
  throw new Error("More than one registered directory has this name. Select its registry ID.");
24
+ }
18
25
  return matches[0] ?? path.join(path.dirname(fallback), selected);
19
26
  }
20
27
  const candidates = [...entries.filter((entry) => path.dirname(entry.path) === home).sort((a, b) => (b.lastOpened ? Date.parse(b.lastOpened) : 0) - (a.lastOpened ? Date.parse(a.lastOpened) : 0)).map((entry) => entry.path), ...roots.flatMap((root) => listDirectories({ root }))];
package/dist/messages.js CHANGED
@@ -5,7 +5,7 @@ export const defaultPlatformMessages = {
5
5
  atomicSymlinkRefused: () => "atomic write refused a symbolic-link destination",
6
6
  atomicPermissionsRefused: () => "atomic write refused non-owner-only permissions",
7
7
  atomicJsonUnserializable: () => "atomic JSON data must be serializable",
8
- fileLockIntervalsInvalid: () => "file lock intervals must be positive safe integers (timeout may be zero; stale may be Infinity)",
8
+ fileLockIntervalsInvalid: () => "file lock intervals must be positive safe integers (timeout may be zero or Infinity; stale may be Infinity)",
9
9
  fileLockWriteNoProgress: () => "file lock ownership write made no progress",
10
10
  egressRefused: () => "egress to the requested host was refused by the outbound network policy",
11
11
  rootKeySealedWarning: ({ subject }) => `IMPORTANT: anything sealed while ${subject} was in place was sealed under key material derived from these same bytes, `,
@@ -1,2 +1,3 @@
1
1
  export { isPrivateAddress, expandIpv6, isLoopbackApiHost, isBlockedExternalApiHostname, isLoopbackHostname } from "./address.js";
2
+ export { probeLoopbackHttp } from './local-http-probe.js';
2
3
  //# sourceMappingURL=index.d.ts.map
package/dist/net/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { isPrivateAddress, expandIpv6, isLoopbackApiHost, isBlockedExternalApiHostname, isLoopbackHostname } from "./address.js";
2
+ export { probeLoopbackHttp } from './local-http-probe.js';
2
3
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Probe a loopback service once, accepting only HTTP 200 without following redirects.
3
+ * @param required Absolute HTTP(S) URL. Non-loopback targets are refused before connecting.
4
+ * @param options Optional expected leaf PEM, TLS server name and request deadline (default 1500ms).
5
+ * @returns False for transport, TLS, timeout or non-200 failures; true for an authenticated 200.
6
+ * @throws For malformed URLs, certificate PEM or invalid timeout values (configuration errors).
7
+ * @complexity O(c) time/space for c certificate bytes; one bounded network request.
8
+ */
9
+ export declare function probeLoopbackHttp({ url }: {
10
+ url: string;
11
+ }, { certificate, servername, timeoutMs }?: {
12
+ certificate?: string | Buffer | undefined;
13
+ servername?: string | undefined;
14
+ timeoutMs?: number;
15
+ }): Promise<boolean>;
16
+ //# sourceMappingURL=local-http-probe.d.ts.map
@@ -0,0 +1,36 @@
1
+ import { X509Certificate } from 'node:crypto';
2
+ import { get as httpGet } from 'node:http';
3
+ import { get as httpsGet } from 'node:https';
4
+ import { isLoopbackHostname } from './address.js';
5
+ /**
6
+ * Probe a loopback service once, accepting only HTTP 200 without following redirects.
7
+ * @param required Absolute HTTP(S) URL. Non-loopback targets are refused before connecting.
8
+ * @param options Optional expected leaf PEM, TLS server name and request deadline (default 1500ms).
9
+ * @returns False for transport, TLS, timeout or non-200 failures; true for an authenticated 200.
10
+ * @throws For malformed URLs, certificate PEM or invalid timeout values (configuration errors).
11
+ * @complexity O(c) time/space for c certificate bytes; one bounded network request.
12
+ */
13
+ export function probeLoopbackHttp({ url }, { certificate, servername, timeoutMs = 1500 } = {}) {
14
+ const target = new URL(url);
15
+ if (!isLoopbackHostname({ hostname: target.hostname }))
16
+ return Promise.resolve(false);
17
+ if (target.protocol !== 'http:' && target.protocol !== 'https:')
18
+ return Promise.resolve(false);
19
+ const hostname = target.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
20
+ // Only the configured leaf is a trust anchor, never an issuer bundled after it.
21
+ // Partial-chain trust permits mkcert leaves without installing their CA into this Node process.
22
+ const trust = certificate === undefined ? {} : { ca: new X509Certificate(certificate).toString(), allowPartialTrustChain: true };
23
+ const options = {
24
+ agent: false, signal: AbortSignal.timeout(timeoutMs), rejectUnauthorized: true,
25
+ // Do not let a hosts-file/DNS override turn the localhost exception into remote trust.
26
+ hostname: hostname.replace(/^localhost$/, '127.0.0.1'),
27
+ servername: servername ?? (hostname === 'localhost' ? 'localhost' : undefined),
28
+ ...trust,
29
+ };
30
+ return new Promise(resolve => {
31
+ const receive = (response) => { response.resume(); resolve(response.statusCode === 200); };
32
+ const get = target.protocol === 'https:' ? httpsGet : httpGet;
33
+ get(target, options, receive).once('error', () => resolve(false));
34
+ });
35
+ }
36
+ //# sourceMappingURL=local-http-probe.js.map
@@ -4,5 +4,6 @@ export { buildVaultAad } from "./vault.aad.js";
4
4
  export { createVaultMetaParsers, isCopyableKind } from "./vault.meta.js";
5
5
  export { SecretsVaultService, type VaultServiceDeps, type VaultPutInput, type VaultCopyInput } from "./vault.service.js";
6
6
  export { InMemoryVaultRepo } from "./vault.repo.memory.js";
7
+ export { captureVaultArchive, prepareVaultArchiveRestore, type VaultArchiveEntry } from "./vault-archive.js";
7
8
  export { matchingCredentialProviderIds, selectCredentialForProvider, suggestCredentialWorksFor, normalizeCredentialWorksFor, type CredentialProviderDescriptor, type MatchableCredential } from "./provider-matches.js";
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,6 @@ export { buildVaultAad } from "./vault.aad.js";
3
3
  export { createVaultMetaParsers, isCopyableKind } from "./vault.meta.js";
4
4
  export { SecretsVaultService } from "./vault.service.js";
5
5
  export { InMemoryVaultRepo } from "./vault.repo.memory.js";
6
+ export { captureVaultArchive, prepareVaultArchiveRestore } from "./vault-archive.js";
6
7
  export { matchingCredentialProviderIds, selectCredentialForProvider, suggestCredentialWorksFor, normalizeCredentialWorksFor } from "./provider-matches.js";
7
8
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,41 @@
1
+ import type { SecretsVaultService } from "./vault.service.js";
2
+ import { type VaultKind, type VaultMeta, type VaultOptions, type VaultPutRecord, type VaultQuery, type VaultRepoPort } from "./vault.types.js";
3
+ /** Logical transport only: source workspace, root keys, envelopes and revision history never travel. */
4
+ export interface VaultArchiveEntry {
5
+ id: string;
6
+ kind: VaultKind;
7
+ ownerKey: string;
8
+ binding: string | null;
9
+ provider: string | null;
10
+ label: string;
11
+ isDefault: boolean;
12
+ meta: VaultMeta;
13
+ payload: VaultMeta | null;
14
+ }
15
+ /** Capture through the owning service's authorization and parsers. Hosts supply exact scopes and
16
+ * attachment-filtered repositories; this helper never discovers other workspaces or principals.
17
+ * Diagnostics contain only a count, because even labels/IDs may contain pasted secrets.
18
+ * @example const captured = await captureVaultArchive({ vault, repo, queries: [siteScope] });
19
+ * @complexity O(rows + payload bytes), bounded to 2,000 rows by the transport contract.
20
+ */
21
+ export declare function captureVaultArchive({ vault, queries, repo }: {
22
+ vault: SecretsVaultService;
23
+ queries: readonly VaultQuery[];
24
+ repo?: Pick<VaultRepoPort, "list">;
25
+ }, _options?: VaultOptions): Promise<{
26
+ entries: VaultArchiveEntry[];
27
+ notices: string[];
28
+ }>;
29
+ /** Validate and re-seal every entry before the host atomically commits any of them. Keeping the
30
+ * transaction in the host lets settings and vault rows join the same commit. Never call put in a
31
+ * loop: a bad later entry must leave the recipient completely unchanged.
32
+ * @throws VaultError or the host's policy error; callers expose a fixed optional-import notice.
33
+ * @example const records = await prepareVaultArchiveRestore({ vault, workspaceId, entries });
34
+ * @complexity O(entries + payload bytes), at most 2,000 entries.
35
+ */
36
+ export declare function prepareVaultArchiveRestore({ vault, workspaceId, entries }: {
37
+ vault: SecretsVaultService;
38
+ workspaceId: string;
39
+ entries: readonly VaultArchiveEntry[];
40
+ }, _options?: VaultOptions): Promise<VaultPutRecord[]>;
41
+ //# sourceMappingURL=vault-archive.d.ts.map
@@ -0,0 +1,64 @@
1
+ import { VaultError, isInVaultQueryScope } from "./vault.types.js";
2
+ function assertPortable(kind, principalId) {
3
+ // Personal and MCP credentials require their own identity/configuration transport, outside v1.
4
+ if (principalId !== null || ["personal-execution", "mcp-env", "mcp-auth"].includes(kind))
5
+ throw new VaultError({ code: "context" });
6
+ }
7
+ /** Capture through the owning service's authorization and parsers. Hosts supply exact scopes and
8
+ * attachment-filtered repositories; this helper never discovers other workspaces or principals.
9
+ * Diagnostics contain only a count, because even labels/IDs may contain pasted secrets.
10
+ * @example const captured = await captureVaultArchive({ vault, repo, queries: [siteScope] });
11
+ * @complexity O(rows + payload bytes), bounded to 2,000 rows by the transport contract.
12
+ */
13
+ export async function captureVaultArchive({ vault, queries, repo }, _options = {}) {
14
+ const entries = [];
15
+ let omitted = 0, count = 0;
16
+ for (const query of queries) {
17
+ assertPortable(query.kind, query.principalId);
18
+ // Optional raw enumeration isolates unsupported metadata; every payload still passes get's
19
+ // exact identity, authorization, parser and AAD checks before it can enter the result.
20
+ for (const row of await (repo ?? vault).list(query)) {
21
+ if (++count > 2000)
22
+ throw new VaultError({ code: "invalid-record" });
23
+ try {
24
+ if (!isInVaultQueryScope({ record: row, query }))
25
+ throw new VaultError({ code: "context" });
26
+ const found = await vault.get(row);
27
+ if (!found)
28
+ throw new VaultError({ code: "conflict" });
29
+ const s = found.summary;
30
+ entries.push({ id: s.id, kind: s.kind, ownerKey: s.ownerKey, binding: s.binding, provider: s.provider,
31
+ label: s.label, isDefault: s.isDefault, meta: s.meta, payload: found.payload });
32
+ }
33
+ catch {
34
+ omitted++;
35
+ }
36
+ }
37
+ }
38
+ return { entries, notices: omitted ? [`${omitted} saved credential(s) could not be opened and were left out.`] : [] };
39
+ }
40
+ /** Validate and re-seal every entry before the host atomically commits any of them. Keeping the
41
+ * transaction in the host lets settings and vault rows join the same commit. Never call put in a
42
+ * loop: a bad later entry must leave the recipient completely unchanged.
43
+ * @throws VaultError or the host's policy error; callers expose a fixed optional-import notice.
44
+ * @example const records = await prepareVaultArchiveRestore({ vault, workspaceId, entries });
45
+ * @complexity O(entries + payload bytes), at most 2,000 entries.
46
+ */
47
+ export async function prepareVaultArchiveRestore({ vault, workspaceId, entries }, _options = {}) {
48
+ if (!Array.isArray(entries) || entries.length > 2000)
49
+ throw new VaultError({ code: "invalid-record" });
50
+ const ids = new Set();
51
+ const records = [];
52
+ for (const entry of entries) {
53
+ assertPortable(entry.kind, null);
54
+ if (ids.has(entry.id))
55
+ throw new VaultError({ code: "invalid-record" });
56
+ ids.add(entry.id);
57
+ // Enumerating fields prevents an archive from supplying a revision, principal or workspace.
58
+ records.push(await vault.prepare({ workspaceId, id: entry.id, kind: entry.kind, ownerKey: entry.ownerKey,
59
+ principalId: null, binding: entry.binding, provider: entry.provider, label: entry.label,
60
+ isDefault: entry.isDefault, meta: entry.meta, payload: entry.payload, expectedRevision: null }));
61
+ }
62
+ return records;
63
+ }
64
+ //# sourceMappingURL=vault-archive.js.map
@@ -155,7 +155,7 @@ export interface VaultRepoPort {
155
155
  /** The record belongs to the query's exact workspace, kind, owner and principal; an omitted provider
156
156
  * matches every provider. @complexity O(1). */
157
157
  export declare function isInVaultQueryScope({ record, query }: {
158
- record: VaultRecord;
158
+ record: Pick<VaultRecord, keyof VaultQuery>;
159
159
  query: VaultQuery;
160
160
  }, _options?: VaultOptions): boolean;
161
161
  /** Unambiguous scope encoding keeps adapter default/identity checks on the same contract. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/platform",
3
- "version": "0.6.15",
3
+ "version": "0.6.16",
4
4
  "description": "Generic OS/platform primitives: process exec and lifecycle, filesystem containment, HTTP readiness polling, toolchain discovery, blob storage, AWS signing, and an SSRF-safe asset cache.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -55,6 +55,13 @@
55
55
  "src/fetch-with-timeout.ts",
56
56
  "src/http/guarded/errors.ts"
57
57
  ]
58
+ },
59
+ "optionalPeers": {
60
+ "hast-util-from-parse5": "opt-in: html to markdown conversion; import @jini-ai/platform/web — HTML to Markdown conversion could not load hast-util-from-parse5. Install hast-util-from-parse5 to enable this feature, or check its installed version and native binaries.",
61
+ "hast-util-to-mdast": "opt-in: html to markdown conversion; import @jini-ai/platform/web — HTML to Markdown conversion could not load hast-util-to-mdast. Install hast-util-to-mdast to enable this feature, or check its installed version and native binaries.",
62
+ "mdast-util-to-markdown": "opt-in: html to markdown conversion; import @jini-ai/platform/web — HTML to Markdown conversion could not load mdast-util-to-markdown. Install mdast-util-to-markdown to enable this feature, or check its installed version and native binaries.",
63
+ "mdast-util-gfm-table": "opt-in: markdown table conversion; import @jini-ai/platform/web — Markdown table conversion could not load mdast-util-gfm-table. Install mdast-util-gfm-table to enable this feature, or check its installed version and native binaries.",
64
+ "parse5": "opt-in: html parsing; import @jini-ai/platform/web — HTML parsing could not load parse5. Install parse5 to enable this feature, or check its installed version and native binaries."
58
65
  }
59
66
  },
60
67
  "type": "module",
@@ -206,7 +213,8 @@
206
213
  "!dist/**/*.map"
207
214
  ],
208
215
  "dependencies": {
209
- "@jini-ai/core": "^0.6.13"
216
+ "@jini-ai/core": "^0.6.13",
217
+ "undici": "^7.25.0"
210
218
  },
211
219
  "publishConfig": {
212
220
  "access": "public",
@@ -223,7 +231,6 @@
223
231
  "fast-check": "4.3.0"
224
232
  },
225
233
  "peerDependencies": {
226
- "undici": "^7.25.0",
227
234
  "hast-util-from-parse5": "8.0.3",
228
235
  "hast-util-to-mdast": "10.1.2",
229
236
  "mdast-util-to-markdown": "2.1.2",
@@ -231,9 +238,6 @@
231
238
  "parse5": "7.3.0"
232
239
  },
233
240
  "peerDependenciesMeta": {
234
- "undici": {
235
- "optional": true
236
- },
237
241
  "hast-util-from-parse5": {
238
242
  "optional": true
239
243
  },