codex-copilot-dx 0.6.8 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,9 +112,11 @@ ccdx doctor --online --profile all
112
112
  ### PM Studio opt-in patch
113
113
 
114
114
  CCDX can add the isolated Claude catalog to PM Studio without switching its
115
- active account or profile. GPT requests continue to use the bearer supplied by
116
- the current PM profile; exact enabled Anthropic model IDs use the isolated
117
- CCDX Claude profile. Neither credential is copied into the other application.
115
+ active account or profile. GPT requests keep PM Studio's native official GitHub
116
+ Copilot transport and the bearer supplied by the current PM profile. Model
117
+ discovery tries the compatible local CCDX merge first, and only exact enabled
118
+ Anthropic model IDs use the isolated CCDX Claude profile. Neither credential is
119
+ copied into the other application.
118
120
 
119
121
  The current patch recipes support only the exact, unmodified PM Studio 2.9.7
120
122
  and 2.9.10 bundles on macOS. This is exact-version support, not a version range:
@@ -155,12 +157,21 @@ matches the frozen official fingerprint. The staged and installed CCDX patch
155
157
  must still pass strict ad-hoc signature verification and match the complete
156
158
  patched-tree record in the backup manifest.
157
159
 
158
- The patch is App-wide: every PM profile sends its Copilot API traffic through
159
- the loopback-only `/pm-ccdx/*` relay. Run ordinary `ccdx` before opening PM
160
- Studio; if port 2026 is unavailable, PM Copilot requests cannot connect. The
161
- relay explicitly supports only the Copilot paths discovered in the validated
162
- 2.9.7 and 2.9.10 bundles (`GET /models` and `POST /chat/completions`, `/responses`, and
163
- `/embeddings`) and is not a general-purpose proxy.
160
+ The patch uses split origins, not an App-wide proxy. GPT and other non-Claude
161
+ inference requests remain on PM Studio's native official GitHub Copilot origin;
162
+ they do not enter CCDX or depend on port 2026. Model discovery first tries the
163
+ loopback-only `GET /pm-ccdx/models` merge and accepts it only when the relay
164
+ returns the `X-CCDX-PM-Relay: split-origin-v1` compatibility marker. If the
165
+ relay is unavailable or incompatible, model discovery safely falls back to the
166
+ official directory and GPT remains available. Exact enabled Claude chat uses
167
+ `POST /pm-ccdx/chat/completions` and fails closed if CCDX is unavailable; it
168
+ never falls back to the PM enterprise account. The relay does not expose
169
+ `/responses` or `/embeddings` and is not a general-purpose proxy.
170
+
171
+ The loopback relay trusts local callers that present a valid Copilot bearer.
172
+ CCDX uses the PM bearer only to fetch or validate official model access; it is
173
+ never used as the credential for the isolated Claude upstream request. Keep
174
+ port 2026 local to the machine.
164
175
 
165
176
  Patching replaces the vendor signature with an ad-hoc signature. PM Studio's
166
177
  updater is not disabled; an official update may overwrite the patch or may
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -73,7 +73,7 @@ function routingTarget(value) {
73
73
  function pmRouteSummary(requests = {}) {
74
74
  const routes = requests.by_route;
75
75
  if (!routes || typeof routes !== "object") return null;
76
- const names = ["pm_models", "pm_chat_completions", "pm_responses", "pm_embeddings"];
76
+ const names = ["pm_models", "pm_chat_completions"];
77
77
  const totals = names.reduce((summary, name) => {
78
78
  const route = routes[name] || {};
79
79
  summary.total += finiteNumber(route.total) || 0;
@@ -81,7 +81,7 @@ function pmRouteSummary(requests = {}) {
81
81
  summary.errors += finiteNumber(route.errors) || 0;
82
82
  return summary;
83
83
  }, { total: 0, active: 0, errors: 0 });
84
- return `PM relay: ${count(totals.total)} requests, ${count(totals.active)} active, ${count(totals.errors)} errors; models ${count(routes.pm_models?.total)}, chat ${count(routes.pm_chat_completions?.total)}, responses ${count(routes.pm_responses?.total)}, embeddings ${count(routes.pm_embeddings?.total)}`;
84
+ return `PM relay: ${count(totals.total)} requests, ${count(totals.active)} active, ${count(totals.errors)} errors; models ${count(routes.pm_models?.total)}, chat ${count(routes.pm_chat_completions?.total)}`;
85
85
  }
86
86
 
87
87
  function errorCode(error) {
@@ -17,8 +17,6 @@ const ROUTE_NAMES = Object.freeze([
17
17
  "messages_count_tokens",
18
18
  "pm_models",
19
19
  "pm_chat_completions",
20
- "pm_responses",
21
- "pm_embeddings",
22
20
  "not_found",
23
21
  ]);
24
22
 
@@ -104,8 +102,6 @@ export function classifyAdapterRoute(method, pathname) {
104
102
  if (method === "POST" && pathname === "/v1/messages/count_tokens") return "messages_count_tokens";
105
103
  if (method === "GET" && pathname === "/pm-ccdx/models") return "pm_models";
106
104
  if (method === "POST" && pathname === "/pm-ccdx/chat/completions") return "pm_chat_completions";
107
- if (method === "POST" && pathname === "/pm-ccdx/responses") return "pm_responses";
108
- if (method === "POST" && pathname === "/pm-ccdx/embeddings") return "pm_embeddings";
109
105
  return "not_found";
110
106
  }
111
107
 
@@ -2,8 +2,22 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  export const PM_STUDIO_ORIGIN = "https://api.githubcopilot.com";
4
4
  export const CCDX_PM_STUDIO_ORIGIN = "http://127.0.0.1:2026/pm-ccdx";
5
+ export const PM_STUDIO_SPLIT_ORIGIN_MARKER = "split-origin-v1";
5
6
  export const ELECTRON_ASAR_INTEGRITY_SENTINEL = "AGbevlPCksUGKNL8TSn7wGmJEuJsXb2A";
6
7
 
8
+ const PM_STUDIO_COPILOT_CONFIG_MODULE_LENGTH = 2_121;
9
+ const PM_STUDIO_COPILOT_CONFIG_MODULE_SOURCE_SHA256 = "3da6245d676015ab6cd26e4d6a0d2f54894a687c370aa419c5ad170533d086aa";
10
+
11
+ function splitOriginConfigModule() {
12
+ const source = `47024(I,e,t){"use strict";t.d(e,{qn:()=>l});const l={CLIENT_ID:"Iv1.b507a08c87ecfe98",CLIENT_SECRET:void 0,API_ENDPOINT:"${PM_STUDIO_ORIGIN}",DEVICE_CODE_URL:"https://github.com/login/device/code",ACCESS_TOKEN_URL:"https://github.com/login/oauth/access_token",COPILOT_TOKEN_URL:"https://api.github.com/copilot_internal/v2/token",USER_AGENT:"GitHubCopilotChat/0.26.7",EDITOR_VERSION:"vscode/1.99.3",EDITOR_PLUGIN_VERSION:"copilot-chat/0.26.7",INTEGRATION_ID:"vscode-chat",STANDARD_HEADERS:{Accept:"application/json","Content-Type":"application/json","User-Agent":"GitHubCopilotChat/0.26.7","Editor-Version":"vscode/1.99.3","Editor-Plugin-Version":"copilot-chat/0.26.7","Copilot-Integration-Id":"vscode-chat","X-Request-Id":()=>\`req_\${Date.now()}_\${Math.random().toString(36).substr(2,9)}\`}};const c=fetch.bind(globalThis),n="${CCDX_PM_STUDIO_ORIGIN}",a="${PM_STUDIO_SPLIT_ORIGIN_MARKER}",h="X-CCDX-PM-Relay",d=new Set,m=I=>I.headers.get(h)===a,q=I=>AbortSignal.any([I,AbortSignal.timeout(750)].filter(Boolean)),f=(I,e=400)=>Response.json({error:{code:I}},{status:e,headers:{[h]:a}});globalThis.fetch=async(I,e)=>{const t=I.url||I+"";if(t===\`\${l.API_ENDPOINT}/models\`){try{const t=await c(\`\${n}/models\`,{...e,signal:q(e?.signal)});if(t.ok&&m(t))try{let I=await t.clone().json(),e=I.data||I;if(Array.isArray(e)){d.clear();for(const I of e)/anthropic/i.test(I.vendor||I.owned_by)&&d.add(I.id);return t}}catch{}t.body?.cancel?.()?.catch?.(()=>{})}catch(I){if(e?.signal?.aborted)throw e.signal.reason||I}return c(I,e)}const s=t.startsWith(l.API_ENDPOINT)?t.slice(l.API_ENDPOINT.length):"";if("POST"===e?.method&&["/chat/completions","/responses","/embeddings"].includes(s)){let t="";try{t=(JSON.parse(e.body).model||"").trim()}catch{}if(d.has(t)||/^claude-/i.test(t)){if("/chat/completions"!==s)return f("model_not_supported");try{const I=await c(\`\${n}/models\`,{signal:q(e?.signal)}),t=401===I.status&&m(I);await I.text();if(!t)throw 0;const l=await c(\`\${n}\${s}\`,e);if(!m(l)){await l.body?.cancel?.();throw 0}return l}catch(I){if(e?.signal?.aborted)throw e.signal.reason||I;return f("relay_incompatible",503)}}}return c(I,e)}}`;
13
+ if (source.length > PM_STUDIO_COPILOT_CONFIG_MODULE_LENGTH) {
14
+ throw new Error("PM Studio split-origin module exceeds its exact source window");
15
+ }
16
+ return `${source.slice(0, -1)}${" ".repeat(PM_STUDIO_COPILOT_CONFIG_MODULE_LENGTH - source.length)}}`;
17
+ }
18
+
19
+ export const PM_STUDIO_SPLIT_ORIGIN_CONFIG_MODULE = splitOriginConfigModule();
20
+
7
21
  export const PM_STUDIO_2_9_7_RECIPE = Object.freeze({
8
22
  id: "pm-studio-2.9.7-build-2.9.7",
9
23
  version: "2.9.7",
@@ -21,50 +35,43 @@ export const PM_STUDIO_2_9_7_RECIPE = Object.freeze({
21
35
  dataOffset: 3_832_764,
22
36
  sourceAsarSha256: "36fb8bf2fc9326ddf49baab5cc0b54b38751a7db041bda3c42a3b01107b54118",
23
37
  sourceHeaderSha256: "87746d92e00db2d40c03830ea06de22ec7cba112d07729801ccd02e226ffd961",
24
- patchedAsarSha256: "3dd0f53cdaa35a644d2cf56e4fc2dd20f5c90dc2989b6d81a467ef00ebb620a7",
25
- patchedHeaderSha256: "86ed1113a563b82ecec05de89aab87a5fd1666f35bee76e2d76e2ed5f6dcb3fa",
26
- sourceSentinel: PM_STUDIO_ORIGIN,
27
- patchedSentinel: CCDX_PM_STUDIO_ORIGIN,
38
+ patchedAsarSha256: "d520d115604225c1a3feb749dbe29ad0d2cd175c5233c010de0e1fade527fa0b",
39
+ patchedHeaderSha256: "83e5fba3b6751b3098d8f6901db361a9b620ab3b924e0e551d3df612ff412642",
28
40
  targets: Object.freeze([
29
41
  Object.freeze({
30
42
  path: "dist/main/main.js",
31
43
  offset: 101_621_627,
32
44
  size: 9_909_974,
33
- sentinelOffset: 1_120_480,
34
- absoluteSentinelOffset: 106_574_871,
35
- sentinelCount: 1,
36
45
  blockSize: 4_194_304,
37
46
  sourceSha256: "58383be112886becdb8d8e7cce1b3efce07be6716190aa4940627f456eb8edb2",
38
- patchedSha256: "24f1c47831c30f59e8464a66237c6bc9ed771d79f3a6889019dfdf53bc20fe42",
47
+ patchedSha256: "69156040fb233a835b7be2f86fbe2f653380f03379adfedf46c63ab45de9d39d",
39
48
  sourceBlocks: Object.freeze([
40
49
  "3ce05a21e8992ccf12966cf9b78fcd28b2f66e6786489b5bb0a06f1d4b2a0c1e",
41
50
  "0a4832413d8d8cdda67c8bb318c0e5c25df20c75802d637097ffd045a31cd443",
42
51
  "bcc10582d8a74717f8f81b98182d3551f1eaf23eb9cadd908d160ce5e4ef8cba",
43
52
  ]),
44
53
  patchedBlocks: Object.freeze([
45
- "afc18cc7d56cce6dd6f3bade8661268a534f2de93de5ba638f6609d2638c7436",
54
+ "f0b5585d2ff0d403e5c4d35e9bd0b8cbfec5c41a9b9057b8cc808107e889cf46",
46
55
  "0a4832413d8d8cdda67c8bb318c0e5c25df20c75802d637097ffd045a31cd443",
47
56
  "bcc10582d8a74717f8f81b98182d3551f1eaf23eb9cadd908d160ce5e4ef8cba",
48
57
  ]),
49
- }),
50
- Object.freeze({
51
- path: "dist/renderer/js/main.09f18d95.js",
52
- offset: 137_002_230,
53
- size: 2_469_533,
54
- sentinelOffset: 489_935,
55
- absoluteSentinelOffset: 141_324_929,
56
- sentinelCount: 1,
57
- blockSize: 4_194_304,
58
- sourceSha256: "eb63abf35be3db101da179789e8ffdcc5b67131258ac8d7976109713ff0cc994",
59
- patchedSha256: "89bbea9516a914da3fa4f32befd33d0dbb0e63b09545e4d85392bccb52adc145",
60
- sourceBlocks: Object.freeze([
61
- "eb63abf35be3db101da179789e8ffdcc5b67131258ac8d7976109713ff0cc994",
62
- ]),
63
- patchedBlocks: Object.freeze([
64
- "89bbea9516a914da3fa4f32befd33d0dbb0e63b09545e4d85392bccb52adc145",
65
- ]),
58
+ edit: Object.freeze({
59
+ offset: 1_120_359,
60
+ absoluteOffset: 106_574_750,
61
+ length: PM_STUDIO_COPILOT_CONFIG_MODULE_LENGTH,
62
+ anchor: "47024(I,e,t){",
63
+ anchorCount: 1,
64
+ sourceSha256: PM_STUDIO_COPILOT_CONFIG_MODULE_SOURCE_SHA256,
65
+ patchedSha256: "a5b9312ed78e8a7fc3bd38f7ea5d4ab008f21bae18ba3dd6aa2d4be6b69191db",
66
+ replacement: PM_STUDIO_SPLIT_ORIGIN_CONFIG_MODULE,
67
+ }),
66
68
  }),
67
69
  ]),
70
+ legacy: Object.freeze({
71
+ kind: "global-origin-replacement",
72
+ asarSha256: "3dd0f53cdaa35a644d2cf56e4fc2dd20f5c90dc2989b6d81a467ef00ebb620a7",
73
+ headerSha256: "86ed1113a563b82ecec05de89aab87a5fd1666f35bee76e2d76e2ed5f6dcb3fa",
74
+ }),
68
75
  });
69
76
 
70
77
  export const PM_STUDIO_2_9_10_RECIPE = Object.freeze({
@@ -117,50 +124,43 @@ export const PM_STUDIO_2_9_10_RECIPE = Object.freeze({
117
124
  dataOffset: 3_833_292,
118
125
  sourceAsarSha256: "d243860770e8b1d8044213924f9704d1fc52f900d6c33461eff6358962330b78",
119
126
  sourceHeaderSha256: "5fc77d5b61e9647fba10fd2bd5752d0889c14da5a00b21d31bc48fdcccf74a69",
120
- patchedAsarSha256: "49f72d999a6085102341c2d551577e164db6f22e4707d2112efa3fd280e7315e",
121
- patchedHeaderSha256: "b264ece5b8e469cd3885a7a91f904f1902e1991f214ecbf228dfe91059ac7008",
122
- sourceSentinel: PM_STUDIO_ORIGIN,
123
- patchedSentinel: CCDX_PM_STUDIO_ORIGIN,
127
+ patchedAsarSha256: "ea28d998056b32ca4115d208a4d81ce629c83f3f5f89c6a66d50749849beb6fc",
128
+ patchedHeaderSha256: "348d80f5f2d1ebb92e6089195b1703b87467678f80d8946b8ab83b3ead03e5be",
124
129
  targets: Object.freeze([
125
130
  Object.freeze({
126
131
  path: "dist/main/main.js",
127
132
  offset: 101_622_115,
128
133
  size: 10_150_157,
129
- sentinelOffset: 1_241_344,
130
- absoluteSentinelOffset: 106_696_751,
131
- sentinelCount: 1,
132
134
  blockSize: 4_194_304,
133
135
  sourceSha256: "03607bb1edc068396b99f02b9c9e0d8e10388fb658afc112e06e875c123dbc27",
134
- patchedSha256: "e400300575e2d1c5ad089613f14e138612114547ff5060e94cb81e8de587a75a",
136
+ patchedSha256: "b5b5e37af3d06ffc603bdef620fc1e0254580f0ee325f1a77096af6c78379b79",
135
137
  sourceBlocks: Object.freeze([
136
138
  "3910a9bc5459f493956aafee7b3644e8d242027758f0dcdea97d7c10a324c6bf",
137
139
  "49fa64c68425d35b1baff0d5745d856d377e014a7a7829322cd1b39aade9532e",
138
140
  "bd7cc14a1d2e02abe66c0ec6fc4e97b263cfc1f67c4ae39e956c06d3bcc2fe67",
139
141
  ]),
140
142
  patchedBlocks: Object.freeze([
141
- "74022f5ad06c7879b37875efe0a090b23ccfbcf269afd26b10b77690413dca63",
143
+ "63aa15d8c44614a7b2d9656ddba23109d40bab66b66b36339154953bd164da75",
142
144
  "49fa64c68425d35b1baff0d5745d856d377e014a7a7829322cd1b39aade9532e",
143
145
  "bd7cc14a1d2e02abe66c0ec6fc4e97b263cfc1f67c4ae39e956c06d3bcc2fe67",
144
146
  ]),
145
- }),
146
- Object.freeze({
147
- path: "dist/renderer/js/main.5ad30c24.js",
148
- offset: 137_266_992,
149
- size: 2_520_547,
150
- sentinelOffset: 528_919,
151
- absoluteSentinelOffset: 141_629_203,
152
- sentinelCount: 1,
153
- blockSize: 4_194_304,
154
- sourceSha256: "3d123f7a3eb91777f723fe2c4d412677e6722b6348373e4b4bfda41dda8b60ad",
155
- patchedSha256: "dc4a9d550f4beb3a2cd7ad0fa6fc82286106762bdf789189d8f88abd0a90f619",
156
- sourceBlocks: Object.freeze([
157
- "3d123f7a3eb91777f723fe2c4d412677e6722b6348373e4b4bfda41dda8b60ad",
158
- ]),
159
- patchedBlocks: Object.freeze([
160
- "dc4a9d550f4beb3a2cd7ad0fa6fc82286106762bdf789189d8f88abd0a90f619",
161
- ]),
147
+ edit: Object.freeze({
148
+ offset: 1_241_223,
149
+ absoluteOffset: 106_696_630,
150
+ length: PM_STUDIO_COPILOT_CONFIG_MODULE_LENGTH,
151
+ anchor: "47024(I,e,t){",
152
+ anchorCount: 1,
153
+ sourceSha256: PM_STUDIO_COPILOT_CONFIG_MODULE_SOURCE_SHA256,
154
+ patchedSha256: "a5b9312ed78e8a7fc3bd38f7ea5d4ab008f21bae18ba3dd6aa2d4be6b69191db",
155
+ replacement: PM_STUDIO_SPLIT_ORIGIN_CONFIG_MODULE,
156
+ }),
162
157
  }),
163
158
  ]),
159
+ legacy: Object.freeze({
160
+ kind: "global-origin-replacement",
161
+ asarSha256: "49f72d999a6085102341c2d551577e164db6f22e4707d2112efa3fd280e7315e",
162
+ headerSha256: "b264ece5b8e469cd3885a7a91f904f1902e1991f214ecbf228dfe91059ac7008",
163
+ }),
164
164
  });
165
165
 
166
166
  export const PM_STUDIO_RECIPES = Object.freeze([
@@ -334,7 +334,7 @@ function addMismatch(issues, label, actual, expected) {
334
334
  if (actual !== expected) issues.push(`${label}: expected ${expected}, got ${actual}`);
335
335
  }
336
336
 
337
- function inspectTarget(parsed, target, recipe) {
337
+ function inspectTarget(parsed, target) {
338
338
  const entry = findAsarEntry(parsed.header, target.path);
339
339
  if (!entry) return { path: target.path, missing: true };
340
340
 
@@ -352,8 +352,28 @@ function inspectTarget(parsed, target, recipe) {
352
352
  }
353
353
 
354
354
  const bytes = parsed.buffer.subarray(absoluteOffset, absoluteOffset + size);
355
- const sourceSentinel = Buffer.from(recipe.sourceSentinel);
356
- const patchedSentinel = Buffer.from(recipe.patchedSentinel);
355
+ let edit = null;
356
+ if (target.edit) {
357
+ let editOffset;
358
+ let editLength;
359
+ try {
360
+ editOffset = integer(target.edit.offset, `${target.path} edit offset`);
361
+ editLength = integer(target.edit.length, `${target.path} edit length`);
362
+ } catch (error) {
363
+ return { path: target.path, offset, size, absoluteOffset, malformed: error.message };
364
+ }
365
+ if (editLength === 0 || editOffset + editLength > bytes.length) {
366
+ return { path: target.path, offset, size, absoluteOffset, malformed: "edit window is outside the target" };
367
+ }
368
+ const anchor = Buffer.from(target.edit.anchor || "");
369
+ edit = {
370
+ offset: editOffset,
371
+ absoluteOffset: absoluteOffset + editOffset,
372
+ length: editLength,
373
+ sha256: sha256Hex(bytes.subarray(editOffset, editOffset + editLength)),
374
+ anchorPositions: occurrencePositions(bytes, anchor),
375
+ };
376
+ }
357
377
  return {
358
378
  path: target.path,
359
379
  offset,
@@ -363,20 +383,17 @@ function inspectTarget(parsed, target, recipe) {
363
383
  blockSize: entry.integrity?.blockSize,
364
384
  blocks: blockSha256(bytes, target.blockSize),
365
385
  integrity: entry.integrity || null,
366
- sourceSentinelPositions: occurrencePositions(bytes, sourceSentinel),
367
- patchedSentinelPositions: occurrencePositions(bytes, patchedSentinel),
386
+ edit,
368
387
  };
369
388
  }
370
389
 
371
- function targetStateIssues(detail, target, recipe, state) {
390
+ function targetStateIssues(detail, target, state) {
372
391
  const issues = [];
373
392
  if (detail.missing) return [`${target.path}: entry is missing`];
374
393
  if (detail.malformed) return [`${target.path}: ${detail.malformed}`];
375
394
 
376
395
  addMismatch(issues, `${target.path} offset`, detail.offset, target.offset);
377
396
  addMismatch(issues, `${target.path} size`, detail.size, target.size);
378
- addMismatch(issues, `${target.path} absolute sentinel offset`,
379
- detail.absoluteOffset + target.sentinelOffset, target.absoluteSentinelOffset);
380
397
  addMismatch(issues, `${target.path} integrity algorithm`,
381
398
  detail.integrity?.algorithm, "SHA256");
382
399
  addMismatch(issues, `${target.path} block size`, detail.integrity?.blockSize, target.blockSize);
@@ -384,20 +401,23 @@ function targetStateIssues(detail, target, recipe, state) {
384
401
  const patched = state === "patched";
385
402
  const expectedHash = patched ? target.patchedSha256 : target.sourceSha256;
386
403
  const expectedBlocks = patched ? target.patchedBlocks : target.sourceBlocks;
387
- const expectedSentinelPositions = [target.sentinelOffset];
388
- const presentPositions = patched ? detail.patchedSentinelPositions : detail.sourceSentinelPositions;
389
- const absentPositions = patched ? detail.sourceSentinelPositions : detail.patchedSentinelPositions;
390
404
 
391
405
  addMismatch(issues, `${target.path} SHA-256`, detail.sha256, expectedHash);
392
406
  addMismatch(issues, `${target.path} integrity hash`, detail.integrity?.hash, expectedHash);
393
407
  if (!sameArray(detail.blocks, expectedBlocks)) issues.push(`${target.path} computed block hashes do not match ${state} recipe`);
394
408
  if (!sameArray(detail.integrity?.blocks, expectedBlocks)) issues.push(`${target.path} header block hashes do not match ${state} recipe`);
395
- if (!sameArray(presentPositions, expectedSentinelPositions)) {
396
- issues.push(`${target.path} ${state} sentinel position/count does not match recipe`);
397
- }
398
- if (absentPositions.length !== 0) issues.push(`${target.path} contains the opposite-state sentinel`);
399
- if (presentPositions.length !== target.sentinelCount) {
400
- issues.push(`${target.path} ${state} sentinel count is ${presentPositions.length}, expected ${target.sentinelCount}`);
409
+ if (target.edit) {
410
+ addMismatch(issues, `${target.path} edit offset`, detail.edit?.offset, target.edit.offset);
411
+ addMismatch(issues, `${target.path} absolute edit offset`, detail.edit?.absoluteOffset, target.edit.absoluteOffset);
412
+ addMismatch(issues, `${target.path} edit length`, detail.edit?.length, target.edit.length);
413
+ addMismatch(issues, `${target.path} edit SHA-256`, detail.edit?.sha256,
414
+ patched ? target.edit.patchedSha256 : target.edit.sourceSha256);
415
+ if (!sameArray(detail.edit?.anchorPositions, [target.edit.offset])) {
416
+ issues.push(`${target.path} module anchor position/count does not match recipe`);
417
+ }
418
+ if (detail.edit?.anchorPositions?.length !== target.edit.anchorCount) {
419
+ issues.push(`${target.path} module anchor count is ${detail.edit?.anchorPositions?.length || 0}, expected ${target.edit.anchorCount}`);
420
+ }
401
421
  }
402
422
  return issues;
403
423
  }
@@ -406,7 +426,7 @@ export function inspectAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
406
426
  const parsed = parseAsarBuffer(value);
407
427
  const asarSha256 = sha256Hex(parsed.buffer);
408
428
  const headerSha256 = sha256Hex(parsed.headerBytes);
409
- const targets = recipe.targets.map((target) => inspectTarget(parsed, target, recipe));
429
+ const targets = recipe.targets.map((target) => inspectTarget(parsed, target));
410
430
  const commonIssues = [];
411
431
  addMismatch(commonIssues, "ASAR data offset", parsed.dataOffset, recipe.dataOffset);
412
432
 
@@ -418,14 +438,25 @@ export function inspectAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
418
438
  addMismatch(issues, "ASAR header SHA-256", headerSha256,
419
439
  patched ? recipe.patchedHeaderSha256 : recipe.sourceHeaderSha256);
420
440
  for (let index = 0; index < recipe.targets.length; index += 1) {
421
- issues.push(...targetStateIssues(targets[index], recipe.targets[index], recipe, state));
441
+ issues.push(...targetStateIssues(targets[index], recipe.targets[index], state));
422
442
  }
423
443
  return issues;
424
444
  };
425
445
 
426
446
  const cleanIssues = issuesFor("clean");
427
447
  const patchedIssues = issuesFor("patched");
428
- const state = cleanIssues.length === 0 ? "clean" : patchedIssues.length === 0 ? "patched" : "drift";
448
+ const legacyIssues = [...commonIssues];
449
+ if (!recipe.legacy) {
450
+ legacyIssues.push("recipe has no recognized legacy patch");
451
+ } else {
452
+ addMismatch(legacyIssues, "ASAR SHA-256", asarSha256, recipe.legacy.asarSha256);
453
+ addMismatch(legacyIssues, "ASAR header SHA-256", headerSha256, recipe.legacy.headerSha256);
454
+ }
455
+ const state = cleanIssues.length === 0
456
+ ? "clean"
457
+ : patchedIssues.length === 0
458
+ ? "patched"
459
+ : legacyIssues.length === 0 ? "legacy" : "drift";
429
460
  return {
430
461
  recipeId: recipe.id,
431
462
  state,
@@ -435,19 +466,23 @@ export function inspectAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
435
466
  targets,
436
467
  cleanIssues,
437
468
  patchedIssues,
469
+ legacyIssues,
438
470
  };
439
471
  }
440
472
 
441
473
  export function patchAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
442
- if (Buffer.byteLength(recipe.sourceSentinel) !== Buffer.byteLength(recipe.patchedSentinel)) {
443
- throw new Error("PM Studio patch requires equal-length origin sentinels");
444
- }
445
474
  const before = inspectAsarBuffer(value, recipe);
446
475
  if (before.state === "patched") {
447
476
  return { changed: false, buffer: Buffer.from(value), before, after: before };
448
477
  }
478
+ if (before.state === "legacy") {
479
+ const error = new Error("Legacy PM Studio global-origin patch must be migrated from a verified clean backup");
480
+ error.code = "PM_STUDIO_ASAR_LEGACY";
481
+ error.inspection = before;
482
+ throw error;
483
+ }
449
484
  if (before.state !== "clean") {
450
- const error = new Error("PM Studio ASAR does not match the clean or patched recipe");
485
+ const error = new Error("PM Studio ASAR does not match the clean, split-origin, or legacy recipe");
451
486
  error.code = "PM_STUDIO_ASAR_DRIFT";
452
487
  error.inspection = before;
453
488
  throw error;
@@ -455,16 +490,25 @@ export function patchAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
455
490
 
456
491
  const output = Buffer.from(value);
457
492
  const parsed = parseAsarBuffer(output);
458
- const sourceSentinel = Buffer.from(recipe.sourceSentinel);
459
- const patchedSentinel = Buffer.from(recipe.patchedSentinel);
460
493
 
461
494
  for (const target of recipe.targets) {
462
495
  const entry = findAsarEntry(parsed.header, target.path);
463
496
  const offset = entryOffset(entry.offset, `${target.path} offset`);
464
- const absoluteSentinelOffset = parsed.dataOffset + offset + target.sentinelOffset;
465
- const existing = output.subarray(absoluteSentinelOffset, absoluteSentinelOffset + sourceSentinel.length);
466
- if (!existing.equals(sourceSentinel)) throw new Error(`${target.path} source sentinel moved before patching`);
467
- patchedSentinel.copy(output, absoluteSentinelOffset);
497
+ const edit = target.edit;
498
+ if (!edit) throw new Error(`${target.path} has no split-origin edit recipe`);
499
+ const replacement = Buffer.from(edit.replacement);
500
+ if (replacement.length !== edit.length || sha256Hex(replacement) !== edit.patchedSha256) {
501
+ throw new Error(`${target.path} split-origin replacement does not match its recipe`);
502
+ }
503
+ const absoluteEditOffset = parsed.dataOffset + offset + edit.offset;
504
+ if (absoluteEditOffset !== edit.absoluteOffset) {
505
+ throw new Error(`${target.path} split-origin edit moved before patching`);
506
+ }
507
+ const existing = output.subarray(absoluteEditOffset, absoluteEditOffset + edit.length);
508
+ if (sha256Hex(existing) !== edit.sourceSha256) {
509
+ throw new Error(`${target.path} source edit window does not match its recipe`);
510
+ }
511
+ replacement.copy(output, absoluteEditOffset);
468
512
  entry.integrity = {
469
513
  ...entry.integrity,
470
514
  algorithm: "SHA256",
@@ -16,6 +16,8 @@ import { requireUpstreamEventStream } from "./stream-contract.mjs";
16
16
  import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
17
17
 
18
18
  export const PM_STUDIO_RELAY_PREFIX = "/pm-ccdx";
19
+ export const PM_STUDIO_RELAY_MARKER_HEADER = "X-CCDX-PM-Relay";
20
+ export const PM_STUDIO_RELAY_MARKER_VALUE = "split-origin-v1";
19
21
  const COPILOT_ORIGIN = "https://api.githubcopilot.com";
20
22
  const DEFAULT_VALIDATION_TTL_MS = 5 * 60 * 1000;
21
23
  const DEFAULT_VALIDATION_CACHE_SIZE = 128;
@@ -32,11 +34,7 @@ const HOP_BY_HOP_HEADERS = new Set([
32
34
  "transfer-encoding",
33
35
  "upgrade",
34
36
  ]);
35
- const POST_PATHS = new Set([
36
- "/chat/completions",
37
- "/responses",
38
- "/embeddings",
39
- ]);
37
+ const CHAT_COMPLETIONS_PATH = "/chat/completions";
40
38
 
41
39
  function localError(statusCode, code, message) {
42
40
  const error = httpError(message, statusCode);
@@ -46,7 +44,11 @@ function localError(statusCode, code, message) {
46
44
 
47
45
  function sendLocalError(res, statusCode, code, message, headers = {}) {
48
46
  if (res.destroyed || res.writableEnded) return;
49
- res.writeHead(statusCode, { "Content-Type": "application/json", ...headers });
47
+ res.writeHead(statusCode, {
48
+ "Content-Type": "application/json",
49
+ [PM_STUDIO_RELAY_MARKER_HEADER]: PM_STUDIO_RELAY_MARKER_VALUE,
50
+ ...headers,
51
+ });
50
52
  res.end(JSON.stringify({ error: { message, type: "invalid_request_error", code } }));
51
53
  }
52
54
 
@@ -160,6 +162,7 @@ async function responseSnapshot(response) {
160
162
  function responseHeaders(headers, body, options) {
161
163
  return {
162
164
  ...safeUpstreamResponseHeaders(headers, options),
165
+ [PM_STUDIO_RELAY_MARKER_HEADER]: PM_STUDIO_RELAY_MARKER_VALUE,
163
166
  "Content-Length": String(body.length),
164
167
  };
165
168
  }
@@ -179,8 +182,6 @@ function rejectUpstreamRedirect(response) {
179
182
 
180
183
  function mergeModelCatalog(snapshot, modelRouter) {
181
184
  if (!snapshot.ok) return null;
182
- const allowed = modelRouter.allowedModels();
183
- if (!allowed.length) return null;
184
185
  let enterprise;
185
186
  try {
186
187
  enterprise = JSON.parse(snapshot.body.toString("utf8"));
@@ -189,6 +190,8 @@ function mergeModelCatalog(snapshot, modelRouter) {
189
190
  }
190
191
  const data = catalogData(enterprise);
191
192
  if (!data) return null;
193
+ const allowed = modelRouter.allowedModels();
194
+ if (!allowed.length) return snapshot.body;
192
195
  const seen = new Set(data.map((model) => String(model?.id || "").trim()).filter(Boolean));
193
196
  const additions = [];
194
197
  for (const model of allowed) {
@@ -197,7 +200,7 @@ function mergeModelCatalog(snapshot, modelRouter) {
197
200
  seen.add(id);
198
201
  additions.push(model);
199
202
  }
200
- if (!additions.length) return null;
203
+ if (!additions.length) return snapshot.body;
201
204
  return Buffer.from(JSON.stringify(Array.isArray(enterprise)
202
205
  ? [...enterprise, ...additions]
203
206
  : { ...enterprise, data: [...data, ...additions] }));
@@ -215,6 +218,7 @@ async function relayUpstreamResponse(response, reqBody, res, abort, streamIdleTi
215
218
  abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
216
219
  res.writeHead(response.status, {
217
220
  ...safeUpstreamResponseHeaders(response.headers, { contentType: "text/event-stream" }),
221
+ [PM_STUDIO_RELAY_MARKER_HEADER]: PM_STUDIO_RELAY_MARKER_VALUE,
218
222
  "Cache-Control": "no-cache",
219
223
  });
220
224
  const reader = response.body.getReader();
@@ -424,6 +428,9 @@ export function createPmStudioRelayHandler(options = {}) {
424
428
  }
425
429
  const merged = mergeModelCatalog(snapshot, modelRouter);
426
430
  if (!merged) {
431
+ throw localError(502, "invalid_upstream", "PM Studio upstream model catalog has an invalid JSON shape");
432
+ }
433
+ if (merged === snapshot.body) {
427
434
  sendSnapshot(res, snapshot);
428
435
  return;
429
436
  }
@@ -431,15 +438,26 @@ export function createPmStudioRelayHandler(options = {}) {
431
438
  res.end(merged);
432
439
  }
433
440
 
434
- async function handlePost(req, res, pathname, token, abort) {
441
+ async function handleChatCompletions(req, res, token, abort) {
435
442
  let releaseRequest = releaseOnce();
436
443
  try {
437
444
  const admission = await acquireRequest(req, { signal: abort.signal });
438
445
  releaseRequest = releaseOnce(admission);
439
446
  abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
440
447
  const body = await readJsonBody(req, { admission, signal: abort.signal });
441
- const supportsStream = pathname === "/chat/completions" || pathname === "/responses";
442
- const streaming = supportsStream && body?.stream === true;
448
+ const modelType = modelRouter.classify(body?.model);
449
+ if (modelType === "unsupported_claude") {
450
+ throw localError(400, "model_not_supported", "The requested Claude model is not enabled for PM Studio");
451
+ }
452
+ if (modelType !== "claude") {
453
+ throw localError(
454
+ 400,
455
+ "native_route_required",
456
+ "Non-Claude PM Studio requests must use the native GitHub Copilot origin",
457
+ );
458
+ }
459
+
460
+ const streaming = body?.stream === true;
443
461
  const requestTimeoutMs = streaming ? streamHandshakeTimeoutMs : upstreamTimeoutMs;
444
462
  const timeoutReason = streaming ? "stream_handshake_timeout" : "upstream_timeout";
445
463
  abort.setTimeout(requestTimeoutMs, timeoutReason);
@@ -452,23 +470,12 @@ export function createPmStudioRelayHandler(options = {}) {
452
470
  }
453
471
  abort.setTimeout(requestTimeoutMs, timeoutReason);
454
472
 
455
- const modelType = modelRouter.classify(body?.model);
456
- if (modelType === "unsupported_claude" || (modelType === "claude" && pathname !== "/chat/completions")) {
457
- throw localError(400, "model_not_supported", "The requested Claude model is not available for this endpoint");
458
- }
459
-
460
- let upstream;
461
- if (modelType === "claude") {
462
- upstream = await options.claudeClient.chatCompletions(body, {
463
- signal: abort.signal,
464
- fetchImpl: fetchClaudeUpstream,
465
- });
466
- } else {
467
- const bodyText = JSON.stringify(body);
468
- upstream = await fetchEnterprise(pathname, "POST", req.headers, token, bodyText, abort.signal);
469
- }
473
+ const upstream = await options.claudeClient.chatCompletions(body, {
474
+ signal: abort.signal,
475
+ fetchImpl: fetchClaudeUpstream,
476
+ });
470
477
  releaseRequest();
471
- await relayUpstreamResponse(upstream, supportsStream ? body : null, res, abort, streamIdleTimeoutMs);
478
+ await relayUpstreamResponse(upstream, body, res, abort, streamIdleTimeoutMs);
472
479
  } finally {
473
480
  releaseRequest();
474
481
  }
@@ -494,7 +501,9 @@ export function createPmStudioRelayHandler(options = {}) {
494
501
  }
495
502
 
496
503
  const upstreamPath = pathname.slice(PM_STUDIO_RELAY_PREFIX.length);
497
- const allowedMethod = upstreamPath === "/models" ? "GET" : POST_PATHS.has(upstreamPath) ? "POST" : null;
504
+ const allowedMethod = upstreamPath === "/models"
505
+ ? "GET"
506
+ : upstreamPath === CHAT_COMPLETIONS_PATH ? "POST" : null;
498
507
  if (!allowedMethod) {
499
508
  sendLocalError(res, 404, "route_not_found", "PM Studio relay route not found");
500
509
  return true;
@@ -512,13 +521,14 @@ export function createPmStudioRelayHandler(options = {}) {
512
521
  abort.setTimeout(upstreamTimeoutMs, "upstream_timeout");
513
522
  await handleModels(req, res, token, abort);
514
523
  } else {
515
- await handlePost(req, res, upstreamPath, token, abort);
524
+ await handleChatCompletions(req, res, token, abort);
516
525
  }
517
526
  } catch (error) {
518
527
  const safe = safeFailure(error, abort);
519
528
  if (res.headersSent) {
520
529
  if (!res.destroyed) res.destroy();
521
530
  } else {
531
+ res.setHeader(PM_STUDIO_RELAY_MARKER_HEADER, PM_STUDIO_RELAY_MARKER_VALUE);
522
532
  sendJsonError(res, safe, safe.statusCode || 502);
523
533
  }
524
534
  } finally {
@@ -530,8 +530,12 @@ export function inspectPmStudioApp({
530
530
  && asar.state === "patched"
531
531
  && integrityMatches(plistIntegrity, "SHA256", recipe.patchedHeaderSha256)
532
532
  && patchedSignatureAccepted;
533
+ const legacy = issues.length === 0
534
+ && asar.state === "legacy"
535
+ && integrityMatches(plistIntegrity, "SHA256", recipe.legacy?.headerSha256)
536
+ && patchedSignatureAccepted;
533
537
 
534
- if (!clean && !patched) {
538
+ if (!clean && !patched && !legacy) {
535
539
  if (asar.state === "drift") issues.push("app.asar is unknown drift");
536
540
  else if (asar.state === "clean"
537
541
  && !integrityMatches(plistIntegrity, "SHA256", recipe.sourceHeaderSha256)) {
@@ -539,6 +543,9 @@ export function inspectPmStudioApp({
539
543
  } else if (asar.state === "patched"
540
544
  && !integrityMatches(plistIntegrity, "SHA256", recipe.patchedHeaderSha256)) {
541
545
  issues.push("patched ASAR does not match ElectronAsarIntegrity");
546
+ } else if (asar.state === "legacy"
547
+ && !integrityMatches(plistIntegrity, "SHA256", recipe.legacy?.headerSha256)) {
548
+ issues.push("legacy ASAR does not match ElectronAsarIntegrity");
542
549
  }
543
550
  if (asar.state === "clean" && recipe.sourceBundleContent && !exactSourceBundle) {
544
551
  issues.push("bundle content does not match the exact clean recipe");
@@ -547,7 +554,9 @@ export function inspectPmStudioApp({
547
554
  issues.push("codesign verification failed");
548
555
  }
549
556
  else if (asar.state === "clean" && codeSign.adHoc) issues.push("clean bundle has an unexpected ad-hoc signature");
550
- else if (asar.state === "patched" && !codeSign.adHoc) issues.push("patched bundle is not ad-hoc signed");
557
+ else if (["patched", "legacy"].includes(asar.state) && !codeSign.adHoc) {
558
+ issues.push(`${asar.state} bundle is not ad-hoc signed`);
559
+ }
551
560
  else if (asar.state === "clean" && codeSign.teamIdentifier !== recipe.sourceTeamIdentifier) {
552
561
  issues.push(`clean bundle TeamIdentifier is ${codeSign.teamIdentifier || "missing"}, expected ${recipe.sourceTeamIdentifier}`);
553
562
  }
@@ -559,15 +568,15 @@ export function inspectPmStudioApp({
559
568
  && executableIntegrity.frameworkSha256 !== recipe.sourceElectronFrameworkSha256) {
560
569
  issues.push("Electron Framework hash does not match the clean recipe");
561
570
  }
562
- if (asar.state === "patched" && codeSign?.valid && codeSign?.adHoc
571
+ if (["patched", "legacy"].includes(asar.state) && codeSign?.valid && codeSign?.adHoc
563
572
  && recipe.patchedSigningMetadata
564
573
  && !patchedSignatureAccepted) {
565
- issues.push("patched bundle signing metadata does not match the recipe");
574
+ issues.push(`${asar.state} bundle signing metadata does not match the recipe`);
566
575
  }
567
576
  }
568
577
 
569
578
  return {
570
- state: clean ? "clean" : patched ? "patched" : "drift",
579
+ state: clean ? "clean" : patched ? "patched" : legacy ? "legacy" : "drift",
571
580
  metadata: {
572
581
  version: String(metadata.version),
573
582
  build: String(metadata.build),
@@ -676,6 +685,23 @@ function backupManifest(recipe, createdAt) {
676
685
  };
677
686
  }
678
687
 
688
+ function patchRecordMatches(record, recipe, state) {
689
+ const expected = state === "legacy"
690
+ ? recipe.legacy
691
+ : { asarSha256: recipe.patchedAsarSha256, headerSha256: recipe.patchedHeaderSha256 };
692
+ return Boolean(expected)
693
+ && record?.asar_sha256 === expected.asarSha256
694
+ && record?.asar_header_sha256 === expected.headerSha256
695
+ && integrityMatches(record?.electron_asar_integrity, "SHA256", expected.headerSha256);
696
+ }
697
+
698
+ function patchRecordForState(manifest, recipe, state) {
699
+ if (state === "legacy" && patchRecordMatches(manifest?.legacy_patched, recipe, state)) {
700
+ return manifest.legacy_patched;
701
+ }
702
+ return patchRecordMatches(manifest?.patched, recipe, state) ? manifest.patched : null;
703
+ }
704
+
679
705
  function validateBackup({ backupDir, recipe, operations }) {
680
706
  const manifestPath = path.join(backupDir, "manifest.json");
681
707
  const backupAppPath = path.join(backupDir, "PM Studio.app");
@@ -702,9 +728,8 @@ function validateBackup({ backupDir, recipe, operations }) {
702
728
  && JSON.stringify(manifest?.source?.bundle_content) !== JSON.stringify(recipe.sourceBundleContent))
703
729
  || (recipe.sourceArtifact
704
730
  && JSON.stringify(manifest?.source?.artifact) !== JSON.stringify(recipe.sourceArtifact))
705
- || manifest?.patched?.asar_sha256 !== recipe.patchedAsarSha256
706
- || manifest?.patched?.asar_header_sha256 !== recipe.patchedHeaderSha256
707
- || !integrityMatches(manifest?.patched?.electron_asar_integrity, "SHA256", recipe.patchedHeaderSha256)) {
731
+ || (!patchRecordForState(manifest, recipe, "patched")
732
+ && !patchRecordForState(manifest, recipe, "legacy"))) {
708
733
  throw setupError("PM_STUDIO_BACKUP_INVALID", `Existing PM Studio backup does not match recipe ${recipe.id}`);
709
734
  }
710
735
  const inspection = inspectPmStudioApp({ appPath: backupAppPath, recipe, operations });
@@ -750,12 +775,21 @@ function ensureBackup({ appPath, backupRoot, recipe, operations }) {
750
775
 
751
776
  function writePatchedBinaryRecord({ backup, inspection, operations, recipe }) {
752
777
  const manifest = JSON.parse(fs.readFileSync(backup.manifestPath, "utf8"));
753
- manifest.patched.binaries = {
754
- main_executable_sha256: inspection.executableIntegrity.executableSha256,
755
- electron_framework_sha256: inspection.executableIntegrity.frameworkSha256,
778
+ if (!patchRecordMatches(manifest.legacy_patched, recipe, "legacy")
779
+ && patchRecordMatches(manifest.patched, recipe, "legacy")) {
780
+ manifest.legacy_patched = manifest.patched;
781
+ }
782
+ manifest.patched = {
783
+ asar_sha256: recipe.patchedAsarSha256,
784
+ asar_header_sha256: recipe.patchedHeaderSha256,
785
+ electron_asar_integrity: { algorithm: "SHA256", hash: recipe.patchedHeaderSha256 },
786
+ binaries: {
787
+ main_executable_sha256: inspection.executableIntegrity.executableSha256,
788
+ electron_framework_sha256: inspection.executableIntegrity.frameworkSha256,
789
+ },
790
+ signing_metadata: signingMetadata(inspection.codeSign),
791
+ ...(recipe.sourceBundleContent ? { bundle_content: inspection.bundleContent } : {}),
756
792
  };
757
- manifest.patched.signing_metadata = signingMetadata(inspection.codeSign);
758
- if (recipe.sourceBundleContent) manifest.patched.bundle_content = inspection.bundleContent;
759
793
  const temporaryPath = `${backup.manifestPath}.${operations.uuid()}.tmp`;
760
794
  try {
761
795
  fs.writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
@@ -766,7 +800,12 @@ function writePatchedBinaryRecord({ backup, inspection, operations, recipe }) {
766
800
  backup.manifest = manifest;
767
801
  }
768
802
 
769
- export function assertPatchedBinaryRecord({ inspection, manifestPath, recipe }) {
803
+ export function assertPatchedBinaryRecord({
804
+ inspection,
805
+ manifestPath,
806
+ recipe,
807
+ recordState = inspection?.state === "legacy" ? "legacy" : "patched",
808
+ }) {
770
809
  let manifest;
771
810
  try {
772
811
  manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -790,23 +829,24 @@ export function assertPatchedBinaryRecord({ inspection, manifestPath, recipe })
790
829
  || JSON.stringify(manifest?.source?.bundle_content) !== JSON.stringify(recipe.sourceBundleContent)
791
830
  ))
792
831
  || (recipe.sourceArtifact
793
- && JSON.stringify(manifest?.source?.artifact) !== JSON.stringify(recipe.sourceArtifact))
794
- || manifest?.patched?.asar_sha256 !== recipe.patchedAsarSha256
795
- || manifest?.patched?.asar_header_sha256 !== recipe.patchedHeaderSha256
796
- || !integrityMatches(manifest?.patched?.electron_asar_integrity, "SHA256", recipe.patchedHeaderSha256))) {
832
+ && JSON.stringify(manifest?.source?.artifact) !== JSON.stringify(recipe.sourceArtifact)))) {
797
833
  throw setupError("PM_STUDIO_PATCH_RECORD_INVALID", "PM Studio patched-binary record does not match the installed patch recipe");
798
834
  }
799
- const binaries = manifest?.patched?.binaries;
835
+ const record = patchRecordForState(manifest, recipe, recordState);
836
+ if (!record) {
837
+ throw setupError("PM_STUDIO_PATCH_RECORD_INVALID", `PM Studio ${recordState} record does not match the installed patch recipe`);
838
+ }
839
+ const binaries = record.binaries;
800
840
  if (!binaries
801
841
  || binaries.main_executable_sha256 !== inspection.executableIntegrity.executableSha256
802
842
  || binaries.electron_framework_sha256 !== inspection.executableIntegrity.frameworkSha256) {
803
843
  throw setupError("PM_STUDIO_BUNDLE_DRIFT", "PM Studio executable or Electron Framework differs from the installed patch record");
804
844
  }
805
- if (JSON.stringify(manifest?.patched?.signing_metadata) !== JSON.stringify(signingMetadata(inspection.codeSign))) {
845
+ if (JSON.stringify(record.signing_metadata) !== JSON.stringify(signingMetadata(inspection.codeSign))) {
806
846
  throw setupError("PM_STUDIO_BUNDLE_DRIFT", "PM Studio signing metadata differs from the installed patch record");
807
847
  }
808
848
  if (recipe?.sourceBundleContent
809
- && JSON.stringify(manifest?.patched?.bundle_content) !== JSON.stringify(inspection.bundleContent)) {
849
+ && JSON.stringify(record.bundle_content) !== JSON.stringify(inspection.bundleContent)) {
810
850
  throw setupError("PM_STUDIO_BUNDLE_DRIFT", "PM Studio bundle content differs from the installed patch record");
811
851
  }
812
852
  }
@@ -922,12 +962,13 @@ export async function runPmStudioSetup({
922
962
  emit(`[OK] PM Studio ${recipe.version} build ${recipe.build} is already patched; no backup or signing was repeated.`);
923
963
  emit(`[OK] Verified backup: ${backup.backupAppPath}`);
924
964
  emit(`[OK] Backup manifest: ${backup.manifestPath}`);
925
- emit(`[INFO] Start ${commandName} before opening PM Studio.`);
965
+ emit(`[INFO] Start ${commandName} before using Claude in PM Studio; GPT remains on PM Studio's native GitHub Copilot path.`);
926
966
  return { status: "already_patched", changed: false, recipe: recipe.id, backup, inspection: source, messages };
927
967
  }
928
- if (source.state !== "clean") {
968
+ const migratingLegacy = source.state === "legacy";
969
+ if (source.state !== "clean" && !migratingLegacy) {
929
970
  throw setupError("PM_STUDIO_BUNDLE_DRIFT",
930
- `PM Studio ${recipe.version} build ${recipe.build} does not match the clean or patched recipe; no files were changed`, source.issues);
971
+ `PM Studio ${recipe.version} build ${recipe.build} does not match the clean, split-origin, or legacy recipe; no files were changed`, source.issues);
931
972
  }
932
973
 
933
974
  if (source.sourceVerification === "exact-bundle-content") {
@@ -935,17 +976,36 @@ export async function runPmStudioSetup({
935
976
  }
936
977
 
937
978
  const backupExists = fs.existsSync(finalBackupDir);
979
+ let verifiedBackup = null;
980
+ if (migratingLegacy) {
981
+ if (!backupExists) {
982
+ throw setupError("PM_STUDIO_BACKUP_INVALID",
983
+ "Legacy PM Studio global-origin patch has no matching verified clean backup; no files were changed");
984
+ }
985
+ verifiedBackup = validateBackup({ backupDir: finalBackupDir, recipe, operations });
986
+ assertPatchedBinaryRecord({
987
+ inspection: source,
988
+ manifestPath: verifiedBackup.manifestPath,
989
+ recipe,
990
+ recordState: "legacy",
991
+ });
992
+ emit("[WARN] Legacy global-origin patch detected; setup will rebuild from the verified clean backup so GPT and enterprise authentication return to PM Studio's native origin.");
993
+ }
938
994
  assertSpace({ appPath, backupRoot, backupExists, operations });
939
995
 
940
996
  emit("[WARN] PM Studio's vendor signature will be replaced with an ad-hoc signature; an official update or reinstall may overwrite this patch.");
941
- const backup = ensureBackup({ appPath, backupRoot, recipe, operations });
997
+ const backup = verifiedBackup || ensureBackup({ appPath, backupRoot, recipe, operations });
942
998
  const stagePath = path.join(path.dirname(appPath), `.${path.basename(appPath)}.ccdx-stage-${operations.uuid()}`);
943
999
  let preserveStage = false;
944
1000
  try {
945
- operations.copyBundle({ source: appPath, destination: stagePath, processRunner: operations.processRunner });
1001
+ operations.copyBundle({
1002
+ source: migratingLegacy ? backup.backupAppPath : appPath,
1003
+ destination: stagePath,
1004
+ processRunner: operations.processRunner,
1005
+ });
946
1006
  const stagedSource = inspectPmStudioApp({ appPath: stagePath, recipe, operations });
947
1007
  if (stagedSource.state !== "clean"
948
- || (recipe.sourceBundleContent
1008
+ || (!migratingLegacy && recipe.sourceBundleContent
949
1009
  && stagedSource.bundleContent?.sha256 !== source.bundleContent?.sha256)) {
950
1010
  throw setupError("PM_STUDIO_STAGE_INVALID", "PM Studio staging copy does not match the exact clean source bundle", stagedSource.issues);
951
1011
  }
@@ -966,12 +1026,12 @@ export async function runPmStudioSetup({
966
1026
  if (staged.state !== "patched") {
967
1027
  throw setupError("PM_STUDIO_STAGE_VERIFY_FAILED", "Patched PM Studio staging bundle failed verification", staged.issues);
968
1028
  }
969
- assertSigningMetadataPreserved(source, staged, recipe);
1029
+ assertSigningMetadataPreserved(stagedSource, staged, recipe);
970
1030
  writePatchedBinaryRecord({ backup, inspection: staged, operations, recipe });
971
1031
 
972
1032
  assertNoBlockingProcesses(operations, appPath);
973
1033
  const unchangedSource = inspectPmStudioApp({ appPath, recipe, operations });
974
- if (unchangedSource.state !== "clean"
1034
+ if (unchangedSource.state !== source.state
975
1035
  || unchangedSource.asar.asarSha256 !== source.asar.asarSha256
976
1036
  || unchangedSource.asar.headerSha256 !== source.asar.headerSha256
977
1037
  || (recipe.sourceBundleContent
@@ -989,12 +1049,17 @@ export async function runPmStudioSetup({
989
1049
  `PM Studio replacement final state could not be verified. Do not launch it. ${recoveryMessage(backup)}${preserveStage ? ` Verified staging retained: ${stagePath}.` : ""}`);
990
1050
  }
991
1051
  if (installed.state !== "patched") {
992
- preserveStage = installed.state !== "clean" && fs.existsSync(stagePath);
993
- const code = installed.state === "clean"
1052
+ const originalRetained = installed.state === source.state
1053
+ && installed.asar.asarSha256 === source.asar.asarSha256
1054
+ && installed.asar.headerSha256 === source.asar.headerSha256
1055
+ && (!recipe.sourceBundleContent
1056
+ || installed.bundleContent?.sha256 === source.bundleContent?.sha256);
1057
+ preserveStage = !originalRetained && fs.existsSync(stagePath);
1058
+ const code = originalRetained
994
1059
  ? "PM_STUDIO_REPLACE_FAILED"
995
1060
  : "PM_STUDIO_INSTALL_VERIFY_FAILED";
996
- const stateMessage = installed.state === "clean"
997
- ? "The installed PM Studio remains the verified clean bundle"
1061
+ const stateMessage = originalRetained
1062
+ ? `The installed PM Studio remains the verified ${source.state} bundle`
998
1063
  : "The installed PM Studio did not retain the verified patched state; do not launch it";
999
1064
  throw setupError(code,
1000
1065
  `${stateMessage}. ${recoveryMessage(backup)}${preserveStage ? ` Verified staging retained: ${stagePath}.` : ""}`,
@@ -1011,7 +1076,9 @@ export async function runPmStudioSetup({
1011
1076
  emit("[WARN] Atomic replacement reported an error after the exact patched bundle became installed; the final bundle was independently verified.");
1012
1077
  }
1013
1078
 
1014
- emit(`[OK] Patched PM Studio ${recipe.version} build ${recipe.build}.`);
1079
+ emit(migratingLegacy
1080
+ ? `[OK] Migrated PM Studio ${recipe.version} build ${recipe.build} from the legacy global-origin patch to the split-origin patch.`
1081
+ : `[OK] Patched PM Studio ${recipe.version} build ${recipe.build}.`);
1015
1082
  emit(`[OK] app.asar SHA-256: ${installed.asar.asarSha256}`);
1016
1083
  emit(`[OK] Verified backup: ${backup.backupAppPath}${backup.reused ? " (reused)" : ""}`);
1017
1084
  emit(`[OK] Backup manifest: ${backup.manifestPath}`);
@@ -1019,9 +1086,9 @@ export async function runPmStudioSetup({
1019
1086
  emit(`[INFO] Restore step 1/3: quit PM Studio and its updater.`);
1020
1087
  emit(`[INFO] Restore step 2/3: confirm the installed Info.plist still reports version/build ${recipe.version}/${recipe.build}; never restore this backup over another version.`);
1021
1088
  emit(`[INFO] Restore step 3/3: move ${appPath} aside, copy ${backup.backupAppPath} to that exact path, and verify its signature before launch.`);
1022
- emit(`[INFO] Start ${commandName} before opening PM Studio.`);
1089
+ emit(`[INFO] Start ${commandName} before using Claude in PM Studio; GPT remains on PM Studio's native GitHub Copilot path.`);
1023
1090
  return {
1024
- status: "patched",
1091
+ status: migratingLegacy ? "migrated" : "patched",
1025
1092
  changed: true,
1026
1093
  recipe: recipe.id,
1027
1094
  backup,
@@ -16,6 +16,11 @@ import {
16
16
  pmStudioPatchManifestPath,
17
17
  } from "./pm-studio-setup.mjs";
18
18
  import { readAdapterStatus } from "./cli-status.mjs";
19
+ import {
20
+ PM_STUDIO_RELAY_MARKER_HEADER,
21
+ PM_STUDIO_RELAY_MARKER_VALUE,
22
+ PM_STUDIO_RELAY_PREFIX,
23
+ } from "./pm-studio-relay.mjs";
19
24
  import { checkRunningAdapter } from "./running-adapter.mjs";
20
25
  import { status } from "./status.mjs";
21
26
  import {
@@ -27,12 +32,11 @@ import {
27
32
 
28
33
  const PM_RELAY_HOST = "127.0.0.1";
29
34
  const PM_RELAY_PORT = 2026;
30
- const PM_RELAY_CAPABILITY = "pm_studio_relay_v1";
35
+ const PM_RELAY_PROBE_TIMEOUT_MS = 500;
36
+ const PM_RELAY_CAPABILITY = "pm_studio_split_origin_v1";
31
37
  const PM_RELAY_ROUTE_NAMES = Object.freeze([
32
38
  "pm_models",
33
39
  "pm_chat_completions",
34
- "pm_responses",
35
- "pm_embeddings",
36
40
  ]);
37
41
 
38
42
  function selectRecipe(metadata, recipes) {
@@ -45,6 +49,31 @@ function safeMessage(error) {
45
49
  return String(error?.message || "inspection failed").replace(/\s+/g, " ").trim();
46
50
  }
47
51
 
52
+ export async function probePmStudioRelay({
53
+ baseUrl,
54
+ fetchImpl = fetch,
55
+ timeoutMs = PM_RELAY_PROBE_TIMEOUT_MS,
56
+ } = {}) {
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
59
+ try {
60
+ const response = await fetchImpl(new URL(`${PM_STUDIO_RELAY_PREFIX}/models`, baseUrl).href, {
61
+ method: "GET",
62
+ headers: { Accept: "application/json" },
63
+ redirect: "error",
64
+ signal: controller.signal,
65
+ });
66
+ const result = {
67
+ status: response.status,
68
+ marker: response.headers.get(PM_STUDIO_RELAY_MARKER_HEADER) || "",
69
+ };
70
+ await response.body?.cancel().catch(() => {});
71
+ return result;
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
48
77
  function inspectClaudeProfile(home, readCredentials) {
49
78
  try {
50
79
  const credentials = readCredentials(AUTH_PROFILE_CLAUDE, { home });
@@ -73,7 +102,7 @@ function inspectAdapterRuntime(adapter, runtimeStatus) {
73
102
  issues.push("health and runtime status do not describe the same adapter instance");
74
103
  }
75
104
  const capabilities = new Set(Array.isArray(data.capabilities) ? data.capabilities : []);
76
- if (!capabilities.has(PM_RELAY_CAPABILITY)) issues.push("PM Studio relay capability is missing");
105
+ if (!capabilities.has(PM_RELAY_CAPABILITY)) issues.push("PM Studio split-origin relay capability is missing");
77
106
  if (data.profiles?.claude?.mode !== "isolated") issues.push("Claude profile mode is not isolated");
78
107
  if (data.profiles?.claude?.profile_current !== true) {
79
108
  issues.push("running Claude credentials do not match the current isolated profile");
@@ -93,6 +122,21 @@ function inspectAdapterRuntime(adapter, runtimeStatus) {
93
122
  };
94
123
  }
95
124
 
125
+ function inspectRelayProbe(runtime, probe) {
126
+ const relayProbe = {
127
+ status: probe?.status,
128
+ marker: String(probe?.marker || ""),
129
+ };
130
+ const issues = [...runtime.issues];
131
+ if (relayProbe.status !== 401) {
132
+ issues.push(`PM Studio relay probe returned HTTP ${relayProbe.status ?? "unknown"}; expected 401`);
133
+ }
134
+ if (relayProbe.marker !== PM_STUDIO_RELAY_MARKER_VALUE) {
135
+ issues.push("PM Studio relay compatibility marker is missing or incompatible");
136
+ }
137
+ return { ...runtime, relayProbe, ok: issues.length === 0, issues };
138
+ }
139
+
96
140
  export async function inspectPmStudioStatus({
97
141
  appPath = DEFAULT_PM_STUDIO_APP_PATH,
98
142
  home = os.homedir(),
@@ -105,6 +149,7 @@ export async function inspectPmStudioStatus({
105
149
  readClaudeCredentials = readAuthProfileCredentials,
106
150
  checkRunningAdapterFn = checkRunningAdapter,
107
151
  readAdapterStatusFn = readAdapterStatus,
152
+ probePmStudioRelayFn = probePmStudioRelay,
108
153
  } = {}) {
109
154
  const operations = createPmStudioSetupOperations(operationOverrides);
110
155
  let app;
@@ -163,6 +208,18 @@ export async function inspectPmStudioStatus({
163
208
  } catch (error) {
164
209
  runtime = { ok: false, baseUrl: adapter.baseUrl, data: null, issues: [safeMessage(error)] };
165
210
  }
211
+ if (runtime.data) {
212
+ try {
213
+ runtime = inspectRelayProbe(runtime, await probePmStudioRelayFn({ baseUrl: adapter.baseUrl }));
214
+ } catch (error) {
215
+ runtime = {
216
+ ...runtime,
217
+ ok: false,
218
+ relayProbe: null,
219
+ issues: [...runtime.issues, `PM Studio relay probe failed: ${safeMessage(error)}`],
220
+ };
221
+ }
222
+ }
166
223
  }
167
224
  const operational = app.state === "patched"
168
225
  && app.patchRecord?.valid === true
@@ -182,6 +239,9 @@ function appStatusItem(result, commandName) {
182
239
  const reason = app.patchRecord?.reason || "installed patch record is missing";
183
240
  return { kind: "err", component: "App patch", detail: `${version}; patch record not verified`, message: `PM Studio ${version} matches the patch recipe, but its installed patch record is not verified: ${reason}` };
184
241
  }
242
+ if (app.state === "legacy") {
243
+ return { kind: "err", component: "App patch", detail: `${version}; legacy global-origin patch`, message: `PM Studio ${version} has a legacy global-origin patch; run ${commandName} pms setup to migrate` };
244
+ }
185
245
  if (app.state === "clean") return { kind: "warn", component: "App patch", detail: `${version}; supported, not patched`, message: `PM Studio ${version} is supported but not patched; run ${commandName} pms setup` };
186
246
  if (app.state === "unsupported") return { kind: "err", component: "App patch", detail: `${version}; unsupported version`, message: `PM Studio ${version} has no exact patch recipe; no files will be changed` };
187
247
  if (app.state === "drift" && app.patchRecipeMatched) {
@@ -203,7 +263,7 @@ function pmStudioStatusItems(result, commandName) {
203
263
  items.push({ kind: "err", component: "Claude profile", detail: "not ready", message: `Isolated Claude profile is not ready${reason}; run ${PM_STUDIO_CLAUDE_AUTH_COMMAND}` });
204
264
  }
205
265
  if (result.adapter?.ok && result.runtime?.ok) {
206
- items.push({ kind: "ok", component: "PM relay", detail: `verified at ${result.adapter.baseUrl}`, message: `PM relay and isolated Claude routing are verified at ${result.adapter.baseUrl}` });
266
+ items.push({ kind: "ok", component: "PM relay", detail: `verified at ${result.adapter.baseUrl}`, message: `PM model discovery and isolated Claude routing are verified at ${result.adapter.baseUrl}` });
207
267
  } else if (result.adapter?.ok) {
208
268
  const reason = result.runtime?.issues?.join("; ") || "runtime status is unavailable";
209
269
  items.push({ kind: "err", component: "PM relay", detail: "running but routing not ready", message: `The adapter is running at ${result.adapter.baseUrl}, but PM relay routing is not ready: ${reason}; stop it and run ${commandName} start` });
@@ -213,9 +273,9 @@ function pmStudioStatusItems(result, commandName) {
213
273
  items.push({ kind: "warn", component: "PM relay", detail: `not running at ${PM_RELAY_HOST}:${PM_RELAY_PORT}`, message: `PM relay is not running at http://${PM_RELAY_HOST}:${PM_RELAY_PORT}; run ${commandName} start` });
214
274
  }
215
275
  if (result.runtime?.ok) {
216
- items.push({ kind: "info", component: "Routing", detail: "GPT -> PM bearer; Claude -> isolated profile", message: "Routing: PM GPT uses the PM Studio bearer; eligible Claude chat uses the isolated Claude profile" });
276
+ items.push({ kind: "info", component: "Routing", detail: "GPT -> native official; models/Claude -> local", message: "Routing: PM GPT uses its native official GitHub Copilot path; model discovery and eligible Claude chat use the local CCDX relay" });
217
277
  } else {
218
- items.push({ kind: "info", component: "Routing", detail: "expected: GPT -> PM bearer; Claude -> isolated profile", message: "Expected routing: PM GPT uses the PM Studio bearer; eligible Claude chat uses the isolated Claude profile" });
278
+ items.push({ kind: "info", component: "Routing", detail: "expected: GPT -> native official; models/Claude -> local", message: "Expected routing: PM GPT uses its native official GitHub Copilot path; model discovery and eligible Claude chat use the local CCDX relay" });
219
279
  }
220
280
  return items;
221
281
  }
@@ -65,9 +65,21 @@ function replayableResponsesOutputItems(output) {
65
65
  return output.filter((item) => item && typeof item === "object");
66
66
  }
67
67
 
68
+ function isEncryptedContentPart(value) {
69
+ return value && typeof value === "object" && value.type === "encrypted_content";
70
+ }
71
+
68
72
  function stripEncryptedReasoningValue(value, state) {
69
73
  if (Array.isArray(value)) {
70
- return value.map((item) => stripEncryptedReasoningValue(item, state));
74
+ const out = [];
75
+ for (const item of value) {
76
+ if (isEncryptedContentPart(item)) {
77
+ state.changed = true;
78
+ continue;
79
+ }
80
+ out.push(stripEncryptedReasoningValue(item, state));
81
+ }
82
+ return out;
71
83
  }
72
84
 
73
85
  if (value && typeof value === "object") {
@@ -87,7 +99,9 @@ function stripEncryptedReasoningValue(value, state) {
87
99
 
88
100
  function isEncryptedReasoningInputItem(item) {
89
101
  return item && typeof item === "object"
90
- && (item.type === "reasoning" || Object.prototype.hasOwnProperty.call(item, "encrypted_content"));
102
+ && (item.type === "reasoning"
103
+ || item.type === "encrypted_content"
104
+ || Object.prototype.hasOwnProperty.call(item, "encrypted_content"));
91
105
  }
92
106
 
93
107
  export function sanitizeEncryptedReasoningRequest(reqContext) {
@@ -5,7 +5,9 @@ export const ADAPTER_HEALTH_PATH = "/_ccdx/health";
5
5
  export const ADAPTER_STATUS_PATH = "/_ccdx/status";
6
6
  export const ADAPTER_PROTOCOL_VERSION = 2;
7
7
  export const ADAPTER_VERSION = localPackageVersion();
8
- export const ADAPTER_CAPABILITIES = Object.freeze(["pm_studio_relay_v1"]);
8
+ export const ADAPTER_CAPABILITIES = Object.freeze([
9
+ "pm_studio_split_origin_v1",
10
+ ]);
9
11
  const ADAPTER_INSTANCE_ID = randomUUID();
10
12
 
11
13
  export function adapterHealthPayload() {