mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 06:15:03 +00:00
fix(cliproxy): harden account pools per pre-merge usefulness review
- pool --enable/--disable: refuse remote targets with manual config guidance; resolve the lifecycle port instead of assuming 8317 - enablePoolRouting: roll back the pool flag when config regeneration fails; already-enabled path re-runs regeneration (repairable state) - pool opt-in prompt: gate on hasUnifiedConfig (legacy installs skip); never auto-accept consent under --yes/CCS_YES - order show (file mode): render selector pick order incl. residual on-disk priorities and surface drift instead of alphabetical order - order --reset: clear residual priority fields via management-API PATCH when proxy runs, atomic direct write when stopped - quota pool section: classify in-proxy 429 cooldowns as cooling with reset times (graceful degradation when proxy or endpoint is absent); honest paused label plus resume hint - routing state: remote targets report pool as not manageable instead of echoing the local flag; strategy/affinity apply warns when pool routing overrides the change (CLI, API message, and dashboard) - claude model-neutral: --config explicit pins survive the stale-pin migration; remote env path filters historical default pins read from claude.settings.json without mutating the file - cross-lane guard: also checks account-profile CLAUDE_CONFIG_DIR lanes for email overlap, not only the ambient ~/.claude login - onboarding hint: opt-in copy naming ccs cliproxy pool --enable - dashboard card: pool-ON shows drain-order pointer, pool-OFF shows the enable command, local-only note for remote proxies (i18n x5)
This commit is contained in:
1 parent
2b7cfa32c8
commit
1c90388b5d
25 files changed
+2489
-125
No files matched your search
@@ -383,8 +383,8 @@ describe('Phase 5: Pool Onboarding Hint', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 13. Hint copy includes 'ccs claude' and quota trade-off language ──────
|
// ── 13. Hint copy is opt-in framed and names the enable command ──────────
|
||||||
it("hint copy mentions 'ccs claude' and quota trade-off", async () => {
|
it("hint copy mentions 'ccs claude' and the opt-in enable command", async () => {
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
writeProfiles(ccsDir, ['work', 'personal']);
|
writeProfiles(ccsDir, ['work', 'personal']);
|
||||||
|
|
||||||
@@ -397,7 +397,12 @@ describe('Phase 5: Pool Onboarding Hint', () => {
|
|||||||
expect(result.printed).toBe(true);
|
expect(result.printed).toBe(true);
|
||||||
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||||
expect(allOutput).toContain('ccs claude');
|
expect(allOutput).toContain('ccs claude');
|
||||||
expect(allOutput).toContain('share quota');
|
// Copy must be opt-in (not declarative "already pools") and name the
|
||||||
|
// actual enable command so the user has an actionable next step.
|
||||||
|
expect(allOutput).toContain('ccs cliproxy pool --enable');
|
||||||
|
expect(allOutput.toLowerCase()).toContain('can auto-continue');
|
||||||
|
// Must NOT read as already-active behavior.
|
||||||
|
expect(allOutput).not.toContain('pool auto-continues');
|
||||||
} finally {
|
} finally {
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -793,6 +793,118 @@ describe('Phase 3: cross-lane email overlap guard', () => {
|
|||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Account-profile lane enumeration (isolated CLAUDE_CONFIG_DIR lanes) ──
|
||||||
|
// The ambient ~/.claude check alone misses the isolated lanes of CCS account
|
||||||
|
// profiles — exactly the multi-account population the guard protects. These
|
||||||
|
// tests prove the guard also reads each profile lane's .claude.json email.
|
||||||
|
|
||||||
|
/** Write a profiles.json account entry + its instance-lane .claude.json email. */
|
||||||
|
function seedAccountLane(ccsDir: string, name: string, email: string | null): void {
|
||||||
|
const profilesPath = path.join(ccsDir, 'profiles.json');
|
||||||
|
let payload: { version: string; profiles: Record<string, unknown>; default: string | null };
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(fs.readFileSync(profilesPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
payload = { version: '2.0.0', profiles: {}, default: null };
|
||||||
|
}
|
||||||
|
payload.profiles[name] = {
|
||||||
|
type: 'account',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
last_used: null,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(profilesPath, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
|
||||||
|
const instanceDir = path.join(ccsDir, 'instances', name);
|
||||||
|
fs.mkdirSync(instanceDir, { recursive: true });
|
||||||
|
if (email !== null) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(instanceDir, '.claude.json'),
|
||||||
|
JSON.stringify({ oauthAccount: { emailAddress: email } }, null, 2),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('warns when a CCS account-profile lane email matches, even though ambient ~/.claude is logged out', () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
seedAccountLane(ccsDir, 'work', 'lane@example.com');
|
||||||
|
|
||||||
|
// Ambient default lane is logged OUT — old guard would stay silent.
|
||||||
|
const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({
|
||||||
|
loggedIn: false,
|
||||||
|
email: null,
|
||||||
|
authMethod: null,
|
||||||
|
apiProvider: null,
|
||||||
|
orgId: null,
|
||||||
|
orgName: null,
|
||||||
|
subscriptionType: null,
|
||||||
|
});
|
||||||
|
const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => {
|
||||||
|
if (typeof msg === 'string') stderrOutput.push(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
checkCrossLaneEmailOverlap('agy', 'lane@example.com');
|
||||||
|
|
||||||
|
const combined = stderrOutput.join('\n');
|
||||||
|
expect(combined).toContain('cross-lane email overlap');
|
||||||
|
// The matching profile lane is named so the user knows which lane overlaps.
|
||||||
|
expect(combined).toContain('profile "work"');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn when no account-profile lane email matches', () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
seedAccountLane(ccsDir, 'work', 'someone-else@example.com');
|
||||||
|
|
||||||
|
const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({
|
||||||
|
loggedIn: false,
|
||||||
|
email: null,
|
||||||
|
authMethod: null,
|
||||||
|
apiProvider: null,
|
||||||
|
orgId: null,
|
||||||
|
orgName: null,
|
||||||
|
subscriptionType: null,
|
||||||
|
});
|
||||||
|
const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => {
|
||||||
|
if (typeof msg === 'string') stderrOutput.push(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
checkCrossLaneEmailOverlap('agy', 'mine@example.com');
|
||||||
|
|
||||||
|
expect(stderrOutput.join('\n')).not.toContain('cross-lane');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silently skips an account-profile lane with a missing/unreadable .claude.json', () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
// Account profile exists but its lane has no .claude.json (email === null).
|
||||||
|
seedAccountLane(ccsDir, 'broken', null);
|
||||||
|
|
||||||
|
const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({
|
||||||
|
loggedIn: false,
|
||||||
|
email: null,
|
||||||
|
authMethod: null,
|
||||||
|
apiProvider: null,
|
||||||
|
orgId: null,
|
||||||
|
orgName: null,
|
||||||
|
subscriptionType: null,
|
||||||
|
});
|
||||||
|
const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => {
|
||||||
|
if (typeof msg === 'string') stderrOutput.push(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must not warn and must not throw on the unreadable lane.
|
||||||
|
expect(() => checkCrossLaneEmailOverlap('agy', 'lane@example.com')).not.toThrow();
|
||||||
|
expect(stderrOutput.join('\n')).not.toContain('cross-lane');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
// ── 15. Safety invariant: disablePoolRouting always restores cooling=true ─
|
// ── 15. Safety invariant: disablePoolRouting always restores cooling=true ─
|
||||||
it('safety invariant: disablePoolRouting ALWAYS writes disable-cooling: true', async () => {
|
it('safety invariant: disablePoolRouting ALWAYS writes disable-cooling: true', async () => {
|
||||||
const { enablePoolRouting, disablePoolRouting } = await import(
|
const { enablePoolRouting, disablePoolRouting } = await import(
|
||||||
@@ -1280,3 +1392,194 @@ describe('Phase 3: routing-subcommand pool-active warning regression', () => {
|
|||||||
affinitySpy.mockRestore();
|
affinitySpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── PR #1514 review fixes ────────────────────────────────────────────────────
|
||||||
|
// Legacy-config gate, automation bypass, enable rollback-on-regenerate-failure,
|
||||||
|
// remote pool-state manageable flag, and apply-message pool override note.
|
||||||
|
|
||||||
|
describe('PR #1514: maybeOfferPoolRouting legacy-config and automation guards', () => {
|
||||||
|
let tempHome: string;
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHome = createTestHome();
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
invalidateSharedConfigCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
delete process.env.CCS_YES;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix index 5: legacy profiles.json-only install (no config.yaml) must skip the
|
||||||
|
// prompt before any prompting OR dismissal persistence, so we never implicitly
|
||||||
|
// create config.yaml and silently flip isUnifiedMode().
|
||||||
|
it('skips with legacy-config and does not create config.yaml (decline path not reached)', async () => {
|
||||||
|
// Remove the config.yaml createTestHome wrote so hasUnifiedConfig() is false.
|
||||||
|
const configYaml = path.join(tempHome, '.ccs', 'config.yaml');
|
||||||
|
fs.rmSync(configYaml, { force: true });
|
||||||
|
invalidateSharedConfigCache();
|
||||||
|
|
||||||
|
const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1);
|
||||||
|
|
||||||
|
expect(result.skipped).toBe(true);
|
||||||
|
expect(result.skipReason).toBe('legacy-config');
|
||||||
|
// Critical: the dismissal/accept paths (which write config.yaml) must NOT run.
|
||||||
|
expect(fs.existsSync(configYaml)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix index 9: --yes / CCS_YES must NOT auto-accept this instance-global consent.
|
||||||
|
// It must skip WITHOUT printing the prompt and WITHOUT persisting dismissal.
|
||||||
|
it('skips with automation-bypass under CCS_YES=1 without prompting or dismissing', async () => {
|
||||||
|
process.env.CCS_YES = '1';
|
||||||
|
|
||||||
|
// 2 accounts so we are past the post-add count check and reach the TTY/bypass guards.
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
const accountsPath = path.join(ccsDir, 'cliproxy', 'accounts.json');
|
||||||
|
const authDir = path.join(ccsDir, 'cliproxy', 'auth');
|
||||||
|
fs.mkdirSync(path.dirname(accountsPath), { recursive: true });
|
||||||
|
fs.mkdirSync(authDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
accountsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
claude: {
|
||||||
|
default: 'a1',
|
||||||
|
accounts: {
|
||||||
|
a1: { email: 'a@b.com', tokenFile: 'a.json', nickname: 'a', createdAt: 0 },
|
||||||
|
a2: { email: 'c@d.com', tokenFile: 'c.json', nickname: 'c', createdAt: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(authDir, 'a.json'), '{}', 'utf-8');
|
||||||
|
fs.writeFileSync(path.join(authDir, 'c.json'), '{}', 'utf-8');
|
||||||
|
|
||||||
|
// Force TTY so only the automation guard (not the non-tty guard) can fire.
|
||||||
|
const origStdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
||||||
|
const origStderrTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||||
|
Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
|
||||||
|
|
||||||
|
// confirm must NEVER be called on the bypass path.
|
||||||
|
const confirmSpy = spyOn(promptModule.InteractivePrompt, 'confirm').mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1, 8317);
|
||||||
|
|
||||||
|
expect(result.skipped).toBe(true);
|
||||||
|
expect(result.skipReason).toBe('automation-bypass');
|
||||||
|
expect(result.enabled).toBe(false);
|
||||||
|
expect(confirmSpy).not.toHaveBeenCalled();
|
||||||
|
// Dismissal must NOT be persisted (future interactive run should still offer).
|
||||||
|
expect(poolOptInModule.isPoolPromptDismissed()).toBe(false);
|
||||||
|
|
||||||
|
confirmSpy.mockRestore();
|
||||||
|
if (origStdinTTY) {
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', origStdinTTY);
|
||||||
|
} else {
|
||||||
|
delete (process.stdin as { isTTY?: boolean }).isTTY;
|
||||||
|
}
|
||||||
|
if (origStderrTTY) {
|
||||||
|
Object.defineProperty(process.stderr, 'isTTY', origStderrTTY);
|
||||||
|
} else {
|
||||||
|
delete (process.stderr as { isTTY?: boolean }).isTTY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PR #1514: enablePoolRouting rollback on regenerate failure', () => {
|
||||||
|
let tempHome: string;
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHome = createTestHome();
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
invalidateSharedConfigCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix index 6: if regenerateConfig throws, the pool_routing.enabled flag must be
|
||||||
|
// rolled back so status surfaces do not lie, and a recovery message is returned.
|
||||||
|
// Force a real fs failure (no mock): place a regular FILE where the config
|
||||||
|
// directory must be, so regenerateConfig's mkdirSync(dirname) throws ENOTDIR.
|
||||||
|
it('rolls back enabled flag and returns failure recovery copy when regenerate throws', async () => {
|
||||||
|
const ts = Date.now();
|
||||||
|
const { enablePoolRouting } = await import(`../routing/routing-strategy?p1514fail=${ts}`);
|
||||||
|
const { loadOrCreateUnifiedConfig } = await import(
|
||||||
|
`../../config/config-loader-facade?p1514fail=${ts}`
|
||||||
|
);
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
const cliproxyDir = path.join(ccsDir, 'cliproxy');
|
||||||
|
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||||
|
// Block the config dir: create a FILE named "blocked" then aim the config path
|
||||||
|
// at blocked/config.yaml so mkdirSync(dirname) hits ENOTDIR.
|
||||||
|
const blockedFile = path.join(cliproxyDir, 'blocked');
|
||||||
|
fs.writeFileSync(blockedFile, 'not a dir', 'utf-8');
|
||||||
|
const configPath = path.join(blockedFile, 'config.yaml');
|
||||||
|
const authDir = path.join(cliproxyDir, 'auth');
|
||||||
|
|
||||||
|
const result = enablePoolRouting(8317, { configPath, authDir });
|
||||||
|
|
||||||
|
expect(result.failed).toBe(true);
|
||||||
|
expect(result.changed).toBe(false);
|
||||||
|
expect(result.message).toContain('[X] Could not write CLIProxy config');
|
||||||
|
expect(result.message).toContain('ccs cliproxy pool --enable');
|
||||||
|
|
||||||
|
// The flag must be rolled back to NOT-enabled so status surfaces do not lie.
|
||||||
|
const cfg = loadOrCreateUnifiedConfig();
|
||||||
|
expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Repair path: when the flag is already true (a prior regenerate failed),
|
||||||
|
// pool --enable must re-run regenerateConfig instead of a dead no-op.
|
||||||
|
it('already-enabled path re-runs regenerateConfig (idempotent repair)', async () => {
|
||||||
|
const ts = Date.now();
|
||||||
|
// Persist enabled=true WITHOUT a regenerated config.yaml (simulate prior failure).
|
||||||
|
const { mutateConfig, invalidateConfigCache } = await import(
|
||||||
|
`../../config/config-loader-facade?p1514repair=${ts}`
|
||||||
|
);
|
||||||
|
mutateConfig((cfg: { cliproxy?: { pool_routing?: Record<string, unknown> } }) => {
|
||||||
|
cfg.cliproxy = cfg.cliproxy ?? {};
|
||||||
|
cfg.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
|
||||||
|
});
|
||||||
|
invalidateConfigCache();
|
||||||
|
invalidateSharedConfigCache();
|
||||||
|
|
||||||
|
const { enablePoolRouting } = await import(`../routing/routing-strategy?p1514repair=${ts}`);
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
const configPath = path.join(ccsDir, 'cliproxy', 'config.yaml');
|
||||||
|
const authDir = path.join(ccsDir, 'cliproxy', 'auth');
|
||||||
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
|
fs.mkdirSync(authDir, { recursive: true });
|
||||||
|
// Config does NOT exist yet — the repair must create it.
|
||||||
|
expect(fs.existsSync(configPath)).toBe(false);
|
||||||
|
|
||||||
|
const result = enablePoolRouting(8317, { configPath, authDir });
|
||||||
|
|
||||||
|
// Idempotent (changed=false) but the config was regenerated with pool rails.
|
||||||
|
expect(result.changed).toBe(false);
|
||||||
|
expect(result.failed).toBeFalsy();
|
||||||
|
expect(fs.existsSync(configPath)).toBe(true);
|
||||||
|
const content = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
expect(content).toContain('disable-cooling: false');
|
||||||
|
expect(content).toContain('strategy: fill-first');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -721,3 +721,215 @@ describe('resolveEffectiveDrainOrder', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// clearAuthFilePriorityDirect + clearDrainOrderPriorities
|
||||||
|
// (the missing half of `order --reset`: actually strip residual priorities)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('clearAuthFilePriorityDirect', () => {
|
||||||
|
it('removes the priority field and preserves all other fields', async () => {
|
||||||
|
await withIsolatedHome(async (homeDir) => {
|
||||||
|
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||||
|
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 4, keep: 'me' });
|
||||||
|
|
||||||
|
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
|
||||||
|
const removed = clearAuthFilePriorityDirect('a.json');
|
||||||
|
|
||||||
|
expect(removed).toBe(true);
|
||||||
|
const data = readAuthFile(authDir, 'a.json');
|
||||||
|
expect('priority' in data).toBe(false);
|
||||||
|
expect(data.keep).toBe('me');
|
||||||
|
expect(data.email).toBe('a@x.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false (no-op) when the file has no priority field', async () => {
|
||||||
|
await withIsolatedHome(async (homeDir) => {
|
||||||
|
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||||
|
writeAuthFile(authDir, 'a.json', { email: 'a@x.com' });
|
||||||
|
|
||||||
|
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
|
||||||
|
expect(clearAuthFilePriorityDirect('a.json')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the file does not exist', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
|
||||||
|
expect(() => clearAuthFilePriorityDirect('ghost.json')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearDrainOrderPriorities - direct path (proxy stopped)', () => {
|
||||||
|
it('clears residual priorities from all files via direct write', async () => {
|
||||||
|
await withIsolatedHome(async (homeDir) => {
|
||||||
|
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||||
|
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
|
||||||
|
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear
|
||||||
|
|
||||||
|
const { clearDrainOrderPriorities } = await loadDrainOrder();
|
||||||
|
const result = await clearDrainOrderPriorities(['a.json', 'b.json'], false);
|
||||||
|
|
||||||
|
expect(result.usedManagementApi).toBe(false);
|
||||||
|
expect(result.cleared).toEqual(['a.json']);
|
||||||
|
expect(result.alreadyClear).toEqual(['b.json']);
|
||||||
|
expect(result.failed).toHaveLength(0);
|
||||||
|
expect('priority' in readAuthFile(authDir, 'a.json')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a failure for a missing file', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const { clearDrainOrderPriorities } = await loadDrainOrder();
|
||||||
|
const result = await clearDrainOrderPriorities(['ghost.json'], false);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
expect(result.cleared).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearDrainOrderPriorities - management API path (proxy running)', () => {
|
||||||
|
it('PATCHes priority:0 (delete) for files with a residual priority and skips clear ones', async () => {
|
||||||
|
await withIsolatedHome(async (homeDir) => {
|
||||||
|
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||||
|
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
|
||||||
|
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear -> no API call
|
||||||
|
|
||||||
|
const calls: Array<{ url: string; body: unknown }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
calls.push({ url, body: JSON.parse(String(init?.body ?? '{}')) });
|
||||||
|
return new Response('{}', { status: 200 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clearDrainOrderPriorities } = await loadDrainOrder();
|
||||||
|
const result = await clearDrainOrderPriorities(['a.json', 'b.json'], true);
|
||||||
|
|
||||||
|
expect(result.usedManagementApi).toBe(true);
|
||||||
|
expect(result.cleared).toEqual(['a.json']);
|
||||||
|
expect(result.alreadyClear).toEqual(['b.json']);
|
||||||
|
expect(result.failed).toHaveLength(0);
|
||||||
|
|
||||||
|
// Only a.json (which had a residual priority) triggered an API call,
|
||||||
|
// and it patched priority:0 (the delete sentinel).
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0].url).toContain('/v0/management/auth-files/fields');
|
||||||
|
expect(calls[0].body).toEqual({ name: 'a.json', priority: 0 });
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a failure when the PATCH returns a non-ok status', async () => {
|
||||||
|
await withIsolatedHome(async (homeDir) => {
|
||||||
|
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||||
|
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clearDrainOrderPriorities } = await loadDrainOrder();
|
||||||
|
const result = await clearDrainOrderPriorities(['a.json'], true);
|
||||||
|
expect(result.cleared).toHaveLength(0);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
expect(result.failed[0].tokenFile).toBe('a.json');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fetchProxyAuthCooldowns - live in-proxy 429 cooldowns
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('fetchProxyAuthCooldowns', () => {
|
||||||
|
it('classifies unavailable entries as cooldowns and parses next_retry_after', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const next = '2026-06-11T12:00:00.000Z';
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
files: [
|
||||||
|
{ name: 'cooling.json', unavailable: true, next_retry_after: next },
|
||||||
|
{ name: 'no-retry.json', unavailable: true },
|
||||||
|
{ name: 'healthy.json', unavailable: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200 }
|
||||||
|
)) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
|
||||||
|
const cooldowns = await fetchProxyAuthCooldowns();
|
||||||
|
|
||||||
|
// Only the two unavailable entries are returned; healthy.json is excluded.
|
||||||
|
expect(cooldowns.map((c: { tokenFile: string }) => c.tokenFile).sort()).toEqual([
|
||||||
|
'cooling.json',
|
||||||
|
'no-retry.json',
|
||||||
|
]);
|
||||||
|
const cooling = cooldowns.find(
|
||||||
|
(c: { tokenFile: string }) => c.tokenFile === 'cooling.json'
|
||||||
|
);
|
||||||
|
expect(cooling?.cooldownUntil).toBe(Date.parse(next));
|
||||||
|
const noRetry = cooldowns.find(
|
||||||
|
(c: { tokenFile: string }) => c.tokenFile === 'no-retry.json'
|
||||||
|
);
|
||||||
|
expect(noRetry?.cooldownUntil).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades silently to [] on a non-ok response', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () => new Response('err', { status: 404 })) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
|
||||||
|
expect(await fetchProxyAuthCooldowns()).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades silently to [] on a network error', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
throw new TypeError('network down');
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
|
||||||
|
expect(await fetchProxyAuthCooldowns()).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades silently to [] on an unexpected response shape', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response(JSON.stringify({ unexpected: 'shape' }), { status: 200 })) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
|
||||||
|
expect(await fetchProxyAuthCooldowns()).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,13 @@ async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SETTINGS_ON: PoolRoutingSettings = {
|
||||||
|
poolEnabled: true,
|
||||||
|
strategy: 'fill-first',
|
||||||
|
sessionAffinityEnabled: true,
|
||||||
|
sessionAffinityTtl: '1h',
|
||||||
|
};
|
||||||
|
|
||||||
async function loadPoolState() {
|
async function loadPoolState() {
|
||||||
return import(`../pool-state?pool-state=${Date.now()}`);
|
return import(`../pool-state?pool-state=${Date.now()}`);
|
||||||
}
|
}
|
||||||
@@ -260,3 +267,157 @@ describe('resolvePoolState - drain order', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolvePoolState - in-proxy cooldown classification', () => {
|
||||||
|
it('classifies an injected proxy cooldown as cooling with source "proxy"', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const { resolvePoolState } = await loadPoolState();
|
||||||
|
const until = 2_000_000;
|
||||||
|
const pool = resolvePoolState({
|
||||||
|
provider: 'claude',
|
||||||
|
settings: SETTINGS_ON,
|
||||||
|
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||||
|
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: until }],
|
||||||
|
now: 1_000_000,
|
||||||
|
});
|
||||||
|
expect(pool.states[0].state).toBe('cooling');
|
||||||
|
expect(pool.states[0].cooldownUntil).toBe(until);
|
||||||
|
expect(pool.states[0].cooldownSource).toBe('proxy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a proxy cooldown with no next_retry_after as cooling (no reset time)', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const { resolvePoolState } = await loadPoolState();
|
||||||
|
const pool = resolvePoolState({
|
||||||
|
provider: 'claude',
|
||||||
|
settings: SETTINGS_ON,
|
||||||
|
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||||
|
proxyCooldowns: [{ tokenFile: 'claude-a.json' }],
|
||||||
|
now: 1_000_000,
|
||||||
|
});
|
||||||
|
expect(pool.states[0].state).toBe('cooling');
|
||||||
|
expect(pool.states[0].cooldownUntil).toBeUndefined();
|
||||||
|
expect(pool.states[0].cooldownSource).toBe('proxy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxy cooldown wins over a persisted quota cooldown for the same account', async () => {
|
||||||
|
await withIsolatedHome(async (homeDir) => {
|
||||||
|
const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json');
|
||||||
|
fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
quotaPausedPath,
|
||||||
|
JSON.stringify({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
provider: 'claude',
|
||||||
|
accountId: 'a@x.com',
|
||||||
|
pausedAt: '2026-06-10T12:00:00.000Z',
|
||||||
|
until: 1_500_000,
|
||||||
|
reason: 'quota_exhausted',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { resolvePoolState } = await loadPoolState();
|
||||||
|
const pool = resolvePoolState({
|
||||||
|
provider: 'claude',
|
||||||
|
settings: SETTINGS_ON,
|
||||||
|
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||||
|
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 9_000_000 }],
|
||||||
|
now: 1_000_000,
|
||||||
|
});
|
||||||
|
// Proxy (live routing truth) wins: source 'proxy', its reset time, not the
|
||||||
|
// persisted quota-paused.json one.
|
||||||
|
expect(pool.states[0].cooldownSource).toBe('proxy');
|
||||||
|
expect(pool.states[0].cooldownUntil).toBe(9_000_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves accounts available when no proxy cooldown is provided (graceful degradation)', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState();
|
||||||
|
const pool = resolvePoolState({
|
||||||
|
provider: 'claude',
|
||||||
|
settings: SETTINGS_ON,
|
||||||
|
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||||
|
// proxyCooldowns omitted -> CCS-side view only.
|
||||||
|
now: 1_000_000,
|
||||||
|
});
|
||||||
|
expect(pool.states[0].state).toBe('available');
|
||||||
|
expect(hasProxySourcedCooling(pool)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasProxySourcedCooling reports true only when a proxy-sourced cooling exists', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState();
|
||||||
|
const pool = resolvePoolState({
|
||||||
|
provider: 'claude',
|
||||||
|
settings: SETTINGS_ON,
|
||||||
|
accounts: [
|
||||||
|
account({ id: 'a@x.com', tokenFile: 'claude-a.json' }),
|
||||||
|
account({ id: 'b@x.com', tokenFile: 'claude-b.json' }),
|
||||||
|
],
|
||||||
|
proxyCooldowns: [{ tokenFile: 'claude-b.json', cooldownUntil: 2_000_000 }],
|
||||||
|
now: 1_000_000,
|
||||||
|
});
|
||||||
|
expect(hasProxySourcedCooling(pool)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolvePoolStateWithProxyCooldowns - fetch + degradation', () => {
|
||||||
|
it('skips the proxy fetch and stays available when pool routing is OFF', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const { resolvePoolStateWithProxyCooldowns } = await loadPoolState();
|
||||||
|
let fetched = false;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
fetched = true;
|
||||||
|
return new Response('{}', { status: 200 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const pool = await resolvePoolStateWithProxyCooldowns({
|
||||||
|
provider: 'claude',
|
||||||
|
settings: SETTINGS_OFF,
|
||||||
|
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||||
|
now: 1_000_000,
|
||||||
|
});
|
||||||
|
expect(pool.states[0].state).toBe('available');
|
||||||
|
// Pool OFF -> no management API call at all.
|
||||||
|
expect(fetched).toBe(false);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours injected proxyCooldowns without fetching, even when pool is ON', async () => {
|
||||||
|
await withIsolatedHome(async () => {
|
||||||
|
const { resolvePoolStateWithProxyCooldowns } = await loadPoolState();
|
||||||
|
let fetched = false;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
fetched = true;
|
||||||
|
return new Response('{}', { status: 200 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const pool = await resolvePoolStateWithProxyCooldowns({
|
||||||
|
provider: 'claude',
|
||||||
|
settings: SETTINGS_ON,
|
||||||
|
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
|
||||||
|
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 2_000_000 }],
|
||||||
|
now: 1_000_000,
|
||||||
|
});
|
||||||
|
expect(pool.states[0].cooldownSource).toBe('proxy');
|
||||||
|
// Injected cooldowns short-circuit the fetch.
|
||||||
|
expect(fetched).toBe(false);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,30 +8,107 @@
|
|||||||
* Google/Anthropic treat as suspicious.
|
* Google/Anthropic treat as suspicious.
|
||||||
*
|
*
|
||||||
* Scope of check: compare the newly registered CLIProxy account email against
|
* Scope of check: compare the newly registered CLIProxy account email against
|
||||||
* the email of the currently active native Claude Code profile (via `claude
|
* the email of every native Claude Code lane the user has:
|
||||||
* auth status`). The profiles.json v3.0 schema removed the email field,
|
* 1. The ambient ~/.claude login (via `claude auth status`).
|
||||||
* so we rely on the live auth status command which is already used by
|
* 2. Each isolated CCS account profile lane (per-profile CLAUDE_CONFIG_DIR
|
||||||
* quota-fetcher-claude.ts.
|
* instance dir). These are the exact multi-account population the guard
|
||||||
|
* exists to protect, so they MUST be inspected, not just the default lane.
|
||||||
|
*
|
||||||
|
* Lane emails are read directly from each lane's `.claude.json`
|
||||||
|
* (`oauthAccount.emailAddress`) — cheap file reads, no CLI spawn. The
|
||||||
|
* profiles.json v3.0 schema removed the email field, so the per-lane config is
|
||||||
|
* the source of truth for an account profile's logged-in email.
|
||||||
*
|
*
|
||||||
* This guard is advisory only: it warns on stderr but does not block the add.
|
* This guard is advisory only: it warns on stderr but does not block the add.
|
||||||
* The user may intentionally separate accounts; a false positive is less harmful
|
* The user may intentionally separate accounts; a false positive is less harmful
|
||||||
* than silently allowing a true overlap.
|
* than silently allowing a true overlap. Missing or unreadable lane files are
|
||||||
|
* silently skipped — a corrupt profile must never block or crash auth.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import { warn } from '../../utils/ui';
|
import { warn } from '../../utils/ui';
|
||||||
import { getClaudeAuthStatus } from '../../utils/claude-detector';
|
import { getClaudeAuthStatus } from '../../utils/claude-detector';
|
||||||
import { maskEmail } from './account-safety';
|
import { maskEmail } from './account-safety';
|
||||||
|
import InstanceManager from '../../management/instance-manager';
|
||||||
|
import { ProfileRegistry } from '../../auth/profile-registry';
|
||||||
import type { CLIProxyProvider } from '../types';
|
import type { CLIProxyProvider } from '../types';
|
||||||
|
|
||||||
/** Providers where CLIProxy OAuth could create a cross-lane conflict with native Claude */
|
/** Providers where CLIProxy OAuth could create a cross-lane conflict with native Claude */
|
||||||
const CROSS_LANE_RISK_PROVIDERS: CLIProxyProvider[] = ['claude', 'agy', 'gemini', 'codex'];
|
const CROSS_LANE_RISK_PROVIDERS: CLIProxyProvider[] = ['claude', 'agy', 'gemini', 'codex'];
|
||||||
|
|
||||||
|
/** A native Claude lane that overlaps with the new CLIProxy account email. */
|
||||||
|
interface LaneOverlap {
|
||||||
|
/** The lane's masked email (for display). */
|
||||||
|
maskedEmail: string;
|
||||||
|
/** Human label for the lane, e.g. "native Claude Code login" or `profile "work"`. */
|
||||||
|
laneLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the newly added CLIProxy account email matches the email of
|
* Read the OAuth email stored in a native Claude lane's `.claude.json`.
|
||||||
* the currently active native Claude Code profile.
|
|
||||||
*
|
*
|
||||||
* Emits a warning to stderr if an overlap is detected. Silent on errors
|
* Returns the lowercased/trimmed email, or null when the file is missing,
|
||||||
* (CLI not found, not logged in, etc.) — the check is best-effort.
|
* unreadable, malformed, or has no logged-in account. Never throws — a corrupt
|
||||||
|
* profile is silently skipped (the guard is advisory).
|
||||||
|
*/
|
||||||
|
function readLaneEmail(claudeConfigDir: string): string | null {
|
||||||
|
try {
|
||||||
|
const claudeJsonPath = path.join(claudeConfigDir, '.claude.json');
|
||||||
|
const raw = fs.readFileSync(claudeJsonPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
|
const oauthAccount = (parsed as Record<string, unknown>).oauthAccount;
|
||||||
|
if (!oauthAccount || typeof oauthAccount !== 'object') return null;
|
||||||
|
const email = (oauthAccount as Record<string, unknown>).emailAddress;
|
||||||
|
if (typeof email !== 'string' || email.trim().length === 0) return null;
|
||||||
|
return email.toLowerCase().trim();
|
||||||
|
} catch {
|
||||||
|
// Missing / unreadable / malformed lane config — skip silently.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate CCS account profile lanes and collect any whose stored OAuth email
|
||||||
|
* matches the newly added CLIProxy account email.
|
||||||
|
*
|
||||||
|
* Pure file reads (one `.claude.json` per profile) — no CLI spawn. Best-effort:
|
||||||
|
* any failure to enumerate or read a lane is swallowed so the guard can never
|
||||||
|
* block or crash the add.
|
||||||
|
*/
|
||||||
|
function findAccountProfileOverlaps(normalizedEmail: string): LaneOverlap[] {
|
||||||
|
const overlaps: LaneOverlap[] = [];
|
||||||
|
try {
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const profiles = registry.getAllProfilesMerged();
|
||||||
|
const accountNames = Object.entries(profiles)
|
||||||
|
.filter(([, meta]) => meta.type === 'account')
|
||||||
|
.map(([name]) => name);
|
||||||
|
if (accountNames.length === 0) return overlaps;
|
||||||
|
|
||||||
|
const instanceMgr = new InstanceManager();
|
||||||
|
for (const name of accountNames) {
|
||||||
|
const instancePath = instanceMgr.getInstancePath(name);
|
||||||
|
const laneEmail = readLaneEmail(instancePath);
|
||||||
|
if (laneEmail && laneEmail === normalizedEmail) {
|
||||||
|
overlaps.push({ maskedEmail: maskEmail(laneEmail), laneLabel: `profile "${name}"` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Registry / instance-manager construction failed — skip lane enumeration.
|
||||||
|
}
|
||||||
|
return overlaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the newly added CLIProxy account email matches the email of any
|
||||||
|
* native Claude Code lane: the ambient ~/.claude login or any isolated CCS
|
||||||
|
* account profile lane.
|
||||||
|
*
|
||||||
|
* Emits a warning to stderr for each overlapping lane. Silent on errors
|
||||||
|
* (CLI not found, not logged in, unreadable profile, etc.) — the check is
|
||||||
|
* best-effort and never blocks the add.
|
||||||
*
|
*
|
||||||
* @param provider - The CLIProxy provider being added
|
* @param provider - The CLIProxy provider being added
|
||||||
* @param email - Email address of the account that was just registered
|
* @param email - Email address of the account that was just registered
|
||||||
@@ -40,21 +117,36 @@ export function checkCrossLaneEmailOverlap(provider: CLIProxyProvider, email: st
|
|||||||
if (!CROSS_LANE_RISK_PROVIDERS.includes(provider)) return;
|
if (!CROSS_LANE_RISK_PROVIDERS.includes(provider)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = getClaudeAuthStatus();
|
|
||||||
if (!status?.loggedIn || !status.email) return;
|
|
||||||
|
|
||||||
const normalized = email.toLowerCase().trim();
|
const normalized = email.toLowerCase().trim();
|
||||||
const nativeNormalized = status.email.toLowerCase().trim();
|
if (normalized.length === 0) return;
|
||||||
|
|
||||||
if (normalized !== nativeNormalized) return;
|
const overlaps: LaneOverlap[] = [];
|
||||||
|
|
||||||
|
// 1. Ambient ~/.claude login (default lane).
|
||||||
|
const status = getClaudeAuthStatus();
|
||||||
|
if (status?.loggedIn && status.email) {
|
||||||
|
const ambientNormalized = status.email.toLowerCase().trim();
|
||||||
|
if (ambientNormalized === normalized) {
|
||||||
|
overlaps.push({
|
||||||
|
maskedEmail: maskEmail(status.email),
|
||||||
|
laneLabel: 'native Claude Code login',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Isolated CCS account profile lanes (per-profile CLAUDE_CONFIG_DIR).
|
||||||
|
overlaps.push(...findAccountProfileOverlaps(normalized));
|
||||||
|
|
||||||
|
if (overlaps.length === 0) return;
|
||||||
|
|
||||||
const masked = maskEmail(email);
|
const masked = maskEmail(email);
|
||||||
const nativeMasked = maskEmail(status.email);
|
|
||||||
|
|
||||||
console.error('');
|
console.error('');
|
||||||
console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`));
|
console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`));
|
||||||
console.error(` CLIProxy account: ${masked} (${provider})`);
|
console.error(` CLIProxy account: ${masked} (${provider})`);
|
||||||
console.error(` Native Claude Code profile: ${nativeMasked} (logged in)`);
|
for (const overlap of overlaps) {
|
||||||
|
console.error(` Native Claude Code lane: ${overlap.maskedEmail} (${overlap.laneLabel})`);
|
||||||
|
}
|
||||||
console.error(
|
console.error(
|
||||||
' Same account active in both CLIProxy and native Claude lanes is a known ban risk.'
|
' Same account active in both CLIProxy and native Claude lanes is a known ban risk.'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,9 +29,30 @@ export const MIN_PRIORITY = 1;
|
|||||||
/** Management API path for patching auth file fields */
|
/** Management API path for patching auth file fields */
|
||||||
const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields';
|
const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields';
|
||||||
|
|
||||||
|
/** Management API path for listing auth files (and their runtime status). */
|
||||||
|
const AUTH_FILES_PATH = '/v0/management/auth-files';
|
||||||
|
|
||||||
/** Timeout for management API calls in ms */
|
/** Timeout for management API calls in ms */
|
||||||
const MGMT_TIMEOUT_MS = 5000;
|
const MGMT_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One in-proxy cooldown reading, keyed by auth file basename.
|
||||||
|
*
|
||||||
|
* Source of truth: GET /v0/management/auth-files. Verified field names from
|
||||||
|
* CLIProxyAPIPlus internal/api/handlers/management/auth_files.go buildAuthFileEntry:
|
||||||
|
* - "name" -> the token file basename
|
||||||
|
* - "unavailable" -> bool, set when a credential is cooling after a 429
|
||||||
|
* - "next_retry_after" -> RFC3339 timestamp, present only when non-zero
|
||||||
|
* CLIProxyAPI (the base upstream) exposes the same fields via its own
|
||||||
|
* auth-files handler (sdk/cliproxy/auth/types.go: Unavailable / NextRetryAfter).
|
||||||
|
*/
|
||||||
|
export interface ProxyAuthCooldown {
|
||||||
|
/** Token file basename (matches AccountInfo.tokenFile). */
|
||||||
|
tokenFile: string;
|
||||||
|
/** Epoch ms when the in-proxy cooldown is eligible to lift, if known. */
|
||||||
|
cooldownUntil?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tier rank for priority derivation.
|
* Tier rank for priority derivation.
|
||||||
* Higher rank = higher priority = drained first.
|
* Higher rank = higher priority = drained first.
|
||||||
@@ -258,6 +279,29 @@ export async function writeAuthFilePriorityViaApi(
|
|||||||
throw new Error(`Priority must be >= ${MIN_PRIORITY}, got ${priority}`);
|
throw new Error(`Priority must be >= ${MIN_PRIORITY}, got ${priority}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return patchAuthFilePriority(tokenFile, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the priority attribute via CLIProxy management API (proxy running).
|
||||||
|
* Sends PATCH /v0/management/auth-files/fields with priority:0, which the
|
||||||
|
* management layer treats as "delete the attribute" (verified upstream:
|
||||||
|
* CLIProxyAPIPlus syncAuthFilePriorityAttribute deletes the attribute when the
|
||||||
|
* incoming priority is 0 or absent, which then persists the auth file without a
|
||||||
|
* top-level priority field).
|
||||||
|
*
|
||||||
|
* Using the API path is mandatory while the proxy is running: a direct file
|
||||||
|
* write would be clobbered by the proxy's whole-file MarkResult persist.
|
||||||
|
*
|
||||||
|
* @param tokenFile File name (basename, e.g. "antigravity-foo.json")
|
||||||
|
* @returns true on success, false on failure
|
||||||
|
*/
|
||||||
|
export async function clearAuthFilePriorityViaApi(tokenFile: string): Promise<boolean> {
|
||||||
|
return patchAuthFilePriority(tokenFile, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared PATCH-fields call for setting (>=1) or clearing (0) a file's priority. */
|
||||||
|
async function patchAuthFilePriority(tokenFile: string, priority: number): Promise<boolean> {
|
||||||
const target = getProxyTarget();
|
const target = getProxyTarget();
|
||||||
const url = buildProxyUrl(target, AUTH_FILES_FIELDS_PATH);
|
const url = buildProxyUrl(target, AUTH_FILES_FIELDS_PATH);
|
||||||
const headers = buildManagementHeaders(target, { 'Content-Type': 'application/json' });
|
const headers = buildManagementHeaders(target, { 'Content-Type': 'application/json' });
|
||||||
@@ -283,6 +327,105 @@ export async function writeAuthFilePriorityViaApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the priority field directly from an auth JSON file (proxy stopped).
|
||||||
|
* No-op (returns false) when the file has no priority field. Preserves all
|
||||||
|
* other fields; uses the same atomic temp-rename as writeAuthFilePriorityDirect.
|
||||||
|
*
|
||||||
|
* @returns true if a priority field was present and removed, false otherwise
|
||||||
|
*/
|
||||||
|
export function clearAuthFilePriorityDirect(tokenFile: string): boolean {
|
||||||
|
const authDir = getAuthDir();
|
||||||
|
const filePath = path.join(authDir, tokenFile);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`Auth file not found: ${tokenFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(content) as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!('priority' in data)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete data.priority;
|
||||||
|
|
||||||
|
const tempPath = `${filePath}.tmp.${process.pid}`;
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
fs.renameSync(tempPath, filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of clearing residual priorities across a set of auth files. */
|
||||||
|
export interface ClearDrainOrderPrioritiesResult {
|
||||||
|
/** Token files where a priority field was present and removed. */
|
||||||
|
cleared: string[];
|
||||||
|
/** Token files that had no priority field (nothing to do). */
|
||||||
|
alreadyClear: string[];
|
||||||
|
/** Token files that failed, with the reason. */
|
||||||
|
failed: Array<{ tokenFile: string; reason: string }>;
|
||||||
|
/** Whether the management API path was used (proxy was running). */
|
||||||
|
usedManagementApi: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear residual on-disk priority fields for the given auth files.
|
||||||
|
*
|
||||||
|
* Mirrors applyDrainOrder's dual write path:
|
||||||
|
* - Proxy running: PATCH priority:0 via the management API (the only safe path;
|
||||||
|
* a direct write is clobbered by the proxy's whole-file persist).
|
||||||
|
* - Proxy stopped: delete the field directly with an atomic temp-rename.
|
||||||
|
*
|
||||||
|
* Idempotent: files with no priority field are reported as alreadyClear, not
|
||||||
|
* failures. The running path always reports the file as cleared on a 200 (the
|
||||||
|
* API treats a missing-attribute delete as a successful no-op), so this is safe
|
||||||
|
* to run repeatedly.
|
||||||
|
*
|
||||||
|
* @param tokenFiles Auth file basenames to clear.
|
||||||
|
* @param proxyRunning Whether the proxy is currently running.
|
||||||
|
*/
|
||||||
|
export async function clearDrainOrderPriorities(
|
||||||
|
tokenFiles: string[],
|
||||||
|
proxyRunning: boolean
|
||||||
|
): Promise<ClearDrainOrderPrioritiesResult> {
|
||||||
|
const result: ClearDrainOrderPrioritiesResult = {
|
||||||
|
cleared: [],
|
||||||
|
alreadyClear: [],
|
||||||
|
failed: [],
|
||||||
|
usedManagementApi: proxyRunning,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const tokenFile of tokenFiles) {
|
||||||
|
if (proxyRunning) {
|
||||||
|
// Skip the API round-trip when the file already has no priority on disk.
|
||||||
|
if (readAuthFilePriority(tokenFile) === undefined) {
|
||||||
|
result.alreadyClear.push(tokenFile);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ok = await clearAuthFilePriorityViaApi(tokenFile);
|
||||||
|
if (ok) {
|
||||||
|
result.cleared.push(tokenFile);
|
||||||
|
} else {
|
||||||
|
result.failed.push({ tokenFile, reason: 'management API PATCH failed' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const removed = clearAuthFilePriorityDirect(tokenFile);
|
||||||
|
if (removed) {
|
||||||
|
result.cleared.push(tokenFile);
|
||||||
|
} else {
|
||||||
|
result.alreadyClear.push(tokenFile);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
result.failed.push({ tokenFile, reason: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of a drain order apply operation.
|
* Result of a drain order apply operation.
|
||||||
*/
|
*/
|
||||||
@@ -496,3 +639,76 @@ export function resolveEffectiveDrainOrder(
|
|||||||
|
|
||||||
return { mode, entries, hasDrift };
|
return { mode, entries, hasDrift };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Raw auth-file entry shape we parse out of the management listing. */
|
||||||
|
interface RawManagementAuthFile {
|
||||||
|
name?: unknown;
|
||||||
|
unavailable?: unknown;
|
||||||
|
next_retry_after?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch in-proxy cooldowns from the running CLIProxy.
|
||||||
|
*
|
||||||
|
* Pool routing turns cooling ON, so when a credential hits a 429 the proxy
|
||||||
|
* marks it Unavailable and rotates to a healthy one. That cooldown lives inside
|
||||||
|
* the proxy process (not in CCS's quota-paused.json), so the only way the quota
|
||||||
|
* Pool section can explain a session hop is to read it here.
|
||||||
|
*
|
||||||
|
* GET /v0/management/auth-files returns { files: [{ name, unavailable,
|
||||||
|
* next_retry_after, ... }] }. We classify any entry with unavailable=true as a
|
||||||
|
* cooling credential and parse next_retry_after (RFC3339) into epoch ms when
|
||||||
|
* present.
|
||||||
|
*
|
||||||
|
* Degrades silently to an empty array on any failure (proxy not running,
|
||||||
|
* endpoint missing on an older binary, malformed/unknown response shape,
|
||||||
|
* timeout): the caller falls back to its CCS-side cooldown view with no error
|
||||||
|
* spam in quota output. Latency is bounded by a single request and MGMT_TIMEOUT_MS.
|
||||||
|
*
|
||||||
|
* @returns One entry per auth file the proxy reports unavailable.
|
||||||
|
*/
|
||||||
|
export async function fetchProxyAuthCooldowns(): Promise<ProxyAuthCooldown[]> {
|
||||||
|
const target = getProxyTarget();
|
||||||
|
const url = buildProxyUrl(target, AUTH_FILES_PATH);
|
||||||
|
const headers = buildManagementHeaders(target);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), MGMT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { files?: unknown };
|
||||||
|
if (!Array.isArray(data.files)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldowns: ProxyAuthCooldown[] = [];
|
||||||
|
for (const raw of data.files as RawManagementAuthFile[]) {
|
||||||
|
// Defensive parse: only entries the proxy explicitly flags unavailable.
|
||||||
|
if (!raw || raw.unavailable !== true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = typeof raw.name === 'string' ? raw.name : undefined;
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cooldownUntil: number | undefined;
|
||||||
|
if (typeof raw.next_retry_after === 'string') {
|
||||||
|
const parsed = Date.parse(raw.next_retry_after);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
cooldownUntil = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cooldowns.push({ tokenFile: name, cooldownUntil });
|
||||||
|
}
|
||||||
|
return cooldowns;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@
|
|||||||
* with "it ran out of quota".
|
* with "it ran out of quota".
|
||||||
*
|
*
|
||||||
* Cooldown source precedence:
|
* Cooldown source precedence:
|
||||||
|
* - The in-proxy cooldown (read from GET /v0/management/auth-files) is the real
|
||||||
|
* hop mechanism when pool routing is ON: the proxy cools a credential on a 429
|
||||||
|
* and rotates. It wins over everything else because it is the live routing
|
||||||
|
* truth that actually caused the session to move.
|
||||||
* - The proxy/monitor process writes quota-paused.json (cross-process truth).
|
* - The proxy/monitor process writes quota-paused.json (cross-process truth).
|
||||||
* When pool routing's cooling flip is OFF (stock disable-cooling: true) there
|
* When pool routing's cooling flip is OFF (stock disable-cooling: true) there
|
||||||
* is simply no cooldown data to show; this resolver reports that honestly.
|
* is simply no cooldown data to show; this resolver reports that honestly.
|
||||||
@@ -23,10 +27,18 @@
|
|||||||
* both exist, because that is the routing truth across processes.
|
* both exist, because that is the routing truth across processes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { resolveEffectiveDrainOrder, type DrainOrderInput } from './drain-order';
|
import {
|
||||||
|
resolveEffectiveDrainOrder,
|
||||||
|
fetchProxyAuthCooldowns,
|
||||||
|
type DrainOrderInput,
|
||||||
|
type ProxyAuthCooldown,
|
||||||
|
} from './drain-order';
|
||||||
import { getProviderAccounts } from './query';
|
import { getProviderAccounts } from './query';
|
||||||
import { readQuotaCooldownEntries } from './account-safety';
|
import { readQuotaCooldownEntries } from './account-safety';
|
||||||
import { getCooldownUntil } from '../quota/quota-manager';
|
import { getCooldownUntil } from '../quota/quota-manager';
|
||||||
|
import { getProxyTarget } from '../proxy/proxy-target-resolver';
|
||||||
|
import { detectRunningProxy } from '../proxy/proxy-detector';
|
||||||
|
import { resolveLifecyclePort } from '../config/port-manager';
|
||||||
import type { AccountInfo, AccountTier } from './types';
|
import type { AccountInfo, AccountTier } from './types';
|
||||||
import type { CLIProxyProvider } from '../types';
|
import type { CLIProxyProvider } from '../types';
|
||||||
|
|
||||||
@@ -54,9 +66,11 @@ export interface PoolAccountState {
|
|||||||
cooldownUntil?: number;
|
cooldownUntil?: number;
|
||||||
/**
|
/**
|
||||||
* For state 'cooling': where the cooldown reading came from.
|
* For state 'cooling': where the cooldown reading came from.
|
||||||
* 'persisted' = quota-paused.json (cross-process), 'memory' = in-process map.
|
* 'proxy' = live in-proxy 429 cooldown (GET /v0/management/auth-files),
|
||||||
|
* 'persisted' = quota-paused.json (cross-process),
|
||||||
|
* 'memory' = in-process quota-manager map.
|
||||||
*/
|
*/
|
||||||
cooldownSource?: 'persisted' | 'memory';
|
cooldownSource?: 'proxy' | 'persisted' | 'memory';
|
||||||
/** ISO timestamp the account was paused (manual pause), when state is 'paused'. */
|
/** ISO timestamp the account was paused (manual pause), when state is 'paused'. */
|
||||||
pausedAt?: string;
|
pausedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -103,23 +117,34 @@ export interface ResolvePoolStateInput {
|
|||||||
accounts?: AccountInfo[];
|
accounts?: AccountInfo[];
|
||||||
/** Override for tests; defaults to Date.now(). */
|
/** Override for tests; defaults to Date.now(). */
|
||||||
now?: number;
|
now?: number;
|
||||||
|
/**
|
||||||
|
* Live in-proxy cooldowns (GET /v0/management/auth-files), keyed nowhere -
|
||||||
|
* passed as a flat list and indexed by tokenFile internally. When provided,
|
||||||
|
* an entry takes precedence over CCS-side cooldown views for the matching
|
||||||
|
* auth file. Defaults to none (CCS-side classification only).
|
||||||
|
*/
|
||||||
|
proxyCooldowns?: ProxyAuthCooldown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a single account's pool state.
|
* Classify a single account's pool state.
|
||||||
*
|
*
|
||||||
* Precedence:
|
* Precedence:
|
||||||
* 1. A persisted quota cooldown whose pausedAt matches the account's pausedAt is
|
* 1. A live in-proxy cooldown (proxy reports the credential unavailable after a
|
||||||
|
* 429) -> 'cooling' source 'proxy'. This is the actual hop mechanism when
|
||||||
|
* pool routing is ON, so it wins over the CCS-side views below.
|
||||||
|
* 2. A persisted quota cooldown whose pausedAt matches the account's pausedAt is
|
||||||
* a quota cooldown -> 'cooling' (cross-process truth wins).
|
* a quota cooldown -> 'cooling' (cross-process truth wins).
|
||||||
* 2. A paused account without a matching cooldown record is a manual/safety pause
|
* 3. A paused account without a matching cooldown record is a manual/safety pause
|
||||||
* -> 'paused'.
|
* -> 'paused'.
|
||||||
* 3. An unpaused account with an in-memory cooldown still in effect -> 'cooling'.
|
* 4. An unpaused account with an in-memory cooldown still in effect -> 'cooling'.
|
||||||
* 4. Otherwise -> 'available'.
|
* 5. Otherwise -> 'available'.
|
||||||
*/
|
*/
|
||||||
function classifyAccount(
|
function classifyAccount(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
account: AccountInfo,
|
account: AccountInfo,
|
||||||
cooldownByAccount: Map<string, { until: number; pausedAt: string }>,
|
cooldownByAccount: Map<string, { until: number; pausedAt: string }>,
|
||||||
|
proxyCooldownByTokenFile: Map<string, ProxyAuthCooldown>,
|
||||||
now: number
|
now: number
|
||||||
): PoolAccountState {
|
): PoolAccountState {
|
||||||
const base: PoolAccountState = {
|
const base: PoolAccountState = {
|
||||||
@@ -130,6 +155,22 @@ function classifyAccount(
|
|||||||
state: 'available',
|
state: 'available',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Live in-proxy cooldown wins: this is the credential the proxy actually
|
||||||
|
// rotated out on a 429. A missing/zero next_retry_after still means cooling;
|
||||||
|
// we just cannot show a reset time for it.
|
||||||
|
const proxyCooldown = proxyCooldownByTokenFile.get(account.tokenFile);
|
||||||
|
if (
|
||||||
|
proxyCooldown &&
|
||||||
|
(proxyCooldown.cooldownUntil === undefined || proxyCooldown.cooldownUntil > now)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
state: 'cooling',
|
||||||
|
cooldownUntil: proxyCooldown.cooldownUntil,
|
||||||
|
cooldownSource: 'proxy',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const persisted = cooldownByAccount.get(account.id);
|
const persisted = cooldownByAccount.get(account.id);
|
||||||
|
|
||||||
if (account.paused) {
|
if (account.paused) {
|
||||||
@@ -194,6 +235,10 @@ function resolveDrainOrder(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the full observable pool state for a provider.
|
* Resolve the full observable pool state for a provider.
|
||||||
|
*
|
||||||
|
* Synchronous: callers that want live in-proxy 429 cooldowns surfaced (the real
|
||||||
|
* hop mechanism when pool routing is ON) pass them in via input.proxyCooldowns,
|
||||||
|
* or use resolvePoolStateWithProxyCooldowns() which fetches them first.
|
||||||
*/
|
*/
|
||||||
export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
|
export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
|
||||||
const { provider, settings } = input;
|
const { provider, settings } = input;
|
||||||
@@ -207,8 +252,14 @@ export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
|
|||||||
cooldownByAccount.set(entry.accountId, { until: entry.until, pausedAt: entry.pausedAt });
|
cooldownByAccount.set(entry.accountId, { until: entry.until, pausedAt: entry.pausedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index live in-proxy cooldowns by token file (the proxy reports by file name).
|
||||||
|
const proxyCooldownByTokenFile = new Map<string, ProxyAuthCooldown>();
|
||||||
|
for (const entry of input.proxyCooldowns ?? []) {
|
||||||
|
proxyCooldownByTokenFile.set(entry.tokenFile, entry);
|
||||||
|
}
|
||||||
|
|
||||||
const states = accounts.map((account) =>
|
const states = accounts.map((account) =>
|
||||||
classifyAccount(provider, account, cooldownByAccount, now)
|
classifyAccount(provider, account, cooldownByAccount, proxyCooldownByTokenFile, now)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drain order is computed over accounts that are in rotation (not paused).
|
// Drain order is computed over accounts that are in rotation (not paused).
|
||||||
@@ -222,3 +273,44 @@ export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
|
|||||||
|
|
||||||
return { provider, states, drainOrder, settings };
|
return { provider, states, drainOrder, settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any account was classified 'cooling' from the live in-proxy source.
|
||||||
|
* Used by the renderer to decide whether the "in-proxy cooldowns not shown"
|
||||||
|
* honesty note still applies.
|
||||||
|
*/
|
||||||
|
export function hasProxySourcedCooling(pool: PoolState): boolean {
|
||||||
|
return pool.states.some((s) => s.state === 'cooling' && s.cooldownSource === 'proxy');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve pool state and, when pool routing is ON and the local proxy is
|
||||||
|
* reachable, fold in live in-proxy 429 cooldowns (the actual session-hop
|
||||||
|
* mechanism). Remote targets and a stopped/older proxy degrade silently to the
|
||||||
|
* synchronous CCS-side view with no error output.
|
||||||
|
*
|
||||||
|
* Kept separate from resolvePoolState() so the pure, synchronous resolver stays
|
||||||
|
* test-friendly and free of network/process detection.
|
||||||
|
*/
|
||||||
|
export async function resolvePoolStateWithProxyCooldowns(
|
||||||
|
input: ResolvePoolStateInput
|
||||||
|
): Promise<PoolState> {
|
||||||
|
// Only the local proxy exposes a management API we can read here; remote
|
||||||
|
// drain/cooldown management is out of scope (v1), so skip the fetch.
|
||||||
|
const shouldFetch = input.settings.poolEnabled && !getProxyTarget().isRemote;
|
||||||
|
|
||||||
|
let proxyCooldowns: ProxyAuthCooldown[] | undefined = input.proxyCooldowns;
|
||||||
|
if (proxyCooldowns === undefined && shouldFetch) {
|
||||||
|
try {
|
||||||
|
const proxyStatus = await detectRunningProxy(resolveLifecyclePort());
|
||||||
|
if (proxyStatus.running && proxyStatus.verified) {
|
||||||
|
proxyCooldowns = await fetchProxyAuthCooldowns();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Degrade silently: keep the CCS-side cooldown view.
|
||||||
|
proxyCooldowns = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvePoolState({ ...input, proxyCooldowns });
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import * as fs from 'fs';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
import { getClaudeEnvVars, ensureProviderSettings } from '../env-builder';
|
import { getClaudeEnvVars, ensureProviderSettings, getRemoteEnvVars } from '../env-builder';
|
||||||
import { clearConfigCache } from '../base-config-loader';
|
import { clearConfigCache } from '../base-config-loader';
|
||||||
|
|
||||||
const MODEL_KEYS = [
|
const MODEL_KEYS = [
|
||||||
@@ -380,4 +380,174 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
|
|||||||
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||||
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
|
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Upgrader --config ordering: migrate-first, then re-pin survives ───────────
|
||||||
|
// Models the `ccs claude --config` flow on an UPGRADER: the executor runs
|
||||||
|
// ensureProviderSettings BEFORE configureProviderModel writes the user's pick.
|
||||||
|
// Step 1 (ensureProviderSettings) strips the old auto-pins and sets the marker;
|
||||||
|
// Step 2 (the user's --config write) lands a fresh pin; Step 3 (next plain
|
||||||
|
// launch) must NOT strip it because the marker is already set.
|
||||||
|
it('upgrader --config: ensureProviderSettings runs first, so a re-pin equal to a stale default survives the next launch', () => {
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||||
|
const markerPath = path.join(ccsDir, 'cliproxy', '.claude-model-migrated');
|
||||||
|
|
||||||
|
// Pre-existing upgrader file with OLD auto-written default pins, no marker.
|
||||||
|
fs.writeFileSync(
|
||||||
|
settingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
expect(fs.existsSync(markerPath)).toBe(false);
|
||||||
|
|
||||||
|
// Step 1: executor runs ensureProviderSettings BEFORE the --config write.
|
||||||
|
// This strips the old auto-pins and sets the migration marker.
|
||||||
|
ensureProviderSettings('claude');
|
||||||
|
expect(fs.existsSync(markerPath)).toBe(true);
|
||||||
|
const afterMigrate = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
for (const key of MODEL_KEYS) {
|
||||||
|
const value = afterMigrate.env[key];
|
||||||
|
expect(!value || value.trim().length === 0).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: configureProviderModel writes the user's deliberate pick — and the
|
||||||
|
// pick happens to equal a current catalog default that is in the stale set.
|
||||||
|
const userPin = {
|
||||||
|
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-sonnet-4-6',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6',
|
||||||
|
};
|
||||||
|
const current = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||||
|
env: Record<string, string>;
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
settingsPath,
|
||||||
|
JSON.stringify({ ...current, env: { ...current.env, ...userPin } }, null, 2) + '\n',
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: next plain `ccs claude` launch must NOT strip the just-written pin
|
||||||
|
// because the marker was already set in Step 1.
|
||||||
|
ensureProviderSettings('claude');
|
||||||
|
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||||
|
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-sonnet-4-6');
|
||||||
|
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||||
|
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Remote read path: stale-pin filter without file mutation ──────────────────
|
||||||
|
|
||||||
|
it('getRemoteEnvVars (claude): drops stale default model pins from the settings file at read level', () => {
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||||
|
const originalContent =
|
||||||
|
JSON.stringify({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||||
|
},
|
||||||
|
}) + '\n';
|
||||||
|
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
|
||||||
|
|
||||||
|
const env = getRemoteEnvVars('claude', {
|
||||||
|
host: 'example.com',
|
||||||
|
port: 8317,
|
||||||
|
protocol: 'http',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stale defaults must NOT leak into the remote env.
|
||||||
|
for (const key of MODEL_KEYS) {
|
||||||
|
expect(env[key]).toBeUndefined();
|
||||||
|
}
|
||||||
|
// Transport keys are always set from the remote config.
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toContain('example.com');
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||||
|
|
||||||
|
// Read-level only: the settings file on disk is untouched.
|
||||||
|
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(originalContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRemoteEnvVars (claude): preserves a user-custom model pin not in any stale set', () => {
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
settingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MODEL: 'claude-opus-4-8', // custom — not a historical default
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const env = getRemoteEnvVars('claude', {
|
||||||
|
host: 'example.com',
|
||||||
|
port: 8317,
|
||||||
|
protocol: 'http',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom pin survives the remote read path.
|
||||||
|
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRemoteEnvVars (claude): Priority 1 explicit custom settings path is NOT stale-filtered', () => {
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
// An explicitly passed settings file is the user's deliberate choice and
|
||||||
|
// must be honored verbatim — even when it carries a value equal to a
|
||||||
|
// historical default.
|
||||||
|
const customPath = path.join(ccsDir, 'custom-claude.settings.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
customPath,
|
||||||
|
JSON.stringify({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but explicit
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const env = getRemoteEnvVars(
|
||||||
|
'claude',
|
||||||
|
{ host: 'example.com', port: 8317, protocol: 'http' },
|
||||||
|
customPath
|
||||||
|
);
|
||||||
|
|
||||||
|
// Priority 1 untouched: the explicit pin survives.
|
||||||
|
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -590,6 +590,26 @@ function migrateClaudeStaleModelPins(env: Record<string, string>): boolean {
|
|||||||
return mutated;
|
return mutated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-level equivalent of the stale-pin migration for paths that must not
|
||||||
|
* mutate the settings file (e.g. the remote env builder). Returns a copy of
|
||||||
|
* the env with any value equal to a historical default dropped per-key, while
|
||||||
|
* user-custom pins are preserved. Same per-key Sets as the local migration, so
|
||||||
|
* local / --config / remote read paths agree on which pins are stale.
|
||||||
|
*
|
||||||
|
* No marker is consulted or written: this is purely a read-time filter, so it
|
||||||
|
* is idempotent across launches without a one-shot guard.
|
||||||
|
*/
|
||||||
|
function filterClaudeStaleModelPins(env: Record<string, string>): Record<string, string> {
|
||||||
|
const result = { ...env };
|
||||||
|
for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) {
|
||||||
|
if (staleValues.has(result[key])) {
|
||||||
|
delete result[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy bundled settings template to user directory if not exists
|
* Copy bundled settings template to user directory if not exists
|
||||||
* Called during installation/first run
|
* Called during installation/first run
|
||||||
@@ -783,6 +803,17 @@ export function getRemoteEnvVars(
|
|||||||
migrateDeprecatedModelNames(settingsPath, provider, settings);
|
migrateDeprecatedModelNames(settingsPath, provider, settings);
|
||||||
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
|
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
|
||||||
userEnvVars = settings.env as Record<string, string>;
|
userEnvVars = settings.env as Record<string, string>;
|
||||||
|
// claude is model-neutral. The local launch path runs the one-time
|
||||||
|
// stale-pin migration (ensureProviderSettings), but the remote path
|
||||||
|
// never rewrites the file, so a remote-only user whose settings still
|
||||||
|
// carry an old auto-written default would stay pinned. Filter stale
|
||||||
|
// defaults at read level so values equal to a historical default are
|
||||||
|
// dropped while user-custom pins survive. Priority 1 (explicit custom
|
||||||
|
// settings path) is intentionally left untouched: an explicitly passed
|
||||||
|
// settings file is the user's deliberate choice.
|
||||||
|
if (provider === 'claude') {
|
||||||
|
userEnvVars = filterClaudeStaleModelPins(userEnvVars);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid JSON - fall through to base config
|
// Invalid JSON - fall through to base config
|
||||||
|
|||||||
@@ -230,6 +230,17 @@ export async function execClaudeWithCLIProxy(
|
|||||||
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`);
|
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
|
// Run the one-time stale-pin migration on the pre-existing settings file
|
||||||
|
// BEFORE writing the user's chosen pin. ensureProviderSettingsFile sets the
|
||||||
|
// migration marker, so the explicit --config pick survives the next launch
|
||||||
|
// even when it equals a historical default. Without this, the --config flow
|
||||||
|
// exits before the only ensureProviderSettingsFile call site (later in this
|
||||||
|
// function), so a later plain launch would strip the just-written pin.
|
||||||
|
// Skipped for custom-settings variants: those have no claude.settings
|
||||||
|
// migration and are written verbatim.
|
||||||
|
if (!cfg.customSettingsPath) {
|
||||||
|
ensureProviderSettingsFile(provider);
|
||||||
|
}
|
||||||
await configureProviderModel(provider, true, cfg.customSettingsPath);
|
await configureProviderModel(provider, true, cfg.customSettingsPath);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,4 +285,84 @@ describe('cliproxy routing strategy service', () => {
|
|||||||
expect(state.message).toContain('not reachable');
|
expect(state.message).toContain('not reachable');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PR #1514 fix index 2: for a remote target, readCliproxyRoutingState must NOT
|
||||||
|
// present the local pool flag as if it described the remote proxy. It surfaces
|
||||||
|
// manageable:false + a message, mirroring the session-affinity remote handling.
|
||||||
|
it('marks remote pool routing as not manageable (local flag does not describe the remote proxy)', async () => {
|
||||||
|
await withScopedConfig(async () => {
|
||||||
|
routingTarget = {
|
||||||
|
host: 'remote.example.com',
|
||||||
|
port: 8080,
|
||||||
|
protocol: 'http',
|
||||||
|
isRemote: true,
|
||||||
|
};
|
||||||
|
responseFactory = async () =>
|
||||||
|
new Response(JSON.stringify({ strategy: 'round-robin' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable the LOCAL pool flag — the remote proxy must still report not-manageable.
|
||||||
|
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
|
||||||
|
mutateUnifiedConfig((config) => {
|
||||||
|
if (config.cliproxy) {
|
||||||
|
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mod = await loadRoutingModule();
|
||||||
|
const state = await mod.readCliproxyRoutingState();
|
||||||
|
|
||||||
|
expect(state.target).toBe('remote');
|
||||||
|
expect(state.poolRouting?.manageable).toBe(false);
|
||||||
|
expect(state.poolRouting?.message).toContain('remote proxy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// PR #1514 fix index 14 (backend): when local pool routing is enabled, the apply
|
||||||
|
// result message must carry the pool-override note so API/dashboard consumers see
|
||||||
|
// the same caveat the CLI prints.
|
||||||
|
it('appends a pool-active override note to local strategy apply when pool routing is on', async () => {
|
||||||
|
await withScopedConfig(async () => {
|
||||||
|
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
|
||||||
|
mutateUnifiedConfig((config) => {
|
||||||
|
if (config.cliproxy) {
|
||||||
|
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mod = await loadRoutingModule();
|
||||||
|
const result = await mod.applyCliproxyRoutingStrategy('round-robin');
|
||||||
|
|
||||||
|
expect(result.message).toContain('Pool routing is active');
|
||||||
|
expect(result.message).toContain('ccs cliproxy pool --disable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a pool-active override note to local affinity apply when pool routing is on', async () => {
|
||||||
|
await withScopedConfig(async () => {
|
||||||
|
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
|
||||||
|
mutateUnifiedConfig((config) => {
|
||||||
|
if (config.cliproxy) {
|
||||||
|
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mod = await loadRoutingModule();
|
||||||
|
const result = await mod.applyCliproxySessionAffinitySettings({ enabled: true, ttl: '1h' });
|
||||||
|
|
||||||
|
expect(result.message).toContain('Pool routing is active');
|
||||||
|
expect(result.message).toContain('ccs cliproxy pool --disable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without pool routing, the apply message must NOT carry the override note.
|
||||||
|
it('does not append the pool-override note when pool routing is off', async () => {
|
||||||
|
await withScopedConfig(async () => {
|
||||||
|
const mod = await loadRoutingModule();
|
||||||
|
const result = await mod.applyCliproxyRoutingStrategy('fill-first');
|
||||||
|
expect(result.message).not.toContain('Pool routing is active');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -166,7 +166,7 @@ export function maybeShowPoolOnboardingHint(profileCount?: number): OnboardingHi
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
info(
|
info(
|
||||||
`You have ${count} Claude profiles. 'ccs claude' pool auto-continues on limits (accounts share quota). Docs: ${POOL_DOCS_LINK}`
|
`You have ${count} Claude profiles. Pool routing can auto-continue 'ccs claude' when one account hits its limit. Enable: ccs cliproxy pool --enable Docs: ${POOL_DOCS_LINK}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import {
|
|||||||
POOL_ROUTING_VERIFIED_PROVIDERS,
|
POOL_ROUTING_VERIFIED_PROVIDERS,
|
||||||
POOL_MAX_RETRY_CREDENTIALS,
|
POOL_MAX_RETRY_CREDENTIALS,
|
||||||
} from './routing-strategy';
|
} from './routing-strategy';
|
||||||
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
|
import {
|
||||||
|
loadOrCreateUnifiedConfig,
|
||||||
|
mutateConfig,
|
||||||
|
hasUnifiedConfig,
|
||||||
|
} from '../../config/config-loader-facade';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '../config/port-manager';
|
import { CLIPROXY_DEFAULT_PORT } from '../config/port-manager';
|
||||||
import { getConfigPathForPort } from '../config/path-resolver';
|
import { getConfigPathForPort } from '../config/path-resolver';
|
||||||
import { getAuthDir } from '../config/path-resolver';
|
import { getAuthDir } from '../config/path-resolver';
|
||||||
@@ -63,6 +67,10 @@ export function isPoolPromptDismissed(): boolean {
|
|||||||
* Mark the pool routing prompt as permanently dismissed (user said no explicitly).
|
* Mark the pool routing prompt as permanently dismissed (user said no explicitly).
|
||||||
*/
|
*/
|
||||||
export function dismissPoolPrompt(): void {
|
export function dismissPoolPrompt(): void {
|
||||||
|
// Defense in depth: never write the unified config on a legacy install — that
|
||||||
|
// would create config.yaml and silently flip isUnifiedMode(). maybeOfferPoolRouting
|
||||||
|
// already gates the only caller, but this keeps the exported helper safe too.
|
||||||
|
if (!hasUnifiedConfig()) return;
|
||||||
mutateConfig((cfg) => {
|
mutateConfig((cfg) => {
|
||||||
if (!cfg.cliproxy) return;
|
if (!cfg.cliproxy) return;
|
||||||
cfg.cliproxy.pool_routing = {
|
cfg.cliproxy.pool_routing = {
|
||||||
@@ -134,6 +142,23 @@ export async function maybeOfferPoolRouting(
|
|||||||
return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' };
|
return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy install guard: on a profiles.json/config.json-only install there is no
|
||||||
|
// unified config.yaml. Both accept (enablePoolRouting) and decline
|
||||||
|
// (dismissPoolPrompt) write the unified config, which would create config.yaml
|
||||||
|
// and silently flip isUnifiedMode() outside the deliberate `ccs migrate` flow,
|
||||||
|
// orphaning the user's config.json profiles. Skip entirely before any prompt
|
||||||
|
// or dismissal persistence. Mirrors the guards in create-command.ts and
|
||||||
|
// account-flow.ts and the hazard documented in pool-onboarding-hint.ts.
|
||||||
|
if (!hasUnifiedConfig()) {
|
||||||
|
console.log(
|
||||||
|
info(
|
||||||
|
'[i] Multiple accounts detected, but pool routing needs the unified config. ' +
|
||||||
|
"Run 'ccs migrate' first, then 'ccs cliproxy pool --enable'."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { prompted: false, enabled: false, skipped: true, skipReason: 'legacy-config' };
|
||||||
|
}
|
||||||
|
|
||||||
// Only for providers where pool routing is verified
|
// Only for providers where pool routing is verified
|
||||||
if (!POOL_ROUTING_VERIFIED_PROVIDERS.has(provider)) {
|
if (!POOL_ROUTING_VERIFIED_PROVIDERS.has(provider)) {
|
||||||
return {
|
return {
|
||||||
@@ -178,6 +203,19 @@ export async function maybeOfferPoolRouting(
|
|||||||
return { prompted: false, enabled: false, skipped: true, skipReason: 'non-tty' };
|
return { prompted: false, enabled: false, skipped: true, skipReason: 'non-tty' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automation bypass: InteractivePrompt.confirm auto-returns true when --yes/-y
|
||||||
|
// is in argv or CCS_YES=1 is set, regardless of the default:false. This is an
|
||||||
|
// INSTANCE-GLOBAL routing/cooling consent decision; a generic automation flag
|
||||||
|
// must never grant it. Skip WITHOUT printing the prompt and WITHOUT persisting
|
||||||
|
// dismissal, so a future interactive run still offers the opt-in.
|
||||||
|
if (
|
||||||
|
process.env.CCS_YES === '1' ||
|
||||||
|
process.argv.includes('--yes') ||
|
||||||
|
process.argv.includes('-y')
|
||||||
|
) {
|
||||||
|
return { prompted: false, enabled: false, skipped: true, skipReason: 'automation-bypass' };
|
||||||
|
}
|
||||||
|
|
||||||
// Gather all providers with 2+ accounts for disclosure
|
// Gather all providers with 2+ accounts for disclosure
|
||||||
const multiAccountProviders = getMultiAccountProviders();
|
const multiAccountProviders = getMultiAccountProviders();
|
||||||
const providerList =
|
const providerList =
|
||||||
@@ -226,6 +264,14 @@ export async function maybeOfferPoolRouting(
|
|||||||
const result = enablePoolRouting(port, { configPath, authDir });
|
const result = enablePoolRouting(port, { configPath, authDir });
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
if (result.failed) {
|
||||||
|
// Config regeneration failed and the flag was rolled back: pool routing is
|
||||||
|
// NOT active. Surface the recovery copy and report enabled:false so callers
|
||||||
|
// (and quota/dashboard) do not claim pool ON.
|
||||||
|
console.log(warn(result.message));
|
||||||
|
console.log('');
|
||||||
|
return { prompted: true, enabled: false, skipped: false };
|
||||||
|
}
|
||||||
if (result.changed) {
|
if (result.changed) {
|
||||||
console.log(info(result.message));
|
console.log(info(result.message));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,16 +46,43 @@ export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']);
|
|||||||
*/
|
*/
|
||||||
export const POOL_ROUTING_MIN_VERSION = '6.9.45';
|
export const POOL_ROUTING_MIN_VERSION = '6.9.45';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pool-active override warning text. When pool routing is enabled the generator
|
||||||
|
* forces fill-first/affinity/cooling and ignores the stored strategy/affinity, so
|
||||||
|
* an apply via API or dashboard will not take effect. The CLI prints this same
|
||||||
|
* text before applying; appending it to the apply result message lets dashboard
|
||||||
|
* and API consumers surface the same caveat (the CLI warns, the dashboard did not).
|
||||||
|
*/
|
||||||
|
function poolActiveOverrideNote(kind: 'strategy' | 'affinity'): string {
|
||||||
|
const what = kind === 'strategy' ? 'stored strategy' : 'stored affinity setting';
|
||||||
|
return (
|
||||||
|
`[!] Pool routing is active. The ${what} will not take effect\n` +
|
||||||
|
` until pool routing is disabled: ccs cliproxy pool --disable`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether pool routing is enabled in the local unified config. */
|
||||||
|
function isLocalPoolRoutingEnabled(): boolean {
|
||||||
|
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.enabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnablePoolRoutingResult {
|
export interface EnablePoolRoutingResult {
|
||||||
/** Whether the pool routing state actually changed */
|
/** Whether the pool routing state actually changed */
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
/** Whether an existing explicit user routing setting was preserved */
|
/** Whether an existing explicit user routing setting was preserved */
|
||||||
preservedExplicitSetting: boolean;
|
preservedExplicitSetting: boolean;
|
||||||
|
/**
|
||||||
|
* True when the config could not be regenerated and the pool flag was rolled
|
||||||
|
* back. Pool routing is NOT active; the message carries recovery guidance.
|
||||||
|
*/
|
||||||
|
failed?: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisablePoolRoutingResult {
|
export interface DisablePoolRoutingResult {
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
|
/** True when the config could not be regenerated and the flag was rolled back. */
|
||||||
|
failed?: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +172,24 @@ export function enablePoolRouting(
|
|||||||
): EnablePoolRoutingResult {
|
): EnablePoolRoutingResult {
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
const already = config.cliproxy?.pool_routing?.enabled === true;
|
const already = config.cliproxy?.pool_routing?.enabled === true;
|
||||||
|
const configPath = options.configPath ?? getConfigPathForPort(port);
|
||||||
|
const authDir = options.authDir ?? getAuthDir();
|
||||||
|
|
||||||
|
// Already-enabled repair path: the flag may have been persisted by a prior call
|
||||||
|
// whose regenerateConfig threw (leaving config.yaml non-pool while the flag says
|
||||||
|
// enabled). Re-run regenerateConfig so `pool --enable` is an idempotent repair
|
||||||
|
// command rather than a no-op that can never fix a half-applied state.
|
||||||
if (already) {
|
if (already) {
|
||||||
|
try {
|
||||||
|
regenerateConfig(port, { configPath, authDir });
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
preservedExplicitSetting: false,
|
||||||
|
failed: true,
|
||||||
|
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged enabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --enable`,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
changed: false,
|
changed: false,
|
||||||
preservedExplicitSetting: false,
|
preservedExplicitSetting: false,
|
||||||
@@ -185,9 +229,26 @@ export function enablePoolRouting(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const configPath = options.configPath ?? getConfigPathForPort(port);
|
// The flag is persisted before regenerateConfig. If regeneration throws, roll
|
||||||
const authDir = options.authDir ?? getAuthDir();
|
// the flag back so status surfaces (pool --enable, quota, dashboard) do not
|
||||||
regenerateConfig(port, { configPath, authDir });
|
// report pool ON while config.yaml still runs non-pool routing.
|
||||||
|
try {
|
||||||
|
regenerateConfig(port, { configPath, authDir });
|
||||||
|
} catch (err) {
|
||||||
|
mutateConfig((cfg) => {
|
||||||
|
if (!cfg.cliproxy) return;
|
||||||
|
cfg.cliproxy.pool_routing = {
|
||||||
|
...cfg.cliproxy.pool_routing,
|
||||||
|
enabled: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
preservedExplicitSetting,
|
||||||
|
failed: true,
|
||||||
|
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing was not enabled; fix the file permission and re-run: ccs cliproxy pool --enable`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changed: true,
|
changed: true,
|
||||||
@@ -218,6 +279,8 @@ export function disablePoolRouting(
|
|||||||
): DisablePoolRoutingResult {
|
): DisablePoolRoutingResult {
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
const wasEnabled = config.cliproxy?.pool_routing?.enabled === true;
|
const wasEnabled = config.cliproxy?.pool_routing?.enabled === true;
|
||||||
|
const configPath = options.configPath ?? getConfigPathForPort(port);
|
||||||
|
const authDir = options.authDir ?? getAuthDir();
|
||||||
if (!wasEnabled) {
|
if (!wasEnabled) {
|
||||||
return {
|
return {
|
||||||
changed: false,
|
changed: false,
|
||||||
@@ -236,9 +299,19 @@ export function disablePoolRouting(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const configPath = options.configPath ?? getConfigPathForPort(port);
|
// The flag is already cleared (matching user intent). If regeneration throws we
|
||||||
const authDir = options.authDir ?? getAuthDir();
|
// keep enabled:false rather than rolling back to true — re-asserting pool ON
|
||||||
regenerateConfig(port, { configPath, authDir });
|
// would reintroduce the single-account blackout the disable path exists to
|
||||||
|
// prevent. Surface the failure so the user can fix permissions and re-run.
|
||||||
|
try {
|
||||||
|
regenerateConfig(port, { configPath, authDir });
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
failed: true,
|
||||||
|
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged disabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --disable`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changed: true,
|
changed: true,
|
||||||
@@ -262,6 +335,16 @@ const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`);
|
|||||||
export interface CliproxyPoolRoutingState {
|
export interface CliproxyPoolRoutingState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
maxRetryCredentials?: number;
|
maxRetryCredentials?: number;
|
||||||
|
/**
|
||||||
|
* Whether CCS can manage pool routing for this target. enablePoolRouting only
|
||||||
|
* writes LOCAL config files, so a remote proxy is never affected by the local
|
||||||
|
* pool flag. For remote targets this is false and `enabled` reflects the local
|
||||||
|
* config only (not the remote proxy's behaviour). Omitted (treated as true)
|
||||||
|
* for local targets. Mirrors CliproxySessionAffinityState.manageable.
|
||||||
|
*/
|
||||||
|
manageable?: boolean;
|
||||||
|
/** Explanation surfaced when manageable is false (remote target). */
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CliproxyRoutingState {
|
export interface CliproxyRoutingState {
|
||||||
@@ -270,7 +353,11 @@ export interface CliproxyRoutingState {
|
|||||||
target: 'local' | 'remote';
|
target: 'local' | 'remote';
|
||||||
reachable: boolean;
|
reachable: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
/** Pool routing mode (proxy-wide). Present for both local and remote targets. */
|
/**
|
||||||
|
* Pool routing mode. For local targets `enabled` reflects the active proxy
|
||||||
|
* config. For remote targets `manageable` is false and `enabled` reflects only
|
||||||
|
* the local config (the remote proxy is not affected by it).
|
||||||
|
*/
|
||||||
poolRouting?: CliproxyPoolRoutingState;
|
poolRouting?: CliproxyPoolRoutingState;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,12 +504,24 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
|
|||||||
const poolRouting = getCliproxyPoolRoutingState();
|
const poolRouting = getCliproxyPoolRoutingState();
|
||||||
|
|
||||||
if (target.isRemote) {
|
if (target.isRemote) {
|
||||||
|
// Do NOT attach the local pool flag as if it described the remote proxy.
|
||||||
|
// enablePoolRouting only writes local config files; the remote proxy keeps
|
||||||
|
// its own routing/cooling. Surface manageable:false + a message so the
|
||||||
|
// dashboard renders "local only / not applied" instead of claiming the
|
||||||
|
// remote proxy is pool-managed. Mirrors readCliproxySessionAffinityState.
|
||||||
return {
|
return {
|
||||||
strategy: await fetchLiveCliproxyRoutingStrategy(),
|
strategy: await fetchLiveCliproxyRoutingStrategy(),
|
||||||
source: 'live',
|
source: 'live',
|
||||||
target: 'remote',
|
target: 'remote',
|
||||||
reachable: true,
|
reachable: true,
|
||||||
poolRouting,
|
poolRouting: {
|
||||||
|
enabled: poolRouting.enabled,
|
||||||
|
maxRetryCredentials: poolRouting.maxRetryCredentials,
|
||||||
|
manageable: false,
|
||||||
|
message:
|
||||||
|
'Pool routing is managed from the local config only and does not affect this remote proxy. ' +
|
||||||
|
'Configure cooling and routing on the host running CLIProxy instead.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +596,11 @@ export async function applyCliproxyRoutingStrategy(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool routing overrides the stored strategy at config-generation time, so the
|
||||||
|
// apply will not take effect until pool routing is disabled. Append the same
|
||||||
|
// note the CLI prints so dashboard/API consumers see the override too.
|
||||||
|
const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('strategy')}` : '';
|
||||||
|
|
||||||
mutateConfig((config) => {
|
mutateConfig((config) => {
|
||||||
if (config.cliproxy) {
|
if (config.cliproxy) {
|
||||||
config.cliproxy.routing = { ...config.cliproxy.routing, strategy };
|
config.cliproxy.routing = { ...config.cliproxy.routing, strategy };
|
||||||
@@ -512,7 +616,7 @@ export async function applyCliproxyRoutingStrategy(
|
|||||||
target: 'local',
|
target: 'local',
|
||||||
reachable: true,
|
reachable: true,
|
||||||
applied: 'live-and-config',
|
applied: 'live-and-config',
|
||||||
message: 'Updated the running proxy and saved the local startup default.',
|
message: 'Updated the running proxy and saved the local startup default.' + poolNote,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
@@ -521,7 +625,8 @@ export async function applyCliproxyRoutingStrategy(
|
|||||||
target: 'local',
|
target: 'local',
|
||||||
reachable: false,
|
reachable: false,
|
||||||
applied: 'config-only',
|
applied: 'config-only',
|
||||||
message: 'Saved the local startup default. It will apply the next time CLIProxy starts.',
|
message:
|
||||||
|
'Saved the local startup default. It will apply the next time CLIProxy starts.' + poolNote,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,6 +657,10 @@ export async function applyCliproxySessionAffinitySettings(
|
|||||||
current.ttl ??
|
current.ttl ??
|
||||||
DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL;
|
DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL;
|
||||||
|
|
||||||
|
// Pool routing overrides the stored session-affinity at config-generation time;
|
||||||
|
// append the same override note the CLI prints so dashboard/API consumers see it.
|
||||||
|
const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('affinity')}` : '';
|
||||||
|
|
||||||
mutateConfig((config) => {
|
mutateConfig((config) => {
|
||||||
if (config.cliproxy) {
|
if (config.cliproxy) {
|
||||||
config.cliproxy.routing = {
|
config.cliproxy.routing = {
|
||||||
@@ -572,9 +681,11 @@ export async function applyCliproxySessionAffinitySettings(
|
|||||||
reachable,
|
reachable,
|
||||||
manageable: true,
|
manageable: true,
|
||||||
applied: 'config-only',
|
applied: 'config-only',
|
||||||
message: reachable
|
message:
|
||||||
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
|
(reachable
|
||||||
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.',
|
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
|
||||||
|
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.') +
|
||||||
|
poolNote,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Tests for handleOrderSubcommand presentation + reset behavior.
|
||||||
|
*
|
||||||
|
* Why these matter:
|
||||||
|
* - File-mode SHOW must render from the shared resolver (selector pick order +
|
||||||
|
* drift), not an alphabetical re-sort. Otherwise the displayed order is the
|
||||||
|
* inverse of what CLIProxy actually drains whenever residual on-disk
|
||||||
|
* priorities exist (e.g. left by a prior managed order), and the drift
|
||||||
|
* warning the manual/tier branch shows is silently dropped.
|
||||||
|
* - `--reset` must actually strip residual priorities from the auth files, not
|
||||||
|
* just delete the stored config. With the proxy STOPPED the field is removed
|
||||||
|
* by a direct atomic write; the running-proxy PATCH path is covered at the
|
||||||
|
* clearDrainOrderPriorities unit level (drain-order.test.ts).
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { handleOrderSubcommand } from '../order-subcommand';
|
||||||
|
|
||||||
|
describe('handleOrderSubcommand', () => {
|
||||||
|
let tempHome: string;
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
let logSpy: ReturnType<typeof spyOn>;
|
||||||
|
let lines: string[];
|
||||||
|
|
||||||
|
function authDir(): string {
|
||||||
|
return path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
|
||||||
|
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(authDir(), fileName),
|
||||||
|
JSON.stringify({ type: 'claude', ...fields }, null, 2),
|
||||||
|
{ mode: 0o600 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuthFile(fileName: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(fs.readFileSync(path.join(authDir(), fileName), 'utf-8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerClaude(): Promise<{
|
||||||
|
registerAccount: (provider: string, tokenFile: string, email: string) => unknown;
|
||||||
|
saveDrainOrderConfig: (provider: string, config: unknown) => boolean;
|
||||||
|
}> {
|
||||||
|
return import(`../../../cliproxy/accounts/registry?order-subcommand=${Date.now()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-'));
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
process.exitCode = 0;
|
||||||
|
lines = [];
|
||||||
|
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
||||||
|
if (typeof msg === 'string') lines.push(msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
process.exitCode = 0;
|
||||||
|
if (originalCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file-mode show with residual priorities', () => {
|
||||||
|
it('renders selector pick order (priority desc) and flags drift instead of alphabetical order', async () => {
|
||||||
|
// Residual on-disk priorities, no stored config -> file mode + drift.
|
||||||
|
// claude-a sorts first alphabetically, but b has the higher priority, so
|
||||||
|
// the selector drains b first. The display must follow the selector.
|
||||||
|
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
|
||||||
|
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
|
||||||
|
|
||||||
|
const { registerAccount } = await registerClaude();
|
||||||
|
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||||
|
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||||
|
|
||||||
|
await handleOrderSubcommand(['claude']);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
// b@x.com (priority 5) must appear before a@x.com (priority 1).
|
||||||
|
const idxB = output.indexOf('b@x.com');
|
||||||
|
const idxA = output.indexOf('a@x.com');
|
||||||
|
expect(idxB).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(idxA).toBeGreaterThan(idxB);
|
||||||
|
|
||||||
|
// Drift surfaced (same as the manual/tier branch), and the mode label no
|
||||||
|
// longer falsely claims "no priority set" under residual priorities.
|
||||||
|
expect(output).toContain('Drift detected');
|
||||||
|
expect(output).toContain('residual priorities present');
|
||||||
|
expect(output).not.toContain('no priority set');
|
||||||
|
// [priority: N] annotations preserved.
|
||||||
|
expect(output).toContain('[priority: 5]');
|
||||||
|
expect(output).toContain('[priority: 1]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the plain "no priority set" label and no drift when there are no residuals', async () => {
|
||||||
|
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||||
|
writeAuthFile('claude-b.json', { email: 'b@x.com' });
|
||||||
|
|
||||||
|
const { registerAccount } = await registerClaude();
|
||||||
|
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||||
|
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||||
|
|
||||||
|
await handleOrderSubcommand(['claude']);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
expect(output).toContain('no priority set');
|
||||||
|
expect(output).not.toContain('Drift detected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('--reset clears residual priorities (proxy stopped -> direct write)', () => {
|
||||||
|
it('removes the priority field from auth files and reports per-file results', async () => {
|
||||||
|
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 4 });
|
||||||
|
writeAuthFile('claude-b.json', { email: 'b@x.com' }); // already clear
|
||||||
|
|
||||||
|
const { registerAccount, saveDrainOrderConfig } = await registerClaude();
|
||||||
|
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||||
|
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||||
|
saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] });
|
||||||
|
|
||||||
|
await handleOrderSubcommand(['claude', '--reset']);
|
||||||
|
|
||||||
|
// Residual priority is actually gone from disk (not just config deleted).
|
||||||
|
expect('priority' in readAuthFile('claude-a.json')).toBe(false);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
expect(output).toContain('reset to file order');
|
||||||
|
// Honest per-file reporting, and it no longer claims residuals remain.
|
||||||
|
expect(output).toContain('Cleared residual priority from 1 auth file');
|
||||||
|
expect(output).not.toContain('CLIProxy will continue using them');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports already-clear files and still resets when no priorities exist', async () => {
|
||||||
|
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||||
|
|
||||||
|
const { registerAccount } = await registerClaude();
|
||||||
|
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||||
|
|
||||||
|
await handleOrderSubcommand(['claude', '--reset']);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
expect(output).toContain('reset to file order');
|
||||||
|
expect(output).toContain('no priority set');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
* account states must be NAMED differently because they map to different client
|
* account states must be NAMED differently because they map to different client
|
||||||
* failure modes), reset formatting, and drain-order mode labels.
|
* failure modes), reset formatting, and drain-order mode labels.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'bun:test';
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
import { describeAccountState, formatCooldownReset, modeLabel } from '../pool-state-renderer';
|
import { describeAccountState, formatCooldownReset, modeLabel } from '../pool-state-renderer';
|
||||||
import type { PoolAccountState } from '../../../cliproxy/accounts/pool-state';
|
import type { PoolAccountState } from '../../../cliproxy/accounts/pool-state';
|
||||||
|
|
||||||
@@ -30,7 +33,10 @@ describe('describeAccountState', () => {
|
|||||||
|
|
||||||
expect(available.label).toBe('available');
|
expect(available.label).toBe('available');
|
||||||
expect(available.tone).toBe('available');
|
expect(available.tone).toBe('available');
|
||||||
expect(paused.label).toContain('paused');
|
// The pause label must NOT claim "manual" - a pause can be automatic (ban
|
||||||
|
// detection, cross-provider isolation, expired-but-unrestored cooldown).
|
||||||
|
expect(paused.label).toBe('paused (manual or safety)');
|
||||||
|
expect(paused.label).not.toBe('paused (manual)');
|
||||||
expect(paused.tone).toBe('paused');
|
expect(paused.tone).toBe('paused');
|
||||||
expect(cooling.label).toContain('cooling until');
|
expect(cooling.label).toContain('cooling until');
|
||||||
expect(cooling.tone).toBe('cooling');
|
expect(cooling.tone).toBe('cooling');
|
||||||
@@ -78,3 +84,141 @@ describe('modeLabel', () => {
|
|||||||
expect(modeLabel('file')).toBe('file order');
|
expect(modeLabel('file')).toBe('file order');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// renderProviderPoolSection - integration (resume hint, file-mode drift copy,
|
||||||
|
// pool-on cooling honesty note). Uses an isolated CCS_HOME and captures console.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('renderProviderPoolSection', () => {
|
||||||
|
let tempHome: string;
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
let logSpy: ReturnType<typeof spyOn>;
|
||||||
|
let lines: string[];
|
||||||
|
|
||||||
|
function writeAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
|
||||||
|
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||||
|
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(authDir, fileName),
|
||||||
|
JSON.stringify({ type: 'claude', ...fields }, null, 2),
|
||||||
|
{ mode: 0o600 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS_OFF = {
|
||||||
|
poolEnabled: false,
|
||||||
|
strategy: 'round-robin',
|
||||||
|
sessionAffinityEnabled: false,
|
||||||
|
sessionAffinityTtl: '1h',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SETTINGS_ON = {
|
||||||
|
poolEnabled: true,
|
||||||
|
strategy: 'fill-first',
|
||||||
|
sessionAffinityEnabled: true,
|
||||||
|
sessionAffinityTtl: '1h',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-renderer-'));
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
process.exitCode = 0;
|
||||||
|
lines = [];
|
||||||
|
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
||||||
|
if (typeof msg === 'string') lines.push(msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
process.exitCode = 0;
|
||||||
|
if (originalCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a resume hint pointing at the real command when an account is paused', async () => {
|
||||||
|
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||||
|
writeAuthFile('claude-b.json', { email: 'b@x.com' });
|
||||||
|
|
||||||
|
const { registerAccount, pauseAccount } = await import(
|
||||||
|
`../../../cliproxy/accounts/registry?renderer-resume=${Date.now()}`
|
||||||
|
);
|
||||||
|
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||||
|
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||||
|
pauseAccount('claude', 'a@x.com');
|
||||||
|
|
||||||
|
const { renderProviderPoolSection } = await import(
|
||||||
|
`../pool-state-renderer?renderer-resume=${Date.now()}`
|
||||||
|
);
|
||||||
|
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
// The hint names the actual resume subcommand (ccs cliproxy resume <account>).
|
||||||
|
expect(output).toContain('Resume:');
|
||||||
|
expect(output).toContain('ccs cliproxy resume <account>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT emit a resume hint when no account is paused', async () => {
|
||||||
|
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||||
|
const { registerAccount } = await import(
|
||||||
|
`../../../cliproxy/accounts/registry?renderer-no-resume=${Date.now()}`
|
||||||
|
);
|
||||||
|
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||||
|
|
||||||
|
const { renderProviderPoolSection } = await import(
|
||||||
|
`../pool-state-renderer?renderer-no-resume=${Date.now()}`
|
||||||
|
);
|
||||||
|
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
|
||||||
|
|
||||||
|
expect(lines.join('\n')).not.toContain('Resume:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses honest file-mode drift copy (no "stored order") when residual priorities exist', async () => {
|
||||||
|
// Residual on-disk priorities, but no stored drain config -> file mode drift.
|
||||||
|
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
|
||||||
|
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
|
||||||
|
|
||||||
|
const { registerAccount } = await import(
|
||||||
|
`../../../cliproxy/accounts/registry?renderer-drift=${Date.now()}`
|
||||||
|
);
|
||||||
|
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||||
|
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||||
|
|
||||||
|
const { renderProviderPoolSection } = await import(
|
||||||
|
`../pool-state-renderer?renderer-drift=${Date.now()}`
|
||||||
|
);
|
||||||
|
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
expect(output).toContain('Drift:');
|
||||||
|
// File mode has no stored order, so the copy must not claim one nor tell the
|
||||||
|
// user to "re-apply with --set/--by-tier" (which re-adopts managed ordering).
|
||||||
|
expect(output).toContain('residual priorities');
|
||||||
|
expect(output).toContain('--reset');
|
||||||
|
expect(output).not.toContain('stored order does not match');
|
||||||
|
expect(output).not.toContain('re-apply with --set/--by-tier');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints the in-proxy-cooldown honesty note when pool is ON but no proxy data is available', async () => {
|
||||||
|
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||||
|
const { registerAccount } = await import(
|
||||||
|
`../../../cliproxy/accounts/registry?renderer-note=${Date.now()}`
|
||||||
|
);
|
||||||
|
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||||
|
|
||||||
|
const { renderProviderPoolSection } = await import(
|
||||||
|
`../pool-state-renderer?renderer-note=${Date.now()}`
|
||||||
|
);
|
||||||
|
// No proxy is running in the test home, so the fetch degrades to no data and
|
||||||
|
// the honesty note must be printed instead of implying every account is fine.
|
||||||
|
await renderProviderPoolSection('claude', SETTINGS_ON, 1_000_000);
|
||||||
|
|
||||||
|
expect(lines.join('\n')).toContain('live in-proxy 429 cooldowns are not shown here');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Tests for handlePoolSubcommand: remote-target refusal and local lifecycle-port
|
||||||
|
* resolution.
|
||||||
|
*
|
||||||
|
* Why these matter:
|
||||||
|
* - Remote refusal: enable/disable only mutate LOCAL config files. On a remote
|
||||||
|
* target the running proxy is never touched, so flipping the local flag and
|
||||||
|
* printing a hot-reload success message would lie. The command must refuse
|
||||||
|
* (exitCode 1) and leave the local pool flag untouched.
|
||||||
|
* - Port resolution: a user on a custom local.port (e.g. 9000) must have
|
||||||
|
* config-9000.yaml regenerated (the file the running proxy reads), not the
|
||||||
|
* default config.yaml.
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||||
|
|
||||||
|
import { handlePoolSubcommand } from '../pool-subcommand';
|
||||||
|
import {
|
||||||
|
invalidateConfigCache as invalidateSharedConfigCache,
|
||||||
|
mutateConfig,
|
||||||
|
} from '../../../config/config-loader-facade';
|
||||||
|
|
||||||
|
function createTestHome(): string {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-subcommand-test-'));
|
||||||
|
const ccsDir = path.join(dir, '.ccs');
|
||||||
|
fs.mkdirSync(path.join(ccsDir, 'cliproxy', 'auth'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8');
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('handlePoolSubcommand', () => {
|
||||||
|
let tempHome: string;
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
let logSpy: ReturnType<typeof spyOn>;
|
||||||
|
let lines: string[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHome = createTestHome();
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
invalidateSharedConfigCache();
|
||||||
|
process.exitCode = 0;
|
||||||
|
lines = [];
|
||||||
|
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
||||||
|
if (typeof msg === 'string') lines.push(msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
process.exitCode = 0;
|
||||||
|
if (originalCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses --enable on a remote target, sets exitCode 1, and does not flip the local flag', async () => {
|
||||||
|
mutateConfig((cfg) => {
|
||||||
|
cfg.cliproxy_server = {
|
||||||
|
remote: { enabled: true, host: '192.168.1.50', protocol: 'http' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
invalidateSharedConfigCache();
|
||||||
|
|
||||||
|
await handlePoolSubcommand(['--enable']);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
expect(output).toContain('Remote proxy target detected');
|
||||||
|
// Manual-config guidance is shown for the enable path
|
||||||
|
expect(output).toContain('disable-cooling: false');
|
||||||
|
expect(output).toContain('strategy: fill-first');
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
|
||||||
|
// Local pool flag must remain untouched (not enabled).
|
||||||
|
invalidateSharedConfigCache();
|
||||||
|
const { loadOrCreateUnifiedConfig } = await import(
|
||||||
|
`../../../config/config-loader-facade?poolremote=${Date.now()}`
|
||||||
|
);
|
||||||
|
const cfg = loadOrCreateUnifiedConfig();
|
||||||
|
expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true);
|
||||||
|
|
||||||
|
// No local config.yaml should have been regenerated by the refused command.
|
||||||
|
const cliproxyConfig = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml');
|
||||||
|
expect(fs.existsSync(cliproxyConfig)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses --disable on a remote target without the enable-only manual snippet', async () => {
|
||||||
|
mutateConfig((cfg) => {
|
||||||
|
cfg.cliproxy_server = {
|
||||||
|
remote: { enabled: true, host: '192.168.1.50', protocol: 'http' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
invalidateSharedConfigCache();
|
||||||
|
|
||||||
|
await handlePoolSubcommand(['--disable']);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
expect(output).toContain('Remote proxy target detected');
|
||||||
|
// The disable refusal must NOT print the enable manual snippet
|
||||||
|
expect(output).not.toContain('disable-cooling: false');
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--enable on a local custom port regenerates config-<port>.yaml, not config.yaml', async () => {
|
||||||
|
// Configure a custom local lifecycle port. resolveLifecyclePort() must pick
|
||||||
|
// it up so the regenerated file matches what the running proxy reads.
|
||||||
|
mutateConfig((cfg) => {
|
||||||
|
cfg.cliproxy_server = { local: { port: 9000 } };
|
||||||
|
});
|
||||||
|
invalidateSharedConfigCache();
|
||||||
|
|
||||||
|
await handlePoolSubcommand(['--enable']);
|
||||||
|
|
||||||
|
const cliproxyDir = path.join(tempHome, '.ccs', 'cliproxy');
|
||||||
|
const customConfig = path.join(cliproxyDir, 'config-9000.yaml');
|
||||||
|
const defaultConfig = path.join(cliproxyDir, 'config.yaml');
|
||||||
|
|
||||||
|
// The custom-port config file must be the one regenerated.
|
||||||
|
expect(fs.existsSync(customConfig)).toBe(true);
|
||||||
|
// The default-port config.yaml must NOT be written by a custom-port enable.
|
||||||
|
expect(fs.existsSync(defaultConfig)).toBe(false);
|
||||||
|
|
||||||
|
// And the regenerated file must carry the pool-on rails.
|
||||||
|
const content = fs.readFileSync(customConfig, 'utf-8');
|
||||||
|
expect(content).toContain('disable-cooling: false');
|
||||||
|
expect(content).toContain('strategy: fill-first');
|
||||||
|
// Success path must not flag a failure exit code.
|
||||||
|
expect(process.exitCode).not.toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
computeTierDrainOrder,
|
computeTierDrainOrder,
|
||||||
applyDrainOrder,
|
applyDrainOrder,
|
||||||
resolveEffectiveDrainOrder,
|
resolveEffectiveDrainOrder,
|
||||||
readAuthFilePriority,
|
clearDrainOrderPriorities,
|
||||||
tieBreakKey,
|
tieBreakKey,
|
||||||
type DrainOrderEntry,
|
type DrainOrderEntry,
|
||||||
type DrainOrderInput,
|
type DrainOrderInput,
|
||||||
@@ -122,9 +122,11 @@ async function handleOrderShow(provider: CLIProxyProvider): Promise<void> {
|
|||||||
const effective = resolveEffectiveDrainOrder(provider, accounts);
|
const effective = resolveEffectiveDrainOrder(provider, accounts);
|
||||||
|
|
||||||
if (effective.mode === 'file') {
|
if (effective.mode === 'file') {
|
||||||
// File order: no priority writes; show stable file order. Reached when no
|
// File order: no priority writes. Render from the resolver's output so the
|
||||||
// config is stored, or when a stored manual order has only stale IDs.
|
// displayed order matches what the selector actually drains, honouring any
|
||||||
printFileOrderView(provider, accounts);
|
// residual on-disk priorities (e.g. left by `order --reset`). Reached when
|
||||||
|
// no config is stored, or when a stored manual order has only stale IDs.
|
||||||
|
printFileOrderView(provider, effective.entries, effective.hasDrift);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +162,25 @@ async function handleOrderShow(provider: CLIProxyProvider): Promise<void> {
|
|||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function readFilePriorityNote(tokenFile: string): string {
|
function filePriorityNote(entry: DrainOrderEntry): string {
|
||||||
try {
|
return entry.currentPriority !== undefined ? dim(` [priority: ${entry.currentPriority}]`) : '';
|
||||||
const p = readAuthFilePriority(tokenFile);
|
|
||||||
return p !== undefined ? dim(` [priority: ${p}]`) : '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the stable file-order view (no priority writes). Used when no drain
|
* Render the file-order view from the shared resolver's output. No priority
|
||||||
* order config is stored, or when a stored manual order has only stale IDs.
|
* writes; used when no drain order config is stored, or when a stored manual
|
||||||
|
* order has only stale IDs.
|
||||||
|
*
|
||||||
|
* Entries arrive already sorted in selector pick order (on-disk priority desc,
|
||||||
|
* tokenFile asc on tie), so the displayed order matches what CLIProxy actually
|
||||||
|
* drains even when residual priorities exist (e.g. left by `order --reset`). The
|
||||||
|
* mode label and drift warning are kept honest for that residual case.
|
||||||
*/
|
*/
|
||||||
function printFileOrderView(provider: CLIProxyProvider, accounts: DrainOrderInput[]): void {
|
function printFileOrderView(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
entries: DrainOrderEntry[],
|
||||||
|
hasDrift: boolean
|
||||||
|
): void {
|
||||||
if (!TIER_AWARE_PROVIDERS.has(provider)) {
|
if (!TIER_AWARE_PROVIDERS.has(provider)) {
|
||||||
console.log(
|
console.log(
|
||||||
info(
|
info(
|
||||||
@@ -182,17 +189,27 @@ function printFileOrderView(provider: CLIProxyProvider, accounts: DrainOrderInpu
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log(` Mode: ${dim('file order (no priority set)')}`);
|
// Under drift, "no priority set" would be a lie - residual on-disk priorities
|
||||||
|
// are present and are what the selector follows. Label accordingly.
|
||||||
|
const label = hasDrift
|
||||||
|
? 'file order (residual priorities present)'
|
||||||
|
: 'file order (no priority set)';
|
||||||
|
console.log(` Mode: ${dim(label)}`);
|
||||||
|
if (hasDrift) {
|
||||||
|
console.log('');
|
||||||
|
console.log(
|
||||||
|
warn(
|
||||||
|
`Drift detected: residual on-disk priorities steer the selector;\n` +
|
||||||
|
` clear them with --reset (or re-auth the accounts) to return to plain file order.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(subheader('Active accounts (file order):'));
|
console.log(subheader('Active accounts (selector pick order):'));
|
||||||
// Sort by tokenFile ascending to match the Go selector's stable order.
|
// Entries are already in selector pick order from the resolver; render as-is.
|
||||||
// Upstream sorts by Auth.ID byte order and only lowercases on Windows (see tieBreakKey).
|
entries.forEach((entry, idx) => {
|
||||||
const sortedByFile = accounts
|
const pri = filePriorityNote(entry);
|
||||||
.slice()
|
console.log(` ${String(idx + 1).padStart(3)} ${color(entry.accountId, 'command')}${pri}`);
|
||||||
.sort((a, b) => (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1));
|
|
||||||
sortedByFile.forEach((a, idx) => {
|
|
||||||
const pri = readFilePriorityNote(a.tokenFile);
|
|
||||||
console.log(` ${String(idx + 1).padStart(3)} ${color(a.accountId, 'command')}${pri}`);
|
|
||||||
});
|
});
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(
|
console.log(
|
||||||
@@ -463,21 +480,114 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset drain order to file order (removes persisted config, no priority writes).
|
* Reset drain order to file order: remove the persisted config AND clear the
|
||||||
|
* residual priority field from the provider's auth files so the selector
|
||||||
|
* actually returns to plain file order.
|
||||||
|
*
|
||||||
|
* Clearing the field uses the same dual write path as --set/--by-tier:
|
||||||
|
* - Proxy running: PATCH priority:0 via the management API (the management layer
|
||||||
|
* treats 0 as delete). A direct file write here would be clobbered by the
|
||||||
|
* proxy's whole-file MarkResult persist, so the API path is mandatory.
|
||||||
|
* - Proxy stopped: delete the field directly with an atomic temp-rename.
|
||||||
|
*
|
||||||
|
* Remote targets are refused with guidance (same as --set/--by-tier), and an
|
||||||
|
* ambiguous proxy state (alive but not yet serving HTTP) is refused to avoid a
|
||||||
|
* clobbered write.
|
||||||
*/
|
*/
|
||||||
async function handleOrderReset(provider: CLIProxyProvider): Promise<void> {
|
async function handleOrderReset(provider: CLIProxyProvider): Promise<void> {
|
||||||
const cleared = clearDrainOrderConfig(provider);
|
const configCleared = clearDrainOrderConfig(provider);
|
||||||
if (cleared) {
|
|
||||||
console.log(ok(`Drain order reset to file order for ${provider}.`));
|
// Collect the auth files that may carry residual priorities. Use
|
||||||
|
// getProviderAccounts() so file-copied fleets (not yet in accounts.json) are
|
||||||
|
// covered too; clearing is idempotent for files with no priority field.
|
||||||
|
const tokenFiles = getProviderAccounts(provider).map((a) => a.tokenFile);
|
||||||
|
|
||||||
|
if (tokenFiles.length === 0) {
|
||||||
|
if (configCleared) {
|
||||||
|
console.log(ok(`Drain order reset to file order for ${provider}.`));
|
||||||
|
} else {
|
||||||
|
console.log(info(`No drain order config found for ${provider}. Already in file order.`));
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote targets: refuse with guidance - clearing priorities needs management
|
||||||
|
// API access that is not wired for remote proxies in v1.
|
||||||
|
const proxyTarget = getProxyTarget();
|
||||||
|
if (proxyTarget.isRemote) {
|
||||||
|
if (configCleared) {
|
||||||
|
console.log(ok(`Drain order config cleared for ${provider} (mode reset to file order).`));
|
||||||
|
} else {
|
||||||
|
console.log(info(`No drain order config found for ${provider}.`));
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
dim(
|
warn(
|
||||||
' Note: existing priority values in auth files are not removed.\n' +
|
`Remote proxy target detected. Residual auth-file priorities were NOT cleared;\n` +
|
||||||
' CLIProxy will continue using them until they are overwritten or files are re-created.'
|
` drain order management for remote proxies is not supported in v1. Run this\n` +
|
||||||
|
` command on the host running CLIProxy to clear them.`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
console.log('');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPort = resolveLifecyclePort();
|
||||||
|
const proxyStatus = await detectRunningProxy(localPort);
|
||||||
|
|
||||||
|
// Ambiguous state: proxy alive but not yet serving HTTP. A direct write could
|
||||||
|
// be clobbered; refuse rather than risk a half-cleared state.
|
||||||
|
if (proxyStatus.running && !proxyStatus.verified) {
|
||||||
|
if (configCleared) {
|
||||||
|
console.log(ok(`Drain order config cleared for ${provider} (mode reset to file order).`));
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
fail(
|
||||||
|
'Proxy state ambiguous - residual priorities not cleared. Retry in a moment or stop the proxy first.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyRunning = proxyStatus.running && proxyStatus.verified;
|
||||||
|
if (proxyRunning) {
|
||||||
|
console.log(
|
||||||
|
info('Proxy is running - clearing priorities via management API (avoids clobber race).')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(info('Proxy is stopped - clearing priorities directly in auth files.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await clearDrainOrderPriorities(tokenFiles, proxyRunning);
|
||||||
|
|
||||||
|
if (configCleared || result.cleared.length > 0) {
|
||||||
|
console.log(ok(`Drain order reset to file order for ${provider}.`));
|
||||||
} else {
|
} else {
|
||||||
console.log(info(`No drain order config found for ${provider}. Already in file order.`));
|
console.log(info(`No drain order config found for ${provider}. Already in file order.`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.cleared.length > 0) {
|
||||||
|
console.log(ok(`Cleared residual priority from ${result.cleared.length} auth file(s).`));
|
||||||
|
}
|
||||||
|
if (result.alreadyClear.length > 0) {
|
||||||
|
console.log(
|
||||||
|
info(`${result.alreadyClear.length} auth file(s) had no priority set (nothing to clear).`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.failed.length > 0) {
|
||||||
|
for (const f of result.failed) {
|
||||||
|
console.log(fail(`Failed to clear priority for ${f.tokenFile}: ${f.reason}`));
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
dim(
|
||||||
|
' Residual priorities remain on the failed files above and will still steer the selector.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
|
|
||||||
import { subheader, color, dim, warn } from '../../utils/ui';
|
import { subheader, color, dim, warn } from '../../utils/ui';
|
||||||
import {
|
import {
|
||||||
resolvePoolState,
|
resolvePoolStateWithProxyCooldowns,
|
||||||
|
hasProxySourcedCooling,
|
||||||
POOL_TIER_AWARE_PROVIDERS,
|
POOL_TIER_AWARE_PROVIDERS,
|
||||||
type PoolAccountState,
|
type PoolAccountState,
|
||||||
type PoolRoutingSettings,
|
type PoolRoutingSettings,
|
||||||
type PoolDrainOrderMode,
|
type PoolDrainOrderMode,
|
||||||
|
type PoolState,
|
||||||
} from '../../cliproxy/accounts/pool-state';
|
} from '../../cliproxy/accounts/pool-state';
|
||||||
import {
|
import {
|
||||||
getConfiguredCliproxyRoutingStrategy,
|
getConfiguredCliproxyRoutingStrategy,
|
||||||
@@ -95,7 +97,11 @@ export function describeAccountState(
|
|||||||
return { label: `cooling until ${reset}${src}`, tone: 'cooling' };
|
return { label: `cooling until ${reset}${src}`, tone: 'cooling' };
|
||||||
}
|
}
|
||||||
case 'paused':
|
case 'paused':
|
||||||
return { label: 'paused (manual)', tone: 'paused' };
|
// A pause can be manual (the user) OR automatic (ban detection,
|
||||||
|
// cross-provider isolation, an expired-but-not-restored quota cooldown).
|
||||||
|
// The classifier cannot tell which without a stored reason, so the label
|
||||||
|
// must not claim "manual". See pool-state.ts classifyAccount.
|
||||||
|
return { label: 'paused (manual or safety)', tone: 'paused' };
|
||||||
case 'available':
|
case 'available':
|
||||||
default:
|
default:
|
||||||
return { label: 'available', tone: 'available' };
|
return { label: 'available', tone: 'available' };
|
||||||
@@ -114,16 +120,83 @@ export function modeLabel(mode: PoolDrainOrderMode): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the drift warning. The honest copy depends on whether a stored order
|
||||||
|
* exists: in file mode there is no stored order (the user reset or never set
|
||||||
|
* one), so referencing a "stored order" or telling them to "re-apply" would be
|
||||||
|
* false. File-mode drift means residual on-disk priorities (e.g. left by a prior
|
||||||
|
* managed order) still steer the selector, and the fix is to clear them.
|
||||||
|
*/
|
||||||
|
function renderDrainOrderDrift(provider: CLIProxyProvider, pool: PoolState): void {
|
||||||
|
if (!pool.drainOrder.hasDrift) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pool.drainOrder.mode === 'file') {
|
||||||
|
console.log(
|
||||||
|
` ${warn(
|
||||||
|
`Drift: residual priorities from a previous managed order still steer the selector;\n` +
|
||||||
|
` clear them with "ccs cliproxy accounts order ${provider} --reset" (or re-auth the accounts).`
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ${warn(
|
||||||
|
`Drift: stored order does not match auth files; run "ccs cliproxy accounts order ${provider}" to inspect, then re-apply with --set/--by-tier.`
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the honesty note about cooling visibility.
|
||||||
|
*
|
||||||
|
* - Pool OFF: cooling is disabled at the proxy (stock stability mode), so no
|
||||||
|
* account ever shows a quota cooldown here. State that plainly.
|
||||||
|
* - Pool ON but no live proxy reading available (proxy stopped/older binary, or
|
||||||
|
* the listing returned no cooling entries): the in-proxy 429 cooldowns that
|
||||||
|
* actually drive session hops are not visible, so say so rather than implying
|
||||||
|
* every account is healthy.
|
||||||
|
*/
|
||||||
|
function renderCoolingNote(pool: PoolState, settings: PoolRoutingSettings): void {
|
||||||
|
if (!settings.poolEnabled) {
|
||||||
|
const anyCooling = pool.states.some((s) => s.state === 'cooling');
|
||||||
|
if (!anyCooling) {
|
||||||
|
console.log(
|
||||||
|
dim(
|
||||||
|
' Note: cooling is off (stock stability mode); accounts never show a quota cooldown here.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool ON: only omit the caveat once we actually have live proxy-sourced
|
||||||
|
// cooling data to show. Otherwise the states reflect CCS-side checks only.
|
||||||
|
if (!hasProxySourcedCooling(pool)) {
|
||||||
|
console.log(
|
||||||
|
dim(
|
||||||
|
' Note: live in-proxy 429 cooldowns are not shown here; states reflect CCS-side checks only.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the pool context section for one provider. Returns silently (renders
|
* Render the pool context section for one provider. Returns silently (renders
|
||||||
* nothing) when the provider has no accounts, so quota output stays clean.
|
* nothing) when the provider has no accounts, so quota output stays clean.
|
||||||
|
*
|
||||||
|
* Async because it folds in live in-proxy 429 cooldowns (the real session-hop
|
||||||
|
* mechanism when pool routing is ON) via a single bounded management API read;
|
||||||
|
* that read degrades silently when the proxy is stopped, remote, or older.
|
||||||
*/
|
*/
|
||||||
export function renderProviderPoolSection(
|
export async function renderProviderPoolSection(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
settings: PoolRoutingSettings,
|
settings: PoolRoutingSettings,
|
||||||
now: number = Date.now()
|
now: number = Date.now()
|
||||||
): void {
|
): Promise<void> {
|
||||||
const pool = resolvePoolState({ provider, settings, now });
|
const pool = await resolvePoolStateWithProxyCooldowns({ provider, settings, now });
|
||||||
|
|
||||||
if (pool.states.length === 0) {
|
if (pool.states.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -155,16 +228,7 @@ export function renderProviderPoolSection(
|
|||||||
console.log(` Order: ${dim(orderMode)} ${dim('(no active accounts)')}`);
|
console.log(` Order: ${dim(orderMode)} ${dim('(no active accounts)')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drift: stored config order diverges from what is on disk. Re-auth drops the
|
renderDrainOrderDrift(provider, pool);
|
||||||
// priority attribute and --reset leaves residuals, so warn instead of showing
|
|
||||||
// a "next account" that silently disagrees with the selector.
|
|
||||||
if (pool.drainOrder.hasDrift) {
|
|
||||||
console.log(
|
|
||||||
` ${warn(
|
|
||||||
`Drift: stored order does not match auth files; run "ccs cliproxy accounts order ${provider}" to inspect, then re-apply with --set/--by-tier.`
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-account state. Render in drain order first, then any accounts not in the
|
// Per-account state. Render in drain order first, then any accounts not in the
|
||||||
// order (paused accounts are excluded from the order but still listed).
|
// order (paused accounts are excluded from the order but still listed).
|
||||||
@@ -188,16 +252,13 @@ export function renderProviderPoolSection(
|
|||||||
console.log(` - ${account.accountId}${defaultMark}: ${rendered}`);
|
console.log(` - ${account.accountId}${defaultMark}: ${rendered}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Honest note when cooling is impossible to observe (cooling flip off).
|
renderCoolingNote(pool, settings);
|
||||||
if (!settings.poolEnabled) {
|
|
||||||
const anyCooling = pool.states.some((s) => s.state === 'cooling');
|
// Resume hint: a pause can be manual OR automatic (ban detection,
|
||||||
if (!anyCooling) {
|
// cross-provider isolation, expired-but-unrestored quota cooldown). Point the
|
||||||
console.log(
|
// user at the real resume command so they can recover whichever caused it.
|
||||||
dim(
|
if (pool.states.some((s) => s.state === 'paused')) {
|
||||||
' Note: cooling is off (stock stability mode); accounts never show a quota cooldown here.'
|
console.log(` ${dim(`Resume: ccs cliproxy resume <account>`)}`);
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset/management hints. Dim hint matches the order subcommand's "To update:"
|
// Reset/management hints. Dim hint matches the order subcommand's "To update:"
|
||||||
|
|||||||
@@ -8,25 +8,77 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { initUI, header, ok, warn, info } from '../../utils/ui';
|
import { initUI, header, ok, warn, info } from '../../utils/ui';
|
||||||
import { enablePoolRouting, disablePoolRouting } from '../../cliproxy/routing/routing-strategy';
|
import {
|
||||||
|
enablePoolRouting,
|
||||||
|
disablePoolRouting,
|
||||||
|
POOL_MAX_RETRY_CREDENTIALS,
|
||||||
|
} from '../../cliproxy/routing/routing-strategy';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
|
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
|
||||||
|
import { getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver';
|
||||||
import { getConfigPathForPort, getAuthDir } from '../../cliproxy/config/path-resolver';
|
import { getConfigPathForPort, getAuthDir } from '../../cliproxy/config/path-resolver';
|
||||||
import { hasAnyFlag } from '../arg-extractor';
|
import { hasAnyFlag } from '../arg-extractor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the manual-config guidance for remote/Docker targets, then refuse.
|
||||||
|
* enablePoolRouting/disablePoolRouting only write local config files, so a
|
||||||
|
* remote proxy is never touched — refuse rather than lie about hot-reload.
|
||||||
|
* Mirrors the remote-refusal copy in order-subcommand and the manual snippet
|
||||||
|
* in pool-opt-in-prompt's printRemoteHint().
|
||||||
|
*/
|
||||||
|
function printRemotePoolRefusal(enable: boolean): void {
|
||||||
|
console.log(
|
||||||
|
warn(
|
||||||
|
`Remote proxy target detected. Pool routing management for remote proxies is not\n` +
|
||||||
|
` supported in v1. Run this command on the host running CLIProxy instead.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
if (enable) {
|
||||||
|
console.log(' To enable pool routing manually, add to your CLIProxy config.yaml:');
|
||||||
|
console.log(' disable-cooling: false');
|
||||||
|
console.log(` max-retry-credentials: ${POOL_MAX_RETRY_CREDENTIALS}`);
|
||||||
|
console.log(' routing:');
|
||||||
|
console.log(' strategy: fill-first');
|
||||||
|
console.log(' session-affinity: true');
|
||||||
|
console.log(' session-affinity-ttl: "1h"');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handlePoolSubcommand(args: string[]): Promise<void> {
|
export async function handlePoolSubcommand(args: string[]): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(header('CLIProxy Pool Routing'));
|
console.log(header('CLIProxy Pool Routing'));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
const port = CLIPROXY_DEFAULT_PORT;
|
const wantsEnable = hasAnyFlag(args, ['--enable']);
|
||||||
|
const wantsDisable = hasAnyFlag(args, ['--disable']);
|
||||||
|
|
||||||
|
// Resolve the real proxy target before any config write. Remote targets are
|
||||||
|
// refused (enable/disable only mutate local files; the remote proxy is never
|
||||||
|
// touched, so the "hot-reload" success message would be a lie). Local targets
|
||||||
|
// use the configured lifecycle port so the regenerated config-<port>.yaml is
|
||||||
|
// the same file the running proxy reads.
|
||||||
|
if (wantsEnable || wantsDisable) {
|
||||||
|
const target = getProxyTarget();
|
||||||
|
if (target.isRemote) {
|
||||||
|
printRemotePoolRefusal(wantsEnable);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = resolveLifecyclePort();
|
||||||
const configPath = getConfigPathForPort(port);
|
const configPath = getConfigPathForPort(port);
|
||||||
const authDir = getAuthDir();
|
const authDir = getAuthDir();
|
||||||
|
|
||||||
if (hasAnyFlag(args, ['--enable'])) {
|
if (wantsEnable) {
|
||||||
const result = enablePoolRouting(port, { configPath, authDir });
|
const result = enablePoolRouting(port, { configPath, authDir });
|
||||||
if (result.changed) {
|
if (result.failed) {
|
||||||
|
console.log(warn(result.message));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else if (result.changed) {
|
||||||
console.log(ok(result.message));
|
console.log(ok(result.message));
|
||||||
} else {
|
} else {
|
||||||
console.log(info(result.message));
|
console.log(info(result.message));
|
||||||
@@ -35,9 +87,12 @@ export async function handlePoolSubcommand(args: string[]): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAnyFlag(args, ['--disable'])) {
|
if (wantsDisable) {
|
||||||
const result = disablePoolRouting(port, { configPath, authDir });
|
const result = disablePoolRouting(port, { configPath, authDir });
|
||||||
if (result.changed) {
|
if (result.failed) {
|
||||||
|
console.log(warn(result.message));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else if (result.changed) {
|
||||||
console.log(ok(result.message));
|
console.log(ok(result.message));
|
||||||
} else {
|
} else {
|
||||||
console.log(info(result.message));
|
console.log(info(result.message));
|
||||||
|
|||||||
@@ -907,7 +907,8 @@ export async function handleQuotaStatus(
|
|||||||
runtime.render(result);
|
runtime.render(result);
|
||||||
// Pool context: drain order + per-account state (available/cooling/paused).
|
// Pool context: drain order + per-account state (available/cooling/paused).
|
||||||
// QuotaSupportedProvider ids are all valid CLIProxyProvider values.
|
// QuotaSupportedProvider ids are all valid CLIProxyProvider values.
|
||||||
renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
|
// Async: folds in live in-proxy 429 cooldowns when pool routing is on.
|
||||||
|
await renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export function RoutingGuidanceCard({
|
|||||||
const currentStrategy = state?.strategy ?? 'round-robin';
|
const currentStrategy = state?.strategy ?? 'round-robin';
|
||||||
const poolEnabled = state?.poolRouting?.enabled ?? false;
|
const poolEnabled = state?.poolRouting?.enabled ?? false;
|
||||||
const poolMaxRetry = state?.poolRouting?.maxRetryCredentials;
|
const poolMaxRetry = state?.poolRouting?.maxRetryCredentials;
|
||||||
|
// Undefined manageable means local (managed); only false signals a remote
|
||||||
|
// proxy where the local pool flag may not reflect the running proxy.
|
||||||
|
const poolManageable = state?.poolRouting?.manageable ?? true;
|
||||||
|
const poolLocalOnly = !poolManageable;
|
||||||
|
const poolLocalOnlyMessage =
|
||||||
|
state?.poolRouting?.message ?? t('routingGuidance.poolRoutingLocalOnly');
|
||||||
const currentAffinityEnabled = sessionAffinityState?.enabled ?? false;
|
const currentAffinityEnabled = sessionAffinityState?.enabled ?? false;
|
||||||
const currentAffinityTtl = sessionAffinityState?.ttl ?? '1h';
|
const currentAffinityTtl = sessionAffinityState?.ttl ?? '1h';
|
||||||
const sessionAffinityManageable = sessionAffinityState?.manageable ?? true;
|
const sessionAffinityManageable = sessionAffinityState?.manageable ?? true;
|
||||||
@@ -174,26 +180,45 @@ export function RoutingGuidanceCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{poolEnabled && poolManageable ? (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-4 text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{t('routingGuidance.poolRoutingApplyWarning')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[10px] font-medium text-foreground">
|
<div className="text-[10px] font-medium text-foreground">
|
||||||
{t('routingGuidance.poolRouting')}
|
{t('routingGuidance.poolRouting')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
{poolEnabled && poolMaxRetry !== undefined
|
{poolLocalOnly
|
||||||
? t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })
|
? poolLocalOnlyMessage
|
||||||
: t('routingGuidance.drainOrderHint')}
|
: poolEnabled
|
||||||
|
? t('routingGuidance.drainOrderHint')
|
||||||
|
: t('routingGuidance.poolRoutingOffHint')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant={poolEnabled ? 'secondary' : 'outline'}
|
variant={poolLocalOnly ? 'outline' : poolEnabled ? 'secondary' : 'outline'}
|
||||||
title={
|
title={
|
||||||
poolEnabled
|
poolLocalOnly
|
||||||
? t('routingGuidance.poolRoutingManaged')
|
? poolLocalOnlyMessage
|
||||||
: t('routingGuidance.poolRoutingOffHint')
|
: poolEnabled
|
||||||
|
? t('routingGuidance.poolRoutingManaged')
|
||||||
|
: t('routingGuidance.poolRoutingOffHint')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{poolEnabled ? t('routingGuidance.poolRoutingOn') : t('routingGuidance.poolRoutingOff')}
|
{poolLocalOnly
|
||||||
|
? t('routingGuidance.localOnly')
|
||||||
|
: poolEnabled
|
||||||
|
? `${t('routingGuidance.poolRoutingOn')} · ${t('routingGuidance.poolMaxRetry', {
|
||||||
|
count: poolMaxRetry ?? 0,
|
||||||
|
})}`
|
||||||
|
: t('routingGuidance.poolRoutingOff')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,18 +292,22 @@ export function RoutingGuidanceCard({
|
|||||||
{state ? <Badge variant="outline">{state.target}</Badge> : null}
|
{state ? <Badge variant="outline">{state.target}</Badge> : null}
|
||||||
{state ? (
|
{state ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant={poolEnabled ? 'secondary' : 'outline'}
|
variant={poolLocalOnly ? 'outline' : poolEnabled ? 'secondary' : 'outline'}
|
||||||
title={
|
title={
|
||||||
poolEnabled
|
poolLocalOnly
|
||||||
? t('routingGuidance.poolRoutingManaged')
|
? poolLocalOnlyMessage
|
||||||
: t('routingGuidance.poolRoutingOffHint')
|
: poolEnabled
|
||||||
|
? t('routingGuidance.poolRoutingManaged')
|
||||||
|
: t('routingGuidance.poolRoutingOffHint')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('routingGuidance.poolRouting')}:{' '}
|
{t('routingGuidance.poolRouting')}:{' '}
|
||||||
{poolEnabled
|
{poolLocalOnly
|
||||||
? t('routingGuidance.poolRoutingOn')
|
? t('routingGuidance.localOnly')
|
||||||
: t('routingGuidance.poolRoutingOff')}
|
: poolEnabled
|
||||||
{poolEnabled && poolMaxRetry !== undefined
|
? t('routingGuidance.poolRoutingOn')
|
||||||
|
: t('routingGuidance.poolRoutingOff')}
|
||||||
|
{!poolLocalOnly && poolEnabled && poolMaxRetry !== undefined
|
||||||
? ` · ${t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })}`
|
? ` · ${t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })}`
|
||||||
: ''}
|
: ''}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -339,6 +368,14 @@ export function RoutingGuidanceCard({
|
|||||||
{isSaving ? 'Saving...' : `Use ${selected}`}
|
{isSaving ? 'Saving...' : `Use ${selected}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{poolEnabled && poolManageable ? (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="max-w-xs text-[11px] leading-4 text-amber-700 dark:text-amber-400 xl:text-right"
|
||||||
|
>
|
||||||
|
{t('routingGuidance.poolRoutingApplyWarning')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground xl:col-span-2">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground xl:col-span-2">
|
||||||
|
|||||||
@@ -512,6 +512,14 @@ export type RoutingStrategy = 'round-robin' | 'fill-first';
|
|||||||
export interface CliproxyPoolRoutingState {
|
export interface CliproxyPoolRoutingState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
maxRetryCredentials?: number;
|
maxRetryCredentials?: number;
|
||||||
|
/**
|
||||||
|
* Whether the pool flag reflects the actual proxy. For a remote proxy target
|
||||||
|
* the pool flag is local-only and may not be applied to the remote, so the
|
||||||
|
* backend sets this to false. Undefined is treated as manageable (local).
|
||||||
|
*/
|
||||||
|
manageable?: boolean;
|
||||||
|
/** Explanation surfaced when manageable is false (remote target). */
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CliproxyRoutingState {
|
export interface CliproxyRoutingState {
|
||||||
|
|||||||
@@ -1924,6 +1924,10 @@ const resources = {
|
|||||||
'Pool routing is on: CCS manages strategy (fill-first), session affinity, and cooling for the whole proxy.',
|
'Pool routing is on: CCS manages strategy (fill-first), session affinity, and cooling for the whole proxy.',
|
||||||
poolRoutingOffHint:
|
poolRoutingOffHint:
|
||||||
'Pool routing is off. Enable it from the CLI (ccs cliproxy pool --enable) for fill-first drain with cooldown.',
|
'Pool routing is off. Enable it from the CLI (ccs cliproxy pool --enable) for fill-first drain with cooldown.',
|
||||||
|
poolRoutingApplyWarning:
|
||||||
|
'Pool routing is on. Strategy and session affinity changes will not take effect until you disable it: ccs cliproxy pool --disable',
|
||||||
|
poolRoutingLocalOnly:
|
||||||
|
'Pool routing is managed locally; this remote proxy may not reflect it.',
|
||||||
poolMaxRetry: 'Max retry {{count}}',
|
poolMaxRetry: 'Max retry {{count}}',
|
||||||
drainOrderHint:
|
drainOrderHint:
|
||||||
'Per-account drain order and cooldown state: run ccs cliproxy quota or ccs cliproxy accounts order <provider>.',
|
'Per-account drain order and cooldown state: run ccs cliproxy quota or ccs cliproxy accounts order <provider>.',
|
||||||
@@ -4559,6 +4563,9 @@ const resources = {
|
|||||||
'账号池路由已开启:CCS 为整个代理统一管理策略(优先填满)、会话粘性和冷却。',
|
'账号池路由已开启:CCS 为整个代理统一管理策略(优先填满)、会话粘性和冷却。',
|
||||||
poolRoutingOffHint:
|
poolRoutingOffHint:
|
||||||
'账号池路由已关闭。可在命令行启用(ccs cliproxy pool --enable)以使用带冷却的优先填满。',
|
'账号池路由已关闭。可在命令行启用(ccs cliproxy pool --enable)以使用带冷却的优先填满。',
|
||||||
|
poolRoutingApplyWarning:
|
||||||
|
'账号池路由已开启。在禁用之前,策略和会话粘性的更改不会生效:ccs cliproxy pool --disable',
|
||||||
|
poolRoutingLocalOnly: '账号池路由仅在本地管理;此远程代理可能不会反映该设置。',
|
||||||
poolMaxRetry: '最大重试 {{count}}',
|
poolMaxRetry: '最大重试 {{count}}',
|
||||||
drainOrderHint:
|
drainOrderHint:
|
||||||
'查看每个账号的排空顺序和冷却状态:运行 ccs cliproxy quota 或 ccs cliproxy accounts order <provider>。',
|
'查看每个账号的排空顺序和冷却状态:运行 ccs cliproxy quota 或 ccs cliproxy accounts order <provider>。',
|
||||||
@@ -7257,6 +7264,10 @@ const resources = {
|
|||||||
'Định tuyến nhóm đang bật: CCS quản lý chiến lược (fill-first), ghim phiên và làm nguội cho toàn bộ proxy.',
|
'Định tuyến nhóm đang bật: CCS quản lý chiến lược (fill-first), ghim phiên và làm nguội cho toàn bộ proxy.',
|
||||||
poolRoutingOffHint:
|
poolRoutingOffHint:
|
||||||
'Định tuyến nhóm đang tắt. Bật từ CLI (ccs cliproxy pool --enable) để dùng fill-first kèm làm nguội.',
|
'Định tuyến nhóm đang tắt. Bật từ CLI (ccs cliproxy pool --enable) để dùng fill-first kèm làm nguội.',
|
||||||
|
poolRoutingApplyWarning:
|
||||||
|
'Định tuyến nhóm đang bật. Thay đổi chiến lược và ghim phiên sẽ không có hiệu lực cho đến khi bạn tắt nó: ccs cliproxy pool --disable',
|
||||||
|
poolRoutingLocalOnly:
|
||||||
|
'Định tuyến nhóm chỉ được quản lý cục bộ; proxy từ xa này có thể không phản ánh điều đó.',
|
||||||
poolMaxRetry: 'Thử lại tối đa {{count}}',
|
poolMaxRetry: 'Thử lại tối đa {{count}}',
|
||||||
drainOrderHint:
|
drainOrderHint:
|
||||||
'Thứ tự rút và trạng thái làm nguội từng tài khoản: chạy ccs cliproxy quota hoặc ccs cliproxy accounts order <provider>.',
|
'Thứ tự rút và trạng thái làm nguội từng tài khoản: chạy ccs cliproxy quota hoặc ccs cliproxy accounts order <provider>.',
|
||||||
@@ -10432,6 +10443,10 @@ const resources = {
|
|||||||
'プールルーティングが有効です。CCS がプロキシ全体の戦略(fill-first)、セッション固定、クールダウンを管理します。',
|
'プールルーティングが有効です。CCS がプロキシ全体の戦略(fill-first)、セッション固定、クールダウンを管理します。',
|
||||||
poolRoutingOffHint:
|
poolRoutingOffHint:
|
||||||
'プールルーティングは無効です。CLI(ccs cliproxy pool --enable)で有効にすると、クールダウン付きの fill-first を利用できます。',
|
'プールルーティングは無効です。CLI(ccs cliproxy pool --enable)で有効にすると、クールダウン付きの fill-first を利用できます。',
|
||||||
|
poolRoutingApplyWarning:
|
||||||
|
'プールルーティングが有効です。無効化するまで、戦略とセッション固定の変更は反映されません:ccs cliproxy pool --disable',
|
||||||
|
poolRoutingLocalOnly:
|
||||||
|
'プールルーティングはローカルで管理されます。このリモートプロキシには反映されない場合があります。',
|
||||||
poolMaxRetry: '最大リトライ {{count}}',
|
poolMaxRetry: '最大リトライ {{count}}',
|
||||||
drainOrderHint:
|
drainOrderHint:
|
||||||
'アカウントごとのドレイン順序とクールダウン状態は、ccs cliproxy quota または ccs cliproxy accounts order <provider> を実行してください。',
|
'アカウントごとのドレイン順序とクールダウン状態は、ccs cliproxy quota または ccs cliproxy accounts order <provider> を実行してください。',
|
||||||
@@ -12704,6 +12719,10 @@ const resources = {
|
|||||||
'풀 라우팅이 켜져 있습니다. CCS가 프록시 전체의 전략(fill-first), 세션 어피니티, 쿨다운을 관리합니다.',
|
'풀 라우팅이 켜져 있습니다. CCS가 프록시 전체의 전략(fill-first), 세션 어피니티, 쿨다운을 관리합니다.',
|
||||||
poolRoutingOffHint:
|
poolRoutingOffHint:
|
||||||
'풀 라우팅이 꺼져 있습니다. CLI(ccs cliproxy pool --enable)에서 켜면 쿨다운이 있는 fill-first를 사용할 수 있습니다.',
|
'풀 라우팅이 꺼져 있습니다. CLI(ccs cliproxy pool --enable)에서 켜면 쿨다운이 있는 fill-first를 사용할 수 있습니다.',
|
||||||
|
poolRoutingApplyWarning:
|
||||||
|
'풀 라우팅이 켜져 있습니다. 비활성화하기 전에는 전략과 세션 어피니티 변경이 적용되지 않습니다: ccs cliproxy pool --disable',
|
||||||
|
poolRoutingLocalOnly:
|
||||||
|
'풀 라우팅은 로컬에서 관리됩니다. 이 원격 프록시에는 반영되지 않을 수 있습니다.',
|
||||||
poolMaxRetry: '최대 재시도 {{count}}',
|
poolMaxRetry: '최대 재시도 {{count}}',
|
||||||
drainOrderHint:
|
drainOrderHint:
|
||||||
'계정별 드레인 순서와 쿨다운 상태는 ccs cliproxy quota 또는 ccs cliproxy accounts order <provider>를 실행하세요.',
|
'계정별 드레인 순서와 쿨다운 상태는 ccs cliproxy quota 또는 ccs cliproxy accounts order <provider>를 실행하세요.',
|
||||||
|
|||||||
@@ -87,4 +87,109 @@ describe('RoutingGuidanceCard', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /session affinity unavailable/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /session affinity unavailable/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('compact: pool ON shows the drain-order pointer and an inline override warning', () => {
|
||||||
|
render(
|
||||||
|
<RoutingGuidanceCard
|
||||||
|
compact
|
||||||
|
state={{
|
||||||
|
strategy: 'fill-first',
|
||||||
|
source: 'live',
|
||||||
|
target: 'local',
|
||||||
|
reachable: true,
|
||||||
|
poolRouting: { enabled: true, maxRetryCredentials: 3 },
|
||||||
|
}}
|
||||||
|
sessionAffinityState={{
|
||||||
|
enabled: true,
|
||||||
|
ttl: '1h',
|
||||||
|
source: 'config',
|
||||||
|
target: 'local',
|
||||||
|
reachable: true,
|
||||||
|
manageable: true,
|
||||||
|
}}
|
||||||
|
isLoading={false}
|
||||||
|
isSaving={false}
|
||||||
|
onApply={() => undefined}
|
||||||
|
onApplyAffinity={() => undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drain-order CLI pointer is visible (not hidden behind a hover tooltip).
|
||||||
|
expect(
|
||||||
|
screen.getByText(/ccs cliproxy quota or ccs cliproxy accounts order/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Max retry stays visible in the badge.
|
||||||
|
expect(screen.getByText(/On · Max retry 3/i)).toBeInTheDocument();
|
||||||
|
// Static override warning is rendered before the user clicks anything.
|
||||||
|
expect(
|
||||||
|
screen.getByText(/will not take effect until you disable it: ccs cliproxy pool --disable/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compact: pool OFF surfaces the how-to-enable command as visible text', () => {
|
||||||
|
render(
|
||||||
|
<RoutingGuidanceCard
|
||||||
|
compact
|
||||||
|
state={{
|
||||||
|
strategy: 'round-robin',
|
||||||
|
source: 'live',
|
||||||
|
target: 'local',
|
||||||
|
reachable: true,
|
||||||
|
poolRouting: { enabled: false },
|
||||||
|
}}
|
||||||
|
sessionAffinityState={{
|
||||||
|
enabled: false,
|
||||||
|
ttl: '1h',
|
||||||
|
source: 'config',
|
||||||
|
target: 'local',
|
||||||
|
reachable: true,
|
||||||
|
manageable: true,
|
||||||
|
}}
|
||||||
|
isLoading={false}
|
||||||
|
isSaving={false}
|
||||||
|
onApply={() => undefined}
|
||||||
|
onApplyAffinity={() => undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The enable command is visible, not buried in a hover-only tooltip.
|
||||||
|
expect(screen.getByText(/ccs cliproxy pool --enable/i)).toBeInTheDocument();
|
||||||
|
// No override warning when pool is off.
|
||||||
|
expect(screen.queryByText(/ccs cliproxy pool --disable/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compact: remote pool (manageable false) shows a local-only note, not a confident On/Off', () => {
|
||||||
|
render(
|
||||||
|
<RoutingGuidanceCard
|
||||||
|
compact
|
||||||
|
state={{
|
||||||
|
strategy: 'round-robin',
|
||||||
|
source: 'live',
|
||||||
|
target: 'remote',
|
||||||
|
reachable: true,
|
||||||
|
poolRouting: {
|
||||||
|
enabled: true,
|
||||||
|
manageable: false,
|
||||||
|
message: 'Pool routing is managed locally; this remote proxy may not reflect it.',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sessionAffinityState={{
|
||||||
|
source: 'unsupported',
|
||||||
|
target: 'remote',
|
||||||
|
reachable: true,
|
||||||
|
manageable: false,
|
||||||
|
}}
|
||||||
|
isLoading={false}
|
||||||
|
isSaving={false}
|
||||||
|
onApply={() => undefined}
|
||||||
|
onApplyAffinity={() => undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Pool routing is managed locally; this remote proxy may not reflect it.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// No override warning for a remote proxy the local flag does not manage.
|
||||||
|
expect(screen.queryByText(/ccs cliproxy pool --disable/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user