claude-multiacc 1.0.13 → 1.0.15
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 +276 -15
- package/bin/claude +565 -46
- package/bin/claude-accounts +447 -75
- package/bin/codex +254 -26
- package/bin/codex-accounts +157 -52
- package/install.sh +196 -69
- package/lib/common.sh +262 -2
- package/lib/credential.py +336 -0
- package/lib/report.py +434 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +1556 -8
package/lib/common.sh
CHANGED
|
@@ -18,20 +18,33 @@ DEFAULT_SERVER_REPO="/root/claude-multiacc"
|
|
|
18
18
|
|
|
19
19
|
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)"
|
|
20
20
|
|
|
21
|
+
# INSTANCE-SCOPED POOLS: <PROVIDER>_ACCOUNTS_ROOT relocates the whole pool, so
|
|
22
|
+
# several app-robot instances on one machine/user keep completely isolated account
|
|
23
|
+
# sets. The older <PROVIDER>_ACCOUNTS_DIR spelling still works (it is what the test
|
|
24
|
+
# suite and existing runner envs set) — _ROOT wins when both are present.
|
|
21
25
|
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
|
|
22
|
-
ACC_ROOT="${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}"
|
|
26
|
+
ACC_ROOT="${CODEX_ACCOUNTS_ROOT:-${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}}"
|
|
27
|
+
DEFAULT_ACC_ROOT="$HOME/.codex-accounts"
|
|
23
28
|
DEFAULT_SERVER_ROOT="/root/.codex-accounts"
|
|
24
29
|
USAGE_URL="${CODEX_MULTIACC_USAGE_URL:-https://chatgpt.com/backend-api/codex/usage}"
|
|
25
30
|
AUDIT_PY="$LIB_DIR/codex_audit.py"
|
|
26
31
|
PROVIDER_CLI="codex-accounts"
|
|
32
|
+
SYNC_TARGET_ENV="${CODEX_MULTIACC_SYNC_TARGET:-${MULTIACC_SYNC_TARGET:-}}"
|
|
33
|
+
SYNC_ROOT_ENV="${CODEX_MULTIACC_SYNC_ROOT:-${MULTIACC_SYNC_ROOT:-}}"
|
|
34
|
+
SYNC_REPO_ENV="${CODEX_MULTIACC_SYNC_REPO:-${MULTIACC_SYNC_REPO:-}}"
|
|
27
35
|
else
|
|
28
|
-
ACC_ROOT="${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}"
|
|
36
|
+
ACC_ROOT="${CLAUDE_ACCOUNTS_ROOT:-${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}}"
|
|
37
|
+
DEFAULT_ACC_ROOT="$HOME/.claude-accounts"
|
|
29
38
|
DEFAULT_SERVER_ROOT="/root/.claude-accounts"
|
|
30
39
|
USAGE_URL="${CLAUDE_MULTIACC_USAGE_URL:-https://api.anthropic.com/api/oauth/usage}"
|
|
31
40
|
AUDIT_PY="$LIB_DIR/audit.py"
|
|
32
41
|
PROVIDER_CLI="claude-accounts"
|
|
42
|
+
SYNC_TARGET_ENV="${CLAUDE_MULTIACC_SYNC_TARGET:-${MULTIACC_SYNC_TARGET:-}}"
|
|
43
|
+
SYNC_ROOT_ENV="${CLAUDE_MULTIACC_SYNC_ROOT:-${MULTIACC_SYNC_ROOT:-}}"
|
|
44
|
+
SYNC_REPO_ENV="${CLAUDE_MULTIACC_SYNC_REPO:-${MULTIACC_SYNC_REPO:-}}"
|
|
33
45
|
fi
|
|
34
46
|
MANIFEST="$ACC_ROOT/accounts.json"
|
|
47
|
+
REPORT_PY="$LIB_DIR/report.py"
|
|
35
48
|
|
|
36
49
|
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
|
37
50
|
epoch_now() { date +%s; }
|
|
@@ -159,6 +172,253 @@ valid_ssh_target() {
|
|
|
159
172
|
|
|
160
173
|
acct_dir() { printf '%s/%s\n' "$ACC_ROOT" "$1"; }
|
|
161
174
|
|
|
175
|
+
# ---- sync target resolution ----------------------------------------------------
|
|
176
|
+
# Where this pool pushes, in precedence order:
|
|
177
|
+
# 1. <PROVIDER>_MULTIACC_SYNC_TARGET / MULTIACC_SYNC_TARGET (env — the runner daemon
|
|
178
|
+
# sets these per instance, without rewriting a manifest it does not own)
|
|
179
|
+
# 2. the manifest's server/server_root/server_repo (what install.sh --server wrote)
|
|
180
|
+
# 3. DEFAULT_SERVER — the historical default, unchanged for existing installs.
|
|
181
|
+
# The value 'none' (also 'local'/'off'/'disabled'/empty) selects LOCAL-ONLY mode: the
|
|
182
|
+
# pool has no ssh target because something else (the panel/runner daemon) distributes
|
|
183
|
+
# it. That is a supported mode, not a misconfiguration.
|
|
184
|
+
sync_target_is_local() {
|
|
185
|
+
case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
|
|
186
|
+
''|none|local|off|disabled) return 0 ;;
|
|
187
|
+
*) return 1 ;;
|
|
188
|
+
esac
|
|
189
|
+
}
|
|
190
|
+
sync_target() {
|
|
191
|
+
if [ -n "${SYNC_TARGET_ENV:-}" ]; then printf '%s\n' "$SYNC_TARGET_ENV"
|
|
192
|
+
else manifest_get server "$DEFAULT_SERVER"; fi
|
|
193
|
+
}
|
|
194
|
+
sync_target_root() {
|
|
195
|
+
if [ -n "${SYNC_ROOT_ENV:-}" ]; then printf '%s\n' "$SYNC_ROOT_ENV"
|
|
196
|
+
else manifest_get server_root "$DEFAULT_SERVER_ROOT"; fi
|
|
197
|
+
}
|
|
198
|
+
sync_target_repo() {
|
|
199
|
+
if [ -n "${SYNC_REPO_ENV:-}" ]; then printf '%s\n' "$SYNC_REPO_ENV"
|
|
200
|
+
else manifest_get server_repo "$DEFAULT_SERVER_REPO"; fi
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# The machine-readable pool document shared by `list|status|limits --json`
|
|
204
|
+
# (lib/report.py). Stdout is JSON and nothing else — every human line a --json run
|
|
205
|
+
# would otherwise print goes to stderr or a log.
|
|
206
|
+
emit_report_json() { # emit_report_json <list|status|limits>
|
|
207
|
+
local target mode
|
|
208
|
+
target="$(sync_target)"
|
|
209
|
+
if sync_target_is_local "$target"; then mode=local; target=""; else mode=server; fi
|
|
210
|
+
MULTIACC_REPORT_SYNC_TARGET="$target" \
|
|
211
|
+
MULTIACC_REPORT_SYNC_ROOT="$(sync_target_root)" \
|
|
212
|
+
MULTIACC_REPORT_SYNC_REPO="$(sync_target_repo)" \
|
|
213
|
+
MULTIACC_REPORT_SYNC_MODE="$mode" \
|
|
214
|
+
"$PYBIN" "$REPORT_PY" "$ACC_ROOT" "$MULTIACC_PROVIDER" "$(machine_kind)" "${1:-list}" \
|
|
215
|
+
|| die "could not build the JSON report from $ACC_ROOT"
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# True when every manifest account has a well-formed id and an email. A corrupt
|
|
219
|
+
# manifest must never be pushed (it would blank the target pools) and must never be
|
|
220
|
+
# reported as a clean local sync either.
|
|
221
|
+
manifest_well_formed() {
|
|
222
|
+
"$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
|
|
223
|
+
import json, re, sys
|
|
224
|
+
doc = json.load(open(sys.argv[1]))
|
|
225
|
+
accounts = doc.get('accounts')
|
|
226
|
+
if not isinstance(accounts, list):
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
for a in accounts:
|
|
229
|
+
if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
|
|
230
|
+
or not str(a.get('email', '')).strip():
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
PYEOF
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# The housekeeping a pool does on ITSELF: seed any missing account dirs and put the
|
|
236
|
+
# credential files back to 0600. It is what a sync target runs after a push, and all
|
|
237
|
+
# a local-only pool has to do (nothing is pushed anywhere there).
|
|
238
|
+
local_pool_fixup() {
|
|
239
|
+
local id d
|
|
240
|
+
for id in $(account_ids); do
|
|
241
|
+
d="$ACC_ROOT/$id"
|
|
242
|
+
seed_account_dir "$d"
|
|
243
|
+
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
|
|
244
|
+
[ -f "$d/auth.json" ] && chmod 600 "$d/auth.json" 2>/dev/null
|
|
245
|
+
else
|
|
246
|
+
[ -f "$d/server.token" ] && chmod 600 "$d/server.token" 2>/dev/null
|
|
247
|
+
[ -f "$d/.credentials.json" ] && chmod 600 "$d/.credentials.json" 2>/dev/null
|
|
248
|
+
fi
|
|
249
|
+
done
|
|
250
|
+
return 0
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
CREDENTIAL_PY="$LIB_DIR/credential.py"
|
|
254
|
+
|
|
255
|
+
# EXIT-trap body for the limits refresh lock — a function for the same reason as
|
|
256
|
+
# import_cred_cleanup: the lock path contains the pool root.
|
|
257
|
+
LIMITS_LOCK=""
|
|
258
|
+
limits_lock_release() {
|
|
259
|
+
[ -n "${LIMITS_LOCK:-}" ] && rm -rf "$LIMITS_LOCK" 2>/dev/null
|
|
260
|
+
LIMITS_LOCK=""
|
|
261
|
+
return 0
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# EXIT-trap body for cmd_import_credential. A function, not an interpolated string:
|
|
265
|
+
# the staged blob lives under the pool root, and a root containing a quote would
|
|
266
|
+
# otherwise inject shell code into the trap program.
|
|
267
|
+
IMPORT_STAGE=""
|
|
268
|
+
import_cred_cleanup() {
|
|
269
|
+
[ -n "${IMPORT_STAGE:-}" ] && rm -f "$IMPORT_STAGE" 2>/dev/null
|
|
270
|
+
IMPORT_STAGE=""
|
|
271
|
+
mutate_unlock 2>/dev/null || true
|
|
272
|
+
return 0
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
cmd_export_credential() {
|
|
276
|
+
# Emit a self-contained transfer blob for ONE account. Read-only on this pool:
|
|
277
|
+
# nothing is written, refreshed or re-minted, so exporting a live production
|
|
278
|
+
# account cannot disturb it. lib/credential.py owns the format AND the
|
|
279
|
+
# portability rule (it exits 3 on a machine-local credential); this wrapper only
|
|
280
|
+
# decides where the blob lands and propagates that exit code verbatim, because a
|
|
281
|
+
# daemon branches on it.
|
|
282
|
+
require_manifest
|
|
283
|
+
local id="" out="" identity=0
|
|
284
|
+
while [ $# -gt 0 ]; do
|
|
285
|
+
case "$1" in
|
|
286
|
+
--out) out="${2:?--out requires a path}"; shift 2 ;;
|
|
287
|
+
--identity-only) identity=1; shift ;;
|
|
288
|
+
--*) die "unknown option: $1" ;;
|
|
289
|
+
*) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
|
|
290
|
+
esac
|
|
291
|
+
done
|
|
292
|
+
[ -n "$id" ] || die "usage: $PROVIDER_CLI export-credential <acct-NN> [--out PATH] [--identity-only]"
|
|
293
|
+
valid_acct_id "$id" || die "not a valid account id: $id"
|
|
294
|
+
set -- export "$ACC_ROOT" "$MULTIACC_PROVIDER" "$id"
|
|
295
|
+
[ "$identity" = "1" ] && set -- "$@" --identity-only
|
|
296
|
+
# --out is handled INSIDE credential.py: it creates a unique 0600 file next to the
|
|
297
|
+
# target and renames it into place, so the blob is never briefly world-readable and
|
|
298
|
+
# a planted symlink at the target is replaced rather than written through — neither
|
|
299
|
+
# of which a shell redirect can promise.
|
|
300
|
+
[ -n "$out" ] && set -- "$@" --out "$out"
|
|
301
|
+
local rc=0
|
|
302
|
+
"$PYBIN" "$CREDENTIAL_PY" "$@" || rc=$?
|
|
303
|
+
[ "$rc" = "0" ] || return "$rc"
|
|
304
|
+
log_to ops.log "export-credential $id -> ${out:-stdout} identity_only=$identity"
|
|
305
|
+
if [ -n "$out" ]; then
|
|
306
|
+
if [ "$identity" = "1" ]; then
|
|
307
|
+
echo "Wrote $out (identity only — no credential material)."
|
|
308
|
+
else
|
|
309
|
+
echo "Wrote $out (carries a live credential: keep it secret, delete it once imported)."
|
|
310
|
+
fi
|
|
311
|
+
fi
|
|
312
|
+
return 0
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
cmd_import_credential() {
|
|
316
|
+
# Install a transfer blob into this pool, non-interactively. Idempotent by EMAIL:
|
|
317
|
+
# re-importing an account that is already here refreshes its credential in place
|
|
318
|
+
# instead of creating a second entry, so a daemon can push the same pool to a
|
|
319
|
+
# machine repeatedly. The secret never passes through a shell variable or argv —
|
|
320
|
+
# it is staged 0600 and installed by lib/credential.py.
|
|
321
|
+
require_manifest
|
|
322
|
+
local id="" in_file="-" home="" no_sync=0 force=0
|
|
323
|
+
while [ $# -gt 0 ]; do
|
|
324
|
+
case "$1" in
|
|
325
|
+
--in|--file) in_file="${2:?--in requires a path}"; shift 2 ;;
|
|
326
|
+
--id) id="${2:?--id requires acct-NN}"; shift 2 ;;
|
|
327
|
+
--home) home="${2:?--home requires mac|server}"; shift 2 ;;
|
|
328
|
+
--no-sync) no_sync=1; shift ;;
|
|
329
|
+
--force) force=1; shift ;;
|
|
330
|
+
--*) die "unknown option: $1" ;;
|
|
331
|
+
*) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
|
|
332
|
+
esac
|
|
333
|
+
done
|
|
334
|
+
if [ -n "$id" ]; then valid_acct_id "$id" || die "not a valid account id: $id"; fi
|
|
335
|
+
if [ "$in_file" = "-" ] && [ -t 0 ]; then
|
|
336
|
+
die "import-credential reads the blob on stdin — pipe it in, or pass --in PATH"
|
|
337
|
+
fi
|
|
338
|
+
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
|
|
339
|
+
# mktemp (0600, O_EXCL, unpredictable name), not "$$": the staged file holds live
|
|
340
|
+
# credential material, and a predictable path could be pre-planted as a symlink and
|
|
341
|
+
# written through. The cleanup trap is armed BEFORE anything is written, so an
|
|
342
|
+
# interrupt during staging cannot leave the blob behind either.
|
|
343
|
+
local stage rc=0
|
|
344
|
+
stage="$(mktemp "$ACC_ROOT/tmp/import-cred.XXXXXX")" \
|
|
345
|
+
|| die "could not create a staging file under $ACC_ROOT/tmp"
|
|
346
|
+
IMPORT_STAGE="$stage"
|
|
347
|
+
trap import_cred_cleanup EXIT
|
|
348
|
+
if [ "$in_file" = "-" ]; then
|
|
349
|
+
cat > "$stage" || rc=$?
|
|
350
|
+
else
|
|
351
|
+
[ -f "$in_file" ] || { import_cred_cleanup; trap - EXIT; die "credential blob not found: $in_file"; }
|
|
352
|
+
cat "$in_file" > "$stage" || rc=$?
|
|
353
|
+
fi
|
|
354
|
+
[ "$rc" = "0" ] || { import_cred_cleanup; trap - EXIT; die "could not read the credential blob"; }
|
|
355
|
+
chmod 600 "$stage" 2>/dev/null || true
|
|
356
|
+
|
|
357
|
+
local meta cclass bid bemail bhome badded btype us
|
|
358
|
+
# 0x1F, not a tab: bash collapses runs of IFS *whitespace*, so a blob with an empty
|
|
359
|
+
# `home` would shift added_at into it and corrupt the manifest entry. credential.py
|
|
360
|
+
# refuses any control character in the metadata, so the separator is unambiguous.
|
|
361
|
+
us="$(printf '\037')"
|
|
362
|
+
meta="$("$PYBIN" "$CREDENTIAL_PY" inspect "$MULTIACC_PROVIDER" "$stage")" || rc=$?
|
|
363
|
+
if [ "$rc" != "0" ]; then
|
|
364
|
+
import_cred_cleanup
|
|
365
|
+
trap - EXIT
|
|
366
|
+
return "$rc"
|
|
367
|
+
fi
|
|
368
|
+
IFS="$us" read -r cclass bid bemail bhome badded btype <<EOF
|
|
369
|
+
$meta
|
|
370
|
+
EOF
|
|
371
|
+
[ -n "$home" ] || home="$bhome"
|
|
372
|
+
[ -n "$home" ] || home="$(machine_kind)"
|
|
373
|
+
|
|
374
|
+
mutate_lock || { import_cred_cleanup; trap - EXIT; die "could not acquire the account lock (another op is stuck?) — try again"; }
|
|
375
|
+
fail_locked() { import_cred_cleanup; trap - EXIT; die "$@"; }
|
|
376
|
+
local owner target_id existing
|
|
377
|
+
owner="$(email_owner "$bemail")"
|
|
378
|
+
target_id="$id"
|
|
379
|
+
if [ -z "$target_id" ]; then
|
|
380
|
+
if [ -n "$owner" ]; then
|
|
381
|
+
target_id="$owner" # this email already lives here: refresh it in place
|
|
382
|
+
elif [ -n "$bid" ] && [ ! -e "$ACC_ROOT/$bid" ] && ! account_ids | grep -qx "$bid"; then
|
|
383
|
+
target_id="$bid" # the source's id is free here: keep ids aligned
|
|
384
|
+
else
|
|
385
|
+
target_id="$(next_id)"
|
|
386
|
+
fi
|
|
387
|
+
fi
|
|
388
|
+
if [ -n "$owner" ] && [ "$owner" != "$target_id" ] && [ "$force" != "1" ]; then
|
|
389
|
+
fail_locked "$bemail is already registered as $owner — import into it ('$PROVIDER_CLI import-credential $owner') or pass --force"
|
|
390
|
+
fi
|
|
391
|
+
existing="$(manifest_email_of "$target_id")"
|
|
392
|
+
if [ -n "$existing" ] && [ "$existing" != "$bemail" ] && [ "$force" != "1" ]; then
|
|
393
|
+
fail_locked "$target_id is $existing on this machine, not $bemail — pick another id or pass --force"
|
|
394
|
+
fi
|
|
395
|
+
# An adopted account dir is a symlink (usually to ~/.claude). Writing a credential
|
|
396
|
+
# through it would land outside the pool — the very boundary account ids are
|
|
397
|
+
# validated to protect — so this is refused outright, --force included: --force
|
|
398
|
+
# settles identity conflicts, it does not authorize writing outside the pool.
|
|
399
|
+
if [ -L "$ACC_ROOT/$target_id" ]; then
|
|
400
|
+
fail_locked "$target_id is adopted (a symlink to $(readlink "$ACC_ROOT/$target_id")) — a credential must never be written through it; remove the account first ('$PROVIDER_CLI remove $target_id'), or import under a different id"
|
|
401
|
+
fi
|
|
402
|
+
local d="$ACC_ROOT/$target_id"
|
|
403
|
+
seed_account_dir "$d"
|
|
404
|
+
"$PYBIN" "$CREDENTIAL_PY" install "$MULTIACC_PROVIDER" "$stage" "$d" >/dev/null \
|
|
405
|
+
|| fail_locked "could not install the credential into $d — nothing registered"
|
|
406
|
+
manifest_add_account "$target_id" "$bemail" "$home" "$badded" \
|
|
407
|
+
|| fail_locked "manifest update failed — $target_id was seeded but NOT registered (re-run import-credential)"
|
|
408
|
+
# Fresh material landed: whatever parked this account before no longer applies.
|
|
409
|
+
[ "$cclass" = "portable" ] && clear_auth_markers "$d"
|
|
410
|
+
import_cred_cleanup
|
|
411
|
+
trap - EXIT
|
|
412
|
+
log_to ops.log "import-credential $target_id $bemail class=$cclass type=$btype home=$home"
|
|
413
|
+
if [ "$cclass" = "portable" ]; then
|
|
414
|
+
echo "Imported $target_id ($bemail, home=$home) with a portable $btype credential."
|
|
415
|
+
else
|
|
416
|
+
echo "Registered $target_id ($bemail, home=$home) — identity only, no credential."
|
|
417
|
+
echo "It needs one interactive sign-in on a machine that will run it: $PROVIDER_CLI login $target_id"
|
|
418
|
+
fi
|
|
419
|
+
[ "$no_sync" = "1" ] || auto_sync
|
|
420
|
+
}
|
|
421
|
+
|
|
162
422
|
has_local_auth() { # $1 = acct dir
|
|
163
423
|
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
|
|
164
424
|
[ -s "$1/auth.json" ]
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Portable-credential transfer — the blob format `export-credential` writes and
|
|
2
|
+
`import-credential` installs, for both providers, in ONE place so the two ends can
|
|
3
|
+
never drift apart.
|
|
4
|
+
|
|
5
|
+
Run (all paths absolute; the blob never passes through argv):
|
|
6
|
+
python3 lib/credential.py export <acc-root> <provider> <acct-NN> [--identity-only]
|
|
7
|
+
[--out PATH]
|
|
8
|
+
writes the blob to stdout, or to PATH (created 0600 and renamed into place, so
|
|
9
|
+
a refusal leaves nothing behind and no other process ever sees it wider)
|
|
10
|
+
python3 lib/credential.py inspect <provider> <blob-file>
|
|
11
|
+
validates a blob and prints one 0x1F-separated record:
|
|
12
|
+
class, id, email, home, added_at, cred_type. The separator is deliberately NOT
|
|
13
|
+
whitespace: bash collapses runs of IFS whitespace, which would silently shift
|
|
14
|
+
an empty field (a blob with no `home`) into the next one.
|
|
15
|
+
python3 lib/credential.py install <provider> <blob-file> <dest-dir>
|
|
16
|
+
writes the credential material into <dest-dir> (0600, atomic); no-op for an
|
|
17
|
+
identity-only blob. Prints the file it wrote (or nothing).
|
|
18
|
+
|
|
19
|
+
Exit codes are the contract a daemon branches on:
|
|
20
|
+
0 done
|
|
21
|
+
2 usage / unknown account / malformed blob
|
|
22
|
+
3 the credential is MACHINE-LOCAL — copying it would break both machines
|
|
23
|
+
4 the account has no credential material here
|
|
24
|
+
5 credential material is present but unusable (corrupt / wrong shape)
|
|
25
|
+
|
|
26
|
+
WHAT IS PORTABLE
|
|
27
|
+
claude: `server.token` — a subscription setup-token (`sk-ant-oat…`, ~1y life,
|
|
28
|
+
inference-only). It authenticates from any machine and is exactly what the
|
|
29
|
+
existing sync already mirrors to the server, so distributing it is safe.
|
|
30
|
+
claude: `.credentials.json` — NOT portable. Its refresh token rotates on every
|
|
31
|
+
refresh; a second machine refreshing the same grant strands the first.
|
|
32
|
+
codex: `auth.json` — NOT portable, same rotating-refresh-token reason. Codex has
|
|
33
|
+
no portable credential type at all: each machine signs in once with the
|
|
34
|
+
device-code flow (`codex-accounts login <id>`, works over SSH).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import socket
|
|
41
|
+
import sys
|
|
42
|
+
import tempfile
|
|
43
|
+
import time
|
|
44
|
+
|
|
45
|
+
FORMAT = 'claude-multiacc/credential'
|
|
46
|
+
VERSION = 1
|
|
47
|
+
VALID_ID = re.compile(r'acct-\d{2}')
|
|
48
|
+
# Metadata crosses into a shell (the CLIs read it as a record) and into the manifest.
|
|
49
|
+
# A control character there could truncate or shift a record, so it is refused at the
|
|
50
|
+
# boundary rather than escaped downstream.
|
|
51
|
+
CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]')
|
|
52
|
+
FIELD_SEP = '\x1f'
|
|
53
|
+
# A subscription setup-token, and nothing else — API keys are refused pool-wide
|
|
54
|
+
# (bin/claude-accounts: valid_subscription_token). Kept in sync with that check.
|
|
55
|
+
SETUP_TOKEN = re.compile(r'sk-ant-oat\d{2}-[A-Za-z0-9_-]{40,}\Z')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def die(msg, code=2):
|
|
59
|
+
sys.stderr.write(msg.rstrip('\n') + '\n')
|
|
60
|
+
sys.exit(code)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load(path):
|
|
64
|
+
try:
|
|
65
|
+
with open(path) as f:
|
|
66
|
+
return json.load(f)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
die(f'not readable JSON: {path} ({str(e)[:120]})')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _clean(value, what):
|
|
72
|
+
"""Metadata that is safe to hand to a shell and to write into the manifest."""
|
|
73
|
+
text = '' if value is None else str(value)
|
|
74
|
+
if CONTROL_CHARS.search(text):
|
|
75
|
+
die(f'{what} contains a control character — refusing to transfer it')
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _write_secure(path, data, nofollow_dir=False):
|
|
80
|
+
"""Create <path> with the content, never following a symlink and never widening
|
|
81
|
+
permissions: a unique 0600 temp file in the same directory (mkstemp is O_EXCL),
|
|
82
|
+
then an atomic rename. os.replace does not follow a symlink at the destination,
|
|
83
|
+
so a planted link is replaced rather than written through.
|
|
84
|
+
|
|
85
|
+
nofollow_dir additionally pins the DIRECTORY: it is opened O_NOFOLLOW|O_DIRECTORY
|
|
86
|
+
once and every later step runs relative to that descriptor, so swapping the
|
|
87
|
+
directory for a symlink after the check cannot redirect the write. Used for
|
|
88
|
+
credential material landing in a pool; a caller-named --out path does not need it
|
|
89
|
+
(the caller chose the path) and may live on a filesystem without dir_fd support."""
|
|
90
|
+
d = os.path.dirname(os.path.abspath(path)) or '.'
|
|
91
|
+
base = os.path.basename(path)
|
|
92
|
+
if not os.path.isdir(d):
|
|
93
|
+
die(f'destination directory does not exist: {d}')
|
|
94
|
+
dir_fd = None
|
|
95
|
+
if nofollow_dir:
|
|
96
|
+
# Fail CLOSED: if the platform cannot pin the directory, refuse rather than
|
|
97
|
+
# quietly downgrading to the path-based write this argument exists to avoid.
|
|
98
|
+
# NB: the rename primitive is os.rename, not os.replace — on POSIX both are
|
|
99
|
+
# renameat(2) (rename already replaces atomically), but CPython registers only
|
|
100
|
+
# os.rename in supports_dir_fd on macOS, and demanding os.replace would make
|
|
101
|
+
# this fail closed on the very platform the pool runs on.
|
|
102
|
+
missing = [n for n in ('O_DIRECTORY', 'O_NOFOLLOW') if not hasattr(os, n)]
|
|
103
|
+
if missing or os.open not in os.supports_dir_fd \
|
|
104
|
+
or os.rename not in os.supports_dir_fd \
|
|
105
|
+
or os.unlink not in os.supports_dir_fd:
|
|
106
|
+
die(f'this platform cannot write a credential safely (no directory-fd '
|
|
107
|
+
f'support{": missing " + ", ".join(missing) if missing else ""}) — '
|
|
108
|
+
f'refusing to install it', 5)
|
|
109
|
+
try:
|
|
110
|
+
dir_fd = os.open(d, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
111
|
+
except OSError as e:
|
|
112
|
+
die(f'could not open {d} safely ({str(e)[:120]})')
|
|
113
|
+
tmp = None
|
|
114
|
+
try:
|
|
115
|
+
if dir_fd is not None:
|
|
116
|
+
tmp = f'.cred.{os.getpid()}.{os.urandom(6).hex()}'
|
|
117
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
118
|
+
0o600, dir_fd=dir_fd)
|
|
119
|
+
os.fchmod(fd, 0o600)
|
|
120
|
+
with os.fdopen(fd, 'w') as f:
|
|
121
|
+
f.write(data)
|
|
122
|
+
os.rename(tmp, base, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
|
123
|
+
tmp = None
|
|
124
|
+
else:
|
|
125
|
+
fd, tmp = tempfile.mkstemp(prefix='.cred.', dir=d)
|
|
126
|
+
os.fchmod(fd, 0o600)
|
|
127
|
+
with os.fdopen(fd, 'w') as f:
|
|
128
|
+
f.write(data)
|
|
129
|
+
os.replace(tmp, path)
|
|
130
|
+
tmp = None
|
|
131
|
+
except Exception as e:
|
|
132
|
+
# Any exception, not just OSError: an interrupt or an unexpected TypeError
|
|
133
|
+
# must not leave a file holding live credential material behind.
|
|
134
|
+
die(f'could not write {path} ({str(e)[:120]})')
|
|
135
|
+
finally:
|
|
136
|
+
if tmp:
|
|
137
|
+
try:
|
|
138
|
+
if dir_fd is not None:
|
|
139
|
+
os.unlink(tmp, dir_fd=dir_fd)
|
|
140
|
+
else:
|
|
141
|
+
os.unlink(tmp)
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
if dir_fd is not None:
|
|
145
|
+
os.close(dir_fd)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _manifest_account(root, aid):
|
|
149
|
+
doc = _load(os.path.join(root, 'accounts.json'))
|
|
150
|
+
if not isinstance(doc, dict):
|
|
151
|
+
die(f'manifest is not a JSON object: {os.path.join(root, "accounts.json")}')
|
|
152
|
+
for a in doc.get('accounts', []):
|
|
153
|
+
if isinstance(a, dict) and str(a.get('id', '')) == aid:
|
|
154
|
+
return a
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_export(argv):
|
|
159
|
+
if len(argv) < 3:
|
|
160
|
+
die('usage: credential.py export <acc-root> <provider> <acct-NN> '
|
|
161
|
+
'[--identity-only] [--out PATH]')
|
|
162
|
+
root, provider, aid = argv[0], argv[1], argv[2]
|
|
163
|
+
rest = list(argv[3:])
|
|
164
|
+
identity_only = '--identity-only' in rest
|
|
165
|
+
out_path = None
|
|
166
|
+
if '--out' in rest:
|
|
167
|
+
i = rest.index('--out')
|
|
168
|
+
if i + 1 >= len(rest):
|
|
169
|
+
die('--out requires a path')
|
|
170
|
+
out_path = rest[i + 1]
|
|
171
|
+
|
|
172
|
+
def emit(blob):
|
|
173
|
+
text = json.dumps(blob, indent=2) + '\n'
|
|
174
|
+
if out_path:
|
|
175
|
+
_write_secure(out_path, text)
|
|
176
|
+
else:
|
|
177
|
+
sys.stdout.write(text)
|
|
178
|
+
|
|
179
|
+
if not VALID_ID.fullmatch(aid):
|
|
180
|
+
die(f'not a valid account id: {aid}')
|
|
181
|
+
acct = _manifest_account(root, aid)
|
|
182
|
+
if acct is None:
|
|
183
|
+
die(f'{aid} is not registered in {os.path.join(root, "accounts.json")}')
|
|
184
|
+
d = os.path.join(root, aid)
|
|
185
|
+
blob = {
|
|
186
|
+
'format': FORMAT,
|
|
187
|
+
'version': VERSION,
|
|
188
|
+
'provider': provider,
|
|
189
|
+
'class': 'identity',
|
|
190
|
+
'account': {
|
|
191
|
+
'id': aid,
|
|
192
|
+
'email': _clean(acct.get('email', ''), 'the account email'),
|
|
193
|
+
'home': _clean(acct.get('home', ''), 'the account home'),
|
|
194
|
+
'added_at': _clean(acct.get('added_at', ''), 'added_at') or None,
|
|
195
|
+
},
|
|
196
|
+
'exported_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
|
197
|
+
'exported_from': {'host': socket.gethostname(),
|
|
198
|
+
'machine': 'mac' if sys.platform == 'darwin' else 'linux',
|
|
199
|
+
'pool_root': root},
|
|
200
|
+
}
|
|
201
|
+
if identity_only:
|
|
202
|
+
emit(blob)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
if provider == 'codex':
|
|
206
|
+
has_auth = os.path.isfile(os.path.join(d, 'auth.json')) \
|
|
207
|
+
and os.path.getsize(os.path.join(d, 'auth.json')) > 0
|
|
208
|
+
if not has_auth:
|
|
209
|
+
die(f'{aid} has no codex credential in {d}\n'
|
|
210
|
+
f' fix: sign in on the machine that should run it — '
|
|
211
|
+
f'codex-accounts login {aid}', 4)
|
|
212
|
+
die(f'{aid} is a MACHINE-LOCAL codex credential (auth.json) and cannot be exported.\n'
|
|
213
|
+
f' Why: auth.json carries a refresh token that ROTATES on every refresh; a\n'
|
|
214
|
+
f' second machine using the same grant invalidates the first one.\n'
|
|
215
|
+
f' Codex has no portable credential type — sign in once per machine:\n'
|
|
216
|
+
f' codex-accounts login {aid} (device-code flow; works over SSH)\n'
|
|
217
|
+
f' To move only the registry entry (id/email/home), add --identity-only.', 3)
|
|
218
|
+
|
|
219
|
+
tpath = os.path.join(d, 'server.token')
|
|
220
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
221
|
+
has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
|
|
222
|
+
if not has_token:
|
|
223
|
+
if os.path.isfile(cpath) and os.path.getsize(cpath) > 0:
|
|
224
|
+
die(f'{aid} only has a MACHINE-LOCAL credential (.credentials.json) and cannot '
|
|
225
|
+
f'be exported.\n'
|
|
226
|
+
f' Why: the OAuth grant\'s refresh token ROTATES on every refresh; a second\n'
|
|
227
|
+
f' machine refreshing the same grant strands the first one.\n'
|
|
228
|
+
f' Make it portable (one interactive sign-in on THIS machine):\n'
|
|
229
|
+
f' claude-accounts mint {aid} # mints a portable setup-token\n'
|
|
230
|
+
f' then re-run export-credential. Or sign in on the other machine:\n'
|
|
231
|
+
f' claude-accounts login {aid}\n'
|
|
232
|
+
f' To move only the registry entry (id/email/home), add --identity-only.', 3)
|
|
233
|
+
die(f'{aid} has no credential material in {d}\n'
|
|
234
|
+
f' fix: claude-accounts login {aid} (or --token for a portable one)', 4)
|
|
235
|
+
try:
|
|
236
|
+
with open(tpath) as f:
|
|
237
|
+
token = f.read().strip()
|
|
238
|
+
except OSError as e:
|
|
239
|
+
die(f'{aid}: server.token unreadable ({str(e)[:120]})', 5)
|
|
240
|
+
if not SETUP_TOKEN.fullmatch(token):
|
|
241
|
+
die(f'{aid}: server.token is not a subscription setup-token (sk-ant-oat…) — '
|
|
242
|
+
f'refusing to export unusable material.\n'
|
|
243
|
+
f' fix: claude-accounts mint {aid}', 5)
|
|
244
|
+
blob['class'] = 'portable'
|
|
245
|
+
blob['credential'] = {'type': 'setup-token', 'value': token}
|
|
246
|
+
emit(blob)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _validate(provider, path):
|
|
250
|
+
blob = _load(path)
|
|
251
|
+
if not isinstance(blob, dict):
|
|
252
|
+
die('blob is not a JSON object')
|
|
253
|
+
if blob.get('format') != FORMAT:
|
|
254
|
+
die(f'not a claude-multiacc credential blob (format={blob.get("format")!r})')
|
|
255
|
+
try:
|
|
256
|
+
version = int(blob.get('version'))
|
|
257
|
+
except (TypeError, ValueError):
|
|
258
|
+
die(f'blob version is not a number: {blob.get("version")!r}')
|
|
259
|
+
if version > VERSION:
|
|
260
|
+
die(f'blob is version {version}; this claude-multiacc understands up to '
|
|
261
|
+
f'{VERSION} — update the addon (claude-accounts self-update)')
|
|
262
|
+
if blob.get('provider') != provider:
|
|
263
|
+
die(f'blob is for provider {blob.get("provider")!r}, not {provider!r} — '
|
|
264
|
+
f'use {blob.get("provider")}-accounts import-credential')
|
|
265
|
+
cclass = blob.get('class')
|
|
266
|
+
if cclass not in ('portable', 'identity'):
|
|
267
|
+
die(f'unknown credential class {cclass!r} (expected portable or identity)')
|
|
268
|
+
acct = blob.get('account')
|
|
269
|
+
if not isinstance(acct, dict):
|
|
270
|
+
die("blob has no 'account' object")
|
|
271
|
+
aid = _clean(acct.get('id', ''), 'blob account id')
|
|
272
|
+
if aid and not VALID_ID.fullmatch(aid):
|
|
273
|
+
die(f'blob account id is malformed: {aid!r}')
|
|
274
|
+
email = _clean(acct.get('email', ''), 'blob account email').strip()
|
|
275
|
+
if not email:
|
|
276
|
+
die('blob account has no email')
|
|
277
|
+
cred_type = ''
|
|
278
|
+
if cclass == 'portable':
|
|
279
|
+
cred = blob.get('credential')
|
|
280
|
+
if not isinstance(cred, dict):
|
|
281
|
+
die("blob is class 'portable' but has no 'credential' object")
|
|
282
|
+
cred_type = _clean(cred.get('type', ''), 'credential type')
|
|
283
|
+
value = cred.get('value')
|
|
284
|
+
if provider == 'codex':
|
|
285
|
+
die('codex credentials are machine-local — a portable codex blob cannot be '
|
|
286
|
+
'installed. Sign in on this machine instead: codex-accounts login <id>', 3)
|
|
287
|
+
if cred_type != 'setup-token':
|
|
288
|
+
die(f'unsupported credential type {cred_type!r} for provider {provider} '
|
|
289
|
+
f'(expected setup-token)', 3)
|
|
290
|
+
if not isinstance(value, str) or not SETUP_TOKEN.fullmatch(value.strip()):
|
|
291
|
+
die('credential value is not a subscription setup-token (sk-ant-oat…) — '
|
|
292
|
+
'API keys and truncated tokens are refused', 5)
|
|
293
|
+
return (blob, cclass, aid, email,
|
|
294
|
+
_clean(acct.get('home', ''), 'blob account home'),
|
|
295
|
+
_clean(acct.get('added_at', ''), 'blob added_at'), cred_type)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_inspect(argv):
|
|
299
|
+
if len(argv) < 2:
|
|
300
|
+
die('usage: credential.py inspect <provider> <blob-file>')
|
|
301
|
+
_, cclass, aid, email, home, added_at, cred_type = _validate(argv[0], argv[1])
|
|
302
|
+
# 0x1F-separated: a NON-whitespace separator, so an empty field (a blob with no
|
|
303
|
+
# `home`) stays an empty field when bash splits the record instead of collapsing
|
|
304
|
+
# into the next one. _clean has already refused any control character in the data.
|
|
305
|
+
print(FIELD_SEP.join((cclass, aid, email, home, added_at, cred_type)))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def cmd_install(argv):
|
|
309
|
+
if len(argv) < 3:
|
|
310
|
+
die('usage: credential.py install <provider> <blob-file> <dest-dir>')
|
|
311
|
+
provider, path, dest = argv[0], argv[1], argv[2]
|
|
312
|
+
blob, cclass, _aid, _email, _home, _added, _type = _validate(provider, path)
|
|
313
|
+
if cclass == 'identity':
|
|
314
|
+
return # registry-only: nothing to write
|
|
315
|
+
if os.path.islink(dest):
|
|
316
|
+
die(f'destination is a symlink ({dest} -> {os.readlink(dest)}) — refusing to '
|
|
317
|
+
f'write a credential through it', 2)
|
|
318
|
+
if not os.path.isdir(dest):
|
|
319
|
+
die(f'destination is not a directory: {dest}')
|
|
320
|
+
out = os.path.join(dest, 'server.token')
|
|
321
|
+
_write_secure(out, blob['credential']['value'].strip(), nofollow_dir=True)
|
|
322
|
+
print(out)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == '__main__':
|
|
326
|
+
if len(sys.argv) < 2:
|
|
327
|
+
die(__doc__.strip())
|
|
328
|
+
verb, rest = sys.argv[1], sys.argv[2:]
|
|
329
|
+
if verb == 'export':
|
|
330
|
+
cmd_export(rest)
|
|
331
|
+
elif verb == 'inspect':
|
|
332
|
+
cmd_inspect(rest)
|
|
333
|
+
elif verb == 'install':
|
|
334
|
+
cmd_install(rest)
|
|
335
|
+
else:
|
|
336
|
+
die(f'unknown verb: {verb}')
|