@jsonstudio/rcc 0.89.1503 → 0.89.1552
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/dist/build-info.js +3 -3
- package/dist/build-info.js.map +1 -1
- package/dist/cli/commands/code.js +46 -6
- package/dist/cli/commands/code.js.map +1 -1
- package/dist/cli/commands/start.js +5 -1
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/config/init-provider-catalog.js +63 -0
- package/dist/cli/config/init-provider-catalog.js.map +1 -1
- package/dist/cli.js +0 -0
- package/dist/commands/camoufox-fp.js +129 -1
- package/dist/commands/camoufox-fp.js.map +1 -1
- package/dist/commands/oauth.js +78 -1
- package/dist/commands/oauth.js.map +1 -1
- package/dist/docs/daemon-admin-ui.html +9 -1
- package/dist/manager/modules/quota/antigravity-quota-manager.js +1 -1
- package/dist/manager/modules/quota/antigravity-quota-manager.js.map +1 -1
- package/dist/modules/llmswitch/bridge.d.ts +2 -0
- package/dist/modules/llmswitch/bridge.js +39 -0
- package/dist/modules/llmswitch/bridge.js.map +1 -1
- package/dist/providers/auth/antigravity-fingerprint.d.ts +7 -0
- package/dist/providers/auth/antigravity-fingerprint.js +98 -0
- package/dist/providers/auth/antigravity-fingerprint.js.map +1 -0
- package/dist/providers/auth/antigravity-reauth-state.d.ts +13 -0
- package/dist/providers/auth/antigravity-reauth-state.js +103 -0
- package/dist/providers/auth/antigravity-reauth-state.js.map +1 -0
- package/dist/providers/auth/antigravity-user-agent.d.ts +27 -0
- package/dist/providers/auth/antigravity-user-agent.js +245 -0
- package/dist/providers/auth/antigravity-user-agent.js.map +1 -0
- package/dist/providers/auth/antigravity-userinfo-helper.d.ts +7 -2
- package/dist/providers/auth/antigravity-userinfo-helper.js +61 -16
- package/dist/providers/auth/antigravity-userinfo-helper.js.map +1 -1
- package/dist/providers/auth/antigravity-warmup.d.ts +32 -0
- package/dist/providers/auth/antigravity-warmup.js +254 -0
- package/dist/providers/auth/antigravity-warmup.js.map +1 -0
- package/dist/providers/auth/oauth-lifecycle.js +3 -3
- package/dist/providers/auth/oauth-lifecycle.js.map +1 -1
- package/dist/providers/core/config/camoufox-launcher.d.ts +5 -0
- package/dist/providers/core/config/camoufox-launcher.js +63 -8
- package/dist/providers/core/config/camoufox-launcher.js.map +1 -1
- package/dist/providers/core/runtime/antigravity-quota-client.d.ts +3 -1
- package/dist/providers/core/runtime/antigravity-quota-client.js +2 -2
- package/dist/providers/core/runtime/antigravity-quota-client.js.map +1 -1
- package/dist/providers/core/runtime/base-provider.d.ts +1 -0
- package/dist/providers/core/runtime/base-provider.js +31 -11
- package/dist/providers/core/runtime/base-provider.js.map +1 -1
- package/dist/providers/core/runtime/gemini-cli-http-provider.d.ts +2 -1
- package/dist/providers/core/runtime/gemini-cli-http-provider.js +113 -10
- package/dist/providers/core/runtime/gemini-cli-http-provider.js.map +1 -1
- package/dist/providers/core/runtime/http-request-executor.js +34 -10
- package/dist/providers/core/runtime/http-request-executor.js.map +1 -1
- package/dist/scripts/camoufox/gen-fingerprint-env.py +6 -2
- package/dist/server/runtime/http-server/daemon-admin/quota-handler.js +44 -3
- package/dist/server/runtime/http-server/daemon-admin/quota-handler.js.map +1 -1
- package/dist/server/runtime/http-server/index.js +86 -0
- package/dist/server/runtime/http-server/index.js.map +1 -1
- package/dist/server/runtime/http-server/request-executor.js +40 -0
- package/dist/server/runtime/http-server/request-executor.js.map +1 -1
- package/dist/server/runtime/http-server/routes.js +5 -2
- package/dist/server/runtime/http-server/routes.js.map +1 -1
- package/dist/token-portal/fingerprint-summary.d.ts +9 -0
- package/dist/token-portal/fingerprint-summary.js +84 -0
- package/dist/token-portal/fingerprint-summary.js.map +1 -0
- package/dist/token-portal/local-token-portal.js +4 -1
- package/dist/token-portal/local-token-portal.js.map +1 -1
- package/dist/token-portal/render.d.ts +8 -0
- package/dist/token-portal/render.js +22 -1
- package/dist/token-portal/render.js.map +1 -1
- package/dist/utils/log-helpers.js +28 -0
- package/dist/utils/log-helpers.js.map +1 -1
- package/docs/daemon-admin-ui.html +9 -1
- package/docs/providers/antigravity-fingerprint-ua-warmup.md +164 -0
- package/docs/providers/antigravity-gemini-provider-compat.md +317 -0
- package/docs/providers/gemini-provider.md +6 -0
- package/package.json +6 -5
- package/scripts/camoufox/gen-fingerprint-env.py +6 -2
- package/scripts/ci/repo-sanity.mjs +50 -0
- package/scripts/mock-provider/run-regressions.mjs +8 -0
- package/scripts/pack-mode.mjs +5 -1
- package/scripts/pack-rcc.mjs +41 -3
- package/scripts/publish-rcc.mjs +39 -2
- package/scripts/tests/blackbox-rcc-vs-routecodex-antigravity.mjs +1092 -0
- package/scripts/verify-install-e2e.mjs +4 -1
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Black-box parity test:
|
|
4
|
+
* - Always validate RouteCodex behavior against Antigravity-Manager invariants.
|
|
5
|
+
* - Optionally compare against rcc release (off by default) because rcc bundles a published @jsonstudio/llms
|
|
6
|
+
* which may intentionally lag behind dev during incident response.
|
|
7
|
+
* - Both point to a local mock Cloud Code Assist (v1internal) upstream.
|
|
8
|
+
* - Assert:
|
|
9
|
+
* - RouteCodex upstream requests are "clean" and match Antigravity-Manager signature semantics
|
|
10
|
+
* - (optional) client-visible responses are equivalent across RouteCodex and rcc
|
|
11
|
+
*
|
|
12
|
+
* This catches "dirty request" regressions and CLI divergence early.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
const MOCK_THOUGHT_SIGNATURE = `tsig-${'x'.repeat(80)}`; // >= 50 chars (Antigravity cache requires min length)
|
|
23
|
+
|
|
24
|
+
function sleep(ms) {
|
|
25
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function withTimeout(promise, ms, label) {
|
|
29
|
+
let t;
|
|
30
|
+
const timeout = new Promise((_, rej) => {
|
|
31
|
+
t = setTimeout(() => rej(new Error(`Timeout (${ms}ms): ${label}`)), ms);
|
|
32
|
+
});
|
|
33
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(t));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function jsonClone(value) {
|
|
37
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deepSortObject(value) {
|
|
41
|
+
if (!value || typeof value !== 'object') return value;
|
|
42
|
+
if (Array.isArray(value)) return value.map(deepSortObject);
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const k of Object.keys(value).sort()) out[k] = deepSortObject(value[k]);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function walkJson(value, fn) {
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
for (const item of value) walkJson(item, fn);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (value && typeof value === 'object') {
|
|
54
|
+
fn(value);
|
|
55
|
+
for (const v of Object.values(value)) walkJson(v, fn);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertNoUndefinedStrings(label, value) {
|
|
60
|
+
const hits = [];
|
|
61
|
+
walkJson(value, (node) => {
|
|
62
|
+
for (const [k, v] of Object.entries(node)) {
|
|
63
|
+
if (typeof v === 'string' && v.trim() === '[undefined]') hits.push(k);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
assert.equal(hits.length, 0, `${label}: found "[undefined]" string fields: ${hits.slice(0, 20).join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertNoForbiddenWrapperFields(label, raw) {
|
|
70
|
+
const top = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
71
|
+
const carrier =
|
|
72
|
+
top.request && typeof top.request === 'object' && !Array.isArray(top.request)
|
|
73
|
+
? top
|
|
74
|
+
: top.data && typeof top.data === 'object' && !Array.isArray(top.data) && top.data.request && typeof top.data.request === 'object'
|
|
75
|
+
? top.data
|
|
76
|
+
: top;
|
|
77
|
+
const inner =
|
|
78
|
+
carrier.request && typeof carrier.request === 'object' && !Array.isArray(carrier.request) ? carrier.request : {};
|
|
79
|
+
|
|
80
|
+
for (const key of ['metadata', 'web_search', 'messages', 'stream', 'sessionId', 'action']) {
|
|
81
|
+
assert.equal(Object.prototype.hasOwnProperty.call(top, key), false, `${label}: forbidden top-level ${key}`);
|
|
82
|
+
assert.equal(Object.prototype.hasOwnProperty.call(inner, key), false, `${label}: forbidden request.${key}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getFirstUserTextFromGeminiRequest(raw) {
|
|
87
|
+
const top = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
88
|
+
const carrier =
|
|
89
|
+
top.request && typeof top.request === 'object' && !Array.isArray(top.request)
|
|
90
|
+
? top
|
|
91
|
+
: top.data && typeof top.data === 'object' && !Array.isArray(top.data) && top.data.request && typeof top.data.request === 'object'
|
|
92
|
+
? top.data
|
|
93
|
+
: top;
|
|
94
|
+
const requestNode =
|
|
95
|
+
carrier.request && typeof carrier.request === 'object' && !Array.isArray(carrier.request) ? carrier.request : {};
|
|
96
|
+
const contents = Array.isArray(requestNode.contents) ? requestNode.contents : [];
|
|
97
|
+
for (const content of contents) {
|
|
98
|
+
if (!content || typeof content !== 'object') continue;
|
|
99
|
+
if (content.role !== 'user') continue;
|
|
100
|
+
const parts = Array.isArray(content.parts) ? content.parts : [];
|
|
101
|
+
const texts = [];
|
|
102
|
+
for (const part of parts) {
|
|
103
|
+
if (!part || typeof part !== 'object') continue;
|
|
104
|
+
if (typeof part.text === 'string' && part.text.trim()) texts.push(part.text.trim());
|
|
105
|
+
}
|
|
106
|
+
const combined = texts.join(' ').trim();
|
|
107
|
+
if (combined) return combined;
|
|
108
|
+
}
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractThoughtSignaturesFromGeminiRequest(raw) {
|
|
113
|
+
const hits = [];
|
|
114
|
+
const top = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
115
|
+
const carrier =
|
|
116
|
+
top.request && typeof top.request === 'object' && !Array.isArray(top.request)
|
|
117
|
+
? top
|
|
118
|
+
: top.data && typeof top.data === 'object' && !Array.isArray(top.data) && top.data.request && typeof top.data.request === 'object'
|
|
119
|
+
? top.data
|
|
120
|
+
: top;
|
|
121
|
+
const requestNode =
|
|
122
|
+
carrier.request && typeof carrier.request === 'object' && !Array.isArray(carrier.request) ? carrier.request : {};
|
|
123
|
+
const contents = Array.isArray(requestNode.contents) ? requestNode.contents : [];
|
|
124
|
+
for (const content of contents) {
|
|
125
|
+
if (!content || typeof content !== 'object') continue;
|
|
126
|
+
const parts = Array.isArray(content.parts) ? content.parts : [];
|
|
127
|
+
for (const part of parts) {
|
|
128
|
+
if (!part || typeof part !== 'object') continue;
|
|
129
|
+
if (!part.functionCall || typeof part.functionCall !== 'object') continue;
|
|
130
|
+
if (typeof part.thoughtSignature === 'string' && part.thoughtSignature.trim()) {
|
|
131
|
+
hits.push(part.thoughtSignature.trim());
|
|
132
|
+
} else {
|
|
133
|
+
hits.push('');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return hits;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractThoughtSignaturePresenceFromGeminiRequest(raw) {
|
|
141
|
+
const hits = [];
|
|
142
|
+
const top = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
143
|
+
const carrier =
|
|
144
|
+
top.request && typeof top.request === 'object' && !Array.isArray(top.request)
|
|
145
|
+
? top
|
|
146
|
+
: top.data && typeof top.data === 'object' && !Array.isArray(top.data) && top.data.request && typeof top.data.request === 'object'
|
|
147
|
+
? top.data
|
|
148
|
+
: top;
|
|
149
|
+
const requestNode =
|
|
150
|
+
carrier.request && typeof carrier.request === 'object' && !Array.isArray(carrier.request) ? carrier.request : {};
|
|
151
|
+
const contents = Array.isArray(requestNode.contents) ? requestNode.contents : [];
|
|
152
|
+
for (const content of contents) {
|
|
153
|
+
if (!content || typeof content !== 'object') continue;
|
|
154
|
+
const parts = Array.isArray(content.parts) ? content.parts : [];
|
|
155
|
+
for (const part of parts) {
|
|
156
|
+
if (!part || typeof part !== 'object') continue;
|
|
157
|
+
if (!part.functionCall || typeof part.functionCall !== 'object') continue;
|
|
158
|
+
hits.push(Object.prototype.hasOwnProperty.call(part, 'thoughtSignature'));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return hits;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeWriteJson(filePath, value) {
|
|
165
|
+
try {
|
|
166
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
167
|
+
} catch {
|
|
168
|
+
// ignore
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function canonicalizeUpstream(body) {
|
|
173
|
+
const cloned = jsonClone(body) || {};
|
|
174
|
+
// Antigravity v1internal wrapper: { project, requestId, request, model, userAgent, requestType }
|
|
175
|
+
delete cloned.requestId; // random per request
|
|
176
|
+
delete cloned.action; // never expected
|
|
177
|
+
// inner request:
|
|
178
|
+
if (cloned.request && typeof cloned.request === 'object' && !Array.isArray(cloned.request)) {
|
|
179
|
+
const inner = cloned.request;
|
|
180
|
+
delete inner.action;
|
|
181
|
+
delete inner.metadata;
|
|
182
|
+
delete inner.web_search;
|
|
183
|
+
delete inner.stream;
|
|
184
|
+
delete inner.sessionId;
|
|
185
|
+
// Normalize tool declarations order
|
|
186
|
+
if (Array.isArray(inner.tools)) {
|
|
187
|
+
inner.tools = inner.tools.map((t) => deepSortObject(t));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return deepSortObject(cloned);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function canonicalizeResponses(payload) {
|
|
194
|
+
const cloned = jsonClone(payload) || {};
|
|
195
|
+
// Remove typical non-deterministic ids/timestamps
|
|
196
|
+
delete cloned.id;
|
|
197
|
+
delete cloned.created;
|
|
198
|
+
delete cloned.created_at;
|
|
199
|
+
delete cloned.system_fingerprint;
|
|
200
|
+
delete cloned.request_id;
|
|
201
|
+
// Some conversions include nested response id
|
|
202
|
+
if (cloned.response && typeof cloned.response === 'object') {
|
|
203
|
+
delete cloned.response.id;
|
|
204
|
+
delete cloned.response.created;
|
|
205
|
+
}
|
|
206
|
+
if (Array.isArray(cloned.output)) {
|
|
207
|
+
for (const item of cloned.output) {
|
|
208
|
+
if (item && typeof item === 'object') {
|
|
209
|
+
delete item.id;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return deepSortObject(cloned);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function extractTextFromResponses(body) {
|
|
217
|
+
// Best-effort: pull final text across common shapes.
|
|
218
|
+
if (!body || typeof body !== 'object') return '';
|
|
219
|
+
const direct = typeof body.output_text === 'string' ? body.output_text : '';
|
|
220
|
+
if (direct) return direct;
|
|
221
|
+
const output = Array.isArray(body.output) ? body.output : [];
|
|
222
|
+
const texts = [];
|
|
223
|
+
for (const item of output) {
|
|
224
|
+
if (!item || typeof item !== 'object') continue;
|
|
225
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
226
|
+
for (const c of content) {
|
|
227
|
+
if (c && typeof c === 'object' && c.type === 'output_text' && typeof c.text === 'string') {
|
|
228
|
+
texts.push(c.text);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return texts.join('');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertAntigravityUaFresh(label, requests) {
|
|
236
|
+
// Only enforce this for routecodex dev. rcc release is pinned to a published stack and may lag.
|
|
237
|
+
if (label !== 'routecodex') return;
|
|
238
|
+
const req = Array.isArray(requests) ? requests.find((r) => String(r?.url || '').includes(':generateContent') || String(r?.url || '').includes(':streamGenerateContent')) : null;
|
|
239
|
+
const ua = req?.headers?.['user-agent'];
|
|
240
|
+
const uaString = typeof ua === 'string' ? ua : Array.isArray(ua) ? ua.join(' ') : '';
|
|
241
|
+
assert.ok(uaString.toLowerCase().startsWith('antigravity/'), `${label}: upstream User-Agent must start with antigravity/ (got ${JSON.stringify(uaString)})`);
|
|
242
|
+
assert.ok(!uaString.toLowerCase().includes('codex_cli_rs/'), `${label}: upstream User-Agent must not be codex_cli_rs (got ${JSON.stringify(uaString)})`);
|
|
243
|
+
// RouteCodex must keep a stable per-alias fingerprint suffix derived from camoufox OAuth fingerprint.
|
|
244
|
+
// (This blackbox provides a fake camoufox fingerprint for alias "test" and asserts it is honored.)
|
|
245
|
+
assert.ok(
|
|
246
|
+
/^antigravity\/\d+\.\d+\.\d+ windows\/amd64$/i.test(uaString.trim()),
|
|
247
|
+
`${label}: UA must honor per-alias suffix windows/amd64 (got ${JSON.stringify(uaString)})`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function writeCamoufoxFingerprintForAlias({ dir, provider, alias, platform, userAgent, oscpu }) {
|
|
252
|
+
const providerFamily =
|
|
253
|
+
String(provider || '').toLowerCase() === 'antigravity' || String(provider || '').toLowerCase() === 'gemini-cli'
|
|
254
|
+
? 'gemini'
|
|
255
|
+
: String(provider || '').toLowerCase();
|
|
256
|
+
const profileId = `rc-${providerFamily}.${String(alias || '').toLowerCase()}`;
|
|
257
|
+
const fpDir = path.join(dir, '.routecodex', 'camoufox-fp');
|
|
258
|
+
fs.mkdirSync(fpDir, { recursive: true });
|
|
259
|
+
const fpPath = path.join(fpDir, `${profileId}.json`);
|
|
260
|
+
const camouConfig = {
|
|
261
|
+
'navigator.platform': platform,
|
|
262
|
+
'navigator.userAgent': userAgent,
|
|
263
|
+
'navigator.oscpu': oscpu
|
|
264
|
+
};
|
|
265
|
+
fs.writeFileSync(fpPath, JSON.stringify({ env: { CAMOU_CONFIG_1: JSON.stringify(camouConfig) } }, null, 2));
|
|
266
|
+
return fpPath;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function startMockUpstream() {
|
|
270
|
+
const requests = [];
|
|
271
|
+
let lastIssuedSignature = null;
|
|
272
|
+
const server = http.createServer(async (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
if (req.method !== 'POST') {
|
|
275
|
+
res.writeHead(405);
|
|
276
|
+
res.end();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
280
|
+
const chunks = [];
|
|
281
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
282
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
283
|
+
const parsed = raw.trim() ? JSON.parse(raw) : {};
|
|
284
|
+
requests.push({ url: url.toString(), headers: req.headers, body: parsed });
|
|
285
|
+
|
|
286
|
+
// Minimal OAuth helper compatibility for Antigravity/Gemini CLI.
|
|
287
|
+
if (url.pathname.endsWith('/v1internal:loadCodeAssist')) {
|
|
288
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
289
|
+
res.end(JSON.stringify({ cloudaicompanionProject: { id: 'test-project' } }));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (url.pathname.endsWith('/v1internal:onboardUser')) {
|
|
293
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
294
|
+
res.end(JSON.stringify({ cloudaicompanionProject: { id: 'test-project' } }));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Minimal schema sanity checks (focus on "dirty request" regressions)
|
|
299
|
+
const requestNode = parsed?.request;
|
|
300
|
+
if (!requestNode || typeof requestNode !== 'object' || Array.isArray(requestNode)) {
|
|
301
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
302
|
+
res.end(JSON.stringify({ error: { message: 'missing request wrapper' } }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
for (const forbidden of ['metadata', 'action', 'web_search', 'stream', 'sessionId']) {
|
|
306
|
+
if (Object.prototype.hasOwnProperty.call(requestNode, forbidden)) {
|
|
307
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
308
|
+
res.end(JSON.stringify({ error: { message: `forbidden request.${forbidden}` } }));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Tool schema types should be uppercase (llmswitch-core compat normalizes this)
|
|
314
|
+
const tools = requestNode.tools;
|
|
315
|
+
if (Array.isArray(tools)) {
|
|
316
|
+
const walk = (node) => {
|
|
317
|
+
if (!node || typeof node !== 'object') return;
|
|
318
|
+
if (Array.isArray(node)) {
|
|
319
|
+
node.forEach(walk);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
for (const [k, v] of Object.entries(node)) {
|
|
323
|
+
if (k === 'type' && typeof v === 'string') {
|
|
324
|
+
// accept both (some versions may not uppercase); store for diff
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
walk(v);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
walk(tools);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Cloud Code Assist stream endpoint
|
|
334
|
+
const isSse = url.pathname.endsWith(':streamGenerateContent') || url.searchParams.get('alt') === 'sse';
|
|
335
|
+
if (isSse) {
|
|
336
|
+
const firstUserText = getFirstUserTextFromGeminiRequest(parsed);
|
|
337
|
+
const wantsSignaturePriming = firstUserText.includes('bb-prime-thought-signature');
|
|
338
|
+
|
|
339
|
+
res.writeHead(200, {
|
|
340
|
+
'Content-Type': 'text/event-stream',
|
|
341
|
+
'Cache-Control': 'no-cache',
|
|
342
|
+
'Connection': 'keep-alive'
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (wantsSignaturePriming) {
|
|
346
|
+
lastIssuedSignature = MOCK_THOUGHT_SIGNATURE;
|
|
347
|
+
const response = {
|
|
348
|
+
response: {
|
|
349
|
+
candidates: [
|
|
350
|
+
{
|
|
351
|
+
index: 0,
|
|
352
|
+
finishReason: 'TOOL_CALLS',
|
|
353
|
+
content: {
|
|
354
|
+
role: 'model',
|
|
355
|
+
parts: [
|
|
356
|
+
{
|
|
357
|
+
thoughtSignature: MOCK_THOUGHT_SIGNATURE,
|
|
358
|
+
functionCall: {
|
|
359
|
+
name: 'exec_command',
|
|
360
|
+
args: { command: 'echo prime' }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
]
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
],
|
|
367
|
+
modelVersion: 'mock'
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
res.write(`data: ${JSON.stringify(response)}\n\n`);
|
|
371
|
+
res.end();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const response = {
|
|
376
|
+
response: {
|
|
377
|
+
candidates: [
|
|
378
|
+
{
|
|
379
|
+
index: 0,
|
|
380
|
+
finishReason: 'STOP',
|
|
381
|
+
content: {
|
|
382
|
+
role: 'model',
|
|
383
|
+
parts: [{ text: 'ok-from-mock' }]
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
],
|
|
387
|
+
modelVersion: 'mock'
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
res.write(`data: ${JSON.stringify(response)}\n\n`);
|
|
391
|
+
res.end();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Non-stream fallback
|
|
396
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
397
|
+
const firstUserText = getFirstUserTextFromGeminiRequest(parsed);
|
|
398
|
+
if (firstUserText.includes('bb-prime-thought-signature')) {
|
|
399
|
+
lastIssuedSignature = MOCK_THOUGHT_SIGNATURE;
|
|
400
|
+
res.end(
|
|
401
|
+
JSON.stringify({
|
|
402
|
+
candidates: [
|
|
403
|
+
{
|
|
404
|
+
content: {
|
|
405
|
+
role: 'model',
|
|
406
|
+
parts: [
|
|
407
|
+
{
|
|
408
|
+
thoughtSignature: MOCK_THOUGHT_SIGNATURE,
|
|
409
|
+
functionCall: {
|
|
410
|
+
name: 'exec_command',
|
|
411
|
+
args: { command: 'echo prime' }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
]
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
]
|
|
418
|
+
})
|
|
419
|
+
);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
res.end(JSON.stringify({ candidates: [{ content: { role: 'model', parts: [{ text: 'ok-from-mock' }] } }] }));
|
|
423
|
+
} catch (e) {
|
|
424
|
+
if (!res.headersSent) {
|
|
425
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
426
|
+
res.end(JSON.stringify({ error: { message: e instanceof Error ? e.message : String(e) } }));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
res.end();
|
|
431
|
+
} catch {
|
|
432
|
+
// ignore
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
438
|
+
const addr = server.address();
|
|
439
|
+
assert.equal(typeof addr, 'object');
|
|
440
|
+
const port = addr.port;
|
|
441
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
442
|
+
return {
|
|
443
|
+
baseUrl,
|
|
444
|
+
requests,
|
|
445
|
+
getLastIssuedSignature: () => lastIssuedSignature,
|
|
446
|
+
close: async () => new Promise((resolve) => server.close(() => resolve()))
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function writeTempConfig({ dir, serverPort, upstreamBaseUrl, tokenFile, targetModel }) {
|
|
451
|
+
const cfgPath = path.join(dir, `config-${serverPort}.json`);
|
|
452
|
+
const modelName = String(targetModel || 'gemini-3-pro-high').trim() || 'gemini-3-pro-high';
|
|
453
|
+
const cfg = {
|
|
454
|
+
version: '1.0.0',
|
|
455
|
+
server: { quotaRoutingEnabled: false },
|
|
456
|
+
httpserver: { host: '127.0.0.1', port: serverPort, apikey: 'verify-key' },
|
|
457
|
+
virtualrouter: {
|
|
458
|
+
providers: {
|
|
459
|
+
antigravity: {
|
|
460
|
+
id: 'antigravity',
|
|
461
|
+
enabled: true,
|
|
462
|
+
type: 'gemini-cli-http-provider',
|
|
463
|
+
providerType: 'gemini',
|
|
464
|
+
compatibilityProfile: 'chat:gemini-cli',
|
|
465
|
+
baseURL: upstreamBaseUrl,
|
|
466
|
+
auth: {
|
|
467
|
+
type: 'antigravity-oauth',
|
|
468
|
+
entries: [
|
|
469
|
+
{ alias: 'test', type: 'antigravity-oauth', tokenFile }
|
|
470
|
+
]
|
|
471
|
+
},
|
|
472
|
+
models: {
|
|
473
|
+
[modelName]: { supportsStreaming: true }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
routing: {
|
|
478
|
+
default: [
|
|
479
|
+
{
|
|
480
|
+
id: 'default-primary',
|
|
481
|
+
priority: 200,
|
|
482
|
+
mode: 'priority',
|
|
483
|
+
targets: [`antigravity.${modelName}`]
|
|
484
|
+
}
|
|
485
|
+
],
|
|
486
|
+
thinking: [
|
|
487
|
+
{
|
|
488
|
+
id: 'thinking-primary',
|
|
489
|
+
priority: 200,
|
|
490
|
+
mode: 'priority',
|
|
491
|
+
targets: [`antigravity.${modelName}`]
|
|
492
|
+
}
|
|
493
|
+
]
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
|
498
|
+
return cfgPath;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function waitForHealth(baseUrl) {
|
|
502
|
+
for (let i = 0; i < 80; i++) {
|
|
503
|
+
try {
|
|
504
|
+
const res = await fetch(`${baseUrl}/health`);
|
|
505
|
+
if (res.ok) return;
|
|
506
|
+
} catch {
|
|
507
|
+
// ignore
|
|
508
|
+
}
|
|
509
|
+
await sleep(250);
|
|
510
|
+
}
|
|
511
|
+
throw new Error(`server health timeout: ${baseUrl}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function runOnceBlackbox(opts) {
|
|
515
|
+
const {
|
|
516
|
+
label,
|
|
517
|
+
entryScript,
|
|
518
|
+
port,
|
|
519
|
+
configPath,
|
|
520
|
+
homeDir,
|
|
521
|
+
antigravityApiBase
|
|
522
|
+
} = opts;
|
|
523
|
+
|
|
524
|
+
const env = {
|
|
525
|
+
...process.env,
|
|
526
|
+
...(homeDir ? { HOME: homeDir } : {}),
|
|
527
|
+
ROUTECODEX_CONFIG_PATH: configPath,
|
|
528
|
+
ROUTECODEX_PORT: String(port),
|
|
529
|
+
RCC_CONFIG_PATH: configPath,
|
|
530
|
+
RCC_PORT: String(port),
|
|
531
|
+
ROUTECODEX_V2_HOOKS: '0',
|
|
532
|
+
RCC_V2_HOOKS: '0',
|
|
533
|
+
// Disable ManagerDaemon (quota/health background tasks) so the parity run is isolated and deterministic.
|
|
534
|
+
ROUTECODEX_USE_MOCK: '1',
|
|
535
|
+
RCC_USE_MOCK: '1',
|
|
536
|
+
// Disable token daemon + OAuth auto-open to keep the blackbox test non-interactive.
|
|
537
|
+
ROUTECODEX_DISABLE_TOKEN_DAEMON: '1',
|
|
538
|
+
RCC_DISABLE_TOKEN_DAEMON: '1',
|
|
539
|
+
ROUTECODEX_OAUTH_AUTO_OPEN: '0',
|
|
540
|
+
// Keep blackbox deterministic/hermetic: do not hit Antigravity auto-updater from CI.
|
|
541
|
+
ROUTECODEX_ANTIGRAVITY_UA_DISABLE_REMOTE: '1',
|
|
542
|
+
RCC_ANTIGRAVITY_UA_DISABLE_REMOTE: '1',
|
|
543
|
+
// Also pin UA version so we don't rely on hardcoded fallbacks in tests.
|
|
544
|
+
ROUTECODEX_ANTIGRAVITY_UA_VERSION: '1.11.9',
|
|
545
|
+
RCC_ANTIGRAVITY_UA_VERSION: '1.11.9',
|
|
546
|
+
...(antigravityApiBase
|
|
547
|
+
? {
|
|
548
|
+
ROUTECODEX_ANTIGRAVITY_API_BASE: antigravityApiBase,
|
|
549
|
+
RCC_ANTIGRAVITY_API_BASE: antigravityApiBase
|
|
550
|
+
}
|
|
551
|
+
: {}),
|
|
552
|
+
// keep verbose logs off
|
|
553
|
+
ROUTECODEX_LOG_LEVEL: process.env.ROUTECODEX_LOG_LEVEL || 'warn',
|
|
554
|
+
RCC_LOG_LEVEL: process.env.RCC_LOG_LEVEL || 'warn'
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const child = spawn('node', [entryScript], {
|
|
558
|
+
env,
|
|
559
|
+
stdio: ['ignore', 'inherit', 'inherit']
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
563
|
+
const shutdown = () => {
|
|
564
|
+
if (!child.killed) child.kill('SIGTERM');
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
await withTimeout(waitForHealth(baseUrl), 25000, `${label}:waitForHealth`);
|
|
569
|
+
|
|
570
|
+
// 1) /v1/responses smoke (existing parity baseline)
|
|
571
|
+
const res = await withTimeout(
|
|
572
|
+
fetch(`${baseUrl}/v1/responses`, {
|
|
573
|
+
method: 'POST',
|
|
574
|
+
headers: {
|
|
575
|
+
'Content-Type': 'application/json',
|
|
576
|
+
'Accept': 'application/json',
|
|
577
|
+
'x-api-key': 'verify-key',
|
|
578
|
+
'x-route-hint': 'thinking',
|
|
579
|
+
'x-session-id': 'bb-antigravity-session'
|
|
580
|
+
},
|
|
581
|
+
body: JSON.stringify({
|
|
582
|
+
model: 'gpt-5.2-codex',
|
|
583
|
+
stream: false,
|
|
584
|
+
// Include a tool schema to exercise Gemini tool schema + compat cleaning.
|
|
585
|
+
tools: [
|
|
586
|
+
{
|
|
587
|
+
type: 'function',
|
|
588
|
+
function: {
|
|
589
|
+
name: 'exec_command',
|
|
590
|
+
description: 'Run a shell command',
|
|
591
|
+
parameters: {
|
|
592
|
+
type: 'object',
|
|
593
|
+
properties: {
|
|
594
|
+
cmd: { type: 'string' }
|
|
595
|
+
},
|
|
596
|
+
required: ['cmd']
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
],
|
|
601
|
+
input: [
|
|
602
|
+
{
|
|
603
|
+
role: 'user',
|
|
604
|
+
content: [
|
|
605
|
+
{ type: 'input_text', text: 'Say exactly: ok-from-mock' }
|
|
606
|
+
]
|
|
607
|
+
}
|
|
608
|
+
]
|
|
609
|
+
})
|
|
610
|
+
}),
|
|
611
|
+
25000,
|
|
612
|
+
`${label}:/v1/responses`
|
|
613
|
+
);
|
|
614
|
+
const text = await res.text();
|
|
615
|
+
if (!res.ok) {
|
|
616
|
+
throw new Error(`${label} /v1/responses HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
617
|
+
}
|
|
618
|
+
const json = text.trim() ? JSON.parse(text) : {};
|
|
619
|
+
|
|
620
|
+
// 2) Cold followup: include assistant tool call history WITHOUT priming a signature first.
|
|
621
|
+
// Antigravity-Manager does not invent dummy thoughtSignature fields for functionCall parts.
|
|
622
|
+
const coldRes = await withTimeout(
|
|
623
|
+
fetch(`${baseUrl}/v1/chat/completions`, {
|
|
624
|
+
method: 'POST',
|
|
625
|
+
headers: {
|
|
626
|
+
'Content-Type': 'application/json',
|
|
627
|
+
'Accept': 'application/json',
|
|
628
|
+
'x-api-key': 'verify-key',
|
|
629
|
+
'x-route-hint': 'thinking',
|
|
630
|
+
'x-session-id': 'bb-antigravity-session'
|
|
631
|
+
},
|
|
632
|
+
body: JSON.stringify({
|
|
633
|
+
model: 'gpt-5.2-codex',
|
|
634
|
+
stream: false,
|
|
635
|
+
tools: [
|
|
636
|
+
{
|
|
637
|
+
type: 'function',
|
|
638
|
+
function: {
|
|
639
|
+
name: 'exec_command',
|
|
640
|
+
description: 'Run a shell command',
|
|
641
|
+
parameters: {
|
|
642
|
+
type: 'object',
|
|
643
|
+
properties: {
|
|
644
|
+
cmd: { type: 'string' }
|
|
645
|
+
},
|
|
646
|
+
required: ['cmd']
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
],
|
|
651
|
+
messages: [
|
|
652
|
+
{ role: 'user', content: 'bb-cold-followup: please call exec_command' },
|
|
653
|
+
{
|
|
654
|
+
role: 'assistant',
|
|
655
|
+
content: null,
|
|
656
|
+
tool_calls: [
|
|
657
|
+
{
|
|
658
|
+
id: 'call_bb_cold_1',
|
|
659
|
+
type: 'function',
|
|
660
|
+
function: {
|
|
661
|
+
name: 'exec_command',
|
|
662
|
+
arguments: JSON.stringify({ cmd: 'echo cold' })
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
]
|
|
666
|
+
},
|
|
667
|
+
{ role: 'tool', tool_call_id: 'call_bb_cold_1', content: 'ok' },
|
|
668
|
+
{ role: 'user', content: 'bb-cold-followup: continue' }
|
|
669
|
+
]
|
|
670
|
+
})
|
|
671
|
+
}),
|
|
672
|
+
25000,
|
|
673
|
+
`${label}:/v1/chat/completions:cold`
|
|
674
|
+
);
|
|
675
|
+
const coldText = await coldRes.text();
|
|
676
|
+
if (!coldRes.ok) {
|
|
677
|
+
throw new Error(`${label} cold /v1/chat/completions HTTP ${coldRes.status}: ${coldText.slice(0, 300)}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 3) Prime Antigravity thoughtSignature cache (mock returns a functionCall part with thoughtSignature)
|
|
681
|
+
const primeRes = await withTimeout(
|
|
682
|
+
fetch(`${baseUrl}/v1/chat/completions`, {
|
|
683
|
+
method: 'POST',
|
|
684
|
+
headers: {
|
|
685
|
+
'Content-Type': 'application/json',
|
|
686
|
+
'Accept': 'application/json',
|
|
687
|
+
'x-api-key': 'verify-key',
|
|
688
|
+
'x-route-hint': 'thinking',
|
|
689
|
+
'x-session-id': 'bb-antigravity-session'
|
|
690
|
+
},
|
|
691
|
+
body: JSON.stringify({
|
|
692
|
+
model: 'gpt-5.2-codex',
|
|
693
|
+
stream: false,
|
|
694
|
+
tools: [
|
|
695
|
+
{
|
|
696
|
+
type: 'function',
|
|
697
|
+
function: {
|
|
698
|
+
name: 'exec_command',
|
|
699
|
+
description: 'Run a shell command',
|
|
700
|
+
parameters: {
|
|
701
|
+
type: 'object',
|
|
702
|
+
properties: {
|
|
703
|
+
cmd: { type: 'string' }
|
|
704
|
+
},
|
|
705
|
+
required: ['cmd']
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
],
|
|
710
|
+
messages: [
|
|
711
|
+
{ role: 'user', content: 'bb-prime-thought-signature: please call exec_command' }
|
|
712
|
+
]
|
|
713
|
+
})
|
|
714
|
+
}),
|
|
715
|
+
25000,
|
|
716
|
+
`${label}:/v1/chat/completions:prime`
|
|
717
|
+
);
|
|
718
|
+
const primeText = await primeRes.text();
|
|
719
|
+
if (!primeRes.ok) {
|
|
720
|
+
throw new Error(`${label} prime /v1/chat/completions HTTP ${primeRes.status}: ${primeText.slice(0, 300)}`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// 4) Followup: include assistant tool call history (no thoughtSignature in OpenAI format).
|
|
724
|
+
// llmswitch-core compat must inject the cached signature into Gemini functionCall parts.
|
|
725
|
+
const followRes = await withTimeout(
|
|
726
|
+
fetch(`${baseUrl}/v1/chat/completions`, {
|
|
727
|
+
method: 'POST',
|
|
728
|
+
headers: {
|
|
729
|
+
'Content-Type': 'application/json',
|
|
730
|
+
'Accept': 'application/json',
|
|
731
|
+
'x-api-key': 'verify-key',
|
|
732
|
+
'x-route-hint': 'thinking',
|
|
733
|
+
'x-session-id': 'bb-antigravity-session'
|
|
734
|
+
},
|
|
735
|
+
body: JSON.stringify({
|
|
736
|
+
model: 'gpt-5.2-codex',
|
|
737
|
+
stream: false,
|
|
738
|
+
tools: [
|
|
739
|
+
{
|
|
740
|
+
type: 'function',
|
|
741
|
+
function: {
|
|
742
|
+
name: 'exec_command',
|
|
743
|
+
description: 'Run a shell command',
|
|
744
|
+
parameters: {
|
|
745
|
+
type: 'object',
|
|
746
|
+
properties: {
|
|
747
|
+
cmd: { type: 'string' }
|
|
748
|
+
},
|
|
749
|
+
required: ['cmd']
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
],
|
|
754
|
+
messages: [
|
|
755
|
+
{ role: 'user', content: 'bb-prime-thought-signature: please call exec_command' },
|
|
756
|
+
{
|
|
757
|
+
role: 'assistant',
|
|
758
|
+
content: null,
|
|
759
|
+
tool_calls: [
|
|
760
|
+
{
|
|
761
|
+
id: 'call_bb_1',
|
|
762
|
+
type: 'function',
|
|
763
|
+
function: {
|
|
764
|
+
name: 'exec_command',
|
|
765
|
+
arguments: JSON.stringify({ cmd: 'echo followup' })
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
]
|
|
769
|
+
},
|
|
770
|
+
{ role: 'tool', tool_call_id: 'call_bb_1', content: 'ok' },
|
|
771
|
+
{ role: 'user', content: 'bb-verify-injection: continue' }
|
|
772
|
+
]
|
|
773
|
+
})
|
|
774
|
+
}),
|
|
775
|
+
25000,
|
|
776
|
+
`${label}:/v1/chat/completions:followup`
|
|
777
|
+
);
|
|
778
|
+
const followText = await followRes.text();
|
|
779
|
+
if (!followRes.ok) {
|
|
780
|
+
throw new Error(`${label} followup /v1/chat/completions HTTP ${followRes.status}: ${followText.slice(0, 300)}`);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return { label, response: json };
|
|
784
|
+
} finally {
|
|
785
|
+
shutdown();
|
|
786
|
+
await withTimeout(
|
|
787
|
+
new Promise((resolve) => child.on('exit', () => resolve())),
|
|
788
|
+
15000,
|
|
789
|
+
`${label}:shutdown`
|
|
790
|
+
).catch(() => {});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async function main() {
|
|
795
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-blackbox-antigravity-'));
|
|
796
|
+
try {
|
|
797
|
+
const runRcc = String(process.env.ROUTECODEX_BLACKBOX_RUN_RCC || '').trim() === '1';
|
|
798
|
+
|
|
799
|
+
const rccLlmsHasAntigravitySignatureCache = (() => {
|
|
800
|
+
try {
|
|
801
|
+
const candidate = path.join(
|
|
802
|
+
process.cwd(),
|
|
803
|
+
'node_modules/@jsonstudio/rcc/node_modules/@jsonstudio/llms/dist/conversion/compat/antigravity-session-signature.js'
|
|
804
|
+
);
|
|
805
|
+
return fs.existsSync(candidate);
|
|
806
|
+
} catch {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
})();
|
|
810
|
+
|
|
811
|
+
// Name includes "-static" so OAuth lifecycle (if invoked) will never try refresh/reauth in this blackbox test.
|
|
812
|
+
const tokenFile = path.join(tempDir, 'antigravity-oauth-1-static.json');
|
|
813
|
+
fs.writeFileSync(
|
|
814
|
+
tokenFile,
|
|
815
|
+
JSON.stringify({ access_token: 'test_access_token', token_type: 'Bearer', project_id: 'test-project' }, null, 2)
|
|
816
|
+
);
|
|
817
|
+
// Provide a deterministic camoufox fingerprint so UA suffix is selected per-alias (no machine drift).
|
|
818
|
+
writeCamoufoxFingerprintForAlias({
|
|
819
|
+
dir: tempDir,
|
|
820
|
+
provider: 'antigravity',
|
|
821
|
+
alias: 'test',
|
|
822
|
+
platform: 'Win32',
|
|
823
|
+
oscpu: 'Windows NT 10.0; Win64; x64',
|
|
824
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0'
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const upstream = await startMockUpstream();
|
|
828
|
+
try {
|
|
829
|
+
// Run routecodex (dev) against the SAME upstream.
|
|
830
|
+
const cfg1 = writeTempConfig({
|
|
831
|
+
dir: tempDir,
|
|
832
|
+
serverPort: 5591,
|
|
833
|
+
upstreamBaseUrl: upstream.baseUrl,
|
|
834
|
+
tokenFile,
|
|
835
|
+
targetModel: 'gemini-3-pro-high'
|
|
836
|
+
});
|
|
837
|
+
const beforeA = upstream.requests.length;
|
|
838
|
+
const routecodex = await runOnceBlackbox({
|
|
839
|
+
label: 'routecodex',
|
|
840
|
+
entryScript: 'dist/index.js',
|
|
841
|
+
port: 5591,
|
|
842
|
+
configPath: cfg1,
|
|
843
|
+
homeDir: tempDir,
|
|
844
|
+
antigravityApiBase: upstream.baseUrl
|
|
845
|
+
});
|
|
846
|
+
const afterA = upstream.requests.length;
|
|
847
|
+
const sliceA = upstream.requests.slice(beforeA, afterA);
|
|
848
|
+
assertAntigravityUaFresh('routecodex', sliceA);
|
|
849
|
+
assert.equal(extractTextFromResponses(routecodex.response), 'ok-from-mock');
|
|
850
|
+
const genA = sliceA.filter((r) => {
|
|
851
|
+
const u = String(r.url || '');
|
|
852
|
+
return u.includes(':streamGenerateContent') || u.includes(':generateContent');
|
|
853
|
+
});
|
|
854
|
+
assert.ok(genA.length >= 4, `routecodex: expected >=4 content-generation calls, got ${genA.length}`);
|
|
855
|
+
const coldReqA = genA[1]?.body;
|
|
856
|
+
const followUpReqA = genA[genA.length - 1]?.body;
|
|
857
|
+
assert.ok(coldReqA, 'routecodex: missing cold upstream request body');
|
|
858
|
+
assert.ok(followUpReqA, 'routecodex: missing followup upstream request body');
|
|
859
|
+
|
|
860
|
+
// Cold followup should not include dummy or any thoughtSignature keys.
|
|
861
|
+
const coldSigsA = extractThoughtSignaturesFromGeminiRequest(coldReqA).filter((s) => s);
|
|
862
|
+
assert.ok(
|
|
863
|
+
coldSigsA.every((s) => s !== 'skip_thought_signature_validator'),
|
|
864
|
+
`routecodex: cold followup must not send dummy thoughtSignature (got ${JSON.stringify(coldSigsA.slice(0, 10))})`
|
|
865
|
+
);
|
|
866
|
+
const coldPresenceA = extractThoughtSignaturePresenceFromGeminiRequest(coldReqA);
|
|
867
|
+
if (coldPresenceA.some(Boolean)) {
|
|
868
|
+
safeWriteJson(path.join(tempDir, 'upstream.routecodex.cold.json'), coldReqA);
|
|
869
|
+
throw new Error(`routecodex: cold followup unexpectedly contains thoughtSignature keys (captures in ${tempDir})`);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const sigsA = extractThoughtSignaturesFromGeminiRequest(followUpReqA).filter((s) => s);
|
|
873
|
+
const routecodexInjected = sigsA.includes(MOCK_THOUGHT_SIGNATURE);
|
|
874
|
+
if (!routecodexInjected) {
|
|
875
|
+
safeWriteJson(path.join(tempDir, 'upstream.routecodex.followup.json'), followUpReqA);
|
|
876
|
+
safeWriteJson(path.join(tempDir, 'upstream.routecodex.prime.json'), genA[2]?.body || null);
|
|
877
|
+
safeWriteJson(path.join(tempDir, 'debug.routecodex.signature.json'), {
|
|
878
|
+
expected: MOCK_THOUGHT_SIGNATURE,
|
|
879
|
+
got: sigsA,
|
|
880
|
+
firstUserTextFollowup: getFirstUserTextFromGeminiRequest(followUpReqA),
|
|
881
|
+
firstUserTextPrime: getFirstUserTextFromGeminiRequest(genA[2]?.body || null)
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
assert.ok(
|
|
886
|
+
routecodexInjected,
|
|
887
|
+
`routecodex: thoughtSignature injection missing (captures in ${tempDir})`
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
// Cleanliness invariants (Antigravity-Manager alignment signals):
|
|
891
|
+
const reqA = genA[0]?.body;
|
|
892
|
+
assert.ok(reqA, 'routecodex: expected upstream request for /v1/responses baseline');
|
|
893
|
+
|
|
894
|
+
const canonUpA = canonicalizeUpstream(reqA);
|
|
895
|
+
|
|
896
|
+
{
|
|
897
|
+
const label = 'routecodex.upstream';
|
|
898
|
+
const raw = reqA;
|
|
899
|
+
assertNoUndefinedStrings(label, raw);
|
|
900
|
+
assertNoForbiddenWrapperFields(label, raw);
|
|
901
|
+
assert.equal(typeof raw?.project, 'string', `${label}: missing project`);
|
|
902
|
+
assert.equal(typeof raw?.model, 'string', `${label}: missing model`);
|
|
903
|
+
assert.equal(typeof raw?.requestId, 'string', `${label}: missing requestId`);
|
|
904
|
+
assert.equal(typeof raw?.request, 'object', `${label}: missing request`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Second pass: Claude model routed via Antigravity (same v1internal transport; different model family).
|
|
908
|
+
upstream.requests.splice(0, upstream.requests.length);
|
|
909
|
+
const cfgClaude = writeTempConfig({
|
|
910
|
+
dir: tempDir,
|
|
911
|
+
serverPort: 5593,
|
|
912
|
+
upstreamBaseUrl: upstream.baseUrl,
|
|
913
|
+
tokenFile,
|
|
914
|
+
targetModel: 'claude-sonnet-4-5-thinking'
|
|
915
|
+
});
|
|
916
|
+
const beforeC = upstream.requests.length;
|
|
917
|
+
const routecodexClaude = await runOnceBlackbox({
|
|
918
|
+
label: 'routecodex',
|
|
919
|
+
entryScript: 'dist/index.js',
|
|
920
|
+
port: 5593,
|
|
921
|
+
configPath: cfgClaude,
|
|
922
|
+
homeDir: tempDir,
|
|
923
|
+
antigravityApiBase: upstream.baseUrl
|
|
924
|
+
});
|
|
925
|
+
const afterC = upstream.requests.length;
|
|
926
|
+
const sliceC = upstream.requests.slice(beforeC, afterC);
|
|
927
|
+
assertAntigravityUaFresh('routecodex', sliceC);
|
|
928
|
+
assert.equal(extractTextFromResponses(routecodexClaude.response), 'ok-from-mock');
|
|
929
|
+
const genC = sliceC.filter((r) => {
|
|
930
|
+
const u = String(r.url || '');
|
|
931
|
+
return u.includes(':streamGenerateContent') || u.includes(':generateContent');
|
|
932
|
+
});
|
|
933
|
+
assert.ok(genC.length >= 4, `routecodex (claude): expected >=4 content-generation calls, got ${genC.length}`);
|
|
934
|
+
const coldReqC = genC[1]?.body;
|
|
935
|
+
const followUpReqC = genC[genC.length - 1]?.body;
|
|
936
|
+
assert.ok(coldReqC, 'routecodex (claude): missing cold upstream request body');
|
|
937
|
+
assert.ok(followUpReqC, 'routecodex (claude): missing followup upstream request body');
|
|
938
|
+
|
|
939
|
+
const coldSigsC = extractThoughtSignaturesFromGeminiRequest(coldReqC).filter((s) => s);
|
|
940
|
+
assert.ok(
|
|
941
|
+
coldSigsC.every((s) => s !== 'skip_thought_signature_validator'),
|
|
942
|
+
`routecodex (claude): cold followup must not send dummy thoughtSignature (got ${JSON.stringify(coldSigsC.slice(0, 10))})`
|
|
943
|
+
);
|
|
944
|
+
const coldPresenceC = extractThoughtSignaturePresenceFromGeminiRequest(coldReqC);
|
|
945
|
+
if (coldPresenceC.some(Boolean)) {
|
|
946
|
+
safeWriteJson(path.join(tempDir, 'upstream.routecodex.claude.cold.json'), coldReqC);
|
|
947
|
+
throw new Error(`routecodex (claude): cold followup unexpectedly contains thoughtSignature keys (captures in ${tempDir})`);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const sigsC = extractThoughtSignaturesFromGeminiRequest(followUpReqC).filter((s) => s);
|
|
951
|
+
const routecodexClaudeInjected = sigsC.includes(MOCK_THOUGHT_SIGNATURE);
|
|
952
|
+
if (!routecodexClaudeInjected) {
|
|
953
|
+
safeWriteJson(path.join(tempDir, 'upstream.routecodex.claude.followup.json'), followUpReqC);
|
|
954
|
+
safeWriteJson(path.join(tempDir, 'upstream.routecodex.claude.prime.json'), genC[2]?.body || null);
|
|
955
|
+
safeWriteJson(path.join(tempDir, 'debug.routecodex.claude.signature.json'), {
|
|
956
|
+
expected: MOCK_THOUGHT_SIGNATURE,
|
|
957
|
+
got: sigsC,
|
|
958
|
+
firstUserTextFollowup: getFirstUserTextFromGeminiRequest(followUpReqC),
|
|
959
|
+
firstUserTextPrime: getFirstUserTextFromGeminiRequest(genC[2]?.body || null)
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
assert.ok(
|
|
963
|
+
routecodexClaudeInjected,
|
|
964
|
+
`routecodex (claude): thoughtSignature injection missing (captures in ${tempDir})`
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
const reqC = genC[0]?.body;
|
|
968
|
+
assert.ok(reqC, 'routecodex (claude): expected upstream request for /v1/responses baseline');
|
|
969
|
+
assertNoUndefinedStrings('routecodex.upstream', reqC);
|
|
970
|
+
assertNoForbiddenWrapperFields('routecodex.upstream', reqC);
|
|
971
|
+
assert.equal(typeof reqC?.project, 'string', 'routecodex.upstream: missing project');
|
|
972
|
+
assert.equal(typeof reqC?.model, 'string', 'routecodex.upstream: missing model');
|
|
973
|
+
assert.equal(typeof reqC?.requestId, 'string', 'routecodex.upstream: missing requestId');
|
|
974
|
+
assert.equal(typeof reqC?.request, 'object', 'routecodex.upstream: missing request');
|
|
975
|
+
|
|
976
|
+
if (runRcc) {
|
|
977
|
+
// Reset upstream capture for the next run (keep the same server instance).
|
|
978
|
+
upstream.requests.splice(0, upstream.requests.length);
|
|
979
|
+
|
|
980
|
+
const cfg2 = writeTempConfig({
|
|
981
|
+
dir: tempDir,
|
|
982
|
+
serverPort: 5592,
|
|
983
|
+
upstreamBaseUrl: upstream.baseUrl,
|
|
984
|
+
tokenFile,
|
|
985
|
+
targetModel: 'gemini-3-pro-high'
|
|
986
|
+
});
|
|
987
|
+
const beforeB = upstream.requests.length;
|
|
988
|
+
const rcc = await runOnceBlackbox({
|
|
989
|
+
label: 'rcc',
|
|
990
|
+
entryScript: 'node_modules/@jsonstudio/rcc/dist/index.js',
|
|
991
|
+
port: 5592,
|
|
992
|
+
configPath: cfg2,
|
|
993
|
+
homeDir: tempDir,
|
|
994
|
+
antigravityApiBase: upstream.baseUrl
|
|
995
|
+
});
|
|
996
|
+
const afterB = upstream.requests.length;
|
|
997
|
+
const sliceB = upstream.requests.slice(beforeB, afterB);
|
|
998
|
+
const genB = sliceB.filter((r) => {
|
|
999
|
+
const u = String(r.url || '');
|
|
1000
|
+
return u.includes(':streamGenerateContent') || u.includes(':generateContent');
|
|
1001
|
+
});
|
|
1002
|
+
assert.ok(genB.length >= 4, `rcc: expected >=4 content-generation calls, got ${genB.length}`);
|
|
1003
|
+
const coldReqB = genB[1]?.body;
|
|
1004
|
+
const followUpReqB = genB[genB.length - 1]?.body;
|
|
1005
|
+
assert.ok(coldReqB, 'rcc: missing cold upstream request body');
|
|
1006
|
+
assert.ok(followUpReqB, 'rcc: missing followup upstream request body');
|
|
1007
|
+
|
|
1008
|
+
// rcc cold followup signature semantics may lag; only assert if it has the cache implementation.
|
|
1009
|
+
if (rccLlmsHasAntigravitySignatureCache) {
|
|
1010
|
+
const coldSigsB = extractThoughtSignaturesFromGeminiRequest(coldReqB).filter((s) => s);
|
|
1011
|
+
assert.ok(
|
|
1012
|
+
coldSigsB.every((s) => s !== 'skip_thought_signature_validator'),
|
|
1013
|
+
`rcc: cold followup must not send dummy thoughtSignature (got ${JSON.stringify(coldSigsB.slice(0, 10))})`
|
|
1014
|
+
);
|
|
1015
|
+
const coldPresenceB = extractThoughtSignaturePresenceFromGeminiRequest(coldReqB);
|
|
1016
|
+
if (coldPresenceB.some(Boolean)) {
|
|
1017
|
+
safeWriteJson(path.join(tempDir, 'upstream.rcc.cold.json'), coldReqB);
|
|
1018
|
+
throw new Error(`rcc: cold followup unexpectedly contains thoughtSignature keys (captures in ${tempDir})`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const sigsB = extractThoughtSignaturesFromGeminiRequest(followUpReqB).filter((s) => s);
|
|
1023
|
+
const rccInjected = sigsB.includes(MOCK_THOUGHT_SIGNATURE);
|
|
1024
|
+
if (rccLlmsHasAntigravitySignatureCache) {
|
|
1025
|
+
assert.ok(rccInjected, `rcc: thoughtSignature injection missing (captures in ${tempDir})`);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const canonRespA = canonicalizeResponses(routecodex.response);
|
|
1029
|
+
const canonRespB = canonicalizeResponses(rcc.response);
|
|
1030
|
+
assert.deepEqual(
|
|
1031
|
+
canonRespA,
|
|
1032
|
+
canonRespB,
|
|
1033
|
+
`Client response mismatch (routecodex vs rcc).\nroutecodex=${JSON.stringify(canonRespA).slice(0, 2000)}\nrcc=${JSON.stringify(canonRespB).slice(0, 2000)}`
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
const reqB = genB[0]?.body;
|
|
1037
|
+
assert.ok(reqB, 'rcc: expected upstream request for /v1/responses baseline');
|
|
1038
|
+
const canonUpB = canonicalizeUpstream(reqB);
|
|
1039
|
+
|
|
1040
|
+
// Optional strict parity: upstream request must be equal after canonicalization.
|
|
1041
|
+
// Default off because tool registries can legitimately diverge between routecodex dev and rcc release.
|
|
1042
|
+
if (String(process.env.ROUTECODEX_BLACKBOX_STRICT || '').trim() === '1') {
|
|
1043
|
+
const upRoutecodexPath = path.join(tempDir, 'upstream.routecodex.json');
|
|
1044
|
+
const upRccPath = path.join(tempDir, 'upstream.rcc.json');
|
|
1045
|
+
fs.writeFileSync(upRoutecodexPath, `${JSON.stringify(reqA, null, 2)}\n`, 'utf8');
|
|
1046
|
+
fs.writeFileSync(upRccPath, `${JSON.stringify(reqB, null, 2)}\n`, 'utf8');
|
|
1047
|
+
assert.deepEqual(
|
|
1048
|
+
canonUpA,
|
|
1049
|
+
canonUpB,
|
|
1050
|
+
`Upstream request mismatch (routecodex vs rcc). Captures: ${upRoutecodexPath} ${upRccPath}`
|
|
1051
|
+
);
|
|
1052
|
+
} else if (
|
|
1053
|
+
String(process.env.ROUTECODEX_BLACKBOX_KEEP || '').trim() === '1' &&
|
|
1054
|
+
JSON.stringify(canonUpA) !== JSON.stringify(canonUpB)
|
|
1055
|
+
) {
|
|
1056
|
+
const upRoutecodexPath = path.join(tempDir, 'upstream.routecodex.json');
|
|
1057
|
+
const upRccPath = path.join(tempDir, 'upstream.rcc.json');
|
|
1058
|
+
fs.writeFileSync(upRoutecodexPath, `${JSON.stringify(reqA, null, 2)}\n`, 'utf8');
|
|
1059
|
+
fs.writeFileSync(upRccPath, `${JSON.stringify(reqB, null, 2)}\n`, 'utf8');
|
|
1060
|
+
console.warn(
|
|
1061
|
+
`⚠️ upstream request differs (routecodex vs rcc); captures written: ${upRoutecodexPath} ${upRccPath}`
|
|
1062
|
+
);
|
|
1063
|
+
} else if (JSON.stringify(canonUpA) !== JSON.stringify(canonUpB)) {
|
|
1064
|
+
console.warn('⚠️ upstream request differs (routecodex vs rcc); set ROUTECODEX_BLACKBOX_KEEP=1 to write captures');
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
console.log('✅ blackbox ok: routecodex (Antigravity invariants) + rcc response parity');
|
|
1068
|
+
} else {
|
|
1069
|
+
void canonUpA;
|
|
1070
|
+
console.log('✅ blackbox ok: routecodex (Antigravity-Manager invariants)');
|
|
1071
|
+
}
|
|
1072
|
+
} finally {
|
|
1073
|
+
await upstream.close();
|
|
1074
|
+
}
|
|
1075
|
+
} finally {
|
|
1076
|
+
try {
|
|
1077
|
+
const keep = String(process.env.ROUTECODEX_BLACKBOX_KEEP || '').trim() === '1';
|
|
1078
|
+
if (keep) {
|
|
1079
|
+
console.log(`🧾 blackbox artifacts kept: ${tempDir}`);
|
|
1080
|
+
} else {
|
|
1081
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
1082
|
+
}
|
|
1083
|
+
} catch {
|
|
1084
|
+
// ignore cleanup failure
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
main().catch((err) => {
|
|
1090
|
+
console.error('❌ blackbox parity failed:', err instanceof Error ? err.stack || err.message : String(err));
|
|
1091
|
+
process.exit(1);
|
|
1092
|
+
});
|