@jini-ai/platform 0.6.16 → 0.6.19

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # @jini-ai/platform
2
2
 
3
+ ## 0.6.19
4
+
5
+ - File locks: a dead holder recorded on this machine under another hostname spelling (macOS `name.lan` / `name.local` / `name`) is reclaimed when it was taken in this same kernel boot (its recorded boot id, or for a record without one, an `acquiredAt` after this boot began), so a never-ageing (`staleMs: Infinity`) lock a killed process left no longer wedges every later acquisition. Other hosts, unrecorded boots and live owners are still never evicted. `@jini-ai/platform/fs/file-lock` re-exports `readBootIdentity` and `BootIdentity` for host process ports.
6
+
7
+ ## 0.6.17
8
+
9
+ ### Patch Changes
10
+
11
+ - Version bump only.
12
+
3
13
  ## 0.6.15
4
14
 
5
15
  ### Minor Changes
@@ -19,4 +19,14 @@ export declare function fromEarlierBoot({ boot, bootId, atMs }: {
19
19
  bootId: string | undefined;
20
20
  atMs: number;
21
21
  }, _options?: {}): boolean;
22
+ /** True only when a recorded holder provably ran in THIS boot: a Linux boot_id compared exactly, or two
23
+ * time-derived (numeric) ids within {@link BOOT_CLOCK_TOLERANCE_MS}, since Darwin/Windows re-derive them
24
+ * from a wall clock the OS may step. A legacy record with no boot id counts only when its own
25
+ * timestamp `atMs` falls after this boot began (same tolerance). No boot is never proof. @complexity O(1).
26
+ */
27
+ export declare function fromThisBoot({ boot, bootId, atMs }: {
28
+ boot: BootIdentity | undefined;
29
+ bootId: string | undefined;
30
+ atMs: number;
31
+ }, _options?: {}): boolean;
22
32
  //# sourceMappingURL=boot-identity.d.ts.map
@@ -58,4 +58,18 @@ export function fromEarlierBoot({ boot, bootId, atMs }, _options = {}) {
58
58
  const recordedAtMs = recordedBootTimeMs({ bootId, atMs });
59
59
  return recordedAtMs === undefined ? bootId !== boot.id : recordedAtMs < boot.startedAtMs - BOOT_CLOCK_TOLERANCE_MS;
60
60
  }
61
+ /** True only when a recorded holder provably ran in THIS boot: a Linux boot_id compared exactly, or two
62
+ * time-derived (numeric) ids within {@link BOOT_CLOCK_TOLERANCE_MS}, since Darwin/Windows re-derive them
63
+ * from a wall clock the OS may step. A legacy record with no boot id counts only when its own
64
+ * timestamp `atMs` falls after this boot began (same tolerance). No boot is never proof. @complexity O(1).
65
+ */
66
+ export function fromThisBoot({ boot, bootId, atMs }, _options = {}) {
67
+ if (!boot)
68
+ return false;
69
+ if (bootId === undefined)
70
+ return Number.isFinite(atMs) && atMs >= boot.startedAtMs - BOOT_CLOCK_TOLERANCE_MS;
71
+ if (bootId === boot.id)
72
+ return true;
73
+ return /^\d+$/.test(bootId) && /^\d+$/.test(boot.id) && Math.abs(Number(bootId) - Number(boot.id)) <= BOOT_CLOCK_TOLERANCE_MS;
74
+ }
61
75
  //# sourceMappingURL=boot-identity.js.map
@@ -5,25 +5,37 @@ import { fromEarlierBoot } from "./boot-identity.js";
5
5
  * @complexity O(1) bounded JSON I/O and one liveness probe, plus the file lock wait.
6
6
  */
7
7
  export function createDirectoryRunningLock({ file, isAlive, remove }, { bootIdentity } = {}) {
8
- /** Validate before any mutation; corrupt or unreadable records fail closed. O(1) JSON I/O. */
9
- function readRecord() {
8
+ /** Validate the holder before probing liveness; unreadable/unknown identities fail closed.
9
+ * A malformed address is recoverable only after proving this holder cannot own the DB. O(1).
10
+ */
11
+ function readOwner() {
10
12
  const result = file.read({});
11
13
  if (result.state === "missing")
12
14
  return null;
13
15
  if (result.state !== "ok")
14
16
  throw new Error("The running lock is unreadable; refusing to open this directory's database.");
15
17
  const record = result.value;
16
- if (!validRunningRecord(record))
18
+ if (!validRunningOwner(record))
19
+ throw new Error("The running lock is invalid; refusing to open this directory's database.");
20
+ return record;
21
+ }
22
+ /** Release still requires a complete valid record and its token, even for a dead holder. O(1). */
23
+ function readRecord() {
24
+ const record = readOwner();
25
+ if (record && !validRunningRecord(record))
17
26
  throw new Error("The running lock is invalid; refusing to open this directory's database.");
18
27
  return record;
19
28
  }
20
29
  function readLive(cleanup = true) {
21
- const record = readRecord();
30
+ const record = readOwner();
22
31
  if (!record)
23
32
  return null;
24
33
  const earlierBoot = fromEarlierBoot({ boot: bootIdentity?.(), bootId: record.bootId, atMs: Date.parse(record.started) });
25
- if (!earlierBoot && isAlive({ pid: record.pid }))
34
+ if (!earlierBoot && isAlive({ pid: record.pid })) {
35
+ if (!validRunningRecord(record))
36
+ throw new Error("The running lock is invalid; refusing to open this directory's database.");
26
37
  return record;
38
+ }
27
39
  if (cleanup)
28
40
  remove();
29
41
  return null;
@@ -64,12 +76,18 @@ export function createDirectoryRunningLock({ file, isAlive, remove }, { bootIden
64
76
  };
65
77
  }
66
78
  function validRunningRecord(value) {
79
+ if (!validRunningOwner(value))
80
+ return false;
81
+ const validPort = Number.isInteger(value.port) && typeof value.port === "number" && value.port >= 0 && value.port <= 65535;
82
+ return validPort && (value.scheme === "http" || value.scheme === "https");
83
+ }
84
+ /** Address corruption must not prevent dead-holder recovery, but never guess a holder. O(1). */
85
+ function validRunningOwner(value) {
67
86
  if (!value || typeof value !== "object")
68
87
  return false;
69
88
  const record = value;
70
89
  const validPid = Number.isSafeInteger(record.pid) && record.pid > 0;
71
- const validPort = Number.isInteger(record.port) && record.port >= 0 && record.port <= 65535;
72
- return validPid && validPort && validRunningIdentity({ record }) && ["http", "https"].includes(record.scheme);
90
+ return validPid && validRunningIdentity({ record });
73
91
  }
74
92
  function validRunningIdentity({ record }, _options = {}) {
75
93
  const validBootId = record.bootId === undefined || typeof record.bootId === "string";
@@ -8,13 +8,16 @@
8
8
  * Stale removal rechecks inode, bytes and mtime immediately before unlink. There is still a residual
9
9
  * check-to-unlink race on filesystems without conditional unlink; rename/restore chains create wider
10
10
  * multi-process races and are deliberately avoided. Atomic file replacement independently prevents
11
- * torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
11
+ * torn content. Proven live owners are never age-evicted; staleMs: Infinity also disables age
12
+ * recovery for owners whose liveness is unknown.
12
13
  */
13
14
  import { type BootIdentity } from "./boot-identity.js";
14
15
  import fs from "node:fs";
15
16
  import * as asyncFs from "node:fs/promises";
16
17
  import { type Clock } from "@jini-ai/core/primitives";
17
18
  import { type PlatformMessages } from "../messages.js";
19
+ /** Re-exported so a host's own process port can record the boot its locks were taken in. */
20
+ export { readBootIdentity, type BootIdentity } from "./boot-identity.js";
18
21
  export declare const DEFAULT_LOCK_TIMEOUT_MS = 15000;
19
22
  export declare const DEFAULT_LOCK_STALE_AFTER_MS = 10000;
20
23
  export interface FileLockHolder {
@@ -37,7 +40,7 @@ export type FileLockAsyncFilesystem = Pick<typeof asyncFs, "mkdir" | "open" | "s
37
40
  export interface FileLockOptions {
38
41
  /** Infinity follows an operation with its own deadline; dead local owners remain reclaimable. */
39
42
  timeoutMs?: number;
40
- /** Infinity disables age stealing; dead local owners remain reclaimable. */
43
+ /** Age threshold for unknown owners. Proven live owners are never evicted; Infinity disables age recovery. */
41
44
  staleMs?: number;
42
45
  pollMs?: number;
43
46
  clock?: Clock;
@@ -98,7 +101,10 @@ export declare function isContendedLockError({ code, platform }: {
98
101
  platform: NodeJS.Platform;
99
102
  }): boolean;
100
103
  /** One pure stale decision serves both I/O adapters. Invalid/foreign pids have unknown liveness,
101
- * never false liveness; otherwise kill(0/negative, 0) would address a process group. */
104
+ * never false liveness; otherwise kill(0/negative, 0) would address a process group.
105
+ * @returns Whether a dead or aged unknown owner may be reclaimed on the unchanged identity.
106
+ * @complexity O(1) time and space; no I/O.
107
+ */
102
108
  export declare function isLockStale(required: {
103
109
  nowMs: number;
104
110
  mtimeMs: number;
@@ -108,7 +114,7 @@ export declare function isLockStale(required: {
108
114
  currentInode: number;
109
115
  observedDevice: number;
110
116
  currentDevice: number;
111
- }): boolean;
117
+ }, _options?: Record<string, never>): boolean;
112
118
  /** Synchronous read-modify-write guard. Durable stores pass their former 5s/30s/10ms values.
113
119
  * Release never evicts a later owner, even when the old owner reaches finally after a steal. */
114
120
  export declare function withFileLockSync<T>({ lockPath, run }: {
@@ -8,9 +8,10 @@
8
8
  * Stale removal rechecks inode, bytes and mtime immediately before unlink. There is still a residual
9
9
  * check-to-unlink race on filesystems without conditional unlink; rename/restore chains create wider
10
10
  * multi-process races and are deliberately avoided. Atomic file replacement independently prevents
11
- * torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
11
+ * torn content. Proven live owners are never age-evicted; staleMs: Infinity also disables age
12
+ * recovery for owners whose liveness is unknown.
12
13
  */
13
- import { fromEarlierBoot, readBootIdentity } from "./boot-identity.js";
14
+ import { fromEarlierBoot, fromThisBoot, readBootIdentity } from "./boot-identity.js";
14
15
  import fs from "node:fs";
15
16
  import * as asyncFs from "node:fs/promises";
16
17
  import { randomUUID } from "node:crypto";
@@ -19,6 +20,8 @@ import path from "node:path";
19
20
  import { createSystemClock } from "@jini-ai/core/primitives";
20
21
  import { defaultPlatformMessages } from "../messages.js";
21
22
  import { errorCodeOf, fallbackIf } from "./errno.js";
23
+ /** Re-exported so a host's own process port can record the boot its locks were taken in. */
24
+ export { readBootIdentity } from "./boot-identity.js";
22
25
  export const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
23
26
  export const DEFAULT_LOCK_STALE_AFTER_MS = 10_000;
24
27
  export class FileLockTimeoutError extends Error {
@@ -73,8 +76,14 @@ function parseHolder(raw) {
73
76
  }
74
77
  }
75
78
  /** One pure stale decision serves both I/O adapters. Invalid/foreign pids have unknown liveness,
76
- * never false liveness; otherwise kill(0/negative, 0) would address a process group. */
77
- export function isLockStale(required) {
79
+ * never false liveness; otherwise kill(0/negative, 0) would address a process group.
80
+ * @returns Whether a dead or aged unknown owner may be reclaimed on the unchanged identity.
81
+ * @complexity O(1) time and space; no I/O.
82
+ */
83
+ export function isLockStale(required, _options = {}) {
84
+ // A slow live operation still owns its critical section; elapsed age cannot prove death.
85
+ if (required.ownerAlive === true)
86
+ return false;
78
87
  return (required.ownerAlive === false || required.nowMs - required.mtimeMs > required.staleMs) && required.observedInode === required.currentInode && required.observedDevice === required.currentDevice;
79
88
  }
80
89
  function intervalsValid({ timeoutMs, staleMs, pollMs }) {
@@ -110,8 +119,21 @@ function ownership(s, options) {
110
119
  return `${s.processPort.pid}:${s.token}${s.boot ? ":" + s.boot.id : ""}`;
111
120
  return JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString(), ...(s.boot ? { bootId: s.boot.id } : {}) });
112
121
  }
122
+ /** The first DNS label, case-folded: macOS reports one machine as `name.lan`, `name.local` or `name`
123
+ * as its network changes. O(n) in the hostname length. */
124
+ function shortHostname(hostname) { return hostname.split(".", 1)[0].toLowerCase(); }
125
+ /** Whether `holder` was written on this machine, so a local PID probe can speak for it. The exact
126
+ * hostname, or the same first label in this same kernel boot: a lock a killed process left under one
127
+ * hostname spelling would otherwise read as foreign under the next and, with `staleMs: Infinity`,
128
+ * wedge every later acquisition (demo practice run, 2026-11-02: an Agent Plugin stayed off on every
129
+ * boot). Another machine with the same first label would also need this exact boot to be mistaken. */
130
+ function sameMachine(holder, s) {
131
+ if (holder.hostname === s.hostname)
132
+ return true;
133
+ return shortHostname(holder.hostname) === shortHostname(s.hostname) && fromThisBoot({ boot: s.boot, bootId: holder.bootId, atMs: Date.parse(holder.acquiredAt) });
134
+ }
113
135
  function holderAlive(holder, s) {
114
- if (holder.hostname !== s.hostname)
136
+ if (!sameMachine(holder, s))
115
137
  return undefined;
116
138
  // A recorded boot id decides; without one, only an acquisition before this boot proves a restart.
117
139
  if (fromEarlierBoot({ boot: s.boot, bootId: holder.bootId, atMs: Date.parse(holder.acquiredAt) }))
@@ -130,8 +152,28 @@ function legacyOwnerAlive(snapshot, s) {
130
152
  return false;
131
153
  return s.processPort.isAlive({ pid });
132
154
  }
155
+ /** Incomplete JSON may still prove a local owner's death. Display/release metadata is not
156
+ * required for a PID probe; absent/invalid PIDs and foreign hosts remain unknown, never dead.
157
+ * This prevents an incomplete dead holder from wedging Infinity-staleness stores forever.
158
+ * @complexity O(n) parsing for n lock bytes, one local process probe.
159
+ */
160
+ function incompleteOwnerAlive({ raw, s }, _options = {}) {
161
+ let value;
162
+ try {
163
+ value = JSON.parse(raw);
164
+ }
165
+ catch {
166
+ return undefined;
167
+ }
168
+ if (typeof value !== "object" || value === null)
169
+ return undefined;
170
+ const row = value;
171
+ if (!validPid(row.pid) || row.hostname !== s.hostname)
172
+ return undefined;
173
+ return s.processPort.isAlive({ pid: row.pid });
174
+ }
133
175
  function ownerAlive(snapshot, s) {
134
- return snapshot.holder ? holderAlive(snapshot.holder, s) : legacyOwnerAlive(snapshot, s);
176
+ return snapshot.holder ? holderAlive(snapshot.holder, s) : legacyOwnerAlive(snapshot, s) ?? incompleteOwnerAlive({ raw: snapshot.raw, s });
135
177
  }
136
178
  function sameFile(a, b) { return a.ino === b.ino && a.dev === b.dev; }
137
179
  function sameStat(a, b) { return sameFile(a, b) && a.mtimeMs === b.mtimeMs; }
@@ -10,6 +10,16 @@ export declare function directoryFingerprint({ dir }: {
10
10
  excluded?: readonly string[];
11
11
  filesystem?: typeof fs;
12
12
  }): string;
13
+ /** SHA-256 of one file through the same bounded reader used by directory verification.
14
+ * @param required.path - File path already authorized/contained by the host.
15
+ * @param optional.filesystem - Injectable native read/close operations.
16
+ * @returns Raw digest bytes; read/open errors propagate and the descriptor always closes.
17
+ * @complexity O(file bytes) time, O(64 KiB) space. */
18
+ export declare function fileDigest({ path: absolute }: {
19
+ path: string;
20
+ }, { filesystem }?: {
21
+ filesystem?: Pick<typeof fs, 'openSync' | 'readSync' | 'closeSync'>;
22
+ }): Buffer;
13
23
  interface MoveDirectoryOptions {
14
24
  excluded?: readonly string[];
15
25
  filesystem?: typeof fs;
@@ -24,7 +24,7 @@ export function directoryFingerprint({ dir }, { excluded = [], filesystem = fs }
24
24
  else if (kind === "link")
25
25
  digest.update(JSON.stringify(filesystem.readlinkSync(absolute)));
26
26
  else
27
- digest.update(fileDigest({ absolute, filesystem }));
27
+ digest.update(fileDigest({ path: absolute }, { filesystem }));
28
28
  }
29
29
  }
30
30
  visit("");
@@ -37,7 +37,12 @@ function entryKind(stat) {
37
37
  return "directory";
38
38
  return stat.isFile() ? "file" : "unsupported";
39
39
  }
40
- function fileDigest({ absolute, filesystem }) {
40
+ /** SHA-256 of one file through the same bounded reader used by directory verification.
41
+ * @param required.path - File path already authorized/contained by the host.
42
+ * @param optional.filesystem - Injectable native read/close operations.
43
+ * @returns Raw digest bytes; read/open errors propagate and the descriptor always closes.
44
+ * @complexity O(file bytes) time, O(64 KiB) space. */
45
+ export function fileDigest({ path: absolute }, { filesystem = fs } = {}) {
41
46
  const digest = createHash("sha256"), buffer = Buffer.allocUnsafe(64 * 1024);
42
47
  const descriptor = filesystem.openSync(absolute, "r");
43
48
  try {
@@ -2,8 +2,10 @@ 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
4
  * Hosts may probe availability to resolve an ambiguous name to its sole available directory.
5
+ * Two live directories with one name never stop a boot: the one inside a configured root wins,
6
+ * then the most recently opened, then path order, and `report` names every path and ID.
5
7
  * Offline records and explicit ID selection are preserved; probe failures propagate.
6
- * @complexity O(n) registered/child directories and validation probes.
8
+ * @complexity O(n log n) registered/child directories and validation probes.
7
9
  */
8
10
  export declare function selectWorkspaceDirectory({ home, entries, fallback, selected, roots, listDirectories, isInitialized }: {
9
11
  home: string;
@@ -17,9 +19,12 @@ export declare function selectWorkspaceDirectory({ home, entries, fallback, sele
17
19
  isInitialized: (required: {
18
20
  path: string;
19
21
  }) => boolean;
20
- }, { isAvailable }?: {
22
+ }, { isAvailable, report }?: {
21
23
  isAvailable?: (required: {
22
24
  path: string;
23
25
  }) => boolean;
26
+ report?: (required: {
27
+ message: string;
28
+ }) => void;
24
29
  }): string;
25
30
  //# sourceMappingURL=workspace-directory.d.ts.map
@@ -2,10 +2,12 @@ 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
4
  * Hosts may probe availability to resolve an ambiguous name to its sole available directory.
5
+ * Two live directories with one name never stop a boot: the one inside a configured root wins,
6
+ * then the most recently opened, then path order, and `report` names every path and ID.
5
7
  * Offline records and explicit ID selection are preserved; probe failures propagate.
6
- * @complexity O(n) registered/child directories and validation probes.
8
+ * @complexity O(n log n) registered/child directories and validation probes.
7
9
  */
8
- export function selectWorkspaceDirectory({ home, entries, fallback, selected, roots = [home], listDirectories, isInitialized }, { isAvailable } = {}) {
10
+ export function selectWorkspaceDirectory({ home, entries, fallback, selected, roots = [home], listDirectories, isInitialized }, { isAvailable, report = ({ message }) => console.warn(message) } = {}) {
9
11
  if (selected !== undefined) {
10
12
  const byId = entries.find((entry) => entry.id === selected);
11
13
  if (byId)
@@ -15,16 +17,28 @@ export function selectWorkspaceDirectory({ home, entries, fallback, selected, ro
15
17
  return inHome;
16
18
  const matches = [...new Set([...entries.filter((entry) => path.basename(entry.path) === selected).map((entry) => entry.path),
17
19
  ...roots.filter((root) => root !== home).map((root) => path.join(root, selected)).filter((dir) => isInitialized({ path: dir }))])];
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];
23
- throw new Error("More than one registered directory has this name. Select its registry ID.");
24
- }
20
+ if (matches.length > 1)
21
+ return pickDuplicate({ matches, entries, roots, isAvailable, report });
25
22
  return matches[0] ?? path.join(path.dirname(fallback), selected);
26
23
  }
27
24
  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 }))];
28
25
  return candidates.find((dir) => isInitialized({ path: dir })) ?? fallback;
29
26
  }
27
+ /** One name, several directories. A stale scratch catalog row must not block the sole live site or
28
+ * choose by row order; a live scratch copy (another tool's sandbox) must not stop the boot either.
29
+ * Throws only when none is available, because then there is nothing to serve.
30
+ * @complexity O(n log n) matches.
31
+ */
32
+ function pickDuplicate({ matches, entries, roots, isAvailable, report }) {
33
+ const label = (dir) => `${dir} (${entries.find((entry) => entry.path === dir)?.id ?? "not registered"})`;
34
+ const available = matches.filter((dir) => isAvailable?.({ path: dir }) ?? true);
35
+ if (available.length === 0)
36
+ throw new Error(`No registered directory with this name is available: ${matches.map(label).join(", ")}. Select its registry ID.`);
37
+ const inRoot = (dir) => (roots.includes(path.dirname(dir)) ? 1 : 0);
38
+ const opened = (dir) => Date.parse(entries.find((entry) => entry.path === dir)?.lastOpened ?? "") || 0;
39
+ const [chosen, ...others] = [...available].sort((a, b) => inRoot(b) - inRoot(a) || opened(b) - opened(a) || a.localeCompare(b));
40
+ if (others.length > 0)
41
+ report({ message: `More than one directory has this name. Using ${label(chosen)}; also found ${others.map(label).join(", ")}. Select a registry ID to choose another.` });
42
+ return chosen;
43
+ }
30
44
  //# sourceMappingURL=workspace-directory.js.map
@@ -1,6 +1,6 @@
1
1
  // Policy refusals use EgressRefusedError; DNS, connection and transport failures retain their
2
2
  // own errors because they are operational failures, not decisions that a different URL fixes.
3
- import { classifyIpAddress } from "../../net/address.js";
3
+ import { classifyIpAddress, isLoopbackHostname } from "../../net/address.js";
4
4
  import { effectivePort } from "../../core/effective-port.js";
5
5
  import { FetchTimeoutError } from "../../fetch-with-timeout.js";
6
6
  import { isIP } from "node:net";
@@ -43,10 +43,18 @@ async function resolveHostAddresses(hostname, dns) {
43
43
  return [literal];
44
44
  return [...await dns.resolve({ hostname })];
45
45
  }
46
- // Keep the resolved IP in internal diagnostics only; the caller-safe refusal must not disclose it.
47
- function assertNoPrivateAddress(hostname, addresses) {
46
+ /** Validate every resolved peer, allowing loopback only for an explicitly configured origin.
47
+ * Keep the resolved IP in internal diagnostics only; the caller-safe refusal must not disclose it.
48
+ * @throws EgressRefusedError before any connection for a denied or mixed DNS answer.
49
+ * @complexity O(a + o) time for a addresses and o configured origins, O(1) space.
50
+ */
51
+ function assertNoPrivateAddress({ url, addresses, policy }, _options = {}) {
52
+ const hostname = url.hostname;
53
+ const allowLoopback = isLoopbackHostname({ hostname }) && (policy.loopbackOriginAllowlist?.includes(url.origin) ?? false);
48
54
  for (const address of addresses) {
49
55
  const addressClass = classifyAddress({ ip: address });
56
+ if (allowLoopback && addressClass === "loopback")
57
+ continue;
50
58
  if (addressClass !== "public") {
51
59
  throw new EgressRefusedError({ message: `egress to '${hostname}' (${address}) rejected: resolved address is ${addressClass}` }, {
52
60
  callerSafeMessage: `egress to '${hostname}' rejected: resolved address is ${addressClass}`,
@@ -64,7 +72,7 @@ async function resolvePinnedPeer(url, policy, dns) {
64
72
  throw new Error(`could not resolve any address for host '${url.hostname}'`);
65
73
  }
66
74
  if (policy.denyPrivateAddresses && !isDevAllowlisted) {
67
- assertNoPrivateAddress(url.hostname, addresses);
75
+ assertNoPrivateAddress({ url, addresses, policy });
68
76
  }
69
77
  return {
70
78
  ip: addresses[0],
@@ -29,6 +29,11 @@ export interface EgressPolicy {
29
29
  readonly denyPrivateAddresses: boolean;
30
30
  /** Explicit development capability; IPv6 literals use their bracket-free hostname (e.g. fd00::1). */
31
31
  readonly devHostAllowlist: readonly string[];
32
+ /** Trusted host configuration only: exact normalized scheme+host+port origins on localhost
33
+ * or loopback IP literals whose loopback peers may be dialed. Public DNS names cannot rebind
34
+ * to loopback through this exception. Other private classes remain denied, including mixed answers.
35
+ * Rechecked on every redirect; this never grants other ports or hostname aliases. */
36
+ readonly loopbackOriginAllowlist?: readonly string[];
32
37
  readonly maxRedirects: number;
33
38
  /** Historical name: ceiling on each socket's idle timeout, not total transfer time. */
34
39
  readonly connectTimeoutMs: number;
@@ -0,0 +1,15 @@
1
+ /** Decodes bounded multipart bytes into successful controls, preserving repeated choice names.
2
+ * @param required - Buffered request bytes and the multipart Content-Type including its boundary.
3
+ * @param options - Positive integer byte/control caps; defaults to 12 MiB and 2000 controls.
4
+ * @returns A null-prototype body; repeated names become arrays, empty file pickers are omitted.
5
+ * @throws RangeError for invalid limits, oversized bodies or too many controls; parser errors propagate.
6
+ * @example await parseFormMultipart({ bytes, contentType }, { maxBytes: 1024, maxControls: 10 });
7
+ * @complexity O(b + c) time and space for bounded body bytes b and controls c; no network I/O. */
8
+ export declare function parseFormMultipart({ bytes, contentType }: {
9
+ bytes: Uint8Array;
10
+ contentType: string;
11
+ }, { maxBytes, maxControls }?: {
12
+ maxBytes?: number;
13
+ maxControls?: number;
14
+ }): Promise<Record<string, unknown>>;
15
+ //# sourceMappingURL=multipart.adapter.d.ts.map
@@ -0,0 +1,54 @@
1
+ /** Bounded multipart controls decoded by the installed Web Request implementation. */
2
+ // Node's bundled parser can turn filename="" into a text control, losing upload identity.
3
+ // The installed Undici parser distinguishes absent filenames from empty ones (filename !== null).
4
+ import { Request } from "undici";
5
+ /** The browser's empty file picker (no filename, no bytes) is not a successful control. */
6
+ const isEmptyFilePicker = (raw) => typeof raw !== "string" && raw.name === "" && raw.size === 0;
7
+ async function toFormValue(raw) {
8
+ return typeof raw === "string" ? raw : { filename: raw.name, contentType: raw.type, bytes: new Uint8Array(await raw.arrayBuffer()) };
9
+ }
10
+ /** Adds one control, turning a repeated name into an array of values in submission order. */
11
+ function appendControl({ body, name, value }) {
12
+ if (!Object.hasOwn(body, name)) {
13
+ body[name] = value;
14
+ return;
15
+ }
16
+ const existing = body[name];
17
+ // This array belongs to this parse only; append without quadratic repeated copies.
18
+ if (Array.isArray(existing))
19
+ existing.push(value);
20
+ else
21
+ body[name] = [existing, value];
22
+ }
23
+ /** Decodes bounded multipart bytes into successful controls, preserving repeated choice names.
24
+ * @param required - Buffered request bytes and the multipart Content-Type including its boundary.
25
+ * @param options - Positive integer byte/control caps; defaults to 12 MiB and 2000 controls.
26
+ * @returns A null-prototype body; repeated names become arrays, empty file pickers are omitted.
27
+ * @throws RangeError for invalid limits, oversized bodies or too many controls; parser errors propagate.
28
+ * @example await parseFormMultipart({ bytes, contentType }, { maxBytes: 1024, maxControls: 10 });
29
+ * @complexity O(b + c) time and space for bounded body bytes b and controls c; no network I/O. */
30
+ export async function parseFormMultipart({ bytes, contentType }, { maxBytes = 12 * 1024 * 1024, maxControls = 2000 } = {}) {
31
+ validateLimits({ maxBytes, maxControls });
32
+ if (bytes.byteLength > maxBytes)
33
+ throw new RangeError(`form upload exceeds ${maxBytes / (1024 * 1024)} MB`);
34
+ const request = new Request("https://form.invalid/", { method: "POST", headers: { "content-type": contentType }, body: Buffer.from(bytes) });
35
+ const data = await request.formData();
36
+ const body = Object.create(null);
37
+ let count = 0;
38
+ for (const [name, raw] of data.entries()) {
39
+ count += 1;
40
+ if (count > maxControls)
41
+ throw new RangeError("too many form controls");
42
+ if (!isEmptyFilePicker(raw))
43
+ appendControl({ body, name, value: await toFormValue(raw) });
44
+ }
45
+ return body;
46
+ }
47
+ /** Reject invalid host configuration before allocating a parsed form. */
48
+ function validateLimits({ maxBytes, maxControls }) {
49
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
50
+ throw new RangeError("maxBytes must be a positive safe integer");
51
+ if (!Number.isSafeInteger(maxControls) || maxControls <= 0)
52
+ throw new RangeError("maxControls must be a positive safe integer");
53
+ }
54
+ //# sourceMappingURL=multipart.adapter.js.map
package/dist/http.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  export type HttpWaitOptions = {
2
2
  timeoutMs?: number;
3
+ /** Optional transport policy (for example, a pinned loopback TLS probe). False retries; throwing
4
+ * records the diagnostic and retries. Each call receives the remaining overall time budget.
5
+ */
6
+ probe?: ((required: {
7
+ url: string;
8
+ timeoutMs: number;
9
+ }) => Promise<boolean>) | undefined;
3
10
  };
4
11
  /**
5
12
  * Poll a URL until it returns an OK (2xx) response or the timeout elapses,
@@ -10,5 +17,5 @@ export type HttpWaitOptions = {
10
17
  * @returns `true` once an OK response is received.
11
18
  * @throws When the timeout elapses without an OK response (message includes the last error).
12
19
  */
13
- export declare function waitForHttpOk(url: string, { timeoutMs }?: HttpWaitOptions): Promise<true>;
20
+ export declare function waitForHttpOk(url: string, { timeoutMs, probe }?: HttpWaitOptions): Promise<true>;
14
21
  //# sourceMappingURL=http.d.ts.map
package/dist/http.js CHANGED
@@ -7,6 +7,17 @@
7
7
  */
8
8
  import { setTimeout as sleep } from "node:timers/promises";
9
9
  import { fetchWithTimeout } from "./fetch-with-timeout.js";
10
+ /** Use the injected transport policy or the existing bounded fetch, retaining HTTP diagnostics.
11
+ * @returns Whether this attempt is ready. @complexity One network request, bounded by timeoutMs.
12
+ */
13
+ async function requestHttpOk({ url, timeoutMs }, { probe } = {}) {
14
+ if (probe)
15
+ return probe({ url, timeoutMs });
16
+ const response = await fetchWithTimeout(url, { cache: "no-store" }, { timeoutMs });
17
+ if (!response.ok)
18
+ throw new Error(`HTTP ${response.status} from ${url}`);
19
+ return true;
20
+ }
10
21
  /** @internal Extract a human-readable message from an unknown thrown value. */
11
22
  function errorMessage(error) {
12
23
  return error instanceof Error ? error.message : String(error);
@@ -20,7 +31,7 @@ function errorMessage(error) {
20
31
  * @returns `true` once an OK response is received.
21
32
  * @throws When the timeout elapses without an OK response (message includes the last error).
22
33
  */
23
- export async function waitForHttpOk(url, { timeoutMs = 20000 } = {}) {
34
+ export async function waitForHttpOk(url, { timeoutMs = 20000, probe } = {}) {
24
35
  const startedAt = Date.now();
25
36
  let lastError = null;
26
37
  while (Date.now() - startedAt < timeoutMs) {
@@ -33,10 +44,9 @@ export async function waitForHttpOk(url, { timeoutMs = 20000 } = {}) {
33
44
  // clock once `await fetch(...)` itself returns, so an unbounded fetch call defeats the loop's
34
45
  // own deadline exactly like Finding 2 of the 2026-08-16 audit describes.
35
46
  const remaining = timeoutMs - (Date.now() - startedAt);
36
- const response = await fetchWithTimeout(url, { cache: "no-store" }, { timeoutMs: Math.max(1, remaining) });
37
- if (response.ok)
47
+ if (await requestHttpOk({ url, timeoutMs: Math.max(1, remaining) }, { probe }))
38
48
  return true;
39
- lastError = new Error(`HTTP ${response.status} from ${url}`);
49
+ lastError = new Error(`Readiness probe refused ${url}`);
40
50
  }
41
51
  catch (error) {
42
52
  lastError = new Error(errorMessage(error));
@@ -1,3 +1,4 @@
1
1
  export { isPrivateAddress, expandIpv6, isLoopbackApiHost, isBlockedExternalApiHostname, isLoopbackHostname } from "./address.js";
2
2
  export { probeLoopbackHttp } from './local-http-probe.js';
3
+ export { requestLoopbackHttp } from './local-http-request.js';
3
4
  //# sourceMappingURL=index.d.ts.map
package/dist/net/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { isPrivateAddress, expandIpv6, isLoopbackApiHost, isBlockedExternalApiHostname, isLoopbackHostname } from "./address.js";
2
2
  export { probeLoopbackHttp } from './local-http-probe.js';
3
+ export { requestLoopbackHttp } from './local-http-request.js';
3
4
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,20 @@
1
+ /** Bounded private local-control request, with the same pinned-leaf TLS policy as local probes.
2
+ * Never follows redirects, resolves DNS, logs payloads, or permits remote credentials.
3
+ * @returns Status, content type and bounded response bytes. Transport/size/deadline errors reject.
4
+ * @complexity O(response bytes), bounded to maxBytes (1 MiB) and timeoutMs (5s) by default.
5
+ */
6
+ export declare function requestLoopbackHttp({ url, method, headers, body }: {
7
+ url: string;
8
+ method: string;
9
+ headers?: Readonly<Record<string, string>>;
10
+ body?: string;
11
+ }, { certificate, timeoutMs, maxBytes }?: {
12
+ certificate?: string | Buffer | undefined;
13
+ timeoutMs?: number;
14
+ maxBytes?: number;
15
+ }): Promise<{
16
+ status: number;
17
+ body: Buffer;
18
+ contentType: string | undefined;
19
+ }>;
20
+ //# sourceMappingURL=local-http-request.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { X509Certificate } from 'node:crypto';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { isLoopbackHostname } from './address.js';
5
+ /** Bounded private local-control request, with the same pinned-leaf TLS policy as local probes.
6
+ * Never follows redirects, resolves DNS, logs payloads, or permits remote credentials.
7
+ * @returns Status, content type and bounded response bytes. Transport/size/deadline errors reject.
8
+ * @complexity O(response bytes), bounded to maxBytes (1 MiB) and timeoutMs (5s) by default.
9
+ */
10
+ export function requestLoopbackHttp({ url, method, headers = {}, body }, { certificate, timeoutMs = 5000, maxBytes = 1024 * 1024 } = {}) {
11
+ const target = new URL(url);
12
+ if (!isLoopbackHostname({ hostname: target.hostname }) || !['http:', 'https:'].includes(target.protocol)) {
13
+ return Promise.reject(new Error('Expected loopback HTTP URL'));
14
+ }
15
+ const trust = certificate === undefined ? {} : { ca: new X509Certificate(certificate).toString(), allowPartialTrustChain: true };
16
+ return new Promise((resolve, reject) => {
17
+ const request = target.protocol === 'https:' ? httpsRequest : httpRequest;
18
+ const req = request(target, { method, headers, agent: false, signal: AbortSignal.timeout(timeoutMs),
19
+ hostname: '127.0.0.1', servername: 'localhost', rejectUnauthorized: true, ...trust,
20
+ }, res => {
21
+ const chunks = [];
22
+ let size = 0;
23
+ res.on('data', (chunk) => {
24
+ size += chunk.length;
25
+ if (size > maxBytes) {
26
+ res.destroy(new Error('Local response too large'));
27
+ return;
28
+ }
29
+ chunks.push(chunk);
30
+ });
31
+ res.once('error', reject);
32
+ res.once('end', () => resolve({ status: res.statusCode ?? 502, body: Buffer.concat(chunks), contentType: res.headers['content-type'] }));
33
+ });
34
+ req.once('error', reject);
35
+ req.end(body);
36
+ });
37
+ }
38
+ //# sourceMappingURL=local-http-request.js.map
package/dist/process.d.ts CHANGED
@@ -49,6 +49,14 @@ export type ProcessUsagePorts = {
49
49
  export type ProcessUsageOptions = {
50
50
  timeoutMs?: number;
51
51
  };
52
+ /** Private-memory samples are separate from RSS; unsupported hosts retain null readings. */
53
+ export type ProcessPrivateMemory = {
54
+ metric: "physical-footprint" | "private-working-set" | "unavailable";
55
+ readings: Array<{
56
+ pid: number;
57
+ bytes: number | null;
58
+ }>;
59
+ };
52
60
  export type StampedProcessMatchCriteria<TStamp extends ProcessStampShape> = Partial<TStamp>;
53
61
  export type StopProcessesResult = {
54
62
  alreadyStopped: boolean;
@@ -153,9 +161,15 @@ export declare function waitForProcessExit(pid: number | null | undefined, timeo
153
161
  * Enumerate all running processes as `{ pid, ppid, command }` snapshots, using
154
162
  * the platform-appropriate backend. Returns an empty list on any failure.
155
163
  *
156
- * @returns The current process snapshots (empty on error).
164
+ * @param input - Optional host platform and bounded execution port.
165
+ * @param options - Probe timeout (5000ms); commandLines=false reads executable names only and
166
+ * preserves nameless Windows identities even when usage diagnostics are unavailable.
167
+ * @returns The current process snapshots (empty on error), capped at 8MiB of probe output.
168
+ * @example await listProcessSnapshots({}, { commandLines: false })
157
169
  */
158
- export declare function listProcessSnapshots(): Promise<ProcessSnapshot[]>;
170
+ export declare function listProcessSnapshots(input?: ProcessUsagePorts, options?: ProcessUsageOptions & {
171
+ commandLines?: boolean;
172
+ }): Promise<ProcessSnapshot[]>;
159
173
  /**
160
174
  * Sample process RSS and cumulative CPU without reading command lines. Returns
161
175
  * [] on command, timeout, buffer or JSON failure. RSS totals may count shared
@@ -164,6 +178,29 @@ export declare function listProcessSnapshots(): Promise<ProcessSnapshot[]>;
164
178
  * @param options - Execution timeout (default 5000ms); output is capped at 8MiB.
165
179
  */
166
180
  export declare function listProcessUsage(input?: ProcessUsagePorts, options?: ProcessUsageOptions): Promise<ProcessUsage[]>;
181
+ /**
182
+ * Decode macOS `footprint -p` output into bytes; absent or invalid readings return null.
183
+ * @example parseProcessFootprint({ text: "Footprint: 187 MB" })
184
+ */
185
+ export declare function parseProcessFootprint({ text }: {
186
+ text: string;
187
+ }, _options?: {}): number | null;
188
+ /**
189
+ * Read private memory once per unique PID, without attaching an inspector or changing target state.
190
+ * macOS uses physical footprint; Windows uses private working set joined by PID, never PrivateBytes
191
+ * or Win32_Process.WorkingSetSize. Failed probes return null; invalid PIDs/limits throw RangeError.
192
+ * @param input - Requested PIDs and optional host platform/bounded execution port.
193
+ * @param options - Per-probe timeout (5000ms) and unique-PID limit (512); excess is rejected.
194
+ * @returns Accurately labeled bytes, including a null row for every unavailable PID.
195
+ * @complexity O(n) output space for n unique PIDs; macOS makes n sequential bounded probes,
196
+ * Windows makes one bounded bulk probe plus O(m) parsing space for m host processes.
197
+ * @example await sampleProcessPrivateMemory({ pids: [123] }, { timeoutMs: 10000 })
198
+ */
199
+ export declare function sampleProcessPrivateMemory(input: ProcessUsagePorts & {
200
+ pids: readonly number[];
201
+ }, options?: ProcessUsageOptions & {
202
+ maxPids?: number;
203
+ }): Promise<ProcessPrivateMemory>;
167
204
  /**
168
205
  * Collect the transitive set of descendant PIDs (including the roots) from a
169
206
  * process snapshot list, returned sorted by numeric PID descending for deterministic ordering.
package/dist/process.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * Uses `command` for invocation construction and `shell`'s direct buffered
11
11
  * executor for bounded usage samples; keeps a private `errorCode` copy.
12
12
  */
13
- import { execFile, spawn } from "node:child_process";
13
+ import { spawn } from "node:child_process";
14
14
  import { posix, win32 } from "node:path";
15
15
  import { setTimeout as sleep } from "node:timers/promises";
16
16
  import { captureLineTail } from "@jini-ai/core/text";
@@ -231,33 +231,24 @@ function parsePsOutput(stdout) {
231
231
  .filter((snapshot) => snapshot != null);
232
232
  }
233
233
  /** @internal Enumerate process snapshots on POSIX via `ps`. */
234
- async function listPosixProcessSnapshots() {
235
- const stdout = await new Promise((resolveList, rejectList) => {
236
- // eslint-disable-next-line sonarjs/no-os-command-from-path -- Fixed process-enumeration utility intentionally uses the trusted host PATH; command and argv are code-owned.
237
- execFile("ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => {
238
- if (error)
239
- rejectList(error);
240
- else
241
- resolveList(out);
242
- });
243
- });
244
- return parsePsOutput(stdout);
234
+ async function listPosixProcessSnapshots(input, options) {
235
+ // Fixed process-enumeration utility intentionally uses the trusted host PATH; command and argv are code-owned.
236
+ const stdout = await processProbeOutput({ command: "ps", run: input.run ?? execFileBuffered,
237
+ args: ["-axo", options.commandLines === false ? "pid=,ppid=,comm=" : "pid=,ppid=,command="],
238
+ }, options);
239
+ return parsePsOutput(stdout ?? "");
245
240
  }
246
241
  /** @internal Enumerate process snapshots on Windows via `Get-CimInstance Win32_Process` JSON. */
247
- async function listWindowsProcessSnapshots() {
242
+ async function listWindowsProcessSnapshots(input, options) {
243
+ const field = options.commandLines === false ? "Name" : "CommandLine";
248
244
  const command = [
249
245
  "$ErrorActionPreference = 'Stop'",
250
- "Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, CommandLine | ConvertTo-Json -Compress",
246
+ `Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, ${field} | ConvertTo-Json -Compress`,
251
247
  ].join("; ");
252
- const stdout = await new Promise((resolveList, rejectList) => {
253
- execFile(windowsPowerShell(), ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => {
254
- if (error)
255
- rejectList(error);
256
- else
257
- resolveList(out);
258
- });
259
- });
260
- const payload = stdout.trim();
248
+ const stdout = await processProbeOutput({ command: windowsPowerShell(), run: input.run ?? execFileBuffered,
249
+ args: ["-NoProfile", "-NonInteractive", "-Command", command],
250
+ }, options);
251
+ const payload = stdout?.trim();
261
252
  if (!payload)
262
253
  return [];
263
254
  const records = JSON.parse(payload);
@@ -265,8 +256,10 @@ async function listWindowsProcessSnapshots() {
265
256
  .map((record) => {
266
257
  const pid = Number(record.ProcessId);
267
258
  const ppid = Number(record.ParentProcessId);
268
- const commandLine = record.CommandLine?.trim();
269
- if (!commandLine || Number.isNaN(pid) || Number.isNaN(ppid))
259
+ const commandLine = options.commandLines === false ? record.Name?.trim() ?? "" : record.CommandLine?.trim();
260
+ if (commandLine == null || Number.isNaN(pid) || Number.isNaN(ppid))
261
+ return null;
262
+ if (options.commandLines !== false && !commandLine)
270
263
  return null;
271
264
  return { command: commandLine, pid, ppid };
272
265
  })
@@ -276,13 +269,17 @@ async function listWindowsProcessSnapshots() {
276
269
  * Enumerate all running processes as `{ pid, ppid, command }` snapshots, using
277
270
  * the platform-appropriate backend. Returns an empty list on any failure.
278
271
  *
279
- * @returns The current process snapshots (empty on error).
272
+ * @param input - Optional host platform and bounded execution port.
273
+ * @param options - Probe timeout (5000ms); commandLines=false reads executable names only and
274
+ * preserves nameless Windows identities even when usage diagnostics are unavailable.
275
+ * @returns The current process snapshots (empty on error), capped at 8MiB of probe output.
276
+ * @example await listProcessSnapshots({}, { commandLines: false })
280
277
  */
281
- export async function listProcessSnapshots() {
278
+ export async function listProcessSnapshots(input = {}, options = {}) {
282
279
  try {
283
- return process.platform === "win32"
284
- ? await listWindowsProcessSnapshots()
285
- : await listPosixProcessSnapshots();
280
+ return (input.platform ?? process.platform) === "win32"
281
+ ? await listWindowsProcessSnapshots(input, options)
282
+ : await listPosixProcessSnapshots(input, options);
286
283
  }
287
284
  catch {
288
285
  return [];
@@ -369,6 +366,89 @@ export async function listProcessUsage(input = {}, options = {}) {
369
366
  return [];
370
367
  }
371
368
  }
369
+ /**
370
+ * Decode macOS `footprint -p` output into bytes; absent or invalid readings return null.
371
+ * @example parseProcessFootprint({ text: "Footprint: 187 MB" })
372
+ */
373
+ export function parseProcessFootprint({ text }, _options = {}) {
374
+ const match = /Footprint:\s+([\d.]+)\s+(B|KB|MB|GB)\b/.exec(text);
375
+ if (!match)
376
+ return null;
377
+ const units = { B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024 };
378
+ const bytes = Number(match[1]) * units[match[2]];
379
+ return Number.isFinite(bytes) ? bytes : null;
380
+ }
381
+ /** @internal One bounded read-only OS probe; partial output on failure is discarded. */
382
+ async function processProbeOutput(input, options) {
383
+ try {
384
+ const result = await input.run(input.command, input.args, {
385
+ encoding: "utf8", timeout: options.timeoutMs ?? 5000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
386
+ });
387
+ return result.ok ? result.stdout : null;
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ }
393
+ /** @internal Performance instance names can reorder (node, node#1); only IDProcess is identity. */
394
+ function parsePrivateWorkingSets({ stdout }) {
395
+ const readings = new Map();
396
+ try {
397
+ const payload = JSON.parse(stdout ?? "null");
398
+ for (const record of Array.isArray(payload) ? payload : [payload]) {
399
+ if (!record || typeof record !== "object")
400
+ continue;
401
+ const row = record;
402
+ const pid = cimNumber(row.IDProcess);
403
+ const bytes = cimNumber(row.WorkingSetPrivate);
404
+ if (!validSamplePid(pid))
405
+ continue;
406
+ readings.set(pid, Number.isSafeInteger(bytes) && bytes >= 0 ? bytes : null);
407
+ }
408
+ }
409
+ catch { /* Malformed/absent counters are unavailable, never zero. */ }
410
+ return readings;
411
+ }
412
+ const validSamplePid = (pid) => Number.isSafeInteger(pid) && pid > 0;
413
+ /** @internal Reject invalid identities and oversized collections before making any OS calls. */
414
+ function assertPrivateMemorySample({ pids, maxPids }) {
415
+ if (!pids.every(validSamplePid))
416
+ throw new RangeError("invalid process PID");
417
+ if (!Number.isSafeInteger(maxPids) || maxPids < 1 || pids.length > maxPids)
418
+ throw new RangeError("process sample exceeds PID limit");
419
+ }
420
+ /**
421
+ * Read private memory once per unique PID, without attaching an inspector or changing target state.
422
+ * macOS uses physical footprint; Windows uses private working set joined by PID, never PrivateBytes
423
+ * or Win32_Process.WorkingSetSize. Failed probes return null; invalid PIDs/limits throw RangeError.
424
+ * @param input - Requested PIDs and optional host platform/bounded execution port.
425
+ * @param options - Per-probe timeout (5000ms) and unique-PID limit (512); excess is rejected.
426
+ * @returns Accurately labeled bytes, including a null row for every unavailable PID.
427
+ * @complexity O(n) output space for n unique PIDs; macOS makes n sequential bounded probes,
428
+ * Windows makes one bounded bulk probe plus O(m) parsing space for m host processes.
429
+ * @example await sampleProcessPrivateMemory({ pids: [123] }, { timeoutMs: 10000 })
430
+ */
431
+ export async function sampleProcessPrivateMemory(input, options = {}) {
432
+ const pids = [...new Set(input.pids)];
433
+ assertPrivateMemorySample({ pids, maxPids: options.maxPids ?? 512 });
434
+ const platform = input.platform ?? process.platform;
435
+ const run = input.run ?? execFileBuffered;
436
+ const readings = [];
437
+ if (platform === "darwin") {
438
+ for (const pid of pids) {
439
+ const stdout = await processProbeOutput({ command: "footprint", args: ["-p", String(pid)], run }, options);
440
+ readings.push({ pid, bytes: parseProcessFootprint({ text: stdout ?? "" }) });
441
+ }
442
+ return { metric: "physical-footprint", readings };
443
+ }
444
+ if (platform !== "win32")
445
+ return { metric: "unavailable", readings: pids.map((pid) => ({ pid, bytes: null })) };
446
+ const stdout = await processProbeOutput({ command: windowsPowerShell(), run, args: ["-NoProfile", "-NonInteractive", "-Command",
447
+ "$ErrorActionPreference = 'Stop'; Get-CimInstance Win32_PerfFormattedData_PerfProc_Process | Select-Object IDProcess, WorkingSetPrivate | ConvertTo-Json -Compress"],
448
+ }, options);
449
+ const byPid = parsePrivateWorkingSets({ stdout });
450
+ return { metric: "private-working-set", readings: pids.map((pid) => ({ pid, bytes: byPid.get(pid) ?? null })) };
451
+ }
372
452
  /**
373
453
  * Collect the transitive set of descendant PIDs (including the roots) from a
374
454
  * process snapshot list, returned sorted by numeric PID descending for deterministic ordering.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/platform",
3
- "version": "0.6.16",
3
+ "version": "0.6.19",
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": {
@@ -21,6 +21,7 @@
21
21
  "./secrets/credential-sets/contracts": "universal",
22
22
  "./secrets/testing": "node",
23
23
  "./http/guarded": "node",
24
+ "./http/multipart": "node",
24
25
  "./mail": "node",
25
26
  "./mail/senders": "universal",
26
27
  "./mail/smtp": "node",
@@ -104,6 +105,11 @@
104
105
  "import": "./dist/secrets/testing.js",
105
106
  "default": "./dist/secrets/testing.js"
106
107
  },
108
+ "./http/multipart": {
109
+ "types": "./dist/http/multipart.adapter.d.ts",
110
+ "import": "./dist/http/multipart.adapter.js",
111
+ "default": "./dist/http/multipart.adapter.js"
112
+ },
107
113
  "./http/guarded": {
108
114
  "types": "./dist/http/guarded/index.d.ts",
109
115
  "import": "./dist/http/guarded/index.js",