codex-copilot-dx 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # codex-copilot-dx
2
2
 
3
- Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and optionally Claude App with your **GitHub Copilot** subscription.
3
+ Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and optionally Claude App or PM Studio with your **GitHub Copilot** subscription.
4
4
 
5
5
  ## How it works
6
6
 
@@ -10,6 +10,7 @@ account by default, or over isolated Codex and Claude accounts when configured:
10
10
  - **Codex** -> OpenAI Responses API (`/v1/responses`, `/v1/responses/compact`); Responses-only models and compaction proxy directly, chat models convert to Chat Completions.
11
11
  - **Claude Code** -> Anthropic Messages API (`/v1/messages`, `/v1/messages/count_tokens`), translated to/from Chat Completions.
12
12
  - **Claude App** -> optional Claude Desktop App gateway profile using the same local Messages API plus local model discovery for the configured gateway key.
13
+ - **PM Studio** -> optional macOS app patch that keeps the active PM profile for GPT models while routing enabled Claude models through an isolated CCDX Claude account.
13
14
 
14
15
  Supports both HTTP SSE streaming and non-streaming.
15
16
 
@@ -19,7 +20,7 @@ Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adap
19
20
 
20
21
  - GitHub Copilot subscription (Individual, Business, or Enterprise)
21
22
  - Node.js 22.15+ (required for built-in Zstandard request decompression)
22
- - [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and/or Claude App installed
23
+ - [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), Claude App, and/or a supported PM Studio installation
23
24
 
24
25
  ## Usage
25
26
 
@@ -97,6 +98,74 @@ ccdx models --profile claude
97
98
  ccdx doctor --online --profile all
98
99
  ```
99
100
 
101
+ ### PM Studio opt-in patch
102
+
103
+ CCDX can add the isolated Claude catalog to PM Studio without switching its
104
+ active account or profile. GPT requests continue to use the bearer supplied by
105
+ the current PM profile; exact enabled Anthropic model IDs use the isolated
106
+ CCDX Claude profile. Neither credential is copied into the other application.
107
+
108
+ The first recipe supports only an exact, unmodified PM Studio 2.9.7 bundle on
109
+ macOS. Authorize the isolated Claude profile first, quit PM Studio and its
110
+ updater, then run the one-time setup command:
111
+
112
+ ```bash
113
+ ccdx auth login claude --reauth --github-login <personal-github-login>
114
+ ccdx pms setup
115
+ ```
116
+
117
+ `ccdx pms setup` performs only local preflight, backup, staging, integrity,
118
+ signing, and replacement work. It never starts Device Flow, the adapter,
119
+ Codex, or PM Studio, and it does not modify PM profiles, cached model lists, or
120
+ saved PM tokens. Unknown versions, hashes, signatures, partial patches, a
121
+ running PM process, insufficient space, and every pre-replacement verification
122
+ failure leave the installed App unchanged. If the final read-only verification
123
+ after an atomic replacement detects an exceptional filesystem drift, setup
124
+ does not attempt a second automatic write: it preserves the verified backup,
125
+ retains any diagnostic stage still present, and prints recovery paths. Repeating
126
+ setup for the exact installed recipe is idempotent.
127
+
128
+ The patch is App-wide: every PM profile sends its Copilot API traffic through
129
+ the loopback-only `/pm-ccdx/*` relay. Run ordinary `ccdx` before opening PM
130
+ Studio; if port 2026 is unavailable, PM Copilot requests cannot connect. The
131
+ relay explicitly supports only the PM 2.9.7 Copilot paths discovered in the
132
+ validated bundle (`GET /models` and `POST /chat/completions`, `/responses`, and
133
+ `/embeddings`) and is not a general-purpose proxy.
134
+
135
+ Patching replaces the vendor signature with an ad-hoc signature. PM Studio's
136
+ updater is not disabled; an official update may overwrite the patch or may
137
+ require reinstalling the official App. Setup prints the verified backup and
138
+ manifest paths plus the version-matched restore procedure. Never restore a
139
+ 2.9.7 backup over a newer PM Studio installation.
140
+
141
+ To restore, first quit PM Studio and its updater. Use the exact verified backup
142
+ path printed by setup as `BACKUP_APP`, then confirm that both the installed App
143
+ and backup still report version/build `2.9.7/2.9.7`:
144
+
145
+ ```bash
146
+ BACKUP_APP="<verified-backup-path-from-ccdx-pms-setup>"
147
+ /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "/Applications/PM Studio.app/Contents/Info.plist"
148
+ /usr/bin/plutil -extract CFBundleVersion raw -o - "/Applications/PM Studio.app/Contents/Info.plist"
149
+ /usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$BACKUP_APP/Contents/Info.plist"
150
+ /usr/bin/plutil -extract CFBundleVersion raw -o - "$BACKUP_APP/Contents/Info.plist"
151
+ ```
152
+
153
+ Only when all four values match `2.9.7`, move the patched App aside, restore the
154
+ complete vendor-signed backup, and verify it before launch:
155
+
156
+ ```bash
157
+ RECOVERY_DIR="$(/usr/bin/mktemp -d '/Applications/CCDX-PM-Studio-recovery.XXXXXX')"
158
+ PATCHED_APP="$RECOVERY_DIR/PM Studio.app"
159
+ mv "/Applications/PM Studio.app" "$PATCHED_APP"
160
+ /usr/bin/ditto --rsrc --extattr --acl "$BACKUP_APP" "/Applications/PM Studio.app"
161
+ /usr/bin/codesign --verify --deep --strict --verbose=2 "/Applications/PM Studio.app"
162
+ ```
163
+
164
+ If the version check, copy, or signature verification fails, do not launch the
165
+ restored App. Keep the moved patched bundle at `$PATCHED_APP` and reinstall the official PM Studio
166
+ instead of applying a 2.9.7 backup to unknown content. CCDX never runs these
167
+ restore commands or `sudo` automatically.
168
+
100
169
  To change the model used by Codex Auto-review, run:
101
170
 
102
171
  ```bash
package/bin/cli.mjs CHANGED
@@ -123,6 +123,16 @@ if (CLI.command === "update") {
123
123
  process.exit(1);
124
124
  }
125
125
  }
126
+ if (CLI.command === "pms") {
127
+ try {
128
+ const { runPmStudioSetup } = await import("../src/pm-studio-setup.mjs");
129
+ await runPmStudioSetup({ commandName: CLI_NAME });
130
+ process.exit(0);
131
+ } catch (e) {
132
+ console.error(status("err", e.message));
133
+ process.exit(1);
134
+ }
135
+ }
126
136
 
127
137
  let RUNTIME;
128
138
  try {
@@ -296,6 +306,7 @@ try {
296
306
  autoReviewModelResolver: () => autoReviewModelPreference().model,
297
307
  codexClient: profileRuntime.codexClient,
298
308
  claudeClient: profileRuntime.claudeClient,
309
+ claudeProfile: profileRuntime.claudeProfile,
299
310
  codexModelRegistry: profileModels.codexRegistry,
300
311
  claudeModelRegistry: profileModels.claudeRegistry,
301
312
  claudeMode: profileRuntime.claudeMode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  runtimeStatusPayload,
12
12
  } from "./observability.mjs";
13
13
  import { createRequestId, runWithRequestContext } from "./request-context.mjs";
14
+ import { createPmStudioRelayHandler, PM_STUDIO_RELAY_PREFIX } from "./pm-studio-relay.mjs";
14
15
  import { createStreamPerformanceMetrics } from "./stream-performance.mjs";
15
16
  import { status } from "./status.mjs";
16
17
  import { createTerminalActivityIndicator } from "./terminal-activity.mjs";
@@ -172,6 +173,17 @@ export function createAdapterHandler(options = {}) {
172
173
  streamIdleTimeoutMs,
173
174
  upstreamTimeoutMs,
174
175
  });
176
+ const pmStudioRelayHandler = createPmStudioRelayHandler({
177
+ acquireRequest,
178
+ claudeClient,
179
+ claudeMode,
180
+ claudeProfile: options.claudeProfile,
181
+ claudeModelRegistry,
182
+ fetchImpl: options.pmStudioFetchImpl,
183
+ streamHandshakeTimeoutMs,
184
+ streamIdleTimeoutMs,
185
+ upstreamTimeoutMs,
186
+ });
175
187
 
176
188
  const dispatch = (req, res, pathname) => {
177
189
  if (req.method === "GET" && pathname === ADAPTER_STATUS_PATH) {
@@ -201,6 +213,10 @@ export function createAdapterHandler(options = {}) {
201
213
  return;
202
214
  }
203
215
 
216
+ if (pathname.startsWith(`${PM_STUDIO_RELAY_PREFIX}/`)) {
217
+ return pmStudioRelayHandler(req, res, pathname);
218
+ }
219
+
204
220
  if (req.method === "POST" && pathname === "/v1/responses") {
205
221
  return responsesHandler(req, res);
206
222
  }
@@ -58,6 +58,13 @@ export function parseCliArgs(args = []) {
58
58
  if (rest.length) unexpectedArgs(rest);
59
59
  return { command: "usage", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
60
60
  }
61
+ if (command === "pms") {
62
+ const [action, ...pmsArgs] = rest;
63
+ if (!action) throw new Error("Missing pms action: expected setup");
64
+ if (action !== "setup") throw new Error(`Unknown pms action: ${action}`);
65
+ if (pmsArgs.length) unexpectedArgs(pmsArgs);
66
+ return { command: "pms", action: "setup", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
67
+ }
61
68
  if (command === "models") {
62
69
  const options = parseOptions(rest, new Map([["--profile", "value"]]));
63
70
  return {
@@ -210,6 +217,7 @@ export function cliHelp(commandName = "ccdx") {
210
217
  ${name} doctor [--online] [--compat] [--profile codex|claude|all]
211
218
  ${name} status
212
219
  ${name} models [--profile codex|claude]
220
+ ${name} pms setup
213
221
  ${name} usage
214
222
  ${name} auto-review-model
215
223
  ${name} update [npm|github]
@@ -14,6 +14,10 @@ const ROUTE_NAMES = Object.freeze([
14
14
  "models",
15
15
  "messages",
16
16
  "messages_count_tokens",
17
+ "pm_models",
18
+ "pm_chat_completions",
19
+ "pm_responses",
20
+ "pm_embeddings",
17
21
  "not_found",
18
22
  ]);
19
23
 
@@ -97,6 +101,10 @@ export function classifyAdapterRoute(method, pathname) {
97
101
  if (method === "GET" && pathname === "/v1/models") return "models";
98
102
  if (method === "POST" && pathname === "/v1/messages") return "messages";
99
103
  if (method === "POST" && pathname === "/v1/messages/count_tokens") return "messages_count_tokens";
104
+ if (method === "GET" && pathname === "/pm-ccdx/models") return "pm_models";
105
+ if (method === "POST" && pathname === "/pm-ccdx/chat/completions") return "pm_chat_completions";
106
+ if (method === "POST" && pathname === "/pm-ccdx/responses") return "pm_responses";
107
+ if (method === "POST" && pathname === "/pm-ccdx/embeddings") return "pm_embeddings";
100
108
  return "not_found";
101
109
  }
102
110
 
@@ -0,0 +1,392 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const PM_STUDIO_ORIGIN = "https://api.githubcopilot.com";
4
+ export const CCDX_PM_STUDIO_ORIGIN = "http://127.0.0.1:2026/pm-ccdx";
5
+ export const ELECTRON_ASAR_INTEGRITY_SENTINEL = "AGbevlPCksUGKNL8TSn7wGmJEuJsXb2A";
6
+
7
+ export const PM_STUDIO_2_9_7_RECIPE = Object.freeze({
8
+ id: "pm-studio-2.9.7-build-2.9.7",
9
+ version: "2.9.7",
10
+ build: "2.9.7",
11
+ bundleIdentifier: "com.pm-studio.app",
12
+ sourceTeamIdentifier: "HL75GKK4W4",
13
+ executable: "PM Studio",
14
+ sourceExecutableSha256: "6364edd0561790610ee82399865f42f160523551d8d72220e26c4b18da324017",
15
+ electronFrameworkPath: "Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework",
16
+ sourceElectronFrameworkSha256: "e684d310334840f7a64f2d5171052eb514822af155779d3185e4f068daee4387",
17
+ embeddedAsarIntegrity: "absent",
18
+ asarPath: "Contents/Resources/app.asar",
19
+ infoPlistPath: "Contents/Info.plist",
20
+ integrityKey: "ElectronAsarIntegrity.Resources/app.asar",
21
+ dataOffset: 3_832_764,
22
+ sourceAsarSha256: "36fb8bf2fc9326ddf49baab5cc0b54b38751a7db041bda3c42a3b01107b54118",
23
+ sourceHeaderSha256: "87746d92e00db2d40c03830ea06de22ec7cba112d07729801ccd02e226ffd961",
24
+ patchedAsarSha256: "3dd0f53cdaa35a644d2cf56e4fc2dd20f5c90dc2989b6d81a467ef00ebb620a7",
25
+ patchedHeaderSha256: "86ed1113a563b82ecec05de89aab87a5fd1666f35bee76e2d76e2ed5f6dcb3fa",
26
+ sourceSentinel: PM_STUDIO_ORIGIN,
27
+ patchedSentinel: CCDX_PM_STUDIO_ORIGIN,
28
+ targets: Object.freeze([
29
+ Object.freeze({
30
+ path: "dist/main/main.js",
31
+ offset: 101_621_627,
32
+ size: 9_909_974,
33
+ sentinelOffset: 1_120_480,
34
+ absoluteSentinelOffset: 106_574_871,
35
+ sentinelCount: 1,
36
+ blockSize: 4_194_304,
37
+ sourceSha256: "58383be112886becdb8d8e7cce1b3efce07be6716190aa4940627f456eb8edb2",
38
+ patchedSha256: "24f1c47831c30f59e8464a66237c6bc9ed771d79f3a6889019dfdf53bc20fe42",
39
+ sourceBlocks: Object.freeze([
40
+ "3ce05a21e8992ccf12966cf9b78fcd28b2f66e6786489b5bb0a06f1d4b2a0c1e",
41
+ "0a4832413d8d8cdda67c8bb318c0e5c25df20c75802d637097ffd045a31cd443",
42
+ "bcc10582d8a74717f8f81b98182d3551f1eaf23eb9cadd908d160ce5e4ef8cba",
43
+ ]),
44
+ patchedBlocks: Object.freeze([
45
+ "afc18cc7d56cce6dd6f3bade8661268a534f2de93de5ba638f6609d2638c7436",
46
+ "0a4832413d8d8cdda67c8bb318c0e5c25df20c75802d637097ffd045a31cd443",
47
+ "bcc10582d8a74717f8f81b98182d3551f1eaf23eb9cadd908d160ce5e4ef8cba",
48
+ ]),
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
+ ]),
66
+ }),
67
+ ]),
68
+ });
69
+
70
+ export const PM_STUDIO_RECIPES = Object.freeze([PM_STUDIO_2_9_7_RECIPE]);
71
+
72
+ export function sha256Hex(value) {
73
+ return createHash("sha256").update(value).digest("hex");
74
+ }
75
+
76
+ function checkedBuffer(value) {
77
+ if (!Buffer.isBuffer(value)) throw new TypeError("ASAR input must be a Buffer");
78
+ return value;
79
+ }
80
+
81
+ function checkedUInt32(buffer, offset, name) {
82
+ if (offset < 0 || offset + 4 > buffer.length) throw new Error(`Invalid ASAR: missing ${name}`);
83
+ return buffer.readUInt32LE(offset);
84
+ }
85
+
86
+ function integer(value, name) {
87
+ const result = Number(value);
88
+ if (!Number.isSafeInteger(result) || result < 0) throw new Error(`Invalid ASAR ${name}: ${value}`);
89
+ return result;
90
+ }
91
+
92
+ function entryOffset(value, name) {
93
+ if (typeof value !== "string" || !/^(?:0|[1-9]\d*)$/.test(value)) {
94
+ throw new Error(`Invalid ASAR ${name}: ${value}`);
95
+ }
96
+ return integer(value, name);
97
+ }
98
+
99
+ function entrySize(value, name) {
100
+ if (typeof value !== "number") throw new Error(`Invalid ASAR ${name}: ${value}`);
101
+ return integer(value, name);
102
+ }
103
+
104
+ function aligned4(value) {
105
+ return (value + 3) & ~3;
106
+ }
107
+
108
+ export function parseAsarBuffer(value) {
109
+ const buffer = checkedBuffer(value);
110
+ if (buffer.length < 16) throw new Error("Invalid ASAR: header is truncated");
111
+
112
+ const sizePicklePayload = checkedUInt32(buffer, 0, "size pickle");
113
+ const headerPickleSize = checkedUInt32(buffer, 4, "header pickle size");
114
+ const headerPicklePayload = checkedUInt32(buffer, 8, "header pickle payload");
115
+ const headerLength = checkedUInt32(buffer, 12, "header string size");
116
+ const headerStart = 16;
117
+ const headerEnd = headerStart + headerLength;
118
+ const dataOffset = 8 + headerPickleSize;
119
+
120
+ if (sizePicklePayload !== 4
121
+ || headerPickleSize < 8
122
+ || headerPickleSize % 4 !== 0
123
+ || headerPicklePayload !== headerPickleSize - 4
124
+ || headerPicklePayload !== 4 + aligned4(headerLength)) {
125
+ throw new Error("Invalid ASAR: malformed pickle sizes");
126
+ }
127
+ if (headerEnd > dataOffset || dataOffset > buffer.length) {
128
+ throw new Error("Invalid ASAR: header extends outside the archive");
129
+ }
130
+ if (buffer.subarray(headerEnd, dataOffset).some((byte) => byte !== 0)) {
131
+ throw new Error("Invalid ASAR: header padding is not zeroed");
132
+ }
133
+
134
+ const headerBytes = buffer.subarray(headerStart, headerEnd);
135
+ let header;
136
+ try {
137
+ header = JSON.parse(headerBytes.toString("utf8"));
138
+ } catch {
139
+ throw new Error("Invalid ASAR: header JSON could not be parsed");
140
+ }
141
+ if (!header || typeof header !== "object" || !header.files || typeof header.files !== "object") {
142
+ throw new Error("Invalid ASAR: header has no files tree");
143
+ }
144
+
145
+ return {
146
+ buffer,
147
+ header,
148
+ headerBytes,
149
+ headerLength,
150
+ headerStart,
151
+ headerEnd,
152
+ headerPickleSize,
153
+ dataOffset,
154
+ };
155
+ }
156
+
157
+ export function findAsarEntry(header, filePath) {
158
+ const parts = String(filePath || "").split("/").filter(Boolean);
159
+ if (parts.length === 0) return null;
160
+ let node = { files: header?.files };
161
+ for (const part of parts) {
162
+ node = node?.files?.[part];
163
+ if (!node) return null;
164
+ }
165
+ return node;
166
+ }
167
+
168
+ function occurrencePositions(haystack, needle) {
169
+ const positions = [];
170
+ if (needle.length === 0) return positions;
171
+ let offset = 0;
172
+ while (offset <= haystack.length - needle.length) {
173
+ const position = haystack.indexOf(needle, offset);
174
+ if (position < 0) break;
175
+ positions.push(position);
176
+ offset = position + needle.length;
177
+ }
178
+ return positions;
179
+ }
180
+
181
+ export function blockSha256(buffer, blockSize) {
182
+ const size = integer(blockSize, "block size");
183
+ if (size === 0) throw new Error("Invalid ASAR block size: 0");
184
+ const hashes = [];
185
+ if (buffer.length === 0) return [sha256Hex(buffer)];
186
+ for (let offset = 0; offset < buffer.length; offset += size) {
187
+ hashes.push(sha256Hex(buffer.subarray(offset, Math.min(offset + size, buffer.length))));
188
+ }
189
+ return hashes;
190
+ }
191
+
192
+ export function inspectElectronAsarIntegritySlots(value) {
193
+ const buffer = checkedBuffer(value);
194
+ const sentinel = Buffer.from(ELECTRON_ASAR_INTEGRITY_SENTINEL);
195
+ const slots = [];
196
+ let searchOffset = 0;
197
+ let truncated = false;
198
+ while (searchOffset <= buffer.length - sentinel.length) {
199
+ const offset = buffer.indexOf(sentinel, searchOffset);
200
+ if (offset < 0) break;
201
+ if (offset + 66 > buffer.length) {
202
+ truncated = true;
203
+ break;
204
+ }
205
+ const used = buffer[offset + 32];
206
+ const version = buffer[offset + 33];
207
+ slots.push({
208
+ offset,
209
+ used,
210
+ version,
211
+ digest: buffer.subarray(offset + 34, offset + 66).toString("hex"),
212
+ });
213
+ searchOffset = offset + sentinel.length;
214
+ }
215
+ const malformed = truncated
216
+ || slots.length > 1
217
+ || slots.some((slot) => ![0, 1].includes(slot.used) || (slot.used === 1 && slot.version !== 1));
218
+ const active = slots.some((slot) => slot.used === 1);
219
+ return {
220
+ state: malformed ? "drift" : active ? "active" : slots.length === 1 ? "inactive" : "absent",
221
+ active,
222
+ supported: !malformed && !active,
223
+ slots,
224
+ };
225
+ }
226
+
227
+ function sameArray(actual, expected) {
228
+ return Array.isArray(actual)
229
+ && Array.isArray(expected)
230
+ && actual.length === expected.length
231
+ && actual.every((value, index) => value === expected[index]);
232
+ }
233
+
234
+ function addMismatch(issues, label, actual, expected) {
235
+ if (actual !== expected) issues.push(`${label}: expected ${expected}, got ${actual}`);
236
+ }
237
+
238
+ function inspectTarget(parsed, target, recipe) {
239
+ const entry = findAsarEntry(parsed.header, target.path);
240
+ if (!entry) return { path: target.path, missing: true };
241
+
242
+ let offset;
243
+ let size;
244
+ try {
245
+ offset = entryOffset(entry.offset, `${target.path} offset`);
246
+ size = entrySize(entry.size, `${target.path} size`);
247
+ } catch (error) {
248
+ return { path: target.path, malformed: error.message };
249
+ }
250
+ const absoluteOffset = parsed.dataOffset + offset;
251
+ if (entry.unpacked || absoluteOffset + size > parsed.buffer.length) {
252
+ return { path: target.path, offset, size, absoluteOffset, malformed: "entry data is outside the packed archive" };
253
+ }
254
+
255
+ const bytes = parsed.buffer.subarray(absoluteOffset, absoluteOffset + size);
256
+ const sourceSentinel = Buffer.from(recipe.sourceSentinel);
257
+ const patchedSentinel = Buffer.from(recipe.patchedSentinel);
258
+ return {
259
+ path: target.path,
260
+ offset,
261
+ size,
262
+ absoluteOffset,
263
+ sha256: sha256Hex(bytes),
264
+ blockSize: entry.integrity?.blockSize,
265
+ blocks: blockSha256(bytes, target.blockSize),
266
+ integrity: entry.integrity || null,
267
+ sourceSentinelPositions: occurrencePositions(bytes, sourceSentinel),
268
+ patchedSentinelPositions: occurrencePositions(bytes, patchedSentinel),
269
+ };
270
+ }
271
+
272
+ function targetStateIssues(detail, target, recipe, state) {
273
+ const issues = [];
274
+ if (detail.missing) return [`${target.path}: entry is missing`];
275
+ if (detail.malformed) return [`${target.path}: ${detail.malformed}`];
276
+
277
+ addMismatch(issues, `${target.path} offset`, detail.offset, target.offset);
278
+ addMismatch(issues, `${target.path} size`, detail.size, target.size);
279
+ addMismatch(issues, `${target.path} absolute sentinel offset`,
280
+ detail.absoluteOffset + target.sentinelOffset, target.absoluteSentinelOffset);
281
+ addMismatch(issues, `${target.path} integrity algorithm`,
282
+ detail.integrity?.algorithm, "SHA256");
283
+ addMismatch(issues, `${target.path} block size`, detail.integrity?.blockSize, target.blockSize);
284
+
285
+ const patched = state === "patched";
286
+ const expectedHash = patched ? target.patchedSha256 : target.sourceSha256;
287
+ const expectedBlocks = patched ? target.patchedBlocks : target.sourceBlocks;
288
+ const expectedSentinelPositions = [target.sentinelOffset];
289
+ const presentPositions = patched ? detail.patchedSentinelPositions : detail.sourceSentinelPositions;
290
+ const absentPositions = patched ? detail.sourceSentinelPositions : detail.patchedSentinelPositions;
291
+
292
+ addMismatch(issues, `${target.path} SHA-256`, detail.sha256, expectedHash);
293
+ addMismatch(issues, `${target.path} integrity hash`, detail.integrity?.hash, expectedHash);
294
+ if (!sameArray(detail.blocks, expectedBlocks)) issues.push(`${target.path} computed block hashes do not match ${state} recipe`);
295
+ if (!sameArray(detail.integrity?.blocks, expectedBlocks)) issues.push(`${target.path} header block hashes do not match ${state} recipe`);
296
+ if (!sameArray(presentPositions, expectedSentinelPositions)) {
297
+ issues.push(`${target.path} ${state} sentinel position/count does not match recipe`);
298
+ }
299
+ if (absentPositions.length !== 0) issues.push(`${target.path} contains the opposite-state sentinel`);
300
+ if (presentPositions.length !== target.sentinelCount) {
301
+ issues.push(`${target.path} ${state} sentinel count is ${presentPositions.length}, expected ${target.sentinelCount}`);
302
+ }
303
+ return issues;
304
+ }
305
+
306
+ export function inspectAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
307
+ const parsed = parseAsarBuffer(value);
308
+ const asarSha256 = sha256Hex(parsed.buffer);
309
+ const headerSha256 = sha256Hex(parsed.headerBytes);
310
+ const targets = recipe.targets.map((target) => inspectTarget(parsed, target, recipe));
311
+ const commonIssues = [];
312
+ addMismatch(commonIssues, "ASAR data offset", parsed.dataOffset, recipe.dataOffset);
313
+
314
+ const issuesFor = (state) => {
315
+ const patched = state === "patched";
316
+ const issues = [...commonIssues];
317
+ addMismatch(issues, "ASAR SHA-256", asarSha256,
318
+ patched ? recipe.patchedAsarSha256 : recipe.sourceAsarSha256);
319
+ addMismatch(issues, "ASAR header SHA-256", headerSha256,
320
+ patched ? recipe.patchedHeaderSha256 : recipe.sourceHeaderSha256);
321
+ for (let index = 0; index < recipe.targets.length; index += 1) {
322
+ issues.push(...targetStateIssues(targets[index], recipe.targets[index], recipe, state));
323
+ }
324
+ return issues;
325
+ };
326
+
327
+ const cleanIssues = issuesFor("clean");
328
+ const patchedIssues = issuesFor("patched");
329
+ const state = cleanIssues.length === 0 ? "clean" : patchedIssues.length === 0 ? "patched" : "drift";
330
+ return {
331
+ recipeId: recipe.id,
332
+ state,
333
+ asarSha256,
334
+ headerSha256,
335
+ dataOffset: parsed.dataOffset,
336
+ targets,
337
+ cleanIssues,
338
+ patchedIssues,
339
+ };
340
+ }
341
+
342
+ export function patchAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
343
+ if (Buffer.byteLength(recipe.sourceSentinel) !== Buffer.byteLength(recipe.patchedSentinel)) {
344
+ throw new Error("PM Studio patch requires equal-length origin sentinels");
345
+ }
346
+ const before = inspectAsarBuffer(value, recipe);
347
+ if (before.state === "patched") {
348
+ return { changed: false, buffer: Buffer.from(value), before, after: before };
349
+ }
350
+ if (before.state !== "clean") {
351
+ const error = new Error("PM Studio ASAR does not match the clean or patched recipe");
352
+ error.code = "PM_STUDIO_ASAR_DRIFT";
353
+ error.inspection = before;
354
+ throw error;
355
+ }
356
+
357
+ const output = Buffer.from(value);
358
+ const parsed = parseAsarBuffer(output);
359
+ const sourceSentinel = Buffer.from(recipe.sourceSentinel);
360
+ const patchedSentinel = Buffer.from(recipe.patchedSentinel);
361
+
362
+ for (const target of recipe.targets) {
363
+ const entry = findAsarEntry(parsed.header, target.path);
364
+ const offset = entryOffset(entry.offset, `${target.path} offset`);
365
+ const absoluteSentinelOffset = parsed.dataOffset + offset + target.sentinelOffset;
366
+ const existing = output.subarray(absoluteSentinelOffset, absoluteSentinelOffset + sourceSentinel.length);
367
+ if (!existing.equals(sourceSentinel)) throw new Error(`${target.path} source sentinel moved before patching`);
368
+ patchedSentinel.copy(output, absoluteSentinelOffset);
369
+ entry.integrity = {
370
+ ...entry.integrity,
371
+ algorithm: "SHA256",
372
+ hash: target.patchedSha256,
373
+ blockSize: target.blockSize,
374
+ blocks: [...target.patchedBlocks],
375
+ };
376
+ }
377
+
378
+ const nextHeaderBytes = Buffer.from(JSON.stringify(parsed.header));
379
+ if (nextHeaderBytes.length !== parsed.headerLength) {
380
+ throw new Error(`Patched ASAR header changed size (${parsed.headerLength} -> ${nextHeaderBytes.length})`);
381
+ }
382
+ nextHeaderBytes.copy(output, parsed.headerStart);
383
+
384
+ const after = inspectAsarBuffer(output, recipe);
385
+ if (after.state !== "patched") {
386
+ const error = new Error("Patched PM Studio ASAR failed recipe verification");
387
+ error.code = "PM_STUDIO_ASAR_PATCH_VERIFY_FAILED";
388
+ error.inspection = after;
389
+ throw error;
390
+ }
391
+ return { changed: true, buffer: output, before, after };
392
+ }