@makerbi/remodex 1.4.0 → 1.4.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/package.json +1 -1
- package/src/bridge.js +4 -0
- package/src/desktop-handler.js +100 -11
- package/src/git-handler.js +9 -3
- package/src/pet-handler.js +537 -0
- package/src/qr.js +21 -2
- package/src/workspace-handler.js +8 -1
- package/src/private-defaults.json +0 -4
package/package.json
CHANGED
package/src/bridge.js
CHANGED
|
@@ -24,6 +24,7 @@ const { handleGitRequest } = require("./git-handler");
|
|
|
24
24
|
const { handleThreadContextRequest } = require("./thread-context-handler");
|
|
25
25
|
const { handleWorkspaceRequest } = require("./workspace-handler");
|
|
26
26
|
const { handleProjectRequest } = require("./project-handler");
|
|
27
|
+
const { handlePetRequest } = require("./pet-handler");
|
|
27
28
|
const { createNotificationsHandler } = require("./notifications-handler");
|
|
28
29
|
const { createVoiceHandler, resolveVoiceAuth } = require("./voice-handler");
|
|
29
30
|
const {
|
|
@@ -554,6 +555,9 @@ function startBridge({
|
|
|
554
555
|
if (handleProjectRequest(rawMessage, sendApplicationResponse)) {
|
|
555
556
|
return;
|
|
556
557
|
}
|
|
558
|
+
if (handlePetRequest(rawMessage, sendApplicationResponse)) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
557
561
|
if (notificationsHandler.handleNotificationsRequest(rawMessage, sendApplicationResponse)) {
|
|
558
562
|
return;
|
|
559
563
|
}
|
package/src/desktop-handler.js
CHANGED
|
@@ -20,6 +20,8 @@ const DEFAULT_APP_BOOT_WAIT_MS = 1_200;
|
|
|
20
20
|
const DEFAULT_THREAD_MATERIALIZE_WAIT_MS = 4_000;
|
|
21
21
|
const DEFAULT_THREAD_MATERIALIZE_POLL_MS = 250;
|
|
22
22
|
const DEFAULT_WAKE_DISPLAY_DURATION_SECONDS = 30;
|
|
23
|
+
const WINDOWS_BOUNCE_URL = "codex://settings";
|
|
24
|
+
const DESKTOP_THREAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
|
|
23
25
|
|
|
24
26
|
function handleDesktopRequest(rawMessage, sendResponse, options = {}) {
|
|
25
27
|
let parsed;
|
|
@@ -71,16 +73,39 @@ async function handleDesktopMethod(method, params, options = {}) {
|
|
|
71
73
|
const threadMaterializeWaitMs = options.threadMaterializeWaitMs ?? DEFAULT_THREAD_MATERIALIZE_WAIT_MS;
|
|
72
74
|
const threadMaterializePollMs = options.threadMaterializePollMs ?? DEFAULT_THREAD_MATERIALIZE_POLL_MS;
|
|
73
75
|
|
|
74
|
-
if (platform !== "darwin") {
|
|
75
|
-
throw desktopError(
|
|
76
|
-
"unsupported_platform",
|
|
77
|
-
"Mac handoff is only available when the bridge is running on macOS."
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
76
|
switch (method) {
|
|
77
|
+
case "desktop/continueOnDesktop":
|
|
78
|
+
if (platform !== "darwin" && platform !== "win32") {
|
|
79
|
+
throw desktopError(
|
|
80
|
+
"unsupported_platform",
|
|
81
|
+
"Desktop handoff is only available when the bridge is running on macOS or Windows."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return continueOnDesktop(params, {
|
|
86
|
+
platform,
|
|
87
|
+
bundleId,
|
|
88
|
+
appPath,
|
|
89
|
+
executor,
|
|
90
|
+
env,
|
|
91
|
+
fsModule,
|
|
92
|
+
isAppRunning,
|
|
93
|
+
sleepFn,
|
|
94
|
+
appBootWaitMs,
|
|
95
|
+
relaunchWaitMs,
|
|
96
|
+
threadMaterializeWaitMs,
|
|
97
|
+
threadMaterializePollMs,
|
|
98
|
+
});
|
|
82
99
|
case "desktop/continueOnMac":
|
|
83
|
-
|
|
100
|
+
if (platform !== "darwin") {
|
|
101
|
+
throw desktopError(
|
|
102
|
+
"unsupported_platform",
|
|
103
|
+
"Mac handoff is only available when the bridge is running on macOS."
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return continueOnDesktop(params, {
|
|
108
|
+
platform,
|
|
84
109
|
bundleId,
|
|
85
110
|
appPath,
|
|
86
111
|
executor,
|
|
@@ -106,10 +131,11 @@ async function handleDesktopMethod(method, params, options = {}) {
|
|
|
106
131
|
}
|
|
107
132
|
}
|
|
108
133
|
|
|
109
|
-
// Waits for fresh phone-authored chats to materialize locally before deep-linking them on
|
|
110
|
-
async function
|
|
134
|
+
// Waits for fresh phone-authored chats to materialize locally before deep-linking them on desktop.
|
|
135
|
+
async function continueOnDesktop(
|
|
111
136
|
params,
|
|
112
137
|
{
|
|
138
|
+
platform,
|
|
113
139
|
bundleId,
|
|
114
140
|
appPath,
|
|
115
141
|
executor,
|
|
@@ -125,11 +151,53 @@ async function continueOnMac(
|
|
|
125
151
|
) {
|
|
126
152
|
const threadId = resolveThreadId(params);
|
|
127
153
|
if (!threadId) {
|
|
128
|
-
throw desktopError("missing_thread_id", "A thread id is required to continue on
|
|
154
|
+
throw desktopError("missing_thread_id", "A thread id is required to continue on desktop.");
|
|
155
|
+
}
|
|
156
|
+
if (!isValidDesktopThreadId(threadId)) {
|
|
157
|
+
throw desktopError("invalid_thread_id", "The requested desktop thread id is not valid.");
|
|
129
158
|
}
|
|
130
159
|
|
|
131
160
|
const targetUrl = `codex://threads/${threadId}`;
|
|
132
161
|
const desktopKnown = isThreadLikelyKnownOnDesktop(threadId, { env, fsModule });
|
|
162
|
+
|
|
163
|
+
if (platform === "win32") {
|
|
164
|
+
try {
|
|
165
|
+
if (desktopKnown) {
|
|
166
|
+
await refreshWindowsCodex(targetUrl, {
|
|
167
|
+
executor,
|
|
168
|
+
env,
|
|
169
|
+
sleepFn,
|
|
170
|
+
settleMs: relaunchWaitMs,
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
await openWindowsDeepLink(WINDOWS_BOUNCE_URL, { executor, env });
|
|
174
|
+
await sleepFn(appBootWaitMs);
|
|
175
|
+
await waitForThreadMaterialization(threadId, {
|
|
176
|
+
env,
|
|
177
|
+
fsModule,
|
|
178
|
+
sleepFn,
|
|
179
|
+
timeoutMs: threadMaterializeWaitMs,
|
|
180
|
+
pollMs: threadMaterializePollMs,
|
|
181
|
+
});
|
|
182
|
+
await openWindowsDeepLink(targetUrl, { executor, env });
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw desktopError(
|
|
186
|
+
"handoff_failed",
|
|
187
|
+
"Could not open Codex on this PC.",
|
|
188
|
+
error
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
success: true,
|
|
194
|
+
relaunched: false,
|
|
195
|
+
targetUrl,
|
|
196
|
+
threadId,
|
|
197
|
+
desktopKnown,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
133
201
|
const appRunning = typeof isAppRunning === "function"
|
|
134
202
|
? await isAppRunning(appPath)
|
|
135
203
|
: await detectRunningCodexApp(appPath, executor);
|
|
@@ -311,6 +379,11 @@ function resolveThreadId(params) {
|
|
|
311
379
|
return "";
|
|
312
380
|
}
|
|
313
381
|
|
|
382
|
+
// Keeps desktop deep links to a single safe route segment before handing them to OS launchers.
|
|
383
|
+
function isValidDesktopThreadId(threadId) {
|
|
384
|
+
return typeof threadId === "string" && DESKTOP_THREAD_ID_PATTERN.test(threadId);
|
|
385
|
+
}
|
|
386
|
+
|
|
314
387
|
function desktopError(errorCode, userMessage, cause = null) {
|
|
315
388
|
const error = new Error(userMessage);
|
|
316
389
|
error.errorCode = errorCode;
|
|
@@ -372,6 +445,22 @@ async function openCodexApp({ bundleId, appPath, executor }) {
|
|
|
372
445
|
}
|
|
373
446
|
}
|
|
374
447
|
|
|
448
|
+
async function openWindowsDeepLink(targetUrl, { executor, env }) {
|
|
449
|
+
await executor(env?.SystemRoot ? path.join(env.SystemRoot, "System32", "rundll32.exe") : "rundll32.exe", [
|
|
450
|
+
"url.dll,FileProtocolHandler",
|
|
451
|
+
targetUrl,
|
|
452
|
+
], {
|
|
453
|
+
timeout: HANDOFF_TIMEOUT_MS,
|
|
454
|
+
windowsHide: true,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function refreshWindowsCodex(targetUrl, { executor, env, sleepFn, settleMs }) {
|
|
459
|
+
await openWindowsDeepLink(WINDOWS_BOUNCE_URL, { executor, env });
|
|
460
|
+
await sleepFn(settleMs);
|
|
461
|
+
await openWindowsDeepLink(targetUrl, { executor, env });
|
|
462
|
+
}
|
|
463
|
+
|
|
375
464
|
// Gives the desktop a short window to materialize the requested thread before the final deep link.
|
|
376
465
|
async function openWhenThreadReady(
|
|
377
466
|
threadId,
|
package/src/git-handler.js
CHANGED
|
@@ -13,6 +13,8 @@ const { promisify } = require("util");
|
|
|
13
13
|
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
15
15
|
const GIT_TIMEOUT_MS = 30_000;
|
|
16
|
+
/** Node defaults maxBuffer to 1 MiB; large repo diffs exceed it ("stdout maxBuffer length exceeded"). */
|
|
17
|
+
const GIT_EXEC_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
16
18
|
const GIT_DRAFT_TIMEOUT_MS = 120_000;
|
|
17
19
|
const GITHUB_CLI_TIMEOUT_MS = 120_000;
|
|
18
20
|
const GIT_DRAFT_PATCH_MAX_BYTES = 80_000;
|
|
@@ -2387,7 +2389,7 @@ async function gitDiffNoIndexNumstat(cwd, filePath) {
|
|
|
2387
2389
|
const { stdout } = await execFileAsync(
|
|
2388
2390
|
"git",
|
|
2389
2391
|
["diff", "--no-index", "--numstat", "--", "/dev/null", filePath],
|
|
2390
|
-
{ cwd, timeout: GIT_TIMEOUT_MS }
|
|
2392
|
+
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES }
|
|
2391
2393
|
);
|
|
2392
2394
|
return stdout;
|
|
2393
2395
|
} catch (err) {
|
|
@@ -2413,7 +2415,7 @@ async function gitDiffNoIndexPatch(cwd, filePath) {
|
|
|
2413
2415
|
const { stdout } = await execFileAsync(
|
|
2414
2416
|
"git",
|
|
2415
2417
|
["diff", "--no-index", "--binary", "--", "/dev/null", filePath],
|
|
2416
|
-
{ cwd, timeout: GIT_TIMEOUT_MS }
|
|
2418
|
+
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES }
|
|
2417
2419
|
);
|
|
2418
2420
|
return stdout;
|
|
2419
2421
|
} catch (err) {
|
|
@@ -2428,7 +2430,11 @@ async function gitDiffNoIndexPatch(cwd, filePath) {
|
|
|
2428
2430
|
// ─── Helpers ──────────────────────────────────────────────────
|
|
2429
2431
|
|
|
2430
2432
|
function git(cwd, ...args) {
|
|
2431
|
-
return execFileAsync("git", args, {
|
|
2433
|
+
return execFileAsync("git", args, {
|
|
2434
|
+
cwd,
|
|
2435
|
+
timeout: GIT_TIMEOUT_MS,
|
|
2436
|
+
maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES,
|
|
2437
|
+
})
|
|
2432
2438
|
.then(({ stdout }) => stdout)
|
|
2433
2439
|
.catch((err) => {
|
|
2434
2440
|
const msg = (err.stderr || err.message || "").trim();
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
// FILE: pet-handler.js
|
|
2
|
+
// Purpose: Lists Codex-compatible local pet packages for the mobile companion overlay.
|
|
3
|
+
// Layer: Bridge handler
|
|
4
|
+
// Exports: handlePetRequest, handlePetMethod
|
|
5
|
+
// Depends on: fs, path, ./codex-home
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { resolveCodexHome } = require("./codex-home");
|
|
10
|
+
|
|
11
|
+
const ATLAS_WIDTH = 1536;
|
|
12
|
+
const ATLAS_HEIGHT = 1872;
|
|
13
|
+
const MAX_SPRITESHEET_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
const IMAGE_MIME_TYPES_BY_EXTENSION = new Map([
|
|
15
|
+
[".png", "image/png"],
|
|
16
|
+
[".webp", "image/webp"],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function handlePetRequest(rawMessage, sendResponse) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(rawMessage);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
|
|
28
|
+
if (!isPetMethod(method)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const id = parsed.id;
|
|
33
|
+
const params = parsed.params || {};
|
|
34
|
+
handlePetMethod(method, params)
|
|
35
|
+
.then((result) => {
|
|
36
|
+
sendResponse(JSON.stringify({ id, result }));
|
|
37
|
+
})
|
|
38
|
+
.catch((err) => {
|
|
39
|
+
const errorCode = err.errorCode || "pet_error";
|
|
40
|
+
const message = err.userMessage || err.message || "Unable to load Codex pets.";
|
|
41
|
+
sendResponse(
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
id,
|
|
44
|
+
error: {
|
|
45
|
+
code: -32000,
|
|
46
|
+
message,
|
|
47
|
+
data: { errorCode },
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function handlePetMethod(method, params = {}) {
|
|
57
|
+
if (!isPetMethod(method)) {
|
|
58
|
+
throw petError("pet_method_unknown", "Unknown Codex pet method.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (method === "pet/read" || method === "custom-avatar/read") {
|
|
62
|
+
return readPet(params);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const codexHome = resolveCodexHome();
|
|
66
|
+
const includeData = params.includeData !== false && params.metadataOnly !== true;
|
|
67
|
+
const results = [];
|
|
68
|
+
const errors = [];
|
|
69
|
+
|
|
70
|
+
for (const directory of petDirectories(codexHome)) {
|
|
71
|
+
const discovered = await loadPetDirectory(directory, { includeData });
|
|
72
|
+
results.push(...discovered.avatars);
|
|
73
|
+
errors.push(...discovered.errors);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const avatars = mergeCustomAvatars(results);
|
|
77
|
+
return {
|
|
78
|
+
avatarDirectory: path.join(codexHome, "pets"),
|
|
79
|
+
petDirectory: path.join(codexHome, "pets"),
|
|
80
|
+
avatars,
|
|
81
|
+
pets: avatars,
|
|
82
|
+
errors,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Reads a single selected pet so mobile does not have to embed every atlas in pet/list.
|
|
87
|
+
async function readPet(params = {}) {
|
|
88
|
+
const codexHome = resolveCodexHome();
|
|
89
|
+
const folderName = petFolderNameFromID(params.id || params.folderName);
|
|
90
|
+
let lastError = null;
|
|
91
|
+
|
|
92
|
+
for (const directory of petDirectories(codexHome)) {
|
|
93
|
+
try {
|
|
94
|
+
const avatar = await loadCustomAvatar(directory, folderName, { includeData: true });
|
|
95
|
+
if (avatar) {
|
|
96
|
+
return avatar;
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
lastError = error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (lastError) {
|
|
104
|
+
throw lastError;
|
|
105
|
+
}
|
|
106
|
+
throw petError("pet_not_found", "The selected Codex pet could not be found.");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isPetMethod(method) {
|
|
110
|
+
return method === "pet/list"
|
|
111
|
+
|| method === "custom-avatars"
|
|
112
|
+
|| method === "pet/read"
|
|
113
|
+
|| method === "custom-avatar/read";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Mirrors Codex desktop's pets-first custom avatar lookup while keeping legacy avatars usable.
|
|
117
|
+
function petDirectories(codexHome) {
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
root: path.join(codexHome, "pets"),
|
|
121
|
+
manifestName: "pet.json",
|
|
122
|
+
kind: "pet",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
root: path.join(codexHome, "avatars"),
|
|
126
|
+
manifestName: "avatar.json",
|
|
127
|
+
kind: "avatar",
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Accept only a local package id/folder name; the spritesheet path is validated separately.
|
|
133
|
+
function petFolderNameFromID(rawID) {
|
|
134
|
+
if (typeof rawID !== "string") {
|
|
135
|
+
throw petError("pet_id_invalid", "A pet id is required.");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const folderName = rawID.startsWith("custom:") ? rawID.slice("custom:".length) : rawID;
|
|
139
|
+
if (
|
|
140
|
+
!folderName
|
|
141
|
+
|| folderName === "."
|
|
142
|
+
|| folderName === ".."
|
|
143
|
+
|| path.isAbsolute(folderName)
|
|
144
|
+
|| folderName.includes("/")
|
|
145
|
+
|| folderName.includes("\\")
|
|
146
|
+
) {
|
|
147
|
+
throw petError("pet_id_invalid", "The selected pet id is invalid.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return folderName;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function loadPetDirectory(directory, { includeData }) {
|
|
154
|
+
const avatars = [];
|
|
155
|
+
const errors = [];
|
|
156
|
+
let entries = [];
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
entries = await fs.promises.readdir(directory.root, { withFileTypes: true });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error && error.code === "ENOENT") {
|
|
162
|
+
return { avatars, errors };
|
|
163
|
+
}
|
|
164
|
+
throw petError("pet_directory_unreadable", "Could not read local Codex pet folders.");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (!entry.isDirectory()) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const avatar = await loadCustomAvatar(directory, entry.name, { includeData });
|
|
174
|
+
if (avatar) {
|
|
175
|
+
avatars.push(avatar);
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
errors.push({
|
|
179
|
+
folderName: entry.name,
|
|
180
|
+
kind: directory.kind,
|
|
181
|
+
message: error.userMessage || error.message || "Invalid pet package.",
|
|
182
|
+
errorCode: error.errorCode || "invalid_pet",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { avatars, errors };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function loadCustomAvatar(directory, folderName, { includeData }) {
|
|
191
|
+
const petRoot = path.join(directory.root, folderName);
|
|
192
|
+
const manifestPath = path.join(petRoot, directory.manifestName);
|
|
193
|
+
const manifest = await readManifest(manifestPath);
|
|
194
|
+
if (!manifest) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const spritesheetPath = resolveSpritesheetPath(
|
|
199
|
+
petRoot,
|
|
200
|
+
typeof manifest.spritesheetPath === "string" ? manifest.spritesheetPath : "spritesheet.webp"
|
|
201
|
+
);
|
|
202
|
+
const image = await readValidatedSpritesheet(spritesheetPath, { includeData });
|
|
203
|
+
const displayName = firstNonEmptyString([manifest.displayName, manifest.name, displayFromSlug(folderName)]);
|
|
204
|
+
const description = firstNonEmptyString([manifest.description]) || "A custom Codex pet.";
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id: `custom:${folderName}`,
|
|
208
|
+
folderName,
|
|
209
|
+
kind: directory.kind,
|
|
210
|
+
displayName,
|
|
211
|
+
description,
|
|
212
|
+
spritesheetPath,
|
|
213
|
+
spritesheetMimeType: image.mimeType,
|
|
214
|
+
spritesheetByteLength: image.byteLength,
|
|
215
|
+
spritesheetDataUrl: image.dataUrl,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function readManifest(manifestPath) {
|
|
220
|
+
let contents;
|
|
221
|
+
try {
|
|
222
|
+
contents = await fs.promises.readFile(manifestPath, "utf8");
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error && error.code === "ENOENT") {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
throw petError("pet_manifest_unreadable", "Could not read the pet manifest.");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const manifest = JSON.parse(contents);
|
|
232
|
+
return manifest && typeof manifest === "object" ? manifest : {};
|
|
233
|
+
} catch {
|
|
234
|
+
throw petError("pet_manifest_invalid", "The pet manifest is not valid JSON.");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function resolveSpritesheetPath(petRoot, rawSpritesheetPath) {
|
|
239
|
+
const trimmedPath = rawSpritesheetPath.trim() || "spritesheet.webp";
|
|
240
|
+
if (path.isAbsolute(trimmedPath)) {
|
|
241
|
+
throw petError("pet_spritesheet_path_invalid", "Pet spritesheet paths must be relative.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const candidate = path.resolve(petRoot, trimmedPath);
|
|
245
|
+
const relative = path.relative(petRoot, candidate);
|
|
246
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
247
|
+
throw petError("pet_spritesheet_path_invalid", "Pet spritesheet paths cannot escape the pet folder.");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return candidate;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function readValidatedSpritesheet(spritesheetPath, { includeData }) {
|
|
254
|
+
const extension = path.extname(spritesheetPath).toLowerCase();
|
|
255
|
+
const mimeType = IMAGE_MIME_TYPES_BY_EXTENSION.get(extension);
|
|
256
|
+
if (!mimeType) {
|
|
257
|
+
throw petError("pet_spritesheet_type_invalid", "Pet spritesheets must be PNG or WebP files.");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!includeData) {
|
|
261
|
+
return readValidatedSpritesheetMetadata(spritesheetPath, mimeType);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const stat = await readSpritesheetStat(spritesheetPath);
|
|
265
|
+
assertSpritesheetByteLength(stat.size);
|
|
266
|
+
|
|
267
|
+
let data;
|
|
268
|
+
try {
|
|
269
|
+
data = await fs.promises.readFile(spritesheetPath);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error && error.code === "ENOENT") {
|
|
272
|
+
throw petError("pet_spritesheet_missing", "The pet spritesheet file does not exist.");
|
|
273
|
+
}
|
|
274
|
+
throw petError("pet_spritesheet_unreadable", "Could not read the pet spritesheet file.");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const dimensions = imageDimensions(data, mimeType);
|
|
278
|
+
if (!dimensions || dimensions.width !== ATLAS_WIDTH || dimensions.height !== ATLAS_HEIGHT) {
|
|
279
|
+
throw petError("pet_spritesheet_dimensions_invalid", "Pet spritesheets must be exactly 1536x1872 pixels.");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
mimeType,
|
|
284
|
+
byteLength: data.byteLength,
|
|
285
|
+
dataUrl: includeData ? `data:${mimeType};base64,${data.toString("base64")}` : undefined,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function readValidatedSpritesheetMetadata(spritesheetPath, mimeType) {
|
|
290
|
+
let file;
|
|
291
|
+
try {
|
|
292
|
+
file = await fs.promises.open(spritesheetPath, "r");
|
|
293
|
+
const stat = await file.stat();
|
|
294
|
+
assertSpritesheetByteLength(stat.size);
|
|
295
|
+
const dimensions = await readImageDimensionsFromFile(file, mimeType, stat.size);
|
|
296
|
+
if (!dimensions || dimensions.width !== ATLAS_WIDTH || dimensions.height !== ATLAS_HEIGHT) {
|
|
297
|
+
throw petError("pet_spritesheet_dimensions_invalid", "Pet spritesheets must be exactly 1536x1872 pixels.");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
mimeType,
|
|
302
|
+
byteLength: stat.size,
|
|
303
|
+
dataUrl: undefined,
|
|
304
|
+
};
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error && error.errorCode) {
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (error && error.code === "ENOENT") {
|
|
310
|
+
throw petError("pet_spritesheet_missing", "The pet spritesheet file does not exist.");
|
|
311
|
+
}
|
|
312
|
+
throw petError("pet_spritesheet_unreadable", "Could not read the pet spritesheet file.");
|
|
313
|
+
} finally {
|
|
314
|
+
await file?.close();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Rejects oversized local packages before base64 expansion can bloat relay payloads.
|
|
319
|
+
async function readSpritesheetStat(spritesheetPath) {
|
|
320
|
+
try {
|
|
321
|
+
return await fs.promises.stat(spritesheetPath);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (error && error.code === "ENOENT") {
|
|
324
|
+
throw petError("pet_spritesheet_missing", "The pet spritesheet file does not exist.");
|
|
325
|
+
}
|
|
326
|
+
throw petError("pet_spritesheet_unreadable", "Could not read the pet spritesheet file.");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function assertSpritesheetByteLength(byteLength) {
|
|
331
|
+
if (byteLength > MAX_SPRITESHEET_BYTES) {
|
|
332
|
+
throw petError("pet_spritesheet_too_large", "Pet spritesheets must be 16 MB or smaller.");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function readImageDimensionsFromFile(file, mimeType, fileSize) {
|
|
337
|
+
if (mimeType === "image/png") {
|
|
338
|
+
return pngDimensions(await readFileSlice(file, 0, 24));
|
|
339
|
+
}
|
|
340
|
+
if (mimeType === "image/webp") {
|
|
341
|
+
return readWebPDimensionsFromFile(file, fileSize);
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function readWebPDimensionsFromFile(file, fileSize) {
|
|
347
|
+
const riffHeader = await readFileSlice(file, 0, 12);
|
|
348
|
+
if (
|
|
349
|
+
riffHeader.length < 12
|
|
350
|
+
|| riffHeader.toString("ascii", 0, 4) !== "RIFF"
|
|
351
|
+
|| riffHeader.toString("ascii", 8, 12) !== "WEBP"
|
|
352
|
+
) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let offset = 12;
|
|
357
|
+
while (offset + 8 <= fileSize) {
|
|
358
|
+
const chunkHeader = await readFileSlice(file, offset, 8);
|
|
359
|
+
if (chunkHeader.length < 8) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const chunkType = chunkHeader.toString("ascii", 0, 4);
|
|
364
|
+
const chunkSize = chunkHeader.readUInt32LE(4);
|
|
365
|
+
const payloadOffset = offset + 8;
|
|
366
|
+
if (payloadOffset + chunkSize > fileSize) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const dimensionsPayloadLength = webpDimensionsPayloadLength(chunkType);
|
|
371
|
+
if (dimensionsPayloadLength > 0) {
|
|
372
|
+
const payload = await readFileSlice(file, payloadOffset, Math.min(chunkSize, dimensionsPayloadLength));
|
|
373
|
+
const chunkBuffer = Buffer.concat([chunkHeader, payload]);
|
|
374
|
+
const dimensions = webpChunkDimensions(chunkBuffer, chunkType, 8, chunkSize);
|
|
375
|
+
if (dimensions) {
|
|
376
|
+
return dimensions;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
offset = payloadOffset + chunkSize + (chunkSize % 2);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function webpDimensionsPayloadLength(chunkType) {
|
|
387
|
+
if (chunkType === "VP8X") {
|
|
388
|
+
return 10;
|
|
389
|
+
}
|
|
390
|
+
if (chunkType === "VP8L") {
|
|
391
|
+
return 5;
|
|
392
|
+
}
|
|
393
|
+
if (chunkType === "VP8 ") {
|
|
394
|
+
return 10;
|
|
395
|
+
}
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function readFileSlice(file, offset, length) {
|
|
400
|
+
const buffer = Buffer.alloc(length);
|
|
401
|
+
const { bytesRead } = await file.read(buffer, 0, length, offset);
|
|
402
|
+
return buffer.subarray(0, bytesRead);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function imageDimensions(data, mimeType) {
|
|
406
|
+
if (mimeType === "image/png") {
|
|
407
|
+
return pngDimensions(data);
|
|
408
|
+
}
|
|
409
|
+
if (mimeType === "image/webp") {
|
|
410
|
+
return webpDimensions(data);
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function pngDimensions(data) {
|
|
416
|
+
if (data.length < 24 || data.readUInt32BE(0) !== 0x89504e47 || data.readUInt32BE(4) !== 0x0d0a1a0a) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
width: data.readUInt32BE(16),
|
|
421
|
+
height: data.readUInt32BE(20),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function webpDimensions(data) {
|
|
426
|
+
if (
|
|
427
|
+
data.length < 30
|
|
428
|
+
|| data.toString("ascii", 0, 4) !== "RIFF"
|
|
429
|
+
|| data.toString("ascii", 8, 12) !== "WEBP"
|
|
430
|
+
) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
let offset = 12;
|
|
435
|
+
while (offset + 8 <= data.length) {
|
|
436
|
+
const chunkType = data.toString("ascii", offset, offset + 4);
|
|
437
|
+
const chunkSize = data.readUInt32LE(offset + 4);
|
|
438
|
+
const payloadOffset = offset + 8;
|
|
439
|
+
if (payloadOffset + chunkSize > data.length) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const dimensions = webpChunkDimensions(data, chunkType, payloadOffset, chunkSize);
|
|
444
|
+
if (dimensions) {
|
|
445
|
+
return dimensions;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
offset = payloadOffset + chunkSize + (chunkSize % 2);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function webpChunkDimensions(data, chunkType, payloadOffset, chunkSize) {
|
|
455
|
+
if (chunkType === "VP8X" && chunkSize >= 10) {
|
|
456
|
+
return {
|
|
457
|
+
width: readUInt24LE(data, payloadOffset + 4) + 1,
|
|
458
|
+
height: readUInt24LE(data, payloadOffset + 7) + 1,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (chunkType === "VP8L" && chunkSize >= 5 && data[payloadOffset] === 0x2f) {
|
|
463
|
+
const bits = data.readUInt32LE(payloadOffset + 1);
|
|
464
|
+
return {
|
|
465
|
+
width: (bits & 0x3fff) + 1,
|
|
466
|
+
height: ((bits >> 14) & 0x3fff) + 1,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (chunkType === "VP8 " && chunkSize >= 10) {
|
|
471
|
+
const startCodeOffset = payloadOffset + 3;
|
|
472
|
+
if (
|
|
473
|
+
data[startCodeOffset] !== 0x9d
|
|
474
|
+
|| data[startCodeOffset + 1] !== 0x01
|
|
475
|
+
|| data[startCodeOffset + 2] !== 0x2a
|
|
476
|
+
) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
width: data.readUInt16LE(payloadOffset + 6) & 0x3fff,
|
|
482
|
+
height: data.readUInt16LE(payloadOffset + 8) & 0x3fff,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function readUInt24LE(data, offset) {
|
|
490
|
+
return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Keeps ~/.codex/pets entries authoritative when legacy ~/.codex/avatars has the same folder.
|
|
494
|
+
function mergeCustomAvatars(avatars) {
|
|
495
|
+
const byID = new Map();
|
|
496
|
+
for (const avatar of avatars) {
|
|
497
|
+
const existing = byID.get(avatar.id);
|
|
498
|
+
if (!existing || avatar.kind === "pet") {
|
|
499
|
+
byID.set(avatar.id, avatar);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return Array.from(byID.values()).sort((left, right) => left.displayName.localeCompare(right.displayName));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function firstNonEmptyString(candidates) {
|
|
506
|
+
for (const candidate of candidates) {
|
|
507
|
+
if (typeof candidate !== "string") {
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const trimmed = candidate.trim();
|
|
511
|
+
if (trimmed) {
|
|
512
|
+
return trimmed;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function displayFromSlug(slug) {
|
|
519
|
+
return slug
|
|
520
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
521
|
+
.filter(Boolean)
|
|
522
|
+
.map((word) => word.slice(0, 1).toUpperCase() + word.slice(1))
|
|
523
|
+
.join(" ") || slug;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function petError(errorCode, userMessage) {
|
|
527
|
+
const error = new Error(userMessage);
|
|
528
|
+
error.errorCode = errorCode;
|
|
529
|
+
error.userMessage = userMessage;
|
|
530
|
+
return error;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
module.exports = {
|
|
534
|
+
handlePetMethod,
|
|
535
|
+
handlePetRequest,
|
|
536
|
+
imageDimensions,
|
|
537
|
+
};
|
package/src/qr.js
CHANGED
|
@@ -40,9 +40,12 @@ function normalizePairingSession(pairingSessionOrPayload) {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function printQR(pairingSessionOrPayload) {
|
|
43
|
+
function printQR(pairingSessionOrPayload, options = {}) {
|
|
44
44
|
const { pairingPayload, pairingCode } = normalizePairingSession(pairingSessionOrPayload);
|
|
45
45
|
const payload = JSON.stringify(pairingPayload);
|
|
46
|
+
const sessionId = typeof pairingPayload?.sessionId === "string" ? pairingPayload.sessionId.trim() : "";
|
|
47
|
+
const sessionIdShort = sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId;
|
|
48
|
+
const env = options.env || process.env;
|
|
46
49
|
|
|
47
50
|
console.log("\nScan this QR with the iPhone:\n");
|
|
48
51
|
qrcode.generate(payload, { small: true });
|
|
@@ -50,9 +53,24 @@ function printQR(pairingSessionOrPayload) {
|
|
|
50
53
|
console.log("Or paste this pairing code in the iPhone app:\n");
|
|
51
54
|
console.log(pairingCode);
|
|
52
55
|
}
|
|
53
|
-
console.log(`\nSession ID: ${
|
|
56
|
+
console.log(`\nSession ID: ${sessionIdShort || "(none)"}`);
|
|
54
57
|
console.log(`Device ID: ${pairingPayload.macDeviceId}`);
|
|
55
58
|
console.log(`Expires: ${new Date(pairingPayload.expiresAt).toISOString()}\n`);
|
|
59
|
+
|
|
60
|
+
if (shouldPrintPairingJson({ env, explicitValue: options.printPairingJson })) {
|
|
61
|
+
// Opt-in only: this is the same bearer-like payload as the QR scan target.
|
|
62
|
+
console.log("Pairing JSON (debug only; same sensitive bytes as the QR):\n");
|
|
63
|
+
console.log(`${payload}\n`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function shouldPrintPairingJson({ env = process.env, explicitValue } = {}) {
|
|
68
|
+
if (typeof explicitValue === "boolean") {
|
|
69
|
+
return explicitValue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const rawValue = env?.REMODEX_PRINT_PAIRING_JSON || env?.PHODEX_PRINT_PAIRING_JSON || "";
|
|
73
|
+
return ["1", "true", "yes", "on"].includes(String(rawValue).trim().toLowerCase());
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
module.exports = {
|
|
@@ -60,4 +78,5 @@ module.exports = {
|
|
|
60
78
|
SHORT_PAIRING_CODE_LENGTH,
|
|
61
79
|
createShortPairingCode,
|
|
62
80
|
printQR,
|
|
81
|
+
shouldPrintPairingJson,
|
|
63
82
|
};
|
package/src/workspace-handler.js
CHANGED
|
@@ -37,6 +37,8 @@ const IMAGE_MIME_TYPES_BY_EXTENSION = new Map([
|
|
|
37
37
|
[".heic", "image/heic"],
|
|
38
38
|
[".heif", "image/heif"],
|
|
39
39
|
]);
|
|
40
|
+
/** Match git-handler.js: Node default maxBuffer is 1 MiB. */
|
|
41
|
+
const GIT_EXEC_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
40
42
|
const repoMutationLocks = new Map();
|
|
41
43
|
|
|
42
44
|
function handleWorkspaceRequest(rawMessage, sendResponse) {
|
|
@@ -592,6 +594,7 @@ async function runGitApply(cwd, args, patchText) {
|
|
|
592
594
|
const { stdout, stderr } = await execFileAsync("git", [...args, tempPatchPath], {
|
|
593
595
|
cwd,
|
|
594
596
|
timeout: GIT_TIMEOUT_MS,
|
|
597
|
+
maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES,
|
|
595
598
|
});
|
|
596
599
|
return { ok: true, stdout, stderr };
|
|
597
600
|
} catch (err) {
|
|
@@ -750,7 +753,11 @@ function workspaceError(errorCode, userMessage) {
|
|
|
750
753
|
}
|
|
751
754
|
|
|
752
755
|
function git(cwd, ...args) {
|
|
753
|
-
return execFileAsync("git", args, {
|
|
756
|
+
return execFileAsync("git", args, {
|
|
757
|
+
cwd,
|
|
758
|
+
timeout: GIT_TIMEOUT_MS,
|
|
759
|
+
maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES,
|
|
760
|
+
})
|
|
754
761
|
.then(({ stdout }) => stdout)
|
|
755
762
|
.catch((err) => {
|
|
756
763
|
const msg = (err.stderr || err.message || "").trim();
|