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 +71 -2
- package/bin/cli.mjs +11 -0
- package/package.json +1 -1
- package/src/adapter.mjs +16 -0
- package/src/cli-options.mjs +8 -0
- package/src/observability.mjs +8 -0
- package/src/pm-studio-asar.mjs +392 -0
- package/src/pm-studio-relay.mjs +557 -0
- package/src/pm-studio-setup.mjs +734 -0
- package/src/running-adapter.mjs +16 -0
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
|
|
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
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
|
}
|
package/src/cli-options.mjs
CHANGED
|
@@ -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]
|
package/src/observability.mjs
CHANGED
|
@@ -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
|
+
}
|