@jini-ai/core 0.6.17 → 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,12 @@
1
1
  # @jini-ai/core
2
2
 
3
+ ## 0.6.19
4
+
5
+ - Preserve detached, frozen batch schema symbol metadata in tool exposure snapshots and single-call required-field guidance in plan-mode descriptions.
6
+ - Redact complete quoted credential keys and values in inspected and JSON diagnostics, including API keys, cookies, tokens and private keys, while preserving diagnostic context.
7
+ - Add universal `formatErrorForLog` diagnostics that retain stacks, codes and structured context, redact secrets and never interrupt recovery for hostile thrown values.
8
+ - Redact default settings-stream exception logs through the shared secret policy.
9
+
3
10
  ## 0.6.17
4
11
 
5
12
  ### Patch Changes
package/dist/approval.js CHANGED
@@ -128,7 +128,7 @@ export function createBatchApprovalHandler({ steps, combineDescriptions }, { max
128
128
  plans.push(await steps.describe(entry, options));
129
129
  if (!prepared.batch)
130
130
  return plans[0];
131
- return { ask: true, description: combineDescriptions({ descriptions: plans.map(plan => plan.description) }) };
131
+ return { ask: plans.some(plan => plan.ask), description: combineDescriptions({ descriptions: plans.map(plan => plan.description) }) };
132
132
  },
133
133
  askHuman: steps.askHuman,
134
134
  run: async ({ prepared, confirmer, choice }, options) => {
@@ -5,6 +5,8 @@ import { type ToolRegistration } from './tool-registry.js';
5
5
  * smaller window per item that the caller may still raise. */
6
6
  export interface BatchableTool {
7
7
  readonly toolId: string;
8
+ /** A domain that owns a combined approval plan receives the whole normalized batch once. */
9
+ readonly mode?: 'items' | 'plan';
8
10
  readonly maxItems?: number;
9
11
  readonly itemDefaults?: Readonly<Record<string, unknown>>;
10
12
  /** Host-specific result constraints, checked on the normalized item before its handler runs. */
@@ -20,6 +20,10 @@ const ITEM_NEEDS = " Each item needs ";
20
20
  const ITEM_NEEDS_END = " (inside each item).";
21
21
  // Keep discovery requirements through descriptor/schema spreads without adding wire-schema keys.
22
22
  const BATCH_ITEM_REQUIRED_FIELDS = Symbol('batchItemRequiredFields');
23
+ /** Shared by item and plan descriptions so neither can make single calls look argument-free. */
24
+ function singleCallGuidance({ fields }, _options = {}) {
25
+ return fields.length > 0 ? `Single call requires: ${fields.join(', ')}. ` : '';
26
+ }
23
27
  /** The original top-level `required` fields retained by {@link batchDescriptorFor} as schema metadata.
24
28
  * A batch schema declares no top-level `required`, so a discovery signature built from it alone
25
29
  * makes a single call look argument-free and the model sends `{}` — this recovers them.
@@ -87,7 +91,7 @@ export function batchDescriptorFor({ descriptor, tool }, _optional = {}) {
87
91
  delete rest.required;
88
92
  const needs = requiredFields.length > 0 ? `${ITEM_NEEDS}${requiredFields.join(", ")}${ITEM_NEEDS_END}` : "";
89
93
  // Inside the removable guidance so search keeps only the authored description and signature.
90
- const singleNeeds = requiredFields.length > 0 ? `Single call requires: ${requiredFields.join(", ")}. ` : "";
94
+ const singleNeeds = singleCallGuidance({ fields: requiredFields });
91
95
  const items = {
92
96
  type: "array",
93
97
  minItems: 1,
@@ -203,14 +207,14 @@ function summarizeBatchResults({ results }, _options = {}) {
203
207
  * @complexity O(n) bounded normalization/target checks for n items, plus the existing
204
208
  * per-item executor or combined plan lifecycle. The default cap is 25.
205
209
  */
206
- export function withBatchInput({ registration, tool }, { mode = 'items' } = {}) {
210
+ export function withBatchInput({ registration, tool }, { mode = tool.mode ?? 'items' } = {}) {
207
211
  const toolId = registration.descriptor.id;
208
212
  const maxItems = tool.maxItems ?? DEFAULT_MAX_BATCH_ITEMS;
209
213
  const descriptor = batchDescriptorFor({ descriptor: registration.descriptor, tool });
210
214
  if (mode === 'plan')
211
215
  return {
212
216
  ...registration, descriptor: { ...descriptor,
213
- description: `${registration.descriptor.description ?? ''}${BATCH_GUIDANCE_MARKER}up to ${maxItems} inputs are normalized before authorization and passed to the existing combined approval plan.`,
217
+ description: `${registration.descriptor.description ?? ''}${BATCH_GUIDANCE_MARKER}${singleCallGuidance({ fields: batchItemRequiredFields({ inputSchema: descriptor.inputSchema }) })}up to ${maxItems} inputs are normalized before authorization and passed to the existing combined approval plan.`,
214
218
  },
215
219
  // The plan owns one confirmation. Normalize every merged item before the outer gate so
216
220
  // its policy and planner receive the same targets; never execute items to preflight them.
package/dist/redact.d.ts CHANGED
@@ -99,6 +99,19 @@ export declare const NON_SECRET_TOKEN_RE: RegExp;
99
99
  export declare function redactSecrets({ input }: {
100
100
  input: string;
101
101
  }, options?: SecretRedactionOptions): string;
102
+ /**
103
+ * Universal diagnostic text for any thrown value. Keeps native stacks and structured metadata,
104
+ * then applies the shared credential policy (PII stays readable unless explicitly requested).
105
+ * Diagnostic getters, serialization and coercion can themselves fail; fixed fallback text keeps
106
+ * those failures from replacing the original error or interrupting the caller's recovery.
107
+ * @param required.error - Untrusted thrown value; never mutated.
108
+ * @param options - Shared redaction options, including caller-owned exact secrets.
109
+ * @returns Redacted text; never throws, including for circular objects and hostile accessors.
110
+ * @complexity O(n) serialization in the diagnostic's fields/characters, plus shared redaction passes.
111
+ */
112
+ export declare function formatErrorForLog(required: {
113
+ error: unknown;
114
+ }, options?: SecretRedactionOptions): string;
102
115
  /**
103
116
  * Same as {@link redactSecrets} but also returns per-category counts so the
104
117
  * caller can attach an audit summary to the trace metadata ('we stripped 2
package/dist/redact.js CHANGED
@@ -165,7 +165,9 @@ const PATTERNS = [
165
165
  // non-card numbers (timestamps, IDs, hashes). We isolate the candidate
166
166
  // then run a Luhn check before redacting.
167
167
  const CARD_CANDIDATE = /\b(?:\d[ -]?){12,18}\d\b/g;
168
- const API_KEY_HEADER = /(^|[^?&\w-])("?)(x-api-key|api-key|x-goog-api-key)\2(\s*[:=]\s*)("[^"]*"|[^\s,;"'#}]+)/gi;
168
+ // Inspect/JSON credentials can contain spaces and escaped quotes; consume the entire value.
169
+ const QUOTED_SECRET_VALUE = String.raw `"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'`;
170
+ const API_KEY_HEADER = new RegExp(`(^|[^?&\\w-])(["']?)(x-api-key|api-key|x-goog-api-key)\\2(\\s*[:=]\\s*)(${QUOTED_SECRET_VALUE}|[^\\s,;"'#}]+)`, 'gi');
169
171
  const API_KEY_QUERY = /([?&](?:key|api_key|api-key)=)[^&#\s,;"']+/gi;
170
172
  // The length guard and the per-digit `d < 0 || d > 9` guard from the origin
171
173
  // were both dropped here (a documented deviation from the otherwise-verbatim
@@ -199,6 +201,9 @@ function isLuhnValid(digits) {
199
201
  // operator; delegated-tool-calls is the route segment named in delegated tool failures.
200
202
  // Matching the ENTIRE run keeps a secret glued onto either spelling redactable.
201
203
  const SECRET_LABELS = 'authorization|bearer|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret|cookie';
204
+ // Keep historical URL categories and the bounded sanitizer's vocabulary stable.
205
+ const SERIALIZED_SECRET_LABELS = `${SECRET_LABELS}|x[-_](?:goog[-_])?api[-_]?key|api key|token|set[-_]cookie|private[-_]?key`;
206
+ const EMPTY_QUOTED_ASSIGNMENT_RE = new RegExp(`\\b(?:${SERIALIZED_SECRET_LABELS})\\s*[:=]\\s*$`, 'i');
202
207
  export const LABELED_SECRET_RE = new RegExp(`\\b(${SECRET_LABELS})\\b\\s*[:=]\\s*\\S+`, 'gi');
203
208
  export const OPAQUE_TOKEN_RE = /[A-Za-z0-9_-]{20,}/g;
204
209
  // Pattern parts, only ever used inside the anchored NON_SECRET_TOKEN_RE.
@@ -209,7 +214,9 @@ export const NON_SECRET_TOKEN_RE = new RegExp(`^(?:${CORRELATION_ID}|delegated-t
209
214
  // its historical lowercase marker contract and uses the unmodified lexical vocabulary.
210
215
  // Reject a masked header/query value before matching, so a following field's credential
211
216
  // remains available to the global scan rather than being consumed beside the marker.
212
- const MASK_AWARE_LABELED_SECRET_RE = new RegExp(LABELED_SECRET_RE.source.replace(/\\S\+$/, "(?!Bearer\\s+\\[REDACTED:[^\\]]+\\](?:\\s|$))(?!\\[REDACTED:[^\\]]+\\][;&])(\\S+)"), LABELED_SECRET_RE.flags);
217
+ // Quoted values end at their matching quote; opaque unquoted values retain punctuation.
218
+ // An ampersand cannot start a value: an empty URL parameter must not eat its neighbor.
219
+ const MASK_AWARE_LABELED_SECRET_RE = new RegExp(`(?<![\\w-])((["']?)(?:${SERIALIZED_SECRET_LABELS})\\2\\s*[:=]\\s*)(?!Bearer\\s+\\[REDACTED:[^\\]]+\\](?:\\s|$))(?!\\[REDACTED:[^\\]]+\\][;&])(${QUOTED_SECRET_VALUE}|(?!&)\\S+)`, 'gi');
213
220
  // Provider failures also echo short bearer values. Exclude our own marker on subsequent passes.
214
221
  // The leading lookahead only lets the lookbehind run where a value can start (see bearer_token).
215
222
  const SHORT_BEARER_RE = /(?=[\w.+/=-])(?<=Bearer\s+)[\w.+/=-]+/gi;
@@ -249,6 +256,59 @@ function isMaskedValue(value) {
249
256
  export function redactSecrets({ input }, options = {}) {
250
257
  return redactSecretsWithCounts({ input }, options).redacted;
251
258
  }
259
+ /**
260
+ * Universal diagnostic text for any thrown value. Keeps native stacks and structured metadata,
261
+ * then applies the shared credential policy (PII stays readable unless explicitly requested).
262
+ * Diagnostic getters, serialization and coercion can themselves fail; fixed fallback text keeps
263
+ * those failures from replacing the original error or interrupting the caller's recovery.
264
+ * @param required.error - Untrusted thrown value; never mutated.
265
+ * @param options - Shared redaction options, including caller-owned exact secrets.
266
+ * @returns Redacted text; never throws, including for circular objects and hostile accessors.
267
+ * @complexity O(n) serialization in the diagnostic's fields/characters, plus shared redaction passes.
268
+ */
269
+ export function formatErrorForLog(required, options = {}) {
270
+ try {
271
+ const { error } = required;
272
+ let text;
273
+ if (error instanceof Error) {
274
+ const message = String(error.message);
275
+ text = String(error.stack ?? `${error.name}: ${message}`);
276
+ if (!text.includes(message))
277
+ text += `\n${message}`;
278
+ const fields = Object.fromEntries(Object.getOwnPropertyNames(error)
279
+ .filter(key => !['name', 'message', 'stack'].includes(key)).map(key => [key, Reflect.get(error, key)]));
280
+ if (Object.keys(fields).length)
281
+ text += `\n${diagnosticJson({ error: fields }, options)}`;
282
+ }
283
+ else {
284
+ text = error !== null && typeof error === 'object' ? diagnosticJson({ error }, options) : String(error);
285
+ }
286
+ return redactSecrets({ input: text }, { redactPii: false, ...options });
287
+ }
288
+ catch {
289
+ return '[Unformattable error]';
290
+ }
291
+ }
292
+ /** JSON preserves structured fields without coercing objects; track cycles and native Error fields.
293
+ * Property access remains inside formatErrorForLog's recovery boundary. O(n) visited fields. */
294
+ function diagnosticJson({ error }, options = {}) {
295
+ const seen = new WeakSet();
296
+ return JSON.stringify(error, (_key, value) => {
297
+ if (value !== null && typeof value === 'object') {
298
+ if (seen.has(value))
299
+ return '[Circular]';
300
+ seen.add(value);
301
+ if (value instanceof Error)
302
+ return Object.fromEntries(Object.getOwnPropertyNames(value).map(key => [key, Reflect.get(value, key)]));
303
+ }
304
+ // Scrub string contents before JSON escapes their credential labels. Exact-only is kept
305
+ // for the final pass so a caller's literal marker is never reprocessed as a supplied secret.
306
+ if (typeof value === 'string' && options.policy !== 'exact-only') {
307
+ return redactSecrets({ input: value }, { redactPii: false, ...options });
308
+ }
309
+ return typeof value === 'bigint' ? String(value) : value;
310
+ }) ?? String(error);
311
+ }
252
312
  /**
253
313
  * Same as {@link redactSecrets} but also returns per-category counts so the
254
314
  * caller can attach an audit summary to the trace metadata ('we stripped 2
@@ -317,7 +377,7 @@ function applyPatterns({ text, marker, patterns }) {
317
377
  // and left `hunter2` visible; `api_key` swallowed the whole `api_key=` label), and they split
318
378
  // earlier markers (`a` inside `exact_secret`, `email` inside `[REDACTED:email]`).
319
379
  const PROTECTED_SPAN_RE = new RegExp([
320
- `\\b(?:${SECRET_LABELS})\\b\\s*[:=]`,
380
+ `(?<![\\w-])["']?(?:${SERIALIZED_SECRET_LABELS})["']?\\s*[:=]`,
321
381
  '\\bbearer\\s+',
322
382
  URL_SECRET_PARAM_RE.source.slice(0, URL_SECRET_PARAM_RE.source.lastIndexOf('=') + 1),
323
383
  URL_CREDENTIALS_RE.source.slice(0, URL_CREDENTIALS_RE.source.indexOf(')') + 1),
@@ -373,14 +433,30 @@ function redactUrlCredentials({ text, marker }) {
373
433
  .replace(URL_CREDENTIALS_RE, (match, scheme, userinfo) => isMaskedValue(userinfo) ? match : `${scheme}${marker('url_credentials')}@`)
374
434
  .replace(SIGNED_QUERY_RE, (match, prefix, value) => isMaskedValue(value) ? match : `${prefix}${marker('signed_query')}`);
375
435
  }
436
+ /** Mask quoted hints ending in an empty credential assignment before scanning labels. Otherwise
437
+ * their closing quote is mistaken for a value's opening quote and consumes the next field's key,
438
+ * leaving that field's secret unrecognized. Only the hint token is replaced; neighboring labels
439
+ * remain available to the existing matchers, including inspect's unquoted keys.
440
+ * @complexity O(n) scanning the diagnostic's quoted tokens; no I/O or input mutation.
441
+ */
442
+ function redactEmptyQuotedAssignments({ text, marker }, _options = {}) {
443
+ return text.replace(new RegExp(QUOTED_SECRET_VALUE, 'g'), (quoted) => {
444
+ if (!EMPTY_QUOTED_ASSIGNMENT_RE.test(quoted.slice(1, -1)))
445
+ return quoted;
446
+ const quote = quoted.charAt(0);
447
+ return `${quote}${marker('labeled_secret')}${quote}`;
448
+ });
449
+ }
376
450
  /** Values named by a header, query parameter, bearer prefix or credential label. */
377
451
  function redactKeyedCredentials({ text, marker }) {
378
- let out = text.replace(API_KEY_HEADER, (_match, prefix, quote, name, separator, value) => {
452
+ let out = redactEmptyQuotedAssignments({ text, marker });
453
+ out = out.replace(API_KEY_HEADER, (_match, prefix, quote, name, separator, value) => {
379
454
  // Already masked values are not new credentials and should not inflate the audit count.
380
455
  if (isMaskedValue(value))
381
456
  return `${prefix}${quote}${name}${quote}${separator}${value}`;
382
457
  marker('api_key_header');
383
- const redactedValue = value.startsWith('"') ? '"[REDACTED:api_key_header]"' : '[REDACTED:api_key_header]';
458
+ const valueQuote = /^["']/.test(value) ? value.charAt(0) : '';
459
+ const redactedValue = `${valueQuote}[REDACTED:api_key_header]${valueQuote}`;
384
460
  return `${prefix}${quote}${name}${quote}${separator}${redactedValue}`;
385
461
  });
386
462
  // The provider rule includes a query fragment in an echoed key; mask that whole value
@@ -392,12 +468,13 @@ function redactKeyedCredentials({ text, marker }) {
392
468
  // Sweep other URL parameters before the general labeled rule can consume harmless
393
469
  // neighboring parameters (`password=x&page=2` used to lose `&page=2`).
394
470
  out = redactUrlSecretParams({ text: out, marker });
395
- return out.replace(MASK_AWARE_LABELED_SECRET_RE, (match, _label, value) => {
471
+ return out.replace(MASK_AWARE_LABELED_SECRET_RE, (match, prefix, _quote, value) => {
396
472
  // Replace only the captured value, retaining labels even when no whitespace separates
397
473
  // them. Glued credential suffixes fail the complete-marker check and remain maskable.
398
474
  if (isMaskedValue(value))
399
475
  return match;
400
- return `${match.slice(0, -value.length)}${marker('labeled_secret')}`;
476
+ const valueQuote = /^["']/.test(value) ? value.charAt(0) : '';
477
+ return `${prefix}${valueQuote}${marker('labeled_secret')}${valueQuote}`;
401
478
  });
402
479
  }
403
480
  /** Secret-named URL query/fragment values; values already masked above keep their category. */
@@ -1,3 +1,5 @@
1
+ import { inspect } from 'node:util';
2
+ import { redactSecrets } from '../../redact.js';
1
3
  /** Starts after HTTP authorization. All scheduling and storage are injected.
2
4
  * Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
3
5
  * O(page size) per tick and O(1) connection state; repository effects live behind the feed port.
@@ -149,7 +151,7 @@ function createErrorReporter(optional) {
149
151
  if (optional.onError)
150
152
  optional.onError({ operation, error });
151
153
  else
152
- console.error(`[settings] change feed ${operation} failed`, error);
154
+ console.error(`[settings] change feed ${operation} failed`, redactSecrets({ input: inspect(error) }, { redactPii: false }));
153
155
  }
154
156
  catch { /* Diagnostic failures cannot interrupt stream cleanup or retries. */ }
155
157
  };
@@ -123,6 +123,7 @@ export interface SurfaceExchange {
123
123
  * When the exchange expires if nothing more happens, in epoch ms: the earlier of the idle and
124
124
  * lifetime deadlines. Activity moves the idle one, so read it right before rendering a deadline
125
125
  * for the human; a card drawn just before its `send()` shows a deadline a few ms early, never late.
126
+ * Positive infinity denotes a run-lifetime lease; renderers omit a countdown for nonfinite dates.
126
127
  */
127
128
  expiresAtMs(): number;
128
129
  }
@@ -155,6 +156,20 @@ export interface SurfaceDeliverySpec {
155
156
  channel?: SurfaceAnswerChannel;
156
157
  }
157
158
  export interface SurfaceExchangeStore {
159
+ /** Restore an unanswered call through its original executor, or observe its still-live card.
160
+ * Streams human-only emissions until the exchange settles. A late answer is delivered once to
161
+ * the replacement exchange; it is never retained, logged or used as model input. Optional for
162
+ * compatibility with host-supplied stores that do not support restoration. */
163
+ restore?(required: {
164
+ exchangeId: string;
165
+ principalId: string;
166
+ emit: SurfaceEmitter;
167
+ }, optional?: {
168
+ toolId?: string;
169
+ channel?: SurfaceAnswerChannel;
170
+ params?: Record<string, unknown>;
171
+ signal?: AbortSignal;
172
+ }): Promise<DeliverResult>;
158
173
  /**
159
174
  * Opens an exchange bound to one in-flight tool call.
160
175
  *
@@ -72,14 +72,23 @@ function freezeData(value, seen = new Set()) {
72
72
  if (value === null || typeof value !== 'object' || Object.isFrozen(value) || seen.has(value))
73
73
  return;
74
74
  seen.add(value);
75
- for (const child of Object.values(value))
76
- freezeData(child, seen);
75
+ for (const key of Reflect.ownKeys(value))
76
+ freezeData(Reflect.get(value, key), seen);
77
77
  Object.freeze(value);
78
78
  }
79
79
  /** Clone and measure before publication. Invalid data cannot partially change exposure state. */
80
80
  function pinDescriptor(descriptor) {
81
81
  try {
82
82
  const pinned = structuredClone(descriptor);
83
+ // structuredClone omits symbol keys. Batch schemas keep discovery requirements there
84
+ // so providers see a plain wire schema; detach that metadata before freezing it too.
85
+ if (descriptor.inputSchema !== null && typeof descriptor.inputSchema === 'object') {
86
+ for (const symbol of Object.getOwnPropertySymbols(descriptor.inputSchema)) {
87
+ Object.defineProperty(pinned.inputSchema, symbol, {
88
+ value: structuredClone(Reflect.get(descriptor.inputSchema, symbol)), enumerable: true,
89
+ });
90
+ }
91
+ }
83
92
  const json = JSON.stringify(pinned.inputSchema);
84
93
  const bytes = json === undefined ? 0 : new TextEncoder().encode(json).byteLength;
85
94
  freezeData(pinned);
@@ -150,7 +150,20 @@ export interface SurfaceEmission {
150
150
  * headless or synthetic execution) omits it, and a handler that finds it absent must fall back to
151
151
  * returning its surface rather than parking — see the `@jini-ai/daemon` bridge for the supplying end.
152
152
  */
153
- export type SurfaceEmitter = (emission: SurfaceEmission) => Promise<void>;
153
+ export type SurfaceEmitter = ((emission: SurfaceEmission) => Promise<void>) & {
154
+ /** Executor-owned lifetime, never supplied by a rendered card. A retry uses the original public
155
+ * tool input and re-enters the complete authorization/audit stack with a fresh human emitter. */
156
+ readonly exchangeLifetime?: {
157
+ readonly signal: AbortSignal;
158
+ readonly runId: string;
159
+ readonly toolId: string;
160
+ readonly principalId: string;
161
+ readonly retry: (required: {
162
+ emit: SurfaceEmitter;
163
+ signal: AbortSignal;
164
+ }, optional?: Record<string, never>) => Promise<void>;
165
+ };
166
+ };
154
167
  /** What a `ToolHandler` receives — everything it needs and nothing it could use to bypass the gate (no registry access, no other tools' state). */
155
168
  export interface ToolExecutionContext {
156
169
  /** The id `ToolExecutor` assigned this call — for the handler's own logging/correlation; carries no authority (the gate already ran before the handler is invoked). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/core",
3
- "version": "0.6.17",
3
+ "version": "0.6.19",
4
4
  "description": "The Jini kernel: typed DI tokens, pack composition and lifecycle, the tool registry, redaction, and origin/API-token auth primitives. Zero dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {