llm-switcher 1.2.12 → 1.3.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/CHANGELOG.md +23 -0
- package/README.md +109 -19
- package/README.vi.md +107 -19
- package/config.example.json +13 -1
- package/formats.mjs +128 -12
- package/hook-status.mjs +5 -4
- package/mcp.mjs +6 -5
- package/package.json +1 -1
- package/plugin.mjs +80 -9
- package/proxy.mjs +155 -18
- package/shim.mjs +62 -20
- package/skills/llm-switcher/SKILL.md +6 -6
- package/state.mjs +50 -13
- package/switch.mjs +15 -9
- package/tests/agy.e2e.test.mjs +529 -0
- package/tests/agy.unit.test.mjs +190 -0
- package/tests/dashboard.e2e.test.mjs +18 -17
- package/tests/formats.test.mjs +67 -1
- package/tests/gateway.e2e.test.mjs +20 -2
- package/tests/mcp.test.mjs +5 -5
- package/tests/plugin.test.mjs +4 -2
- package/tests/shim.test.mjs +165 -0
- package/tests/state.test.mjs +8 -8
- package/ui.html +51 -7
- package/verify-gateway.mjs +35 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Unit tests for the Antigravity CLI (agy) in the launch state, the shims and the launch hook.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-agy-unit-'));
|
|
9
|
+
process.env.LLM_SWITCHER_STATE_DIR = tmp;
|
|
10
|
+
process.env.LLM_SWITCHER_CONFIG = path.join(tmp, 'config.json');
|
|
11
|
+
const s = await import('../state.mjs');
|
|
12
|
+
const shim = await import('../shim.mjs');
|
|
13
|
+
const plugin = await import('../plugin.mjs');
|
|
14
|
+
|
|
15
|
+
const cfgWith = (active) => ({
|
|
16
|
+
activeProfiles: { claude: null, codex: null, agy: null, ...active },
|
|
17
|
+
profiles: { pool: { name: 'pool', tool: 'agy', baseURL: 'https://intact.example/v1', apiKey: 'k', defaultModels: { main: 'antigravity/*' } } }
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('agy alone gets CLOUD_CODE_URL and starts no interceptor', () => {
|
|
21
|
+
const st = s.computeLaunchState(cfgWith({ agy: 'pool' }), 3456);
|
|
22
|
+
assert.equal(st.active, true);
|
|
23
|
+
assert.equal(st.blindfold, null);
|
|
24
|
+
assert.deepEqual(st.envAgy, [['CLOUD_CODE_URL', 'http://127.0.0.1:3456']]);
|
|
25
|
+
assert.deepEqual(st.envClaude, []);
|
|
26
|
+
assert.match(st.routeAgy, /^agy -> pool \| intact\.example \| antigravity\/\*/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('agy off writes no agy variables', () => {
|
|
30
|
+
const st = s.computeLaunchState(cfgWith({}), 3456);
|
|
31
|
+
assert.equal(st.active, false);
|
|
32
|
+
assert.deepEqual(st.envAgy, []);
|
|
33
|
+
assert.equal(st.routeAgy, '');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('only an agy profile serves agy, and the old single pointer never turns agy on', () => {
|
|
37
|
+
assert.equal(s.profileAcceptsTarget({ tool: 'agy' }, 'agy'), true);
|
|
38
|
+
assert.equal(s.profileAcceptsTarget({ inFormat: 'auto' }, 'agy'), false);
|
|
39
|
+
assert.equal(s.profileAcceptsTarget({ tool: 'agy' }, 'claude'), false);
|
|
40
|
+
assert.equal(s.getActiveMap({ activeProfile: 'legacy' }).agy, null);
|
|
41
|
+
assert.deepEqual(s.modelSlotsForProfile({ tool: 'agy' }), ['main']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the launch files of agy are written and emptied with the others', () => {
|
|
45
|
+
s.applyLaunchState(cfgWith({ agy: 'pool' }), 3456);
|
|
46
|
+
assert.match(fs.readFileSync(s.paths.envAgySh, 'utf8'), /export CLOUD_CODE_URL='http:\/\/127\.0\.0\.1:3456'/);
|
|
47
|
+
assert.match(fs.readFileSync(s.paths.envAgyCmd, 'utf8'), /SET "CLOUD_CODE_URL=http:\/\/127\.0\.0\.1:3456"/);
|
|
48
|
+
assert.ok(fs.existsSync(s.paths.activeFlag));
|
|
49
|
+
s.emptyToolEnvFiles('agy');
|
|
50
|
+
assert.equal(fs.readFileSync(s.paths.envAgySh, 'utf8'), '');
|
|
51
|
+
assert.equal(fs.readFileSync(s.paths.routeAgy, 'utf8'), '');
|
|
52
|
+
s.applyLaunchState(cfgWith({ agy: 'pool' }), 3456);
|
|
53
|
+
s.clearLaunchState(3456);
|
|
54
|
+
assert.equal(fs.readFileSync(s.paths.envAgyCmd, 'utf8'), '');
|
|
55
|
+
assert.equal(fs.existsSync(s.paths.activeFlag), false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the agy shim reads only its own files, takes no CA, and scrubs a stale gateway URL only while off', () => {
|
|
59
|
+
const win = shim.renderShim('agy', 'win32');
|
|
60
|
+
assert.match(win, /env-agy\.cmd/);
|
|
61
|
+
assert.match(win, /route-agy\.txt/);
|
|
62
|
+
assert.doesNotMatch(win, /env-claude|NODE_EXTRA_CA_CERTS|STATE_HELPER/);
|
|
63
|
+
assert.match(win, /if not "%TOOL_ACTIVE%"=="1" if defined CLOUD_CODE_URL call :SCRUB_URL CLOUD_CODE_URL/);
|
|
64
|
+
assert.match(win, /where agy\.exe/);
|
|
65
|
+
const posix = shim.renderShim('agy', 'linux');
|
|
66
|
+
assert.match(posix, /env-agy\.sh/);
|
|
67
|
+
assert.doesNotMatch(posix, /export NODE_EXTRA_CA_CERTS/);
|
|
68
|
+
assert.match(posix, /if \[ "\$TOOL_ACTIVE" != "1" \] && \[ -n "\$_u" \]; then/);
|
|
69
|
+
assert.match(shim.renderShim('claude', 'win32'), /NODE_EXTRA_CA_CERTS/, 'Claude keeps its CA block');
|
|
70
|
+
assert.equal(shim.routeEvidence('agy', ' CLOUD_CODE_URL=http://127.0.0.1:3456 PATH=x'), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the agy hook is merged into hooks.json, unquoted, and removed without touching other hooks', () => {
|
|
74
|
+
const agyHome = path.join(tmp, 'agy');
|
|
75
|
+
fs.mkdirSync(agyHome, { recursive: true });
|
|
76
|
+
const file = path.join(agyHome, 'hooks.json');
|
|
77
|
+
fs.writeFileSync(file, JSON.stringify({ 'lint-checker': { PostToolUse: [] } }));
|
|
78
|
+
const opts = { agyHome, claudeSkillsDir: path.join(tmp, 'skills'), codexHome: path.join(tmp, 'codex') };
|
|
79
|
+
const r = plugin.installPlugin(opts, 'test');
|
|
80
|
+
assert.ok(r.installed.includes('agy'), JSON.stringify(r));
|
|
81
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
82
|
+
assert.ok(doc['lint-checker'], 'another hook stays');
|
|
83
|
+
const cmd = doc[plugin.AGY_HOOK_NAME].SessionStart[0].command;
|
|
84
|
+
assert.match(cmd, /^node \S+\/hook-status\.mjs agy$/);
|
|
85
|
+
assert.doesNotMatch(cmd, /["\\]/, 'agy keeps quotes literally, so the command has none');
|
|
86
|
+
assert.equal(plugin.pluginStatus(opts).agy.installed, true);
|
|
87
|
+
plugin.uninstallPlugin(opts);
|
|
88
|
+
assert.deepEqual(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))), ['lint-checker']);
|
|
89
|
+
assert.equal(plugin.agyHookCommand('C:/Program Files/x/hook-status.mjs'), null, 'a path with a space cannot be run by agy');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
93
|
+
|
|
94
|
+
test('the launch files of every tool stay out of git and out of the npm package', () => {
|
|
95
|
+
const ignored = fs.readFileSync(new URL('../.gitignore', import.meta.url), 'utf8').split(/\r?\n/);
|
|
96
|
+
for (const tool of s.TOOLS) {
|
|
97
|
+
for (const f of [`env-${tool}.cmd`, `env-${tool}.sh`, `route-${tool}.txt`]) {
|
|
98
|
+
assert.ok(ignored.includes(f), `${f} must be ignored: it holds this machine's route`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('migration keeps the agy pointer and renames every pointer of a command-word profile', () => {
|
|
104
|
+
const mig = (cfg) => s.migrateConfigInMemory(cfg).config.activeProfiles;
|
|
105
|
+
assert.equal(mig({ profiles: { old: { baseURL: 'x' }, a: { tool: 'agy', baseURL: 'x' } }, activeProfiles: { claude: null, codex: null, agy: 'a' } }).agy, 'a');
|
|
106
|
+
assert.equal(mig({ profiles: { agy: { tool: 'claude', baseURL: 'x' } }, activeProfiles: { claude: 'agy' } }).claude, 'agy-profile');
|
|
107
|
+
assert.equal(mig({ profiles: { agy: { tool: 'agy', baseURL: 'x' } }, activeProfiles: { agy: 'agy' } }).agy, 'agy-profile');
|
|
108
|
+
assert.equal(mig({ profiles: { agy: { tool: 'codex', baseURL: 'x' } }, activeProfiles: { codex: 'agy' } }).codex, 'agy-profile');
|
|
109
|
+
assert.equal(mig({ profiles: { c: { tool: 'claude', baseURL: 'x' } }, activeProfiles: { agy: 'c' } }).agy, null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('every target alias of the switch command is a reserved command word', () => {
|
|
113
|
+
// A word that is not reserved can also be a profile key, and then `switch <word>` is ambiguous.
|
|
114
|
+
const src = fs.readFileSync(new URL('../switch.mjs', import.meta.url), 'utf8');
|
|
115
|
+
const literal = src.match(/const TARGET_ALIASES = (\{[^}]*\})/)[1];
|
|
116
|
+
const aliases = Function(`return ${literal}`)();
|
|
117
|
+
for (const key of Object.keys(aliases)) assert.ok(s.COMMAND_WORDS.has(key), `${key} is not in COMMAND_WORDS`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const pluginDirs = (name) => {
|
|
121
|
+
const root = path.join(tmp, name);
|
|
122
|
+
const opts = { agyHome: path.join(root, 'agy'), claudeSkillsDir: path.join(root, 'skills'), codexHome: path.join(root, 'codex') };
|
|
123
|
+
for (const d of Object.values(opts)) fs.mkdirSync(d, { recursive: true });
|
|
124
|
+
return opts;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
test('a hook script path with a space skips agy, and the other tools still install', () => {
|
|
128
|
+
const opts = { ...pluginDirs('space'), hookScript: path.join(tmp, 'Program Files', 'hook-status.mjs') };
|
|
129
|
+
const r = plugin.installPlugin(opts);
|
|
130
|
+
assert.deepEqual(r.installed.sort(), ['claude', 'codex']);
|
|
131
|
+
assert.deepEqual(r.failed, []);
|
|
132
|
+
assert.equal(r.skipped.length, 1);
|
|
133
|
+
assert.equal(r.skipped[0].tool, 'agy');
|
|
134
|
+
assert.match(r.skipped[0].reason, /space/);
|
|
135
|
+
assert.equal(fs.existsSync(path.join(opts.agyHome, 'hooks.json')), false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('an agy hooks.json whose root is not an object is left exactly as it is', () => {
|
|
139
|
+
for (const body of ['[{"a":1}]', '"text"', '7']) {
|
|
140
|
+
const opts = pluginDirs(`root-${body.length}`);
|
|
141
|
+
const file = path.join(opts.agyHome, 'hooks.json');
|
|
142
|
+
fs.writeFileSync(file, body);
|
|
143
|
+
const inst = plugin.installPlugin(opts);
|
|
144
|
+
assert.equal(inst.failed.find(f => f.tool === 'agy')?.tool, 'agy', `install ${body}`);
|
|
145
|
+
assert.equal(fs.readFileSync(file, 'utf8'), body);
|
|
146
|
+
const un = plugin.uninstallPlugin(opts);
|
|
147
|
+
assert.ok(un.installed.includes('agy') && !un.failed.length, `uninstall ${body}: our hook cannot be there`);
|
|
148
|
+
assert.equal(fs.readFileSync(file, 'utf8'), body);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a tool whose install throws is reported, and the other tools still install', () => {
|
|
153
|
+
const opts = pluginDirs('throws');
|
|
154
|
+
// A directory where the file must go makes the write throw.
|
|
155
|
+
fs.mkdirSync(path.join(opts.codexHome, 'hooks.json'));
|
|
156
|
+
const r = plugin.installPlugin(opts);
|
|
157
|
+
assert.ok(r.installed.includes('claude') && r.installed.includes('agy'), JSON.stringify(r));
|
|
158
|
+
assert.equal(r.failed.find(f => f.tool === 'codex')?.tool, 'codex');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('plugin status names an agy hooks.json that does not parse', () => {
|
|
162
|
+
const opts = pluginDirs('status-broken');
|
|
163
|
+
fs.writeFileSync(path.join(opts.agyHome, 'hooks.json'), '{ broken');
|
|
164
|
+
const st = plugin.pluginStatus(opts).agy;
|
|
165
|
+
assert.equal(st.installed, false);
|
|
166
|
+
assert.match(st.error, /parse/);
|
|
167
|
+
fs.writeFileSync(path.join(opts.agyHome, 'hooks.json'), '[]');
|
|
168
|
+
assert.match(plugin.pluginStatus(opts).agy.error, /object/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('the agy launch hook warns when agy is routed and no gateway answers, and is silent when it is not routed', async () => {
|
|
172
|
+
const { spawnSync } = await import('node:child_process');
|
|
173
|
+
const net = await import('node:net');
|
|
174
|
+
const closed = await new Promise((resolve) => { const sv = net.createServer().listen(0, '127.0.0.1', () => { const { port } = sv.address(); sv.close(() => resolve(port)); }); });
|
|
175
|
+
const run = () => spawnSync(process.execPath, [new URL('../hook-status.mjs', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'), 'agy'], {
|
|
176
|
+
env: { ...process.env, LLM_SWITCHER_STATE_DIR: tmp, LLM_SWITCHER_CONFIG: path.join(tmp, 'config.json'), LLM_SWITCHER_PORT: String(closed) },
|
|
177
|
+
encoding: 'utf8'
|
|
178
|
+
});
|
|
179
|
+
fs.writeFileSync(s.paths.routeAgy, 'agy -> pool | intact.example | antigravity/*');
|
|
180
|
+
fs.writeFileSync(s.paths.activeFlag, '1');
|
|
181
|
+
try {
|
|
182
|
+
const warned = JSON.parse(run().stdout);
|
|
183
|
+
assert.match(warned.systemMessage, /^WARNING: .*agy -> pool/);
|
|
184
|
+
fs.writeFileSync(s.paths.routeAgy, '');
|
|
185
|
+
assert.deepEqual(JSON.parse(run().stdout), {});
|
|
186
|
+
} finally {
|
|
187
|
+
fs.rmSync(s.paths.routeAgy, { force: true });
|
|
188
|
+
fs.rmSync(s.paths.activeFlag, { force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
@@ -7,11 +7,11 @@ import assert from 'node:assert/strict';
|
|
|
7
7
|
import { launchBrowser, skipReason } from './cdp.mjs';
|
|
8
8
|
import { startDashboardFixture } from './dashboard-fixture.mjs';
|
|
9
9
|
|
|
10
|
-
const TOOLS = ['claude', 'codex'];
|
|
11
|
-
const LABEL = { claude: 'Claude', codex: 'Codex' };
|
|
10
|
+
const TOOLS = ['claude', 'codex', 'agy'];
|
|
11
|
+
const LABEL = { claude: 'Claude', codex: 'Codex', agy: 'agy' };
|
|
12
12
|
const MASKED = '__LLM_SWITCHER_KEEP_KEY__';
|
|
13
13
|
// A profile's `tool` in config.json is one tool, or nothing for a profile that serves both.
|
|
14
|
-
const VALID_TOOLS = [undefined, null, 'claude', 'codex'];
|
|
14
|
+
const VALID_TOOLS = [undefined, null, 'claude', 'codex', 'agy'];
|
|
15
15
|
|
|
16
16
|
describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
17
17
|
let fx, browser, page;
|
|
@@ -59,8 +59,8 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
59
59
|
const readScreen = () => page.evaluate(`(() => {
|
|
60
60
|
const text = id => document.getElementById(id)?.textContent.trim();
|
|
61
61
|
return {
|
|
62
|
-
badges: { claude: text('badge-slot-claude'), codex: text('badge-slot-codex') },
|
|
63
|
-
selects: { claude: document.getElementById('select-slot-claude').value, codex: document.getElementById('select-slot-codex').value },
|
|
62
|
+
badges: { claude: text('badge-slot-claude'), codex: text('badge-slot-codex'), agy: text('badge-slot-agy') },
|
|
63
|
+
selects: { claude: document.getElementById('select-slot-claude').value, codex: document.getElementById('select-slot-codex').value, agy: document.getElementById('select-slot-agy').value },
|
|
64
64
|
aside: text('status-aside-indicator'),
|
|
65
65
|
cards: [...document.querySelectorAll('#profiles-grid .pcard')].map(c => ({
|
|
66
66
|
key: c.querySelector('.mono.faint').textContent.trim(),
|
|
@@ -75,8 +75,8 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
75
75
|
const active = cfg.activeProfiles;
|
|
76
76
|
const on = TOOLS.filter(t => active[t]);
|
|
77
77
|
return {
|
|
78
|
-
badges: { claude: active.claude || 'OFF', codex: active.codex || 'OFF' },
|
|
79
|
-
selects: { claude: active.claude || '', codex: active.codex || '' },
|
|
78
|
+
badges: { claude: active.claude || 'OFF', codex: active.codex || 'OFF', agy: active.agy || 'OFF' },
|
|
79
|
+
selects: { claude: active.claude || '', codex: active.codex || '', agy: active.agy || '' },
|
|
80
80
|
aside: on.length ? `Active (${on.length})` : 'Idle',
|
|
81
81
|
cards: Object.keys(cfg.profiles).map(key => {
|
|
82
82
|
const tools = TOOLS.filter(t => active[t] === key);
|
|
@@ -100,7 +100,8 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
100
100
|
async function expectScreenMatchesDisk(step, opts) {
|
|
101
101
|
const cfg = fx.config();
|
|
102
102
|
const served = (await fx.api('/api/status')).json;
|
|
103
|
-
|
|
103
|
+
// A config written before agy existed has no agy key: on disk that means agy is off.
|
|
104
|
+
assert.deepEqual(served.activeProfiles, { agy: null, ...cfg.activeProfiles }, `${step}: the gateway reports routes that are not on disk`);
|
|
104
105
|
assert.deepEqual(await readScreen(), expectedScreen(cfg), `${step}: the page and the file disagree`);
|
|
105
106
|
expectQuiet(step, opts);
|
|
106
107
|
}
|
|
@@ -139,22 +140,22 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
139
140
|
it('turns one profile off and on again for the tool it serves, and leaves the other tool alone', async () => {
|
|
140
141
|
await act(() => page.click(switchOf('intact-claude')));
|
|
141
142
|
assert.match(await toast(), /intact-claude deactivated/);
|
|
142
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: 'intact-codex' });
|
|
143
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: 'intact-codex', agy: null });
|
|
143
144
|
await expectScreenMatchesDisk('claude profile off');
|
|
144
145
|
|
|
145
146
|
await act(() => page.click(switchOf('intact-claude')));
|
|
146
147
|
assert.match(await toast(), /intact-claude activated/);
|
|
147
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex' });
|
|
148
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex', agy: null });
|
|
148
149
|
await expectScreenMatchesDisk('claude profile back on');
|
|
149
150
|
});
|
|
150
151
|
|
|
151
152
|
it('turns a profile that serves both tools on for both, and off again', async () => {
|
|
152
153
|
await act(() => page.click(switchOf('shared')));
|
|
153
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'shared' });
|
|
154
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'shared', agy: null });
|
|
154
155
|
await expectScreenMatchesDisk('shared on');
|
|
155
156
|
|
|
156
157
|
await act(() => page.click(switchOf('shared')));
|
|
157
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
|
|
158
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null, agy: null });
|
|
158
159
|
await expectScreenMatchesDisk('shared off');
|
|
159
160
|
});
|
|
160
161
|
|
|
@@ -173,11 +174,11 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
173
174
|
describe('the route list of each tool', () => {
|
|
174
175
|
it('sets a route for one tool and can return it to the official endpoint', async () => {
|
|
175
176
|
await act(() => page.select('#select-slot-claude', 'shared'));
|
|
176
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'intact-codex' });
|
|
177
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'intact-codex', agy: null });
|
|
177
178
|
await expectScreenMatchesDisk('claude to shared');
|
|
178
179
|
|
|
179
180
|
await act(() => page.select('#select-slot-codex', ''));
|
|
180
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: null });
|
|
181
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: null, agy: null });
|
|
181
182
|
await expectScreenMatchesDisk('codex to official');
|
|
182
183
|
});
|
|
183
184
|
|
|
@@ -191,7 +192,7 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
191
192
|
describe('the buttons that turn every route off and on', () => {
|
|
192
193
|
it('"Use official endpoints" in the page header turns every route off', async () => {
|
|
193
194
|
await act(() => page.clickText('#ph-actions button', 'Use official endpoints'));
|
|
194
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
|
|
195
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null, agy: null });
|
|
195
196
|
await expectScreenMatchesDisk('all off');
|
|
196
197
|
const labels = await page.evaluate(`[...document.querySelectorAll('#ph-actions button')].map(b => b.textContent.trim())`);
|
|
197
198
|
assert.deepEqual(labels, ['+ Add Profile', 'Activate compatible routes', 'Refresh']);
|
|
@@ -200,13 +201,13 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
200
201
|
it('turning every route off and on again gives each tool its route back', async () => {
|
|
201
202
|
await act(() => page.clickText('#ph-actions button', 'Use official endpoints'));
|
|
202
203
|
await act(() => page.clickText('#ph-actions button', 'Activate compatible routes'));
|
|
203
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex' }, 'off and on lost a route');
|
|
204
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex', agy: null }, 'off and on lost a route');
|
|
204
205
|
await expectScreenMatchesDisk('off and on');
|
|
205
206
|
});
|
|
206
207
|
|
|
207
208
|
it('"Toggle All" in the sidebar does the same as the header button', async () => {
|
|
208
209
|
await act(() => page.click('.prefs button.btn-outline'));
|
|
209
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
|
|
210
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null, agy: null });
|
|
210
211
|
await expectScreenMatchesDisk('sidebar toggle');
|
|
211
212
|
});
|
|
212
213
|
});
|
package/tests/formats.test.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { test } from 'node:test';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import {
|
|
5
|
-
anthropicToIR, responsesToIR,
|
|
5
|
+
anthropicToIR, responsesToIR, codeAssistToIR,
|
|
6
6
|
healToolPairs, irToChatBody, irToAnthropicBody, irToVertexBody, toGeminiSchema,
|
|
7
7
|
createUpstreamNormalizer, createCollector, createThinkTagSplitter,
|
|
8
8
|
createAnthropicStream, createResponsesStream, createVertexStream,
|
|
@@ -832,3 +832,69 @@ test('structured output: Responses text.format keeps name and strict; JSON mode
|
|
|
832
832
|
assert.equal(irToChatBody(txt, 'm').response_format, undefined);
|
|
833
833
|
assert.equal(irToVertexBody(txt, 'm').generationConfig, undefined);
|
|
834
834
|
});
|
|
835
|
+
|
|
836
|
+
// ---- Code Assist (Antigravity CLI) input ----
|
|
837
|
+
const ca = (contents, request = {}) => codeAssistToIR({ model: 'm', request: { contents, ...request } });
|
|
838
|
+
const call = (name, id) => ({ functionCall: { ...(id ? { id } : {}), name, args: {} } });
|
|
839
|
+
const resp = (name, id, output = 'r') => ({ functionResponse: { ...(id ? { id } : {}), name, response: { output } } });
|
|
840
|
+
const toolMsgs = (ir) => ir.messages.filter(m => m.role === 'tool');
|
|
841
|
+
|
|
842
|
+
test('codeAssistToIR: an answered id leaves the queue, so a later id-less result pairs with its own call', () => {
|
|
843
|
+
const ir = ca([
|
|
844
|
+
{ role: 'user', parts: [{ text: 'go' }] },
|
|
845
|
+
{ role: 'model', parts: [call('f', 'x')] }, { role: 'model', parts: [resp('f', 'x')] },
|
|
846
|
+
{ role: 'model', parts: [call('f')] }, { role: 'model', parts: [resp('f')] }
|
|
847
|
+
]);
|
|
848
|
+
const generated = ir.messages.find(m => m.role === 'assistant' && m.toolCalls?.[0]?.id !== 'x').toolCalls[0].id;
|
|
849
|
+
assert.deepEqual(toolMsgs(ir).map(m => m.toolCallId), ['x', generated]);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test('codeAssistToIR: two id-less parallel calls of one name pair first in, first out', () => {
|
|
853
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'go' }] }, { role: 'model', parts: [call('f'), call('f')] }, { role: 'user', parts: [resp('f', null, 'a'), resp('f', null, 'b')] }]);
|
|
854
|
+
const ids = ir.messages.find(m => m.role === 'assistant').toolCalls.map(c => c.id);
|
|
855
|
+
assert.deepEqual(toolMsgs(ir).map(m => [m.toolCallId, m.content]), [[ids[0], 'a'], [ids[1], 'b']]);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test('codeAssistToIR: two id-less orphans of one name get distinct ids', () => {
|
|
859
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'go' }] }, { role: 'user', parts: [resp('f'), resp('f')] }]);
|
|
860
|
+
const ids = toolMsgs(ir).map(m => m.toolCallId);
|
|
861
|
+
assert.equal(ids.length, 2);
|
|
862
|
+
assert.notEqual(ids[0], ids[1]);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test('codeAssistToIR: an unknown explicit id does not consume the open call of that name', () => {
|
|
866
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'go' }] }, { role: 'model', parts: [call('f')] }, { role: 'model', parts: [resp('f', 'zzz')] }, { role: 'model', parts: [resp('f')] }]);
|
|
867
|
+
assert.deepEqual(toolMsgs(ir).map(m => m.toolCallId), ['zzz', 'call_agy_0_f']);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test('codeAssistToIR: thought parts stay out of the text', () => {
|
|
871
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'q' }] }, { role: 'model', parts: [{ text: 'secret', thought: true }, { text: 'answer' }] }]);
|
|
872
|
+
assert.equal(ir.messages[1].content, 'answer');
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test('codeAssistToIR: nested schema types are lowered', () => {
|
|
876
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'q' }] }], { tools: [{ functionDeclarations: [{ name: 't', parameters: {
|
|
877
|
+
type: 'OBJECT', properties: { list: { type: 'ARRAY', items: { type: 'STRING' } }, pick: { anyOf: [{ type: 'INTEGER' }, { type: 'BOOLEAN' }] } }
|
|
878
|
+
} }] }] });
|
|
879
|
+
const p = ir.tools[0].parameters;
|
|
880
|
+
assert.equal(p.type, 'object');
|
|
881
|
+
assert.equal(p.properties.list.type, 'array');
|
|
882
|
+
assert.equal(p.properties.list.items.type, 'string');
|
|
883
|
+
assert.ok(JSON.stringify(p.properties.pick).includes('"integer"'));
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test('codeAssistToIR: each thinkingConfig form maps to ir.thinking', () => {
|
|
887
|
+
const think = (thinkingConfig) => ca([{ role: 'user', parts: [{ text: 'q' }] }], { generationConfig: { thinkingConfig } }).thinking;
|
|
888
|
+
assert.deepEqual(think({ thinkingBudget: 0 }), { type: 'disabled' });
|
|
889
|
+
assert.equal(think({ thinkingBudget: 4000 }).type, 'enabled');
|
|
890
|
+
assert.equal(think({ thinkingLevel: 'HIGH' }).effort, 'high');
|
|
891
|
+
assert.deepEqual(think({ includeThoughts: true, thinkingBudget: -1 }), { type: 'enabled', budget: 2048 });
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test('codeAssistToIR: generation parameters map to ir.params', () => {
|
|
895
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'q' }] }], { generationConfig: { maxOutputTokens: 900, temperature: 0.2, topP: 0.9, stopSequences: ['END'] } });
|
|
896
|
+
assert.equal(ir.params.maxTokens, 900);
|
|
897
|
+
assert.equal(ir.params.temperature, 0.2);
|
|
898
|
+
assert.equal(ir.params.topP, 0.9);
|
|
899
|
+
assert.deepEqual(ir.params.stop, ['END']);
|
|
900
|
+
});
|
|
@@ -616,6 +616,24 @@ test('Bifrost: Claude Code to a Claude Code account on intact changes only the k
|
|
|
616
616
|
assert.equal(logs[0].outFormat, 'bifrost');
|
|
617
617
|
});
|
|
618
618
|
|
|
619
|
+
// intact pins a conversation to one account by this header, so its prompt cache hits and a
|
|
620
|
+
// compacted conversation can move. Every route that reaches intact must carry it.
|
|
621
|
+
test('The Claude Code session id reaches the upstream on every route', async () => {
|
|
622
|
+
const routes = [
|
|
623
|
+
{ name: 'direct', model: 'claude-opus-4-6', profile: 'ant', ua: 'claude-cli/2.1.300 (external, cli)', url: '/ant/' },
|
|
624
|
+
{ name: 'convert', model: 'claude-sonnet-5', profile: 'intactcc', ua: 'claude-cli/2.1.300 (external, cli)', url: '/intact/v1/chat/completions' },
|
|
625
|
+
{ name: 'bifrost', model: 'claude-opus-4-6', profile: 'intactcc', ua: 'claude-cli/2.1.300 (external, cli)', url: '/intact/v1/messages' }
|
|
626
|
+
];
|
|
627
|
+
for (const r of routes) {
|
|
628
|
+
const res = await post('/v1/messages', { model: r.model, max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
|
|
629
|
+
{ 'x-llm-profile': r.profile, 'user-agent': r.ua, 'x-claude-code-session-id': `sess-${r.name}` });
|
|
630
|
+
assert.equal(res.status, 200, r.name);
|
|
631
|
+
const up = received.at(-1);
|
|
632
|
+
assert.ok(up.url.startsWith(r.url), `${r.name} went to ${up.url}`);
|
|
633
|
+
assert.equal(up.headers['x-claude-code-session-id'], `sess-${r.name}`, `${r.name} dropped the session id`);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
619
637
|
test('Bifrost stays off for a model that is not a Claude Code account', async () => {
|
|
620
638
|
const res = await post('/v1/messages', { model: 'claude-sonnet-5', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
|
|
621
639
|
{ 'x-llm-profile': 'intactcc', 'user-agent': 'claude-cli/2.1.300 (external, cli)' });
|
|
@@ -1279,7 +1297,7 @@ test('/api/switch deactivate: the dashboard payload works, the CLI payload works
|
|
|
1279
1297
|
// What the dashboard sends: the profile in `profile`, a boolean in `deactivate`.
|
|
1280
1298
|
const dash = await post('/api/switch', { profile: 'chat', deactivate: true });
|
|
1281
1299
|
assert.equal(dash.status, 200);
|
|
1282
|
-
assert.deepEqual((await dash.json()).activeProfiles, { claude: null, codex: null });
|
|
1300
|
+
assert.deepEqual((await dash.json()).activeProfiles, { claude: null, codex: null, agy: null });
|
|
1283
1301
|
|
|
1284
1302
|
// The same profile is now inactive, so asking again must not report success.
|
|
1285
1303
|
const again = await post('/api/switch', { profile: 'chat', deactivate: true });
|
|
@@ -1290,7 +1308,7 @@ test('/api/switch deactivate: the dashboard payload works, the CLI payload works
|
|
|
1290
1308
|
assert.equal((await post('/api/switch', { profile: 'chat' })).status, 200);
|
|
1291
1309
|
const cli = await post('/api/switch', { deactivate: 'chat' });
|
|
1292
1310
|
assert.equal(cli.status, 200);
|
|
1293
|
-
assert.deepEqual((await cli.json()).activeProfiles, { claude: null, codex: null });
|
|
1311
|
+
assert.deepEqual((await cli.json()).activeProfiles, { claude: null, codex: null, agy: null });
|
|
1294
1312
|
|
|
1295
1313
|
// `deactivate: true` with no profile names nothing.
|
|
1296
1314
|
assert.equal((await post('/api/switch', { deactivate: true })).status, 400);
|
package/tests/mcp.test.mjs
CHANGED
|
@@ -115,15 +115,15 @@ test('routeEvidence does not take the shim --config arguments for an environment
|
|
|
115
115
|
|
|
116
116
|
// ==================== Task 5 ====================
|
|
117
117
|
|
|
118
|
-
// The gateway stands between
|
|
118
|
+
// The gateway stands between three tools and nothing else. An agent that is offered the retired
|
|
119
119
|
// names would be told they worked, then fail at the gateway.
|
|
120
|
-
test('R6: switcher_switch_profile accepts claude and
|
|
120
|
+
test('R6: switcher_switch_profile accepts claude, codex and agy targets and rejects legacy targets', async (t) => {
|
|
121
121
|
const [list] = await mcpCall(t, {}, [{ method: 'tools/list' }]);
|
|
122
122
|
const def = list.result.tools.find(x => x.name === 'switcher_switch_profile');
|
|
123
123
|
assert.ok(def, 'the tool is advertised');
|
|
124
|
-
assert.deepEqual(def.inputSchema.properties.target.enum, ['claude', 'codex']);
|
|
125
|
-
assert.match(def.inputSchema.properties.target.description, /"claude" \(Claude Code\)
|
|
126
|
-
assert.match(def.description, /Claude Code \(claude\)
|
|
124
|
+
assert.deepEqual(def.inputSchema.properties.target.enum, ['claude', 'codex', 'agy']);
|
|
125
|
+
assert.match(def.inputSchema.properties.target.description, /"claude" \(Claude Code\), "codex" \(Codex\) or "agy" \(Antigravity CLI\)/);
|
|
126
|
+
assert.match(def.description, /Claude Code \(claude\), Codex \(codex\) or the Antigravity CLI \(agy\)/);
|
|
127
127
|
// openai and vertex are not tools a caller may name, so they are not in the schema at all.
|
|
128
128
|
assert.ok(!JSON.stringify(def).includes('openai'), 'no retired target in the schema');
|
|
129
129
|
assert.ok(!JSON.stringify(def).includes('vertex'), 'no retired target in the schema');
|
package/tests/plugin.test.mjs
CHANGED
|
@@ -20,9 +20,11 @@ function dirs(t) {
|
|
|
20
20
|
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
21
21
|
const claudeSkillsDir = path.join(home, '.claude', 'skills');
|
|
22
22
|
const codexHome = path.join(home, '.codex');
|
|
23
|
+
// Every tool gets a home of its own here: a test must never write the hooks of this machine.
|
|
24
|
+
const agyHome = path.join(home, '.gemini', 'antigravity-cli');
|
|
23
25
|
fs.mkdirSync(claudeSkillsDir, { recursive: true });
|
|
24
26
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
25
|
-
return { home, claudeSkillsDir, codexHome, opts: { claudeSkillsDir, codexHome } };
|
|
27
|
+
return { home, claudeSkillsDir, codexHome, agyHome, opts: { claudeSkillsDir, codexHome, agyHome } };
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const readJson = (f) => JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
@@ -55,7 +57,7 @@ test('install writes a plugin for Claude Code and a hooks file for Codex, and re
|
|
|
55
57
|
const { claudeSkillsDir, codexHome, opts } = dirs(t);
|
|
56
58
|
const first = installPlugin(opts);
|
|
57
59
|
assert.deepEqual(first.failed, [], 'nothing failed');
|
|
58
|
-
assert.deepEqual(first.installed.sort(), ['claude', 'codex']);
|
|
60
|
+
assert.deepEqual(first.installed.sort(), ['agy', 'claude', 'codex']);
|
|
59
61
|
|
|
60
62
|
const manifest = path.join(claudeSkillsDir, 'llm-switcher-status', '.claude-plugin', 'plugin.json');
|
|
61
63
|
assert.ok(fs.existsSync(manifest), 'the manifest is what makes the folder a plugin');
|
package/tests/shim.test.mjs
CHANGED
|
@@ -452,3 +452,168 @@ test('the Windows shim raises the toast only while this tool is routed', (t) =>
|
|
|
452
452
|
assert.match(off.out, /ran/);
|
|
453
453
|
assert.equal(off.asked, '', 'a tool that is off raises no toast');
|
|
454
454
|
});
|
|
455
|
+
|
|
456
|
+
test('shim status follows the Windows lookup: an .exe earlier on PATH wins over the shim', () => {
|
|
457
|
+
const dirA = fs.mkdtempSync(path.join(STATE, 'real-'));
|
|
458
|
+
const shimDir = fs.mkdtempSync(path.join(STATE, 'shim-'));
|
|
459
|
+
fs.writeFileSync(path.join(dirA, 'agy.exe'), '');
|
|
460
|
+
fs.writeFileSync(path.join(shimDir, 'agy.cmd'), '');
|
|
461
|
+
const opts = { pathEnv: [dirA, shimDir].join(';'), shimDir, platform: 'win32', pathExt: '.COM;.EXE;.BAT;.CMD' };
|
|
462
|
+
const st = shimStatus(['agy'], opts);
|
|
463
|
+
assert.equal(st.shims[0].effective, path.join(dirA, 'agy.exe'));
|
|
464
|
+
assert.equal(st.shims[0].active, false, 'the real agy.exe runs first, so the shim is not active');
|
|
465
|
+
const fixed = shimStatus(['agy'], { ...opts, pathEnv: [shimDir, dirA].join(';') });
|
|
466
|
+
assert.equal(fixed.shims[0].active, true);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ---- agy: the shim proves the gateway before it hands over CLOUD_CODE_URL -----------------------
|
|
470
|
+
// agy sends its Google token in clear text to CLOUD_CODE_URL. A process that took the gateway port
|
|
471
|
+
// must therefore never receive it: the shim asks for the identity proof first.
|
|
472
|
+
const { identityProof } = await import(pathToFileURL(path.join(ROOT, 'state.mjs')).href);
|
|
473
|
+
const AGY_TOKEN = 'a'.repeat(64);
|
|
474
|
+
// Outside STATE: the other tests empty STATE file by file.
|
|
475
|
+
const AGY_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'agyconfig-'));
|
|
476
|
+
const AGY_CONFIG = path.join(AGY_CONFIG_DIR, 'config.json');
|
|
477
|
+
test.after(() => fs.rmSync(AGY_CONFIG_DIR, { recursive: true, force: true }));
|
|
478
|
+
fs.writeFileSync(path.join(path.dirname(AGY_CONFIG), 'admin.token'), AGY_TOKEN);
|
|
479
|
+
const VERIFY = path.join(ROOT, 'verify-gateway.mjs');
|
|
480
|
+
// Async on purpose: the /health listener lives in this process, and a spawnSync would block it.
|
|
481
|
+
const { spawn: spawnAsync } = await import('node:child_process');
|
|
482
|
+
function runAsync(cmd, args, opts) {
|
|
483
|
+
return new Promise((resolve) => {
|
|
484
|
+
const child = spawnAsync(cmd, args, opts);
|
|
485
|
+
let out = '', err = '';
|
|
486
|
+
child.stdout?.on('data', d => { out += d; });
|
|
487
|
+
child.stderr?.on('data', d => { err += d; });
|
|
488
|
+
const timer = setTimeout(() => child.kill(), 30000);
|
|
489
|
+
child.on('close', (status) => { clearTimeout(timer); resolve({ status, stdout: out, stderr: err }); });
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const NOTICE = /did not prove it is this switcher/;
|
|
493
|
+
|
|
494
|
+
// A /health listener: 'real' signs like this gateway, 'forged' sends a wrong proof, 'silent' never answers.
|
|
495
|
+
async function agyListener(kind) {
|
|
496
|
+
const http = await import('node:http');
|
|
497
|
+
const net = await import('node:net');
|
|
498
|
+
const server = kind === 'silent'
|
|
499
|
+
? net.createServer((sock) => sock.on('error', () => {}))
|
|
500
|
+
: http.createServer((req, res) => {
|
|
501
|
+
const port = server.address().port;
|
|
502
|
+
const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
|
|
503
|
+
const proof = kind === 'real' ? identityProof(nonce, { role: 'gateway', port, pid: 4242 }, AGY_TOKEN) : 'forged';
|
|
504
|
+
res.setHeader('content-type', 'application/json');
|
|
505
|
+
res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher', port, pid: 4242, proof }));
|
|
506
|
+
});
|
|
507
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
508
|
+
return server;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function runAgyWindowsShim(port) {
|
|
512
|
+
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyshim-'));
|
|
513
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyfake-'));
|
|
514
|
+
fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), '@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\n');
|
|
515
|
+
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
516
|
+
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
517
|
+
fs.writeFileSync(path.join(STATE, 'env-agy.cmd'), `SET "CLOUD_CODE_URL=http://127.0.0.1:${port}"\r\n`);
|
|
518
|
+
const shim = path.join(shimDir, 'agy.cmd');
|
|
519
|
+
fs.writeFileSync(shim, renderShim('agy', 'win32'), 'utf8');
|
|
520
|
+
const started = Date.now();
|
|
521
|
+
const child = await runAsync('cmd.exe', ['/d', '/c', shim], {
|
|
522
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)].join(path.delimiter) }
|
|
523
|
+
});
|
|
524
|
+
fs.rmSync(shimDir, { recursive: true, force: true });
|
|
525
|
+
fs.rmSync(fakeDir, { recursive: true, force: true });
|
|
526
|
+
return { out: child.stdout || '', err: child.stderr || '', ms: Date.now() - started };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
for (const [kind, routed] of [['forged', false], ['silent', false], ['real', true]]) {
|
|
530
|
+
test(`the Windows agy shim with a ${kind} listener on the gateway port ${routed ? 'keeps' : 'drops'} CLOUD_CODE_URL`, async (t) => {
|
|
531
|
+
if (process.platform !== 'win32') return t.skip('cmd.exe only');
|
|
532
|
+
const server = await agyListener(kind);
|
|
533
|
+
t.after(() => server.close());
|
|
534
|
+
const port = server.address().port;
|
|
535
|
+
const { out, err, ms } = await runAgyWindowsShim(port);
|
|
536
|
+
if (routed) {
|
|
537
|
+
assert.match(out, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
|
|
538
|
+
assert.doesNotMatch(err, NOTICE);
|
|
539
|
+
} else {
|
|
540
|
+
assert.match(out, /CCU=\[\]/, 'the token must not go to a port that did not prove itself');
|
|
541
|
+
assert.match(err, NOTICE);
|
|
542
|
+
}
|
|
543
|
+
assert.ok(ms < 8000, `the shim took ${ms} ms`);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (const [kind, routed] of [['forged', false], ['silent', false], ['real', true]]) {
|
|
548
|
+
test(`the POSIX agy shim with a ${kind} listener on the gateway port ${routed ? 'keeps' : 'drops'} CLOUD_CODE_URL`, async (t) => {
|
|
549
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
550
|
+
const server = await agyListener(kind);
|
|
551
|
+
t.after(() => server.close());
|
|
552
|
+
const port = server.address().port;
|
|
553
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyposix-'));
|
|
554
|
+
fs.writeFileSync(path.join(dir, 'agy'), '#!/usr/bin/env bash\necho "CCU=[${CLOUD_CODE_URL:-}]"\n');
|
|
555
|
+
fs.chmodSync(path.join(dir, 'agy'), 0o755);
|
|
556
|
+
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
557
|
+
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
558
|
+
fs.writeFileSync(path.join(STATE, 'env-agy.sh'), `export CLOUD_CODE_URL='http://127.0.0.1:${port}'\n`);
|
|
559
|
+
const started = Date.now();
|
|
560
|
+
const out = await runAsync(path.join(RENDER_DIR, 'agy'), [], {
|
|
561
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [RENDER_DIR, dir, '/usr/bin:/bin', NODE_DIR].join(path.delimiter) }
|
|
562
|
+
});
|
|
563
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
564
|
+
if (routed) {
|
|
565
|
+
assert.match(out.stdout, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
|
|
566
|
+
assert.doesNotMatch(out.stderr, NOTICE);
|
|
567
|
+
} else {
|
|
568
|
+
assert.match(out.stdout, /CCU=\[\]/);
|
|
569
|
+
assert.match(out.stderr, NOTICE);
|
|
570
|
+
}
|
|
571
|
+
assert.ok(Date.now() - started < 8000, `the shim took ${Date.now() - started} ms`);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
test('verify-gateway accepts only 127.0.0.1 with a real port, whatever listens', async (t) => {
|
|
576
|
+
const server = await agyListener('real');
|
|
577
|
+
t.after(() => server.close());
|
|
578
|
+
const port = server.address().port;
|
|
579
|
+
const run = async (u) => (await runAsync(process.execPath, [VERIFY, u], { env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG } })).status;
|
|
580
|
+
assert.equal(await run(`http://127.0.0.1:${port}`), 0, 'the real gateway passes');
|
|
581
|
+
assert.notEqual(await run('http://127.0.0.1/'), 0, 'no port');
|
|
582
|
+
assert.notEqual(await run(`http://localhost:${port}`), 0, 'localhost can resolve to ::1, which the probe never checked');
|
|
583
|
+
assert.notEqual(await run(`http://other.example:${port}`), 0);
|
|
584
|
+
assert.notEqual(await run('not a url'), 0);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// While agy is off, a gateway address left in the shell from an earlier run is the switcher's own
|
|
588
|
+
// value and goes; an address the person set for another host is theirs and stays.
|
|
589
|
+
for (const [value, kept] of [['http://127.0.0.1:3456', false], ['https://cloudcode.corp.example', true]]) {
|
|
590
|
+
test(`the Windows agy shim, with agy off, ${kept ? 'keeps' : 'scrubs'} CLOUD_CODE_URL=${value}`, async (t) => {
|
|
591
|
+
if (process.platform !== 'win32') return t.skip('cmd.exe only');
|
|
592
|
+
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyoff-'));
|
|
593
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyofffake-'));
|
|
594
|
+
t.after(() => { fs.rmSync(shimDir, { recursive: true, force: true }); fs.rmSync(fakeDir, { recursive: true, force: true }); });
|
|
595
|
+
fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), '@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\n');
|
|
596
|
+
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
597
|
+
const shim = path.join(shimDir, 'agy.cmd');
|
|
598
|
+
fs.writeFileSync(shim, renderShim('agy', 'win32'), 'utf8');
|
|
599
|
+
const out = await runAsync('cmd.exe', ['/d', '/c', shim], {
|
|
600
|
+
env: { ...process.env, CLOUD_CODE_URL: value, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)].join(path.delimiter) }
|
|
601
|
+
});
|
|
602
|
+
assert.ok(out.stdout.includes(`CCU=[${kept ? value : ''}]`), out.stdout);
|
|
603
|
+
assert.doesNotMatch(out.stderr, NOTICE, 'no gateway check runs while agy is off');
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test(`the POSIX agy shim, with agy off, ${kept ? 'keeps' : 'scrubs'} CLOUD_CODE_URL=${value}`, async (t) => {
|
|
607
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
608
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyoffposix-'));
|
|
609
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
610
|
+
fs.writeFileSync(path.join(dir, 'agy'), '#!/usr/bin/env bash\necho "CCU=[${CLOUD_CODE_URL:-}]"\n');
|
|
611
|
+
fs.chmodSync(path.join(dir, 'agy'), 0o755);
|
|
612
|
+
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
613
|
+
const out = await runAsync(path.join(RENDER_DIR, 'agy'), [], {
|
|
614
|
+
env: { ...process.env, CLOUD_CODE_URL: value, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [RENDER_DIR, dir, '/usr/bin:/bin', NODE_DIR].join(path.delimiter) }
|
|
615
|
+
});
|
|
616
|
+
assert.ok(out.stdout.includes(`CCU=[${kept ? value : ''}]`), out.stdout);
|
|
617
|
+
assert.doesNotMatch(out.stderr, NOTICE);
|
|
618
|
+
});
|
|
619
|
+
}
|