@jini-ai/daemon 0.6.15-rc.2 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -4
  3. package/dist/agent-executor/codex/home-config.js +3 -2
  4. package/dist/agent-executor/codex/home-staging.js +2 -2
  5. package/dist/agent-executor/codex/isolation.js +4 -3
  6. package/dist/agent-executor/contracts.d.ts +1 -1
  7. package/dist/agent-executor/contracts.js +0 -1
  8. package/dist/agent-executor/event-translation.d.ts +3 -3
  9. package/dist/agent-executor/event-translation.js +6 -6
  10. package/dist/agent-executor/events.js +65 -51
  11. package/dist/agent-executor/index.d.ts +5 -5
  12. package/dist/agent-executor/index.js +5 -5
  13. package/dist/agent-executor/launch.js +30 -46
  14. package/dist/agent-executor/live-session-state.js +2 -2
  15. package/dist/agent-executor/mcp-bridge.js +5 -0
  16. package/dist/agent-executor/message-relay.js +5 -6
  17. package/dist/agent-executor/run-output.js +7 -2
  18. package/dist/ai-trace-lifecycle.d.ts +1 -1
  19. package/dist/ai-trace-lifecycle.js +1 -1
  20. package/dist/close-status.js +6 -2
  21. package/dist/core/omit-keys.d.ts +7 -0
  22. package/dist/core/omit-keys.js +12 -0
  23. package/dist/core/read-only-tools.js +5 -1
  24. package/dist/core/tool-audit.js +11 -7
  25. package/dist/core/tool-gaps.js +1 -1
  26. package/dist/delegated-result-correlation.js +2 -2
  27. package/dist/frontend-capability-tools.d.ts +2 -0
  28. package/dist/frontend-capability-tools.js +7 -1
  29. package/dist/frontend-session-registry.d.ts +42 -3
  30. package/dist/frontend-session-registry.js +61 -9
  31. package/dist/http/agents.d.ts +4 -0
  32. package/dist/http/attachments.js +13 -12
  33. package/dist/http/component-catalog.js +1 -1
  34. package/dist/http/connectors.js +7 -5
  35. package/dist/http/db-ops.js +5 -0
  36. package/dist/http/delegated-tools.js +5 -0
  37. package/dist/http/frontend-control.d.ts +13 -0
  38. package/dist/http/frontend-control.js +14 -3
  39. package/dist/http/frontend-guest-scope.d.ts +32 -0
  40. package/dist/http/frontend-guest-scope.js +63 -0
  41. package/dist/http/frontend-sessions.d.ts +29 -6
  42. package/dist/http/frontend-sessions.js +108 -28
  43. package/dist/http/host-tools.js +7 -5
  44. package/dist/http/index.d.ts +1 -0
  45. package/dist/http/index.js +1 -0
  46. package/dist/http/memory.js +3 -1
  47. package/dist/http/model-proxy.js +13 -8
  48. package/dist/http/research-tavily.adapter.js +2 -1
  49. package/dist/http/routines.js +7 -5
  50. package/dist/http/terminals.js +5 -0
  51. package/dist/http/tool-catalog.js +1 -1
  52. package/dist/http/xai.js +10 -7
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.js +2 -2
  55. package/dist/legacy-data-migration.d.ts +0 -12
  56. package/dist/legacy-data-migration.js +21 -13
  57. package/dist/remote-tool-executor.js +2 -1
  58. package/dist/routines/schedule.js +2 -1
  59. package/dist/routines/scheduler.js +6 -7
  60. package/dist/run-lifecycle.js +2 -1
  61. package/dist/store/agent-sessions/sql.js +7 -3
  62. package/dist/store/event-log/sqlite-kernel.js +7 -2
  63. package/dist/store/event-log/sqlite.js +6 -2
  64. package/dist/surface-exchanges.js +3 -3
  65. package/dist/terminal-session.d.ts +51 -20
  66. package/dist/terminal-session.js +10 -94
  67. package/dist/terminal.adapter.d.ts +22 -0
  68. package/dist/terminal.adapter.js +47 -0
  69. package/dist/tool-executor.d.ts +4 -2
  70. package/dist/tool-executor.js +95 -15
  71. package/dist/tool-recovery/recovery.js +5 -3
  72. package/dist/tool-recovery/redaction.js +1 -2
  73. package/dist/tool-recovery/stack.js +5 -1
  74. package/dist/tool-result-media.d.ts +4 -0
  75. package/dist/tool-result-media.js +10 -3
  76. package/dist/tool-result-surfaces.js +10 -9
  77. package/package.json +30 -34
@@ -46,6 +46,34 @@
46
46
  * so the existing `timeoutMs` on a tool descriptor covers this for free.
47
47
  */
48
48
  import { randomUUID } from 'node:crypto';
49
+ /** Shared owner comparison for attachment, binding and human-only reply gates. */
50
+ export function sameFrontendOwner({ expected: a, actual: b }, _options = {}) {
51
+ if (!a || !b)
52
+ return a === b;
53
+ return a.workspaceId === b.workspaceId && a.principalId === b.principalId && a.tabId === b.tabId;
54
+ }
55
+ /** Expected admission refusal; HTTP maps it to a quiet 429 before opening SSE. */
56
+ export class FrontendSessionCapacityError extends Error {
57
+ constructor() { super('Frontend session capacity reached'); this.name = 'FrontendSessionCapacityError'; }
58
+ }
59
+ /** Admission is synchronous with attach, so concurrent opens cannot oversubscribe a scope.
60
+ * O(s × k) over the bounded live sessions and the host's quota keys; no retained IP counters.
61
+ * Detach automatically releases capacity, including stale handles and expired connections. */
62
+ function assertSessionCapacity({ sessions, descriptor, options }) {
63
+ if (options.maxSessions !== undefined && sessions.size >= options.maxSessions)
64
+ throw new FrontendSessionCapacityError();
65
+ if (options.maxSessionsPerKey === undefined)
66
+ return;
67
+ for (const key of descriptor.capacityKeys ?? []) {
68
+ let count = 0;
69
+ for (const session of sessions.values()) {
70
+ if (session.descriptor.capacityKeys?.includes(key))
71
+ count++;
72
+ }
73
+ if (count >= options.maxSessionsPerKey)
74
+ throw new FrontendSessionCapacityError();
75
+ }
76
+ }
49
77
  /**
50
78
  * Creates an in-memory registry of attached surfaces.
51
79
  *
@@ -53,8 +81,8 @@ import { randomUUID } from 'node:crypto';
53
81
  * so it cannot outlive the process that holds it, and a restart correctly presents as "nothing
54
82
  * attached" rather than as a stale route to a tab that is long gone.
55
83
  *
56
- * @complexity Every operation is O(1) except `detach`, which is O(p + b) in that surface\'s pending
57
- * invocations and bound runs.
84
+ * @complexity `attach` checks O(s × k) live sessions/quota keys when limits are configured;
85
+ * `detach` is O(p + b) in pending invocations and bound runs. Other addressing operations are O(1).
58
86
  */
59
87
  export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
60
88
  const options = { ...requiredArgs, ...optionalArgs };
@@ -106,11 +134,12 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
106
134
  }
107
135
  return session;
108
136
  }
109
- function attach({ descriptor, deliver }) {
137
+ function attach({ descriptor, deliver, cancel }) {
138
+ assertSessionCapacity({ sessions, descriptor, options });
110
139
  if (sessions.has(descriptor.sessionId)) {
111
140
  throw new Error(`FrontendSessionRegistry: session "${descriptor.sessionId}" is already attached`);
112
141
  }
113
- const session = { descriptor, deliver, pending: new Map() };
142
+ const session = { descriptor, deliver, ...(cancel ? { cancel } : {}), pending: new Map() };
114
143
  sessions.set(descriptor.sessionId, session);
115
144
  const bindToken = newBindToken();
116
145
  bindTokens.set(bindToken, descriptor.sessionId);
@@ -133,6 +162,7 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
133
162
  }
134
163
  for (const [, entry] of session.pending) {
135
164
  entry.dispose();
165
+ entry.onApprovalStateChange?.({ pending: false });
136
166
  entry.reject(new Error(`frontend session "${descriptor.sessionId}" detached before answering`));
137
167
  }
138
168
  session.pending.clear();
@@ -153,12 +183,12 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
153
183
  runBindings.delete(runId);
154
184
  };
155
185
  }
156
- function bindRunByToken({ runId, bindToken }) {
186
+ function bindRunByToken({ runId, bindToken, owner }) {
157
187
  const sessionId = bindTokens.get(bindToken);
158
188
  // One message for both "no such token" and "the surface holding it detached". Distinguishing
159
189
  // them tells a caller whether a guess was close, which is the feedback a probe needs and the
160
190
  // only feedback it needs.
161
- if (sessionId === undefined) {
191
+ if (sessionId === undefined || !sameFrontendOwner({ expected: sessions.get(sessionId)?.descriptor.owner, actual: owner })) {
162
192
  throw new Error(`FrontendSessionRegistry: cannot bind run "${runId}" — unknown or expired bind token`);
163
193
  }
164
194
  return bindRun({ runId: runId, sessionId: sessionId });
@@ -167,13 +197,18 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
167
197
  // every other failure here. Throwing synchronously out of a Promise-returning function would
168
198
  // make `invoke(...).catch(...)` an uncaught exception for exactly the fail-closed cases this
169
199
  // registry exists to report.
170
- async function invoke({ runId, capabilityId, input }, { signal } = {}) {
200
+ async function invoke({ runId, capabilityId, input }, { signal, onApprovalStateChange } = {}) {
171
201
  const target = resolveTarget(runId, capabilityId);
172
202
  const invocationId = newInvocationId();
173
203
  return new Promise((resolve, reject) => {
174
204
  const cancelled = () => new Error(`"${capabilityId}" was cancelled before the frontend answered`);
175
205
  const onAbort = () => {
176
206
  target.pending.delete(invocationId);
207
+ try {
208
+ target.cancel?.({ invocationId });
209
+ }
210
+ catch { /* A closed channel cannot revive the action. */ }
211
+ onApprovalStateChange?.({ pending: false });
177
212
  reject(cancelled());
178
213
  };
179
214
  const dispose = () => signal?.removeEventListener('abort', onAbort);
@@ -182,7 +217,7 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
182
217
  return;
183
218
  }
184
219
  signal?.addEventListener('abort', onAbort, { once: true });
185
- target.pending.set(invocationId, { resolve, reject, dispose });
220
+ target.pending.set(invocationId, { resolve, reject, dispose, ...(onApprovalStateChange ? { onApprovalStateChange } : {}) });
186
221
  try {
187
222
  target.deliver({ invocationId, capabilityId, input });
188
223
  }
@@ -190,10 +225,23 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
190
225
  // A dead channel must fail the call rather than leave it pending until the tool times out.
191
226
  target.pending.delete(invocationId);
192
227
  dispose();
228
+ onApprovalStateChange?.({ pending: false });
193
229
  reject(error instanceof Error ? error : new Error(String(error)));
194
230
  }
195
231
  });
196
232
  }
233
+ function progress({ sessionId, invocationId, pending, decision }) {
234
+ const entry = sessions.get(sessionId)?.pending.get(invocationId);
235
+ if (!entry)
236
+ return false;
237
+ // Only the first outcome following a real wait is recorded; replayed frames are observation-free.
238
+ const settledDecision = entry.approvalPending && !pending ? decision : undefined;
239
+ if (settledDecision)
240
+ entry.approvalRecorded = true;
241
+ entry.approvalPending = pending && !entry.approvalRecorded;
242
+ entry.onApprovalStateChange?.({ pending, ...(settledDecision ? { decision: settledDecision } : {}) });
243
+ return true;
244
+ }
197
245
  function settle({ sessionId, invocationId, outcome }) {
198
246
  const session = sessions.get(sessionId);
199
247
  const entry = session?.pending.get(invocationId);
@@ -201,6 +249,7 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
201
249
  return false;
202
250
  session.pending.delete(invocationId);
203
251
  entry.dispose();
252
+ entry.onApprovalStateChange?.({ pending: false });
204
253
  if (outcome.ok)
205
254
  entry.resolve(outcome.output);
206
255
  else
@@ -213,6 +262,9 @@ export function createFrontendSessionRegistry(requiredArgs, optionalArgs = {}) {
213
262
  function capabilitiesFor({ runId }) {
214
263
  return sessionFor({ runId: runId })?.capabilities ?? [];
215
264
  }
216
- return { attach, bindRun, bindRunByToken, invoke, settle, capabilitiesFor, sessionFor };
265
+ function ownsSession({ sessionId, owner, bindToken }) {
266
+ return bindTokens.get(bindToken) === sessionId && sameFrontendOwner({ expected: sessions.get(sessionId)?.descriptor.owner, actual: owner });
267
+ }
268
+ return { attach, bindRun, bindRunByToken, invoke, settle, progress, capabilitiesFor, sessionFor, ownsSession };
217
269
  }
218
270
  //# sourceMappingURL=frontend-session-registry.js.map
@@ -51,6 +51,10 @@ export interface AgentSummary {
51
51
  * as `true` (no known reason to warn) rather than assuming the worst.
52
52
  */
53
53
  readonly supportsTools?: boolean;
54
+ /** Project the runtime definition so picker preflight agrees with launch enforcement. */
55
+ readonly supportsNativeToolAccess?: boolean;
56
+ readonly fullCliAccessOnly?: boolean;
57
+ readonly fullCliAccessNote?: string;
54
58
  }
55
59
  export interface AgentsHttpDeps {
56
60
  /** Returns the host's cached or freshly resolved client-safe agent inventory. */
@@ -284,13 +284,12 @@ const SIGNATURE_BYTES = FTYP_COMPATIBLE_BRANDS_OFFSET + MAX_SCANNED_COMPATIBLE_B
284
284
  * production is a check nobody knows works — a fake identity states each case directly.
285
285
  */
286
286
  export function isUnchangedAttachment({ recorded, observed }, _optional = {}) {
287
- return observed.isRegularFile
288
- && observed.canonicalPath === recorded.filePath
289
- && observed.dev === recorded.dev
290
- && observed.ino === recorded.ino
291
- && observed.size === recorded.size
292
- && (recorded.mtimeMs === undefined || observed.mtimeMs === recorded.mtimeMs)
287
+ const sameFile = observed.isRegularFile && observed.canonicalPath === recorded.filePath
288
+ && observed.dev === recorded.dev && observed.ino === recorded.ino;
289
+ // A recorded timestamp must still hold; one never recorded cannot disagree.
290
+ const sameTimes = (recorded.mtimeMs === undefined || observed.mtimeMs === recorded.mtimeMs)
293
291
  && (recorded.ctimeMs === undefined || observed.ctimeMs === recorded.ctimeMs);
292
+ return sameFile && observed.size === recorded.size && sameTimes;
294
293
  }
295
294
  /**
296
295
  * Phase 1 of `claim()`: synchronously reserves `attachments` for `runId`, or throws (releasing
@@ -384,13 +383,15 @@ export function sanitizeAttachmentName({ requestedName }, _optional = {}) {
384
383
  return 'attachment';
385
384
  return basename(requestedName).replaceAll(/[^a-zA-Z0-9._ -]/gu, '_') || 'attachment';
386
385
  }
386
+ /** An attachment no run has claimed, or one claimed by `runId` (never by an absent run id). */
387
+ function isUnclaimedOrClaimedBy(claimedRunId, runId) {
388
+ return claimedRunId === undefined || (runId !== undefined && claimedRunId === runId);
389
+ }
390
+ /** The complete PNG signature includes the CR/LF and control-byte corruption markers. */
391
+ const PNG_SIGNATURE_PREFIX = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
387
392
  /** `true` when `body`'s first 8 bytes are the PNG signature. */
388
393
  export function hasPngSignature({ body }, _optional = {}) {
389
- return body.length >= 8
390
- && body[0] === 0x89
391
- && body[1] === 0x50
392
- && body[2] === 0x4e
393
- && body[3] === 0x47;
394
+ return body.length >= 8 && PNG_SIGNATURE_PREFIX.every((byte, index) => body[index] === byte);
394
395
  }
395
396
  /** `true` when `body`'s first 3 bytes are the JPEG start-of-image marker. */
396
397
  export function hasJpegSignature({ body }, _optional = {}) {
@@ -934,7 +935,7 @@ export async function createDiskAttachmentStore(requiredArgs, optionalArgs = {})
934
935
  // what makes an ownerless record excluded rather than accidentally matched by a falsy-ish
935
936
  // `ownerId` argument — see this method's own doc on why that must never be a wildcard.
936
937
  .filter((record) => record.ownerId !== undefined && record.ownerId === ownerId
937
- && (record.claimedRunId === undefined || (runId !== undefined && record.claimedRunId === runId)))
938
+ && isUnclaimedOrClaimedBy(record.claimedRunId, runId))
938
939
  .sort((a, b) => a.createdAt - b.createdAt)
939
940
  .map((record) => ({ ref: record.id, name: record.name, kind: record.kind, size: record.size, createdAt: record.createdAt }));
940
941
  },
@@ -12,7 +12,7 @@ function parseSearchInput(input) {
12
12
  const rawLimit = input.query.limit;
13
13
  if (rawLimit === undefined)
14
14
  return ok({ value: { query: rawQuery, limit: DEFAULT_SEARCH_LIMIT } });
15
- if (typeof rawLimit !== 'string' || !/^[0-9]+$/.test(rawLimit)) {
15
+ if (typeof rawLimit !== 'string' || !/^\d+$/.test(rawLimit)) {
16
16
  return err({ error: validationError({ message: 'limit must be a single positive-integer query-string value when provided' }) });
17
17
  }
18
18
  const parsed = Number(rawLimit);
@@ -49,6 +49,8 @@ import { createApiError } from '@jini-ai/protocol';
49
49
  import { ClientFacingError, defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
50
50
  import { validationError } from '@jini-ai/http-kit';
51
51
  import { err, ok } from '@jini-ai/http-kit';
52
+ /** Validation message for a request body that is not a JSON object. */
53
+ const BODY_NOT_OBJECT_MESSAGE = 'body must be a JSON object';
52
54
  function defaultInternalErrorSink(context) {
53
55
  // eslint-disable-next-line no-console
54
56
  console.error(`[@jini-ai/http-kit] internal error (connectors/${context.source}.${context.operation}, correlationId=${context.correlationId})`, context.error);
@@ -86,7 +88,7 @@ function firstQueryValue(raw) {
86
88
  // ---------------------------------------------------------------------------
87
89
  function parseAuthCredentials(input) {
88
90
  if (!isRecord(input.body))
89
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
91
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
90
92
  const email = nonEmptyString(input.body.email);
91
93
  if (!email)
92
94
  return err({ error: validationError({ message: 'email must be a non-empty string' }, { issues: [{ path: 'email', message: 'required non-empty string' }] }) });
@@ -97,7 +99,7 @@ function parseAuthCredentials(input) {
97
99
  }
98
100
  function parseAuthToken(input) {
99
101
  if (!isRecord(input.body))
100
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
102
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
101
103
  const token = nonEmptyString(input.body.token);
102
104
  if (!token)
103
105
  return err({ error: validationError({ message: 'token must be a non-empty string' }, { issues: [{ path: 'token', message: 'required non-empty string' }] }) });
@@ -164,7 +166,7 @@ function parseStoragePut(input) {
164
166
  if (!keyResult.ok)
165
167
  return keyResult;
166
168
  if (!isRecord(input.body))
167
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
169
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
168
170
  const dataBase64 = input.body.dataBase64;
169
171
  if (typeof dataBase64 !== 'string' || !isWellFormedBase64(dataBase64)) {
170
172
  return err({ error: validationError({ message: 'dataBase64 must be a non-empty base64-encoded string' }, { issues: [{ path: 'dataBase64', message: 'required base64 string' }] }) });
@@ -231,7 +233,7 @@ function parseDbInsert(input) {
231
233
  if (!collectionResult.ok)
232
234
  return collectionResult;
233
235
  if (!isRecord(input.body))
234
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
236
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
235
237
  const id = input.body.id;
236
238
  if (typeof id !== 'string' || id.length === 0) {
237
239
  return err({ error: validationError({ message: 'id must be a non-empty string field' }, { issues: [{ path: 'id', message: 'required non-empty string' }] }) });
@@ -252,7 +254,7 @@ function parseDbUpdate(input) {
252
254
  if (!parsed.ok)
253
255
  return parsed;
254
256
  if (!isRecord(input.body))
255
- return err({ error: validationError({ message: 'body must be a JSON object' }) });
257
+ return err({ error: validationError({ message: BODY_NOT_OBJECT_MESSAGE }) });
256
258
  return ok({ value: { ...parsed.value, patch: input.body } });
257
259
  }
258
260
  function parseDbQueryRequest(input) {
@@ -138,6 +138,11 @@ function toolResultToApiResult(deps, source, result) {
138
138
  sink({ source, correlationId, error: result.status === 'failed' ? result.error : result.status });
139
139
  return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
140
140
  }
141
+ default: {
142
+ // Unreachable: every tool result status has a case above, and a new one fails typecheck here.
143
+ const unhandled = result.status;
144
+ throw new TypeError(`Unhandled tool result status: ${String(unhandled)}`);
145
+ }
141
146
  }
142
147
  }
143
148
  /** `GET /api/daemon/db` — the database inventory (schema version, tables, row counts, size). */
@@ -144,6 +144,11 @@ function toolExecutionResultToApiResult(deps, { runId, toolId, result }) {
144
144
  return err({ error: reportInternalError(deps, { source: 'delegated-tool-execute', error: result.status, runId, toolId, status: result.status }) });
145
145
  case 'failed':
146
146
  return err({ error: failedResultToApiError(deps, { runId, toolId, result }) });
147
+ default: {
148
+ // Unreachable: every tool result status has a case above, and a new one fails typecheck here.
149
+ const unhandled = result.status;
150
+ throw new TypeError(`Unhandled tool result status: ${String(unhandled)}`);
151
+ }
147
152
  }
148
153
  }
149
154
  /** The `failed` arm of {@link toolExecutionResultToApiResult} — see its doc for the three-way split. */
@@ -35,6 +35,8 @@
35
35
  * their own metadata in it. The engine stays neutral and the host keeps one place to look.
36
36
  */
37
37
  import type { Express } from 'express';
38
+ import type { FrontendSessionOwner } from '../frontend-session-registry.js';
39
+ import type { FrontendSessionsHttpDeps } from './frontend-sessions.js';
38
40
  import type { ToolPolicy, ToolRegistration } from '@jini-ai/core';
39
41
  import { type FrontendCapabilitySpec } from '../index.js';
40
42
  import type { AdapterContext } from '@jini-ai/http-kit';
@@ -67,6 +69,10 @@ export interface CreateFrontendControlOptions {
67
69
  * (see `frontend-capability-tools.ts`'s module doc for why the edge points one way).
68
70
  */
69
71
  readonly capabilities: readonly FrontendCapabilitySpec[];
72
+ readonly session?: Pick<FrontendSessionsHttpDeps, 'routePrefix' | 'resolveOwner' | 'sessionTtlMs' | 'resolveCapacityKeys'> & {
73
+ readonly maxSessions?: number;
74
+ readonly maxSessionsPerKey?: number;
75
+ };
70
76
  /**
71
77
  * Reads this run's bind token out of the host's own run request, or returns `undefined` when the
72
78
  * run has no originating surface.
@@ -98,6 +104,13 @@ export interface CreateFrontendControlOptions {
98
104
  readonly onBindError?: (context: FrontendBindErrorContext) => void;
99
105
  }
100
106
  export interface FrontendControl {
107
+ /** Request-lifetime binding for hosts without daemon runs; release in finally and on cancel. */
108
+ bindRequest(required: {
109
+ runId: string;
110
+ bindToken: string;
111
+ owner: FrontendSessionOwner;
112
+ signal: AbortSignal;
113
+ }, optional?: Record<string, never>): () => void;
101
114
  /**
102
115
  * Mounts the frontend-session stream and response routes. Pass in `createLocalNodeDaemon`'s
103
116
  * `httpExtensions`, or call it directly with `(app, { adapter })` from a host that owns its own
@@ -17,7 +17,10 @@ function defaultBindErrorSink(context) {
17
17
  export function createFrontendControl(requiredArgs, optionalArgs = {}) {
18
18
  const options = { ...requiredArgs, ...optionalArgs };
19
19
  // Closed over, never exposed. See the module doc: reaching this is reaching an ungated `invoke`.
20
- const registry = createFrontendSessionRegistry({});
20
+ const registry = createFrontendSessionRegistry({}, {
21
+ ...(options.session?.maxSessions !== undefined ? { maxSessions: options.session.maxSessions } : {}),
22
+ ...(options.session?.maxSessionsPerKey !== undefined ? { maxSessionsPerKey: options.session.maxSessionsPerKey } : {}),
23
+ });
21
24
  const onBindError = options.onBindError ?? defaultBindErrorSink;
22
25
  const toolRegistrations = createFrontendCapabilityRegistrations({
23
26
  registry,
@@ -28,7 +31,7 @@ export function createFrontendControl(requiredArgs, optionalArgs = {}) {
28
31
  ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
29
32
  });
30
33
  const httpExtension = ({ app, context }) => {
31
- registerFrontendSessionRoutes({ app, deps: { registry }, adapter: context.adapter });
34
+ registerFrontendSessionRoutes({ app, deps: { registry, ...options.session, allowedCapabilities: options.capabilities.map(c => c.id) }, adapter: context.adapter });
32
35
  };
33
36
  /**
34
37
  * The error boundary the `onBindError` contract actually needs. A `RunStartHandler` throwing is
@@ -74,6 +77,14 @@ export function createFrontendControl(requiredArgs, optionalArgs = {}) {
74
77
  .then(release, release)
75
78
  .catch(() => undefined);
76
79
  };
77
- return { httpExtension, toolRegistrations, bindOnStarted };
80
+ const bindRequest = ({ runId, bindToken, owner, signal }) => {
81
+ if (signal.aborted)
82
+ throw new Error('Frontend request cancelled');
83
+ const unbind = registry.bindRunByToken({ runId, bindToken, owner });
84
+ const release = () => { signal.removeEventListener('abort', release); unbind(); };
85
+ signal.addEventListener('abort', release, { once: true });
86
+ return release;
87
+ };
88
+ return { httpExtension, toolRegistrations, bindOnStarted, bindRequest };
78
89
  }
79
90
  //# sourceMappingURL=frontend-control.js.map
@@ -0,0 +1,32 @@
1
+ import type { Request, Response } from 'express';
2
+ import type { FrontendSessionOwner } from '../frontend-session-registry.js';
3
+ /** Per-instance keys isolate visitor control from every other frontend registry.
4
+ * Tokens are bounded and expire; never put either credential in URLs or diagnostics.
5
+ * O(1) bounded cookie/token parsing and signing, no persistent guest database. */
6
+ export declare function createFrontendGuestScope({ workspaceId, cookieName, cookiePath, allowsRequest }: {
7
+ workspaceId: string;
8
+ cookieName: string;
9
+ cookiePath: string;
10
+ allowsRequest: (args: {
11
+ req: Request;
12
+ }) => Promise<boolean>;
13
+ }, { now, lifetimeMs }?: {
14
+ now?: () => number;
15
+ lifetimeMs?: number;
16
+ }): {
17
+ /** Authenticate the cookie independently of a tab for credential-minting budgets. */
18
+ resolveGuest({ req }: {
19
+ req: Request;
20
+ }, _options?: {}): Promise<string | undefined>;
21
+ issue({ req, res }: {
22
+ req: Request;
23
+ res: Response;
24
+ }, _options?: {}): Promise<{
25
+ tabCredential: string;
26
+ } | null>;
27
+ resolveOwner({ req }: {
28
+ req: Request;
29
+ res?: Response;
30
+ }, _options?: {}): Promise<FrontendSessionOwner | null>;
31
+ };
32
+ //# sourceMappingURL=frontend-guest-scope.d.ts.map
@@ -0,0 +1,63 @@
1
+ /** Opaque guest cookie plus independently minted document credential for public frontends. */
2
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { timingSafeTokenMatch } from '@jini-ai/core';
4
+ /** Per-instance keys isolate visitor control from every other frontend registry.
5
+ * Tokens are bounded and expire; never put either credential in URLs or diagnostics.
6
+ * O(1) bounded cookie/token parsing and signing, no persistent guest database. */
7
+ export function createFrontendGuestScope({ workspaceId, cookieName, cookiePath, allowsRequest }, { now = Date.now, lifetimeMs = 30 * 60_000 } = {}) {
8
+ const key = randomBytes(32);
9
+ const sign = (text) => createHmac('sha256', key).update(text).digest('base64url');
10
+ const mint = (value) => { const payload = Buffer.from(JSON.stringify(value)).toString('base64url'); return `${payload}.${sign(payload)}`; };
11
+ function read(value) {
12
+ if (value.length > 1024)
13
+ return null;
14
+ const parts = value.split('.');
15
+ if (parts.length !== 2)
16
+ return null;
17
+ const [payload, signature] = parts;
18
+ if (!payload || !signature)
19
+ return null;
20
+ const expected = sign(payload);
21
+ // The shared matcher checks UTF-8 byte lengths before native comparison can throw.
22
+ if (!timingSafeTokenMatch({ presented: signature, expected, timingSafeEqual: ({ left, right }) => timingSafeEqual(left, right) }))
23
+ return null;
24
+ try {
25
+ const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
26
+ return typeof parsed.expires === 'number' && parsed.expires > now() ? parsed : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function guest(req) {
33
+ const cookie = (req.get('cookie') ?? '').split(';').map(part => part.trim()).find(part => part.startsWith(`${cookieName}=`));
34
+ const value = read(cookie?.slice(cookieName.length + 1) ?? '');
35
+ return value?.kind === 'guest' && typeof value.guest === 'string' ? value.guest : undefined;
36
+ }
37
+ return {
38
+ /** Authenticate the cookie independently of a tab for credential-minting budgets. */
39
+ async resolveGuest({ req }, _options = {}) {
40
+ return await allowsRequest({ req }) ? guest(req) : undefined;
41
+ },
42
+ async issue({ req, res }, _options = {}) {
43
+ if (!await allowsRequest({ req }))
44
+ return null;
45
+ const principalId = guest(req) ?? randomBytes(24).toString('base64url');
46
+ const expires = now() + lifetimeMs;
47
+ // A JS-readable tab token must never authenticate as the HttpOnly guest cookie.
48
+ res.cookie(cookieName, mint({ kind: 'guest', guest: principalId, expires }), { httpOnly: true, sameSite: 'strict', secure: req.secure, path: cookiePath, maxAge: lifetimeMs });
49
+ res.setHeader('Cache-Control', 'no-store');
50
+ return { tabCredential: mint({ kind: 'tab', guest: principalId, tab: randomBytes(24).toString('base64url'), expires }) };
51
+ },
52
+ async resolveOwner({ req }, _options = {}) {
53
+ if (!await allowsRequest({ req }))
54
+ return null;
55
+ const principalId = guest(req);
56
+ const tab = read(req.get('x-frontend-tab') ?? '');
57
+ if (!principalId || tab?.kind !== 'tab' || tab.guest !== principalId || typeof tab.tab !== 'string')
58
+ return null;
59
+ return { workspaceId, principalId, tabId: tab.tab };
60
+ },
61
+ };
62
+ }
63
+ //# sourceMappingURL=frontend-guest-scope.js.map
@@ -1,5 +1,6 @@
1
+ import type { ToolApprovalDecision } from '@jini-ai/core';
1
2
  import type { Express, Request, Response } from 'express';
2
- import type { FrontendSessionRegistry } from '../index.js';
3
+ import type { FrontendSessionRegistry, FrontendSessionOwner } from '../frontend-session-registry.js';
3
4
  import { type AdapterContext } from '@jini-ai/http-kit';
4
5
  export declare const FRONTEND_SESSION_STREAM_ROUTE_PATH = "/api/frontend-sessions/stream";
5
6
  export declare const FRONTEND_SESSION_RESPONSE_ROUTE_PATH = "/api/frontend-sessions/:sessionId/responses";
@@ -29,7 +30,10 @@ export interface FrontendSessionErrorEvent {
29
30
  readonly type: 'error';
30
31
  readonly message: string;
31
32
  }
32
- export type FrontendSessionStreamEvent = FrontendSessionAttachedEvent | FrontendSessionInvocationEvent | FrontendSessionErrorEvent;
33
+ export type FrontendSessionStreamEvent = {
34
+ readonly type: 'cancelled';
35
+ readonly invocationId: string;
36
+ } | FrontendSessionAttachedEvent | FrontendSessionInvocationEvent | FrontendSessionErrorEvent;
33
37
  /**
34
38
  * A surface's answer, as a discriminated union rather than a flat record with two optional fields.
35
39
  *
@@ -40,6 +44,11 @@ export type FrontendSessionStreamEvent = FrontendSessionAttachedEvent | Frontend
40
44
  * to fabricate an impossible input.
41
45
  */
42
46
  export type FrontendSessionResponseRequest = {
47
+ readonly sessionId: string;
48
+ readonly invocationId: string;
49
+ readonly phase: 'approval-pending' | 'execution-started';
50
+ readonly decision?: ToolApprovalDecision;
51
+ } | {
43
52
  readonly sessionId: string;
44
53
  readonly invocationId: string;
45
54
  readonly ok: true;
@@ -56,6 +65,20 @@ export interface FrontendSessionResponseBody {
56
65
  }
57
66
  export interface FrontendSessionsHttpDeps {
58
67
  readonly registry: FrontendSessionRegistry;
68
+ /** An isolated public instance supplies its own endpoints and authenticated guest/tab owner. */
69
+ readonly routePrefix?: string;
70
+ /** Must authenticate same-origin metadata as well as credentials on every request. */
71
+ readonly resolveOwner?: (args: {
72
+ req: Request;
73
+ res: Response;
74
+ }) => Promise<FrontendSessionOwner | null>;
75
+ readonly allowedCapabilities?: readonly string[];
76
+ readonly sessionTtlMs?: number;
77
+ /** Trusted quota identities supplied by the host, not query parameters. */
78
+ readonly resolveCapacityKeys?: (args: {
79
+ req: Request;
80
+ owner?: FrontendSessionOwner;
81
+ }) => readonly string[];
59
82
  /**
60
83
  * Mints session ids. Defaults to `randomUUID`. A surface never supplies its own: an id the
61
84
  * caller chose could collide with, or deliberately impersonate, a session already attached.
@@ -79,14 +102,14 @@ export declare function parseCapabilityQuery({ raw }: {
79
102
  /**
80
103
  * Handles one surface's stream from open to close.
81
104
  *
82
- * SSE commits `text/event-stream` headers before anything else can be known, so a bad request is
83
- * reported as one `{type:'error'}` data event and then closed — there is no status-code channel
84
- * left. Same precedent as `run-stream.ts`.
105
+ * Invalid capability queries use a final SSE error event. Capacity admission happens before
106
+ * opening the stream so resource refusals retain a real HTTP status and create no connection.
85
107
  */
86
- export declare function handleFrontendSessionStream({ req, res, deps }: {
108
+ export declare function handleFrontendSessionStream({ req, res, deps, owner }: {
87
109
  readonly req: Request;
88
110
  readonly res: Response;
89
111
  readonly deps: FrontendSessionsHttpDeps;
112
+ readonly owner?: FrontendSessionOwner;
90
113
  }, _optional?: Record<string, never>): void;
91
114
  /**
92
115
  * `POST /api/frontend-sessions/:sessionId/responses` — delivers a surface's answer to the tool