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,1247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { collectMirrorChangesDetailed, getProjectMirror, markMirrorDirty, syncOverleafToMirror } = require('./mirrorWorkspace');
|
|
7
|
+
const { computeLineDiff } = require('./diffEngine');
|
|
8
|
+
const { computeTextPatches } = require('./textPatch');
|
|
9
|
+
const { buildCodexHomeEnv } = require('./codexHome');
|
|
10
|
+
const { buildCodexSpeedArgs } = require('./codexArgs');
|
|
11
|
+
const { truncateText } = require('./debugLog');
|
|
12
|
+
const { enforceNativeOkResponseBudget } = require('./nativeResponseBudget');
|
|
13
|
+
const { buildCodexTurnPrompt: buildCodexPromptParts } = require('./codexPromptAssembly');
|
|
14
|
+
const { evaluateSkillCommand } = require('./commandApproval');
|
|
15
|
+
const {
|
|
16
|
+
getCodexOverleafSkillsRoot,
|
|
17
|
+
loadSelectedCodexOverleafSkill,
|
|
18
|
+
loadSelectedProjectSkills
|
|
19
|
+
} = require('./localSkills');
|
|
20
|
+
|
|
21
|
+
const TURN_ATTACHMENTS_DIR = '.codex-overleaf-attachments';
|
|
22
|
+
const MAX_TURN_ATTACHMENT_BYTES = 12 * 1024 * 1024;
|
|
23
|
+
const MAX_TURN_ATTACHMENTS = 8;
|
|
24
|
+
const MAX_TURN_ATTACHMENT_TOTAL_BYTES = MAX_TURN_ATTACHMENT_BYTES * MAX_TURN_ATTACHMENTS;
|
|
25
|
+
|
|
26
|
+
async function runCodexSession({ params = {}, env = process.env, emit = () => {}, rootDir, executeCodex, signal } = {}) {
|
|
27
|
+
throwIfAborted(signal);
|
|
28
|
+
const projectId = params.projectId || params.project?.projectId || params.project?.id || params.project?.url || 'overleaf-project';
|
|
29
|
+
const skillInvocation = normalizeSkillInvocation(params.skillInvocation);
|
|
30
|
+
const skillInstallTurn = isSkillInstallerInvocation(skillInvocation);
|
|
31
|
+
if (skillInstallTurn && Array.isArray(params.attachments) && params.attachments.length) {
|
|
32
|
+
throw new Error('Skill installer turns do not accept attachments');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let mirror;
|
|
36
|
+
if (params.skipMirrorSync) {
|
|
37
|
+
mirror = getProjectMirror(projectId, { rootDir });
|
|
38
|
+
mirror.fileCount = 0;
|
|
39
|
+
} else {
|
|
40
|
+
emitCodexEvent(emit, 'overleaf.sync.started', 'Syncing Overleaf project to local workspace', {
|
|
41
|
+
projectId,
|
|
42
|
+
fileCount: Array.isArray(params.project?.files) ? params.project.files.length : 0
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
mirror = await syncOverleafToMirror({
|
|
46
|
+
projectId,
|
|
47
|
+
project: params.project || { files: [] },
|
|
48
|
+
rootDir
|
|
49
|
+
});
|
|
50
|
+
throwIfAborted(signal);
|
|
51
|
+
|
|
52
|
+
emitCodexEvent(emit, 'overleaf.sync.completed', 'Overleaf project synced to local workspace', {
|
|
53
|
+
projectId: mirror.projectKey,
|
|
54
|
+
workspacePath: mirror.workspacePath,
|
|
55
|
+
fileCount: mirror.fileCount
|
|
56
|
+
}, 'completed');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const projectLocalSkills = loadProjectLocalSkillsContext(params, mirror);
|
|
60
|
+
if (projectLocalSkills.missing.length) {
|
|
61
|
+
emitCodexEvent(emit, 'codex.local_skills.missing', 'Selected project-local skills were missing', {
|
|
62
|
+
missingSkillIds: projectLocalSkills.missing
|
|
63
|
+
}, 'failed');
|
|
64
|
+
}
|
|
65
|
+
const turnAttachments = materializeTurnAttachments(params.attachments, mirror.workspacePath);
|
|
66
|
+
const settings = buildCodexSettings(params);
|
|
67
|
+
const skillLoading = normalizeSkillLoadingSettings(params);
|
|
68
|
+
const codexSkillInvocationContext = loadCodexSkillInvocationContext({
|
|
69
|
+
skillInvocation,
|
|
70
|
+
loadCodexOverleafSkills: skillLoading.loadCodexOverleafSkills,
|
|
71
|
+
env,
|
|
72
|
+
emit
|
|
73
|
+
});
|
|
74
|
+
const effectiveSkillInvocation = getEffectiveSkillInvocation(codexSkillInvocationContext);
|
|
75
|
+
const runnerWorkspacePath = skillInstallTurn
|
|
76
|
+
? getCodexOverleafSkillsRoot({ env })
|
|
77
|
+
: mirror.workspacePath;
|
|
78
|
+
if (skillInstallTurn) {
|
|
79
|
+
fs.mkdirSync(runnerWorkspacePath, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
const runner = executeCodex || runCodexAppServerSession;
|
|
82
|
+
const runnerResult = await runner({
|
|
83
|
+
workspacePath: runnerWorkspacePath,
|
|
84
|
+
task: buildCodexTurnPrompt(params, mirror, projectLocalSkills, turnAttachments, codexSkillInvocationContext),
|
|
85
|
+
userTask: String(params.task || ''),
|
|
86
|
+
session: params.session || null,
|
|
87
|
+
threadId: params.threadId || '',
|
|
88
|
+
mode: params.mode || 'auto',
|
|
89
|
+
model: params.model || '',
|
|
90
|
+
reasoningEffort: params.reasoningEffort || '',
|
|
91
|
+
speedTier: normalizeSpeedTier(params.speedTier),
|
|
92
|
+
loadCodexLocalSkills: skillLoading.loadCodexLocalSkills,
|
|
93
|
+
loadCodexOverleafSkills: skillLoading.loadCodexOverleafSkills,
|
|
94
|
+
skillInvocation: effectiveSkillInvocation,
|
|
95
|
+
installCodexOverleafSkillsTarget: skillInstallTurn,
|
|
96
|
+
projectLocalSkills: null,
|
|
97
|
+
sandboxMode: settings.sandboxMode,
|
|
98
|
+
approvalPolicy: settings.approvalPolicy,
|
|
99
|
+
env,
|
|
100
|
+
emit,
|
|
101
|
+
signal
|
|
102
|
+
});
|
|
103
|
+
throwIfAborted(signal);
|
|
104
|
+
|
|
105
|
+
if (skillInstallTurn) {
|
|
106
|
+
return enforceNativeOkResponseBudget({
|
|
107
|
+
status: 'completed',
|
|
108
|
+
projectId: mirror.projectKey,
|
|
109
|
+
workspacePath: mirror.workspacePath,
|
|
110
|
+
assistantMessage: cleanAssistantMessage(runnerResult?.assistantMessage),
|
|
111
|
+
threadId: runnerResult?.threadId || '',
|
|
112
|
+
syncChanges: [],
|
|
113
|
+
unsupportedChanges: []
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const collected = await collectMirrorChangesDetailed({
|
|
118
|
+
projectId,
|
|
119
|
+
rootDir
|
|
120
|
+
});
|
|
121
|
+
const filteredChanges = filterSyncChangesForFocus({
|
|
122
|
+
changes: collected.changes || [],
|
|
123
|
+
focusFiles: params.focusFiles || params.session?.focusFiles,
|
|
124
|
+
restrictToFocusFiles: params.restrictToFocusFiles
|
|
125
|
+
});
|
|
126
|
+
const rawSyncChanges = filteredChanges.changes;
|
|
127
|
+
const unsupportedChanges = [
|
|
128
|
+
...(collected.unsupportedChanges || []),
|
|
129
|
+
...filteredChanges.unsupportedChanges
|
|
130
|
+
];
|
|
131
|
+
if (rawSyncChanges.length || unsupportedChanges.length) {
|
|
132
|
+
markMirrorDirty({
|
|
133
|
+
projectId,
|
|
134
|
+
rootDir,
|
|
135
|
+
reason: params.mode === 'ask' ? 'ask_mode_local_changes' : 'codex_run_local_changes'
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
throwIfAborted(signal);
|
|
139
|
+
|
|
140
|
+
if (params.mode === 'ask') {
|
|
141
|
+
emitCodexEvent(emit, 'overleaf.sync.changes', 'Ask mode finished without Overleaf writeback', {
|
|
142
|
+
changedCount: 0,
|
|
143
|
+
files: [],
|
|
144
|
+
unsupportedCount: 0,
|
|
145
|
+
unsupportedFiles: [],
|
|
146
|
+
ignoredChangedCount: rawSyncChanges.length,
|
|
147
|
+
ignoredUnsupportedCount: unsupportedChanges.length
|
|
148
|
+
}, rawSyncChanges.length || unsupportedChanges.length ? 'warning' : 'completed');
|
|
149
|
+
return enforceNativeOkResponseBudget({
|
|
150
|
+
status: 'completed',
|
|
151
|
+
projectId: mirror.projectKey,
|
|
152
|
+
workspacePath: mirror.workspacePath,
|
|
153
|
+
assistantMessage: cleanAssistantMessage(runnerResult?.assistantMessage),
|
|
154
|
+
threadId: runnerResult?.threadId || '',
|
|
155
|
+
syncChanges: [],
|
|
156
|
+
unsupportedChanges: []
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const syncChanges = rawSyncChanges.map(change => {
|
|
161
|
+
if (change.type === 'write' && typeof change.previousContent === 'string') {
|
|
162
|
+
return {
|
|
163
|
+
...change,
|
|
164
|
+
diff: computeLineDiff(change.previousContent, change.content),
|
|
165
|
+
patches: computeTextPatches(change.previousContent, change.content)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return change;
|
|
169
|
+
});
|
|
170
|
+
const response = enforceNativeOkResponseBudget({
|
|
171
|
+
status: 'completed',
|
|
172
|
+
projectId: mirror.projectKey,
|
|
173
|
+
workspacePath: mirror.workspacePath,
|
|
174
|
+
assistantMessage: cleanAssistantMessage(runnerResult?.assistantMessage),
|
|
175
|
+
threadId: runnerResult?.threadId || '',
|
|
176
|
+
syncChanges,
|
|
177
|
+
unsupportedChanges
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
emitCodexEvent(emit, 'overleaf.sync.changes', 'Local Codex changes collected for Overleaf sync', {
|
|
181
|
+
changedCount: response.syncChanges.length,
|
|
182
|
+
files: response.syncChanges.map(change => change.path),
|
|
183
|
+
unsupportedCount: response.unsupportedChanges.length,
|
|
184
|
+
unsupportedFiles: response.unsupportedChanges.map(change => change.path)
|
|
185
|
+
}, 'completed');
|
|
186
|
+
|
|
187
|
+
return response;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildCodexTurnPrompt(params = {}, mirror = {}, projectLocalSkills, turnAttachments = [], codexSkillInvocationContext = null) {
|
|
191
|
+
const prompt = buildCodexPromptParts({
|
|
192
|
+
params,
|
|
193
|
+
mirror,
|
|
194
|
+
projectLocalSkills,
|
|
195
|
+
turnAttachments,
|
|
196
|
+
codexSkillInvocationContext
|
|
197
|
+
});
|
|
198
|
+
return [prompt.systemPrompt, prompt.userPrompt].filter(Boolean).join('\n\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function materializeTurnAttachments(attachments = [], workspacePath = '') {
|
|
202
|
+
if (!workspacePath) {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
const attachmentDir = path.join(workspacePath, TURN_ATTACHMENTS_DIR);
|
|
206
|
+
fs.rmSync(attachmentDir, { recursive: true, force: true });
|
|
207
|
+
|
|
208
|
+
const normalized = normalizeTurnAttachments(attachments);
|
|
209
|
+
if (!normalized.length) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
fs.mkdirSync(attachmentDir, { recursive: true });
|
|
214
|
+
const usedNames = new Set();
|
|
215
|
+
return normalized.map(attachment => {
|
|
216
|
+
const fileName = dedupeAttachmentFileName(attachment.name, usedNames);
|
|
217
|
+
const target = path.join(attachmentDir, fileName);
|
|
218
|
+
const resolvedTarget = path.resolve(target);
|
|
219
|
+
const resolvedDir = path.resolve(attachmentDir);
|
|
220
|
+
if (!resolvedTarget.startsWith(resolvedDir + path.sep)) {
|
|
221
|
+
throw new Error('Unsafe attachment path');
|
|
222
|
+
}
|
|
223
|
+
fs.writeFileSync(target, attachment.bytes);
|
|
224
|
+
return {
|
|
225
|
+
name: fileName,
|
|
226
|
+
path: `${TURN_ATTACHMENTS_DIR}/${fileName}`,
|
|
227
|
+
mimeType: attachment.mimeType,
|
|
228
|
+
size: attachment.bytes.length
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeTurnAttachments(value) {
|
|
234
|
+
const input = Array.isArray(value) ? value : [];
|
|
235
|
+
if (input.length > MAX_TURN_ATTACHMENTS) {
|
|
236
|
+
throw new Error(`Too many attachments (${input.length}/${MAX_TURN_ATTACHMENTS})`);
|
|
237
|
+
}
|
|
238
|
+
const result = [];
|
|
239
|
+
let totalBytes = 0;
|
|
240
|
+
for (const item of input) {
|
|
241
|
+
const name = sanitizeAttachmentFileName(item?.name);
|
|
242
|
+
const contentBase64 = String(item?.contentBase64 || '').replace(/\s+/g, '');
|
|
243
|
+
if (!name || !contentBase64) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const declared = Number(item?.size);
|
|
247
|
+
const estimatedBytes = Math.max(
|
|
248
|
+
Number.isFinite(declared) && declared > 0 ? declared : 0,
|
|
249
|
+
estimateBase64DecodedBytes(contentBase64)
|
|
250
|
+
);
|
|
251
|
+
if (estimatedBytes > MAX_TURN_ATTACHMENT_BYTES) {
|
|
252
|
+
throw new Error(`Attachment is too large: ${name}`);
|
|
253
|
+
}
|
|
254
|
+
const bytes = Buffer.from(contentBase64, 'base64');
|
|
255
|
+
if (!bytes.length) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (bytes.length > MAX_TURN_ATTACHMENT_BYTES) {
|
|
259
|
+
throw new Error(`Attachment is too large: ${name}`);
|
|
260
|
+
}
|
|
261
|
+
totalBytes += Math.max(estimatedBytes, bytes.length);
|
|
262
|
+
if (totalBytes > MAX_TURN_ATTACHMENT_TOTAL_BYTES) {
|
|
263
|
+
throw new Error(`Attachments are too large (${totalBytes}/${MAX_TURN_ATTACHMENT_TOTAL_BYTES} bytes)`);
|
|
264
|
+
}
|
|
265
|
+
result.push({
|
|
266
|
+
name,
|
|
267
|
+
mimeType: String(item?.mimeType || '').trim().slice(0, 120),
|
|
268
|
+
bytes
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function estimateBase64DecodedBytes(value) {
|
|
275
|
+
const clean = String(value || '').replace(/\s+/g, '');
|
|
276
|
+
if (!clean) {
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
|
|
280
|
+
return Math.max(0, Math.floor(clean.length * 3 / 4) - padding);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function sanitizeAttachmentFileName(value) {
|
|
284
|
+
const basename = String(value || '')
|
|
285
|
+
.replace(/\0/g, '')
|
|
286
|
+
.replace(/\\/g, '/')
|
|
287
|
+
.split('/')
|
|
288
|
+
.filter(Boolean)
|
|
289
|
+
.pop()
|
|
290
|
+
?.trim()
|
|
291
|
+
.slice(0, 180) || '';
|
|
292
|
+
return basename.replace(/[/:]/g, '-');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function dedupeAttachmentFileName(name, usedNames) {
|
|
296
|
+
let candidate = name || 'attachment';
|
|
297
|
+
if (!usedNames.has(candidate)) {
|
|
298
|
+
usedNames.add(candidate);
|
|
299
|
+
return candidate;
|
|
300
|
+
}
|
|
301
|
+
const parsed = path.parse(candidate);
|
|
302
|
+
let index = 2;
|
|
303
|
+
do {
|
|
304
|
+
candidate = `${parsed.name}-${index}${parsed.ext}`;
|
|
305
|
+
index += 1;
|
|
306
|
+
} while (usedNames.has(candidate));
|
|
307
|
+
usedNames.add(candidate);
|
|
308
|
+
return candidate;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeFocusFiles(value) {
|
|
312
|
+
const seen = new Set();
|
|
313
|
+
const files = [];
|
|
314
|
+
for (const item of Array.isArray(value) ? value : []) {
|
|
315
|
+
const filePath = normalizeProjectPath(item);
|
|
316
|
+
if (!filePath || seen.has(filePath)) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
seen.add(filePath);
|
|
320
|
+
files.push(filePath);
|
|
321
|
+
if (files.length >= 8) {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return files;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function filterSyncChangesForFocus({ changes = [], focusFiles = [], restrictToFocusFiles = false } = {}) {
|
|
329
|
+
if (!restrictToFocusFiles) {
|
|
330
|
+
return { changes, unsupportedChanges: [] };
|
|
331
|
+
}
|
|
332
|
+
const focusSet = new Set(normalizeFocusFiles(focusFiles));
|
|
333
|
+
if (!focusSet.size) {
|
|
334
|
+
return { changes, unsupportedChanges: [] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const accepted = [];
|
|
338
|
+
const rejected = [];
|
|
339
|
+
for (const change of changes || []) {
|
|
340
|
+
if (focusSet.has(normalizeProjectPath(change?.path))) {
|
|
341
|
+
accepted.push(change);
|
|
342
|
+
} else if (change?.path) {
|
|
343
|
+
rejected.push({
|
|
344
|
+
type: 'ignored-local-change',
|
|
345
|
+
path: change.path,
|
|
346
|
+
reason: 'out_of_focus_partial_snapshot'
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
changes: accepted,
|
|
352
|
+
unsupportedChanges: rejected
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function normalizeProjectPath(value) {
|
|
357
|
+
return String(value || '')
|
|
358
|
+
.replace(/^@file:/i, '')
|
|
359
|
+
.replace(/\\/g, '/')
|
|
360
|
+
.trim()
|
|
361
|
+
.replace(/^\/+/, '');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function loadProjectLocalSkillsContext(params = {}, mirror = {}) {
|
|
365
|
+
const selectedSkillIds = Array.isArray(params.selectedSkillIds) ? params.selectedSkillIds : [];
|
|
366
|
+
if (!selectedSkillIds.length) {
|
|
367
|
+
return { skills: [], missing: [], selected: [] };
|
|
368
|
+
}
|
|
369
|
+
const projectId = mirror.projectKey || params.projectId || params.project?.id || params.project?.projectId;
|
|
370
|
+
return loadSelectedProjectSkills({
|
|
371
|
+
projectId,
|
|
372
|
+
selectedSkillIds,
|
|
373
|
+
rootDir: params.rootDir,
|
|
374
|
+
projectRoot: mirror.projectRoot
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function normalizeSkillLoadingSettings(params = {}) {
|
|
379
|
+
return {
|
|
380
|
+
loadCodexLocalSkills: params.loadCodexLocalSkills !== false,
|
|
381
|
+
loadCodexOverleafSkills: params.loadCodexOverleafSkills !== false
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function loadCodexSkillInvocationContext({
|
|
386
|
+
skillInvocation,
|
|
387
|
+
loadCodexOverleafSkills = true,
|
|
388
|
+
env = process.env,
|
|
389
|
+
emit = () => {}
|
|
390
|
+
} = {}) {
|
|
391
|
+
const invocation = normalizeSkillInvocation(skillInvocation);
|
|
392
|
+
if (!invocation) {
|
|
393
|
+
return { invocation: null, skill: null, missing: [], ignored: [] };
|
|
394
|
+
}
|
|
395
|
+
if (isSkillInstallerInvocation(invocation)) {
|
|
396
|
+
return { invocation, skill: null, missing: [], ignored: [] };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const result = loadSelectedCodexOverleafSkill({
|
|
400
|
+
skillId: invocation.id,
|
|
401
|
+
loadCodexOverleafSkills,
|
|
402
|
+
env
|
|
403
|
+
});
|
|
404
|
+
if (result.missing.length) {
|
|
405
|
+
emitCodexEvent(emit, 'codex.overleaf_skills.missing', 'Selected Codex Overleaf skill was missing', {
|
|
406
|
+
missingSkillIds: result.missing
|
|
407
|
+
}, 'failed');
|
|
408
|
+
}
|
|
409
|
+
if (result.ignored.length) {
|
|
410
|
+
emitCodexEvent(emit, 'codex.overleaf_skill_invocation.ignored', 'Selected Codex Overleaf skill was ignored', {
|
|
411
|
+
ignoredSkillIds: result.ignored.map(item => item.id),
|
|
412
|
+
reason: result.ignored[0]?.reason || 'ignored'
|
|
413
|
+
}, 'warning');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
invocation,
|
|
418
|
+
skill: result.skill,
|
|
419
|
+
missing: result.missing,
|
|
420
|
+
ignored: result.ignored
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function getEffectiveSkillInvocation(context = {}) {
|
|
425
|
+
const invocation = normalizeSkillInvocation(context.invocation);
|
|
426
|
+
if (!invocation) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
if (isSkillInstallerInvocation(invocation)) {
|
|
430
|
+
return invocation;
|
|
431
|
+
}
|
|
432
|
+
return context.skill ? invocation : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function normalizeSkillInvocation(value) {
|
|
436
|
+
const id = String(value?.id || '').trim();
|
|
437
|
+
if (!isSafeSkillId(id)) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
const title = String(value?.title || 'Skill Installer').trim().slice(0, 80) || 'Skill Installer';
|
|
441
|
+
if (id === 'skill-installer') {
|
|
442
|
+
return { id, title };
|
|
443
|
+
}
|
|
444
|
+
if (value?.scope !== 'codex-overleaf') {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
return { id, title, scope: 'codex-overleaf' };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isSkillInstallerInvocation(value) {
|
|
451
|
+
return normalizeSkillInvocation(value)?.id === 'skill-installer';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isSafeSkillId(id) {
|
|
455
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(String(id || ''))
|
|
456
|
+
&& !String(id || '').includes('..');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function buildCodexSettings(params = {}) {
|
|
460
|
+
if (isSkillInstallerInvocation(params.skillInvocation)) {
|
|
461
|
+
return {
|
|
462
|
+
sandboxMode: 'workspace-write',
|
|
463
|
+
approvalPolicy: 'never'
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (params.mode === 'ask') {
|
|
467
|
+
return {
|
|
468
|
+
sandboxMode: 'read-only',
|
|
469
|
+
approvalPolicy: 'never'
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
sandboxMode: 'workspace-write',
|
|
474
|
+
approvalPolicy: 'never'
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function buildThreadStartParams(input = {}) {
|
|
479
|
+
return {
|
|
480
|
+
cwd: input.workspacePath,
|
|
481
|
+
model: input.model || null,
|
|
482
|
+
approvalPolicy: input.approvalPolicy,
|
|
483
|
+
sandbox: input.sandboxMode,
|
|
484
|
+
experimentalRawEvents: false
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function buildThreadResumeParams(input = {}) {
|
|
489
|
+
return {
|
|
490
|
+
threadId: input.threadId,
|
|
491
|
+
cwd: input.workspacePath,
|
|
492
|
+
model: input.model || null,
|
|
493
|
+
approvalPolicy: input.approvalPolicy,
|
|
494
|
+
sandbox: input.sandboxMode
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function buildCodexAppServerArgs(input = {}) {
|
|
499
|
+
const args = [
|
|
500
|
+
...buildCodexSpeedArgs(normalizeSpeedTier(input.speedTier))
|
|
501
|
+
];
|
|
502
|
+
if (input.loadCodexLocalSkills === false) {
|
|
503
|
+
args.push('--disable', 'plugins');
|
|
504
|
+
}
|
|
505
|
+
args.push(
|
|
506
|
+
'app-server',
|
|
507
|
+
'--listen',
|
|
508
|
+
'stdio://'
|
|
509
|
+
);
|
|
510
|
+
return [
|
|
511
|
+
...args
|
|
512
|
+
];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function applyCodexSkillIsolation({ input = {}, childEnv = process.env, request, emit = () => {} } = {}) {
|
|
516
|
+
if (input.loadCodexLocalSkills !== false) {
|
|
517
|
+
return { disabled: [] };
|
|
518
|
+
}
|
|
519
|
+
if (typeof request !== 'function') {
|
|
520
|
+
throw new Error('Codex skill isolation requires an app-server request function');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const listResult = await request('skills/list', {
|
|
524
|
+
cwd: input.workspacePath,
|
|
525
|
+
includeDisabled: true
|
|
526
|
+
});
|
|
527
|
+
const skills = flattenCodexSkillsList(listResult);
|
|
528
|
+
const disabled = [];
|
|
529
|
+
for (const skill of skills) {
|
|
530
|
+
if (skill?.enabled === false || !shouldDisableCodexSkillForIsolation(skill, input, childEnv)) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const params = buildSkillDisableParams(skill);
|
|
534
|
+
if (!params) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
await request('skills/config/write', params);
|
|
538
|
+
disabled.push(String(skill.name || skill.path || '').trim());
|
|
539
|
+
}
|
|
540
|
+
if (disabled.length) {
|
|
541
|
+
emitCodexEvent(emit, 'codex.skill_isolation.applied', 'Disabled non-Overleaf Codex skills for this turn', {
|
|
542
|
+
disabledSkillNames: disabled.filter(Boolean)
|
|
543
|
+
}, 'completed');
|
|
544
|
+
}
|
|
545
|
+
return { disabled };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function flattenCodexSkillsList(listResult = {}) {
|
|
549
|
+
const data = Array.isArray(listResult?.data) ? listResult.data : [];
|
|
550
|
+
return data.flatMap(entry => Array.isArray(entry?.skills) ? entry.skills : []);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function shouldDisableCodexSkillForIsolation(skill = {}, input = {}, childEnv = process.env) {
|
|
554
|
+
if (isCodexSystemSkill(skill)) {
|
|
555
|
+
return !isAllowedSystemSkillForIsolation(skill, input);
|
|
556
|
+
}
|
|
557
|
+
return !isAllowedCodexOverleafSkillPath(skill.path, input, childEnv);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function isCodexSystemSkill(skill = {}) {
|
|
561
|
+
return String(skill.scope || '') === 'system' || isSystemSkillPath(skill.path);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function isAllowedSystemSkillForIsolation(skill = {}, input = {}) {
|
|
565
|
+
return input.installCodexOverleafSkillsTarget === true && String(skill.name || '') === 'skill-installer';
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function isAllowedCodexOverleafSkillPath(skillPath, input = {}, childEnv = process.env) {
|
|
569
|
+
if (input.loadCodexOverleafSkills === false && input.installCodexOverleafSkillsTarget !== true) {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
const pathText = String(skillPath || '');
|
|
573
|
+
if (!pathText || !path.isAbsolute(pathText) || isSystemSkillPath(pathText)) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
const roots = [
|
|
577
|
+
path.join(String(childEnv.CODEX_HOME || ''), 'skills'),
|
|
578
|
+
getCodexOverleafSkillsRoot({ env: childEnv })
|
|
579
|
+
].filter(Boolean);
|
|
580
|
+
return roots.some(root => isInsideOrSamePath(pathText, root));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function isSystemSkillPath(skillPath) {
|
|
584
|
+
return String(skillPath || '').split(path.sep).includes('.system');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function buildSkillDisableParams(skill = {}) {
|
|
588
|
+
const name = String(skill.name || '').trim();
|
|
589
|
+
if (isCodexSystemSkill(skill) && name) {
|
|
590
|
+
return { name, enabled: false };
|
|
591
|
+
}
|
|
592
|
+
const skillPath = String(skill.path || '').trim();
|
|
593
|
+
if (path.isAbsolute(skillPath)) {
|
|
594
|
+
return { path: skillPath, enabled: false };
|
|
595
|
+
}
|
|
596
|
+
if (name) {
|
|
597
|
+
return { name, enabled: false };
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function isInsideOrSamePath(target, root) {
|
|
603
|
+
const targetPaths = comparablePaths(target);
|
|
604
|
+
const rootPaths = comparablePaths(root);
|
|
605
|
+
return targetPaths.some(targetPath => rootPaths.some(rootPath => (
|
|
606
|
+
targetPath === rootPath || targetPath.startsWith(rootPath + path.sep)
|
|
607
|
+
)));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function comparablePaths(value) {
|
|
611
|
+
const resolved = path.resolve(String(value || ''));
|
|
612
|
+
const candidates = [resolved];
|
|
613
|
+
try {
|
|
614
|
+
candidates.push(fs.realpathSync.native(resolved));
|
|
615
|
+
} catch (_) {
|
|
616
|
+
// Fall back to the lexical path when the file is not present yet.
|
|
617
|
+
}
|
|
618
|
+
return Array.from(new Set(candidates));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function buildTurnStartParams(input = {}, threadId = input.threadId || '') {
|
|
622
|
+
const params = {
|
|
623
|
+
threadId,
|
|
624
|
+
input: [
|
|
625
|
+
{
|
|
626
|
+
type: 'text',
|
|
627
|
+
text: input.task,
|
|
628
|
+
text_elements: []
|
|
629
|
+
}
|
|
630
|
+
],
|
|
631
|
+
cwd: input.workspacePath,
|
|
632
|
+
model: input.model || null,
|
|
633
|
+
effort: normalizeReasoningEffort(input.reasoningEffort)
|
|
634
|
+
};
|
|
635
|
+
if (supportsReasoningSummary(input.model)) {
|
|
636
|
+
params.summary = 'detailed';
|
|
637
|
+
}
|
|
638
|
+
return params;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function supportsReasoningSummary(model) {
|
|
642
|
+
return String(model || '').toLowerCase() !== 'gpt-5.3-codex-spark';
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function runCodexAppServerSession(input) {
|
|
646
|
+
return new Promise((resolve, reject) => {
|
|
647
|
+
if (input.signal?.aborted) {
|
|
648
|
+
reject(getAbortReason(input.signal));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const childEnv = buildCodexHomeEnv(input.env || process.env, {
|
|
652
|
+
loadCodexLocalSkills: input.loadCodexLocalSkills !== false,
|
|
653
|
+
loadCodexOverleafSkills: input.loadCodexOverleafSkills !== false,
|
|
654
|
+
installCodexOverleafSkillsTarget: input.installCodexOverleafSkillsTarget === true,
|
|
655
|
+
projectLocalSkills: input.projectLocalSkills || null
|
|
656
|
+
});
|
|
657
|
+
const codexCommand = resolveCodexCommand(childEnv);
|
|
658
|
+
if (!codexCommand) {
|
|
659
|
+
reject(new Error('Codex CLI was not found. Install Codex or make sure the `codex` command is available in your login shell.'));
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const child = spawn(codexCommand, buildCodexAppServerArgs(input), {
|
|
664
|
+
env: childEnv,
|
|
665
|
+
shell: shouldUseShellForCommand(codexCommand, childEnv),
|
|
666
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
667
|
+
});
|
|
668
|
+
const pending = new Map();
|
|
669
|
+
let nextId = 1;
|
|
670
|
+
let stdoutBuffer = '';
|
|
671
|
+
let stderr = '';
|
|
672
|
+
let activeThreadId = '';
|
|
673
|
+
let activeTurnId = '';
|
|
674
|
+
const assistantMessages = new Map();
|
|
675
|
+
const assistantMessageOrder = [];
|
|
676
|
+
let settled = false;
|
|
677
|
+
const timeout = createOptionalTimeout(childEnv.CODEX_OVERLEAF_CODEX_TIMEOUT_MS, timeoutMs => {
|
|
678
|
+
fail(new Error(`Codex app-server did not complete within configured timeout (${timeoutMs}ms)`));
|
|
679
|
+
});
|
|
680
|
+
const onAbort = () => {
|
|
681
|
+
fail(getAbortReason(input.signal));
|
|
682
|
+
};
|
|
683
|
+
input.signal?.addEventListener('abort', onAbort, { once: true });
|
|
684
|
+
|
|
685
|
+
child.stdout.setEncoding('utf8');
|
|
686
|
+
child.stderr.setEncoding('utf8');
|
|
687
|
+
child.stdout.on('data', chunk => {
|
|
688
|
+
stdoutBuffer += chunk;
|
|
689
|
+
const lines = stdoutBuffer.split(/\r?\n/);
|
|
690
|
+
stdoutBuffer = lines.pop() || '';
|
|
691
|
+
for (const line of lines) {
|
|
692
|
+
if (line.trim()) {
|
|
693
|
+
handleMessage(line);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
child.stderr.on('data', chunk => {
|
|
698
|
+
stderr += chunk;
|
|
699
|
+
});
|
|
700
|
+
child.on('error', fail);
|
|
701
|
+
child.on('close', code => {
|
|
702
|
+
if (settled) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
fail(new Error(stderr || `codex app-server exited before turn completed with code ${code}`));
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
start().catch(fail);
|
|
709
|
+
|
|
710
|
+
async function start() {
|
|
711
|
+
await request('initialize', {
|
|
712
|
+
clientInfo: {
|
|
713
|
+
name: 'codex-overleaf-link',
|
|
714
|
+
version: '0.1.0'
|
|
715
|
+
},
|
|
716
|
+
capabilities: null
|
|
717
|
+
});
|
|
718
|
+
notify('initialized');
|
|
719
|
+
await applyCodexSkillIsolation({
|
|
720
|
+
input,
|
|
721
|
+
childEnv,
|
|
722
|
+
request,
|
|
723
|
+
emit: input.emit
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
if (input.threadId) {
|
|
727
|
+
try {
|
|
728
|
+
const resumeResponse = await request('thread/resume', buildThreadResumeParams(input));
|
|
729
|
+
activeThreadId = resumeResponse?.thread?.id || resumeResponse?.threadId || input.threadId;
|
|
730
|
+
} catch (resumeError) {
|
|
731
|
+
const error = new Error(resumeError.message || 'thread/resume failed');
|
|
732
|
+
error.code = 'thread_resume_failed';
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
const threadResponse = await request('thread/start', buildThreadStartParams(input));
|
|
737
|
+
activeThreadId = threadResponse?.thread?.id || threadResponse?.threadId || '';
|
|
738
|
+
if (!activeThreadId) {
|
|
739
|
+
throw new Error('Codex app-server did not return a thread id');
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const turnResponse = await startTurnWithSummaryFallback(activeThreadId);
|
|
744
|
+
activeTurnId = turnResponse?.turn?.id || '';
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function startTurnWithSummaryFallback(threadId) {
|
|
748
|
+
const params = buildTurnStartParams(input, threadId);
|
|
749
|
+
try {
|
|
750
|
+
return await request('turn/start', params);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (!params.summary || !isUnsupportedReasoningSummaryError(error)) {
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
755
|
+
emitCodexEvent(input.emit, 'codex.session.event', 'reasoning summary unsupported; retrying without it', {
|
|
756
|
+
method: 'turn/start',
|
|
757
|
+
params: {
|
|
758
|
+
model: input.model || '',
|
|
759
|
+
retriedWithoutSummary: true
|
|
760
|
+
}
|
|
761
|
+
}, 'completed');
|
|
762
|
+
const retryParams = { ...params };
|
|
763
|
+
delete retryParams.summary;
|
|
764
|
+
return request('turn/start', retryParams);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function request(method, params) {
|
|
769
|
+
const id = nextId++;
|
|
770
|
+
const message = { id, method, params };
|
|
771
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
772
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
773
|
+
pending.set(id, {
|
|
774
|
+
resolve: resolveRequest,
|
|
775
|
+
reject: rejectRequest
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function notify(method, params) {
|
|
781
|
+
child.stdin.write(`${JSON.stringify({ method, params })}\n`);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function response(id, result) {
|
|
785
|
+
child.stdin.write(`${JSON.stringify({ id, result })}\n`);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function handleMessage(line) {
|
|
789
|
+
let message;
|
|
790
|
+
try {
|
|
791
|
+
message = JSON.parse(line);
|
|
792
|
+
} catch {
|
|
793
|
+
emitCodexEvent(input.emit, 'codex.session.raw', 'Codex app-server emitted non-JSON output', {
|
|
794
|
+
text: truncateText(line, 1000)
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (Object.prototype.hasOwnProperty.call(message, 'id') &&
|
|
800
|
+
(Object.prototype.hasOwnProperty.call(message, 'result') || message.error)) {
|
|
801
|
+
const pendingRequest = pending.get(message.id);
|
|
802
|
+
if (!pendingRequest) {
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
pending.delete(message.id);
|
|
806
|
+
if (message.error) {
|
|
807
|
+
pendingRequest.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
|
808
|
+
} else {
|
|
809
|
+
pendingRequest.resolve(message.result);
|
|
810
|
+
}
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (Object.prototype.hasOwnProperty.call(message, 'id') && message.method) {
|
|
815
|
+
handleServerRequest(message);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (message.method) {
|
|
820
|
+
recordAssistantMessage(message);
|
|
821
|
+
emitCodexEvent(input.emit, 'codex.session.event', message.method, {
|
|
822
|
+
method: message.method,
|
|
823
|
+
params: message.params || {}
|
|
824
|
+
}, inferNotificationStatus(message));
|
|
825
|
+
if (message.method === 'turn/completed' && (!activeTurnId || message.params?.turn?.id === activeTurnId || message.params?.turnId === activeTurnId)) {
|
|
826
|
+
succeed();
|
|
827
|
+
}
|
|
828
|
+
if (message.method === 'error') {
|
|
829
|
+
fail(new Error(message.params?.error?.message || 'Codex turn failed'));
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function handleServerRequest(message) {
|
|
835
|
+
emitCodexEvent(input.emit, 'codex.session.request', message.method, {
|
|
836
|
+
method: message.method,
|
|
837
|
+
params: message.params || {}
|
|
838
|
+
}, 'running');
|
|
839
|
+
|
|
840
|
+
if (/fileChange\/requestApproval/.test(message.method)) {
|
|
841
|
+
if (isSkillInstallerInvocation(input.skillInvocation)) {
|
|
842
|
+
response(message.id, { decision: 'decline', reason: 'Skill installation must not edit Overleaf workspace files.' });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
response(message.id, { decision: input.mode === 'ask' ? 'decline' : 'accept' });
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (/commandExecution\/requestApproval/.test(message.method)) {
|
|
849
|
+
response(message.id, decideCommandApproval({
|
|
850
|
+
mode: input.mode,
|
|
851
|
+
skillInvocation: input.skillInvocation,
|
|
852
|
+
env: childEnv,
|
|
853
|
+
workspacePath: input.workspacePath,
|
|
854
|
+
params: message.params || {}
|
|
855
|
+
}));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
response(message.id, { decision: 'decline' });
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function recordAssistantMessage(message) {
|
|
862
|
+
const method = String(message.method || '');
|
|
863
|
+
const params = message.params || {};
|
|
864
|
+
const item = params.item || {};
|
|
865
|
+
if (method === 'item/agentMessage/delta') {
|
|
866
|
+
const itemId = String(params.itemId || item.id || 'current');
|
|
867
|
+
const next = `${assistantMessages.get(itemId) || ''}${String(params.delta || '')}`;
|
|
868
|
+
setAssistantMessage(itemId, next);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (item.type === 'agentMessage' && typeof item.text === 'string' && item.text.trim()) {
|
|
872
|
+
const itemId = String(item.id || params.itemId || 'current');
|
|
873
|
+
setAssistantMessage(itemId, item.text);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function setAssistantMessage(itemId, text) {
|
|
878
|
+
if (!assistantMessages.has(itemId)) {
|
|
879
|
+
assistantMessageOrder.push(itemId);
|
|
880
|
+
}
|
|
881
|
+
assistantMessages.set(itemId, text);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function succeed() {
|
|
885
|
+
if (settled) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
settled = true;
|
|
889
|
+
cleanup();
|
|
890
|
+
child.kill('SIGTERM');
|
|
891
|
+
resolve({
|
|
892
|
+
assistantMessage: buildFinalAssistantMessage(assistantMessages, assistantMessageOrder),
|
|
893
|
+
threadId: activeThreadId
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function fail(error) {
|
|
898
|
+
if (settled) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
settled = true;
|
|
902
|
+
cleanup();
|
|
903
|
+
for (const pendingRequest of pending.values()) {
|
|
904
|
+
pendingRequest.reject(error);
|
|
905
|
+
}
|
|
906
|
+
pending.clear();
|
|
907
|
+
if (child.exitCode === null && !child.killed) {
|
|
908
|
+
child.kill('SIGTERM');
|
|
909
|
+
}
|
|
910
|
+
reject(error);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function cleanup() {
|
|
914
|
+
timeout.cancel();
|
|
915
|
+
input.signal?.removeEventListener('abort', onAbort);
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function decideCommandApproval({ mode = 'auto', params = {}, skillInvocation = null, env = process.env, workspacePath = '' } = {}) {
|
|
921
|
+
if (isSkillInstallerInvocation(skillInvocation)) {
|
|
922
|
+
return decideSkillInstallerCommandApproval({ params, env, workspacePath });
|
|
923
|
+
}
|
|
924
|
+
if (mode === 'ask') {
|
|
925
|
+
return { decision: 'decline' };
|
|
926
|
+
}
|
|
927
|
+
return isAllowedLocalCommand(params)
|
|
928
|
+
? { decision: 'accept' }
|
|
929
|
+
: {
|
|
930
|
+
decision: 'decline',
|
|
931
|
+
reason: 'Command is outside the Codex Overleaf local inspection/LaTeX allowlist.'
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function decideSkillInstallerCommandApproval({ params = {}, env = process.env, workspacePath = '' } = {}) {
|
|
936
|
+
const command = extractCommandValue(params);
|
|
937
|
+
const approval = evaluateSkillCommand({
|
|
938
|
+
command,
|
|
939
|
+
cwd: workspacePath
|
|
940
|
+
}, {
|
|
941
|
+
env,
|
|
942
|
+
workspacePath,
|
|
943
|
+
skillsRoot: getCodexOverleafSkillsRoot({ env })
|
|
944
|
+
});
|
|
945
|
+
return approval.approved
|
|
946
|
+
? { decision: 'accept' }
|
|
947
|
+
: {
|
|
948
|
+
decision: 'decline',
|
|
949
|
+
reason: approval.reason
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function isAllowedLocalCommand(params = {}) {
|
|
954
|
+
const command = extractCommandValue(params);
|
|
955
|
+
if (typeof command === 'string' && hasUnsupportedShellSyntax(command)) {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
const tokens = Array.isArray(command) ? command.map(String) : tokenizeShellCommand(String(command || ''));
|
|
959
|
+
if (!tokens.length) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const executable = pathBasename(tokens[0]);
|
|
964
|
+
if (['bash', 'sh', 'zsh'].includes(executable)) {
|
|
965
|
+
const inline = extractShellInlineCommand(tokens);
|
|
966
|
+
return inline ? isAllowedLocalCommand({ command: inline }) : false;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const allowed = new Set([
|
|
970
|
+
'rg', 'grep', 'cat', 'sed', 'head', 'tail', 'nl', 'find', 'ls',
|
|
971
|
+
'wc', 'diff', 'sort', 'tr', 'awk', 'printf', 'cut', 'uniq',
|
|
972
|
+
'stat', 'file', 'basename', 'dirname', 'realpath',
|
|
973
|
+
'shasum', 'md5', 'md5sum',
|
|
974
|
+
'latexmk', 'pdflatex', 'xelatex', 'lualatex', 'bibtex', 'biber',
|
|
975
|
+
'kpsewhich', 'chktex', 'lacheck'
|
|
976
|
+
]);
|
|
977
|
+
if (!allowed.has(executable)) {
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return !tokens.some(isUnsafeShellToken)
|
|
982
|
+
&& !hasDisallowedCommandArguments(executable, tokens.slice(1));
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function extractCommandValue(params = {}) {
|
|
986
|
+
if (Array.isArray(params.command) || typeof params.command === 'string') {
|
|
987
|
+
return params.command;
|
|
988
|
+
}
|
|
989
|
+
if (Array.isArray(params.cmd) || typeof params.cmd === 'string') {
|
|
990
|
+
return params.cmd;
|
|
991
|
+
}
|
|
992
|
+
if (Array.isArray(params.argv)) {
|
|
993
|
+
return params.argv;
|
|
994
|
+
}
|
|
995
|
+
if (typeof params.shellCommand === 'string') {
|
|
996
|
+
return params.shellCommand;
|
|
997
|
+
}
|
|
998
|
+
return '';
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function extractShellInlineCommand(tokens = []) {
|
|
1002
|
+
const index = tokens.findIndex(token => token === '-c' || token === '-lc' || token === '-ilc');
|
|
1003
|
+
if (index < 0 || index + 1 >= tokens.length || tokens.length !== index + 2) {
|
|
1004
|
+
return '';
|
|
1005
|
+
}
|
|
1006
|
+
return tokens[index + 1];
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function hasUnsupportedShellSyntax(command) {
|
|
1010
|
+
return hasAmbiguousShellEscape(command) || hasUnbalancedShellQuote(command);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function hasAmbiguousShellEscape(command) {
|
|
1014
|
+
return /\\["';&|<>`$(){}\n\r]/.test(command);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function hasUnbalancedShellQuote(command) {
|
|
1018
|
+
let quote = '';
|
|
1019
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
1020
|
+
const char = command[index];
|
|
1021
|
+
if (char === '\\' && quote !== "'") {
|
|
1022
|
+
index += 1;
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (quote) {
|
|
1026
|
+
if (char === quote) {
|
|
1027
|
+
quote = '';
|
|
1028
|
+
}
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
if (char === '"' || char === "'") {
|
|
1032
|
+
quote = char;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return Boolean(quote);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function isUnsafeShellToken(token) {
|
|
1039
|
+
return ['&&', '||', ';', '|', '>', '>>', '<', '<<', '`'].includes(token)
|
|
1040
|
+
|| /\$\(/.test(token);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function hasDisallowedCommandArguments(executable, args = []) {
|
|
1044
|
+
const flags = args.map(String);
|
|
1045
|
+
if (executable === 'find') {
|
|
1046
|
+
return flags.some(flag => ['-exec', '-execdir', '-delete', '-ok', '-okdir'].includes(flag));
|
|
1047
|
+
}
|
|
1048
|
+
if (executable === 'sed') {
|
|
1049
|
+
return flags.some(flag => flag === '-i' || /^-i[^a-zA-Z0-9]?/.test(flag));
|
|
1050
|
+
}
|
|
1051
|
+
if (executable === 'awk') {
|
|
1052
|
+
return flags.some((flag, index) => flag === '-i' && flags[index + 1] === 'inplace');
|
|
1053
|
+
}
|
|
1054
|
+
if (executable === 'shasum' || executable === 'md5sum') {
|
|
1055
|
+
return flags.some(flag => flag === '-c' || flag === '--check');
|
|
1056
|
+
}
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function pathBasename(value) {
|
|
1061
|
+
return String(value || '').split(/[\\/]/).pop();
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function tokenizeShellCommand(command) {
|
|
1065
|
+
const tokens = [];
|
|
1066
|
+
let current = '';
|
|
1067
|
+
let quote = '';
|
|
1068
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
1069
|
+
const char = command[index];
|
|
1070
|
+
if (quote) {
|
|
1071
|
+
if (char === quote) {
|
|
1072
|
+
quote = '';
|
|
1073
|
+
} else {
|
|
1074
|
+
current += char;
|
|
1075
|
+
}
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
if (char === '"' || char === "'") {
|
|
1079
|
+
quote = char;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (/\s/.test(char)) {
|
|
1083
|
+
if (current) {
|
|
1084
|
+
tokens.push(current);
|
|
1085
|
+
current = '';
|
|
1086
|
+
}
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
if (char === '&' && command[index + 1] === '&') {
|
|
1090
|
+
if (current) tokens.push(current);
|
|
1091
|
+
tokens.push('&&');
|
|
1092
|
+
current = '';
|
|
1093
|
+
index += 1;
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (char === '|' && command[index + 1] === '|') {
|
|
1097
|
+
if (current) tokens.push(current);
|
|
1098
|
+
tokens.push('||');
|
|
1099
|
+
current = '';
|
|
1100
|
+
index += 1;
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
if (';|<>`'.includes(char)) {
|
|
1104
|
+
if (current) tokens.push(current);
|
|
1105
|
+
tokens.push(char);
|
|
1106
|
+
current = '';
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
current += char;
|
|
1110
|
+
}
|
|
1111
|
+
if (current) {
|
|
1112
|
+
tokens.push(current);
|
|
1113
|
+
}
|
|
1114
|
+
return tokens;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function createOptionalTimeout(value, onTimeout) {
|
|
1118
|
+
const timeoutMs = parseOptionalPositiveInteger(value);
|
|
1119
|
+
if (!timeoutMs) {
|
|
1120
|
+
return {
|
|
1121
|
+
cancel() {}
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
const timer = setTimeout(() => onTimeout(timeoutMs), timeoutMs);
|
|
1125
|
+
return {
|
|
1126
|
+
cancel() {
|
|
1127
|
+
clearTimeout(timer);
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function parseOptionalPositiveInteger(value) {
|
|
1133
|
+
if (value === undefined || value === null || value === '') {
|
|
1134
|
+
return 0;
|
|
1135
|
+
}
|
|
1136
|
+
const number = Number(value);
|
|
1137
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function throwIfAborted(signal) {
|
|
1141
|
+
if (!signal?.aborted) {
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
throw getAbortReason(signal);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function getAbortReason(signal) {
|
|
1148
|
+
if (signal?.reason instanceof Error) {
|
|
1149
|
+
return signal.reason;
|
|
1150
|
+
}
|
|
1151
|
+
const error = new Error('Codex run was cancelled by the user');
|
|
1152
|
+
error.code = 'codex_cancelled';
|
|
1153
|
+
return error;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function resolveCodexCommand(env = process.env) {
|
|
1157
|
+
if (
|
|
1158
|
+
env.CODEX_OVERLEAF_ENV_READY === '1' ||
|
|
1159
|
+
Object.prototype.hasOwnProperty.call(env, 'CODEX_OVERLEAF_CODEX_PATH')
|
|
1160
|
+
) {
|
|
1161
|
+
return env.CODEX_OVERLEAF_CODEX_PATH || '';
|
|
1162
|
+
}
|
|
1163
|
+
return 'codex';
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function shouldUseShellForCommand(command, env = process.env) {
|
|
1167
|
+
const platform = env.CODEX_OVERLEAF_PLATFORM || process.platform;
|
|
1168
|
+
if (platform !== 'win32') {
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
const text = String(command || '');
|
|
1172
|
+
return text === 'codex' || /\.(?:cmd|bat)$/i.test(text);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function buildFinalAssistantMessage(messages = new Map(), order = []) {
|
|
1176
|
+
const values = [];
|
|
1177
|
+
const seenIds = new Set();
|
|
1178
|
+
const ids = order.length ? order : Array.from(messages.keys());
|
|
1179
|
+
|
|
1180
|
+
for (const id of ids) {
|
|
1181
|
+
seenIds.add(id);
|
|
1182
|
+
addAssistantMessage(values, messages.get(id));
|
|
1183
|
+
}
|
|
1184
|
+
for (const [id, value] of messages) {
|
|
1185
|
+
if (!seenIds.has(id)) {
|
|
1186
|
+
addAssistantMessage(values, value);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
return values.join('\n\n');
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function addAssistantMessage(values, value) {
|
|
1194
|
+
const clean = cleanAssistantMessage(value);
|
|
1195
|
+
if (clean && !values.includes(clean)) {
|
|
1196
|
+
values.push(clean);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function emitCodexEvent(emit, type, title, detail = {}, status = 'running') {
|
|
1201
|
+
emit({
|
|
1202
|
+
type,
|
|
1203
|
+
title,
|
|
1204
|
+
status,
|
|
1205
|
+
detail,
|
|
1206
|
+
timestamp: new Date().toISOString()
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function inferNotificationStatus(message) {
|
|
1211
|
+
if (/completed|updated|delta|started/.test(message.method || '')) {
|
|
1212
|
+
return /completed/.test(message.method || '') ? 'completed' : 'running';
|
|
1213
|
+
}
|
|
1214
|
+
return 'running';
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function normalizeReasoningEffort(value) {
|
|
1218
|
+
return ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(value) ? value : null;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function normalizeSpeedTier(value) {
|
|
1222
|
+
return value === 'fast' ? 'fast' : 'standard';
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function isUnsupportedReasoningSummaryError(error) {
|
|
1226
|
+
const message = String(error?.message || error || '');
|
|
1227
|
+
return /unsupported_parameter/i.test(message) && /reasoning\.summary|summary/i.test(message);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function cleanAssistantMessage(value) {
|
|
1231
|
+
return String(value || '').trim();
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
module.exports = {
|
|
1235
|
+
applyCodexSkillIsolation,
|
|
1236
|
+
buildCodexTurnPrompt,
|
|
1237
|
+
buildCodexAppServerArgs,
|
|
1238
|
+
buildFinalAssistantMessage,
|
|
1239
|
+
buildCodexSettings,
|
|
1240
|
+
buildThreadStartParams,
|
|
1241
|
+
buildThreadResumeParams,
|
|
1242
|
+
buildTurnStartParams,
|
|
1243
|
+
createOptionalTimeout,
|
|
1244
|
+
decideCommandApproval,
|
|
1245
|
+
runCodexAppServerSession,
|
|
1246
|
+
runCodexSession
|
|
1247
|
+
};
|