@jini-ai/server 0.0.0-stage → 0.5.1
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 +118 -0
- package/LICENSE +201 -0
- package/README.md +56 -2
- package/dist/builtin-features.d.ts +138 -0
- package/dist/builtin-features.js +636 -0
- package/dist/compose-jini-kernel.d.ts +162 -0
- package/dist/compose-jini-kernel.js +208 -0
- package/dist/create-local-node-daemon.d.ts +209 -0
- package/dist/create-local-node-daemon.js +281 -0
- package/dist/feature-activation.d.ts +86 -0
- package/dist/feature-activation.js +112 -0
- package/dist/feature.d.ts +153 -0
- package/dist/feature.js +94 -0
- package/dist/frontend-control.d.ts +76 -0
- package/dist/frontend-control.js +77 -0
- package/dist/host-bootstrap.d.ts +103 -0
- package/dist/host-bootstrap.js +146 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +11 -0
- package/dist/kernel-base.d.ts +83 -0
- package/dist/kernel-base.js +125 -0
- package/dist/lifecycle/boot-lifecycle.d.ts +61 -0
- package/dist/lifecycle/boot-lifecycle.js +74 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.js +3 -0
- package/dist/lifecycle/readiness-state.d.ts +6 -0
- package/dist/lifecycle/readiness-state.js +15 -0
- package/dist/storage/backend-config.d.ts +48 -0
- package/dist/storage/backend-config.js +61 -0
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.js +3 -0
- package/dist/storage/legacy/sqlite.d.ts +26 -0
- package/dist/storage/legacy/sqlite.js +63 -0
- package/dist/store/projects/sqlite.d.ts +161 -0
- package/dist/store/projects/sqlite.js +328 -0
- package/package.json +124 -3
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module create-local-node-daemon
|
|
3
|
+
*
|
|
4
|
+
* The "host preset" (extraction-plan.md §2.4) that lets a brand-new product boot a running daemon
|
|
5
|
+
* process by implementing zero interfaces: assembles `@jini-ai/sqlite`'s durable `EventLog`,
|
|
6
|
+
* `@jini-ai/daemon`'s `RunLifecycle` and `AgentExecutor` (the driver that actually spawns an agent
|
|
7
|
+
* CLI subprocess for 23 of the 24 registered defs — see `@jini-ai/daemon`'s own archived provenance ledger), an HTTP
|
|
8
|
+
* app wrapped in `@jini-ai/http-kit`'s route-registration guard and security middleware, a caller's own
|
|
9
|
+
* `@jini-ai/core` packs, and the generic daemon-status routes, then listens and returns `{url, server,
|
|
10
|
+
* stop}`.
|
|
11
|
+
*
|
|
12
|
+
* **As of 2026-07-29 this is a thin listener/discovery wrapper over `composeJiniKernel`** (see
|
|
13
|
+
* `compose-jini-kernel.ts`). Everything it used to assemble inline — the kernel, the tool registry,
|
|
14
|
+
* every route family — now comes from the shared feature catalog, pinned to the immutable
|
|
15
|
+
* `local-daemon-v1` capability profile so this function's mounted surface, route order, tool
|
|
16
|
+
* registration order and shutdown sequence are byte-for-byte what they have always been. The reason
|
|
17
|
+
* for the change is not this function; it is that a preset assembling routes and tools inline, next
|
|
18
|
+
* to a separate composable surface, guarantees the two eventually disagree about what a "feature"
|
|
19
|
+
* mounts. There is now one path, and this is a caller of it.
|
|
20
|
+
*
|
|
21
|
+
* Callers who want a different surface pass `profile`/`capabilities`/`features`; callers who pass
|
|
22
|
+
* nothing get the historical daemon exactly. `local-daemon-v1` is immutable: a future feature never
|
|
23
|
+
* silently joins it.
|
|
24
|
+
*
|
|
25
|
+
* Also writes (2026-07-21) a `@jini-ai/sidecar`-backed local daemon-registry record — see
|
|
26
|
+
* `resolveDaemonRegistryPath`'s own doc and this file's `CreateLocalNodeDaemonConfig.discoveryFile`
|
|
27
|
+
* — once the real bound port is known, and removes it during `stop()`.
|
|
28
|
+
*
|
|
29
|
+
* This preset supported a switchable `transport: 'express' | 'fastify'` HTTP transport from
|
|
30
|
+
* 2026-07-19 through 2026-07-22; it was removed since nothing ever consumed `transport: 'fastify'`
|
|
31
|
+
* in practice — see `archived provenance ledger`'s dated entry and the `future/fastify-transport` branch.
|
|
32
|
+
*/
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import express, {} from 'express';
|
|
35
|
+
import { installRouteRegistrationGuard, mountPackHttp } from '@jini-ai/http-kit';
|
|
36
|
+
import { removeDaemonRegistryRecordIfCurrent, resolveDaemonRegistryPath, writeDaemonRegistryRecord } from '@jini-ai/sidecar';
|
|
37
|
+
import { composeJiniKernel } from './compose-jini-kernel.js';
|
|
38
|
+
import { closeHttpServer, normalizeDaemonBindHost } from './host-bootstrap.js';
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
/** This package's own `package.json` version, echoed back by `GET /api/daemon/status`. */
|
|
41
|
+
const packageVersion = require('../package.json').version;
|
|
42
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
43
|
+
const DEFAULT_TOKEN_ENV_VAR = 'JINI_API_TOKEN';
|
|
44
|
+
const DEFAULT_DISABLE_ENV_VAR = 'JINI_DISABLE_API_AUTH';
|
|
45
|
+
const DEFAULT_BIND_HOST_ENV_VAR = 'JINI_BIND_HOST';
|
|
46
|
+
// Re-exported from their new home so this module's long-standing public surface is unchanged.
|
|
47
|
+
export { buildDaemonDbOperations, projectDetectedAgent } from './builtin-features.js';
|
|
48
|
+
export { classifyRunFailureForRetry } from './kernel-base.js';
|
|
49
|
+
/**
|
|
50
|
+
* Extracts the real bound TCP port from `server.address()`'s result once a `'listening'` event
|
|
51
|
+
* has fired.
|
|
52
|
+
*
|
|
53
|
+
* @param address - The raw return value of `server.address()`.
|
|
54
|
+
* @returns The bound port, or `null` if `address` is `null` (not yet listening), a string (a Unix
|
|
55
|
+
* domain socket path — this module never listens on one), or an `AddressInfo` with a non-positive port.
|
|
56
|
+
* @complexity O(1).
|
|
57
|
+
* @overallScore 100/100
|
|
58
|
+
*/
|
|
59
|
+
export function resolveBoundPort(address) {
|
|
60
|
+
if (address == null || typeof address === 'string')
|
|
61
|
+
return null;
|
|
62
|
+
return address.port > 0 ? address.port : null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The host a daemon's reported base URL should use: binding to every interface (`0.0.0.0` / `::`)
|
|
66
|
+
* is not itself a connectable address, so callers are told to use the IPv4 loopback address
|
|
67
|
+
* instead; and an IPv6 literal is returned in bracketed authority form.
|
|
68
|
+
*
|
|
69
|
+
* The brackets are not cosmetic. `http://${'::1'}:5000` is not a URL — `new URL()` throws on it and
|
|
70
|
+
* so does `fetch`, which made a daemon bound to `::1` hand back a base URL nothing could use and
|
|
71
|
+
* write that same unusable string into its discovery record. RFC 3986 §3.2.2 requires the brackets
|
|
72
|
+
* for any IPv6 literal in an authority, precisely so the address's own colons cannot be read as the
|
|
73
|
+
* port separator.
|
|
74
|
+
*
|
|
75
|
+
* @param bindHost - The literal host `createLocalNodeDaemon` bound to (already normalized).
|
|
76
|
+
* @returns `'127.0.0.1'` for an all-interfaces bind host, `'[…]'` for an IPv6 literal (already-
|
|
77
|
+
* bracketed input is returned unchanged), otherwise `bindHost` unchanged.
|
|
78
|
+
* @complexity O(1).
|
|
79
|
+
* @overallScore 100/100
|
|
80
|
+
*/
|
|
81
|
+
export function resolveReportHost(bindHost) {
|
|
82
|
+
if (bindHost === '0.0.0.0' || bindHost === '::')
|
|
83
|
+
return '127.0.0.1';
|
|
84
|
+
// A colon can only mean "IPv6 literal" here: a bind host is a host, never a `host:port` pair, and
|
|
85
|
+
// no DNS name or IPv4 address contains one.
|
|
86
|
+
if (bindHost.includes(':') && !bindHost.startsWith('['))
|
|
87
|
+
return `[${bindHost}]`;
|
|
88
|
+
return bindHost;
|
|
89
|
+
}
|
|
90
|
+
export async function createLocalNodeDaemon(config) {
|
|
91
|
+
const env = config.env ?? process.env;
|
|
92
|
+
const host = normalizeDaemonBindHost(config.host ?? DEFAULT_HOST);
|
|
93
|
+
const requestedPort = config.port ?? 0;
|
|
94
|
+
const registryPath = config.discoveryFile === false ? null : (config.discoveryFile ?? resolveDaemonRegistryPath({ dataDir: config.dataDir }));
|
|
95
|
+
// `@jini-ai/http-kit`'s origin policy resolves `bindHost` from `JINI_BIND_HOST`. Setting it here,
|
|
96
|
+
// before any request can possibly be served, keeps every same-origin decision in sync with the
|
|
97
|
+
// host this daemon actually bound to.
|
|
98
|
+
env[DEFAULT_BIND_HOST_ENV_VAR] = host;
|
|
99
|
+
const resolvedPortRef = { current: requestedPort };
|
|
100
|
+
// `env`, not `process.env`: when a caller injects its own environment, the line above wrote the
|
|
101
|
+
// bind host into *that* object, and `JINI_ALLOWED_ORIGINS`/`JINI_WEB_PORT` come from it too. The
|
|
102
|
+
// origin-guard middleware is already handed this same `env`; carrying it on the adapter is what
|
|
103
|
+
// makes the per-route `requireSameOrigin` guard evaluate the same supplied environment.
|
|
104
|
+
const adapter = { resolvedPortRef, env, allowedOriginsEnvVar: 'JINI_ALLOWED_ORIGINS', webPortEnvVar: 'JINI_WEB_PORT', bindHostEnvVar: 'JINI_BIND_HOST' };
|
|
105
|
+
let shuttingDown = false;
|
|
106
|
+
let stopPromise = null;
|
|
107
|
+
let server;
|
|
108
|
+
// Owned here rather than by the xai feature so `stop()` observes the same ref the routes wrote to.
|
|
109
|
+
const xaiListenerRef = { current: null };
|
|
110
|
+
const app = express();
|
|
111
|
+
installRouteRegistrationGuard({ app });
|
|
112
|
+
const kernel = await composeJiniKernel({
|
|
113
|
+
app,
|
|
114
|
+
adapter,
|
|
115
|
+
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
|
+
},
|
|
132
|
+
env,
|
|
133
|
+
featureOptions: {
|
|
134
|
+
health: { getVersion: () => packageVersion, isShuttingDown: () => shuttingDown },
|
|
135
|
+
runs: {
|
|
136
|
+
...(config.onRunStarted === undefined ? {} : { onRunStarted: config.onRunStarted }),
|
|
137
|
+
...(config.resolveRunInput === undefined ? {} : { resolveRunInput: config.resolveRunInput }),
|
|
138
|
+
},
|
|
139
|
+
...(config.agentDetector === undefined ? {} : { agents: { detector: config.agentDetector } }),
|
|
140
|
+
hostTools: {
|
|
141
|
+
...(config.resolveWorkspaceRoot === undefined ? {} : { resolveWorkspaceRoot: config.resolveWorkspaceRoot }),
|
|
142
|
+
},
|
|
143
|
+
terminal: {
|
|
144
|
+
...(config.resolveWorkspaceRoot === undefined ? {} : { resolveWorkspaceRoot: config.resolveWorkspaceRoot }),
|
|
145
|
+
},
|
|
146
|
+
...(config.resolveDelegatedPrincipal === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { delegatedToolCalls: { resolvePrincipal: config.resolveDelegatedPrincipal } }),
|
|
149
|
+
xai: { listenerRef: xaiListenerRef },
|
|
150
|
+
daemonStatus: {
|
|
151
|
+
getVersion: () => packageVersion,
|
|
152
|
+
host,
|
|
153
|
+
getPort: () => resolvedPortRef.current,
|
|
154
|
+
dataDir: config.dataDir,
|
|
155
|
+
isShuttingDown: () => shuttingDown,
|
|
156
|
+
requestShutdown: () => {
|
|
157
|
+
// `POST /api/daemon/shutdown` answers before shutdown begins, so nothing downstream can
|
|
158
|
+
// ever await this promise. Dropping it with a bare `void` turned any shutdown failure
|
|
159
|
+
// into a process-level unhandled rejection — which on Node's default policy terminates
|
|
160
|
+
// the very process this was trying to shut down gracefully. Reported here instead; the
|
|
161
|
+
// rejection stays on `stopPromise`, so an explicit `stop()` caller still observes it.
|
|
162
|
+
void stop().catch((error) => {
|
|
163
|
+
// eslint-disable-next-line no-console
|
|
164
|
+
console.error('[@jini-ai/server] shutdown requested through POST /api/daemon/shutdown failed', error);
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
// Product extensions, then caller-pack routes — the historical position, between the locked
|
|
170
|
+
// route families and the daemon-status routes.
|
|
171
|
+
onAfterApiRoutes: (mountedApp, daemon, base) => {
|
|
172
|
+
for (const registerExtension of config.httpExtensions ?? []) {
|
|
173
|
+
registerExtension(mountedApp, { adapter, lifecycle: base.lifecycle, dataDir: config.dataDir });
|
|
174
|
+
}
|
|
175
|
+
mountPackHttp({ app: mountedApp, packs: config.packs, daemon });
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
async function stop() {
|
|
179
|
+
shuttingDown = true;
|
|
180
|
+
if (!stopPromise) {
|
|
181
|
+
stopPromise = (async () => {
|
|
182
|
+
// `finally`, not a bare sequence: `closeHttpServer` rejects on whatever error
|
|
183
|
+
// `server.close()`'s callback reports, and a listener that refused to close is a reason to
|
|
184
|
+
// report, never a reason to abandon every other resource this daemon holds. Skipping the
|
|
185
|
+
// rest would leave two sqlite handles open, a discovery record pointing at a dying process,
|
|
186
|
+
// and the host's `onShutdown` never run — a strictly worse outcome than the failure that
|
|
187
|
+
// caused it. The original rejection still propagates once teardown has finished.
|
|
188
|
+
try {
|
|
189
|
+
await closeHttpServer(server);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
// Feature teardown sits exactly where the inline xAI-listener stop used to: after the
|
|
193
|
+
// listener is closed, before the discovery record is removed. `disposePacks` is
|
|
194
|
+
// best-effort by contract, so one feature's failure cannot block shutdown.
|
|
195
|
+
await kernel.disposeFeatures();
|
|
196
|
+
// The kernel built the caller's pack services too, so it releases them — after the
|
|
197
|
+
// features that were composed against them.
|
|
198
|
+
await kernel.disposeCallerPacks();
|
|
199
|
+
if (registryPath !== null) {
|
|
200
|
+
// Best-effort: a daemon that already served every request successfully must not fail
|
|
201
|
+
// its own shutdown just because its discovery record couldn't be removed.
|
|
202
|
+
try {
|
|
203
|
+
await removeDaemonRegistryRecordIfCurrent({ registryPath, pid: process.pid });
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Intentionally swallowed — see the try's own comment.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// A caller-supplied `onShutdown` failing must never leak any sqlite handle this call
|
|
210
|
+
// opened: `finally` guarantees the close still runs, then the original rejection (if any)
|
|
211
|
+
// propagates to whoever is awaiting `stop()`.
|
|
212
|
+
try {
|
|
213
|
+
await config.onShutdown?.();
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await kernel.closeBase();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
})();
|
|
220
|
+
}
|
|
221
|
+
return stopPromise;
|
|
222
|
+
}
|
|
223
|
+
const listen = () => new Promise((resolve, reject) => {
|
|
224
|
+
let listeningServer;
|
|
225
|
+
try {
|
|
226
|
+
listeningServer = app.listen(requestedPort, host);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
reject(error);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
listeningServer.once('listening', () => resolve(listeningServer));
|
|
233
|
+
// `app.listen` throws synchronously when the port is already in use on some Node versions,
|
|
234
|
+
// but emits an `error` event on others — wiring both paths means this promise always settles.
|
|
235
|
+
listeningServer.on('error', (error) => reject(error));
|
|
236
|
+
});
|
|
237
|
+
const activeFeatures = kernel.activation.active.map((record) => record.id);
|
|
238
|
+
return await new Promise((resolve, reject) => {
|
|
239
|
+
const failToBind = (error) => {
|
|
240
|
+
// Best-effort: a failed boot must not leave any resource this call already opened dangling.
|
|
241
|
+
void kernel.close().finally(() => reject(error));
|
|
242
|
+
};
|
|
243
|
+
listen()
|
|
244
|
+
.then((listeningServer) => {
|
|
245
|
+
server = listeningServer;
|
|
246
|
+
// Widen the between-request idle window so kept-alive sockets survive gaps between bursts
|
|
247
|
+
// (e.g. an SSE stream's idle periods); `headersTimeout` must exceed `keepAliveTimeout`.
|
|
248
|
+
server.keepAliveTimeout = 120_000;
|
|
249
|
+
server.headersTimeout = 125_000;
|
|
250
|
+
const boundPort = resolveBoundPort(server.address());
|
|
251
|
+
if (!boundPort) {
|
|
252
|
+
failToBind(new Error(`@jini-ai/server: daemon failed to resolve listening port (address=${JSON.stringify(server.address())})`));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
resolvedPortRef.current = boundPort;
|
|
256
|
+
const reportedUrl = `http://${resolveReportHost(host)}:${boundPort}`;
|
|
257
|
+
// Writing the discovery record is async; this promise must not resolve — handing the URL
|
|
258
|
+
// back to the caller — until the record a same-machine CLI would read is in place.
|
|
259
|
+
void (async () => {
|
|
260
|
+
if (registryPath !== null) {
|
|
261
|
+
try {
|
|
262
|
+
await writeDaemonRegistryRecord({ registryPath, record: {
|
|
263
|
+
url: reportedUrl,
|
|
264
|
+
host: resolveReportHost(host),
|
|
265
|
+
port: boundPort,
|
|
266
|
+
pid: process.pid,
|
|
267
|
+
startedAt: new Date().toISOString(),
|
|
268
|
+
} });
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Best-effort — a daemon that is otherwise fully up must not fail to boot just
|
|
272
|
+
// because its discovery record couldn't be written.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
resolve({ url: reportedUrl, server, stop, activeFeatures });
|
|
276
|
+
})();
|
|
277
|
+
})
|
|
278
|
+
.catch((error) => failToBind(error));
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
//# sourceMappingURL=create-local-node-daemon.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module feature-activation
|
|
3
|
+
*
|
|
4
|
+
* Decides which features a composition mounts, as a pure function of (profile, capability
|
|
5
|
+
* overrides, feature overrides, catalog). Separated from `composeJiniKernel` so the whole activation
|
|
6
|
+
* policy — the part with the security consequences — is unit-testable without booting a kernel,
|
|
7
|
+
* opening a database, or binding a socket.
|
|
8
|
+
*
|
|
9
|
+
* The four rules, in the order they are applied:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Capabilities are a ceiling.** `granted` starts at the profile's grant set and is adjusted by
|
|
12
|
+
* explicit overrides. A feature is *permitted* only if every capability it provides is granted.
|
|
13
|
+
* 2. **An explicit enable can never beat a denied capability.** This is the rule that keeps a
|
|
14
|
+
* coarse "no network egress" switch meaningful: a caller cannot re-open it one feature at a time
|
|
15
|
+
* by naming that feature. Both halves are reported in the error so the fix is unambiguous.
|
|
16
|
+
* 3. **Permitted is not active.** Under `core-only`, raising the ceiling activates nothing by
|
|
17
|
+
* itself — the caller still names what it wants. Under `all-permitted`, the ceiling *is* the
|
|
18
|
+
* selection, which is what makes a batteries-included daemon preset expressible.
|
|
19
|
+
* 4. **Declared dependencies are validated, not documented.** A feature whose `requires` are not all
|
|
20
|
+
* active is a boot error naming both ends, never a half-mounted capability.
|
|
21
|
+
*
|
|
22
|
+
* An unknown feature id in an override is also a boot error. A silently-ignored typo in a
|
|
23
|
+
* security-relevant config is the exact failure mode of a documented option that no code reads.
|
|
24
|
+
*/
|
|
25
|
+
import type { CapabilityId, JiniFeature, JiniProfile } from './feature.js';
|
|
26
|
+
/** Why a feature ended up active. */
|
|
27
|
+
export type ActivationReason =
|
|
28
|
+
/** The profile's activation policy selected it; the caller said nothing. */
|
|
29
|
+
'default'
|
|
30
|
+
/** The caller named it explicitly, and its capabilities were permitted. */
|
|
31
|
+
| 'opt-in';
|
|
32
|
+
/** Why a feature ended up inactive. */
|
|
33
|
+
export type DeactivationReason =
|
|
34
|
+
/** At least one capability it provides is not granted. */
|
|
35
|
+
'capability-denied'
|
|
36
|
+
/** Permitted, but the profile's activation policy does not default it on. */
|
|
37
|
+
| 'not-default'
|
|
38
|
+
/** The caller turned it off explicitly. */
|
|
39
|
+
| 'opt-out';
|
|
40
|
+
export interface ActiveFeatureRecord {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly provides: readonly CapabilityId[];
|
|
43
|
+
readonly reason: ActivationReason;
|
|
44
|
+
}
|
|
45
|
+
export interface InactiveFeatureRecord {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly provides: readonly CapabilityId[];
|
|
48
|
+
readonly reason: DeactivationReason;
|
|
49
|
+
/** The specific capabilities that blocked it. Empty unless `reason` is `capability-denied`. */
|
|
50
|
+
readonly deniedCapabilities: readonly CapabilityId[];
|
|
51
|
+
}
|
|
52
|
+
export interface FeatureActivationInput {
|
|
53
|
+
/** Every selectable feature, in the order they should compose. */
|
|
54
|
+
readonly features: readonly JiniFeature[];
|
|
55
|
+
readonly profile: JiniProfile;
|
|
56
|
+
/**
|
|
57
|
+
* Raises (`true`) or lowers (`false`) the profile's ceiling. An explicit `undefined` means "no
|
|
58
|
+
* opinion" and leaves the profile's own grant untouched — `undefined` is admitted in the type
|
|
59
|
+
* because building this object by optional spread is the normal way a host assembles it, and a
|
|
60
|
+
* config that silently denied a capability because a spread produced `undefined` would be a
|
|
61
|
+
* genuinely dangerous surprise.
|
|
62
|
+
*/
|
|
63
|
+
readonly capabilities?: Readonly<Partial<Record<CapabilityId, boolean | undefined>>>;
|
|
64
|
+
/** Names features on or off explicitly, within whatever ceiling the capabilities allow. `undefined` again means "no opinion". */
|
|
65
|
+
readonly featureOverrides?: Readonly<Record<string, boolean | undefined>>;
|
|
66
|
+
}
|
|
67
|
+
export interface FeatureActivationPlan {
|
|
68
|
+
/** Active features, in catalog order. */
|
|
69
|
+
readonly active: readonly ActiveFeatureRecord[];
|
|
70
|
+
/** Known-but-inactive features — so a host can tell "switched off" from "does not exist". */
|
|
71
|
+
readonly inactive: readonly InactiveFeatureRecord[];
|
|
72
|
+
readonly grantedCapabilities: readonly CapabilityId[];
|
|
73
|
+
/** Every capability some catalogued feature needs that this composition does not grant. */
|
|
74
|
+
readonly deniedCapabilities: readonly CapabilityId[];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolves the composition's feature plan.
|
|
78
|
+
*
|
|
79
|
+
* @throws If an override names an unknown feature id, if an explicitly-enabled feature needs a
|
|
80
|
+
* denied capability, if two catalogued features share an id, or if an active feature's `requires`
|
|
81
|
+
* are not satisfied. Every one of these is a composition-time programming/config error that would
|
|
82
|
+
* otherwise surface as a mysteriously missing (or mysteriously present) route at runtime.
|
|
83
|
+
* @complexity O(f · c) in features and their declared capabilities.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveFeatureActivation(input: FeatureActivationInput): FeatureActivationPlan;
|
|
86
|
+
//# sourceMappingURL=feature-activation.d.ts.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { CAPABILITY_IDS, CORE_CAPABILITIES, isCapabilityId } from './feature.js';
|
|
2
|
+
function resolveGrants(input) {
|
|
3
|
+
const granted = new Set(input.profile.grants);
|
|
4
|
+
for (const [capability, enabled] of Object.entries(input.capabilities ?? {})) {
|
|
5
|
+
if (enabled === undefined)
|
|
6
|
+
continue;
|
|
7
|
+
if (enabled)
|
|
8
|
+
granted.add(capability);
|
|
9
|
+
else
|
|
10
|
+
granted.delete(capability);
|
|
11
|
+
}
|
|
12
|
+
return granted;
|
|
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) {
|
|
24
|
+
const byId = new Map();
|
|
25
|
+
for (const feature of input.features) {
|
|
26
|
+
if (byId.has(feature.id)) {
|
|
27
|
+
throw new Error(`jini: duplicate feature id "${feature.id}" in the composition catalog`);
|
|
28
|
+
}
|
|
29
|
+
byId.set(feature.id, feature);
|
|
30
|
+
}
|
|
31
|
+
const overrides = input.featureOverrides ?? {};
|
|
32
|
+
for (const id of Object.keys(overrides)) {
|
|
33
|
+
if (!byId.has(id)) {
|
|
34
|
+
const known = [...byId.keys()].sort().join(', ');
|
|
35
|
+
throw new Error(`jini: unknown feature "${id}" in features config — known features are: ${known}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Capability keys are validated for exactly the reason feature ids are, and the consequence is
|
|
39
|
+
// worse: `{'host:exce': false}` deletes a grant nobody holds, so the correctly-spelled
|
|
40
|
+
// `host:exec` survives and every feature it gates stays mounted. The typo reads as an applied
|
|
41
|
+
// denial in the config and is not one — a security switch that fails *open* on a typo. TypeScript
|
|
42
|
+
// catches this at a typed call site; a config parsed from JSON, YAML or env has no such call site.
|
|
43
|
+
for (const capability of Object.keys(input.capabilities ?? {})) {
|
|
44
|
+
if (!isCapabilityId({ value: capability })) {
|
|
45
|
+
const known = [...CAPABILITY_IDS].sort().join(', ');
|
|
46
|
+
throw new Error(`jini: unknown capability "${capability}" in capabilities config — known capabilities are: ${known}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
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: [] });
|
|
90
|
+
}
|
|
91
|
+
// Rule 4 — dependencies validated after the full active set is known, so declaration order in the
|
|
92
|
+
// catalog never decides whether a legitimate configuration is accepted.
|
|
93
|
+
const activeIds = new Set(active.map((record) => record.id));
|
|
94
|
+
for (const record of active) {
|
|
95
|
+
for (const required of byId.get(record.id).requires ?? []) {
|
|
96
|
+
if (!byId.has(required)) {
|
|
97
|
+
throw new Error(`jini: feature "${record.id}" declares requires "${required}", which is not a known feature`);
|
|
98
|
+
}
|
|
99
|
+
if (!activeIds.has(required)) {
|
|
100
|
+
throw new Error(`jini: feature "${record.id}" requires "${required}", which is not active — ` +
|
|
101
|
+
`enable "${required}" or disable "${record.id}".`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
active,
|
|
107
|
+
inactive,
|
|
108
|
+
grantedCapabilities: [...granted],
|
|
109
|
+
deniedCapabilities: [...denied],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=feature-activation.js.map
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module feature
|
|
3
|
+
*
|
|
4
|
+
* The capability/selection vocabulary that sits **over** `@jini-ai/core`'s `Pack`, and the two
|
|
5
|
+
* immutable profiles that name a starting capability grant.
|
|
6
|
+
*
|
|
7
|
+
* Why this is a separate layer rather than fields on `Pack` itself: `Pack` is the universal
|
|
8
|
+
* composition unit every consumer (including third-party packs this package knows nothing about)
|
|
9
|
+
* builds on, and it must stay free of any one host's security vocabulary. `CapabilityId` is
|
|
10
|
+
* `@jini-ai/server`'s answer to "what authority does mounting this grant?", which is a *local-host*
|
|
11
|
+
* question — a different embedder could reasonably classify differently. So the atomicity guarantee
|
|
12
|
+
* lives in the primitive (`Pack.tools` + `Pack.http` are one contribution, see that module's doc)
|
|
13
|
+
* while the activation policy lives here.
|
|
14
|
+
*
|
|
15
|
+
* **Capabilities are an upper bound, not a trigger.** Granting `net:egress` says "this host is
|
|
16
|
+
* *permitted* to run features that reach the network"; it does not silently mount the model proxy,
|
|
17
|
+
* research, connectors and xAI together. What a capability grant does is set the ceiling that a
|
|
18
|
+
* profile's activation policy and a caller's explicit `features` selection must both stay under.
|
|
19
|
+
* The inverse is enforced too: explicitly enabling a feature whose capability is denied is a boot
|
|
20
|
+
* error, never a silent win for either side.
|
|
21
|
+
*/
|
|
22
|
+
import type { Pack } from '@jini-ai/core';
|
|
23
|
+
import type { AdapterContext } from '@jini-ai/http-kit';
|
|
24
|
+
import type { JiniKernelBase } from './kernel-base.js';
|
|
25
|
+
/** Any pack, regardless of its dep/service types — mirrors `@jini-ai/http-kit`'s own local alias. */
|
|
26
|
+
export type AnyPack = Pack<any, any, string>;
|
|
27
|
+
/**
|
|
28
|
+
* What mounting a feature grants a caller who can reach it. Deliberately coarse and closed: the
|
|
29
|
+
* value of this vocabulary is that a host can answer "what did I just turn on?" by reading one
|
|
30
|
+
* list, which stops being true the moment the list is long enough to skim.
|
|
31
|
+
*/
|
|
32
|
+
export type CapabilityId =
|
|
33
|
+
/** Create, observe, cancel and stream runs. The contract every consumer of this kernel needs. */
|
|
34
|
+
'run:transport'
|
|
35
|
+
/** Write agent events into a run this process does not own. Strictly more authority than
|
|
36
|
+
* `tool:delegated` (which at least routes through `ToolExecutor`'s policy) — never granted by
|
|
37
|
+
* any shipped profile. */
|
|
38
|
+
| 'run:inject'
|
|
39
|
+
/** Enumerate and re-probe the agent CLIs installed on this machine. */
|
|
40
|
+
| 'agent:discovery'
|
|
41
|
+
/** The agent-facing door onto the shared `ToolRegistry`. */
|
|
42
|
+
| 'tool:delegated'
|
|
43
|
+
/** Durable, searchable descriptor catalog of the registered tool set. */
|
|
44
|
+
| 'tool:catalog'
|
|
45
|
+
/** Read host state: installed editors, active resource context. */
|
|
46
|
+
| 'host:read'
|
|
47
|
+
/** Spawn a process on the host — a pty, or an external application. */
|
|
48
|
+
| 'host:exec'
|
|
49
|
+
/** Inspect, integrity-check, or rewrite a database file in place. */
|
|
50
|
+
| 'db:admin'
|
|
51
|
+
/** Outbound calls to third parties, and the credentials that go with them. */
|
|
52
|
+
| 'net:egress'
|
|
53
|
+
/** Report daemon status and request its shutdown. */
|
|
54
|
+
| 'daemon:control'
|
|
55
|
+
/** Read/write the durable memory note store. */
|
|
56
|
+
| 'memory:store'
|
|
57
|
+
/** Create and run scheduled routines. */
|
|
58
|
+
| 'routines:schedule'
|
|
59
|
+
/** Generate media through a dispatch engine. */
|
|
60
|
+
| 'media:generate'
|
|
61
|
+
/** Bridge to an attached frontend surface's own capabilities. */
|
|
62
|
+
| 'ui:session';
|
|
63
|
+
/** Every capability this vocabulary declares, for error messages and for exhaustive iteration. */
|
|
64
|
+
export declare const CAPABILITY_IDS: readonly CapabilityId[];
|
|
65
|
+
/** Whether `value` names a capability this vocabulary actually declares. */
|
|
66
|
+
export declare function isCapabilityId(required: {
|
|
67
|
+
value: string;
|
|
68
|
+
}): required is {
|
|
69
|
+
value: CapabilityId;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The capabilities a feature may default to *on* under the `core-only` activation policy — the
|
|
73
|
+
* run transport, installed-agent discovery and the delegated-tool gateway. These are the frozen
|
|
74
|
+
* embedding contract; adding host execution or network egress would expand the security ceiling.
|
|
75
|
+
*
|
|
76
|
+
* A feature qualifies only if **every** capability it provides is in here, so a feature that mixes
|
|
77
|
+
* a core capability with a non-core one (say `run:transport` plus `host:exec`) does not sneak in on
|
|
78
|
+
* the strength of its safe half.
|
|
79
|
+
*/
|
|
80
|
+
export declare const CORE_CAPABILITIES: readonly CapabilityId[];
|
|
81
|
+
/**
|
|
82
|
+
* When a feature's routes mount, relative to the composition's own middleware.
|
|
83
|
+
*
|
|
84
|
+
* - `probe` — before the JSON body parser and before any auth/origin middleware. A liveness or
|
|
85
|
+
* readiness probe must never need a body parser, a bearer token, or a same-origin `Origin`
|
|
86
|
+
* header just to confirm the process is up.
|
|
87
|
+
* - `api` — the normal position, behind whatever security the composition installed.
|
|
88
|
+
* - `status` — last, after every product/pack route, so a status surface reports on a fully
|
|
89
|
+
* assembled app.
|
|
90
|
+
*/
|
|
91
|
+
export type FeaturePhase = 'probe' | 'api' | 'status';
|
|
92
|
+
/** Everything a feature needs to build its pack. Supplied by `composeJiniKernel`. */
|
|
93
|
+
export interface FeatureBuildContext {
|
|
94
|
+
/** Kernel-owned infrastructure. Features **borrow** from here; they never own or close it. */
|
|
95
|
+
readonly kernel: JiniKernelBase;
|
|
96
|
+
readonly adapter: AdapterContext;
|
|
97
|
+
readonly env: NodeJS.ProcessEnv;
|
|
98
|
+
}
|
|
99
|
+
/** What a feature contributes once it has been selected for composition. */
|
|
100
|
+
export interface FeatureComposition {
|
|
101
|
+
/** The atomic unit: this feature's services, tools, routes and teardown, together. */
|
|
102
|
+
readonly pack: AnyPack;
|
|
103
|
+
/**
|
|
104
|
+
* Runs after **every** active feature's tools are in the shared registry, and before any routes
|
|
105
|
+
* mount. The one ordering a `Pack` cannot express on its own, needed by any feature that derives
|
|
106
|
+
* something from the complete tool set rather than contributing to it (today: the tool catalog's
|
|
107
|
+
* durable snapshot, which would be silently partial if it ran during its own pack's `tools`).
|
|
108
|
+
*/
|
|
109
|
+
readonly afterTools?: () => void;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A selectable built-in or product feature: capability metadata plus a builder for the one `Pack`
|
|
113
|
+
* that carries its routes, tools and teardown together.
|
|
114
|
+
*/
|
|
115
|
+
export interface JiniFeature {
|
|
116
|
+
readonly id: string;
|
|
117
|
+
/** The authority mounting this feature grants. `[]` means none — always permitted (e.g. health). */
|
|
118
|
+
readonly provides: readonly CapabilityId[];
|
|
119
|
+
/** Other feature ids that must also be active. Unmet is a boot error naming both ends. */
|
|
120
|
+
readonly requires?: readonly string[];
|
|
121
|
+
/** @default 'api' */
|
|
122
|
+
readonly phase?: FeaturePhase;
|
|
123
|
+
readonly compose: (context: FeatureBuildContext) => FeatureComposition;
|
|
124
|
+
}
|
|
125
|
+
/** Identity helper that pins a feature's shape at the definition site. */
|
|
126
|
+
export declare function defineJiniFeature(feature: JiniFeature): JiniFeature;
|
|
127
|
+
/**
|
|
128
|
+
* How a profile decides which *permitted* features are on by default.
|
|
129
|
+
*
|
|
130
|
+
* - `core-only` — only features whose capabilities are all in {@link CORE_CAPABILITIES}. Granting
|
|
131
|
+
* an extra capability under this policy raises the ceiling; it activates nothing on its own.
|
|
132
|
+
* - `all-permitted` — every feature the grant set permits. The coarse, batteries-included posture
|
|
133
|
+
* the standalone daemon has always had; denying a capability here turns off everything that
|
|
134
|
+
* needed it, in one line.
|
|
135
|
+
*/
|
|
136
|
+
export type ProfileActivation = 'core-only' | 'all-permitted';
|
|
137
|
+
export interface JiniProfile {
|
|
138
|
+
readonly id: JiniProfileId;
|
|
139
|
+
readonly grants: readonly CapabilityId[];
|
|
140
|
+
readonly activation: ProfileActivation;
|
|
141
|
+
}
|
|
142
|
+
export type JiniProfileId = 'agent-core-v1' | 'local-daemon-v1';
|
|
143
|
+
/**
|
|
144
|
+
* The shipped profiles. **Versioned and immutable**: a future capability must never silently join
|
|
145
|
+
* an existing profile's grant set, because a consumer pinned to `local-daemon-v1` chose the
|
|
146
|
+
* authority surface it had on the day it pinned. A new set is a new id (`-v2`), not an edit.
|
|
147
|
+
*
|
|
148
|
+
* Both name a *capability grant*, never a hand-listed feature array — so the active feature list is
|
|
149
|
+
* always computed from capability semantics and cannot drift away from them when a feature is
|
|
150
|
+
* added, renamed, or reclassified.
|
|
151
|
+
*/
|
|
152
|
+
export declare const JINI_PROFILES: Readonly<Record<JiniProfileId, JiniProfile>>;
|
|
153
|
+
//# sourceMappingURL=feature.d.ts.map
|