@tarunspandit/codexflow 0.29.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/AGENTS.example.md +18 -0
- package/CHANGELOG.md +311 -0
- package/CHATGPT_PROMPT.md +12 -0
- package/CODEX_PROMPT.md +12 -0
- package/CONTRIBUTING.md +51 -0
- package/DOMAIN_SETUP.md +241 -0
- package/FAQ.md +327 -0
- package/FAQ_ZH.md +279 -0
- package/LICENSE +21 -0
- package/NOTICE +9 -0
- package/PUBLIC_LAUNCH_CHECKLIST.md +111 -0
- package/README.md +287 -0
- package/README_ZH.md +461 -0
- package/SECURITY.md +145 -0
- package/config.example.env +31 -0
- package/dist/analysis/cache.js +27 -0
- package/dist/analysis/cache.js.map +1 -0
- package/dist/analysis/classify.js +102 -0
- package/dist/analysis/classify.js.map +1 -0
- package/dist/analysis/extract.js +139 -0
- package/dist/analysis/extract.js.map +1 -0
- package/dist/analysis/graph.js +22 -0
- package/dist/analysis/graph.js.map +1 -0
- package/dist/analysis/impact.js +167 -0
- package/dist/analysis/impact.js.map +1 -0
- package/dist/analysis/index.js +215 -0
- package/dist/analysis/index.js.map +1 -0
- package/dist/analysis/inventory.js +51 -0
- package/dist/analysis/inventory.js.map +1 -0
- package/dist/analysis/providers.js +24 -0
- package/dist/analysis/providers.js.map +1 -0
- package/dist/analysis/rank.js +27 -0
- package/dist/analysis/rank.js.map +1 -0
- package/dist/analysis/types.js +8 -0
- package/dist/analysis/types.js.map +1 -0
- package/dist/bashOps.js +233 -0
- package/dist/bashOps.js.map +1 -0
- package/dist/capabilitiesOps.js +365 -0
- package/dist/capabilitiesOps.js.map +1 -0
- package/dist/codexSessions.js +379 -0
- package/dist/codexSessions.js.map +1 -0
- package/dist/config.js +289 -0
- package/dist/config.js.map +1 -0
- package/dist/fsOps.js +286 -0
- package/dist/fsOps.js.map +1 -0
- package/dist/gitOps.js +79 -0
- package/dist/gitOps.js.map +1 -0
- package/dist/guard.js +198 -0
- package/dist/guard.js.map +1 -0
- package/dist/http.js +1671 -0
- package/dist/http.js.map +1 -0
- package/dist/proContext.js +274 -0
- package/dist/proContext.js.map +1 -0
- package/dist/profileStore.js +89 -0
- package/dist/profileStore.js.map +1 -0
- package/dist/projectCatalog.js +134 -0
- package/dist/projectCatalog.js.map +1 -0
- package/dist/redact.js +73 -0
- package/dist/redact.js.map +1 -0
- package/dist/searchOps.js +186 -0
- package/dist/searchOps.js.map +1 -0
- package/dist/server.js +2502 -0
- package/dist/server.js.map +1 -0
- package/dist/stdio.js +36 -0
- package/dist/stdio.js.map +1 -0
- package/dist/toolCardWidget.js +1155 -0
- package/dist/toolCardWidget.js.map +1 -0
- package/dist/workspaceOps.js +229 -0
- package/dist/workspaceOps.js.map +1 -0
- package/docs/.nojekyll +1 -0
- package/docs/favicon.svg +5 -0
- package/docs/index.html +638 -0
- package/docs/og.png +0 -0
- package/docs/script.js +80 -0
- package/docs/star.svg +11 -0
- package/docs/styles.css +1229 -0
- package/docs/zh.html +436 -0
- package/package.json +94 -0
- package/scripts/analysis-cli-smoke.mjs +81 -0
- package/scripts/analysis-smoke.mjs +179 -0
- package/scripts/clean.mjs +6 -0
- package/scripts/cli-smoke.mjs +168 -0
- package/scripts/codexflow.mjs +4375 -0
- package/scripts/doctor-smoke.mjs +90 -0
- package/scripts/execute-handoff-smoke.mjs +1110 -0
- package/scripts/http-smoke.mjs +812 -0
- package/scripts/pro-apply.mjs +141 -0
- package/scripts/pro-bundle.mjs +121 -0
- package/scripts/pro-smoke.mjs +95 -0
- package/scripts/settings-smoke.mjs +756 -0
- package/scripts/smoke.mjs +1194 -0
- package/scripts/stress.mjs +835 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
9
|
+
|
|
10
|
+
async function getFreePort() {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const server = net.createServer();
|
|
13
|
+
server.listen(0, '127.0.0.1', () => {
|
|
14
|
+
const address = server.address();
|
|
15
|
+
const port = typeof address === 'object' && address ? address.port : undefined;
|
|
16
|
+
server.close(() => (port ? resolve(port) : reject(new Error('no free port'))));
|
|
17
|
+
});
|
|
18
|
+
server.on('error', reject);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function waitForListening(child) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
let stderr = '';
|
|
25
|
+
const timer = setTimeout(() => reject(new Error(`timeout waiting for HTTP server\n${stderr}`)), 15000);
|
|
26
|
+
timer.unref();
|
|
27
|
+
child.stderr.on('data', (chunk) => {
|
|
28
|
+
stderr += String(chunk);
|
|
29
|
+
if (stderr.includes('HTTP MCP listening')) {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
child.on('exit', (code) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
reject(new Error(`HTTP server exited before listening: ${code}\n${stderr}`));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function waitForExit(child, timeoutMs = 5000) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let stderr = '';
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
child.kill('SIGTERM');
|
|
46
|
+
reject(new Error(`timeout waiting for process exit\n${stderr}`));
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
timer.unref();
|
|
49
|
+
child.stderr.on('data', (chunk) => {
|
|
50
|
+
stderr += String(chunk);
|
|
51
|
+
});
|
|
52
|
+
child.on('exit', (code, signal) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve({ code, signal, stderr });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function waitForHealthJson(url, timeoutMs = 15000) {
|
|
60
|
+
const started = Date.now();
|
|
61
|
+
let lastError = '';
|
|
62
|
+
while (Date.now() - started < timeoutMs) {
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(url);
|
|
65
|
+
if (response.ok) return await response.json();
|
|
66
|
+
lastError = `${response.status} ${await response.text()}`;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
69
|
+
}
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`timeout waiting for ${url}\n${lastError}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function expectHttpTokenRequired(name, overrides = {}, options = {}) {
|
|
76
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), `codexflow-http-no-token-${name}-`));
|
|
77
|
+
const port = await getFreePort();
|
|
78
|
+
const env = {
|
|
79
|
+
...process.env,
|
|
80
|
+
CODEXFLOW_ROOT: root,
|
|
81
|
+
CODEXFLOW_ALLOWED_ROOTS: root,
|
|
82
|
+
CODEXFLOW_HOST: '127.0.0.1',
|
|
83
|
+
CODEXFLOW_PORT: String(port),
|
|
84
|
+
CODEXFLOW_BASH_MODE: 'safe',
|
|
85
|
+
CODEXFLOW_WRITE_MODE: 'handoff',
|
|
86
|
+
...overrides
|
|
87
|
+
};
|
|
88
|
+
delete env.CODEXFLOW_HTTP_TOKEN;
|
|
89
|
+
delete env.CODEBASE_BRIDGE_HTTP_TOKEN;
|
|
90
|
+
if (!options.keepAllowNoToken) delete env.CODEXFLOW_ALLOW_NO_HTTP_TOKEN;
|
|
91
|
+
|
|
92
|
+
const child = spawn('node', ['dist/http.js'], {
|
|
93
|
+
cwd: path.resolve('.'),
|
|
94
|
+
env,
|
|
95
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
96
|
+
});
|
|
97
|
+
const result = await waitForExit(child);
|
|
98
|
+
if (result.code === 0) {
|
|
99
|
+
throw new Error(`expected ${name} HTTP server without token to fail closed`);
|
|
100
|
+
}
|
|
101
|
+
if (!result.stderr.includes('CODEXFLOW_HTTP_TOKEN is required')) {
|
|
102
|
+
throw new Error(`expected ${name} missing-token failure, got:\n${result.stderr}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function listTools(url, token) {
|
|
107
|
+
const client = new Client({ name: 'codexflow-http-smoke', version: '0.0.0' });
|
|
108
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
109
|
+
requestInit: token ? { headers: { Authorization: `Bearer ${token}` } } : undefined
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
await client.connect(transport);
|
|
113
|
+
const result = await client.listTools();
|
|
114
|
+
return result.tools;
|
|
115
|
+
} finally {
|
|
116
|
+
await client.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toolNames(tools) {
|
|
121
|
+
return tools.map((tool) => tool.name);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hasWidgetMeta(tools, name, uri) {
|
|
125
|
+
const tool = tools.find((item) => item.name === name);
|
|
126
|
+
const meta = tool?._meta ?? {};
|
|
127
|
+
return meta.ui?.resourceUri === uri && meta['openai/outputTemplate'] === uri;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasToolCardStatusMeta(tools, name) {
|
|
131
|
+
const tool = tools.find((item) => item.name === name);
|
|
132
|
+
const meta = tool?._meta ?? {};
|
|
133
|
+
return Boolean(meta['openai/toolInvocation/invoking'] || meta['openai/toolInvocation/invoked']);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await expectHttpTokenRequired('loopback-default');
|
|
137
|
+
await expectHttpTokenRequired('non-loopback', { CODEXFLOW_HOST: '0.0.0.0' });
|
|
138
|
+
await expectHttpTokenRequired('non-loopback-allow-no-token', { CODEXFLOW_HOST: '0.0.0.0', CODEXFLOW_ALLOW_NO_HTTP_TOKEN: '1' }, { keepAllowNoToken: true });
|
|
139
|
+
await expectHttpTokenRequired('tunnel-mode', { CODEXFLOW_TUNNEL_MODE: '1' });
|
|
140
|
+
|
|
141
|
+
async function withClient(url, fn) {
|
|
142
|
+
const client = new Client({ name: 'codexflow-http-smoke', version: '0.0.0' });
|
|
143
|
+
const transport = new StreamableHTTPClientTransport(new URL(url));
|
|
144
|
+
try {
|
|
145
|
+
await client.connect(transport);
|
|
146
|
+
return await fn(client, transport);
|
|
147
|
+
} finally {
|
|
148
|
+
await client.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function callTool(client, name, args = {}) {
|
|
153
|
+
const result = await client.callTool({ name, arguments: args });
|
|
154
|
+
if (result.isError) {
|
|
155
|
+
const text = result.content?.find?.((part) => part.type === 'text')?.text ?? JSON.stringify(result.structuredContent);
|
|
156
|
+
throw new Error(`${name} failed: ${text}`);
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function expectSessionNotFound(response, label) {
|
|
162
|
+
const body = await response.json();
|
|
163
|
+
if (
|
|
164
|
+
response.status !== 404 ||
|
|
165
|
+
!response.headers.get('content-type')?.includes('application/json') ||
|
|
166
|
+
body.error?.code !== -32001 ||
|
|
167
|
+
body.error?.message !== 'Session not found'
|
|
168
|
+
) {
|
|
169
|
+
throw new Error(`expected ${label} to return JSON-RPC session-not-found 404, got ${response.status} ${JSON.stringify(body)}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function postToolsListWithSession(baseUrl, token, sessionId) {
|
|
174
|
+
return fetch(`${baseUrl}/mcp?codexflow_token=${encodeURIComponent(token)}`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: {
|
|
177
|
+
accept: 'application/json, text/event-stream',
|
|
178
|
+
'content-type': 'application/json',
|
|
179
|
+
'mcp-session-id': sessionId
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 404, method: 'tools/list', params: {} })
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-http-smoke-'));
|
|
186
|
+
const alternateProject = path.join(root, 'alternate-project');
|
|
187
|
+
await fs.mkdir(alternateProject, { recursive: true });
|
|
188
|
+
await fs.writeFile(path.join(alternateProject, 'package.json'), '{"name":"alternate-project"}\n', 'utf8');
|
|
189
|
+
await fs.writeFile(path.join(alternateProject, 'routing.txt'), 'alternate-chat-binding\n', 'utf8');
|
|
190
|
+
await fs.writeFile(path.join(root, 'routing.txt'), 'default-chat-binding\n', 'utf8');
|
|
191
|
+
const profileHome = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-http-profile-home-'));
|
|
192
|
+
await fs.mkdir(path.join(root, '.codex', 'skills', 'http-smoke-skill'), { recursive: true });
|
|
193
|
+
await fs.writeFile(path.join(root, '.codex', 'skills', 'http-smoke-skill', 'SKILL.md'), [
|
|
194
|
+
'---',
|
|
195
|
+
'name: http-smoke-skill',
|
|
196
|
+
'description: HTTP smoke test skill discovery.',
|
|
197
|
+
'---',
|
|
198
|
+
'',
|
|
199
|
+
'# HTTP Smoke Skill',
|
|
200
|
+
''
|
|
201
|
+
].join('\n'), 'utf8');
|
|
202
|
+
const port = await getFreePort();
|
|
203
|
+
const genericPort = await getFreePort();
|
|
204
|
+
const token = 'codexflow-http-smoke-token';
|
|
205
|
+
const runtimeQuerySecret = 'runtimequerysecret1234567890';
|
|
206
|
+
const runtimeAccessSecret = 'runtimeaccesssecret1234567890';
|
|
207
|
+
const runtimeCloudflareSecret = 'eyJhbGciOiJIUzI1NiJ9.eyJ0dW5uZWwiOiJodHRwLXNtb2tlIn0.signature1234567890';
|
|
208
|
+
const staleCloudflareToken = 'eyJhbGciOiJIUzI1NiJ9.eyJ0dW5uZWwiOiJzdGFsZS1odHRwLXNtb2tlIn0.signature1234567890';
|
|
209
|
+
const runtimeId = createHash('sha256').update(root).digest('hex').slice(0, 24);
|
|
210
|
+
await fs.mkdir(path.join(profileHome, 'runtime'), { recursive: true });
|
|
211
|
+
await fs.writeFile(path.join(profileHome, 'runtime', `${runtimeId}.json`), JSON.stringify({
|
|
212
|
+
version: 1,
|
|
213
|
+
root,
|
|
214
|
+
endpoint: `https://runtime.example/mcp?token=${runtimeQuerySecret}`,
|
|
215
|
+
localStatusUrl: `http://127.0.0.1:${port}/?codexflow_token=${token}&access_token=${runtimeAccessSecret}`,
|
|
216
|
+
note: `cloudflared tunnel run --token ${runtimeCloudflareSecret}`
|
|
217
|
+
}, null, 2), 'utf8');
|
|
218
|
+
const child = spawn('node', ['dist/http.js'], {
|
|
219
|
+
cwd: path.resolve('.'),
|
|
220
|
+
env: {
|
|
221
|
+
...process.env,
|
|
222
|
+
HOST: '0.0.0.0',
|
|
223
|
+
PORT: String(genericPort),
|
|
224
|
+
CODEXFLOW_ROOT: root,
|
|
225
|
+
CODEXFLOW_ALLOWED_ROOTS: root,
|
|
226
|
+
CODEXFLOW_HOST: '127.0.0.1',
|
|
227
|
+
CODEXFLOW_PORT: String(port),
|
|
228
|
+
CODEXFLOW_HTTP_TOKEN: token,
|
|
229
|
+
CODEXFLOW_BASH_MODE: 'safe',
|
|
230
|
+
CODEXFLOW_WRITE_MODE: 'handoff',
|
|
231
|
+
CODEXFLOW_TOOL_MODE: 'full',
|
|
232
|
+
CODEXFLOW_TOOL_CARDS: '0',
|
|
233
|
+
CODEXFLOW_WIDGET_DOMAIN: 'https://widgets.codexflow.test',
|
|
234
|
+
CODEXFLOW_HOME: profileHome
|
|
235
|
+
},
|
|
236
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await waitForListening(child);
|
|
241
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
242
|
+
|
|
243
|
+
const unauthorized = await fetch(`${baseUrl}/healthz`);
|
|
244
|
+
if (unauthorized.status !== 401) {
|
|
245
|
+
throw new Error(`expected unauthenticated healthz to return 401, got ${unauthorized.status}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const authorized = await fetch(`${baseUrl}/healthz`, {
|
|
249
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
250
|
+
});
|
|
251
|
+
if (authorized.status !== 200) {
|
|
252
|
+
throw new Error(`expected authenticated healthz to return 200, got ${authorized.status}`);
|
|
253
|
+
}
|
|
254
|
+
const authorizedJson = await authorized.json();
|
|
255
|
+
if (authorizedJson.authRequired !== true) {
|
|
256
|
+
throw new Error(`expected authenticated healthz to report authRequired=true, got ${JSON.stringify(authorizedJson)}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const header of [`bearer ${token}`, `Bearer ${token}`]) {
|
|
260
|
+
const variant = await fetch(`${baseUrl}/healthz`, {
|
|
261
|
+
headers: { Authorization: header }
|
|
262
|
+
});
|
|
263
|
+
if (variant.status !== 200) {
|
|
264
|
+
throw new Error(`expected authorization header variant ${JSON.stringify(header)} to return 200, got ${variant.status}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const queryAuthorized = await fetch(`${baseUrl}/healthz?codexflow_token=${encodeURIComponent(token)}`);
|
|
269
|
+
if (queryAuthorized.status !== 200) {
|
|
270
|
+
throw new Error(`expected URL-token healthz to return 200, got ${queryAuthorized.status}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const badAdminJson = await fetch(`${baseUrl}/admin/profile?codexflow_token=${encodeURIComponent(token)}`, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: { 'content-type': 'application/json' },
|
|
276
|
+
body: '{"tunnel":'
|
|
277
|
+
});
|
|
278
|
+
const badAdminBody = await badAdminJson.json();
|
|
279
|
+
if (badAdminJson.status !== 400 || badAdminBody.error?.code !== 'invalid_json') {
|
|
280
|
+
throw new Error(`expected invalid admin JSON to return structured 400, got ${badAdminJson.status} ${JSON.stringify(badAdminBody)}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const badMcpJson = await fetch(`${baseUrl}/mcp?codexflow_token=${encodeURIComponent(token)}`, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: { 'content-type': 'application/json' },
|
|
286
|
+
body: '{"jsonrpc":'
|
|
287
|
+
});
|
|
288
|
+
const badMcpBody = await badMcpJson.json();
|
|
289
|
+
if (badMcpJson.status !== 400 || badMcpBody.error?.code !== -32700) {
|
|
290
|
+
throw new Error(`expected invalid MCP JSON to return JSON-RPC parse error, got ${badMcpJson.status} ${JSON.stringify(badMcpBody)}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const hugeMcpJson = await fetch(`${baseUrl}/mcp?codexflow_token=${encodeURIComponent(token)}`, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: { 'content-type': 'application/json' },
|
|
296
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { filler: 'x'.repeat(21 * 1024 * 1024) } })
|
|
297
|
+
});
|
|
298
|
+
const hugeMcpBody = await hugeMcpJson.json();
|
|
299
|
+
if (hugeMcpJson.status !== 413 || hugeMcpBody.error?.code !== -32000) {
|
|
300
|
+
throw new Error(`expected oversized MCP body to return JSON-RPC payload error, got ${hugeMcpJson.status} ${JSON.stringify(hugeMcpBody)}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const favicon = await fetch(`${baseUrl}/favicon.ico`);
|
|
304
|
+
if (favicon.status !== 200 || !favicon.headers.get('content-type')?.includes('image/svg+xml')) {
|
|
305
|
+
throw new Error(`expected unauthenticated favicon to return SVG 200, got ${favicon.status} ${favicon.headers.get('content-type')}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const home = await fetch(`${baseUrl}/?codexflow_token=${encodeURIComponent(token)}`);
|
|
309
|
+
const homeText = await home.text();
|
|
310
|
+
if (home.status !== 200 || !home.headers.get('content-type')?.includes('text/html')) {
|
|
311
|
+
throw new Error(`expected authenticated onboarding page to return HTML 200, got ${home.status}`);
|
|
312
|
+
}
|
|
313
|
+
if (!homeText.includes('CodexFlow Local Control') || !homeText.includes('CLI controls') || !homeText.includes('Connect ChatGPT') || !homeText.includes('Runtime guardrails')) {
|
|
314
|
+
throw new Error('onboarding page did not include expected admin setup copy');
|
|
315
|
+
}
|
|
316
|
+
if (!homeText.includes('Connection profile') || !homeText.includes('data-profile-form')) {
|
|
317
|
+
throw new Error('onboarding page did not include the saved profile editor');
|
|
318
|
+
}
|
|
319
|
+
for (const fieldName of ['tunnelName', 'ngrokConfig', 'cloudflareConfig', 'cloudflareTokenFile', 'toolCards', 'noInstallCloudflared']) {
|
|
320
|
+
if (!homeText.includes(`name="${fieldName}"`)) {
|
|
321
|
+
throw new Error(`onboarding page did not include profile field ${fieldName}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (homeText.includes(token)) {
|
|
325
|
+
throw new Error('onboarding page leaked the raw auth token');
|
|
326
|
+
}
|
|
327
|
+
for (const leaked of [runtimeQuerySecret, runtimeAccessSecret, runtimeCloudflareSecret]) {
|
|
328
|
+
if (homeText.includes(leaked)) throw new Error(`onboarding page leaked runtime secret: ${leaked}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const profileBefore = await fetch(`${baseUrl}/admin/profile?codexflow_token=${encodeURIComponent(token)}`);
|
|
332
|
+
const profileBeforeJson = await profileBefore.json();
|
|
333
|
+
if (profileBefore.status !== 200 || profileBeforeJson.exists !== false) {
|
|
334
|
+
throw new Error(`expected empty admin profile response, got ${profileBefore.status} ${JSON.stringify(profileBeforeJson)}`);
|
|
335
|
+
}
|
|
336
|
+
if (JSON.stringify(profileBeforeJson).includes(token)) {
|
|
337
|
+
throw new Error('admin profile GET leaked the raw auth token');
|
|
338
|
+
}
|
|
339
|
+
for (const leaked of [runtimeQuerySecret, runtimeAccessSecret, runtimeCloudflareSecret]) {
|
|
340
|
+
if (JSON.stringify(profileBeforeJson).includes(leaked)) throw new Error(`admin profile GET leaked runtime secret: ${leaked}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const invalidProfile = await fetch(`${baseUrl}/admin/profile?codexflow_token=${encodeURIComponent(token)}`, {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers: { 'content-type': 'application/json' },
|
|
346
|
+
body: JSON.stringify({
|
|
347
|
+
tunnel: 'ngrok',
|
|
348
|
+
hostname: 'codexflow-http-smoke.ngrok-free.app',
|
|
349
|
+
requireBashSession: true,
|
|
350
|
+
bashSession: ''
|
|
351
|
+
})
|
|
352
|
+
});
|
|
353
|
+
if (invalidProfile.status !== 400) {
|
|
354
|
+
throw new Error(`expected invalid guarded profile to return 400, got ${invalidProfile.status}`);
|
|
355
|
+
}
|
|
356
|
+
await fs.mkdir(path.join(profileHome, 'profiles'), { recursive: true });
|
|
357
|
+
await fs.writeFile(path.join(profileHome, 'profiles', `${runtimeId}.json`), JSON.stringify({
|
|
358
|
+
version: 1,
|
|
359
|
+
root,
|
|
360
|
+
tunnel: 'cloudflare-named',
|
|
361
|
+
hostname: 'stale.example.com',
|
|
362
|
+
cloudflareToken: staleCloudflareToken,
|
|
363
|
+
cloudflareTokenFile: path.join(root, 'stale-cloudflare-token')
|
|
364
|
+
}, null, 2), 'utf8');
|
|
365
|
+
|
|
366
|
+
const profileSave = await fetch(`${baseUrl}/admin/profile?codexflow_token=${encodeURIComponent(token)}`, {
|
|
367
|
+
method: 'POST',
|
|
368
|
+
headers: { 'content-type': 'application/json' },
|
|
369
|
+
body: JSON.stringify({
|
|
370
|
+
tunnel: 'ngrok',
|
|
371
|
+
hostname: 'https://codexflow-http-smoke.ngrok-free.app/mcp',
|
|
372
|
+
port,
|
|
373
|
+
mode: 'agent',
|
|
374
|
+
bash: 'safe',
|
|
375
|
+
bashTranscript: 'full',
|
|
376
|
+
codexSessions: 'metadata',
|
|
377
|
+
codexDir: path.join(root, '.codex'),
|
|
378
|
+
bashSession: 'http-main',
|
|
379
|
+
requireBashSession: true,
|
|
380
|
+
write: 'workspace',
|
|
381
|
+
toolMode: 'full',
|
|
382
|
+
toolCards: true,
|
|
383
|
+
widgetDomain: 'https://widgets.codexflow.test',
|
|
384
|
+
ngrokConfig: path.join(root, 'ngrok.yml'),
|
|
385
|
+
cloudflareTokenFile: 'cloudflare-token',
|
|
386
|
+
noInstallCloudflared: true
|
|
387
|
+
})
|
|
388
|
+
});
|
|
389
|
+
const profileSaveJson = await profileSave.json();
|
|
390
|
+
if (profileSave.status !== 200 || profileSaveJson.saved !== true) {
|
|
391
|
+
throw new Error(`expected admin profile save to pass, got ${profileSave.status} ${JSON.stringify(profileSaveJson)}`);
|
|
392
|
+
}
|
|
393
|
+
if (JSON.stringify(profileSaveJson).includes(token)) {
|
|
394
|
+
throw new Error('admin profile save response leaked the raw auth token');
|
|
395
|
+
}
|
|
396
|
+
const savedProfile = JSON.parse(await fs.readFile(profileSaveJson.profile_path, 'utf8'));
|
|
397
|
+
if (
|
|
398
|
+
savedProfile.tunnel !== 'ngrok' ||
|
|
399
|
+
savedProfile.hostname !== 'codexflow-http-smoke.ngrok-free.app' ||
|
|
400
|
+
savedProfile.bashTranscript !== 'full' ||
|
|
401
|
+
savedProfile.codexSessions !== 'metadata' ||
|
|
402
|
+
savedProfile.bashSession !== 'http-main' ||
|
|
403
|
+
savedProfile.requireBashSession !== true ||
|
|
404
|
+
savedProfile.toolCards !== true ||
|
|
405
|
+
savedProfile.ngrokConfig !== path.join(root, 'ngrok.yml') ||
|
|
406
|
+
savedProfile.noInstallCloudflared !== true ||
|
|
407
|
+
savedProfile.token !== token
|
|
408
|
+
) {
|
|
409
|
+
throw new Error(`admin profile save wrote unexpected profile: ${JSON.stringify(savedProfile)}`);
|
|
410
|
+
}
|
|
411
|
+
if (savedProfile.cloudflareToken || savedProfile.cloudflareTokenFile) {
|
|
412
|
+
throw new Error(`admin profile save kept cloudflare token config on ngrok profile: ${JSON.stringify(savedProfile)}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const localProfile = await fetch(`${baseUrl}/admin/profile?codexflow_token=${encodeURIComponent(token)}`, {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers: { 'content-type': 'application/json' },
|
|
418
|
+
body: JSON.stringify({ tunnel: 'none' })
|
|
419
|
+
});
|
|
420
|
+
const localProfileJson = await localProfile.json();
|
|
421
|
+
const localSavedProfile = JSON.parse(await fs.readFile(localProfileJson.profile_path, 'utf8'));
|
|
422
|
+
if (
|
|
423
|
+
localProfile.status !== 200 ||
|
|
424
|
+
localSavedProfile.hostname ||
|
|
425
|
+
localSavedProfile.ngrokConfig ||
|
|
426
|
+
localSavedProfile.tunnelName ||
|
|
427
|
+
localSavedProfile.cloudflareConfig ||
|
|
428
|
+
localSavedProfile.cloudflareToken ||
|
|
429
|
+
localSavedProfile.cloudflareTokenFile ||
|
|
430
|
+
localProfileJson.profile?.hostname ||
|
|
431
|
+
localProfileJson.profile?.ngrokConfig ||
|
|
432
|
+
localProfileJson.profile?.cloudflareToken ||
|
|
433
|
+
localProfileJson.profile?.cloudflareTokenFile ||
|
|
434
|
+
localProfileJson.effective?.hostname ||
|
|
435
|
+
localProfileJson.effective?.ngrokConfig ||
|
|
436
|
+
localProfileJson.effective?.cloudflareToken ||
|
|
437
|
+
localProfileJson.effective?.cloudflareTokenFile
|
|
438
|
+
) {
|
|
439
|
+
throw new Error(`admin profile local-only save kept stale tunnel config: ${JSON.stringify(localProfileJson)} ${JSON.stringify(localSavedProfile)}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const queryTools = await listTools(`${baseUrl}/mcp?codexflow_token=${encodeURIComponent(token)}`);
|
|
443
|
+
const queryToolNames = toolNames(queryTools);
|
|
444
|
+
for (const expected of ['server_config', 'codexflow_self_test', 'codexflow_inventory', 'list_projects', 'select_project', 'open_current_workspace', 'open_workspace', 'workspace_snapshot', 'tree', 'search', 'load_skill', 'git_status', 'git_diff', 'show_changes', 'read_handoff', 'wait_for_handoff', 'codex_context', 'handoff_to_agent', 'handoff_to_codex', 'export_pro_context']) {
|
|
445
|
+
if (!queryToolNames.includes(expected)) {
|
|
446
|
+
throw new Error(`URL-token MCP tools/list missing ${expected}; got ${queryToolNames.join(', ')}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const hidden of ['write', 'edit']) {
|
|
450
|
+
if (queryToolNames.includes(hidden)) {
|
|
451
|
+
throw new Error(`HTTP handoff mode should not advertise ${hidden}; got ${queryToolNames.join(', ')}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const toolCardUri = 'ui://widget/codexflow-tool-card-v10.html';
|
|
455
|
+
for (const visualTool of queryToolNames) {
|
|
456
|
+
if (visualTool === 'list_projects' || visualTool === 'select_project') {
|
|
457
|
+
if (!hasWidgetMeta(queryTools, visualTool, toolCardUri)) throw new Error(`${visualTool} did not expose its required project picker widget`);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (hasWidgetMeta(queryTools, visualTool, toolCardUri) || hasToolCardStatusMeta(queryTools, visualTool)) {
|
|
461
|
+
throw new Error(`${visualTool} exposed widget metadata while CODEXFLOW_TOOL_CARDS is off`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const headerTools = await listTools(`${baseUrl}/mcp`, token);
|
|
466
|
+
const headerToolNames = toolNames(headerTools);
|
|
467
|
+
if (!headerToolNames.includes('server_config')) {
|
|
468
|
+
throw new Error(`bearer MCP tools/list missing server_config; got ${headerToolNames.join(', ')}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const mcpUrl = `${baseUrl}/mcp?codexflow_token=${encodeURIComponent(token)}`;
|
|
472
|
+
const unknownSession = '00000000-0000-4000-8000-000000000000';
|
|
473
|
+
await expectSessionNotFound(await postToolsListWithSession(baseUrl, token, unknownSession), 'unknown POST session');
|
|
474
|
+
await expectSessionNotFound(await fetch(`${baseUrl}/mcp?codexflow_token=${encodeURIComponent(token)}`, {
|
|
475
|
+
headers: {
|
|
476
|
+
accept: 'text/event-stream',
|
|
477
|
+
'mcp-session-id': unknownSession
|
|
478
|
+
}
|
|
479
|
+
}), 'unknown GET session');
|
|
480
|
+
await withClient(mcpUrl, async (client, transport) => {
|
|
481
|
+
await client.listTools();
|
|
482
|
+
const staleSession = transport.sessionId;
|
|
483
|
+
if (!staleSession) throw new Error('HTTP MCP client did not receive a session id');
|
|
484
|
+
await transport.terminateSession();
|
|
485
|
+
await expectSessionNotFound(await postToolsListWithSession(baseUrl, token, staleSession), 'stale POST session');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await withClient(mcpUrl, async (client) => {
|
|
489
|
+
const resources = await client.listResources();
|
|
490
|
+
const toolCard = resources.resources.find((resource) => resource.uri === toolCardUri);
|
|
491
|
+
if (!toolCard) throw new Error(`HTTP MCP resources/list missing ${toolCardUri}`);
|
|
492
|
+
if (toolCard.mimeType !== 'text/html;profile=mcp-app') {
|
|
493
|
+
throw new Error(`unexpected HTTP tool-card mime type: ${toolCard.mimeType}`);
|
|
494
|
+
}
|
|
495
|
+
const legacyToolCardUri = 'ui://widget/codexflow-tool-card-v8.html';
|
|
496
|
+
const legacyToolCard = resources.resources.find((resource) => resource.uri === legacyToolCardUri);
|
|
497
|
+
if (!legacyToolCard) throw new Error(`HTTP MCP resources/list missing legacy ${legacyToolCardUri}`);
|
|
498
|
+
const widget = await client.readResource({ uri: toolCardUri });
|
|
499
|
+
const widgetText = widget.contents?.[0]?.text ?? '';
|
|
500
|
+
const widgetMeta = widget.contents?.[0]?._meta ?? {};
|
|
501
|
+
if (!widgetText.includes('Waiting for tool result') || !widgetText.includes('renderWorkspace') || !widgetText.includes('renderProjects') || !widgetText.includes('callTool("select_project"') || !widgetText.includes('renderSelfTest') || !widgetText.includes('details class="fold"') || !widgetText.includes('ui/notifications/tool-result')) {
|
|
502
|
+
throw new Error('HTTP tool-card widget resource did not include expected Apps bridge code');
|
|
503
|
+
}
|
|
504
|
+
if (!widgetMeta.ui?.csp || !widgetMeta['openai/widgetCSP']) {
|
|
505
|
+
throw new Error('HTTP tool-card widget resource did not expose standard and ChatGPT CSP metadata');
|
|
506
|
+
}
|
|
507
|
+
if (widgetMeta.ui?.domain !== 'https://widgets.codexflow.test' || widgetMeta['openai/widgetDomain'] !== 'https://widgets.codexflow.test') {
|
|
508
|
+
throw new Error('HTTP tool-card widget resource did not expose standard and ChatGPT widget domain metadata');
|
|
509
|
+
}
|
|
510
|
+
const legacyWidget = await client.readResource({ uri: legacyToolCardUri });
|
|
511
|
+
if (legacyWidget.contents?.[0]?.uri !== legacyToolCardUri) {
|
|
512
|
+
throw new Error('HTTP legacy tool-card widget resource did not preserve requested URI');
|
|
513
|
+
}
|
|
514
|
+
if (!(legacyWidget.contents?.[0]?.text ?? '').includes('Waiting for tool result')) {
|
|
515
|
+
throw new Error('HTTP legacy tool-card widget resource did not serve widget HTML');
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const currentOpened = await withClient(mcpUrl, async (client) => {
|
|
520
|
+
const result = await callTool(client, 'open_current_workspace', { include_tree: false });
|
|
521
|
+
if (result.structuredContent.codexflow_tool !== 'open_current_workspace') {
|
|
522
|
+
throw new Error('HTTP tool result was not tagged for widget rendering');
|
|
523
|
+
}
|
|
524
|
+
if (result.structuredContent.tool_mode !== 'full') {
|
|
525
|
+
throw new Error(`open_current_workspace did not expose tool_mode: ${result.structuredContent.tool_mode}`);
|
|
526
|
+
}
|
|
527
|
+
if (result.structuredContent.skill_inventory?.length) {
|
|
528
|
+
throw new Error('HTTP open_current_workspace discovered skills by default');
|
|
529
|
+
}
|
|
530
|
+
const withSkills = await callTool(client, 'open_current_workspace', {
|
|
531
|
+
include_tree: false,
|
|
532
|
+
include_skills: true
|
|
533
|
+
});
|
|
534
|
+
if (!withSkills.structuredContent.skill_inventory?.some?.((skill) => skill.name === 'http-smoke-skill')) {
|
|
535
|
+
throw new Error('HTTP open_current_workspace did not discover workspace skill inventory when requested');
|
|
536
|
+
}
|
|
537
|
+
return result.structuredContent.workspace_id;
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const bindingClientA = new Client({ name: 'codexflow-binding-a', version: '0.0.0' });
|
|
541
|
+
const bindingClientB = new Client({ name: 'codexflow-binding-b', version: '0.0.0' });
|
|
542
|
+
const bindingTransportA = new StreamableHTTPClientTransport(new URL(mcpUrl));
|
|
543
|
+
const bindingTransportB = new StreamableHTTPClientTransport(new URL(mcpUrl));
|
|
544
|
+
try {
|
|
545
|
+
await Promise.all([bindingClientA.connect(bindingTransportA), bindingClientB.connect(bindingTransportB)]);
|
|
546
|
+
const [catalogA, catalogB] = await Promise.all([
|
|
547
|
+
callTool(bindingClientA, 'list_projects', { refresh: true }),
|
|
548
|
+
callTool(bindingClientB, 'list_projects')
|
|
549
|
+
]);
|
|
550
|
+
const alternate = catalogA.structuredContent.projects.find((project) => project.name === 'alternate-project');
|
|
551
|
+
const primary = catalogB.structuredContent.projects.find((project) => project.sources?.includes?.('default'));
|
|
552
|
+
if (!alternate || !primary) throw new Error(`project catalogs did not contain both routing targets: ${JSON.stringify(catalogA.structuredContent)}`);
|
|
553
|
+
await Promise.all([
|
|
554
|
+
callTool(bindingClientA, 'select_project', { project_id: alternate.project_id, include_tree: false }),
|
|
555
|
+
callTool(bindingClientB, 'select_project', { project_id: primary.project_id, include_tree: false })
|
|
556
|
+
]);
|
|
557
|
+
const [readA, readB] = await Promise.all([
|
|
558
|
+
callTool(bindingClientA, 'read', { path: 'routing.txt' }),
|
|
559
|
+
callTool(bindingClientB, 'read', { path: 'routing.txt' })
|
|
560
|
+
]);
|
|
561
|
+
if (!readA.structuredContent.text?.includes('alternate-chat-binding')) throw new Error('chat A did not retain its selected project binding');
|
|
562
|
+
if (!readB.structuredContent.text?.includes('default-chat-binding')) throw new Error('chat B did not retain an independent project binding');
|
|
563
|
+
} finally {
|
|
564
|
+
await Promise.allSettled([bindingClientA.close(), bindingClientB.close()]);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
await withClient(mcpUrl, async (client) => {
|
|
568
|
+
const result = await callTool(client, 'open_workspace', {
|
|
569
|
+
root,
|
|
570
|
+
include_tree: false
|
|
571
|
+
});
|
|
572
|
+
if (result.structuredContent.skill_inventory?.length) {
|
|
573
|
+
throw new Error('HTTP open_workspace discovered skills by default');
|
|
574
|
+
}
|
|
575
|
+
const withSkills = await callTool(client, 'open_workspace', {
|
|
576
|
+
root,
|
|
577
|
+
include_tree: false,
|
|
578
|
+
include_skills: true
|
|
579
|
+
});
|
|
580
|
+
if (!withSkills.structuredContent.skill_inventory?.some?.((skill) => skill.name === 'http-smoke-skill')) {
|
|
581
|
+
throw new Error('HTTP open_workspace did not discover workspace skill inventory when requested');
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
await withClient(mcpUrl, async (client) => {
|
|
586
|
+
const inventory = await callTool(client, 'codexflow_inventory', {
|
|
587
|
+
include_global_skills: false,
|
|
588
|
+
include_mcp_servers: false
|
|
589
|
+
});
|
|
590
|
+
if (inventory.structuredContent.codexflow_tool !== 'codexflow_inventory') {
|
|
591
|
+
throw new Error('HTTP inventory result was not tagged for widget rendering');
|
|
592
|
+
}
|
|
593
|
+
const loadedSkill = await callTool(client, 'load_skill', {
|
|
594
|
+
name: 'http-smoke-skill',
|
|
595
|
+
source: 'workspace'
|
|
596
|
+
});
|
|
597
|
+
if (loadedSkill.structuredContent.skill?.name !== 'http-smoke-skill' || !loadedSkill.structuredContent.text?.includes('# HTTP Smoke Skill')) {
|
|
598
|
+
throw new Error('HTTP load_skill did not return bounded SKILL.md content');
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const opened = await withClient(mcpUrl, async (client) => {
|
|
603
|
+
const result = await callTool(client, 'open_workspace', { include_tree: false });
|
|
604
|
+
return result.structuredContent.workspace_id;
|
|
605
|
+
});
|
|
606
|
+
if (opened !== currentOpened) {
|
|
607
|
+
throw new Error(`open_current_workspace returned ${currentOpened}, open_workspace default returned ${opened}`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
await withClient(mcpUrl, async (client) => {
|
|
611
|
+
const list = await callTool(client, 'list_workspaces');
|
|
612
|
+
const ids = list.structuredContent.workspaces.map((workspace) => workspace.id);
|
|
613
|
+
if (!ids.includes(opened)) {
|
|
614
|
+
throw new Error(`cross-session list_workspaces missing ${opened}; got ${ids.join(', ')}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const snapshot = await callTool(client, 'workspace_snapshot', { workspace_id: opened, max_depth: 1 });
|
|
618
|
+
if (snapshot.structuredContent.workspace_id !== opened) {
|
|
619
|
+
throw new Error(`workspace_snapshot returned ${snapshot.structuredContent.workspace_id}, expected ${opened}`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const tree = await callTool(client, 'tree', { workspace_id: opened, max_depth: 1, max_entries: 10 });
|
|
623
|
+
if (tree.structuredContent.workspace_id !== opened) {
|
|
624
|
+
throw new Error(`tree returned ${tree.structuredContent.workspace_id}, expected ${opened}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const codexContext = await callTool(client, 'codex_context', { workspace_id: opened });
|
|
628
|
+
if (codexContext.structuredContent.workspace_id !== opened) {
|
|
629
|
+
throw new Error(`codex_context returned ${codexContext.structuredContent.workspace_id}, expected ${opened}`);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
await fs.stat(path.join(root, '.ai-bridge'));
|
|
635
|
+
throw new Error('read-only HTTP smoke path created .ai-bridge unexpectedly');
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
await withClient(mcpUrl, async (client) => {
|
|
641
|
+
const exported = await callTool(client, 'export_pro_context', {
|
|
642
|
+
workspace_id: opened,
|
|
643
|
+
max_files: 4,
|
|
644
|
+
max_total_bytes: 80000
|
|
645
|
+
});
|
|
646
|
+
if (exported.structuredContent.path !== '.ai-bridge/pro-context.md') {
|
|
647
|
+
throw new Error(`unexpected pro context path: ${exported.structuredContent.path}`);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
await fs.stat(path.join(root, '.ai-bridge', 'pro-context.md'));
|
|
651
|
+
} finally {
|
|
652
|
+
child.kill('SIGTERM');
|
|
653
|
+
await waitForExit(child).catch(() => {});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const disabledRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-http-disabled-tools-'));
|
|
657
|
+
const disabledPort = await getFreePort();
|
|
658
|
+
const disabledToken = 'codexflow-http-disabled-token';
|
|
659
|
+
const disabledChild = spawn('node', ['dist/http.js'], {
|
|
660
|
+
cwd: path.resolve('.'),
|
|
661
|
+
env: {
|
|
662
|
+
...process.env,
|
|
663
|
+
CODEXFLOW_ROOT: disabledRoot,
|
|
664
|
+
CODEXFLOW_ALLOWED_ROOTS: disabledRoot,
|
|
665
|
+
CODEXFLOW_PORT: String(disabledPort),
|
|
666
|
+
CODEXFLOW_HTTP_TOKEN: disabledToken,
|
|
667
|
+
CODEXFLOW_BASH_MODE: 'off',
|
|
668
|
+
CODEXFLOW_WRITE_MODE: 'off',
|
|
669
|
+
CODEXFLOW_TOOL_MODE: 'full'
|
|
670
|
+
},
|
|
671
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
672
|
+
});
|
|
673
|
+
try {
|
|
674
|
+
await waitForListening(disabledChild);
|
|
675
|
+
const disabledBase = `http://127.0.0.1:${disabledPort}`;
|
|
676
|
+
const disabledTools = await listTools(`${disabledBase}/mcp?codexflow_token=${encodeURIComponent(disabledToken)}`);
|
|
677
|
+
const disabledToolNames = toolNames(disabledTools);
|
|
678
|
+
for (const hiddenTool of ['bash', 'write', 'edit']) {
|
|
679
|
+
if (disabledToolNames.includes(hiddenTool)) {
|
|
680
|
+
throw new Error(`HTTP disabled mode should not advertise ${hiddenTool}; got ${disabledToolNames.join(', ')}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
await withClient(`${disabledBase}/mcp?codexflow_token=${encodeURIComponent(disabledToken)}`, async (client) => {
|
|
684
|
+
const config = await callTool(client, 'server_config');
|
|
685
|
+
if (config.structuredContent.bashMode !== 'off' || config.structuredContent.writeMode !== 'off') {
|
|
686
|
+
throw new Error(`HTTP disabled mode server_config mismatch: ${JSON.stringify(config.structuredContent)}`);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
} finally {
|
|
690
|
+
disabledChild.kill('SIGTERM');
|
|
691
|
+
await waitForExit(disabledChild).catch(() => {});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const cliRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-cli-http-smoke-'));
|
|
695
|
+
await fs.mkdir(path.join(cliRoot, '.codex'), { recursive: true });
|
|
696
|
+
const cliPort = await getFreePort();
|
|
697
|
+
const badNoAuth = spawn(process.execPath, [
|
|
698
|
+
'scripts/codexflow.mjs',
|
|
699
|
+
'start',
|
|
700
|
+
'--root',
|
|
701
|
+
cliRoot,
|
|
702
|
+
'--tunnel',
|
|
703
|
+
'none',
|
|
704
|
+
'--no-auth',
|
|
705
|
+
'--host',
|
|
706
|
+
'0.0.0.0',
|
|
707
|
+
'--port',
|
|
708
|
+
String(cliPort)
|
|
709
|
+
], {
|
|
710
|
+
cwd: path.resolve('.'),
|
|
711
|
+
env: {
|
|
712
|
+
...process.env,
|
|
713
|
+
CODEXFLOW_HOME: await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-cli-http-bad-home-'))
|
|
714
|
+
},
|
|
715
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
716
|
+
});
|
|
717
|
+
const badNoAuthExit = await waitForExit(badNoAuth);
|
|
718
|
+
if (badNoAuthExit.code === 0 || !badNoAuthExit.stderr.includes('--no-auth is only allowed')) {
|
|
719
|
+
throw new Error(`non-loopback --no-auth was not rejected\n${badNoAuthExit.stderr}`);
|
|
720
|
+
}
|
|
721
|
+
const cliChild = spawn(process.execPath, [
|
|
722
|
+
'scripts/codexflow.mjs',
|
|
723
|
+
'start',
|
|
724
|
+
'--root',
|
|
725
|
+
cliRoot,
|
|
726
|
+
'--tunnel',
|
|
727
|
+
'none',
|
|
728
|
+
'--no-auth',
|
|
729
|
+
'--port',
|
|
730
|
+
String(cliPort),
|
|
731
|
+
'--codex-sessions',
|
|
732
|
+
'metadata',
|
|
733
|
+
'--codex-dir',
|
|
734
|
+
'.codex'
|
|
735
|
+
], {
|
|
736
|
+
cwd: path.resolve('.'),
|
|
737
|
+
env: {
|
|
738
|
+
...process.env,
|
|
739
|
+
CODEXFLOW_HOME: await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-cli-http-home-'))
|
|
740
|
+
},
|
|
741
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
742
|
+
});
|
|
743
|
+
try {
|
|
744
|
+
await waitForHealthJson(`http://127.0.0.1:${cliPort}/healthz`);
|
|
745
|
+
const expectedCliCodexDir = path.join(await fs.realpath(cliRoot), '.codex');
|
|
746
|
+
await withClient(`http://127.0.0.1:${cliPort}/mcp`, async (client) => {
|
|
747
|
+
const config = await callTool(client, 'server_config');
|
|
748
|
+
if (config.structuredContent.codexDir !== expectedCliCodexDir) {
|
|
749
|
+
throw new Error(`relative --codex-dir resolved to ${config.structuredContent.codexDir}, expected ${expectedCliCodexDir}`);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
} finally {
|
|
753
|
+
cliChild.kill('SIGTERM');
|
|
754
|
+
await waitForExit(cliChild).catch(() => {});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const connectionTestPort = await getFreePort();
|
|
758
|
+
let connectionTestStderr = '';
|
|
759
|
+
const connectionTestChild = spawn(process.execPath, [
|
|
760
|
+
'scripts/codexflow.mjs',
|
|
761
|
+
'connection-test',
|
|
762
|
+
'--root',
|
|
763
|
+
cliRoot,
|
|
764
|
+
'--tunnel',
|
|
765
|
+
'none',
|
|
766
|
+
'--no-auth',
|
|
767
|
+
'--no-profile',
|
|
768
|
+
'--port',
|
|
769
|
+
String(connectionTestPort)
|
|
770
|
+
], {
|
|
771
|
+
cwd: path.resolve('.'),
|
|
772
|
+
env: {
|
|
773
|
+
...process.env,
|
|
774
|
+
CODEXFLOW_HOME: await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-connection-test-home-'))
|
|
775
|
+
},
|
|
776
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
777
|
+
});
|
|
778
|
+
connectionTestChild.stderr.on('data', (chunk) => {
|
|
779
|
+
connectionTestStderr += String(chunk);
|
|
780
|
+
});
|
|
781
|
+
try {
|
|
782
|
+
await waitForHealthJson(`http://127.0.0.1:${connectionTestPort}/healthz`);
|
|
783
|
+
const tools = await listTools(`http://127.0.0.1:${connectionTestPort}/mcp`);
|
|
784
|
+
const names = toolNames(tools);
|
|
785
|
+
for (const expected of ['read', 'tree', 'search', 'load_skill']) {
|
|
786
|
+
if (!names.includes(expected)) throw new Error(`connection-test missing ${expected}; got ${names.join(', ')}`);
|
|
787
|
+
}
|
|
788
|
+
for (const hidden of ['codexflow', 'codexflow_self_test', 'write', 'edit', 'apply_patch', 'bash', 'export_pro_context', 'handoff_to_agent', 'handoff_to_codex']) {
|
|
789
|
+
if (names.includes(hidden)) throw new Error(`connection-test exposed ${hidden}; got ${names.join(', ')}`);
|
|
790
|
+
}
|
|
791
|
+
for (const tool of tools) {
|
|
792
|
+
const annotations = tool.annotations ?? {};
|
|
793
|
+
if (annotations.readOnlyHint !== true || annotations.openWorldHint !== false || annotations.destructiveHint !== false) {
|
|
794
|
+
throw new Error(`connection-test exposed non-read-only annotations for ${tool.name}: ${JSON.stringify(annotations)}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
await withClient(`http://127.0.0.1:${connectionTestPort}/mcp`, async (client) => {
|
|
798
|
+
const config = await callTool(client, 'server_config');
|
|
799
|
+
if (config.structuredContent.connectionTest !== true || config.structuredContent.toolCards !== false) {
|
|
800
|
+
throw new Error(`unexpected connection-test config: ${JSON.stringify(config.structuredContent)}`);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
804
|
+
if (!connectionTestStderr.includes('[CodexFlow] POST /mcp received')) {
|
|
805
|
+
throw new Error(`connection-test did not print request-arrival logs\n${connectionTestStderr}`);
|
|
806
|
+
}
|
|
807
|
+
} finally {
|
|
808
|
+
connectionTestChild.kill('SIGTERM');
|
|
809
|
+
await waitForExit(connectionTestChild).catch(() => {});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
console.log('✓ http smoke test passed');
|