@jini-ai/daemon 0.6.15 → 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 (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -4
  3. package/dist/agent-executor/codex/isolation.js +2 -3
  4. package/dist/agent-executor/contracts.d.ts +1 -1
  5. package/dist/agent-executor/contracts.js +0 -1
  6. package/dist/agent-executor/event-translation.d.ts +3 -3
  7. package/dist/agent-executor/event-translation.js +4 -4
  8. package/dist/agent-executor/events.js +1 -3
  9. package/dist/agent-executor/index.d.ts +5 -5
  10. package/dist/agent-executor/index.js +5 -5
  11. package/dist/ai-trace-lifecycle.d.ts +1 -1
  12. package/dist/ai-trace-lifecycle.js +1 -1
  13. package/dist/core/tool-audit.js +11 -7
  14. package/dist/frontend-capability-tools.d.ts +2 -0
  15. package/dist/frontend-capability-tools.js +7 -1
  16. package/dist/frontend-session-registry.d.ts +42 -3
  17. package/dist/frontend-session-registry.js +61 -9
  18. package/dist/http/frontend-control.d.ts +13 -0
  19. package/dist/http/frontend-control.js +14 -3
  20. package/dist/http/frontend-guest-scope.d.ts +32 -0
  21. package/dist/http/frontend-guest-scope.js +63 -0
  22. package/dist/http/frontend-sessions.d.ts +29 -6
  23. package/dist/http/frontend-sessions.js +106 -28
  24. package/dist/http/index.d.ts +1 -0
  25. package/dist/http/index.js +1 -0
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.js +2 -2
  28. package/dist/store/event-log/sqlite-kernel.js +2 -1
  29. package/dist/terminal-session.d.ts +51 -20
  30. package/dist/terminal-session.js +6 -89
  31. package/dist/terminal.adapter.d.ts +22 -0
  32. package/dist/terminal.adapter.js +47 -0
  33. package/dist/tool-executor.d.ts +4 -2
  34. package/dist/tool-executor.js +50 -14
  35. package/dist/tool-result-media.d.ts +4 -0
  36. package/dist/tool-result-media.js +3 -2
  37. package/package.json +29 -38
@@ -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
@@ -30,6 +30,7 @@
30
30
  */
31
31
  import { randomUUID } from 'node:crypto';
32
32
  import { createApiError } from '@jini-ai/protocol';
33
+ import { FrontendSessionCapacityError } from '../frontend-session-registry.js';
33
34
  import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
34
35
  import { guardSameOrigin } from '@jini-ai/http-kit';
35
36
  import { createSseResponse } from '@jini-ai/http-kit';
@@ -72,20 +73,34 @@ export function parseCapabilityQuery({ raw }, _optional = {}) {
72
73
  /**
73
74
  * Handles one surface's stream from open to close.
74
75
  *
75
- * SSE commits `text/event-stream` headers before anything else can be known, so a bad request is
76
- * reported as one `{type:'error'}` data event and then closed — there is no status-code channel
77
- * left. Same precedent as `run-stream.ts`.
76
+ * Invalid capability queries use a final SSE error event. Capacity admission happens before
77
+ * opening the stream so resource refusals retain a real HTTP status and create no connection.
78
78
  */
79
- export function handleFrontendSessionStream({ req, res, deps }, _optional = {}) {
80
- const capabilities = parseCapabilityQuery({ raw: req.query['capability'] });
79
+ export function handleFrontendSessionStream({ req, res, deps, owner }, _optional = {}) {
80
+ const requested = parseCapabilityQuery({ raw: req.query['capability'] });
81
+ const capabilities = requested?.filter(id => !deps.allowedCapabilities || deps.allowedCapabilities.includes(id)) ?? null;
81
82
  // Assigned below, after the connection exists — `attach` needs something to deliver through, and
82
83
  // `onClose` has to be armed before then so a connection that dies during setup still detaches.
83
84
  let handle;
84
- const connection = createSseResponse({ req, res }, {
85
- // Detach on close rather than on an explicit teardown call: this fires for a closed tab and a
86
- // dropped connection too, which an unregister route would not.
87
- onClose: () => handle?.detach({}),
88
- });
85
+ let expiry;
86
+ // Reserve capacity before committing SSE headers, so a full scope gets a real 429.
87
+ // Delivery cannot occur until the attached token has been sent to the client.
88
+ let connection;
89
+ if (capabilities !== null) {
90
+ handle = attachFrontendSession({ req, deps, capabilities, owner,
91
+ deliver: invocation => connection.send({ data: { type: 'invocation', ...invocation } }),
92
+ cancel: ({ invocationId }) => connection.send({ data: { type: 'cancelled', invocationId } }) });
93
+ }
94
+ try {
95
+ connection = createSseResponse({ req, res }, {
96
+ // Close and expiry release the same quota reservation, without a separate counter lifecycle.
97
+ onClose: () => { clearTimeout(expiry); handle?.detach({}); },
98
+ });
99
+ }
100
+ catch (error) {
101
+ handle?.detach({});
102
+ throw error;
103
+ }
89
104
  if (capabilities === null) {
90
105
  connection.send({
91
106
  data: {
@@ -96,16 +111,22 @@ export function handleFrontendSessionStream({ req, res, deps }, _optional = {})
96
111
  connection.close();
97
112
  return;
98
113
  }
99
- const sessionId = (deps.newSessionId ?? randomUUID)();
100
- handle = deps.registry.attach({ descriptor: { sessionId, capabilities }, deliver: (invocation) => connection.send({ data: { type: 'invocation', ...invocation } }) });
114
+ expiry = expireScopedSession({ owner, handle: handle, close: () => connection.close(), ttl: deps.sessionTtlMs });
101
115
  connection.send({
102
116
  data: {
103
117
  type: 'attached',
104
- sessionId,
118
+ sessionId: handle.sessionId,
105
119
  bindToken: handle.bindToken,
106
120
  }
107
121
  });
108
122
  }
123
+ /** Reserve the registry's capacity before SSE commits headers. Host keys are never client input. */
124
+ function attachFrontendSession({ req, deps, capabilities, owner, deliver, cancel }) {
125
+ const sessionId = (deps.newSessionId ?? randomUUID)();
126
+ const capacityKeys = deps.resolveCapacityKeys?.({ req, ...(owner ? { owner } : {}) });
127
+ return deps.registry.attach({ descriptor: { sessionId, capabilities, ...(owner ? { owner } : {}), ...(capacityKeys ? { capacityKeys } : {}) },
128
+ deliver, cancel });
129
+ }
109
130
  function isRecord(value) {
110
131
  return typeof value === 'object' && value !== null && !Array.isArray(value);
111
132
  }
@@ -120,12 +141,28 @@ function parseFrontendSessionResponse(input) {
120
141
  if (typeof invocationId !== 'string' || invocationId.trim().length === 0) {
121
142
  return err({ error: validationError({ message: 'invocationId must be a non-empty string' }, { issues: [{ path: 'invocationId', message: 'required non-empty string' }] }) });
122
143
  }
123
- const okFlag = input.body['ok'];
144
+ return parseResponseOutcome({ sessionId, invocationId, body: input.body });
145
+ }
146
+ /** Progress is scoped to a pending invocation; only fixed, value-free decisions enter the audit. */
147
+ function parseApprovalProgress({ sessionId, invocationId, phase, decision }, _options = {}) {
148
+ if (decision === undefined)
149
+ return ok({ value: { sessionId, invocationId, phase } });
150
+ if (phase !== 'execution-started' || typeof decision !== 'string' || !['approved', 'declined', 'expired', 'abandoned'].includes(decision)) {
151
+ return err({ error: validationError({ message: 'invalid approval decision' }) });
152
+ }
153
+ return ok({ value: { sessionId, invocationId, phase, decision: decision } });
154
+ }
155
+ /** Identity parsing precedes outcome parsing so progress and results share exactly one scope. */
156
+ function parseResponseOutcome({ sessionId, invocationId, body }, _options = {}) {
157
+ const phase = body['phase'];
158
+ if (phase === 'approval-pending' || phase === 'execution-started')
159
+ return parseApprovalProgress({ sessionId, invocationId, phase, decision: body['decision'] });
160
+ const okFlag = body['ok'];
124
161
  if (typeof okFlag !== 'boolean') {
125
162
  return err({ error: validationError({ message: 'ok must be a boolean' }, { issues: [{ path: 'ok', message: 'required boolean' }] }) });
126
163
  }
127
164
  if (!okFlag) {
128
- const message = input.body['message'];
165
+ const message = body['message'];
129
166
  if (typeof message !== 'string' || message.trim().length === 0) {
130
167
  // A failure with no reason gives the agent nothing to act on, which is how a caller ends up
131
168
  // retrying the same refusal forever.
@@ -133,7 +170,7 @@ function parseFrontendSessionResponse(input) {
133
170
  }
134
171
  return ok({ value: { sessionId, invocationId, ok: false, message } });
135
172
  }
136
- return ok({ value: { sessionId, invocationId, ok: true, output: input.body['output'] } });
173
+ return ok({ value: { sessionId, invocationId, ok: true, output: body['output'] } });
137
174
  }
138
175
  /**
139
176
  * `POST /api/frontend-sessions/:sessionId/responses` — delivers a surface's answer to the tool
@@ -142,6 +179,8 @@ function parseFrontendSessionResponse(input) {
142
179
  */
143
180
  export const frontendSessionResponseRoute = defineJsonRoute({
144
181
  method: 'post', path: FRONTEND_SESSION_RESPONSE_ROUTE_PATH, parse: parseFrontendSessionResponse, handle: ({ input, deps }) => {
182
+ if ('phase' in input)
183
+ return ok({ value: { settled: deps.registry.progress({ sessionId: input.sessionId, invocationId: input.invocationId, pending: input.phase === 'approval-pending', ...(input.decision ? { decision: input.decision } : {}) }) } });
145
184
  const settled = deps.registry.settle({ sessionId: input.sessionId, invocationId: input.invocationId, outcome: input.ok
146
185
  ? { ok: true, output: input.output }
147
186
  : { ok: false, message: input.message } });
@@ -157,26 +196,65 @@ export const frontendSessionResponseRoute = defineJsonRoute({
157
196
  * never gets a stream (and, notably, never mints a session id or `bindToken` either).
158
197
  */
159
198
  export function registerFrontendSessionRoutes({ app, deps, adapter }, _optional = {}) {
160
- app.get(FRONTEND_SESSION_STREAM_ROUTE_PATH, (req, res) => {
161
- const origin = guardSameOrigin({ req, origin: adapter });
199
+ const prefix = deps.routePrefix ?? '/api/frontend-sessions';
200
+ app.get(`${prefix}/stream`, async (req, res) => {
201
+ // Scoped hosts authenticate origin together with guest/tab ownership in resolveOwner.
202
+ const origin = deps.resolveOwner ? ok({ value: undefined }) : guardSameOrigin({ req, origin: adapter });
162
203
  if (!origin.ok) {
163
204
  sendApiError({ res, status: statusForError({ error: origin.error }), error: origin.error });
164
205
  return;
165
206
  }
166
207
  try {
167
- handleFrontendSessionStream({ req, res, deps });
208
+ const owner = deps.resolveOwner ? await deps.resolveOwner({ req, res }) : undefined;
209
+ if (owner === null) {
210
+ res.sendStatus(403);
211
+ return;
212
+ }
213
+ handleFrontendSessionStream({ req, res, deps, ...(owner ? { owner } : {}) });
168
214
  }
169
215
  catch (error) {
170
- reportInternalError(deps, 'stream', error);
171
- if (res.headersSent) {
172
- if (!res.writableEnded)
173
- res.end();
174
- }
175
- else {
176
- res.status(500).json({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }) });
177
- }
216
+ endStreamOnError({ error, res, deps });
178
217
  }
179
218
  });
180
- mountJsonRoute({ app, spec: frontendSessionResponseRoute, deps, adapter });
219
+ const responsePath = `${prefix}/:sessionId/responses`;
220
+ if (deps.resolveOwner)
221
+ app.post(responsePath, async (req, res, next) => {
222
+ try {
223
+ const owner = await deps.resolveOwner({ req, res });
224
+ const bindToken = req.get('x-frontend-bind-token') ?? '';
225
+ if (!owner || !deps.registry.ownsSession({ sessionId: String(req.params['sessionId']), owner, bindToken })) {
226
+ res.sendStatus(403);
227
+ return;
228
+ }
229
+ next();
230
+ }
231
+ catch {
232
+ res.sendStatus(403);
233
+ }
234
+ });
235
+ mountJsonRoute({ app, spec: { ...frontendSessionResponseRoute, path: responsePath, requireSameOrigin: !deps.resolveOwner }, deps, adapter });
236
+ }
237
+ /** Capacity is ordinary admission refusal, not a loggable internal failure. */
238
+ function endStreamOnError({ error, res, deps }) {
239
+ if (error instanceof FrontendSessionCapacityError && !res.headersSent) {
240
+ res.sendStatus(429);
241
+ return;
242
+ }
243
+ reportInternalError(deps, 'stream', error);
244
+ if (res.headersSent) {
245
+ if (!res.writableEnded)
246
+ res.end();
247
+ }
248
+ else {
249
+ res.status(500).json({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }) });
250
+ }
251
+ }
252
+ /** Expiry tears down addressing and the stream together, including clients which never disconnect. */
253
+ function expireScopedSession({ owner, handle, close, ttl = 30 * 60_000 }) {
254
+ if (!owner)
255
+ return undefined;
256
+ const timer = setTimeout(() => { handle.detach({}); close(); }, ttl);
257
+ timer.unref();
258
+ return timer;
181
259
  }
182
260
  //# sourceMappingURL=frontend-sessions.js.map
@@ -57,4 +57,5 @@ export { ATTACHMENT_LOOKUP_ROUTE_PATH, registerAttachmentLookupRoutes } from './
57
57
  export type { RemoteAttachmentLookupOptional, RemoteAttachmentLookupRequired } from '../remote-attachment-lookup.js';
58
58
  export { createRemoteAttachmentLookup } from '../remote-attachment-lookup.js';
59
59
  export { createTavilyResearchProvider } from './research-tavily.adapter.js';
60
+ export { createFrontendGuestScope } from './frontend-guest-scope.js';
60
61
  //# sourceMappingURL=index.d.ts.map
@@ -30,4 +30,5 @@ export { DEFAULT_TOOL_EXEC_MAX_BODY_BYTES, DEFAULT_TOOL_EXEC_TOKEN_ENV_VAR, regi
30
30
  export { ATTACHMENT_LOOKUP_ROUTE_PATH, registerAttachmentLookupRoutes } from './attachment-lookup-routes.js';
31
31
  export { createRemoteAttachmentLookup } from '../remote-attachment-lookup.js';
32
32
  export { createTavilyResearchProvider } from './research-tavily.adapter.js';
33
+ export { createFrontendGuestScope } from './frontend-guest-scope.js';
33
34
  //# sourceMappingURL=index.js.map
package/dist/index.d.ts CHANGED
@@ -72,8 +72,8 @@
72
72
  * solution belongs at the lifecycle or transport boundary, rather than a
73
73
  * restart-unsafe recorder cache that makes the two paths disagree.
74
74
  *
75
- * node-pty uses native addons. Linux deployment requires python3, make and a
76
- * C/C++ compiler during installation; source compilation is the fallback.
75
+ * The opt-in @jini-ai/daemon/terminal adapter uses node-pty's native addon.
76
+ * Linux hosts selecting it need python3, make and a C/C++ compiler for fallback compilation.
77
77
  * Vendoring foreign prebuilt binaries would create an unowned maintenance burden.
78
78
  * See the owning modules and packages/daemon/README.md for detailed contracts.
79
79
  */
package/dist/index.js CHANGED
@@ -72,8 +72,8 @@
72
72
  * solution belongs at the lifecycle or transport boundary, rather than a
73
73
  * restart-unsafe recorder cache that makes the two paths disagree.
74
74
  *
75
- * node-pty uses native addons. Linux deployment requires python3, make and a
76
- * C/C++ compiler during installation; source compilation is the fallback.
75
+ * The opt-in @jini-ai/daemon/terminal adapter uses node-pty's native addon.
76
+ * Linux hosts selecting it need python3, make and a C/C++ compiler for fallback compilation.
77
77
  * Vendoring foreign prebuilt binaries would create an unowned maintenance burden.
78
78
  * See the owning modules and packages/daemon/README.md for detailed contracts.
79
79
  */
@@ -1,4 +1,5 @@
1
1
  /** Async SQLite execution over the host kernel; event-log policy lives in sqlite-policy. */
2
+ import { loadOptionalPeer } from '@jini-ai/core/optional-peer';
2
3
  import { createSystemClock } from '@jini-ai/core/primitives';
3
4
  import { SQLITE_EVENT_LOG_SCHEMA, resolveMaxEntriesPerRun, assertValidReplayCursor, existingAppendResult, replayResult, rowToEntry, } from './sqlite-policy.js';
4
5
  /**
@@ -57,7 +58,7 @@ export async function createKernelSqliteEventLog(required, optional = {}) {
57
58
  async function setupSchema({ db }, options = {}) {
58
59
  if (options.schemaSetup !== 'host') {
59
60
  // Keep the existing synchronous factory free of a new eager peer import.
60
- const { sql } = await import('kysely');
61
+ const { sql } = await loadOptionalPeer({ feature: 'SQL storage adapters', dependency: 'kysely', load: () => import('kysely') });
61
62
  // Kysely executes one statement at a time; the owner DDL contains no embedded semicolons.
62
63
  for (const statement of SQLITE_EVENT_LOG_SCHEMA.split(';').filter(part => part.trim())) {
63
64
  await sql.raw(statement).execute(db);
@@ -1,3 +1,50 @@
1
+ /**
2
+ * @module terminal-session
3
+ *
4
+ * Interactive-terminal session manager: the `@jini-ai/daemon`-side wiring for
5
+ * OD's `apps/daemon/src/terminals.ts` + `apps/daemon/src/routes/terminal.ts`
6
+ * (see `ADS-memory/reports/proposals/PROP-http-route-packs-terminal-pty-2026-07-21.md`
7
+ * for the full design discussion this module implements a specific decision
8
+ * from). The generic ring-buffer/coalescing/SSE-agnostic session engine
9
+ * already exists as `@jini-ai/platform`'s `terminal.ts` (ported 2026-07-18,
10
+ * before this task) — this module does **not** reimplement that. It adds
11
+ * exactly the three things that engine deliberately left to a caller:
12
+ *
13
+ * 1. **A host-injected `PtySpawn` or `TerminalService`.** The native adapter
14
+ * lives exclusively at `@jini-ai/daemon/terminal`; this default-entry
15
+ * session manager never imports it. The host opts in by supplying
16
+ * `loadSpawnPty: () => loadRealSpawnPty({})` from that subpath.
17
+ *
18
+ * 2. **Session-token gating.** The proposal's blocker was that a
19
+ * *session-scoped* dangerous capability (once a shell is spawned, every
20
+ * subsequent `stdin` write is arbitrary unaudited input for the
21
+ * session's lifetime) does not fit `ToolExecutor`'s call-scoped shape.
22
+ * The resolved design: `ToolExecutor.execute('terminal.create', ...)`
23
+ * (via {@link createTerminalToolRegistrations}) is the one full
24
+ * deny-by-default gated call — it authorizes *creating* a session.
25
+ * Every subsequent `write`/`resize`/`kill`/`attach` call against that
26
+ * session id is validated by a lighter, still-explicit check: this
27
+ * module records which `Principal` created each session at `create()`
28
+ * time, and every later call verifies the calling principal matches,
29
+ * denying (as a `'not-found'` result, never a distinguishable "forbidden"
30
+ * — see {@link checkOwnership}'s doc for why) otherwise. This is not
31
+ * "the session id is unguessable therefore secure" — it is a real,
32
+ * per-call identity check, independent of how hard the id is to guess.
33
+ *
34
+ * 3. **The kill/stdin/resize race fix.** OD's origin (and `@jini-ai/platform`'s
35
+ * faithful port of it) has no lock between a `kill` and a concurrent
36
+ * `write`/`resize` on the same session id: `kill()` only *requests* the
37
+ * real OS process die (sends `SIGTERM`) — the session's `status` does not
38
+ * flip to `'exited'` until the pty's own `onExit` callback fires,
39
+ * asynchronously, once the real process actually dies. A `write`/`resize`
40
+ * processed in that window still observes `status: 'running'` and still
41
+ * reaches the real pty. This module closes that window with its own
42
+ * immediate, lock-protected `killed` flag (see {@link runExclusive} and
43
+ * the `write`/`resize`/`kill` implementations below): once a `kill` call
44
+ * has run its own critical section, any `write`/`resize` queued behind it
45
+ * for the same session id sees `killed: true` and is rejected — it never
46
+ * depends on the real process having actually exited yet.
47
+ */
1
48
  import type { Principal, ToolPolicy, ToolRegistration } from '@jini-ai/core';
2
49
  import { type PtySpawn, type TerminalService, type TerminalSseSink } from '@jini-ai/platform';
3
50
  export type { TerminalSseSink } from '@jini-ai/platform';
@@ -13,24 +60,6 @@ export declare const TERMINAL_CREATE_TOOL_ID = "terminal.create";
13
60
  * merely by registering the tool.
14
61
  */
15
62
  export declare const denyAllTerminalCreatePolicy: ToolPolicy;
16
- /**
17
- * The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
18
- * `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
19
- * static top-level import) so a host process whose platform/environment
20
- * lacks a usable `node-pty` addon can still boot — only an actual
21
- * `terminal.create` call fails, cleanly, rather than crashing at
22
- * module-eval time. Before the first spawn, repairs `node-pty`'s bundled
23
- * `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
24
- * see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
25
- * that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
26
- * but with the resolver anchored at **this** module
27
- * (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
28
- * default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
29
- * its default `require.resolve('node-pty')` would not find the copy this
30
- * package (`@jini-ai/daemon`) actually declares — this is exactly the
31
- * resolver injection seam `spawnHelperCandidatePaths` was built for.
32
- */
33
- export declare function loadRealSpawnPty(_requiredArgs: Record<string, never>): Promise<PtySpawn>;
34
63
  /** The public, principal-neutral session snapshot this module exposes — deliberately its own shape rather than re-exporting `@jini-ai/platform`'s `TerminalSession` verbatim, since that type carries an OD-shaped per-session grouping key (see `@jini-ai/platform`'s `terminal.ts`) this module replaces entirely with the caller-supplied `resourceRef` (see {@link CreateTerminalSessionOptions}), tracked only in this module's own metadata and never forwarded to `@jini-ai/platform` at all — this package's own identifier-neutrality lint (`__tests__/identifier-lint.test.ts`) forbids that origin field's exact name from appearing anywhere in this package's source. */
35
64
  export interface TerminalSessionInfo {
36
65
  readonly id: string;
@@ -129,9 +158,9 @@ export interface TerminalSessionManager {
129
158
  }): Promise<void>;
130
159
  }
131
160
  export interface CreateTerminalSessionManagerOptions {
132
- /** Injected for tests (a fake session manager without a real pty) or an alternate host wiring. @default a fresh `@jini-ai/platform` `TerminalService` backed by {@link loadRealSpawnPty}. */
161
+ /** Host-owned session service. Otherwise a fresh platform service uses the injected `loadSpawnPty`. */
133
162
  readonly terminalService?: TerminalService;
134
- /** @default {@link loadRealSpawnPty} */
163
+ /** Host-owned PTY loader, e.g. `() => loadRealSpawnPty({})` from `@jini-ai/daemon/terminal`. Required for creation unless `terminalService` is supplied. */
135
164
  readonly loadSpawnPty?: () => Promise<PtySpawn>;
136
165
  readonly maxEvents?: number;
137
166
  readonly maxBufferBytes?: number;
@@ -146,6 +175,8 @@ export interface CreateTerminalSessionManagerOptions {
146
175
  * around a `@jini-ai/platform` `TerminalService` adding session ownership,
147
176
  * the kill/write/resize lock, and (via {@link createTerminalToolRegistrations})
148
177
  * `ToolExecutor` gating for creation. See module doc for the full design.
178
+ * Without a host-owned service or loader, boot is inert and `create()` rejects
179
+ * with injection guidance. Wrapper setup is O(1); session work stays in the platform service.
149
180
  */
150
181
  export declare function createTerminalSessionManager(_requiredArgs: Record<string, never>, options?: Pick<CreateTerminalSessionManagerOptions, "terminalService" | "loadSpawnPty" | "maxEvents" | "maxBufferBytes" | "exitTailBytes" | "flushIntervalMs" | "flushThresholdBytes" | "ttlMs" | "shutdownGraceMs">): TerminalSessionManager;
151
182
  export interface CreateTerminalToolRegistrationsOptions {
@@ -1,54 +1,4 @@
1
- /**
2
- * @module terminal-session
3
- *
4
- * Interactive-terminal session manager: the `@jini-ai/daemon`-side wiring for
5
- * OD's `apps/daemon/src/terminals.ts` + `apps/daemon/src/routes/terminal.ts`
6
- * (see `ADS-memory/reports/proposals/PROP-http-route-packs-terminal-pty-2026-07-21.md`
7
- * for the full design discussion this module implements a specific decision
8
- * from). The generic ring-buffer/coalescing/SSE-agnostic session engine
9
- * already exists as `@jini-ai/platform`'s `terminal.ts` (ported 2026-07-18,
10
- * before this task) — this module does **not** reimplement that. It adds
11
- * exactly the three things that engine deliberately left to a caller:
12
- *
13
- * 1. **A real `node-pty`-backed `PtySpawn`.** `@jini-ai/platform` intentionally
14
- * does not depend on `node-pty` (a native compiled addon); `@jini-ai/daemon`
15
- * does (see this package's `package.json` and `archived provenance ledger`'s dated
16
- * section for why that dependency now lives here, not in `@jini-ai/http-kit` or
17
- * `@jini-ai/platform`). {@link loadRealSpawnPty} is the real, dynamically
18
- * imported default the reference session manager below uses.
19
- *
20
- * 2. **Session-token gating.** The proposal's blocker was that a
21
- * *session-scoped* dangerous capability (once a shell is spawned, every
22
- * subsequent `stdin` write is arbitrary unaudited input for the
23
- * session's lifetime) does not fit `ToolExecutor`'s call-scoped shape.
24
- * The resolved design: `ToolExecutor.execute('terminal.create', ...)`
25
- * (via {@link createTerminalToolRegistrations}) is the one full
26
- * deny-by-default gated call — it authorizes *creating* a session.
27
- * Every subsequent `write`/`resize`/`kill`/`attach` call against that
28
- * session id is validated by a lighter, still-explicit check: this
29
- * module records which `Principal` created each session at `create()`
30
- * time, and every later call verifies the calling principal matches,
31
- * denying (as a `'not-found'` result, never a distinguishable "forbidden"
32
- * — see {@link checkOwnership}'s doc for why) otherwise. This is not
33
- * "the session id is unguessable therefore secure" — it is a real,
34
- * per-call identity check, independent of how hard the id is to guess.
35
- *
36
- * 3. **The kill/stdin/resize race fix.** OD's origin (and `@jini-ai/platform`'s
37
- * faithful port of it) has no lock between a `kill` and a concurrent
38
- * `write`/`resize` on the same session id: `kill()` only *requests* the
39
- * real OS process die (sends `SIGTERM`) — the session's `status` does not
40
- * flip to `'exited'` until the pty's own `onExit` callback fires,
41
- * asynchronously, once the real process actually dies. A `write`/`resize`
42
- * processed in that window still observes `status: 'running'` and still
43
- * reaches the real pty. This module closes that window with its own
44
- * immediate, lock-protected `killed` flag (see {@link runExclusive} and
45
- * the `write`/`resize`/`kill` implementations below): once a `kill` call
46
- * has run its own critical section, any `write`/`resize` queued behind it
47
- * for the same session id sees `killed: true` and is rejected — it never
48
- * depends on the real process having actually exited yet.
49
- */
50
- import { createRequire } from 'node:module';
51
- import { createTerminalService, ensureSpawnHelperExecutable, spawnHelperCandidatePaths, } from '@jini-ai/platform';
1
+ import { createTerminalService, } from '@jini-ai/platform';
52
2
  /** The `ToolRegistry` id `terminal.create` executes through — see module doc §2. */
53
3
  export const TERMINAL_CREATE_TOOL_ID = 'terminal.create';
54
4
  /**
@@ -63,43 +13,6 @@ export const TERMINAL_CREATE_TOOL_ID = 'terminal.create';
63
13
  export const denyAllTerminalCreatePolicy = {
64
14
  authorize: () => 'deny',
65
15
  };
66
- const daemonRequire = createRequire(import.meta.url);
67
- /** Adapts a real `node-pty` `IPty` to `@jini-ai/platform`'s narrower `PtyProcess` port — explicit per-member mapping rather than relying on structural assignability, matching this package's established "explicit adapter, no implicit coercion" convention (e.g. `agent-executor.ts`'s `toStringEnvRecord`). */
68
- function toPtyProcess(pty) {
69
- return {
70
- onData: (cb) => {
71
- pty.onData(cb);
72
- },
73
- onExit: (cb) => {
74
- pty.onExit(cb);
75
- },
76
- write: (input) => pty.write(input),
77
- resize: (cols, rows) => pty.resize(cols, rows),
78
- kill: (signal) => pty.kill(signal),
79
- };
80
- }
81
- /**
82
- * The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
83
- * `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
84
- * static top-level import) so a host process whose platform/environment
85
- * lacks a usable `node-pty` addon can still boot — only an actual
86
- * `terminal.create` call fails, cleanly, rather than crashing at
87
- * module-eval time. Before the first spawn, repairs `node-pty`'s bundled
88
- * `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
89
- * see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
90
- * that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
91
- * but with the resolver anchored at **this** module
92
- * (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
93
- * default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
94
- * its default `require.resolve('node-pty')` would not find the copy this
95
- * package (`@jini-ai/daemon`) actually declares — this is exactly the
96
- * resolver injection seam `spawnHelperCandidatePaths` was built for.
97
- */
98
- export async function loadRealSpawnPty(_requiredArgs) {
99
- ensureSpawnHelperExecutable(spawnHelperCandidatePaths({ resolve: (specifier) => daemonRequire.resolve(specifier) }));
100
- const nodePty = await import('node-pty');
101
- return (shell, args, options) => toPtyProcess(nodePty.spawn(shell, args, options));
102
- }
103
16
  function toSessionInfo(session, resourceRef) {
104
17
  return {
105
18
  id: session.id,
@@ -144,11 +57,15 @@ async function runExclusive(locks, id, fn) {
144
57
  * around a `@jini-ai/platform` `TerminalService` adding session ownership,
145
58
  * the kill/write/resize lock, and (via {@link createTerminalToolRegistrations})
146
59
  * `ToolExecutor` gating for creation. See module doc for the full design.
60
+ * Without a host-owned service or loader, boot is inert and `create()` rejects
61
+ * with injection guidance. Wrapper setup is O(1); session work stays in the platform service.
147
62
  */
148
63
  export function createTerminalSessionManager(_requiredArgs, options = {}) {
149
64
  const terminalService = options.terminalService ??
150
65
  createTerminalService({
151
- loadSpawnPty: options.loadSpawnPty ?? (() => loadRealSpawnPty({})),
66
+ loadSpawnPty: options.loadSpawnPty ?? (() => {
67
+ throw new Error('Interactive terminals require a host-supplied terminalService or loadSpawnPty. Import loadRealSpawnPty from @jini-ai/daemon/terminal to enable node-pty.');
68
+ }),
152
69
  ...definedOnly({
153
70
  maxEvents: options.maxEvents,
154
71
  maxBufferBytes: options.maxBufferBytes,
@@ -0,0 +1,22 @@
1
+ import { type PtySpawn } from '@jini-ai/platform';
2
+ /**
3
+ * The real, `node-pty`-backed {@link PtySpawn}. Dynamically imports
4
+ * `node-pty` (mirroring OD's origin `await import('node-pty')`, not a
5
+ * static top-level import) so a host process whose platform/environment
6
+ * lacks a usable `node-pty` addon can still boot — only an actual
7
+ * `terminal.create` call fails, cleanly, rather than crashing at
8
+ * module-eval time. Before the first spawn, repairs `node-pty`'s bundled
9
+ * `spawn-helper` binary's executable bit (pnpm unpacks it non-executable —
10
+ * see `@jini-ai/platform`'s `terminal.ts` module doc for the full story) via
11
+ * that package's own `spawnHelperCandidatePaths`/`ensureSpawnHelperExecutable`,
12
+ * but with the resolver anchored at **this** module
13
+ * (`createRequire(import.meta.url)` here, not `@jini-ai/platform`'s own
14
+ * default): `@jini-ai/platform` deliberately does not depend on `node-pty`, so
15
+ * its default `require.resolve('node-pty')` would not find the copy this
16
+ * package (`@jini-ai/daemon`) actually declares — this is exactly the
17
+ * resolver injection seam `spawnHelperCandidatePaths` was built for.
18
+ * @throws A feature-specific load error with the original resolution/ABI error as `cause`.
19
+ * @complexity O(1) adapter work; module loading and helper repair belong to the platform.
20
+ */
21
+ export declare function loadRealSpawnPty(_requiredArgs: Record<string, never>, _optionalArgs?: Record<string, never>): Promise<PtySpawn>;
22
+ //# sourceMappingURL=terminal.adapter.d.ts.map