@jini-ai/daemon 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.
Files changed (73) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/agent-executor/claude/mcp-config.d.ts +17 -0
  3. package/dist/agent-executor/claude/mcp-config.js +29 -0
  4. package/dist/agent-executor/codex/home-config.d.ts +7 -6
  5. package/dist/agent-executor/codex/home-config.js +15 -3
  6. package/dist/agent-executor/codex/home-staging.d.ts +4 -2
  7. package/dist/agent-executor/codex/home-staging.js +5 -3
  8. package/dist/agent-executor/contracts.d.ts +15 -0
  9. package/dist/agent-executor/event-translation.js +3 -0
  10. package/dist/agent-executor/events.js +2 -1
  11. package/dist/agent-executor/gemini/tool-calls.d.ts +7 -0
  12. package/dist/agent-executor/gemini/tool-calls.js +9 -0
  13. package/dist/agent-executor/http-mcp.d.ts +23 -0
  14. package/dist/agent-executor/http-mcp.js +74 -0
  15. package/dist/agent-executor/launch.js +43 -11
  16. package/dist/agent-executor/mcp-bridge.d.ts +10 -2
  17. package/dist/agent-executor/mcp-bridge.js +2 -2
  18. package/dist/agent-executor/opencode/mcp-config.d.ts +10 -0
  19. package/dist/agent-executor/opencode/mcp-config.js +11 -0
  20. package/dist/agent-executor/opencode/tool-calls.d.ts +7 -0
  21. package/dist/agent-executor/opencode/tool-calls.js +12 -0
  22. package/dist/agent-executor/tool-call-receipts.d.ts +7 -0
  23. package/dist/agent-executor/tool-call-receipts.js +9 -0
  24. package/dist/core/read-only-tools.d.ts +2 -0
  25. package/dist/core/read-only-tools.js +19 -3
  26. package/dist/delegated-tool-bridge.js +6 -1
  27. package/dist/http/attachments.d.ts +2 -101
  28. package/dist/http/attachments.js +13 -1
  29. package/dist/http/connectors.js +3 -1
  30. package/dist/http/db-ops.js +3 -1
  31. package/dist/http/delegated-tools.d.ts +1 -1
  32. package/dist/http/delegated-tools.js +5 -3
  33. package/dist/http/frontend-control.d.ts +0 -36
  34. package/dist/http/frontend-control.js +39 -1
  35. package/dist/http/frontend-sessions.js +4 -2
  36. package/dist/http/index.d.ts +2 -1
  37. package/dist/http/index.js +1 -0
  38. package/dist/http/media.js +3 -1
  39. package/dist/http/model-proxy.js +4 -2
  40. package/dist/http/remote-run-events.js +3 -1
  41. package/dist/http/research.js +3 -2
  42. package/dist/http/runs.js +3 -1
  43. package/dist/http/surface-restoration.d.ts +17 -0
  44. package/dist/http/surface-restoration.js +50 -0
  45. package/dist/http/terminals.js +3 -1
  46. package/dist/http/tool-catalog.d.ts +15 -1
  47. package/dist/http/tool-execution-routes.d.ts +3 -1
  48. package/dist/http/tool-execution-routes.js +4 -3
  49. package/dist/http/xai.js +3 -2
  50. package/dist/idle-monitor.d.ts +29 -0
  51. package/dist/idle-monitor.js +64 -0
  52. package/dist/image-prompt-delivery.d.ts +1 -1
  53. package/dist/image-prompt-delivery.js +4 -3
  54. package/dist/index.d.ts +1 -0
  55. package/dist/index.js +1 -0
  56. package/dist/legacy-data-migration.js +3 -1
  57. package/dist/remote-attachment-lookup.d.ts +3 -1
  58. package/dist/remote-attachment-lookup.js +2 -2
  59. package/dist/routines/scheduler.js +6 -4
  60. package/dist/routing-tool-executor.d.ts +6 -1
  61. package/dist/routing-tool-executor.js +15 -2
  62. package/dist/run-lifecycle.d.ts +0 -16
  63. package/dist/run-lifecycle.js +24 -6
  64. package/dist/surface-exchanges.d.ts +5 -1
  65. package/dist/surface-exchanges.js +41 -7
  66. package/dist/surface-restoration.d.ts +38 -0
  67. package/dist/surface-restoration.js +152 -0
  68. package/dist/tool-recovery/index.d.ts +1 -0
  69. package/dist/tool-recovery/index.js +1 -0
  70. package/dist/tool-recovery/stack.js +2 -1
  71. package/dist/tool-recovery/surface-lifetime.d.ts +11 -0
  72. package/dist/tool-recovery/surface-lifetime.js +41 -0
  73. package/package.json +14 -14
@@ -31,6 +31,8 @@ export function principalIsReadOnlyConstrained({ principal }, _optional = {}) {
31
31
  /**
32
32
  * Hide writes and unclassified tools from a constrained caller's search and schema discovery.
33
33
  * Uses the same classification as dispatch; discovery never grants execution authority.
34
+ * Guidance entries (`targetToolIds`) are shown only when every target is allowed; the catalog is
35
+ * told the allowed ids so an overview can list just those.
34
36
  * @param required - Per-call identity, live descriptors, and the existing catalog owner.
35
37
  * @returns A filtered catalog, or the original catalog for an unconstrained caller.
36
38
  * @example filterReadOnlyToolCatalog({ principal, registry, catalog });
@@ -49,13 +51,27 @@ export function filterReadOnlyToolCatalog(required, _optional = {}) {
49
51
  .filter(hit => allowed.has(hit.id)).slice(0, limit)
50
52
  .map(hit => ({ ...hit, ...toolDiscoveryMetadata({ descriptor: allowed.get(hit.id) }) }));
51
53
  },
52
- describe(input) {
53
- const descriptor = allowedTools().get(input.id);
54
- const entry = descriptor ? required.catalog.describe(input) : null;
54
+ describe(input, options = {}) {
55
+ const allowed = allowedTools();
56
+ const descriptor = allowed.get(input.id);
57
+ // A caller may further narrow discovery; read-only attenuation must never widen that set.
58
+ const visible = new Map([...allowed].filter(([id]) => !options.visibleToolIds || options.visibleToolIds.has(id)));
59
+ if (!descriptor)
60
+ return readOnlyGuidance({ entry: required.catalog.describe(input, { visibleToolIds: new Set(visible.keys()) }), allowed: visible });
61
+ const entry = required.catalog.describe(input, options);
55
62
  return entry ? { ...entry, ...toolDiscoveryMetadata({ descriptor }) } : null;
56
63
  },
57
64
  };
58
65
  }
66
+ // An id that is not a registered read-only tool can still name guidance (a retired alias, a
67
+ // namespace overview). Guidance is judged by what it points at, never by its own id; guidance
68
+ // without declared targets cannot be judged, so it stays hidden (fail closed).
69
+ function readOnlyGuidance({ entry, allowed }) {
70
+ const targets = entry?.targetToolIds;
71
+ if (!targets || targets.length === 0)
72
+ return null;
73
+ return targets.every(id => allowed.has(id)) ? entry : null;
74
+ }
59
75
  /** @returns Required host wording for a registered non-read-only tool. */
60
76
  export function readOnlyToolRefusalMessage({ toolId, messages }, _optional = {}) {
61
77
  return messages.toolRefusalMessage({ toolId });
@@ -64,6 +64,10 @@ export { serializeDelegatedToolOutput, resultContent } from './tool-result-media
64
64
  function linkCancellation({ lifecycle, runId }, { signal }) {
65
65
  const controller = new AbortController();
66
66
  const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => controller.abort() });
67
+ const terminalWatch = new AbortController();
68
+ // A CLI can time out/exit without requesting cancellation. Its unanswered cards must stop
69
+ // holding the daemon busy, while the exchange owner retains their safe restoration handle.
70
+ void lifecycle.waitForTerminal({ runId }, { signal: terminalWatch.signal }).then(() => controller.abort(), () => { });
67
71
  const abortFromTransport = () => controller.abort();
68
72
  if (signal?.aborted)
69
73
  controller.abort();
@@ -73,6 +77,7 @@ function linkCancellation({ lifecycle, runId }, { signal }) {
73
77
  controller,
74
78
  dispose: () => {
75
79
  unsubscribeCancel();
80
+ terminalWatch.abort();
76
81
  signal?.removeEventListener('abort', abortFromTransport);
77
82
  },
78
83
  };
@@ -209,7 +214,7 @@ export function createDelegatedToolBridge(requiredArgs, optionalArgs = {}) {
209
214
  const call = { lifecycle, runId, toolUseId, ...(options.delegatedResultCorrelation ? { delegatedResultCorrelation: options.delegatedResultCorrelation } : {}) };
210
215
  await lifecycle.emit({ runId: runId, input: {
211
216
  event: 'agent',
212
- data: { type: 'tool_use', id: toolUseId, name: toolId, input },
217
+ data: { type: 'tool_use', id: toolUseId, name: toolId, input, ...{ toolOrigin: 'bridge' } },
213
218
  } });
214
219
  const cancellation = linkCancellation(call, invocation);
215
220
  const seam = createSurfaceSeam(call);
@@ -1,104 +1,3 @@
1
- /**
2
- * @module attachments
3
- *
4
- * `POST /api/attachments`, `DELETE /api/attachments` — an HTTP route pack for user file/image
5
- * uploads staged for one agent run, over a narrow, transport-owned `AttachmentStore` port plus a
6
- * batteries-included disk implementation (`createDiskAttachmentStore`).
7
- *
8
- * **The capability this closes.** A chat composer that accepts drag-and-drop needs somewhere to put
9
- * the bytes, a way to hand the agent a path it is actually allowed to read, and a guarantee that
10
- * those bytes disappear when the run ends. That is three separate concerns (an upload endpoint, a
11
- * trusted-metadata registry, and a run-scoped lifetime) which every host would otherwise hand-roll.
12
- * This module owns all three; `@jini-ai/chat-react`'s `createDaemonAttachmentUploader` is the
13
- * matching client half.
14
- *
15
- * **Trust model.** A renderer supplies bytes, a filename, and a batch id. None of that is trusted:
16
- * - the filename is reduced to a basename over a conservative character allowlist
17
- * (`sanitizeAttachmentName`) and is only ever used as *display* text plus a short extension
18
- * suffix — never as the stored filename, which is a fresh `randomUUID()`;
19
- * - the `kind` (`'image' | 'file'`) is sniffed from the leading bytes (`detectAttachmentKind`),
20
- * never taken from a renderer-controlled MIME type or file extension;
21
- * - the byte count is measured while streaming, never read from a client-supplied length;
22
- * - the returned `path` is an opaque `attachment:<uuid>` capability id, not a filesystem path, so a
23
- * renderer never learns where the upload root is and cannot name a file it did not upload.
24
- * `claim()` is what exchanges those ids for real paths, server-side.
25
- *
26
- * **What `claim()` guarantees.** Exactly once per registered attachment, and only if the file is
27
- * still the same file: `lstat` must report a regular non-symlink whose `dev`/`ino`/`size` and
28
- * recorded modification/change timestamps match, and `realpath` must equal the recorded path
29
- * (so neither the file nor any parent directory was swapped for a symlink between upload and run
30
- * start). Legacy sidecars establish their timestamp baseline on first verified read. Every
31
- * claimed attachment must belong to one batch, so the single `batchDirectory` a host grants the
32
- * agent read access to cannot be widened by mixing batches.
33
- *
34
- * **Ownership is opt-in and host-attached, not self-asserted.** `register()`'s optional `ownerId`
35
- * (and `listPendingForOwner`, the discovery method it enables) is never read from anything a
36
- * renderer sends — a host wires `AttachmentsHttpDeps.resolveOwnerId` to pull it from a channel IT
37
- * already trusts (its own session-verified principal header, say), never from `req` unauthenticated.
38
- * A store with no `resolveOwnerId` wired registers every attachment ownerless, and
39
- * `listPendingForOwner` never returns an ownerless record for any caller — see that method's own doc
40
- * for why an absent `ownerId` must never become a wildcard match.
41
- *
42
- * **Storage lifetime is daemon-lifetime by default, and opt-in restart-surviving.** With
43
- * `retainAcrossRestarts` left off (the default, and this pack's only behavior before that option
44
- * existed), `createDiskAttachmentStore` empties its upload directory on construction: files left
45
- * behind by an interrupted previous process cannot be authenticated against an in-memory registry
46
- * that no longer exists, so they are removed rather than adopted. Unclaimed uploads also expire by
47
- * TTL (`pruneExpired`), and a run's claimed files are deleted by `cleanupRun`.
48
- *
49
- * That default is wrong for any host whose process restarts while a person is still using the
50
- * composer. A file-watching dev server restarting the daemon on an unrelated source edit is not a
51
- * crash, but the construction wipe cannot tell the two apart, so it destroys uploads the user is
52
- * about to send — real data loss, not a tidy-up. `retainAcrossRestarts: true` replaces "wipe
53
- * everything, always" with "adopt exactly what can still be authenticated": see
54
- * {@link prepareAttachmentStorage} for the whole mechanism and {@link loadPersistedAttachments} for
55
- * why an adopted record is no less trusted than one this process registered itself.
56
- *
57
- * **This pack does not auto-wire itself into a run's lifecycle**, because no generic hook for that
58
- * exists — the same deliberate choice `@jini-ai/daemon`'s `createRunScopedContextStore` makes. A
59
- * host claims in its own `onRunStarted` and cleans up in a `finally`, roughly:
60
- *
61
- * ```ts
62
- * const store = await createDiskAttachmentStore({ uploadDirectory });
63
- * // ... httpExtensions: [(app, { adapter }) => registerAttachmentRoutes({ app, deps: { store }, adapter })]
64
- * onRunStarted: (context) => {
65
- * void (async () => {
66
- * try {
67
- * const claimed = await store.claim({ attachments: attachmentRefsFrom(context.request), runId: context.run.id });
68
- * await executor.run({
69
- * runId: context.run.id,
70
- * // ... prompt, cwd, agentId
71
- * }, {
72
- * ...(claimed.batchDirectory === undefined ? {} : {
73
- * imagePaths: claimed.attachments.filter((a) => a.kind === 'image').map((a) => a.path),
74
- * extraAllowedDirs: [claimed.batchDirectory],
75
- * uploadRoot: claimed.batchDirectory,
76
- * }),
77
- * });
78
- * } finally {
79
- * await store.cleanupRun({ runId: context.run.id });
80
- * }
81
- * })();
82
- * }
83
- * ```
84
- *
85
- * `imagePaths`/`extraAllowedDirs`/`uploadRoot` are pre-existing `AgentExecutor.run()` options; this
86
- * module only produces real values for them.
87
- *
88
- * **Body-parser ordering (`POST` reads the raw request stream).** The upload route streams
89
- * `request` straight to disk, so any body-parsing middleware that has already consumed the stream
90
- * leaves nothing to write. The classic way to hit this is a JSON body parser mounted app-wide: a
91
- * user drops a `.json` file, the browser sets `content-type: application/json`, and
92
- * `express.json()` eats the body — which without a guard shows up as the deeply unhelpful
93
- * "attachment is empty". `registerAttachmentRoutes` detects an already-consumed stream up front and
94
- * reports it as a host misconfiguration (`'attachment-body-consumed'`) instead of failing silently.
95
- * Mount this pack before any global body parser, or scope the parser so it skips this path.
96
- *
97
- * **SEC-005 redaction**: a store failure can carry filesystem paths a caller must not see, so
98
- * anything that is not an explicitly-classified `AttachmentRejectedError` becomes a
99
- * correlation-id-bearing generic `INTERNAL_ERROR`, with the real error reaching a host-owned sink —
100
- * the same `reportInternalError` shape `media.ts`/`delegated-tools.ts` use.
101
- */
102
1
  import { type FileHandle } from 'node:fs/promises';
103
2
  import type { Express, Request, Response } from 'express';
104
3
  import type { AdapterContext } from '@jini-ai/http-kit';
@@ -688,6 +587,8 @@ export interface AttachmentsInternalErrorContext {
688
587
  }
689
588
  export interface AttachmentsHttpDeps {
690
589
  readonly store: AttachmentStore;
590
+ /** Host busy lease follows handler completion, including cleanup after an aborted response. */
591
+ readonly acquireActivity?: () => () => void;
691
592
  /**
692
593
  * Uploads accepted at once before further requests are rate-limited. Bounds concurrent
693
594
  * filesystem work, which matters because each in-flight upload holds an open handle and may be
@@ -99,6 +99,8 @@
99
99
  * correlation-id-bearing generic `INTERNAL_ERROR`, with the real error reaching a host-owned sink —
100
100
  * the same `reportInternalError` shape `media.ts`/`delegated-tools.ts` use.
101
101
  */
102
+ import { inspect } from 'node:util';
103
+ import { redactSecrets } from '@jini-ai/core';
102
104
  import { chmod, copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, rmdir } from 'node:fs/promises';
103
105
  import { randomUUID } from 'node:crypto';
104
106
  import { basename, dirname, extname, resolve } from 'node:path';
@@ -971,7 +973,7 @@ export async function createDiskAttachmentStore(requiredArgs, optionalArgs = {})
971
973
  export const ATTACHMENTS_ROUTE_PATH = '/api/attachments';
972
974
  function defaultInternalErrorSink(context) {
973
975
  // eslint-disable-next-line no-console
974
- console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
976
+ console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
975
977
  }
976
978
  function reportInternalError(deps, source, error, batchId) {
977
979
  const correlationId = randomUUID();
@@ -1135,7 +1137,9 @@ export function registerAttachmentRoutes({ app, deps, adapter }, _optional = {})
1135
1137
  // second host) must not share one upload budget.
1136
1138
  const state = { activeUploads: 0 };
1137
1139
  app.post(ATTACHMENTS_ROUTE_PATH, async (req, res) => {
1140
+ let release;
1138
1141
  try {
1142
+ release = deps.acquireActivity?.();
1139
1143
  if (!passesOriginGuard(req, res, deps, adapter))
1140
1144
  return;
1141
1145
  await handleAttachmentUpload({ req, res, deps, state });
@@ -1151,9 +1155,14 @@ export function registerAttachmentRoutes({ app, deps, adapter }, _optional = {})
1151
1155
  if (!res.headersSent)
1152
1156
  respondToUploadFailure(res, deps, error, null, 'attachment-upload');
1153
1157
  }
1158
+ finally {
1159
+ release?.();
1160
+ }
1154
1161
  });
1155
1162
  app.delete(ATTACHMENTS_ROUTE_PATH, async (req, res) => {
1163
+ let release;
1156
1164
  try {
1165
+ release = deps.acquireActivity?.();
1157
1166
  if (!passesOriginGuard(req, res, deps, adapter))
1158
1167
  return;
1159
1168
  await handleAttachmentCleanup({ req, res, deps });
@@ -1162,6 +1171,9 @@ export function registerAttachmentRoutes({ app, deps, adapter }, _optional = {})
1162
1171
  if (!res.headersSent)
1163
1172
  sendApiError({ res, status: 500, error: reportInternalError(deps, 'attachment-cleanup', error, null) });
1164
1173
  }
1174
+ finally {
1175
+ release?.();
1176
+ }
1165
1177
  });
1166
1178
  }
1167
1179
  //# sourceMappingURL=attachments.js.map
@@ -44,6 +44,8 @@
44
44
  * to the provider.
45
45
  * - `RealtimeProvider.subscribe` has no route here — see the realtime section below for why.
46
46
  */
47
+ import { inspect } from 'node:util';
48
+ import { redactSecrets } from '@jini-ai/core';
47
49
  import { randomUUID } from 'node:crypto';
48
50
  import { createApiError } from '@jini-ai/protocol';
49
51
  import { ClientFacingError, defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
@@ -53,7 +55,7 @@ import { err, ok } from '@jini-ai/http-kit';
53
55
  const BODY_NOT_OBJECT_MESSAGE = 'body must be a JSON object';
54
56
  function defaultInternalErrorSink(context) {
55
57
  // eslint-disable-next-line no-console
56
- console.error(`[@jini-ai/http-kit] internal error (connectors/${context.source}.${context.operation}, correlationId=${context.correlationId})`, context.error);
58
+ console.error(`[@jini-ai/http-kit] internal error (connectors/${context.source}.${context.operation}, correlationId=${context.correlationId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
57
59
  }
58
60
  function notConfiguredError(capability) {
59
61
  return createApiError({ code: 'NOT_CONFIGURED', message: `${capability} provider not configured` });
@@ -36,6 +36,8 @@
36
36
  * boundary — see {@link denyAllDaemonDbPolicy}'s doc for why the default
37
37
  * policy denies every call rather than allowing one.
38
38
  */
39
+ import { inspect } from 'node:util';
40
+ import { redactSecrets } from '@jini-ai/core';
39
41
  import { randomUUID } from 'node:crypto';
40
42
  import { createApiError } from '@jini-ai/protocol';
41
43
  import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
@@ -116,7 +118,7 @@ export function createDaemonDbToolRegistrations(requiredArgs, optionalArgs = {})
116
118
  }
117
119
  function defaultDaemonDbInternalErrorSink(context) {
118
120
  // eslint-disable-next-line no-console
119
- console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
121
+ console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
120
122
  }
121
123
  /** Every DB-ops call gets its own opaque, single-use `RunRef` — these are one-off operations requests, not steps of a longer-lived agent run, so there is no real run to attach the audit trail to. */
122
124
  function freshRun() {
@@ -70,7 +70,7 @@ export interface DelegatedToolsHttpDeps {
70
70
  * call it cannot verify is refused outright (the configured refusal message), never
71
71
  * waived. Unconstrained calls behave identically with or without it.
72
72
  */
73
- readonly toolRegistry?: ToolRegistry;
73
+ readonly toolRegistry?: Pick<ToolRegistry, 'list'>;
74
74
  /**
75
75
  * Host opt-in: returns `true` for a `failed` result whose `error` text the HOST has already made
76
76
  * safe to show the model — secret values blanked, an error id minted — so this route answers it
@@ -1,3 +1,4 @@
1
+ import { inspect } from 'node:util';
1
2
  /**
2
3
  * @module delegated-tools
3
4
  *
@@ -27,7 +28,7 @@
27
28
  */
28
29
  import { randomUUID } from 'node:crypto';
29
30
  import { createApiError } from '@jini-ai/protocol';
30
- import { batchOutcomeFor, ToolInputError } from '@jini-ai/core';
31
+ import { redactSecrets, batchOutcomeFor, ToolInputError } from '@jini-ai/core';
31
32
  import { createDelegatedToolBridge } from '../index.js';
32
33
  import { defineJsonRoute, err, mountJsonRoute, ok, validationError } from '@jini-ai/http-kit';
33
34
  import * as readOnlyTools from '../core/read-only-tools.js';
@@ -44,8 +45,9 @@ function checkReadOnlyConstraint(deps, input) {
44
45
  }
45
46
  /** Logs the real failure server-side and returns the generic, correlation-id-bearing public error (SEC-005: never the raw exception). */
46
47
  function defaultInternalErrorSink(context) {
48
+ // Exceptions can echo credentials; inspect preserves stacks and codes before shared redaction.
47
49
  // eslint-disable-next-line no-console
48
- console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
50
+ console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
49
51
  }
50
52
  /**
51
53
  * The host's {@link DelegatedToolsHttpDeps.describeInternalError} text for `context`, or `undefined`
@@ -62,7 +64,7 @@ function describeSafely(deps, context) {
62
64
  }
63
65
  catch (error) {
64
66
  // eslint-disable-next-line no-console
65
- console.error(`[@jini-ai/http-kit] describeInternalError threw (correlationId=${context.correlationId})`, error);
67
+ console.error(`[@jini-ai/http-kit] describeInternalError threw (correlationId=${context.correlationId})`, redactSecrets({ input: inspect(error) }, { redactPii: false }));
66
68
  return undefined;
67
69
  }
68
70
  }
@@ -1,39 +1,3 @@
1
- /**
2
- * @module frontend-control
3
- *
4
- * Assembles the three halves of agent-driven frontend control into one thing a host can wire up as
5
- * a config entry rather than a checklist.
6
- *
7
- * The parts already existed and none of them composed: `@jini-ai/daemon`'s `FrontendSessionRegistry`
8
- * addresses a run to a surface, this package's own frontend-session routes carry invocations to a
9
- * browser and answers back, and `createFrontendCapabilityRegistrations` projects a manifest into
10
- * gated tools. A host had to construct all three, keep them consistent, and remember to bind a run
11
- * when it started. Every one of those is a chance to get it subtly wrong, and one of them —
12
- * binding — is a security decision.
13
- *
14
- * **Moved here from `@jini-ai/server` (2026-07-31).** It never needed that package: its real
15
- * dependencies are `core`, `daemon`, `express` and this module's own siblings, all of which
16
- * `http-kit` already has. The only thing pinning it there was a *type-only* import of
17
- * `LocalNodeHttpExtension` — so a helper any Express host could use sat behind a package that also
18
- * pulls in `@jini-ai/sidecar` and `better-sqlite3`. The reference implementation hit exactly this
19
- * and hand-reimplemented the composition instead. `@jini-ai/server` re-exports it, so existing
20
- * imports keep working.
21
- *
22
- * **This facade never returns the registry**, and that is its most important property. The
23
- * registry's `invoke` executes a capability on a real user's screen with no policy check, no
24
- * confirmation, no timeout, and no audit record — it is safe only because the sole thing that can
25
- * reach it is a `ToolHandler` that `ToolExecutor` already gated. Handing a host the registry so it
26
- * could "wire something custom" would hand it a bypass, and a bypass that exists gets used. The
27
- * host gets an HTTP extension, a set of registrations, and a bind hook; none of them can execute a
28
- * capability directly.
29
- *
30
- * **The bind token is not a field on the run DTO.** A run is a neutral kernel noun and a browser
31
- * surface is not; putting `frontendBindToken` on `RunCreateRequest` would push a browser concept
32
- * into a protocol every consumer shares, to serve one transport. Instead the host supplies
33
- * {@link CreateFrontendControlOptions.resolveBindToken}, which reads the token out of whatever
34
- * envelope that host already uses — `contextRef` is an opaque host blob precisely so hosts can put
35
- * their own metadata in it. The engine stays neutral and the host keeps one place to look.
36
- */
37
1
  import type { Express } from 'express';
38
2
  import type { FrontendSessionOwner } from '../frontend-session-registry.js';
39
3
  import type { FrontendSessionsHttpDeps } from './frontend-sessions.js';
@@ -1,8 +1,46 @@
1
+ /**
2
+ * @module frontend-control
3
+ *
4
+ * Assembles the three halves of agent-driven frontend control into one thing a host can wire up as
5
+ * a config entry rather than a checklist.
6
+ *
7
+ * The parts already existed and none of them composed: `@jini-ai/daemon`'s `FrontendSessionRegistry`
8
+ * addresses a run to a surface, this package's own frontend-session routes carry invocations to a
9
+ * browser and answers back, and `createFrontendCapabilityRegistrations` projects a manifest into
10
+ * gated tools. A host had to construct all three, keep them consistent, and remember to bind a run
11
+ * when it started. Every one of those is a chance to get it subtly wrong, and one of them —
12
+ * binding — is a security decision.
13
+ *
14
+ * **Moved here from `@jini-ai/server` (2026-07-31).** It never needed that package: its real
15
+ * dependencies are `core`, `daemon`, `express` and this module's own siblings, all of which
16
+ * `http-kit` already has. The only thing pinning it there was a *type-only* import of
17
+ * `LocalNodeHttpExtension` — so a helper any Express host could use sat behind a package that also
18
+ * pulls in `@jini-ai/sidecar` and `better-sqlite3`. The reference implementation hit exactly this
19
+ * and hand-reimplemented the composition instead. `@jini-ai/server` re-exports it, so existing
20
+ * imports keep working.
21
+ *
22
+ * **This facade never returns the registry**, and that is its most important property. The
23
+ * registry's `invoke` executes a capability on a real user's screen with no policy check, no
24
+ * confirmation, no timeout, and no audit record — it is safe only because the sole thing that can
25
+ * reach it is a `ToolHandler` that `ToolExecutor` already gated. Handing a host the registry so it
26
+ * could "wire something custom" would hand it a bypass, and a bypass that exists gets used. The
27
+ * host gets an HTTP extension, a set of registrations, and a bind hook; none of them can execute a
28
+ * capability directly.
29
+ *
30
+ * **The bind token is not a field on the run DTO.** A run is a neutral kernel noun and a browser
31
+ * surface is not; putting `frontendBindToken` on `RunCreateRequest` would push a browser concept
32
+ * into a protocol every consumer shares, to serve one transport. Instead the host supplies
33
+ * {@link CreateFrontendControlOptions.resolveBindToken}, which reads the token out of whatever
34
+ * envelope that host already uses — `contextRef` is an opaque host blob precisely so hosts can put
35
+ * their own metadata in it. The engine stays neutral and the host keeps one place to look.
36
+ */
37
+ import { inspect } from 'node:util';
38
+ import { redactSecrets } from '@jini-ai/core';
1
39
  import { createFrontendCapabilityRegistrations, createFrontendSessionRegistry, } from '../index.js';
2
40
  import { registerFrontendSessionRoutes } from './frontend-sessions.js';
3
41
  function defaultBindErrorSink(context) {
4
42
  // eslint-disable-next-line no-console
5
- console.error(`[@jini-ai/server] could not bind run "${context.runId}" to a frontend surface`, context.error);
43
+ console.error(`[@jini-ai/server] could not bind run "${context.runId}" to a frontend surface`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
6
44
  }
7
45
  /**
8
46
  * Builds the frontend-control bundle.
@@ -28,6 +28,8 @@
28
28
  * composition root can make (it is the thing that starts runs), so it calls `registry.bindRun`
29
29
  * directly — see `@jini-ai/daemon`'s own module doc for why attach and bind are separate.
30
30
  */
31
+ import { inspect } from 'node:util';
32
+ import { redactSecrets } from '@jini-ai/core';
31
33
  import { randomUUID } from 'node:crypto';
32
34
  import { createApiError } from '@jini-ai/protocol';
33
35
  import { FrontendSessionCapacityError } from '../frontend-session-registry.js';
@@ -45,12 +47,12 @@ function reportInternalError(deps, source, error) {
45
47
  deps.onInternalError({ source, error });
46
48
  // eslint-disable-next-line no-console
47
49
  else
48
- console.error(`[@jini-ai/http-kit] internal error (frontend-sessions:${source})`, error);
50
+ console.error(`[@jini-ai/http-kit] internal error (frontend-sessions:${source})`, redactSecrets({ input: inspect(error) }, { redactPii: false }));
49
51
  }
50
52
  catch (sinkError) {
51
53
  // A diagnostic sink must never turn a contained failure into an unhandled rejection of its own.
52
54
  // eslint-disable-next-line no-console
53
- console.error(`[@jini-ai/http-kit] internal error sink failed (frontend-sessions:${source})`, sinkError);
55
+ console.error(`[@jini-ai/http-kit] internal error sink failed (frontend-sessions:${source})`, redactSecrets({ input: inspect(sinkError) }, { redactPii: false }));
54
56
  }
55
57
  }
56
58
  /**
@@ -21,7 +21,7 @@ export type { RoutineDeleteResponse, RoutineHttpDeps, RoutineListResponse, Routi
21
21
  export { registerRoutineRoutes, routineCreateRoute, routineDeleteRoute, routineGetRoute, routineListRoute, routineRunNowRoute, routineRunsListRoute, routineUpdateRoute, } from './routines.js';
22
22
  export type { CreateDaemonDbToolRegistrationsOptions, DaemonDbHttpDeps, DaemonDbInternalErrorContext, DaemonDbOperations, DaemonDbStatusReport, DaemonDbTableInfo, DaemonDbToolRegistrations, DaemonDbVacuumResult, DbIntegrityIssue, DbIntegrityIssueKind, DbIntegrityReport, } from './db-ops.js';
23
23
  export { createDaemonDbToolRegistrations, daemonDbInspectRoute, daemonDbVacuumRoute, daemonDbVerifyRoute, DB_INSPECT_TOOL_ID, DB_VACUUM_TOOL_ID, DB_VERIFY_TOOL_ID, denyAllDaemonDbPolicy, registerDaemonDbRoutes, } from './db-ops.js';
24
- export type { ToolCatalogEntry, ToolCatalogHttpDeps, ToolCatalogQuery, ToolCatalogSearchHit, } from './tool-catalog.js';
24
+ export type { ToolCatalogEntry, ToolCatalogDescribeOptions, ToolCatalogHttpDeps, ToolCatalogQuery, ToolCatalogSearchHit, } from './tool-catalog.js';
25
25
  export { registerToolCatalogRoutes, toolCatalogDescribeRoute, toolCatalogSearchRoute, } from './tool-catalog.js';
26
26
  export type { ComponentCatalogEntry, ComponentCatalogHttpDeps, ComponentCatalogQuery, ComponentCatalogSearchHit, } from './component-catalog.js';
27
27
  export { registerComponentCatalogRoutes, componentCatalogDescribeRoute, componentCatalogSearchRoute, } from './component-catalog.js';
@@ -58,4 +58,5 @@ export type { RemoteAttachmentLookupOptional, RemoteAttachmentLookupRequired } f
58
58
  export { createRemoteAttachmentLookup } from '../remote-attachment-lookup.js';
59
59
  export { createTavilyResearchProvider } from './research-tavily.adapter.js';
60
60
  export { createFrontendGuestScope } from './frontend-guest-scope.js';
61
+ export { tryRestoreSurface } from './surface-restoration.js';
61
62
  //# sourceMappingURL=index.d.ts.map
@@ -31,4 +31,5 @@ export { ATTACHMENT_LOOKUP_ROUTE_PATH, registerAttachmentLookupRoutes } from './
31
31
  export { createRemoteAttachmentLookup } from '../remote-attachment-lookup.js';
32
32
  export { createTavilyResearchProvider } from './research-tavily.adapter.js';
33
33
  export { createFrontendGuestScope } from './frontend-guest-scope.js';
34
+ export { tryRestoreSurface } from './surface-restoration.js';
34
35
  //# sourceMappingURL=index.js.map
@@ -39,6 +39,8 @@
39
39
  * itself can throw before ever creating a task — an unregistered `toolId`-shaped programming
40
40
  * error, per `MediaDispatchEngine.generate`'s own contract).
41
41
  */
42
+ import { inspect } from 'node:util';
43
+ import { redactSecrets } from '@jini-ai/core';
42
44
  import { randomUUID } from 'node:crypto';
43
45
  import { createApiError } from '@jini-ai/protocol';
44
46
  import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
@@ -49,7 +51,7 @@ const AUDIO_KINDS = new Set(['music', 'speech', 'sfx']);
49
51
  const SPEECH_FORMATS = new Set(['mp3', 'wav', 'flac', 'aac', 'opus']);
50
52
  function defaultInternalErrorSink(context) {
51
53
  // eslint-disable-next-line no-console
52
- console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
54
+ console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
53
55
  }
54
56
  function reportInternalError(deps, source, error, taskId, ownerRef) {
55
57
  const correlationId = randomUUID();
@@ -81,6 +81,8 @@
81
81
  * over routing for the five providers that already have a dedicated, more
82
82
  * specifically-typed path.
83
83
  */
84
+ import { inspect } from 'node:util';
85
+ import { redactSecrets } from '@jini-ai/core';
84
86
  import { randomUUID } from 'node:crypto';
85
87
  import { runAnthropicToolTurn, runAzureToolTurn, runGoogleToolTurn, runOllamaToolTurn, runOpenAiToolTurn, } from '@jini-ai/agent-runtime';
86
88
  import { createApiError } from '@jini-ai/protocol';
@@ -92,7 +94,7 @@ import { err, ok } from '@jini-ai/http-kit';
92
94
  import { firstOptionalFieldError, isArray, isNumber, isRecord, isString, nonEmptyString, pickDefined, requiredFieldError, } from './model-proxy-fields.js';
93
95
  function defaultInternalErrorSink(context) {
94
96
  // eslint-disable-next-line no-console
95
- console.error(`[@jini-ai/http-kit] internal error (model-proxy/${context.provider}, correlationId=${context.correlationId})`, context.error);
97
+ console.error(`[@jini-ai/http-kit] internal error (model-proxy/${context.provider}, correlationId=${context.correlationId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
96
98
  }
97
99
  const TOOLS_CHECK = ['tools', isArray, 'tools must be an array when provided'];
98
100
  const SYSTEM_CHECK = ['system', isString, 'system must be a string when provided'];
@@ -382,7 +384,7 @@ function reportCatchAllError(providerParam, onInternalError, error) {
382
384
  }
383
385
  else {
384
386
  // eslint-disable-next-line no-console
385
- console.error(`[@jini-ai/http-kit] internal error (model-proxy/${providerParam || 'unknown'}, correlationId=${correlationId})`, error);
387
+ console.error(`[@jini-ai/http-kit] internal error (model-proxy/${providerParam || 'unknown'}, correlationId=${correlationId})`, redactSecrets({ input: inspect(error) }, { redactPii: false }));
386
388
  }
387
389
  return correlationId;
388
390
  }
@@ -22,6 +22,8 @@
22
22
  * would also present, and this route grants direct run-event-injection, a materially different
23
23
  * capability from what the general API token was designed to gate.
24
24
  */
25
+ import { inspect } from 'node:util';
26
+ import { redactSecrets } from '@jini-ai/core';
25
27
  import { randomUUID } from 'node:crypto';
26
28
  import { createApiError, isTerminalRunState } from '@jini-ai/protocol';
27
29
  import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
@@ -31,7 +33,7 @@ import { sendApiError, statusForError } from '@jini-ai/http-kit';
31
33
  import { err, ok } from '@jini-ai/http-kit';
32
34
  function defaultInternalErrorSink(context) {
33
35
  // eslint-disable-next-line no-console
34
- console.error(`[@jini-ai/http-kit] internal error (remote-run-events/${context.source}, correlationId=${context.correlationId})`, context.error);
36
+ console.error(`[@jini-ai/http-kit] internal error (remote-run-events/${context.source}, correlationId=${context.correlationId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
35
37
  }
36
38
  const DEFAULT_TOKEN_ENV_VAR = 'JINI_REMOTE_TOOL_BRIDGE_TOKEN';
37
39
  /**
@@ -1,4 +1,3 @@
1
- import { redactSecrets } from '@jini-ai/core';
2
1
  /**
3
2
  * @module research
4
3
  *
@@ -95,6 +94,8 @@ import { redactSecrets } from '@jini-ai/core';
95
94
  * token out of any upstream error text before it is even logged, belt-and-braces alongside the
96
95
  * generic-message substitution.
97
96
  */
97
+ import { inspect } from 'node:util';
98
+ import { redactSecrets } from '@jini-ai/core';
98
99
  import { randomUUID } from 'node:crypto';
99
100
  import { createTavilyResearchProvider } from './research-tavily.adapter.js';
100
101
  import { createApiError } from '@jini-ai/protocol';
@@ -103,7 +104,7 @@ import { defineJsonRoute, err, mountJsonRoute, ok, validationError } from '@jini
103
104
  const MAX_QUERY_LENGTH = 1000;
104
105
  function defaultInternalErrorSink(context) {
105
106
  // eslint-disable-next-line no-console
106
- console.error(`[@jini-ai/http-kit] internal error (research/search, correlationId=${context.correlationId})`, context.error);
107
+ console.error(`[@jini-ai/http-kit] internal error (research/search, correlationId=${context.correlationId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
107
108
  }
108
109
  /**
109
110
  * Default `ResearchHttpDeps.resolveCredentials`: reads `TAVILY_API_KEY` from the environment.
package/dist/http/runs.js CHANGED
@@ -3,13 +3,15 @@
3
3
  * agent, tool, or product vocabulary: a host optionally supplies `onStarted`
4
4
  * to attach its chosen driver after the lifecycle has durably recorded start.
5
5
  */
6
+ import { inspect } from 'node:util';
7
+ import { redactSecrets } from '@jini-ai/core';
6
8
  import { randomUUID } from 'node:crypto';
7
9
  import { createApiError } from '@jini-ai/protocol';
8
10
  import { createSseChannel, defineJsonRoute, err, mountJsonRoute, ok, requestedAfterCursor, sendApiError, sendRawApiError, validationError, } from '@jini-ai/http-kit';
9
11
  /** Default sink when a host does not supply `onInternalError`: still observable, never silent. */
10
12
  function defaultInternalErrorSink(context) {
11
13
  // eslint-disable-next-line no-console
12
- console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
14
+ console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, redactSecrets({ input: inspect(context.error) }, { redactPii: false }));
13
15
  }
14
16
  /**
15
17
  * Logs the real failure server-side and returns the generic, correlation-id-bearing public error
@@ -0,0 +1,17 @@
1
+ import type { Response } from 'express';
2
+ import type { SurfaceExchangeStore } from '@jini-ai/core';
3
+ /** Stream a restored card through the existing authenticated callback route. Only the exchange
4
+ * handle is accepted: tool identity, principal and original arguments come from the shared store.
5
+ * Returns false without writing a response when another process may own the exchange.
6
+ * @complexity O(emitted bytes); no answer or credential is retained by this adapter.
7
+ */
8
+ export declare function tryRestoreSurface({ res, store, exchangeId, principalId, toolId }: {
9
+ res: Response;
10
+ store: SurfaceExchangeStore;
11
+ exchangeId: string;
12
+ principalId: string;
13
+ toolId: string;
14
+ }, { params }?: {
15
+ params?: Record<string, unknown>;
16
+ }): Promise<boolean>;
17
+ //# sourceMappingURL=surface-restoration.d.ts.map
@@ -0,0 +1,50 @@
1
+ /** Stream a restored card through the existing authenticated callback route. Only the exchange
2
+ * handle is accepted: tool identity, principal and original arguments come from the shared store.
3
+ * Returns false without writing a response when another process may own the exchange.
4
+ * @complexity O(emitted bytes); no answer or credential is retained by this adapter.
5
+ */
6
+ export async function tryRestoreSurface({ res, store, exchangeId, principalId, toolId }, { params } = {}) {
7
+ if (!store.restore)
8
+ return false;
9
+ const controller = new AbortController();
10
+ const onClose = () => { if (!res.writableFinished)
11
+ controller.abort(); };
12
+ res.on('close', onClose);
13
+ const write = (value) => {
14
+ if (res.destroyed)
15
+ return;
16
+ if (!res.headersSent)
17
+ res.status(200).set({ 'content-type': 'application/x-ndjson', 'cache-control': 'no-store' });
18
+ res.write(`${JSON.stringify(value)}\n`);
19
+ };
20
+ // The first card frame opens the response. Subsequent heartbeats keep Node fetch's idle-body
21
+ // limit from expiring a legitimate human wait at the proxy hop.
22
+ const heartbeat = setInterval(() => { if (res.headersSent)
23
+ write({ type: 'keepalive' }); }, 15_000);
24
+ heartbeat.unref();
25
+ try {
26
+ const result = await store.restore({ exchangeId, principalId, emit: async (emission) => {
27
+ write({ type: 'surface', emission });
28
+ } }, { toolId, signal: controller.signal, ...(params === undefined ? {} : { params }) });
29
+ if (!result.ok && result.reason === 'unknown-or-closed' && !res.headersSent)
30
+ return false;
31
+ if (!result.ok && !res.headersSent) {
32
+ res.status(409).json({ code: 'SURFACE_NOT_PENDING', reason: result.reason });
33
+ return true;
34
+ }
35
+ write({ type: 'restored', ...result });
36
+ res.end();
37
+ return true;
38
+ }
39
+ catch {
40
+ // Failure messages may contain host or credential details. Restoration never reports them.
41
+ write({ type: 'restored', ok: false });
42
+ res.end();
43
+ return true;
44
+ }
45
+ finally {
46
+ clearInterval(heartbeat);
47
+ res.off('close', onClose);
48
+ }
49
+ }
50
+ //# sourceMappingURL=surface-restoration.js.map