codex-copilot-dx 0.6.14 → 0.6.18

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.
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
2
3
 
3
4
  export const PM_STUDIO_ORIGIN = "https://api.githubcopilot.com";
4
5
  export const CCDX_PM_STUDIO_ORIGIN = "http://127.0.0.1:2026/pm-ccdx";
@@ -272,6 +273,187 @@ function aligned4(value) {
272
273
  return (value + 3) & ~3;
273
274
  }
274
275
 
276
+ const FILE_READ_CHUNK_BYTES = 1024 * 1024;
277
+ const PM_STUDIO_ASAR_HEADER_MAX_BYTES = 64 * 1024 * 1024;
278
+
279
+ function fileIdentity(filePath, stat) {
280
+ return [
281
+ filePath,
282
+ stat.dev,
283
+ stat.ino,
284
+ stat.size,
285
+ stat.mtimeNs,
286
+ stat.ctimeNs,
287
+ ].join("\0");
288
+ }
289
+
290
+ function readExact(file, buffer, position, io) {
291
+ let offset = 0;
292
+ while (offset < buffer.length) {
293
+ const length = io.readSync(file, buffer, offset, buffer.length - offset, position + offset);
294
+ if (length === 0) throw new Error("Invalid ASAR: header is truncated");
295
+ offset += length;
296
+ }
297
+ }
298
+
299
+ function sameFileSnapshot(left, right) {
300
+ return left.dev === right.dev
301
+ && left.ino === right.ino
302
+ && left.size === right.size
303
+ && left.mtimeNs === right.mtimeNs
304
+ && left.ctimeNs === right.ctimeNs;
305
+ }
306
+
307
+ function withVerifiedFile(filePath, {
308
+ expectedIdentity = "",
309
+ io = fs,
310
+ label,
311
+ } = {}, read) {
312
+ const realPath = io.realpathSync(filePath);
313
+ const pathBefore = io.statSync(realPath, { bigint: true });
314
+ const file = io.openSync(realPath, io.constants.O_RDONLY | (io.constants.O_NOFOLLOW || 0));
315
+ try {
316
+ const before = io.fstatSync(file, { bigint: true });
317
+ const identity = fileIdentity(realPath, before);
318
+ if (!sameFileSnapshot(pathBefore, before) || (expectedIdentity && expectedIdentity !== identity)) {
319
+ throw new Error(`PM Studio ${label} changed before it could be read: ${filePath}`);
320
+ }
321
+ const result = read({ file, before, realPath });
322
+ const after = io.fstatSync(file, { bigint: true });
323
+ const pathAfter = io.statSync(realPath, { bigint: true });
324
+ if (!sameFileSnapshot(before, after) || !sameFileSnapshot(after, pathAfter)) {
325
+ throw new Error(`PM Studio ${label} changed while reading: ${filePath}`);
326
+ }
327
+ return {
328
+ result,
329
+ realPath,
330
+ identity: fileIdentity(realPath, after),
331
+ };
332
+ } finally {
333
+ io.closeSync(file);
334
+ }
335
+ }
336
+
337
+ function populateAsarTargetBytes(evidence, targetPaths, io) {
338
+ const missing = targetPaths.filter((targetPath) => !evidence.parsed.targetBytes.has(targetPath));
339
+ withVerifiedFile(evidence.realPath, {
340
+ expectedIdentity: evidence.identity,
341
+ io,
342
+ label: "ASAR",
343
+ }, ({ file, before }) => {
344
+ for (const targetPath of missing) {
345
+ const entry = findAsarEntry(evidence.parsed.header, targetPath);
346
+ if (!entry || entry.unpacked) continue;
347
+ let offset;
348
+ let size;
349
+ try {
350
+ offset = entryOffset(entry.offset, `${targetPath} offset`);
351
+ size = entrySize(entry.size, `${targetPath} size`);
352
+ } catch {
353
+ continue;
354
+ }
355
+ if (size > PM_STUDIO_COMPATIBLE_TARGET_MAX_BYTES) {
356
+ throw new Error(`Invalid ASAR: ${targetPath} exceeds the safe inspection limit`);
357
+ }
358
+ const absoluteOffset = evidence.parsed.dataOffset + offset;
359
+ if (absoluteOffset + size > Number(before.size)) continue;
360
+ const bytes = Buffer.allocUnsafe(size);
361
+ readExact(file, bytes, absoluteOffset, io);
362
+ evidence.parsed.targetBytes.set(targetPath, bytes);
363
+ }
364
+ });
365
+ }
366
+
367
+ function readAsarFileEvidence(filePath, targetPaths, { cache, io = fs } = {}) {
368
+ const realPath = io.realpathSync(filePath);
369
+ const pathStat = io.statSync(realPath, { bigint: true });
370
+ const paths = [...new Set(targetPaths)].sort();
371
+ const cacheKey = `${fileIdentity(realPath, pathStat)}\0${paths.join("\0")}`;
372
+ if (cache?.has(cacheKey)) {
373
+ const evidence = cache.get(cacheKey);
374
+ populateAsarTargetBytes(evidence, paths, io);
375
+ return evidence;
376
+ }
377
+
378
+ const opened = withVerifiedFile(realPath, {
379
+ expectedIdentity: fileIdentity(realPath, pathStat),
380
+ io,
381
+ label: "ASAR",
382
+ }, ({ file, before }) => {
383
+ const prefix = Buffer.allocUnsafe(16);
384
+ readExact(file, prefix, 0, io);
385
+ const headerPickleSize = checkedUInt32(prefix, 4, "header pickle size");
386
+ const dataOffset = 8 + headerPickleSize;
387
+ if (dataOffset > PM_STUDIO_ASAR_HEADER_MAX_BYTES) {
388
+ throw new Error("Invalid ASAR: header exceeds the safe inspection limit");
389
+ }
390
+ if (dataOffset > Number(before.size)) {
391
+ throw new Error("Invalid ASAR: header extends outside the archive");
392
+ }
393
+ const headerRegion = Buffer.allocUnsafe(dataOffset);
394
+ readExact(file, headerRegion, 0, io);
395
+ const parsedHeader = parseAsarBuffer(headerRegion);
396
+ const targetRanges = new Map();
397
+ for (const targetPath of paths) {
398
+ const entry = findAsarEntry(parsedHeader.header, targetPath);
399
+ if (!entry || entry.unpacked) continue;
400
+ let offset;
401
+ let size;
402
+ try {
403
+ offset = entryOffset(entry.offset, `${targetPath} offset`);
404
+ size = entrySize(entry.size, `${targetPath} size`);
405
+ } catch {
406
+ continue;
407
+ }
408
+ if (size > PM_STUDIO_COMPATIBLE_TARGET_MAX_BYTES) {
409
+ throw new Error(`Invalid ASAR: ${targetPath} exceeds the safe inspection limit`);
410
+ }
411
+ const absoluteOffset = parsedHeader.dataOffset + offset;
412
+ if (absoluteOffset + size <= Number(before.size)) {
413
+ targetRanges.set(targetPath, { start: absoluteOffset, end: absoluteOffset + size, bytes: Buffer.allocUnsafe(size) });
414
+ }
415
+ }
416
+
417
+ const hash = createHash("sha256");
418
+ const chunk = Buffer.allocUnsafe(FILE_READ_CHUNK_BYTES);
419
+ let position = 0;
420
+ for (;;) {
421
+ const length = io.readSync(file, chunk, 0, chunk.length, position);
422
+ if (length === 0) break;
423
+ const bytes = chunk.subarray(0, length);
424
+ hash.update(bytes);
425
+ const chunkEnd = position + length;
426
+ for (const range of targetRanges.values()) {
427
+ const start = Math.max(position, range.start);
428
+ const end = Math.min(chunkEnd, range.end);
429
+ if (start < end) bytes.copy(range.bytes, start - range.start, start - position, end - position);
430
+ }
431
+ position = chunkEnd;
432
+ }
433
+ if (position !== Number(before.size)) {
434
+ throw new Error(`PM Studio ASAR changed while reading: ${filePath}`);
435
+ }
436
+ return {
437
+ parsed: {
438
+ ...parsedHeader,
439
+ buffer: null,
440
+ headerBytes: null,
441
+ headerSha256: sha256Hex(parsedHeader.headerBytes),
442
+ archiveLength: Number(before.size),
443
+ targetBytes: new Map([...targetRanges].map(([name, range]) => [name, range.bytes])),
444
+ },
445
+ asarSha256: hash.digest("hex"),
446
+ };
447
+ });
448
+ const evidence = {
449
+ ...opened.result,
450
+ realPath: opened.realPath,
451
+ identity: opened.identity,
452
+ };
453
+ cache?.set(cacheKey, evidence);
454
+ return evidence;
455
+ }
456
+
275
457
  export function parseAsarBuffer(value) {
276
458
  const buffer = checkedBuffer(value);
277
459
  if (buffer.length < 16) throw new Error("Invalid ASAR: header is truncated");
@@ -456,8 +638,7 @@ function requiredRecipeString(value, name) {
456
638
  return value;
457
639
  }
458
640
 
459
- export function createCompatiblePmStudioRecipe(value, options = {}) {
460
- const parsed = parseAsarBuffer(value);
641
+ function compatibleRecipeFromEvidence(parsed, sourceTarget, sourceAsarSha256, patchedAsarSha256For, options) {
461
642
  const entry = findAsarEntry(parsed.header, PM_STUDIO_COPILOT_CONFIG_MODULE_PATH);
462
643
  if (!entry) throw incompatibleAsar(`${PM_STUDIO_COPILOT_CONFIG_MODULE_PATH} is missing`);
463
644
 
@@ -473,7 +654,8 @@ export function createCompatiblePmStudioRecipe(value, options = {}) {
473
654
  throw incompatibleAsar(error.message);
474
655
  }
475
656
  const absoluteOffset = parsed.dataOffset + targetOffset;
476
- if (entry.unpacked || entry.link || absoluteOffset + targetSize > parsed.buffer.length) {
657
+ const archiveLength = parsed.archiveLength ?? parsed.buffer.length;
658
+ if (entry.unpacked || entry.link || absoluteOffset + targetSize > archiveLength) {
477
659
  throw incompatibleAsar(`${PM_STUDIO_COPILOT_CONFIG_MODULE_PATH} is not one packed regular ASAR entry`);
478
660
  }
479
661
  if (blockSize === 0
@@ -488,8 +670,9 @@ export function createCompatiblePmStudioRecipe(value, options = {}) {
488
670
  || entry.integrity.blocks.length !== expectedBlockCount) {
489
671
  throw incompatibleAsar(`${PM_STUDIO_COPILOT_CONFIG_MODULE_PATH} has unsafe integrity block geometry`);
490
672
  }
491
-
492
- const sourceTarget = parsed.buffer.subarray(absoluteOffset, absoluteOffset + targetSize);
673
+ if (!sourceTarget || sourceTarget.length !== targetSize) {
674
+ throw incompatibleAsar(`${PM_STUDIO_COPILOT_CONFIG_MODULE_PATH} is not one packed regular ASAR entry`);
675
+ }
493
676
  const sourceTargetSha256 = sha256Hex(sourceTarget);
494
677
  const sourceBlocks = blockSha256(sourceTarget, blockSize);
495
678
  if (entry.integrity.hash !== sourceTargetSha256
@@ -519,10 +702,8 @@ export function createCompatiblePmStudioRecipe(value, options = {}) {
519
702
  replacement.copy(patchedTarget, editOffset);
520
703
  const patchedTargetSha256 = sha256Hex(patchedTarget);
521
704
  const patchedBlocks = blockSha256(patchedTarget, blockSize);
522
- const patchedAsar = Buffer.from(parsed.buffer);
523
- replacement.copy(patchedAsar, absoluteOffset + editOffset);
524
- const patchedParsed = parseAsarBuffer(patchedAsar);
525
- const patchedEntry = findAsarEntry(patchedParsed.header, PM_STUDIO_COPILOT_CONFIG_MODULE_PATH);
705
+ const patchedHeader = JSON.parse(JSON.stringify(parsed.header));
706
+ const patchedEntry = findAsarEntry(patchedHeader, PM_STUDIO_COPILOT_CONFIG_MODULE_PATH);
526
707
  patchedEntry.integrity = {
527
708
  ...patchedEntry.integrity,
528
709
  algorithm: "SHA256",
@@ -530,16 +711,19 @@ export function createCompatiblePmStudioRecipe(value, options = {}) {
530
711
  blockSize,
531
712
  blocks: [...patchedBlocks],
532
713
  };
533
- const patchedHeaderBytes = Buffer.from(JSON.stringify(patchedParsed.header));
534
- if (patchedHeaderBytes.length !== patchedParsed.headerLength) {
535
- throw incompatibleAsar(`Compatible ASAR header changed size (${patchedParsed.headerLength} -> ${patchedHeaderBytes.length})`);
714
+ const patchedHeaderBytes = Buffer.from(JSON.stringify(patchedHeader));
715
+ if (patchedHeaderBytes.length !== parsed.headerLength) {
716
+ throw incompatibleAsar(`Compatible ASAR header changed size (${parsed.headerLength} -> ${patchedHeaderBytes.length})`);
536
717
  }
537
- patchedHeaderBytes.copy(patchedAsar, patchedParsed.headerStart);
718
+ const patchedAsarSha256 = patchedAsarSha256For({
719
+ absoluteEditOffset: absoluteOffset + editOffset,
720
+ patchedHeaderBytes,
721
+ replacement,
722
+ });
538
723
 
539
724
  const version = requiredRecipeString(String(options.version || ""), "version");
540
725
  const build = requiredRecipeString(String(options.build || ""), "build");
541
726
  const bundleIdentifier = requiredRecipeString(options.bundleIdentifier, "bundle identifier");
542
- const sourceAsarSha256 = sha256Hex(parsed.buffer);
543
727
  const identity = sha256Hex(`${PM_STUDIO_COMPATIBILITY_ID}\0${bundleIdentifier}\0${version}\0${build}\0${sourceAsarSha256}`).slice(0, 24);
544
728
  const sourceBundleContent = options.sourceBundleContent
545
729
  ? Object.freeze({
@@ -588,14 +772,111 @@ export function createCompatiblePmStudioRecipe(value, options = {}) {
588
772
  integrityKey: requiredRecipeString(options.integrityKey, "ASAR integrity key"),
589
773
  dataOffset: parsed.dataOffset,
590
774
  sourceAsarSha256,
591
- sourceHeaderSha256: sha256Hex(parsed.headerBytes),
592
- patchedAsarSha256: sha256Hex(patchedAsar),
775
+ sourceHeaderSha256: parsed.headerSha256 || sha256Hex(parsed.headerBytes),
776
+ patchedAsarSha256,
593
777
  patchedHeaderSha256: sha256Hex(patchedHeaderBytes),
594
778
  ...(sourceBundleContent ? { sourceBundleContent } : {}),
595
779
  targets: Object.freeze([target]),
596
780
  });
597
781
  }
598
782
 
783
+ export function createCompatiblePmStudioRecipe(value, options = {}) {
784
+ const parsed = parseAsarBuffer(value);
785
+ const entry = findAsarEntry(parsed.header, PM_STUDIO_COPILOT_CONFIG_MODULE_PATH);
786
+ let sourceTarget = null;
787
+ try {
788
+ const absoluteOffset = parsed.dataOffset
789
+ + entryOffset(entry?.offset, `${PM_STUDIO_COPILOT_CONFIG_MODULE_PATH} offset`);
790
+ const targetSize = entrySize(entry?.size, `${PM_STUDIO_COPILOT_CONFIG_MODULE_PATH} size`);
791
+ if (entry && !entry.unpacked && absoluteOffset + targetSize <= parsed.buffer.length) {
792
+ sourceTarget = parsed.buffer.subarray(absoluteOffset, absoluteOffset + targetSize);
793
+ }
794
+ } catch {
795
+ // The shared validator below preserves the existing classified error.
796
+ }
797
+ return compatibleRecipeFromEvidence(
798
+ parsed,
799
+ sourceTarget,
800
+ sha256Hex(parsed.buffer),
801
+ ({ absoluteEditOffset, patchedHeaderBytes, replacement }) => {
802
+ const patchedAsar = Buffer.from(parsed.buffer);
803
+ replacement.copy(patchedAsar, absoluteEditOffset);
804
+ patchedHeaderBytes.copy(patchedAsar, parsed.headerStart);
805
+ return sha256Hex(patchedAsar);
806
+ },
807
+ options,
808
+ );
809
+ }
810
+
811
+ function hashFileWithReplacements(filePath, expectedIdentity, replacements, io = fs) {
812
+ return withVerifiedFile(filePath, {
813
+ expectedIdentity,
814
+ io,
815
+ label: "ASAR",
816
+ }, ({ file, before }) => {
817
+ const ordered = [...replacements].sort((left, right) => left.start - right.start);
818
+ let cursor = 0;
819
+ for (const item of ordered) {
820
+ if (item.start < cursor || item.end < item.start || item.end > Number(before.size)
821
+ || item.bytes.length !== item.end - item.start) {
822
+ throw new Error("Invalid patched ASAR hash replacement range");
823
+ }
824
+ cursor = item.end;
825
+ }
826
+ const hash = createHash("sha256");
827
+ const chunk = Buffer.allocUnsafe(FILE_READ_CHUNK_BYTES);
828
+ const hashRange = (start, end) => {
829
+ let position = start;
830
+ while (position < end) {
831
+ const requested = Math.min(chunk.length, end - position);
832
+ const length = io.readSync(file, chunk, 0, requested, position);
833
+ if (length === 0) throw new Error(`PM Studio ASAR changed while reading: ${filePath}`);
834
+ hash.update(chunk.subarray(0, length));
835
+ position += length;
836
+ }
837
+ };
838
+ cursor = 0;
839
+ for (const item of ordered) {
840
+ hashRange(cursor, item.start);
841
+ hash.update(item.bytes);
842
+ cursor = item.end;
843
+ }
844
+ hashRange(cursor, Number(before.size));
845
+ return hash.digest("hex");
846
+ }).result;
847
+ }
848
+
849
+ export function createCompatiblePmStudioRecipeFromFile(filePath, options = {}, fileOptions = {}) {
850
+ const evidence = readAsarFileEvidence(filePath, [PM_STUDIO_COPILOT_CONFIG_MODULE_PATH], fileOptions);
851
+ try {
852
+ return compatibleRecipeFromEvidence(
853
+ evidence.parsed,
854
+ evidence.parsed.targetBytes.get(PM_STUDIO_COPILOT_CONFIG_MODULE_PATH),
855
+ evidence.asarSha256,
856
+ ({ absoluteEditOffset, patchedHeaderBytes, replacement }) => hashFileWithReplacements(
857
+ evidence.realPath,
858
+ evidence.identity,
859
+ [
860
+ {
861
+ start: evidence.parsed.headerStart,
862
+ end: evidence.parsed.headerEnd,
863
+ bytes: patchedHeaderBytes,
864
+ },
865
+ {
866
+ start: absoluteEditOffset,
867
+ end: absoluteEditOffset + replacement.length,
868
+ bytes: replacement,
869
+ },
870
+ ],
871
+ fileOptions.io || fs,
872
+ ),
873
+ options,
874
+ );
875
+ } finally {
876
+ if (fileOptions.releaseTargets) evidence.parsed.targetBytes.clear();
877
+ }
878
+ }
879
+
599
880
  export function inspectElectronAsarIntegritySlots(value) {
600
881
  const buffer = checkedBuffer(value);
601
882
  const sentinel = Buffer.from(ELECTRON_ASAR_INTEGRITY_SENTINEL);
@@ -619,6 +900,10 @@ export function inspectElectronAsarIntegritySlots(value) {
619
900
  });
620
901
  searchOffset = offset + sentinel.length;
621
902
  }
903
+ return electronSlotResult(slots, truncated);
904
+ }
905
+
906
+ function electronSlotResult(slots, truncated) {
622
907
  const malformed = truncated
623
908
  || slots.length > 1
624
909
  || slots.some((slot) => ![0, 1].includes(slot.used) || (slot.used === 1 && slot.version !== 1));
@@ -631,6 +916,86 @@ export function inspectElectronAsarIntegritySlots(value) {
631
916
  };
632
917
  }
633
918
 
919
+ export function inspectElectronAsarIntegrityFile(filePath, {
920
+ cache,
921
+ io = fs,
922
+ chunkSize = FILE_READ_CHUNK_BYTES,
923
+ } = {}) {
924
+ if (!Number.isSafeInteger(chunkSize) || chunkSize < 66) {
925
+ throw new TypeError("Electron integrity file chunk size must be at least 66 bytes");
926
+ }
927
+ const realPath = io.realpathSync(filePath);
928
+ const pathStat = io.statSync(realPath, { bigint: true });
929
+ const cacheKey = `electron\0${fileIdentity(realPath, pathStat)}`;
930
+ if (cache?.has(cacheKey)) {
931
+ withVerifiedFile(realPath, {
932
+ expectedIdentity: fileIdentity(realPath, pathStat),
933
+ io,
934
+ label: "executable",
935
+ }, () => {});
936
+ return cache.get(cacheKey);
937
+ }
938
+ const opened = withVerifiedFile(realPath, {
939
+ expectedIdentity: fileIdentity(realPath, pathStat),
940
+ io,
941
+ label: "executable",
942
+ }, ({ file, before }) => {
943
+ const sentinel = Buffer.from(ELECTRON_ASAR_INTEGRITY_SENTINEL);
944
+ const hash = createHash("sha256");
945
+ const chunk = Buffer.allocUnsafe(chunkSize);
946
+ const slots = [];
947
+ let tail = Buffer.alloc(0);
948
+ let position = 0;
949
+ let nextSearchOffset = 0;
950
+ for (;;) {
951
+ const length = io.readSync(file, chunk, 0, chunk.length, position);
952
+ if (length === 0) break;
953
+ const bytes = chunk.subarray(0, length);
954
+ hash.update(bytes);
955
+ const scan = tail.length ? Buffer.concat([tail, bytes]) : bytes;
956
+ const scanOffset = position - tail.length;
957
+ let localOffset = Math.max(0, nextSearchOffset - scanOffset);
958
+ while (localOffset <= scan.length - sentinel.length) {
959
+ const found = scan.indexOf(sentinel, localOffset);
960
+ if (found < 0) break;
961
+ const absoluteOffset = scanOffset + found;
962
+ if (found + 66 > scan.length) break;
963
+ slots.push({
964
+ offset: absoluteOffset,
965
+ used: scan[found + 32],
966
+ version: scan[found + 33],
967
+ digest: scan.subarray(found + 34, found + 66).toString("hex"),
968
+ });
969
+ nextSearchOffset = absoluteOffset + sentinel.length;
970
+ localOffset = nextSearchOffset - scanOffset;
971
+ }
972
+ tail = Buffer.from(scan.subarray(Math.max(0, scan.length - 65)));
973
+ position += length;
974
+ }
975
+ if (position !== Number(before.size)) {
976
+ throw new Error(`PM Studio executable changed while reading: ${filePath}`);
977
+ }
978
+ let truncated = false;
979
+ const tailOffset = position - tail.length;
980
+ let localOffset = Math.max(0, nextSearchOffset - tailOffset);
981
+ while (localOffset <= tail.length - sentinel.length) {
982
+ const found = tail.indexOf(sentinel, localOffset);
983
+ if (found < 0) break;
984
+ if (found + 66 > tail.length) {
985
+ truncated = true;
986
+ break;
987
+ }
988
+ localOffset = found + sentinel.length;
989
+ }
990
+ return {
991
+ sha256: hash.digest("hex"),
992
+ integrity: electronSlotResult(slots, truncated),
993
+ };
994
+ });
995
+ cache?.set(cacheKey, opened.result);
996
+ return opened.result;
997
+ }
998
+
634
999
  function sameArray(actual, expected) {
635
1000
  return Array.isArray(actual)
636
1001
  && Array.isArray(expected)
@@ -655,11 +1020,13 @@ function inspectTarget(parsed, target) {
655
1020
  return { path: target.path, malformed: error.message };
656
1021
  }
657
1022
  const absoluteOffset = parsed.dataOffset + offset;
658
- if (entry.unpacked || absoluteOffset + size > parsed.buffer.length) {
1023
+ const archiveLength = parsed.archiveLength ?? parsed.buffer.length;
1024
+ if (entry.unpacked || absoluteOffset + size > archiveLength) {
659
1025
  return { path: target.path, offset, size, absoluteOffset, malformed: "entry data is outside the packed archive" };
660
1026
  }
661
1027
 
662
- const bytes = parsed.buffer.subarray(absoluteOffset, absoluteOffset + size);
1028
+ const bytes = parsed.targetBytes?.get(target.path)
1029
+ || parsed.buffer.subarray(absoluteOffset, absoluteOffset + size);
663
1030
  let edit = null;
664
1031
  if (target.edit) {
665
1032
  let editOffset;
@@ -740,7 +1107,11 @@ function targetStateIssues(detail, target, state) {
740
1107
  export function inspectAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
741
1108
  const parsed = parseAsarBuffer(value);
742
1109
  const asarSha256 = sha256Hex(parsed.buffer);
743
- const headerSha256 = sha256Hex(parsed.headerBytes);
1110
+ return inspectParsedAsar(parsed, asarSha256, recipe);
1111
+ }
1112
+
1113
+ function inspectParsedAsar(parsed, asarSha256, recipe) {
1114
+ const headerSha256 = parsed.headerSha256 || sha256Hex(parsed.headerBytes);
744
1115
  const targets = recipe.targets.map((target) => inspectTarget(parsed, target));
745
1116
  const commonIssues = [];
746
1117
  addMismatch(commonIssues, "ASAR data offset", parsed.dataOffset, recipe.dataOffset);
@@ -793,6 +1164,15 @@ export function inspectAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
793
1164
  };
794
1165
  }
795
1166
 
1167
+ export function inspectAsarFile(filePath, recipe = PM_STUDIO_2_9_7_RECIPE, options = {}) {
1168
+ const evidence = readAsarFileEvidence(filePath, recipe.targets.map((target) => target.path), options);
1169
+ try {
1170
+ return inspectParsedAsar(evidence.parsed, evidence.asarSha256, recipe);
1171
+ } finally {
1172
+ if (options.releaseTargets) evidence.parsed.targetBytes.clear();
1173
+ }
1174
+ }
1175
+
796
1176
  export function patchAsarBuffer(value, recipe = PM_STUDIO_2_9_7_RECIPE) {
797
1177
  const before = inspectAsarBuffer(value, recipe);
798
1178
  if (before.state === "patched") {
@@ -445,11 +445,14 @@ export function createPmStudioRelayHandler(options = {}) {
445
445
  releaseRequest = releaseOnce(admission);
446
446
  abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
447
447
  const body = await readJsonBody(req, { admission, signal: abort.signal });
448
- const modelType = modelRouter.classify(body?.model);
449
- if (modelType === "unsupported_claude") {
448
+ const routePlan = modelRouter.plan(body?.model);
449
+ if (routePlan.disposition === "reject") {
450
450
  throw localError(400, "model_not_supported", "The requested Claude model is not enabled for PM Studio");
451
451
  }
452
- if (modelType !== "claude") {
452
+ if (routePlan.disposition !== "relay"
453
+ || routePlan.origin !== "ccdx"
454
+ || routePlan.profile !== "claude"
455
+ || routePlan.protocol !== "openai-chat-completions") {
453
456
  throw localError(
454
457
  400,
455
458
  "native_route_required",