claude-multiacc 2.0.16 → 2.0.18
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/bin/claude-accounts +28 -62
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/ceremony.py +193 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +96 -0
package/bin/claude-accounts
CHANGED
|
@@ -943,69 +943,18 @@ commit_ceremony_token() {
|
|
|
943
943
|
CEREMONY_LAST_WORDS=""
|
|
944
944
|
CEREMONY_TRANSCRIPT=""
|
|
945
945
|
ceremony_debrief() { # $1 = capture file, $2 = redacted transcript to write; prints the last words
|
|
946
|
-
"$PYBIN"
|
|
947
|
-
import os, re, sys
|
|
948
|
-
raw = open(sys.argv[1], 'rb').read().decode('utf-8', 'ignore')
|
|
949
|
-
txt = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', raw) # OSC (hyperlink payloads)
|
|
950
|
-
txt = re.sub(r'\x1b\[[0-9;?<>=]*[A-Za-z]', '', txt) # CSI, private modes too ([>4m, [<u)
|
|
951
|
-
txt = re.sub(r'\x1b[()][A-Z0-9]|\x1b[78=>]|[\x0e\x0f]', '', txt) # charset, save/restore cursor, SI/SO
|
|
952
|
-
txt = txt.replace('\r', '\n')
|
|
953
|
-
lines = []
|
|
954
|
-
flat = re.sub(r'\s+', '', txt)
|
|
955
|
-
api_key = bool(re.search(r'sk-ant-api\d{2}-', flat)) and not re.search(r'sk-ant-oat\d{2}-', flat)
|
|
956
|
-
txt = re.sub(r'sk-ant-[A-Za-z0-9]+-[A-Za-z0-9_-]+', 'sk-ant-***', txt)
|
|
957
|
-
for ln in txt.splitlines():
|
|
958
|
-
ln = ln.strip()
|
|
959
|
-
if not ln or re.fullmatch(r'[\W_]+', ln): # spinner frames, logo art
|
|
960
|
-
continue
|
|
961
|
-
if 'https://' in ln or re.search(r'Paste\s*code\s*here', ln): # the sign-in link, the paste prompt
|
|
962
|
-
continue
|
|
963
|
-
if lines and lines[-1] == ln:
|
|
964
|
-
continue
|
|
965
|
-
lines.append(ln)
|
|
966
|
-
if api_key:
|
|
967
|
-
# Said LAST, so it is what the operator reads: the client minted an API key, not a
|
|
968
|
-
# subscription token — the browser session was signed into a Console org.
|
|
969
|
-
lines.append('the client minted an API KEY (sk-ant-api…), not a subscription setup-token: '
|
|
970
|
-
'the browser session belongs to a Console / API-billing organization — sign in '
|
|
971
|
-
'as the subscription account and try again')
|
|
972
|
-
try:
|
|
973
|
-
fd = os.open(sys.argv[2], os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
974
|
-
with os.fdopen(fd, 'w') as f:
|
|
975
|
-
f.write('\n'.join(lines) + '\n')
|
|
976
|
-
except OSError:
|
|
977
|
-
pass
|
|
978
|
-
print(' | '.join(lines[-3:])[:400])
|
|
979
|
-
PYEOF
|
|
946
|
+
"$PYBIN" "$LIB_DIR/ceremony.py" debrief "$1" "$2" 2>/dev/null
|
|
980
947
|
}
|
|
981
948
|
|
|
982
|
-
# The token, out of the transcript.
|
|
983
|
-
#
|
|
984
|
-
#
|
|
985
|
-
#
|
|
986
|
-
#
|
|
987
|
-
#
|
|
988
|
-
#
|
|
989
|
-
# real inference in commit_ceremony_token then proves whatever came out.
|
|
949
|
+
# The token, out of the transcript. `claude setup-token` is a TUI: its renderer places
|
|
950
|
+
# words with absolute cursor moves, so the bytes are terminal OPERATIONS and the token
|
|
951
|
+
# exists only in the RENDERED result. Stripping escapes reassembles it wrongly — a
|
|
952
|
+
# `sk-ant-\x1b[10Gat01-…` stream loses the `o` and three mints failed as "no token
|
|
953
|
+
# captured" seconds after the client said the token was created (2026-08-29). lib/
|
|
954
|
+
# ceremony.py replays the transcript onto a virtual screen and reads what the operator
|
|
955
|
+
# saw; the real inference in commit_ceremony_token then proves whatever came out.
|
|
990
956
|
ceremony_extract() { # $1 = capture file -> the setup-token, or nothing
|
|
991
|
-
"$PYBIN"
|
|
992
|
-
import re, sys
|
|
993
|
-
raw = open(sys.argv[1], 'rb').read().decode('utf-8', 'ignore')
|
|
994
|
-
txt = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', raw) # OSC (hyperlink payloads)
|
|
995
|
-
txt = re.sub(r'\x1b\[[0-9;?<>=]*[A-Za-z]', '', txt) # CSI, private modes too ([>4m, [<u)
|
|
996
|
-
txt = re.sub(r'\x1b[()][A-Z0-9]|\x1b[78=>]|[\x0e\x0f]', '', txt) # charset, save/restore cursor, SI/SO
|
|
997
|
-
pat = re.compile(r'sk-ant-oat\d{2}-[A-Za-z0-9_-]{40,}')
|
|
998
|
-
cands = []
|
|
999
|
-
for m in re.finditer(r'Your\s*OAuth\s*token[^\n]{0,80}?:', txt):
|
|
1000
|
-
block = txt[m.end(): m.end() + 4000]
|
|
1001
|
-
stop = re.search(r'Store\s*this\s*token', block)
|
|
1002
|
-
if stop:
|
|
1003
|
-
block = block[: stop.start()]
|
|
1004
|
-
cands += pat.findall(re.sub(r'\s+', '', block))
|
|
1005
|
-
cands += pat.findall(txt)
|
|
1006
|
-
cands += pat.findall(re.sub(r'[ \t]+', '', txt))
|
|
1007
|
-
print(max(cands, key=len) if cands else '')
|
|
1008
|
-
PYEOF
|
|
957
|
+
"$PYBIN" "$LIB_DIR/ceremony.py" extract "$1" 2>/dev/null
|
|
1009
958
|
}
|
|
1010
959
|
|
|
1011
960
|
CEREMONY_TOKEN=""
|
|
@@ -2165,6 +2114,10 @@ for acct in manifest.get('accounts', []):
|
|
|
2165
2114
|
'percent': pct,
|
|
2166
2115
|
'resets_at': resets,
|
|
2167
2116
|
'resets_epoch': int(parse_iso(resets) or now + 3600),
|
|
2117
|
+
# Present only for a bucket that belongs to ONE model: such a
|
|
2118
|
+
# bucket parks that model, never the account (see the marker
|
|
2119
|
+
# choice below and the shim's scoped_blocks_run).
|
|
2120
|
+
'model': model,
|
|
2168
2121
|
})
|
|
2169
2122
|
if not buckets:
|
|
2170
2123
|
# Fallback for a payload with no limits[] array: scan EVERY top-level object
|
|
@@ -2226,8 +2179,21 @@ for acct in manifest.get('accounts', []):
|
|
|
2226
2179
|
offenders = [b for b in buckets if b['percent'] >= threshold]
|
|
2227
2180
|
mpath = os.path.join(d, '.limited')
|
|
2228
2181
|
if offenders:
|
|
2229
|
-
|
|
2230
|
-
|
|
2182
|
+
# ONE bucket, described consistently: the marker's epoch is the reset of
|
|
2183
|
+
# the bucket its detail line names. It used to pair the highest PERCENT
|
|
2184
|
+
# with the LATEST reset over every offender, and the two came from
|
|
2185
|
+
# different buckets: a 100% session bucket resetting in a minute was
|
|
2186
|
+
# written with a Fable weekly bucket's epoch six days out, and everything
|
|
2187
|
+
# that trusts the marker (this shim, app-robot's fleet verdict on every
|
|
2188
|
+
# Mac) parked the whole account for six days over a five-hour window
|
|
2189
|
+
# (2026-08-29, acct-05). An account-level bucket wins over a model-scoped
|
|
2190
|
+
# one — the latter parks a model, not the account (scoped_blocks_run) —
|
|
2191
|
+
# and among those, the longest-lived exclusion, since any bucket over the
|
|
2192
|
+
# threshold stays there until its own reset.
|
|
2193
|
+
account_level = [b for b in offenders if not b.get('model')]
|
|
2194
|
+
worst = max(account_level or offenders,
|
|
2195
|
+
key=lambda b: (int(b['resets_epoch']), b['percent']))
|
|
2196
|
+
reset_epoch = int(worst['resets_epoch'])
|
|
2231
2197
|
# Atomic: a concurrent shim must never read a half-written marker.
|
|
2232
2198
|
with open(mpath + '.tmp', 'w') as f:
|
|
2233
2199
|
f.write(f'{reset_epoch}\n')
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/ceremony.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Read what the sign-in ceremony actually PUT ON THE SCREEN.
|
|
2
|
+
|
|
3
|
+
`claude setup-token` is a TUI. Its renderer places words with absolute cursor
|
|
4
|
+
moves rather than spaces, so the bytes it emits are terminal OPERATIONS, not
|
|
5
|
+
text: on 2026-08-29 a perfectly good token arrived as
|
|
6
|
+
|
|
7
|
+
sk-ant-\x1b[10Gat01-w_C6…
|
|
8
|
+
|
|
9
|
+
Stripping the escapes yields `sk-ant-at01-…` — the sequence's own final byte ate
|
|
10
|
+
the token's `o` — and no amount of whitespace-joining puts it back. Three mints
|
|
11
|
+
failed as "no token captured" seconds after the client printed
|
|
12
|
+
"✓ Long-lived authentication token created successfully!".
|
|
13
|
+
|
|
14
|
+
So the transcript is REPLAYED onto a virtual screen and the result is read, which
|
|
15
|
+
is by definition what the operator saw. Only the handful of sequences this TUI
|
|
16
|
+
uses are honoured; everything else is skipped, and any byte that would move the
|
|
17
|
+
cursor off-screen is clamped rather than trusted.
|
|
18
|
+
|
|
19
|
+
Run directly: ceremony.py extract <capture> -> the setup-token, or nothing
|
|
20
|
+
ceremony.py debrief <capture> <out> -> last words; writes a
|
|
21
|
+
redacted transcript (0600)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
TOKEN = re.compile(r'sk-ant-oat\d{2}-[A-Za-z0-9_-]{40,}')
|
|
29
|
+
ANY_CRED = re.compile(r'sk-ant-[A-Za-z0-9]+-[A-Za-z0-9_-]+')
|
|
30
|
+
API_KEY = re.compile(r'sk-ant-api\d{2}-')
|
|
31
|
+
_CSI = re.compile(r'\x1b\[([0-9;?<>=]*)([@-~])')
|
|
32
|
+
_CHARSET = re.compile(r'\x1b[()][A-Z0-9]|\x1b[78=>]')
|
|
33
|
+
# A screen this TUI could never legitimately need; a corrupt stream must not make
|
|
34
|
+
# the renderer allocate without bound.
|
|
35
|
+
MAX_ROWS = 2000
|
|
36
|
+
MAX_COLS = 4000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render(raw):
|
|
40
|
+
"""The transcript's final screen, as a list of lines."""
|
|
41
|
+
rows, cur, col = [[]], 0, 0
|
|
42
|
+
|
|
43
|
+
def put(ch):
|
|
44
|
+
nonlocal col
|
|
45
|
+
if cur >= MAX_ROWS or col >= MAX_COLS:
|
|
46
|
+
return
|
|
47
|
+
while len(rows) <= cur:
|
|
48
|
+
rows.append([])
|
|
49
|
+
row = rows[cur]
|
|
50
|
+
while len(row) <= col:
|
|
51
|
+
row.append(' ')
|
|
52
|
+
row[col] = ch
|
|
53
|
+
col += 1
|
|
54
|
+
|
|
55
|
+
i, n = 0, len(raw)
|
|
56
|
+
while i < n:
|
|
57
|
+
ch = raw[i]
|
|
58
|
+
if ch == '\x1b':
|
|
59
|
+
if raw.startswith('\x1b]', i): # OSC … BEL | ST
|
|
60
|
+
bel, st = raw.find('\x07', i), raw.find('\x1b\\', i)
|
|
61
|
+
ends = [x for x in ((bel, 1), (st, 2)) if x[0] != -1]
|
|
62
|
+
i = min(ends)[0] + min(ends)[1] if ends else n
|
|
63
|
+
continue
|
|
64
|
+
m = _CSI.match(raw, i)
|
|
65
|
+
if m:
|
|
66
|
+
params, fin = m.group(1), m.group(2)
|
|
67
|
+
nums = [int(p) for p in params.split(';') if p.isdigit()]
|
|
68
|
+
a = nums[0] if nums else 1
|
|
69
|
+
if fin == 'G':
|
|
70
|
+
col = min(max(0, a - 1), MAX_COLS)
|
|
71
|
+
elif fin == 'C':
|
|
72
|
+
col = min(col + a, MAX_COLS)
|
|
73
|
+
elif fin == 'D':
|
|
74
|
+
col = max(0, col - a)
|
|
75
|
+
elif fin == 'A':
|
|
76
|
+
cur = max(0, cur - a)
|
|
77
|
+
elif fin == 'B':
|
|
78
|
+
cur = min(cur + a, MAX_ROWS)
|
|
79
|
+
elif fin == 'H':
|
|
80
|
+
cur = min(max(0, a - 1), MAX_ROWS)
|
|
81
|
+
col = min(max(0, (nums[1] if len(nums) > 1 else 1) - 1), MAX_COLS)
|
|
82
|
+
elif fin == 'K':
|
|
83
|
+
while len(rows) <= cur:
|
|
84
|
+
rows.append([])
|
|
85
|
+
if params.startswith('2'):
|
|
86
|
+
rows[cur] = []
|
|
87
|
+
elif not params or params == '0':
|
|
88
|
+
rows[cur] = rows[cur][:col]
|
|
89
|
+
elif fin == 'J' and params.startswith('2'):
|
|
90
|
+
rows, cur, col = [[]], 0, 0
|
|
91
|
+
i = m.end()
|
|
92
|
+
continue
|
|
93
|
+
m = _CHARSET.match(raw, i)
|
|
94
|
+
i = m.end() if m else i + 1
|
|
95
|
+
continue
|
|
96
|
+
if ch == '\r':
|
|
97
|
+
col = 0
|
|
98
|
+
elif ch == '\n':
|
|
99
|
+
cur = min(cur + 1, MAX_ROWS)
|
|
100
|
+
col = 0
|
|
101
|
+
elif ord(ch) >= 32:
|
|
102
|
+
put(ch)
|
|
103
|
+
i += 1
|
|
104
|
+
return [''.join(r).rstrip() for r in rows]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# The block the client prints the token inside. Joining WITHIN it is what survives a
|
|
108
|
+
# terminal narrow enough to wrap the token across rows, without gluing the token to
|
|
109
|
+
# whatever the next paragraph happens to say.
|
|
110
|
+
_BLOCK_START = re.compile(r'Your\s*OAuth\s*token[^\n]{0,80}?:')
|
|
111
|
+
_BLOCK_END = re.compile(r'Store\s*this\s*token')
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_token(raw):
|
|
115
|
+
"""The setup-token the ceremony printed, or ''.
|
|
116
|
+
|
|
117
|
+
Every reading is a candidate and the LONGEST wins, because the two shapes fail
|
|
118
|
+
each other's method: a token painted out of order is whole only on the rendered
|
|
119
|
+
line, while one the terminal wrapped is whole only across rows. A per-line match
|
|
120
|
+
on a wrapped token returns its first 79 characters — exactly the truncation this
|
|
121
|
+
whole path exists to prevent — so the joined reading has to compete with it.
|
|
122
|
+
"""
|
|
123
|
+
lines = render(raw)
|
|
124
|
+
text = '\n'.join(lines)
|
|
125
|
+
cands = [m.group(0) for ln in lines for m in [TOKEN.search(ln)] if m]
|
|
126
|
+
for m in _BLOCK_START.finditer(text):
|
|
127
|
+
block = text[m.end(): m.end() + 4000]
|
|
128
|
+
stop = _BLOCK_END.search(block)
|
|
129
|
+
if stop:
|
|
130
|
+
block = block[: stop.start()]
|
|
131
|
+
cands += TOKEN.findall(re.sub(r'\s+', '', block))
|
|
132
|
+
if not cands: # a TUI this renderer misreads
|
|
133
|
+
flat = _CSI.sub('', _CHARSET.sub('', raw))
|
|
134
|
+
cands = TOKEN.findall(flat) + TOKEN.findall(re.sub(r'\s+', '', flat))
|
|
135
|
+
return max(cands, key=len) if cands else ''
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def debrief(raw):
|
|
139
|
+
"""(readable lines, api_key_minted). The screen the operator was left looking
|
|
140
|
+
at, with every credential-shaped string masked."""
|
|
141
|
+
api_key = False
|
|
142
|
+
for ln in render(raw):
|
|
143
|
+
if API_KEY.search(ln):
|
|
144
|
+
api_key = True
|
|
145
|
+
if extract_token(raw):
|
|
146
|
+
api_key = False
|
|
147
|
+
lines = []
|
|
148
|
+
for ln in render(raw):
|
|
149
|
+
ln = ANY_CRED.sub('sk-ant-***', ln).strip()
|
|
150
|
+
if not ln or re.fullmatch(r'[\W_]+', ln): # spinner frames, logo art
|
|
151
|
+
continue
|
|
152
|
+
if 'https://' in ln or re.search(r'Paste\s*code\s*here', ln):
|
|
153
|
+
continue
|
|
154
|
+
if lines and lines[-1] == ln:
|
|
155
|
+
continue
|
|
156
|
+
lines.append(ln)
|
|
157
|
+
if api_key:
|
|
158
|
+
# Said LAST, so it is what the operator reads.
|
|
159
|
+
lines.append('the client minted an API KEY (sk-ant-api…), not a subscription '
|
|
160
|
+
'setup-token: the browser session belongs to a Console / '
|
|
161
|
+
'API-billing organization — sign in as the subscription account '
|
|
162
|
+
'and try again')
|
|
163
|
+
return lines, api_key
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _read(path):
|
|
167
|
+
with open(path, 'rb') as f:
|
|
168
|
+
return f.read().decode('utf-8', 'ignore')
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv):
|
|
172
|
+
if len(argv) < 2:
|
|
173
|
+
return 2
|
|
174
|
+
verb, path = argv[0], argv[1]
|
|
175
|
+
if verb == 'extract':
|
|
176
|
+
sys.stdout.write(extract_token(_read(path)))
|
|
177
|
+
return 0
|
|
178
|
+
if verb == 'debrief':
|
|
179
|
+
lines, _ = debrief(_read(path))
|
|
180
|
+
if len(argv) > 2:
|
|
181
|
+
try:
|
|
182
|
+
fd = os.open(argv[2], os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
183
|
+
with os.fdopen(fd, 'w') as f:
|
|
184
|
+
f.write('\n'.join(lines) + '\n')
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
print(' | '.join(lines[-3:])[:400])
|
|
188
|
+
return 0
|
|
189
|
+
return 2
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == '__main__':
|
|
193
|
+
sys.exit(main(sys.argv[1:]))
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -94,6 +94,16 @@ if [ "${1:-}" = "setup-token" ]; then
|
|
|
94
94
|
printf 'Store\033[7Gthis\033[12Gtoken\033[18Gsecurely.\n'
|
|
95
95
|
exit 0
|
|
96
96
|
fi
|
|
97
|
+
if [ -n "${FAKE_TOKEN_CURSOR:-}" ]; then
|
|
98
|
+
# The 2026-08-29 shape: the TUI paints the token OUT OF ORDER with absolute cursor
|
|
99
|
+
# moves, so the byte stream is `sk-ant-<ESC>[10Gat01-…` and escape-stripping loses
|
|
100
|
+
# the `o`. Only replaying the transcript onto a screen recovers it.
|
|
101
|
+
tok="sk-ant-oat01-$(printf '%66s' '' | tr ' ' W)$(printf '%23s' '' | tr ' ' T)TAILOK"
|
|
102
|
+
printf ' Your\033[6GOAuth\033[12Gtoken\033[18G(valid\033[25Gfor\033[29G1\033[31Gyear):\n'
|
|
103
|
+
printf ' %s\033[10G%s\033[9G%s\n' "${tok:0:7}" "${tok:8}" "${tok:7:1}"
|
|
104
|
+
printf ' Store\033[7Gthis\033[12Gtoken\033[18Gsecurely.\n'
|
|
105
|
+
exit 0
|
|
106
|
+
fi
|
|
97
107
|
if [ -n "${FAKE_TOKEN_APIKEY:-}" ]; then
|
|
98
108
|
# A browser session signed into a Console (API-billing) org: the client mints an
|
|
99
109
|
# API key, which is not a subscription token and must not be saved.
|
|
@@ -1475,6 +1485,28 @@ esac
|
|
|
1475
1485
|
[ -f "$ACC/acct-04/.server-token-verified" ] && t_ok "the joined token was proven by a real call" \
|
|
1476
1486
|
|| t_fail "split token proof" "marker missing"
|
|
1477
1487
|
printf '%s' "$orig" > "$ACC/acct-04/server.token"; rm -f "$ACC/acct-04/.server-token-verified"
|
|
1488
|
+
# The token painted OUT OF ORDER with absolute cursor moves — the shape that made three
|
|
1489
|
+
# real mints fail as "no token captured" right after "token created successfully".
|
|
1490
|
+
out="$(FAKE_TOKEN_CURSOR=1 claude-accounts mint acct-04 2>&1 </dev/null)"
|
|
1491
|
+
rc=$?
|
|
1492
|
+
[ "$rc" = "0" ] && t_ok "a cursor-painted token is read off the rendered screen" \
|
|
1493
|
+
|| t_fail "cursor-painted mint rc" "rc=$rc: $(printf '%s' "$out" | tail -c 220)"
|
|
1494
|
+
case "$(tr -d '[:space:]' < "$ACC/acct-04/server.token")" in
|
|
1495
|
+
sk-ant-oat01-WWW*TAILOK) [ "$(tr -d '[:space:]' < "$ACC/acct-04/server.token" | wc -c | tr -d ' ')" = "108" ] \
|
|
1496
|
+
&& t_ok "the rendered token is complete and in order" || t_fail "rendered token" "wrong length" ;;
|
|
1497
|
+
*) t_fail "rendered token" "wrong content — escape-stripping order bug" ;;
|
|
1498
|
+
esac
|
|
1499
|
+
printf '%s' "$orig" > "$ACC/acct-04/server.token"; rm -f "$ACC/acct-04/.server-token-verified"
|
|
1500
|
+
# The renderer itself, on the exact byte pattern from the field.
|
|
1501
|
+
printf ' sk-ant-\033[10Gat01-%s\033[9Go\n' "$(printf '%95s' '' | tr ' ' Z)" > "$WORK/painted.raw"
|
|
1502
|
+
n="$(python3 "$REPO_DIR/lib/ceremony.py" extract "$WORK/painted.raw" | wc -c | tr -d ' ')"
|
|
1503
|
+
[ "$n" = "108" ] && t_ok "ceremony.py renders a cursor-painted token whole" \
|
|
1504
|
+
|| t_fail "ceremony.py extract" "got $n characters, expected 108"
|
|
1505
|
+
# ...and the debrief reads as prose, not as words jammed together by the stripping.
|
|
1506
|
+
printf ' Store\033[8Gthis\033[13Gtoken\033[19Gsecurely.\n' > "$WORK/prose.raw"
|
|
1507
|
+
out="$(python3 "$REPO_DIR/lib/ceremony.py" debrief "$WORK/prose.raw" "$WORK/prose.out")"
|
|
1508
|
+
check "the debrief renders readable words" "Store this token securely." "$out"
|
|
1509
|
+
|
|
1478
1510
|
# A browser session on a Console (API-billing) org: the client mints an API KEY. Not a
|
|
1479
1511
|
# subscription token — refused, with the cause named and the raw capture kept.
|
|
1480
1512
|
before_raw="$(ls "$ACC"/tmp/mint-failed.*.raw 2>/dev/null | wc -l | tr -d ' ')"
|
|
@@ -1851,6 +1883,70 @@ sc="$(python3 -c "import json;d=json.load(open('$ACC/acct-01/limits.json'));prin
|
|
|
1851
1883
|
|| t_fail "ranking score" "got $sc"
|
|
1852
1884
|
rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.limited"
|
|
1853
1885
|
|
|
1886
|
+
# ---- 16-marker. the marker names ONE bucket and carries THAT bucket's reset ----------
|
|
1887
|
+
# A 100% session bucket (resets in an hour) beside a 100% Fable-only weekly bucket
|
|
1888
|
+
# (resets in five days) used to produce "bucket=session … resets_at=<+1h>" on line 2
|
|
1889
|
+
# under a line-1 epoch five days out — the highest percent paired with the latest
|
|
1890
|
+
# reset of a DIFFERENT bucket. Every reader of the marker (this shim, app-robot's
|
|
1891
|
+
# fleet-wide verdict) then parked the whole account for five days over a five-hour
|
|
1892
|
+
# window (2026-08-29, acct-05). The account-level bucket is named with its own reset.
|
|
1893
|
+
cat > "$WORK/usage-session-vs-fable.json" <<'EOF2'
|
|
1894
|
+
{"limits":[
|
|
1895
|
+
{"kind":"session","percent":100,"resets_at":"2099-01-01T01:00:00+00:00","scope":null},
|
|
1896
|
+
{"kind":"weekly_all","percent":40,"resets_at":"2099-01-05T00:00:00+00:00","scope":null},
|
|
1897
|
+
{"kind":"weekly_scoped","percent":100,"resets_at":"2099-01-06T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
|
|
1898
|
+
]}
|
|
1899
|
+
EOF2
|
|
1900
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-session-vs-fable.json" claude-accounts limits --force 2>&1)"
|
|
1901
|
+
check "session over threshold names the session bucket, not the Fable one" "LIMITED session at 100%" "$out"
|
|
1902
|
+
python3 - "$ACC/acct-01/.limited" <<'EOF2'
|
|
1903
|
+
import sys, time, calendar
|
|
1904
|
+
lines = open(sys.argv[1]).read().splitlines()
|
|
1905
|
+
epoch, detail = int(lines[0]), lines[1]
|
|
1906
|
+
want = calendar.timegm(time.strptime("2099-01-01T01:00:00", "%Y-%m-%dT%H:%M:%S"))
|
|
1907
|
+
assert epoch == want, (epoch, want, detail) # the SESSION reset
|
|
1908
|
+
assert "bucket=session percent=100" in detail, detail
|
|
1909
|
+
assert "resets_at=2099-01-01T01:00:00+00:00" in detail, detail
|
|
1910
|
+
EOF2
|
|
1911
|
+
[ $? -eq 0 ] && t_ok "marker epoch is the named bucket's own reset (session +1h, not Fable +5d)" \
|
|
1912
|
+
|| t_fail "marker epoch/bucket consistency" "$(cat "$ACC/acct-01/.limited" 2>&1 | tr '\n' ' ')"
|
|
1913
|
+
# Two ACCOUNT-level offenders: the longest-lived exclusion is the one named, with its
|
|
1914
|
+
# own reset — a session bucket that resets in an hour must not shorten a weekly park.
|
|
1915
|
+
cat > "$WORK/usage-session-vs-weekly.json" <<'EOF2'
|
|
1916
|
+
{"limits":[
|
|
1917
|
+
{"kind":"session","percent":100,"resets_at":"2099-01-01T01:00:00+00:00","scope":null},
|
|
1918
|
+
{"kind":"weekly_all","percent":95,"resets_at":"2099-01-05T00:00:00+00:00","scope":null}
|
|
1919
|
+
]}
|
|
1920
|
+
EOF2
|
|
1921
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-session-vs-weekly.json" claude-accounts limits --force 2>&1)"
|
|
1922
|
+
check "two account-level offenders: the longest-lived one is named" "LIMITED weekly_all at 95%" "$out"
|
|
1923
|
+
python3 - "$ACC/acct-01/.limited" <<'EOF2'
|
|
1924
|
+
import sys, time, calendar
|
|
1925
|
+
lines = open(sys.argv[1]).read().splitlines()
|
|
1926
|
+
want = calendar.timegm(time.strptime("2099-01-05T00:00:00", "%Y-%m-%dT%H:%M:%S"))
|
|
1927
|
+
assert int(lines[0]) == want, lines
|
|
1928
|
+
assert "bucket=weekly_all percent=95" in lines[1] and "resets_at=2099-01-05T00:00:00+00:00" in lines[1], lines
|
|
1929
|
+
EOF2
|
|
1930
|
+
[ $? -eq 0 ] && t_ok "weekly_all marker carries the weekly reset" \
|
|
1931
|
+
|| t_fail "weekly_all marker" "$(cat "$ACC/acct-01/.limited" 2>&1 | tr '\n' ' ')"
|
|
1932
|
+
# Only a model-scoped offender left: the scoped bucket is named (the shim treats it
|
|
1933
|
+
# as "switch model", never as an account park) with ITS reset.
|
|
1934
|
+
cat > "$WORK/usage-fable-only.json" <<'EOF2'
|
|
1935
|
+
{"limits":[
|
|
1936
|
+
{"kind":"session","percent":10,"resets_at":"2099-01-01T01:00:00+00:00","scope":null},
|
|
1937
|
+
{"kind":"weekly_scoped","percent":100,"resets_at":"2099-01-06T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
|
|
1938
|
+
]}
|
|
1939
|
+
EOF2
|
|
1940
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-fable-only.json" claude-accounts limits --force 2>&1)"
|
|
1941
|
+
check "a scoped-only offender still marks, scoped" "LIMITED weekly_scoped:Fable at 100%" "$out"
|
|
1942
|
+
grep -q "^bucket=weekly_scoped:Fable percent=100 " "$ACC/acct-01/.limited" 2>/dev/null \
|
|
1943
|
+
&& [ "$(head -1 "$ACC/acct-01/.limited")" = "$(python3 -c 'import time,calendar;print(calendar.timegm(time.strptime("2099-01-06T00:00:00","%Y-%m-%dT%H:%M:%S")))')" ] \
|
|
1944
|
+
&& t_ok "scoped marker carries the scoped bucket's reset" \
|
|
1945
|
+
|| t_fail "scoped marker" "$(cat "$ACC/acct-01/.limited" 2>&1 | tr '\n' ' ')"
|
|
1946
|
+
grep -q '"model": "Fable"' "$ACC/acct-01/limits.json" && t_ok "limits.json buckets record the model scope" \
|
|
1947
|
+
|| t_fail "bucket model field" "missing from limits.json"
|
|
1948
|
+
rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.limited"
|
|
1949
|
+
|
|
1854
1950
|
# ---- 16a. future-proofing: works if the Fable bucket separation disappears ---------
|
|
1855
1951
|
cat > "$WORK/usage-no-fable.json" <<'EOF'
|
|
1856
1952
|
{"limits":[
|