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