@jini-ai/server 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.6.4 — 2026-10-22
2
+
3
+ - 2026-10-22: Kernel composition and feature-activation updates, security fixes, complexity refactors, coverage.
4
+
1
5
  ## 0.6.3 — 2026-10-14
2
6
 
3
7
  - Express 5 accepted: the optional `express` peer is now `^4.21.0 || ^5.0.0` (1442d875), so an Express 5 host no longer hits ERESOLVE.
@@ -12,34 +12,14 @@ import { type JiniFeature } from './feature.js';
12
12
  * existed before it.
13
13
  */
14
14
  export declare const LOCAL_DAEMON_PRINCIPAL: Principal;
15
- /**
16
- * The identity a delegated tool call runs as when the host supplies no resolver. Carries **no
17
- * roles**, deliberately.
18
- *
19
- * ## What actually makes this inert, because an earlier version of this comment named the wrong
20
- * mechanism
21
- *
22
- * It previously claimed inertness came from "every registered tool's own deny-by-default
23
- * `ToolPolicy`". That is **false**, and believing it is dangerous. `@jini-ai/cms` registers every
24
- * one of its tools with a pass-through `policy: { authorize: () => 'allow' }` — deliberately, and
25
- * documented as such in `core/tools/registration-kit.ts`: each tool's permission is evaluated
26
- * exactly once, inside the domain function or via that file's `requireToolPermission`, so a second
27
- * evaluator in the policy would be the bug rather than the guard.
28
- *
29
- * What actually denies this principal is the identity layer being fail-closed on an UNKNOWN
30
- * subject: `identity/authorize.ts` starts with `principals.findById(...)` and returns
31
- * `{ allowed: false, reason: 'principal_disabled' }` when no row comes back. `anonymous-delegated`
32
- * has no row, so every permission-gated tool refuses it.
33
- *
34
- * ## The residual risk that follows from that, stated plainly
35
- *
36
- * The protection is the *tool's own* permission check, not this identity and not the registry. A
37
- * tool registered by some other host with a permissive `ToolPolicy` **and** no internal permission
38
- * check would execute for this principal. That is exactly why `@jini-ai/http-kit`'s
39
- * `DelegatedToolsHttpDeps.resolvePrincipal` documents itself as mandatory, and why the fallback
40
- * below warns rather than staying silent.
41
- */
15
+ /** @deprecated Compatibility export only; never selected as a default delegated identity. */
42
16
  export declare const ANONYMOUS_DELEGATED_PRINCIPAL: Principal;
17
+ /** Resolves a delegated caller through the host's identity owner, refusing absent resolution.
18
+ * @throws Error when the host supplies no resolver. O(1) plus the host resolver's work. */
19
+ export declare function resolveDelegatedToolPrincipal({ request, resolvePrincipal }: {
20
+ request: DelegatedToolExecuteRequest;
21
+ resolvePrincipal: ((request: DelegatedToolExecuteRequest) => Principal | Promise<Principal>) | undefined;
22
+ }, _options?: {}): Principal | Promise<Principal>;
43
23
  /** Per-feature configuration, closed over when the catalog is built. */
44
24
  export interface BuiltInFeatureOptions {
45
25
  readonly health?: {
@@ -36,47 +36,14 @@ export const LOCAL_DAEMON_PRINCIPAL = { id: 'local-daemon' };
36
36
  const require = createRequire(import.meta.url);
37
37
  /** This package's own version, the default a composition reports when a host names none. */
38
38
  const packageVersion = require('../package.json').version;
39
- /**
40
- * The identity a delegated tool call runs as when the host supplies no resolver. Carries **no
41
- * roles**, deliberately.
42
- *
43
- * ## What actually makes this inert, because an earlier version of this comment named the wrong
44
- * mechanism
45
- *
46
- * It previously claimed inertness came from "every registered tool's own deny-by-default
47
- * `ToolPolicy`". That is **false**, and believing it is dangerous. `@jini-ai/cms` registers every
48
- * one of its tools with a pass-through `policy: { authorize: () => 'allow' }` — deliberately, and
49
- * documented as such in `core/tools/registration-kit.ts`: each tool's permission is evaluated
50
- * exactly once, inside the domain function or via that file's `requireToolPermission`, so a second
51
- * evaluator in the policy would be the bug rather than the guard.
52
- *
53
- * What actually denies this principal is the identity layer being fail-closed on an UNKNOWN
54
- * subject: `identity/authorize.ts` starts with `principals.findById(...)` and returns
55
- * `{ allowed: false, reason: 'principal_disabled' }` when no row comes back. `anonymous-delegated`
56
- * has no row, so every permission-gated tool refuses it.
57
- *
58
- * ## The residual risk that follows from that, stated plainly
59
- *
60
- * The protection is the *tool's own* permission check, not this identity and not the registry. A
61
- * tool registered by some other host with a permissive `ToolPolicy` **and** no internal permission
62
- * check would execute for this principal. That is exactly why `@jini-ai/http-kit`'s
63
- * `DelegatedToolsHttpDeps.resolvePrincipal` documents itself as mandatory, and why the fallback
64
- * below warns rather than staying silent.
65
- */
39
+ /** @deprecated Compatibility export only; never selected as a default delegated identity. */
66
40
  export const ANONYMOUS_DELEGATED_PRINCIPAL = { id: 'anonymous-delegated' };
67
- /**
68
- * The `resolvePrincipal` fallback, wrapped so the fallback is announced once at composition rather
69
- * than taken silently. Warns at wiring time, not per request: a per-request warning on a route that
70
- * can be called in a loop is a log-flood, and the thing worth knowing is a deployment fact, not a
71
- * request fact.
72
- */
73
- function warnAndUseAnonymousDelegatedPrincipal() {
74
- // eslint-disable-next-line no-console
75
- console.warn('[@jini-ai/server] delegatedToolCalls is enabled without `resolvePrincipal`; delegated tool ' +
76
- `calls will run as "${ANONYMOUS_DELEGATED_PRINCIPAL.id}", which every permission-gated tool ` +
77
- 'refuses (the identity layer is fail-closed on an unknown subject) but a tool with a ' +
78
- 'permissive ToolPolicy and no internal permission check would still execute for.');
79
- return () => ANONYMOUS_DELEGATED_PRINCIPAL;
41
+ /** Resolves a delegated caller through the host's identity owner, refusing absent resolution.
42
+ * @throws Error when the host supplies no resolver. O(1) plus the host resolver's work. */
43
+ export function resolveDelegatedToolPrincipal({ request, resolvePrincipal }, _options = {}) {
44
+ if (!resolvePrincipal)
45
+ throw new Error('Delegated tool calls require a host principal resolver');
46
+ return resolvePrincipal(request);
80
47
  }
81
48
  /** Thrown when a feature is activated without the host-supplied options it cannot default. */
82
49
  function requireOptions(value, featureId, optionPath) {
@@ -143,6 +110,10 @@ export function isExecutableDetectedAgent(agent) {
143
110
  const def = getAgentDef({ id: agent.id });
144
111
  return def === null || isAgentExecutorSupported({ def });
145
112
  }
113
+ function projectAgentDiagnostic({ agent }, _optional = {}) {
114
+ const diagnostic = agent.diagnostics?.[0]?.message;
115
+ return diagnostic ? { diagnostic } : {};
116
+ }
146
117
  /** Projects a real `DetectedAgent` probe onto the HTTP summary shape. */
147
118
  export function projectDetectedAgent(agent) {
148
119
  return {
@@ -160,7 +131,7 @@ export function projectDetectedAgent(agent) {
160
131
  : {}),
161
132
  modelsSource: agent.modelsSource,
162
133
  ...(agent.supportsCustomModel !== undefined ? { supportsCustomModel: agent.supportsCustomModel } : {}),
163
- ...(agent.diagnostics?.[0]?.message ? { diagnostic: agent.diagnostics[0].message } : {}),
134
+ ...projectAgentDiagnostic({ agent }),
164
135
  };
165
136
  }
166
137
  /**
@@ -395,7 +366,7 @@ export function createBuiltInFeatures(options = {}) {
395
366
  }, {
396
367
  http: ({ app, services }) => registerToolCatalogRoutes({ app: app, deps: {
397
368
  catalog: {
398
- search: ({ query }, { limit } = {}) => searchToolCatalog({ db: sqlite.connection, query }, limit === undefined ? {} : { limit }),
369
+ search: ({ query }, optionalArgs) => searchToolCatalog({ db: sqlite.connection, query }, optionalArgs),
399
370
  describe: ({ id }) => getToolCatalogEntry({ db: sqlite.connection, id }),
400
371
  },
401
372
  }, adapter: services.context.adapter }),
@@ -435,16 +406,7 @@ export function createBuiltInFeatures(options = {}) {
435
406
  // gate — an unverifiable constraint is refused, never waived — it would just make the
436
407
  // read-only gateway refuse everything, so a kernel host gets it wired by default.
437
408
  toolRegistry: services.context.kernel.registry,
438
- // `DelegatedToolsHttpDeps.resolvePrincipal` documents itself as MANDATORY — "there is
439
- // no safe default identity this package could assume on a host's behalf" — and this
440
- // line supplied one anyway, silently. The default stays (removing it is a breaking
441
- // change for hosts already relying on it, and is the owner's call, not this file's)
442
- // but it no longer happens quietly: a host that enables this route without deciding
443
- // who its calls run as should learn that from a startup line, not from an audit.
444
- // See ANONYMOUS_DELEGATED_PRINCIPAL for what does and does not make it inert.
445
- resolvePrincipal: resolveDelegatedPrincipal
446
- ? ({ request }) => resolveDelegatedPrincipal(request)
447
- : warnAndUseAnonymousDelegatedPrincipal(),
409
+ resolvePrincipal: ({ request }) => resolveDelegatedToolPrincipal({ request, resolvePrincipal: resolveDelegatedPrincipal }),
448
410
  }, adapter: services.context.adapter }),
449
411
  }),
450
412
  }),
@@ -45,24 +45,17 @@ function mountPhase(phase, app, composed, daemon) {
45
45
  mountPackHttp({ app, packs: [entry.pack], daemon });
46
46
  }
47
47
  }
48
- /**
49
- * Composes the kernel, the selected feature set, and the caller's own packs onto `config.app`.
50
- *
51
- * @throws For any activation error (unknown feature id, denied capability under an explicit enable,
52
- * unmet `requires`), any missing required feature option, a duplicate tool id, or a failed
53
- * rehydration. Every kernel resource opened before the failure is disposed and closed before the
54
- * error propagates, so a failed composition never leaks a sqlite file handle or a pty manager.
55
- */
56
- export async function composeJiniKernel(config) {
48
+ const originConfig = { allowedOriginsEnvVar: 'JINI_ALLOWED_ORIGINS', webPortEnvVar: 'JINI_WEB_PORT', bindHostEnvVar: 'JINI_BIND_HOST' };
49
+ function prepareComposition({ config }, _optional = {}) {
57
50
  const security = config.security;
58
51
  if (!security || !['host', 'jini-local', 'sidecar-strict'].includes(security.mode)) {
59
52
  throw new Error(defaultServerMessages.invalidSecurityMode);
60
53
  }
61
54
  const env = config.env ?? process.env;
62
55
  // Boot-time, unconditional — deliberately not scoped to `security.mode === 'sidecar-strict' |
63
- // 'jini-local'` the way the two `configuredAllowedOrigins({ config, env })` calls below are. Those compute
56
+ // 'jini-local'` the way `mountApiSecurity`'s `configuredAllowedOrigins({ config, env })` call is. It computes
64
57
  // `extraAllowedOrigins` once at composition time too, but only for the two modes that mount
65
- // `registerApiOriginGuardMiddleware`; the explicit `'host'` mode never reaches either call, so a
58
+ // `registerApiOriginGuardMiddleware`; the explicit `'host'` mode never reaches that call, so a
66
59
  // malformed `JINI_ALLOWED_ORIGINS` would otherwise go undetected at boot regardless of mode and
67
60
  // only surface later, inside `isLocalSameOrigin` (via `guardSameOrigin`, which every
68
61
  // `mountJsonRoute` route and the attachment/model-proxy routes call), on whichever request
@@ -70,7 +63,6 @@ export async function composeJiniKernel(config) {
70
63
  // opens, matching the "fails before a single resource is opened" convention just below — turns
71
64
  // that into a fail-fast boot error instead. `configuredAllowedOrigins` itself no longer throws
72
65
  // (see its own doc): this is the ONE place that still does, deliberately.
73
- const originConfig = { allowedOriginsEnvVar: 'JINI_ALLOWED_ORIGINS', webPortEnvVar: 'JINI_WEB_PORT', bindHostEnvVar: 'JINI_BIND_HOST' };
74
66
  assertValidAllowedOrigins({ config: originConfig, env });
75
67
  const profile = JINI_PROFILES[config.profile ?? 'agent-core-v1'];
76
68
  const catalog = [
@@ -84,6 +76,80 @@ export async function composeJiniKernel(config) {
84
76
  ...(config.capabilities === undefined ? {} : { capabilities: config.capabilities }),
85
77
  ...(config.features === undefined ? {} : { featureOverrides: config.features }),
86
78
  });
79
+ return { security, env, catalog, activation };
80
+ }
81
+ function composeFeatures({ base, adapter, env, catalog, activation, composed }, _optional = {}) {
82
+ const featureContext = { kernel: base, adapter, env };
83
+ const byId = new Map(catalog.map((feature) => [feature.id, feature]));
84
+ for (const record of activation.active) {
85
+ const feature = byId.get(record.id);
86
+ const composition = feature.compose(featureContext);
87
+ composed.push({
88
+ feature,
89
+ pack: composition.pack,
90
+ ...(composition.afterTools === undefined ? {} : { afterTools: composition.afterTools }),
91
+ });
92
+ }
93
+ }
94
+ function registerCompositionTools({ base, featurePacks, featureDaemon, callerPacks, callerDaemon, registrations, composed }, _optional = {}) {
95
+ // Every tool, from every source, before any route mounts.
96
+ registerPackTools({ registry: base.registry, packs: featurePacks, daemon: featureDaemon });
97
+ // Host-contributed tools after the features' own, so a collision names the host's id rather
98
+ // than silently shadowing a built-in.
99
+ for (const registration of registrations)
100
+ base.registry.register(registration);
101
+ registerPackTools({ registry: base.registry, packs: callerPacks, daemon: callerDaemon });
102
+ // Only now is the registry complete — see `toolCatalog`'s own `afterTools` doc.
103
+ for (const entry of composed)
104
+ entry.afterTools?.();
105
+ }
106
+ function mountSecurity({ config, security, env }, _optional = {}) {
107
+ const { app } = config;
108
+ // `sidecar-strict`'s bearer gate mounts BEFORE the body parser, unlike `jini-local`'s: a caller
109
+ // this gate is going to reject with 401 should never have had its body parsed in the first place.
110
+ // The gate reads only `req.path` and the `Authorization` header, so it needs nothing the parser
111
+ // provides. Its origin guard still mounts below, alongside `jini-local`'s.
112
+ if (security.mode === 'sidecar-strict') {
113
+ app.use(requireStrictBearerToken({
114
+ tokenEnvVar: security.tokenEnvVar,
115
+ env,
116
+ }, security.exemptPaths !== undefined ? { exemptPaths: security.exemptPaths } : {}));
117
+ }
118
+ if (config.installJsonBodyParser !== false)
119
+ app.use(express.json());
120
+ mountApiSecurity({ config, security, env });
121
+ }
122
+ function mountApiSecurity({ config, security, env }, _optional = {}) {
123
+ const { app } = config;
124
+ if (security.mode === 'jini-local') {
125
+ registerApiBearerAuthMiddleware({ app,
126
+ tokenConfig: {
127
+ tokenEnvVar: security.apiToken?.tokenEnvVar ?? 'JINI_API_TOKEN',
128
+ disableEnvVar: security.apiToken?.disableEnvVar ?? 'JINI_DISABLE_API_AUTH',
129
+ },
130
+ env,
131
+ });
132
+ }
133
+ if (security.mode === 'sidecar-strict' || security.mode === 'jini-local') {
134
+ registerApiOriginGuardMiddleware({ app, deps: {
135
+ host: security.host,
136
+ extraAllowedOrigins: configuredAllowedOrigins({ config: originConfig, env }),
137
+ getResolvedPort: () => config.adapter.resolvedPortRef.current,
138
+ env,
139
+ ...originConfig,
140
+ } });
141
+ }
142
+ }
143
+ /**
144
+ * Composes the kernel, the selected feature set, and the caller's own packs onto `config.app`.
145
+ *
146
+ * @throws For any activation error (unknown feature id, denied capability under an explicit enable,
147
+ * unmet `requires`), any missing required feature option, a duplicate tool id, or a failed
148
+ * rehydration. Every kernel resource opened before the failure is disposed and closed before the
149
+ * error propagates, so a failed composition never leaks a sqlite file handle or a pty manager.
150
+ */
151
+ export async function composeJiniKernel(config) {
152
+ const { security, env, catalog, activation } = prepareComposition({ config });
87
153
  const base = await createJiniKernelBase({
88
154
  storage: config.storage,
89
155
  agentExecutor: { ...config.agentExecutor, hostEnv: config.agentExecutor?.hostEnv ?? env },
@@ -99,68 +165,14 @@ export async function composeJiniKernel(config) {
99
165
  .bind({ token: AgentExecutorToken, impl: base.agentExecutor });
100
166
  const boundBindings = config.bindings ? config.bindings(kernelBindings) : kernelBindings;
101
167
  callerDaemon = createDaemon({ packs: callerPacks, bindings: boundBindings });
102
- const featureContext = { kernel: base, adapter: config.adapter, env };
103
- const byId = new Map(catalog.map((feature) => [feature.id, feature]));
104
- for (const record of activation.active) {
105
- const feature = byId.get(record.id);
106
- const composition = feature.compose(featureContext);
107
- composed.push({
108
- feature,
109
- pack: composition.pack,
110
- ...(composition.afterTools === undefined ? {} : { afterTools: composition.afterTools }),
111
- });
112
- }
168
+ composeFeatures({ base, adapter: config.adapter, env, catalog, activation, composed });
113
169
  const featurePacks = composed.map((entry) => entry.pack);
114
170
  featureDaemon = createDaemon({ packs: featurePacks, bindings: bindings({}) });
115
- // Every tool, from every source, before any route mounts.
116
- registerPackTools({ registry: base.registry, packs: featurePacks, daemon: featureDaemon });
117
- // Host-contributed tools after the features' own, so a collision names the host's id rather
118
- // than silently shadowing a built-in.
119
- for (const registration of config.toolRegistrations ?? [])
120
- base.registry.register(registration);
121
- registerPackTools({ registry: base.registry, packs: callerPacks, daemon: callerDaemon });
122
- // Only now is the registry complete — see `toolCatalog`'s own `afterTools` doc.
123
- for (const entry of composed)
124
- entry.afterTools?.();
171
+ registerCompositionTools({ base, featurePacks, featureDaemon, callerPacks, callerDaemon,
172
+ registrations: config.toolRegistrations ?? [], composed });
125
173
  const { app } = config;
126
174
  mountPhase('probe', app, composed, featureDaemon);
127
- // `sidecar-strict`'s bearer gate mounts BEFORE the body parser, unlike `jini-local`'s: a caller
128
- // this gate is going to reject with 401 should never have had its body parsed in the first place.
129
- // The gate reads only `req.path` and the `Authorization` header, so it needs nothing the parser
130
- // provides. Its origin guard still mounts below, alongside `jini-local`'s.
131
- if (security.mode === 'sidecar-strict') {
132
- app.use(requireStrictBearerToken({
133
- tokenEnvVar: security.tokenEnvVar,
134
- env,
135
- }, security.exemptPaths !== undefined ? { exemptPaths: security.exemptPaths } : {}));
136
- }
137
- if (config.installJsonBodyParser !== false)
138
- app.use(express.json());
139
- if (security.mode === 'sidecar-strict') {
140
- registerApiOriginGuardMiddleware({ app, deps: {
141
- host: security.host,
142
- extraAllowedOrigins: configuredAllowedOrigins({ config: originConfig, env }),
143
- getResolvedPort: () => config.adapter.resolvedPortRef.current,
144
- env,
145
- ...originConfig,
146
- } });
147
- }
148
- if (security.mode === 'jini-local') {
149
- registerApiBearerAuthMiddleware({ app,
150
- tokenConfig: {
151
- tokenEnvVar: security.apiToken?.tokenEnvVar ?? 'JINI_API_TOKEN',
152
- disableEnvVar: security.apiToken?.disableEnvVar ?? 'JINI_DISABLE_API_AUTH',
153
- },
154
- env,
155
- });
156
- registerApiOriginGuardMiddleware({ app, deps: {
157
- host: security.host,
158
- extraAllowedOrigins: configuredAllowedOrigins({ config: originConfig, env }),
159
- getResolvedPort: () => config.adapter.resolvedPortRef.current,
160
- env,
161
- ...originConfig,
162
- } });
163
- }
175
+ mountSecurity({ config, security, env });
164
176
  mountPhase('api', app, composed, featureDaemon);
165
177
  config.onAfterApiRoutes?.(app, callerDaemon, base);
166
178
  mountPhase('status', app, composed, featureDaemon);
@@ -87,6 +87,56 @@ export function resolveReportHost(bindHost) {
87
87
  return `[${bindHost}]`;
88
88
  return bindHost;
89
89
  }
90
+ function localKernelOptions({ config }, _optional = {}) {
91
+ return {
92
+ profile: config.profile ?? 'local-daemon-v1',
93
+ ...(config.capabilities === undefined ? {} : { capabilities: config.capabilities }),
94
+ ...(config.features === undefined ? {} : { features: config.features }),
95
+ packs: config.packs,
96
+ ...(config.bindings === undefined ? {} : { bindings: config.bindings }),
97
+ ...(config.toolRegistrations === undefined ? {} : { toolRegistrations: config.toolRegistrations }),
98
+ };
99
+ }
100
+ function localSecurity({ config, host }, _optional = {}) {
101
+ return config.security
102
+ ? { ...config.security, host }
103
+ : {
104
+ mode: 'jini-local',
105
+ host,
106
+ apiToken: {
107
+ tokenEnvVar: config.apiToken?.tokenEnvVar ?? DEFAULT_TOKEN_ENV_VAR,
108
+ disableEnvVar: config.apiToken?.disableEnvVar ?? DEFAULT_DISABLE_ENV_VAR,
109
+ },
110
+ };
111
+ }
112
+ function localFeatureOptions({ config, host, resolvedPortRef, xaiListenerRef, isShuttingDown, requestShutdown }, _optional = {}) {
113
+ return {
114
+ health: { getVersion: () => packageVersion, isShuttingDown },
115
+ runs: {
116
+ ...(config.onRunStarted === undefined ? {} : { onRunStarted: config.onRunStarted }),
117
+ ...(config.resolveRunInput === undefined ? {} : { resolveRunInput: config.resolveRunInput }),
118
+ },
119
+ ...(config.agentDetector === undefined ? {} : { agents: { detector: config.agentDetector } }),
120
+ hostTools: {
121
+ ...(config.resolveWorkspaceRoot === undefined ? {} : { resolveWorkspaceRoot: config.resolveWorkspaceRoot }),
122
+ },
123
+ terminal: {
124
+ ...(config.resolveWorkspaceRoot === undefined ? {} : { resolveWorkspaceRoot: config.resolveWorkspaceRoot }),
125
+ },
126
+ ...(config.resolveDelegatedPrincipal === undefined
127
+ ? {}
128
+ : { delegatedToolCalls: { resolvePrincipal: config.resolveDelegatedPrincipal } }),
129
+ xai: { listenerRef: xaiListenerRef },
130
+ daemonStatus: {
131
+ getVersion: () => packageVersion,
132
+ host,
133
+ getPort: () => resolvedPortRef.current,
134
+ dataDir: config.dataDir,
135
+ isShuttingDown,
136
+ requestShutdown,
137
+ },
138
+ };
139
+ }
90
140
  export async function createLocalNodeDaemon(config) {
91
141
  const env = config.env ?? process.env;
92
142
  const host = normalizeDaemonBindHost(config.host ?? DEFAULT_HOST);
@@ -108,65 +158,30 @@ export async function createLocalNodeDaemon(config) {
108
158
  // Owned here rather than by the xai feature so `stop()` observes the same ref the routes wrote to.
109
159
  const xaiListenerRef = { current: null };
110
160
  const app = express();
161
+ app.disable('x-powered-by');
111
162
  installRouteRegistrationGuard({ app });
112
163
  const kernel = await composeJiniKernel({
113
164
  app,
114
165
  adapter,
115
166
  storage: { kind: 'sqlite', dataDir: config.dataDir, open: config.open },
116
- profile: config.profile ?? 'local-daemon-v1',
117
- ...(config.capabilities === undefined ? {} : { capabilities: config.capabilities }),
118
- ...(config.features === undefined ? {} : { features: config.features }),
119
- packs: config.packs,
120
- ...(config.bindings === undefined ? {} : { bindings: config.bindings }),
121
- ...(config.toolRegistrations === undefined ? {} : { toolRegistrations: config.toolRegistrations }),
122
- security: config.security
123
- ? { ...config.security, host }
124
- : {
125
- mode: 'jini-local',
126
- host,
127
- apiToken: {
128
- tokenEnvVar: config.apiToken?.tokenEnvVar ?? DEFAULT_TOKEN_ENV_VAR,
129
- disableEnvVar: config.apiToken?.disableEnvVar ?? DEFAULT_DISABLE_ENV_VAR,
130
- },
131
- },
167
+ ...localKernelOptions({ config }),
168
+ security: localSecurity({ config, host }),
132
169
  env,
133
170
  ...(config.agentExecutor ? { agentExecutor: config.agentExecutor } : {}),
134
- featureOptions: {
135
- health: { getVersion: () => packageVersion, isShuttingDown: () => shuttingDown },
136
- runs: {
137
- ...(config.onRunStarted === undefined ? {} : { onRunStarted: config.onRunStarted }),
138
- ...(config.resolveRunInput === undefined ? {} : { resolveRunInput: config.resolveRunInput }),
139
- },
140
- ...(config.agentDetector === undefined ? {} : { agents: { detector: config.agentDetector } }),
141
- hostTools: {
142
- ...(config.resolveWorkspaceRoot === undefined ? {} : { resolveWorkspaceRoot: config.resolveWorkspaceRoot }),
171
+ featureOptions: localFeatureOptions({ config, host, resolvedPortRef, xaiListenerRef,
172
+ isShuttingDown: () => shuttingDown,
173
+ requestShutdown: () => {
174
+ // `POST /api/daemon/shutdown` answers before shutdown begins, so nothing downstream can
175
+ // ever await this promise. Dropping it with a bare `void` turned any shutdown failure
176
+ // into a process-level unhandled rejection — which on Node's default policy terminates
177
+ // the very process this was trying to shut down gracefully. Reported here instead; the
178
+ // rejection stays on `stopPromise`, so an explicit `stop()` caller still observes it.
179
+ void stop().catch((error) => {
180
+ // eslint-disable-next-line no-console
181
+ console.error('[@jini-ai/server] shutdown requested through POST /api/daemon/shutdown failed', error);
182
+ });
143
183
  },
144
- terminal: {
145
- ...(config.resolveWorkspaceRoot === undefined ? {} : { resolveWorkspaceRoot: config.resolveWorkspaceRoot }),
146
- },
147
- ...(config.resolveDelegatedPrincipal === undefined
148
- ? {}
149
- : { delegatedToolCalls: { resolvePrincipal: config.resolveDelegatedPrincipal } }),
150
- xai: { listenerRef: xaiListenerRef },
151
- daemonStatus: {
152
- getVersion: () => packageVersion,
153
- host,
154
- getPort: () => resolvedPortRef.current,
155
- dataDir: config.dataDir,
156
- isShuttingDown: () => shuttingDown,
157
- requestShutdown: () => {
158
- // `POST /api/daemon/shutdown` answers before shutdown begins, so nothing downstream can
159
- // ever await this promise. Dropping it with a bare `void` turned any shutdown failure
160
- // into a process-level unhandled rejection — which on Node's default policy terminates
161
- // the very process this was trying to shut down gracefully. Reported here instead; the
162
- // rejection stays on `stopPromise`, so an explicit `stop()` caller still observes it.
163
- void stop().catch((error) => {
164
- // eslint-disable-next-line no-console
165
- console.error('[@jini-ai/server] shutdown requested through POST /api/daemon/shutdown failed', error);
166
- });
167
- },
168
- },
169
- },
184
+ }),
170
185
  // Product extensions, then caller-pack routes — the historical position, between the locked
171
186
  // route families and the daemon-status routes.
172
187
  onAfterApiRoutes: (mountedApp, daemon, base) => {
@@ -239,7 +254,7 @@ export async function createLocalNodeDaemon(config) {
239
254
  return await new Promise((resolve, reject) => {
240
255
  const failToBind = (error) => {
241
256
  // Best-effort: a failed boot must not leave any resource this call already opened dangling.
242
- void kernel.close().finally(() => reject(error));
257
+ void kernel.close().then(() => reject(error), () => reject(error));
243
258
  };
244
259
  listen()
245
260
  .then((listeningServer) => {
@@ -11,16 +11,7 @@ function resolveGrants(input) {
11
11
  }
12
12
  return granted;
13
13
  }
14
- /**
15
- * Resolves the composition's feature plan.
16
- *
17
- * @throws If an override names an unknown feature id, if an explicitly-enabled feature needs a
18
- * denied capability, if two catalogued features share an id, or if an active feature's `requires`
19
- * are not satisfied. Every one of these is a composition-time programming/config error that would
20
- * otherwise surface as a mysteriously missing (or mysteriously present) route at runtime.
21
- * @complexity O(f · c) in features and their declared capabilities.
22
- */
23
- export function resolveFeatureActivation(input) {
14
+ function validateActivationConfig({ input }, _optional = {}) {
24
15
  const byId = new Map();
25
16
  for (const feature of input.features) {
26
17
  if (byId.has(feature.id)) {
@@ -46,48 +37,38 @@ export function resolveFeatureActivation(input) {
46
37
  throw new Error(`jini: unknown capability "${capability}" in capabilities config — known capabilities are: ${known}`);
47
38
  }
48
39
  }
49
- const granted = resolveGrants(input);
50
- const coreCapabilities = new Set(CORE_CAPABILITIES);
51
- const active = [];
52
- const inactive = [];
53
- const denied = new Set();
54
- for (const feature of input.features) {
55
- const missing = feature.provides.filter((capability) => !granted.has(capability));
56
- for (const capability of missing)
57
- denied.add(capability);
58
- const permitted = missing.length === 0;
59
- const explicit = overrides[feature.id];
60
- // Rule 2 — an explicit enable never overrides a denied capability, in either direction.
61
- if (explicit === true && !permitted) {
62
- throw new Error(`jini: feature "${feature.id}" was explicitly enabled but requires denied ` +
63
- `capabilit${missing.length > 1 ? 'ies' : 'y'} [${missing.join(', ')}] under profile ` +
64
- `"${input.profile.id}". Grant them via config.capabilities, or remove "${feature.id}" from config.features.`);
65
- }
66
- if (explicit === false) {
67
- inactive.push({ id: feature.id, provides: feature.provides, reason: 'opt-out', deniedCapabilities: [] });
68
- continue;
69
- }
70
- if (explicit === true) {
71
- active.push({ id: feature.id, provides: feature.provides, reason: 'opt-in' });
72
- continue;
73
- }
74
- if (!permitted) {
75
- inactive.push({
76
- id: feature.id,
77
- provides: feature.provides,
78
- reason: 'capability-denied',
79
- deniedCapabilities: missing,
80
- });
81
- continue;
82
- }
83
- // Rule 3 — permitted is not active. `core-only` still requires the caller to name it.
84
- const defaultOn = input.profile.activation === 'all-permitted' ||
85
- feature.provides.every((capability) => coreCapabilities.has(capability));
86
- if (defaultOn)
87
- active.push({ id: feature.id, provides: feature.provides, reason: 'default' });
88
- else
89
- inactive.push({ id: feature.id, provides: feature.provides, reason: 'not-default', deniedCapabilities: [] });
40
+ return byId;
41
+ }
42
+ function selectFeature({ feature, missing, explicit, profile }, _optional = {}) {
43
+ const permitted = missing.length === 0;
44
+ // Rule 2 — an explicit enable never overrides a denied capability, in either direction.
45
+ if (explicit === true && !permitted) {
46
+ throw new Error(`jini: feature "${feature.id}" was explicitly enabled but requires denied ` +
47
+ `capabilit${missing.length > 1 ? 'ies' : 'y'} [${missing.join(', ')}] under profile ` +
48
+ `"${profile.id}". Grant them via config.capabilities, or remove "${feature.id}" from config.features.`);
90
49
  }
50
+ if (explicit === false) {
51
+ return { id: feature.id, provides: feature.provides, reason: 'opt-out', deniedCapabilities: [] };
52
+ }
53
+ if (explicit === true) {
54
+ return { id: feature.id, provides: feature.provides, reason: 'opt-in' };
55
+ }
56
+ if (!permitted) {
57
+ return {
58
+ id: feature.id,
59
+ provides: feature.provides,
60
+ reason: 'capability-denied',
61
+ deniedCapabilities: missing,
62
+ };
63
+ }
64
+ // Rule 3 — permitted is not active. `core-only` still requires the caller to name it.
65
+ const defaultOn = profile.activation === 'all-permitted' ||
66
+ feature.provides.every((capability) => CORE_CAPABILITIES.includes(capability));
67
+ if (defaultOn)
68
+ return { id: feature.id, provides: feature.provides, reason: 'default' };
69
+ return { id: feature.id, provides: feature.provides, reason: 'not-default', deniedCapabilities: [] };
70
+ }
71
+ function validateFeatureDependencies({ active, byId }, _optional = {}) {
91
72
  // Rule 4 — dependencies validated after the full active set is known, so declaration order in the
92
73
  // catalog never decides whether a legitimate configuration is accepted.
93
74
  const activeIds = new Set(active.map((record) => record.id));
@@ -102,6 +83,34 @@ export function resolveFeatureActivation(input) {
102
83
  }
103
84
  }
104
85
  }
86
+ }
87
+ /**
88
+ * Resolves the composition's feature plan.
89
+ *
90
+ * @throws If an override names an unknown feature id, if an explicitly-enabled feature needs a
91
+ * denied capability, if two catalogued features share an id, or if an active feature's `requires`
92
+ * are not satisfied. Every one of these is a composition-time programming/config error that would
93
+ * otherwise surface as a mysteriously missing (or mysteriously present) route at runtime.
94
+ * @complexity O(f · c) in features and their declared capabilities.
95
+ */
96
+ export function resolveFeatureActivation(input) {
97
+ const byId = validateActivationConfig({ input });
98
+ const overrides = input.featureOverrides ?? {};
99
+ const granted = resolveGrants(input);
100
+ const active = [];
101
+ const inactive = [];
102
+ const denied = new Set();
103
+ for (const feature of input.features) {
104
+ const missing = feature.provides.filter((capability) => !granted.has(capability));
105
+ for (const capability of missing)
106
+ denied.add(capability);
107
+ const selection = selectFeature({ feature, missing, explicit: overrides[feature.id], profile: input.profile });
108
+ if ('deniedCapabilities' in selection)
109
+ inactive.push(selection);
110
+ else
111
+ active.push(selection);
112
+ }
113
+ validateFeatureDependencies({ active, byId });
105
114
  return {
106
115
  active,
107
116
  inactive,
@@ -26,6 +26,22 @@ import { openSqliteEventLog } from '@jini-ai/daemon/store/event-log/sqlite';
26
26
  export function classifyRunFailureForRetry(context) {
27
27
  return resumableFromProcessExit({ code: context.code, signal: context.signal }, context.sideEffects === undefined ? {} : { sideEffects: context.sideEffects });
28
28
  }
29
+ async function closeFailedAcquisition({ featureConnection, opened }, _optional = {}) {
30
+ // Every acquired handle is attempted even if another closer fails. Preserve the boot error.
31
+ try {
32
+ featureConnection?.close();
33
+ }
34
+ catch { /* Keep the boot failure. */ }
35
+ await Promise.allSettled(opened.map((log) => log.close?.({})));
36
+ }
37
+ function createKernelAgentRuntime({ options }, _optional = {}) {
38
+ const agentRuntime = createNativeAgentRuntime({ env: options.agentExecutor?.hostEnv ?? process.env }, {
39
+ ...(options.agentExecutor?.runtimeState ? { state: options.agentExecutor.runtimeState } : {}),
40
+ ...(options.agentExecutor?.codexDisablePlugins !== undefined ? { codexDisablePlugins: options.agentExecutor.codexDisablePlugins } : {}),
41
+ ...(options.agentExecutor?.piGracefulShutdownMs !== undefined ? { piGracefulShutdownMs: options.agentExecutor.piGracefulShutdownMs } : {}),
42
+ });
43
+ return agentRuntime;
44
+ }
29
45
  /**
30
46
  * Opens every kernel-owned resource and rehydrates durable run history.
31
47
  *
@@ -82,19 +98,10 @@ export async function createJiniKernelBase(options) {
82
98
  await lifecycle.rehydrate({});
83
99
  }
84
100
  catch (error) {
85
- // Every acquired handle is attempted even if another closer fails. Preserve the boot error.
86
- try {
87
- featureConnection?.close();
88
- }
89
- catch { /* Keep the boot failure. */ }
90
- await Promise.allSettled(opened.map((log) => log.close?.({})));
101
+ await closeFailedAcquisition({ featureConnection, opened });
91
102
  throw error;
92
103
  }
93
- const agentRuntime = createNativeAgentRuntime({ env: options.agentExecutor?.hostEnv ?? process.env }, {
94
- ...(options.agentExecutor?.runtimeState ? { state: options.agentExecutor.runtimeState } : {}),
95
- ...(options.agentExecutor?.codexDisablePlugins !== undefined ? { codexDisablePlugins: options.agentExecutor.codexDisablePlugins } : {}),
96
- ...(options.agentExecutor?.piGracefulShutdownMs !== undefined ? { piGracefulShutdownMs: options.agentExecutor.piGracefulShutdownMs } : {}),
97
- });
104
+ const agentRuntime = createKernelAgentRuntime({ options });
98
105
  const agentExecutor = createAgentExecutor({ lifecycle }, {
99
106
  ...options.agentExecutor,
100
107
  runtimeState: agentRuntime.state,
@@ -6,6 +6,39 @@ async function stopInReverse(modules) {
6
6
  catch { /* Preserve the original failure and continue cleanup. */ }
7
7
  }
8
8
  }
9
+ function bootModuleFailure(required, _optional = {}) {
10
+ const { module, error } = required;
11
+ return {
12
+ name: module.name, owner: module.owner, criticality: module.criticality,
13
+ lifecycle: {
14
+ status: 'failed', reasonCode: error instanceof Error ? error.message : String(error),
15
+ remediationHint: 'check the boot log for the underlying error and retry after fixing it',
16
+ },
17
+ };
18
+ }
19
+ async function runBootStage(required, _optional = {}) {
20
+ const { stage, modules, results } = required;
21
+ const completed = [];
22
+ for (const module of modules) {
23
+ try {
24
+ await module[stage]();
25
+ completed.push(module);
26
+ if (stage === 'start')
27
+ results.set(module.name, {
28
+ name: module.name, owner: module.owner, criticality: module.criticality,
29
+ lifecycle: { status: 'ready' },
30
+ });
31
+ }
32
+ catch (error) {
33
+ results.set(module.name, bootModuleFailure({ module, error }));
34
+ if (module.criticality === 'critical') {
35
+ await stopInReverse(completed);
36
+ return { completed, aborted: true };
37
+ }
38
+ }
39
+ }
40
+ return { completed, aborted: false };
41
+ }
9
42
  /**
10
43
  * Preserve source rollback semantics: prepare failure unwinds completed preparations;
11
44
  * start failure unwinds completed starts only. Failing modules own their partial cleanup.
@@ -22,38 +55,9 @@ export async function runBootLifecycle(required, optional = {}) {
22
55
  throw new Error('boot module names must be unique');
23
56
  }
24
57
  const results = new Map();
25
- const prepared = [];
26
- const ready = [];
27
- let aborted = false;
28
- for (const stage of ['prepare', 'start']) {
29
- const completed = stage === 'prepare' ? prepared : ready;
30
- for (const module of stage === 'prepare' ? modules : prepared) {
31
- try {
32
- await module[stage]();
33
- completed.push(module);
34
- if (stage === 'start')
35
- results.set(module.name, {
36
- name: module.name, owner: module.owner, criticality: module.criticality,
37
- lifecycle: { status: 'ready' },
38
- });
39
- }
40
- catch (error) {
41
- results.set(module.name, {
42
- name: module.name, owner: module.owner, criticality: module.criticality,
43
- lifecycle: {
44
- status: 'failed', reasonCode: error instanceof Error ? error.message : String(error),
45
- remediationHint: 'check the boot log for the underlying error and retry after fixing it',
46
- },
47
- });
48
- if (module.criticality === 'critical') {
49
- await stopInReverse(completed);
50
- aborted = true;
51
- break;
52
- }
53
- }
54
- }
55
- if (aborted)
56
- break;
58
+ const prepared = await runBootStage({ stage: 'prepare', modules, results });
59
+ if (!prepared.aborted) {
60
+ await runBootStage({ stage: 'start', modules: prepared.completed, results });
57
61
  }
58
62
  const ordered = modules.flatMap(module => {
59
63
  const result = results.get(module.name);
@@ -16,6 +16,27 @@ export class SqliteBackendConfigError extends Error {
16
16
  this.name = 'SqliteBackendConfigError';
17
17
  }
18
18
  }
19
+ function resolvePostgresSslMode(required, _optional = {}) {
20
+ const e = required.env;
21
+ return e.JINI_PG_SSL_MODE === 'disable' || e.JINI_PG_SSL_MODE === 'verify-full' ? e.JINI_PG_SSL_MODE : 'require';
22
+ }
23
+ function resolvePostgresConfig(required, _optional = {}) {
24
+ const e = required.env;
25
+ const host = e.JINI_PG_HOST ?? '';
26
+ const portStr = e.JINI_PG_PORT ?? '5432';
27
+ const database = e.JINI_PG_DATABASE ?? '';
28
+ const user = e.JINI_PG_USER ?? '';
29
+ const sslMode = resolvePostgresSslMode({ env: e });
30
+ if (!host || !database || !user) {
31
+ throw new SqliteBackendConfigError({ message: 'JINI_SQLITE_BACKEND=postgres requires JINI_PG_HOST, JINI_PG_DATABASE, JINI_PG_USER. ' +
32
+ 'JINI_PG_PORT defaults to 5432; JINI_PG_SSL_MODE defaults to "require".',
33
+ });
34
+ }
35
+ return {
36
+ kind: 'postgres',
37
+ postgres: { host, port: Number.parseInt(portStr, 10) || 5432, database, user, sslMode },
38
+ };
39
+ }
19
40
  /**
20
41
  * Reads `JINI_SQLITE_BACKEND` (`'sqlite'` default, or `'postgres'`) and, for
21
42
  * `postgres`, `JINI_PG_HOST`/`JINI_PG_PORT`/`JINI_PG_DATABASE`/`JINI_PG_USER`/
@@ -32,26 +53,7 @@ export function resolveSqliteBackendConfig(required, optional = {}) {
32
53
  const e = optional.env ?? process.env;
33
54
  const kind = (e.JINI_SQLITE_BACKEND ?? 'sqlite').trim().toLowerCase();
34
55
  if (kind === 'postgres') {
35
- const host = e.JINI_PG_HOST ?? '';
36
- const portStr = e.JINI_PG_PORT ?? '5432';
37
- const database = e.JINI_PG_DATABASE ?? '';
38
- const user = e.JINI_PG_USER ?? '';
39
- const sslMode = e.JINI_PG_SSL_MODE === 'disable' || e.JINI_PG_SSL_MODE === 'verify-full' ? e.JINI_PG_SSL_MODE : 'require';
40
- if (!host || !database || !user) {
41
- throw new SqliteBackendConfigError({ message: 'JINI_SQLITE_BACKEND=postgres requires JINI_PG_HOST, JINI_PG_DATABASE, JINI_PG_USER. ' +
42
- 'JINI_PG_PORT defaults to 5432; JINI_PG_SSL_MODE defaults to "require".',
43
- });
44
- }
45
- return {
46
- kind: 'postgres',
47
- postgres: {
48
- host,
49
- port: Number.parseInt(portStr, 10) || 5432,
50
- database,
51
- user,
52
- sslMode,
53
- },
54
- };
56
+ return resolvePostgresConfig({ env: e });
55
57
  }
56
58
  if (kind !== 'sqlite' && kind !== '') {
57
59
  throw new SqliteBackendConfigError({ message: `unknown JINI_SQLITE_BACKEND value '${kind}'. Accepted: 'sqlite' (default), 'postgres'.` });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/server",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -65,17 +65,17 @@
65
65
  "@vitest/coverage-v8": "^2.1.9",
66
66
  "better-sqlite3": "^13.0.0",
67
67
  "express": "^4.21.0",
68
- "@jini-ai/sidecar": "0.4.3",
69
- "@jini-ai/daemon": "0.6.5",
70
- "@jini-ai/db": "0.3.4",
71
- "@jini-ai/agent-runtime": "0.4.11",
72
- "@jini-ai/registry": "0.5.2",
73
- "@jini-ai/core": "0.4.6",
74
- "@jini-ai/http-kit": "0.4.2",
75
- "@jini-ai/chat": "0.6.2"
68
+ "@jini-ai/core": "0.4.9",
69
+ "@jini-ai/agent-runtime": "0.4.12",
70
+ "@jini-ai/http-kit": "0.4.3",
71
+ "@jini-ai/daemon": "0.6.9",
72
+ "@jini-ai/sidecar": "0.4.4",
73
+ "@jini-ai/chat": "0.6.7",
74
+ "@jini-ai/registry": "0.5.3",
75
+ "@jini-ai/db": "0.3.6"
76
76
  },
77
77
  "dependencies": {
78
- "@jini-ai/core": "^0.4.6"
78
+ "@jini-ai/core": "^0.4.9"
79
79
  },
80
80
  "peerDependencies": {
81
81
  "better-sqlite3": "^13.0.0",