managed-deepagents 0.0.3-dev.46 → 0.0.3-dev.47
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/dist/connectors/langsmith.d.ts +255 -4
- package/dist/connectors/langsmith.d.ts.map +1 -1
- package/dist/connectors/langsmith.js +167 -4
- package/dist/connectors/langsmith.js.map +1 -1
- package/dist/identity/index.d.ts +88 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/{identity.js → identity/index.js} +6 -125
- package/dist/identity/index.js.map +1 -0
- package/dist/identity/supabase.d.ts +48 -0
- package/dist/identity/supabase.d.ts.map +1 -0
- package/dist/identity/supabase.js +139 -0
- package/dist/identity/supabase.js.map +1 -0
- package/dist/{identity.d.ts → identity/types.d.ts} +4 -111
- package/dist/identity/types.d.ts.map +1 -0
- package/dist/identity/types.js +8 -0
- package/dist/identity/types.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/auth.d.ts +1 -1
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/connector.d.ts +1 -1
- package/dist/runtime/connector.d.ts.map +1 -1
- package/dist/runtime/credentials.d.ts +1 -1
- package/dist/runtime/credentials.d.ts.map +1 -1
- package/dist/runtime/identity-http.d.ts +1 -1
- package/dist/runtime/identity-http.d.ts.map +1 -1
- package/dist/runtime/identity-runtime.d.ts +1 -1
- package/dist/runtime/identity-runtime.d.ts.map +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +41 -67
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/langsmith-connector.d.ts +110 -12
- package/dist/runtime/langsmith-connector.d.ts.map +1 -1
- package/dist/runtime/langsmith-connector.js +467 -40
- package/dist/runtime/langsmith-connector.js.map +1 -1
- package/dist/runtime/managed-middleware.d.ts +1 -1
- package/dist/runtime/managed-middleware.d.ts.map +1 -1
- package/dist/runtime/managed-tools.d.ts +1 -1
- package/dist/runtime/managed-tools.d.ts.map +1 -1
- package/dist/runtime/sandbox-manager.d.ts +3 -2
- package/dist/runtime/sandbox-manager.d.ts.map +1 -1
- package/dist/runtime/sandbox-manager.js.map +1 -1
- package/dist/runtime/setup-script.d.ts +4 -7
- package/dist/runtime/setup-script.d.ts.map +1 -1
- package/dist/runtime/setup-script.js.map +1 -1
- package/dist/runtime/types.d.ts +1 -15
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/validated-token.d.ts +20 -3
- package/dist/runtime/validated-token.d.ts.map +1 -1
- package/dist/runtime/validated-token.js +71 -20
- package/dist/runtime/validated-token.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -7
- package/dist/identity.d.ts.map +0 -1
- package/dist/identity.js.map +0 -1
|
@@ -18,6 +18,10 @@ import { Client } from "langsmith";
|
|
|
18
18
|
/** An error carrying an HTTP status the engine surfaces to the caller. */
|
|
19
19
|
export class EnforcementError extends Error {
|
|
20
20
|
status;
|
|
21
|
+
/**
|
|
22
|
+
* @param status - HTTP status code returned to the caller.
|
|
23
|
+
* @param message - Human-readable error detail.
|
|
24
|
+
*/
|
|
21
25
|
constructor(status, message) {
|
|
22
26
|
super(message);
|
|
23
27
|
this.status = status;
|
|
@@ -28,6 +32,10 @@ export class EnforcementError extends Error {
|
|
|
28
32
|
* Mount the capability route on the connector's namespaced sub-router. Runs the
|
|
29
33
|
* identity-dependent authoring checks at mount time (fails startup on an illegal
|
|
30
34
|
* scope/ingress combination), then registers the uniform POST endpoint.
|
|
35
|
+
*
|
|
36
|
+
* @param ctx - HTTP mount context with the namespaced router and identity config.
|
|
37
|
+
* @param capabilities - Declared LangSmith capabilities to expose.
|
|
38
|
+
* @param client - LangSmith client used for server-side operations; defaults to {@link defaultClient}.
|
|
31
39
|
*/
|
|
32
40
|
export function mountLangSmithRoutes(ctx, capabilities, client = defaultClient()) {
|
|
33
41
|
validateAgainstIdentity(capabilities, ctx.identity);
|
|
@@ -41,7 +49,14 @@ export function mountLangSmithRoutes(ctx, capabilities, client = defaultClient()
|
|
|
41
49
|
// second argument.
|
|
42
50
|
ctx.router.post("/capabilities/:id", (request, identity) => handleCapabilityRequest(request, identity, deps));
|
|
43
51
|
}
|
|
44
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Identity-aware authoring validation, run when the app is constructed.
|
|
54
|
+
* Rejects illegal scope/ingress combinations before any request is served.
|
|
55
|
+
*
|
|
56
|
+
* @param capabilities - Declared LangSmith capabilities to validate.
|
|
57
|
+
* @param identity - Deployment identity config, or `undefined` when identity is not configured.
|
|
58
|
+
* @throws {Error} When a capability's scope, exposure, or tenancy is illegal for this deployment.
|
|
59
|
+
*/
|
|
45
60
|
export function validateAgainstIdentity(capabilities, identity) {
|
|
46
61
|
const httpMode = identity?.ingress.http;
|
|
47
62
|
const hasValidatedToken = httpMode !== undefined && httpMode !== "trusted_backend";
|
|
@@ -74,7 +89,16 @@ export function validateAgainstIdentity(capabilities, identity) {
|
|
|
74
89
|
}
|
|
75
90
|
}
|
|
76
91
|
}
|
|
77
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Per-request enforcement pipeline for a capability POST.
|
|
94
|
+
* Resolves the capability, checks surface/action, proves thread ownership when
|
|
95
|
+
* needed, enforces scope, performs the LangSmith call, and shapes the response.
|
|
96
|
+
*
|
|
97
|
+
* @param request - Incoming HTTP request for `/capabilities/:id`.
|
|
98
|
+
* @param identity - Frozen runtime identity resolved by the secure router.
|
|
99
|
+
* @param deps - Closed-over capabilities map, LangSmith client, and optional access prover.
|
|
100
|
+
* @returns JSON `Response` with `{ data }` on success or `{ error }` on failure.
|
|
101
|
+
*/
|
|
78
102
|
export async function handleCapabilityRequest(request, identity, deps) {
|
|
79
103
|
try {
|
|
80
104
|
const capabilityId = capabilityIdFromUrl(request.url);
|
|
@@ -91,9 +115,15 @@ export async function handleCapabilityRequest(request, identity, deps) {
|
|
|
91
115
|
if (!capability.actions.includes(action)) {
|
|
92
116
|
throw new EnforcementError(403, `action "${action}" is not allowed for capability "${capability.id}"`);
|
|
93
117
|
}
|
|
94
|
-
// Connector HTTP has no LangGraph configurable.thread_id
|
|
95
|
-
//
|
|
96
|
-
|
|
118
|
+
// Connector HTTP has no LangGraph configurable.thread_id. Resolve a thread
|
|
119
|
+
// id from identity / run / body, prove access when it is client-supplied,
|
|
120
|
+
// then bind the proven id onto identity for the rest of the request.
|
|
121
|
+
const assertThreadAccess = deps.assertThreadAccess ??
|
|
122
|
+
defaultAssertThreadAccess(request, identity, deps.identityConfig);
|
|
123
|
+
const provenThreadId = await resolveProvenThreadId(capability, identity, body, deps.client, assertThreadAccess);
|
|
124
|
+
const scopedIdentity = provenThreadId
|
|
125
|
+
? withIdentityThread(identity, provenThreadId)
|
|
126
|
+
: identity;
|
|
97
127
|
await assertCapabilityScope(capability, scopedIdentity, body, deps.client);
|
|
98
128
|
const raw = toRecord(await performLangSmithCall(capability, action, body, deps, scopedIdentity));
|
|
99
129
|
const shaped = shapeResponse(raw, capability);
|
|
@@ -107,7 +137,13 @@ export async function handleCapabilityRequest(request, identity, deps) {
|
|
|
107
137
|
return jsonResponse(status, { error: errorMessage(error) });
|
|
108
138
|
}
|
|
109
139
|
}
|
|
110
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Map the resolved identity + ingress mode to a caller surface.
|
|
142
|
+
*
|
|
143
|
+
* @param identity - Frozen runtime identity for this request.
|
|
144
|
+
* @param identityConfig - Deployment identity config (ingress mode).
|
|
145
|
+
* @returns The surface used for `exposeTo` checks (`browser`, `trusted_backend`, `channel`, or `schedule`).
|
|
146
|
+
*/
|
|
111
147
|
export function callerSurface(identity, identityConfig) {
|
|
112
148
|
const provider = identity.source.provider;
|
|
113
149
|
if (provider === "slack") {
|
|
@@ -122,7 +158,12 @@ export function callerSurface(identity, identityConfig) {
|
|
|
122
158
|
: "browser";
|
|
123
159
|
}
|
|
124
160
|
// --- Ownership + constraints -----------------------------------------------
|
|
125
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Read the thread id from the frozen identity envelope (`source.threadId`).
|
|
163
|
+
*
|
|
164
|
+
* @param identity - Frozen runtime identity.
|
|
165
|
+
* @returns Non-empty thread id, or `undefined` when absent.
|
|
166
|
+
*/
|
|
126
167
|
function identityThreadId(identity) {
|
|
127
168
|
const threadId = identity.source.threadId;
|
|
128
169
|
return typeof threadId === "string" && threadId.length > 0
|
|
@@ -130,29 +171,192 @@ function identityThreadId(identity) {
|
|
|
130
171
|
: undefined;
|
|
131
172
|
}
|
|
132
173
|
/**
|
|
133
|
-
*
|
|
174
|
+
* Attach a proven thread id onto a copy of the identity envelope.
|
|
175
|
+
* Returns the same object when the identity already carries that thread id.
|
|
134
176
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
177
|
+
* @param identity - Frozen runtime identity to copy.
|
|
178
|
+
* @param threadId - Proven thread id to bind onto `source.threadId`.
|
|
179
|
+
* @returns Identity with `source.threadId` set to `threadId`.
|
|
138
180
|
*/
|
|
139
|
-
export function
|
|
140
|
-
|
|
141
|
-
const fromBody = optionalBodyString(body, "threadId", "thread_id");
|
|
142
|
-
if (fromIdentity && fromBody && fromIdentity !== fromBody) {
|
|
143
|
-
throw new EnforcementError(403, "body thread id does not match identity thread");
|
|
144
|
-
}
|
|
145
|
-
if (fromIdentity || !fromBody) {
|
|
181
|
+
export function withIdentityThread(identity, threadId) {
|
|
182
|
+
if (identityThreadId(identity) === threadId) {
|
|
146
183
|
return identity;
|
|
147
184
|
}
|
|
148
185
|
return {
|
|
149
186
|
...identity,
|
|
150
|
-
source: { ...identity.source, threadId
|
|
187
|
+
source: { ...identity.source, threadId },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Resolve the thread id for a thread/run-scoped capability and prove access
|
|
192
|
+
* when it did not come from the trusted identity envelope.
|
|
193
|
+
*
|
|
194
|
+
* - identity `source.threadId` (server-derived from agent runs) is trusted
|
|
195
|
+
* - LangSmith run session/thread (when `run_id` is present) is authoritative
|
|
196
|
+
* - bare body `threadId`/`thread_id` (no identity thread, no run) requires
|
|
197
|
+
* {@link AssertThreadAccess}
|
|
198
|
+
*
|
|
199
|
+
* @param capability - Capability whose `scope` drives whether a thread id is required.
|
|
200
|
+
* @param identity - Frozen runtime identity for this request.
|
|
201
|
+
* @param body - Parsed JSON request body.
|
|
202
|
+
* @param client - LangSmith client used to load a run when `run_id` is present.
|
|
203
|
+
* @param assertThreadAccess - Ownership prover for bare body thread ids.
|
|
204
|
+
* @returns Proven thread id, or the identity thread id for non-thread/run scopes.
|
|
205
|
+
* @throws {EnforcementError} On missing/mismatched thread ids or failed ownership proof.
|
|
206
|
+
*/
|
|
207
|
+
export async function resolveProvenThreadId(capability, identity, body, client, assertThreadAccess) {
|
|
208
|
+
if (capability.scope !== "thread" && capability.scope !== "run") {
|
|
209
|
+
return identityThreadId(identity);
|
|
210
|
+
}
|
|
211
|
+
const fromIdentity = identityThreadId(identity);
|
|
212
|
+
const fromBody = optionalBodyString(body, "threadId", "thread_id");
|
|
213
|
+
const runId = optionalBodyString(body, "runId", "run_id");
|
|
214
|
+
let fromRun;
|
|
215
|
+
if (runId) {
|
|
216
|
+
const run = toRecord(await client.readRun({ runId }));
|
|
217
|
+
fromRun = runThreadId(run);
|
|
218
|
+
if (!fromRun) {
|
|
219
|
+
throw new EnforcementError(403, "run has no thread id");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (capability.scope === "run") {
|
|
223
|
+
// update/delete may omit runId; require identity or body thread below
|
|
224
|
+
}
|
|
225
|
+
for (const [left, right, label] of [
|
|
226
|
+
[fromIdentity, fromBody, "body thread id does not match identity thread"],
|
|
227
|
+
[fromIdentity, fromRun, "run does not belong to identity thread"],
|
|
228
|
+
[fromBody, fromRun, "body thread id does not match run thread"],
|
|
229
|
+
]) {
|
|
230
|
+
if (left && right && left !== right) {
|
|
231
|
+
throw new EnforcementError(403, label);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const threadId = fromIdentity ?? fromRun ?? fromBody;
|
|
235
|
+
if (!threadId) {
|
|
236
|
+
throw new EnforcementError(403, `${capability.scope} scope requires thread id (identity source, run, or request body)`);
|
|
237
|
+
}
|
|
238
|
+
// Identity- and run-derived thread ids are already proven. Only a bare body
|
|
239
|
+
// thread id (no identity thread, no run) needs a live ownership check.
|
|
240
|
+
if (!fromIdentity && !fromRun) {
|
|
241
|
+
await assertThreadAccess(threadId);
|
|
242
|
+
}
|
|
243
|
+
return threadId;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Build the default ownership proof: `GET /threads/{id}` with the caller's
|
|
247
|
+
* credentials and require `metadata.owner` to match the identity's thread-owner key.
|
|
248
|
+
*
|
|
249
|
+
* @param request - Incoming request whose auth headers are forwarded to the thread GET.
|
|
250
|
+
* @param identity - Frozen runtime identity used to derive the expected owner key.
|
|
251
|
+
* @param identityConfig - Deployment identity config (`scoping.threads`).
|
|
252
|
+
* @returns An `AssertThreadAccess` function closed over the request and expected owner.
|
|
253
|
+
*/
|
|
254
|
+
export function defaultAssertThreadAccess(request, identity, identityConfig) {
|
|
255
|
+
const expectedOwner = threadOwnerFromIdentity(identity, identityConfig);
|
|
256
|
+
return async (threadId) => {
|
|
257
|
+
if (!expectedOwner) {
|
|
258
|
+
throw new EnforcementError(403, "thread access requires identity.actor.id");
|
|
259
|
+
}
|
|
260
|
+
const threadUrl = new URL(request.url);
|
|
261
|
+
threadUrl.pathname = `/threads/${encodeURIComponent(threadId)}`;
|
|
262
|
+
threadUrl.search = "";
|
|
263
|
+
const headers = forwardAuthHeaders(request);
|
|
264
|
+
let response;
|
|
265
|
+
try {
|
|
266
|
+
response = await fetch(threadUrl, { method: "GET", headers });
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
throw new EnforcementError(502, "failed to verify thread access");
|
|
270
|
+
}
|
|
271
|
+
if (response.status === 401) {
|
|
272
|
+
throw new EnforcementError(401, "unauthorized");
|
|
273
|
+
}
|
|
274
|
+
if (response.status === 403 || response.status === 404) {
|
|
275
|
+
throw new EnforcementError(403, "thread is not accessible to this identity");
|
|
276
|
+
}
|
|
277
|
+
if (!response.ok) {
|
|
278
|
+
throw new EnforcementError(502, `failed to verify thread access (${response.status})`);
|
|
279
|
+
}
|
|
280
|
+
let payload;
|
|
281
|
+
try {
|
|
282
|
+
payload = await response.json();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const owner = threadOwnerFromPayload(payload);
|
|
288
|
+
if (owner && owner !== expectedOwner) {
|
|
289
|
+
throw new EnforcementError(403, "thread is not accessible to this identity");
|
|
290
|
+
}
|
|
151
291
|
};
|
|
152
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Owner key used by `@auth.on.threads` for this identity.
|
|
295
|
+
*
|
|
296
|
+
* @param identity - Frozen runtime identity.
|
|
297
|
+
* @param identityConfig - Deployment identity config (`scoping.threads`).
|
|
298
|
+
* @returns Actor or tenant id used as the thread `metadata.owner`, or `undefined` when missing.
|
|
299
|
+
*/
|
|
300
|
+
function threadOwnerFromIdentity(identity, identityConfig) {
|
|
301
|
+
const scope = identityConfig?.scoping?.threads ?? "actor";
|
|
302
|
+
if (scope === "tenant") {
|
|
303
|
+
return identity.tenant?.id ?? identity.actor?.id;
|
|
304
|
+
}
|
|
305
|
+
return identity.actor?.id;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Extract `metadata.owner` from a LangGraph thread payload.
|
|
309
|
+
*
|
|
310
|
+
* @param payload - Parsed JSON body from `GET /threads/{id}`.
|
|
311
|
+
* @returns Non-empty owner string, or `undefined` when absent/malformed.
|
|
312
|
+
*/
|
|
313
|
+
function threadOwnerFromPayload(payload) {
|
|
314
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
const metadata = payload.metadata;
|
|
318
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
const owner = metadata.owner;
|
|
322
|
+
return typeof owner === "string" && owner.length > 0 ? owner : undefined;
|
|
323
|
+
}
|
|
324
|
+
const FORWARDED_AUTH_HEADERS = [
|
|
325
|
+
"authorization",
|
|
326
|
+
"cookie",
|
|
327
|
+
"x-api-key",
|
|
328
|
+
"x-auth-key",
|
|
329
|
+
"x-supabase-region",
|
|
330
|
+
"x-mda-ingress-secret",
|
|
331
|
+
"x-mda-actor-id",
|
|
332
|
+
"x-mda-tenant-id",
|
|
333
|
+
];
|
|
334
|
+
/**
|
|
335
|
+
* Copy auth-related headers from the inbound request for same-origin thread GETs.
|
|
336
|
+
*
|
|
337
|
+
* @param request - Incoming capability request.
|
|
338
|
+
* @returns Headers containing only the allowlisted auth fields that were present.
|
|
339
|
+
*/
|
|
340
|
+
function forwardAuthHeaders(request) {
|
|
341
|
+
const headers = new Headers();
|
|
342
|
+
for (const name of FORWARDED_AUTH_HEADERS) {
|
|
343
|
+
const value = request.headers.get(name);
|
|
344
|
+
if (value) {
|
|
345
|
+
headers.set(name, value);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return headers;
|
|
349
|
+
}
|
|
153
350
|
/**
|
|
154
351
|
* Prove the capability's ownership scope from `runtime.identity` before the
|
|
155
|
-
* LangSmith call.
|
|
352
|
+
* LangSmith call. Thread id must already be present on identity when required
|
|
353
|
+
* (see {@link resolveProvenThreadId}).
|
|
354
|
+
*
|
|
355
|
+
* @param capability - Capability whose `scope` is enforced.
|
|
356
|
+
* @param identity - Identity with any proven thread id already bound.
|
|
357
|
+
* @param body - Parsed JSON request body (used for run-scoped `run_id` checks).
|
|
358
|
+
* @param client - LangSmith client used to load runs for run-scope verification.
|
|
359
|
+
* @throws {EnforcementError} When the identity lacks the required scope fields or the run is foreign.
|
|
156
360
|
*/
|
|
157
361
|
export async function assertCapabilityScope(capability, identity, body, client) {
|
|
158
362
|
switch (capability.scope) {
|
|
@@ -173,18 +377,20 @@ export async function assertCapabilityScope(capability, identity, body, client)
|
|
|
173
377
|
return;
|
|
174
378
|
}
|
|
175
379
|
case "thread": {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
throw new EnforcementError(403, "thread scope requires thread id (identity source or request body)");
|
|
380
|
+
if (!identityThreadId(identity)) {
|
|
381
|
+
throw new EnforcementError(403, "thread scope requires thread id (identity source, run, or request body)");
|
|
179
382
|
}
|
|
180
383
|
return;
|
|
181
384
|
}
|
|
182
385
|
case "run": {
|
|
183
386
|
const threadId = identityThreadId(identity);
|
|
184
387
|
if (!threadId) {
|
|
185
|
-
throw new EnforcementError(403, "run scope requires thread id (identity source or request body)");
|
|
388
|
+
throw new EnforcementError(403, "run scope requires thread id (identity source, run, or request body)");
|
|
389
|
+
}
|
|
390
|
+
const runId = await resolveRunIdForScope(body, client);
|
|
391
|
+
if (!runId) {
|
|
392
|
+
throw new EnforcementError(403, "run scope requires run id or feedback id");
|
|
186
393
|
}
|
|
187
|
-
const runId = bodyString(body, "runId", "run_id");
|
|
188
394
|
const run = toRecord(await client.readRun({ runId }));
|
|
189
395
|
const runThread = runThreadId(run);
|
|
190
396
|
if (!runThread || runThread !== threadId) {
|
|
@@ -196,25 +402,43 @@ export async function assertCapabilityScope(capability, identity, body, client)
|
|
|
196
402
|
throw new EnforcementError(400, `unknown capability scope "${String(capability.scope)}"`);
|
|
197
403
|
}
|
|
198
404
|
}
|
|
199
|
-
/**
|
|
405
|
+
/**
|
|
406
|
+
* Resolve a LangGraph thread id from a LangSmith run.
|
|
407
|
+
*
|
|
408
|
+
* Prefer explicit `thread_id` fields. Metadata may also carry `session_id` as
|
|
409
|
+
* the conversation/thread id. Do **not** use the run's top-level `session_id`:
|
|
410
|
+
* on LangSmith that field is the tracing project/session UUID, not the
|
|
411
|
+
* LangGraph thread.
|
|
412
|
+
*
|
|
413
|
+
* @param run - LangSmith run record (top-level and metadata bags).
|
|
414
|
+
* @returns First non-empty LangGraph thread id found, or `undefined`.
|
|
415
|
+
*/
|
|
200
416
|
function runThreadId(run) {
|
|
201
|
-
const
|
|
202
|
-
|
|
417
|
+
const threadKeys = ["thread_id", "threadId"];
|
|
418
|
+
const metadataKeys = [
|
|
419
|
+
"thread_id",
|
|
420
|
+
"threadId",
|
|
421
|
+
"session_id",
|
|
422
|
+
"sessionId",
|
|
423
|
+
];
|
|
424
|
+
for (const key of threadKeys) {
|
|
203
425
|
const value = run[key];
|
|
204
426
|
if (typeof value === "string" && value.length > 0) {
|
|
205
427
|
return value;
|
|
206
428
|
}
|
|
207
429
|
}
|
|
430
|
+
const metadataBags = [run.metadata];
|
|
208
431
|
const extra = run.extra;
|
|
209
432
|
if (extra && typeof extra === "object" && !Array.isArray(extra)) {
|
|
210
433
|
const extraRecord = extra;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
434
|
+
metadataBags.push(extraRecord.metadata ?? extraRecord);
|
|
435
|
+
}
|
|
436
|
+
for (const bag of metadataBags) {
|
|
437
|
+
if (!bag || typeof bag !== "object" || Array.isArray(bag))
|
|
438
|
+
continue;
|
|
439
|
+
const record = bag;
|
|
440
|
+
for (const key of metadataKeys) {
|
|
441
|
+
const value = record[key];
|
|
218
442
|
if (typeof value === "string" && value.length > 0) {
|
|
219
443
|
return value;
|
|
220
444
|
}
|
|
@@ -222,6 +446,34 @@ function runThreadId(run) {
|
|
|
222
446
|
}
|
|
223
447
|
return undefined;
|
|
224
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Resolve the run id for run-scoped ownership checks.
|
|
451
|
+
* Prefer an explicit body `runId`/`run_id`; otherwise load the feedback and
|
|
452
|
+
* use its `run_id` so update/delete cannot skip thread ownership.
|
|
453
|
+
*
|
|
454
|
+
* @param body - Parsed JSON request body.
|
|
455
|
+
* @param client - LangSmith client used to load feedback when needed.
|
|
456
|
+
* @returns Run id to verify against the identity thread, or `undefined`.
|
|
457
|
+
*/
|
|
458
|
+
async function resolveRunIdForScope(body, client) {
|
|
459
|
+
const fromBody = optionalBodyString(body, "runId", "run_id");
|
|
460
|
+
if (fromBody) {
|
|
461
|
+
return fromBody;
|
|
462
|
+
}
|
|
463
|
+
const feedbackId = optionalBodyString(body, "feedbackId", "feedback_id");
|
|
464
|
+
if (!feedbackId) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
const feedback = toRecord(await client.readFeedback({ feedbackId }));
|
|
468
|
+
const runId = feedback.run_id ?? feedback.runId;
|
|
469
|
+
return typeof runId === "string" && runId.length > 0 ? runId : undefined;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Read the MDA actor id stamped onto feedback metadata (`mda_actor_id`).
|
|
473
|
+
*
|
|
474
|
+
* @param feedback - LangSmith feedback record.
|
|
475
|
+
* @returns Actor id from top-level or `feedback_source` metadata, or `undefined`.
|
|
476
|
+
*/
|
|
225
477
|
function feedbackActorId(feedback) {
|
|
226
478
|
const direct = feedback.metadata;
|
|
227
479
|
if (direct && typeof direct === "object" && !Array.isArray(direct)) {
|
|
@@ -242,6 +494,12 @@ function feedbackActorId(feedback) {
|
|
|
242
494
|
}
|
|
243
495
|
return undefined;
|
|
244
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* Read the MDA thread id stamped onto example metadata (`mda_thread_id`).
|
|
499
|
+
*
|
|
500
|
+
* @param example - LangSmith example record.
|
|
501
|
+
* @returns Thread id from `metadata.mda_thread_id`, or `undefined`.
|
|
502
|
+
*/
|
|
245
503
|
function exampleThreadId(example) {
|
|
246
504
|
const metadata = example.metadata;
|
|
247
505
|
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
|
|
@@ -252,7 +510,17 @@ function exampleThreadId(example) {
|
|
|
252
510
|
}
|
|
253
511
|
return undefined;
|
|
254
512
|
}
|
|
255
|
-
|
|
513
|
+
/**
|
|
514
|
+
* Dispatch a capability action to the matching LangSmith resource handler.
|
|
515
|
+
*
|
|
516
|
+
* @param capability - Capability declaring the resource and constraints.
|
|
517
|
+
* @param action - Requested action name from the body.
|
|
518
|
+
* @param body - Parsed JSON request body.
|
|
519
|
+
* @param deps - Handler dependencies including the LangSmith client.
|
|
520
|
+
* @param identity - Scoped identity (with proven thread id when applicable).
|
|
521
|
+
* @returns Raw LangSmith SDK result before response shaping.
|
|
522
|
+
* @throws {EnforcementError} When the resource is unsupported or the action fails validation.
|
|
523
|
+
*/
|
|
256
524
|
async function performLangSmithCall(capability, action, body, deps, identity) {
|
|
257
525
|
switch (capability.resource) {
|
|
258
526
|
case "feedback":
|
|
@@ -271,6 +539,18 @@ async function performLangSmithCall(capability, action, body, deps, identity) {
|
|
|
271
539
|
throw new EnforcementError(400, `resource "${capability.resource}" is not supported in v0`);
|
|
272
540
|
}
|
|
273
541
|
}
|
|
542
|
+
/**
|
|
543
|
+
* Execute a feedback create/update/delete against LangSmith, applying
|
|
544
|
+
* capability constraints (`keys`, `scores`, `maxCommentChars`, `onePerActor`).
|
|
545
|
+
*
|
|
546
|
+
* @param capability - Feedback capability with constraints.
|
|
547
|
+
* @param action - One of `create`, `update`, or `delete`.
|
|
548
|
+
* @param body - Parsed JSON request body.
|
|
549
|
+
* @param client - LangSmith client.
|
|
550
|
+
* @param identity - Runtime identity (actor id for `onePerActor` and metadata).
|
|
551
|
+
* @returns Created feedback, or `void` for update/delete.
|
|
552
|
+
* @throws {EnforcementError} On invalid action, constraint violation, or duplicate actor feedback.
|
|
553
|
+
*/
|
|
274
554
|
async function feedbackCall(capability, action, body, client, identity) {
|
|
275
555
|
const c = capability.constraints ?? {};
|
|
276
556
|
if (action === "create") {
|
|
@@ -325,6 +605,15 @@ async function feedbackCall(capability, action, body, client, identity) {
|
|
|
325
605
|
}
|
|
326
606
|
throw new EnforcementError(400, `unknown feedback action "${action}"`);
|
|
327
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* Execute a runs read or share against LangSmith.
|
|
610
|
+
*
|
|
611
|
+
* @param action - One of `read` or `share`.
|
|
612
|
+
* @param body - Parsed JSON request body (`runId` / `run_id`).
|
|
613
|
+
* @param client - LangSmith client.
|
|
614
|
+
* @returns Run record or share URL payload.
|
|
615
|
+
* @throws {EnforcementError} On unknown action or missing run id.
|
|
616
|
+
*/
|
|
328
617
|
async function runsCall(action, body, client) {
|
|
329
618
|
const runId = bodyString(body, "runId", "run_id");
|
|
330
619
|
if (action === "read") {
|
|
@@ -335,6 +624,17 @@ async function runsCall(action, body, client) {
|
|
|
335
624
|
}
|
|
336
625
|
throw new EnforcementError(400, `unknown runs action "${action}"`);
|
|
337
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* Create a LangSmith example in the capability's dataset, applying field and
|
|
629
|
+
* per-thread caps and stamping MDA metadata.
|
|
630
|
+
*
|
|
631
|
+
* @param capability - Examples capability with `dataset` and related constraints.
|
|
632
|
+
* @param body - Parsed JSON request body (`inputs`, optional `outputs`).
|
|
633
|
+
* @param client - LangSmith client.
|
|
634
|
+
* @param identity - Runtime identity (thread/actor metadata and `maxExamplesPerThread`).
|
|
635
|
+
* @returns Created example.
|
|
636
|
+
* @throws {EnforcementError} On missing dataset, disallowed fields, or thread example cap.
|
|
637
|
+
*/
|
|
338
638
|
async function examplesCall(capability, body, client, identity) {
|
|
339
639
|
const c = capability.constraints ?? {};
|
|
340
640
|
const dataset = c.dataset;
|
|
@@ -383,6 +683,15 @@ async function examplesCall(capability, body, client, identity) {
|
|
|
383
683
|
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
|
384
684
|
});
|
|
385
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* Enqueue a run onto the capability's annotation queue.
|
|
688
|
+
*
|
|
689
|
+
* @param capability - Annotation-queue capability with `queue` and related constraints.
|
|
690
|
+
* @param body - Parsed JSON request body (`runId`, optional `priority` / `note`).
|
|
691
|
+
* @param client - LangSmith client.
|
|
692
|
+
* @returns Resolves when the run is enqueued.
|
|
693
|
+
* @throws {EnforcementError} On missing queue or disallowed priority/note.
|
|
694
|
+
*/
|
|
386
695
|
async function annotationQueueCall(capability, body, client) {
|
|
387
696
|
const c = capability.constraints ?? {};
|
|
388
697
|
const queue = c.queue;
|
|
@@ -401,6 +710,16 @@ async function annotationQueueCall(capability, body, client) {
|
|
|
401
710
|
...(note !== undefined ? { note } : {}),
|
|
402
711
|
});
|
|
403
712
|
}
|
|
713
|
+
/**
|
|
714
|
+
* Execute a threads read or metadata update against LangSmith.
|
|
715
|
+
*
|
|
716
|
+
* @param capability - Threads capability with metadata constraints.
|
|
717
|
+
* @param action - One of `read` or `update_metadata`.
|
|
718
|
+
* @param body - Parsed JSON request body (`threadId`, optional `metadata`).
|
|
719
|
+
* @param client - LangSmith client.
|
|
720
|
+
* @returns Thread/run record, or `void` for metadata updates.
|
|
721
|
+
* @throws {EnforcementError} On unknown action or disallowed metadata keys/title length.
|
|
722
|
+
*/
|
|
404
723
|
async function threadsCall(capability, action, body, client) {
|
|
405
724
|
const threadId = bodyString(body, "threadId", "thread_id");
|
|
406
725
|
if (action === "read") {
|
|
@@ -428,7 +747,13 @@ async function threadsCall(capability, action, body, client) {
|
|
|
428
747
|
throw new EnforcementError(400, `unknown threads action "${action}"`);
|
|
429
748
|
}
|
|
430
749
|
// --- Response shaping ------------------------------------------------------
|
|
431
|
-
/**
|
|
750
|
+
/**
|
|
751
|
+
* Apply the capability's `include` allowlist, then drop any `redact` roots.
|
|
752
|
+
*
|
|
753
|
+
* @param raw - Full LangSmith result coerced to a plain record.
|
|
754
|
+
* @param capability - Capability whose `response.include` / `response.redact` apply.
|
|
755
|
+
* @returns Allowlisted (and redacted) response object returned to the caller.
|
|
756
|
+
*/
|
|
432
757
|
export function shapeResponse(raw, capability) {
|
|
433
758
|
const include = capability.response?.include ?? [];
|
|
434
759
|
const redact = new Set(capability.response?.redact ?? []);
|
|
@@ -452,6 +777,13 @@ export function shapeResponse(raw, capability) {
|
|
|
452
777
|
}
|
|
453
778
|
return picked;
|
|
454
779
|
}
|
|
780
|
+
/**
|
|
781
|
+
* Walk a dotted path into a nested value.
|
|
782
|
+
*
|
|
783
|
+
* @param value - Root value to traverse.
|
|
784
|
+
* @param path - Remaining path segments after the root key.
|
|
785
|
+
* @returns Nested value at `path`, or `undefined` if any segment is missing.
|
|
786
|
+
*/
|
|
455
787
|
function pickNested(value, path) {
|
|
456
788
|
let current = value;
|
|
457
789
|
for (const segment of path) {
|
|
@@ -465,6 +797,13 @@ function pickNested(value, path) {
|
|
|
465
797
|
return current;
|
|
466
798
|
}
|
|
467
799
|
// --- Helpers ---------------------------------------------------------------
|
|
800
|
+
/**
|
|
801
|
+
* Extract the capability id from a `/capabilities/{id}` request URL.
|
|
802
|
+
*
|
|
803
|
+
* @param url - Absolute or relative request URL.
|
|
804
|
+
* @returns Decoded capability id path segment.
|
|
805
|
+
* @throws {EnforcementError} When the path has no `/capabilities/` marker.
|
|
806
|
+
*/
|
|
468
807
|
function capabilityIdFromUrl(url) {
|
|
469
808
|
const { pathname } = new URL(url);
|
|
470
809
|
const marker = "/capabilities/";
|
|
@@ -474,6 +813,13 @@ function capabilityIdFromUrl(url) {
|
|
|
474
813
|
}
|
|
475
814
|
return decodeURIComponent(pathname.slice(index + marker.length));
|
|
476
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* Parse the request body as a JSON object.
|
|
818
|
+
*
|
|
819
|
+
* @param request - Incoming HTTP request.
|
|
820
|
+
* @returns Parsed object body.
|
|
821
|
+
* @throws {EnforcementError} When the body is missing, non-JSON, or not an object.
|
|
822
|
+
*/
|
|
477
823
|
async function parseJsonBody(request) {
|
|
478
824
|
try {
|
|
479
825
|
const parsed = await request.json();
|
|
@@ -486,6 +832,14 @@ async function parseJsonBody(request) {
|
|
|
486
832
|
}
|
|
487
833
|
throw new EnforcementError(400, "request body must be a JSON object");
|
|
488
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* Read a required non-empty string field from the body.
|
|
837
|
+
*
|
|
838
|
+
* @param body - Parsed JSON request body.
|
|
839
|
+
* @param key - Exact field name to read.
|
|
840
|
+
* @returns Non-empty string value.
|
|
841
|
+
* @throws {EnforcementError} When the field is missing, non-string, or empty.
|
|
842
|
+
*/
|
|
489
843
|
function requireString(body, key) {
|
|
490
844
|
const value = body[key];
|
|
491
845
|
if (typeof value !== "string" || value.length === 0) {
|
|
@@ -493,7 +847,15 @@ function requireString(body, key) {
|
|
|
493
847
|
}
|
|
494
848
|
return value;
|
|
495
849
|
}
|
|
496
|
-
/**
|
|
850
|
+
/**
|
|
851
|
+
* Read a required string from the body, accepting either camelCase or snake_case.
|
|
852
|
+
*
|
|
853
|
+
* @param body - Parsed JSON request body.
|
|
854
|
+
* @param camel - Preferred camelCase field name (used in error messages).
|
|
855
|
+
* @param snake - Alternate snake_case field name.
|
|
856
|
+
* @returns Non-empty string from either key.
|
|
857
|
+
* @throws {EnforcementError} When neither key yields a non-empty string.
|
|
858
|
+
*/
|
|
497
859
|
export function bodyString(body, camel, snake) {
|
|
498
860
|
const value = optionalBodyString(body, camel, snake);
|
|
499
861
|
if (value === undefined) {
|
|
@@ -501,6 +863,14 @@ export function bodyString(body, camel, snake) {
|
|
|
501
863
|
}
|
|
502
864
|
return value;
|
|
503
865
|
}
|
|
866
|
+
/**
|
|
867
|
+
* Read an optional string from the body, accepting either camelCase or snake_case.
|
|
868
|
+
*
|
|
869
|
+
* @param body - Parsed JSON request body.
|
|
870
|
+
* @param camel - Preferred camelCase field name.
|
|
871
|
+
* @param snake - Alternate snake_case field name.
|
|
872
|
+
* @returns Non-empty string from either key, or `undefined` when both are absent.
|
|
873
|
+
*/
|
|
504
874
|
function optionalBodyString(body, camel, snake) {
|
|
505
875
|
const camelValue = body[camel];
|
|
506
876
|
if (typeof camelValue === "string" && camelValue.length > 0) {
|
|
@@ -512,6 +882,14 @@ function optionalBodyString(body, camel, snake) {
|
|
|
512
882
|
}
|
|
513
883
|
return undefined;
|
|
514
884
|
}
|
|
885
|
+
/**
|
|
886
|
+
* Read a required plain-object field from the body.
|
|
887
|
+
*
|
|
888
|
+
* @param body - Parsed JSON request body.
|
|
889
|
+
* @param key - Field name to read.
|
|
890
|
+
* @returns Object value (not an array).
|
|
891
|
+
* @throws {EnforcementError} When the field is missing, not an object, or an array.
|
|
892
|
+
*/
|
|
515
893
|
function requireObject(body, key) {
|
|
516
894
|
const value = body[key];
|
|
517
895
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -519,11 +897,28 @@ function requireObject(body, key) {
|
|
|
519
897
|
}
|
|
520
898
|
return value;
|
|
521
899
|
}
|
|
900
|
+
/**
|
|
901
|
+
* Enforce an optional allowlist for a string value.
|
|
902
|
+
*
|
|
903
|
+
* @param allowed - Allowed values, or `undefined` to skip the check.
|
|
904
|
+
* @param value - Candidate value.
|
|
905
|
+
* @param label - Field label used in the error message.
|
|
906
|
+
* @throws {EnforcementError} When `allowed` is set and does not include `value`.
|
|
907
|
+
*/
|
|
522
908
|
function assertAllowed(allowed, value, label) {
|
|
523
909
|
if (allowed && !allowed.includes(value)) {
|
|
524
910
|
throw new EnforcementError(400, `${label} "${value}" is not allowed`);
|
|
525
911
|
}
|
|
526
912
|
}
|
|
913
|
+
/**
|
|
914
|
+
* Read an optional string comment/note from the body and enforce a max length.
|
|
915
|
+
*
|
|
916
|
+
* @param body - Parsed JSON request body.
|
|
917
|
+
* @param maxChars - Maximum allowed length, or `undefined` for no limit.
|
|
918
|
+
* @param field - Body field name (defaults to `"comment"`).
|
|
919
|
+
* @returns Trimmed-present string, or `undefined` when the field is nullish.
|
|
920
|
+
* @throws {EnforcementError} When the field is non-string or exceeds `maxChars`.
|
|
921
|
+
*/
|
|
527
922
|
function optionalComment(body, maxChars, field = "comment") {
|
|
528
923
|
const value = body[field];
|
|
529
924
|
if (value === undefined || value === null) {
|
|
@@ -537,13 +932,25 @@ function optionalComment(body, maxChars, field = "comment") {
|
|
|
537
932
|
}
|
|
538
933
|
return value;
|
|
539
934
|
}
|
|
935
|
+
/**
|
|
936
|
+
* Build a JSON HTTP response with a fixed content type.
|
|
937
|
+
*
|
|
938
|
+
* @param status - HTTP status code.
|
|
939
|
+
* @param body - Response payload (coerced via {@link jsonSafe}).
|
|
940
|
+
* @returns `Response` with `application/json` body.
|
|
941
|
+
*/
|
|
540
942
|
function jsonResponse(status, body) {
|
|
541
943
|
return new Response(JSON.stringify(jsonSafe(body)), {
|
|
542
944
|
status,
|
|
543
945
|
headers: { "content-type": "application/json" },
|
|
544
946
|
});
|
|
545
947
|
}
|
|
546
|
-
/**
|
|
948
|
+
/**
|
|
949
|
+
* Coerce values LangSmith may return (Date, etc.) into JSON-serializable form.
|
|
950
|
+
*
|
|
951
|
+
* @param value - Arbitrary value from the SDK or engine.
|
|
952
|
+
* @returns JSON-safe clone (Dates → ISO strings; objects/arrays walked recursively).
|
|
953
|
+
*/
|
|
547
954
|
function jsonSafe(value) {
|
|
548
955
|
if (value === null || typeof value !== "object") {
|
|
549
956
|
return value;
|
|
@@ -560,6 +967,12 @@ function jsonSafe(value) {
|
|
|
560
967
|
}
|
|
561
968
|
return out;
|
|
562
969
|
}
|
|
970
|
+
/**
|
|
971
|
+
* Map an unknown thrown value to an HTTP status for the error response.
|
|
972
|
+
*
|
|
973
|
+
* @param error - Caught value that is not an {@link EnforcementError}.
|
|
974
|
+
* @returns Status in `400..599` when present on the error; otherwise `502`.
|
|
975
|
+
*/
|
|
563
976
|
function statusFromUnknownError(error) {
|
|
564
977
|
const status = error?.status;
|
|
565
978
|
if (typeof status === "number" && status >= 400 && status <= 599) {
|
|
@@ -567,13 +980,24 @@ function statusFromUnknownError(error) {
|
|
|
567
980
|
}
|
|
568
981
|
return 502;
|
|
569
982
|
}
|
|
983
|
+
/**
|
|
984
|
+
* Extract a human-readable message from an unknown thrown value.
|
|
985
|
+
*
|
|
986
|
+
* @param error - Caught value.
|
|
987
|
+
* @returns `Error.message` when available; otherwise `"internal error"`.
|
|
988
|
+
*/
|
|
570
989
|
function errorMessage(error) {
|
|
571
990
|
if (error instanceof Error) {
|
|
572
991
|
return error.message;
|
|
573
992
|
}
|
|
574
993
|
return "internal error";
|
|
575
994
|
}
|
|
576
|
-
/**
|
|
995
|
+
/**
|
|
996
|
+
* Coerce an SDK result (object | void | null) into a plain record for shaping.
|
|
997
|
+
*
|
|
998
|
+
* @param value - Raw LangSmith call result.
|
|
999
|
+
* @returns Shallow-copied object, or `{}` when `value` is not an object.
|
|
1000
|
+
*/
|
|
577
1001
|
function toRecord(value) {
|
|
578
1002
|
if (value && typeof value === "object") {
|
|
579
1003
|
return { ...value };
|
|
@@ -587,6 +1011,8 @@ function toRecord(value) {
|
|
|
587
1011
|
*
|
|
588
1012
|
* A connector "score" is numeric/boolean → LangSmith `score`, or categorical
|
|
589
1013
|
* (string/object) → LangSmith feedback `value`.
|
|
1014
|
+
*
|
|
1015
|
+
* @returns `LangSmithClient` adapter over the official SDK.
|
|
590
1016
|
*/
|
|
591
1017
|
export function defaultClient() {
|
|
592
1018
|
let client;
|
|
@@ -617,6 +1043,7 @@ export function defaultClient() {
|
|
|
617
1043
|
}
|
|
618
1044
|
return out;
|
|
619
1045
|
},
|
|
1046
|
+
readFeedback: ({ feedbackId }) => sdk().readFeedback(feedbackId),
|
|
620
1047
|
readRun: ({ runId }) => sdk().readRun(runId),
|
|
621
1048
|
shareRun: async ({ runId }) => ({ url: await sdk().shareRun(runId) }),
|
|
622
1049
|
createExample: ({ dataset, inputs, outputs, metadata }) => sdk().createExample({
|