@sciilo.ai/codex-sidecar 0.1.0
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 +202 -0
- package/NOTICE +2 -0
- package/README.md +332 -0
- package/bin/sciilo-sidecar.js +221 -0
- package/package.json +53 -0
- package/src/banner.js +63 -0
- package/src/bridge.js +844 -0
- package/src/codex-app-server.js +125 -0
- package/src/codex-cli.js +37 -0
- package/src/config.js +87 -0
- package/src/document-seal.js +195 -0
- package/src/vault.js +493 -0
package/src/bridge.js
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
import { basename } from 'node:path'
|
|
4
|
+
import WebSocket from 'ws'
|
|
5
|
+
import { CodexAppServer } from './codex-app-server.js'
|
|
6
|
+
import { createHandoffKeypair, openSealedDek } from './vault.js'
|
|
7
|
+
import { DEFAULT_REASONING_EFFORT } from './config.js'
|
|
8
|
+
import { openText, sealArguments } from './document-seal.js'
|
|
9
|
+
|
|
10
|
+
const RECONNECT_DELAYS = [500, 1_000, 2_000, 5_000, 10_000, 30_000]
|
|
11
|
+
const HANDSHAKE_TIMEOUT = 10_000
|
|
12
|
+
const HEARTBEAT_INTERVAL = 20_000
|
|
13
|
+
const HEARTBEAT_TIMEOUT = 8_000
|
|
14
|
+
export const PAIRING_REQUIRED_CLOSE_CODE = 4001
|
|
15
|
+
const APPROVAL_METHODS = new Set([
|
|
16
|
+
'item/commandExecution/requestApproval',
|
|
17
|
+
'item/fileChange/requestApproval',
|
|
18
|
+
'item/permissions/requestApproval',
|
|
19
|
+
'execCommandApproval',
|
|
20
|
+
'applyPatchApproval',
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
export class SidecarBridge extends EventEmitter {
|
|
24
|
+
constructor(config, {
|
|
25
|
+
WebSocketClass = WebSocket,
|
|
26
|
+
codex = new CodexAppServer({
|
|
27
|
+
command: config.codexCommand,
|
|
28
|
+
cwd: config.workspace,
|
|
29
|
+
}),
|
|
30
|
+
} = {}) {
|
|
31
|
+
super()
|
|
32
|
+
this.config = config
|
|
33
|
+
this.WebSocketClass = WebSocketClass
|
|
34
|
+
this.codex = codex
|
|
35
|
+
this.socket = null
|
|
36
|
+
this.tools = []
|
|
37
|
+
// Server-owned, received once per connection and reused for every thread
|
|
38
|
+
// this process starts. Never read from disk, never written to it.
|
|
39
|
+
this.baseInstructions = null
|
|
40
|
+
this.threads = new Map()
|
|
41
|
+
this.activeByThread = new Map()
|
|
42
|
+
this.pendingTools = new Map()
|
|
43
|
+
this.pendingApprovals = new Map()
|
|
44
|
+
this.pendingInputs = new Map()
|
|
45
|
+
this.reconnectAttempt = 0
|
|
46
|
+
this.reconnectTimer = null
|
|
47
|
+
this.handshakeTimer = null
|
|
48
|
+
this.heartbeat = null
|
|
49
|
+
this.heartbeatTimeout = null
|
|
50
|
+
this.stopping = false
|
|
51
|
+
this.pairingRequired = false
|
|
52
|
+
this.context = null
|
|
53
|
+
this.ready = false
|
|
54
|
+
this.instanceId = randomUUID()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async start() {
|
|
58
|
+
this.codex.on('notification', frame => this.onCodexNotification(frame))
|
|
59
|
+
this.codex.on('request', frame => this.onCodexRequest(frame))
|
|
60
|
+
this.codex.on('log', line => this.emit('log', line))
|
|
61
|
+
this.codex.on('exit', error => this.emit('error', error))
|
|
62
|
+
await this.codex.start()
|
|
63
|
+
// A throwaway key pair, forged at start-up and lost on exit. The private
|
|
64
|
+
// half is non-extractable: it cannot be serialised, therefore it cannot be
|
|
65
|
+
// written to a config file even by mistake. The browser seals the vault key
|
|
66
|
+
// for this public half alone; Sciilo relays a block it cannot open.
|
|
67
|
+
this.vault = await createHandoffKeypair()
|
|
68
|
+
this.vaultKey = null
|
|
69
|
+
this.connect()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop() {
|
|
73
|
+
this.stopping = true
|
|
74
|
+
clearTimeout(this.reconnectTimer)
|
|
75
|
+
this.clearHandshakeTimeout()
|
|
76
|
+
this.clearHeartbeat()
|
|
77
|
+
this.socket?.close()
|
|
78
|
+
this.socket = null
|
|
79
|
+
this.codex.stop()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
connect() {
|
|
83
|
+
if (this.stopping || this.pairingRequired) return
|
|
84
|
+
if (this.socket && [this.WebSocketClass.CONNECTING, this.WebSocketClass.OPEN]
|
|
85
|
+
.includes(this.socket.readyState)) return
|
|
86
|
+
const socket = new this.WebSocketClass(sidecarWebSocketUrl(this.config.appUrl), {
|
|
87
|
+
headers: { Authorization: `Bearer ${this.config.connectionKey}` },
|
|
88
|
+
handshakeTimeout: HANDSHAKE_TIMEOUT,
|
|
89
|
+
})
|
|
90
|
+
this.socket = socket
|
|
91
|
+
socket.on('open', () => {
|
|
92
|
+
if (this.socket !== socket) return
|
|
93
|
+
this.ready = false
|
|
94
|
+
this.startHandshakeTimeout(socket)
|
|
95
|
+
this.startHeartbeat(socket)
|
|
96
|
+
})
|
|
97
|
+
socket.on('message', data => {
|
|
98
|
+
if (this.socket !== socket) return
|
|
99
|
+
this.markSocketAlive(socket)
|
|
100
|
+
try {
|
|
101
|
+
this.onApplicationFrame(JSON.parse(data.toString()))
|
|
102
|
+
} catch (error) {
|
|
103
|
+
this.emit('error', new Error(`Invalid application frame: ${error.message}`))
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
socket.on('close', (code, reason) => {
|
|
107
|
+
if (this.socket !== socket) return
|
|
108
|
+
this.clearHandshakeTimeout()
|
|
109
|
+
this.clearHeartbeat()
|
|
110
|
+
this.socket = null
|
|
111
|
+
this.ready = false
|
|
112
|
+
if (code === PAIRING_REQUIRED_CLOSE_CODE) {
|
|
113
|
+
this.requirePairing(reason?.toString() || 'connection_key_invalid')
|
|
114
|
+
}
|
|
115
|
+
this.emit('disconnected', {
|
|
116
|
+
pairingRequired: this.pairingRequired,
|
|
117
|
+
reason: reason?.toString() || null,
|
|
118
|
+
})
|
|
119
|
+
if (!this.pairingRequired) this.scheduleReconnect()
|
|
120
|
+
})
|
|
121
|
+
socket.on('error', error => {
|
|
122
|
+
this.emit('error', error)
|
|
123
|
+
if (this.socket === socket && socket.readyState !== this.WebSocketClass.CLOSED) {
|
|
124
|
+
socket.terminate?.()
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
clearHeartbeat() {
|
|
130
|
+
clearInterval(this.heartbeat)
|
|
131
|
+
clearTimeout(this.heartbeatTimeout)
|
|
132
|
+
this.heartbeat = null
|
|
133
|
+
this.heartbeatTimeout = null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
clearHandshakeTimeout() {
|
|
137
|
+
clearTimeout(this.handshakeTimer)
|
|
138
|
+
this.handshakeTimer = null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
startHandshakeTimeout(socket) {
|
|
142
|
+
this.clearHandshakeTimeout()
|
|
143
|
+
this.handshakeTimer = setTimeout(() => {
|
|
144
|
+
this.handshakeTimer = null
|
|
145
|
+
if (this.socket === socket && !this.ready
|
|
146
|
+
&& socket.readyState === this.WebSocketClass.OPEN) {
|
|
147
|
+
socket.terminate?.()
|
|
148
|
+
}
|
|
149
|
+
}, HANDSHAKE_TIMEOUT)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
startHeartbeat(socket) {
|
|
153
|
+
this.clearHeartbeat()
|
|
154
|
+
this.heartbeat = setInterval(() => {
|
|
155
|
+
if (this.socket !== socket || socket.readyState !== this.WebSocketClass.OPEN) return
|
|
156
|
+
if (!this.send({ type: 'ping' })) return
|
|
157
|
+
clearTimeout(this.heartbeatTimeout)
|
|
158
|
+
this.heartbeatTimeout = setTimeout(() => {
|
|
159
|
+
this.heartbeatTimeout = null
|
|
160
|
+
if (this.socket === socket && socket.readyState === this.WebSocketClass.OPEN) {
|
|
161
|
+
socket.terminate?.()
|
|
162
|
+
}
|
|
163
|
+
}, HEARTBEAT_TIMEOUT)
|
|
164
|
+
}, HEARTBEAT_INTERVAL)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
markSocketAlive(socket) {
|
|
168
|
+
if (this.socket !== socket) return
|
|
169
|
+
clearTimeout(this.heartbeatTimeout)
|
|
170
|
+
this.heartbeatTimeout = null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
scheduleReconnect() {
|
|
174
|
+
if (this.stopping || this.pairingRequired || this.reconnectTimer) return
|
|
175
|
+
const index = Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)
|
|
176
|
+
this.reconnectAttempt += 1
|
|
177
|
+
this.reconnectTimer = setTimeout(() => {
|
|
178
|
+
this.reconnectTimer = null
|
|
179
|
+
this.connect()
|
|
180
|
+
}, RECONNECT_DELAYS[index])
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
send(frame) {
|
|
184
|
+
if (this.socket?.readyState === this.WebSocketClass.OPEN) {
|
|
185
|
+
try {
|
|
186
|
+
this.socket.send(JSON.stringify(frame))
|
|
187
|
+
return true
|
|
188
|
+
} catch (error) {
|
|
189
|
+
this.emit('error', error)
|
|
190
|
+
this.socket.terminate?.()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
requirePairing(reason = 'connection_key_invalid') {
|
|
197
|
+
if (this.pairingRequired) return
|
|
198
|
+
this.pairingRequired = true
|
|
199
|
+
this.ready = false
|
|
200
|
+
clearTimeout(this.reconnectTimer)
|
|
201
|
+
this.reconnectTimer = null
|
|
202
|
+
this.clearHandshakeTimeout()
|
|
203
|
+
this.clearHeartbeat()
|
|
204
|
+
this.emit('pairingRequired', { reason })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
updateConnectionKey(connectionKey) {
|
|
208
|
+
if (!connectionKey || typeof connectionKey !== 'string') {
|
|
209
|
+
throw new Error('The new connection key is missing.')
|
|
210
|
+
}
|
|
211
|
+
const previous = this.socket
|
|
212
|
+
this.socket = null
|
|
213
|
+
this.clearHandshakeTimeout()
|
|
214
|
+
this.clearHeartbeat()
|
|
215
|
+
previous?.close()
|
|
216
|
+
clearTimeout(this.reconnectTimer)
|
|
217
|
+
this.reconnectTimer = null
|
|
218
|
+
this.config = { ...this.config, connectionKey }
|
|
219
|
+
this.pairingRequired = false
|
|
220
|
+
this.ready = false
|
|
221
|
+
this.reconnectAttempt = 0
|
|
222
|
+
this.connect()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
onApplicationFrame(frame) {
|
|
226
|
+
switch (frame.type) {
|
|
227
|
+
case 'sidecar.pairing_required':
|
|
228
|
+
this.requirePairing(frame.reason || 'revoked')
|
|
229
|
+
break
|
|
230
|
+
case 'sidecar.welcome':
|
|
231
|
+
this.clearHandshakeTimeout()
|
|
232
|
+
this.reconnectAttempt = 0
|
|
233
|
+
this.tools = frame.tools || []
|
|
234
|
+
this.ready = true
|
|
235
|
+
this.emit('connected')
|
|
236
|
+
this.send({
|
|
237
|
+
type: 'sidecar.ready',
|
|
238
|
+
protocol: frame.protocol,
|
|
239
|
+
instanceId: this.instanceId,
|
|
240
|
+
codex: true,
|
|
241
|
+
toolCount: this.tools.length,
|
|
242
|
+
workspace: workspaceIdentity(this.config.workspace),
|
|
243
|
+
vaultPublicKey: this.vault?.publicKey ?? null,
|
|
244
|
+
})
|
|
245
|
+
break
|
|
246
|
+
case 'vault.key':
|
|
247
|
+
|
|
248
|
+
openSealedDek(this.vault.privateKey, frame.sealed)
|
|
249
|
+
.then(key => {
|
|
250
|
+
this.vaultKey = key
|
|
251
|
+
this.emit('vaultUnlocked')
|
|
252
|
+
})
|
|
253
|
+
.catch(() => {
|
|
254
|
+
// Sealed for another sidecar, or altered in transit. Working
|
|
255
|
+
// without it beats working with a key we cannot trust.
|
|
256
|
+
this.vaultKey = null
|
|
257
|
+
this.emit('vaultLocked')
|
|
258
|
+
})
|
|
259
|
+
break
|
|
260
|
+
case 'assistant.turn':
|
|
261
|
+
this.startTurn(frame).catch(error => this.send({
|
|
262
|
+
type: 'assistant.failed',
|
|
263
|
+
requestId: frame.requestId,
|
|
264
|
+
message: error.message,
|
|
265
|
+
}))
|
|
266
|
+
break
|
|
267
|
+
case 'assistant.interrupt':
|
|
268
|
+
this.interrupt(frame).catch(error => this.emit('error', error))
|
|
269
|
+
break
|
|
270
|
+
case 'context.update':
|
|
271
|
+
this.context = frame.context || null
|
|
272
|
+
break
|
|
273
|
+
case 'approval.resolve':
|
|
274
|
+
this.resolveApproval(frame)
|
|
275
|
+
break
|
|
276
|
+
case 'input.resolve':
|
|
277
|
+
this.resolveInput(frame)
|
|
278
|
+
break
|
|
279
|
+
case 'interaction.sync':
|
|
280
|
+
this.replayPendingInteractions()
|
|
281
|
+
break
|
|
282
|
+
case 'tool.result':
|
|
283
|
+
this.resolveTool(frame)
|
|
284
|
+
break
|
|
285
|
+
default:
|
|
286
|
+
break
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async startTurn(frame) {
|
|
291
|
+
if (!this.ready) {
|
|
292
|
+
throw new Error('The sidecar is still loading the application tools.')
|
|
293
|
+
}
|
|
294
|
+
// The server owns these instructions and now sends them ONCE per connection,
|
|
295
|
+
// not with every turn: they only ever configure a newly created thread, and
|
|
296
|
+
// resending thousands of words per message was both waste and one more
|
|
297
|
+
// chance to capture the prompt off a machine that is not ours. Keeping them
|
|
298
|
+
// here is what makes the second thread of a session as well-configured as
|
|
299
|
+
// the first. The cache dies with the process, exactly like the threads it
|
|
300
|
+
// configures — so the server, which tracks the same transport, sends again
|
|
301
|
+
// at the next connection without either side asking.
|
|
302
|
+
if (typeof frame.baseInstructions === 'string' && frame.baseInstructions.trim()) {
|
|
303
|
+
this.baseInstructions = frame.baseInstructions
|
|
304
|
+
}
|
|
305
|
+
const conversationId = frame.conversationId || randomUUID()
|
|
306
|
+
let threadId = this.threads.get(conversationId)
|
|
307
|
+
const startsNewThread = !threadId
|
|
308
|
+
if (!threadId) {
|
|
309
|
+
const threadOptions = {
|
|
310
|
+
cwd: this.config.workspace,
|
|
311
|
+
model: this.config.model,
|
|
312
|
+
modelProvider: this.config.modelProvider,
|
|
313
|
+
approvalPolicy: 'on-request',
|
|
314
|
+
sandbox: 'workspace-write',
|
|
315
|
+
dynamicTools: dynamicTools(this.tools),
|
|
316
|
+
// Codex settings travel in `config`, keyed as in config.toml — no
|
|
317
|
+
// top-level `reasoningEffort` parameter exists, and passing one is
|
|
318
|
+
// accepted in silence while the machine's own value keeps winning.
|
|
319
|
+
config: {
|
|
320
|
+
model_reasoning_effort:
|
|
321
|
+
this.config.reasoningEffort || DEFAULT_REASONING_EFFORT,
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
if (this.baseInstructions) {
|
|
325
|
+
threadOptions.baseInstructions = this.baseInstructions
|
|
326
|
+
}
|
|
327
|
+
const started = await this.codex.request('thread/start', threadOptions)
|
|
328
|
+
threadId = started.thread.id
|
|
329
|
+
this.threads.set(conversationId, threadId)
|
|
330
|
+
}
|
|
331
|
+
if (this.activeByThread.has(threadId)) {
|
|
332
|
+
throw new Error('A Codex turn is already running in this conversation.')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const state = {
|
|
336
|
+
requestId: frame.requestId,
|
|
337
|
+
conversationId,
|
|
338
|
+
threadId,
|
|
339
|
+
turnId: null,
|
|
340
|
+
text: '',
|
|
341
|
+
}
|
|
342
|
+
this.activeByThread.set(threadId, state)
|
|
343
|
+
const context = frame.context || this.context
|
|
344
|
+
const inputText = buildTurnInput(frame.message, {
|
|
345
|
+
context,
|
|
346
|
+
history: startsNewThread ? frame.history : null,
|
|
347
|
+
})
|
|
348
|
+
try {
|
|
349
|
+
const response = await this.codex.request('turn/start', {
|
|
350
|
+
threadId,
|
|
351
|
+
input: [{ type: 'text', text: inputText, text_elements: [] }],
|
|
352
|
+
})
|
|
353
|
+
state.turnId = response.turn.id
|
|
354
|
+
} catch (error) {
|
|
355
|
+
this.activeByThread.delete(threadId)
|
|
356
|
+
throw error
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async interrupt(frame) {
|
|
361
|
+
const threadId = this.threads.get(frame.conversationId)
|
|
362
|
+
const state = threadId ? this.activeByThread.get(threadId) : null
|
|
363
|
+
const turnId = frame.turnId || state?.turnId
|
|
364
|
+
if (threadId && turnId) {
|
|
365
|
+
await this.codex.request('turn/interrupt', { threadId, turnId })
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
onCodexNotification(frame) {
|
|
370
|
+
const params = frame.params || {}
|
|
371
|
+
const state = params.threadId ? this.activeByThread.get(params.threadId) : null
|
|
372
|
+
const visible = publicCodexNotification(frame)
|
|
373
|
+
if (visible) {
|
|
374
|
+
this.send({
|
|
375
|
+
type: 'codex.event',
|
|
376
|
+
requestId: state?.requestId,
|
|
377
|
+
conversationId: state?.conversationId,
|
|
378
|
+
method: visible.method,
|
|
379
|
+
params: visible.params,
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
if (frame.method === 'item/agentMessage/delta' && state) {
|
|
383
|
+
state.text += params.delta || ''
|
|
384
|
+
this.send({
|
|
385
|
+
type: 'assistant.delta',
|
|
386
|
+
requestId: state.requestId,
|
|
387
|
+
conversationId: state.conversationId,
|
|
388
|
+
turnId: params.turnId,
|
|
389
|
+
delta: params.delta || '',
|
|
390
|
+
fullText: state.text,
|
|
391
|
+
})
|
|
392
|
+
} else if (frame.method === 'item/completed' && state
|
|
393
|
+
&& params.item?.type === 'agentMessage' && params.item.text) {
|
|
394
|
+
state.text = params.item.text
|
|
395
|
+
} else if (frame.method === 'turn/completed' && state) {
|
|
396
|
+
const failed = params.turn?.status === 'failed'
|
|
397
|
+
this.send({
|
|
398
|
+
type: failed ? 'assistant.failed' : 'assistant.completed',
|
|
399
|
+
requestId: state.requestId,
|
|
400
|
+
conversationId: state.conversationId,
|
|
401
|
+
turnId: params.turn?.id,
|
|
402
|
+
text: state.text,
|
|
403
|
+
message: params.turn?.error?.message,
|
|
404
|
+
})
|
|
405
|
+
this.activeByThread.delete(params.threadId)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
onCodexRequest(frame) {
|
|
410
|
+
if (frame.method === 'currentTime/read') {
|
|
411
|
+
this.codex.respond(frame.id, {
|
|
412
|
+
currentTimeAt: Math.floor(Date.now() / 1_000),
|
|
413
|
+
})
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
if (frame.method === 'item/tool/call') {
|
|
417
|
+
this.dispatchToolCall(frame)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
if (frame.method === 'item/tool/requestUserInput') {
|
|
421
|
+
const externalId = randomUUID()
|
|
422
|
+
const state = this.activeStateFor(frame)
|
|
423
|
+
this.pendingInputs.set(externalId, {
|
|
424
|
+
codexId: frame.id,
|
|
425
|
+
turnRequestId: state?.requestId,
|
|
426
|
+
conversationId: state?.conversationId,
|
|
427
|
+
questions: frame.params?.questions || [],
|
|
428
|
+
})
|
|
429
|
+
if (!this.send(this.pendingInputFrame(externalId,
|
|
430
|
+
this.pendingInputs.get(externalId)))) {
|
|
431
|
+
this.pendingInputs.delete(externalId)
|
|
432
|
+
this.codex.respond(frame.id, { answers: {} })
|
|
433
|
+
}
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
if (APPROVAL_METHODS.has(frame.method)) {
|
|
437
|
+
const externalId = randomUUID()
|
|
438
|
+
const state = this.activeStateFor(frame)
|
|
439
|
+
this.pendingApprovals.set(externalId, {
|
|
440
|
+
codexId: frame.id,
|
|
441
|
+
method: frame.method,
|
|
442
|
+
params: frame.params,
|
|
443
|
+
turnRequestId: state?.requestId,
|
|
444
|
+
conversationId: state?.conversationId,
|
|
445
|
+
})
|
|
446
|
+
const sent = this.send(this.pendingApprovalFrame(externalId,
|
|
447
|
+
this.pendingApprovals.get(externalId)))
|
|
448
|
+
if (!sent) this.resolveApproval({ requestId: externalId, decision: 'decline' })
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
if (frame.method === 'mcpServer/elicitation/request') {
|
|
452
|
+
this.send({
|
|
453
|
+
type: 'error',
|
|
454
|
+
message: `MCP server ${frame.params?.serverName || ''} asked for an unsupported input.`,
|
|
455
|
+
})
|
|
456
|
+
this.codex.respond(frame.id, {
|
|
457
|
+
action: 'decline',
|
|
458
|
+
content: null,
|
|
459
|
+
_meta: null,
|
|
460
|
+
})
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
this.codex.respondError(frame.id, `Unsupported Codex request: ${frame.method}`, -32601)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Sends a tool call out, with its content sealed first.
|
|
468
|
+
*
|
|
469
|
+
* The sealed arguments are what gets remembered, not the originals: they are
|
|
470
|
+
* echoed back in `artifact.created`, and echoing the clear text there would
|
|
471
|
+
* hand the server exactly what the sealing just took away from it.
|
|
472
|
+
*
|
|
473
|
+
* Sealing failure does not cancel the call. Losing the user's work is the
|
|
474
|
+
* worse outcome, and the database guard reports anything readable that lands
|
|
475
|
+
* in storage — a silent skip here cannot pass for success there.
|
|
476
|
+
*/
|
|
477
|
+
async dispatchToolCall(frame) {
|
|
478
|
+
const externalId = randomUUID()
|
|
479
|
+
const state = this.activeStateFor(frame)
|
|
480
|
+
let args = frame.params.arguments || {}
|
|
481
|
+
try {
|
|
482
|
+
args = await sealArguments(this.vaultKey, frame.params.tool, args)
|
|
483
|
+
} catch (failure) {
|
|
484
|
+
this.emit('vaultSealFailed', { tool: frame.params.tool, reason: failure.message })
|
|
485
|
+
}
|
|
486
|
+
this.pendingTools.set(externalId, {
|
|
487
|
+
codexId: frame.id,
|
|
488
|
+
tool: frame.params.tool,
|
|
489
|
+
arguments: args,
|
|
490
|
+
turnRequestId: state?.requestId,
|
|
491
|
+
conversationId: state?.conversationId,
|
|
492
|
+
})
|
|
493
|
+
if (!this.send({
|
|
494
|
+
type: 'tool.call',
|
|
495
|
+
requestId: externalId,
|
|
496
|
+
tool: frame.params.tool,
|
|
497
|
+
arguments: args,
|
|
498
|
+
})) {
|
|
499
|
+
this.pendingTools.delete(externalId)
|
|
500
|
+
this.codex.respond(frame.id, {
|
|
501
|
+
contentItems: [{ type: 'inputText', text: 'The application is disconnected.' }],
|
|
502
|
+
success: false,
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
activeStateFor(frame) {
|
|
508
|
+
return frame.params?.threadId
|
|
509
|
+
? this.activeByThread.get(frame.params.threadId)
|
|
510
|
+
: this.activeByThread.size === 1
|
|
511
|
+
? this.activeByThread.values().next().value
|
|
512
|
+
: null
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
pendingApprovalFrame(requestId, pending) {
|
|
516
|
+
return {
|
|
517
|
+
type: 'approval.request',
|
|
518
|
+
requestId,
|
|
519
|
+
turnRequestId: pending?.turnRequestId,
|
|
520
|
+
conversationId: pending?.conversationId,
|
|
521
|
+
kind: approvalKind(pending?.method || ''),
|
|
522
|
+
title: approvalTitle(pending?.method || ''),
|
|
523
|
+
command: pending?.params?.command,
|
|
524
|
+
reason: pending?.params?.reason,
|
|
525
|
+
detail: pending?.params?.command || pending?.params?.reason,
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
pendingInputFrame(requestId, pending) {
|
|
530
|
+
return {
|
|
531
|
+
type: 'input.request',
|
|
532
|
+
requestId,
|
|
533
|
+
turnRequestId: pending?.turnRequestId,
|
|
534
|
+
conversationId: pending?.conversationId,
|
|
535
|
+
questions: pending?.questions || [],
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
replayPendingInteractions() {
|
|
540
|
+
for (const [requestId, pending] of this.pendingApprovals) {
|
|
541
|
+
this.send(this.pendingApprovalFrame(requestId, pending))
|
|
542
|
+
}
|
|
543
|
+
for (const [requestId, pending] of this.pendingInputs) {
|
|
544
|
+
this.send(this.pendingInputFrame(requestId, pending))
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async resolveTool(frame) {
|
|
549
|
+
const pending = this.pendingTools.get(frame.requestId)
|
|
550
|
+
if (!pending) return
|
|
551
|
+
this.pendingTools.delete(frame.requestId)
|
|
552
|
+
// The other half of the boundary. A read tool now answers with ciphertext
|
|
553
|
+
// where the document body used to be; the agent has to receive the body.
|
|
554
|
+
// Without this, sealing would not make the project private — it would make
|
|
555
|
+
// it incomprehensible to the one writing it.
|
|
556
|
+
let content = frame.content || ''
|
|
557
|
+
try {
|
|
558
|
+
content = await openText(this.vaultKey, content)
|
|
559
|
+
} catch (failure) {
|
|
560
|
+
this.emit('vaultOpenFailed', { tool: pending.tool, reason: failure.message })
|
|
561
|
+
}
|
|
562
|
+
this.codex.respond(pending.codexId, {
|
|
563
|
+
contentItems: [{ type: 'inputText', text: content }],
|
|
564
|
+
success: Boolean(frame.success),
|
|
565
|
+
})
|
|
566
|
+
const result = parseToolContent(frame.content)
|
|
567
|
+
this.send({
|
|
568
|
+
type: 'artifact.created',
|
|
569
|
+
requestId: frame.requestId,
|
|
570
|
+
tool: pending.tool,
|
|
571
|
+
arguments: pending.arguments,
|
|
572
|
+
result,
|
|
573
|
+
success: Boolean(frame.success),
|
|
574
|
+
turnRequestId: pending.turnRequestId,
|
|
575
|
+
conversationId: pending.conversationId,
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
resolveApproval(frame) {
|
|
580
|
+
const pending = this.pendingApprovals.get(frame.requestId)
|
|
581
|
+
if (!pending) return
|
|
582
|
+
this.pendingApprovals.delete(frame.requestId)
|
|
583
|
+
const accepted = frame.decision === 'accept'
|
|
584
|
+
const legacy = pending.method === 'execCommandApproval'
|
|
585
|
+
|| pending.method === 'applyPatchApproval'
|
|
586
|
+
if (pending.method === 'item/permissions/requestApproval') {
|
|
587
|
+
this.codex.respond(pending.codexId, {
|
|
588
|
+
permissions: accepted ? compactPermissions(pending.params?.permissions) : {},
|
|
589
|
+
scope: 'turn',
|
|
590
|
+
})
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
this.codex.respond(pending.codexId, {
|
|
594
|
+
decision: legacy
|
|
595
|
+
? accepted ? 'approved' : { denied: { rejection: 'Declined by the user.' } }
|
|
596
|
+
: accepted ? 'accept' : 'decline',
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
resolveInput(frame) {
|
|
601
|
+
const pending = this.pendingInputs.get(frame.requestId)
|
|
602
|
+
if (!pending) return
|
|
603
|
+
this.pendingInputs.delete(frame.requestId)
|
|
604
|
+
this.codex.respond(pending.codexId, { answers: frame.answers || {} })
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function workspaceIdentity(workspace) {
|
|
609
|
+
const normalized = String(workspace || '')
|
|
610
|
+
return {
|
|
611
|
+
name: basename(normalized) || 'Project',
|
|
612
|
+
fingerprint: createHash('sha256').update(normalized).digest('hex'),
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function buildTurnInput(message, { context = null, history = null } = {}) {
|
|
617
|
+
const blocks = []
|
|
618
|
+
if (Array.isArray(history) && history.length) {
|
|
619
|
+
const previous = history
|
|
620
|
+
.filter(entry => ['user', 'assistant'].includes(entry?.role)
|
|
621
|
+
&& typeof entry.text === 'string' && entry.text.trim())
|
|
622
|
+
.map(entry => ({ role: entry.role, text: entry.text }))
|
|
623
|
+
if (previous.length) {
|
|
624
|
+
blocks.push(`<conversation_history>${JSON.stringify(previous)}</conversation_history>`)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
blocks.push(String(message || ''))
|
|
628
|
+
if (context) {
|
|
629
|
+
blocks.push(`<application_context>${JSON.stringify(context)}</application_context>`)
|
|
630
|
+
}
|
|
631
|
+
return blocks.join('\n\n')
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export function sidecarWebSocketUrl(appUrl) {
|
|
635
|
+
const url = new URL(appUrl)
|
|
636
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
637
|
+
url.pathname = `${url.pathname.replace(/\/+$/, '')}/api/sidecar/connect`
|
|
638
|
+
url.search = ''
|
|
639
|
+
url.hash = ''
|
|
640
|
+
return url.toString()
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function dynamicTools(tools) {
|
|
644
|
+
return tools.map(tool => ({
|
|
645
|
+
type: 'function',
|
|
646
|
+
name: tool.name,
|
|
647
|
+
description: tool.description,
|
|
648
|
+
inputSchema: tool.inputSchema,
|
|
649
|
+
}))
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function parseToolContent(content) {
|
|
653
|
+
if (typeof content !== 'string') return content
|
|
654
|
+
try {
|
|
655
|
+
return JSON.parse(content)
|
|
656
|
+
} catch {
|
|
657
|
+
const result = { message: content }
|
|
658
|
+
const documentId = content.match(
|
|
659
|
+
/\bid=([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\b/i,
|
|
660
|
+
)?.[1]
|
|
661
|
+
if (documentId) result.document_id = documentId
|
|
662
|
+
return result
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function publicCodexNotification(frame) {
|
|
667
|
+
const method = frame?.method
|
|
668
|
+
if (!method) return null
|
|
669
|
+
if (method === 'turn/started' || method === 'turn/completed') {
|
|
670
|
+
return { method, params: publicTurnParams(frame.params) }
|
|
671
|
+
}
|
|
672
|
+
if (method === 'item/started' || method === 'item/completed') {
|
|
673
|
+
const item = publicCodexItem(frame.params?.item)
|
|
674
|
+
return item ? {
|
|
675
|
+
method,
|
|
676
|
+
params: {
|
|
677
|
+
threadId: frame.params?.threadId,
|
|
678
|
+
turnId: frame.params?.turnId,
|
|
679
|
+
item,
|
|
680
|
+
},
|
|
681
|
+
} : null
|
|
682
|
+
}
|
|
683
|
+
if (method === 'warning' || method === 'configWarning' || method === 'error') {
|
|
684
|
+
return {
|
|
685
|
+
method,
|
|
686
|
+
params: { message: compactPublicText(frame.params?.message
|
|
687
|
+
|| frame.params?.summary || frame.params?.error) },
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (method === 'model/rerouted') {
|
|
691
|
+
return {
|
|
692
|
+
method,
|
|
693
|
+
params: {
|
|
694
|
+
fromModel: compactPublicText(frame.params?.fromModel, 80),
|
|
695
|
+
toModel: compactPublicText(frame.params?.toModel, 80),
|
|
696
|
+
},
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return null
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function publicTurnParams(params = {}) {
|
|
703
|
+
const turn = params.turn || {}
|
|
704
|
+
return {
|
|
705
|
+
threadId: params.threadId,
|
|
706
|
+
turn: {
|
|
707
|
+
id: turn.id,
|
|
708
|
+
status: turn.status,
|
|
709
|
+
},
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function publicCodexItem(item) {
|
|
714
|
+
if (!item || ['userMessage', 'agentMessage'].includes(item.type)) return null
|
|
715
|
+
const summary = {
|
|
716
|
+
id: item.id,
|
|
717
|
+
type: item.type,
|
|
718
|
+
status: item.status,
|
|
719
|
+
}
|
|
720
|
+
switch (item.type) {
|
|
721
|
+
case 'reasoning':
|
|
722
|
+
case 'plan':
|
|
723
|
+
case 'contextCompaction':
|
|
724
|
+
case 'enteredReviewMode':
|
|
725
|
+
case 'exitedReviewMode':
|
|
726
|
+
return summary
|
|
727
|
+
case 'commandExecution':
|
|
728
|
+
return { ...summary, ...commandActivity(item.command) }
|
|
729
|
+
case 'fileChange':
|
|
730
|
+
return { ...summary, ...publicFileChanges(item.changes) }
|
|
731
|
+
case 'dynamicToolCall':
|
|
732
|
+
return {
|
|
733
|
+
...summary,
|
|
734
|
+
tool: compactPublicText(item.tool, 120),
|
|
735
|
+
subject: publicToolSubject(item.arguments),
|
|
736
|
+
success: item.success,
|
|
737
|
+
}
|
|
738
|
+
case 'mcpToolCall':
|
|
739
|
+
case 'collabToolCall':
|
|
740
|
+
return {
|
|
741
|
+
...summary,
|
|
742
|
+
tool: compactPublicText(item.tool, 120),
|
|
743
|
+
status: item.error ? 'failed' : item.status,
|
|
744
|
+
}
|
|
745
|
+
case 'webSearch':
|
|
746
|
+
return { ...summary, subject: compactPublicText(item.query, 140) }
|
|
747
|
+
case 'imageView':
|
|
748
|
+
return summary
|
|
749
|
+
default:
|
|
750
|
+
return null
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function commandActivity(command) {
|
|
755
|
+
const text = (Array.isArray(command) ? command.join(' ') : String(command || '')).trim()
|
|
756
|
+
if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test(?::[\w.-]+)?\b/i.test(text)
|
|
757
|
+
|| /(?:^|[\s;&|])(?:node\s+--test|pytest|playwright\s+test|cargo\s+test|go\s+test)\b/i.test(text)
|
|
758
|
+
|| /(?:^|[\s;&|])(?:mvn|\.\/gradlew|gradle)\b[^;&|]*\btest\b/i.test(text)) {
|
|
759
|
+
return { activity: 'verify', detail: 'tests' }
|
|
760
|
+
}
|
|
761
|
+
if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+run\s+(?:build|package)\b/i.test(text)
|
|
762
|
+
|| /(?:^|[\s;&|])(?:vite\s+build|cargo\s+build)\b/i.test(text)
|
|
763
|
+
|| /(?:^|[\s;&|])(?:mvn|\.\/gradlew|gradle)\b[^;&|]*\b(?:build|compile|package)\b/i.test(text)) {
|
|
764
|
+
return { activity: 'verify', detail: 'build' }
|
|
765
|
+
}
|
|
766
|
+
if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+run\s+(?:lint|format)\b/i.test(text)
|
|
767
|
+
|| /(?:^|[\s;&|])(?:eslint|prettier)\b/i.test(text)) {
|
|
768
|
+
return { activity: 'verify', detail: 'lint' }
|
|
769
|
+
}
|
|
770
|
+
if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+run\s+typecheck\b/i.test(text)
|
|
771
|
+
|| /(?:^|[\s;&|])tsc\b/i.test(text)) {
|
|
772
|
+
return { activity: 'verify', detail: 'types' }
|
|
773
|
+
}
|
|
774
|
+
if (/(?:^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+run\s+(?:verify|check)\b/i.test(text)
|
|
775
|
+
|| /(?:^|[\s;&|])(?:cargo\s+check|mvn\b[^;&|]*\bverify\b)/i.test(text)) {
|
|
776
|
+
return { activity: 'verify', detail: 'checks' }
|
|
777
|
+
}
|
|
778
|
+
if (/\bgit\s+(?:status|diff|log|show)\b/i.test(text)) {
|
|
779
|
+
return { activity: 'inspect', detail: 'git' }
|
|
780
|
+
}
|
|
781
|
+
if (/(?:^|[\s;&|])(?:rg|grep)\b/i.test(text)) {
|
|
782
|
+
return { activity: 'inspect', detail: 'codeSearch' }
|
|
783
|
+
}
|
|
784
|
+
if (/(?:^|[\s;&|])(?:sed|cat|head|tail)\b/i.test(text)) {
|
|
785
|
+
return { activity: 'inspect', detail: 'files' }
|
|
786
|
+
}
|
|
787
|
+
if (/(?:^|[\s;&|])(?:find|ls|pwd)\b/i.test(text)) {
|
|
788
|
+
return { activity: 'inspect', detail: 'structure' }
|
|
789
|
+
}
|
|
790
|
+
if (/(?:^|[\s;&|])(?:ps|ss)\b/i.test(text)) {
|
|
791
|
+
return { activity: 'inspect', detail: 'services' }
|
|
792
|
+
}
|
|
793
|
+
return { activity: 'command', detail: 'localCommand' }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function publicFileChanges(changes) {
|
|
797
|
+
const list = Array.isArray(changes) ? changes : []
|
|
798
|
+
const files = [...new Set(list.map(change => publicFileName(change?.path)).filter(Boolean))]
|
|
799
|
+
.slice(0, 4)
|
|
800
|
+
return { changeCount: list.length, files }
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function publicFileName(path) {
|
|
804
|
+
const segments = String(path || '').replaceAll('\\', '/').split('/').filter(Boolean)
|
|
805
|
+
return compactPublicText(segments.at(-1), 100)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function publicToolSubject(args = {}) {
|
|
809
|
+
for (const key of ['title', 'name', 'query', 'term']) {
|
|
810
|
+
if (typeof args?.[key] === 'string' && args[key].trim()) {
|
|
811
|
+
return compactPublicText(args[key].replace(/\s+/g, ' ').trim(), 140)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return ''
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function compactPublicText(value, limit = 320) {
|
|
818
|
+
const text = typeof value === 'string'
|
|
819
|
+
? value
|
|
820
|
+
: value == null ? '' : String(value?.message || value)
|
|
821
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function approvalKind(method) {
|
|
825
|
+
if (method === 'item/permissions/requestApproval') return 'permissions'
|
|
826
|
+
if (method.includes('fileChange') || method === 'applyPatchApproval') return 'file-change'
|
|
827
|
+
return 'command'
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function approvalTitle(method) {
|
|
831
|
+
if (method === 'item/permissions/requestApproval') {
|
|
832
|
+
return 'Allow additional permissions'
|
|
833
|
+
}
|
|
834
|
+
if (method.includes('fileChange') || method === 'applyPatchApproval') {
|
|
835
|
+
return 'Allow file changes'
|
|
836
|
+
}
|
|
837
|
+
return 'Allow the command to run'
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function compactPermissions(permissions = {}) {
|
|
841
|
+
return Object.fromEntries(
|
|
842
|
+
Object.entries(permissions).filter(([, value]) => value != null),
|
|
843
|
+
)
|
|
844
|
+
}
|