@crvouga/dev-fleet 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +51 -1
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +4 -4
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +30 -2
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/authentication.md +90 -0
- package/docs/declared.md +5 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +26 -15
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +35 -28
- package/examples/fleet.json +19 -1
- package/llms.txt +3 -0
- package/mise.toml +10 -1
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +244 -1
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -0
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +31 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +23 -14
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +126 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +86 -0
- package/src/cli/registry.ts +26 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +226 -7
- package/src/git-identity.ts +160 -0
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +13 -17
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +44 -12
- package/src/notify.ts +239 -0
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-entry.ts +15 -1
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/service-auth.ts +15 -5
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-linked-check.ts +1 -1
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/transport.ts +3 -2
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +68 -13
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -36,3 +36,219 @@ def pending(auths):
|
|
|
36
36
|
if wanted and data.get('prefix') != wanted:
|
|
37
37
|
result.append((path.name, wanted))
|
|
38
38
|
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Account state ────────────────────────────────────────────────────────────
|
|
42
|
+
#
|
|
43
|
+
# One state per credential, from its file (never a token, only whether one is
|
|
44
|
+
# there and when the access token expires) and from the proxy's management
|
|
45
|
+
# API, which knows about refreshes, cooldowns and disabled credentials.
|
|
46
|
+
|
|
47
|
+
STATES = ('usable', 'cooling-down', 'needs-login', 'refresh-failing', 'removed')
|
|
48
|
+
|
|
49
|
+
# A refresh the provider refused for good: only a new sign-in helps.
|
|
50
|
+
REJECTED = re.compile(r'invalid_grant|invalid_token|unauthorized|revoked|refresh token (?:is )?(?:invalid|expired|rejected|reused)', re.I)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _instant(value):
|
|
54
|
+
"""Seconds since the epoch from an ISO 8601 string or a number, or None."""
|
|
55
|
+
import datetime
|
|
56
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
|
57
|
+
return float(value) / (1000 if value > 1e11 else 1)
|
|
58
|
+
if isinstance(value, str) and value and not value.startswith('0001-'):
|
|
59
|
+
try:
|
|
60
|
+
parsed = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
if parsed.tzinfo is None:
|
|
64
|
+
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
|
|
65
|
+
return parsed.timestamp()
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def iso(seconds):
|
|
70
|
+
import datetime
|
|
71
|
+
return datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def classify(credential, entry, now):
|
|
75
|
+
"""(state, until): `credential` is the auth file, `entry` what the
|
|
76
|
+
management API says about it (None when it does not list it). `until` is
|
|
77
|
+
when a cooldown clears, as epoch seconds, or None."""
|
|
78
|
+
entry = entry or {}
|
|
79
|
+
if entry.get('disabled') is True or credential.get('disabled') is True:
|
|
80
|
+
return 'removed', None
|
|
81
|
+
message = str(entry.get('status_message') or entry.get('last_error') or '')
|
|
82
|
+
expires = _instant(credential.get('expired') or credential.get('expiry'))
|
|
83
|
+
expired = expires is not None and expires <= now
|
|
84
|
+
has_refresh = bool(credential.get('refresh_token'))
|
|
85
|
+
if REJECTED.search(message) or (expired and not has_refresh):
|
|
86
|
+
return 'needs-login', None
|
|
87
|
+
if entry.get('status') == 'error' and 'refresh' in message.lower():
|
|
88
|
+
return ('needs-login' if expired else 'refresh-failing'), None
|
|
89
|
+
quota = entry.get('quota') if isinstance(entry.get('quota'), dict) else {}
|
|
90
|
+
until = max(
|
|
91
|
+
[t for t in (_instant(entry.get('next_retry_after')),
|
|
92
|
+
_instant(quota.get('next_recover_at'))) if t is not None and t > now]
|
|
93
|
+
or [0]
|
|
94
|
+
)
|
|
95
|
+
if until or entry.get('unavailable') is True or quota.get('exceeded') is True:
|
|
96
|
+
return 'cooling-down', (until or None)
|
|
97
|
+
return 'usable', None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def credentials(auths):
|
|
101
|
+
"""[(file name, credential)] for every readable credential, in a stable
|
|
102
|
+
order: by provider, then sign-in email, then file name. Each provider's
|
|
103
|
+
accounts are numbered in this order, the same order every node lists
|
|
104
|
+
them in (src/agents/accounts.ts), so an index means one account
|
|
105
|
+
everywhere."""
|
|
106
|
+
found = []
|
|
107
|
+
for path in pathlib.Path(auths).glob('*.json'):
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(path.read_text())
|
|
110
|
+
except (OSError, ValueError):
|
|
111
|
+
continue
|
|
112
|
+
if isinstance(data, dict) and isinstance(data.get('type'), str):
|
|
113
|
+
found.append((path.name, data))
|
|
114
|
+
email = lambda data: data.get('email') if isinstance(data.get('email'), str) else ''
|
|
115
|
+
return sorted(found, key=lambda item: (item[1]['type'], email(item[1]), item[0]))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def roster(auths, entries=(), now=None):
|
|
119
|
+
"""Every provider account: provider, opaque index (1, 2, … per provider),
|
|
120
|
+
label, state and, while cooling down, when it clears. `entries` is the
|
|
121
|
+
management API's list of auth files. Never a token."""
|
|
122
|
+
import time
|
|
123
|
+
now = time.time() if now is None else now
|
|
124
|
+
by_name = {e.get('name'): e for e in entries if isinstance(e, dict)}
|
|
125
|
+
counters = {}
|
|
126
|
+
result = []
|
|
127
|
+
for name, credential in credentials(auths):
|
|
128
|
+
provider = credential['type']
|
|
129
|
+
counters[provider] = counters.get(provider, 0) + 1
|
|
130
|
+
state, until = classify(credential, by_name.get(name), now)
|
|
131
|
+
email = credential.get('email')
|
|
132
|
+
result.append({
|
|
133
|
+
'provider': provider,
|
|
134
|
+
'index': counters[provider],
|
|
135
|
+
'label': email if isinstance(email, str) else provider + '-' + str(counters[provider]),
|
|
136
|
+
'prefix': prefix(provider, email),
|
|
137
|
+
'state': state,
|
|
138
|
+
'until': iso(until) if until else None,
|
|
139
|
+
'file': name,
|
|
140
|
+
})
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def version(accounts):
|
|
145
|
+
"""The roster version: a digest of the non-secret metadata of the accounts
|
|
146
|
+
every node offers (their routing prefixes). Every node computes the same
|
|
147
|
+
digest from the proxy's model list (src/accounts/roster.ts)."""
|
|
148
|
+
import hashlib
|
|
149
|
+
prefixes = sorted({a['prefix'] for a in accounts if a.get('prefix') and a.get('state') != 'removed'})
|
|
150
|
+
return hashlib.sha256('\n'.join(prefixes).encode()).hexdigest()[:16]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def public(accounts):
|
|
154
|
+
"""The roster as reported: no file name."""
|
|
155
|
+
return [{k: v for k, v in a.items() if k != 'file'} for a in accounts]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def find(accounts, provider, index):
|
|
159
|
+
"""The one account `provider` `index` names, or None."""
|
|
160
|
+
for account in accounts:
|
|
161
|
+
if account['provider'] == provider and account['index'] == index:
|
|
162
|
+
return account
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Sign-in, re-login and removal ────────────────────────────────────────────
|
|
167
|
+
#
|
|
168
|
+
# A login writes into a staging directory, never into the auth directory. Only
|
|
169
|
+
# a complete credential (readable JSON with a type) is moved in, with one
|
|
170
|
+
# rename, so an interrupted login leaves no half-written auth file.
|
|
171
|
+
|
|
172
|
+
def staging(auths):
|
|
173
|
+
return pathlib.Path(auths).parent / 'auths-staging'
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def begin(auths):
|
|
177
|
+
"""A fresh, empty staging directory; leftovers of an interrupted login are
|
|
178
|
+
discarded."""
|
|
179
|
+
import shutil
|
|
180
|
+
stage = staging(auths)
|
|
181
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
182
|
+
stage.mkdir(mode=0o700, parents=True)
|
|
183
|
+
return stage
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def adopt(stage, auths, replace=None):
|
|
187
|
+
"""Moves every complete credential from `stage` into `auths` and returns
|
|
188
|
+
their file names. `replace` (a file name) is the credential a re-login
|
|
189
|
+
signs in again: it is replaced by the new one in the same rename. The
|
|
190
|
+
staging directory is removed whatever happens."""
|
|
191
|
+
import os
|
|
192
|
+
import shutil
|
|
193
|
+
stage, auths = pathlib.Path(stage), pathlib.Path(auths)
|
|
194
|
+
adopted = []
|
|
195
|
+
try:
|
|
196
|
+
complete = []
|
|
197
|
+
for path in sorted(stage.glob('*.json')):
|
|
198
|
+
try:
|
|
199
|
+
data = json.loads(path.read_text())
|
|
200
|
+
except (OSError, ValueError):
|
|
201
|
+
continue
|
|
202
|
+
if isinstance(data, dict) and isinstance(data.get('type'), str):
|
|
203
|
+
complete.append(path)
|
|
204
|
+
if replace and len(complete) != 1:
|
|
205
|
+
raise ValueError('A re-login must produce exactly one credential')
|
|
206
|
+
for path in complete:
|
|
207
|
+
target = auths / (replace or path.name)
|
|
208
|
+
os.chmod(path, 0o600)
|
|
209
|
+
os.replace(path, target)
|
|
210
|
+
adopted.append(target.name)
|
|
211
|
+
if replace and adopted and adopted[0] != replace:
|
|
212
|
+
(auths / replace).unlink(missing_ok=True)
|
|
213
|
+
return adopted
|
|
214
|
+
finally:
|
|
215
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def remove(auths, provider, index):
|
|
219
|
+
"""Deletes the credential of one account; returns its file name."""
|
|
220
|
+
account = find(roster(auths), provider, index)
|
|
221
|
+
if account is None:
|
|
222
|
+
raise ValueError('No such account')
|
|
223
|
+
(pathlib.Path(auths) / account['file']).unlink()
|
|
224
|
+
return account['file']
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Refresh tokens across checkpoints ────────────────────────────────────────
|
|
228
|
+
#
|
|
229
|
+
# Providers rotate refresh tokens. Each checkpoint records a short digest of
|
|
230
|
+
# every credential's refresh token (never the token). After a restore, a
|
|
231
|
+
# credential whose digest differs from the newest checkpoint's carries a
|
|
232
|
+
# token the provider has already replaced: that account needs a new login.
|
|
233
|
+
|
|
234
|
+
def token_digests(auths):
|
|
235
|
+
import hashlib
|
|
236
|
+
digests = {}
|
|
237
|
+
for name, credential in credentials(auths):
|
|
238
|
+
token = credential.get('refresh_token')
|
|
239
|
+
if isinstance(token, str) and token:
|
|
240
|
+
digests[name] = hashlib.sha256(token.encode()).hexdigest()[:16]
|
|
241
|
+
return digests
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def stale_after_restore(auths, newest):
|
|
245
|
+
"""[(provider, index)] of the restored accounts whose refresh token is
|
|
246
|
+
older than the one `newest` (the token digests of the newest checkpoint)
|
|
247
|
+
records."""
|
|
248
|
+
current = token_digests(auths)
|
|
249
|
+
stale = []
|
|
250
|
+
for account in roster(auths):
|
|
251
|
+
name = account['file']
|
|
252
|
+
if name in newest and name in current and current[name] != newest[name]:
|
|
253
|
+
stale.append((account['provider'], account['index']))
|
|
254
|
+
return stale
|
|
@@ -68,7 +68,7 @@ def validate(root):
|
|
|
68
68
|
raise ValueError("Provider cache directory missing")
|
|
69
69
|
for path in (state / "runtime/auths").iterdir():
|
|
70
70
|
if path.name == "kv.db" and path.is_file() and path.stat().st_size > 0:
|
|
71
|
-
continue # Opaque upstream bbolt state,
|
|
71
|
+
continue # Opaque upstream bbolt state, copied while the proxy runs (see capture).
|
|
72
72
|
# .json: provider credential; .cds: upstream per-account cooldown
|
|
73
73
|
# records (JSON object, no credential), kept so limits survive moves.
|
|
74
74
|
if path.suffix not in (".json", ".cds") or not path.is_file():
|
|
@@ -127,11 +127,39 @@ def copy_state(root, target):
|
|
|
127
127
|
shutil.copyfile(source, destination)
|
|
128
128
|
shutil.copyfile(root / "INSTALLATION.json", target / "INSTALLATION.json")
|
|
129
129
|
(target / "CHECKPOINT.json").write_text(
|
|
130
|
-
json.dumps(
|
|
130
|
+
json.dumps(
|
|
131
|
+
{
|
|
132
|
+
"version": 1,
|
|
133
|
+
"files": hashes(target),
|
|
134
|
+
"tokens": _accounts().token_digests(target / "state/runtime/auths"),
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
+ "\n"
|
|
131
138
|
)
|
|
132
139
|
validate(target)
|
|
133
140
|
|
|
134
141
|
|
|
142
|
+
def _accounts():
|
|
143
|
+
"""accounts.py beside this file, however this module was loaded."""
|
|
144
|
+
import importlib.util
|
|
145
|
+
|
|
146
|
+
spec = importlib.util.spec_from_file_location(
|
|
147
|
+
"accounts", pathlib.Path(__file__).resolve().parent / "accounts.py"
|
|
148
|
+
)
|
|
149
|
+
module = importlib.util.module_from_spec(spec)
|
|
150
|
+
spec.loader.exec_module(module)
|
|
151
|
+
return module
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def stale_accounts(restored, newest):
|
|
155
|
+
"""[(provider, index)] of the accounts in the restored checkpoint
|
|
156
|
+
`restored` whose refresh token the provider has since replaced, judged
|
|
157
|
+
against the manifest of the newest checkpoint `newest`. Restoring the
|
|
158
|
+
newest checkpoint itself finds none."""
|
|
159
|
+
tokens = json.loads((newest / "CHECKPOINT.json").read_text()).get("tokens") or {}
|
|
160
|
+
return _accounts().stale_after_restore(restored / "state/runtime/auths", tokens)
|
|
161
|
+
|
|
162
|
+
|
|
135
163
|
def capture(root, target, attempts=6, sleep=time.sleep):
|
|
136
164
|
"""Copies the state of the running stack without ever stopping it: agents
|
|
137
165
|
use the proxy around the clock, and a stopped connector fails every one of
|
package/cliproxy/scripts/cli.py
CHANGED
|
@@ -21,7 +21,7 @@ root = cliproxy_host.default_root().resolve()
|
|
|
21
21
|
args = sys.argv[1:]
|
|
22
22
|
if not args or args[0] in ["help", "--help", "-h"]:
|
|
23
23
|
print(
|
|
24
|
-
"Usage: devfleet cliproxy COMMAND\nCommands: install [--revision REV|--rollback|--dry-run|--prepare-only], audit, public-endpoint [enable|start|disable|verify], status, start, stop, restart, health, models, logs, validate, apply FILE, add-account PROVIDER, inference PROTOCOL MODEL, model-health [--profile liveness|readiness|light|nightly|manual] [--suite core|cursor|all] [--target live|mock], model-suite (same flags; defaults to nightly), backup, gc, snapshots, check, check-full, restore SNAPSHOT EMPTY_DIRECTORY, restore-cache --previous-node-stopped, vault-login, recover, recover-node NEW_DIRECTORY [--vault-token-file FILE], verify-recovery, portable-login, activate, drain"
|
|
24
|
+
"Usage: devfleet cliproxy COMMAND\nCommands: install [--revision REV|--rollback|--dry-run|--prepare-only], audit, public-endpoint [enable|start|disable|verify], status, start, stop, restart, health, models, logs, validate, apply FILE, add-account PROVIDER, relogin PROVIDER INDEX, remove-account PROVIDER INDEX, accounts [--json], client-keys, issue-key|rotate-key|retire-key|revoke-key NODE, inference PROTOCOL MODEL, model-health [--profile liveness|readiness|light|nightly|manual] [--suite core|cursor|all] [--target live|mock], model-suite (same flags; defaults to nightly), backup, gc, snapshots, check, check-full, restore SNAPSHOT EMPTY_DIRECTORY, restore-cache --previous-node-stopped, vault-login, recover, recover-node NEW_DIRECTORY [--vault-token-file FILE], verify-recovery, portable-login, activate, drain"
|
|
25
25
|
)
|
|
26
26
|
sys.exit(0)
|
|
27
27
|
command, *rest = args
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""One proxy client key per node, rotated with overlap and revoked with the node.
|
|
2
|
+
|
|
3
|
+
The key ring is a private JSON document in the proxy's secrets
|
|
4
|
+
(`state/secrets/client-keys.json`): per node, the current key and, during a
|
|
5
|
+
rotation, the previous one. The proxy accepts every key in the ring
|
|
6
|
+
(`api_keys`), so a rotation adds the new key, every node moves to it, and
|
|
7
|
+
only then is the old key retired: no request fails in between. Revoking a
|
|
8
|
+
node removes its keys and leaves every other node working. Reports name keys
|
|
9
|
+
only by a short fingerprint, never by value.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
|
|
18
|
+
NODE = re.compile(r'^[a-z][a-z0-9-]{0,62}$')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fingerprint(key):
|
|
22
|
+
return hashlib.sha256(key.encode()).hexdigest()[:12]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load(path):
|
|
26
|
+
try:
|
|
27
|
+
ring = json.loads(pathlib.Path(path).read_text())
|
|
28
|
+
except (OSError, ValueError):
|
|
29
|
+
return {'version': 1, 'nodes': {}}
|
|
30
|
+
if not isinstance(ring, dict) or ring.get('version') != 1:
|
|
31
|
+
raise ValueError('Unsupported client key ring')
|
|
32
|
+
return ring
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save(path, ring):
|
|
36
|
+
path = pathlib.Path(path)
|
|
37
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
staged = path.with_suffix('.new')
|
|
39
|
+
staged.write_text(json.dumps(ring, indent=2, sort_keys=True) + '\n')
|
|
40
|
+
os.chmod(staged, 0o600)
|
|
41
|
+
os.replace(staged, path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _node(ring, node):
|
|
45
|
+
if not NODE.match(node):
|
|
46
|
+
raise ValueError('Invalid node id')
|
|
47
|
+
return ring['nodes'].setdefault(node, {'current': None, 'previous': None})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def issue(ring, node, generate=lambda: secrets.token_urlsafe(32)):
|
|
51
|
+
"""Gives `node` a key when it has none; returns its current key."""
|
|
52
|
+
entry = _node(ring, node)
|
|
53
|
+
if not entry['current']:
|
|
54
|
+
entry['current'] = generate()
|
|
55
|
+
return entry['current']
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def rotate(ring, node, generate=lambda: secrets.token_urlsafe(32)):
|
|
59
|
+
"""Starts a rotation: a new current key, the old one kept as previous
|
|
60
|
+
until `retire`. Both are accepted meanwhile."""
|
|
61
|
+
entry = _node(ring, node)
|
|
62
|
+
if entry['previous']:
|
|
63
|
+
raise ValueError('A rotation of this node is already in progress; retire it first')
|
|
64
|
+
entry['previous'], entry['current'] = entry['current'], generate()
|
|
65
|
+
return entry['current']
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def retire(ring, node):
|
|
69
|
+
"""Ends a rotation once the node uses its new key."""
|
|
70
|
+
_node(ring, node)['previous'] = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def revoke(ring, node):
|
|
74
|
+
"""Removes every key of `node`."""
|
|
75
|
+
ring['nodes'].pop(node, None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def api_keys(ring, shared=()):
|
|
79
|
+
"""Every key the proxy accepts: the ring's, plus `shared` keys still in
|
|
80
|
+
use (the fleet-wide key, until each node has its own)."""
|
|
81
|
+
keys = list(shared)
|
|
82
|
+
for node in sorted(ring['nodes']):
|
|
83
|
+
entry = ring['nodes'][node]
|
|
84
|
+
for key in (entry['current'], entry['previous']):
|
|
85
|
+
if key and key not in keys:
|
|
86
|
+
keys.append(key)
|
|
87
|
+
return keys
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def report(ring):
|
|
91
|
+
"""Per node: the current key's fingerprint and whether a rotation is
|
|
92
|
+
still waiting to be retired. No key value."""
|
|
93
|
+
return {
|
|
94
|
+
node: {
|
|
95
|
+
'current': fingerprint(entry['current']) if entry['current'] else None,
|
|
96
|
+
'rotating': bool(entry['previous']),
|
|
97
|
+
}
|
|
98
|
+
for node, entry in sorted(ring['nodes'].items())
|
|
99
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""A fencing lease on the proxy state: only its holder is the writer.
|
|
2
|
+
|
|
3
|
+
Providers rotate refresh tokens, so two proxies started from the same
|
|
4
|
+
checkpoint spend each other's tokens. The lease is one small JSON document in
|
|
5
|
+
a store both would see (the checkpoint bucket in production, a directory in
|
|
6
|
+
tests): the holder, a fencing epoch that only ever grows, and when it
|
|
7
|
+
expires. A proxy that cannot take the lease refuses to start as writer.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
TTL = 120
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Held(Exception):
|
|
18
|
+
"""Another proxy holds the lease."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, holder, expires):
|
|
21
|
+
super().__init__('The proxy state lease is held by another proxy')
|
|
22
|
+
self.holder = holder
|
|
23
|
+
self.expires = expires
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DirectoryStore:
|
|
27
|
+
"""A lease document in a directory, replaced with one rename. `put`
|
|
28
|
+
writes only when the document is still the one `get` returned."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, directory):
|
|
31
|
+
self.path = pathlib.Path(directory) / 'proxy-lease.json'
|
|
32
|
+
|
|
33
|
+
def get(self):
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(self.path.read_text())
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def put(self, document, expected):
|
|
40
|
+
import fcntl
|
|
41
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with open(self.path.with_suffix('.lock'), 'w') as lock:
|
|
43
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
44
|
+
if self.get() != expected:
|
|
45
|
+
return False
|
|
46
|
+
staged = self.path.with_suffix('.new')
|
|
47
|
+
staged.write_text(json.dumps(document) + '\n')
|
|
48
|
+
os.replace(staged, self.path)
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def acquire(store, holder, now=None, ttl=TTL):
|
|
53
|
+
"""Takes or renews the lease for `holder`; returns its fencing epoch.
|
|
54
|
+
Raises Held while another holder's lease has not expired. Taking over an
|
|
55
|
+
expired lease increases the epoch, so the old holder's writes are fenced."""
|
|
56
|
+
now = time.time() if now is None else now
|
|
57
|
+
current = store.get()
|
|
58
|
+
if current and current.get('holder') != holder and current.get('expires', 0) > now:
|
|
59
|
+
raise Held(current.get('holder'), current.get('expires'))
|
|
60
|
+
epoch = (current or {}).get('epoch', 0)
|
|
61
|
+
if not current or current.get('holder') != holder:
|
|
62
|
+
epoch += 1
|
|
63
|
+
document = {'holder': holder, 'epoch': epoch, 'expires': now + ttl}
|
|
64
|
+
if not store.put(document, current):
|
|
65
|
+
raise Held(None, None)
|
|
66
|
+
return epoch
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def valid(store, holder, epoch, now=None):
|
|
70
|
+
"""Whether `holder` still holds the lease at `epoch`: checked before a
|
|
71
|
+
token refresh is written."""
|
|
72
|
+
now = time.time() if now is None else now
|
|
73
|
+
current = store.get() or {}
|
|
74
|
+
return current.get('holder') == holder and current.get('epoch') == epoch and current.get('expires', 0) > now
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def release(store, holder):
|
|
78
|
+
current = store.get()
|
|
79
|
+
if current and current.get('holder') == holder:
|
|
80
|
+
store.put({**current, 'expires': 0}, current)
|
package/cliproxy/scripts/ops
CHANGED
|
@@ -8,6 +8,7 @@ import pathlib as _pathlib
|
|
|
8
8
|
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
9
9
|
import accounts
|
|
10
10
|
import cliproxy_host
|
|
11
|
+
import clientkeys
|
|
11
12
|
|
|
12
13
|
import fcntl, json, os, pathlib, shutil, subprocess, sys, tempfile, time, urllib.request, urllib.error
|
|
13
14
|
|
|
@@ -205,6 +206,92 @@ def apply_prefixes(prefixes):
|
|
|
205
206
|
sys.exit("The proxy did not record the declared account prefixes.")
|
|
206
207
|
|
|
207
208
|
|
|
209
|
+
LOGIN_FLAGS = {
|
|
210
|
+
"codex": "-codex-device-login",
|
|
211
|
+
"claude": "-claude-login",
|
|
212
|
+
"antigravity": "-antigravity-login",
|
|
213
|
+
"kimi": "-kimi-login",
|
|
214
|
+
"xai": "-xai-login",
|
|
215
|
+
"meta": "-meta-login",
|
|
216
|
+
"devin": "-devin-login",
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def login(provider, replace=None):
|
|
221
|
+
"""Runs the proxy's own login into a staging directory, then moves the
|
|
222
|
+
complete credential in with one rename: an interrupted login leaves no
|
|
223
|
+
half-written auth file. `replace` is the credential a re-login renews."""
|
|
224
|
+
import yaml
|
|
225
|
+
|
|
226
|
+
if provider not in LOGIN_FLAGS:
|
|
227
|
+
sys.exit(
|
|
228
|
+
"This release has no login flag for that provider. Use the official dashboard OAuth/API key configuration."
|
|
229
|
+
)
|
|
230
|
+
auths = root / "state/runtime/auths"
|
|
231
|
+
stage = accounts.begin(auths)
|
|
232
|
+
config = yaml.safe_load((root / "state/runtime/config.yaml").read_text())
|
|
233
|
+
config.setdefault("oauth", {})["auth-dir"] = "/state/" + stage.name
|
|
234
|
+
login_config = root / "state/runtime/login-config.yaml"
|
|
235
|
+
login_config.write_text(yaml.safe_dump(config))
|
|
236
|
+
try:
|
|
237
|
+
compose(
|
|
238
|
+
"exec",
|
|
239
|
+
"proxy",
|
|
240
|
+
"/CLIProxyAPI/CLIProxyAPI",
|
|
241
|
+
"-config",
|
|
242
|
+
"/state/" + login_config.name,
|
|
243
|
+
LOGIN_FLAGS[provider],
|
|
244
|
+
"-no-browser",
|
|
245
|
+
)
|
|
246
|
+
if not accounts.adopt(stage, auths, replace=replace):
|
|
247
|
+
sys.exit("The login wrote no credential; nothing was changed.")
|
|
248
|
+
finally:
|
|
249
|
+
login_config.unlink(missing_ok=True)
|
|
250
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def account_roster():
|
|
254
|
+
"""The roster with each account's state, from the credential files and
|
|
255
|
+
the management API (which knows cooldowns and refresh failures)."""
|
|
256
|
+
entries = []
|
|
257
|
+
try:
|
|
258
|
+
admin = (root / "state/secrets/management-key").read_text().strip()
|
|
259
|
+
code, body = request("/v0/management/auth-files", admin)
|
|
260
|
+
if code == 200:
|
|
261
|
+
entries = json.loads(body).get("files") or []
|
|
262
|
+
except Exception:
|
|
263
|
+
entries = []
|
|
264
|
+
roster = accounts.roster(root / "state/runtime/auths", entries)
|
|
265
|
+
return {
|
|
266
|
+
"version": 1,
|
|
267
|
+
"rosterVersion": accounts.version(roster),
|
|
268
|
+
"managementApi": bool(entries),
|
|
269
|
+
"accounts": accounts.public(roster),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def key_ring():
|
|
274
|
+
return clientkeys.load(root / "state/secrets/client-keys.json")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def apply_keys(ring):
|
|
278
|
+
"""The proxy accepts exactly the shared key and the ring's keys."""
|
|
279
|
+
import yaml
|
|
280
|
+
|
|
281
|
+
live = yaml.safe_load((root / "state/runtime/config.yaml").read_text())
|
|
282
|
+
shared = (root / "state/secrets/client-key").read_text().strip()
|
|
283
|
+
live.setdefault("access", {})["api-keys"] = clientkeys.api_keys(ring, [shared])
|
|
284
|
+
with tempfile.NamedTemporaryFile(
|
|
285
|
+
"w", dir=root / "state/runtime", suffix=".yaml", delete=False
|
|
286
|
+
) as file:
|
|
287
|
+
file.write(yaml.safe_dump(live, sort_keys=False))
|
|
288
|
+
try:
|
|
289
|
+
os.chmod(file.name, 0o600)
|
|
290
|
+
apply_config(pathlib.Path(file.name), restart=False)
|
|
291
|
+
finally:
|
|
292
|
+
pathlib.Path(file.name).unlink(missing_ok=True)
|
|
293
|
+
|
|
294
|
+
|
|
208
295
|
def lock():
|
|
209
296
|
f = open(root / "state/status/operation.lock", "w")
|
|
210
297
|
if os.geteuid() == 0:
|
|
@@ -414,33 +501,58 @@ try:
|
|
|
414
501
|
sys.exit(
|
|
415
502
|
"Install a reviewed Git revision with bun run cliproxy install --revision REV; use --rollback for the recorded previous revision."
|
|
416
503
|
)
|
|
417
|
-
elif command
|
|
504
|
+
elif command in ["add-account", "relogin"]:
|
|
505
|
+
# `add-account PROVIDER` signs in a new account; `relogin PROVIDER
|
|
506
|
+
# INDEX` signs that one account in again. Prefixes follow at once.
|
|
418
507
|
operation_lock = lock()
|
|
419
508
|
provider = sys.argv[2]
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
"antigravity": "-antigravity-login",
|
|
425
|
-
"kimi": "-kimi-login",
|
|
426
|
-
"xai": "-xai-login",
|
|
427
|
-
"meta": "-meta-login",
|
|
428
|
-
"devin": "-devin-login",
|
|
429
|
-
}
|
|
430
|
-
if provider == "gemini":
|
|
431
|
-
sys.exit(
|
|
432
|
-
"This release has no Gemini CLI login flag. Use official dashboard OAuth/API key configuration."
|
|
509
|
+
replace = None
|
|
510
|
+
if command == "relogin":
|
|
511
|
+
account = accounts.find(
|
|
512
|
+
accounts.roster(root / "state/runtime/auths"), provider, int(sys.argv[3])
|
|
433
513
|
)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
"
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
514
|
+
if account is None:
|
|
515
|
+
sys.exit("No such account. List them with: devfleet fleet-accounts")
|
|
516
|
+
replace = account["file"]
|
|
517
|
+
login(provider, replace)
|
|
518
|
+
prefixes = pending_prefixes()
|
|
519
|
+
if prefixes:
|
|
520
|
+
apply_prefixes(prefixes)
|
|
521
|
+
elif command == "remove-account":
|
|
522
|
+
# Deletes the credential; the proxy stops serving it, and the next
|
|
523
|
+
# account propagation removes its instances from every node.
|
|
524
|
+
operation_lock = lock()
|
|
525
|
+
accounts.remove(root / "state/runtime/auths", sys.argv[2], int(sys.argv[3]))
|
|
526
|
+
elif command == "accounts":
|
|
527
|
+
report = account_roster()
|
|
528
|
+
if "--json" in sys.argv[2:]:
|
|
529
|
+
print(json.dumps(report))
|
|
530
|
+
else:
|
|
531
|
+
print("Roster version:", report["version"])
|
|
532
|
+
for account in report["accounts"]:
|
|
533
|
+
print(
|
|
534
|
+
account["provider"],
|
|
535
|
+
account["index"],
|
|
536
|
+
account["state"],
|
|
537
|
+
account["until"] or "",
|
|
538
|
+
)
|
|
539
|
+
elif command == "client-keys":
|
|
540
|
+
print(json.dumps({"version": 1, "nodes": clientkeys.report(key_ring())}))
|
|
541
|
+
elif command in ["issue-key", "rotate-key", "retire-key", "revoke-key"]:
|
|
542
|
+
# Per-node client keys. A rotation keeps the old key accepted until
|
|
543
|
+
# retire-key, so no running agent fails while nodes move over.
|
|
544
|
+
operation_lock = lock()
|
|
545
|
+
ring = key_ring()
|
|
546
|
+
node = sys.argv[2]
|
|
547
|
+
{
|
|
548
|
+
"issue-key": clientkeys.issue,
|
|
549
|
+
"rotate-key": clientkeys.rotate,
|
|
550
|
+
"retire-key": clientkeys.retire,
|
|
551
|
+
"revoke-key": clientkeys.revoke,
|
|
552
|
+
}[command](ring, node)
|
|
553
|
+
clientkeys.save(root / "state/secrets/client-keys.json", ring)
|
|
554
|
+
apply_keys(ring)
|
|
555
|
+
print(json.dumps({"version": 1, "nodes": clientkeys.report(ring)}))
|
|
444
556
|
elif command == "inference":
|
|
445
557
|
protocol, model = sys.argv[2:4]
|
|
446
558
|
key = (root / "state/secrets/client-key").read_text().strip()
|
|
@@ -519,7 +631,7 @@ try:
|
|
|
519
631
|
)
|
|
520
632
|
else:
|
|
521
633
|
sys.exit(
|
|
522
|
-
"Commands: start stop restart status health models logs validate apply FILE declared [--apply] add-account PROVIDER inference PROTOCOL MODEL portable-login activate drain"
|
|
634
|
+
"Commands: start stop restart status health models logs validate apply FILE declared [--apply] add-account PROVIDER relogin PROVIDER INDEX remove-account PROVIDER INDEX accounts [--json] client-keys issue-key|rotate-key|retire-key|revoke-key NODE inference PROTOCOL MODEL portable-login activate drain"
|
|
523
635
|
)
|
|
524
636
|
except Exception:
|
|
525
637
|
sys.exit(
|
|
@@ -58,3 +58,18 @@ print(
|
|
|
58
58
|
"credential files validated offline.",
|
|
59
59
|
)
|
|
60
60
|
print("No restored credentials or code were activated.")
|
|
61
|
+
# `--newest DIR`: the newest checkpoint. Refresh tokens rotate, so an older
|
|
62
|
+
# checkpoint can hold tokens the provider has already replaced; name exactly
|
|
63
|
+
# the accounts that need a new login (provider and index, never an account).
|
|
64
|
+
if "--newest" in sys.argv[2:]:
|
|
65
|
+
import checkpoint
|
|
66
|
+
|
|
67
|
+
newest = pathlib.Path(sys.argv[sys.argv.index("--newest") + 1]).resolve()
|
|
68
|
+
if not (newest / "CHECKPOINT.json").is_file():
|
|
69
|
+
newest = checkpoint.locate(newest)
|
|
70
|
+
stale = checkpoint.stale_accounts(root, newest)
|
|
71
|
+
for provider, index in stale:
|
|
72
|
+
print("needs-login:", provider, index)
|
|
73
|
+
print("Accounts restored with a current refresh token:", "all" if not stale else "all others")
|
|
74
|
+
if stale:
|
|
75
|
+
sys.exit(4)
|