@oh-my-pi/pi-utils 16.2.13 → 16.3.2

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
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.1] - 2026-07-02
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `parseJsonWithRepair` failing tool calls whose streamed arguments contain an unquoted string value (e.g. `{"paths": packages/foo/*, "i": "…"}`). Final parsing now recovers such barewords in object/array value position as strings, terminating at `,` / `}` / `]` / newline. Recovery deliberately refuses anything that could mask real structure or bad data — truncated values, tokens containing `"` / `{` / `[` or a key-like `:` (URL `://` and Windows `:\` colons stay literal), and non-finite atoms (`NaN`, `Infinity`, `undefined`) — and streaming partial parses still roll back unfinished barewords instead of committing them.
10
+
11
+ ## [16.3.0] - 2026-07-02
12
+
13
+ ### Added
14
+
15
+ - Added `wrapFetchForExtraCa` and `withExtraCaFetch` utility functions to apply `NODE_EXTRA_CA_CERTS` to Bun's `RequestInit.tls.ca` configuration.
16
+
5
17
  ## [16.2.9] - 2026-06-30
6
18
 
7
19
  ### Added
@@ -109,24 +121,16 @@
109
121
 
110
122
  - Added profile-aware directory helpers and isolated profile state roots, while keeping the install ID shared across profiles.
111
123
  - Added a named-profile API to the `dirs` module — `setProfile()`, `getActiveProfile()`, `getProfileRootDir()`, and `normalizeProfileName()` — plus `resolveProfileEnv()`, which selects the active profile from `OMP_PROFILE` (canonical; takes precedence) then `PI_PROFILE` (legacy fallback, consulted only when `OMP_PROFILE` is unset).
112
- - Added the side-effect-free `@oh-my-pi/pi-utils/worker-host` module (`declareWorkerHostEntry()` / `workerHostEntry()`), extracted from `env` (still re-exported there) so worker spawn sites can resolve the self-dispatching CLI host entry without importing `env`'s side-effecting module graph.
113
-
114
- ### Fixed
115
-
116
- - Fixed profile directory isolation when a profile's agent `.env` customizes directory roots: directory-affecting keys (`XDG_DATA_HOME`/`XDG_STATE_HOME`/`XDG_CACHE_HOME`, and a default-mode `PI_CODING_AGENT_DIR`) are now honored. The `env` loader rebuilds the `dirs` resolver after applying `.env` files (`refreshDirsFromEnv()`), so a profile `.env` that points XDG roots elsewhere no longer leaks state into the home-based config dir.
117
- - Fixed `installRuntimeModuleResolver()` to keep bare requests from runtime-cache modules inside that registered runtime before falling back to host/workspace packages.
118
-
119
- ## [15.13.1] - 2026-06-15
120
-
121
- ### Added
122
-
123
124
  - Added support for a runtime `overrides` map in `RuntimeInstallSpec`, which is now written into generated runtime `package.json` manifests to force dependency pins (including transitive ones) across the runtime tree
124
125
  - Added a lightweight loop-phase breadcrumb stack (`pushLoopPhase`/`popLoopPhase`/`currentLoopPhase`, plus `takeRecentLoopPhase` which returns the live phase or the most recently popped one and clears it) so the TUI event-loop watchdog can attribute a main-thread block to the phase that caused it — including a synchronous phase already popped before the watchdog's delayed tick runs ([#2485](https://github.com/can1357/oh-my-pi/issues/2485))
125
126
  - Added `FetchWithRetryOptions.timeout` (forwarded to the underlying `fetch` call). `false` disables Bun's native ~300s pre-response timeout; a positive number overrides the ceiling. Bare browser/Node fetch ignores it ([#2422](https://github.com/can1357/oh-my-pi/issues/2422))
127
+ - Added the side-effect-free `@oh-my-pi/pi-utils/worker-host` module (`declareWorkerHostEntry()` / `workerHostEntry()`), extracted from `env` (still re-exported there) so worker spawn sites can resolve the self-dispatching CLI host entry without importing `env`'s side-effecting module graph.
126
128
 
127
129
  ### Fixed
128
130
 
131
+ - Fixed profile directory isolation when a profile's agent `.env` customizes directory roots: directory-affecting keys (`XDG_DATA_HOME`/`XDG_STATE_HOME`/`XDG_CACHE_HOME`, and a default-mode `PI_CODING_AGENT_DIR`) are now honored. The `env` loader rebuilds the `dirs` resolver after applying `.env` files (`refreshDirsFromEnv()`), so a profile `.env` that points XDG roots elsewhere no longer leaks state into the home-based config dir.
129
132
  - Made `TempDir` cleanup retry transient Windows `EBUSY`/`EPERM`/`ENOTEMPTY` removal failures so tests are less likely to fail when deleting just-used temp directories.
133
+ - Fixed `installRuntimeModuleResolver()` to keep bare requests from runtime-cache modules inside that registered runtime before falling back to host/workspace packages.
130
134
 
131
135
  ## [15.12.4] - 2026-06-13
132
136
 
@@ -29,6 +29,7 @@ export * from "./snowflake";
29
29
  export * from "./stream";
30
30
  export * from "./tab-spacing";
31
31
  export * from "./temp";
32
+ export * from "./tls-fetch";
32
33
  export * from "./type-guards";
33
34
  export * from "./which";
34
35
  export declare function structuredCloneJSON<T>(value: T): T;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `fetch`-compatible function. Accepts any callable matching the standard
3
+ * fetch signature; `preconnect` is optional because non-Bun runtimes
4
+ * (browsers, test mocks) won't expose it.
5
+ */
6
+ export type FetchImpl = ((input: string | URL | Request, init?: RequestInit) => Promise<Response>) & {
7
+ preconnect?: typeof globalThis.fetch.preconnect;
8
+ };
9
+ /**
10
+ * `NODE_EXTRA_CA_CERTS` was set but unusable (path does not exist). This is
11
+ * a config/contract error, not a transient transport fault — it is never
12
+ * retried.
13
+ */
14
+ export declare class ExtraCaError extends Error {
15
+ constructor(message: string, options?: {
16
+ cause?: unknown;
17
+ });
18
+ }
19
+ /** Test seam: drop the cached PEM so a follow-up call re-reads the env. */
20
+ export declare function __resetExtraCaCache(): void;
21
+ /**
22
+ * Wrap `fetchImpl` so every call honours `NODE_EXTRA_CA_CERTS`. Idempotent:
23
+ * a fetch already wrapped is returned unchanged so repeated composition
24
+ * (Anthropic auth-retry replays, request-debug fan-out) never stacks
25
+ * wrappers. When the env var is unset the original fetch is returned, so
26
+ * default deployments pay nothing.
27
+ */
28
+ export declare function wrapFetchForExtraCa(fetchImpl: FetchImpl): FetchImpl;
29
+ /**
30
+ * Convenience for options-bag composition (e.g. the stream-entry path in
31
+ * `@oh-my-pi/pi-ai`'s `stream.ts`, which mirrors `withRequestDebugFetch` so
32
+ * the proxy/debug/extra-CA wrappers compose uniformly). No-op when the env
33
+ * var is unset.
34
+ */
35
+ export declare function withExtraCaFetch<T extends {
36
+ fetch?: FetchImpl;
37
+ } | undefined>(options: T): T;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "16.2.13",
4
+ "version": "16.3.2",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "16.2.13",
34
+ "@oh-my-pi/pi-natives": "16.3.2",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export * from "./snowflake";
29
29
  export * from "./stream";
30
30
  export * from "./tab-spacing";
31
31
  export * from "./temp";
32
+ export * from "./tls-fetch";
32
33
  export * from "./type-guards";
33
34
  export * from "./which";
34
35
 
package/src/json-parse.ts CHANGED
@@ -49,6 +49,18 @@ const KEYWORDS: readonly (readonly [string, unknown])[] = [
49
49
  ["None", null],
50
50
  ];
51
51
 
52
+ /**
53
+ * JS-only atoms never recovered as bareword strings — a tool must not execute
54
+ * with a non-finite or undefined argument masquerading as a string.
55
+ */
56
+ const NON_RECOVERABLE_BAREWORDS: Record<string, true> = {
57
+ NaN: true,
58
+ Infinity: true,
59
+ "-Infinity": true,
60
+ "+Infinity": true,
61
+ undefined: true,
62
+ };
63
+
52
64
  /**
53
65
  * Sentinel returned by partial-mode value parsing when an atomic value
54
66
  * (number / keyword) is incomplete at the streaming edge, so the enclosing
@@ -157,7 +169,10 @@ export function repairJson(json: string): string {
157
169
  * - Python literals `True` / `False` / `None` and JS `NaN` / `Infinity`;
158
170
  * - raw control characters and invalid `\x` escapes inside strings (kept literally);
159
171
  * - unescaped quotes inside strings — a quote only closes a string when followed
160
- * by a value terminator, recovering apostrophes such as `'it's'`.
172
+ * by a value terminator, recovering apostrophes such as `'it's'`;
173
+ * - unquoted string values in object/array value position (strict mode only) —
174
+ * an unrecognized bareword such as `{"paths": packages/foo/*}` is recovered as
175
+ * a string up to the next `,` / `}` / `]` / newline.
161
176
  *
162
177
  * In `partial` mode an unterminated string/object/array (or a value cut off at
163
178
  * end-of-input) is auto-closed with whatever was parsed so far — for streaming.
@@ -182,7 +197,7 @@ class RelaxedJson {
182
197
  if (this.#partial) return undefined;
183
198
  throw new SyntaxError("Unexpected end of JSON input");
184
199
  }
185
- const value = this.#value();
200
+ const value = this.#value(false);
186
201
  if (value === INCOMPLETE) return undefined;
187
202
  this.#ws();
188
203
  if (!this.#partial && this.#i < this.#n) {
@@ -218,7 +233,7 @@ class RelaxedJson {
218
233
  }
219
234
  }
220
235
 
221
- #value(): unknown {
236
+ #value(allowBareword: boolean): unknown {
222
237
  const s = this.#s;
223
238
  const c = s[this.#i];
224
239
  if (c === "{") return this.#object();
@@ -231,7 +246,7 @@ class RelaxedJson {
231
246
  // NaN guard (strict throw / partial rollback) like other bad tokens.
232
247
  return this.#number();
233
248
  }
234
- return this.#keyword();
249
+ return this.#keyword(allowBareword);
235
250
  }
236
251
 
237
252
  #object(): Record<string, unknown> {
@@ -267,7 +282,7 @@ class RelaxedJson {
267
282
  if (this.#partial) return out;
268
283
  throw new SyntaxError("Expected value after ':'");
269
284
  }
270
- const value = this.#value();
285
+ const value = this.#value(true);
271
286
  if (value === INCOMPLETE) return out;
272
287
  out[key] = value;
273
288
  this.#ws();
@@ -303,7 +318,7 @@ class RelaxedJson {
303
318
  this.#i++;
304
319
  continue;
305
320
  }
306
- const value = this.#value();
321
+ const value = this.#value(true);
307
322
  if (value === INCOMPLETE) return out;
308
323
  out.push(value);
309
324
  this.#ws();
@@ -468,7 +483,7 @@ class RelaxedJson {
468
483
  return num;
469
484
  }
470
485
 
471
- #keyword(): unknown {
486
+ #keyword(allowBareword: boolean): unknown {
472
487
  const s = this.#s;
473
488
  const i = this.#i;
474
489
  for (const [word, value] of KEYWORDS) {
@@ -485,8 +500,47 @@ class RelaxedJson {
485
500
  this.#i = this.#n;
486
501
  return INCOMPLETE;
487
502
  }
503
+ if (allowBareword) return this.#bareword();
488
504
  throw new SyntaxError(`Unexpected token at position ${this.#i}`);
489
505
  }
506
+
507
+ /**
508
+ * Strict-mode recovery of an unquoted string value, e.g.
509
+ * `{"paths": packages/foo/*}`: consume until `,` / `}` / `]` / newline and
510
+ * trim trailing whitespace. Recovery still throws — so a final parse never
511
+ * accepts a half-formed or non-finite argument — when the token:
512
+ * - hits end-of-input before a delimiter (truncated value);
513
+ * - contains a `"`, `{`, `[`, or a key-like `:` — this parser accepts
514
+ * unquoted keys, so a missed comma (`{"a": foo "b": 1}`, `{a: foo b: 1}`)
515
+ * would otherwise silently swallow the following field. A colon followed
516
+ * by `/` or `\` stays literal so URL and Windows-path values recover;
517
+ * - is a non-finite atom ({@link NON_RECOVERABLE_BAREWORDS}).
518
+ */
519
+ #bareword(): string {
520
+ const s = this.#s;
521
+ const start = this.#i;
522
+ let i = start;
523
+ while (i < this.#n) {
524
+ const cc = s.charCodeAt(i);
525
+ if (cc === 0x2c /* , */ || cc === 0x7d /* } */ || cc === 0x5d /* ] */ || cc === 0x0a || cc === 0x0d) break;
526
+ if (
527
+ cc === QUOTE ||
528
+ cc === 0x7b /* { */ ||
529
+ cc === 0x5b /* [ */ ||
530
+ (cc === 0x3a /* : */ && s.charCodeAt(i + 1) !== 0x2f /* / */ && s.charCodeAt(i + 1) !== 0x5c) /* \ */
531
+ ) {
532
+ throw new SyntaxError(`Unexpected token at position ${start}`);
533
+ }
534
+ i++;
535
+ }
536
+ if (i >= this.#n) throw new SyntaxError(`Unexpected token at position ${start}`);
537
+ let end = i;
538
+ while (end > start && isWhitespace(s.charCodeAt(end - 1))) end--;
539
+ const word = s.slice(start, end);
540
+ if (NON_RECOVERABLE_BAREWORDS[word]) throw new SyntaxError(`Unexpected token at position ${start}`);
541
+ this.#i = i;
542
+ return word;
543
+ }
490
544
  }
491
545
 
492
546
  /**
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `NODE_EXTRA_CA_CERTS` shim for Bun's `fetch`.
3
+ *
4
+ * Node's TLS layer honours `NODE_EXTRA_CA_CERTS` natively, but Bun's
5
+ * `fetch` does not, and both the provider streams (`openai-responses`,
6
+ * `openai-completions`, `openai-codex-responses`, `ollama-chat`, ...) and
7
+ * catalog model discovery (`/models` probes) route every request through
8
+ * Bun's runtime. Without this wrapper, corporate relays and private
9
+ * gateways behind a custom CA bundle fail with
10
+ * `unknown certificate verification error` even when the env var is set.
11
+ *
12
+ * The wrapper merges the resolved CA bundle into Bun's `RequestInit.tls.ca`.
13
+ * Bun's `tls.ca` REPLACES the default trust store when set, so the wrapper
14
+ * always seeds {@link tls.rootCertificates} when the caller has not already
15
+ * curated their own CA list.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as tls from "node:tls";
19
+ import { $env } from "./env";
20
+ import { isEnoent } from "./fs-error";
21
+
22
+ /**
23
+ * `fetch`-compatible function. Accepts any callable matching the standard
24
+ * fetch signature; `preconnect` is optional because non-Bun runtimes
25
+ * (browsers, test mocks) won't expose it.
26
+ */
27
+ export type FetchImpl = ((input: string | URL | Request, init?: RequestInit) => Promise<Response>) & {
28
+ preconnect?: typeof globalThis.fetch.preconnect;
29
+ };
30
+
31
+ /**
32
+ * `NODE_EXTRA_CA_CERTS` was set but unusable (path does not exist). This is
33
+ * a config/contract error, not a transient transport fault — it is never
34
+ * retried.
35
+ */
36
+ export class ExtraCaError extends Error {
37
+ constructor(message: string, options?: { cause?: unknown }) {
38
+ super(message, options?.cause === undefined ? undefined : { cause: options.cause });
39
+ this.name = "ExtraCaError";
40
+ }
41
+ }
42
+
43
+ /** Bun extension to `RequestInit` for the TLS options we touch. */
44
+ type BunTlsOptions = {
45
+ ca?: string | string[];
46
+ cert?: string;
47
+ key?: string;
48
+ rejectUnauthorized?: boolean;
49
+ serverName?: string;
50
+ ciphers?: string;
51
+ };
52
+
53
+ type BunTlsRequestInit = RequestInit & { tls?: BunTlsOptions };
54
+
55
+ const EXTRA_CA_FETCH_MARKER = Symbol("omp.extraCaFetch");
56
+ type ExtraCaFetch = FetchImpl & { [EXTRA_CA_FETCH_MARKER]?: true };
57
+
58
+ /**
59
+ * Cached resolution of `NODE_EXTRA_CA_CERTS`. Keyed on the env value plus
60
+ * the file mtime for path values so on-disk cert rotation (short-lived
61
+ * corporate bundles) invalidates the cache instead of pinning the first
62
+ * read forever.
63
+ */
64
+ let cacheKey: string | undefined;
65
+ let cacheValue: string | undefined;
66
+
67
+ /**
68
+ * Returns the PEM bytes referenced by `NODE_EXTRA_CA_CERTS`, or `undefined`
69
+ * when the env var is unset/empty.
70
+ *
71
+ * Accepts the same shapes Node accepts plus an inline-PEM escape hatch:
72
+ * - Inline PEM (`-----BEGIN CERTIFICATE-----...`). Literal `\n` escapes in
73
+ * the env value are expanded so callers can ship single-line PEMs through
74
+ * shell exports.
75
+ * - File path. Anything that does not contain a PEM header is treated as a
76
+ * path, matching Node's "extensionless filename is still a path" contract.
77
+ * `ENOENT` becomes {@link ExtraCaError}; other I/O errors bubble.
78
+ */
79
+ function resolveExtraCa(): string | undefined {
80
+ const raw = $env.NODE_EXTRA_CA_CERTS?.trim();
81
+ if (!raw) return undefined;
82
+
83
+ let key: string;
84
+ if (raw.includes("-----BEGIN")) {
85
+ key = raw;
86
+ } else {
87
+ try {
88
+ key = `${raw}@${fs.statSync(raw).mtimeMs}`;
89
+ } catch {
90
+ key = raw;
91
+ }
92
+ }
93
+ if (key === cacheKey) return cacheValue;
94
+
95
+ if (raw.includes("-----BEGIN")) {
96
+ cacheValue = raw.replace(/\\n/g, "\n");
97
+ } else {
98
+ try {
99
+ cacheValue = fs.readFileSync(raw, "utf8");
100
+ } catch (error) {
101
+ if (isEnoent(error)) {
102
+ throw new ExtraCaError(`NODE_EXTRA_CA_CERTS path does not exist: ${raw}`);
103
+ }
104
+ throw error;
105
+ }
106
+ }
107
+ cacheKey = key;
108
+ return cacheValue;
109
+ }
110
+
111
+ /** Test seam: drop the cached PEM so a follow-up call re-reads the env. */
112
+ export function __resetExtraCaCache(): void {
113
+ cacheKey = undefined;
114
+ cacheValue = undefined;
115
+ }
116
+
117
+ /**
118
+ * Merge `extraCa` into `init.tls.ca`. When the caller has not supplied a CA
119
+ * list, the system root store is included alongside the extra bundle —
120
+ * Bun's `tls.ca` replaces the default trust store, so omitting roots would
121
+ * break every public host. When the caller already curated a list (e.g.
122
+ * Anthropic Foundry's mTLS options, which already seed
123
+ * `tls.rootCertificates`), only the extra CA is appended.
124
+ */
125
+ function withExtraCaInit(init: RequestInit | undefined, extraCa: string): RequestInit {
126
+ const existingTls = (init as BunTlsRequestInit | undefined)?.tls;
127
+ const existingCa = existingTls?.ca;
128
+ let mergedCa: string[];
129
+ if (existingCa === undefined) {
130
+ mergedCa = [...tls.rootCertificates, extraCa];
131
+ } else if (Array.isArray(existingCa)) {
132
+ mergedCa = [...existingCa, extraCa];
133
+ } else {
134
+ mergedCa = [existingCa, extraCa];
135
+ }
136
+ return { ...init, tls: { ...existingTls, ca: mergedCa } } as RequestInit;
137
+ }
138
+
139
+ /**
140
+ * Wrap `fetchImpl` so every call honours `NODE_EXTRA_CA_CERTS`. Idempotent:
141
+ * a fetch already wrapped is returned unchanged so repeated composition
142
+ * (Anthropic auth-retry replays, request-debug fan-out) never stacks
143
+ * wrappers. When the env var is unset the original fetch is returned, so
144
+ * default deployments pay nothing.
145
+ */
146
+ export function wrapFetchForExtraCa(fetchImpl: FetchImpl): FetchImpl {
147
+ const maybeWrapped = fetchImpl as ExtraCaFetch;
148
+ if (maybeWrapped[EXTRA_CA_FETCH_MARKER]) return fetchImpl;
149
+ // Peek once at construction — if the env var is unset, skip the wrapper
150
+ // entirely so the hot fetch path stays a single function call. The env
151
+ // is evaluated again per request below to catch in-process updates from
152
+ // tests (`__resetExtraCaCache` + env mutation).
153
+ if (!$env.NODE_EXTRA_CA_CERTS?.trim()) return fetchImpl;
154
+
155
+ const wrapped = Object.assign(
156
+ async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
157
+ const extraCa = resolveExtraCa();
158
+ return extraCa ? fetchImpl(input, withExtraCaInit(init, extraCa)) : fetchImpl(input, init);
159
+ },
160
+ fetchImpl.preconnect ? { preconnect: fetchImpl.preconnect } : {},
161
+ { [EXTRA_CA_FETCH_MARKER]: true as const },
162
+ );
163
+ return wrapped;
164
+ }
165
+
166
+ /**
167
+ * Convenience for options-bag composition (e.g. the stream-entry path in
168
+ * `@oh-my-pi/pi-ai`'s `stream.ts`, which mirrors `withRequestDebugFetch` so
169
+ * the proxy/debug/extra-CA wrappers compose uniformly). No-op when the env
170
+ * var is unset.
171
+ */
172
+ export function withExtraCaFetch<T extends { fetch?: FetchImpl } | undefined>(options: T): T {
173
+ if (!$env.NODE_EXTRA_CA_CERTS?.trim()) return options;
174
+ const fetchImpl = options?.fetch ?? (globalThis.fetch as FetchImpl);
175
+ const wrapped = wrapFetchForExtraCa(fetchImpl);
176
+ if (wrapped === fetchImpl && options?.fetch !== undefined) return options;
177
+ return { ...(options ?? {}), fetch: wrapped } as T;
178
+ }