@vellumai/assistant 0.10.2-dev.202606250106.466483e → 0.10.2-dev.202606250318.5e7cfb0
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/package.json +1 -1
- package/src/__tests__/assistant-attachments.test.ts +42 -0
- package/src/__tests__/config-loader-backfill.test.ts +10 -9
- package/src/__tests__/http-user-message-parity.test.ts +123 -0
- package/src/__tests__/mcp-config-secret-boundary.test.ts +390 -0
- package/src/config/__tests__/sync-gated-profiles.test.ts +11 -3
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/seed-inference-profiles.ts +7 -6
- package/src/config/sync-gated-profiles.ts +12 -13
- package/src/daemon/assistant-attachments.ts +27 -4
- package/src/onboarding/checkin-event.test.ts +2 -0
- package/src/onboarding/checkin-event.ts +1 -1
- package/src/runtime/routes/conversation-query-routes.ts +72 -0
- package/src/runtime/routes/conversation-routes.ts +11 -5
package/package.json
CHANGED
|
@@ -373,6 +373,48 @@ describe("extractVellumLinks", () => {
|
|
|
373
373
|
expect(result.directiveRequests[1].path).toBe("/tmp/b.pdf");
|
|
374
374
|
});
|
|
375
375
|
|
|
376
|
+
test("decodes URL-encoded spaces in workspace paths", () => {
|
|
377
|
+
const text =
|
|
378
|
+
"[file with spaces.txt](vellum://workspace/scratch/file%20with%20spaces.txt)";
|
|
379
|
+
const result = extractVellumLinks(text);
|
|
380
|
+
|
|
381
|
+
expect(result.directiveRequests).toHaveLength(1);
|
|
382
|
+
expect(result.directiveRequests[0].source).toBe("sandbox");
|
|
383
|
+
expect(result.directiveRequests[0].path).toBe(
|
|
384
|
+
"scratch/file with spaces.txt",
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("decodes URL-encoded spaces in host paths", () => {
|
|
389
|
+
const text =
|
|
390
|
+
"[my file.pdf](vellum://host/Users/me/my%20file.pdf)";
|
|
391
|
+
const result = extractVellumLinks(text);
|
|
392
|
+
|
|
393
|
+
expect(result.directiveRequests).toHaveLength(1);
|
|
394
|
+
expect(result.directiveRequests[0].source).toBe("host");
|
|
395
|
+
expect(result.directiveRequests[0].path).toBe("/Users/me/my file.pdf");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("warns on malformed percent-encoding instead of throwing", () => {
|
|
399
|
+
const text =
|
|
400
|
+
"[100% complete.txt](vellum://workspace/scratch/100%25complete.txt)";
|
|
401
|
+
const result = extractVellumLinks(text);
|
|
402
|
+
|
|
403
|
+
// %25 decodes to %, so this should succeed
|
|
404
|
+
expect(result.directiveRequests).toHaveLength(1);
|
|
405
|
+
expect(result.directiveRequests[0].path).toBe("scratch/100%complete.txt");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("warns on malformed percent-encoding and skips the link", () => {
|
|
409
|
+
const text =
|
|
410
|
+
"[bad file](vellum://workspace/scratch/100%complete.txt)";
|
|
411
|
+
const result = extractVellumLinks(text);
|
|
412
|
+
|
|
413
|
+
expect(result.directiveRequests).toHaveLength(0);
|
|
414
|
+
expect(result.parseWarnings).toHaveLength(1);
|
|
415
|
+
expect(result.parseWarnings[0]).toContain("malformed percent-encoding");
|
|
416
|
+
});
|
|
417
|
+
|
|
376
418
|
test("warns on empty workspace path", () => {
|
|
377
419
|
const text = "[file](vellum://workspace/)";
|
|
378
420
|
const result = extractVellumLinks(text);
|
|
@@ -1703,8 +1703,8 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
|
|
|
1703
1703
|
// ---------------------------------------------------------------------------
|
|
1704
1704
|
// Tests: OS Beta flag-gated managed profile. The template is defined but
|
|
1705
1705
|
// intentionally NOT part of MANAGED_PROFILE_TEMPLATES, so seedInferenceProfiles
|
|
1706
|
-
// must never create it.
|
|
1707
|
-
// feature flag.
|
|
1706
|
+
// must never create it. The flag-gated reconcile creates or removes it based on
|
|
1707
|
+
// the `os-beta` feature flag.
|
|
1708
1708
|
// ---------------------------------------------------------------------------
|
|
1709
1709
|
|
|
1710
1710
|
describe("OS Beta managed profile template", () => {
|
|
@@ -1751,20 +1751,21 @@ describe("OS Beta managed profile template", () => {
|
|
|
1751
1751
|
expect(MANAGED_PROFILE_NAMES.has("os-beta")).toBe(true);
|
|
1752
1752
|
});
|
|
1753
1753
|
|
|
1754
|
-
test("materializeProfile
|
|
1754
|
+
test("materializeProfile resolves OS Beta to the Balanced model with low effort", () => {
|
|
1755
1755
|
const entry = materializeProfile(
|
|
1756
1756
|
OS_BETA_PROFILE_TEMPLATE,
|
|
1757
|
-
"
|
|
1758
|
-
"
|
|
1757
|
+
"together",
|
|
1758
|
+
"together-managed",
|
|
1759
1759
|
);
|
|
1760
1760
|
|
|
1761
|
-
expect(entry.model).toBe("
|
|
1762
|
-
expect(entry.provider_connection).toBe("
|
|
1763
|
-
expect(entry.provider).toBe("
|
|
1761
|
+
expect(entry.model).toBe("MiniMaxAI/MiniMax-M3");
|
|
1762
|
+
expect(entry.provider_connection).toBe("together-managed");
|
|
1763
|
+
expect(entry.provider).toBe("together");
|
|
1764
1764
|
expect(entry.label).toBe("OS Beta");
|
|
1765
1765
|
expect(entry.source).toBe("managed");
|
|
1766
1766
|
expect(entry.maxTokens).toBe(32000);
|
|
1767
|
-
expect(entry.effort).toBe("
|
|
1767
|
+
expect(entry.effort).toBe("low");
|
|
1768
1768
|
expect(entry.thinking?.enabled).toBe(true);
|
|
1769
|
+
expect(entry.topP).toBe(0.95);
|
|
1769
1770
|
});
|
|
1770
1771
|
});
|
|
@@ -127,6 +127,44 @@ mock.module("../runtime/local-actor-identity.js", () => ({
|
|
|
127
127
|
)?.principalId as string | undefined,
|
|
128
128
|
}));
|
|
129
129
|
|
|
130
|
+
// Capture the sourceActorPrincipalId that handleSendMessage threads into
|
|
131
|
+
// shouldAttachHostProxyForCapability / preactivateHostProxySkills, so tests
|
|
132
|
+
// can assert the dev-bypass translation landed before the CU proxy gate.
|
|
133
|
+
// The macOS "native_support" path short-circuits before reading the
|
|
134
|
+
// principal, so only web/ios turns exercise the same-actor branch.
|
|
135
|
+
const hostProxyAttachCalls: Array<{
|
|
136
|
+
capability: string;
|
|
137
|
+
sourceInterface: unknown;
|
|
138
|
+
sourceActorPrincipalId: string | undefined;
|
|
139
|
+
}> = [];
|
|
140
|
+
const preactivateCalls: Array<{
|
|
141
|
+
sourceInterface: unknown;
|
|
142
|
+
sourceActorPrincipalId: string | undefined;
|
|
143
|
+
}> = [];
|
|
144
|
+
mock.module("../daemon/host-proxy-preactivation.js", () => ({
|
|
145
|
+
shouldAttachHostProxyForCapability: (
|
|
146
|
+
capability: string,
|
|
147
|
+
sourceInterface: unknown,
|
|
148
|
+
sourceActorPrincipalId: string | undefined,
|
|
149
|
+
) => {
|
|
150
|
+
hostProxyAttachCalls.push({
|
|
151
|
+
capability,
|
|
152
|
+
sourceInterface,
|
|
153
|
+
sourceActorPrincipalId,
|
|
154
|
+
});
|
|
155
|
+
// Return false so the route skips proxy instantiation; we only care
|
|
156
|
+
// that the translated principal reached the gate.
|
|
157
|
+
return false;
|
|
158
|
+
},
|
|
159
|
+
preactivateHostProxySkills: (
|
|
160
|
+
_conversation: unknown,
|
|
161
|
+
sourceInterface: unknown,
|
|
162
|
+
sourceActorPrincipalId: string | undefined,
|
|
163
|
+
) => {
|
|
164
|
+
preactivateCalls.push({ sourceInterface, sourceActorPrincipalId });
|
|
165
|
+
},
|
|
166
|
+
}));
|
|
167
|
+
|
|
130
168
|
let mockGuardians: Array<Record<string, unknown>> | null = [
|
|
131
169
|
{
|
|
132
170
|
channelType: "vellum",
|
|
@@ -612,4 +650,89 @@ describe("HTTP POST /v1/messages trust context from the gateway binding", () =>
|
|
|
612
650
|
expect(ctx.trustClass).toBe("guardian");
|
|
613
651
|
expect(ctx.sourceChannel).toBe("telegram");
|
|
614
652
|
});
|
|
653
|
+
|
|
654
|
+
// A web turn's "dev-bypass" principal must translate to the real guardian
|
|
655
|
+
// principal before the CU/app-control same-actor proxy-attachment gate,
|
|
656
|
+
// so it matches the macOS client's SSE-registered principal.
|
|
657
|
+
test("dev-bypass is translated to the guardian principal before the CU proxy attach gate (web turn)", async () => {
|
|
658
|
+
hostProxyAttachCalls.length = 0;
|
|
659
|
+
preactivateCalls.length = 0;
|
|
660
|
+
const conversation = makeConversation();
|
|
661
|
+
const res = await callHandler(
|
|
662
|
+
(args) =>
|
|
663
|
+
handleSendMessage(args, {
|
|
664
|
+
sendMessageDeps: {
|
|
665
|
+
getOrCreateConversation: async () => conversation,
|
|
666
|
+
assistantEventHub: { publish: async () => {} } as any,
|
|
667
|
+
resolveAttachments: () => [],
|
|
668
|
+
},
|
|
669
|
+
}),
|
|
670
|
+
new Request("http://localhost/v1/messages", {
|
|
671
|
+
method: "POST",
|
|
672
|
+
headers: {
|
|
673
|
+
"Content-Type": "application/json",
|
|
674
|
+
"x-vellum-actor-principal-id": "dev-bypass",
|
|
675
|
+
"x-vellum-principal-type": "actor",
|
|
676
|
+
},
|
|
677
|
+
body: JSON.stringify({
|
|
678
|
+
conversationKey: "cu-attach-key",
|
|
679
|
+
content: "hi",
|
|
680
|
+
sourceChannel: "vellum",
|
|
681
|
+
interface: "web",
|
|
682
|
+
}),
|
|
683
|
+
}),
|
|
684
|
+
undefined,
|
|
685
|
+
202,
|
|
686
|
+
);
|
|
687
|
+
expect(res.status).toBe(202);
|
|
688
|
+
|
|
689
|
+
// The CU attach gate receives the translated guardian principal, not
|
|
690
|
+
// the raw "dev-bypass" string.
|
|
691
|
+
const cuCall = hostProxyAttachCalls.find(
|
|
692
|
+
(c) => c.capability === "host_cu",
|
|
693
|
+
);
|
|
694
|
+
expect(cuCall).toBeDefined();
|
|
695
|
+
expect(cuCall?.sourceActorPrincipalId).toBe("test-user");
|
|
696
|
+
expect(cuCall?.sourceActorPrincipalId).not.toBe("dev-bypass");
|
|
697
|
+
|
|
698
|
+
// Preactivation receives the same translated principal.
|
|
699
|
+
const preactivateCall = preactivateCalls[0];
|
|
700
|
+
expect(preactivateCall?.sourceActorPrincipalId).toBe("test-user");
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("real (non-dev-bypass) principal passes through the CU proxy attach gate unchanged", async () => {
|
|
704
|
+
hostProxyAttachCalls.length = 0;
|
|
705
|
+
const conversation = makeConversation();
|
|
706
|
+
await callHandler(
|
|
707
|
+
(args) =>
|
|
708
|
+
handleSendMessage(args, {
|
|
709
|
+
sendMessageDeps: {
|
|
710
|
+
getOrCreateConversation: async () => conversation,
|
|
711
|
+
assistantEventHub: { publish: async () => {} } as any,
|
|
712
|
+
resolveAttachments: () => [],
|
|
713
|
+
},
|
|
714
|
+
}),
|
|
715
|
+
new Request("http://localhost/v1/messages", {
|
|
716
|
+
method: "POST",
|
|
717
|
+
headers: {
|
|
718
|
+
"Content-Type": "application/json",
|
|
719
|
+
"x-vellum-actor-principal-id": "real-jwt-principal",
|
|
720
|
+
"x-vellum-principal-type": "actor",
|
|
721
|
+
},
|
|
722
|
+
body: JSON.stringify({
|
|
723
|
+
conversationKey: "cu-attach-real-key",
|
|
724
|
+
content: "hi",
|
|
725
|
+
sourceChannel: "vellum",
|
|
726
|
+
interface: "web",
|
|
727
|
+
}),
|
|
728
|
+
}),
|
|
729
|
+
undefined,
|
|
730
|
+
202,
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
const cuCall = hostProxyAttachCalls.find(
|
|
734
|
+
(c) => c.capability === "host_cu",
|
|
735
|
+
);
|
|
736
|
+
expect(cuCall?.sourceActorPrincipalId).toBe("real-jwt-principal");
|
|
737
|
+
});
|
|
615
738
|
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { makeMockLogger } from "./helpers/mock-logger.js";
|
|
4
|
+
|
|
5
|
+
mock.module("../util/logger.js", () => ({
|
|
6
|
+
LOG_FILE_PATTERN: /^assistant-(\d{4}-\d{2}-\d{2})\.log$/,
|
|
7
|
+
getCliLogger: () => makeMockLogger(),
|
|
8
|
+
getLogger: () => makeMockLogger(),
|
|
9
|
+
initLogger: () => {},
|
|
10
|
+
pruneOldLogFiles: () => 0,
|
|
11
|
+
truncateForLog: (value: string, maxLen = 500) => value.slice(0, maxLen),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
let rawConfig: Record<string, unknown> = {};
|
|
15
|
+
let savedRawConfig: Record<string, unknown> | null = null;
|
|
16
|
+
|
|
17
|
+
function deepMerge(
|
|
18
|
+
target: Record<string, unknown>,
|
|
19
|
+
patch: Record<string, unknown>,
|
|
20
|
+
): void {
|
|
21
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
22
|
+
if (
|
|
23
|
+
value !== null &&
|
|
24
|
+
typeof value === "object" &&
|
|
25
|
+
!Array.isArray(value) &&
|
|
26
|
+
target[key] !== null &&
|
|
27
|
+
typeof target[key] === "object" &&
|
|
28
|
+
!Array.isArray(target[key])
|
|
29
|
+
) {
|
|
30
|
+
deepMerge(
|
|
31
|
+
target[key] as Record<string, unknown>,
|
|
32
|
+
value as Record<string, unknown>,
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
target[key] = value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setNestedValue(
|
|
41
|
+
obj: Record<string, unknown>,
|
|
42
|
+
path: string,
|
|
43
|
+
value: unknown,
|
|
44
|
+
): void {
|
|
45
|
+
const keys = path.split(".");
|
|
46
|
+
let current = obj;
|
|
47
|
+
for (const key of keys.slice(0, -1)) {
|
|
48
|
+
if (
|
|
49
|
+
current[key] === null ||
|
|
50
|
+
typeof current[key] !== "object" ||
|
|
51
|
+
Array.isArray(current[key])
|
|
52
|
+
) {
|
|
53
|
+
current[key] = {};
|
|
54
|
+
}
|
|
55
|
+
current = current[key] as Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
current[keys[keys.length - 1]!] = value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
mock.module("../config/loader.js", () => ({
|
|
61
|
+
API_KEY_PROVIDERS: [],
|
|
62
|
+
applyNestedDefaults: (config: unknown) => config,
|
|
63
|
+
loadRawConfig: () => structuredClone(savedRawConfig ?? rawConfig),
|
|
64
|
+
saveRawConfig: (raw: Record<string, unknown>) => {
|
|
65
|
+
savedRawConfig = structuredClone(raw);
|
|
66
|
+
},
|
|
67
|
+
deepMergeOverwrite: deepMerge,
|
|
68
|
+
fillContextDefaultsForMissingKeys: () => {},
|
|
69
|
+
loadConfig: () => structuredClone(savedRawConfig ?? rawConfig),
|
|
70
|
+
getConfig: () => structuredClone(savedRawConfig ?? rawConfig),
|
|
71
|
+
getConfigReadOnly: () => structuredClone(savedRawConfig ?? rawConfig),
|
|
72
|
+
getDeploymentContextDefaults: () => ({}),
|
|
73
|
+
getNestedValue: (obj: Record<string, unknown>, path: string) =>
|
|
74
|
+
path.split(".").reduce<unknown>((current, key) => {
|
|
75
|
+
if (
|
|
76
|
+
current === null ||
|
|
77
|
+
typeof current !== "object" ||
|
|
78
|
+
Array.isArray(current)
|
|
79
|
+
) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return (current as Record<string, unknown>)[key];
|
|
83
|
+
}, obj),
|
|
84
|
+
invalidateConfigCache: () => {},
|
|
85
|
+
mergeDefaultWorkspaceConfig: () => ({
|
|
86
|
+
merged: false,
|
|
87
|
+
config: structuredClone(savedRawConfig ?? rawConfig),
|
|
88
|
+
}),
|
|
89
|
+
setNestedValue,
|
|
90
|
+
withSuppressedConfigDiskWrites: async (fn: () => unknown) => fn(),
|
|
91
|
+
withSuppressedConfigDiskWritesSync: (fn: () => unknown) => fn(),
|
|
92
|
+
_writeQuarantineNotice: () => {},
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
mock.module("../daemon/config-watcher.js", () => ({
|
|
96
|
+
getConfigWatcher: () => ({
|
|
97
|
+
suppressConfigReload: false,
|
|
98
|
+
timers: { schedule: () => {} },
|
|
99
|
+
updateFingerprint: () => {},
|
|
100
|
+
}),
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
mock.module("../providers/registry.js", () => ({
|
|
104
|
+
clearConnectionProviderCache: () => {},
|
|
105
|
+
getProvider: () => {
|
|
106
|
+
throw new Error("provider registry mock not implemented");
|
|
107
|
+
},
|
|
108
|
+
getProviderRoutingSource: () => null,
|
|
109
|
+
initializeProviders: async () => {},
|
|
110
|
+
isNativeWebSearchCapableProvider: () => false,
|
|
111
|
+
listProviders: () => [],
|
|
112
|
+
resolveProviderFromConnection: async () => null,
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
mock.module("../memory/embedding-backend.js", () => ({
|
|
116
|
+
EmbeddingBackendUnavailableError: class EmbeddingBackendUnavailableError extends Error {},
|
|
117
|
+
SPARSE_EMBEDDING_VERSION: 4,
|
|
118
|
+
clearEmbeddingBackendCache: () => {},
|
|
119
|
+
embedWithBackend: async () => ({
|
|
120
|
+
provider: "local",
|
|
121
|
+
model: "test",
|
|
122
|
+
vectors: [],
|
|
123
|
+
}),
|
|
124
|
+
generateSparseEmbedding: () => ({ indices: [], values: [] }),
|
|
125
|
+
getMemoryBackendStatus: async () => ({
|
|
126
|
+
enabled: false,
|
|
127
|
+
provider: null,
|
|
128
|
+
model: null,
|
|
129
|
+
}),
|
|
130
|
+
resetLocalEmbeddingFailureState: () => {},
|
|
131
|
+
selectEmbeddingBackend: async () => null,
|
|
132
|
+
selectedBackendSupportsMultimodal: async () => false,
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
mock.module("../security/secret-allowlist.js", () => ({
|
|
136
|
+
isAllowlisted: () => false,
|
|
137
|
+
loadAllowlist: () => {},
|
|
138
|
+
resetAllowlist: () => {},
|
|
139
|
+
validateAllowlistFile: () => null,
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
const { ROUTES } =
|
|
143
|
+
await import("../runtime/routes/conversation-query-routes.js");
|
|
144
|
+
const { BadRequestError } = await import("../runtime/routes/errors.js");
|
|
145
|
+
|
|
146
|
+
function findRoute(operationId: string) {
|
|
147
|
+
const route = ROUTES.find((r) => r.operationId === operationId);
|
|
148
|
+
if (!route) throw new Error(`Route not found: ${operationId}`);
|
|
149
|
+
return route;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const configGetRoute = findRoute("config_get");
|
|
153
|
+
const configPatchRoute = findRoute("config_patch");
|
|
154
|
+
const configSetRoute = findRoute("config_set");
|
|
155
|
+
|
|
156
|
+
describe("MCP config secret boundary", () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
rawConfig = {};
|
|
159
|
+
savedRawConfig = null;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("config_get omits legacy MCP transport headers from settings-read responses", () => {
|
|
163
|
+
rawConfig = {
|
|
164
|
+
mcp: {
|
|
165
|
+
servers: {
|
|
166
|
+
remote: {
|
|
167
|
+
transport: {
|
|
168
|
+
type: "streamable-http",
|
|
169
|
+
url: "https://mcp.example.com",
|
|
170
|
+
headers: {
|
|
171
|
+
Authorization: "Bearer mcp-secret",
|
|
172
|
+
"X-API-Key": "mcp-api-secret",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const result = configGetRoute.handler({}) as Record<string, unknown>;
|
|
181
|
+
|
|
182
|
+
expect(JSON.stringify(result)).not.toContain("mcp-secret");
|
|
183
|
+
expect(JSON.stringify(result)).not.toContain("mcp-api-secret");
|
|
184
|
+
const mcp = result.mcp as {
|
|
185
|
+
servers: { remote: { transport: Record<string, unknown> } };
|
|
186
|
+
};
|
|
187
|
+
expect(mcp.servers.remote.transport).toEqual({
|
|
188
|
+
type: "streamable-http",
|
|
189
|
+
url: "https://mcp.example.com",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("config_get omits headers inside malformed MCP server trees", () => {
|
|
194
|
+
rawConfig = {
|
|
195
|
+
mcp: {
|
|
196
|
+
servers: [
|
|
197
|
+
{
|
|
198
|
+
transport: {
|
|
199
|
+
headers: { Authorization: "Bearer malformed-secret" },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const result = configGetRoute.handler({}) as Record<string, unknown>;
|
|
207
|
+
|
|
208
|
+
expect(JSON.stringify(result)).not.toContain("malformed-secret");
|
|
209
|
+
expect(result).toEqual({
|
|
210
|
+
mcp: {
|
|
211
|
+
servers: [
|
|
212
|
+
{
|
|
213
|
+
transport: {},
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("config_get preserves an MCP server named headers", () => {
|
|
221
|
+
rawConfig = {
|
|
222
|
+
mcp: {
|
|
223
|
+
servers: {
|
|
224
|
+
headers: {
|
|
225
|
+
transport: {
|
|
226
|
+
type: "streamable-http",
|
|
227
|
+
url: "https://mcp.example.com",
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const result = configGetRoute.handler({}) as Record<string, unknown>;
|
|
235
|
+
|
|
236
|
+
expect(result).toEqual(rawConfig);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("config_get preserves non-credential headers env vars", () => {
|
|
240
|
+
rawConfig = {
|
|
241
|
+
mcp: {
|
|
242
|
+
servers: {
|
|
243
|
+
local: {
|
|
244
|
+
transport: {
|
|
245
|
+
type: "stdio",
|
|
246
|
+
command: "npx",
|
|
247
|
+
env: {
|
|
248
|
+
headers: "not-a-transport-header",
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const result = configGetRoute.handler({}) as Record<string, unknown>;
|
|
257
|
+
|
|
258
|
+
expect(result).toEqual(rawConfig);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("config_patch rejects MCP transport headers so generic writes cannot reintroduce plaintext credentials", async () => {
|
|
262
|
+
await expect(
|
|
263
|
+
configPatchRoute.handler({
|
|
264
|
+
body: {
|
|
265
|
+
mcp: {
|
|
266
|
+
servers: {
|
|
267
|
+
remote: {
|
|
268
|
+
transport: {
|
|
269
|
+
type: "streamable-http",
|
|
270
|
+
url: "https://mcp.example.com",
|
|
271
|
+
headers: { Authorization: "Bearer mcp-secret" },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
).rejects.toThrow(BadRequestError);
|
|
279
|
+
expect(savedRawConfig).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("config_patch allows an MCP server named headers when its value has no header credentials", async () => {
|
|
283
|
+
const result = await configPatchRoute.handler({
|
|
284
|
+
body: {
|
|
285
|
+
mcp: {
|
|
286
|
+
servers: {
|
|
287
|
+
headers: {
|
|
288
|
+
transport: {
|
|
289
|
+
type: "streamable-http",
|
|
290
|
+
url: "https://mcp.example.com",
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(result).toEqual({
|
|
299
|
+
mcp: {
|
|
300
|
+
servers: {
|
|
301
|
+
headers: {
|
|
302
|
+
transport: {
|
|
303
|
+
type: "streamable-http",
|
|
304
|
+
url: "https://mcp.example.com",
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("config_patch allows non-credential headers env vars", async () => {
|
|
313
|
+
const result = await configPatchRoute.handler({
|
|
314
|
+
body: {
|
|
315
|
+
mcp: {
|
|
316
|
+
servers: {
|
|
317
|
+
local: {
|
|
318
|
+
transport: {
|
|
319
|
+
type: "stdio",
|
|
320
|
+
command: "npx",
|
|
321
|
+
env: {
|
|
322
|
+
headers: "not-a-transport-header",
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(result).toEqual({
|
|
332
|
+
mcp: {
|
|
333
|
+
servers: {
|
|
334
|
+
local: {
|
|
335
|
+
transport: {
|
|
336
|
+
type: "stdio",
|
|
337
|
+
command: "npx",
|
|
338
|
+
env: {
|
|
339
|
+
headers: "not-a-transport-header",
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("config_set rejects malformed MCP server trees containing headers", async () => {
|
|
349
|
+
await expect(
|
|
350
|
+
configSetRoute.handler({
|
|
351
|
+
body: {
|
|
352
|
+
path: "mcp.servers",
|
|
353
|
+
value: [
|
|
354
|
+
{
|
|
355
|
+
transport: {
|
|
356
|
+
headers: { Authorization: "Bearer malformed-secret" },
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
362
|
+
).rejects.toThrow(BadRequestError);
|
|
363
|
+
expect(savedRawConfig).toBeNull();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("config_set rejects direct MCP transport header paths", async () => {
|
|
367
|
+
rawConfig = {
|
|
368
|
+
mcp: {
|
|
369
|
+
servers: {
|
|
370
|
+
remote: {
|
|
371
|
+
transport: {
|
|
372
|
+
type: "streamable-http",
|
|
373
|
+
url: "https://mcp.example.com",
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
await expect(
|
|
381
|
+
configSetRoute.handler({
|
|
382
|
+
body: {
|
|
383
|
+
path: "mcp.servers.remote.transport.headers.Authorization",
|
|
384
|
+
value: "Bearer mcp-secret",
|
|
385
|
+
},
|
|
386
|
+
}),
|
|
387
|
+
).rejects.toThrow(BadRequestError);
|
|
388
|
+
expect(savedRawConfig).toBeNull();
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -109,10 +109,13 @@ describe("reconcileFlagGatedProfiles", () => {
|
|
|
109
109
|
|
|
110
110
|
const raw = readConfig();
|
|
111
111
|
const osBeta = raw.llm.profiles["os-beta"]!;
|
|
112
|
-
expect(osBeta.model).toBe("
|
|
113
|
-
expect(osBeta.provider_connection).toBe("
|
|
112
|
+
expect(osBeta.model).toBe("MiniMaxAI/MiniMax-M3");
|
|
113
|
+
expect(osBeta.provider_connection).toBe("together-managed");
|
|
114
|
+
expect(osBeta.provider).toBe("together");
|
|
114
115
|
expect(osBeta.source).toBe("managed");
|
|
115
116
|
expect(osBeta.label).toBe("OS Beta");
|
|
117
|
+
expect(osBeta.effort).toBe("low");
|
|
118
|
+
expect(osBeta.topP).toBe(0.95);
|
|
116
119
|
|
|
117
120
|
const order = raw.llm.profileOrder;
|
|
118
121
|
expect(order.indexOf("os-beta")).toBe(order.indexOf("balanced") + 1);
|
|
@@ -128,6 +131,7 @@ describe("reconcileFlagGatedProfiles", () => {
|
|
|
128
131
|
expect(osBeta.status).toBe("disabled");
|
|
129
132
|
expect(osBeta.label).toBe("OS Beta (Managed)");
|
|
130
133
|
expect(osBeta.source).toBe("managed");
|
|
134
|
+
expect(osBeta.effort).toBe("low");
|
|
131
135
|
});
|
|
132
136
|
|
|
133
137
|
test("flag on is idempotent across repeated runs", () => {
|
|
@@ -150,6 +154,7 @@ describe("reconcileFlagGatedProfiles", () => {
|
|
|
150
154
|
raw.llm.profiles["os-beta"]!.label = "My OS Beta";
|
|
151
155
|
raw.llm.profiles["os-beta"]!.status = "disabled";
|
|
152
156
|
raw.llm.profiles["os-beta"]!.advisorEnabled = true;
|
|
157
|
+
raw.llm.profiles["os-beta"]!.topP = 0.8;
|
|
153
158
|
writeConfig(raw);
|
|
154
159
|
invalidateConfigCache();
|
|
155
160
|
|
|
@@ -159,7 +164,10 @@ describe("reconcileFlagGatedProfiles", () => {
|
|
|
159
164
|
expect(after.label).toBe("My OS Beta");
|
|
160
165
|
expect(after.status).toBe("disabled");
|
|
161
166
|
expect(after.advisorEnabled).toBe(true);
|
|
162
|
-
expect(after.
|
|
167
|
+
expect(after.topP).toBe(0.8);
|
|
168
|
+
expect(after.model).toBe("MiniMaxAI/MiniMax-M3");
|
|
169
|
+
expect(after.provider_connection).toBe("together-managed");
|
|
170
|
+
expect(after.effort).toBe("low");
|
|
163
171
|
});
|
|
164
172
|
|
|
165
173
|
test("flag off removes a managed os-beta and applies fallbacks", () => {
|
|
@@ -415,7 +415,7 @@
|
|
|
415
415
|
"scope": "assistant",
|
|
416
416
|
"key": "os-beta",
|
|
417
417
|
"label": "OS Beta",
|
|
418
|
-
"description": "Enable the OS Beta model profile (
|
|
418
|
+
"description": "Enable the OS Beta model profile (MiniMax M3 / Together) in the assistant's model profile selection.",
|
|
419
419
|
"defaultEnabled": false
|
|
420
420
|
}
|
|
421
421
|
]
|
|
@@ -164,19 +164,20 @@ export const OS_BETA_FEATURE_FLAG_KEY = "os-beta";
|
|
|
164
164
|
* Flag-gated managed profile. NOT in MANAGED_PROFILE_TEMPLATES, so the
|
|
165
165
|
* unconditional boot seed never creates it. Reconciled in/out by
|
|
166
166
|
* the flag-gated profile reconcile based on the `os-beta` feature flag.
|
|
167
|
-
* Balanced
|
|
167
|
+
* Balanced defaults, with lower reasoning effort while the profile is in beta.
|
|
168
168
|
*/
|
|
169
169
|
export const OS_BETA_PROFILE_TEMPLATE: ManagedProfileTemplate = {
|
|
170
|
-
|
|
171
|
-
provider: "
|
|
172
|
-
connectionName: "
|
|
170
|
+
intent: "balanced",
|
|
171
|
+
provider: "together",
|
|
172
|
+
connectionName: "together-managed",
|
|
173
173
|
source: "managed",
|
|
174
174
|
label: "OS Beta",
|
|
175
|
-
description: "
|
|
175
|
+
description: "Good balance of quality, cost, and speed, in beta",
|
|
176
176
|
maxTokens: 32000,
|
|
177
|
-
effort: "
|
|
177
|
+
effort: "low",
|
|
178
178
|
thinking: { enabled: true, streamThinking: true },
|
|
179
179
|
contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS },
|
|
180
|
+
topP: 0.95,
|
|
180
181
|
};
|
|
181
182
|
|
|
182
183
|
// Membership here marks a name as managed. The route layer applies managed
|
|
@@ -23,12 +23,12 @@ const log = getLogger("sync-gated-profiles");
|
|
|
23
23
|
* Reconcile flag-gated managed profiles against the current feature-flag state.
|
|
24
24
|
*
|
|
25
25
|
* `seedInferenceProfiles()` runs synchronously at boot before feature flags are
|
|
26
|
-
* available, so the OS Beta profile (
|
|
27
|
-
* here once flags have loaded. When the `os-beta` flag is on, the
|
|
28
|
-
* is created (ordered right after `balanced`); when it is off, a
|
|
29
|
-
* managed entry is removed with `profileOrder` / `activeProfile` /
|
|
30
|
-
* fallbacks. The reconcile is idempotent and never touches a
|
|
31
|
-
* the same name.
|
|
26
|
+
* available, so the OS Beta profile (MiniMax M3 / together-managed) is
|
|
27
|
+
* materialized here once flags have loaded. When the `os-beta` flag is on, the
|
|
28
|
+
* managed profile is created (ordered right after `balanced`); when it is off, a
|
|
29
|
+
* previously managed entry is removed with `profileOrder` / `activeProfile` /
|
|
30
|
+
* `advisorProfile` fallbacks. The reconcile is idempotent and never touches a
|
|
31
|
+
* user-owned profile of the same name.
|
|
32
32
|
*
|
|
33
33
|
* Returns whether the on-disk config changed.
|
|
34
34
|
*/
|
|
@@ -105,23 +105,22 @@ function enableProfile(
|
|
|
105
105
|
OS_BETA_PROFILE_TEMPLATE.connectionName,
|
|
106
106
|
) as Record<string, unknown>;
|
|
107
107
|
|
|
108
|
-
// BYOK installs seed managed profiles disabled: the
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// later reconciles.
|
|
108
|
+
// BYOK installs seed managed profiles disabled: the managed inference
|
|
109
|
+
// connection backing this profile isn't usable until the user enables it, so a
|
|
110
|
+
// fresh OS Beta entry starts disabled to avoid offering an unusable route. A
|
|
111
|
+
// user's own status override (preserved below) wins on later reconciles.
|
|
113
112
|
if (isByokMode && !previous) {
|
|
114
113
|
next.status = "disabled";
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
if (previous) {
|
|
118
|
-
//
|
|
119
|
-
// by key-presence so an explicit null (user cleared it) survives too.
|
|
117
|
+
// Preserve user-owned overrides across reconciles.
|
|
120
118
|
if ("label" in previous) next.label = previous.label;
|
|
121
119
|
if ("status" in previous) next.status = previous.status;
|
|
122
120
|
if ("advisorEnabled" in previous) {
|
|
123
121
|
next.advisorEnabled = previous.advisorEnabled;
|
|
124
122
|
}
|
|
123
|
+
if ("topP" in previous) next.topP = previous.topP;
|
|
125
124
|
}
|
|
126
125
|
|
|
127
126
|
let changed = false;
|
|
@@ -284,6 +284,19 @@ interface VellumLinkExtractResult {
|
|
|
284
284
|
* markdown links from assistant text and return corresponding directive
|
|
285
285
|
* requests. The text is NOT modified — the links remain as rendered markdown.
|
|
286
286
|
*/
|
|
287
|
+
/**
|
|
288
|
+
* Decode a vellum:// path segment, returning null on malformed percent-encoding
|
|
289
|
+
* (e.g. a literal `%` not followed by two hex digits). This prevents a single
|
|
290
|
+
* bad link from throwing URIError and aborting the entire assistant message.
|
|
291
|
+
*/
|
|
292
|
+
function safeDecodePath(rawPath: string): string | null {
|
|
293
|
+
try {
|
|
294
|
+
return decodeURIComponent(rawPath);
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
287
300
|
export function extractVellumLinks(text: string): VellumLinkExtractResult {
|
|
288
301
|
const directiveRequests: DirectiveRequest[] = [];
|
|
289
302
|
const parseWarnings: string[] = [];
|
|
@@ -294,9 +307,19 @@ export function extractVellumLinks(text: string): VellumLinkExtractResult {
|
|
|
294
307
|
const authority = m[2]!;
|
|
295
308
|
const rawPath = m[3]!;
|
|
296
309
|
|
|
310
|
+
const decodedPath = safeDecodePath(rawPath);
|
|
311
|
+
if (decodedPath === null) {
|
|
312
|
+
parseWarnings.push(
|
|
313
|
+
`Ignored vellum://${authority} link "${linkText}": malformed percent-encoding in path.`,
|
|
314
|
+
);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
297
318
|
if (authority === "workspace") {
|
|
298
319
|
// Strip the leading "/" to get a workspace-relative path
|
|
299
|
-
const path =
|
|
320
|
+
const path = decodedPath.startsWith("/")
|
|
321
|
+
? decodedPath.slice(1)
|
|
322
|
+
: decodedPath;
|
|
300
323
|
if (!path) {
|
|
301
324
|
parseWarnings.push(
|
|
302
325
|
`Ignored vellum://workspace link "${linkText}": empty path.`,
|
|
@@ -310,8 +333,8 @@ export function extractVellumLinks(text: string): VellumLinkExtractResult {
|
|
|
310
333
|
mimeType: undefined,
|
|
311
334
|
});
|
|
312
335
|
} else {
|
|
313
|
-
// host:
|
|
314
|
-
if (!
|
|
336
|
+
// host: decodedPath is already absolute (starts with /)
|
|
337
|
+
if (!decodedPath || decodedPath === "/") {
|
|
315
338
|
parseWarnings.push(
|
|
316
339
|
`Ignored vellum://host link "${linkText}": empty path.`,
|
|
317
340
|
);
|
|
@@ -319,7 +342,7 @@ export function extractVellumLinks(text: string): VellumLinkExtractResult {
|
|
|
319
342
|
}
|
|
320
343
|
directiveRequests.push({
|
|
321
344
|
source: "host",
|
|
322
|
-
path:
|
|
345
|
+
path: decodedPath,
|
|
323
346
|
filename: linkText || undefined,
|
|
324
347
|
mimeType: undefined,
|
|
325
348
|
});
|
|
@@ -54,6 +54,8 @@ describe("buildCheckinDescription", () => {
|
|
|
54
54
|
expect(html).toContain(
|
|
55
55
|
"https://www.vellum.ai/assistant/conversations/uuid-123?prompt=What%20would%20you%20recommend",
|
|
56
56
|
);
|
|
57
|
+
// Carries onboarding attribution for the calendar-event CTA.
|
|
58
|
+
expect(html).toContain("&utm_source=onboarding&utm_medium=calendar_event");
|
|
57
59
|
// Only sanitization-safe tags; the CTA is a bold link, not a styled button.
|
|
58
60
|
expect(html).toContain("<a href=");
|
|
59
61
|
expect(html).toContain("<strong>");
|
|
@@ -66,7 +66,7 @@ export function buildCheckinTitle({
|
|
|
66
66
|
* (`uuid`) pre-seeded with the first-week prompt.
|
|
67
67
|
*/
|
|
68
68
|
export function buildCheckinDescription(uuid: string): string {
|
|
69
|
-
const href = `https://www.vellum.ai/assistant/conversations/${uuid}?prompt=${CTA_ENCODED_PROMPT}`;
|
|
69
|
+
const href = `https://www.vellum.ai/assistant/conversations/${uuid}?prompt=${CTA_ENCODED_PROMPT}&utm_source=onboarding&utm_medium=calendar_event`;
|
|
70
70
|
return [
|
|
71
71
|
"<p>👋 <strong>Hi, it was great to meet you properly.</strong></p>",
|
|
72
72
|
"<p>You just set me up, and I've already started learning <strong>what you're working on</strong>. This 15 minutes is the natural place to put that to work. I'll walk you through one thing I'd like to do for you this week.</p>",
|
|
@@ -486,6 +486,74 @@ function readPlainObject(value: unknown): Record<string, unknown> | undefined {
|
|
|
486
486
|
return value as Record<string, unknown>;
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
+
function stripTransportHeadersRecursively(value: unknown): void {
|
|
490
|
+
if (Array.isArray(value)) {
|
|
491
|
+
for (const item of value) {
|
|
492
|
+
stripTransportHeadersRecursively(item);
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const object = readPlainObject(value);
|
|
498
|
+
if (!object) return;
|
|
499
|
+
const transport = readPlainObject(object.transport);
|
|
500
|
+
if (transport) delete transport.headers;
|
|
501
|
+
for (const child of Object.values(object)) {
|
|
502
|
+
stripTransportHeadersRecursively(child);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function containsTransportHeadersRecursively(value: unknown): boolean {
|
|
507
|
+
if (Array.isArray(value)) {
|
|
508
|
+
return value.some((item) => containsTransportHeadersRecursively(item));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const object = readPlainObject(value);
|
|
512
|
+
if (!object) return false;
|
|
513
|
+
const transport = readPlainObject(object.transport);
|
|
514
|
+
if (transport && Object.hasOwn(transport, "headers")) return true;
|
|
515
|
+
return Object.values(object).some((child) =>
|
|
516
|
+
containsTransportHeadersRecursively(child),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function sanitizeMcpTransportHeadersForSettingsRead(config: unknown): void {
|
|
521
|
+
const root = readPlainObject(config);
|
|
522
|
+
if (!root) return;
|
|
523
|
+
const mcp = readPlainObject(root.mcp);
|
|
524
|
+
if (!mcp || !Object.hasOwn(mcp, "servers")) return;
|
|
525
|
+
if (Array.isArray(mcp.servers)) {
|
|
526
|
+
stripTransportHeadersRecursively(mcp.servers);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const servers = readPlainObject(mcp.servers);
|
|
530
|
+
if (!servers) return;
|
|
531
|
+
for (const server of Object.values(servers)) {
|
|
532
|
+
stripTransportHeadersRecursively(server);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function patchContainsMcpTransportHeaders(patch: unknown): boolean {
|
|
537
|
+
const root = readPlainObject(patch);
|
|
538
|
+
const mcp = readPlainObject(root?.mcp);
|
|
539
|
+
if (!mcp || !Object.hasOwn(mcp, "servers")) return false;
|
|
540
|
+
if (Array.isArray(mcp.servers)) {
|
|
541
|
+
return containsTransportHeadersRecursively(mcp.servers);
|
|
542
|
+
}
|
|
543
|
+
const servers = readPlainObject(mcp.servers);
|
|
544
|
+
if (!servers) return false;
|
|
545
|
+
return Object.values(servers).some((server) =>
|
|
546
|
+
containsTransportHeadersRecursively(server),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function rejectMcpTransportHeaderWrite(patch: unknown): void {
|
|
551
|
+
if (!patchContainsMcpTransportHeaders(patch)) return;
|
|
552
|
+
throw new BadRequestError(
|
|
553
|
+
"MCP authentication headers must be managed through MCP server add/update APIs, not generic config writes.",
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
489
557
|
const WireProfileEntry = ProfileEntry.extend({
|
|
490
558
|
supportsVision: z.boolean().optional(),
|
|
491
559
|
})
|
|
@@ -688,6 +756,7 @@ const ConfigPatchRequestSchema = z
|
|
|
688
756
|
function handleGetConfig() {
|
|
689
757
|
try {
|
|
690
758
|
const config = applyContextDefaultsToRawConfig(loadRawConfig());
|
|
759
|
+
sanitizeMcpTransportHeadersForSettingsRead(config);
|
|
691
760
|
enrichProfilesWithVisionFlag(config);
|
|
692
761
|
return config;
|
|
693
762
|
} catch (err) {
|
|
@@ -840,6 +909,7 @@ async function handlePatchConfig({ body }: RouteHandlerArgs) {
|
|
|
840
909
|
throw new BadRequestError("Body must be a non-empty JSON object");
|
|
841
910
|
}
|
|
842
911
|
rejectManagedProfileDeletion(body as Record<string, unknown>);
|
|
912
|
+
rejectMcpTransportHeaderWrite(body);
|
|
843
913
|
|
|
844
914
|
const raw = loadRawConfig();
|
|
845
915
|
const patch = body as Record<string, unknown>;
|
|
@@ -848,6 +918,7 @@ async function handlePatchConfig({ body }: RouteHandlerArgs) {
|
|
|
848
918
|
await commitConfigWrite(raw, "patch");
|
|
849
919
|
|
|
850
920
|
const merged = applyContextDefaultsToRawConfig(loadRawConfig());
|
|
921
|
+
sanitizeMcpTransportHeadersForSettingsRead(merged);
|
|
851
922
|
enrichProfilesWithVisionFlag(merged);
|
|
852
923
|
return merged;
|
|
853
924
|
}
|
|
@@ -892,6 +963,7 @@ async function handleSetConfig({ body }: RouteHandlerArgs) {
|
|
|
892
963
|
const patchShape: Record<string, unknown> = {};
|
|
893
964
|
setNestedValue(patchShape, path, value);
|
|
894
965
|
rejectManagedProfileDeletion(patchShape);
|
|
966
|
+
rejectMcpTransportHeaderWrite(patchShape);
|
|
895
967
|
|
|
896
968
|
const raw = loadRawConfig();
|
|
897
969
|
setNestedValue(raw, path, value);
|
|
@@ -135,7 +135,10 @@ import type {
|
|
|
135
135
|
RuntimeMessagePayload,
|
|
136
136
|
SendMessageDeps,
|
|
137
137
|
} from "../http-types.js";
|
|
138
|
-
import {
|
|
138
|
+
import {
|
|
139
|
+
findLocalGuardianPrincipalId,
|
|
140
|
+
resolveActorPrincipalIdForLocalGuardian,
|
|
141
|
+
} from "../local-actor-identity.js";
|
|
139
142
|
import { resolveLocalPrincipalTrustContext } from "../local-principal-trust.js";
|
|
140
143
|
import * as pendingInteractions from "../pending-interactions.js";
|
|
141
144
|
import {
|
|
@@ -1509,10 +1512,13 @@ export async function handleSendMessage(
|
|
|
1509
1512
|
}
|
|
1510
1513
|
|
|
1511
1514
|
const isInteractive = isInteractiveInterface(sourceInterface);
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1514
|
-
//
|
|
1515
|
-
|
|
1515
|
+
// Translate the dev-bypass actor principal to the real guardian principal
|
|
1516
|
+
// before the same-actor host-proxy gate so web/iOS turns match the macOS
|
|
1517
|
+
// client's SSE-registered principal. No-op for real JWT principals in
|
|
1518
|
+
// non-dev-bypass deployments.
|
|
1519
|
+
const sourceActorPrincipalId = await resolveActorPrincipalIdForLocalGuardian(
|
|
1520
|
+
actorPrincipalId ?? undefined,
|
|
1521
|
+
);
|
|
1516
1522
|
// Bash/File/Transfer singletons are globally available via isAvailable() —
|
|
1517
1523
|
// no per-conversation gating needed. CU is per-conversation (owns step
|
|
1518
1524
|
// count, AX tree history, loop detection).
|