@mycadet/assistant 0.12.8 → 0.12.9

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.
Files changed (42) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/package.json +1 -1
  2. package/node_modules/@mycadet/ces-client/package.json +1 -1
  3. package/node_modules/@mycadet/ces-client/src/__tests__/ces-client.test.ts +14 -14
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/package.json +1 -1
  5. package/node_modules/@mycadet/service-contracts/package.json +1 -1
  6. package/openapi.yaml +2 -18
  7. package/package.json +2 -2
  8. package/src/__tests__/cloud-profile-guard.test.ts +21 -19
  9. package/src/__tests__/config-set-route.test.ts +12 -8
  10. package/src/__tests__/credential-health-service.test.ts +4 -2
  11. package/src/__tests__/get-skill-detail-audit.test.ts +134 -289
  12. package/src/__tests__/mcp-abort-signal.test.ts +0 -1
  13. package/src/__tests__/mcp-client-auth.test.ts +0 -1
  14. package/src/__tests__/mcp-config-secret-boundary.test.ts +44 -40
  15. package/src/__tests__/mcp-tool-annotations-risk.test.ts +0 -1
  16. package/src/__tests__/plugin-api-store-credential.test.ts +4 -1
  17. package/src/__tests__/skills-file-content-endpoint.test.ts +221 -535
  18. package/src/__tests__/skills-files-catalog-fallback.test.ts +258 -576
  19. package/src/api/events/show-cloud-login.ts +2 -2
  20. package/src/cli/commands/cloud/__tests__/callback-routes-list.test.ts +2 -2
  21. package/src/cli/commands/cloud/index.ts +1 -1
  22. package/src/cli/commands/config.ts +1 -1
  23. package/src/config/__tests__/substrate-twin-shadowing.test.ts +36 -26
  24. package/src/config/env-registry.ts +2 -9
  25. package/src/config/schemas/ingress.ts +30 -0
  26. package/src/config/schemas/llm.ts +0 -9
  27. package/src/config/seed-inference-profiles.ts +2 -2
  28. package/src/config/substrate-twin-shadowing.ts +12 -17
  29. package/src/config/sync-gated-profiles.ts +3 -4
  30. package/src/credential-execution/prompted-credential.ts +1 -10
  31. package/src/credential-health/credential-health-service.ts +8 -4
  32. package/src/daemon/credential-transcript-scrub.ts +11 -0
  33. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +5 -0
  34. package/src/providers/inference/backfill.ts +2 -2
  35. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +100 -57
  36. package/src/runtime/routes/cloud-routes.ts +5 -5
  37. package/src/runtime/routes/conversation-query-routes.ts +275 -397
  38. package/src/runtime/routes/secret-routes.ts +2 -17
  39. package/src/tools/credentials/store.ts +1 -10
  40. package/src/util/layout.ts +479 -0
  41. package/src/util/platform.ts +11 -467
  42. package/src/workspace/migrations/127-backfill-default-provider.ts +2 -2
@@ -37,7 +37,6 @@ import {
37
37
  } from "../../config/chat-settings.js";
38
38
  import {
39
39
  catalogEntryFor,
40
- type InputModalities,
41
40
  modalitiesOf,
42
41
  resolveModalityOverride,
43
42
  } from "../../config/input-modalities.js";
@@ -523,132 +522,23 @@ async function handleSetEmbeddingConfig({ body }: RouteHandlerArgs) {
523
522
  }
524
523
  }
525
524
 
526
- function readPlainObject(value: unknown): Record<string, unknown> | undefined {
527
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
528
- return undefined;
529
- }
530
- return value as Record<string, unknown>;
531
- }
532
-
533
- function stripTransportHeadersRecursively(value: unknown): void {
534
- if (Array.isArray(value)) {
535
- for (const item of value) {
536
- stripTransportHeadersRecursively(item);
537
- }
538
- return;
539
- }
540
-
541
- const object = readPlainObject(value);
542
- if (!object) {
543
- return;
544
- }
545
- const transport = readPlainObject(object.transport);
546
- if (transport) {
547
- delete transport.headers;
548
- }
549
- for (const child of Object.values(object)) {
550
- stripTransportHeadersRecursively(child);
551
- }
552
- }
553
-
554
- function containsTransportHeadersRecursively(value: unknown): boolean {
555
- if (Array.isArray(value)) {
556
- return value.some((item) => containsTransportHeadersRecursively(item));
557
- }
558
-
559
- const object = readPlainObject(value);
560
- if (!object) {
561
- return false;
562
- }
563
- const transport = readPlainObject(object.transport);
564
- if (transport && Object.hasOwn(transport, "headers")) {
565
- return true;
566
- }
567
- return Object.values(object).some((child) =>
568
- containsTransportHeadersRecursively(child),
569
- );
570
- }
571
-
572
- function sanitizeMcpTransportHeadersForSettingsRead(config: unknown): void {
573
- const root = readPlainObject(config);
574
- if (!root) {
575
- return;
576
- }
577
- const mcp = readPlainObject(root.mcp);
578
- if (!mcp || !Object.hasOwn(mcp, "servers")) {
579
- return;
580
- }
581
- if (Array.isArray(mcp.servers)) {
582
- stripTransportHeadersRecursively(mcp.servers);
583
- return;
584
- }
585
- const servers = readPlainObject(mcp.servers);
586
- if (!servers) {
587
- return;
588
- }
589
- for (const server of Object.values(servers)) {
590
- stripTransportHeadersRecursively(server);
591
- }
592
- }
593
-
594
- function patchContainsMcpTransportHeaders(patch: unknown): boolean {
595
- const root = readPlainObject(patch);
596
- const mcp = readPlainObject(root?.mcp);
597
- if (!mcp || !Object.hasOwn(mcp, "servers")) {
598
- return false;
599
- }
600
- if (Array.isArray(mcp.servers)) {
601
- return containsTransportHeadersRecursively(mcp.servers);
602
- }
603
- const servers = readPlainObject(mcp.servers);
604
- if (!servers) {
605
- return false;
606
- }
607
- return Object.values(servers).some((server) =>
608
- containsTransportHeadersRecursively(server),
609
- );
610
- }
611
-
612
- function rejectMcpTransportHeaderWrite(patch: unknown): void {
613
- if (!patchContainsMcpTransportHeaders(patch)) {
614
- return;
615
- }
616
- throw new BadRequestError(
617
- "MCP authentication headers must be managed through MCP server add/update APIs, not generic config writes.",
618
- );
619
- }
620
-
621
- const MCP_CONFIG_WRITE_MESSAGE =
622
- "MCP servers are stored in mcp.json. Use assistant mcp add or assistant mcp remove.";
623
-
624
- function stripMcpFromConfigWrite(patch: Record<string, unknown>): void {
625
- if (Object.hasOwn(patch, "mcp")) {
626
- delete patch.mcp;
627
- }
628
- }
629
-
630
- function rejectMcpConfigSetPath(path: string, value: unknown): void {
631
- if (path !== "mcp" && !path.startsWith("mcp.")) {
632
- return;
633
- }
634
- const relative =
635
- path === "mcp" ? value : nestPath(path.slice("mcp.".length), value);
636
- if (patchContainsMcpTransportHeaders({ mcp: relative })) {
637
- throw new BadRequestError(
638
- "MCP authentication headers must be managed through MCP server add/update APIs, not generic config writes.",
639
- );
640
- }
641
- throw new BadRequestError(MCP_CONFIG_WRITE_MESSAGE);
642
- }
643
-
644
- function nestPath(path: string, value: unknown): unknown {
645
- const segments = path.split(".");
646
- let current: unknown = value;
647
- for (let i = segments.length - 1; i >= 0; i--) {
648
- current = { [segments[i]!]: current };
649
- }
650
- return current;
651
- }
525
+ /**
526
+ * Tolerant shape parses for raw config-write fragments — zod replaces the
527
+ * hand-rolled plain-object narrowing (`readPlainObject`) at every guard.
528
+ * They model exactly the fields each guard reads and pass everything else
529
+ * through, so a write guard sees intent even when the surrounding payload
530
+ * is sparse or malformed. A parse copies, so mutating guards write the
531
+ * normalized slice back onto the caller's object instead of relying on
532
+ * shared references.
533
+ */
534
+ const JsonRecordSchema = z.record(z.string(), z.unknown());
535
+ const RawProfileEntrySchema = z.record(z.string(), z.unknown());
536
+ const RawLlmWriteSliceSchema = z
537
+ .object({
538
+ default: z.unknown().optional(),
539
+ profiles: z.record(z.string(), z.unknown()).nullable().optional(),
540
+ })
541
+ .passthrough();
652
542
 
653
543
  /**
654
544
  * Wire shape of the `memory` section in config responses. Passthrough
@@ -790,9 +680,12 @@ const ConfigGetResponseSchema = AssistantConfigSchema.pick({
790
680
 
791
681
  function handleGetConfig() {
792
682
  try {
793
- const config = loadConfig();
683
+ // The overlays stamp wire-only state (catalog bodies, effective chat
684
+ // settings, capability flags) onto the returned object. Clone first so
685
+ // the loader's cached config — and every other `getConfig()` reader,
686
+ // including the config-write merge base — never sees the wire view.
687
+ const config = structuredClone(loadConfig());
794
688
  overlayWorkspaceMcpForConfigRead(config);
795
- sanitizeMcpTransportHeadersForSettingsRead(config);
796
689
  overlayEffectiveProfilesForWire(config);
797
690
  enrichProfilesForWire(config);
798
691
  overlayChatSettingsForWire(config);
@@ -802,7 +695,6 @@ function handleGetConfig() {
802
695
  throw new InternalError(`Failed to read config: ${message}`);
803
696
  }
804
697
  }
805
-
806
698
  function overlayChatSettingsForWire(config: AssistantConfig): void {
807
699
  const effective = getConfig();
808
700
  config.conversations = {
@@ -843,37 +735,6 @@ function overlayEffectiveProfilesForWire(config: AssistantConfig): void {
843
735
  );
844
736
  }
845
737
 
846
- /**
847
- * Per-profile keys that exist only on the wire — stamped onto config
848
- * responses by {@link enrichProfilesForWire}, never persisted to disk.
849
- * {@link stripWireOnlyProfileKeys} removes them from incoming writes so a
850
- * `config get` → `config set`/PATCH round-trip isn't rejected for phantom
851
- * fields; keep the stamp and strip lists in lock-step.
852
- */
853
- const WIRE_ONLY_PROFILE_KEYS = new Set(["invariant", "supportsVision"]);
854
-
855
- /**
856
- * Delete the wire-only keys ({@link WIRE_ONLY_PROFILE_KEYS}) from every
857
- * profile entry in a config-write fragment, in place.
858
- */
859
- function stripWireOnlyProfileKeys(patch: unknown): void {
860
- const root = readPlainObject(patch);
861
- const llm = readPlainObject(root?.llm);
862
- const profiles = readPlainObject(llm?.profiles);
863
- if (!profiles) {
864
- return;
865
- }
866
- for (const profile of Object.values(profiles)) {
867
- const entry = readPlainObject(profile);
868
- if (!entry) {
869
- continue;
870
- }
871
- for (const key of WIRE_ONLY_PROFILE_KEYS) {
872
- delete entry[key];
873
- }
874
- }
875
- }
876
-
877
738
  /**
878
739
  * Normalize managed default-profile entries in a config-write fragment, in
879
740
  * place, so a `config get` → write round-trip of the enriched wire view is a
@@ -893,33 +754,39 @@ function stripWireOnlyProfileKeys(patch: unknown): void {
893
754
  * shadowed or given content. A clean echo reduces to a no-op
894
755
  * `{source: "managed"}` stub.
895
756
  *
896
- * Entries are mutated (never replaced) so the normalization also reaches
897
- * `handleSetConfig`'s `raw` write, which shares object references with the
898
- * inspected patch shape.
757
+ * The llm slice is parsed through {@link RawLlmWriteSliceSchema} and the
758
+ * normalized slice written back — a zod parse copies, so the write-back (not
759
+ * shared references) is what carries the normalization to the caller's patch.
760
+ * `handleSetConfig` reads the written-back shape for entry-level SETs.
899
761
  */
900
- export function normalizeCloudProfileWrites(patch: unknown): void {
901
- const root = readPlainObject(patch);
902
- const llm = readPlainObject(root?.llm);
903
- const profiles = readPlainObject(llm?.profiles);
904
- if (!profiles) {
762
+ export function normalizeCloudProfileWrites(
763
+ patch: Record<string, unknown>,
764
+ ): void {
765
+ const llm = RawLlmWriteSliceSchema.safeParse(patch.llm);
766
+ if (!llm.success || !llm.data.profiles) {
905
767
  return;
906
768
  }
769
+ const profiles = llm.data.profiles;
907
770
 
908
- const currentProfiles = readPlainObject(
909
- readPlainObject(loadRawConfig().llm)?.profiles,
910
- ) as Record<string, ProfileEntry> | undefined;
771
+ const currentLlm = RawLlmWriteSliceSchema.safeParse(loadRawConfig().llm);
772
+ const typedCurrent = currentLlm.success
773
+ ? LLMSchema.shape.profiles.safeParse(currentLlm.data.profiles)
774
+ : undefined;
775
+ const currentProfiles: Record<string, unknown> | undefined =
776
+ typedCurrent?.success ? typedCurrent.data : undefined;
911
777
 
912
778
  for (const name of Object.keys(profiles)) {
913
779
  if (!MANAGED_PROFILE_NAMES.has(name)) {
914
780
  continue;
915
781
  }
916
- const entry = readPlainObject(profiles[name]);
917
- if (!entry) {
782
+ const entry = RawProfileEntrySchema.safeParse(profiles[name]);
783
+ if (!entry.success) {
918
784
  continue;
919
785
  }
920
786
 
921
- const current = readPlainObject(currentProfiles?.[name]);
922
- if (current && current.source !== "managed") {
787
+ const current = RawProfileEntrySchema.safeParse(currentProfiles?.[name]);
788
+
789
+ if (current.success && current.data.source !== "managed") {
923
790
  // Legacy user-owned shadow: fully editable, normal custom-profile rules.
924
791
  continue;
925
792
  }
@@ -927,26 +794,27 @@ export function normalizeCloudProfileWrites(patch: unknown): void {
927
794
  // Must resolve through the same provider column as
928
795
  // `overlayEffectiveProfilesForWire`, or an echo of the wire view would
929
796
  // not match `effective` and an honest round-trip would be rejected.
930
- const effective = readPlainObject(
931
- resolveDefaultProfileForProvider(
932
- currentProfiles,
933
- name,
934
- getConfig().llm.default,
935
- ),
797
+ const resolved = resolveDefaultProfileForProvider(
798
+ typedCurrent?.success ? typedCurrent.data : undefined,
799
+ name,
800
+ getConfig().llm.default,
936
801
  );
937
- for (const key of Object.keys(entry)) {
802
+ const effective: Record<string, unknown> | undefined = resolved
803
+ ? { ...resolved }
804
+ : undefined;
805
+ for (const key of Object.keys(entry.data)) {
938
806
  if (key === "source" || key === "status") {
939
807
  continue;
940
808
  }
941
809
  const matchesEffective =
942
810
  effective != null &&
943
811
  key in effective &&
944
- isDeepStrictEqual(entry[key], effective[key]);
945
- if (current == null || !(key in current)) {
812
+ isDeepStrictEqual(entry.data[key], effective[key]);
813
+ if (!current.success || !(key in current.data)) {
946
814
  // Not on disk: a value matching the wire view is a client echo of
947
815
  // catalog content — drop it. Anything else stays for the guard.
948
816
  if (matchesEffective) {
949
- delete entry[key];
817
+ delete entry.data[key];
950
818
  }
951
819
  continue;
952
820
  }
@@ -956,36 +824,46 @@ export function normalizeCloudProfileWrites(patch: unknown): void {
956
824
  // from disk is pinned back to the on-disk value (no change for the
957
825
  // guard to reject); everything else is left for the guard's
958
826
  // frozen-field comparison.
959
- if (matchesEffective && !isDeepStrictEqual(entry[key], current[key])) {
960
- entry[key] = current[key];
827
+ if (
828
+ matchesEffective &&
829
+ !isDeepStrictEqual(entry.data[key], current.data[key])
830
+ ) {
831
+ entry.data[key] = current.data[key];
961
832
  }
962
833
  }
963
834
 
964
- if (current) {
835
+ if (current.success) {
965
836
  // Existing stub: the guard compares the merged result against it and
966
837
  // rejects everything but the status re-enable.
967
- if (entry.source === undefined) {
968
- entry.source = "managed";
838
+ if (entry.data.source === undefined) {
839
+ entry.data.source = "managed";
969
840
  }
841
+ profiles[name] = entry.data;
970
842
  continue;
971
843
  }
972
844
 
973
845
  // No on-disk entry: the name is catalog-owned. Nothing but a clean echo
974
- // (which reduced to source/status above) may pass.
975
- if ("source" in entry && entry.source !== "managed") {
846
+ // (which reduced to source/status above) may pass. The wire-only flags
847
+ // ride echoes too — the write path's schema parse strips them before
848
+ // anything persists.
849
+ if ("source" in entry.data && entry.data.source !== "managed") {
976
850
  throw new BadRequestError(
977
851
  `Cannot create profile "${name}" — the name is reserved for a code-defined default profile.`,
978
852
  );
979
853
  }
980
854
  if (
981
- "status" in entry &&
982
- entry.status != null &&
983
- entry.status !== "active"
855
+ "status" in entry.data &&
856
+ entry.data.status != null &&
857
+ entry.data.status !== "active"
984
858
  ) {
985
859
  throw new BadRequestError(`Cannot disable cloud profile "${name}".`);
986
860
  }
987
- const residual = Object.keys(entry).filter(
988
- (key) => key !== "source" && key !== "status",
861
+ const residual = Object.keys(entry.data).filter(
862
+ (key) =>
863
+ key !== "source" &&
864
+ key !== "status" &&
865
+ key !== "invariant" &&
866
+ key !== "supportsVision",
989
867
  );
990
868
  if (residual.length > 0) {
991
869
  throw new BadRequestError(
@@ -993,13 +871,28 @@ export function normalizeCloudProfileWrites(patch: unknown): void {
993
871
  `Cloud profiles are read-only; duplicate to a custom profile to customize.`,
994
872
  );
995
873
  }
996
- entry.source = "managed";
874
+ entry.data.source = "managed";
875
+ profiles[name] = entry.data;
997
876
  }
877
+
878
+ patch.llm = llm.data;
998
879
  }
999
880
 
1000
881
  /**
1001
- * Annotate each profile in `config.llm.profiles` with wire-only flags
1002
- * (`WIRE_ONLY_PROFILE_KEYS`) — never persisted to disk:
882
+ * The enriched profile shape clients consume: {@link ProfileEntry} plus the
883
+ * wire-only flags `enrichProfilesForWire` stamps onto config-read responses.
884
+ * The flags are deliberately absent from `ProfileEntry` — the persisted
885
+ * schema — so a config write's schema parse strips echoed copies instead of
886
+ * persisting them.
887
+ */
888
+ type WireProfileEntry = ProfileEntry & {
889
+ invariant?: boolean;
890
+ supportsVision?: boolean;
891
+ };
892
+
893
+ /**
894
+ * Annotate each profile in `config.llm.profiles` with wire-only flags —
895
+ * never persisted to disk:
1003
896
  *
1004
897
  * - `supportsVision`: resolved from the model catalog, with a profile
1005
898
  * `inputModalities.image` override winning when set. Unknown (provider,
@@ -1011,38 +904,21 @@ export function normalizeCloudProfileWrites(patch: unknown): void {
1011
904
  * a cloud name is fully editable, so it must render as a normal custom
1012
905
  * profile.
1013
906
  */
1014
- function enrichProfilesForWire(config: unknown): void {
1015
- const root = readPlainObject(config);
1016
- if (!root) {
1017
- return;
1018
- }
1019
- const llm = readPlainObject(root.llm);
1020
- if (!llm) {
1021
- return;
1022
- }
1023
- const profiles = readPlainObject(llm.profiles);
1024
- if (!profiles) {
1025
- return;
1026
- }
1027
-
1028
- for (const [name, profile] of Object.entries(profiles)) {
1029
- const entry = readPlainObject(profile);
1030
- if (!entry) {
1031
- continue;
1032
- }
907
+ function enrichProfilesForWire(config: AssistantConfig): void {
908
+ const profiles: Record<string, WireProfileEntry> = config.llm.profiles;
909
+ for (const [name, entry] of Object.entries(profiles)) {
1033
910
  if (INVARIANT_PROFILE_NAMES.has(name) && entry.source === "managed") {
1034
911
  entry.invariant = true;
1035
912
  }
1036
- const provider = entry.provider;
1037
- const model = entry.model;
1038
- if (typeof provider !== "string" || typeof model !== "string") {
913
+
914
+ if (entry.provider == null || entry.model == null) {
1039
915
  continue;
1040
916
  }
1041
917
 
1042
- const catalogModel = catalogEntryFor(provider, model);
918
+ const catalogModel = catalogEntryFor(entry.provider, entry.model);
1043
919
  const catalogVision = catalogModel?.supportsVision;
1044
920
  const imageOverride = modalitiesOf({
1045
- inputModalities: entry.inputModalities as InputModalities | null,
921
+ inputModalities: entry.inputModalities,
1046
922
  })?.image;
1047
923
  entry.supportsVision =
1048
924
  resolveModalityOverride(imageOverride, catalogVision) ?? true;
@@ -1084,20 +960,23 @@ function handleGetConfigSchema({ queryParams = {} }: RouteHandlerArgs) {
1084
960
  export function rejectCloudProfileDeletion(
1085
961
  body: Record<string, unknown>,
1086
962
  ): void {
1087
- const llm = asMutablePlainObject(body.llm);
1088
- if (!llm) {
963
+ const llm = RawLlmWriteSliceSchema.safeParse(body.llm);
964
+ if (!llm.success) {
1089
965
  return;
1090
966
  }
1091
- if ("profiles" in llm && llm.profiles === null) {
967
+ if (llm.data.profiles === null) {
1092
968
  throw new BadRequestError(
1093
969
  "Cannot null llm.profiles — cloud profiles would be deleted.",
1094
970
  );
1095
971
  }
1096
- const profiles = asMutablePlainObject(llm.profiles);
972
+ const profiles = llm.data.profiles;
1097
973
  if (!profiles) {
1098
974
  return;
1099
975
  }
1100
- const existingProfiles = asMutablePlainObject(getConfig().llm.profiles) ?? {};
976
+ const existingLlm = RawLlmWriteSliceSchema.safeParse(getConfig().llm);
977
+ const existingProfiles = existingLlm.success
978
+ ? (existingLlm.data.profiles ?? {})
979
+ : {};
1101
980
  for (const name of Object.keys(profiles)) {
1102
981
  if (profiles[name] !== null || !MANAGED_PROFILE_NAMES.has(name)) {
1103
982
  continue;
@@ -1105,8 +984,8 @@ export function rejectCloudProfileDeletion(
1105
984
  // Only block deletion when the on-disk entry is Cadet Cloud. A
1106
985
  // user-owned profile sharing a cloud name carries a non-managed `source`
1107
986
  // and is freely deletable.
1108
- const existing = asMutablePlainObject(existingProfiles[name]);
1109
- if (existing?.source === "managed") {
987
+ const existing = RawProfileEntrySchema.safeParse(existingProfiles[name]);
988
+ if (existing.success && existing.data.source === "managed") {
1110
989
  throw new BadRequestError(`Cannot delete cloud profile "${name}".`);
1111
990
  }
1112
991
  }
@@ -1138,11 +1017,11 @@ export function rejectCloudProfileDeletion(
1138
1017
  * profile can never be disabled; a changed `status` must be `"active"`,
1139
1018
  * `null`, or absent — re-enabling a disabled profile. Any other value is
1140
1019
  * rejected.
1141
- * - Wire-only keys (`WIRE_ONLY_PROFILE_KEYS`) are ignored on both sides:
1142
- * incoming writes have them stripped, but configs persisted before the
1143
- * strip existed may still carry them on disk, and treating that stale key
1144
- * as a removed field would reject every round-trip write until reboot
1145
- * reseeds the profile.
1020
+ * - The wire-only flags (`invariant`, `supportsVision`) are ignored on both
1021
+ * sides: the write path's schema parse strips them from incoming writes,
1022
+ * but configs persisted before that was true may still carry them on
1023
+ * disk, and treating that stale key as a removed field would reject every
1024
+ * round-trip write until reboot reseeds the profile.
1146
1025
  * - Every other field is frozen: any changed, added, or removed key across
1147
1026
  * the union of both entries' keys (except `status` and the wire-only keys)
1148
1027
  * is rejected. A pre-existing on-disk override (e.g. `topP`) is preserved
@@ -1197,7 +1076,8 @@ function assertInvariantProfilesPreserved(
1197
1076
  ].filter(
1198
1077
  (key) =>
1199
1078
  key !== "status" &&
1200
- !WIRE_ONLY_PROFILE_KEYS.has(key) &&
1079
+ key !== "invariant" &&
1080
+ key !== "supportsVision" &&
1201
1081
  !isDeepStrictEqual(oldEntry[key], newEntry[key]),
1202
1082
  );
1203
1083
  if (changedKeys.length > 0) {
@@ -1222,17 +1102,17 @@ function completeChangedCustomProfiles(
1222
1102
  preWrite: Record<string, unknown>,
1223
1103
  raw: Record<string, unknown>,
1224
1104
  ): void {
1225
- const profiles = asMutablePlainObject(readPlainObject(raw.llm)?.profiles);
1226
- if (!profiles) {
1105
+ const llm = RawLlmWriteSliceSchema.safeParse(raw.llm);
1106
+ if (!llm.success || !llm.data.profiles) {
1227
1107
  return;
1228
1108
  }
1229
- const parsedDefault = LLMConfigBase.safeParse(
1230
- readPlainObject(raw.llm)?.default ?? {},
1231
- );
1109
+ const profiles = llm.data.profiles;
1110
+ const parsedDefault = LLMConfigBase.safeParse(llm.data.default ?? {});
1232
1111
  if (!parsedDefault.success) {
1233
1112
  return;
1234
1113
  }
1235
- const prior = readPlainObject(readPlainObject(preWrite.llm)?.profiles) ?? {};
1114
+ const priorLlm = RawLlmWriteSliceSchema.safeParse(preWrite.llm);
1115
+ const prior = priorLlm.success ? (priorLlm.data.profiles ?? {}) : {};
1236
1116
  for (const [name, entry] of Object.entries(profiles)) {
1237
1117
  if (isDeepStrictEqual(entry, prior[name])) {
1238
1118
  continue;
@@ -1252,14 +1132,20 @@ function completeChangedCustomProfiles(
1252
1132
  // depth: `safeParse` strips keys the schema doesn't know (top-level and
1253
1133
  // inside nested objects like `contextWindow`), and dropping them here
1254
1134
  // would delete forward-compatible fields whenever the entry is edited.
1135
+ const entryRecord = RawProfileEntrySchema.safeParse(entry);
1136
+ if (!entryRecord.success) {
1137
+ continue;
1138
+ }
1255
1139
  profiles[name] = mergePreservingUnknownKeys(
1256
- readPlainObject(entry) ?? {},
1140
+ entryRecord.data,
1257
1141
  completeCustomProfile(parsedDefault.data, entryData) as Record<
1258
1142
  string,
1259
1143
  unknown
1260
1144
  >,
1261
1145
  );
1262
1146
  }
1147
+ // A zod parse copies — write the completed slice back so `raw` carries it.
1148
+ raw.llm = llm.data;
1263
1149
  }
1264
1150
 
1265
1151
  /**
@@ -1359,19 +1245,58 @@ function assertRoutableIdentityEntries(
1359
1245
  * `null`.
1360
1246
  */
1361
1247
  function assertCodeOwnedFallbackProfiles(raw: Record<string, unknown>): void {
1362
- const llm = readPlainObject(raw.llm);
1363
- const profiles = readPlainObject(llm?.profiles);
1248
+ const llm = RawLlmWriteSliceSchema.safeParse(raw.llm);
1249
+ if (!llm.success) {
1250
+ return;
1251
+ }
1364
1252
  // The sibling `llm.default` decides whether the cloud backups are valid
1365
1253
  // fallback targets: they resolve on the cloud column alone.
1366
1254
  const issues = collectFallbackProfileIssues(
1367
- profiles ?? undefined,
1368
- llm?.default,
1255
+ llm.data.profiles ?? undefined,
1256
+ llm.data.default,
1369
1257
  );
1370
1258
  if (issues.length > 0) {
1371
1259
  throw new BadRequestError(issues.map((issue) => issue.message).join(" "));
1372
1260
  }
1373
1261
  }
1374
1262
 
1263
+ /**
1264
+ * Stamp the managed marker onto `llm.profiles` entries of catalog-owned
1265
+ * names that a write created without a `source` — a source-less entry reads
1266
+ * as a user shadow that blocks the catalog body. A user-owned entry sharing
1267
+ * the name (a legacy shadow) keeps its source, so its edits stay fully
1268
+ * user-owned.
1269
+ */
1270
+ function stampManagedProfileEntries(
1271
+ preWrite: Record<string, unknown>,
1272
+ raw: Record<string, unknown>,
1273
+ ): void {
1274
+ const newLlm = RawLlmWriteSliceSchema.safeParse(raw.llm);
1275
+ if (!newLlm.success || !newLlm.data.profiles) {
1276
+ return;
1277
+ }
1278
+ const oldLlm = RawLlmWriteSliceSchema.safeParse(preWrite.llm);
1279
+ let stamped = false;
1280
+ for (const name of MANAGED_PROFILE_NAMES) {
1281
+ const written = RawProfileEntrySchema.safeParse(newLlm.data.profiles[name]);
1282
+ if (!written.success || written.data.source !== undefined) {
1283
+ continue;
1284
+ }
1285
+ const prior = RawProfileEntrySchema.safeParse(
1286
+ oldLlm.success ? oldLlm.data.profiles?.[name] : undefined,
1287
+ );
1288
+ if (prior.success && prior.data.source !== "managed") {
1289
+ continue;
1290
+ }
1291
+ written.data.source = "managed";
1292
+ newLlm.data.profiles[name] = written.data;
1293
+ stamped = true;
1294
+ }
1295
+ if (stamped) {
1296
+ raw.llm = newLlm.data;
1297
+ }
1298
+ }
1299
+
1375
1300
  export async function commitConfigWrite(
1376
1301
  raw: Record<string, unknown>,
1377
1302
  opLabel: string,
@@ -1381,10 +1306,14 @@ export async function commitConfigWrite(
1381
1306
  // false diffs. Runs before the watcher-suppress/save sequence so a
1382
1307
  // rejection needs no suppress-flag or cache cleanup.
1383
1308
  const preWrite = loadRawConfig();
1309
+ // Direct callers (the profile routes) skip `handleSetConfig`'s full-schema
1310
+ // parse — this keeps a chat-settings write's invalid values from reaching
1311
+ // disk, while untouched legacy values ride (their issues filter out).
1384
1312
  const chatSettings = validateChatSettingsWrite(preWrite, raw);
1385
1313
  if (!chatSettings.success) {
1386
1314
  throw new BadRequestError(chatSettings.error.message);
1387
1315
  }
1316
+ stampManagedProfileEntries(preWrite, raw);
1388
1317
  completeChangedCustomProfiles(preWrite, raw);
1389
1318
  assertInvariantProfilesPreserved(preWrite, raw);
1390
1319
  assertRoutableIdentityEntries(preWrite, raw);
@@ -1439,95 +1368,66 @@ export async function commitConfigWrite(
1439
1368
  publishConfigChanged();
1440
1369
  }
1441
1370
 
1442
- /**
1443
- * Web clients pair provider writes with a legacy `mode` key so their saves
1444
- * stay valid on daemons whose schemas still carry it. The provider-only
1445
- * services (web-search, image-generation) parse-strip that key, but the
1446
- * deep-merge would still persist it to raw config.json on every save —
1447
- * scrub it so the removed field cannot be resurrected on disk.
1448
- */
1449
- function scrubRemovedServiceModes(raw: Record<string, unknown>): void {
1450
- const services = raw.services as
1451
- | Record<string, Record<string, unknown> | undefined>
1452
- | undefined;
1453
- if (!services) {
1454
- return;
1455
- }
1456
- for (const key of ["web-search", "image-generation"]) {
1457
- const entry = services[key];
1458
- if (entry && typeof entry === "object" && "mode" in entry) {
1459
- delete entry.mode;
1460
- }
1461
- }
1462
- }
1463
-
1464
- /**
1465
- * A persisted `services.stt` block must satisfy SttServiceSchema, whose
1466
- * `provider` is required. The services-level default fills the provider only
1467
- * when `services.stt` is wholly absent, so a sparse patch like
1468
- * `{ services: { stt: { language } } }` deep-merged into a config with no
1469
- * stt block persists a provider-less block that fails validation and trips
1470
- * the loader's salvage ladder: the patched value never applies and the whole
1471
- * `services` section can reset to defaults on the next load (the LUM-2758
1472
- * failure family). Seed the effective provider into any stt block that lacks
1473
- * a non-empty string one so every config_patch writer stays self-consistent.
1474
- * The seed applies to any stt key, not just language: the schema requires
1475
- * the provider whenever the block exists. A block carrying a non-empty
1476
- * string provider is left alone, even an invalid one, so an explicit
1477
- * provider write is never silently rewritten.
1478
- */
1479
- function seedSttProviderForSparseBlock(raw: Record<string, unknown>): void {
1480
- const services = readPlainObject(raw.services);
1481
- const stt = readPlainObject(services?.stt);
1482
- if (!stt) {
1483
- return;
1484
- }
1485
- const provider = stt.provider;
1486
- if (typeof provider === "string" && provider.trim().length > 0) {
1487
- return;
1488
- }
1489
- stt.provider = getConfig().services.stt.provider;
1490
- }
1491
-
1492
1371
  async function handlePatchConfig({ body }: RouteHandlerArgs) {
1493
- if (
1494
- !body ||
1495
- typeof body !== "object" ||
1496
- Array.isArray(body) ||
1497
- Object.keys(body).length === 0
1498
- ) {
1372
+ const patch = JsonRecordSchema.safeParse(body);
1373
+ if (!patch.success || Object.keys(patch.data).length === 0) {
1499
1374
  throw new BadRequestError("Body must be a non-empty JSON object");
1500
1375
  }
1501
- const chatSettings = ChatSettingsPatchSchema.safeParse(body);
1502
- if (!chatSettings.success) {
1503
- throw new BadRequestError(chatSettings.error.message);
1504
- }
1505
- stripWireOnlyProfileKeys(body);
1506
- normalizeCloudProfileWrites(body);
1507
- rejectCloudProfileDeletion(body as Record<string, unknown>);
1508
- rejectMcpTransportHeaderWrite(body);
1509
- stripMcpFromConfigWrite(body as Record<string, unknown>);
1510
-
1511
- const raw = loadRawConfig();
1512
- const patch = body as Record<string, unknown>;
1513
- const patchedIngressUrl = readPlainObject(patch.ingress)?.publicBaseUrl;
1376
+ // Managed-profile normalization runs on the fragment before it merges:
1377
+ // an honest `config get` → PATCH round-trip reduces to a no-op, and a
1378
+ // genuine edit reaches the invariant guard's precise rejections.
1379
+ normalizeCloudProfileWrites(patch.data);
1380
+ rejectCloudProfileDeletion(patch.data);
1381
+
1382
+ // A patch is partial: merge it onto the current config so the schema
1383
+ // parse in `handleSetConfig` always sees a complete config — untouched
1384
+ // values ride, sparse fragments are completed by the merge base.
1385
+ const merged: AssistantConfig = structuredClone(getConfig());
1386
+ const ingress = z
1387
+ .object({ publicBaseUrl: z.unknown().optional() })
1388
+ .passthrough()
1389
+ .safeParse(patch.data.ingress);
1390
+ const patchedIngressUrl = ingress.success
1391
+ ? ingress.data.publicBaseUrl
1392
+ : undefined;
1514
1393
  if (patchedIngressUrl !== undefined) {
1515
- dropTunnelRecordsForNewUrl(readPlainObject(raw.ingress), patchedIngressUrl);
1394
+ dropTunnelRecordsForNewUrl(merged.ingress, patchedIngressUrl);
1516
1395
  }
1517
- deepMergeOverwrite(raw, patch);
1518
- scrubRemovedServiceModes(raw);
1519
- scrubNulledAcpAgentLeaves(raw);
1520
- scrubNulledChatSettings(raw, patch);
1521
- seedSttProviderForSparseBlock(raw);
1396
+ deepMergeOverwrite(merged, patch.data);
1397
+ scrubNulledAcpAgentLeaves(merged);
1398
+ scrubNulledChatSettings(merged, patch.data);
1522
1399
 
1523
- await commitConfigWrite(raw, "patch");
1400
+ await handleSetConfig(merged);
1524
1401
 
1525
1402
  return handleGetConfig();
1526
1403
  }
1527
1404
 
1528
1405
  /**
1529
- * Direct path assignment - replaces `config_patch` for the `assistant
1530
- * config set <key> <value>` CLI path.
1406
+ * The one config write: validate the merged config through
1407
+ * `AssistantConfigSchema`, and either reject with `BadRequestError` or save.
1408
+ * `handlePatchConfig` merges the patch with `getConfig()` first;
1409
+ * `handleSetConfigByKey` replaces one path first. Everything the schema
1410
+ * accepts is what lands on disk.
1411
+ */
1412
+ async function handleSetConfig(merged: AssistantConfig): Promise<void> {
1413
+ const parsed = AssistantConfigSchema.safeParse(merged);
1414
+ if (!parsed.success) {
1415
+ throw new BadRequestError(
1416
+ parsed.error.issues
1417
+ .map((issue) =>
1418
+ issue.path.length > 0
1419
+ ? `${issue.path.join(".")}: ${issue.message}`
1420
+ : issue.message,
1421
+ )
1422
+ .join("; "),
1423
+ );
1424
+ }
1425
+ await commitConfigWrite(parsed.data, "save");
1426
+ }
1427
+
1428
+ /**
1429
+ * Direct path assignment - the `assistant config set <key> <value>` CLI
1430
+ * contract.
1531
1431
  *
1532
1432
  * `config_patch` uses `deepMergeOverwrite` semantics, which strips `null`
1533
1433
  * leaves when the target subtree doesn't exist and merges (rather than
@@ -1537,17 +1437,18 @@ async function handlePatchConfig({ body }: RouteHandlerArgs) {
1537
1437
  * - `set heartbeat.activeHoursStart null` to persist explicit `null`
1538
1438
  * - `set llm {}` to replace `llm`, not merge into it
1539
1439
  *
1540
- * `config_set` performs `setNestedValue` directly on the loaded raw config
1541
- * (no merge), then runs the same post-write side effects as patch.
1440
+ * `config_set` performs `setNestedValue` directly on the current config
1441
+ * (no merge), then funnels into the same schema-validated
1442
+ * {@link handleSetConfig} write.
1542
1443
  */
1543
- async function handleSetConfig({ body }: RouteHandlerArgs) {
1544
- if (!body || typeof body !== "object" || Array.isArray(body)) {
1444
+ async function handleSetConfigByKey({ body }: RouteHandlerArgs) {
1445
+ const parsedBody = JsonRecordSchema.safeParse(body);
1446
+ if (!parsedBody.success) {
1545
1447
  throw new BadRequestError(
1546
1448
  "Body must be a JSON object with `path` and `value`",
1547
1449
  );
1548
1450
  }
1549
- const bodyRecord = body as Record<string, unknown>;
1550
- const { path, value } = bodyRecord as { path?: unknown; value?: unknown };
1451
+ const path = parsedBody.data.path;
1551
1452
  if (typeof path !== "string" || path.length === 0) {
1552
1453
  throw new BadRequestError("`path` must be a non-empty string");
1553
1454
  }
@@ -1555,85 +1456,62 @@ async function handleSetConfig({ body }: RouteHandlerArgs) {
1555
1456
  // this check, `undefined` flows into `setNestedValue` and gets dropped by
1556
1457
  // `JSON.stringify` at save time, silently removing the key - which is
1557
1458
  // distinct from the documented "set to null" semantics.
1558
- if (!("value" in bodyRecord)) {
1459
+ if (!Object.hasOwn(parsedBody.data, "value")) {
1559
1460
  throw new BadRequestError(
1560
1461
  "`value` is required (use `null` to clear a key)",
1561
1462
  );
1562
1463
  }
1563
- // A leaf-path SET targeting a wire-only profile key is dropped without
1564
- // writing — the same treatment PATCH gives wire-only keys embedded in a
1565
- // profile fragment.
1566
- const pathSegments = path.split(".");
1567
- if (
1568
- pathSegments[0] === "llm" &&
1569
- pathSegments[1] === "profiles" &&
1570
- pathSegments.length >= 4 &&
1571
- WIRE_ONLY_PROFILE_KEYS.has(pathSegments[3]!)
1572
- ) {
1573
- return { ok: true };
1574
- }
1575
- // Build the equivalent patch shape so the cloud-profile guard can
1576
- // inspect the touched subtree. `setNestedValue` places `value` into
1577
- // `patchShape` by reference, so stripping wire-only profile keys here
1578
- // also strips the object written to `raw` below.
1579
- const patchShape: Record<string, unknown> = {};
1580
- setNestedValue(patchShape, path, value);
1581
- stripWireOnlyProfileKeys(patchShape);
1582
- normalizeCloudProfileWrites(patchShape);
1583
- rejectCloudProfileDeletion(patchShape);
1584
- rejectMcpConfigSetPath(path, value);
1585
- rejectMcpTransportHeaderWrite(patchShape);
1464
+ const value = parsedBody.data.value;
1586
1465
 
1587
- const raw = loadRawConfig();
1588
- // A SET below the entry level (`llm.profiles.<name>.<leaf>`) writes the
1589
- // primitive leaf directly into `raw`, bypassing the by-reference
1590
- // normalization above — creating an entry for a cloud-owned name would
1591
- // leave it source-less, and a source-less entry reads as a user shadow
1592
- // that blocks the catalog body. Stamp the cloud marker onto the written
1593
- // entry when the name was absent or managed-owned before this write.
1594
- const managedEntryName =
1595
- pathSegments[0] === "llm" &&
1596
- pathSegments[1] === "profiles" &&
1597
- pathSegments.length >= 3 &&
1598
- MANAGED_PROFILE_NAMES.has(pathSegments[2]!)
1599
- ? pathSegments[2]!
1600
- : undefined;
1601
- const priorManagedEntry = managedEntryName
1602
- ? readPlainObject(
1603
- readPlainObject(readPlainObject(raw.llm)?.profiles)?.[managedEntryName],
1604
- )
1605
- : undefined;
1466
+ const merged: AssistantConfig = structuredClone(getConfig());
1606
1467
  // `assistant config set ingress.publicBaseUrl <url>`, what the webhook
1607
1468
  // diagnostics tell users to run, retargets ingress like the settings route.
1608
1469
  if (path === "ingress.publicBaseUrl") {
1609
- dropTunnelRecordsForNewUrl(readPlainObject(raw.ingress), value);
1610
- }
1611
- setNestedValue(raw, path, value);
1612
- if (
1613
- managedEntryName &&
1614
- (priorManagedEntry == null || priorManagedEntry.source === "managed")
1615
- ) {
1616
- const written = readPlainObject(
1617
- readPlainObject(readPlainObject(raw.llm)?.profiles)?.[managedEntryName],
1618
- );
1619
- if (written && written.source === undefined) {
1620
- written.source = "managed";
1470
+ dropTunnelRecordsForNewUrl(merged.ingress, value);
1471
+ }
1472
+ // Any llm-touching SET rides the same normalization the profile routes
1473
+ // use: `normalizeCloudProfileWrites` reduces an echo of the wire view to
1474
+ // the workspace-owned stub and rejects genuine edits to managed entries.
1475
+ // Its normalization lives in the parsed llm slice (a zod parse copies), so
1476
+ // the written value below is the normalized slice/entry — and a leaf write
1477
+ // below the entry level rides raw; the ladder stamps the managed marker
1478
+ // onto an entry the write creates.
1479
+ const pathSegments = path.split(".");
1480
+ let writtenValue: unknown = value;
1481
+ if (pathSegments[0] === "llm") {
1482
+ const patchShape: Record<string, unknown> = {};
1483
+ setNestedValue(patchShape, path, value);
1484
+ normalizeCloudProfileWrites(patchShape);
1485
+ rejectCloudProfileDeletion(patchShape);
1486
+ const patchLlm = RawLlmWriteSliceSchema.safeParse(patchShape.llm);
1487
+ if (patchLlm.success) {
1488
+ if (pathSegments.length === 1) {
1489
+ writtenValue = patchLlm.data;
1490
+ } else if (pathSegments[1] === "profiles") {
1491
+ if (pathSegments.length === 2) {
1492
+ if (patchLlm.data.profiles) {
1493
+ writtenValue = patchLlm.data.profiles;
1494
+ }
1495
+ } else if (pathSegments.length === 3) {
1496
+ const normalized = patchLlm.data.profiles?.[pathSegments[2]!];
1497
+ if (normalized !== undefined) {
1498
+ writtenValue = normalized;
1499
+ }
1500
+ }
1501
+ }
1621
1502
  }
1622
1503
  }
1623
- scrubNulledAcpAgentLeaves(raw);
1624
- // A SET can create `services.stt` with a leaf like `language` and no
1625
- // `provider`, which SttServiceSchema requires whenever the block exists;
1626
- // the same seeding that guards PATCH keeps this write's persisted block
1627
- // schema-valid.
1628
- seedSttProviderForSparseBlock(raw);
1504
+ setNestedValue(merged, path, writtenValue);
1505
+ scrubNulledAcpAgentLeaves(merged);
1506
+
1507
+ await handleSetConfig(merged);
1629
1508
 
1630
- await commitConfigWrite(raw, "set");
1631
1509
  // A `memory.v2` substrate tunable whose `memory.substrate` twin is set does
1632
1510
  // not mean what an unpaired write means: the substrate namespace wins in
1633
1511
  // `resolveSubstrateTuning`, and for the three twins the v2 injection engine
1634
1512
  // reads directly the two namespaces diverge instead. Report which case this
1635
1513
  // is alongside the success so the caller can tell the operator.
1636
- const shadowing = findSubstrateShadowing(raw, path);
1514
+ const shadowing = findSubstrateShadowing(path);
1637
1515
  if (shadowing) {
1638
1516
  const warning = describeShadowedConfigSet(shadowing, path);
1639
1517
  log.warn({ path, substratePath: shadowing.substratePath }, warning);
@@ -2348,7 +2226,7 @@ export const ROUTES: RouteDefinition[] = [
2348
2226
  "(preserves explicit null, replaces object subtrees instead of merging). " +
2349
2227
  "Used by the `assistant config set <key> <value>` CLI command.",
2350
2228
  tags: ["config"],
2351
- handler: handleSetConfig,
2229
+ handler: handleSetConfigByKey,
2352
2230
  },
2353
2231
  {
2354
2232
  operationId: "config_allowlist_validate",