@jini-ai/server 0.0.0-stage → 0.5.0
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 +631 -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 +282 -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 +156 -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 +86 -3
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module compose-jini-kernel
|
|
3
|
+
*
|
|
4
|
+
* The one composition path. Everything this package can mount goes through here — including
|
|
5
|
+
* `createLocalNodeDaemon`, which is now a listener/discovery wrapper over this function rather than
|
|
6
|
+
* a second, independently-drifting assembly.
|
|
7
|
+
*
|
|
8
|
+
* That single-path property is the point. When a preset assembles routes and tools inline *and* a
|
|
9
|
+
* separate "composable" surface exists beside it, the two disagree the moment someone adds a family
|
|
10
|
+
* to one and forgets the other — and the disagreement is invisible, because both compile. Here a
|
|
11
|
+
* new family is a `JiniFeature` in the catalog, and every consumer picks it up or explicitly does
|
|
12
|
+
* not; there is no second place to forget.
|
|
13
|
+
*
|
|
14
|
+
* Mounting order is fixed and load-bearing:
|
|
15
|
+
*
|
|
16
|
+
* 1. `probe`-phase routes — before the body parser and before any auth, so liveness/readiness never
|
|
17
|
+
* depends on either.
|
|
18
|
+
* 2. `sidecar-strict`'s bearer gate, when that mode is active — ahead of the body parser, so a
|
|
19
|
+
* request this gate will reject never has its body parsed at all. It needs only `req.path` and the
|
|
20
|
+
* `Authorization` header.
|
|
21
|
+
* 3. the JSON body parser, then (when this composition owns security) the remaining gates:
|
|
22
|
+
* `sidecar-strict`'s origin guard, or `jini-local`'s bearer + origin pair.
|
|
23
|
+
* 4. `api`-phase routes, in catalog order.
|
|
24
|
+
* 5. the caller's own `onAfterApiRoutes` hook — product HTTP extensions and pack routes.
|
|
25
|
+
* 6. `status`-phase routes, last, so a status surface reports on a fully assembled app.
|
|
26
|
+
*
|
|
27
|
+
* Tools are registered for every active feature **before any route mounts**, so a feature's routes
|
|
28
|
+
* can assume its own tools exist, and `afterTools` hooks (the tool catalog's durable snapshot) see
|
|
29
|
+
* the complete registry rather than a partial one.
|
|
30
|
+
*/
|
|
31
|
+
import { type Express } from 'express';
|
|
32
|
+
import { type Bindings, type Daemon, type Pack, type ToolRegistration } from '@jini-ai/core';
|
|
33
|
+
import { type AdapterContext } from '@jini-ai/http-kit';
|
|
34
|
+
import { type BuiltInFeatureOptions } from './builtin-features.js';
|
|
35
|
+
import { type AnyPack, type CapabilityId, type JiniFeature, type JiniProfileId } from './feature.js';
|
|
36
|
+
import { type FeatureActivationPlan } from './feature-activation.js';
|
|
37
|
+
import { createJiniKernelBase, type JiniKernelBase, type JiniKernelStorage } from './kernel-base.js';
|
|
38
|
+
/** Replaceable server validation wording shared by every composition entry. */
|
|
39
|
+
export declare const defaultServerMessages: {
|
|
40
|
+
invalidSecurityMode: string;
|
|
41
|
+
};
|
|
42
|
+
/** The token ids every composition binds before any caller customization runs. */
|
|
43
|
+
export type KernelBoundIds = 'jini.eventLog' | 'jini.runLifecycle' | 'jini.agentExecutor';
|
|
44
|
+
/** How this composition's `/api` surface is guarded. */
|
|
45
|
+
export type JiniKernelSecurity =
|
|
46
|
+
/** The caller's app already owns authentication/origin policy. Nothing is installed. */
|
|
47
|
+
{
|
|
48
|
+
readonly mode: 'host';
|
|
49
|
+
}
|
|
50
|
+
/** This composition installs the loopback-daemon bearer gate and origin guard. */
|
|
51
|
+
| {
|
|
52
|
+
readonly mode: 'jini-local';
|
|
53
|
+
readonly host: string;
|
|
54
|
+
readonly apiToken?: {
|
|
55
|
+
readonly tokenEnvVar?: string;
|
|
56
|
+
readonly disableEnvVar?: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Like `jini-local`, but for a daemon whose threat model is **another process running as the same
|
|
61
|
+
* OS user**, not a remote attacker: every `/api` request must carry the bearer token, including
|
|
62
|
+
* one arriving from `127.0.0.1`, and an unconfigured token fails closed with 503 rather than
|
|
63
|
+
* silently serving unauthenticated callers.
|
|
64
|
+
*
|
|
65
|
+
* Use this when the daemon holds authority a co-resident process should not be able to borrow —
|
|
66
|
+
* starting real agent runs, executing tools against a real database. A loopback bind keeps remote
|
|
67
|
+
* hosts out but does nothing about a sibling process, which is precisely what `jini-local`'s
|
|
68
|
+
* loopback short-circuit trusts.
|
|
69
|
+
*
|
|
70
|
+
* `probe`-phase routes (health/readiness/version) mount before this gate and therefore stay
|
|
71
|
+
* reachable without a token — monitoring never needs a secret.
|
|
72
|
+
*/
|
|
73
|
+
| {
|
|
74
|
+
readonly mode: 'sidecar-strict';
|
|
75
|
+
readonly host: string;
|
|
76
|
+
/**
|
|
77
|
+
* Env var carrying the required bearer token. Required with no default — this package does
|
|
78
|
+
* not name a host's secret, matching every other host-resolved seam in the engine.
|
|
79
|
+
*/
|
|
80
|
+
readonly tokenEnvVar: string;
|
|
81
|
+
/**
|
|
82
|
+
* Exact `req.path` values this gate does not apply to. Defaults to none. Every entry is a hole
|
|
83
|
+
* in the gate, so each one belongs at the mount site with a stated reason — the intended use
|
|
84
|
+
* is a callback route whose only legitimate caller cannot yet present a credential.
|
|
85
|
+
*/
|
|
86
|
+
readonly exemptPaths?: readonly string[];
|
|
87
|
+
};
|
|
88
|
+
export interface ComposeJiniKernelConfig {
|
|
89
|
+
/** The app to mount onto. Always caller-supplied — this function never opens a listener. */
|
|
90
|
+
readonly app: Express;
|
|
91
|
+
readonly adapter: AdapterContext;
|
|
92
|
+
readonly storage: JiniKernelStorage;
|
|
93
|
+
/** @default 'agent-core-v1' — the conservative embedding posture. */
|
|
94
|
+
readonly profile?: JiniProfileId;
|
|
95
|
+
/** Raises or lowers the profile's capability ceiling. A denial here always wins. */
|
|
96
|
+
readonly capabilities?: Readonly<Partial<Record<CapabilityId, boolean | undefined>>>;
|
|
97
|
+
/** Names built-in (or `extraFeatures`) features on/off within that ceiling. */
|
|
98
|
+
readonly features?: Readonly<Record<string, boolean | undefined>>;
|
|
99
|
+
readonly featureOptions?: BuiltInFeatureOptions;
|
|
100
|
+
/** Product features. Same shape, same activation rules, same atomicity — no second path. */
|
|
101
|
+
readonly extraFeatures?: readonly JiniFeature[];
|
|
102
|
+
/** Host-contributed tools, registered after every active feature's own. */
|
|
103
|
+
readonly toolRegistrations?: readonly ToolRegistration[];
|
|
104
|
+
/** Caller-owned `@jini-ai/core` packs, composed against the kernel bindings. */
|
|
105
|
+
readonly packs?: readonly AnyPack[];
|
|
106
|
+
readonly bindings?: (b: Bindings<KernelBoundIds>) => Bindings<string>;
|
|
107
|
+
readonly agentExecutor?: Parameters<typeof createJiniKernelBase>[0]['agentExecutor'];
|
|
108
|
+
/** Explicit trust policy; omitted and unknown modes are rejected before opening resources. */
|
|
109
|
+
readonly security: JiniKernelSecurity;
|
|
110
|
+
/** @default true — set false when the caller's app already parses JSON bodies. */
|
|
111
|
+
readonly installJsonBodyParser?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Runs after `api`-phase routes and before `status`-phase ones.
|
|
114
|
+
*
|
|
115
|
+
* Receives `base` explicitly rather than letting the caller close over the `JiniKernel` this
|
|
116
|
+
* function returns: the hook fires *during* composition, so that binding does not exist yet and
|
|
117
|
+
* reading it would be a temporal-dead-zone `ReferenceError` at boot.
|
|
118
|
+
*/
|
|
119
|
+
readonly onAfterApiRoutes?: (app: Express, daemon: Daemon<readonly AnyPack[]>, base: JiniKernelBase) => void;
|
|
120
|
+
/** @default process.env */
|
|
121
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
122
|
+
}
|
|
123
|
+
export interface JiniKernel {
|
|
124
|
+
readonly base: JiniKernelBase;
|
|
125
|
+
/** The composed caller packs' services, exactly as `createDaemon` returns them. */
|
|
126
|
+
readonly daemon: Daemon<readonly AnyPack[]>;
|
|
127
|
+
/** What was mounted, why, and what it grants — the machine-readable answer to "is X on?". */
|
|
128
|
+
readonly activation: FeatureActivationPlan;
|
|
129
|
+
/**
|
|
130
|
+
* Runs every active feature pack's `dispose`, in reverse composition order, best-effort. Separate
|
|
131
|
+
* from {@link closeBase} so a composition root can interleave its own shutdown steps between the
|
|
132
|
+
* two — which is exactly what the standalone daemon does.
|
|
133
|
+
*/
|
|
134
|
+
disposeFeatures(): Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* Runs every **caller** pack's `dispose`, in reverse composition order, best-effort. Idempotent.
|
|
137
|
+
*
|
|
138
|
+
* This composition called `createDaemon` on `config.packs`, so it — not the caller — is what
|
|
139
|
+
* built their services; a pack whose `services()` opened a socket, a timer or a database handle
|
|
140
|
+
* has no other owner to release it. Kept separate from {@link disposeFeatures} for the same
|
|
141
|
+
* reason that one is separate from {@link closeBase}: a composition root shuts down in its own
|
|
142
|
+
* order, and the standalone daemon interleaves discovery-record removal and the host's
|
|
143
|
+
* `onShutdown` hook between these steps.
|
|
144
|
+
*/
|
|
145
|
+
disposeCallerPacks(): Promise<void>;
|
|
146
|
+
/** Closes the kernel-owned sqlite handles. Idempotent. */
|
|
147
|
+
closeBase(): Promise<void>;
|
|
148
|
+
/** {@link disposeFeatures}, then {@link disposeCallerPacks}, then {@link closeBase}. */
|
|
149
|
+
close(): Promise<void>;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Composes the kernel, the selected feature set, and the caller's own packs onto `config.app`.
|
|
153
|
+
*
|
|
154
|
+
* @throws For any activation error (unknown feature id, denied capability under an explicit enable,
|
|
155
|
+
* unmet `requires`), any missing required feature option, a duplicate tool id, or a failed
|
|
156
|
+
* rehydration. Every kernel resource opened before the failure is disposed and closed before the
|
|
157
|
+
* error propagates, so a failed composition never leaks a sqlite file handle or a pty manager.
|
|
158
|
+
*/
|
|
159
|
+
export declare function composeJiniKernel(config: ComposeJiniKernelConfig): Promise<JiniKernel>;
|
|
160
|
+
/** Re-exported so a caller can name the pack type without reaching into `@jini-ai/core`'s internals. */
|
|
161
|
+
export type { Pack };
|
|
162
|
+
//# sourceMappingURL=compose-jini-kernel.d.ts.map
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module compose-jini-kernel
|
|
3
|
+
*
|
|
4
|
+
* The one composition path. Everything this package can mount goes through here — including
|
|
5
|
+
* `createLocalNodeDaemon`, which is now a listener/discovery wrapper over this function rather than
|
|
6
|
+
* a second, independently-drifting assembly.
|
|
7
|
+
*
|
|
8
|
+
* That single-path property is the point. When a preset assembles routes and tools inline *and* a
|
|
9
|
+
* separate "composable" surface exists beside it, the two disagree the moment someone adds a family
|
|
10
|
+
* to one and forgets the other — and the disagreement is invisible, because both compile. Here a
|
|
11
|
+
* new family is a `JiniFeature` in the catalog, and every consumer picks it up or explicitly does
|
|
12
|
+
* not; there is no second place to forget.
|
|
13
|
+
*
|
|
14
|
+
* Mounting order is fixed and load-bearing:
|
|
15
|
+
*
|
|
16
|
+
* 1. `probe`-phase routes — before the body parser and before any auth, so liveness/readiness never
|
|
17
|
+
* depends on either.
|
|
18
|
+
* 2. `sidecar-strict`'s bearer gate, when that mode is active — ahead of the body parser, so a
|
|
19
|
+
* request this gate will reject never has its body parsed at all. It needs only `req.path` and the
|
|
20
|
+
* `Authorization` header.
|
|
21
|
+
* 3. the JSON body parser, then (when this composition owns security) the remaining gates:
|
|
22
|
+
* `sidecar-strict`'s origin guard, or `jini-local`'s bearer + origin pair.
|
|
23
|
+
* 4. `api`-phase routes, in catalog order.
|
|
24
|
+
* 5. the caller's own `onAfterApiRoutes` hook — product HTTP extensions and pack routes.
|
|
25
|
+
* 6. `status`-phase routes, last, so a status surface reports on a fully assembled app.
|
|
26
|
+
*
|
|
27
|
+
* Tools are registered for every active feature **before any route mounts**, so a feature's routes
|
|
28
|
+
* can assume its own tools exist, and `afterTools` hooks (the tool catalog's durable snapshot) see
|
|
29
|
+
* the complete registry rather than a partial one.
|
|
30
|
+
*/
|
|
31
|
+
import express, {} from 'express';
|
|
32
|
+
import { bindings, assertValidAllowedOrigins, configuredAllowedOrigins, createDaemon, disposePacks, registerPackTools, } from '@jini-ai/core';
|
|
33
|
+
import { AgentExecutorToken, EventLogToken, RunLifecycleToken } from '@jini-ai/daemon';
|
|
34
|
+
import { mountPackHttp, registerApiBearerAuthMiddleware, registerApiOriginGuardMiddleware, requireStrictBearerToken, } from '@jini-ai/http-kit';
|
|
35
|
+
import { createBuiltInFeatures } from './builtin-features.js';
|
|
36
|
+
import { JINI_PROFILES, } from './feature.js';
|
|
37
|
+
import { resolveFeatureActivation } from './feature-activation.js';
|
|
38
|
+
import { createJiniKernelBase } from './kernel-base.js';
|
|
39
|
+
/** Replaceable server validation wording shared by every composition entry. */
|
|
40
|
+
export const defaultServerMessages = { invalidSecurityMode: 'an explicit supported security mode is required' };
|
|
41
|
+
function mountPhase(phase, app, composed, daemon) {
|
|
42
|
+
for (const entry of composed) {
|
|
43
|
+
if ((entry.feature.phase ?? 'api') !== phase)
|
|
44
|
+
continue;
|
|
45
|
+
mountPackHttp({ app, packs: [entry.pack], daemon });
|
|
46
|
+
}
|
|
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) {
|
|
57
|
+
const security = config.security;
|
|
58
|
+
if (!security || !['host', 'jini-local', 'sidecar-strict'].includes(security.mode)) {
|
|
59
|
+
throw new Error(defaultServerMessages.invalidSecurityMode);
|
|
60
|
+
}
|
|
61
|
+
const env = config.env ?? process.env;
|
|
62
|
+
// 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
|
|
64
|
+
// `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
|
|
66
|
+
// malformed `JINI_ALLOWED_ORIGINS` would otherwise go undetected at boot regardless of mode and
|
|
67
|
+
// only surface later, inside `isLocalSameOrigin` (via `guardSameOrigin`, which every
|
|
68
|
+
// `mountJsonRoute` route and the attachment/model-proxy routes call), on whichever request
|
|
69
|
+
// happened to be first to need a same-origin decision. Asserting here — before any resource
|
|
70
|
+
// opens, matching the "fails before a single resource is opened" convention just below — turns
|
|
71
|
+
// that into a fail-fast boot error instead. `configuredAllowedOrigins` itself no longer throws
|
|
72
|
+
// (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
|
+
assertValidAllowedOrigins({ config: originConfig, env });
|
|
75
|
+
const profile = JINI_PROFILES[config.profile ?? 'agent-core-v1'];
|
|
76
|
+
const catalog = [
|
|
77
|
+
...createBuiltInFeatures(config.featureOptions ?? {}),
|
|
78
|
+
...(config.extraFeatures ?? []),
|
|
79
|
+
];
|
|
80
|
+
// Pure, and first: an invalid selection fails before a single resource is opened.
|
|
81
|
+
const activation = resolveFeatureActivation({
|
|
82
|
+
features: catalog,
|
|
83
|
+
profile,
|
|
84
|
+
...(config.capabilities === undefined ? {} : { capabilities: config.capabilities }),
|
|
85
|
+
...(config.features === undefined ? {} : { featureOverrides: config.features }),
|
|
86
|
+
});
|
|
87
|
+
const base = await createJiniKernelBase({
|
|
88
|
+
storage: config.storage,
|
|
89
|
+
...(config.agentExecutor === undefined ? {} : { agentExecutor: config.agentExecutor }),
|
|
90
|
+
});
|
|
91
|
+
const composed = [];
|
|
92
|
+
let featureDaemon = { services: {} };
|
|
93
|
+
let callerDaemon = { services: {} };
|
|
94
|
+
const callerPacks = config.packs ?? [];
|
|
95
|
+
try {
|
|
96
|
+
const kernelBindings = bindings({})
|
|
97
|
+
.bind({ token: EventLogToken, impl: base.eventLog })
|
|
98
|
+
.bind({ token: RunLifecycleToken, impl: base.lifecycle })
|
|
99
|
+
.bind({ token: AgentExecutorToken, impl: base.agentExecutor });
|
|
100
|
+
const boundBindings = config.bindings ? config.bindings(kernelBindings) : kernelBindings;
|
|
101
|
+
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
|
+
}
|
|
113
|
+
const featurePacks = composed.map((entry) => entry.pack);
|
|
114
|
+
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?.();
|
|
125
|
+
const { app } = config;
|
|
126
|
+
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
|
+
}
|
|
164
|
+
mountPhase('api', app, composed, featureDaemon);
|
|
165
|
+
config.onAfterApiRoutes?.(app, callerDaemon, base);
|
|
166
|
+
mountPhase('status', app, composed, featureDaemon);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
// Reverse of the order they were built: features composed against an already-constructed caller
|
|
170
|
+
// daemon, so they release first. The caller's packs are disposed here too — `createDaemon` ran
|
|
171
|
+
// their `services()` before the first feature composed, so a failure anywhere after that point
|
|
172
|
+
// leaves whatever those services opened with no owner at all.
|
|
173
|
+
await disposePacks({
|
|
174
|
+
packs: composed.map((entry) => entry.pack),
|
|
175
|
+
daemon: featureDaemon,
|
|
176
|
+
});
|
|
177
|
+
await disposePacks({ packs: callerPacks, daemon: callerDaemon });
|
|
178
|
+
await base.close();
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
let disposedFeatures = null;
|
|
182
|
+
const disposeFeatures = () => {
|
|
183
|
+
disposedFeatures ??= disposePacks({
|
|
184
|
+
packs: composed.map((entry) => entry.pack),
|
|
185
|
+
daemon: featureDaemon,
|
|
186
|
+
}).then(() => undefined);
|
|
187
|
+
return disposedFeatures;
|
|
188
|
+
};
|
|
189
|
+
let disposedCallerPacks = null;
|
|
190
|
+
const disposeCallerPacks = () => {
|
|
191
|
+
disposedCallerPacks ??= disposePacks({ packs: callerPacks, daemon: callerDaemon }).then(() => undefined);
|
|
192
|
+
return disposedCallerPacks;
|
|
193
|
+
};
|
|
194
|
+
return {
|
|
195
|
+
base,
|
|
196
|
+
daemon: callerDaemon,
|
|
197
|
+
activation,
|
|
198
|
+
disposeFeatures,
|
|
199
|
+
disposeCallerPacks,
|
|
200
|
+
closeBase: () => base.close(),
|
|
201
|
+
async close() {
|
|
202
|
+
await disposeFeatures();
|
|
203
|
+
await disposeCallerPacks();
|
|
204
|
+
await base.close();
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
//# sourceMappingURL=compose-jini-kernel.js.map
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { SqliteSyncOpener } from '@jini-ai/db/sqlite';
|
|
2
|
+
import type { Server } from 'node:http';
|
|
3
|
+
import { type Express } from 'express';
|
|
4
|
+
import type { DetectedAgent } from '@jini-ai/agent-runtime';
|
|
5
|
+
import type { Bindings, Principal, ToolRegistration } from '@jini-ai/core';
|
|
6
|
+
import type { AnyPack, MissingTokenIds } from '@jini-ai/core/composition';
|
|
7
|
+
import type { ResolveRunInput, RunLifecycle } from '@jini-ai/daemon';
|
|
8
|
+
import { type AdapterContext } from '@jini-ai/http-kit';
|
|
9
|
+
import { type DelegatedToolExecuteRequest, type RunStartHandler, type WorkspaceRootResolver } from '@jini-ai/daemon/http';
|
|
10
|
+
import { type JiniKernelSecurity, type KernelBoundIds } from './compose-jini-kernel.js';
|
|
11
|
+
import type { CapabilityId, JiniProfileId } from './feature.js';
|
|
12
|
+
export { buildDaemonDbOperations, projectDetectedAgent } from './builtin-features.js';
|
|
13
|
+
export { classifyRunFailureForRetry } from './kernel-base.js';
|
|
14
|
+
/**
|
|
15
|
+
* Extracts the real bound TCP port from `server.address()`'s result once a `'listening'` event
|
|
16
|
+
* has fired.
|
|
17
|
+
*
|
|
18
|
+
* @param address - The raw return value of `server.address()`.
|
|
19
|
+
* @returns The bound port, or `null` if `address` is `null` (not yet listening), a string (a Unix
|
|
20
|
+
* domain socket path — this module never listens on one), or an `AddressInfo` with a non-positive port.
|
|
21
|
+
* @complexity O(1).
|
|
22
|
+
* @overallScore 100/100
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveBoundPort(address: {
|
|
25
|
+
port: number;
|
|
26
|
+
} | string | null): number | null;
|
|
27
|
+
/**
|
|
28
|
+
* The host a daemon's reported base URL should use: binding to every interface (`0.0.0.0` / `::`)
|
|
29
|
+
* is not itself a connectable address, so callers are told to use the IPv4 loopback address
|
|
30
|
+
* instead; and an IPv6 literal is returned in bracketed authority form.
|
|
31
|
+
*
|
|
32
|
+
* The brackets are not cosmetic. `http://${'::1'}:5000` is not a URL — `new URL()` throws on it and
|
|
33
|
+
* so does `fetch`, which made a daemon bound to `::1` hand back a base URL nothing could use and
|
|
34
|
+
* write that same unusable string into its discovery record. RFC 3986 §3.2.2 requires the brackets
|
|
35
|
+
* for any IPv6 literal in an authority, precisely so the address's own colons cannot be read as the
|
|
36
|
+
* port separator.
|
|
37
|
+
*
|
|
38
|
+
* @param bindHost - The literal host `createLocalNodeDaemon` bound to (already normalized).
|
|
39
|
+
* @returns `'127.0.0.1'` for an all-interfaces bind host, `'[…]'` for an IPv6 literal (already-
|
|
40
|
+
* bracketed input is returned unchanged), otherwise `bindHost` unchanged.
|
|
41
|
+
* @complexity O(1).
|
|
42
|
+
* @overallScore 100/100
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveReportHost(bindHost: string): string;
|
|
45
|
+
export type { KernelBoundIds };
|
|
46
|
+
/**
|
|
47
|
+
* Product-level HTTP composition seam. Incubating capabilities register their routes here instead
|
|
48
|
+
* of becoming downward dependencies of this locked host preset.
|
|
49
|
+
*/
|
|
50
|
+
export interface LocalNodeHttpExtensionContext {
|
|
51
|
+
readonly adapter: AdapterContext;
|
|
52
|
+
readonly lifecycle: RunLifecycle;
|
|
53
|
+
readonly dataDir: string;
|
|
54
|
+
}
|
|
55
|
+
export type LocalNodeHttpExtension = (app: Express, context: LocalNodeHttpExtensionContext) => void;
|
|
56
|
+
export interface CreateLocalNodeDaemonConfig<Packs extends readonly AnyPack[], BoundIds extends string = KernelBoundIds> {
|
|
57
|
+
/** Directory the daemon's durable state lives in. The directory itself must already exist. */
|
|
58
|
+
dataDir: string;
|
|
59
|
+
/** The external host supplies its driver; Jini never autoloads SQLite. */
|
|
60
|
+
open: SqliteSyncOpener;
|
|
61
|
+
packs: Packs;
|
|
62
|
+
/**
|
|
63
|
+
* Extends the kernel's own pre-bound `EventLog`/`RunLifecycle` bindings with whatever a pack's
|
|
64
|
+
* own deps require. A callback rather than a pre-built `Bindings` instance because
|
|
65
|
+
* `Bindings.bind()` mutates and returns `this`.
|
|
66
|
+
*/
|
|
67
|
+
bindings?: (b: Bindings<KernelBoundIds>) => Bindings<BoundIds>;
|
|
68
|
+
/** TCP port to listen on. Defaults to `0` (ask the OS for an ephemeral free port). */
|
|
69
|
+
port?: number;
|
|
70
|
+
/** Host/address to bind to. Defaults to `'127.0.0.1'` (loopback-only). */
|
|
71
|
+
host?: string;
|
|
72
|
+
/** Env var names for the optional bearer-token gate. Defaults to `JINI_API_TOKEN` / `JINI_DISABLE_API_AUTH`. Ignored when `security` below selects `sidecar-strict`. */
|
|
73
|
+
apiToken?: {
|
|
74
|
+
tokenEnvVar?: string;
|
|
75
|
+
disableEnvVar?: string;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Raises this preset's default `jini-local` gate to `sidecar-strict` — see
|
|
79
|
+
* {@link JiniKernelSecurity}'s own doc for what that changes: every `/api` request must carry the
|
|
80
|
+
* bearer token, including one from `127.0.0.1`, and an unconfigured token fails closed rather than
|
|
81
|
+
* serving unauthenticated callers. `host` is supplied automatically from this config's own `host`
|
|
82
|
+
* (there is nowhere else for it to disagree with, so it is not this option's to set again).
|
|
83
|
+
* Omitted (the default) keeps `jini-local` exactly as every existing caller already has it.
|
|
84
|
+
*/
|
|
85
|
+
security?: Omit<Extract<JiniKernelSecurity, {
|
|
86
|
+
readonly mode: 'sidecar-strict';
|
|
87
|
+
}>, 'host'>;
|
|
88
|
+
/** Invoked once the HTTP listener has fully closed, before the durable `EventLog` is closed. */
|
|
89
|
+
onShutdown?: () => Promise<void> | void;
|
|
90
|
+
/** Defaults to `process.env`. */
|
|
91
|
+
env?: NodeJS.ProcessEnv;
|
|
92
|
+
/** Optional detector override for a host with custom PATH/env policy or a deterministic test fixture. */
|
|
93
|
+
agentDetector?: () => Promise<readonly DetectedAgent[]>;
|
|
94
|
+
/**
|
|
95
|
+
* Optional host-owned driver attached immediately after `POST /api/runs` durably starts a run.
|
|
96
|
+
* Takes full precedence over {@link resolveRunInput} when both are supplied.
|
|
97
|
+
*/
|
|
98
|
+
onRunStarted?: RunStartHandler;
|
|
99
|
+
/**
|
|
100
|
+
* Host-owned prompt/cwd/env composition seam. When supplied and `onRunStarted` is not, this
|
|
101
|
+
* daemon builds a default `RunStartHandler` that resolves each run's input through this seam and
|
|
102
|
+
* drives it straight to the zero-config `AgentExecutor`. Ignored when `onRunStarted` is supplied.
|
|
103
|
+
*/
|
|
104
|
+
resolveRunInput?: ResolveRunInput;
|
|
105
|
+
/**
|
|
106
|
+
* Resolves a `resourceRef` to a filesystem working directory for `POST /api/resources/:resourceRef/open-in`.
|
|
107
|
+
* Defaults to `denyAllWorkspaceRoots`: the route exists and is reachable but denies every call
|
|
108
|
+
* with `404`, never fabricating or guessing a path.
|
|
109
|
+
*/
|
|
110
|
+
resolveWorkspaceRoot?: WorkspaceRootResolver;
|
|
111
|
+
/**
|
|
112
|
+
* Optional product/capability route registrars. They run after the host's security middleware
|
|
113
|
+
* and locked route packs, before caller packs and daemon-status routes.
|
|
114
|
+
*/
|
|
115
|
+
httpExtensions?: readonly LocalNodeHttpExtension[];
|
|
116
|
+
/**
|
|
117
|
+
* Host-contributed `{descriptor, handler, policy}` triples, registered into the same internal
|
|
118
|
+
* registry this preset's own gated tools (`jini.terminal.create`, `daemon.db.*`) use — and so
|
|
119
|
+
* executed through the same `ToolExecutor`, inheriting its authorization, confirmation, timeout,
|
|
120
|
+
* cancellation, output truncation, and audit trail.
|
|
121
|
+
*
|
|
122
|
+
* @throws At startup if a `descriptor.id` is already registered — by another entry here, or by
|
|
123
|
+
* one of the preset's own tools. The registry is append-only by design.
|
|
124
|
+
*/
|
|
125
|
+
toolRegistrations?: readonly ToolRegistration[];
|
|
126
|
+
/**
|
|
127
|
+
* Resolves the `Principal` one `POST /api/delegated-tool-calls` request executes as.
|
|
128
|
+
*
|
|
129
|
+
* @default the anonymous, role-less delegated principal — inertness comes from deny-by-default
|
|
130
|
+
* tool policies, not from the identity.
|
|
131
|
+
*/
|
|
132
|
+
resolveDelegatedPrincipal?: (request: DelegatedToolExecuteRequest) => Principal | Promise<Principal>;
|
|
133
|
+
/**
|
|
134
|
+
* Where this daemon's local discovery record (URL/host/port/pid) is written once it starts
|
|
135
|
+
* listening. Defaults to `<dataDir>/daemon.json`. Pass `false` to disable. Best-effort: a failure
|
|
136
|
+
* here never fails daemon startup or shutdown.
|
|
137
|
+
*/
|
|
138
|
+
discoveryFile?: string | false;
|
|
139
|
+
/**
|
|
140
|
+
* Capability profile this daemon composes against.
|
|
141
|
+
*
|
|
142
|
+
* @default 'local-daemon-v1' — the immutable compatibility profile holding exactly the surface
|
|
143
|
+
* this preset has always mounted. Changing it changes which route families exist; see
|
|
144
|
+
* `feature.ts` for the vocabulary and why profiles are versioned rather than edited.
|
|
145
|
+
*/
|
|
146
|
+
profile?: JiniProfileId;
|
|
147
|
+
/**
|
|
148
|
+
* Raises or lowers the profile's capability ceiling. A denial always wins: explicitly enabling a
|
|
149
|
+
* feature whose capability is denied here is a startup error naming both, never a silent
|
|
150
|
+
* re-opening of a coarse switch.
|
|
151
|
+
*/
|
|
152
|
+
capabilities?: Readonly<Partial<Record<CapabilityId, boolean | undefined>>>;
|
|
153
|
+
/**
|
|
154
|
+
* Turns individual features on or off within whatever the capability ceiling permits — e.g.
|
|
155
|
+
* `{ terminal: false, daemonDb: false }`. A feature turned off here registers **neither its
|
|
156
|
+
* routes nor its tools**, so a disabled capability is absent from the shared registry rather than
|
|
157
|
+
* merely unrouted.
|
|
158
|
+
*/
|
|
159
|
+
features?: Readonly<Record<string, boolean | undefined>>;
|
|
160
|
+
}
|
|
161
|
+
export interface LocalNodeDaemon {
|
|
162
|
+
/** The daemon's real, resolved base URL (reflects the actual bound port even when `port: 0` was requested). */
|
|
163
|
+
readonly url: string;
|
|
164
|
+
readonly server: Server;
|
|
165
|
+
/**
|
|
166
|
+
* Gracefully shuts the daemon down: closes the HTTP listener, disposes every composed feature
|
|
167
|
+
* (releasing e.g. an in-flight xAI OAuth loopback listener), removes the discovery record, runs
|
|
168
|
+
* the caller's `onShutdown` hook, then closes the durable sqlite handles. Idempotent and safe to
|
|
169
|
+
* call more than once, or concurrently.
|
|
170
|
+
*/
|
|
171
|
+
stop(): Promise<void>;
|
|
172
|
+
/** Every feature actually composed, with why and what it grants. */
|
|
173
|
+
readonly activeFeatures: readonly string[];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Boots a complete, runnable `@jini-ai/core` daemon process.
|
|
177
|
+
*
|
|
178
|
+
* Preserves `createDaemon`'s compile-time "missing binding" error through this wrapper: the same
|
|
179
|
+
* `MissingTokenIds<Packs, BoundIds>` conditional gate `createDaemon` itself uses forces a call site
|
|
180
|
+
* with an unbound pack dependency to fail to typecheck with the missing token id(s) visible.
|
|
181
|
+
*
|
|
182
|
+
* **Why two overloads instead of one generic signature:** when a type parameter both has a default
|
|
183
|
+
* and is referenced inside a conditional type in the same parameter position where it also needs to
|
|
184
|
+
* be inferred from a nested callback's return type, TypeScript resolves it to the default *instead
|
|
185
|
+
* of* inferring from the callback, silently defeating the gate on exactly the call shape
|
|
186
|
+
* (`bindings` provided) where it matters most. Splitting into two overloads sidesteps the inference
|
|
187
|
+
* conflict entirely. Both directions are covered by `@ts-expect-error` proofs in
|
|
188
|
+
* `create-local-node-daemon.typecheck.ts`.
|
|
189
|
+
*
|
|
190
|
+
* @param config - See {@link CreateLocalNodeDaemonConfig}.
|
|
191
|
+
* @returns A promise resolving to `{url, server, stop, activeFeatures}` once the daemon is listening.
|
|
192
|
+
* @throws Rejects if the port is already in use (`EADDRINUSE`), the host can't be bound
|
|
193
|
+
* (`EACCES`/`EADDRNOTAVAIL`), the OS reports a listening socket with no resolvable port, or the
|
|
194
|
+
* composition itself is invalid. On any of these every sqlite handle already opened is closed
|
|
195
|
+
* before rejecting, so a failed boot never leaks an open file handle.
|
|
196
|
+
* @complexity O(1) beyond the packs' own `services()`/`http()` costs.
|
|
197
|
+
* @overallScore 100/100
|
|
198
|
+
*/
|
|
199
|
+
export declare function createLocalNodeDaemon<const Packs extends readonly AnyPack[]>(config: CreateLocalNodeDaemonConfig<Packs, KernelBoundIds> & {
|
|
200
|
+
bindings?: undefined;
|
|
201
|
+
} & (MissingTokenIds<Packs, KernelBoundIds> extends never ? unknown : {
|
|
202
|
+
readonly __missingBindings: MissingTokenIds<Packs, KernelBoundIds>;
|
|
203
|
+
})): Promise<LocalNodeDaemon>;
|
|
204
|
+
export declare function createLocalNodeDaemon<const Packs extends readonly AnyPack[], BoundIds extends string>(config: CreateLocalNodeDaemonConfig<Packs, BoundIds> & {
|
|
205
|
+
bindings: (b: Bindings<KernelBoundIds>) => Bindings<BoundIds>;
|
|
206
|
+
} & (MissingTokenIds<Packs, BoundIds> extends never ? unknown : {
|
|
207
|
+
readonly __missingBindings: MissingTokenIds<Packs, BoundIds>;
|
|
208
|
+
})): Promise<LocalNodeDaemon>;
|
|
209
|
+
//# sourceMappingURL=create-local-node-daemon.d.ts.map
|