Skip to content

Commit 8df225e

Browse files
fix(crs): config-driven pool scheduler and token-feed improvements (#633)
* fix(crs): max-out pool policy and config-driven account mapping - Max-out schedulable policy (genuine RL/529 only) with conservative fallback via crs.policy - Remove hardcoded vault↔CRS name maps; use crsAccountName / crs.nameByVaultKey - Shared crs-pool-config.mjs + unit tests; generic remote tunnel env (CRS_TUNNEL_*) Co-authored-by: Cursor <cursoragent@cursor.com> * style(crs): prettier format priority daemon and token-feed Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: auroracapital <auroracapital@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fb35a75 commit 8df225e

10 files changed

Lines changed: 590 additions & 204 deletions

claude-ops/scripts/account-rotation/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,24 @@ Each account entry:
5353
| `dashlaneTokenPath` | no | If you store the token in Dashlane: `dl://<vault-name>/password`. |
5454
| `extraUsageEnabled` | no | Set to `true` ONLY if the account has paid overage on. Triggers safety margin. |
5555
| `capacityMultiplier` | no | Override per-account threshold (default 1.0 = standard Max 20x quota). |
56+
| `crsAccountName` | no | CRS admin account name this vault entry maps to (for crs-token-feed / crs-priority). |
57+
58+
## CRS pool (optional)
59+
60+
If you run [claude-relay-service](https://github.com/anthropics/claude-relay-service) (CRS) alongside the rotator:
61+
62+
1. Add `crsAccountName` on each account (must match the account `name` in the CRS admin UI), or supply `crs.nameByVaultKey` in `config.json`.
63+
2. Set `crs.enabled`, `crs.baseUrl`, and install the priority daemon: `scripts/install-crs-priority-agent.sh`.
64+
3. On Linux/EC2, `crs-token-feed.mjs` propagates vault tokens into CRS (systemd timer when installed).
65+
4. On macOS clients that reach a **remote** CRS via SSH, install the tunnel (requires `CRS_TUNNEL_SSH_HOST`):
66+
67+
```bash
68+
CRS_TUNNEL_SSH_HOST=your-remote-host bash scripts/install-crs-fra-tunnel.sh
69+
```
70+
71+
Point Claude Code at `http://127.0.0.1:3005/api` (or your `CRS_TUNNEL_LOCAL_PORT`).
72+
73+
See `config.example.json``crs` block for all tunables (`policy`, `fileVaultPath`, `containerName`, thresholds).
5674

5775
## Keychain layout
5876

claude-ops/scripts/account-rotation/config.example.json

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"_account_schema_example": {
55
"email": "user@example.com",
66
"label": "optional-disambiguator",
7+
"crsAccountName": "pool-example",
78
"orgName": "Optional Workspace Name",
89
"orgUuid": "optional-workspace-uuid",
910
"dashlaneTokenPath": "dl://claude-max-user@example.com/password",
@@ -28,17 +29,25 @@
2829
"refreshSession": false,
2930
"notifyOnRotation": true,
3031
"crs": {
31-
"_comment": "Optional: auto-prioritization for a claude-relay-service (CRS) pool. CRS load-balances across many Claude accounts at once and exposes a per-account 'schedulable' flag. The crs-priority daemon flips that flag from live utilization so the pool avoids near-maxed accounts and re-enables them on recovery. Off by default — set enabled=true and install via scripts/install-crs-priority-agent.sh. Admin password is NOT stored here: set $CRS_ADMIN_PASSWORD or `lib/credential-store.sh set CRS-Admin-<adminUser> \"$USER\" '<pw>'`.",
32+
"_comment": "Optional: claude-relay-service (CRS) pool integration. Off by default — set enabled=true and install via scripts/install-crs-priority-agent.sh. Map each rotator account to its CRS row with crsAccountName on the account entry (or crs.nameByVaultKey). Admin password is NOT stored here: set $CRS_ADMIN_PASSWORD or credential-store CRS-Admin-<adminUser>.",
3233
"enabled": false,
33-
"baseUrl": "http://127.0.0.1:3000",
34+
"policy": "conservative",
35+
"_policy_note": "conservative (default) deprioritizes on utilization/session warnings; max-out keeps accounts schedulable until genuine rate-limit or overload (util % is telemetry only). Override with $CRS_POLICY.",
36+
"baseUrl": "http://127.0.0.1:3005",
37+
"fileVaultPath": "~/.claude/.credentials.json",
38+
"containerName": "crs-claude-relay-1",
3439
"adminUser": "cradmin",
3540
"adminPasswordEnv": "CRS_ADMIN_PASSWORD",
41+
"policy": "conservative",
42+
"nameByVaultKey": {
43+
"user@example.com": "pool-example"
44+
},
3645
"off5h": 90,
3746
"off7d": 95,
3847
"on5h": 70,
3948
"on7d": 85,
4049
"floor": 3,
4150
"freshMinutes": 15,
42-
"_thresholds_note": "off* = deprioritize when fresh 5h/7d utilization reaches these; on* = re-enable only when all utilization drops below these (hysteresis prevents flapping). floor = minimum usable (schedulable, not-rate-limited) accounts kept even under soft pressure. freshMinutes = max age of claudeUsage data trusted for proactive deprioritize (stale/absent usage never drives a re-enable; real-time sessionWindowStatus + rate-limit + overload always do)."
51+
"_thresholds_note": "Legacy conservative thresholds (off/on/floor) apply only when policy=conservative. Default max-out policy keeps accounts schedulable until genuine rate-limit or overload; utilization % is telemetry only. freshMinutes = max age of claudeUsage trusted for secondary signals."
4352
}
4453
}

claude-ops/scripts/account-rotation/crs-health-watch.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const HEALTH_URL = process.env.CRS_HEALTH_URL || ROUTE_CRS.healthUrl || CRS_BASE
5252
const CRS_SMOKE_URL = process.env.CRS_SMOKE_URL || `${CRS_BASE}/v1/messages?beta=true`;
5353
const CRS_SMOKE_MODEL = process.env.CRS_SMOKE_MODEL || 'claude-sonnet-4-6';
5454
const DOWN_STRIKES = +(process.env.CRS_DOWN_STRIKES || 3); // ~3 min at 60s tick
55-
const UP_STRIKES = +(process.env.CRS_UP_STRIKES || 3);
55+
const UP_STRIKES = +(process.env.CRS_UP_STRIKES || 1);
5656

5757
const ts = () => new Date().toISOString().slice(11, 19);
5858
const log = (m) => console.log(`${ts()} [crs-health-watch] ${m}`);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env node
2+
/**
3+
* crs-pool-config.mjs — shared CRS + rotation config resolution for public installs.
4+
*
5+
* Vault key ↔ CRS account name mapping is NEVER hardcoded in repo scripts.
6+
* Configure per account via `crsAccountName` and/or optional `crs.nameByVaultKey`.
7+
*/
8+
9+
import { readFileSync, existsSync } from 'fs';
10+
import { join, dirname } from 'path';
11+
import { fileURLToPath } from 'url';
12+
import { homedir } from 'os';
13+
14+
const __dirname = dirname(fileURLToPath(import.meta.url));
15+
const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || join(__dirname, '..', '..');
16+
const DATA_DIR =
17+
process.env.CLAUDE_PLUGIN_DATA_DIR || join(homedir(), '.claude', 'plugins', 'data', 'ops-ops-marketplace');
18+
19+
export const CONFIG_CANDIDATES = [
20+
process.env.CRS_CONFIG,
21+
join(DATA_DIR, 'account-rotation', 'config.json'),
22+
join(homedir(), '.claude', 'plugins', 'data', 'ops', 'account-rotation', 'config.json'),
23+
join(PLUGIN_ROOT, 'scripts', 'account-rotation', 'config.json'),
24+
join(__dirname, 'config.json'),
25+
].filter(Boolean);
26+
27+
export function resolveConfigPath() {
28+
for (const p of CONFIG_CANDIDATES) {
29+
if (existsSync(p)) return p;
30+
}
31+
return null;
32+
}
33+
34+
export function loadRotationConfig() {
35+
const path = resolveConfigPath();
36+
if (!path) return { crs: {}, accounts: [] };
37+
try {
38+
return { crs: {}, accounts: [], ...JSON.parse(readFileSync(path, 'utf8')) };
39+
} catch {
40+
return { crs: {}, accounts: [] };
41+
}
42+
}
43+
44+
/**
45+
* @returns {{ nameByVaultKey: Record<string,string>, vaultKeyByCrsName: Record<string,string> }}
46+
*/
47+
export function buildCrsNameMaps(config = loadRotationConfig()) {
48+
const nameByVaultKey = {};
49+
const vaultKeyByCrsName = {};
50+
51+
const overrides = config.crs?.nameByVaultKey;
52+
if (overrides && typeof overrides === 'object') {
53+
for (const [vaultKey, crsName] of Object.entries(overrides)) {
54+
if (!vaultKey || !crsName) continue;
55+
nameByVaultKey[vaultKey] = crsName;
56+
if (!vaultKeyByCrsName[crsName]) vaultKeyByCrsName[crsName] = vaultKey;
57+
}
58+
}
59+
60+
for (const a of config.accounts || []) {
61+
const crsName = a.crsAccountName || a.crsName;
62+
if (!crsName) continue;
63+
const keys = [];
64+
if (a.email) keys.push(a.email);
65+
if (a.label) keys.push(a.label);
66+
for (const k of keys) {
67+
if (!k) continue;
68+
nameByVaultKey[k] = crsName;
69+
}
70+
if (!vaultKeyByCrsName[crsName]) {
71+
vaultKeyByCrsName[crsName] = a.email || a.label || null;
72+
}
73+
}
74+
75+
for (const [vaultKey, crsName] of Object.entries(nameByVaultKey)) {
76+
if (crsName && !vaultKeyByCrsName[crsName]) vaultKeyByCrsName[crsName] = vaultKey;
77+
}
78+
79+
return { nameByVaultKey, vaultKeyByCrsName };
80+
}
81+
82+
export function crsBaseUrl(config = loadRotationConfig()) {
83+
return process.env.CRS_BASE || config.crs?.baseUrl || 'http://127.0.0.1:3005';
84+
}
85+
86+
export function crsFileVaultPath(config = loadRotationConfig()) {
87+
const fromEnv = process.env.CRS_FILE_VAULT;
88+
if (fromEnv) return fromEnv.replace(/^~(?=$|\/)/, homedir());
89+
const fromCfg = config.crs?.fileVaultPath;
90+
if (fromCfg) return fromCfg.replace(/^~(?=$|\/)/, homedir());
91+
return join(homedir(), '.claude', '.credentials.json');
92+
}
93+
94+
export function crsPolicy(config = loadRotationConfig()) {
95+
const raw = String(process.env.CRS_POLICY || config.crs?.policy || 'conservative')
96+
.trim()
97+
.toLowerCase()
98+
.replace(/_/g, '-');
99+
if (raw === 'maxout' || raw === 'max-out') return 'max-out';
100+
return 'conservative';
101+
}
102+
103+
export function vaultLookupKeysForEmail(email, accounts = []) {
104+
const keys = new Set();
105+
if (email) keys.add(email);
106+
for (const a of accounts) {
107+
const label = a.label || a.email;
108+
const addr = a.email || a.label;
109+
if (addr === email || label === email) {
110+
if (label) keys.add(label);
111+
if (addr) keys.add(addr);
112+
}
113+
}
114+
return [...keys];
115+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { buildCrsNameMaps, crsPolicy } from './crs-pool-config.mjs';
4+
5+
test('buildCrsNameMaps uses crsAccountName and nameByVaultKey', () => {
6+
const config = {
7+
accounts: [{ email: 'a@example.com', crsAccountName: 'pool-a' }],
8+
crs: { nameByVaultKey: { legacy: 'pool-legacy' } },
9+
};
10+
const { nameByVaultKey, vaultKeyByCrsName } = buildCrsNameMaps(config);
11+
assert.equal(nameByVaultKey['a@example.com'], 'pool-a');
12+
assert.equal(nameByVaultKey.legacy, 'pool-legacy');
13+
assert.equal(vaultKeyByCrsName['pool-a'], 'a@example.com');
14+
});
15+
16+
test('crsPolicy defaults to conservative', () => {
17+
assert.equal(crsPolicy({ crs: {} }), 'conservative');
18+
assert.equal(crsPolicy({ crs: { policy: 'max-out' } }), 'max-out');
19+
});

0 commit comments

Comments
 (0)