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/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}')