remote-codex 0.11.50 → 0.11.52
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/README.md +31 -0
- package/apps/supervisor-api/dist/index.js +2132 -417
- package/apps/supervisor-web/dist/assets/index-BBq6mr8o.js +22 -0
- package/apps/supervisor-web/dist/assets/{index-Dy8PgXgw.css → index-Bg8FdhS1.css} +1 -1
- package/apps/supervisor-web/dist/assets/thread-ui-C5pxOmDJ.js +3974 -0
- package/apps/supervisor-web/dist/index.html +3 -3
- package/package.json +7 -1
- package/packages/acp/src/agent-catalog.test.ts +25 -0
- package/packages/acp/src/agent-catalog.ts +3 -1
- package/packages/acp/src/capabilities.ts +139 -0
- package/packages/acp/src/capability-parity.test.ts +99 -0
- package/packages/acp/src/catalog-runtime.test.ts +165 -6
- package/packages/acp/src/catalog-runtime.ts +234 -25
- package/packages/acp/src/extension-registry.test.ts +198 -0
- package/packages/acp/src/extension-registry.ts +285 -0
- package/packages/acp/src/extensions.test.ts +45 -0
- package/packages/acp/src/extensions.ts +103 -0
- package/packages/acp/src/harness-contract.test.ts +81 -0
- package/packages/acp/src/harness-contract.ts +62 -0
- package/packages/acp/src/index.ts +7 -0
- package/packages/acp/src/item-mapper.ts +28 -2
- package/packages/acp/src/prompt-content.test.ts +90 -0
- package/packages/acp/src/prompt-content.ts +99 -0
- package/packages/acp/src/runtimeAdapter.test.ts +471 -5
- package/packages/acp/src/runtimeAdapter.ts +791 -60
- package/packages/acp/src/session-hydrator.test.ts +135 -0
- package/packages/acp/src/session-hydrator.ts +147 -0
- package/packages/acp/src/terminal-service.test.ts +21 -2
- package/packages/acp/src/terminal-service.ts +23 -3
- package/packages/acp/src/test/fixtures/fake-acp-agent.mjs +514 -0
- package/packages/acp/src/workspace-boundary.test.ts +32 -0
- package/packages/acp/src/workspace-boundary.ts +47 -0
- package/packages/agent-runtime/src/types.ts +61 -1
- package/packages/codex/src/appServerManager.test.ts +2 -2
- package/packages/db/src/repositories.ts +3 -2
- package/packages/shared/src/index.ts +32 -1
- package/apps/supervisor-web/dist/assets/index-BXypG9kl.js +0 -22
- package/apps/supervisor-web/dist/assets/thread-ui-gcslNXur.js +0 -3968
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { Readable, Writable } from 'node:stream';
|
|
8
|
+
import { clearTimeout, setTimeout } from 'node:timers';
|
|
9
|
+
|
|
10
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
11
|
+
|
|
12
|
+
if (process.argv.includes('--help')) {
|
|
13
|
+
process.stdout.write('Remote Codex fake ACP agent\n');
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const statePath = process.env.REMOTE_CODEX_FAKE_ACP_STATE?.trim() || null;
|
|
18
|
+
const streamDelayMs = Number(
|
|
19
|
+
process.env.REMOTE_CODEX_FAKE_ACP_STREAM_DELAY_MS ?? 0,
|
|
20
|
+
);
|
|
21
|
+
const capabilityProfile =
|
|
22
|
+
process.env.REMOTE_CODEX_FAKE_ACP_CAPABILITY_PROFILE ?? 'full';
|
|
23
|
+
const skipPermission = process.env.REMOTE_CODEX_FAKE_ACP_SKIP_PERMISSION === '1';
|
|
24
|
+
const agentKind = process.env.REMOTE_CODEX_FAKE_ACP_AGENT_KIND ?? 'fixture';
|
|
25
|
+
const supportsFork = process.env.REMOTE_CODEX_FAKE_ACP_FORK === '1';
|
|
26
|
+
const noSessionCleanup = process.env.REMOTE_CODEX_FAKE_ACP_NO_SESSION_CLEANUP === '1';
|
|
27
|
+
const noSessionDelete = process.env.REMOTE_CODEX_FAKE_ACP_NO_SESSION_DELETE === '1';
|
|
28
|
+
const goalVersion = process.env.REMOTE_CODEX_FAKE_ACP_GOAL_VERSION ?? '1';
|
|
29
|
+
const goalActions = (process.env.REMOTE_CODEX_FAKE_ACP_GOAL_ACTIONS ?? 'get,set,clear')
|
|
30
|
+
.split(',')
|
|
31
|
+
.map((action) => action.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
const sessions = new Map();
|
|
34
|
+
|
|
35
|
+
function defaultConfig() {
|
|
36
|
+
return {
|
|
37
|
+
model: 'fixture-model',
|
|
38
|
+
thought: 'medium',
|
|
39
|
+
mode: 'agent',
|
|
40
|
+
fast: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function configOptions(session) {
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
id: 'model',
|
|
48
|
+
name: 'Model',
|
|
49
|
+
category: 'model',
|
|
50
|
+
type: 'select',
|
|
51
|
+
currentValue: session.config.model,
|
|
52
|
+
options: [
|
|
53
|
+
{ value: 'fixture-model', name: 'Fixture model' },
|
|
54
|
+
{ value: 'fixture-fast', name: 'Fixture fast model' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'thought-level',
|
|
59
|
+
name: 'Reasoning',
|
|
60
|
+
category: 'thought_level',
|
|
61
|
+
type: 'select',
|
|
62
|
+
currentValue: session.config.thought,
|
|
63
|
+
options: [
|
|
64
|
+
{ value: 'low', name: 'Low' },
|
|
65
|
+
{ value: 'medium', name: 'Medium' },
|
|
66
|
+
{ value: 'high', name: 'High' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
...(agentKind === 'codex'
|
|
70
|
+
? [{
|
|
71
|
+
id: 'fast-mode',
|
|
72
|
+
name: 'Fast mode',
|
|
73
|
+
category: 'model_config',
|
|
74
|
+
type: 'boolean',
|
|
75
|
+
currentValue: session.config.fast,
|
|
76
|
+
}]
|
|
77
|
+
: []),
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function modes(session) {
|
|
82
|
+
return {
|
|
83
|
+
currentModeId: session.config.mode,
|
|
84
|
+
availableModes: [
|
|
85
|
+
{ id: 'agent', name: 'Agent' },
|
|
86
|
+
{ id: 'plan', name: 'Plan' },
|
|
87
|
+
{ id: 'read-only', name: 'Read only' },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function responseState(session) {
|
|
93
|
+
return {
|
|
94
|
+
modes: modes(session),
|
|
95
|
+
configOptions: configOptions(session),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadState() {
|
|
100
|
+
if (!statePath) return;
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(await fs.readFile(statePath, 'utf8'));
|
|
103
|
+
for (const session of parsed.sessions ?? []) {
|
|
104
|
+
sessions.set(session.sessionId, {
|
|
105
|
+
...session,
|
|
106
|
+
config: { ...defaultConfig(), ...session.config },
|
|
107
|
+
turns: Array.isArray(session.turns) ? session.turns : [],
|
|
108
|
+
pendingPrompt: null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function saveState() {
|
|
117
|
+
if (!statePath) return;
|
|
118
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
119
|
+
const payload = {
|
|
120
|
+
sessions: [...sessions.values()].map((session) => {
|
|
121
|
+
const serializable = { ...session };
|
|
122
|
+
delete serializable.pendingPrompt;
|
|
123
|
+
return serializable;
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
await fs.writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function requireSession(sessionId) {
|
|
130
|
+
const session = sessions.get(sessionId);
|
|
131
|
+
if (!session) throw new Error(`Unknown fixture session: ${sessionId}`);
|
|
132
|
+
return session;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function promptText(blocks) {
|
|
136
|
+
return blocks.map((block) => {
|
|
137
|
+
if (block.type === 'text') return block.text;
|
|
138
|
+
if (block.type === 'resource_link') return block.uri;
|
|
139
|
+
if (block.type === 'image') return `[image:${block.mimeType}]`;
|
|
140
|
+
if (block.type === 'audio') return `[audio:${block.mimeType}]`;
|
|
141
|
+
if (block.type === 'resource' && 'text' in block.resource) return block.resource.text;
|
|
142
|
+
return '';
|
|
143
|
+
}).join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function completedPromptResponse(turnNumber) {
|
|
147
|
+
return {
|
|
148
|
+
stopReason: 'end_turn',
|
|
149
|
+
usage: {
|
|
150
|
+
totalTokens: turnNumber * 100,
|
|
151
|
+
inputTokens: turnNumber * 60,
|
|
152
|
+
outputTokens: turnNumber * 40,
|
|
153
|
+
thoughtTokens: turnNumber * 10,
|
|
154
|
+
cachedReadTokens: 0,
|
|
155
|
+
cachedWriteTokens: 0,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function notify(client, sessionId, update) {
|
|
161
|
+
await client.notify(acp.methods.client.session.update, { sessionId, update });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function delay(ms, signal) {
|
|
165
|
+
if (ms <= 0) return;
|
|
166
|
+
await new Promise((resolve, reject) => {
|
|
167
|
+
const timer = setTimeout(resolve, ms);
|
|
168
|
+
signal.addEventListener('abort', () => {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
reject(new Error('Fixture prompt cancelled.'));
|
|
171
|
+
}, { once: true });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function replayTurn(client, session, turn, index) {
|
|
176
|
+
await notify(client, session.sessionId, {
|
|
177
|
+
sessionUpdate: 'user_message_chunk',
|
|
178
|
+
content: { type: 'text', text: turn.prompt },
|
|
179
|
+
});
|
|
180
|
+
await notify(client, session.sessionId, {
|
|
181
|
+
sessionUpdate: 'agent_thought_chunk',
|
|
182
|
+
content: { type: 'text', text: turn.reasoning },
|
|
183
|
+
});
|
|
184
|
+
await notify(client, session.sessionId, {
|
|
185
|
+
sessionUpdate: 'tool_call',
|
|
186
|
+
toolCallId: `${session.sessionId}:replay-tool:${index}`,
|
|
187
|
+
title: 'Optional fixture check',
|
|
188
|
+
kind: 'execute',
|
|
189
|
+
status: 'failed',
|
|
190
|
+
rawInput: { command: 'fixture-check' },
|
|
191
|
+
rawOutput: { exitCode: 1 },
|
|
192
|
+
});
|
|
193
|
+
await notify(client, session.sessionId, {
|
|
194
|
+
sessionUpdate: 'plan',
|
|
195
|
+
entries: [
|
|
196
|
+
{ content: 'Inspect fixture', priority: 'high', status: 'completed' },
|
|
197
|
+
{ content: 'Return marker', priority: 'high', status: 'completed' },
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
await notify(client, session.sessionId, {
|
|
201
|
+
sessionUpdate: 'agent_message_chunk',
|
|
202
|
+
content: { type: 'text', text: turn.response },
|
|
203
|
+
});
|
|
204
|
+
await notify(client, session.sessionId, {
|
|
205
|
+
sessionUpdate: 'usage_update',
|
|
206
|
+
used: turn.usage.used,
|
|
207
|
+
size: turn.usage.size,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function replaySession(client, session) {
|
|
212
|
+
for (const [index, turn] of session.turns.entries()) {
|
|
213
|
+
await replayTurn(client, session, turn, index);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function handleGoalControl(context) {
|
|
218
|
+
const session = requireSession(context.params.sessionId);
|
|
219
|
+
if (context.params.action === 'clear') {
|
|
220
|
+
session.goal = null;
|
|
221
|
+
} else if (context.params.action === 'set') {
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
session.goal = {
|
|
224
|
+
objective: context.params.objective,
|
|
225
|
+
status: 'active',
|
|
226
|
+
tokenBudget: null,
|
|
227
|
+
tokensUsed: 0,
|
|
228
|
+
timeUsedSeconds: 0,
|
|
229
|
+
createdAt: now,
|
|
230
|
+
updatedAt: now,
|
|
231
|
+
};
|
|
232
|
+
} else if (session.goal) {
|
|
233
|
+
session.goal.status = context.params.action === 'pause' ? 'paused' : 'active';
|
|
234
|
+
session.goal.updatedAt = Date.now();
|
|
235
|
+
}
|
|
236
|
+
await saveState();
|
|
237
|
+
await notify(context.client, session.sessionId, {
|
|
238
|
+
sessionUpdate: 'session_info_update',
|
|
239
|
+
_meta: { goal: session.goal ?? null },
|
|
240
|
+
});
|
|
241
|
+
return {};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await loadState();
|
|
245
|
+
|
|
246
|
+
const input = Writable.toWeb(process.stdout);
|
|
247
|
+
const output = Readable.toWeb(process.stdin);
|
|
248
|
+
const stream = acp.ndJsonStream(input, output);
|
|
249
|
+
|
|
250
|
+
acp.agent({ name: 'remote-codex-fake-acp-agent' })
|
|
251
|
+
.onRequest(acp.methods.agent.initialize, async () => ({
|
|
252
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
253
|
+
agentInfo: {
|
|
254
|
+
name: agentKind === 'codex'
|
|
255
|
+
? '@agentclientprotocol/codex-acp'
|
|
256
|
+
: 'remote-codex-fake-acp-agent',
|
|
257
|
+
title: agentKind === 'codex' ? 'Codex' : 'Remote Codex Fake ACP Agent',
|
|
258
|
+
version: '1.0.0',
|
|
259
|
+
},
|
|
260
|
+
agentCapabilities: capabilityProfile === 'minimal'
|
|
261
|
+
? { loadSession: false, promptCapabilities: {} }
|
|
262
|
+
: {
|
|
263
|
+
loadSession: true,
|
|
264
|
+
promptCapabilities: { image: true, audio: true, embeddedContext: true },
|
|
265
|
+
sessionCapabilities: {
|
|
266
|
+
list: {},
|
|
267
|
+
resume: {},
|
|
268
|
+
...(!noSessionCleanup ? { close: {} } : {}),
|
|
269
|
+
...(!noSessionCleanup && !noSessionDelete ? { delete: {} } : {}),
|
|
270
|
+
...(supportsFork ? { fork: {} } : {}),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
_meta: {
|
|
274
|
+
'remoteCodex.harnessExtensions': [{
|
|
275
|
+
id: 'fixture.session',
|
|
276
|
+
version: 1,
|
|
277
|
+
stability: 'experimental',
|
|
278
|
+
methods: ['compact'],
|
|
279
|
+
events: ['checkpoint'],
|
|
280
|
+
}],
|
|
281
|
+
steering: { supported: true },
|
|
282
|
+
goal: {
|
|
283
|
+
version: /^\d+$/.test(goalVersion) ? Number(goalVersion) : goalVersion,
|
|
284
|
+
controlMethod: agentKind === 'codex'
|
|
285
|
+
? '_session/goal'
|
|
286
|
+
: 'fixture/goal/control',
|
|
287
|
+
actions: goalActions,
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
}))
|
|
291
|
+
.onRequest(acp.methods.agent.session.new, async (context) => {
|
|
292
|
+
const now = new Date().toISOString();
|
|
293
|
+
const session = {
|
|
294
|
+
sessionId: randomUUID(),
|
|
295
|
+
cwd: path.resolve(context.params.cwd),
|
|
296
|
+
title: 'Fixture session',
|
|
297
|
+
createdAt: now,
|
|
298
|
+
updatedAt: now,
|
|
299
|
+
config: defaultConfig(),
|
|
300
|
+
turns: [],
|
|
301
|
+
pendingPrompt: null,
|
|
302
|
+
};
|
|
303
|
+
sessions.set(session.sessionId, session);
|
|
304
|
+
await saveState();
|
|
305
|
+
return { sessionId: session.sessionId, ...responseState(session) };
|
|
306
|
+
})
|
|
307
|
+
.onRequest(acp.methods.agent.session.list, async () => ({
|
|
308
|
+
sessions: [...sessions.values()].map((session) => ({
|
|
309
|
+
sessionId: session.sessionId,
|
|
310
|
+
cwd: session.cwd,
|
|
311
|
+
title: session.title,
|
|
312
|
+
updatedAt: session.updatedAt,
|
|
313
|
+
})),
|
|
314
|
+
nextCursor: null,
|
|
315
|
+
}))
|
|
316
|
+
.onRequest(acp.methods.agent.session.load, async (context) => {
|
|
317
|
+
const session = requireSession(context.params.sessionId);
|
|
318
|
+
await replaySession(context.client, session);
|
|
319
|
+
return responseState(session);
|
|
320
|
+
})
|
|
321
|
+
.onRequest(acp.methods.agent.session.resume, async (context) =>
|
|
322
|
+
responseState(requireSession(context.params.sessionId)))
|
|
323
|
+
.onRequest(acp.methods.agent.session.close, async (context) => {
|
|
324
|
+
requireSession(context.params.sessionId).pendingPrompt?.abort();
|
|
325
|
+
return {};
|
|
326
|
+
})
|
|
327
|
+
.onRequest(acp.methods.agent.session.delete, async (context) => {
|
|
328
|
+
sessions.delete(context.params.sessionId);
|
|
329
|
+
await saveState();
|
|
330
|
+
return {};
|
|
331
|
+
})
|
|
332
|
+
.onRequest(acp.methods.agent.session.fork, async (context) => {
|
|
333
|
+
if (!supportsFork) throw new Error('Fixture session fork is disabled.');
|
|
334
|
+
const source = requireSession(context.params.sessionId);
|
|
335
|
+
const now = new Date().toISOString();
|
|
336
|
+
const forked = {
|
|
337
|
+
...globalThis.structuredClone(source),
|
|
338
|
+
sessionId: randomUUID(),
|
|
339
|
+
cwd: path.resolve(context.params.cwd),
|
|
340
|
+
title: `${source.title} fork`,
|
|
341
|
+
createdAt: now,
|
|
342
|
+
updatedAt: now,
|
|
343
|
+
pendingPrompt: null,
|
|
344
|
+
};
|
|
345
|
+
sessions.set(forked.sessionId, forked);
|
|
346
|
+
await saveState();
|
|
347
|
+
return { sessionId: forked.sessionId, ...responseState(forked) };
|
|
348
|
+
})
|
|
349
|
+
.onRequest(acp.methods.agent.session.setMode, async (context) => {
|
|
350
|
+
const session = requireSession(context.params.sessionId);
|
|
351
|
+
session.config.mode = context.params.modeId;
|
|
352
|
+
session.updatedAt = new Date().toISOString();
|
|
353
|
+
await saveState();
|
|
354
|
+
return {};
|
|
355
|
+
})
|
|
356
|
+
.onRequest(acp.methods.agent.session.setConfigOption, async (context) => {
|
|
357
|
+
const session = requireSession(context.params.sessionId);
|
|
358
|
+
if (context.params.configId === 'model') session.config.model = context.params.value;
|
|
359
|
+
if (context.params.configId === 'thought-level') session.config.thought = context.params.value;
|
|
360
|
+
if (context.params.configId === 'fast-mode') session.config.fast = context.params.value === true;
|
|
361
|
+
session.updatedAt = new Date().toISOString();
|
|
362
|
+
await saveState();
|
|
363
|
+
return { configOptions: configOptions(session) };
|
|
364
|
+
})
|
|
365
|
+
.onRequest(
|
|
366
|
+
'remoteCodex/fixture.session/v1/compact',
|
|
367
|
+
(params) => params,
|
|
368
|
+
async (context) => {
|
|
369
|
+
const providerSessionId = context.params.params?.providerSessionId;
|
|
370
|
+
requireSession(providerSessionId);
|
|
371
|
+
await context.client.notify('remoteCodex/harness-extension/event', {
|
|
372
|
+
protocol: 'remote-codex.harness-extension/v1',
|
|
373
|
+
extensionId: 'fixture.session',
|
|
374
|
+
extensionVersion: 1,
|
|
375
|
+
event: 'checkpoint',
|
|
376
|
+
operationId: context.params.operationId,
|
|
377
|
+
providerSessionId,
|
|
378
|
+
providerTurnId: context.params.params?.providerTurnId ?? null,
|
|
379
|
+
providerItemId: 'fixture-checkpoint',
|
|
380
|
+
sequence: 1,
|
|
381
|
+
payload: { status: 'completed' },
|
|
382
|
+
});
|
|
383
|
+
return {
|
|
384
|
+
compacted: true,
|
|
385
|
+
operationId: context.params.operationId,
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
.onRequest('_session/steering', (params) => params, async (context) => {
|
|
390
|
+
const session = requireSession(context.params.sessionId);
|
|
391
|
+
if (session.pendingPrompt) {
|
|
392
|
+
await notify(context.client, session.sessionId, {
|
|
393
|
+
sessionUpdate: 'agent_message_chunk',
|
|
394
|
+
content: { type: 'text', text: ' STEERED' },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return { accepted: true };
|
|
398
|
+
})
|
|
399
|
+
.onRequest('_session/goal', (params) => params, handleGoalControl)
|
|
400
|
+
.onRequest('fixture/goal/control', (params) => params, handleGoalControl)
|
|
401
|
+
.onRequest(acp.methods.agent.session.prompt, async (context) => {
|
|
402
|
+
const session = requireSession(context.params.sessionId);
|
|
403
|
+
const pendingPrompt = new globalThis.AbortController();
|
|
404
|
+
session.pendingPrompt?.abort();
|
|
405
|
+
session.pendingPrompt = pendingPrompt;
|
|
406
|
+
const prompt = promptText(context.params.prompt);
|
|
407
|
+
const turnNumber = session.turns.length + 1;
|
|
408
|
+
|
|
409
|
+
await notify(context.client, session.sessionId, {
|
|
410
|
+
sessionUpdate: 'agent_thought_chunk',
|
|
411
|
+
content: { type: 'text', text: `Reasoning for fixture turn ${turnNumber}.` },
|
|
412
|
+
});
|
|
413
|
+
await notify(context.client, session.sessionId, {
|
|
414
|
+
sessionUpdate: 'tool_call',
|
|
415
|
+
toolCallId: `${session.sessionId}:failed-check:${turnNumber}`,
|
|
416
|
+
title: 'Optional fixture check',
|
|
417
|
+
kind: 'execute',
|
|
418
|
+
status: 'failed',
|
|
419
|
+
rawInput: { command: 'fixture-check' },
|
|
420
|
+
rawOutput: { exitCode: 1 },
|
|
421
|
+
});
|
|
422
|
+
await notify(context.client, session.sessionId, {
|
|
423
|
+
sessionUpdate: 'plan',
|
|
424
|
+
entries: [
|
|
425
|
+
{ content: 'Inspect fixture', priority: 'high', status: 'completed' },
|
|
426
|
+
{ content: 'Return marker', priority: 'high', status: 'in_progress' },
|
|
427
|
+
],
|
|
428
|
+
});
|
|
429
|
+
const partialResponse = `FAKE_ACP_PARTIAL_${turnNumber}`;
|
|
430
|
+
const finalResponse = `${partialResponse}_COMPLETE`;
|
|
431
|
+
const persistedTurn = {
|
|
432
|
+
prompt,
|
|
433
|
+
reasoning: `Reasoning for fixture turn ${turnNumber}.`,
|
|
434
|
+
response: partialResponse,
|
|
435
|
+
usage: { used: turnNumber * 100, size: 4096 },
|
|
436
|
+
};
|
|
437
|
+
if (streamDelayMs > 0) {
|
|
438
|
+
session.turns.push(persistedTurn);
|
|
439
|
+
session.updatedAt = new Date().toISOString();
|
|
440
|
+
await saveState();
|
|
441
|
+
await notify(context.client, session.sessionId, {
|
|
442
|
+
sessionUpdate: 'agent_message_chunk',
|
|
443
|
+
content: { type: 'text', text: partialResponse },
|
|
444
|
+
});
|
|
445
|
+
await delay(streamDelayMs, pendingPrompt.signal);
|
|
446
|
+
persistedTurn.response = finalResponse;
|
|
447
|
+
await notify(context.client, session.sessionId, {
|
|
448
|
+
sessionUpdate: 'agent_message_chunk',
|
|
449
|
+
content: { type: 'text', text: '_COMPLETE' },
|
|
450
|
+
});
|
|
451
|
+
await notify(context.client, session.sessionId, {
|
|
452
|
+
sessionUpdate: 'usage_update',
|
|
453
|
+
used: turnNumber * 100,
|
|
454
|
+
size: 4096,
|
|
455
|
+
});
|
|
456
|
+
session.updatedAt = new Date().toISOString();
|
|
457
|
+
session.pendingPrompt = null;
|
|
458
|
+
await saveState();
|
|
459
|
+
return completedPromptResponse(turnNumber);
|
|
460
|
+
}
|
|
461
|
+
const permission = skipPermission
|
|
462
|
+
? { outcome: { outcome: 'selected', optionId: 'allow-once' } }
|
|
463
|
+
: await context.client.request(
|
|
464
|
+
acp.methods.client.session.requestPermission,
|
|
465
|
+
{
|
|
466
|
+
sessionId: session.sessionId,
|
|
467
|
+
toolCall: {
|
|
468
|
+
toolCallId: `${session.sessionId}:write:${turnNumber}`,
|
|
469
|
+
title: 'Write fixture result',
|
|
470
|
+
kind: 'edit',
|
|
471
|
+
status: 'pending',
|
|
472
|
+
rawInput: { path: 'fixture-output.txt' },
|
|
473
|
+
},
|
|
474
|
+
options: [
|
|
475
|
+
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
|
476
|
+
{ optionId: 'allow-always', name: 'Allow always', kind: 'allow_always' },
|
|
477
|
+
{ optionId: 'reject-once', name: 'Reject once', kind: 'reject_once' },
|
|
478
|
+
],
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
if (pendingPrompt.signal.aborted || permission.outcome.outcome === 'cancelled') {
|
|
482
|
+
session.pendingPrompt = null;
|
|
483
|
+
return { stopReason: 'cancelled' };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
await notify(context.client, session.sessionId, {
|
|
487
|
+
sessionUpdate: 'tool_call_update',
|
|
488
|
+
toolCallId: `${session.sessionId}:write:${turnNumber}`,
|
|
489
|
+
title: 'Write fixture result',
|
|
490
|
+
kind: 'edit',
|
|
491
|
+
status: 'completed',
|
|
492
|
+
rawOutput: { permission: permission.outcome.optionId },
|
|
493
|
+
});
|
|
494
|
+
session.turns.push(persistedTurn);
|
|
495
|
+
session.updatedAt = new Date().toISOString();
|
|
496
|
+
await saveState();
|
|
497
|
+
await notify(context.client, session.sessionId, {
|
|
498
|
+
sessionUpdate: 'agent_message_chunk',
|
|
499
|
+
content: { type: 'text', text: partialResponse },
|
|
500
|
+
});
|
|
501
|
+
await notify(context.client, session.sessionId, {
|
|
502
|
+
sessionUpdate: 'usage_update',
|
|
503
|
+
used: turnNumber * 100,
|
|
504
|
+
size: 4096,
|
|
505
|
+
});
|
|
506
|
+
session.updatedAt = new Date().toISOString();
|
|
507
|
+
session.pendingPrompt = null;
|
|
508
|
+
await saveState();
|
|
509
|
+
return completedPromptResponse(turnNumber);
|
|
510
|
+
})
|
|
511
|
+
.onNotification(acp.methods.agent.session.cancel, async (context) => {
|
|
512
|
+
requireSession(context.params.sessionId).pendingPrompt?.abort();
|
|
513
|
+
})
|
|
514
|
+
.connect(stream);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { resolveAcpWorkspacePath } from './workspace-boundary';
|
|
8
|
+
|
|
9
|
+
const directories: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(directories.splice(0).map((directory) =>
|
|
13
|
+
fs.rm(directory, { recursive: true, force: true })));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('ACP workspace boundary', () => {
|
|
17
|
+
it('allows missing descendants while rejecting symlink escapes', async () => {
|
|
18
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-codex-acp-boundary-'));
|
|
19
|
+
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-codex-acp-outside-'));
|
|
20
|
+
directories.push(root, outside);
|
|
21
|
+
const realRoot = await fs.realpath(root);
|
|
22
|
+
expect(await resolveAcpWorkspacePath(
|
|
23
|
+
root,
|
|
24
|
+
path.join(root, 'new', 'file.txt'),
|
|
25
|
+
)).toBe(path.join(realRoot, 'new', 'file.txt'));
|
|
26
|
+
await fs.symlink(outside, path.join(root, 'escape'));
|
|
27
|
+
await expect(resolveAcpWorkspacePath(
|
|
28
|
+
root,
|
|
29
|
+
path.join(root, 'escape', 'file.txt'),
|
|
30
|
+
)).rejects.toThrow(/resolves outside/);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function isInside(root: string, candidate: string) {
|
|
5
|
+
const relative = path.relative(root, candidate);
|
|
6
|
+
return relative === '' || (
|
|
7
|
+
relative !== '..' &&
|
|
8
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
9
|
+
!path.isAbsolute(relative)
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function resolveAcpWorkspacePath(
|
|
14
|
+
workspacePath: string,
|
|
15
|
+
candidatePath: string,
|
|
16
|
+
) {
|
|
17
|
+
if (!path.isAbsolute(candidatePath)) {
|
|
18
|
+
throw new Error('ACP workspace paths must be absolute.');
|
|
19
|
+
}
|
|
20
|
+
const rootPath = path.resolve(workspacePath);
|
|
21
|
+
const requestedPath = path.resolve(candidatePath);
|
|
22
|
+
if (!isInside(rootPath, requestedPath)) {
|
|
23
|
+
throw new Error('ACP path must stay inside the session workspace.');
|
|
24
|
+
}
|
|
25
|
+
const rootRealPath = await fs.realpath(rootPath);
|
|
26
|
+
let existingPath = requestedPath;
|
|
27
|
+
const missingSegments: string[] = [];
|
|
28
|
+
while (true) {
|
|
29
|
+
try {
|
|
30
|
+
const existingRealPath = await fs.realpath(existingPath);
|
|
31
|
+
if (!isInside(rootRealPath, existingRealPath)) {
|
|
32
|
+
throw new Error('ACP path resolves outside the session workspace.');
|
|
33
|
+
}
|
|
34
|
+
return path.join(existingRealPath, ...missingSegments);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
const parent = path.dirname(existingPath);
|
|
40
|
+
if (parent === existingPath) {
|
|
41
|
+
throw new Error('ACP workspace path has no existing parent.');
|
|
42
|
+
}
|
|
43
|
+
missingSegments.unshift(path.basename(existingPath));
|
|
44
|
+
existingPath = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|