@arnilo/prism 0.2.0 → 0.2.2
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 +10 -0
- package/dist/cache-telemetry.js +4 -1
- package/dist/content.d.ts +2 -2
- package/dist/content.js +30 -50
- package/dist/contracts-core.d.ts +32 -2
- package/dist/contracts-core.js +20 -0
- package/dist/conversations.d.ts +2 -0
- package/dist/conversations.js +1 -0
- package/dist/event-multiplexer.d.ts +13 -0
- package/dist/event-multiplexer.js +39 -16
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/oauth-device-code.d.ts +58 -0
- package/dist/oauth-device-code.js +119 -0
- package/dist/pinned-fetch.d.ts +25 -0
- package/dist/pinned-fetch.js +246 -0
- package/dist/providers/openai-compatible.d.ts +2 -2
- package/dist/providers/openai-compatible.js +2 -2
- package/dist/providers/transport.d.ts +14 -1
- package/dist/providers/transport.js +43 -0
- package/dist/testing/persistence-schema.d.ts +1 -1
- package/dist/testing/persistence-schema.js +32 -28
- package/dist/testing/state-concurrency-conformance.d.ts +145 -0
- package/dist/testing/state-concurrency-conformance.js +348 -0
- package/docs/agent-events.md +1 -1
- package/docs/conversations.md +5 -2
- package/docs/credential-storage.md +1 -0
- package/docs/credentials-and-redaction.md +1 -0
- package/docs/database-persistence.md +4 -0
- package/docs/enterprise-postgres-state.md +4 -3
- package/docs/index.md +17 -17
- package/docs/mcp-tools.md +1 -1
- package/docs/migration.md +83 -0
- package/docs/model-routing.md +13 -10
- package/docs/multimodal-content.md +1 -0
- package/docs/policy-and-audit.md +1 -0
- package/docs/provider-caching.md +4 -1
- package/docs/provider-primitives.md +17 -0
- package/docs/providers/azure.md +1 -1
- package/docs/providers/bedrock.md +1 -0
- package/docs/providers/openai-compatible.md +4 -3
- package/docs/providers/vertex.md +1 -1
- package/docs/public-contracts.md +3 -3
- package/docs/release-and-install.md +39 -0
- package/docs/workflows.md +2 -1
- package/package.json +8 -4
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type MediaHostAddress, type MediaHostnameResolver, type SsrfPolicy } from "./content.js";
|
|
2
|
+
export interface PinnedFetchOptions {
|
|
3
|
+
/** Prefix for request-level error messages ("redirects are not allowed", "response exceeds", ...). Default "Request". */
|
|
4
|
+
readonly errorPrefix?: string;
|
|
5
|
+
/** Prefix for hostname-resolution error messages. Default: `errorPrefix`. */
|
|
6
|
+
readonly hostnameErrorPrefix?: string;
|
|
7
|
+
/** Custom resolver; defaults to `node:dns/promises` lookup (all answers, verbatim). */
|
|
8
|
+
readonly resolver?: MediaHostnameResolver;
|
|
9
|
+
/** Allow loopback destinations only when the requested hostname is itself loopback AND this is true. */
|
|
10
|
+
readonly allowLoopback?: boolean;
|
|
11
|
+
/** SSRF policy applied to the URL precheck and to every resolved candidate. */
|
|
12
|
+
readonly ssrf?: SsrfPolicy;
|
|
13
|
+
/** Byte ceiling for the response stream (content-length precheck + streaming bound). */
|
|
14
|
+
readonly maxResponseBytes?: number;
|
|
15
|
+
}
|
|
16
|
+
/** One DNS-pinned, redirect-free, byte-bounded fetch. See module comment. */
|
|
17
|
+
export declare function pinnedFetch(url: URL, init: RequestInit | undefined, options?: PinnedFetchOptions): Promise<Response>;
|
|
18
|
+
export declare function resolvePinnedAddress(url: URL, resolver: MediaHostnameResolver, signal: AbortSignal | null | undefined, allowLoopback: boolean, ssrf: SsrfPolicy | undefined, hostnameErrorPrefix?: string): Promise<MediaHostAddress>;
|
|
19
|
+
export declare function defaultResolver(hostname: string): Promise<readonly MediaHostAddress[]>;
|
|
20
|
+
export declare function requestPinned(url: URL, address: MediaHostAddress, init: RequestInit | undefined, errorPrefix?: string): Promise<Response>;
|
|
21
|
+
export declare function boundResponse(response: Response, maxBytes: number, errorPrefix?: string): Response;
|
|
22
|
+
export declare function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | null | undefined): Promise<T>;
|
|
23
|
+
export declare function normalizeHostname(value: string): string;
|
|
24
|
+
export declare function isLoopbackHostname(value: string): boolean;
|
|
25
|
+
export declare function isLoopbackAddress(value: string): boolean;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNS-pinned outbound fetch primitive (0.2.1 task 4).
|
|
3
|
+
*
|
|
4
|
+
* One resolve per request, bounded to 1..32 addresses, family-verified, and
|
|
5
|
+
* every candidate checked against `assertSsrfAllowedUrl` BEFORE the connect —
|
|
6
|
+
* the connect itself uses a node lookup hook that returns exactly the pinned
|
|
7
|
+
* address, so the socket cannot re-resolve (DNS-rebinding defense). Redirects
|
|
8
|
+
* are rejected outright (3xx), and the response stream is byte-bounded.
|
|
9
|
+
*
|
|
10
|
+
* Throws `MediaContentError` (`ssrf_denied` for address violations, `redirect`
|
|
11
|
+
* for 3xx). Error messages are parameterized by `errorPrefix` so each caller
|
|
12
|
+
* (MCP, OIDC, OPA, content) keeps its own taxonomy and message text.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: imports from ./content.js and is imported by it (content's default
|
|
15
|
+
* media fetch routes through here) — a deliberate ESM cycle; both modules only
|
|
16
|
+
* reference the other's exports inside function bodies, never at module scope.
|
|
17
|
+
*/
|
|
18
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
19
|
+
import { request as httpRequest } from "node:http";
|
|
20
|
+
import { request as httpsRequest } from "node:https";
|
|
21
|
+
import { isIP } from "node:net";
|
|
22
|
+
import { assertSsrfAllowedUrl, MediaContentError } from "./content.js";
|
|
23
|
+
/** One DNS-pinned, redirect-free, byte-bounded fetch. See module comment. */
|
|
24
|
+
export async function pinnedFetch(url, init, options) {
|
|
25
|
+
const errorPrefix = options?.errorPrefix ?? "Request";
|
|
26
|
+
const hostnameErrorPrefix = options?.hostnameErrorPrefix ?? errorPrefix;
|
|
27
|
+
if (url.username || url.password)
|
|
28
|
+
throw new MediaContentError("ssrf_denied", `${errorPrefix} URL must not embed credentials`);
|
|
29
|
+
if (url.hash)
|
|
30
|
+
throw new MediaContentError("ssrf_denied", `${errorPrefix} URL must not contain a fragment`);
|
|
31
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
32
|
+
throw new MediaContentError("ssrf_denied", `${errorPrefix} URL must use https: (got ${url.protocol})`);
|
|
33
|
+
}
|
|
34
|
+
if (url.protocol === "http:" && !(options?.allowLoopback === true && isLoopbackHostname(url.hostname))) {
|
|
35
|
+
throw new MediaContentError("ssrf_denied", `Plaintext ${errorPrefix} is allowed only for an explicitly enabled loopback endpoint`);
|
|
36
|
+
}
|
|
37
|
+
if (!(options?.allowLoopback === true && isLoopbackHostname(url.hostname))) {
|
|
38
|
+
try {
|
|
39
|
+
assertSsrfAllowedUrl(url.href, options?.ssrf);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error instanceof MediaContentError && error.code === "unsupported_url_scheme")
|
|
43
|
+
throw error;
|
|
44
|
+
throw new MediaContentError("ssrf_denied", `${errorPrefix} URL is not public`, { cause: error });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const signal = init?.signal;
|
|
48
|
+
const address = await resolvePinnedAddress(url, options?.resolver ?? defaultResolver, signal, options?.allowLoopback === true, options?.ssrf);
|
|
49
|
+
const response = await requestPinned(url, address, init, errorPrefix);
|
|
50
|
+
if (response.status >= 300 && response.status < 400) {
|
|
51
|
+
await response.body?.cancel();
|
|
52
|
+
throw new MediaContentError("redirect", `${errorPrefix} redirects are not allowed (status ${response.status})`);
|
|
53
|
+
}
|
|
54
|
+
return options?.maxResponseBytes === undefined ? response : boundResponse(response, options.maxResponseBytes, errorPrefix);
|
|
55
|
+
}
|
|
56
|
+
export async function resolvePinnedAddress(url, resolver, signal, allowLoopback, ssrf, hostnameErrorPrefix = "Request") {
|
|
57
|
+
signal?.throwIfAborted();
|
|
58
|
+
const hostname = normalizeHostname(url.hostname);
|
|
59
|
+
const family = isIP(hostname);
|
|
60
|
+
const addresses = family
|
|
61
|
+
? [{ address: hostname, family: family }]
|
|
62
|
+
: await raceAbort(resolver(hostname, signal ?? new AbortController().signal), signal);
|
|
63
|
+
if (addresses.length < 1 || addresses.length > 32)
|
|
64
|
+
throw new MediaContentError("fetch_failed", `${hostnameErrorPrefix} hostname returned an invalid address count`);
|
|
65
|
+
for (const candidate of addresses) {
|
|
66
|
+
const normalized = normalizeHostname(candidate.address);
|
|
67
|
+
if (isIP(normalized) !== candidate.family)
|
|
68
|
+
throw new MediaContentError("fetch_failed", `${hostnameErrorPrefix} hostname resolver returned an invalid address`);
|
|
69
|
+
if (allowLoopback && isLoopbackHostname(hostname)) {
|
|
70
|
+
if (!isLoopbackAddress(normalized))
|
|
71
|
+
throw new MediaContentError("ssrf_denied", `${hostnameErrorPrefix} loopback hostname resolved outside loopback`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const literal = candidate.family === 6 ? `[${normalized}]` : normalized;
|
|
75
|
+
// Fail closed on resolved candidates: an explicit hostname allow-list is honored
|
|
76
|
+
// for the URL itself, but every resolved address is still private-checked.
|
|
77
|
+
const candidatePolicy = ssrf?.allowedHostnames?.length ? { denyPrivateHosts: ssrf.denyPrivateHosts } : ssrf;
|
|
78
|
+
try {
|
|
79
|
+
assertSsrfAllowedUrl(`${url.protocol}//${literal}`, candidatePolicy);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw new MediaContentError("ssrf_denied", `${hostnameErrorPrefix} hostname resolved to a private or non-public address`, {
|
|
83
|
+
cause: error,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ponytail: pin first validated address; add bounded public-address retry only if availability data requires it.
|
|
88
|
+
const selected = addresses[0];
|
|
89
|
+
return { address: normalizeHostname(selected.address), family: selected.family };
|
|
90
|
+
}
|
|
91
|
+
export async function defaultResolver(hostname) {
|
|
92
|
+
return dnsLookup(hostname, { all: true, verbatim: true });
|
|
93
|
+
}
|
|
94
|
+
export async function requestPinned(url, address, init, errorPrefix = "Request") {
|
|
95
|
+
const body = await requestBody(init?.body, errorPrefix);
|
|
96
|
+
const headers = new Headers(init?.headers);
|
|
97
|
+
const method = init?.method ?? "GET";
|
|
98
|
+
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const nodeRequest = request(url, {
|
|
101
|
+
method,
|
|
102
|
+
headers: Object.fromEntries(headers.entries()),
|
|
103
|
+
signal: init?.signal ?? undefined,
|
|
104
|
+
lookup: ((_hostname, options, callback) => {
|
|
105
|
+
if (options.all)
|
|
106
|
+
callback(null, [{ address: address.address, family: address.family }]);
|
|
107
|
+
else
|
|
108
|
+
callback(null, address.address, address.family);
|
|
109
|
+
}),
|
|
110
|
+
}, (incoming) => {
|
|
111
|
+
const responseHeaders = new Headers();
|
|
112
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
113
|
+
if (Array.isArray(value))
|
|
114
|
+
for (const item of value)
|
|
115
|
+
responseHeaders.append(name, item);
|
|
116
|
+
else if (value !== undefined)
|
|
117
|
+
responseHeaders.set(name, value);
|
|
118
|
+
}
|
|
119
|
+
const noBody = method === "HEAD" || incoming.statusCode === 204 || incoming.statusCode === 304;
|
|
120
|
+
const iterator = incoming[Symbol.asyncIterator]();
|
|
121
|
+
const stream = noBody
|
|
122
|
+
? null
|
|
123
|
+
: new ReadableStream({
|
|
124
|
+
async pull(controller) {
|
|
125
|
+
try {
|
|
126
|
+
const next = await iterator.next();
|
|
127
|
+
if (next.done)
|
|
128
|
+
controller.close();
|
|
129
|
+
else
|
|
130
|
+
controller.enqueue(new Uint8Array(next.value));
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
controller.error(error);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
cancel(reason) {
|
|
137
|
+
incoming.destroy(reason instanceof Error ? reason : undefined);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
resolve(new Response(stream, {
|
|
141
|
+
status: incoming.statusCode ?? 500,
|
|
142
|
+
statusText: incoming.statusMessage,
|
|
143
|
+
headers: responseHeaders,
|
|
144
|
+
}));
|
|
145
|
+
});
|
|
146
|
+
nodeRequest.on("error", reject);
|
|
147
|
+
if (body)
|
|
148
|
+
nodeRequest.end(body);
|
|
149
|
+
else
|
|
150
|
+
nodeRequest.end();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async function requestBody(body, errorPrefix) {
|
|
154
|
+
if (body === undefined || body === null)
|
|
155
|
+
return undefined;
|
|
156
|
+
if (typeof body === "string")
|
|
157
|
+
return new TextEncoder().encode(body);
|
|
158
|
+
if (body instanceof URLSearchParams)
|
|
159
|
+
return new TextEncoder().encode(body.toString());
|
|
160
|
+
if (body instanceof ArrayBuffer)
|
|
161
|
+
return new Uint8Array(body);
|
|
162
|
+
if (ArrayBuffer.isView(body))
|
|
163
|
+
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
164
|
+
if (body instanceof Blob)
|
|
165
|
+
return new Uint8Array(await body.arrayBuffer());
|
|
166
|
+
throw new MediaContentError("ssrf_denied", `${errorPrefix} request body type is not supported by the pinned transport`);
|
|
167
|
+
}
|
|
168
|
+
export function boundResponse(response, maxBytes, errorPrefix = "Request") {
|
|
169
|
+
const declared = Number(response.headers.get("content-length"));
|
|
170
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
171
|
+
void response.body?.cancel();
|
|
172
|
+
throw new MediaContentError("ssrf_denied", `${errorPrefix} response exceeds ${maxBytes} bytes`);
|
|
173
|
+
}
|
|
174
|
+
if (!response.body)
|
|
175
|
+
return response;
|
|
176
|
+
const reader = response.body.getReader();
|
|
177
|
+
let bytes = 0;
|
|
178
|
+
const body = new ReadableStream({
|
|
179
|
+
async pull(controller) {
|
|
180
|
+
try {
|
|
181
|
+
const next = await reader.read();
|
|
182
|
+
if (next.done) {
|
|
183
|
+
reader.releaseLock();
|
|
184
|
+
controller.close();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
bytes += next.value.byteLength;
|
|
188
|
+
if (bytes > maxBytes) {
|
|
189
|
+
await reader.cancel();
|
|
190
|
+
reader.releaseLock();
|
|
191
|
+
controller.error(new MediaContentError("ssrf_denied", `${errorPrefix} response exceeds ${maxBytes} bytes`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
controller.enqueue(next.value);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
try {
|
|
198
|
+
reader.releaseLock();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* Already released after EOF/overflow. */
|
|
202
|
+
}
|
|
203
|
+
controller.error(error);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
async cancel(reason) {
|
|
207
|
+
await reader.cancel(reason);
|
|
208
|
+
try {
|
|
209
|
+
reader.releaseLock();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* Already released after EOF/overflow. */
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
217
|
+
}
|
|
218
|
+
export async function raceAbort(promise, signal) {
|
|
219
|
+
if (!signal)
|
|
220
|
+
return promise;
|
|
221
|
+
signal.throwIfAborted();
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const abort = () => reject(signal.reason);
|
|
224
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
225
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
export function normalizeHostname(value) {
|
|
229
|
+
return value
|
|
230
|
+
.toLowerCase()
|
|
231
|
+
.replace(/^\[|\]$/g, "")
|
|
232
|
+
.replace(/\.$/, "");
|
|
233
|
+
}
|
|
234
|
+
export function isLoopbackHostname(value) {
|
|
235
|
+
const hostname = normalizeHostname(value);
|
|
236
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname);
|
|
237
|
+
}
|
|
238
|
+
export function isLoopbackAddress(value) {
|
|
239
|
+
const address = normalizeHostname(value);
|
|
240
|
+
if (address === "::1")
|
|
241
|
+
return true;
|
|
242
|
+
if (isIP(address) !== 4)
|
|
243
|
+
return false;
|
|
244
|
+
return Number(address.split(".", 1)[0]) === 127;
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=pinned-fetch.js.map
|
|
@@ -21,7 +21,7 @@ export interface OpenAICompatibleProviderOptions {
|
|
|
21
21
|
readonly extraHeaders?: (request: ProviderRequest) => Record<string, string>;
|
|
22
22
|
/** Final body transform applied last (token limits, compat stripping). Wins over everything. */
|
|
23
23
|
readonly transformBody?: (body: JsonObject, request: ProviderRequest) => JsonObject;
|
|
24
|
-
/** Require `[DONE]` and a `finish_reason` before emitting `done`; truncated streams yield an error. `done` then carries the final usage. */
|
|
24
|
+
/** Require `[DONE]` and a `finish_reason` before emitting `done`; truncated streams yield an error. `done` then carries the final usage. Default `true` (fail-closed); set `false` explicitly to accept streams that end without completion evidence. */
|
|
25
25
|
readonly strictCompletion?: boolean;
|
|
26
26
|
/** Emit the final stream usage on the `done` event (without strict completion checks). */
|
|
27
27
|
readonly doneUsage?: boolean;
|
|
@@ -34,7 +34,7 @@ export interface OpenAICompatibleProviderOptions {
|
|
|
34
34
|
}
|
|
35
35
|
export interface OpenAIChatEventsOptions {
|
|
36
36
|
readonly signal?: AbortSignal;
|
|
37
|
-
/** Require `[DONE]` and a `finish_reason`; `done` then carries the final usage. */
|
|
37
|
+
/** Require `[DONE]` and a `finish_reason`; `done` then carries the final usage. Default `true` (fail-closed); set `false` explicitly to accept streams that end without completion evidence. */
|
|
38
38
|
readonly strictCompletion?: boolean;
|
|
39
39
|
/** Emit the final stream usage on the `done` event. */
|
|
40
40
|
readonly doneUsage?: boolean;
|
|
@@ -72,7 +72,7 @@ export async function* openAIChatEvents(body, options = {}) {
|
|
|
72
72
|
yield providerError(new ProviderTransportError("incomplete_delta", `Incomplete tool call delta at index ${incomplete[0]}`));
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
|
-
if (options.strictCompletion && (!sawDoneMarker || !sawFinishReason)) {
|
|
75
|
+
if ((options.strictCompletion ?? true) && (!sawDoneMarker || !sawFinishReason)) {
|
|
76
76
|
// Truncated streams must fail loudly — emitting done would mark partial output as succeeded.
|
|
77
77
|
yield providerError(new Error(`Chat stream ended without completion evidence ` +
|
|
78
78
|
`([DONE]: ${sawDoneMarker ? "received" : "missing"}, ` +
|
|
@@ -82,7 +82,7 @@ export async function* openAIChatEvents(body, options = {}) {
|
|
|
82
82
|
for (const call of tools.values()) {
|
|
83
83
|
yield providerToolCall(toolCallFromArgumentsText(call.id, call.name, call.argumentsText));
|
|
84
84
|
}
|
|
85
|
-
yield providerDone(options.strictCompletion || options.doneUsage ? usage : undefined);
|
|
85
|
+
yield providerDone((options.strictCompletion ?? true) || options.doneUsage ? usage : undefined);
|
|
86
86
|
}
|
|
87
87
|
export function createOpenAICompatibleProvider(options) {
|
|
88
88
|
const providerId = options.id ?? "openai-compatible";
|
|
@@ -14,7 +14,7 @@ export interface SseEvent {
|
|
|
14
14
|
readonly data: string;
|
|
15
15
|
readonly comments?: readonly string[];
|
|
16
16
|
}
|
|
17
|
-
export type ProviderTransportErrorCode = "sse_buffer_overflow" | "sse_event_overflow" | "response_body_overflow" | "aborted" | "invalid_json_arguments" | "incomplete_delta";
|
|
17
|
+
export type ProviderTransportErrorCode = "sse_buffer_overflow" | "sse_event_overflow" | "response_body_overflow" | "aborted" | "invalid_json_arguments" | "incomplete_delta" | "response_body_shape";
|
|
18
18
|
export declare class ProviderTransportError extends Error {
|
|
19
19
|
readonly code: ProviderTransportErrorCode;
|
|
20
20
|
readonly limitBytes?: number;
|
|
@@ -32,6 +32,15 @@ export interface ReadSseEventsOptions extends BoundedStreamLimits {
|
|
|
32
32
|
export interface ReadBoundedResponseTextOptions extends BoundedStreamLimits {
|
|
33
33
|
readonly secrets?: readonly (string | undefined)[];
|
|
34
34
|
}
|
|
35
|
+
export interface ReadBoundedResponseJsonOptions extends ReadBoundedResponseTextOptions {
|
|
36
|
+
/** Max JSON nesting depth. Default: 32. */
|
|
37
|
+
readonly maxDepth?: number;
|
|
38
|
+
/** Max object properties / array elements per container. Default: 4_096. */
|
|
39
|
+
readonly maxProperties?: number;
|
|
40
|
+
/** Caller-supplied shape gate; failure throws `response_body_shape`. */
|
|
41
|
+
readonly shape?: (value: unknown) => boolean;
|
|
42
|
+
readonly signal?: AbortSignal;
|
|
43
|
+
}
|
|
35
44
|
export interface ParseJsonObjectArgumentsOptions {
|
|
36
45
|
readonly toolName?: string;
|
|
37
46
|
readonly maxBytes?: number;
|
|
@@ -42,6 +51,10 @@ export declare function readSseEvents(body: ReadableStream<Uint8Array>, options?
|
|
|
42
51
|
export declare function readSseData(body: ReadableStream<Uint8Array>, options?: ReadSseEventsOptions): AsyncGenerator<string>;
|
|
43
52
|
/** Read a response body with a hard byte ceiling; redacts optional secrets. */
|
|
44
53
|
export declare function readBoundedResponseText(response: Response, options?: ReadBoundedResponseTextOptions): Promise<string>;
|
|
54
|
+
/** Read a success body with the same byte ceiling as {@link readBoundedResponseText}, then parse it as JSON
|
|
55
|
+
* with depth/property caps and an optional caller-supplied shape gate. Malformed JSON, over-limit nesting,
|
|
56
|
+
* or shape failure throw `ProviderTransportError` with code `response_body_shape`. */
|
|
57
|
+
export declare function readBoundedResponseJson<T>(response: Response, options?: ReadBoundedResponseJsonOptions): Promise<T>;
|
|
45
58
|
export type ParseJsonObjectArgumentsResult = {
|
|
46
59
|
readonly ok: true;
|
|
47
60
|
readonly value: JsonObject;
|
|
@@ -218,6 +218,49 @@ export async function readBoundedResponseText(response, options) {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
+
function assertJsonBounds(value, maxDepth, maxProperties, depth = 0) {
|
|
222
|
+
if (depth > maxDepth) {
|
|
223
|
+
throw new ProviderTransportError("response_body_shape", `JSON nesting exceeded ${maxDepth} levels`);
|
|
224
|
+
}
|
|
225
|
+
if (value === null || typeof value !== "object")
|
|
226
|
+
return;
|
|
227
|
+
if (Array.isArray(value)) {
|
|
228
|
+
if (value.length > maxProperties) {
|
|
229
|
+
throw new ProviderTransportError("response_body_shape", `JSON array exceeded ${maxProperties} elements`);
|
|
230
|
+
}
|
|
231
|
+
for (const entry of value)
|
|
232
|
+
assertJsonBounds(entry, maxDepth, maxProperties, depth + 1);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const keys = Object.keys(value);
|
|
236
|
+
if (keys.length > maxProperties) {
|
|
237
|
+
throw new ProviderTransportError("response_body_shape", `JSON object exceeded ${maxProperties} properties`);
|
|
238
|
+
}
|
|
239
|
+
for (const key of keys)
|
|
240
|
+
assertJsonBounds(value[key], maxDepth, maxProperties, depth + 1);
|
|
241
|
+
}
|
|
242
|
+
/** Read a success body with the same byte ceiling as {@link readBoundedResponseText}, then parse it as JSON
|
|
243
|
+
* with depth/property caps and an optional caller-supplied shape gate. Malformed JSON, over-limit nesting,
|
|
244
|
+
* or shape failure throw `ProviderTransportError` with code `response_body_shape`. */
|
|
245
|
+
export async function readBoundedResponseJson(response, options) {
|
|
246
|
+
throwIfAborted(options?.signal);
|
|
247
|
+
const text = await readBoundedResponseText(response, options);
|
|
248
|
+
throwIfAborted(options?.signal);
|
|
249
|
+
const maxDepth = options?.maxDepth ?? 32;
|
|
250
|
+
const maxProperties = options?.maxProperties ?? 4_096;
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(text);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
throw new ProviderTransportError("response_body_shape", "Response body is not valid JSON");
|
|
257
|
+
}
|
|
258
|
+
assertJsonBounds(parsed, maxDepth, maxProperties);
|
|
259
|
+
if (options?.shape && !options.shape(parsed)) {
|
|
260
|
+
throw new ProviderTransportError("response_body_shape", "Response body does not match the expected shape");
|
|
261
|
+
}
|
|
262
|
+
return parsed;
|
|
263
|
+
}
|
|
221
264
|
/** Parse streamed tool arguments without throwing; prefer this for recoverable tool-call recovery. */
|
|
222
265
|
export function tryParseJsonObjectArguments(text, options) {
|
|
223
266
|
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_ARGUMENT_BYTES;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PersistencePage, SessionEntry, SessionEntryQuery } from "../contracts.js";
|
|
2
2
|
/** Current shared persistence schema version for production database adapters. */
|
|
3
|
-
export declare const PERSISTENCE_SCHEMA_VERSION =
|
|
3
|
+
export declare const PERSISTENCE_SCHEMA_VERSION = 8;
|
|
4
4
|
export type PersistenceTableName = "prism_tenants" | "prism_accounts" | "prism_users" | "prism_agent_definitions" | "prism_sessions" | "prism_branches" | "prism_session_entries" | "prism_session_append_idempotency" | "prism_runs" | "prism_agent_events" | "prism_agent_event_streams" | "prism_tool_calls" | "prism_usage" | "prism_run_feedback" | "prism_retention_policies" | "prism_legal_holds" | "prism_tenant_quotas" | "prism_migrations";
|
|
5
5
|
export type PersistenceColumnType = "text" | "integer" | "number" | "boolean" | "json" | "timestamp";
|
|
6
6
|
export interface PersistenceColumnDefinition {
|
|
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
// this module defines the shared table/index/pagination/migration expectations
|
|
5
5
|
// adapter authors implement and test against before shipping dialect-specific DDL.
|
|
6
6
|
/** Current shared persistence schema version for production database adapters. */
|
|
7
|
-
export const PERSISTENCE_SCHEMA_VERSION =
|
|
7
|
+
export const PERSISTENCE_SCHEMA_VERSION = 8;
|
|
8
8
|
/** Guidance adapters must follow: values are bound parameters, never interpolated. */
|
|
9
9
|
export const PARAMETERIZED_QUERY_GUIDANCE = "Bind every user-supplied value (session ids, idempotency keys, tenant ids, timestamps, JSON payloads) as a query parameter. Quote/validate schema and table identifiers only; never interpolate untrusted strings into SQL text.";
|
|
10
10
|
const TENANT_COLUMNS = [
|
|
@@ -81,6 +81,7 @@ export function createPersistenceSchemaModel() {
|
|
|
81
81
|
{ name: "expires_at", type: "timestamp", nullable: true },
|
|
82
82
|
{ name: "retention_policy_id", type: "text", nullable: true },
|
|
83
83
|
{ name: "metadata", type: "json", nullable: true },
|
|
84
|
+
{ name: "version", type: "integer", defaultValue: "0" },
|
|
84
85
|
],
|
|
85
86
|
},
|
|
86
87
|
{
|
|
@@ -499,33 +500,35 @@ function migrationStep(version, name, description) {
|
|
|
499
500
|
}
|
|
500
501
|
: version === 7
|
|
501
502
|
? { indexes: ["prism_agent_events_owner_timestamp_sequence_idx"] }
|
|
502
|
-
: version ===
|
|
503
|
-
? {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
: version ===
|
|
510
|
-
? {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
"
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
503
|
+
: version === 8
|
|
504
|
+
? { table: "prism_sessions", columns: ["version"] }
|
|
505
|
+
: version === 6
|
|
506
|
+
? {
|
|
507
|
+
tables: ["prism_agent_event_streams"],
|
|
508
|
+
indexes: ["prism_agent_events_run_sequence_idx"],
|
|
509
|
+
}
|
|
510
|
+
: version === 2
|
|
511
|
+
? { table: "prism_usage", columns: ["scope", "turn", "attempt"], indexes: ["prism_usage_session_scope_recorded_idx"] }
|
|
512
|
+
: version === 3
|
|
513
|
+
? {
|
|
514
|
+
tables: ["prism_run_feedback"],
|
|
515
|
+
indexes: model.indexes.filter((index) => index.name.startsWith("prism_run_feedback_")).map((index) => index.name),
|
|
516
|
+
}
|
|
517
|
+
: version === 4
|
|
518
|
+
? // Adapter-local FTS objects (SQLite FTS5 / Postgres tsvector) map to this canonical name.
|
|
519
|
+
{ search: ["prism_session_search"], indexes: ["prism_sessions_updated_id_idx"] }
|
|
520
|
+
: version === 5
|
|
521
|
+
? {
|
|
522
|
+
tables: ["prism_legal_holds", "prism_tenant_quotas"],
|
|
523
|
+
indexes: [
|
|
524
|
+
"prism_legal_holds_owner_resource_idx",
|
|
525
|
+
"prism_legal_holds_created_id_idx",
|
|
526
|
+
"prism_tenant_quotas_owner_kind_idx",
|
|
527
|
+
],
|
|
528
|
+
}
|
|
529
|
+
: (() => {
|
|
530
|
+
throw new Error(`Unknown migration version ${version}`);
|
|
531
|
+
})();
|
|
529
532
|
return {
|
|
530
533
|
version,
|
|
531
534
|
name,
|
|
@@ -546,6 +549,7 @@ export function createPersistenceMigrationContract() {
|
|
|
546
549
|
migrationStep(5, "005_lifecycle_hold_quota", "Add legal-hold and tenant-quota tables for retention lifecycle."),
|
|
547
550
|
migrationStep(6, "006_agent_event_source", "Add transactional per-run event counters and unique durable event sequencing."),
|
|
548
551
|
migrationStep(7, "007_agent_event_retention_index", "Add an exact-owner durable-event retention cleanup index."),
|
|
552
|
+
migrationStep(8, "008_session_version", "Add a NOT NULL DEFAULT 0 version column to prism_sessions for appendSession metadata CAS."),
|
|
549
553
|
],
|
|
550
554
|
lockGuidance: "Acquire a dialect-specific migration lock before applying steps (PostgreSQL advisory lock; SQLite exclusive transaction). Only one process should migrate at a time.",
|
|
551
555
|
leastPrivilegeGuidance: "Run migrations with a DDL-capable role; use a separate least-privilege runtime role limited to INSERT/SELECT/UPDATE on adapter tables. Never grant migration credentials to the agent runtime.",
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { AgentEventSource, AgentIdentity, CheckpointStore, ProductionPersistenceStore } from "../contracts.js";
|
|
2
|
+
/**
|
|
3
|
+
* Narrow router-state seam (structurally satisfied by
|
|
4
|
+
* `@arnilo/prism-model-router`'s `ModelRouterStateStore`). Core stays
|
|
5
|
+
* dependency-free; adapter packages pass their real stores.
|
|
6
|
+
*/
|
|
7
|
+
export interface StateConcurrencyRouterKey {
|
|
8
|
+
readonly tenantId: string;
|
|
9
|
+
readonly accountId?: string;
|
|
10
|
+
readonly userId?: string;
|
|
11
|
+
readonly principalId: string;
|
|
12
|
+
readonly provider: string;
|
|
13
|
+
readonly model: string;
|
|
14
|
+
}
|
|
15
|
+
export interface StateConcurrencyRouterStore {
|
|
16
|
+
reserveBudget(input: {
|
|
17
|
+
readonly key: StateConcurrencyRouterKey;
|
|
18
|
+
readonly tokens?: number;
|
|
19
|
+
readonly costUsd?: number;
|
|
20
|
+
readonly maxTokens?: number;
|
|
21
|
+
readonly maxCostUsd?: number;
|
|
22
|
+
readonly windowMs: number;
|
|
23
|
+
readonly reservationTtlMs: number;
|
|
24
|
+
readonly now: number;
|
|
25
|
+
readonly maxBudgetKeys?: number;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
readonly admitted: boolean;
|
|
28
|
+
readonly reservationId?: string;
|
|
29
|
+
readonly fencingToken?: string;
|
|
30
|
+
readonly retryAfterMs?: number;
|
|
31
|
+
}>;
|
|
32
|
+
commitBudget(input: {
|
|
33
|
+
readonly key: StateConcurrencyRouterKey;
|
|
34
|
+
readonly reservationId: string;
|
|
35
|
+
readonly fencingToken: string;
|
|
36
|
+
readonly tokens?: number;
|
|
37
|
+
readonly costUsd?: number;
|
|
38
|
+
readonly windowMs: number;
|
|
39
|
+
readonly now: number;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
readonly unknownUsage: boolean;
|
|
42
|
+
}>;
|
|
43
|
+
releaseBudget(input: {
|
|
44
|
+
readonly key: StateConcurrencyRouterKey;
|
|
45
|
+
readonly reservationId: string;
|
|
46
|
+
readonly fencingToken: string;
|
|
47
|
+
readonly windowMs: number;
|
|
48
|
+
readonly now: number;
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
readBudget(input: {
|
|
51
|
+
readonly key: StateConcurrencyRouterKey;
|
|
52
|
+
readonly windowMs: number;
|
|
53
|
+
readonly now: number;
|
|
54
|
+
}): Promise<{
|
|
55
|
+
readonly tokens: number;
|
|
56
|
+
readonly costUsd: number;
|
|
57
|
+
}>;
|
|
58
|
+
}
|
|
59
|
+
export interface StateConcurrencyRouterFactory {
|
|
60
|
+
readonly create: () => StateConcurrencyRouterStore | Promise<StateConcurrencyRouterStore>;
|
|
61
|
+
/**
|
|
62
|
+
* True when the store derives reservation expiry from the caller-supplied
|
|
63
|
+
* `now` (in-memory stores). Durable stores compute expiry from their own
|
|
64
|
+
* clock (`clock_timestamp()`), so the deterministic unknown-outcome probe
|
|
65
|
+
* only runs when this is set; the durable leg is exercised by the Task 1
|
|
66
|
+
* enterprise-conformance integration probe (real 1ms TTL, `test:postgres`).
|
|
67
|
+
*/
|
|
68
|
+
readonly nowInjected?: boolean;
|
|
69
|
+
}
|
|
70
|
+
/** Narrow idempotency seam (structurally satisfied by `@arnilo/prism-work-tools`'s `IdempotencyStore`). */
|
|
71
|
+
export interface StateConcurrencyIdempotencyStore {
|
|
72
|
+
get(input: StateConcurrencyIdempotencyKey): Promise<StateConcurrencyIdempotencyRecord | undefined>;
|
|
73
|
+
begin(input: StateConcurrencyIdempotencyKey): Promise<{
|
|
74
|
+
readonly outcome: "acquired" | "existing";
|
|
75
|
+
readonly record: StateConcurrencyIdempotencyRecord;
|
|
76
|
+
}>;
|
|
77
|
+
complete(input: StateConcurrencyIdempotencyKey & {
|
|
78
|
+
readonly claimToken: string;
|
|
79
|
+
readonly expectedVersion: number;
|
|
80
|
+
readonly result: {
|
|
81
|
+
readonly draftId: string;
|
|
82
|
+
readonly resourceId?: string;
|
|
83
|
+
};
|
|
84
|
+
}): Promise<StateConcurrencyIdempotencyRecord>;
|
|
85
|
+
}
|
|
86
|
+
export interface StateConcurrencyIdempotencyKey {
|
|
87
|
+
readonly identity: AgentIdentity;
|
|
88
|
+
readonly key: string;
|
|
89
|
+
readonly op: string;
|
|
90
|
+
readonly signal?: AbortSignal;
|
|
91
|
+
}
|
|
92
|
+
export interface StateConcurrencyIdempotencyRecord {
|
|
93
|
+
readonly tenantId?: string;
|
|
94
|
+
readonly accountId?: string;
|
|
95
|
+
readonly userId?: string;
|
|
96
|
+
readonly principalId: string;
|
|
97
|
+
readonly key: string;
|
|
98
|
+
readonly op: string;
|
|
99
|
+
readonly status: "in_progress" | "completed" | "failed_retryable" | "failed_terminal" | "unknown";
|
|
100
|
+
readonly attempt: number;
|
|
101
|
+
readonly version: number;
|
|
102
|
+
readonly claimToken?: string;
|
|
103
|
+
readonly result?: {
|
|
104
|
+
readonly draftId: string;
|
|
105
|
+
readonly resourceId?: string;
|
|
106
|
+
};
|
|
107
|
+
readonly failure?: {
|
|
108
|
+
readonly code: string;
|
|
109
|
+
readonly reference?: string;
|
|
110
|
+
};
|
|
111
|
+
readonly createdAt: string;
|
|
112
|
+
readonly updatedAt: string;
|
|
113
|
+
readonly expiresAt?: string;
|
|
114
|
+
}
|
|
115
|
+
export type StateConcurrencyEventSource = AgentEventSource & {
|
|
116
|
+
readonly close?: () => void | Promise<void>;
|
|
117
|
+
};
|
|
118
|
+
export interface StateConcurrencyEventSourceFactory {
|
|
119
|
+
readonly create: () => StateConcurrencyEventSource | Promise<StateConcurrencyEventSource>;
|
|
120
|
+
/**
|
|
121
|
+
* True when the factory can re-open against the same backend (durable
|
|
122
|
+
* stores). Memory stores are process-local; their probe covers cursor
|
|
123
|
+
* resume on the same instance and durable factories additionally re-open.
|
|
124
|
+
*/
|
|
125
|
+
readonly reopenable?: boolean;
|
|
126
|
+
}
|
|
127
|
+
export interface StateConcurrencyFactories {
|
|
128
|
+
/** Checkpoint seam: approval-determinism and checkpoint-CAS probes. */
|
|
129
|
+
readonly checkpoints?: () => CheckpointStore | Promise<CheckpointStore>;
|
|
130
|
+
/** Durable event seam: replay-cursor resume probe (memory and NATS/Postgres). */
|
|
131
|
+
readonly events?: StateConcurrencyEventSourceFactory;
|
|
132
|
+
/** Session-record seam: conversation metadata CAS probe (appendSession). */
|
|
133
|
+
readonly sessions?: () => ProductionPersistenceStore | Promise<ProductionPersistenceStore>;
|
|
134
|
+
/** Work idempotency seam: retry-same-key probe. */
|
|
135
|
+
readonly idempotency?: () => StateConcurrencyIdempotencyStore | Promise<StateConcurrencyIdempotencyStore>;
|
|
136
|
+
/** Router budget reservation seam: oversubscription + unknown-outcome probes. */
|
|
137
|
+
readonly routerState?: StateConcurrencyRouterFactory;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Run the state-concurrency probes for every provided store family. Returns
|
|
141
|
+
* the executed probe names so gates can assert coverage. Deterministic:
|
|
142
|
+
* concurrent ops are awaited through `Promise.allSettled` and state is
|
|
143
|
+
* asserted afterward; no timing-only sleeps anywhere in this file.
|
|
144
|
+
*/
|
|
145
|
+
export declare function assertStateConcurrencyConforms(factories: StateConcurrencyFactories): Promise<readonly string[]>;
|