@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.
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/ces-client/package.json +1 -1
- package/node_modules/@mycadet/ces-client/src/__tests__/ces-client.test.ts +14 -14
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/openapi.yaml +2 -18
- package/package.json +2 -2
- package/src/__tests__/cloud-profile-guard.test.ts +21 -19
- package/src/__tests__/config-set-route.test.ts +12 -8
- package/src/__tests__/credential-health-service.test.ts +4 -2
- package/src/__tests__/get-skill-detail-audit.test.ts +134 -289
- package/src/__tests__/mcp-abort-signal.test.ts +0 -1
- package/src/__tests__/mcp-client-auth.test.ts +0 -1
- package/src/__tests__/mcp-config-secret-boundary.test.ts +44 -40
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +0 -1
- package/src/__tests__/plugin-api-store-credential.test.ts +4 -1
- package/src/__tests__/skills-file-content-endpoint.test.ts +221 -535
- package/src/__tests__/skills-files-catalog-fallback.test.ts +258 -576
- package/src/api/events/show-cloud-login.ts +2 -2
- package/src/cli/commands/cloud/__tests__/callback-routes-list.test.ts +2 -2
- package/src/cli/commands/cloud/index.ts +1 -1
- package/src/cli/commands/config.ts +1 -1
- package/src/config/__tests__/substrate-twin-shadowing.test.ts +36 -26
- package/src/config/env-registry.ts +2 -9
- package/src/config/schemas/ingress.ts +30 -0
- package/src/config/schemas/llm.ts +0 -9
- package/src/config/seed-inference-profiles.ts +2 -2
- package/src/config/substrate-twin-shadowing.ts +12 -17
- package/src/config/sync-gated-profiles.ts +3 -4
- package/src/credential-execution/prompted-credential.ts +1 -10
- package/src/credential-health/credential-health-service.ts +8 -4
- package/src/daemon/credential-transcript-scrub.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +5 -0
- package/src/providers/inference/backfill.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +100 -57
- package/src/runtime/routes/cloud-routes.ts +5 -5
- package/src/runtime/routes/conversation-query-routes.ts +275 -397
- package/src/runtime/routes/secret-routes.ts +2 -17
- package/src/tools/credentials/store.ts +1 -10
- package/src/util/layout.ts +479 -0
- package/src/util/platform.ts +11 -467
- 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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
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(
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
const
|
|
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
|
|
909
|
-
|
|
910
|
-
|
|
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 =
|
|
917
|
-
if (!entry) {
|
|
782
|
+
const entry = RawProfileEntrySchema.safeParse(profiles[name]);
|
|
783
|
+
if (!entry.success) {
|
|
918
784
|
continue;
|
|
919
785
|
}
|
|
920
786
|
|
|
921
|
-
const current =
|
|
922
|
-
|
|
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
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
getConfig().llm.default,
|
|
935
|
-
),
|
|
797
|
+
const resolved = resolveDefaultProfileForProvider(
|
|
798
|
+
typedCurrent?.success ? typedCurrent.data : undefined,
|
|
799
|
+
name,
|
|
800
|
+
getConfig().llm.default,
|
|
936
801
|
);
|
|
937
|
-
|
|
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
|
|
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 (
|
|
960
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
*
|
|
1002
|
-
*
|
|
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:
|
|
1015
|
-
const
|
|
1016
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
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
|
|
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 =
|
|
1088
|
-
if (!llm) {
|
|
963
|
+
const llm = RawLlmWriteSliceSchema.safeParse(body.llm);
|
|
964
|
+
if (!llm.success) {
|
|
1089
965
|
return;
|
|
1090
966
|
}
|
|
1091
|
-
if (
|
|
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 =
|
|
972
|
+
const profiles = llm.data.profiles;
|
|
1097
973
|
if (!profiles) {
|
|
1098
974
|
return;
|
|
1099
975
|
}
|
|
1100
|
-
const
|
|
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 =
|
|
1109
|
-
if (existing
|
|
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
|
-
* -
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
* as a removed field would reject every
|
|
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
|
-
|
|
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
|
|
1226
|
-
if (!profiles) {
|
|
1105
|
+
const llm = RawLlmWriteSliceSchema.safeParse(raw.llm);
|
|
1106
|
+
if (!llm.success || !llm.data.profiles) {
|
|
1227
1107
|
return;
|
|
1228
1108
|
}
|
|
1229
|
-
const
|
|
1230
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
1363
|
-
|
|
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
|
|
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
|
-
|
|
1494
|
-
|
|
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
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1513
|
-
|
|
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(
|
|
1394
|
+
dropTunnelRecordsForNewUrl(merged.ingress, patchedIngressUrl);
|
|
1516
1395
|
}
|
|
1517
|
-
deepMergeOverwrite(
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
scrubNulledChatSettings(raw, patch);
|
|
1521
|
-
seedSttProviderForSparseBlock(raw);
|
|
1396
|
+
deepMergeOverwrite(merged, patch.data);
|
|
1397
|
+
scrubNulledAcpAgentLeaves(merged);
|
|
1398
|
+
scrubNulledChatSettings(merged, patch.data);
|
|
1522
1399
|
|
|
1523
|
-
await
|
|
1400
|
+
await handleSetConfig(merged);
|
|
1524
1401
|
|
|
1525
1402
|
return handleGetConfig();
|
|
1526
1403
|
}
|
|
1527
1404
|
|
|
1528
1405
|
/**
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
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
|
|
1541
|
-
* (no merge), then
|
|
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
|
|
1544
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
|
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(
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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(
|
|
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:
|
|
2229
|
+
handler: handleSetConfigByKey,
|
|
2352
2230
|
},
|
|
2353
2231
|
{
|
|
2354
2232
|
operationId: "config_allowlist_validate",
|