codex-overleaf-link 1.1.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/LICENSE +21 -0
- package/README.md +457 -0
- package/bin/codex-overleaf-link.mjs +223 -0
- package/extension/src/shared/agentTranscript.js +1175 -0
- package/extension/src/shared/auditRecords.js +568 -0
- package/extension/src/shared/compatibility.js +372 -0
- package/extension/src/shared/compileAdapter.js +176 -0
- package/extension/src/shared/governanceRules.js +252 -0
- package/extension/src/shared/i18n.js +565 -0
- package/extension/src/shared/models.js +106 -0
- package/extension/src/shared/otText.js +505 -0
- package/extension/src/shared/projectFiles.js +180 -0
- package/extension/src/shared/reviewing.js +99 -0
- package/extension/src/shared/sensitiveScan.js +116 -0
- package/extension/src/shared/sessionState.js +1084 -0
- package/extension/src/shared/staleGuard.js +150 -0
- package/extension/src/shared/storageDb.js +986 -0
- package/extension/src/shared/storageKeys.js +29 -0
- package/extension/src/shared/storageMigration.js +168 -0
- package/extension/src/shared/summary.js +248 -0
- package/extension/src/shared/undoOperations.js +369 -0
- package/native-host/src/codexArgs.js +43 -0
- package/native-host/src/codexHome.js +538 -0
- package/native-host/src/codexModels.js +247 -0
- package/native-host/src/codexPrompt.js +192 -0
- package/native-host/src/codexPromptAssembly.js +411 -0
- package/native-host/src/codexSessionRunner.js +1247 -0
- package/native-host/src/commandApproval.js +914 -0
- package/native-host/src/debugLog.js +78 -0
- package/native-host/src/diffEngine.js +247 -0
- package/native-host/src/index.js +132 -0
- package/native-host/src/launcher.js +81 -0
- package/native-host/src/localSkills.js +476 -0
- package/native-host/src/manifest.js +226 -0
- package/native-host/src/mirrorSensitiveScan.js +119 -0
- package/native-host/src/mirrorWorkspace.js +1019 -0
- package/native-host/src/nativeDoctor.js +826 -0
- package/native-host/src/nativeEnvironment.js +315 -0
- package/native-host/src/nativeHostPlatform.js +112 -0
- package/native-host/src/nativeMessaging.js +60 -0
- package/native-host/src/nativeQuotas.js +294 -0
- package/native-host/src/nativeResponseBudget.js +194 -0
- package/native-host/src/runtimeInstaller.js +357 -0
- package/native-host/src/taskRunner.js +3 -0
- package/native-host/src/taskRunnerRuntime.js +1083 -0
- package/native-host/src/textPatch.js +287 -0
- package/package.json +40 -0
- package/scripts/codex-json-agent.mjs +269 -0
- package/scripts/install-native-host.mjs +255 -0
- package/scripts/npm-package-files-v1.1.1.txt +52 -0
- package/scripts/uninstall-native-host.mjs +298 -0
- package/scripts/verify-npm-package.mjs +296 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const { buildOperationSummary, splitDeletePlan } = require('../../extension/src/shared/summary');
|
|
6
|
+
const {
|
|
7
|
+
MIN_COMPATIBLE_EXTENSION_VERSION,
|
|
8
|
+
REQUIRED_CAPABILITIES,
|
|
9
|
+
SUPPORTED_NATIVE_PROTOCOL
|
|
10
|
+
} = require('../../extension/src/shared/compatibility');
|
|
11
|
+
const { runCodexSession } = require('./codexSessionRunner');
|
|
12
|
+
const { resolveCodexModels } = require('./codexModels');
|
|
13
|
+
const { clearPluginCodexHistory } = require('./codexHome');
|
|
14
|
+
const { logDebug, truncateText } = require('./debugLog');
|
|
15
|
+
const { HOST_NAME } = require('./manifest');
|
|
16
|
+
const { getNativeRuntimePlatform, summarizeNativeEnvironment } = require('./nativeEnvironment');
|
|
17
|
+
const {
|
|
18
|
+
NATIVE_REQUEST_QUOTAS,
|
|
19
|
+
firstQuotaViolation,
|
|
20
|
+
validateNativeRequestQuotas,
|
|
21
|
+
validateOperationListQuota,
|
|
22
|
+
validateOperationPayloadQuota
|
|
23
|
+
} = require('./nativeQuotas');
|
|
24
|
+
const { version: PACKAGE_VERSION } = require('../../package.json');
|
|
25
|
+
|
|
26
|
+
const activeProjectLocks = new Map();
|
|
27
|
+
const activeRunControllers = new Map();
|
|
28
|
+
const pendingPlans = new Map();
|
|
29
|
+
const PENDING_PLAN_TTL_MS = 30 * 60 * 1000;
|
|
30
|
+
const CODEX_RUN_PASSTHROUGH_ERROR_CODES = new Set(['thread_resume_failed']);
|
|
31
|
+
|
|
32
|
+
async function handleRequest(request, env = process.env, emit = () => {}) {
|
|
33
|
+
if (!request || typeof request !== 'object') {
|
|
34
|
+
return errorResponse(undefined, 'invalid_request', 'Request must be an object');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const quotaError = validateNativeRequestQuotas(request);
|
|
38
|
+
if (quotaError) {
|
|
39
|
+
return quotaErrorResponse(request.id, quotaError);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (request.method === 'bridge.ping') {
|
|
43
|
+
return okResponse(request.id, {
|
|
44
|
+
host: HOST_NAME,
|
|
45
|
+
platform: getNativeRuntimePlatform({ env }),
|
|
46
|
+
protocolVersion: 1,
|
|
47
|
+
supportedProtocol: { ...SUPPORTED_NATIVE_PROTOCOL },
|
|
48
|
+
capabilities: Object.fromEntries(REQUIRED_CAPABILITIES.map(capability => [capability, true])),
|
|
49
|
+
minExtensionVersion: MIN_COMPATIBLE_EXTENSION_VERSION,
|
|
50
|
+
version: PACKAGE_VERSION,
|
|
51
|
+
environment: summarizeNativeEnvironment(env)
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (request.method === 'mirror.sync') {
|
|
56
|
+
return handleMirrorSync(request, env);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (request.method === 'mirror.patchFiles') {
|
|
60
|
+
return handleMirrorPatchFiles(request, env);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (request.method === 'mirror.status') {
|
|
64
|
+
return handleMirrorStatus(request, env);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (request.method === 'mirror.scanSensitive') {
|
|
68
|
+
return handleMirrorScanSensitive(request, env);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (request.method === 'codex.models') {
|
|
72
|
+
return okResponse(request.id, resolveCodexModels(request.params || {}, env));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (request.method === 'codex.run') {
|
|
76
|
+
return handleCodexRun(request, env, emit);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (request.method === 'codex.cancel') {
|
|
80
|
+
return handleCodexCancel(request);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (request.method === 'codex.history.clearPlugin') {
|
|
84
|
+
return handleCodexHistoryClear(request, env);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (request.method === 'skills.list') {
|
|
88
|
+
return handleSkillsList(request, env);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (request.method === 'skills.install') {
|
|
92
|
+
return handleSkillsInstall(request, env);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (request.method === 'skills.remove') {
|
|
96
|
+
return handleSkillsRemove(request, env);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (request.method === 'task.run') {
|
|
100
|
+
return handleTaskRun(request, env, emit);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (request.method === 'task.confirm') {
|
|
104
|
+
return handleTaskConfirm(request);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return errorResponse(request.id, 'method_not_found', `Unknown method: ${request.method}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function quotaErrorResponse(id, violation) {
|
|
111
|
+
return errorResponse(
|
|
112
|
+
id,
|
|
113
|
+
'native_request_quota_exceeded',
|
|
114
|
+
`Native request quota exceeded: ${violation.reason} (${violation.actual}/${violation.limit}).`,
|
|
115
|
+
{
|
|
116
|
+
field: violation.field,
|
|
117
|
+
limit: violation.limit,
|
|
118
|
+
actual: violation.actual
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleCodexRun(request, env, emit) {
|
|
124
|
+
const params = request.params || {};
|
|
125
|
+
if (isCodexMissing(env)) {
|
|
126
|
+
return errorResponse(
|
|
127
|
+
request.id,
|
|
128
|
+
'codex_not_found',
|
|
129
|
+
'Codex CLI was not found. Install Codex or make sure the `codex` command is available in your login shell.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const projectKey = resolveProjectKey(params);
|
|
134
|
+
const lockToken = acquireProjectLock(projectKey);
|
|
135
|
+
if (!lockToken) {
|
|
136
|
+
return errorResponse(request.id, 'project_locked', `Project ${projectKey} is currently in use by codex.run`);
|
|
137
|
+
}
|
|
138
|
+
const abortController = new AbortController();
|
|
139
|
+
if (request.id) {
|
|
140
|
+
activeRunControllers.set(request.id, abortController);
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
if (params.useExistingMirror) {
|
|
144
|
+
const { getMirrorStatus, applyFileOverlays } = require('./mirrorWorkspace');
|
|
145
|
+
const rootDir = env.CODEX_OVERLEAF_MIRROR_ROOT;
|
|
146
|
+
const status = getMirrorStatus(projectKey, { rootDir });
|
|
147
|
+
const maxFreshness = params.expectedMirrorFreshness || 15000;
|
|
148
|
+
const mirrorMissingOrStale = !status.exists || !Number.isFinite(status.ageMs) || status.ageMs > maxFreshness;
|
|
149
|
+
|
|
150
|
+
if (isOtWarmMirrorReuseRequest(params)) {
|
|
151
|
+
const otWarmMirrorReuse = validateOtFocusedWarmMirrorReuse(params, status);
|
|
152
|
+
if (!otWarmMirrorReuse.ok) {
|
|
153
|
+
return errorResponse(
|
|
154
|
+
request.id,
|
|
155
|
+
'mirror_stale',
|
|
156
|
+
otWarmMirrorReuse.message || `Mirror is ${status.ageMs}ms old (max ${maxFreshness}ms)`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
} else if (mirrorMissingOrStale) {
|
|
160
|
+
return errorResponse(
|
|
161
|
+
request.id,
|
|
162
|
+
'mirror_stale',
|
|
163
|
+
`Mirror is ${status.ageMs}ms old (max ${maxFreshness}ms)`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (Array.isArray(params.fileOverlays) && params.fileOverlays.length) {
|
|
168
|
+
await applyFileOverlays({ projectId: projectKey, overlays: params.fileOverlays, rootDir });
|
|
169
|
+
}
|
|
170
|
+
} else if (!isSnapshotlessSkillInstallerRun(params) && !hasRunnableProjectSnapshotEvidence(params)) {
|
|
171
|
+
return errorResponse(
|
|
172
|
+
request.id,
|
|
173
|
+
'codex_run_requires_snapshot_evidence',
|
|
174
|
+
'codex.run requires an explicit full project snapshot or a focused partial snapshot'
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result = await runCodexSession({
|
|
179
|
+
params: params.useExistingMirror ? { ...params, skipMirrorSync: true } : params,
|
|
180
|
+
env,
|
|
181
|
+
emit,
|
|
182
|
+
rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT,
|
|
183
|
+
signal: abortController.signal
|
|
184
|
+
});
|
|
185
|
+
const syncChanges = Array.isArray(result.syncChanges) ? result.syncChanges : [];
|
|
186
|
+
return okResponse(request.id, {
|
|
187
|
+
...result,
|
|
188
|
+
syncChanges
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (isCancellationError(error)) {
|
|
192
|
+
logDebug('codex.run.cancelled', {
|
|
193
|
+
code: error.code,
|
|
194
|
+
message: error.message
|
|
195
|
+
});
|
|
196
|
+
return errorResponse(request.id, 'codex_cancelled', 'Codex run was cancelled by the user');
|
|
197
|
+
}
|
|
198
|
+
if (shouldPassthroughCodexRunError(error)) {
|
|
199
|
+
logDebug('codex.run.passthrough_failed', {
|
|
200
|
+
code: error.code,
|
|
201
|
+
message: error.message,
|
|
202
|
+
stack: error.stack
|
|
203
|
+
});
|
|
204
|
+
return errorResponse(request.id, error.code, truncateText(error.message, 12000));
|
|
205
|
+
}
|
|
206
|
+
logDebug('codex.run.failed', {
|
|
207
|
+
code: error.code,
|
|
208
|
+
message: error.message,
|
|
209
|
+
stack: error.stack
|
|
210
|
+
});
|
|
211
|
+
return errorResponse(request.id, 'codex_run_failed', truncateText(error.message, 12000));
|
|
212
|
+
} finally {
|
|
213
|
+
if (request.id && activeRunControllers.get(request.id) === abortController) {
|
|
214
|
+
activeRunControllers.delete(request.id);
|
|
215
|
+
}
|
|
216
|
+
releaseProjectLock(projectKey, lockToken);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function handleCodexCancel(request) {
|
|
221
|
+
const targetId = request.params?.requestId || request.params?.id;
|
|
222
|
+
if (!targetId || !activeRunControllers.has(targetId)) {
|
|
223
|
+
return okResponse(request.id, {
|
|
224
|
+
cancelled: false,
|
|
225
|
+
reason: 'No active Codex run matched the cancellation request'
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const controller = activeRunControllers.get(targetId);
|
|
230
|
+
controller.abort(createCancellationError());
|
|
231
|
+
return okResponse(request.id, {
|
|
232
|
+
cancelled: true,
|
|
233
|
+
requestId: targetId
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function handleCodexHistoryClear(request, env) {
|
|
238
|
+
try {
|
|
239
|
+
return okResponse(request.id, clearPluginCodexHistory(request.params || {}, env));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return errorResponse(request.id, 'codex_history_clear_failed', error.message);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function handleSkillsList(request, env) {
|
|
246
|
+
try {
|
|
247
|
+
const { CODEX_OVERLEAF_SKILL_SCOPE, listCodexOverleafSkills, listProjectSkills } = require('./localSkills');
|
|
248
|
+
if (request.params?.scope === CODEX_OVERLEAF_SKILL_SCOPE) {
|
|
249
|
+
return okResponse(request.id, listCodexOverleafSkills({ env }));
|
|
250
|
+
}
|
|
251
|
+
return okResponse(request.id, listProjectSkills({
|
|
252
|
+
projectId: request.params?.projectId,
|
|
253
|
+
rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT
|
|
254
|
+
}));
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return errorResponse(request.id, 'skills_list_failed', error.message);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function handleSkillsInstall(request, env) {
|
|
261
|
+
try {
|
|
262
|
+
const { CODEX_OVERLEAF_SKILL_SCOPE, installCodexOverleafSkill, installProjectSkill } = require('./localSkills');
|
|
263
|
+
if (request.params?.scope === CODEX_OVERLEAF_SKILL_SCOPE) {
|
|
264
|
+
return okResponse(request.id, installCodexOverleafSkill({
|
|
265
|
+
skillId: request.params?.skillId || request.params?.id,
|
|
266
|
+
content: request.params?.content,
|
|
267
|
+
env
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
return okResponse(request.id, installProjectSkill({
|
|
271
|
+
projectId: request.params?.projectId,
|
|
272
|
+
skillId: request.params?.skillId || request.params?.id,
|
|
273
|
+
content: request.params?.content,
|
|
274
|
+
rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT
|
|
275
|
+
}));
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return errorResponse(request.id, 'skills_install_failed', error.message);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function handleSkillsRemove(request, env) {
|
|
282
|
+
try {
|
|
283
|
+
const { CODEX_OVERLEAF_SKILL_SCOPE, removeCodexOverleafSkill, removeProjectSkill } = require('./localSkills');
|
|
284
|
+
if (request.params?.scope === CODEX_OVERLEAF_SKILL_SCOPE) {
|
|
285
|
+
return okResponse(request.id, removeCodexOverleafSkill({
|
|
286
|
+
skillId: request.params?.skillId || request.params?.id,
|
|
287
|
+
env
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
return okResponse(request.id, removeProjectSkill({
|
|
291
|
+
projectId: request.params?.projectId,
|
|
292
|
+
skillId: request.params?.skillId || request.params?.id,
|
|
293
|
+
rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT
|
|
294
|
+
}));
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return errorResponse(request.id, 'skills_remove_failed', error.message);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function hasRunnableProjectSnapshotEvidence(params = {}) {
|
|
301
|
+
if (params.project?.capabilities?.fullProjectSnapshot === true) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
if (params.project?.capabilities?.fullProjectSnapshot !== false) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (params.restrictToFocusFiles !== true) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
const normalizedFocusFiles = normalizeSnapshotEvidencePaths(params.focusFiles);
|
|
311
|
+
if (!normalizedFocusFiles.length || !Array.isArray(params.project?.files)) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
const evidenceFiles = new Map();
|
|
315
|
+
for (const file of params.project.files) {
|
|
316
|
+
const filePath = normalizeSnapshotEvidencePath(file?.path);
|
|
317
|
+
if (!filePath || !isUsableSnapshotEvidenceContent(file?.content)) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
evidenceFiles.set(filePath, file);
|
|
321
|
+
}
|
|
322
|
+
return normalizedFocusFiles.every(filePath => evidenceFiles.has(filePath));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isSnapshotlessSkillInstallerRun(params = {}) {
|
|
326
|
+
return params.skipMirrorSync === true
|
|
327
|
+
&& String(params.skillInvocation?.id || '').trim() === 'skill-installer';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function validateOtFocusedWarmMirrorReuse(params = {}, status = {}) {
|
|
331
|
+
if (!isOtWarmMirrorReuseRequest(params)) {
|
|
332
|
+
return { ok: false };
|
|
333
|
+
}
|
|
334
|
+
if (Array.isArray(params.fileOverlays) && params.fileOverlays.length) {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
message: 'OT warm mirror reuse does not accept file overlays'
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (status?.exists !== true) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
message: 'OT warm mirror reuse requires an existing trusted mirror'
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
if (params.restrictToFocusFiles !== true) {
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
message: 'OT warm mirror reuse requires restrictToFocusFiles=true'
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const normalizedFocusFiles = normalizeSnapshotEvidencePaths(params.focusFiles);
|
|
353
|
+
if (!normalizedFocusFiles.length) {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
message: 'OT warm mirror reuse requires focused files'
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const freshFiles = new Set();
|
|
361
|
+
for (const file of Array.isArray(status.otFreshFiles) ? status.otFreshFiles : []) {
|
|
362
|
+
if (file?.state !== 'fresh') {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const filePath = normalizeSnapshotEvidencePath(file.path);
|
|
366
|
+
if (filePath) {
|
|
367
|
+
freshFiles.add(filePath);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const missingFiles = normalizedFocusFiles.filter(filePath => !freshFiles.has(filePath));
|
|
371
|
+
if (missingFiles.length) {
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
message: `OT warm mirror focused files are not OT-fresh: ${missingFiles.join(', ')}`
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { ok: true };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isOtWarmMirrorReuseRequest(params = {}) {
|
|
382
|
+
return params.otWarmStart === true || params.warmStartStrategy === 'ot-warm-mirror';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isUsableSnapshotEvidenceContent(content) {
|
|
386
|
+
if (typeof content !== 'string') {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
const text = content.trim();
|
|
390
|
+
return Boolean(text) && !/^(loading|loading\.{3}|loading…)$/i.test(text);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function normalizeSnapshotEvidencePaths(value) {
|
|
394
|
+
const seen = new Set();
|
|
395
|
+
const paths = [];
|
|
396
|
+
for (const item of Array.isArray(value) ? value : []) {
|
|
397
|
+
const filePath = normalizeSnapshotEvidencePath(item);
|
|
398
|
+
if (!filePath || seen.has(filePath)) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
seen.add(filePath);
|
|
402
|
+
paths.push(filePath);
|
|
403
|
+
}
|
|
404
|
+
return paths;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function normalizeSnapshotEvidencePath(value) {
|
|
408
|
+
return String(value || '')
|
|
409
|
+
.replace(/^@file:/i, '')
|
|
410
|
+
.replace(/\\/g, '/')
|
|
411
|
+
.trim()
|
|
412
|
+
.replace(/^\/+/, '');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function createCancellationError() {
|
|
416
|
+
const error = new Error('Codex run was cancelled by the user');
|
|
417
|
+
error.code = 'codex_cancelled';
|
|
418
|
+
return error;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function isCancellationError(error = {}) {
|
|
422
|
+
return error.code === 'codex_cancelled'
|
|
423
|
+
|| error.name === 'AbortError'
|
|
424
|
+
|| /cancelled by the user|was cancelled/i.test(error.message || '');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function shouldPassthroughCodexRunError(error = {}) {
|
|
428
|
+
return CODEX_RUN_PASSTHROUGH_ERROR_CODES.has(error.code);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function isCodexMissing(env = process.env) {
|
|
432
|
+
return (
|
|
433
|
+
env.CODEX_OVERLEAF_ENV_READY === '1' ||
|
|
434
|
+
Object.prototype.hasOwnProperty.call(env, 'CODEX_OVERLEAF_CODEX_PATH')
|
|
435
|
+
) && !env.CODEX_OVERLEAF_CODEX_PATH;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function handleMirrorSync(request, env) {
|
|
439
|
+
const { syncOverleafToMirror } = require('./mirrorWorkspace');
|
|
440
|
+
const params = request.params || {};
|
|
441
|
+
const projectId = params.projectId || 'unknown';
|
|
442
|
+
const projectKey = resolveProjectKey(params);
|
|
443
|
+
const rootDir = env.CODEX_OVERLEAF_MIRROR_ROOT;
|
|
444
|
+
|
|
445
|
+
const lockToken = acquireProjectLock(projectKey);
|
|
446
|
+
if (!lockToken) {
|
|
447
|
+
return errorResponse(request.id, 'project_locked', `Project ${projectKey} is currently in use by codex.run`);
|
|
448
|
+
}
|
|
449
|
+
if (params.project?.capabilities?.fullProjectSnapshot !== true) {
|
|
450
|
+
releaseProjectLock(projectKey, lockToken);
|
|
451
|
+
return errorResponse(
|
|
452
|
+
request.id,
|
|
453
|
+
'mirror_sync_requires_full_project',
|
|
454
|
+
'mirror.sync requires an explicit full project snapshot'
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const result = await syncOverleafToMirror({
|
|
460
|
+
projectId,
|
|
461
|
+
project: params.project || { files: [] },
|
|
462
|
+
rootDir
|
|
463
|
+
});
|
|
464
|
+
return okResponse(request.id, {
|
|
465
|
+
fileCount: result.fileCount,
|
|
466
|
+
writtenCount: result.writtenCount || 0,
|
|
467
|
+
projectKey: result.projectKey
|
|
468
|
+
});
|
|
469
|
+
} catch (error) {
|
|
470
|
+
return errorResponse(request.id, 'mirror_sync_failed', error.message);
|
|
471
|
+
} finally {
|
|
472
|
+
releaseProjectLock(projectKey, lockToken);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function handleMirrorPatchFiles(request, env) {
|
|
477
|
+
const { patchMirrorFiles } = require('./mirrorWorkspace');
|
|
478
|
+
const params = request.params || {};
|
|
479
|
+
const projectId = params.projectId || 'unknown';
|
|
480
|
+
const projectKey = resolveProjectKey(params);
|
|
481
|
+
const rootDir = env.CODEX_OVERLEAF_MIRROR_ROOT;
|
|
482
|
+
|
|
483
|
+
const lockToken = acquireProjectLock(projectKey);
|
|
484
|
+
if (!lockToken) {
|
|
485
|
+
return errorResponse(request.id, 'project_locked', `Project ${projectKey} is currently in use by codex.run`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
const result = await patchMirrorFiles({
|
|
490
|
+
projectId,
|
|
491
|
+
files: params.files,
|
|
492
|
+
rootDir,
|
|
493
|
+
source: params.source || 'ot'
|
|
494
|
+
});
|
|
495
|
+
return okResponse(request.id, result);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
return errorResponse(request.id, 'mirror_patch_files_failed', error.message);
|
|
498
|
+
} finally {
|
|
499
|
+
releaseProjectLock(projectKey, lockToken);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function handleMirrorStatus(request, env) {
|
|
504
|
+
const { getMirrorStatus } = require('./mirrorWorkspace');
|
|
505
|
+
const params = request.params || {};
|
|
506
|
+
const projectId = params.projectId || 'unknown';
|
|
507
|
+
const rootDir = env.CODEX_OVERLEAF_MIRROR_ROOT;
|
|
508
|
+
const status = getMirrorStatus(projectId, { rootDir });
|
|
509
|
+
return okResponse(request.id, status);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function handleMirrorScanSensitive(request, env) {
|
|
513
|
+
try {
|
|
514
|
+
const { scanMirrorSensitiveFiles } = require('./mirrorSensitiveScan');
|
|
515
|
+
const params = request.params || {};
|
|
516
|
+
return okResponse(request.id, scanMirrorSensitiveFiles({
|
|
517
|
+
projectId: params.projectId || 'unknown',
|
|
518
|
+
rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT
|
|
519
|
+
}));
|
|
520
|
+
} catch (error) {
|
|
521
|
+
return errorResponse(request.id, 'mirror_sensitive_scan_failed', error.message);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function handleTaskRun(request, env, emit) {
|
|
526
|
+
const params = request.params || {};
|
|
527
|
+
const mode = params.mode;
|
|
528
|
+
|
|
529
|
+
if (!['ask', 'confirm', 'auto'].includes(mode)) {
|
|
530
|
+
return errorResponse(request.id, 'invalid_mode', 'Mode must be "ask", "confirm", or "auto"');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (mode === 'auto' && !params.checkpoint?.ok && !isVerifiedReviewing(params.reviewing)) {
|
|
534
|
+
return errorResponse(request.id, 'safety_required', 'Auto Mode requires an Overleaf checkpoint or verified Reviewing/Track Changes');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const fileCount = Array.isArray(params.project?.files) ? params.project.files.length : 0;
|
|
538
|
+
const totalChars = Array.isArray(params.project?.files)
|
|
539
|
+
? params.project.files.reduce((sum, file) => sum + String(file?.content || '').length, 0)
|
|
540
|
+
: 0;
|
|
541
|
+
emitTaskEvent(emit, 'native.task.received', 'Native bridge received task', {
|
|
542
|
+
mode,
|
|
543
|
+
model: params.model,
|
|
544
|
+
reasoningEffort: params.reasoningEffort,
|
|
545
|
+
speedTier: params.speedTier,
|
|
546
|
+
fileCount,
|
|
547
|
+
totalChars
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
let agentSpec;
|
|
551
|
+
try {
|
|
552
|
+
agentSpec = resolveExternalAgent(env);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
return errorResponse(request.id, 'invalid_agent_command', error.message);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (agentSpec) {
|
|
558
|
+
try {
|
|
559
|
+
logDebug('agent.run.start', {
|
|
560
|
+
command: agentSpec.label,
|
|
561
|
+
mode,
|
|
562
|
+
model: params.model,
|
|
563
|
+
reasoningEffort: params.reasoningEffort,
|
|
564
|
+
speedTier: params.speedTier,
|
|
565
|
+
fileCount
|
|
566
|
+
});
|
|
567
|
+
emitTaskEvent(emit, 'agent.command.started', 'Codex agent command started', {
|
|
568
|
+
mode,
|
|
569
|
+
model: params.model,
|
|
570
|
+
reasoningEffort: params.reasoningEffort,
|
|
571
|
+
speedTier: params.speedTier,
|
|
572
|
+
fileCount
|
|
573
|
+
});
|
|
574
|
+
const result = await runExternalAgent(agentSpec, params, emit, {
|
|
575
|
+
env,
|
|
576
|
+
timeoutMs: parseOptionalPositiveInteger(env.CODEX_OVERLEAF_AGENT_TIMEOUT_MS),
|
|
577
|
+
outputMaxBytes: parsePositiveInteger(env.CODEX_OVERLEAF_AGENT_OUTPUT_MAX_BYTES, 1024 * 1024)
|
|
578
|
+
});
|
|
579
|
+
logDebug('agent.run.ok', {
|
|
580
|
+
status: result?.status,
|
|
581
|
+
operationCount: Array.isArray(result?.operations) ? result.operations.length : 0
|
|
582
|
+
});
|
|
583
|
+
emitTaskEvent(emit, 'agent.command.completed', 'Codex agent command completed', {
|
|
584
|
+
status: result?.status || 'completed',
|
|
585
|
+
operationCount: Array.isArray(result?.operations) ? result.operations.length : 0
|
|
586
|
+
}, 'completed');
|
|
587
|
+
const normalized = normalizeAgentResult(mode, result, params);
|
|
588
|
+
const resultQuotaError = validateTaskResultOperationQuotas(normalized);
|
|
589
|
+
if (resultQuotaError) {
|
|
590
|
+
return quotaErrorResponse(request.id, resultQuotaError);
|
|
591
|
+
}
|
|
592
|
+
return okResponse(request.id, prepareResultForResponse(mode, normalized));
|
|
593
|
+
} catch (error) {
|
|
594
|
+
logDebug('agent.run.failed', {
|
|
595
|
+
message: error.message,
|
|
596
|
+
stack: error.stack
|
|
597
|
+
});
|
|
598
|
+
emitTaskEvent(emit, 'agent.command.failed', 'Codex agent command failed', {
|
|
599
|
+
message: error.message
|
|
600
|
+
}, 'failed');
|
|
601
|
+
return errorResponse(request.id, 'agent_failed', truncateText(error.message, 12000));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const operations = params.proposedOperations || [];
|
|
606
|
+
const result = buildDefaultTaskResult(mode, operations, params);
|
|
607
|
+
const resultQuotaError = validateTaskResultOperationQuotas(result);
|
|
608
|
+
if (resultQuotaError) {
|
|
609
|
+
return quotaErrorResponse(request.id, resultQuotaError);
|
|
610
|
+
}
|
|
611
|
+
return okResponse(request.id, prepareResultForResponse(mode, result));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function isVerifiedReviewing(reviewing) {
|
|
615
|
+
return reviewing?.ok === true && reviewing.status !== 'manual-override';
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function handleTaskConfirm(request) {
|
|
619
|
+
const planId = request.params?.planId;
|
|
620
|
+
purgeExpiredPendingPlans();
|
|
621
|
+
if (!planId || !pendingPlans.has(planId)) {
|
|
622
|
+
return errorResponse(request.id, 'plan_not_found', 'No pending task plan matched the supplied planId');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const plan = pendingPlans.get(planId);
|
|
626
|
+
pendingPlans.delete(planId);
|
|
627
|
+
|
|
628
|
+
return okResponse(request.id, {
|
|
629
|
+
status: 'confirmed',
|
|
630
|
+
notes: plan.notes || '',
|
|
631
|
+
userReport: plan.userReport,
|
|
632
|
+
operations: plan.operations
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function buildDefaultTaskResult(mode, operations, params = {}) {
|
|
637
|
+
if (mode === 'ask') {
|
|
638
|
+
return {
|
|
639
|
+
status: 'completed',
|
|
640
|
+
summary: buildOperationSummary([]),
|
|
641
|
+
notes: '',
|
|
642
|
+
userReport: buildDefaultUserReport(mode, [], params),
|
|
643
|
+
operations: []
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const summary = buildOperationSummary(operations);
|
|
648
|
+
|
|
649
|
+
if (mode === 'confirm') {
|
|
650
|
+
return {
|
|
651
|
+
status: 'requires_task_confirmation',
|
|
652
|
+
summary,
|
|
653
|
+
notes: '',
|
|
654
|
+
userReport: buildDefaultUserReport(mode, operations, params),
|
|
655
|
+
operations
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const split = splitDeletePlan(operations);
|
|
660
|
+
if (split.needsConfirmation.length > 0) {
|
|
661
|
+
return {
|
|
662
|
+
status: 'delete_plan_required',
|
|
663
|
+
summary: buildOperationSummary(split.immediate),
|
|
664
|
+
notes: '',
|
|
665
|
+
userReport: buildDefaultUserReport(mode, operations, params),
|
|
666
|
+
operations: split.immediate,
|
|
667
|
+
deletePlan: buildOperationSummary(split.needsConfirmation).deletePlan,
|
|
668
|
+
pendingOperations: split.needsConfirmation
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
status: 'completed',
|
|
674
|
+
summary,
|
|
675
|
+
notes: '',
|
|
676
|
+
userReport: buildDefaultUserReport(mode, operations, params),
|
|
677
|
+
operations
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function normalizeAgentResult(mode, result, params = {}) {
|
|
682
|
+
const operations = Array.isArray(result.operations) ? result.operations : [];
|
|
683
|
+
if (mode === 'ask') {
|
|
684
|
+
return {
|
|
685
|
+
status: 'completed',
|
|
686
|
+
summary: buildOperationSummary([]),
|
|
687
|
+
notes: typeof result.notes === 'string' ? result.notes : '',
|
|
688
|
+
userReport: normalizeUserReport(result.userReport, buildDefaultUserReport(mode, [], params)),
|
|
689
|
+
operations: []
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const normalized = {
|
|
694
|
+
...result,
|
|
695
|
+
summary: result.summary || buildOperationSummary(operations),
|
|
696
|
+
notes: typeof result.notes === 'string' ? result.notes : '',
|
|
697
|
+
userReport: normalizeUserReport(result.userReport, buildDefaultUserReport(mode, collectResultOperations(result), params)),
|
|
698
|
+
operations
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
if (!normalized.status) {
|
|
702
|
+
normalized.status = mode === 'confirm' ? 'requires_task_confirmation' : 'completed';
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return normalized;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function prepareResultForResponse(mode, result) {
|
|
709
|
+
if (mode === 'auto') {
|
|
710
|
+
const operations = collectResultOperations(result);
|
|
711
|
+
const split = splitDeletePlan(operations);
|
|
712
|
+
if (split.needsConfirmation.length > 0) {
|
|
713
|
+
return {
|
|
714
|
+
...result,
|
|
715
|
+
status: 'delete_plan_required',
|
|
716
|
+
summary: buildOperationSummary(split.immediate),
|
|
717
|
+
operations: split.immediate,
|
|
718
|
+
deletePlan: buildOperationSummary(split.needsConfirmation).deletePlan,
|
|
719
|
+
pendingOperations: split.needsConfirmation
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const confirmOperations = mode === 'confirm' ? collectResultOperations(result) : [];
|
|
725
|
+
if (confirmOperations.length > 0) {
|
|
726
|
+
result = {
|
|
727
|
+
...result,
|
|
728
|
+
status: 'requires_task_confirmation',
|
|
729
|
+
summary: buildOperationSummary(confirmOperations),
|
|
730
|
+
operations: confirmOperations
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (mode !== 'confirm' || result.status !== 'requires_task_confirmation') {
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
purgeExpiredPendingPlans();
|
|
739
|
+
const planId = `plan_${crypto.randomUUID()}`;
|
|
740
|
+
pendingPlans.set(planId, {
|
|
741
|
+
createdAt: Date.now(),
|
|
742
|
+
expiresAt: Date.now() + PENDING_PLAN_TTL_MS,
|
|
743
|
+
notes: result.notes || '',
|
|
744
|
+
userReport: result.userReport,
|
|
745
|
+
operations: Array.isArray(result.operations) ? result.operations : []
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const {
|
|
749
|
+
operations: _operations,
|
|
750
|
+
pendingOperations: _pendingOperations,
|
|
751
|
+
deletePlan: _deletePlan,
|
|
752
|
+
...redacted
|
|
753
|
+
} = result;
|
|
754
|
+
return {
|
|
755
|
+
...redacted,
|
|
756
|
+
planId
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function validateTaskResultOperationQuotas(result = {}) {
|
|
761
|
+
const operations = collectResultOperations(result);
|
|
762
|
+
return firstQuotaViolation([
|
|
763
|
+
validateOperationListQuota(operations, 'operations'),
|
|
764
|
+
validateOperationPayloadQuota(operations, 'operations')
|
|
765
|
+
]);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function buildDefaultUserReport(mode, operations = [], params = {}) {
|
|
769
|
+
const checked = (params.project?.files || [])
|
|
770
|
+
.map(file => file?.path)
|
|
771
|
+
.filter(path => typeof path === 'string' && path.length > 0)
|
|
772
|
+
.slice(0, 20);
|
|
773
|
+
const hasOperations = operations.length > 0;
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
conclusion: hasOperations
|
|
777
|
+
? 'Codex 已准备好建议修改,等待确认或写入。'
|
|
778
|
+
: '这轮任务已完成,没有写入 Overleaf 文件。',
|
|
779
|
+
checked,
|
|
780
|
+
findings: [],
|
|
781
|
+
plannedChanges: hasOperations ? operations.map(formatOperationForUserReport) : [],
|
|
782
|
+
appliedChanges: [],
|
|
783
|
+
unchangedReason: hasOperations ? '' : (mode === 'ask' ? '这轮是只问不改。' : ''),
|
|
784
|
+
nextStep: hasOperations
|
|
785
|
+
? '请确认修改方案后写入 Overleaf。'
|
|
786
|
+
: '可以继续追问,或加入更多 @context 后再检查。'
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function normalizeUserReport(value, fallback) {
|
|
791
|
+
if (!value || typeof value !== 'object') {
|
|
792
|
+
return fallback;
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
conclusion: typeof value.conclusion === 'string' ? value.conclusion : fallback.conclusion,
|
|
796
|
+
checked: normalizeStringArray(value.checked, fallback.checked),
|
|
797
|
+
findings: normalizeStringArray(value.findings, fallback.findings),
|
|
798
|
+
plannedChanges: normalizeStringArray(value.plannedChanges, fallback.plannedChanges),
|
|
799
|
+
appliedChanges: normalizeStringArray(value.appliedChanges, fallback.appliedChanges),
|
|
800
|
+
unchangedReason: typeof value.unchangedReason === 'string' ? value.unchangedReason : fallback.unchangedReason,
|
|
801
|
+
nextStep: typeof value.nextStep === 'string' ? value.nextStep : fallback.nextStep
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function normalizeStringArray(value, fallback = []) {
|
|
806
|
+
if (!Array.isArray(value)) {
|
|
807
|
+
return fallback;
|
|
808
|
+
}
|
|
809
|
+
return value.filter(item => typeof item === 'string');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function formatOperationForUserReport(operation) {
|
|
813
|
+
const labels = {
|
|
814
|
+
edit: '编辑',
|
|
815
|
+
create: '新建',
|
|
816
|
+
rename: '重命名',
|
|
817
|
+
move: '移动',
|
|
818
|
+
delete: '删除'
|
|
819
|
+
};
|
|
820
|
+
const label = labels[operation?.type] || operation?.type || '处理';
|
|
821
|
+
const filePath = operation?.path || operation?.to || '未知文件';
|
|
822
|
+
return `${filePath}:${label}`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function collectResultOperations(result) {
|
|
826
|
+
const operations = Array.isArray(result.operations) ? result.operations : [];
|
|
827
|
+
const pendingOperations = Array.isArray(result.pendingOperations) ? result.pendingOperations : [];
|
|
828
|
+
if (!pendingOperations.length) {
|
|
829
|
+
return operations;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const seen = new Set();
|
|
833
|
+
const combined = [];
|
|
834
|
+
for (const operation of [...operations, ...pendingOperations]) {
|
|
835
|
+
const key = JSON.stringify(operation);
|
|
836
|
+
if (seen.has(key)) {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
seen.add(key);
|
|
840
|
+
combined.push(operation);
|
|
841
|
+
}
|
|
842
|
+
return combined;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function resolveProjectKey(params = {}) {
|
|
846
|
+
const projectId = params.projectId || params.project?.projectId || params.project?.id || params.project?.url || 'unknown';
|
|
847
|
+
const raw = String(projectId).trim();
|
|
848
|
+
const fromUrl = raw.match(/\/project\/([^/?#]+)/)?.[1];
|
|
849
|
+
const candidate = fromUrl || raw.split(/[/?#]/).filter(Boolean).pop() || 'unknown';
|
|
850
|
+
return candidate.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'unknown';
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function acquireProjectLock(projectKey) {
|
|
854
|
+
if (activeProjectLocks.has(projectKey)) {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
const token = Symbol(projectKey);
|
|
858
|
+
activeProjectLocks.set(projectKey, token);
|
|
859
|
+
return token;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function releaseProjectLock(projectKey, token) {
|
|
863
|
+
if (activeProjectLocks.get(projectKey) === token) {
|
|
864
|
+
activeProjectLocks.delete(projectKey);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function isProjectLocked(projectKey) {
|
|
869
|
+
return activeProjectLocks.has(projectKey);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function resolveExternalAgent(env) {
|
|
873
|
+
if (env.CODEX_OVERLEAF_AGENT_FILE) {
|
|
874
|
+
const args = parseAgentArgsJson(env.CODEX_OVERLEAF_AGENT_ARGS_JSON);
|
|
875
|
+
return {
|
|
876
|
+
file: env.CODEX_OVERLEAF_AGENT_FILE,
|
|
877
|
+
args,
|
|
878
|
+
label: [env.CODEX_OVERLEAF_AGENT_FILE, ...args].join(' ')
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function parseAgentArgsJson(value) {
|
|
886
|
+
if (!value) {
|
|
887
|
+
return [];
|
|
888
|
+
}
|
|
889
|
+
const parsed = JSON.parse(value);
|
|
890
|
+
if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string')) {
|
|
891
|
+
throw new Error('CODEX_OVERLEAF_AGENT_ARGS_JSON must be a JSON array of strings');
|
|
892
|
+
}
|
|
893
|
+
return parsed;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function runExternalAgent(agentSpec, params, emit = () => {}, options = {}) {
|
|
897
|
+
return new Promise((resolve, reject) => {
|
|
898
|
+
const child = spawn(agentSpec.file, agentSpec.args || [], {
|
|
899
|
+
env: options.env || process.env,
|
|
900
|
+
shell: false,
|
|
901
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
902
|
+
});
|
|
903
|
+
const timeoutMs = parseOptionalPositiveInteger(options.timeoutMs);
|
|
904
|
+
const outputMaxBytes = parsePositiveInteger(options.outputMaxBytes, 1024 * 1024);
|
|
905
|
+
let stdout = '';
|
|
906
|
+
let stderr = '';
|
|
907
|
+
let stderrRemainder = '';
|
|
908
|
+
let outputBytes = 0;
|
|
909
|
+
let settled = false;
|
|
910
|
+
|
|
911
|
+
const timeout = timeoutMs
|
|
912
|
+
? setTimeout(() => {
|
|
913
|
+
fail(new Error(`Agent command timed out after ${timeoutMs}ms`));
|
|
914
|
+
}, timeoutMs)
|
|
915
|
+
: null;
|
|
916
|
+
|
|
917
|
+
function trackOutputBytes(chunk) {
|
|
918
|
+
outputBytes += Buffer.byteLength(String(chunk), 'utf8');
|
|
919
|
+
if (outputBytes > outputMaxBytes) {
|
|
920
|
+
fail(new Error(`Agent output limit exceeded (${outputBytes}/${outputMaxBytes} bytes)`));
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function fail(error) {
|
|
927
|
+
if (settled) {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
settled = true;
|
|
931
|
+
if (timeout) {
|
|
932
|
+
clearTimeout(timeout);
|
|
933
|
+
}
|
|
934
|
+
if (child.exitCode === null && !child.killed) {
|
|
935
|
+
child.kill('SIGTERM');
|
|
936
|
+
}
|
|
937
|
+
reject(error);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function succeed(result) {
|
|
941
|
+
if (settled) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
settled = true;
|
|
945
|
+
if (timeout) {
|
|
946
|
+
clearTimeout(timeout);
|
|
947
|
+
}
|
|
948
|
+
resolve(result);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
child.stdout.setEncoding('utf8');
|
|
952
|
+
child.stderr.setEncoding('utf8');
|
|
953
|
+
child.stdout.on('data', chunk => {
|
|
954
|
+
if (!trackOutputBytes(chunk) || settled) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
stdout += chunk;
|
|
958
|
+
});
|
|
959
|
+
child.stderr.on('data', chunk => {
|
|
960
|
+
if (!trackOutputBytes(chunk) || settled) {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const parsed = parseAgentEventLines(`${stderrRemainder}${chunk}`);
|
|
964
|
+
stderrRemainder = parsed.remainder;
|
|
965
|
+
stderr += parsed.stderr;
|
|
966
|
+
for (const event of parsed.events) {
|
|
967
|
+
emitTaskEvent(emit, event.type || 'agent.progress', event.title || event.type || 'Agent progress', event.detail || {}, event.status || 'running');
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
child.on('error', fail);
|
|
971
|
+
child.on('close', code => {
|
|
972
|
+
if (settled) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (stderrRemainder) {
|
|
976
|
+
const parsed = parseAgentEventLines(`${stderrRemainder}\n`);
|
|
977
|
+
stderr += parsed.stderr;
|
|
978
|
+
for (const event of parsed.events) {
|
|
979
|
+
emitTaskEvent(emit, event.type || 'agent.progress', event.title || event.type || 'Agent progress', event.detail || {}, event.status || 'running');
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (code !== 0) {
|
|
984
|
+
fail(new Error(truncateText(stderr || `Agent command exited with code ${code}`, 12000)));
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
try {
|
|
989
|
+
succeed(JSON.parse(stdout || '{}'));
|
|
990
|
+
} catch (error) {
|
|
991
|
+
fail(new Error(`Agent returned invalid JSON: ${error.message}. stdout=${truncateText(stdout, 4000)}`));
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
child.stdin.end(JSON.stringify(params));
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function purgeExpiredPendingPlans(now = Date.now()) {
|
|
1000
|
+
for (const [planId, plan] of pendingPlans.entries()) {
|
|
1001
|
+
if (Number.isFinite(plan?.expiresAt) && plan.expiresAt <= now) {
|
|
1002
|
+
pendingPlans.delete(planId);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function parsePositiveInteger(value, fallback) {
|
|
1008
|
+
const parsed = Number.parseInt(value, 10);
|
|
1009
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function parseOptionalPositiveInteger(value) {
|
|
1013
|
+
const parsed = Number.parseInt(value, 10);
|
|
1014
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function parseAgentEventLines(text) {
|
|
1018
|
+
const lines = text.split(/\r?\n/);
|
|
1019
|
+
const remainder = lines.pop() || '';
|
|
1020
|
+
const events = [];
|
|
1021
|
+
const stderrLines = [];
|
|
1022
|
+
|
|
1023
|
+
for (const line of lines) {
|
|
1024
|
+
if (!line.startsWith('CODEX_OVERLEAF_EVENT ')) {
|
|
1025
|
+
stderrLines.push(line);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
try {
|
|
1030
|
+
events.push(JSON.parse(line.slice('CODEX_OVERLEAF_EVENT '.length)));
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
stderrLines.push(`Invalid agent event: ${error.message}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return {
|
|
1037
|
+
events,
|
|
1038
|
+
stderr: stderrLines.length ? `${stderrLines.join('\n')}\n` : '',
|
|
1039
|
+
remainder
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function emitTaskEvent(emit, type, title, detail = {}, status = 'running') {
|
|
1044
|
+
if (typeof emit !== 'function') {
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
emit({
|
|
1049
|
+
type,
|
|
1050
|
+
title,
|
|
1051
|
+
status,
|
|
1052
|
+
detail,
|
|
1053
|
+
timestamp: new Date().toISOString()
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function okResponse(id, result) {
|
|
1058
|
+
return {
|
|
1059
|
+
id,
|
|
1060
|
+
ok: true,
|
|
1061
|
+
result
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function errorResponse(id, code, message, details = {}) {
|
|
1066
|
+
return {
|
|
1067
|
+
id,
|
|
1068
|
+
ok: false,
|
|
1069
|
+
error: {
|
|
1070
|
+
code,
|
|
1071
|
+
message,
|
|
1072
|
+
...details
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
module.exports = {
|
|
1078
|
+
NATIVE_REQUEST_QUOTAS,
|
|
1079
|
+
buildDefaultTaskResult,
|
|
1080
|
+
handleRequest,
|
|
1081
|
+
parseAgentEventLines,
|
|
1082
|
+
purgeExpiredPendingPlans
|
|
1083
|
+
};
|