@elliotding/ai-agent-mcp 0.2.20 → 0.2.22

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/dist/client-adapters/codex-adapter.d.ts +48 -0
  2. package/dist/client-adapters/codex-adapter.d.ts.map +1 -0
  3. package/dist/client-adapters/codex-adapter.js +69 -0
  4. package/dist/client-adapters/codex-adapter.js.map +1 -0
  5. package/dist/client-adapters/cursor-adapter.d.ts +36 -0
  6. package/dist/client-adapters/cursor-adapter.d.ts.map +1 -0
  7. package/dist/client-adapters/cursor-adapter.js +65 -0
  8. package/dist/client-adapters/cursor-adapter.js.map +1 -0
  9. package/dist/client-adapters/index.d.ts +89 -0
  10. package/dist/client-adapters/index.d.ts.map +1 -0
  11. package/dist/client-adapters/index.js +49 -0
  12. package/dist/client-adapters/index.js.map +1 -0
  13. package/dist/config/index.d.ts +19 -1
  14. package/dist/config/index.d.ts.map +1 -1
  15. package/dist/config/index.js +12 -2
  16. package/dist/config/index.js.map +1 -1
  17. package/dist/prompts/manager.d.ts +18 -0
  18. package/dist/prompts/manager.d.ts.map +1 -1
  19. package/dist/prompts/manager.js +51 -5
  20. package/dist/prompts/manager.js.map +1 -1
  21. package/dist/server/http.d.ts +10 -6
  22. package/dist/server/http.d.ts.map +1 -1
  23. package/dist/server/http.js +83 -89
  24. package/dist/server/http.js.map +1 -1
  25. package/dist/server/streamable-http.d.ts +28 -0
  26. package/dist/server/streamable-http.d.ts.map +1 -0
  27. package/dist/server/streamable-http.js +126 -0
  28. package/dist/server/streamable-http.js.map +1 -0
  29. package/dist/server.d.ts +8 -1
  30. package/dist/server.d.ts.map +1 -1
  31. package/dist/server.js +60 -96
  32. package/dist/server.js.map +1 -1
  33. package/dist/telemetry/manager.d.ts +5 -0
  34. package/dist/telemetry/manager.d.ts.map +1 -1
  35. package/dist/telemetry/manager.js +5 -0
  36. package/dist/telemetry/manager.js.map +1 -1
  37. package/dist/tools/manage-subscription.d.ts.map +1 -1
  38. package/dist/tools/manage-subscription.js +33 -2
  39. package/dist/tools/manage-subscription.js.map +1 -1
  40. package/dist/tools/policy-generator.d.ts +31 -0
  41. package/dist/tools/policy-generator.d.ts.map +1 -0
  42. package/dist/tools/policy-generator.js +53 -0
  43. package/dist/tools/policy-generator.js.map +1 -0
  44. package/dist/tools/resolve-prompt-content.d.ts.map +1 -1
  45. package/dist/tools/resolve-prompt-content.js +14 -1
  46. package/dist/tools/resolve-prompt-content.js.map +1 -1
  47. package/dist/tools/search-resources.d.ts.map +1 -1
  48. package/dist/tools/search-resources.js +24 -0
  49. package/dist/tools/search-resources.js.map +1 -1
  50. package/dist/tools/sync-resources.d.ts.map +1 -1
  51. package/dist/tools/sync-resources.js +232 -58
  52. package/dist/tools/sync-resources.js.map +1 -1
  53. package/dist/tools/uninstall-resource.d.ts.map +1 -1
  54. package/dist/tools/uninstall-resource.js +21 -3
  55. package/dist/tools/uninstall-resource.js.map +1 -1
  56. package/dist/types/tools.d.ts +113 -4
  57. package/dist/types/tools.d.ts.map +1 -1
  58. package/dist/utils/codex-paths.d.ts +35 -0
  59. package/dist/utils/codex-paths.d.ts.map +1 -0
  60. package/dist/utils/codex-paths.js +49 -0
  61. package/dist/utils/codex-paths.js.map +1 -0
  62. package/package.json +1 -1
  63. package/dist/transport/sse.d.ts +0 -29
  64. package/dist/transport/sse.d.ts.map +0 -1
  65. package/dist/transport/sse.js +0 -271
  66. package/dist/transport/sse.js.map +0 -1
@@ -62,6 +62,9 @@ const errors_1 = require("../types/errors");
62
62
  const index_js_1 = require("../telemetry/index.js");
63
63
  const index_js_2 = require("../prompts/index.js");
64
64
  const md_reference_expander_js_1 = require("../utils/md-reference-expander.js");
65
+ const index_js_3 = require("../client-adapters/index.js");
66
+ const policy_generator_js_1 = require("./policy-generator.js");
67
+ const index_js_4 = require("../config/index.js");
65
68
  const downloadCache = new Map();
66
69
  function syncCacheKey(userToken, resourceId) {
67
70
  return `${userToken}::${resourceId}`;
@@ -85,6 +88,73 @@ function extractFrontmatterDescription(content) {
85
88
  }
86
89
  return undefined;
87
90
  }
91
+ // ── Render-safety: base64-encode non-markdown file content ─────────────────
92
+ //
93
+ // Background: shipping raw shell / script bodies inside `content` /
94
+ // `expected_content` fields of LocalAction items causes Cursor's renderer
95
+ // (marked.js inline-link regex) to enter catastrophic backtracking on
96
+ // `$()`, `[[ ]]`, URL-like substrings, and freezes the main thread.
97
+ // See workspace/Issues/mcp-sync-complex-skill-cursor-crash.md.
98
+ //
99
+ // First attempt (markdown_fence_v1) tried to wrap content in a code fence,
100
+ // but Cursor's renderer feeds the *raw JSON literal* to marked.js, where
101
+ // `\n` is escaped as `\\n` and the fence never starts a real new line, so
102
+ // the fence is just inert text and the inline-link regex still walks the
103
+ // shell-script chars. Real-world test confirmed the freeze persisted.
104
+ //
105
+ // Final fix: for any non-`.md`/`.mdc` text file, base64-encode the content
106
+ // and set `encoding: 'base64'`. The base64 alphabet (`[A-Za-z0-9+/=]`)
107
+ // contains none of the metacharacters that marked.js's link regex looks
108
+ // for (`[`, `]`, `(`, `)`, `<`, `>`, `!`), so backtracking cannot happen
109
+ // no matter how long the string is.
110
+ //
111
+ // Markdown / mdc files stay UTF-8: they are valid markdown by themselves
112
+ // and field testing has not produced a freeze on plain markdown content.
113
+ const MARKDOWN_EXTENSIONS = new Set(['md', 'mdc']);
114
+ function fileExtension(filePath) {
115
+ const m = /\.([A-Za-z0-9]+)$/.exec(filePath);
116
+ return m ? m[1].toLowerCase() : '';
117
+ }
118
+ /**
119
+ * Encode a text file body for safe transport in the JSON tool response.
120
+ *
121
+ * Returns `{ content, encoding }` ready to spread into a write_file action.
122
+ *
123
+ * For `.md` / `.mdc` files: `{ content: rawContent, encoding: 'utf8' }`
124
+ * (no transformation, valid markdown is safe to render directly).
125
+ *
126
+ * For everything else: `{ content: base64(rawContent), encoding: 'base64' }`
127
+ * to defuse Cursor's marked.js link-regex catastrophic backtracking on
128
+ * shell/script characters.
129
+ *
130
+ * If the caller already declared a `priorEncoding` of 'base64' (binary file
131
+ * pre-encoded by the upstream), we pass content through unchanged.
132
+ */
133
+ function encodeForRender(filePath, rawContent, priorEncoding) {
134
+ if (priorEncoding === 'base64') {
135
+ return { content: rawContent, encoding: 'base64' };
136
+ }
137
+ if (MARKDOWN_EXTENSIONS.has(fileExtension(filePath))) {
138
+ return { content: rawContent, encoding: 'utf8' };
139
+ }
140
+ return {
141
+ content: Buffer.from(rawContent, 'utf8').toString('base64'),
142
+ encoding: 'base64',
143
+ };
144
+ }
145
+ /**
146
+ * Same as encodeForRender, but produces the field shape required by
147
+ * CheckFileAction (`expected_content` + a sibling encoding hint stored as
148
+ * `expected_content_encoding`). Returns the encoding even when 'utf8' so
149
+ * the AI Agent has an unambiguous signal.
150
+ */
151
+ function encodeForCheck(filePath, rawContent, priorEncoding) {
152
+ const enc = encodeForRender(filePath, rawContent, priorEncoding);
153
+ return {
154
+ expected_content: enc.content,
155
+ expected_content_encoding: enc.encoding,
156
+ };
157
+ }
88
158
  async function syncResources(params) {
89
159
  const startTime = Date.now();
90
160
  const typedParams = params;
@@ -104,6 +174,13 @@ async function syncResources(params) {
104
174
  ? new Set(typedParams.resource_ids)
105
175
  : null;
106
176
  const confirmedFullSync = typedParams._confirmed_full_sync === true;
177
+ // Resolve client adapter: prefer the caller-supplied agent_profile, fall
178
+ // back to the server-wide config.agentProfile (set via CSP_AGENT_PROFILE).
179
+ const resolvedProfile = typedParams.agent_profile ?? index_js_4.config.agentProfile ?? 'cursor';
180
+ const clientAdapter = index_js_3.adapterRegistry.get(resolvedProfile);
181
+ // Collect per-rule content for Codex policy aggregation (stage 4).
182
+ // Each entry is raw rule markdown to be merged into csp-routing-policy.md.
183
+ const codexRuleContents = [];
107
184
  (0, logger_1.logToolStep)('sync_resources', 'Parameters validated', {
108
185
  mode,
109
186
  scope,
@@ -248,17 +325,18 @@ async function syncResources(params) {
248
325
  localActions.push({
249
326
  action: 'check_file',
250
327
  path: `${skillDir}/${scriptFile.relative_path}`,
251
- expected_content: scriptFile.content,
328
+ ...encodeForCheck(scriptFile.relative_path, scriptFile.content, scriptFile.encoding),
252
329
  resource_id: sub.id,
253
330
  resource_name: sub.name,
254
331
  resource_type: sub.type,
255
332
  });
256
333
  }
257
334
  // Also check the manifest file
335
+ const proxyScript = metadata.script_files[0];
258
336
  localActions.push({
259
337
  action: 'check_file',
260
338
  path: `${(0, cursor_paths_1.getCspAgentRootDirForClient)()}/.manifests/${sub.name}.md`,
261
- expected_content: metadata.script_files[0]?.content ?? '', // Use first script as proxy
339
+ ...encodeForCheck(proxyScript?.relative_path ?? '', proxyScript?.content ?? '', proxyScript?.encoding), // Use first script as proxy
262
340
  resource_id: sub.id,
263
341
  resource_name: sub.name,
264
342
  resource_type: sub.type,
@@ -327,7 +405,7 @@ async function syncResources(params) {
327
405
  localActions.push({
328
406
  action: 'check_file',
329
407
  path: checkPath,
330
- expected_content: file.content,
408
+ ...encodeForCheck(file.path, file.content),
331
409
  resource_id: sub.id,
332
410
  resource_name: sub.name,
333
411
  resource_type: sub.type,
@@ -340,7 +418,7 @@ async function syncResources(params) {
340
418
  localActions.push({
341
419
  action: 'check_file',
342
420
  path: mcpJsonPath,
343
- expected_content: file.content,
421
+ ...encodeForCheck(file.path, file.content),
344
422
  resource_id: sub.id,
345
423
  resource_name: sub.name,
346
424
  resource_type: sub.type,
@@ -352,7 +430,7 @@ async function syncResources(params) {
352
430
  localActions.push({
353
431
  action: 'check_file',
354
432
  path: checkPath,
355
- expected_content: file.content,
433
+ ...encodeForCheck(file.path, file.content),
356
434
  resource_id: sub.id,
357
435
  resource_name: sub.name,
358
436
  resource_type: sub.type,
@@ -484,13 +562,12 @@ async function syncResources(params) {
484
562
  localActions.push({
485
563
  action: 'write_file',
486
564
  path: `${skillDir}/${firstScript.path}`,
487
- content: firstScript.content,
488
- encoding: 'utf8',
565
+ ...encodeForRender(firstScript.path, firstScript.content),
489
566
  mode: firstScript.path.includes('/scripts/') ? '0755' : undefined,
490
567
  // Atomic update marker: client checks manifest FIRST
491
568
  is_skill_manifest: true,
492
569
  // SKILL.md content for version comparison (stored separately in .manifests/)
493
- skill_manifest_content: rawContent,
570
+ skill_manifest_content: Buffer.from(rawContent, "utf8").toString("base64"),
494
571
  });
495
572
  }
496
573
  // 2. Remaining script files (client writes these ONLY if manifest changed)
@@ -501,8 +578,7 @@ async function syncResources(params) {
501
578
  localActions.push({
502
579
  action: 'write_file',
503
580
  path: `${skillDir}/${scriptFile.path}`,
504
- content: scriptFile.content,
505
- encoding: 'utf8',
581
+ ...encodeForRender(scriptFile.path, scriptFile.content),
506
582
  mode: scriptFile.path.includes('/scripts/') ? '0755' : undefined,
507
583
  });
508
584
  }
@@ -532,11 +608,10 @@ async function syncResources(params) {
532
608
  localActions.push({
533
609
  action: 'write_file',
534
610
  path: `${skillDir}/${firstScript.relative_path}`,
535
- content: firstScript.content,
536
- encoding: firstScript.encoding ?? 'utf8',
611
+ ...encodeForRender(firstScript.relative_path, firstScript.content, firstScript.encoding),
537
612
  mode: firstScript.mode,
538
613
  is_skill_manifest: true,
539
- skill_manifest_content: rawContent,
614
+ skill_manifest_content: Buffer.from(rawContent, "utf8").toString("base64"),
540
615
  });
541
616
  }
542
617
  for (let i = 1; i < metadata.script_files.length; i++) {
@@ -546,8 +621,7 @@ async function syncResources(params) {
546
621
  localActions.push({
547
622
  action: 'write_file',
548
623
  path: `${skillDir}/${scriptFile.relative_path}`,
549
- content: scriptFile.content,
550
- encoding: scriptFile.encoding ?? 'utf8',
624
+ ...encodeForRender(scriptFile.relative_path, scriptFile.content, scriptFile.encoding),
551
625
  mode: scriptFile.mode,
552
626
  });
553
627
  }
@@ -672,8 +746,10 @@ async function syncResources(params) {
672
746
  // not on this (possibly remote Linux) server.
673
747
  if (sub.type === 'mcp') {
674
748
  const mcpConfigFile = resourceFiles.find((f) => path.basename(f.path) === 'mcp-config.json');
675
- // ~/.cursor/mcp.json on the user's machine
676
- const mcpJsonPath = `${(0, cursor_paths_1.getCursorRootDirForClient)()}/mcp.json`;
749
+ // Config path differs per client: mcp.json (Cursor) vs config.toml (Codex)
750
+ const mcpJsonPath = resolvedProfile === 'codex'
751
+ ? clientAdapter.getMcpConfigPath() // ~/.codex/config.toml
752
+ : `${(0, cursor_paths_1.getCursorRootDirForClient)()}/mcp.json`;
677
753
  // ── Optimization: skip if already configured (incremental mode only) ────
678
754
  // In incremental mode, if the AI Agent reports this MCP server is already
679
755
  // in ~/.cursor/mcp.json, skip downloading and generating write_file actions.
@@ -721,7 +797,25 @@ async function syncResources(params) {
721
797
  catch {
722
798
  logger_1.logger.warn({ resourceId: sub.id, resourceName: sub.name }, 'sync_resources: failed to parse mcp-config.json — treating as empty config');
723
799
  }
724
- if (typeof cfg['command'] === 'string') {
800
+ if (resolvedProfile === 'codex') {
801
+ // ── Codex MCP: inject server entry into config.toml via merge_toml ──
802
+ // The Codex CLI reads MCP server configuration from ~/.codex/config.toml.
803
+ // We emit a merge_toml action for the AI Agent to update that file.
804
+ // The value is the JSON-serialised mcp-config entry so the agent can
805
+ // parse and embed it in the correct TOML table.
806
+ const tomlPath = clientAdapter.getMcpConfigPath();
807
+ const serverName = cfg['name'] ?? sub.name;
808
+ localActions.push({
809
+ action: 'merge_toml',
810
+ toml_path: tomlPath,
811
+ key: `mcp.servers.${serverName}`,
812
+ value: JSON.stringify(cfg),
813
+ overwrite: false,
814
+ });
815
+ logger_1.logger.info({ resourceId: sub.id, resourceName: sub.name, serverName, tomlPath, profile: 'codex' }, 'sync_resources: Codex MCP — merge_toml action queued');
816
+ (0, logger_1.logToolStep)('sync_resources', 'Codex MCP: merge_toml queued', { resourceId: sub.id });
817
+ }
818
+ else if (typeof cfg['command'] === 'string') {
725
819
  // ── Format A: local executable ──────────────────────────────────
726
820
  const installDir = `${(0, cursor_paths_1.getCursorTypeDirForClient)('mcp')}/${sub.name}`;
727
821
  const writeActions = [];
@@ -733,7 +827,7 @@ async function syncResources(params) {
733
827
  localActions.push({
734
828
  action: 'write_file',
735
829
  path: fileDest,
736
- content: file.content,
830
+ ...encodeForRender(file.path, file.content),
737
831
  });
738
832
  writeActions.push(fileDest);
739
833
  }
@@ -803,43 +897,57 @@ async function syncResources(params) {
803
897
  }
804
898
  else {
805
899
  // No mcp-config.json: heuristic fallback
806
- const installDir = `${(0, cursor_paths_1.getCursorTypeDirForClient)('mcp')}/${sub.name}`;
807
- const writeActions = [];
808
- for (const file of resourceFiles) {
809
- const normalised = path.normalize(file.path);
810
- if (normalised.startsWith('..'))
811
- continue;
812
- const fileDest = `${installDir}/${normalised}`;
900
+ if (resolvedProfile === 'codex') {
901
+ // Codex: emit a bare merge_toml with minimal entry
902
+ const tomlPath = clientAdapter.getMcpConfigPath();
813
903
  localActions.push({
814
- action: 'write_file',
815
- path: fileDest,
816
- content: file.content,
904
+ action: 'merge_toml',
905
+ toml_path: tomlPath,
906
+ key: `mcp.servers.${sub.name}`,
907
+ value: JSON.stringify({ command: 'node', args: [] }),
908
+ overwrite: false,
817
909
  });
818
- writeActions.push(fileDest);
910
+ logger_1.logger.info({ resourceId: sub.id, resourceName: sub.name, tomlPath, profile: 'codex' }, 'sync_resources: Codex MCP heuristic — merge_toml action queued (no mcp-config.json)');
911
+ }
912
+ else {
913
+ const installDir = `${(0, cursor_paths_1.getCursorTypeDirForClient)('mcp')}/${sub.name}`;
914
+ const writeActions = [];
915
+ for (const file of resourceFiles) {
916
+ const normalised = path.normalize(file.path);
917
+ if (normalised.startsWith('..'))
918
+ continue;
919
+ const fileDest = `${installDir}/${normalised}`;
920
+ localActions.push({
921
+ action: 'write_file',
922
+ path: fileDest,
923
+ ...encodeForRender(file.path, file.content),
924
+ });
925
+ writeActions.push(fileDest);
926
+ }
927
+ const jsEntry = resourceFiles.find((f) => f.path.endsWith('.js'));
928
+ const pyEntry = resourceFiles.find((f) => f.path.endsWith('.py'));
929
+ const entryFile = jsEntry ?? pyEntry ?? resourceFiles[0];
930
+ const cmd = jsEntry ? 'node' : 'python3';
931
+ const entryPath = `${installDir}/${entryFile?.path ?? ''}`;
932
+ localActions.push({
933
+ action: 'merge_mcp_json',
934
+ mcp_json_path: mcpJsonPath,
935
+ server_name: sub.name,
936
+ entry: { command: cmd, args: [entryPath] },
937
+ skip_if_exists: true,
938
+ });
939
+ logger_1.logger.info({
940
+ resourceId: sub.id,
941
+ resourceName: sub.name,
942
+ format: 'heuristic',
943
+ installDir,
944
+ mcpJsonPath,
945
+ cmd,
946
+ entryPath,
947
+ writeFiles: writeActions,
948
+ }, 'sync_resources: MCP heuristic fallback — write_file + merge_mcp_json actions queued');
949
+ (0, logger_1.logToolStep)('sync_resources', 'MCP heuristic fallback: write_file + merge_mcp_json queued', { resourceId: sub.id });
819
950
  }
820
- const jsEntry = resourceFiles.find((f) => f.path.endsWith('.js'));
821
- const pyEntry = resourceFiles.find((f) => f.path.endsWith('.py'));
822
- const entryFile = jsEntry ?? pyEntry ?? resourceFiles[0];
823
- const cmd = jsEntry ? 'node' : 'python3';
824
- const entryPath = `${installDir}/${entryFile?.path ?? ''}`;
825
- localActions.push({
826
- action: 'merge_mcp_json',
827
- mcp_json_path: mcpJsonPath,
828
- server_name: sub.name,
829
- entry: { command: cmd, args: [entryPath] },
830
- skip_if_exists: true,
831
- });
832
- logger_1.logger.info({
833
- resourceId: sub.id,
834
- resourceName: sub.name,
835
- format: 'heuristic',
836
- installDir,
837
- mcpJsonPath,
838
- cmd,
839
- entryPath,
840
- writeFiles: writeActions,
841
- }, 'sync_resources: MCP heuristic fallback — write_file + merge_mcp_json actions queued');
842
- (0, logger_1.logToolStep)('sync_resources', 'MCP heuristic fallback: write_file + merge_mcp_json queued', { resourceId: sub.id });
843
951
  }
844
952
  tally.synced++;
845
953
  details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
@@ -853,11 +961,25 @@ async function syncResources(params) {
853
961
  // or has different content, the AI writes it unconditionally, which also
854
962
  // recovers files that were accidentally deleted by the user.
855
963
  //
856
- // DUAL-LAYER STRATEGY (v1.6):
964
+ // DUAL-LAYER STRATEGY (v1.6, Cursor only):
857
965
  // - scope='global' → write to ~/.cursor/rules/ only (macOS support)
858
966
  // - scope='workspace' → write to ${WORKSPACE}/.cursor/rules/ only (Windows support)
859
967
  // - scope='all' → write to BOTH locations (maximum compatibility)
968
+ //
969
+ // CODEX STRATEGY: rules are aggregated into csp-routing-policy.md and injected
970
+ // via developer_instructions in config.toml (no .mdc files written to disk).
860
971
  if (sub.type === 'rule') {
972
+ if (resolvedProfile === 'codex') {
973
+ // Collect rule content for policy aggregation; no individual file actions.
974
+ for (const file of resourceFiles) {
975
+ codexRuleContents.push({ name: sub.name, content: file.content });
976
+ }
977
+ logger_1.logger.info({ resourceId: sub.id, resourceName: sub.name, profile: 'codex' }, 'sync_resources: Rule collected for Codex policy aggregation');
978
+ tally.synced++;
979
+ details.push({ id: sub.id, name: sub.name, action: 'synced', version: resourceVersion });
980
+ continue;
981
+ }
982
+ // ── Cursor: dual-layer .mdc file strategy (unchanged) ──────────────
861
983
  const writeActions = [];
862
984
  // Determine target directories based on scope parameter
863
985
  const targetDirs = [];
@@ -878,7 +1000,7 @@ async function syncResources(params) {
878
1000
  localActions.push({
879
1001
  action: 'write_file',
880
1002
  path: destPath,
881
- content: file.content,
1003
+ ...encodeForRender(file.path, file.content),
882
1004
  });
883
1005
  writeActions.push({ destPath, contentLength: file.content.length });
884
1006
  }
@@ -936,7 +1058,43 @@ async function syncResources(params) {
936
1058
  else if (resourceIds) {
937
1059
  logger_1.logger.info({ resourceIdsFilter: [...resourceIds], expectedPromptCount: expectedPromptNames.size }, 'sync_resources: skipping pruneStalePrompts — resource_ids filter active (partial sync)');
938
1060
  }
939
- // ── Step 5: Health score ───────────────────────────────────────────────
1061
+ // ── Step 5 (Codex): Aggregate collected rules into policy actions ──────
1062
+ // If any rule content was collected during this sync run, materialise the
1063
+ // csp-routing-policy.md file and emit a merge_toml action to inject it
1064
+ // into developer_instructions in ~/.codex/config.toml.
1065
+ let restartRequired = false;
1066
+ let restartHint;
1067
+ if (resolvedProfile === 'codex' && codexRuleContents.length > 0) {
1068
+ try {
1069
+ const policyStrategy = clientAdapter.getPolicyStrategy();
1070
+ const policyContent = (0, policy_generator_js_1.generatePolicyContent)(codexRuleContents);
1071
+ const policyFile = policyStrategy.policyFile ?? '~/.csp-ai-agent/codex/csp-routing-policy.md';
1072
+ const tomlPath = policyStrategy.configTomlPath ?? '~/.codex/config.toml';
1073
+ const tomlKey = policyStrategy.configTomlKey ?? 'developer_instructions';
1074
+ // 1. Write the policy markdown file
1075
+ localActions.push({
1076
+ action: 'write_file',
1077
+ path: policyFile,
1078
+ content: policyContent,
1079
+ encoding: 'utf8',
1080
+ });
1081
+ // 2. Inject the policy file reference into developer_instructions
1082
+ localActions.push({
1083
+ action: 'merge_toml',
1084
+ toml_path: tomlPath,
1085
+ key: tomlKey,
1086
+ value: `Please read and follow the CSP routing policy at: ${policyFile}`,
1087
+ overwrite: false,
1088
+ });
1089
+ restartRequired = true;
1090
+ restartHint = `CSP routing policy has been updated. Please restart Codex for the policy to take effect: codex`;
1091
+ logger_1.logger.info({ profile: 'codex', policyFile, tomlPath, ruleCount: codexRuleContents.length }, 'sync_resources: Codex policy materialised — merge_toml action queued');
1092
+ }
1093
+ catch (err) {
1094
+ logger_1.logger.error({ error: err instanceof Error ? err.message : String(err) }, 'sync_resources: Failed to generate Codex policy');
1095
+ }
1096
+ }
1097
+ // ── Step 6: Health score ───────────────────────────────────────────────
940
1098
  const healthScore = tally.total > 0
941
1099
  ? Math.round(((tally.synced + tally.cached + tally.skipped) / tally.total) * 100)
942
1100
  : 100;
@@ -947,6 +1105,7 @@ async function syncResources(params) {
947
1105
  details,
948
1106
  ...(skippedResources.length > 0 ? { skipped_resources: skippedResources } : {}),
949
1107
  ...(localActions.length > 0 ? { local_actions_required: localActions } : {}),
1108
+ ...(restartRequired ? { restart_required: true, restart_hint: restartHint } : {}),
950
1109
  };
951
1110
  const duration = Date.now() - startTime;
952
1111
  (0, logger_1.logToolCall)('sync_resources', 'user-id', params, duration);
@@ -1055,16 +1214,31 @@ exports.syncResourcesTool = {
1055
1214
  'This allows the server to skip downloading MCP resources that are already configured, ' +
1056
1215
  'significantly reducing API calls, network traffic, and local action overhead. ' +
1057
1216
  '\n' +
1217
+ '⚠️ BASE64 DECODE — APPLIES TO BOTH write_file AND check_file: ' +
1218
+ ' When `encoding === "base64"` (write_file) or `expected_content_encoding === "base64"` ' +
1219
+ ' (check_file), the corresponding `content` / `expected_content` field is base64-encoded ' +
1220
+ ' by the server. The server does this for ALL non-.md/.mdc files (shell scripts, configs, ' +
1221
+ ' binary blobs) to keep Cursor\'s renderer from triggering catastrophic regex backtracking ' +
1222
+ ' on raw script characters ($(), [[ ]], URLs etc.) — see prior incident notes. ' +
1223
+ ' AI Agent MUST decode base64 BEFORE writing to disk or comparing against local content: ' +
1224
+ ' write_file: fileBytes = Buffer.from(action.content, "base64") (write as binary buffer) ' +
1225
+ ' check_file: decoded = Buffer.from(action.expected_content, "base64").toString("utf8") ' +
1226
+ ' then string-compare against fs.readFileSync(action.path, "utf8") ' +
1227
+ ' When the encoding field is "utf8" (or omitted), the value is the literal file body — ' +
1228
+ ' write or compare as-is, no decoding. ' +
1058
1229
  'For write_file actions: ' +
1059
1230
  ' SPECIAL HANDLING FOR COMPLEX SKILLS (when action has `is_skill_manifest: true`): ' +
1060
1231
  ' (1) This action is the FIRST script file — check skill version using manifest FIRST. ' +
1061
1232
  ' (2) Read manifest file at {CSP_AGENT_ROOT}/.manifests/<skill-name>.md (if exists). ' +
1062
1233
  ' Where {CSP_AGENT_ROOT} is a SIBLING of {CURSOR_ROOT} (same parent directory). ' +
1063
- ' (3) Compare manifest content with action.skill_manifest_content field (string equality). ' +
1234
+ ' (3) action.skill_manifest_content is ALWAYS base64-encoded first decode it: ' +
1235
+ ' decodedManifest = Buffer.from(action.skill_manifest_content, "base64").toString("utf8") ' +
1236
+ ' Then compare decodedManifest against the existing manifest file content (string equality). ' +
1064
1237
  ' (4) If identical: SKIP this action AND ALL SUBSEQUENT write_file actions for this skill. ' +
1065
1238
  ' (5) If different or manifest missing: ' +
1066
1239
  ' - PROCEED with this action and all subsequent write_file actions for this skill. ' +
1067
- ' - After writing all script files, write action.skill_manifest_content to {CSP_AGENT_ROOT}/.manifests/<skill-name>.md ' +
1240
+ ' - After writing all script files, write decodedManifest (the UTF-8 form, NOT the raw base64 string) ' +
1241
+ ' to {CSP_AGENT_ROOT}/.manifests/<skill-name>.md ' +
1068
1242
  ' (6) NOTE: SKILL.md is NEVER written to the skills directory (only to .manifests/) to prevent Cursor auto-discovery. ' +
1069
1243
  ' GENERAL HANDLING (when action has NO `is_skill_manifest` marker): ' +
1070
1244
  ' (1) Read the existing file at `path` (if it exists) using fs.readFile() or equivalent. ' +