@cortexkit/opencode-magic-context 0.39.0 → 0.40.1
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/README.md +20 -0
- package/dist/agents/permissions.d.ts +5 -3
- package/dist/agents/permissions.d.ts.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/profiles.d.ts +22 -0
- package/dist/config/profiles.d.ts.map +1 -0
- package/dist/config/project-security.d.ts +3 -0
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +400 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/features/magic-context/fail-closed-block.d.ts +11 -1
- package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-failure.d.ts +11 -0
- package/dist/features/magic-context/memory/embedding-failure.d.ts.map +1 -0
- package/dist/features/magic-context/memory/embedding-local.d.ts +19 -0
- package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts +5 -0
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-provider.d.ts +3 -0
- package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts +2 -0
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/hooks/magic-context/format-embed-failure.d.ts +3 -0
- package/dist/hooks/magic-context/format-embed-failure.d.ts.map +1 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts +4 -3
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
- package/dist/hooks/magic-context/strip-content.d.ts +6 -0
- package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
- package/dist/hooks/magic-context/temporal-awareness.d.ts +1 -1
- package/dist/hooks/magic-context/temporal-awareness.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +1 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/{index-k2ts0wk1.js → index-x64cmpej.js} +507 -89
- package/dist/index.js +128 -166
- package/dist/plugin/hooks/create-session-hooks.d.ts +2 -0
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/{recomp-orchestrator-2pncxee7.js → recomp-orchestrator-dtgww0f0.js} +1 -1
- package/dist/shared/rpc-types.d.ts +2 -0
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +12 -1
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/status-detail-text.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/agents/permissions.ts +14 -4
- package/src/config/index.test.ts +361 -2
- package/src/config/index.ts +29 -10
- package/src/config/profiles.ts +106 -0
- package/src/config/project-security.hostile-matrix.test.ts +9 -0
- package/src/config/project-security.test.ts +17 -0
- package/src/config/project-security.ts +10 -0
- package/src/config/schema/magic-context.test.ts +128 -0
- package/src/config/schema/magic-context.ts +89 -0
- package/src/shared/rpc-types.ts +2 -0
- package/src/shared/rpc-utils.test.ts +32 -0
- package/src/shared/rpc-utils.ts +38 -13
- package/src/shared/status-detail-text.test.ts +2 -0
- package/src/shared/status-detail-text.ts +1 -0
- package/src/tui/data/context-db.ts +1 -0
- package/src/tui-compiled/data/context-db.ts +1 -0
|
@@ -123,9 +123,11 @@ function isPermissionMap(value: unknown): value is Record<string, unknown> {
|
|
|
123
123
|
* Add final `permission.task` denies for Magic Context's internal workers.
|
|
124
124
|
*
|
|
125
125
|
* OpenCode accepts either a whole-permission action or a pattern map for
|
|
126
|
-
* `permission.task`; its evaluator uses the last matching rule.
|
|
127
|
-
* whole-permission form, retain
|
|
128
|
-
* exact agent-id denies
|
|
126
|
+
* `permission.task`; its visibility evaluator uses the last matching rule.
|
|
127
|
+
* Normalize the whole-permission form, retain unrelated user rules, then append
|
|
128
|
+
* our exact agent-id denies. When the ambient task policy has a wildcard deny,
|
|
129
|
+
* re-emit that wildcard after the named denies so the Task tool is hidden rather
|
|
130
|
+
* than shown as a callable-but-refused tool.
|
|
129
131
|
*/
|
|
130
132
|
export function denyTaskRoutingToAgents(
|
|
131
133
|
permission: unknown,
|
|
@@ -143,8 +145,12 @@ export function denyTaskRoutingToAgents(
|
|
|
143
145
|
? task
|
|
144
146
|
: {};
|
|
145
147
|
const internalAgentIdSet = new Set(internalAgentIds);
|
|
148
|
+
const hasAmbientWildcardDeny = configuredTask["*"] === "deny";
|
|
146
149
|
const retainedTask = Object.fromEntries(
|
|
147
|
-
Object.entries(configuredTask).filter(
|
|
150
|
+
Object.entries(configuredTask).filter(
|
|
151
|
+
([agentId]) =>
|
|
152
|
+
!internalAgentIdSet.has(agentId) && (agentId !== "*" || !hasAmbientWildcardDeny),
|
|
153
|
+
),
|
|
148
154
|
);
|
|
149
155
|
|
|
150
156
|
return {
|
|
@@ -152,6 +158,10 @@ export function denyTaskRoutingToAgents(
|
|
|
152
158
|
task: {
|
|
153
159
|
...retainedTask,
|
|
154
160
|
...Object.fromEntries(internalAgentIds.map((agentId) => [agentId, "deny"])),
|
|
161
|
+
// OpenCode's Permission.disabled uses findLast. Re-inserting the
|
|
162
|
+
// ambient wildcard after our named routing denies keeps its whole-tool
|
|
163
|
+
// deny as the final match and makes the Task tool invisible.
|
|
164
|
+
...(hasAmbientWildcardDeny ? { "*": "deny" } : {}),
|
|
155
165
|
},
|
|
156
166
|
};
|
|
157
167
|
}
|
package/src/config/index.test.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
|
|
5
|
+
import { resolveHistorianModel } from "../shared/model-resolution";
|
|
6
6
|
import { loadPluginConfig, loadPluginConfigDetailed } from "./index";
|
|
7
|
+
import { resolveConfigProfile } from "./profiles";
|
|
7
8
|
import { RUST_COMPACTION_OFF_WARNING } from "./transform-mode";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -1014,6 +1015,364 @@ describe("transform_mode resolution", () => {
|
|
|
1014
1015
|
});
|
|
1015
1016
|
});
|
|
1016
1017
|
|
|
1018
|
+
describe("loadPluginConfig — user-owned model profiles", () => {
|
|
1019
|
+
it("merges user base, selected profile, then project config without losing profile fallback qualifiers", () => {
|
|
1020
|
+
const result = loadWithUserAndProjectConfig(
|
|
1021
|
+
JSON.stringify({
|
|
1022
|
+
profile: "personal",
|
|
1023
|
+
historian: {
|
|
1024
|
+
two_pass: true,
|
|
1025
|
+
opencode: {
|
|
1026
|
+
model: "anthropic/base-historian",
|
|
1027
|
+
fallback_models: ["anthropic/base-fallback"],
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
dreamer: {
|
|
1031
|
+
opencode: { model: "anthropic/base-dreamer" },
|
|
1032
|
+
},
|
|
1033
|
+
profiles: {
|
|
1034
|
+
work: {
|
|
1035
|
+
historian: {
|
|
1036
|
+
opencode: {
|
|
1037
|
+
model: { model: "anthropic/work-historian", variant: "high" },
|
|
1038
|
+
fallback_models: [
|
|
1039
|
+
{ model: "openai/work-fallback", variant: "low" },
|
|
1040
|
+
],
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
dreamer: {
|
|
1044
|
+
opencode: {
|
|
1045
|
+
model: "anthropic/work-dreamer",
|
|
1046
|
+
fallback_models: [
|
|
1047
|
+
{ model: "openai/work-dreamer-fallback", variant: "medium" },
|
|
1048
|
+
],
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
sidekick: { model: "anthropic/work-sidekick" },
|
|
1052
|
+
},
|
|
1053
|
+
personal: {
|
|
1054
|
+
historian: { opencode: { model: "anthropic/personal-historian" } },
|
|
1055
|
+
},
|
|
1056
|
+
},
|
|
1057
|
+
}),
|
|
1058
|
+
JSON.stringify({
|
|
1059
|
+
profile: "work",
|
|
1060
|
+
memory: { enabled: false },
|
|
1061
|
+
}),
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
expect(result.profile).toBe("work");
|
|
1065
|
+
expect(result.memory.enabled).toBe(false);
|
|
1066
|
+
expect(result.historian?.two_pass).toBe(true);
|
|
1067
|
+
expect(result.historian?.opencode).toEqual({
|
|
1068
|
+
model: { model: "anthropic/work-historian", variant: "high" },
|
|
1069
|
+
fallback_models: [{ model: "openai/work-fallback", variant: "low" }],
|
|
1070
|
+
});
|
|
1071
|
+
expect(result.dreamer?.opencode?.model).toBe("anthropic/work-dreamer");
|
|
1072
|
+
expect(result.dreamer?.opencode?.fallback_models).toEqual([
|
|
1073
|
+
{ model: "openai/work-dreamer-fallback", variant: "medium" },
|
|
1074
|
+
]);
|
|
1075
|
+
expect(result.sidekick?.model).toBe("anthropic/work-sidekick");
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it("uses project selection over user selection and falls back to the base on an unknown name", () => {
|
|
1079
|
+
const userConfig = JSON.stringify({
|
|
1080
|
+
profile: "personal",
|
|
1081
|
+
historian: { opencode: { model: "anthropic/base" } },
|
|
1082
|
+
profiles: {
|
|
1083
|
+
personal: { historian: { opencode: { model: "anthropic/personal" } } },
|
|
1084
|
+
work: { historian: { opencode: { model: "anthropic/work" } } },
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
const userDefault = loadWithUserAndProjectConfig(userConfig, "{}");
|
|
1089
|
+
const projectOverride = loadWithUserAndProjectConfig(userConfig, '{"profile":"work"}');
|
|
1090
|
+
const unknownProject = loadWithUserAndProjectConfig(userConfig, '{"profile":"missing"}');
|
|
1091
|
+
|
|
1092
|
+
expect(userDefault.profile).toBe("personal");
|
|
1093
|
+
expect(userDefault.historian?.opencode?.model).toBe("anthropic/personal");
|
|
1094
|
+
expect(projectOverride.profile).toBe("work");
|
|
1095
|
+
expect(projectOverride.historian?.opencode?.model).toBe("anthropic/work");
|
|
1096
|
+
expect(unknownProject.profile).toBeUndefined();
|
|
1097
|
+
expect(unknownProject.enabled).toBe(true);
|
|
1098
|
+
expect(unknownProject.historian?.opencode?.model).toBe("anthropic/base");
|
|
1099
|
+
expect(unknownProject.configWarnings?.join("\n")).toContain(
|
|
1100
|
+
'Unknown profile "missing" selected by project config',
|
|
1101
|
+
);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it("rejects invalid profile definitions with a warning instead of applying their fields", () => {
|
|
1105
|
+
const result = loadWithUserConfig(
|
|
1106
|
+
JSON.stringify({
|
|
1107
|
+
profile: "unsafe",
|
|
1108
|
+
historian: { opencode: { model: "anthropic/base" } },
|
|
1109
|
+
profiles: {
|
|
1110
|
+
unsafe: {
|
|
1111
|
+
embedding: { provider: "off" },
|
|
1112
|
+
},
|
|
1113
|
+
},
|
|
1114
|
+
}),
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
expect(result.profile).toBeUndefined();
|
|
1118
|
+
expect(result.embedding.provider).toBe("local");
|
|
1119
|
+
expect(result.historian?.opencode?.model).toBe("anthropic/base");
|
|
1120
|
+
expect(result.configWarnings?.join("\n")).toContain("Ignoring profiles from user config");
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it("strips hostile project profile definitions while allowing only user-owned definitions", () => {
|
|
1124
|
+
const result = loadWithUserAndProjectConfig(
|
|
1125
|
+
JSON.stringify({
|
|
1126
|
+
historian: { opencode: { model: "anthropic/user-base" } },
|
|
1127
|
+
}),
|
|
1128
|
+
JSON.stringify({
|
|
1129
|
+
profile: "work",
|
|
1130
|
+
profiles: {
|
|
1131
|
+
work: {
|
|
1132
|
+
historian: { opencode: { model: "attacker/project-profile" } },
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
}),
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
expect(result.profile).toBeUndefined();
|
|
1139
|
+
expect(result.historian?.opencode?.model).toBe("anthropic/user-base");
|
|
1140
|
+
expect(result.configWarnings?.join("\n")).toContain(
|
|
1141
|
+
"Ignoring profiles from project config",
|
|
1142
|
+
);
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
it("falls back loudly for inherited profile names without changing the resolved base config", () => {
|
|
1146
|
+
const userConfig = JSON.stringify({
|
|
1147
|
+
historian: { opencode: { model: "anthropic/base", fallback_models: ["openai/base"] } },
|
|
1148
|
+
profiles: { known: { historian: { opencode: { model: "anthropic/known" } } } },
|
|
1149
|
+
});
|
|
1150
|
+
const base = loadWithUserConfig(userConfig);
|
|
1151
|
+
const { configWarnings: _baseWarnings, ...baseWithoutWarnings } = base;
|
|
1152
|
+
const hostileNames = [
|
|
1153
|
+
"__proto__",
|
|
1154
|
+
"constructor",
|
|
1155
|
+
"prototype",
|
|
1156
|
+
"toString",
|
|
1157
|
+
"hasOwnProperty",
|
|
1158
|
+
"valueOf",
|
|
1159
|
+
];
|
|
1160
|
+
|
|
1161
|
+
for (const name of hostileNames) {
|
|
1162
|
+
const resolved = loadWithUserAndProjectConfig(
|
|
1163
|
+
userConfig,
|
|
1164
|
+
JSON.stringify({ profile: name }),
|
|
1165
|
+
);
|
|
1166
|
+
const { configWarnings: _warnings, ...resolvedWithoutWarnings } = resolved;
|
|
1167
|
+
|
|
1168
|
+
expect(resolved.profile).toBeUndefined();
|
|
1169
|
+
expect(resolved.configWarnings?.join("\n")).toContain(
|
|
1170
|
+
`Unknown profile "${name}" selected by project config`,
|
|
1171
|
+
);
|
|
1172
|
+
expect(resolvedWithoutWarnings).toEqual(baseWithoutWarnings);
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it("ignores a profile inherited from a polluted Object prototype", () => {
|
|
1177
|
+
const profileName = "profile_overlay_pollution_canary";
|
|
1178
|
+
const previousDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, profileName);
|
|
1179
|
+
const userBase = { historian: { opencode: { model: "anthropic/base" } } };
|
|
1180
|
+
// Null-prototype input isolates lookup behavior from strict schema validation of inherited data.
|
|
1181
|
+
const profileOpenCode = Object.assign(Object.create(null), {
|
|
1182
|
+
model: "anthropic/known",
|
|
1183
|
+
});
|
|
1184
|
+
const profileHistorian = Object.assign(Object.create(null), {
|
|
1185
|
+
opencode: profileOpenCode,
|
|
1186
|
+
});
|
|
1187
|
+
const knownProfile = Object.assign(Object.create(null), {
|
|
1188
|
+
historian: profileHistorian,
|
|
1189
|
+
});
|
|
1190
|
+
const profiles = Object.assign(Object.create(null), {
|
|
1191
|
+
known: knownProfile,
|
|
1192
|
+
}) as Record<string, unknown>;
|
|
1193
|
+
Object.defineProperty(Object.prototype, profileName, {
|
|
1194
|
+
value: { historian: { opencode: { model: "attacker/inherited-overlay" } } },
|
|
1195
|
+
enumerable: true,
|
|
1196
|
+
configurable: true,
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
try {
|
|
1200
|
+
const resolution = resolveConfigProfile({
|
|
1201
|
+
userRaw: {
|
|
1202
|
+
...userBase,
|
|
1203
|
+
profiles,
|
|
1204
|
+
},
|
|
1205
|
+
projectRaw: { profile: profileName },
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
expect(resolution.activeProfile).toBeUndefined();
|
|
1209
|
+
expect(resolution.overlay).toEqual({});
|
|
1210
|
+
expect(resolution.userBase).toEqual(userBase);
|
|
1211
|
+
expect(resolution.projectBase).toEqual({});
|
|
1212
|
+
expect(resolution.warnings).toEqual([
|
|
1213
|
+
`Unknown profile "${profileName}" selected by project config; using base config without a profile.`,
|
|
1214
|
+
]);
|
|
1215
|
+
} finally {
|
|
1216
|
+
if (previousDescriptor) {
|
|
1217
|
+
Object.defineProperty(Object.prototype, profileName, previousDescriptor);
|
|
1218
|
+
} else {
|
|
1219
|
+
delete (Object.prototype as Record<string, unknown>)[profileName];
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it("treats empty, null, and non-string project selectors as no selection", () => {
|
|
1225
|
+
const userConfig = JSON.stringify({
|
|
1226
|
+
profile: "personal",
|
|
1227
|
+
historian: { opencode: { model: "anthropic/base" } },
|
|
1228
|
+
profiles: {
|
|
1229
|
+
personal: { historian: { opencode: { model: "anthropic/personal" } } },
|
|
1230
|
+
},
|
|
1231
|
+
});
|
|
1232
|
+
const invalidProjectSelectors = ["", null, { name: "work" }];
|
|
1233
|
+
|
|
1234
|
+
for (const profile of invalidProjectSelectors) {
|
|
1235
|
+
const result = loadWithUserAndProjectConfig(userConfig, JSON.stringify({ profile }));
|
|
1236
|
+
|
|
1237
|
+
expect(result.profile).toBe("personal");
|
|
1238
|
+
expect(result.historian?.opencode?.model).toBe("anthropic/personal");
|
|
1239
|
+
expect(result.configWarnings?.join("\n")).toContain(
|
|
1240
|
+
"Ignoring invalid profile selection from project config",
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
it("preserves an untouched historian Pi block when a profile overlays OpenCode", () => {
|
|
1246
|
+
const piBase = {
|
|
1247
|
+
model: { model: "github-copilot/base", thinking_level: "high" },
|
|
1248
|
+
fallback_models: [
|
|
1249
|
+
{ model: "openai/base-fallback", thinking_level: "minimal" },
|
|
1250
|
+
{ model: "github-copilot/second-base-fallback", thinking_level: "medium" },
|
|
1251
|
+
],
|
|
1252
|
+
thinking_level: "high",
|
|
1253
|
+
};
|
|
1254
|
+
const result = loadWithUserConfig(
|
|
1255
|
+
JSON.stringify({
|
|
1256
|
+
profile: "work",
|
|
1257
|
+
historian: {
|
|
1258
|
+
opencode: { model: "anthropic/base-opencode" },
|
|
1259
|
+
pi: piBase,
|
|
1260
|
+
},
|
|
1261
|
+
profiles: {
|
|
1262
|
+
work: {
|
|
1263
|
+
historian: { opencode: { model: "anthropic/work-opencode" } },
|
|
1264
|
+
},
|
|
1265
|
+
},
|
|
1266
|
+
}),
|
|
1267
|
+
);
|
|
1268
|
+
|
|
1269
|
+
expect(result.historian?.opencode).toEqual({ model: "anthropic/work-opencode" });
|
|
1270
|
+
expect(result.historian?.pi).toEqual(piBase);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("replaces a base fallback_models array wholesale when a profile provides one", () => {
|
|
1274
|
+
const result = loadWithUserConfig(
|
|
1275
|
+
JSON.stringify({
|
|
1276
|
+
profile: "work",
|
|
1277
|
+
historian: {
|
|
1278
|
+
opencode: {
|
|
1279
|
+
model: "anthropic/base",
|
|
1280
|
+
fallback_models: [
|
|
1281
|
+
{ model: "openai/base-first", variant: "low" },
|
|
1282
|
+
{ model: "anthropic/base-second", variant: "high" },
|
|
1283
|
+
],
|
|
1284
|
+
},
|
|
1285
|
+
},
|
|
1286
|
+
profiles: {
|
|
1287
|
+
work: {
|
|
1288
|
+
historian: {
|
|
1289
|
+
opencode: {
|
|
1290
|
+
fallback_models: [{ model: "google/work-only", variant: "medium" }],
|
|
1291
|
+
},
|
|
1292
|
+
},
|
|
1293
|
+
},
|
|
1294
|
+
},
|
|
1295
|
+
}),
|
|
1296
|
+
);
|
|
1297
|
+
|
|
1298
|
+
expect(result.historian?.opencode?.fallback_models).toEqual([
|
|
1299
|
+
{ model: "google/work-only", variant: "medium" },
|
|
1300
|
+
]);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
it("resolves profiles independently for two projects in one process", () => {
|
|
1304
|
+
const xdg = mkdtempSync(join(tmpdir(), "mc-profile-isolation-user-"));
|
|
1305
|
+
const projectA = mkdtempSync(join(tmpdir(), "mc-profile-isolation-a-"));
|
|
1306
|
+
const projectB = mkdtempSync(join(tmpdir(), "mc-profile-isolation-b-"));
|
|
1307
|
+
const previousXdg = process.env.XDG_CONFIG_HOME;
|
|
1308
|
+
mkdirSync(join(xdg, "cortexkit"), { recursive: true });
|
|
1309
|
+
mkdirSync(join(projectA, ".cortexkit"), { recursive: true });
|
|
1310
|
+
mkdirSync(join(projectB, ".cortexkit"), { recursive: true });
|
|
1311
|
+
writeFileSync(
|
|
1312
|
+
join(xdg, "cortexkit", "magic-context.jsonc"),
|
|
1313
|
+
JSON.stringify({
|
|
1314
|
+
profiles: {
|
|
1315
|
+
work: { historian: { opencode: { model: "anthropic/work" } } },
|
|
1316
|
+
personal: {
|
|
1317
|
+
historian: { opencode: { model: "anthropic/personal" } },
|
|
1318
|
+
},
|
|
1319
|
+
},
|
|
1320
|
+
}),
|
|
1321
|
+
);
|
|
1322
|
+
writeFileSync(join(projectA, ".cortexkit", "magic-context.jsonc"), '{"profile":"work"}');
|
|
1323
|
+
writeFileSync(
|
|
1324
|
+
join(projectB, ".cortexkit", "magic-context.jsonc"),
|
|
1325
|
+
'{"profile":"personal"}',
|
|
1326
|
+
);
|
|
1327
|
+
process.env.XDG_CONFIG_HOME = xdg;
|
|
1328
|
+
try {
|
|
1329
|
+
const work = loadPluginConfig(projectA);
|
|
1330
|
+
const personal = loadPluginConfig(projectB);
|
|
1331
|
+
const workAgain = loadPluginConfig(projectA);
|
|
1332
|
+
expect(work.profile).toBe("work");
|
|
1333
|
+
expect(personal.profile).toBe("personal");
|
|
1334
|
+
expect(workAgain.profile).toBe("work");
|
|
1335
|
+
expect(work.historian?.opencode?.model).toBe("anthropic/work");
|
|
1336
|
+
expect(personal.historian?.opencode?.model).toBe("anthropic/personal");
|
|
1337
|
+
expect(workAgain.historian?.opencode?.model).toBe("anthropic/work");
|
|
1338
|
+
} finally {
|
|
1339
|
+
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
1340
|
+
else process.env.XDG_CONFIG_HOME = previousXdg;
|
|
1341
|
+
rmSync(xdg, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
1342
|
+
rmSync(projectA, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
1343
|
+
rmSync(projectB, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it("feeds the selected profile through the existing historian model resolver", () => {
|
|
1348
|
+
const config = loadWithUserAndProjectConfig(
|
|
1349
|
+
JSON.stringify({
|
|
1350
|
+
profiles: {
|
|
1351
|
+
work: {
|
|
1352
|
+
historian: {
|
|
1353
|
+
opencode: {
|
|
1354
|
+
model: { model: "anthropic/work", variant: "high" },
|
|
1355
|
+
fallback_models: [
|
|
1356
|
+
{ model: "openai/work-fallback", variant: "low" },
|
|
1357
|
+
],
|
|
1358
|
+
},
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1361
|
+
},
|
|
1362
|
+
}),
|
|
1363
|
+
'{"profile":"work"}',
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
// Profile selection changes only the normal resolved model input. The
|
|
1367
|
+
// existing model-switch identity path still sees canonical model IDs and
|
|
1368
|
+
// qualifier-distinct fallback attempts; profiles add no separate cache-bust class.
|
|
1369
|
+
expect(resolveHistorianModel(config, "opencode")).toEqual({
|
|
1370
|
+
primary: { model: "anthropic/work", qualifier: "high" },
|
|
1371
|
+
fallbacks: [{ model: "openai/work-fallback", qualifier: "low" }],
|
|
1372
|
+
});
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1017
1376
|
describe("loadPluginConfigDetailed — prompt-surface registration owner", () => {
|
|
1018
1377
|
it("captures the user default before project guidance routing is merged", () => {
|
|
1019
1378
|
const xdg = mkdtempSync(join(tmpdir(), "mc-config-prompt-surface-"));
|
package/src/config/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./migrate-config-location";
|
|
15
15
|
import { migrateDreamerV2 } from "./migrate-dreamer-v2";
|
|
16
16
|
import { migrateLegacyExperimental } from "./migrate-experimental";
|
|
17
|
+
import { resolveConfigProfile } from "./profiles";
|
|
17
18
|
import {
|
|
18
19
|
constrainProjectThresholdOverrides,
|
|
19
20
|
dropInheritedEmbeddingKeyOnRedirect,
|
|
@@ -548,11 +549,6 @@ export function loadPluginConfigDetailed(directory: string): LoadResultDetailed
|
|
|
548
549
|
: null;
|
|
549
550
|
|
|
550
551
|
const allWarnings: string[] = [];
|
|
551
|
-
let mergedRaw: Record<string, unknown> = {};
|
|
552
|
-
// Threshold trust boundary is relative to the USER/default effective config:
|
|
553
|
-
// a cloned repo may delay compaction, but it may not lower thresholds in a
|
|
554
|
-
// way that forces extra historian work on the user's account.
|
|
555
|
-
const trustedBaseConfig = parsePluginConfig(userLoaded?.config ?? {});
|
|
556
552
|
|
|
557
553
|
if (userLegacyFallback.source) {
|
|
558
554
|
allWarnings.push(
|
|
@@ -576,26 +572,48 @@ export function loadPluginConfigDetailed(directory: string): LoadResultDetailed
|
|
|
576
572
|
|
|
577
573
|
if (userLoaded) {
|
|
578
574
|
allWarnings.push(...userLoaded.warnings.map((w) => `[user config] ${w}`));
|
|
579
|
-
mergedRaw = deepMergeRawConfig(mergedRaw, userLoaded.config);
|
|
580
575
|
}
|
|
581
576
|
|
|
577
|
+
let projectRaw: Record<string, unknown> = {};
|
|
582
578
|
if (projectLoaded) {
|
|
583
579
|
allWarnings.push(...projectLoaded.warnings.map((w) => `[project config] ${w}`));
|
|
584
|
-
|
|
580
|
+
projectRaw = { ...projectLoaded.config };
|
|
585
581
|
for (const warning of stripUnsafeProjectConfigFields(projectRaw)) {
|
|
586
582
|
allWarnings.push(`[project config] ${warning}`);
|
|
587
583
|
}
|
|
588
|
-
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Resolve profiles at the single user→project merge choke point. The profile
|
|
587
|
+
// definition is parsed from the trusted user tier, then its validated model
|
|
588
|
+
// overlay is merged before the untrusted project config. The raw selector is
|
|
589
|
+
// consumed here; only the resolved name remains as status metadata.
|
|
590
|
+
const profileResolution = resolveConfigProfile({
|
|
591
|
+
userRaw: userLoaded?.config ?? {},
|
|
592
|
+
projectRaw,
|
|
593
|
+
});
|
|
594
|
+
allWarnings.push(...profileResolution.warnings.map((warning) => `[config] ${warning}`));
|
|
595
|
+
const trustedProfiledRaw = deepMergeRawConfig(
|
|
596
|
+
profileResolution.userBase,
|
|
597
|
+
profileResolution.overlay,
|
|
598
|
+
);
|
|
599
|
+
let mergedRaw = trustedProfiledRaw;
|
|
600
|
+
// Threshold trust boundary is relative to the USER/default effective config:
|
|
601
|
+
// a cloned repo may delay compaction, but it may not lower thresholds in a
|
|
602
|
+
// way that forces extra historian work on the user's account.
|
|
603
|
+
const trustedBaseConfig = parsePluginConfig(trustedProfiledRaw);
|
|
604
|
+
|
|
605
|
+
if (projectLoaded) {
|
|
606
|
+
mergedRaw = deepMergeRawConfig(mergedRaw, profileResolution.projectBase);
|
|
589
607
|
for (const warning of dropInheritedEmbeddingKeyOnRedirect(
|
|
590
608
|
projectRaw,
|
|
591
609
|
mergedRaw,
|
|
592
|
-
|
|
610
|
+
profileResolution.userBase,
|
|
593
611
|
)) {
|
|
594
612
|
allWarnings.push(`[project config] ${warning}`);
|
|
595
613
|
}
|
|
596
614
|
for (const warning of constrainProjectThresholdOverrides({
|
|
597
615
|
mergedRaw,
|
|
598
|
-
projectRaw,
|
|
616
|
+
projectRaw: profileResolution.projectBase,
|
|
599
617
|
trustedBaseConfig,
|
|
600
618
|
})) {
|
|
601
619
|
allWarnings.push(`[project config] ${warning}`);
|
|
@@ -604,6 +622,7 @@ export function loadPluginConfigDetailed(directory: string): LoadResultDetailed
|
|
|
604
622
|
|
|
605
623
|
const recoveredTopLevelKeys: string[] = [];
|
|
606
624
|
const config = parsePluginConfig(mergedRaw, recoveredTopLevelKeys);
|
|
625
|
+
if (profileResolution.activeProfile) config.profile = profileResolution.activeProfile;
|
|
607
626
|
setOutputReserveConfig(config.output_reserve);
|
|
608
627
|
setWindowOverlayPath(config.models?.window_overlay_path);
|
|
609
628
|
if (config.configWarnings?.length) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { ConfigProfilesSchema } from "./schema/magic-context";
|
|
2
|
+
|
|
3
|
+
export interface ProfileResolution {
|
|
4
|
+
/** User config minus the profile declarations and selector. */
|
|
5
|
+
userBase: Record<string, unknown>;
|
|
6
|
+
/** Project config minus the profile declarations and selector. */
|
|
7
|
+
projectBase: Record<string, unknown>;
|
|
8
|
+
/** The validated user-owned overlay for the active profile, if any. */
|
|
9
|
+
overlay: Record<string, unknown>;
|
|
10
|
+
/** The selected profile name, absent when no valid profile is active. */
|
|
11
|
+
activeProfile?: string;
|
|
12
|
+
warnings: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ProfileSelection {
|
|
16
|
+
declared: boolean;
|
|
17
|
+
name?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function withoutProfileFields(raw: Record<string, unknown>): Record<string, unknown> {
|
|
21
|
+
const copy = { ...raw };
|
|
22
|
+
delete copy.profile;
|
|
23
|
+
delete copy.profiles;
|
|
24
|
+
return copy;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readProfileSelection(raw: Record<string, unknown>): ProfileSelection {
|
|
28
|
+
if (!Object.hasOwn(raw, "profile")) return { declared: false };
|
|
29
|
+
const value = raw.profile;
|
|
30
|
+
if (typeof value !== "string") return { declared: true };
|
|
31
|
+
const name = value.trim();
|
|
32
|
+
return name.length > 0 ? { declared: true, name } : { declared: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the user-owned profile overlay without retaining any process-global
|
|
37
|
+
* selection state. Callers merge `userBase`, `overlay`, then `projectBase` for
|
|
38
|
+
* every project load, so a Pi /cd or OpenCode multi-root session cannot borrow
|
|
39
|
+
* another repository's selected profile.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveConfigProfile(args: {
|
|
42
|
+
userRaw: Record<string, unknown>;
|
|
43
|
+
projectRaw: Record<string, unknown>;
|
|
44
|
+
}): ProfileResolution {
|
|
45
|
+
const warnings: string[] = [];
|
|
46
|
+
const userSelection = readProfileSelection(args.userRaw);
|
|
47
|
+
const projectSelection = readProfileSelection(args.projectRaw);
|
|
48
|
+
const selection = projectSelection.name
|
|
49
|
+
? { name: projectSelection.name, source: "project" }
|
|
50
|
+
: userSelection.name
|
|
51
|
+
? { name: userSelection.name, source: "user" }
|
|
52
|
+
: undefined;
|
|
53
|
+
|
|
54
|
+
if (projectSelection.declared && !projectSelection.name) {
|
|
55
|
+
warnings.push(
|
|
56
|
+
"Ignoring invalid profile selection from project config; expected a non-empty string.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!projectSelection.declared && userSelection.declared && !userSelection.name) {
|
|
60
|
+
warnings.push(
|
|
61
|
+
"Ignoring invalid profile selection from user config; expected a non-empty string.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let profiles: Record<string, Record<string, unknown>> = {};
|
|
66
|
+
if (Object.hasOwn(args.userRaw, "profiles")) {
|
|
67
|
+
const parsed = ConfigProfilesSchema.safeParse(args.userRaw.profiles);
|
|
68
|
+
if (parsed.success) {
|
|
69
|
+
profiles = parsed.data as Record<string, Record<string, unknown>>;
|
|
70
|
+
} else {
|
|
71
|
+
warnings.push(
|
|
72
|
+
"Ignoring profiles from user config: invalid profile configuration; profiles may contain only historian/dreamer harness model blocks and sidekick model-selection fields.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!selection) {
|
|
78
|
+
return {
|
|
79
|
+
userBase: withoutProfileFields(args.userRaw),
|
|
80
|
+
projectBase: withoutProfileFields(args.projectRaw),
|
|
81
|
+
overlay: {},
|
|
82
|
+
warnings,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!Object.hasOwn(profiles, selection.name)) {
|
|
87
|
+
warnings.push(
|
|
88
|
+
`Unknown profile "${selection.name}" selected by ${selection.source} config; using base config without a profile.`,
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
userBase: withoutProfileFields(args.userRaw),
|
|
92
|
+
projectBase: withoutProfileFields(args.projectRaw),
|
|
93
|
+
overlay: {},
|
|
94
|
+
warnings,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const overlay = profiles[selection.name];
|
|
99
|
+
return {
|
|
100
|
+
userBase: withoutProfileFields(args.userRaw),
|
|
101
|
+
projectBase: withoutProfileFields(args.projectRaw),
|
|
102
|
+
overlay,
|
|
103
|
+
activeProfile: selection.name,
|
|
104
|
+
warnings,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -143,6 +143,12 @@ function buildHostileProjectConfig(): Record<string, unknown> {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
return {
|
|
146
|
+
profile: "work",
|
|
147
|
+
profiles: {
|
|
148
|
+
work: {
|
|
149
|
+
historian: { opencode: { model: "hostile/profile-historian" } },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
146
152
|
mural: { enabled: true, model: "hostile/top-mural" },
|
|
147
153
|
experimental: { mural: { enabled: true, model: "hostile/experimental-mural" } },
|
|
148
154
|
historian: {
|
|
@@ -379,6 +385,9 @@ describe("hostile-config stripping matrix", () => {
|
|
|
379
385
|
}
|
|
380
386
|
|
|
381
387
|
expect(survived).toEqual([]);
|
|
388
|
+
expect(hasOwnPath(raw, "profiles")).toBe(false);
|
|
389
|
+
expect(readPath(raw, "profile")).toBe("work");
|
|
390
|
+
expect(warningText).toContain("Ignoring profiles from project config");
|
|
382
391
|
expect(removed.sort()).toEqual(
|
|
383
392
|
[
|
|
384
393
|
...historianUserOnlyPaths(),
|
|
@@ -7,6 +7,23 @@ import {
|
|
|
7
7
|
} from "./project-security";
|
|
8
8
|
|
|
9
9
|
describe("stripUnsafeProjectConfigFields", () => {
|
|
10
|
+
it("strips profile definitions from project config but leaves profile selection", () => {
|
|
11
|
+
const raw: Record<string, unknown> = {
|
|
12
|
+
profile: "work",
|
|
13
|
+
profiles: {
|
|
14
|
+
work: {
|
|
15
|
+
historian: { opencode: { model: "attacker/model" } },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const warnings = stripUnsafeProjectConfigFields(raw);
|
|
21
|
+
|
|
22
|
+
expect(raw.profile).toBe("work");
|
|
23
|
+
expect(raw.profiles).toBeUndefined();
|
|
24
|
+
expect(warnings.join("\n")).toContain("Ignoring profiles from project config");
|
|
25
|
+
});
|
|
26
|
+
|
|
10
27
|
it("strips auto_update from project config", () => {
|
|
11
28
|
const raw: Record<string, unknown> = { auto_update: false, dreamer: { model: "x" } };
|
|
12
29
|
const warnings = stripUnsafeProjectConfigFields(raw);
|
|
@@ -256,6 +256,9 @@ function makeProjectThresholdWarning(field: string, reason: string): string {
|
|
|
256
256
|
* over the user config. Returns warnings describing what was ignored.
|
|
257
257
|
*
|
|
258
258
|
* Closes:
|
|
259
|
+
* - `profiles` — profile definitions choose hidden-agent models and must stay
|
|
260
|
+
* in trusted user config; a repository may select a named profile but cannot
|
|
261
|
+
* supply its contents.
|
|
259
262
|
* - `auto_update` — a repo must not suppress plugin self-updates (which can
|
|
260
263
|
* carry security fixes).
|
|
261
264
|
* - `fail_closed_blocking` — a repo must not un-block (or force-block) the
|
|
@@ -300,6 +303,13 @@ function makeProjectThresholdWarning(field: string, reason: string): string {
|
|
|
300
303
|
export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknown>): string[] {
|
|
301
304
|
const warnings: string[] = [];
|
|
302
305
|
|
|
306
|
+
if ("profiles" in projectRaw) {
|
|
307
|
+
delete projectRaw.profiles;
|
|
308
|
+
warnings.push(
|
|
309
|
+
"Ignoring profiles from project config (security: profile definitions are user-level only; a repository may select a named user profile with profile).",
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
303
313
|
if ("auto_update" in projectRaw) {
|
|
304
314
|
delete projectRaw.auto_update;
|
|
305
315
|
warnings.push(
|