agent-relay-server 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -98,7 +98,9 @@ import {
98
98
  setMessageReaction,
99
99
  ValidationError,
100
100
  } from "./db";
101
- import { cleanString } from "./validation";
101
+ import { cleanString, cleanStringArray, optionalEnum } from "./validation";
102
+ import { listManagedOrchestratorsForAgent } from "./orchestrator-lookup";
103
+ import { bytesToStream, readBodyBytes } from "./http-body";
102
104
  import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
103
105
  import {
104
106
  deleteConfig,
@@ -155,7 +157,7 @@ import { listHostDirectories } from "./agent-spawn";
155
157
  import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
156
158
  import type { ProviderConfig } from "../runner/src/adapter";
157
159
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
158
- import { isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
160
+ import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
159
161
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
160
162
  import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
161
163
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
@@ -263,44 +265,11 @@ type ParseBodyResult<T> =
263
265
 
264
266
  async function parseBody<T>(req: Request): Promise<ParseBodyResult<T>> {
265
267
  if (!req.body) return { ok: true, body: null };
266
-
267
- const reader = req.body.getReader();
268
- const chunks: Uint8Array[] = [];
269
- let totalBytes = 0;
270
-
271
- while (true) {
272
- const { done, value } = await reader.read();
273
- if (done) break;
274
- if (!value) continue;
275
-
276
- totalBytes += value.byteLength;
277
- if (totalBytes > MAX_BODY_BYTES) {
278
- try {
279
- await reader.cancel();
280
- } catch {
281
- // Ignore cancellation errors — we already know this request is too large.
282
- }
283
- return {
284
- ok: false,
285
- status: 413,
286
- error: `request body exceeds ${MAX_BODY_BYTES} bytes`,
287
- };
288
- }
289
-
290
- chunks.push(value);
291
- }
292
-
293
- if (totalBytes === 0) return { ok: true, body: null };
294
-
295
- const merged = new Uint8Array(totalBytes);
296
- let offset = 0;
297
- for (const chunk of chunks) {
298
- merged.set(chunk, offset);
299
- offset += chunk.byteLength;
300
- }
301
-
268
+ const read = await readBodyBytes(req.body, MAX_BODY_BYTES);
269
+ if (!read.ok) return { ok: false, status: read.status, error: read.error };
270
+ if (read.bytes.byteLength === 0) return { ok: true, body: null };
302
271
  try {
303
- const decoded = new TextDecoder().decode(merged);
272
+ const decoded = new TextDecoder().decode(read.bytes);
304
273
  return { ok: true, body: JSON.parse(decoded) as T };
305
274
  } catch {
306
275
  return { ok: false, status: 400, error: "invalid JSON body" };
@@ -359,14 +328,6 @@ function cleanNullableString(value: unknown, field: string, max: number): string
359
328
  return cleanString(value, field, { max }) ?? null;
360
329
  }
361
330
 
362
- function cleanStringArray(value: unknown, field: string): string[] | undefined {
363
- if (value === undefined || value === null) return undefined;
364
- if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
365
- const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: 80 })).filter(Boolean) as string[];
366
- if (cleaned.length > 50) throw new ValidationError(`${field} can contain at most 50 values`);
367
- return [...new Set(cleaned)];
368
- }
369
-
370
331
  function cleanConstraintStringArray(value: unknown, field: string): string[] | undefined {
371
332
  if (value === undefined || value === null) return undefined;
372
333
  if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
@@ -422,33 +383,20 @@ function cleanParams(value: unknown, field = "params"): Record<string, unknown>
422
383
  return value;
423
384
  }
424
385
 
425
- function cleanEnum<T extends readonly string[]>(
426
- value: unknown,
427
- field: string,
428
- valid: T,
429
- fallback?: T[number],
430
- ): T[number] | undefined {
431
- if (value === undefined || value === null) return fallback;
432
- if (typeof value !== "string" || !valid.includes(value)) {
433
- throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
434
- }
435
- return value as T[number];
436
- }
437
-
438
386
  function cleanWorkspaceMetadata(value: unknown, field: string): WorkspaceMetadata | undefined {
439
387
  if (value === undefined || value === null) return undefined;
440
388
  if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
441
389
  return {
442
390
  id: cleanString(value.id, `${field}.id`, { max: 160 }),
443
- mode: cleanEnum(value.mode, `${field}.mode`, VALID_WORKSPACE_MODES, "shared") as WorkspaceMode,
444
- requestedMode: cleanEnum(value.requestedMode, `${field}.requestedMode`, VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
391
+ mode: optionalEnum(value.mode, `${field}.mode`, VALID_WORKSPACE_MODES, "shared") as WorkspaceMode,
392
+ requestedMode: optionalEnum(value.requestedMode, `${field}.requestedMode`, VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
445
393
  repoRoot: cleanString(value.repoRoot, `${field}.repoRoot`, { max: 1000 }),
446
394
  sourceCwd: cleanString(value.sourceCwd, `${field}.sourceCwd`, { max: 1000 }),
447
395
  worktreePath: cleanString(value.worktreePath, `${field}.worktreePath`, { max: 1000 }),
448
396
  branch: cleanString(value.branch, `${field}.branch`, { max: 240 }),
449
397
  baseRef: cleanString(value.baseRef, `${field}.baseRef`, { max: 240 }),
450
398
  baseSha: cleanString(value.baseSha, `${field}.baseSha`, { max: 80 }),
451
- status: cleanEnum(value.status, `${field}.status`, VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined,
399
+ status: optionalEnum(value.status, `${field}.status`, VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined,
452
400
  stewardAgentId: cleanString(value.stewardAgentId, `${field}.stewardAgentId`, { max: 240 }),
453
401
  probe: isRecord(value.probe) ? value.probe as unknown as WorkspaceProbe : undefined,
454
402
  };
@@ -522,16 +470,16 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
522
470
  const input: RegisterAgentInput = {
523
471
  id: cleanString(body.id, "id", { required: true, max: 200 })!,
524
472
  name: cleanString(body.name, "name", { required: true, max: 200 })!,
525
- kind: cleanEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
473
+ kind: optionalEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
526
474
  status: status as RegisterAgentInput["status"] | undefined,
527
475
  ready: body.ready as boolean | undefined,
528
476
  };
529
477
 
530
478
  const label = cleanNullableString(body.label, "label", 120);
531
479
  if (label !== undefined) input.label = label;
532
- const tags = cleanStringArray(body.tags, "tags");
480
+ const tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
533
481
  if (tags) input.tags = tags;
534
- const capabilities = cleanStringArray(body.capabilities, "capabilities");
482
+ const capabilities = cleanStringArray(body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 });
535
483
  if (capabilities) input.capabilities = capabilities;
536
484
  const machine = cleanString(body.machine, "machine", { max: 120 });
537
485
  if (machine) input.machine = machine;
@@ -630,14 +578,14 @@ function normalizeChannelBindingInput(body: unknown): {
630
578
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
631
579
  const channelId = cleanString(body.channelId, "channelId", { required: true, max: 200 })!;
632
580
  const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 });
633
- const mode = cleanEnum(body.mode, "mode", VALID_CHANNEL_BINDING_MODES, "exclusive") as ChannelBindingMode | undefined;
581
+ const mode = optionalEnum(body.mode, "mode", VALID_CHANNEL_BINDING_MODES, "exclusive") as ChannelBindingMode | undefined;
634
582
  const priority = typeof body.priority === "number" && Number.isSafeInteger(body.priority) ? body.priority : undefined;
635
583
 
636
584
  let target: ChannelRouteTarget | undefined;
637
585
  if (typeof body.target === "string") {
638
586
  target = routeTargetFromAddress(body.target);
639
587
  } else if (isRecord(body.target)) {
640
- const type = cleanEnum(body.target.type, "target.type", VALID_CHANNEL_BINDING_TARGET_TYPES)!;
588
+ const type = optionalEnum(body.target.type, "target.type", VALID_CHANNEL_BINDING_TARGET_TYPES)!;
641
589
  const id = type === "broadcast" ? undefined : cleanString(body.target.id, "target.id", { required: true, max: 240 })!;
642
590
  if (type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
643
591
  target = type === "broadcast" ? { type: "broadcast" } : { type, id } as ChannelRouteTarget;
@@ -758,8 +706,8 @@ function validateChannelAttachmentItem(item: unknown, field: string): void {
758
706
  if (!isRecord(item)) throw new ValidationError(`${field} must be an object`);
759
707
  const artifactId = cleanString(item.artifactId, `${field}.artifactId`, { max: 120 });
760
708
  if (artifactId) {
761
- cleanEnum(item.kind, `${field}.kind`, VALID_ARTIFACT_KINDS);
762
- cleanEnum(item.role, `${field}.role`, VALID_ARTIFACT_ROLES);
709
+ optionalEnum(item.kind, `${field}.kind`, VALID_ARTIFACT_KINDS);
710
+ optionalEnum(item.role, `${field}.role`, VALID_ARTIFACT_ROLES);
763
711
  cleanString(item.title, `${field}.title`, { max: 240 });
764
712
  if (item.ref !== undefined) validateChannelAttachmentSourceRef(item.ref, `${field}.ref`);
765
713
  return;
@@ -794,8 +742,8 @@ function cleanAttachmentRefs(value: unknown, field = "attachments"): AttachmentR
794
742
  const normalizedRef = isRecord(ref) ? { ref: { ...ref } as AttachmentRef["ref"] } : {};
795
743
  return {
796
744
  artifactId: cleanString(item.artifactId, `${field}[${index}].artifactId`, { required: true, max: 120 })!,
797
- kind: cleanEnum(item.kind, `${field}[${index}].kind`, VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
798
- role: cleanEnum(item.role, `${field}[${index}].role`, VALID_ARTIFACT_ROLES) as "media" | "patch" | "report" | "log" | "output" | "input" | undefined,
745
+ kind: optionalEnum(item.kind, `${field}[${index}].kind`, VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
746
+ role: optionalEnum(item.role, `${field}[${index}].role`, VALID_ARTIFACT_ROLES) as "media" | "patch" | "report" | "log" | "output" | "input" | undefined,
799
747
  title: cleanString(item.title, `${field}[${index}].title`, { max: 240 }),
800
748
  ...normalizedRef,
801
749
  ...(isRecord(item.metadata) ? { metadata: item.metadata } : {}),
@@ -803,14 +751,6 @@ function cleanAttachmentRefs(value: unknown, field = "attachments"): AttachmentR
803
751
  });
804
752
  }
805
753
 
806
- function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
807
- return new ReadableStream<Uint8Array>({
808
- start(controller) {
809
- controller.enqueue(bytes);
810
- controller.close();
811
- },
812
- });
813
- }
814
754
 
815
755
  function normalizeChannelEventBody(body: unknown): {
816
756
  body: string;
@@ -844,11 +784,11 @@ function requireChannelSession(req: Request, body: unknown, channel: ChannelSumm
844
784
 
845
785
  function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
846
786
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
847
- const status = cleanEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
787
+ const status = optionalEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
848
788
  return {
849
789
  source: cleanString(body.source, "source", { max: 120 }),
850
790
  type: cleanString(body.type, "type", { max: 80 }) ?? "event",
851
- severity: cleanEnum(body.severity, "severity", VALID_TASK_SEVERITIES, "info"),
791
+ severity: optionalEnum(body.severity, "severity", VALID_TASK_SEVERITIES, "info"),
852
792
  status,
853
793
  title: cleanString(body.title, "title", { required: true, max: 240 })!,
854
794
  body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
@@ -886,13 +826,13 @@ function normalizeIntegrationRegistryInput(name: string, body: unknown): {
886
826
  displayName: cleanString(body.displayName, "displayName", { max: 120 }),
887
827
  description: cleanString(body.description, "description", { max: 1000 }),
888
828
  enabled: body.enabled as boolean | undefined,
889
- scopes: cleanStringArray(body.scopes, "scopes"),
890
- targets: cleanStringArray(body.targets, "targets"),
891
- channels: cleanStringArray(body.channels, "channels"),
829
+ scopes: cleanStringArray(body.scopes, "scopes", { itemMax: 80, maxItems: 50 }),
830
+ targets: cleanStringArray(body.targets, "targets", { itemMax: 80, maxItems: 50 }),
831
+ channels: cleanStringArray(body.channels, "channels", { itemMax: 80, maxItems: 50 }),
892
832
  type: cleanString(body.type, "type", { max: 80 }),
893
833
  icon: cleanString(body.icon, "icon", { max: 80 }),
894
834
  accentColor: cleanString(body.accentColor, "accentColor", { max: 32 }),
895
- tags: cleanStringArray(body.tags, "tags"),
835
+ tags: cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 }),
896
836
  homepageUrl: cleanString(body.homepageUrl, "homepageUrl", { max: 500 }),
897
837
  repositoryUrl: cleanString(body.repositoryUrl, "repositoryUrl", { max: 500 }),
898
838
  docsUrl: cleanString(body.docsUrl, "docsUrl", { max: 500 }),
@@ -950,7 +890,7 @@ function resolveIntegrationEventTarget(input: IntegrationEventInput): Integratio
950
890
 
951
891
  function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
952
892
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
953
- const status = cleanEnum(body.status, "status", VALID_TASK_STATUSES);
893
+ const status = optionalEnum(body.status, "status", VALID_TASK_STATUSES);
954
894
  if (!status) throw new ValidationError("status required");
955
895
  return {
956
896
  status: status as TaskStatus,
@@ -1086,7 +1026,7 @@ function normalizeActivityInput(body: unknown): ActivityEventInput {
1086
1026
  return {
1087
1027
  operatorId: cleanOperatorId(body.operatorId),
1088
1028
  clientId: cleanString(body.clientId, "clientId", { max: 240 }),
1089
- kind: cleanEnum(body.kind, "kind", VALID_ACTIVITY_KINDS, "operator") as ActivityKind,
1029
+ kind: optionalEnum(body.kind, "kind", VALID_ACTIVITY_KINDS, "operator") as ActivityKind,
1090
1030
  title: cleanString(body.title, "title", { required: true, max: 200 })!,
1091
1031
  body: cleanString(body.body, "body", { max: 1000 }),
1092
1032
  meta: cleanString(body.meta, "meta", { max: 500 }),
@@ -1216,7 +1156,7 @@ async function postCallback(deliveryId: number, url: string, payload: unknown):
1216
1156
  });
1217
1157
  finishCallbackDelivery(deliveryId, response.ok, response.ok ? undefined : `${response.status} ${response.statusText}`);
1218
1158
  } catch (e) {
1219
- finishCallbackDelivery(deliveryId, false, e instanceof Error ? e.message : String(e));
1159
+ finishCallbackDelivery(deliveryId, false, errMessage(e));
1220
1160
  } finally {
1221
1161
  clearTimeout(timeout);
1222
1162
  }
@@ -1533,8 +1473,8 @@ async function artifactUploadInput(req: Request): Promise<{
1533
1473
  const file = form.get("file");
1534
1474
  if (!(file instanceof File)) throw new ValidationError("multipart upload requires file");
1535
1475
  if (file.size > maxBytes) throw new ValidationError(`artifact exceeds ${maxBytes} bytes`);
1536
- const sensitivity = cleanEnum(form.get("sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity;
1537
- const kind = cleanEnum(form.get("kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined;
1476
+ const sensitivity = optionalEnum(form.get("sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity;
1477
+ const kind = optionalEnum(form.get("kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined;
1538
1478
  const expiresAtRaw = form.get("expiresAt");
1539
1479
  const expiresAt = typeof expiresAtRaw === "string" && expiresAtRaw.trim() ? Number(expiresAtRaw) : undefined;
1540
1480
  if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now())) throw new ValidationError("expiresAt must be a future unix timestamp in milliseconds");
@@ -1562,8 +1502,8 @@ async function artifactUploadInput(req: Request): Promise<{
1562
1502
  filename: cleanString(req.headers.get("X-Artifact-Filename"), "filename", { max: 240 }),
1563
1503
  mediaType: contentType || undefined,
1564
1504
  digest: cleanString(req.headers.get("X-Artifact-Digest"), "digest", { max: 80 }),
1565
- sensitivity: cleanEnum(req.headers.get("X-Artifact-Sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity,
1566
- kind: cleanEnum(req.headers.get("X-Artifact-Kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
1505
+ sensitivity: optionalEnum(req.headers.get("X-Artifact-Sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity,
1506
+ kind: optionalEnum(req.headers.get("X-Artifact-Kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
1567
1507
  metadata: undefined,
1568
1508
  expiresAt,
1569
1509
  };
@@ -1576,10 +1516,10 @@ function cleanMemoryQueryFromParams(params: URLSearchParams): MemoryQuery {
1576
1516
  const visibility = params.get("visibility");
1577
1517
  const minRelevance = params.get("minRelevance");
1578
1518
  const limit = params.get("limit");
1579
- if (type) query.type = cleanEnum(type, "type", VALID_MEMORY_TYPES) as MemoryType;
1519
+ if (type) query.type = optionalEnum(type, "type", VALID_MEMORY_TYPES) as MemoryType;
1580
1520
  if (scope) query.scope = cleanString(scope, "scope", { max: 240 });
1581
1521
  const tags = [...params.getAll("tag"), ...(params.get("tags")?.split(",") ?? [])].map((tag) => tag.trim()).filter(Boolean);
1582
- if (tags.length) query.tags = cleanStringArray(tags, "tags");
1522
+ if (tags.length) query.tags = cleanStringArray(tags, "tags", { itemMax: 80, maxItems: 50 });
1583
1523
  if (minRelevance !== null) {
1584
1524
  const parsed = Number(minRelevance);
1585
1525
  if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) throw new ValidationError("minRelevance must be between 0 and 1");
@@ -1590,7 +1530,7 @@ function cleanMemoryQueryFromParams(params: URLSearchParams): MemoryQuery {
1590
1530
  if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > 100) throw new ValidationError("limit must be an integer between 1 and 100");
1591
1531
  query.limit = parsed;
1592
1532
  }
1593
- if (visibility) query.visibility = cleanEnum(visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility;
1533
+ if (visibility) query.visibility = optionalEnum(visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility;
1594
1534
  query.includeExpired = params.get("includeExpired") === "true" ? true : undefined;
1595
1535
  query.includeSensitive = params.get("includeSensitive") === "true" ? true : undefined;
1596
1536
  return query;
@@ -1599,9 +1539,9 @@ function cleanMemoryQueryFromParams(params: URLSearchParams): MemoryQuery {
1599
1539
  function cleanMemoryQueryFromBody(body: unknown): MemoryQuery {
1600
1540
  if (!isRecord(body)) throw new ValidationError("memory query body must be an object");
1601
1541
  const query: MemoryQuery = {};
1602
- query.type = cleanEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType | undefined;
1542
+ query.type = optionalEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType | undefined;
1603
1543
  query.scope = cleanString(body.scope, "scope", { max: 240 });
1604
- query.tags = cleanStringArray(body.tags, "tags");
1544
+ query.tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
1605
1545
  if (body.minRelevance !== undefined) {
1606
1546
  if (typeof body.minRelevance !== "number" || body.minRelevance < 0 || body.minRelevance > 1) throw new ValidationError("minRelevance must be between 0 and 1");
1607
1547
  query.minRelevance = body.minRelevance;
@@ -1611,7 +1551,7 @@ function cleanMemoryQueryFromBody(body: unknown): MemoryQuery {
1611
1551
  query.limit = body.limit;
1612
1552
  }
1613
1553
  query.includeExpired = typeof body.includeExpired === "boolean" ? body.includeExpired : undefined;
1614
- query.visibility = cleanEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
1554
+ query.visibility = optionalEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
1615
1555
  query.includeSensitive = typeof body.includeSensitive === "boolean" ? body.includeSensitive : undefined;
1616
1556
  return query;
1617
1557
  }
@@ -1633,15 +1573,15 @@ function cleanCreateMemoryInput(body: unknown, ctx: MemoryBrokerContext): Create
1633
1573
  ? undefined
1634
1574
  : cleanMemoryPositiveInteger(body.ttlMs, "ttlMs");
1635
1575
  return {
1636
- type: cleanEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType,
1576
+ type: optionalEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType,
1637
1577
  scope: cleanString(body.scope, "scope", { required: true, max: 240 })!,
1638
1578
  title: cleanString(body.title, "title", { required: true, max: 240 })!,
1639
1579
  content: cleanString(body.content, "content", { required: true, max: MAX_BODY_BYTES })!,
1640
- tags: cleanStringArray(body.tags, "tags"),
1641
- visibility: cleanEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined,
1642
- sensitivity: cleanEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined,
1643
- confidence: cleanEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined,
1644
- redactionState: cleanEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined,
1580
+ tags: cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 }),
1581
+ visibility: optionalEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined,
1582
+ sensitivity: optionalEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined,
1583
+ confidence: optionalEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined,
1584
+ redactionState: optionalEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined,
1645
1585
  relevanceScore: cleanMemoryScore(body.relevanceScore, "relevanceScore"),
1646
1586
  sourceAgent: cleanString(body.sourceAgent, "sourceAgent", { max: 200 }),
1647
1587
  sourceTask: body.sourceTask === undefined ? undefined : cleanMemoryPositiveInteger(body.sourceTask, "sourceTask"),
@@ -1656,11 +1596,11 @@ function cleanUpdateMemoryInput(body: unknown): UpdateMemoryInput {
1656
1596
  const patch: UpdateMemoryInput = {};
1657
1597
  const title = cleanString(body.title, "title", { max: 240 });
1658
1598
  const content = cleanString(body.content, "content", { max: MAX_BODY_BYTES });
1659
- const tags = cleanStringArray(body.tags, "tags");
1660
- const visibility = cleanEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
1661
- const sensitivity = cleanEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined;
1662
- const confidence = cleanEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined;
1663
- const redactionState = cleanEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined;
1599
+ const tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
1600
+ const visibility = optionalEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
1601
+ const sensitivity = optionalEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined;
1602
+ const confidence = optionalEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined;
1603
+ const redactionState = optionalEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined;
1664
1604
  const relevanceScore = cleanMemoryScore(body.relevanceScore, "relevanceScore");
1665
1605
  const metadata = cleanParams(body.metadata, "metadata");
1666
1606
  if (title !== undefined) patch.title = title;
@@ -1689,7 +1629,7 @@ function cleanMemoryInjectInput(body: unknown): {
1689
1629
  } {
1690
1630
  if (!isRecord(body)) throw new ValidationError("memory injection body must be an object");
1691
1631
  const agentId = cleanString(body.agentId ?? body.target, "agentId", { required: true, max: 200 })!;
1692
- const memoryIds = cleanStringArray(body.memoryIds, "memoryIds") ?? [];
1632
+ const memoryIds = cleanStringArray(body.memoryIds, "memoryIds", { itemMax: 80, maxItems: 50 }) ?? [];
1693
1633
  const query = body.query === undefined ? undefined : cleanMemoryQueryFromBody(body.query);
1694
1634
  if (memoryIds.length === 0 && !query) throw new ValidationError("memoryIds or query required");
1695
1635
  return {
@@ -1711,8 +1651,8 @@ function cleanTaskRoutingHints(value: unknown): TaskRoutingHints | undefined {
1711
1651
  title: cleanString(value.title, "task.title", { max: 240 }),
1712
1652
  text: cleanString(value.text, "task.text", { max: 4000 }),
1713
1653
  scope: cleanString(value.scope, "task.scope", { max: 240 }),
1714
- tags: cleanStringArray(value.tags, "task.tags"),
1715
- capabilities: cleanStringArray(value.capabilities, "task.capabilities"),
1654
+ tags: cleanStringArray(value.tags, "task.tags", { itemMax: 80, maxItems: 50 }),
1655
+ capabilities: cleanStringArray(value.capabilities, "task.capabilities", { itemMax: 80, maxItems: 50 }),
1716
1656
  target: cleanString(value.target, "task.target", { max: 200 }),
1717
1657
  };
1718
1658
  }
@@ -2075,7 +2015,7 @@ const patchAgentTags: Handler = async (req, params) => {
2075
2015
  const parsed = await parseBody<unknown>(req);
2076
2016
  if (!parsed.ok) return error(parsed.error, parsed.status);
2077
2017
  try {
2078
- const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags") : undefined;
2018
+ const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) : undefined;
2079
2019
  if (!tags) return error("tags field required");
2080
2020
  const before = getAgent(params.id!);
2081
2021
  const agent = setTags(params.id!, tags);
@@ -2212,18 +2152,15 @@ function agentRuntimeProvider(agent: AgentCard): string | undefined {
2212
2152
 
2213
2153
  function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<typeof getOrchestrator>> | null {
2214
2154
  if (agent.meta?.runnerManaged !== true) return null;
2215
- const metaSessionName = typeof agent.meta.sessionName === "string" ? agent.meta.sessionName : "";
2216
- const metaTmuxSession = typeof agent.meta.tmuxSession === "string" ? agent.meta.tmuxSession : "";
2217
- const metaPolicyName = typeof agent.meta.policyName === "string" ? agent.meta.policyName : "";
2218
- const metaSpawnRequestId = typeof agent.meta.spawnRequestId === "string" ? agent.meta.spawnRequestId : "";
2155
+ const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
2219
2156
  const candidates = [
2220
- ...listOrchestrators().filter((orch) => orch.managedAgents.some((managed) =>
2221
- managed.agentId === agent.id ||
2222
- (!!metaSessionName && managed.sessionName === metaSessionName) ||
2223
- (!!metaTmuxSession && managed.tmuxSession === metaTmuxSession) ||
2224
- (!!metaPolicyName && managed.policyName === metaPolicyName) ||
2225
- (!!metaSpawnRequestId && managed.spawnRequestId === metaSpawnRequestId)
2226
- )),
2157
+ ...listManagedOrchestratorsForAgent({
2158
+ agentId: agent.id,
2159
+ sessionName: str(agent.meta.sessionName),
2160
+ tmuxSession: str(agent.meta.tmuxSession),
2161
+ policyName: str(agent.meta.policyName),
2162
+ spawnRequestId: str(agent.meta.spawnRequestId),
2163
+ }),
2227
2164
  ...(agent.machine ? [getOrchestrator(agent.machine)].filter((orch): orch is NonNullable<ReturnType<typeof getOrchestrator>> => Boolean(orch)) : []),
2228
2165
  ];
2229
2166
  return candidates.find((orch) => orch.status === "online") ?? null;
@@ -2346,7 +2283,7 @@ const postAgentAction: Handler = async (req, params) => {
2346
2283
  if (!parsed.ok) return error(parsed.error, parsed.status);
2347
2284
  try {
2348
2285
  if (!isRecord(parsed.body)) return error("action required");
2349
- const action = cleanEnum(parsed.body.action, "action", VALID_AGENT_ACTIONS);
2286
+ const action = optionalEnum(parsed.body.action, "action", VALID_AGENT_ACTIONS);
2350
2287
  if (!action) return error("action required");
2351
2288
  const agent = getAgent(params.id!);
2352
2289
  if (!agent) return error("agent not found", 404);
@@ -2462,7 +2399,7 @@ const postAgentPermissionDecision: Handler = async (req, params) => {
2462
2399
  try {
2463
2400
  if (!isRecord(parsed.body)) return error("permission decision required");
2464
2401
  const approvalId = cleanString(parsed.body.approvalId, "approvalId", { required: true, max: 240 })!;
2465
- const decision = cleanEnum(parsed.body.decision, "decision", ["approve", "approve-session", "deny", "abort", "answer"] as const);
2402
+ const decision = optionalEnum(parsed.body.decision, "decision", ["approve", "approve-session", "deny", "abort", "answer"] as const);
2466
2403
  if (!decision) return error("decision required");
2467
2404
  const reason = cleanString(parsed.body.reason, "reason", { max: 500 });
2468
2405
  // AskUserQuestion answers: { "<question text>": "<chosen label(s)>" }
@@ -2591,13 +2528,13 @@ const postAgentSpawn: Handler = async (req) => {
2591
2528
  if (!parsed.ok) return error(parsed.error, parsed.status);
2592
2529
  try {
2593
2530
  if (!isRecord(parsed.body)) return error("provider required");
2594
- const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS);
2531
+ const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS);
2595
2532
  if (!provider) return error("provider required");
2596
2533
  const selection = cleanSpawnSelection(parsed.body, provider as SpawnProvider);
2597
- const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
2534
+ const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
2598
2535
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
2599
2536
  const label = cleanString(parsed.body.label, "label", { max: 120 });
2600
- const workspaceMode = cleanEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
2537
+ const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
2601
2538
 
2602
2539
  const orchestrators = listOrchestrators().filter(
2603
2540
  (o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
@@ -2794,7 +2731,7 @@ function cleanTokenProfileInput(body: unknown, partial = false) {
2794
2731
  const name = cleanString(body.name, "name", { required: !partial, max: 120 });
2795
2732
  const description = cleanString(body.description, "description", { max: 500 });
2796
2733
  const role = cleanString(body.role, "role", { required: !partial, max: 80 });
2797
- const scope = cleanStringArray(body.scope ?? body.scopes, "scope");
2734
+ const scope = cleanStringArray(body.scope ?? body.scopes, "scope", { itemMax: 80, maxItems: 50 });
2798
2735
  if (!partial && !scope?.length) throw new ValidationError("scope must contain at least one scope");
2799
2736
  const constraints = body.constraints === null ? undefined : cleanTokenConstraints(body.constraints);
2800
2737
  const ttlSeconds = cleanTtlSeconds(body.ttlSeconds);
@@ -2818,7 +2755,7 @@ const postToken: Handler = async (req) => {
2818
2755
  const role = cleanString(parsed.body.role, "role", { max: 80 }) ?? profile?.role;
2819
2756
  if (!role) return error("role required");
2820
2757
  const sub = cleanString(parsed.body.sub, "sub", { max: 160 }) ?? role;
2821
- const scope = cleanStringArray(parsed.body.scope ?? parsed.body.scopes, "scope");
2758
+ const scope = cleanStringArray(parsed.body.scope ?? parsed.body.scopes, "scope", { itemMax: 80, maxItems: 50 });
2822
2759
  const constraints = cleanTokenConstraints(parsed.body.constraints);
2823
2760
  const createdBy = cleanString(parsed.body.createdBy, "createdBy", { max: 120 });
2824
2761
  const ttlSeconds = cleanTtlSeconds(parsed.body.ttlSeconds);
@@ -2906,7 +2843,7 @@ const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
2906
2843
  if (!parsed.ok) return error(parsed.error, parsed.status);
2907
2844
  try {
2908
2845
  if (!isRecord(parsed.body)) return error("runtime token body required");
2909
- const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
2846
+ const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
2910
2847
  const cwd = cleanString(parsed.body.cwd, "cwd", { required: true, max: 500 })!;
2911
2848
  const runnerId = cleanString(parsed.body.runnerId, "runnerId", { required: true, max: 240 })!;
2912
2849
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
@@ -2953,9 +2890,9 @@ const postMcpRuntimeToken: Handler = async (req) => {
2953
2890
  if (!isRecord(parsed.body)) return error("runtime token body required");
2954
2891
  const sessionId = cleanString(parsed.body.sessionId, "sessionId", { required: true, max: 240 })!;
2955
2892
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
2956
- const targets = cleanStringArray(parsed.body.targets, "targets");
2957
- const channels = cleanStringArray(parsed.body.channels, "channels");
2958
- const memoryScopes = cleanStringArray(parsed.body.memoryScopes, "memoryScopes");
2893
+ const targets = cleanStringArray(parsed.body.targets, "targets", { itemMax: 80, maxItems: 50 });
2894
+ const channels = cleanStringArray(parsed.body.channels, "channels", { itemMax: 80, maxItems: 50 });
2895
+ const memoryScopes = cleanStringArray(parsed.body.memoryScopes, "memoryScopes", { itemMax: 80, maxItems: 50 });
2959
2896
  const runtimeToken = issueMcpRuntimeToken({
2960
2897
  sessionId,
2961
2898
  agentId,
@@ -2994,8 +2931,8 @@ const postIntegrationRuntimeToken: Handler = async (req) => {
2994
2931
  try {
2995
2932
  if (!isRecord(parsed.body)) return error("runtime token body required");
2996
2933
  const name = cleanString(parsed.body.name, "name", { required: true, max: 120 })!;
2997
- const targets = cleanStringArray(parsed.body.targets, "targets");
2998
- const channels = cleanStringArray(parsed.body.channels, "channels");
2934
+ const targets = cleanStringArray(parsed.body.targets, "targets", { itemMax: 80, maxItems: 50 });
2935
+ const channels = cleanStringArray(parsed.body.channels, "channels", { itemMax: 80, maxItems: 50 });
2999
2936
  const runtimeToken = issueIntegrationRuntimeToken({
3000
2937
  name,
3001
2938
  targets,
@@ -3097,7 +3034,7 @@ function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
3097
3034
 
3098
3035
  function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
3099
3036
  const model = cleanString(body.model, "model", { max: 120 });
3100
- const effort = cleanEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
3037
+ const effort = optionalEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
3101
3038
  return resolveSpawnModelParams(provider, model, effort);
3102
3039
  }
3103
3040
 
@@ -3162,7 +3099,7 @@ const postOrchestrator: Handler = async (req) => {
3162
3099
  const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
3163
3100
  const hostname = cleanString(parsed.body.hostname, "hostname", { required: true, max: 120 })!;
3164
3101
  const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
3165
- const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
3102
+ const providers = cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
3166
3103
  if (providers) {
3167
3104
  for (const p of providers) {
3168
3105
  if (!SPAWN_PROVIDERS.includes(p as any)) {
@@ -3170,7 +3107,7 @@ const postOrchestrator: Handler = async (req) => {
3170
3107
  }
3171
3108
  }
3172
3109
  }
3173
- const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
3110
+ const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys", { itemMax: 80, maxItems: 50 });
3174
3111
  const apiUrl = cleanString(parsed.body.apiUrl, "apiUrl", { max: 500 });
3175
3112
  const packageMetadata = cleanRuntimePackageMetadata(parsed.body.package);
3176
3113
  const contracts = cleanRuntimeContracts(parsed.body.contracts);
@@ -3243,7 +3180,7 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
3243
3180
  version: cleanString(body.version, "version", { max: 80 }),
3244
3181
  protocolVersion: cleanProtocolVersion(body.protocolVersion),
3245
3182
  gitSha: cleanString(body.gitSha, "gitSha", { max: 80 }),
3246
- providers: cleanStringArray(body.providers, "providers") as SpawnProvider[] | undefined,
3183
+ providers: cleanStringArray(body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined,
3247
3184
  providerStatus: cleanJsonArray(body.providerStatus, "providerStatus") as OrchestratorRuntimeInput["providerStatus"],
3248
3185
  providerCatalog: cleanJsonArray(body.providerCatalog, "providerCatalog") as OrchestratorRuntimeInput["providerCatalog"],
3249
3186
  };
@@ -3269,12 +3206,12 @@ const postOrchestratorBootstrap: Handler = async (req) => {
3269
3206
  if (!isRecord(parsed.body)) return error("body required");
3270
3207
  const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
3271
3208
  const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
3272
- const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
3209
+ const providers = cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
3273
3210
  const apiPort = cleanSafeNumber(parsed.body.apiPort) ?? 4860;
3274
3211
  if (!Number.isInteger(apiPort) || apiPort < 1 || apiPort > 65535) throw new ValidationError("apiPort must be an integer from 1 to 65535");
3275
3212
  const relayUrl = cleanString(parsed.body.relayUrl, "relayUrl", { max: 500 }) ?? new URL(req.url).origin;
3276
3213
  const version = cleanString(parsed.body.version, "version", { max: 80 }) ?? VERSION;
3277
- const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix");
3214
+ const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix", { itemMax: 80, maxItems: 50 });
3278
3215
  const providerList = providers?.length ? providers : ["claude", "codex"];
3279
3216
  for (const p of providerList) {
3280
3217
  if (!SPAWN_PROVIDERS.includes(p as any)) {
@@ -3369,19 +3306,19 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3369
3306
  ?? cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!;
3370
3307
  return {
3371
3308
  agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
3372
- provider: cleanEnum(a.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider,
3309
+ provider: optionalEnum(a.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider,
3373
3310
  sessionName,
3374
3311
  tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { max: 240 }) ?? sessionName,
3375
- supervisor: cleanEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
3312
+ supervisor: optionalEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
3376
3313
  systemdUnit: cleanString(a.systemdUnit, "systemdUnit", { max: 240 }),
3377
3314
  terminalSession: cleanString(a.terminalSession, "terminalSession", { max: 240 }),
3378
3315
  terminalAvailable: typeof a.terminalAvailable === "boolean" ? a.terminalAvailable : undefined,
3379
3316
  cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
3380
3317
  profile: cleanString(a.profile, "profile", { max: 120 }),
3381
- workspaceMode: cleanEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
3318
+ workspaceMode: optionalEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
3382
3319
  workspace: cleanWorkspaceMetadata(a.workspace, "workspace"),
3383
3320
  label: cleanString(a.label, "label", { max: 120 }),
3384
- approvalMode: (cleanEnum(a.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") ?? "guarded") as SpawnApprovalMode,
3321
+ approvalMode: (optionalEnum(a.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") ?? "guarded") as SpawnApprovalMode,
3385
3322
  policyName: cleanString(a.policyName, "policyName", { max: 120 }),
3386
3323
  spawnRequestId: cleanString(a.spawnRequestId, "spawnRequestId", { max: 160 }),
3387
3324
  automationRunId: cleanString(a.automationRunId, "automationRunId", { max: 160 }),
@@ -3446,7 +3383,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3446
3383
 
3447
3384
  function cleanManagedSessionExitDiagnostics(value: unknown, index: number): ManagedSessionExitDiagnostics {
3448
3385
  if (!isRecord(value)) throw new ValidationError(`exitedAgents[${index}] must be an object`);
3449
- const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`, SPAWN_PROVIDERS);
3386
+ const provider = optionalEnum(value.provider, `exitedAgents[${index}].provider`, SPAWN_PROVIDERS);
3450
3387
  if (!provider) throw new ValidationError(`exitedAgents[${index}].provider required`);
3451
3388
  const systemd = isRecord(value.systemd)
3452
3389
  ? {
@@ -3475,7 +3412,7 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
3475
3412
  const diagnostics: ManagedSessionExitDiagnostics = {
3476
3413
  agentId: cleanString(value.agentId, `exitedAgents[${index}].agentId`, { required: true, max: 240 })!,
3477
3414
  provider: provider as SpawnProvider,
3478
- workspaceMode: cleanEnum(value.workspaceMode, `exitedAgents[${index}].workspaceMode`, VALID_WORKSPACE_MODES),
3415
+ workspaceMode: optionalEnum(value.workspaceMode, `exitedAgents[${index}].workspaceMode`, VALID_WORKSPACE_MODES),
3479
3416
  workspace: cleanWorkspaceMetadata(value.workspace, `exitedAgents[${index}].workspace`),
3480
3417
  sessionName: cleanString(value.sessionName, `exitedAgents[${index}].sessionName`, { max: 240 }),
3481
3418
  tmuxSession: cleanString(value.tmuxSession, `exitedAgents[${index}].tmuxSession`, { required: true, max: 240 })!,
@@ -3484,7 +3421,7 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
3484
3421
  policyName: cleanString(value.policyName, `exitedAgents[${index}].policyName`, { max: 120 }),
3485
3422
  spawnRequestId: cleanString(value.spawnRequestId, `exitedAgents[${index}].spawnRequestId`, { max: 160 }),
3486
3423
  automationRunId: cleanString(value.automationRunId, `exitedAgents[${index}].automationRunId`, { max: 160 }),
3487
- supervisor: cleanEnum(value.supervisor, `exitedAgents[${index}].supervisor`, ["process", "systemd", "launchd", "unknown"] as const, "unknown")!,
3424
+ supervisor: optionalEnum(value.supervisor, `exitedAgents[${index}].supervisor`, ["process", "systemd", "launchd", "unknown"] as const, "unknown")!,
3488
3425
  systemdUnit: cleanString(value.systemdUnit, `exitedAgents[${index}].systemdUnit`, { max: 240 }),
3489
3426
  terminalSession: cleanString(value.terminalSession, `exitedAgents[${index}].terminalSession`, { max: 240 }),
3490
3427
  terminalAvailable: typeof value.terminalAvailable === "boolean" ? value.terminalAvailable : undefined,
@@ -3520,7 +3457,7 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3520
3457
  if (orch.status !== "online") return error("orchestrator is offline", 409);
3521
3458
 
3522
3459
  if (!isRecord(parsed.body)) return error("body required");
3523
- const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
3460
+ const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
3524
3461
  const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
3525
3462
  if (selection instanceof Response) return selection;
3526
3463
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
@@ -3528,14 +3465,14 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3528
3465
  return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
3529
3466
  }
3530
3467
  const label = cleanString(parsed.body.label, "label", { max: 120 });
3531
- const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
3468
+ const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
3532
3469
  const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
3533
3470
  const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
3534
- const tags = cleanStringArray(parsed.body.tags, "tags") ?? [];
3535
- const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities") ?? [];
3536
- const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs") ?? [];
3471
+ const tags = cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) ?? [];
3472
+ const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }) ?? [];
3473
+ const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }) ?? [];
3537
3474
  const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
3538
- const workspaceMode = cleanEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
3475
+ const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
3539
3476
  const agentProfile = profile ? getAgentProfile(profile)?.value : undefined;
3540
3477
  if (profile && !agentProfile) return error("agent profile not found", 404);
3541
3478
  const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
@@ -3709,7 +3646,7 @@ const postOrchestratorAction: Handler = async (req, params) => {
3709
3646
  if (!orch) return error("orchestrator not found", 404);
3710
3647
 
3711
3648
  if (!isRecord(parsed.body)) return error("body required");
3712
- const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
3649
+ const action = optionalEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
3713
3650
  if (!action) return error("action required");
3714
3651
 
3715
3652
  if (action === "upgrade") {
@@ -3718,7 +3655,7 @@ const postOrchestratorAction: Handler = async (req, params) => {
3718
3655
  const targetVersion = cleanString(parsed.body.targetVersion, "targetVersion", { max: 80 }) ?? VERSION;
3719
3656
  if (!ORCH_UPGRADE_SEMVER_RE.test(targetVersion)) return error("targetVersion must be a semver like 0.10.20");
3720
3657
  const force = parsed.body.force === true;
3721
- const providers = (cleanStringArray(parsed.body.providers, "providers") ?? ["orchestrator"])
3658
+ const providers = (cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) ?? ["orchestrator"])
3722
3659
  .filter((p) => (VALID_ORCH_UPGRADE_PROVIDERS as readonly string[]).includes(p));
3723
3660
  if (!providers.length) return error(`providers must be any of: ${VALID_ORCH_UPGRADE_PROVIDERS.join(", ")}`);
3724
3661
  const selfUpgradeError = orchestratorSelfUpgradeError(orch);
@@ -3848,7 +3785,7 @@ const getWorkspaces: Handler = (req) => {
3848
3785
  const url = new URL(req.url);
3849
3786
  const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
3850
3787
  const ownerAgentId = cleanString(url.searchParams.get("agentId") ?? undefined, "agentId", { max: 240 });
3851
- const status = cleanEnum(url.searchParams.get("status") ?? undefined, "status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
3788
+ const status = optionalEnum(url.searchParams.get("status") ?? undefined, "status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
3852
3789
  return json(listWorkspaces({ repoRoot, ownerAgentId, status }));
3853
3790
  } catch (e) {
3854
3791
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -4023,6 +3960,32 @@ function buildWorkspaceCleanupCommand(workspace: WorkspaceRecord, requestedBy: s
4023
3960
  return { ok: true, command };
4024
3961
  }
4025
3962
 
3963
+ // Build a `workspace.deps-refresh` command for a worktree's owning orchestrator
3964
+ // (issue #51). Re-provisions deps the shared symlinked node_modules has gone stale
3965
+ // on, or — checkOnly — just reports staleness. Same owner-resolution as cleanup.
3966
+ function buildWorkspaceDepsRefreshCommand(workspace: WorkspaceRecord, requestedBy: string, checkOnly: boolean): { ok: true; command: Command } | { ok: false; status: number; error: string } {
3967
+ const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
3968
+ const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
3969
+ if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path" };
3970
+ const command = createCommand({
3971
+ type: "workspace.deps-refresh",
3972
+ source: "system",
3973
+ target: owner.agentId,
3974
+ correlationId: workspace.id,
3975
+ params: {
3976
+ action: "deps-refresh",
3977
+ workspaceId: workspace.id,
3978
+ repoRoot: workspace.repoRoot,
3979
+ worktreePath: workspace.worktreePath,
3980
+ checkOnly,
3981
+ requestedBy,
3982
+ requestedAt: Date.now(),
3983
+ queued: owner.status !== "online",
3984
+ },
3985
+ });
3986
+ return { ok: true, command };
3987
+ }
3988
+
4026
3989
  // Fetch + parse a workspace's live git state from its owning host, or report why
4027
3990
  // it's unavailable. Thin typed wrapper over the same host route the proxy uses.
4028
3991
  async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
@@ -4161,7 +4124,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
4161
4124
  if (!isRecord(parsed.body)) return error("body required");
4162
4125
  const workspace = getWorkspace(params.id!);
4163
4126
  if (!workspace) return error("workspace not found", 404);
4164
- const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup", "claim", "release-claim"] as const);
4127
+ const action = optionalEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup", "claim", "release-claim", "deps-refresh"] as const);
4165
4128
  if (!action) return error("action required", 400);
4166
4129
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
4167
4130
  const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
@@ -4198,7 +4161,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
4198
4161
  // Base merges go through the shared helper (lease + command + bind), the same
4199
4162
  // path the auto-merge job uses, so both serialize per repo (issue #157).
4200
4163
  if (action === "merge") {
4201
- const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
4164
+ const strategy = optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
4202
4165
  const result = requestWorkspaceMerge(workspace, {
4203
4166
  requestedBy: agentId ?? "dashboard",
4204
4167
  strategy,
@@ -4222,6 +4185,31 @@ const postWorkspaceAction: Handler = async (req, params) => {
4222
4185
  });
4223
4186
  return json({ workspace: result.workspace, command: result.command }, 202);
4224
4187
  }
4188
+ // Deps refresh (#51): self-service for the owning agent — re-provision deps the
4189
+ // shared symlinked node_modules has gone stale on. Emits a host command but is
4190
+ // authorized at agent:write (it only touches the agent's own worktree), so the
4191
+ // agent can run it themselves the moment typecheck reports a missing module.
4192
+ if (action === "deps-refresh") {
4193
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) {
4194
+ return error(`workspace ${workspace.id} has no isolated worktree to refresh`, 422);
4195
+ }
4196
+ const checkOnly = parsed.body.checkOnly === true;
4197
+ const built = buildWorkspaceDepsRefreshCommand(workspace, agentId ?? "agent", checkOnly);
4198
+ if (!built.ok) return error(built.error, built.status);
4199
+ emitCommand(built.command);
4200
+ auditEvent({
4201
+ clientId: `workspace-deps-refresh-${workspace.id}-${Date.now()}`,
4202
+ kind: "state",
4203
+ title: checkOnly ? "Workspace deps check" : "Workspace deps refresh",
4204
+ body: detail ?? workspace.worktreePath,
4205
+ meta: workspace.branch ?? workspace.id,
4206
+ icon: "ti-package",
4207
+ view: "orchestrators",
4208
+ agentId,
4209
+ metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, checkOnly, commandId: built.command.id, ...authAuditMetadata(req) },
4210
+ });
4211
+ return json({ workspace, command: built.command }, 202);
4212
+ }
4225
4213
  const statusByAction: Record<string, WorkspaceStatus | undefined> = {
4226
4214
  status: undefined,
4227
4215
  ready: "ready",
@@ -4392,7 +4380,7 @@ const getOrchestratorProviders: Handler = async (req, params) => {
4392
4380
  const payload = await res.clone().json().catch(() => null) as unknown;
4393
4381
  if (!isRecord(payload)) return res;
4394
4382
  try {
4395
- const providers = cleanStringArray(payload.providers, "providers") as SpawnProvider[] | undefined;
4383
+ const providers = cleanStringArray(payload.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
4396
4384
  if (providers) {
4397
4385
  for (const p of providers) {
4398
4386
  if (!SPAWN_PROVIDERS.includes(p as any)) {
@@ -4536,7 +4524,7 @@ const patchCommand: Handler = async (req, params) => {
4536
4524
  if (!current) return error("command not found", 404);
4537
4525
  const denied = authorizeRoute(req, { scope: "command:write", resource: commandAuthorizationResource(current) });
4538
4526
  if (denied) return denied;
4539
- const status = cleanEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
4527
+ const status = optionalEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
4540
4528
  const result = cleanParams(parsed.body.result, "result");
4541
4529
  const err = cleanString(parsed.body.error, "error", { max: 4000 });
4542
4530
  const command = updateCommand(params.id!, { status: status as CommandStatus | undefined, result, error: err });
@@ -4574,12 +4562,17 @@ const patchCommand: Handler = async (req, params) => {
4574
4562
  }
4575
4563
  if (command.status === "succeeded" && isRecord(command.result)) {
4576
4564
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4577
- const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4565
+ const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4578
4566
  if (workspaceId && resultStatus) {
4579
4567
  updateWorkspaceStatus(workspaceId, resultStatus, {
4580
4568
  mergeResult: command.result,
4581
4569
  mergeCommandId: command.id,
4582
4570
  mergedAt: Date.now(),
4571
+ // The land consumes the steward's claim (#208 steward contract / vent
4572
+ // #62): a successful land must release it, or `steward inspect` keeps
4573
+ // showing a stale claim on the recycled workspace and blocks the next
4574
+ // automation. Clearing a null claim (auto-merge path) is a harmless no-op.
4575
+ ...claimMetadataPatch(true),
4583
4576
  });
4584
4577
  // Land-and-continue (#206): the worktree was recycled onto a fresh branch.
4585
4578
  // Repoint the row so the next merge targets the live branch, not the deleted one.
@@ -4602,7 +4595,7 @@ const patchCommand: Handler = async (req, params) => {
4602
4595
  }
4603
4596
  if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
4604
4597
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4605
- const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4598
+ const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4606
4599
  const removed = command.result.removed === true;
4607
4600
  if (workspaceId && resultStatus) {
4608
4601
  // Only act on workspaces the agent left in a live state; never overwrite
@@ -4635,6 +4628,14 @@ const patchCommand: Handler = async (req, params) => {
4635
4628
  }
4636
4629
  }
4637
4630
  }
4631
+ if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
4632
+ // Record the outcome on the row without touching status (#51) — observability
4633
+ // for the dashboard; the CLI reads the result straight off the command.
4634
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4635
+ if (workspaceId) {
4636
+ patchWorkspaceMetadata(workspaceId, { lastDepsRefresh: command.result, lastDepsRefreshCommandId: command.id, lastDepsRefreshAt: Date.now() });
4637
+ }
4638
+ }
4638
4639
  settleFailedOrchestratorUpgrade(command);
4639
4640
  emitCommand(command);
4640
4641
  auditCommandOutcome(command);
@@ -5101,13 +5102,13 @@ const getProviderConfigsRoute: Handler = () => {
5101
5102
  };
5102
5103
 
5103
5104
  const getProviderConfigRoute: Handler = (_req, params) => {
5104
- const provider = cleanEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5105
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5105
5106
  if (!provider) return error("provider required");
5106
5107
  return json(providerConfigPublic(loadProviderConfig(provider)));
5107
5108
  };
5108
5109
 
5109
5110
  const putProviderConfigRoute: Handler = async (req, params) => {
5110
- const provider = cleanEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5111
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5111
5112
  if (!provider) return error("provider required");
5112
5113
  const parsed = await parseBody<unknown>(req);
5113
5114
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -5117,13 +5118,13 @@ const putProviderConfigRoute: Handler = async (req, params) => {
5117
5118
  const headless = isRecord(parsed.body.headless) ? parsed.body.headless : {};
5118
5119
  const config: ProviderConfig = {
5119
5120
  command: cleanString(parsed.body.command, "command", { required: true, max: 500 })!,
5120
- defaultArgs: cleanStringArray(parsed.body.defaultArgs, "defaultArgs") ?? defaults.defaultArgs,
5121
+ defaultArgs: cleanStringArray(parsed.body.defaultArgs, "defaultArgs", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultArgs,
5121
5122
  env: cleanEnvRecord(parsed.body.env),
5122
- pluginDirs: cleanStringArray(parsed.body.pluginDirs, "pluginDirs") ?? defaults.pluginDirs,
5123
- defaultCapabilities: cleanStringArray(parsed.body.defaultCapabilities, "defaultCapabilities") ?? defaults.defaultCapabilities,
5123
+ pluginDirs: cleanStringArray(parsed.body.pluginDirs, "pluginDirs", { itemMax: 80, maxItems: 50 }) ?? defaults.pluginDirs,
5124
+ defaultCapabilities: cleanStringArray(parsed.body.defaultCapabilities, "defaultCapabilities", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultCapabilities,
5124
5125
  defaultApprovalMode: cleanString(parsed.body.defaultApprovalMode, "defaultApprovalMode", { max: 80 }) ?? defaults.defaultApprovalMode,
5125
- defaultTags: cleanStringArray(parsed.body.defaultTags, "defaultTags") ?? defaults.defaultTags,
5126
- chatCaptureMode: (cleanEnum(parsed.body.chatCaptureMode, "chatCaptureMode", ["final", "full"]) ?? defaults.chatCaptureMode) as "final" | "full",
5126
+ defaultTags: cleanStringArray(parsed.body.defaultTags, "defaultTags", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultTags,
5127
+ chatCaptureMode: (optionalEnum(parsed.body.chatCaptureMode, "chatCaptureMode", ["final", "full"]) ?? defaults.chatCaptureMode) as "final" | "full",
5127
5128
  headless: {
5128
5129
  tmuxPrefix: cleanString(headless.tmuxPrefix, "headless.tmuxPrefix", { max: 120 }) ?? defaults.headless.tmuxPrefix,
5129
5130
  shutdownTimeoutMs: cleanPositiveInt(headless.shutdownTimeoutMs, "headless.shutdownTimeoutMs") ?? defaults.headless.shutdownTimeoutMs,
@@ -5137,7 +5138,7 @@ const putProviderConfigRoute: Handler = async (req, params) => {
5137
5138
  };
5138
5139
 
5139
5140
  const postProviderConfigTestRoute: Handler = async (_req, params) => {
5140
- const provider = cleanEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5141
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5141
5142
  if (!provider) return error("provider required");
5142
5143
  const config = loadProviderConfig(provider);
5143
5144
  const proc = Bun.spawn(["bash", "-lc", `command -v "$1"`, "bash", config.command], {
@@ -5212,7 +5213,7 @@ const postMessageDeliveryAttempt: Handler = async (req, params) => {
5212
5213
  if (!parsed.ok) return error(parsed.error, parsed.status);
5213
5214
  try {
5214
5215
  if (!isRecord(parsed.body)) throw new ValidationError("body must be an object");
5215
- const status = cleanEnum(parsed.body.status, "status", VALID_DELIVERY_STATUSES);
5216
+ const status = optionalEnum(parsed.body.status, "status", VALID_DELIVERY_STATUSES);
5216
5217
  if (!status) throw new ValidationError("status required");
5217
5218
  const nextRetryAt = cleanPositiveId(parsed.body.nextRetryAt, "nextRetryAt");
5218
5219
  const result = recordMessageDeliveryAttempt(id, {
@@ -5238,7 +5239,7 @@ const postMessageDeliveryAction: Handler = async (req, params) => {
5238
5239
  if (!parsed.ok) return error(parsed.error, parsed.status);
5239
5240
  try {
5240
5241
  if (!isRecord(parsed.body)) throw new ValidationError("body must be an object");
5241
- const action = cleanEnum(parsed.body.action, "action", VALID_DELIVERY_ACTIONS);
5242
+ const action = optionalEnum(parsed.body.action, "action", VALID_DELIVERY_ACTIONS);
5242
5243
  if (!action) throw new ValidationError("action required");
5243
5244
  const result = applyMessageDeliveryAction(id, {
5244
5245
  action,
@@ -5287,7 +5288,7 @@ const postMessage: Handler = async (req) => {
5287
5288
  const memoryInjection = await injectMemoryForMessageDelivery(result.message, autoMemoryTarget, memoryContext(req));
5288
5289
  if (memoryInjection) emitCommand(memoryInjection.command);
5289
5290
  } catch (e) {
5290
- console.warn(`[memory] automatic message context assembly failed: ${e instanceof Error ? e.message : String(e)}`);
5291
+ console.warn(`[memory] automatic message context assembly failed: ${errMessage(e)}`);
5291
5292
  }
5292
5293
  }
5293
5294
  if (result.message.deliveryStatus === "queued") {
@@ -5791,7 +5792,7 @@ const postConnectorAction: Handler = async (req, params) => {
5791
5792
  if (!parsed.ok) return error(parsed.error, parsed.status);
5792
5793
  try {
5793
5794
  if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
5794
- const action = cleanEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
5795
+ const action = optionalEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
5795
5796
  const result = await runConnectorAction(params.id!, action);
5796
5797
  return json(result, result.ok ? 200 : 502);
5797
5798
  } catch (e) {
@@ -6271,7 +6272,7 @@ const postClaimTask: Handler = async (req, params) => {
6271
6272
  const memoryInjection = await injectMemoryForTaskClaim(result.task!, agentId);
6272
6273
  if (memoryInjection) emitCommand(memoryInjection.command);
6273
6274
  } catch (e) {
6274
- console.warn(`[memory] automatic task context assembly failed: ${e instanceof Error ? e.message : String(e)}`);
6275
+ console.warn(`[memory] automatic task context assembly failed: ${errMessage(e)}`);
6275
6276
  }
6276
6277
  void dispatchTaskCallbacks(id, "task.claimed");
6277
6278
  return json(result.task);