@btatum5/codex-bridge 1.3.3 → 1.3.5
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 +2 -1
- package/src/bridge.js +44 -29
- package/src/local-pairing-discovery.js +87 -0
- package/src/workspace-handler.js +457 -54
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@btatum5/codex-bridge",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"description": "Local bridge between Codex and the Codex mobile app. Run `codex-bridge up` to start.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"license": "ISC",
|
|
33
33
|
"type": "commonjs",
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"bonjour-service": "^1.3.0",
|
|
35
36
|
"qrcode-terminal": "^0.12.0",
|
|
36
37
|
"ws": "^8.19.0"
|
|
37
38
|
}
|
package/src/bridge.js
CHANGED
|
@@ -13,8 +13,9 @@ const {
|
|
|
13
13
|
} = require("./codex-desktop-refresher");
|
|
14
14
|
const { createCodexTransport } = require("./codex-transport");
|
|
15
15
|
const { createThreadRolloutActivityWatcher } = require("./rollout-watch");
|
|
16
|
-
const { printQR } = require("./qr");
|
|
17
|
-
const {
|
|
16
|
+
const { printQR } = require("./qr");
|
|
17
|
+
const { startPairingDiscoveryAdvertisement } = require("./local-pairing-discovery");
|
|
18
|
+
const { rememberActiveThread } = require("./session-state");
|
|
18
19
|
const { handleDesktopRequest } = require("./desktop-handler");
|
|
19
20
|
const { handleGitRequest } = require("./git-handler");
|
|
20
21
|
const { handleThreadContextRequest } = require("./thread-context-handler");
|
|
@@ -81,10 +82,11 @@ function startBridge({
|
|
|
81
82
|
// Keep the local Codex runtime alive across transient relay disconnects.
|
|
82
83
|
let socket = null;
|
|
83
84
|
let isShuttingDown = false;
|
|
84
|
-
let reconnectAttempt = 0;
|
|
85
|
-
let reconnectTimer = null;
|
|
86
|
-
let lastConnectionStatus = null;
|
|
87
|
-
let
|
|
85
|
+
let reconnectAttempt = 0;
|
|
86
|
+
let reconnectTimer = null;
|
|
87
|
+
let lastConnectionStatus = null;
|
|
88
|
+
let localPairingDiscovery = null;
|
|
89
|
+
let codexHandshakeState = config.codexEndpoint ? "warm" : "cold";
|
|
88
90
|
const forwardedInitializeRequestIds = new Set();
|
|
89
91
|
const secureTransport = createBridgeSecureTransport({
|
|
90
92
|
sessionId,
|
|
@@ -254,13 +256,20 @@ function startBridge({
|
|
|
254
256
|
});
|
|
255
257
|
}
|
|
256
258
|
|
|
257
|
-
const pairingPayload = secureTransport.createPairingPayload();
|
|
258
|
-
onPairingPayload?.(pairingPayload);
|
|
259
|
-
if (printPairingQr) {
|
|
260
|
-
printQR(pairingPayload);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
259
|
+
const pairingPayload = secureTransport.createPairingPayload();
|
|
260
|
+
onPairingPayload?.(pairingPayload);
|
|
261
|
+
if (printPairingQr) {
|
|
262
|
+
printQR(pairingPayload);
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
localPairingDiscovery = startPairingDiscoveryAdvertisement(pairingPayload);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.warn(
|
|
268
|
+
`[codex-bridge] Local pairing discovery is unavailable: ${(error && error.message) || "unknown error"}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
pushServiceClient.logUnavailable();
|
|
272
|
+
connectRelay();
|
|
264
273
|
|
|
265
274
|
codex.onMessage((message) => {
|
|
266
275
|
trackCodexHandshakeState(message);
|
|
@@ -280,22 +289,28 @@ function startBridge({
|
|
|
280
289
|
});
|
|
281
290
|
isShuttingDown = true;
|
|
282
291
|
clearReconnectTimer();
|
|
283
|
-
stopContextUsageWatcher();
|
|
284
|
-
rolloutLiveMirror?.stopAll();
|
|
285
|
-
desktopRefresher.handleTransportReset();
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}));
|
|
292
|
+
stopContextUsageWatcher();
|
|
293
|
+
rolloutLiveMirror?.stopAll();
|
|
294
|
+
desktopRefresher.handleTransportReset();
|
|
295
|
+
localPairingDiscovery?.stop();
|
|
296
|
+
localPairingDiscovery = null;
|
|
297
|
+
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
|
|
298
|
+
socket.close();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
process.on("SIGINT", () => shutdown(codex, () => socket, () => {
|
|
303
|
+
isShuttingDown = true;
|
|
304
|
+
clearReconnectTimer();
|
|
305
|
+
localPairingDiscovery?.stop();
|
|
306
|
+
localPairingDiscovery = null;
|
|
307
|
+
}));
|
|
308
|
+
process.on("SIGTERM", () => shutdown(codex, () => socket, () => {
|
|
309
|
+
isShuttingDown = true;
|
|
310
|
+
clearReconnectTimer();
|
|
311
|
+
localPairingDiscovery?.stop();
|
|
312
|
+
localPairingDiscovery = null;
|
|
313
|
+
}));
|
|
299
314
|
|
|
300
315
|
// Routes decrypted app payloads through the same bridge handlers as before.
|
|
301
316
|
function handleApplicationMessage(rawMessage) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// FILE: local-pairing-discovery.js
|
|
2
|
+
// Purpose: Advertises the current pairing payload on the local network so iPhone clients can discover it without scanning.
|
|
3
|
+
// Layer: CLI helper
|
|
4
|
+
// Exports: startPairingDiscoveryAdvertisement
|
|
5
|
+
// Depends on: bonjour-service
|
|
6
|
+
|
|
7
|
+
const { Bonjour } = require("bonjour-service");
|
|
8
|
+
|
|
9
|
+
const PAIRING_SERVICE_TYPE = "codex-pairing";
|
|
10
|
+
|
|
11
|
+
function startPairingDiscoveryAdvertisement(pairingPayload, { logger = console } = {}) {
|
|
12
|
+
const bonjour = new Bonjour();
|
|
13
|
+
const serviceName = buildServiceName(pairingPayload);
|
|
14
|
+
const publishedService = bonjour.publish({
|
|
15
|
+
name: serviceName,
|
|
16
|
+
type: PAIRING_SERVICE_TYPE,
|
|
17
|
+
protocol: "tcp",
|
|
18
|
+
port: advertisedPort(pairingPayload.relay),
|
|
19
|
+
txt: buildTxtRecord(pairingPayload),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
publishedService.on("up", () => {
|
|
23
|
+
logger.log?.(`[codex-bridge] Nearby pairing is available on local Wi-Fi as “${serviceName}”.`);
|
|
24
|
+
});
|
|
25
|
+
publishedService.on("error", (error) => {
|
|
26
|
+
logger.warn?.(
|
|
27
|
+
`[codex-bridge] Local pairing advertisement failed: ${(error && error.message) || "unknown error"}`
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let didStop = false;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
stop() {
|
|
35
|
+
if (didStop) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
didStop = true;
|
|
40
|
+
try {
|
|
41
|
+
publishedService.stop(() => {
|
|
42
|
+
bonjour.destroy();
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
bonjour.destroy();
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildServiceName(pairingPayload) {
|
|
52
|
+
const suffix = String(pairingPayload.macDeviceId || "")
|
|
53
|
+
.replace(/[^a-zA-Z0-9]/g, "")
|
|
54
|
+
.slice(0, 6)
|
|
55
|
+
.toUpperCase();
|
|
56
|
+
return suffix ? `Codex ${suffix}` : "Codex";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildTxtRecord(pairingPayload) {
|
|
60
|
+
return {
|
|
61
|
+
v: String(pairingPayload.v || ""),
|
|
62
|
+
relay: String(pairingPayload.relay || ""),
|
|
63
|
+
sid: String(pairingPayload.sessionId || ""),
|
|
64
|
+
mid: String(pairingPayload.macDeviceId || ""),
|
|
65
|
+
mpk: String(pairingPayload.macIdentityPublicKey || ""),
|
|
66
|
+
exp: String(pairingPayload.expiresAt || ""),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function advertisedPort(relayUrl) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = new URL(relayUrl);
|
|
73
|
+
if (parsed.port) {
|
|
74
|
+
return Number(parsed.port);
|
|
75
|
+
}
|
|
76
|
+
return parsed.protocol === "wss:" || parsed.protocol === "https:" ? 443 : 80;
|
|
77
|
+
} catch {
|
|
78
|
+
return 9;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
PAIRING_SERVICE_TYPE,
|
|
84
|
+
advertisedPort,
|
|
85
|
+
buildTxtRecord,
|
|
86
|
+
startPairingDiscoveryAdvertisement,
|
|
87
|
+
};
|
package/src/workspace-handler.js
CHANGED
|
@@ -4,23 +4,28 @@
|
|
|
4
4
|
// Exports: handleWorkspaceRequest
|
|
5
5
|
// Depends on: child_process, fs, os, path, ./git-handler
|
|
6
6
|
|
|
7
|
-
const { execFile } = require("child_process");
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
7
|
+
const { execFile } = require("child_process");
|
|
8
|
+
const { randomUUID } = require("crypto");
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const { promisify } = require("util");
|
|
13
|
+
const { gitStatus } = require("./git-handler");
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
17
|
+
const MOBILE_FILE_IMPORT_MAX_BYTES = 1024 * 1024 * 1024;
|
|
18
|
+
const MOBILE_FILE_IMPORT_MAX_CHUNK_BYTES = 1024 * 1024;
|
|
19
|
+
const MOBILE_FILE_IMPORT_SINGLE_REQUEST_MAX_BYTES = MOBILE_FILE_IMPORT_MAX_CHUNK_BYTES;
|
|
20
|
+
const activeMobileFileImports = new Map();
|
|
21
|
+
const repoMutationLocks = new Map();
|
|
22
|
+
|
|
23
|
+
function handleWorkspaceRequest(rawMessage, sendResponse, options = {}) {
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(rawMessage);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
|
|
@@ -30,12 +35,12 @@ function handleWorkspaceRequest(rawMessage, sendResponse) {
|
|
|
30
35
|
|
|
31
36
|
const id = parsed.id;
|
|
32
37
|
const params = parsed.params || {};
|
|
33
|
-
|
|
34
|
-
handleWorkspaceMethod(method, params)
|
|
35
|
-
.then((result) => {
|
|
36
|
-
sendResponse(JSON.stringify({ id, result }));
|
|
37
|
-
})
|
|
38
|
-
.catch((err) => {
|
|
38
|
+
|
|
39
|
+
handleWorkspaceMethod(method, params, options)
|
|
40
|
+
.then((result) => {
|
|
41
|
+
sendResponse(JSON.stringify({ id, result }));
|
|
42
|
+
})
|
|
43
|
+
.catch((err) => {
|
|
39
44
|
const errorCode = err.errorCode || "workspace_error";
|
|
40
45
|
const message = err.userMessage || err.message || "Unknown workspace error";
|
|
41
46
|
sendResponse(
|
|
@@ -50,22 +55,281 @@ function handleWorkspaceRequest(rawMessage, sendResponse) {
|
|
|
50
55
|
);
|
|
51
56
|
});
|
|
52
57
|
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function handleWorkspaceMethod(method, params) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function handleWorkspaceMethod(method, params, options = {}) {
|
|
62
|
+
switch (method) {
|
|
63
|
+
case "workspace/importMobileFile":
|
|
64
|
+
return workspaceImportMobileFile(params, options);
|
|
65
|
+
case "workspace/beginMobileFileImport":
|
|
66
|
+
return workspaceBeginMobileFileImport(params, options);
|
|
67
|
+
case "workspace/appendMobileFileImportChunk":
|
|
68
|
+
return workspaceAppendMobileFileImportChunk(params);
|
|
69
|
+
case "workspace/finishMobileFileImport":
|
|
70
|
+
return workspaceFinishMobileFileImport(params);
|
|
71
|
+
case "workspace/cancelMobileFileImport":
|
|
72
|
+
return workspaceCancelMobileFileImport(params);
|
|
73
|
+
case "workspace/revertPatchPreview":
|
|
74
|
+
case "workspace/revertPatchApply": {
|
|
75
|
+
const cwd = await resolveWorkspaceCwd(params);
|
|
76
|
+
const repoRoot = await resolveRepoRoot(cwd);
|
|
77
|
+
if (method === "workspace/revertPatchPreview") {
|
|
78
|
+
return workspaceRevertPatchPreview(repoRoot, params);
|
|
79
|
+
}
|
|
80
|
+
return withRepoMutationLock(repoRoot, () => workspaceRevertPatchApply(repoRoot, params));
|
|
81
|
+
}
|
|
82
|
+
default:
|
|
83
|
+
throw workspaceError("unknown_method", `Unknown workspace method: ${method}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function workspaceImportMobileFile(
|
|
88
|
+
params,
|
|
89
|
+
{
|
|
90
|
+
fsModule = fs,
|
|
91
|
+
osModule = os,
|
|
92
|
+
pathModule = path,
|
|
93
|
+
env = process.env,
|
|
94
|
+
} = {}
|
|
95
|
+
) {
|
|
96
|
+
const base64Data = normalizeBase64Payload(params.base64Data);
|
|
97
|
+
if (!base64Data) {
|
|
98
|
+
throw workspaceError("missing_file_data", "The selected file could not be uploaded.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fileBuffer = decodeBase64File(base64Data);
|
|
102
|
+
if (!fileBuffer.length) {
|
|
103
|
+
throw workspaceError("empty_file", "The selected file is empty.");
|
|
104
|
+
}
|
|
105
|
+
if (fileBuffer.length > MOBILE_FILE_IMPORT_SINGLE_REQUEST_MAX_BYTES) {
|
|
106
|
+
throw workspaceError(
|
|
107
|
+
"file_too_large",
|
|
108
|
+
"This file is too large for single-request uploads. Update the app and retry."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const requestedFileName = firstNonEmptyString([params.fileName, params.name]);
|
|
113
|
+
const sanitizedFileName = sanitizeImportFileName(requestedFileName, pathModule);
|
|
114
|
+
const importRoot = resolveMobileImportRoot({
|
|
115
|
+
requestedCwd: firstNonEmptyString([params.cwd, params.currentWorkingDirectory]),
|
|
116
|
+
fsImpl: fsModule,
|
|
117
|
+
osImpl: osModule,
|
|
118
|
+
pathImpl: pathModule,
|
|
119
|
+
env,
|
|
120
|
+
});
|
|
121
|
+
fsModule.mkdirSync(importRoot, { recursive: true });
|
|
122
|
+
|
|
123
|
+
const destinationPath = reserveUniqueImportPath(importRoot, sanitizedFileName, {
|
|
124
|
+
fsImpl: fsModule,
|
|
125
|
+
pathImpl: pathModule,
|
|
126
|
+
});
|
|
127
|
+
await fsModule.promises.writeFile(destinationPath, fileBuffer, { mode: 0o600 });
|
|
128
|
+
try {
|
|
129
|
+
fsModule.chmodSync(destinationPath, 0o600);
|
|
130
|
+
} catch {
|
|
131
|
+
// Best-effort only on filesystems without POSIX mode support.
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const normalizedMimeType = firstNonEmptyString([params.mimeType, params.contentType]);
|
|
135
|
+
return {
|
|
136
|
+
fileName: pathModule.basename(destinationPath),
|
|
137
|
+
path: destinationPath,
|
|
138
|
+
sizeBytes: fileBuffer.length,
|
|
139
|
+
mimeType: normalizedMimeType || null,
|
|
140
|
+
importRoot,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function workspaceBeginMobileFileImport(
|
|
145
|
+
params,
|
|
146
|
+
{
|
|
147
|
+
fsModule = fs,
|
|
148
|
+
osModule = os,
|
|
149
|
+
pathModule = path,
|
|
150
|
+
env = process.env,
|
|
151
|
+
} = {}
|
|
152
|
+
) {
|
|
153
|
+
const requestedFileName = firstNonEmptyString([params.fileName, params.name]);
|
|
154
|
+
const sanitizedFileName = sanitizeImportFileName(requestedFileName, pathModule);
|
|
155
|
+
const totalBytes = readOptionalNonNegativeInteger(params.totalBytes, "totalBytes");
|
|
156
|
+
|
|
157
|
+
if (totalBytes === 0) {
|
|
158
|
+
throw workspaceError("empty_file", "The selected file is empty.");
|
|
159
|
+
}
|
|
160
|
+
if (totalBytes != null && totalBytes > MOBILE_FILE_IMPORT_MAX_BYTES) {
|
|
161
|
+
throw workspaceError(
|
|
162
|
+
"file_too_large",
|
|
163
|
+
"This file is larger than 1 GB. Pick a smaller file and retry."
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const importRoot = resolveMobileImportRoot({
|
|
168
|
+
requestedCwd: firstNonEmptyString([params.cwd, params.currentWorkingDirectory]),
|
|
169
|
+
fsImpl: fsModule,
|
|
170
|
+
osImpl: osModule,
|
|
171
|
+
pathImpl: pathModule,
|
|
172
|
+
env,
|
|
173
|
+
});
|
|
174
|
+
fsModule.mkdirSync(importRoot, { recursive: true });
|
|
175
|
+
|
|
176
|
+
const destinationPath = reserveUniqueImportPath(importRoot, sanitizedFileName, {
|
|
177
|
+
fsImpl: fsModule,
|
|
178
|
+
pathImpl: pathModule,
|
|
179
|
+
});
|
|
180
|
+
const uploadId = randomUUID();
|
|
181
|
+
const tempPath = reserveTempImportPath(importRoot, sanitizedFileName, uploadId, {
|
|
182
|
+
fsImpl: fsModule,
|
|
183
|
+
pathImpl: pathModule,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await fsModule.promises.writeFile(tempPath, Buffer.alloc(0), {
|
|
187
|
+
flag: "wx",
|
|
188
|
+
mode: 0o600,
|
|
189
|
+
});
|
|
190
|
+
try {
|
|
191
|
+
fsModule.chmodSync(tempPath, 0o600);
|
|
192
|
+
} catch {
|
|
193
|
+
// Best-effort only on filesystems without POSIX mode support.
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const normalizedMimeType = firstNonEmptyString([params.mimeType, params.contentType]);
|
|
197
|
+
activeMobileFileImports.set(uploadId, {
|
|
198
|
+
uploadId,
|
|
199
|
+
tempPath,
|
|
200
|
+
destinationPath,
|
|
201
|
+
fileName: pathModule.basename(destinationPath),
|
|
202
|
+
requestedFileName: sanitizedFileName,
|
|
203
|
+
mimeType: normalizedMimeType || null,
|
|
204
|
+
importRoot,
|
|
205
|
+
bytesWritten: 0,
|
|
206
|
+
totalBytes,
|
|
207
|
+
fsModule,
|
|
208
|
+
pathModule,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
uploadId,
|
|
213
|
+
fileName: pathModule.basename(destinationPath),
|
|
214
|
+
path: destinationPath,
|
|
215
|
+
importRoot,
|
|
216
|
+
maxChunkBytes: MOBILE_FILE_IMPORT_MAX_CHUNK_BYTES,
|
|
217
|
+
maxUploadBytes: MOBILE_FILE_IMPORT_MAX_BYTES,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function workspaceAppendMobileFileImportChunk(params) {
|
|
222
|
+
const uploadId = requireUploadId(params.uploadId);
|
|
223
|
+
const session = requireActiveMobileImport(uploadId);
|
|
224
|
+
const base64Data = normalizeBase64Payload(params.base64Data);
|
|
225
|
+
if (!base64Data) {
|
|
226
|
+
throw workspaceError("missing_file_data", "The selected file could not be uploaded.");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const fileBuffer = decodeBase64File(base64Data);
|
|
230
|
+
if (!fileBuffer.length) {
|
|
231
|
+
throw workspaceError("empty_chunk", "The uploaded chunk was empty.");
|
|
232
|
+
}
|
|
233
|
+
if (fileBuffer.length > MOBILE_FILE_IMPORT_MAX_CHUNK_BYTES) {
|
|
234
|
+
throw workspaceError(
|
|
235
|
+
"chunk_too_large",
|
|
236
|
+
"The upload chunk is too large. Retry the upload."
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const nextByteCount = session.bytesWritten + fileBuffer.length;
|
|
241
|
+
if (nextByteCount > MOBILE_FILE_IMPORT_MAX_BYTES) {
|
|
242
|
+
await destroyMobileImportSession(uploadId, session);
|
|
243
|
+
throw workspaceError(
|
|
244
|
+
"file_too_large",
|
|
245
|
+
"This file is larger than 1 GB. Pick a smaller file and retry."
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (session.totalBytes != null && nextByteCount > session.totalBytes) {
|
|
249
|
+
await destroyMobileImportSession(uploadId, session);
|
|
250
|
+
throw workspaceError(
|
|
251
|
+
"upload_size_mismatch",
|
|
252
|
+
"The uploaded file exceeded its declared size. Retry the upload."
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
await session.fsModule.promises.appendFile(session.tempPath, fileBuffer, { mode: 0o600 });
|
|
257
|
+
session.bytesWritten = nextByteCount;
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
uploadId,
|
|
261
|
+
bytesWritten: session.bytesWritten,
|
|
262
|
+
remainingBytes: session.totalBytes == null
|
|
263
|
+
? null
|
|
264
|
+
: Math.max(session.totalBytes - session.bytesWritten, 0),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function workspaceFinishMobileFileImport(params) {
|
|
269
|
+
const uploadId = requireUploadId(params.uploadId);
|
|
270
|
+
const session = requireActiveMobileImport(uploadId);
|
|
271
|
+
|
|
272
|
+
if (session.bytesWritten <= 0) {
|
|
273
|
+
await destroyMobileImportSession(uploadId, session);
|
|
274
|
+
throw workspaceError("empty_file", "The selected file is empty.");
|
|
275
|
+
}
|
|
276
|
+
if (session.totalBytes != null && session.bytesWritten !== session.totalBytes) {
|
|
277
|
+
await destroyMobileImportSession(uploadId, session);
|
|
278
|
+
throw workspaceError(
|
|
279
|
+
"upload_incomplete",
|
|
280
|
+
"The selected file did not finish uploading. Retry the upload."
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let finalPath = session.destinationPath;
|
|
285
|
+
if (session.fsModule.existsSync(finalPath)) {
|
|
286
|
+
finalPath = reserveUniqueImportPath(session.importRoot, session.requestedFileName, {
|
|
287
|
+
fsImpl: session.fsModule,
|
|
288
|
+
pathImpl: session.pathModule,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
await session.fsModule.promises.rename(session.tempPath, finalPath);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
await destroyMobileImportSession(uploadId, session);
|
|
296
|
+
throw workspaceError(
|
|
297
|
+
"file_write_failed",
|
|
298
|
+
err?.message || "The uploaded file could not be finalized."
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
session.fsModule.chmodSync(finalPath, 0o600);
|
|
304
|
+
} catch {
|
|
305
|
+
// Best-effort only on filesystems without POSIX mode support.
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
activeMobileFileImports.delete(uploadId);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
fileName: session.pathModule.basename(finalPath),
|
|
312
|
+
path: finalPath,
|
|
313
|
+
sizeBytes: session.bytesWritten,
|
|
314
|
+
mimeType: session.mimeType,
|
|
315
|
+
importRoot: session.importRoot,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function workspaceCancelMobileFileImport(params) {
|
|
320
|
+
const uploadId = firstNonEmptyString([params.uploadId]);
|
|
321
|
+
if (!uploadId) {
|
|
322
|
+
return { cancelled: false };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const session = activeMobileFileImports.get(uploadId);
|
|
326
|
+
if (!session) {
|
|
327
|
+
return { cancelled: false };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await destroyMobileImportSession(uploadId, session);
|
|
331
|
+
return { cancelled: true };
|
|
332
|
+
}
|
|
69
333
|
|
|
70
334
|
// Validates the reverse patch against the current tree without writing repo files.
|
|
71
335
|
async function workspaceRevertPatchPreview(repoRoot, params) {
|
|
@@ -272,10 +536,10 @@ function extractPatchPath(lines) {
|
|
|
272
536
|
return "";
|
|
273
537
|
}
|
|
274
538
|
|
|
275
|
-
function normalizeDiffPath(rawPath) {
|
|
276
|
-
if (!rawPath) {
|
|
277
|
-
return "";
|
|
278
|
-
}
|
|
539
|
+
function normalizeDiffPath(rawPath) {
|
|
540
|
+
if (!rawPath) {
|
|
541
|
+
return "";
|
|
542
|
+
}
|
|
279
543
|
|
|
280
544
|
if (rawPath.startsWith("a/") || rawPath.startsWith("b/")) {
|
|
281
545
|
return rawPath.slice(2);
|
|
@@ -422,7 +686,7 @@ async function resolveRepoRoot(cwd) {
|
|
|
422
686
|
);
|
|
423
687
|
}
|
|
424
688
|
|
|
425
|
-
function firstNonEmptyString(candidates) {
|
|
689
|
+
function firstNonEmptyString(candidates) {
|
|
426
690
|
for (const candidate of candidates) {
|
|
427
691
|
if (typeof candidate !== "string") {
|
|
428
692
|
continue;
|
|
@@ -434,16 +698,155 @@ function firstNonEmptyString(candidates) {
|
|
|
434
698
|
}
|
|
435
699
|
}
|
|
436
700
|
|
|
437
|
-
return null;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function
|
|
441
|
-
|
|
442
|
-
return
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function normalizeBase64Payload(value) {
|
|
705
|
+
if (typeof value !== "string") {
|
|
706
|
+
return "";
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const trimmed = value.trim();
|
|
710
|
+
if (!trimmed) {
|
|
711
|
+
return "";
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const commaIndex = trimmed.indexOf(",");
|
|
715
|
+
const payload = trimmed.startsWith("data:") && commaIndex >= 0
|
|
716
|
+
? trimmed.slice(commaIndex + 1)
|
|
717
|
+
: trimmed;
|
|
718
|
+
return payload.replace(/\s+/g, "");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function decodeBase64File(base64Data) {
|
|
722
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64Data) || base64Data.length % 4 === 1) {
|
|
723
|
+
throw workspaceError("invalid_file_data", "The selected file could not be decoded.");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const fileBuffer = Buffer.from(base64Data, "base64");
|
|
727
|
+
if (!fileBuffer.length) {
|
|
728
|
+
return Buffer.alloc(0);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const normalizedInput = base64Data.replace(/=+$/, "");
|
|
732
|
+
const normalizedDecoded = fileBuffer.toString("base64").replace(/=+$/, "");
|
|
733
|
+
if (normalizedDecoded !== normalizedInput) {
|
|
734
|
+
throw workspaceError("invalid_file_data", "The selected file could not be decoded.");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return fileBuffer;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function sanitizeImportFileName(fileName, pathImpl = path) {
|
|
741
|
+
const rawName = typeof fileName === "string" ? fileName.trim() : "";
|
|
742
|
+
const baseName = rawName ? pathImpl.basename(rawName) : "upload.bin";
|
|
743
|
+
const sanitized = baseName.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_");
|
|
744
|
+
return sanitized || "upload.bin";
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function resolveMobileImportRoot({
|
|
748
|
+
requestedCwd,
|
|
749
|
+
fsImpl = fs,
|
|
750
|
+
osImpl = os,
|
|
751
|
+
pathImpl = path,
|
|
752
|
+
env = process.env,
|
|
753
|
+
} = {}) {
|
|
754
|
+
if (requestedCwd && isExistingDirectory(requestedCwd, fsImpl)) {
|
|
755
|
+
return pathImpl.join(requestedCwd, ".codex-mobile-uploads");
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const codexHome = firstNonEmptyString([env.CODEX_HOME])
|
|
759
|
+
|| pathImpl.join(osImpl.homedir(), ".codex");
|
|
760
|
+
return pathImpl.join(codexHome, "mobile-uploads");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function reserveUniqueImportPath(importRoot, fileName, { fsImpl = fs, pathImpl = path } = {}) {
|
|
764
|
+
const parsed = pathImpl.parse(fileName);
|
|
765
|
+
const safeBaseName = parsed.name || "upload";
|
|
766
|
+
const extension = parsed.ext || "";
|
|
767
|
+
let attempt = 0;
|
|
768
|
+
|
|
769
|
+
while (true) {
|
|
770
|
+
const candidateName = attempt === 0
|
|
771
|
+
? `${safeBaseName}${extension}`
|
|
772
|
+
: `${safeBaseName}-${attempt + 1}${extension}`;
|
|
773
|
+
const candidatePath = pathImpl.join(importRoot, candidateName);
|
|
774
|
+
if (!fsImpl.existsSync(candidatePath)) {
|
|
775
|
+
return candidatePath;
|
|
776
|
+
}
|
|
777
|
+
attempt += 1;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function reserveTempImportPath(importRoot, fileName, uploadId, { fsImpl = fs, pathImpl = path } = {}) {
|
|
782
|
+
const parsed = pathImpl.parse(fileName);
|
|
783
|
+
const safeBaseName = parsed.name || "upload";
|
|
784
|
+
const extension = parsed.ext || "";
|
|
785
|
+
let attempt = 0;
|
|
786
|
+
|
|
787
|
+
while (true) {
|
|
788
|
+
const candidateName = attempt === 0
|
|
789
|
+
? `.${safeBaseName}-${uploadId}${extension}.partial`
|
|
790
|
+
: `.${safeBaseName}-${uploadId}-${attempt + 1}${extension}.partial`;
|
|
791
|
+
const candidatePath = pathImpl.join(importRoot, candidateName);
|
|
792
|
+
if (!fsImpl.existsSync(candidatePath)) {
|
|
793
|
+
return candidatePath;
|
|
794
|
+
}
|
|
795
|
+
attempt += 1;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function requireUploadId(value) {
|
|
800
|
+
const uploadId = firstNonEmptyString([value]);
|
|
801
|
+
if (!uploadId) {
|
|
802
|
+
throw workspaceError("missing_upload_id", "The upload session is missing. Retry the upload.");
|
|
803
|
+
}
|
|
804
|
+
return uploadId;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function requireActiveMobileImport(uploadId) {
|
|
808
|
+
const session = activeMobileFileImports.get(uploadId);
|
|
809
|
+
if (!session) {
|
|
810
|
+
throw workspaceError("unknown_upload", "The upload session expired. Retry the upload.");
|
|
811
|
+
}
|
|
812
|
+
return session;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function destroyMobileImportSession(uploadId, session) {
|
|
816
|
+
activeMobileFileImports.delete(uploadId);
|
|
817
|
+
try {
|
|
818
|
+
await session.fsModule.promises.unlink(session.tempPath);
|
|
819
|
+
} catch {
|
|
820
|
+
// Ignore temp cleanup failures.
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function readOptionalNonNegativeInteger(value, fieldName) {
|
|
825
|
+
if (value == null) {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const numericValue = typeof value === "number"
|
|
830
|
+
? value
|
|
831
|
+
: (typeof value === "string" && value.trim() ? Number(value) : NaN);
|
|
832
|
+
|
|
833
|
+
if (!Number.isInteger(numericValue) || numericValue < 0) {
|
|
834
|
+
throw workspaceError(
|
|
835
|
+
"invalid_upload_size",
|
|
836
|
+
`${fieldName} must be a non-negative integer.`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return numericValue;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function isExistingDirectory(candidatePath, fsImpl = fs) {
|
|
844
|
+
try {
|
|
845
|
+
return fsImpl.statSync(candidatePath).isDirectory();
|
|
846
|
+
} catch {
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
447
850
|
|
|
448
851
|
function workspaceError(errorCode, userMessage) {
|
|
449
852
|
const err = new Error(userMessage);
|