@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.
Files changed (66) hide show
  1. package/README.md +20 -0
  2. package/dist/agents/permissions.d.ts +5 -3
  3. package/dist/agents/permissions.d.ts.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/profiles.d.ts +22 -0
  6. package/dist/config/profiles.d.ts.map +1 -0
  7. package/dist/config/project-security.d.ts +3 -0
  8. package/dist/config/project-security.d.ts.map +1 -1
  9. package/dist/config/schema/magic-context.d.ts +400 -0
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/features/magic-context/fail-closed-block.d.ts +11 -1
  12. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  13. package/dist/features/magic-context/memory/embedding-failure.d.ts +11 -0
  14. package/dist/features/magic-context/memory/embedding-failure.d.ts.map +1 -0
  15. package/dist/features/magic-context/memory/embedding-local.d.ts +19 -0
  16. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  17. package/dist/features/magic-context/memory/embedding-openai.d.ts +5 -0
  18. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  19. package/dist/features/magic-context/memory/embedding-provider.d.ts +3 -0
  20. package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
  21. package/dist/features/magic-context/project-embedding-registry.d.ts +2 -0
  22. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  23. package/dist/features/magic-context/storage-db.d.ts +1 -1
  24. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  25. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  26. package/dist/hooks/magic-context/format-embed-failure.d.ts +3 -0
  27. package/dist/hooks/magic-context/format-embed-failure.d.ts.map +1 -0
  28. package/dist/hooks/magic-context/inject-compartments.d.ts +4 -3
  29. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  30. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  31. package/dist/hooks/magic-context/strip-content.d.ts +6 -0
  32. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  33. package/dist/hooks/magic-context/temporal-awareness.d.ts +1 -1
  34. package/dist/hooks/magic-context/temporal-awareness.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +1 -0
  36. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  37. package/dist/{index-k2ts0wk1.js → index-x64cmpej.js} +507 -89
  38. package/dist/index.js +128 -166
  39. package/dist/plugin/hooks/create-session-hooks.d.ts +2 -0
  40. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  41. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  42. package/dist/{recomp-orchestrator-2pncxee7.js → recomp-orchestrator-dtgww0f0.js} +1 -1
  43. package/dist/shared/rpc-types.d.ts +2 -0
  44. package/dist/shared/rpc-types.d.ts.map +1 -1
  45. package/dist/shared/rpc-utils.d.ts +12 -1
  46. package/dist/shared/rpc-utils.d.ts.map +1 -1
  47. package/dist/shared/status-detail-text.d.ts.map +1 -1
  48. package/dist/tui/data/context-db.d.ts.map +1 -1
  49. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  50. package/package.json +2 -1
  51. package/src/agents/permissions.ts +14 -4
  52. package/src/config/index.test.ts +361 -2
  53. package/src/config/index.ts +29 -10
  54. package/src/config/profiles.ts +106 -0
  55. package/src/config/project-security.hostile-matrix.test.ts +9 -0
  56. package/src/config/project-security.test.ts +17 -0
  57. package/src/config/project-security.ts +10 -0
  58. package/src/config/schema/magic-context.test.ts +128 -0
  59. package/src/config/schema/magic-context.ts +89 -0
  60. package/src/shared/rpc-types.ts +2 -0
  61. package/src/shared/rpc-utils.test.ts +32 -0
  62. package/src/shared/rpc-utils.ts +38 -13
  63. package/src/shared/status-detail-text.test.ts +2 -0
  64. package/src/shared/status-detail-text.ts +1 -0
  65. package/src/tui/data/context-db.ts +1 -0
  66. 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. Normalize the
127
- * whole-permission form, retain every unrelated user rule, then append our
128
- * exact agent-id denies after both the user's task patterns and any `*` rule.
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(([agentId]) => !internalAgentIdSet.has(agentId)),
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
  }
@@ -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-"));
@@ -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
- const projectRaw = { ...projectLoaded.config };
580
+ projectRaw = { ...projectLoaded.config };
585
581
  for (const warning of stripUnsafeProjectConfigFields(projectRaw)) {
586
582
  allWarnings.push(`[project config] ${warning}`);
587
583
  }
588
- mergedRaw = deepMergeRawConfig(mergedRaw, projectRaw);
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
- userLoaded?.config,
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(