Draw the device serial per install, in both the app and the exporter - #182
Merged
Merged
Conversation
Issue #176, and it is the same 503 as #168 at two call sites nothing covered. That log is worth reading, because half of it is the previous fix working. Apple refused the 2FA submit; the exporter waited, requested a new code, and signed in - GSA authentication successful, LOGGED_IN. Then open_client called request_pet one second later, got another 503, and that one reached the wizard's catch-all: an exception type, a log path, and a link to the issue tracker. The next attempt a minute later was refused at login and did the same. So the user filed an issue, because the program asked them to, about Apple having a bad three minutes. The obstacle was that FindMy.py folded every non-OK status into UnhandledProtocolError, whose meaning is "Apple said something this library does not model" - which is a bug and is worth reporting. Nothing downstream could tell that apart from a refusal without parsing the status back out of the message. So the fork gained AppleServiceUnavailableError, carrying the status and subclassing UnhandledProtocolError so existing handlers keep working, and raises it for 429 and 5xx from both sign-in requests. FindMy.py is re-pinned in all four places per rule 14, verified by installing the new pin and running the bridge suite against it. Here, apple_is_declining walks the cause chain, because the two call sites arrive differently: log_in raises it plainly and open_client raises it several frames down, wrapped. Only the second reached the catch-all, which is why it was the one that produced the issue. The wizard catches the plain case by type and the wrapped case in _report_unexpected; the CLI catches it ahead of its UnhandledProtocolError handler. Nothing retries. The 2FA path can, because it knows what to re-do - the code is spent and a new one can be requested - and that is left alone because #176 shows it working. A refused login or request_pet has nothing to re-do but the whole sign-in, so it says what happened and stops. Twelve tests here, nineteen in the fork. All verified failable: matching by type without walking the chain turns two red, and putting the issue link back in the message turns two more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The exporter half of this landed with the pin bump; the app reaches the same library and had nothing for the new type. It also had a regression waiting, which is the part worth reading first. **ACodeAppleAlreadyTook.spentIt matched the string "UnhandledProtocolError" in the bridged message.** Its own docstring says there is nothing better to match on "until the fork grows a transient-failure type". The fork now has one, and it is a subclass - so a real 503 arrives as AppleServiceUnavailableError, the match fails, and the 2FA wait-and-retry silently stops firing. Every test in that file writes the old name into its own fixture, so all eight checks stayed green over it. A test that supplies the string it is looking for cannot notice the string changing. It now matches both names, and three tests cover the one that is real. Beyond that, a 503 outside the 2FA path had no classification at all: - classifyLoginFailure sent it to REASON_UNKNOWN, so the login screen echoed "The Grand Slam request was refused with HTTP 503" verbatim. Correct, unreadable, and indistinguishable from a bug here, which is how issue #176 came to be filed. - icloud_bridge._unexpected did the same, so the fetch screen fell to its default branch and put the same text on screen. Both now report apple_declined, which the Java side maps to ICloudFailure.APPLE_DECLINED and PythonAccountLoginException's matching reason. Deliberately not CREDENTIALS_REJECTED: that ends in a forced sign-out, and signing somebody out over a fault that clears in minutes is rule 15's mistake in the direction that costs a working session. Also deliberately not REASON_NETWORK, because the advice differs - a network failure is usually the phone's and worth checking, and this one is not. The login screen gets a sentence saying it is Apple's fault and the password is fine. The fetch screen reaches the retry container it already had, with wording that says which of the two failures it is rather than the raw detail. Fourteen tests. Three JVM for the regression, five JVM for the mapping and the sign-out predicate, six Python across both classifiers, and two Espresso - one per screen - asserting the sentence is shown and that the status code and "Grand Slam" are not. Three strings in ten locales via add_strings.py. Not verified locally: no Android SDK here, so the JVM and emulator suites are CI's to run. The Python bridge suite passes at 239. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five people have now reported this and only @parawanderer has confirmed it resolving. crishpeen on #168 says the opposite: every attempt, every 2FA method, device-identity.json cleared, still refused - and their log shows login succeeding and only request_pet getting the 503. The message said it "usually clears on its own within a few minutes", which was one confirmed recovery stated as a rule. Somebody it does not clear for would read that and wait. It now says trying again shortly is worth doing, that it has been seen to last, and asks for a report with the log when it keeps refusing. The 2FA message said the same thing and said it immediately after demonstrating that two waits had not cleared it, which was incoherent as well as unsupported. Both Android strings reworded through --show and --replace across ten locales. The test asserting no bug report is mentioned is narrowed to what it was actually protecting: the dialog must not present this as a defect here, which is what linking the issue tracker did. Asking for a report when it persists is correct and now asserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`EXPORTER_SERIAL` was a constant, so every copy of this program anywhere presented Apple the same `X-Apple-I-SRL-NO` while presenting a *different* machine identity: one serial against thousands of device ids and thousands of Apple IDs, from every continent, at once. Real hardware does not look like that. That is the leading suspect for the Grand Slam 503s in #168, #176 and #181, which some accounts never recover from - and one reporter cleared their device identity to no effect, which is exactly what would happen if the serial were the part being matched on, since clearing the file regenerates the ids and not the serial. It is a hypothesis and is written down as one in `identity.EXPORTER_SERIAL`. The cheap way to test it is this change: an affected user deleting their identity file now draws a different serial rather than the same one. - `generate_serial()` draws four characters after `0PENTAGX` from an alphabet with the confusable pairs left out, using `secrets` rather than `random` so that two runs in the same second cannot collide and no seed can pin it - an install that already has an identity keeps what it has: a stored serial is used as-is, and a stored identity with no serial keeps `0PENTAGXPORT`, because changing the serial on a working install costs a second device-list entry and possibly a sign-in - `device.save` omits the key rather than defaulting it, so a save from a path that does not know the serial cannot blank a stored one Two defects found while testing it, both of which would have shipped: - the line building the Anisette provider was dropped, so an account built without one failed on an attribute of None. No test caught it because every other test passes a provider in - `remember()` re-derived the serial from disk, which on a first run means there is no file - so it drew a *second* serial and stored that, and the next run would have introduced itself as a different device than the one just registered. It reads `account.serial` now, which is the only source that cannot disagree with what was sent `test_serial_is_per_install.py` covers the shape, the spread across 500 draws and 5 separate processes, the three `serial_from` cases, the round trip through the identity file, and both defects above.
Same change as the exporter's, on the other half of the project, and for the same reason: `APP_SERIAL` was a constant, so `0PENTAGVIEWR` reached Apple from every install of this app in the world at once, against thousands of different machine identities. See #168, #176 and #181. The serial is now drawn once - `0PENTAGV` plus four characters from an alphabet that leaves out the pairs a person comparing two screens would confuse - and persisted beside the rest of the device identity in `LocalAnisette.PREFERENCES`. `SecureRandom`, seeded by the platform and not pinnable from here, because a seedable generator would hand every fresh install the same serial and reproduce the fingerprint this exists to break up. **An install that already has an identity keeps its serial**, including the installs that predate this and have no stored serial at all: three keys and no fourth can only be an install that has been presenting `0PENTAGVIEWR` for its whole life, and drawing it a new one now registers a second device beside the row the user already recognises. It is not back-filled either, so `I` or `O` in a serial still means "from before this change" when reading a report. **Python asks rather than holding a copy.** The two constants used to be pinned equal by `IdentityBridgeTest`; a per-install value cannot be, and a second copy of it is exactly what rule 11 is about - in the one field the user actually reads. `identity.appSerial` reads it across the bridge, and `AnisetteSource.serial()` answers without ADI so that a sign-in relayed through a remote server presents the same serial as one that was not. `icloud_bridge` builds its CloudKit identity per session rather than per process, off `account.serial`: a restored session keeps whatever established it, so a constant there would be right for a fresh install and wrong for every other one, in the field CloudKit writes into the escrow record and the recovery picker matches on. The registered-device screen reads the stored value rather than a literal. A screen naming a serial Apple never saw would send the user looking for a row that is not theirs, and conclude the one in front of them belongs to somebody else - which is what gets it removed. Tests: - `DrawingTheSerialTest` (JVM, rule 13) takes a fresh `SecureRandom` per draw, which is the case being tested - a first run is one process making one draw - and asserts 500 of them produce more than 450 distinct serials. A single shared instance producing a good spread would prove nothing, since that is the shape that hides a fixed seed - `LocalAnisetteIdentityTest` covers the legacy install, the fresh draw, that it is stored and re-read, and that the screen's read-only path mints nothing - the fakes answer a *drawn* serial rather than the old constant, so a test cannot pass against code that never reached the bridge and fell back AGENTS.md rule 11 gains the argument, so this does not get put back to a constant.
parawanderer
changed the base branch from
fix/apple-declining-is-not-a-bug
to
main
September 12, 2026 12:30
#182 was opened against another branch to stack it on #177, and reported one green check - the Windows binary build, the only workflow with no branch filter. Every workflow that matters is gated `pull_request: branches: [ "main" ]`, so the APK build, the static checks, the Chaquopy bridge tests, the JVM suite and the emulator suite all matched nothing and never ran. It does not present as skipped, it presents as passing, which is the part worth a rule.
#154 landed a paragraph saying the serial `0PENTAGVIEWR` is the only thing distinguishing this app's device-list entry from real hardware, and the exporter's is `0PENTAGXPORT`. Both literals stop being true forty lines above the bullets in this change, which say the serial is drawn per install. Git merged the two happily, because they are in different paragraphs - a contradiction with no conflict marker anywhere. The paragraph is otherwise right and is what #154 is about, so only the two literals go: the hardware profile is still always `LEGACY_MAC`, and the serial is still the only distinguishing field. What it is is now answered in one place.
This was referenced Sep 13, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Based on
main, but it carries #177's three commits until that merges — the diff collapses on its own.It was opened against #177's branch and that was a mistake: every workflow here is gated
pull_request: branches: [main], so the APK build, the static checks, the bridge tests, the JVM suite and the emulator suite all matched nothing, and the page showed one green tick from the only workflow with no branch filter. Written up as rule 16 in AGENTS.md.Every install of this project presents Apple a serial in
X-Apple-I-SRL-NO, and until now it was a constant:0PENTAGVIEWRfrom every copy of the app,0PENTAGXPORTfrom every copy of the exporter. One serial, thousands of machine identities, thousands of Apple IDs, every continent, simultaneously. Real hardware does not look like that.That is a candidate explanation for the Grand Slam 503s in #168, #176 and #181 — refusals that some accounts never recover from, at three different call sites (
login,td_2fa_submit,request_pet). It is consistent with the one detail that otherwise does not fit: @crishpeen cleareddevice-identity.jsonand it did not help, which is what you would expect if the matched feature were the serial, because clearing that file regenerates the uid and devid and leaves the serial alone.It is a hypothesis, and it is written down as one in
identity.EXPORTER_SERIALandAdiDeviceIdentity.LEGACY_SERIAL. Nothing here confirms it against Apple. What it does do is make it testable for free: an affected user reinstalling now draws a different serial rather than the same one.What changed
APP_SERIAL = "0PENTAGVIEWR"in Python, copied in Java0PENTAGV+ 4, drawn withSecureRandom, stored inLocalAnisette.PREFERENCESEXPORTER_SERIAL = "0PENTAGXPORT"0PENTAGX+ 4, drawn withsecrets, stored indevice-identity.jsonThe alphabet is
ACDEFGHJKLMNPQRTUVWXY34679— uppercase alphanumeric, with the pairs a person comparing two screens would confuse left out. Nobody types a serial; they read it off the app and look for it in their Apple device list, and the only failure mode is looking at the right row and believing it is the wrong one.An install that already has an identity keeps its serial. Three keys and no serial key can only be an install that has been presenting the old constant for its whole life, and changing it now registers a second device beside the row the user already recognises, possibly costing them a sign-in, to change a label. It is not back-filled either, so
IEWRandPORTstay undrawable — both contain a letter the alphabet excludes — which means anIor anOin a serial is, by construction, an install from before this change. That is worth having when reading a bug report.Two defects this found in its own first draft
Both were in the exporter, both would have shipped:
None. Nothing caught it because every other test in the suite passes a provider in.remember()re-derived the serial from disk. A first run has no file, so it drew a second serial and stored that — and the next run would have introduced itself to Apple as a different device than the one just registered, adding a device-list entry every other export. It readsaccount.serialnow.Both have tests that fail with the defect reinstated.
Tests
DrawingTheSerialTest(JVM)SecureRandom— the actual first-run case — produce >450 distinct serials. A shared instance with a good spread would prove nothing, since that is the shape that hides a fixed seedLocalAnisetteIdentityTestIdentityBridgeTest,PythonPackagingTestFetchFromICloudFlowTesttest_serial_is_per_install.pyrandom.seed(0)not pinning it, the threeserial_fromcases, the round trip through the identity file, and both defects aboveThe fakes answer a drawn serial rather than the old constant throughout, so no test can pass against code that never reached the bridge and fell back.
Not in this PR
The wiki is updated locally but unpushed — it describes released behaviour, and pushing it before this ships would make it wrong for everyone on 1.1.0. The five pages that name a full serial now match on the first eight characters, with a note for installs that show the old values exactly.