From 167aa6f80c7794c329a307581b3eee6bbeed6c96 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:04:28 -0400 Subject: [PATCH 1/5] fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS keys every permission grant to the app's designated requirement. Ad-hoc bundles have no certificate to name, so codesign falls back to the binary's cdhash — which changes every build: $ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405" Every update was therefore a brand-new app to macOS, and Screen Recording, Accessibility, Microphone, Files & Folders and Local Network were re-requested from scratch each time. Upstream's own build names a certificate instead and keeps its grants, which is the shape this copies. The fix is not "sign it" but "sign it with an identity that does not move", so a free self-signed certificate is enough — it does not have to be trusted by Apple, it has to be the same one next time. - scripts/t3x/setup-mac-signing.sh creates that identity (10 years, its own keychain so no GUI keychain dialog can block an unattended build), self-verifies it, and exports the p12 for CI. - t3x-release.yml imports it on the mac runner and exports CSC_NAME. - auto-build-desktop.sh picks the same identity up locally, so both paths produce one identical requirement rather than two. - scripts/t3x/verify-mac-signature.ts asserts it on the .app inside the shipped dmg, and fails a CHANGED identity as well as a missing one: a build signed by a different certificate is perfectly signed and still costs a round of dialogs. electron-builder only WARNS when it finds no identity, so without this check the regression is invisible until the dialogs come back days later. No upstream file is edited, and #70's plan to add a third signing mode to build-desktop-artifact.ts turned out to be unnecessary: app-builder-lib consults CSC_IDENTITY_AUTO_DISCOVERY only when no identity was named, so exporting CSC_NAME around the existing unsigned build is the whole mechanism. Zero new SEAMS.md rows. Expect one final round of prompts when the first signed build installs — the identity moves from a cdhash to a certificate — then silence. That install looks exactly like the bug it fixes, so the fix is judged on the second one. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/t3x-release.yml | 130 ++++++++++ docs/t3x/SEAMS.md | 12 + docs/t3x/auto-build-runbook.md | 33 ++- docs/t3x/mac-signing-runbook.md | 207 ++++++++++++++++ docs/t3x/mac-signing/certificate.pem | 19 ++ scripts/t3x/auto-build-desktop.sh | 79 +++++- scripts/t3x/mac-signature.test.ts | 265 ++++++++++++++++++++ scripts/t3x/mac-signature.ts | 257 ++++++++++++++++++++ scripts/t3x/setup-mac-signing.sh | 348 +++++++++++++++++++++++++++ scripts/t3x/verify-mac-signature.ts | 326 +++++++++++++++++++++++++ 10 files changed, 1668 insertions(+), 8 deletions(-) create mode 100644 docs/t3x/mac-signing-runbook.md create mode 100644 docs/t3x/mac-signing/certificate.pem create mode 100644 scripts/t3x/mac-signature.test.ts create mode 100644 scripts/t3x/mac-signature.ts create mode 100755 scripts/t3x/setup-mac-signing.sh create mode 100644 scripts/t3x/verify-mac-signature.ts diff --git a/.github/workflows/t3x-release.yml b/.github/workflows/t3x-release.yml index 2371ec36eada..7d26824d35e1 100644 --- a/.github/workflows/t3x-release.yml +++ b/.github/workflows/t3x-release.yml @@ -241,6 +241,86 @@ jobs: # ci.yml, release.yml and t3x-weekly-verify.yml, which all scope it the same way. run: vp run --filter @t3tools/desktop ensure:electron + # Issue #70: WITHOUT this step the mac artifact is ad-hoc signed, its designated requirement + # is the binary's cdhash, and macOS re-requests every permission (Screen Recording, + # Accessibility, Microphone, Files & Folders, Local Network) after every single update. + # + # The identity is a self-signed certificate created by scripts/t3x/setup-mac-signing.sh; the + # private key reaches CI as a p12 in T3X_MAC_CSC_P12_BASE64. It buys stability, not trust: + # the app is still not notarized, and does not need to be, because the requirement only has to + # stop MOVING for the grants to stick. + # + # No edit to build-desktop-artifact.ts (an upstream-owned file) is needed for any of this. + # That file forces CSC_IDENTITY_AUTO_DISCOVERY=false for unsigned builds, but electron-builder + # checks the flag only when NO identity was named — app-builder-lib's findIdentity() reads + # `qualifier || process.env.CSC_NAME` first and, when that is non-empty, goes straight to + # `security find-identity`. So exporting CSC_NAME on the build step is enough, and the fork's + # zero-seam property survives. Do not "simplify" this by passing --signed instead: that takes + # the macOS passkey path, which demands T3CODE_CLERK_PUBLISHABLE_KEY and a provisioning + # profile, and turns on notarization expectations a self-signed certificate cannot satisfy. + - name: Import the macOS signing identity + id: signing + if: ${{ matrix.platform == 'mac' }} + shell: bash + env: + CSC_P12_BASE64: ${{ secrets.T3X_MAC_CSC_P12_BASE64 }} + CSC_P12_PASSWORD: ${{ secrets.T3X_MAC_CSC_PASSWORD }} + IDENTITY_NAME: T3X Code Signing + run: | + set -euo pipefail + + if [[ -z "${CSC_P12_BASE64:-}" || -z "${CSC_P12_PASSWORD:-}" ]]; then + echo "::warning::No macOS signing secrets set, so this build is ad-hoc signed and will re-request every macOS permission once installed. See docs/t3x/mac-signing-runbook.md." + { + echo "signed=false" + echo "identity=" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + keychain="$RUNNER_TEMP/t3x-signing.keychain-db" + keychain_password="$(openssl rand -base64 24)" + p12="$RUNNER_TEMP/t3x-signing.p12" + printf '%s' "$CSC_P12_BASE64" | base64 --decode > "$p12" + + security create-keychain -p "$keychain_password" "$keychain" + # No -t: the default 300-second idle lock would expire during a 30-minute build and the + # signing step would fail with "no identity found" long after this step went green. + security set-keychain-settings "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$p12" -k "$keychain" -P "$CSC_P12_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security + # -T above is not sufficient on its own since macOS 10.12: without the partition list, + # codesign gets a GUI authorisation prompt, which on a runner means a hung job. + security set-key-partition-list -S apple-tool:,apple:,codesign: -s \ + -k "$keychain_password" "$keychain" >/dev/null + + # electron-builder calls `security find-identity -v` with NO keychain argument, so the + # keychain has to be in the search list. Re-passing the existing entries is required: + # `-s` REPLACES the list. Unquoted on purpose — runner keychain paths contain no spaces. + # shellcheck disable=SC2046 + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + + # A self-signed certificate is not "valid for code signing" until it is trusted, and `-v` + # means valid. Skip this and find-identity lists nothing, electron-builder logs + # "skipped macOS application code signing" as a WARNING, and the release goes green with + # exactly the bundle this whole step exists to prevent. + sudo security add-trusted-cert -d -r trustRoot -p codeSign \ + -k /Library/Keychains/System.keychain docs/t3x/mac-signing/certificate.pem + + rm -f "$p12" + + if ! security find-identity -v -p codesigning | grep -Fq "$IDENTITY_NAME"; then + echo "::error::'$IDENTITY_NAME' is not a valid code-signing identity after import." >&2 + security find-identity -v >&2 || true + exit 1 + fi + echo "Signing identity available: $IDENTITY_NAME" + { + echo "signed=true" + echo "identity=$IDENTITY_NAME" + } >> "$GITHUB_OUTPUT" + # Installed unconditionally by upstream for Windows, separate from the signing block — the # staged `vp install --prod` still runs native lifecycle scripts (node-pty, sharp, # msgpackr-extract) and recent MSVC + node-gyp wants the Spectre-mitigated libs. @@ -324,6 +404,12 @@ jobs: # upstream edits. The verify step below asserts it. GITHUB_REPOSITORY: "" T3CODE_DESKTOP_UPDATE_REPOSITORY: "" + # Issue #70, and the only line that makes the mac build signed. Empty on Windows and + # whenever no signing secret is configured, which is the same as absent: the build script + # scrubs empty variables, and electron-builder's findIdentity() treats an empty CSC_NAME as + # "not specified" and falls back to the ad-hoc path. The verify step below is what turns + # that silent fallback into a visible one. + CSC_NAME: ${{ steps.signing.outputs.identity }} run: | set -euo pipefail node scripts/build-desktop-artifact.ts \ @@ -348,6 +434,50 @@ jobs: fi echo "No app-update.yml packaged." + # The machine-checkable form of "this update will not re-ask for permissions" (issue #70), + # made against the .app inside the shipped .dmg rather than a staging copy. + # + # It exists because electron-builder does not fail when it cannot find an identity — it warns + # and produces an ad-hoc bundle. Without this step the regression is invisible until the user + # is clicking through five system dialogs again, days later, with a green release behind them. + # + # A CHANGED-but-valid identity is failed too, not just a missing one: a different certificate + # is still one full round of prompts, and the recorded requirement is the only thing that can + # tell the difference. + - name: Verify the macOS artifact keeps its permission grants + if: ${{ matrix.platform == 'mac' }} + shell: bash + env: + SIGNED: ${{ steps.signing.outputs.signed }} + IDENTITY: ${{ steps.signing.outputs.identity }} + run: | + set -euo pipefail + dmg="$(find release -maxdepth 1 -name '*.dmg' -print -quit)" + if [[ -z "$dmg" ]]; then + echo "::error::No .dmg in release/ to verify." >&2 + exit 1 + fi + + args=( + --artifact "$dmg" + --expect-requirement-file docs/t3x/mac-signing/designated-requirement.txt + ) + if [[ "$SIGNED" == "true" ]]; then + args+=(--expect-authority "$IDENTITY") + else + # Downgrade to a warning ONLY when this job knowingly had no identity to sign with. + args+=(--allow-unsigned) + fi + + node scripts/t3x/verify-mac-signature.ts "${args[@]}" + + - name: Delete the signing keychain + if: ${{ always() && matrix.platform == 'mac' && steps.signing.outputs.signed == 'true' }} + shell: bash + run: | + security delete-keychain "$RUNNER_TEMP/t3x-signing.keychain-db" 2>/dev/null || true + rm -f "$RUNNER_TEMP/t3x-signing.p12" + - name: Collect and rename id: collect shell: bash diff --git a/docs/t3x/SEAMS.md b/docs/t3x/SEAMS.md index 3d706cc76d5d..4c51628e03ec 100644 --- a/docs/t3x/SEAMS.md +++ b/docs/t3x/SEAMS.md @@ -62,6 +62,18 @@ files upstream has never seen and cannot conflict. > `git log ..upstream/main -- apps/marketing` and port anything worth having (pricing > changes, new pages, security-relevant fixes) by hand. +> **macOS code signing adds no NEW rows either — and the reason is worth keeping.** Issue #70 (every +> update re-requesting every macOS permission) was diagnosed as needing a third signing mode inside +> `scripts/build-desktop-artifact.ts`, because that file forces `CSC_IDENTITY_AUTO_DISCOVERY=false` +> for unsigned builds. It did not. app-builder-lib consults that flag **only when no identity was +> named**: `findIdentity()` reads `qualifier || process.env.CSC_NAME` first and, when that is +> non-empty, goes straight to `security find-identity`. So exporting `CSC_NAME` around the existing +> unsigned build is the whole mechanism, and it lives in `.github/workflows/t3x-release.yml` and +> `scripts/t3x/` — a row on that hot upstream file was priced, considered, and then not needed. +> The general lesson: before spending a row to add a mode, check whether the mode's escape hatch is +> already an environment variable. `--concurrency-limit` for #47 and `GITHUB_REPOSITORY: ""` for the +> updater were the same shape of answer. + The churn and risk columns are measured against that same merge-base, over the 60 days preceding it. The window slides forward at every sync, so these figures move even when the fork does not. diff --git a/docs/t3x/auto-build-runbook.md b/docs/t3x/auto-build-runbook.md index 330a9d988e39..a8b363ab76eb 100644 --- a/docs/t3x/auto-build-runbook.md +++ b/docs/t3x/auto-build-runbook.md @@ -79,10 +79,30 @@ Those are different processes; auto-install updates the installed one only. `-nightly.*` version, the same command starts replacing `T3 Code (Nightly).app` instead. Re-run the check above after a big upstream sync. -**Gatekeeper.** Local builds are unsigned (`T3CODE_DESKTOP_SIGNED` defaults to -`false`), so macOS quarantines them. The installer runs -`xattr -dr com.apple.quarantine` on the installed app so it launches without a -Gatekeeper block. Nothing here is code-signed or notarized. +**Gatekeeper.** Builds are not notarized (`T3CODE_DESKTOP_SIGNED` defaults to +`false`, and this script never turns it on), so macOS quarantines them. The +installer runs `xattr -dr com.apple.quarantine` on the installed app so it +launches without a Gatekeeper block. + +**Code signing — and permission prompts (issue #70).** Builds _are_ code-signed +when this machine has the fork's signing identity, which the script picks up on +its own by exporting `CSC_NAME`. That is what stops macOS re-asking for Screen +Recording, Accessibility, Microphone, Files & Folders and Local Network on every +install: permission grants are keyed to the app's designated requirement, and an +unsigned build's requirement is its own `cdhash`, which changes every build. Set +the identity up once with `scripts/t3x/setup-mac-signing.sh`; the script logs +`signing: NONE` and keeps building without it. Full runbook: +`docs/t3x/mac-signing-runbook.md`. + +> **The FIRST signed build re-prompts for everything, once.** The identity moves +> from a cdhash to a certificate, so the old grants no longer match and macOS asks +> again. That install looks exactly like the bug it fixes — judge the fix on the +> _second_ signed install, which should be silent. + +Every build is verified before it is installed +(`scripts/t3x/verify-mac-signature.ts`), and an install is refused if the +signature is missing when it should be present, or if the identity changed. A +build that would cost you a round of dialogs is not worth installing quietly. ## How the trigger works @@ -286,8 +306,9 @@ out. target in `/Applications`, and copies the new one in. Fine overnight; annoying mid-session. There is no "skip if app is foregrounded" check yet. The new build is staged alongside and swapped in, so a failed copy leaves your existing app intact. -- **Unsigned.** Quarantine-stripping is required on every install. If macOS - tightens Gatekeeper this may stop working. +- **Signed but not notarized.** Quarantine-stripping is still required on every + install. If macOS tightens Gatekeeper this may stop working. Signing fixes the + permission-prompt problem (#70), not the Gatekeeper one. - **Disk — bigger than the prune suggests.** `T3X_AUTOBUILD_KEEP_DMGS` (default 3) prunes `*.dmg` **only**. electron-builder also writes a `.zip` of comparable size (~233 MB next to a ~236 MB dmg), plus `.blockmap`s and `builder-debug.yml`, and **none of those diff --git a/docs/t3x/mac-signing-runbook.md b/docs/t3x/mac-signing-runbook.md new file mode 100644 index 000000000000..43e95e05381a --- /dev/null +++ b/docs/t3x/mac-signing-runbook.md @@ -0,0 +1,207 @@ +# t3x — macOS code signing, or: why the app stopped asking for permissions + +Issue #70. Every update used to re-ask for Screen Recording, Accessibility, Microphone, Files & +Folders and Local Network. That was not the updater misbehaving and not a quarantine problem — it +was code signing, and the fix cost $0. + +**Zero upstream seams.** Everything here lives in `scripts/t3x/`, `.github/workflows/t3x-release.yml` +and this directory. `scripts/build-desktop-artifact.ts` is not touched, so this adds no row to +`SEAMS.md` — see [Why no upstream edit was needed](#why-no-upstream-edit-was-needed). + +## The diagnosis, in two commands + +macOS stores each permission grant against the app's **designated requirement**, a code-signing +predicate it re-evaluates on every access. Compare the fork's build before this landed with +upstream's official one, both installed at the same time: + +``` +$ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # the fork, before +# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405" + +$ codesign -d --requirements - "/Applications/T3 Code (Nightly).app" # upstream +designated => identifier "com.t3tools.t3code" and anchor apple generic + and certificate 1[field.1.2.840.113635.100.6.2.6] and certificate leaf[field.1.2.840.113635.100.6.1.13] + and certificate leaf[subject.OU] = ARK85ZXQ4Z +``` + +With no certificate to name, codesign falls back to the binary's `cdhash` — which changes when any +byte of the app changes. So every build was a different app as far as macOS was concerned, and every +grant was void on arrival. Upstream's requirement names a certificate instead, and survives. + +The fix is therefore **not** "sign the app". It is "sign it with an identity that does not move". The +certificate does not need to be trusted by Apple; it needs to be the same one next time. + +## The identity + +A self-signed code-signing certificate, created by `scripts/t3x/setup-mac-signing.sh`: + +| | | +| --------------------- | ---------------------------------------------------------------------------------------------------------- | +| Common name | `T3X Code Signing` | +| Validity | 10 years (`notAfter=Aug 8 2036`) | +| Public certificate | [`docs/t3x/mac-signing/certificate.pem`](mac-signing/certificate.pem), committed | +| Private key | `~/.t3x/mac-signing/t3x-signing.p12`, mode 0600, never in the repo | +| Keychain | `~/Library/Keychains/t3x-signing.keychain-db`, its own, password in `~/.t3x/mac-signing/keychain-password` | +| Resulting requirement | [`docs/t3x/mac-signing/designated-requirement.txt`](mac-signing/designated-requirement.txt) | + +A dedicated keychain rather than the login keychain, because its password is one we generate: that is +what lets `security set-key-partition-list` run non-interactively, so `codesign` never raises the +"wants to use a key in your keychain" dialog. An unattended 3am autobuild cannot click a dialog. + +Not the $99 Developer Program, and not the `Apple Development` certificate already in this Mac's +keychain. Either would also work — the requirement is stable in all three cases — but a self-signed +certificate is purpose-built (no relation to an Apple ID), lasts ten years instead of one, and does +not print the owner's email address into every shipped artifact's signature. + +## One-time setup + +On the machine that owns the identity: + +```bash +scripts/t3x/setup-mac-signing.sh +``` + +It is idempotent, and it self-verifies: it signs a throwaway bundle at the end and refuses to report +success unless `security find-identity -v -p codesigning` lists the identity. One step needs +`sudo` — marking a self-signed certificate trusted for code signing is an admin operation, and +**without it the certificate is invisible to electron-builder**, which then silently produces an +ad-hoc build. If the script is running somewhere with no terminal to answer on, it prints the exact +command and stops rather than hanging on a `sudo` prompt. + +Then the release workflow needs the private key, as two repository secrets: + +```bash +scripts/t3x/setup-mac-signing.sh --print-ci-secrets # writes the base64 and prints both commands +``` + +| Secret | Value | +| ------------------------ | ------------------------------------------------ | +| `T3X_MAC_CSC_P12_BASE64` | `base64` of `~/.t3x/mac-signing/t3x-signing.p12` | +| `T3X_MAC_CSC_PASSWORD` | contents of `~/.t3x/mac-signing/p12-password` | + +With them absent the release still succeeds — it just warns and ships an ad-hoc build, which is the +pre-#70 behaviour. That is deliberate: a missing secret should not be able to block a release, only +to downgrade it, and the warning plus the verify step make the downgrade visible. + +## What to expect on the first signed build + +**Every permission is asked for one more time.** The identity is moving from "cdhash" to "our +certificate", which is a change like any other, so the existing grants do not match and macOS asks +again. Grant them once. From then on they stick across every update. + +This matters because that first install looks _exactly_ like the bug it fixes. Do not conclude the +fix failed until the **second** signed build installs without prompting. + +## Verifying a build + +```bash +node scripts/t3x/verify-mac-signature.ts --artifact release/T3-Code-*.dmg \ + --expect-requirement-file docs/t3x/mac-signing/designated-requirement.txt +``` + +It mounts the dmg read-only, inspects the `.app` that actually ships, and fails on any of: an ad-hoc +signature, a cdhash-keyed requirement, the wrong bundle id, an unsealed resource envelope, an unbound +`Info.plist`, a failed `codesign --verify --deep --strict`, or a requirement that differs from the +recorded one. + +That last case is the one worth having a file for. A build signed by a _different_ valid certificate +is perfectly signed and still costs the user every dialog once, so "is it signed?" is not a strong +enough question — "is it signed by the same thing as last time?" is. Both the release workflow and +`scripts/t3x/auto-build-desktop.sh` run this check, and the autobuild refuses to install a build +that fails it. + +`spctl -a -vvv` will still reject the app: it is not notarized. That is expected and unrelated — +see below. + +## What this does not fix + +**Gatekeeper on a freshly downloaded copy.** The app is signed but not notarized, so a download from +the releases page still hits the unidentified-developer wall. The update path is unaffected: the +updater strips `com.apple.quarantine` from both the dmg and the staged app +(`apps/desktop/src/t3x/updateDelivery/installCommands.ts`). Notarization needs the paid Developer +Program; this does not. + +**Anyone else's Mac.** The private key lives on one machine and in this repo's secrets. A different +person building this fork gets their own identity, hence their own prompts, once. + +**The bundle id shared with upstream's build.** Both apps report +`CFBundleIdentifier = com.t3tools.t3code`: + +``` +$ mdls -name kMDItemCFBundleIdentifier "/Applications/T3 Code (Alpha).app" \ + "/Applications/T3 Code (Nightly).app" +com.t3tools.t3code +com.t3tools.t3code +``` + +macOS keys a TCC row on `(service, client)` where `client` is that bundle id, so the two apps share +one row per permission and whichever launched most recently owns it. If you run both `T3 Code (Alpha)` +and upstream's `T3 Code (Nightly)`, expect a prompt when you switch — this fix cannot help with that, +because the two apps are the same app to macOS. Options, in increasing cost: stop keeping Nightly +installed, or give the fork its own bundle id (which means editing `DESKTOP_APP_ID` in +`scripts/build-desktop-artifact.ts` — an upstream-owned file, so a new `SEAMS.md` row, plus one more +round of prompts, plus a second copy of everything keyed to that id). + +## Why no upstream edit was needed + +`scripts/build-desktop-artifact.ts:1996` sets `CSC_IDENTITY_AUTO_DISCOVERY=false` whenever `--signed` +is absent, and issue #70's plan concluded from that a third signing mode had to be added to the file. +It does not: the flag is only consulted when **no identity was named**. + +```js +// app-builder-lib/out/codeSign/macCodeSign.js +function findIdentity(certType, qualifier, keychain) { + let identity = qualifier || process.env.CSC_NAME; + if (isEmptyOrSpaces(identity)) { + if (isAutoDiscoveryCodeSignIdentity()) return _findIdentity(certType, null, keychain); + else return Promise.resolve(null); // <- the only place the flag applies + } + return _findIdentity(certType, identity.trim(), keychain); +} +``` + +So exporting `CSC_NAME` around the existing unsigned build is enough, and the fork keeps its +zero-seam property. Two things follow, both load-bearing: + +- **An empty `CSC_NAME` is the same as no `CSC_NAME`.** A machine or a CI job without the identity + behaves exactly as before, which is why this is safe to wire in unconditionally. +- **Do not "simplify" it to `--signed`.** That flag takes the macOS passkey path, which requires + `T3CODE_CLERK_PUBLISHABLE_KEY` / `T3CODE_CLERK_PASSKEY_RP_DOMAINS` and a provisioning profile, and + turns on notarization expectations a self-signed certificate cannot satisfy. + +`type` is left at electron-builder's default (`distribution`), which looks for +`Developer ID Application` and then falls back to "any non-Apple certificate" — the branch our +self-signed identity is found by. Nothing about the mac build config changes. + +**Hardened runtime comes along with signing**, since electron-builder enables it for every non-MAS +signed build (`hardenedRuntime !== false`), and the app is signed with its default entitlements: +`allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`. That set is not a +guess — it is byte-identical to the runtime entitlements upstream uses for its own notarized builds +(`renderMacPasskeyEntitlements` in `scripts/build-desktop-artifact.ts`, minus the passkey keys), so a +signed fork build runs under the same restrictions as the `T3 Code (Nightly)` many people already +use. `disable-library-validation` in particular is what keeps the bundled native modules loadable. + +## Rotating the certificate + +Only if the key leaks or the certificate expires (2036). It costs one round of prompts: + +```bash +scripts/t3x/setup-mac-signing.sh --rotate +scripts/t3x/setup-mac-signing.sh --print-requirement > docs/t3x/mac-signing/designated-requirement.txt +cp ~/.t3x/mac-signing/t3x-signing.crt docs/t3x/mac-signing/certificate.pem +scripts/t3x/setup-mac-signing.sh --print-ci-secrets # then re-set both secrets +``` + +Commit the two files in the same change. The verify step compares against the recorded requirement, +so a rotation that forgets them fails the next release instead of quietly re-prompting the user. + +## Related + +- `docs/t3x/auto-build-runbook.md` — the local build/install loop, which signs the same way. +- Issue #41 — the autobuild relaunch race. Unrelated, adjacent. +- Issue #72 / PR #78 (still open, branch `t3x/install-instructions`) — the first-launch install copy, + written against the _current_ Gatekeeper verdict for a downloaded build ("damaged"). A valid + signature may well downgrade that to the ordinary unidentified-developer dialog, in which case the + strings in that PR's `scripts/t3x/install-instructions.json` want revisiting — the test that pins + the wording is what will say so. Unverified here on purpose: the update path never shows that + dialog, so nothing in this change has been able to observe it. diff --git a/docs/t3x/mac-signing/certificate.pem b/docs/t3x/mac-signing/certificate.pem new file mode 100644 index 000000000000..b04a889ac827 --- /dev/null +++ b/docs/t3x/mac-signing/certificate.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDGzCCAgOgAwIBAgIUZWvteB4frHkhQcTU2VKaKSUhUY4wDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQVDNYIENvZGUgU2lnbmluZzAeFw0yNjA4MTEyMjUzNDJa +Fw0zNjA4MDgyMjUzNDJaMBsxGTAXBgNVBAMMEFQzWCBDb2RlIFNpZ25pbmcwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9oyqRZjQz4kRHeRYcrqJNMaGv +uKUsQBcFtZ/xbwlcQezcLDfLxDh5D9TI+Qsg8JTD2XmLAp2K7y8V2sKgz5uLKHhm +43nihsw3g69IFMfBpMHP3hfweFw9OLe/5mL84VFos97IgR3/OVAS85dxlkgEuxLz +exxPdmLzoMlsn7vIQ9dwOgIdfs2vOiOT57xuw6fqGXjKoOtk8FgsVZrUBSOhbDVu +7deuKCXtQHPLmgha0Jdi716TXrG3DtVlDt28kbhHk8UFWCYhxQw4cB+Q8XcwbIZT +Bo5lrD28sxeTEaEzyE3R7XY1s6T4G6NQa5/7yfGrbBhdV+rBUIb1kKyPmsStAgMB +AAGjVzBVMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM +MAoGCCsGAQUFBwMDMB0GA1UdDgQWBBQDpiQgygasHT/5RBAUx2ZSaULgTTANBgkq +hkiG9w0BAQsFAAOCAQEAEX0TB/O9RPDjfiOo+UXIg8y/XAsEiZA0WZV4wtO8JhFn +8o5ADDW2laEXuMQEtrGCkRTqePNCOwqI9ya6YjdmcFsGmmtJf+9HHue+wpPewfGD +wTpdzdxGBlBnZsIHSFCXJezWE3wKNue34+MO1Ug3XTigxYThCnCbF0CIqnm7Upkz +EaM6symFmu3Zq+6e7gJYNvZ+mNBEA8rax6kqZVULz7j2a+znv/0KM67IAvuCeR4o +63Zr9gX+KUhYaWk+RccZaB/HBu4kTMj9xMppDJMV7B9KE/4spfn8edxPvNUq6ObF +Mb/augM5Nw+n1jD0nA6Pz7e3DO4clsh95s+prK2S1g== +-----END CERTIFICATE----- diff --git a/scripts/t3x/auto-build-desktop.sh b/scripts/t3x/auto-build-desktop.sh index 2a8a5d21ddf5..5129b393fbe7 100755 --- a/scripts/t3x/auto-build-desktop.sh +++ b/scripts/t3x/auto-build-desktop.sh @@ -305,6 +305,64 @@ acquire_lock() { return 0 } +# --- code signing ------------------------------------------------------------ +# Issue #70. An ad-hoc signed build's designated requirement is its own cdhash, so macOS sees a +# brand-new app after every install and re-requests Screen Recording, Accessibility, Microphone, +# Files & Folders and Local Network. Signing with the fork's stable identity is what makes a grant +# survive an update; see docs/t3x/mac-signing-runbook.md. +# +# electron-builder needs nothing from us but CSC_NAME. build-desktop-artifact.ts (upstream-owned) +# forces CSC_IDENTITY_AUTO_DISCOVERY=false for unsigned builds, but app-builder-lib consults that +# flag only when NO identity was named: findIdentity() reads `qualifier || process.env.CSC_NAME` +# first. An empty CSC_NAME counts as absent, so a machine with no identity keeps today's behaviour +# exactly — which is why this needs no upstream edit and no new SEAMS.md row. +SETUP_SIGNING="$SCRIPT_DIR/setup-mac-signing.sh" + +# Prints the identity name, or nothing at all when this machine has none set up. Never fails: an +# unsigned build is worse than a signed one but better than no build. +signing_identity() { + [[ -x "$SETUP_SIGNING" ]] || return 0 + # Keychains lock on reboot, and a locked keychain is invisible to `security find-identity -v` — + # which is indistinguishable from "no identity" at exactly the wrong moment (an unattended + # overnight build), so unlock first and ask second. + "$SETUP_SIGNING" --unlock >/dev/null 2>&1 || return 0 + "$SETUP_SIGNING" --status >/dev/null 2>&1 || return 0 + "$SETUP_SIGNING" --print-identity +} + +log_signing_state() { + local identity="$1" + if [[ -n "$identity" ]]; then + log "signing: '$identity' — permissions granted to the installed app will survive this update" + else + log "signing: NONE. This build will be ad-hoc signed, so macOS will ask for every permission" + log "signing: again after it installs — and again after the next build. Fix it once with:" + log "signing: scripts/t3x/setup-mac-signing.sh" + fi +} + +# Refuse to install a build that would cost the user a round of permission dialogs. +# +# The check runs against the built .dmg, and uses the verifier from the commit that was built (which +# is the build worktree when --ref is in play), not from this checkout. +verify_signature() { + local dmg="$1" identity="$2" + local verifier="$REPO/scripts/t3x/verify-mac-signature.ts" + local recorded="$REPO/docs/t3x/mac-signing/designated-requirement.txt" + [[ -f "$verifier" ]] || return 0 + + local args=(--artifact "$dmg") + [[ -f "$recorded" ]] && args+=(--expect-requirement-file "$recorded") + if [[ -n "$identity" ]]; then + args+=(--expect-authority "$identity") + else + # Only tolerated because this machine knowingly has no identity. A build signed by a + # DIFFERENT identity is never tolerated: it looks fixed and is not. + args+=(--allow-unsigned) + fi + ( cd "$REPO" && node "$verifier" "${args[@]}" ) +} + # --- install ----------------------------------------------------------------- # Answer "which .app would be installed?" WITHOUT changing anything. # @@ -368,7 +426,7 @@ install_dmg() { log "install: '$d_appbase' -> '$d_target'" log "DRY-RUN would: quit app '${d_appbase%.app}'" log "DRY-RUN would: rm -rf '$d_target' && cp -R '$d_target'" - log "DRY-RUN would: xattr -dr com.apple.quarantine '$d_target' (unsigned local build)" + log "DRY-RUN would: xattr -dr com.apple.quarantine '$d_target' (not notarized)" [[ $DO_RELAUNCH -eq 1 ]] && log "DRY-RUN would: open '$d_target'" # The footgun this preview exists to catch: if the target is absent, a real --install # CREATES a new app and silently leaves the one you actually launch untouched. @@ -450,6 +508,10 @@ build_once() { log "building desktop dmg for $cur (last built: ${last:-none})" + local signing_id + signing_id="$(signing_identity)" + log_signing_state "$signing_id" + if [[ $DRY_RUN -eq 1 ]]; then log "DRY-RUN would: pnpm dist:desktop:dmg:arm64 (cwd $REPO)" else @@ -475,7 +537,7 @@ build_once() { return 1 fi log "running: pnpm dist:desktop:dmg:arm64" - if ! ( cd "$REPO" && pnpm dist:desktop:dmg:arm64 ); then + if ! ( cd "$REPO" && CSC_NAME="$signing_id" pnpm dist:desktop:dmg:arm64 ); then write_status "build-failed" "$cur" "" "pnpm dist:desktop:dmg:arm64 failed" log "BUILD FAILED for $cur" return 1 @@ -494,6 +556,19 @@ build_once() { log "DRY-RUN newest existing dmg: ${dmg:-}" else log "built dmg: $dmg" + # Before the install, not after: a build whose identity moved would cost the user every + # permission dialog, and at that point the only honest thing to do is not install it. + # + # Captured rather than piped into `log`. `verify … | while read` would report the WHILE LOOP's + # status, which is always 0 — the same shape of bug the release workflow's retry loop documents. + local verify_log verify_status=0 + verify_log="$(verify_signature "$dmg" "$signing_id" 2>&1)" || verify_status=$? + while IFS= read -r line; do [[ -n "$line" ]] && log "$line"; done <<<"$verify_log" + if [[ $verify_status -ne 0 ]]; then + write_status "build-failed" "$cur" "$dmg" "signature verification failed" + log "BUILD FAILED for $cur (signature verification)" + return 1 + fi fi local install_failed=0 diff --git a/scripts/t3x/mac-signature.test.ts b/scripts/t3x/mac-signature.test.ts new file mode 100644 index 000000000000..2664db772c23 --- /dev/null +++ b/scripts/t3x/mac-signature.test.ts @@ -0,0 +1,265 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + DESKTOP_BUNDLE_IDENTIFIER, + evaluateMacSignature, + isCdhashKeyedRequirement, + normalizeRequirement, + parseCodesignDisplay, + parseDesignatedRequirement, +} from "./mac-signature.ts"; + +/** + * Both fixtures are verbatim `codesign` output, captured on 2026-08-11 from: + * + * codesign --display --verbose=4 "/Applications/T3 Code (Alpha).app" (the shipped fork build) + * codesign --display --verbose=4 + * + * Real output, not a hand-written approximation, because every bug this parser can have lives in + * the difference between the two shapes — `Signature=adhoc` versus `Signature size=…`, + * `Sealed Resources=none` versus `Sealed Resources version=…`, a requirement printed as a comment + * versus one printed as a clause. + */ +const ADHOC_DISPLAY = `Executable=/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha) +Identifier=Electron +Format=app bundle with Mach-O thin (arm64) +CodeDirectory v=20400 size=392 flags=0x20002(adhoc,linker-signed) hashes=9+0 location=embedded +Hash type=sha256 size=32 +CandidateCDHash sha256=d48d810e7b110d8d70a793f827dd23a7b2506405 +CDHash=d48d810e7b110d8d70a793f827dd23a7b2506405 +Signature=adhoc +Info.plist=not bound +TeamIdentifier=not set +Sealed Resources=none +Internal requirements=none +`; + +const ADHOC_REQUIREMENTS = `Executable=/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha) +# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405" +`; + +const SIGNED_DISPLAY = `Executable=/private/tmp/sigtest/A.app/Contents/MacOS/probe +Identifier=com.t3tools.t3code +Format=app bundle with Mach-O thin (arm64) +CodeDirectory v=20500 size=286 flags=0x10000(runtime) hashes=2+3 location=embedded +Hash type=sha256 size=32 +CDHash=8e474702803ad42fe7e5855f2efd10af1eb75d94 +Signature size=4793 +Authority=T3X Code Signing +Signed Time=Aug 11, 2026 at 18:43:41 +Info.plist entries=4 +TeamIdentifier=not set +Sealed Resources version=2 rules=13 files=1 +Internal requirements count=1 size=192 +`; + +/** + * The shape a self-signed certificate produces: no Apple anchor, so codesign pins the exact leaf. + * Stable across rebuilds because the certificate does not change — which is the entire fix. + */ +const SELF_SIGNED_REQUIREMENT = + 'identifier "com.t3tools.t3code" and certificate leaf = H"6dc6e7effe78c5b8406fde43b9afaaf5a85c8eba"'; + +describe("parseCodesignDisplay", () => { + it("reads an ad-hoc bundle as having no certificate, no seal and an unbound Info.plist", () => { + const display = parseCodesignDisplay(ADHOC_DISPLAY); + + assert.strictEqual(display.identifier, "Electron"); + assert.strictEqual(display.signature, "adhoc"); + assert.deepStrictEqual([...display.flags], ["adhoc", "linker-signed"]); + assert.deepStrictEqual([...display.authorities], []); + assert.strictEqual(display.sealedResources, undefined); + assert.strictEqual(display.infoPlistBound, false); + assert.strictEqual(display.teamIdentifier, undefined); + }); + + it("reads a certificate-signed bundle, whose Sealed Resources line has no '=' after the label", () => { + const display = parseCodesignDisplay(SIGNED_DISPLAY); + + assert.strictEqual(display.identifier, "com.t3tools.t3code"); + // `Signature size=4793` is not a `Signature=` field, and must not be read as one. + assert.strictEqual(display.signature, undefined); + assert.deepStrictEqual([...display.flags], ["runtime"]); + assert.deepStrictEqual([...display.authorities], ["T3X Code Signing"]); + assert.strictEqual(display.sealedResources, "version=2 rules=13 files=1"); + assert.strictEqual(display.infoPlistBound, true); + }); + + it("keeps every Authority line, leaf first", () => { + const display = parseCodesignDisplay(`Identifier=com.t3tools.t3code +Authority=Apple Development: someone@example.com (TEAMID1234) +Authority=Apple Worldwide Developer Relations Certification Authority +Authority=Apple Root CA +Sealed Resources version=2 rules=13 files=1 +`); + + assert.strictEqual( + display.authorities[0], + "Apple Development: someone@example.com (TEAMID1234)", + ); + assert.strictEqual(display.authorities.length, 3); + }); +}); + +describe("parseDesignatedRequirement", () => { + it("reads the ad-hoc requirement, which codesign prints as a COMMENT", () => { + assert.strictEqual( + parseDesignatedRequirement(ADHOC_REQUIREMENTS), + 'cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"', + ); + }); + + it("re-joins a requirement that codesign wrapped across lines", () => { + const wrapped = `Executable=/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha) +designated => identifier "com.t3tools.t3code" and anchor apple generic + and certificate leaf[subject.CN] = "Apple Development: someone@example.com (TEAMID1234)" + and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */ +`; + + assert.strictEqual( + parseDesignatedRequirement(wrapped), + 'identifier "com.t3tools.t3code" and anchor apple generic and certificate leaf[subject.CN] = ' + + '"Apple Development: someone@example.com (TEAMID1234)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */', + ); + }); + + it("stops at the next requirement clause", () => { + const multiple = `designated => identifier "com.t3tools.t3code" and certificate leaf = H"abc" +host => anchor apple +`; + + assert.strictEqual( + parseDesignatedRequirement(multiple), + 'identifier "com.t3tools.t3code" and certificate leaf = H"abc"', + ); + }); + + it("returns undefined when there is no designated requirement at all", () => { + assert.strictEqual(parseDesignatedRequirement("Executable=/tmp/x\n"), undefined); + }); +}); + +describe("isCdhashKeyedRequirement", () => { + it("recognises the unstable shape", () => { + assert.strictEqual( + isCdhashKeyedRequirement('cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"'), + true, + ); + }); + + it("does not mistake a certificate-keyed requirement for it", () => { + assert.strictEqual(isCdhashKeyedRequirement(SELF_SIGNED_REQUIREMENT), false); + }); +}); + +describe("evaluateMacSignature", () => { + it("calls the shipped ad-hoc build unsigned, and says why the grants do not survive", () => { + const verdict = evaluateMacSignature({ + display: parseCodesignDisplay(ADHOC_DISPLAY), + requirement: parseDesignatedRequirement(ADHOC_REQUIREMENTS), + }); + + assert.strictEqual(verdict.kind, "unsigned"); + assert.ok(verdict.problems.some((problem) => problem.includes("ad-hoc signed"))); + assert.ok(verdict.problems.some((problem) => problem.includes("keyed to a cdhash"))); + assert.ok(verdict.problems.some((problem) => problem.includes("Electron"))); + assert.ok(verdict.problems.some((problem) => problem.includes("Sealed Resources=none"))); + assert.ok(verdict.problems.some((problem) => problem.includes("Info.plist=not bound"))); + }); + + it("accepts a certificate-signed bundle whose requirement matches the recorded one", () => { + const verdict = evaluateMacSignature({ + display: parseCodesignDisplay(SIGNED_DISPLAY), + requirement: SELF_SIGNED_REQUIREMENT, + expectation: { requirement: SELF_SIGNED_REQUIREMENT, authority: "T3X Code Signing" }, + }); + + assert.deepStrictEqual([...verdict.problems], []); + assert.strictEqual(verdict.kind, "stable"); + assert.strictEqual(verdict.requirement, SELF_SIGNED_REQUIREMENT); + assert.strictEqual(verdict.authority, "T3X Code Signing"); + }); + + it("ignores whitespace differences between the recorded and actual requirement", () => { + const verdict = evaluateMacSignature({ + display: parseCodesignDisplay(SIGNED_DISPLAY), + requirement: SELF_SIGNED_REQUIREMENT, + expectation: { requirement: `\n ${SELF_SIGNED_REQUIREMENT.replace(/ and /, "\n\tand ")}\n` }, + }); + + assert.strictEqual(verdict.kind, "stable"); + }); + + it("REJECTS a signed bundle whose identity changed — signed is not the same as unchanged", () => { + const verdict = evaluateMacSignature({ + display: parseCodesignDisplay(SIGNED_DISPLAY), + requirement: SELF_SIGNED_REQUIREMENT, + expectation: { + requirement: 'identifier "com.t3tools.t3code" and certificate leaf = H"0000000000"', + }, + }); + + assert.strictEqual(verdict.kind, "unstable"); + assert.ok( + verdict.problems.some((problem) => problem.includes("designated requirement changed")), + ); + }); + + it("rejects a signature that claims the wrong bundle id", () => { + const verdict = evaluateMacSignature({ + display: parseCodesignDisplay( + SIGNED_DISPLAY.replace("com.t3tools.t3code", "com.example.other"), + ), + requirement: SELF_SIGNED_REQUIREMENT, + }); + + assert.strictEqual(verdict.kind, "unstable"); + assert.ok(verdict.problems.some((problem) => problem.includes("com.example.other"))); + }); + + it("rejects an unexpected signing identity", () => { + const verdict = evaluateMacSignature({ + display: parseCodesignDisplay(SIGNED_DISPLAY), + requirement: SELF_SIGNED_REQUIREMENT, + expectation: { authority: "Somebody Else" }, + }); + + assert.strictEqual(verdict.kind, "unstable"); + assert.ok(verdict.problems.some((problem) => problem.includes("leaf authority"))); + }); +}); + +describe("normalizeRequirement", () => { + it("collapses codesign's line wrapping so recorded requirements compare equal", () => { + assert.strictEqual( + normalizeRequirement(' identifier "x"\n\tand anchor apple \n'), + 'identifier "x" and anchor apple', + ); + }); +}); + +it.layer(NodeServices.layer)("mirrored upstream constants", (it) => { + /** + * The verifier checks the signature claims OUR bundle id, and that id is defined in + * scripts/build-desktop-artifact.ts — an upstream-owned file that does not export it. This test + * is the drift detector for that copy: if upstream renames the app id, this fails loudly instead + * of the verifier quietly asserting a bundle id nothing produces any more. + */ + it.effect("DESKTOP_BUNDLE_IDENTIFIER still matches DESKTOP_APP_ID upstream", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const source = yield* fs.readFileString( + path.join(import.meta.dirname, "..", "build-desktop-artifact.ts"), + ); + + const match = /const DESKTOP_APP_ID = "([^"]+)"/.exec(source); + assert.ok(match, "DESKTOP_APP_ID is no longer declared as a string literal in that file"); + assert.strictEqual(match[1], DESKTOP_BUNDLE_IDENTIFIER); + }), + ); +}); diff --git a/scripts/t3x/mac-signature.ts b/scripts/t3x/mac-signature.ts new file mode 100644 index 000000000000..737eccd92c1b --- /dev/null +++ b/scripts/t3x/mac-signature.ts @@ -0,0 +1,257 @@ +/** + * Reading a macOS code signature, and deciding whether it is the kind that keeps TCC grants. + * + * Issue #70: the fork's desktop builds were ad-hoc signed, so `codesign` derived each bundle's + * DESIGNATED REQUIREMENT from the binary's cdhash. TCC (the permissions database) stores that + * requirement alongside the grant and re-evaluates it on every access, so a requirement that + * changes on every build makes every update look like a brand-new app — and re-asks for Screen + * Recording, Accessibility, Microphone, Files & Folders and Local Network from scratch. + * + * The fix is not "sign the app" but "sign it with a stable identity". Those are different claims, + * and only the second one keeps the grants: + * + * ad-hoc designated => cdhash H"d48d810e…" <- new every build + * certificate designated => identifier "com.t3tools.t3code" and … <- same every build + * + * So the check this file exists to express is about the SHAPE of the requirement, not the presence + * of a signature. Everything here is pure: the codesign output is parsed, never produced, which is + * what makes the decision testable without a 470 MB build. + */ + +/** + * LOGIC MIRROR of `DESKTOP_APP_ID` in scripts/build-desktop-artifact.ts, which is upstream-owned + * and does not export it. `mac-signature.test.ts` reads that file and fails if the two drift, so a + * rename upstream surfaces as a red test rather than as a verifier that silently checks the wrong + * bundle id. + */ +export const DESKTOP_BUNDLE_IDENTIFIER = "com.t3tools.t3code"; + +/** + * The fork's self-signed code-signing identity, created by scripts/t3x/setup-mac-signing.sh. + * + * A plain name, not a hash: `codesign` and electron-builder both look identities up by name + * (`CSC_NAME`), and the name is what has to match between this Mac's login keychain and the + * ephemeral keychain the release workflow builds from the p12 secret. + */ +export const MAC_SIGNING_IDENTITY_NAME = "T3X Code Signing"; + +export interface CodesignDisplay { + /** `Identifier=` — the SIGNING identifier. Ad-hoc Electron bundles report `Electron` here. */ + readonly identifier: string | undefined; + /** `Signature=` — `adhoc` when there is no certificate at all. */ + readonly signature: string | undefined; + /** The `CodeDirectory … flags=0x…(…)` flag names, e.g. `["adhoc", "linker-signed"]`. */ + readonly flags: readonly string[]; + /** Every `Authority=` line, leaf first. Empty for an ad-hoc signature. */ + readonly authorities: readonly string[]; + readonly teamIdentifier: string | undefined; + /** `Sealed Resources version=…`, or undefined when codesign printed `Sealed Resources=none`. */ + readonly sealedResources: string | undefined; + /** False when codesign printed `Info.plist=not bound` — the signature does not cover our plist. */ + readonly infoPlistBound: boolean; +} + +const FLAGS_PATTERN = /^CodeDirectory\b.*\bflags=0x[0-9a-f]+(?:\(([^)]*)\))?/im; + +/** + * Parse `codesign --display --verbose=4`. + * + * Note for anyone calling codesign directly: it writes this report to STDERR, not stdout. Reading + * only stdout yields an empty string, which parses to "no signature at all" and would report a + * correctly signed app as broken. + */ +export function parseCodesignDisplay(text: string): CodesignDisplay { + const field = (name: string): string | undefined => { + const match = new RegExp(`^${name}=(.*)$`, "m").exec(text); + const value = match?.[1]?.trim(); + return value === undefined || value.length === 0 ? undefined : value; + }; + + const flagsMatch = FLAGS_PATTERN.exec(text); + const flags = (flagsMatch?.[1] ?? "") + .split(",") + .map((flag) => flag.trim()) + .filter((flag) => flag.length > 0); + + const authorities = [...text.matchAll(/^Authority=(.*)$/gm)] + .map((match) => (match[1] ?? "").trim()) + .filter((authority) => authority.length > 0); + + // Two shapes, and only one of them has an `=` after the label: + // Sealed Resources=none (nothing sealed) + // Sealed Resources version=2 rules=13 files=1234 (sealed) + // Reading it as a plain `Label=value` field matches only the first, which reports every + // correctly signed bundle as sealing nothing. + const sealedMatch = /^Sealed Resources (version=.*)$/m.exec(text); + const teamIdentifier = field("TeamIdentifier"); + + return { + identifier: field("Identifier"), + signature: field("Signature"), + flags, + authorities, + // A self-signed certificate has no team, so codesign prints `not set`. That is expected for + // the fork's identity, not a defect — hence normalized away rather than reported. + teamIdentifier: teamIdentifier === "not set" ? undefined : teamIdentifier, + sealedResources: sealedMatch?.[1], + // Absence is the signed case: codesign prints this line only to report the NEGATIVE. + infoPlistBound: !/^Info\.plist=not bound$/m.test(text), + }; +} + +/** + * Pull the requirement out of `codesign --display --requirements -`. + * + * Line-based rather than one regex, for two reasons the real output forces: + * + * - A long requirement WRAPS, so the clause continues on following lines and has to be re-joined. + * - An ad-hoc bundle's clause is emitted as a COMMENT — `# designated => cdhash H"…"` — which is + * the single most important case to recognise, so the leading `#` cannot be part of the anchor. + * + * The clause ends at the next ` => …` line (codesign prints several) or at a bare field line + * such as `Executable=…`, whichever comes first. + */ +export function parseDesignatedRequirement(text: string): string | undefined { + const lines = text.split("\n"); + const startIndex = lines.findIndex((line) => /^\s*(?:#\s*)?designated\s*=>/.test(line)); + if (startIndex === -1) return undefined; + + const parts = [lines[startIndex]!.replace(/^\s*(?:#\s*)?designated\s*=>\s*/, "")]; + for (const line of lines.slice(startIndex + 1)) { + if (/^\s*(?:#\s*)?[\w.]+\s*=>/.test(line) || /^\S+=/.test(line)) break; + parts.push(line); + } + + const normalized = normalizeRequirement(parts.join(" ")); + return normalized.length === 0 ? undefined : normalized; +} + +/** + * Collapse whitespace so a requirement can be compared byte-for-byte against a recorded one. + * + * codesign's own line wrapping depends on the length of the identifier and of the certificate + * hash, neither of which is a semantic difference. + */ +export function normalizeRequirement(requirement: string): string { + return requirement.replace(/\s+/g, " ").trim(); +} + +/** + * Is this requirement keyed to the binary's hash rather than to a certificate? + * + * `cdhash H"…"` is what codesign falls back to when there is no certificate to name. It is the + * whole bug: the hash changes whenever a single byte of the app changes, so the grant is voided by + * the next build. An ad-hoc bundle's requirement is the bare form; a signed bundle's requirement + * names an identifier and a certificate. + */ +export function isCdhashKeyedRequirement(requirement: string): boolean { + return /\bcdhash\s+H"/i.test(requirement); +} + +export type MacSignatureVerdictKind = "stable" | "unstable" | "unsigned"; + +export interface MacSignatureExpectation { + /** The bundle id the signature must claim. Defaults to the desktop app's. */ + readonly identifier?: string | undefined; + /** + * The exact designated requirement previous releases were signed with, normalized. When present, + * a mismatch is a failure even though the artifact is perfectly signed: a DIFFERENT stable + * identity still costs the user every permission dialog once. + */ + readonly requirement?: string | undefined; + /** The identity name expected in the leaf `Authority=`, when it should be pinned. */ + readonly authority?: string | undefined; +} + +export interface MacSignatureVerdict { + readonly kind: MacSignatureVerdictKind; + /** Empty when the artifact is signed with the expected stable identity. */ + readonly problems: readonly string[]; + readonly identifier: string | undefined; + readonly requirement: string | undefined; + readonly authority: string | undefined; +} + +/** + * Judge a parsed signature against what a TCC-stable release has to look like. + * + * `unsigned` and `unstable` are kept apart deliberately. Unsigned is the state the fork shipped in + * before #70 and is what a build with no signing secret still produces — a caller may allow it. + * Unstable means the artifact IS signed but the requirement moved, which no caller should allow, + * because it is indistinguishable from the bug at the point where the user notices it. + */ +export function evaluateMacSignature(input: { + readonly display: CodesignDisplay; + readonly requirement: string | undefined; + readonly expectation?: MacSignatureExpectation; +}): MacSignatureVerdict { + const { display, requirement } = input; + const expectedIdentifier = input.expectation?.identifier ?? DESKTOP_BUNDLE_IDENTIFIER; + const problems: string[] = []; + const authority = display.authorities[0]; + + const isAdhoc = + display.signature === "adhoc" || + display.flags.includes("adhoc") || + display.authorities.length === 0; + + if (isAdhoc) { + problems.push( + "the bundle is ad-hoc signed (no certificate), so its designated requirement is a cdhash that changes every build", + ); + } + if (requirement === undefined) { + problems.push("codesign reported no designated requirement"); + } else if (isCdhashKeyedRequirement(requirement)) { + problems.push(`designated requirement is keyed to a cdhash: ${requirement}`); + } + if (display.identifier !== expectedIdentifier) { + problems.push( + `signing identifier is ${display.identifier ?? "absent"}, expected ${expectedIdentifier}`, + ); + } + if (display.sealedResources === undefined) { + problems.push("the signature seals no resources (Sealed Resources=none)"); + } + if (!display.infoPlistBound) { + problems.push("the signature does not cover Info.plist (Info.plist=not bound)"); + } + + if (isAdhoc) { + return { kind: "unsigned", problems, identifier: display.identifier, requirement, authority }; + } + + const expectedRequirement = input.expectation?.requirement; + if (expectedRequirement !== undefined && requirement !== undefined) { + const expected = normalizeRequirement(expectedRequirement); + if (expected !== requirement) { + problems.push( + `designated requirement changed, so every macOS permission would be re-requested once\n expected: ${expected}\n actual: ${requirement}`, + ); + } + } + + const expectedAuthority = input.expectation?.authority; + if (expectedAuthority !== undefined && authority !== expectedAuthority) { + problems.push(`leaf authority is ${authority ?? "absent"}, expected ${expectedAuthority}`); + } + + return { + kind: problems.length === 0 ? "stable" : "unstable", + problems, + identifier: display.identifier, + requirement, + authority, + }; +} + +/** One-line summary for a build log. */ +export function formatMacSignatureVerdict(verdict: MacSignatureVerdict): string { + const identity = verdict.authority ?? "ad-hoc"; + return [ + `signature: ${verdict.kind}`, + `identifier: ${verdict.identifier ?? "absent"}`, + `identity: ${identity}`, + `designated: ${verdict.requirement ?? "absent"}`, + ].join("\n "); +} diff --git a/scripts/t3x/setup-mac-signing.sh b/scripts/t3x/setup-mac-signing.sh new file mode 100755 index 000000000000..d227017c3389 --- /dev/null +++ b/scripts/t3x/setup-mac-signing.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +# +# t3x — create the macOS code-signing identity that stops the permission prompts. +# +# Issue #70. macOS keys every permission grant (Screen Recording, Accessibility, Microphone, +# Files & Folders, Local Network) to the app's DESIGNATED REQUIREMENT, which codesign derives from +# the signing certificate. With no certificate the requirement degrades to the binary's cdhash: +# +# $ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" +# # designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405" +# +# That hash changes on every build, so every update is a brand-new app to macOS and every grant is +# re-requested. Signing with a certificate — any certificate, including a self-signed one — replaces +# the hash with `identifier "com.t3tools.t3code" and certificate leaf = H""`, which is +# identical across every rebuild. That is the whole fix: the certificate does not have to be +# TRUSTED by Apple, it has to be STABLE. +# +# What this creates: +# ~/.t3x/mac-signing/ the certificate, its key as a .p12, and two passwords (0600) +# ~/Library/Keychains/t3x-signing.keychain-db a dedicated keychain holding the identity +# an admin-domain trust setting so `security find-identity -v -p codesigning` calls it valid +# +# A dedicated keychain, not your login keychain, for one reason: its password is one we generate, so +# `security set-key-partition-list` can be run non-interactively and `codesign` never raises the +# "wants to use a key in your keychain" dialog. An unattended autobuild cannot answer a dialog. +# +# Usage: +# scripts/t3x/setup-mac-signing.sh # create it (idempotent), then self-verify +# scripts/t3x/setup-mac-signing.sh --status # report; exit 0 only if usable right now +# scripts/t3x/setup-mac-signing.sh --unlock # unlock the keychain (builds call this) +# scripts/t3x/setup-mac-signing.sh --print-identity # the CSC_NAME value +# scripts/t3x/setup-mac-signing.sh --print-requirement # the designated requirement it produces +# scripts/t3x/setup-mac-signing.sh --print-ci-secrets # values for the two GitHub secrets +# scripts/t3x/setup-mac-signing.sh --rotate # NEW certificate: re-prompts once more +# +# One sudo prompt, once per machine: marking a self-signed certificate trusted for code signing is +# an admin operation. Without it `security find-identity -v -p codesigning` lists nothing and +# electron-builder silently falls back to an ad-hoc build. +# +# Env: +# T3X_MAC_SIGNING_IDENTITY (default: T3X Code Signing) — the certificate's common name +# T3X_MAC_SIGNING_DIR (default: ~/.t3x/mac-signing) +# +set -euo pipefail + +IDENTITY_NAME="${T3X_MAC_SIGNING_IDENTITY:-T3X Code Signing}" +SIGNING_DIR="${T3X_MAC_SIGNING_DIR:-$HOME/.t3x/mac-signing}" +# `security create-keychain ` puts the file in ~/Library/Keychains and appends `-db`, so the +# two names below are the same keychain spelled the two ways the tool needs it. +KEYCHAIN_CREATE_NAME="${T3X_MAC_SIGNING_KEYCHAIN_NAME:-t3x-signing.keychain}" +KEYCHAIN_PATH="${T3X_MAC_SIGNING_KEYCHAIN:-$HOME/Library/Keychains/${KEYCHAIN_CREATE_NAME}-db}" +CERT_PATH="$SIGNING_DIR/t3x-signing.crt" +KEY_PATH="$SIGNING_DIR/t3x-signing.key" +P12_PATH="$SIGNING_DIR/t3x-signing.p12" +P12_PASSWORD_PATH="$SIGNING_DIR/p12-password" +KEYCHAIN_PASSWORD_PATH="$SIGNING_DIR/keychain-password" +VALIDITY_DAYS=3650 +BUNDLE_ID="com.t3tools.t3code" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || printf '')" + +MODE="ensure" +for arg in "$@"; do + case "$arg" in + --status) MODE="status" ;; + --unlock) MODE="unlock" ;; + --print-identity) MODE="print-identity" ;; + --print-requirement) MODE="print-requirement" ;; + --print-ci-secrets) MODE="print-ci-secrets" ;; + --rotate) MODE="rotate" ;; + -h|--help) sed -n '2,48p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) printf 'unknown argument: %s (try --help)\n' "$arg" >&2; exit 2 ;; + esac +done + +log() { printf '[mac-signing] %s\n' "$*" >&2; } +die() { printf '[mac-signing] ERROR: %s\n' "$*" >&2; exit 1; } + +[[ "$(uname -s)" == "Darwin" ]] || die "macOS only (this is where TCC lives)." + +# --- primitives -------------------------------------------------------------- + +keychain_exists() { [[ -f "$KEYCHAIN_PATH" ]]; } + +read_password() { cat "$1" 2>/dev/null || printf ''; } + +unlock_keychain() { + local password + password="$(read_password "$KEYCHAIN_PASSWORD_PATH")" + [[ -n "$password" ]] || return 1 + security unlock-keychain -p "$password" "$KEYCHAIN_PATH" 2>/dev/null +} + +# The identity is usable only when `security find-identity -v -p codesigning` lists it: that is the +# exact command electron-builder runs (app-builder-lib/out/codeSign/macCodeSign.js, getValidIdentities), +# and -v means "valid", which for a self-signed certificate means "trusted for code signing". +identity_is_valid() { + security find-identity -v -p codesigning "$KEYCHAIN_PATH" 2>/dev/null | + grep -Fq "\"$IDENTITY_NAME\"" +} + +# electron-builder calls `security find-identity -v` with NO keychain argument, so the identity has +# to be reachable from the user's search list, not merely present in a file on disk. +keychain_in_search_list() { + security list-keychains -d user | tr -d '"' | tr -d ' ' | grep -Fq "$KEYCHAIN_PATH" +} + +add_keychain_to_search_list() { + if keychain_in_search_list; then return 0; fi + # `list-keychains -s` REPLACES the list, so the current entries have to be read and passed back. + # Dropping the login keychain here would break password autofill for every app on the machine. + local existing=() + while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%\"}" + line="${line#\"}" + [[ -n "$line" ]] && existing+=("$line") + done < <(security list-keychains -d user) + log "adding to the user keychain search list: $KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" "${existing[@]}" +} + +trust_is_set() { + # -d is the admin domain, where `add-trusted-cert -d` writes. + security dump-trust-settings -d 2>/dev/null | grep -Fq "$IDENTITY_NAME" +} + +# --- reporting --------------------------------------------------------------- + +report_status() { + local ok=0 + if keychain_exists; then log "keychain: $KEYCHAIN_PATH"; else log "keychain: MISSING"; ok=1; fi + if [[ -f "$CERT_PATH" ]]; then + log "certificate: $CERT_PATH ($(openssl x509 -in "$CERT_PATH" -noout -enddate 2>/dev/null | sed 's/notAfter=/expires /'))" + else + log "certificate: MISSING"; ok=1 + fi + if trust_is_set; then log "trust: set (admin domain, code signing)"; else log "trust: NOT set"; ok=1; fi + if keychain_in_search_list; then log "search list: present"; else log "search list: absent"; ok=1; fi + if keychain_exists && unlock_keychain; then log "unlock: ok"; else log "unlock: FAILED"; ok=1; fi + if identity_is_valid; then + log "identity: '$IDENTITY_NAME' is valid for code signing" + else + log "identity: '$IDENTITY_NAME' NOT valid for code signing"; ok=1 + fi + return "$ok" +} + +# Sign a throwaway bundle carrying the real bundle id and report the requirement it produces. +# +# This is how the designated requirement can be recorded WITHOUT a 470 MB desktop build: the +# requirement is a function of exactly two things — the signing identifier and the certificate — +# and a four-file stub bundle has both. The string this prints is byte-for-byte what the shipped +# .app gets, which is what makes docs/t3x/mac-signing/designated-requirement.txt trustworthy. +print_requirement() { + identity_is_valid || die "identity '$IDENTITY_NAME' is not usable yet; run this script with no arguments first." + unlock_keychain || die "could not unlock $KEYCHAIN_PATH" + + local stub + stub="$(mktemp -d "${TMPDIR:-/tmp}/t3x-sig-stub.XXXXXX")" + # shellcheck disable=SC2064 + trap "rm -rf '$stub'" RETURN + + mkdir -p "$stub/Stub.app/Contents/MacOS" "$stub/Stub.app/Contents/Resources" + # A copied system binary, so this works on a machine with no compiler installed. + cp /usr/bin/true "$stub/Stub.app/Contents/MacOS/stub" + /usr/libexec/PlistBuddy \ + -c "Add :CFBundleIdentifier string $BUNDLE_ID" \ + -c "Add :CFBundleExecutable string stub" \ + -c "Add :CFBundleName string Stub" \ + -c "Add :CFBundlePackageType string APPL" \ + "$stub/Stub.app/Contents/Info.plist" >/dev/null + + codesign --force --sign "$IDENTITY_NAME" --keychain "$KEYCHAIN_PATH" \ + --identifier "$BUNDLE_ID" --options runtime --timestamp=none "$stub/Stub.app" >/dev/null 2>&1 || + die "codesign failed with identity '$IDENTITY_NAME'" + + codesign -d --requirements - "$stub/Stub.app" 2>&1 | + sed -n 's/^ *designated => //p' +} + +print_ci_secrets() { + [[ -f "$P12_PATH" ]] || die "no p12 at $P12_PATH; run this script with no arguments first." + cat < "$P12_PATH.base64" + chmod 600 "$P12_PATH.base64" + log "wrote $P12_PATH.base64" +} + +# --- creation ---------------------------------------------------------------- + +create_identity() { + mkdir -p "$SIGNING_DIR" + chmod 700 "$SIGNING_DIR" + + local keychain_password p12_password + if [[ ! -f "$KEYCHAIN_PASSWORD_PATH" ]]; then + openssl rand -base64 24 | tr -d '\n' > "$KEYCHAIN_PASSWORD_PATH" + chmod 600 "$KEYCHAIN_PASSWORD_PATH" + fi + if [[ ! -f "$P12_PASSWORD_PATH" ]]; then + openssl rand -base64 24 | tr -d '\n' > "$P12_PASSWORD_PATH" + chmod 600 "$P12_PASSWORD_PATH" + fi + keychain_password="$(read_password "$KEYCHAIN_PASSWORD_PATH")" + p12_password="$(read_password "$P12_PASSWORD_PATH")" + + if [[ ! -f "$CERT_PATH" || ! -f "$KEY_PATH" ]]; then + log "creating a $VALIDITY_DAYS-day self-signed code-signing certificate: $IDENTITY_NAME" + local config + config="$(mktemp "${TMPDIR:-/tmp}/t3x-openssl.XXXXXX")" + # A config file rather than `-addext`, which LibreSSL — what /usr/bin/openssl is on a stock + # macOS — does not accept. + cat > "$config" </dev/null + rm -f "$config" + chmod 600 "$KEY_PATH" + fi + + if [[ ! -f "$P12_PATH" ]]; then + # PBE-SHA1-3DES and -macalg sha1 are load-bearing. OpenSSL 3 defaults to AES-256-CBC with a + # SHA-256 MAC, which macOS `security import` rejects with the misleading + # "MAC verification failed during PKCS12 import (wrong password?)" — the password is fine, the + # algorithm is not. + openssl pkcs12 -export -inkey "$KEY_PATH" -in "$CERT_PATH" -out "$P12_PATH" \ + -name "$IDENTITY_NAME" -passout "pass:$p12_password" \ + -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 + chmod 600 "$P12_PATH" + fi + + if ! keychain_exists; then + log "creating keychain $KEYCHAIN_PATH" + security create-keychain -p "$keychain_password" "$KEYCHAIN_CREATE_NAME" + # No -t: a keychain that auto-locks after N seconds of idleness makes an overnight autobuild + # fail with "no identity found" for no visible reason. + security set-keychain-settings "$KEYCHAIN_PATH" + fi + unlock_keychain || die "could not unlock $KEYCHAIN_PATH" + + if ! security find-certificate -c "$IDENTITY_NAME" "$KEYCHAIN_PATH" >/dev/null 2>&1; then + log "importing the identity" + security import "$P12_PATH" -k "$KEYCHAIN_PATH" -P "$p12_password" \ + -T /usr/bin/codesign -T /usr/bin/security + # Without this, codesign gets a GUI keychain-access prompt instead of the key, even though + # -T named it above. macOS 10.12 split the two. + security set-key-partition-list -S apple-tool:,apple:,codesign: -s \ + -k "$keychain_password" "$KEYCHAIN_PATH" >/dev/null + fi + + add_keychain_to_search_list + + if ! trust_is_set; then + # The one privileged step: a self-signed certificate is not "valid for code signing" until it is + # trusted, and `security find-identity -v -p codesigning` — the command electron-builder asks — + # lists nothing without it. Never prompt blindly: `sudo` with no terminal to answer on hangs, + # and this script is called from unattended builds and from agents. + if sudo -n true 2>/dev/null || [[ -t 0 ]]; then + log "marking the certificate trusted for code signing (sudo may ask for your password)" + sudo security add-trusted-cert -d -r trustRoot -p codeSign \ + -k /Library/Keychains/System.keychain "$CERT_PATH" + else + log "the certificate exists but is not trusted yet, and there is no terminal here to ask for" + log "sudo on. Run this one command, then re-run this script:" + log "" + log " sudo security add-trusted-cert -d -r trustRoot -p codeSign \\" + log " -k /Library/Keychains/System.keychain '$CERT_PATH'" + log "" + die "trust step not completed" + fi + fi + + identity_is_valid || + die "the identity still is not valid for code signing. Check: security find-identity -v -p codesigning '$KEYCHAIN_PATH'" + + log "identity ready: $IDENTITY_NAME" +} + +# --- modes ------------------------------------------------------------------- + +case "$MODE" in + status) + report_status + ;; + unlock) + unlock_keychain || die "could not unlock $KEYCHAIN_PATH (has the identity been created?)" + ;; + print-identity) + printf '%s\n' "$IDENTITY_NAME" + ;; + print-requirement) + print_requirement + ;; + print-ci-secrets) + print_ci_secrets + ;; + rotate) + log "ROTATING the signing identity. Every macOS permission will be asked for ONE more time" + log "after the next build installs, because the designated requirement changes with the" + log "certificate. Ctrl-C now if that is not what you want." + sleep 5 + security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true + rm -f "$CERT_PATH" "$KEY_PATH" "$P12_PATH" "$P12_PATH.base64" + create_identity + log "new designated requirement: $(print_requirement)" + log "Record it: scripts/t3x/setup-mac-signing.sh --print-requirement > docs/t3x/mac-signing/designated-requirement.txt" + log "And re-set the CI secrets: scripts/t3x/setup-mac-signing.sh --print-ci-secrets" + ;; + ensure) + create_identity + requirement="$(print_requirement)" + log "designated requirement: $requirement" + if [[ -n "$REPO" ]]; then + recorded="$REPO/docs/t3x/mac-signing/designated-requirement.txt" + if [[ -f "$recorded" ]] && [[ "$(tr -d '\n' < "$recorded")" != "$requirement" ]]; then + log "WARNING: this differs from the recorded requirement in" + log " $recorded" + log " recorded: $(tr -d '\n' < "$recorded")" + log "A release signed with this identity would re-request every permission once." + fi + fi + log "" + log "Next: builds pick the identity up through CSC_NAME. scripts/t3x/auto-build-desktop.sh does" + log "that on its own; the release workflow needs the two secrets from --print-ci-secrets." + ;; +esac diff --git a/scripts/t3x/verify-mac-signature.ts b/scripts/t3x/verify-mac-signature.ts new file mode 100644 index 000000000000..8802d77f7a96 --- /dev/null +++ b/scripts/t3x/verify-mac-signature.ts @@ -0,0 +1,326 @@ +#!/usr/bin/env node +/** + * Fail a build that would re-ask the user for every macOS permission. + * + * Issue #70. electron-builder does NOT fail when it cannot find a signing identity — it logs + * `skipped macOS application code signing` as a warning and produces an ad-hoc bundle. That is the + * failure mode worth spending a script on: the release goes green, the dmg installs, and the bug + * reappears three days later as "the permission prompts are back", with nothing in the log anyone + * would think to re-read. + * + * So the claim "this release keeps its permissions" is made machine-checkable here, against the + * artifact that is actually shipped — the .app inside the .dmg, not the staging copy. + * + * Usage: + * node scripts/t3x/verify-mac-signature.ts --artifact release/T3-Code-0.0.33-arm64.dmg \ + * --expect-requirement-file docs/t3x/mac-signing/designated-requirement.txt + * node scripts/t3x/verify-mac-signature.ts --artifact "/Applications/T3 Code (Alpha).app" \ + * --write-requirement-file docs/t3x/mac-signing/designated-requirement.txt + * node scripts/t3x/verify-mac-signature.ts --artifact … --allow-unsigned # warn, don't fail + */ + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + evaluateMacSignature, + formatMacSignatureVerdict, + MAC_SIGNING_IDENTITY_NAME, + normalizeRequirement, + parseCodesignDisplay, + parseDesignatedRequirement, + type MacSignatureVerdict, +} from "./mac-signature.ts"; + +export class MacSignatureVerificationError extends Schema.TaggedErrorClass()( + "MacSignatureVerificationError", + { + artifactPath: Schema.String, + problems: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return [ + `macOS signature verification failed for ${this.artifactPath}:`, + ...this.problems.map((problem) => ` - ${problem}`), + "", + "Every macOS permission grant is keyed to the app's designated requirement, so shipping this", + "artifact would re-request Screen Recording, Accessibility, Microphone, Files & Folders and", + "Local Network. See docs/t3x/mac-signing-runbook.md.", + ].join("\n"); + } +} + +export class MacArtifactNotFoundError extends Schema.TaggedErrorClass()( + "MacArtifactNotFoundError", + { artifactPath: Schema.String }, +) { + override get message(): string { + return `No such artifact: ${this.artifactPath}`; + } +} + +export class MacAppNotFoundInDmgError extends Schema.TaggedErrorClass()( + "MacAppNotFoundInDmgError", + { dmgPath: Schema.String, mountPoint: Schema.String }, +) { + override get message(): string { + return `No .app found at the top level of ${this.dmgPath} (mounted at ${this.mountPoint})`; + } +} + +/** Same shape as build-desktop-artifact.ts's collector: the initial value is a thunk, data-last. */ +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (accumulator: string, chunk: string) => accumulator + chunk, + ), + ); + +/** + * `codesign` writes its report to STDERR, so both streams are collected and concatenated. Reading + * stdout alone yields "" — which parses as an unsigned bundle and would fail every signed build. + * + * Self-scoping (`Effect.scoped` at the end), not relying on an ambient scope. Spawning acquires a + * scoped resource, and one of the callers below is a RELEASE action — where no ambient scope is + * available any more. That combination is how the first version of this file silently skipped its + * `hdiutil detach`: the spawn failed with "Service not found: effect/Scope" and the failure was + * swallowed, leaving the dmg attached and the temp mount point un-removable. + */ +const runCapturing = (bin: string, args: readonly string[]) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make(bin, [...args])); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { output: `${stdout}\n${stderr}`, exitCode } as const; + }).pipe(Effect.scoped); + +/** + * Mount a dmg read-only and hand the caller the `.app` inside it. + * + * Two things are deliberate, and both are inherited from + * apps/desktop/src/t3x/updateDelivery/installCommands.ts, where they were learned the hard way: + * + * - The mount point lives under $TMPDIR, never /Volumes. Mounting under /Volumes raises the macOS + * "access files on a removable volume" prompt, which a CI step cannot answer. + * - `-readonly`: verification must never be able to modify the artifact it is verifying. + * + * The detach is a release action, so a failed verification still unmounts — a dmg left attached + * poisons the next attach to the same mount point. + */ +const withMountedDmg = ( + dmgPath: string, + use: (appPath: string) => Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mountPoint = yield* fs.makeTempDirectoryScoped({ prefix: "t3x-verify-sig-" }); + + yield* Effect.acquireRelease( + runCapturing("hdiutil", [ + "attach", + dmgPath, + "-nobrowse", + "-readonly", + "-quiet", + "-mountpoint", + mountPoint, + ]), + () => + runCapturing("hdiutil", ["detach", mountPoint, "-force"]).pipe( + // Never blanket-ignored. A failed detach leaves the image attached, which poisons the + // next attach to the same mount point — and the temp directory's own removal then fails + // with EBUSY, reporting a mounting problem as a filesystem one. catchCause, not + // catchError: the failure that actually happened here was a defect. + Effect.catchCause((cause) => + Effect.logWarning(`[mac-signature] could not detach ${mountPoint}: ${cause}`), + ), + ), + ); + + const entries = yield* fs.readDirectory(mountPoint); + const appEntry = entries.find((entry) => entry.endsWith(".app")); + if (appEntry === undefined) { + return yield* new MacAppNotFoundInDmgError({ dmgPath, mountPoint }); + } + + return yield* use(path.join(mountPoint, appEntry)); + }).pipe(Effect.scoped); + +/** Everything codesign has to say about one bundle, reduced to a verdict. */ +export const inspectMacSignature = Effect.fn("inspectMacSignature")(function* ( + appPath: string, + expectation: { + readonly requirement?: string | undefined; + readonly authority?: string | undefined; + }, +) { + const display = yield* runCapturing("codesign", ["--display", "--verbose=4", appPath]); + const requirements = yield* runCapturing("codesign", [ + "--display", + "--requirements", + "-", + appPath, + ]); + // --deep so nested helpers, frameworks and the packaged native binaries are validated too: a + // bundle whose top-level signature is fine but whose helper is not fails to launch, and TCC + // grants are attached to the helper's responsible process. + const verify = yield* runCapturing("codesign", [ + "--verify", + "--deep", + "--strict", + "--verbose=1", + appPath, + ]); + + const verdict = evaluateMacSignature({ + display: parseCodesignDisplay(display.output), + requirement: parseDesignatedRequirement(requirements.output), + expectation: { + requirement: expectation.requirement, + authority: expectation.authority, + }, + }); + + const problems = + verify.exitCode === 0 + ? verdict.problems + : [ + ...verdict.problems, + `codesign --verify --deep --strict failed (exit ${verify.exitCode}): ${verify.output.trim()}`, + ]; + + return { ...verdict, problems } satisfies MacSignatureVerdict; +}); + +const verifyMacSignature = Effect.fn("verifyMacSignature")(function* (input: { + readonly artifact: string; + readonly expectRequirementFile: string | undefined; + readonly writeRequirementFile: string | undefined; + readonly expectAuthority: string | undefined; + readonly allowUnsigned: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const artifactPath = path.resolve(input.artifact); + + if (!(yield* fs.exists(artifactPath))) { + return yield* new MacArtifactNotFoundError({ artifactPath }); + } + + const expectedRequirement = + input.expectRequirementFile === undefined + ? undefined + : normalizeRequirement(yield* fs.readFileString(path.resolve(input.expectRequirementFile))); + + const inspect = (appPath: string) => + inspectMacSignature(appPath, { + requirement: expectedRequirement, + authority: input.expectAuthority, + }); + + const verdict = artifactPath.endsWith(".dmg") + ? yield* withMountedDmg(artifactPath, inspect) + : yield* inspect(artifactPath); + + yield* Effect.log(`[mac-signature] ${artifactPath}\n ${formatMacSignatureVerdict(verdict)}`); + + if (verdict.kind === "stable" && input.writeRequirementFile !== undefined) { + const requirementPath = path.resolve(input.writeRequirementFile); + yield* fs.writeFileString(requirementPath, `${verdict.requirement ?? ""}\n`); + yield* Effect.log(`[mac-signature] recorded designated requirement -> ${requirementPath}`); + } + + if (verdict.kind === "stable") { + yield* Effect.log("[mac-signature] permissions granted to this app will survive updates."); + return; + } + + // An unsigned artifact is the pre-#70 status quo, so a caller that has no signing identity + // available (a local build on a fresh machine, a fork clone) can opt into a warning. An UNSTABLE + // one never gets that option: it is signed, so it looks fixed, and it is not. + if (verdict.kind === "unsigned" && input.allowUnsigned) { + yield* Effect.logWarning( + [ + "[mac-signature] this artifact is ad-hoc signed: installing it will re-request every macOS", + `permission, and will do so again on the next build. Create the '${MAC_SIGNING_IDENTITY_NAME}'`, + "identity with scripts/t3x/setup-mac-signing.sh to stop that.", + ...verdict.problems.map((problem) => ` - ${problem}`), + ].join("\n"), + ); + return; + } + + return yield* new MacSignatureVerificationError({ artifactPath, problems: verdict.problems }); +}); + +export const verifyMacSignatureCommand = Command.make( + "verify-mac-signature", + { + artifact: Flag.string("artifact").pipe( + Flag.withDescription("Path to the built .dmg, or to a .app bundle."), + ), + expectRequirementFile: Flag.string("expect-requirement-file").pipe( + Flag.withDescription( + "File holding the designated requirement every release must match, byte for byte.", + ), + Flag.optional, + ), + writeRequirementFile: Flag.string("write-requirement-file").pipe( + Flag.withDescription( + "Record this artifact's designated requirement to a file (only when the verdict is stable).", + ), + Flag.optional, + ), + expectAuthority: Flag.string("expect-authority").pipe( + Flag.withDescription("Signing identity name the leaf Authority must equal."), + Flag.optional, + ), + allowUnsigned: Flag.boolean("allow-unsigned").pipe( + Flag.withDescription( + "Warn instead of failing when no signing identity was used. Never allows a CHANGED identity.", + ), + Flag.optional, + ), + }, + ({ artifact, expectRequirementFile, writeRequirementFile, expectAuthority, allowUnsigned }) => + verifyMacSignature({ + artifact, + expectRequirementFile: Option.getOrUndefined(expectRequirementFile), + writeRequirementFile: Option.getOrUndefined(writeRequirementFile), + expectAuthority: Option.getOrUndefined(expectAuthority), + allowUnsigned: Option.getOrElse(allowUnsigned, () => false), + }), +).pipe( + Command.withDescription( + "Verify a macOS artifact is signed with a stable identity, so permission grants survive updates.", + ), +); + +if (import.meta.main) { + // Effect.scoped, matching build-desktop-artifact.ts: spawning a child process acquires a scoped + // resource, so without it every codesign call dies with "Service not found: effect/Scope". + Command.run(verifyMacSignatureCommand, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} From 003e6b1425e5260e925940a8d428501fab77ea0f Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:42:51 -0400 Subject: [PATCH 2/5] fix(t3x): give the fork its own bundle id, so it stops sharing TCC rows with upstream (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stable signing was necessary but not sufficient. macOS stores one TCC permission row per (service, bundle id), and both apps report the same id: $ mdls -name kMDItemCFBundleIdentifier "/Applications/T3 Code (Alpha).app" \ "/Applications/T3 Code (Nightly).app" com.t3tools.t3code com.t3tools.t3code So the fork's build and upstream's nightly shared one row per permission, and whichever launched last owned it — the other was re-prompted no matter how well either was signed. Anyone running both was getting dialogs from this on top of the cdhash problem. The fork now uses dev.curlycloud.coil, after coil (coil.curlycloud.dev). DESKTOP_APP_ID becomes an env escape hatch rather than a changed literal: `process.env.T3X_DESKTOP_APP_ID?.trim() || "com.t3tools.t3code"`. That keeps upstream's default, upstream's three assertions on the value, and upstream's behaviour on an unset environment — a changed literal would have cost a second seam row on build-desktop-artifact.test.ts as well. One line, ten of comment, recorded in SEAMS.md as the ledger's 38th row and its first deletion. Two deliberate non-changes: - productName stays "T3 Code (Alpha)". The updater refuses an install when the .app name inside the dmg differs from the installed one, so renaming the app would break the update path this is meant to make quiet (#71). - User data does not move: ~/Library/Application Support/t3code comes from a hardcoded userDataDirName, not from the bundle id. Landing in the same release as the signing change costs ONE round of permission prompts between them rather than two. Guarded from the fork side, because a silent revert here is invisible until the dialogs come back days later on someone's machine: mac-signature.test.ts asserts the hook still exists and that the release workflow, the local autobuild and the certificate setup all set it to the same value, and verify-mac-signature.ts fails any artifact whose signing identifier is not that value. The one remaining failing test is the pinned designated requirement, which does not exist yet — it lands with the certificate's trust step. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/t3x-release.yml | 6 + .../2026-08-10-t3x-homepage-cloudflare.md | 700 ++++++++++++++++++ docs/t3x/SEAMS.md | 51 +- docs/t3x/mac-signing-runbook.md | 40 +- scripts/build-desktop-artifact.ts | 12 +- scripts/t3x/auto-build-desktop.sh | 13 +- scripts/t3x/mac-signature.test.ts | 87 ++- scripts/t3x/mac-signature.ts | 25 +- scripts/t3x/setup-mac-signing.sh | 6 +- 9 files changed, 894 insertions(+), 46 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-10-t3x-homepage-cloudflare.md diff --git a/.github/workflows/t3x-release.yml b/.github/workflows/t3x-release.yml index 7d26824d35e1..b8b315c0db80 100644 --- a/.github/workflows/t3x-release.yml +++ b/.github/workflows/t3x-release.yml @@ -410,6 +410,12 @@ jobs: # "not specified" and falls back to the ad-hoc path. The verify step below is what turns # that silent fallback into a visible one. CSC_NAME: ${{ steps.signing.outputs.identity }} + # The other half of #70. macOS stores one permission row per (service, bundle id), and this + # fork's app shared `com.t3tools.t3code` with upstream's nightly — so whichever was launched + # last owned the grants and the other got re-prompted, however well either was signed. + # Keep this in step with DESKTOP_BUNDLE_IDENTIFIER in scripts/t3x/mac-signature.ts; a test + # asserts the two agree, and the verify step below fails an artifact carrying the wrong id. + T3X_DESKTOP_APP_ID: dev.curlycloud.coil run: | set -euo pipefail node scripts/build-desktop-artifact.ts \ diff --git a/docs/superpowers/plans/2026-08-10-t3x-homepage-cloudflare.md b/docs/superpowers/plans/2026-08-10-t3x-homepage-cloudflare.md new file mode 100644 index 000000000000..848a0e395c81 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-t3x-homepage-cloudflare.md @@ -0,0 +1,700 @@ +# T3X Fork Homepage on Cloudflare — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a fork-owned homepage (seeded from upstream's `apps/marketing` Astro site) that presents the T3X fork honestly, serves working download links from the fork's release pipeline, and deploys to Cloudflare Workers static assets — first manually, then automatically on every push to `main`. + +**Architecture:** Copy `apps/marketing` into a new fork-owned package `apps/t3x-home` (never edit `apps/marketing` — the seam ledger's additive invariant is the fork's sync safety, and a 1310-line in-place rebrand would conflict on every upstream marketing change). Rebrand the copy, replace the GitHub-API release lookup (which 404s on the fork — all fork releases are pre-releases) with the already-live update-relay manifest, and serve the built `dist/` as an assets-only Cloudflare Worker named `t3x-home`, mirroring the existing `infra/t3x-update-relay` conventions. + +**Tech Stack:** Astro 7 (static output), pnpm workspace (`apps/*` glob — the new package auto-joins), Cloudflare Workers static assets via `wrangler.jsonc`, `pnpm dlx wrangler@4` (never installed as a dependency), GitHub Actions (`ubuntu-latest` + `voidzero-dev/setup-vp@v1`, matching `t3x-ci.yml`). + +## Global Constraints + +- **NEVER modify anything under `apps/marketing/`.** It is upstream-owned. All work happens in the new `apps/t3x-home/`. +- **Never add `wrangler` as a dependency.** Always invoke via `pnpm dlx wrangler@4 `. Reason (verbatim from `infra/t3x-update-relay/package.json`): it drags ~500 lines into `pnpm-lock.yaml`, the fork's second-worst rebase-conflict surface. +- **Do not touch the `overrides:` block in `pnpm-workspace.yaml`** when `pnpm install` regenerates the lockfile — it carries the security sweep. +- **Branch off a freshly fetched `origin/main`** (`git fetch origin` first — the daily sync automation force-rewrites `main`; a stale base means unmergeable work). Branch name: `t3x/homepage`. +- **Only advertise shipped features.** Verify each claimed feature exists on `origin/main` before writing it into page copy (Task 3 lists the vetted set). Loop Watch, the worklog skill, and the #42–#45 backlog are NOT shipped — never mention them. +- **Do not republish upstream's social proof or legal pages.** The tweets/pfps testimonials, "100,000 devs" / "14k+ stars" stats, privacy policy, terms of service, security policy, and legal pages describe T3 Tools Inc.'s product and company — presenting them as the fork's would be dishonest. Strip them; link to upstream instead. +- **Fork identity strings (use verbatim):** name **T3X**, GitHub `https://github.com/radroid/t3code`, upstream credit `https://github.com/pingdotgg/t3code`, site URL `https://t3x-home.businesses.workers.dev`, update manifest `https://t3x-update-relay.businesses.workers.dev/latest`. +- Package name: `@t3tools/t3x-home`. Worker name: `t3x-home`. Workflow file: `.github/workflows/t3x-deploy-home.yml`. +- Commit after every green task. Prefix commits `feat(t3x):` / `docs(t3x):` / `ci(t3x):` as appropriate. + +## Design & content spec + +This section is the visual and editorial contract for Tasks 3–4. The executor implements it exactly; where it is silent, match the existing `apps/marketing` idiom rather than inventing. + +### Design stance + +The fork **inherits upstream's visual system deliberately** — it ships the same product, and the site should look like the app does. Do not redesign: keep the near-black theme (`--bg: #09090b`), the DM Sans display/body + JetBrains Mono utility pairing, the noise overlay, the 12px radii, and the existing section/card CSS. The fork's identity comes from three deliberate divergences, and only these: + +1. **The accent hue flips from violet to diff-green.** In `Layout.astro`, change one line: `--accent-h: 250` → `--accent-h: 150`. Everything derived (`--accent`, `--accent-dim`) follows. Rationale, which is also the site's story: this fork's entire sync discipline is that every change is an _addition_ — `+N/-0` on every shared file — and green is the color of added lines in a diff. One variable diverged from upstream is also, fittingly, the smallest possible fork. +2. **A live sync strip in the hero** (the page's signature, wired in Task 4): a single mono-type line under the download buttons, populated client-side from the update-relay manifest — `` `0.0.33-t3x.20 · tracking upstream v0.0.33 · built Aug 10` `` (version and date from `fetchLatestManifest()`; derive the upstream version as `manifest.version.split("-t3x")[0]`). Static fallback text if the fetch fails: `Rebased onto upstream daily`. This makes the fork's core promise — _always current with upstream_ — a verifiable, self-updating fact rather than a claim. +3. **The fork graph section** (below), which replaces the deleted endorsements section as the page's second act. + +Nav brand becomes `T3X` (keep the `nav-brand` markup shape); the nav's GitHub-stars pill becomes a plain `GitHub` link to the fork (no star count — those stars are upstream's). Footer brand line: `© {year} T3X contributors · MIT · a fork of T3 Code by T3 Tools Inc` with the T3 Code words linking to `UPSTREAM_REPOSITORY_URL`. Footer links: GitHub (fork), Download, Upstream (T3 Code repo). Drop the Discord and store links — that community and those listings are upstream's. + +### Page map + +``` +┌ nav: T3X ──────────────────────────────── GitHub ┐ +│ HERO (kept, recopied) │ +│ h1: T3X headline · sub: fork-of-T3-Code line │ +│ [Download for macOS] [Windows] │ +│ mono sync strip: 0.0.33-t3x.20 · tracking … │ +│ screenshot.webp │ +├ FORK GRAPH (new — replaces endorsements) ────────┤ +│ h2: Everything T3 Code is. Plus a branch. │ +│ main ○ Every agent, one workspace │ +│ main ○ Bring your own sub │ +│ main ○ ⌘⏎ Commit, push, PR │ +│ main ○ MIT open source │ +│ ╲ │ +│ t3x ● Auto-updates │ +│ t3x ● Needs-input notifications │ +│ t3x ● Web Push + keepalive │ +│ t3x ● Auto-resume │ +│ t3x ● Daily upstream sync │ +│ t3x ● Dependency security sweep │ +├ HARNESSES (kept as-is: "Bring your own sub") ────┤ +├ GIT (kept as-is: one-button commit/push/PR) ─────┤ +├ OPEN ("fork it" section, recopied — see below) ──┤ +├ CTA (kept, download links from manifest) ────────┤ +└ footer ──────────────────────────────────────────┘ +``` + +The kept sections (`#harnesses`, `#git`, and the CTA) are the core product's deep-dive demos — that is how the page "clearly shares the core product's features": by keeping upstream's own demonstrations of them, not by paraphrasing them into a bullet list. + +### The fork graph (signature section) + +A vertical git-graph: one `main` rail carrying the core product's features as hollow commit dots, and a `t3x` branch rail diverging from it carrying the fork's additions as filled accent-green dots. Structure encodes truth: which features come from where, and (via a caption at the divergence point: `rebased onto upstream daily`) that the branch never drifts. Build it as semantic HTML — two `
    ` lists with CSS-drawn rails (borders/pseudo-elements, no images or JS); dots are `::before` circles; the branch rail and its dots use `var(--accent)`. Mobile: the two rails stack vertically (main list, then branch list) with the connector hidden. Respect `prefers-reduced-motion`; at most a single scroll-triggered reveal of the branch rail, no scattered animations. + +Card copy, verbatim (title — one-liner): + +`main` rail (core product — these restate what the kept demo sections show): + +- **Every agent, one workspace** — Threads from all your coding agents, side by side in one control plane. +- **Bring your own sub** — Plug in Claude Code, Codex, or OpenCode. T3 Code doesn't resell tokens. +- **⌘⏎ Commit, push, PR** — One button from finished diff to open pull request. +- **MIT open source** — Read it, patch it, fork it. + +`t3x` branch rail (fork additions — each verified merged on `origin/main`, per the Global Constraints check): + +- **Auto-updates** — New builds announce themselves with a changelog toast and install while you're idle. +- **Needs-input notifications** — When an agent stalls on a question, your desktop and your phone find out — not the tab you forgot. +- **Web Push + keepalive** — Notifications reach the browser on your phone, and sessions stop dozing off in the background. +- **Auto-resume** — Interrupted runs pick themselves back up, with a countdown you can cancel. +- **Daily upstream sync** — An automated agent rebases this fork onto upstream every day and keeps a public ledger of every divergence. +- **Dependency security sweep** — Pinned overrides that took open Dependabot alerts from 107 to 6, and survive every sync. + +### Copy rules + +- Hero headline names **T3X**; the sub-line states in its first sentence that this is a community fork of [T3 Code](https://github.com/pingdotgg/t3code) (MIT). +- The kept `#open` section is upstream's own "If you don't like something, fork it." **Keep that headline verbatim and answer it** — replace the section's body copy with, in this spirit: `So we did. T3X is that fork: same product, same license, plus the things we wanted sooner. Every line we change on top of upstream is public — additions only, re-checked against upstream every day.` Link the word "public" to `https://github.com/radroid/t3code/blob/main/docs/t3x/SEAMS.md`. +- Voice: plain verbs, sentence case, specific over clever, no invented stats, no superlatives ("blazing", "supercharged" are banned). Never claim user counts, star counts, or testimonials. +- Downloads honesty (unchanged from Global Constraints): macOS Apple Silicon + Windows x64 only, unsigned builds, no store apps — stated plainly near every download control. + +## Human-in-the-loop prerequisites (check before starting Tasks 5–6) + +Two things only the user can provide; everything through Task 4 proceeds without them: + +1. **Cloudflare auth for the first manual deploy (Task 5).** Run `pnpm dlx wrangler@4 whoami` from `apps/t3x-home`. If not authenticated, either the user runs `pnpm dlx wrangler@4 login` (interactive browser OAuth — same account that hosts `t3x-update-relay`, workers.dev subdomain `businesses`), or exports `CLOUDFLARE_API_TOKEN`. If neither is possible, pause Task 5/6 and report; Tasks 1–4 and 7 are still completable. +2. **CI secrets (Task 6).** A Cloudflare API token with **Account → Workers Scripts → Edit** permission, stored via `gh secret set CLOUDFLARE_API_TOKEN -R radroid/t3code`, plus `gh variable set CLOUDFLARE_ACCOUNT_ID -R radroid/t3code --body ` (account ID is printed by `wrangler whoami`). Neither exists today — verified: repo secrets are only `CLAUDE_CODE_OAUTH_TOKEN` and `T3X_UPDATE_HMAC_SECRET`. + +--- + +### Task 1: Scaffold `apps/t3x-home` as a building copy + +**Files:** + +- Create: `apps/t3x-home/**` (copied from `apps/marketing/**`) +- Do not copy: `apps/marketing/vercel.ts`, `apps/marketing/.astro/`, `apps/marketing/.DS_Store` + +**Interfaces:** + +- Produces: workspace package `@t3tools/t3x-home` with scripts `dev`, `build`, `preview`, `typecheck` — later tasks build with `vp run --filter @t3tools/t3x-home build` and expect output in `apps/t3x-home/dist/`. + +- [ ] **Step 1: Branch off fresh main** + +```bash +cd /Users/rajdholakia/Developer/t3code +git fetch origin +git checkout -b t3x/homepage origin/main +``` + +- [ ] **Step 2: Copy the app, excluding Vercel and cache files** + +```bash +rsync -a --exclude '.astro' --exclude '.DS_Store' --exclude 'vercel.ts' \ + apps/marketing/ apps/t3x-home/ +``` + +- [ ] **Step 3: Rewrite `apps/t3x-home/package.json`** + +Replace the whole file with: + +```json +{ + "name": "@t3tools/t3x-home", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "typecheck": "astro check", + "//deploy": "wrangler is run via `pnpm dlx`, not depended on — it drags ~500 lines into pnpm-lock.yaml, the fork's second-worst rebase-conflict surface, and is only needed at deploy time.", + "deploy": "pnpm dlx wrangler@4 deploy" + }, + "dependencies": { + "@t3tools/shared": "workspace:*", + "astro": "^7.0.3" + }, + "devDependencies": { + "@astrojs/check": "^0.9.7", + "typescript": "catalog:" + } +} +``` + +(Keeps `@t3tools/shared` for now — the copied `src/pages/schema/t3.json.ts` imports it; both are removed in Task 2. Drops `@vercel/config`.) + +- [ ] **Step 4: Register the package and verify it builds** + +```bash +pnpm install +vp run --filter @t3tools/t3x-home typecheck +vp run --filter @t3tools/t3x-home build +ls apps/t3x-home/dist/index.html +``` + +Expected: typecheck and build succeed; `dist/index.html` exists. `pnpm install` will touch `pnpm-lock.yaml` (adding the new workspace package) — that is expected; verify with `git diff pnpm-workspace.yaml` that the `overrides:` block is untouched (the file should have no diff at all). + +- [ ] **Step 5: Commit** + +```bash +git add apps/t3x-home pnpm-lock.yaml +git commit -m "feat(t3x): seed the fork homepage from apps/marketing" +``` + +--- + +### Task 2: Strip upstream-only content (testimonials, stats, store links, legal, schema) + +**Files:** + +- Modify: `apps/t3x-home/src/pages/index.astro` +- Modify: `apps/t3x-home/src/layouts/Layout.astro` +- Modify: `apps/t3x-home/src/lib/site.ts` +- Delete: `apps/t3x-home/src/lib/tweets.ts`, `apps/t3x-home/tweets.md`, `apps/t3x-home/public/pfps/`, `apps/t3x-home/src/pages/schema/`, `apps/t3x-home/src/pages/privacy-policy.astro`, `apps/t3x-home/src/pages/terms-of-service.astro`, `apps/t3x-home/src/pages/security-policy.astro`, `apps/t3x-home/src/pages/legal.astro`, `apps/t3x-home/src/components/LegalPage.astro` + +**Interfaces:** + +- Produces: `src/lib/site.ts` exporting exactly `GITHUB_REPOSITORY_URL` (fork URL) and `UPSTREAM_REPOSITORY_URL` — Task 3 copy and Task 4 pages import these names. + +- [ ] **Step 1: Delete the upstream-only files** + +```bash +cd apps/t3x-home +git rm -r src/lib/tweets.ts tweets.md public/pfps src/pages/schema \ + src/pages/privacy-policy.astro src/pages/terms-of-service.astro \ + src/pages/security-policy.astro src/pages/legal.astro src/components/LegalPage.astro +``` + +- [ ] **Step 2: Replace `src/lib/site.ts`** + +```ts +export const GITHUB_REPOSITORY_URL = "https://github.com/radroid/t3code"; + +export const UPSTREAM_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; +``` + +(Removes `IOS_APP_STORE_URL`, `ANDROID_PLAY_STORE_URL`, and `MARKETING_STATS` — the store listings and user/star counts are upstream's, not the fork's.) + +- [ ] **Step 3: Purge index.astro of the removed exports** + +In `src/pages/index.astro`: remove the imports of `tweets`, `IOS_APP_STORE_URL`, `ANDROID_PLAY_STORE_URL`, `MARKETING_STATS`; remove the entire endorsements section (the block containing `id="endorsements-title"` — "Tolerated by over … devs" — and every element rendering `tweet` / `pfps/`); remove the iOS/Android store-link elements around lines 76–78; replace the `MARKETING_STATS.githubStars` stars badge (~line 349) with a plain "Open source" link using `GITHUB_REPOSITORY_URL`. Leave all other sections (hero, feature demos, terminal mock, download CTA) intact — Task 3 rebrands their copy. + +- [ ] **Step 4: Fix Layout.astro nav/footer links** + +In `src/layouts/Layout.astro`, remove or repoint any `href` to the deleted pages (`/legal`, `/privacy-policy`, `/terms-of-service`, `/security-policy`). Where a legal footer link existed, substitute a single link: `For the original product and its policies, see T3 Code.` (import `UPSTREAM_REPOSITORY_URL` from `../lib/site`). + +- [ ] **Step 5: Drop the now-unused `@t3tools/shared` dependency** + +Remove `"@t3tools/shared": "workspace:*"` from `apps/t3x-home/package.json` dependencies, then: + +```bash +cd /Users/rajdholakia/Developer/t3code && pnpm install +``` + +- [ ] **Step 6: Verify no dangling references, then build** + +```bash +grep -rn "tweets\|pfps\|MARKETING_STATS\|APP_STORE\|PLAY_STORE\|LegalPage\|privacy-policy\|terms-of-service\|security-policy\|t3tools/shared" apps/t3x-home/src +vp run --filter @t3tools/t3x-home typecheck +vp run --filter @t3tools/t3x-home build +``` + +Expected: grep returns nothing; typecheck and build pass. + +- [ ] **Step 7: Commit** + +```bash +git add -A apps/t3x-home pnpm-lock.yaml +git commit -m "feat(t3x): strip upstream-only social proof, store links, and legal pages" +``` + +--- + +### Task 3: Rebrand copy and metadata as the T3X fork + +**Files:** + +- Modify: `apps/t3x-home/src/layouts/Layout.astro` +- Modify: `apps/t3x-home/src/pages/index.astro` +- Modify: `apps/t3x-home/astro.config.mjs` + +**Interfaces:** + +- Consumes: `GITHUB_REPOSITORY_URL`, `UPSTREAM_REPOSITORY_URL` from Task 2's `site.ts`. + +- [ ] **Step 1: Set site URL in `astro.config.mjs`** + +```js +import { defineConfig } from "astro/config"; + +export default defineConfig({ + site: "https://t3x-home.businesses.workers.dev", + server: { + port: Number(process.env.PORT ?? 4173), + }, +}); +``` + +- [ ] **Step 2: Rebrand Layout.astro metadata** + +In `src/layouts/Layout.astro` change the prop defaults (lines ~16–17): + +```ts +title = "T3X — a T3 Code fork", +description = "T3X — a community fork of T3 Code with auto-updates, push notifications, and a daily upstream sync.", +``` + +Update any OG/twitter meta tags in the same file to use these values and the site URL above. + +- [ ] **Step 3: Flip the accent and rebrand the chrome in Layout.astro** + +Per **Design & content spec → Design stance**: + +- Change `--accent-h: 250;` to `--accent-h: 150;` (one line — the derived `--accent`/`--accent-dim` follow). Change nothing else in the token block. +- `nav-brand-name`: `T3 Code` → `T3X`; update the brand `aria-label` to "T3X home". +- Replace the `nav-stars` pill (star icon + `{MARKETING_STATS.githubStars}` count) with a plain `GitHub` link to `GITHUB_REPOSITORY_URL`, keeping the pill's CSS class so styling holds. +- Footer brand line: `© {new Date().getFullYear()} T3X contributors · MIT · a fork of T3 Code by T3 Tools Inc`. +- Footer links reduce to: GitHub (fork), Download (`/download`), Upstream (`UPSTREAM_REPOSITORY_URL`). Discord and store links go (Task 2 already removed their URL constants). + +- [ ] **Step 4: Recopy the hero in index.astro** + +Per **Design & content spec → Copy rules**: headline names **T3X**; sub-line's first sentence states it is a community fork of T3 Code (MIT), linking `T3 Code` to `UPSTREAM_REPOSITORY_URL`; `hero-source-link` keeps pointing at `GITHUB_REPOSITORY_URL` (now the fork). Keep `screenshot.webp` and update its `alt` to mention T3X. Near the download buttons add the platform-honesty line ("Unsigned builds for macOS (Apple Silicon) and Windows (x64). No store apps.") and the static sync strip that Task 4 wires live: + +```html +

    Rebased onto upstream daily

    +``` + +Style `.hero-sync` with `font-family: var(--font-mono)`, `font-size: 0.8rem`, `color: var(--fg-dim)`. + +- [ ] **Step 5: Build the fork graph section** + +In the slot where the endorsements section was deleted (between the hero and `#harnesses`), add `
    ` implementing **Design & content spec → The fork graph** exactly: `

    Everything T3 Code is. Plus a branch.

    `, then two `
      ` lists — `main` rail with the 4 core-product cards (hollow dots, `--fg-muted` rail) and `t3x` branch rail with the 6 fork cards (filled dots and rail in `var(--accent)`), copy verbatim from the spec, divergence caption `rebased onto upstream daily`. CSS-drawn rails and dots only (borders + `::before`), stacked layout under 720px, one optional scroll reveal guarded by `prefers-reduced-motion`. Before committing, spot-check each of the 6 fork cards against reality: `git log origin/main --oneline | grep -i `. + +- [ ] **Step 6: Answer the "fork it" section** + +In the kept `#open` section: keep the `If you don't like something, fork it.` headline verbatim; replace the body copy with the "So we did." paragraph from **Design & content spec → Copy rules**, linking "public" to `https://github.com/radroid/t3code/blob/main/docs/t3x/SEAMS.md`. Keep the section's terminal-mock styling; its `GITHUB_REPOSITORY_URL` link now points at the fork, which is correct. + +- [ ] **Step 7: Verify branding landed, then build** + +```bash +grep -c "T3X" apps/t3x-home/src/pages/index.astro # expected: >= 3 +grep -n -- "--accent-h: 150" apps/t3x-home/src/layouts/Layout.astro # expected: 1 match +grep -c "fork-graph" apps/t3x-home/src/pages/index.astro # expected: >= 1 +grep -rn "Tolerated by\|nav-stars.*MARKETING" apps/t3x-home/src && echo "FAIL: upstream copy survives" +vp run --filter @t3tools/t3x-home build +``` + +Expected: T3X count ≥ 3, accent hue flipped, fork-graph present, no upstream-copy match, build passes. + +- [ ] **Step 8: Visual smoke check** + +```bash +vp run --filter @t3tools/t3x-home preview & # serves dist on port 4173 +sleep 3 && curl -s http://localhost:4173/ | grep -o "[^<]*" +kill %1 +``` + +Expected: `T3X — a T3 Code fork`. + +- [ ] **Step 9: Commit** + +```bash +git add apps/t3x-home +git commit -m "feat(t3x): rebrand the homepage copy, chrome, and fork graph" +``` + +--- + +### Task 4: Point downloads at the fork's release pipeline + +**Why this must change:** every fork release is a **pre-release**, so upstream's lookup (`api.github.com/repos//releases/latest`) returns 404 for `radroid/t3code` (verified 2026-08-10). The fork already publishes a richer manifest through the update relay. + +**Files:** + +- Modify: `apps/t3x-home/src/lib/releases.ts` (full replacement below) +- Modify: `apps/t3x-home/src/pages/download.astro` +- Modify: `apps/t3x-home/src/pages/index.astro` (wire the hero sync strip from Task 3) + +**Interfaces:** + +- Produces: `fetchLatestManifest(): Promise` and `RELEASES_URL` — `download.astro` consumes these. Manifest shape verified live against `https://t3x-update-relay.businesses.workers.dev/latest`. + +- [ ] **Step 1: Replace `src/lib/releases.ts`** + +```ts +const MANIFEST_URL = "https://t3x-update-relay.businesses.workers.dev/latest"; +const CACHE_KEY = "t3x-latest-manifest"; + +export const RELEASES_URL = "https://github.com/radroid/t3code/releases"; + +export interface ManifestAsset { + platform: "darwin-arm64" | "win32-x64"; + file: string; + url: string; + sha256: string; + bytes: number; +} + +export interface Manifest { + version: string; + releaseTag: string; + builtAt: string; + changes: string[]; + assets: ManifestAsset[]; +} + +export async function fetchLatestManifest(): Promise { + const cached = sessionStorage.getItem(CACHE_KEY); + if (cached) return JSON.parse(cached); + + const data = await fetch(MANIFEST_URL).then((r) => r.json()); + + if (data?.assets) { + sessionStorage.setItem(CACHE_KEY, JSON.stringify(data)); + } + + return data; +} +``` + +- [ ] **Step 2: Rework `download.astro` against the new interface** + +Requirements for the page (currently built around GitHub's `tag_name` / `assets[].browser_download_url` / asset-name sniffing — all of that goes): + +- Two download buttons from `manifest.assets`: `platform === "darwin-arm64"` → "macOS (Apple Silicon)", `platform === "win32-x64"` → "Windows (x64)"; each links to `asset.url` and shows size as `Math.round(bytes / 1e6)` MB. +- Show `manifest.version` and `builtAt` date near the buttons. +- Render `manifest.changes` (first 10 entries) as a "What's new" list. +- Remove any Linux / iOS / Android download affordances; add one line: "Linux builds aren't published yet — build from [source](https://github.com/radroid/t3code)." +- Keep the "all releases" link → `RELEASES_URL`. +- Failure path: if the fetch throws or `assets` is missing, show the `RELEASES_URL` link as the fallback CTA (the relay had a transient 500 on 2026-08-10 — degrade gracefully). + +- [ ] **Step 3: Wire the hero sync strip** + +Add to `index.astro` (Astro bundles ` +``` + +- [ ] **Step 4: Verify the manifest contract still holds** + +```bash +curl -s https://t3x-update-relay.businesses.workers.dev/latest | \ + python3 -c "import json,sys; d=json.load(sys.stdin); assert d['version'] and d['assets'][0]['url'].startswith('https://github.com/radroid/t3code/releases/download/'); print('manifest OK:', d['version'])" +``` + +Expected: `manifest OK: 0.0.33-t3x.20` (or newer). + +- [ ] **Step 5: Typecheck, build, smoke-test the page** + +```bash +vp run --filter @t3tools/t3x-home typecheck +vp run --filter @t3tools/t3x-home build +vp run --filter @t3tools/t3x-home preview & +sleep 3 && curl -s http://localhost:4173/download/ | grep -io "windows\|macos\|apple silicon" | sort -u +kill %1 +``` + +Expected: both platform labels present in the served HTML. + +- [ ] **Step 6: Commit** + +```bash +git add apps/t3x-home +git commit -m "feat(t3x): serve downloads from the fork's update-relay manifest and wire the sync strip" +``` + +--- + +### Task 5: Cloudflare Worker config, 404 page, and first manual deploy + +**Files:** + +- Create: `apps/t3x-home/wrangler.jsonc` +- Create: `apps/t3x-home/src/pages/404.astro` +- (No `.gitignore` needed — the root `.gitignore` already covers `apps/*/dist` and `.astro`.) + +**Interfaces:** + +- Produces: deployed site at `https://t3x-home.businesses.workers.dev`; `pnpm run deploy` from `apps/t3x-home` is the deploy command Task 6's CI reuses. + +- [ ] **Step 1: Write `apps/t3x-home/wrangler.jsonc`** + +```jsonc +{ + "$schema": "https://unpkg.com/wrangler@4/config-schema.json", + "name": "t3x-home", + "compatibility_date": "2026-08-10", + // Assets-only Worker: no `main` script — Cloudflare serves ./dist directly. + "assets": { + "directory": "./dist", + "not_found_handling": "404-page", + }, +} +``` + +(Note the schema URL: unlike `infra/t3x-update-relay`, wrangler is not in `node_modules` here, so the local `node_modules/wrangler/config-schema.json` path would dangle.) + +- [ ] **Step 2: Add a 404 page so `not_found_handling` has a target** + +`src/pages/404.astro`: + +```astro +--- +import Layout from "../layouts/Layout.astro"; +--- + + +
      +

      404

      +

      That page doesn't exist. Back to T3X.

      +
      +
      +``` + +Rebuild and confirm Astro emits it: `vp run --filter @t3tools/t3x-home build && ls apps/t3x-home/dist/404.html`. + +- [ ] **Step 3: Check Cloudflare auth (human-in-the-loop gate)** + +```bash +cd apps/t3x-home && pnpm dlx wrangler@4 whoami +``` + +If this fails with "not authenticated", stop and ask the user to run `pnpm dlx wrangler@4 login` (or export `CLOUDFLARE_API_TOKEN`). Target account: the one hosting `t3x-update-relay` (workers.dev subdomain `businesses`). + +- [ ] **Step 4: Deploy and verify** + +```bash +cd apps/t3x-home && pnpm run deploy +curl -s -o /dev/null -w "%{http_code}\n" https://t3x-home.businesses.workers.dev/ # expect 200 +curl -s https://t3x-home.businesses.workers.dev/ | grep -o "[^<]*" # expect T3X title +curl -s -o /dev/null -w "%{http_code}\n" https://t3x-home.businesses.workers.dev/download/ # expect 200 +curl -s -o /dev/null -w "%{http_code}\n" https://t3x-home.businesses.workers.dev/nope # expect 404 +``` + +If sandboxing blocks the deploy's network calls, rerun with escalated permissions. + +- [ ] **Step 5: Commit** + +```bash +git add apps/t3x-home +git commit -m "feat(t3x): deploy the homepage as a Cloudflare assets-only Worker" +``` + +--- + +### Task 6: CI auto-deploy on push to main + +**Files:** + +- Create: `.github/workflows/t3x-deploy-home.yml` + +**Interfaces:** + +- Consumes: `pnpm run deploy` from Task 5; repo secret `CLOUDFLARE_API_TOKEN` and repo variable `CLOUDFLARE_ACCOUNT_ID` (created in Step 1). + +- [ ] **Step 1: Create the CI credentials (human-in-the-loop gate)** + +Ask the user for a Cloudflare API token (dash.cloudflare.com → My Profile → API Tokens → Create Token → "Edit Cloudflare Workers" template, or custom with **Account → Workers Scripts → Edit**), then: + +```bash +gh secret set CLOUDFLARE_API_TOKEN -R radroid/t3code # paste token when prompted +ACCOUNT_ID=$(cd apps/t3x-home && pnpm dlx wrangler@4 whoami 2>/dev/null | grep -oE '[0-9a-f]{32}' | head -1) +gh variable set CLOUDFLARE_ACCOUNT_ID -R radroid/t3code --body "$ACCOUNT_ID" +``` + +If the user can't provide the token now, still land Steps 2–4 (the workflow simply fails until the secret exists; manual `pnpm run deploy` keeps working) and note it in the final report. + +- [ ] **Step 2: Write `.github/workflows/t3x-deploy-home.yml`** + +```yaml +name: "t3x: deploy homepage" + +on: + push: + branches: + - main + paths: + - "apps/t3x-home/**" + - ".github/workflows/t3x-deploy-home.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: t3x-home-deploy + cancel-in-progress: false + +jobs: + deploy: + name: Build and deploy t3x-home + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/t3x-home... + + - name: Build + run: vp run --filter @t3tools/t3x-home build + + - name: Deploy to Cloudflare + working-directory: apps/t3x-home + run: pnpm dlx wrangler@4 deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + + - name: Verify live site + run: | + code=$(curl -s -o /dev/null -w "%{http_code}" https://t3x-home.businesses.workers.dev/) + test "$code" = "200" || { echo "site returned $code"; exit 1; } +``` + +Known behaviors to leave alone: the daily sync force-pushes `main`, and on an undiffable force push GitHub runs `paths`-filtered workflows anyway — that's fine, `wrangler deploy` is idempotent. The workflow becomes active only after it lands on `main` (new-workflow registration behavior). + +- [ ] **Step 3: Lint the workflow locally** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/t3x-deploy-home.yml')); print('yaml OK')" +``` + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/t3x-deploy-home.yml +git commit -m "ci(t3x): auto-deploy the homepage to Cloudflare on push to main" +``` + +--- + +### Task 7: Ledger note, runbook note, and PR + +**Files:** + +- Modify: `docs/t3x/SEAMS.md` (append a note — adds no upstream-file rows) +- Modify: `docs/t3x/sync-agent-runbook.md` (parallel-paths check item) + +**Interfaces:** + +- Consumes: nothing from earlier tasks besides their existence; this is bookkeeping the sync agent reads. + +- [ ] **Step 1: Append the parallel-path note to `docs/t3x/SEAMS.md`** + +Follow the precedent of the "Update delivery adds no NEW rows" note. Append (adjust surrounding formatting to match the file): + +```markdown +> **The fork homepage adds no NEW rows.** `apps/t3x-home/` is a fork-owned copy of +> `apps/marketing/` deployed to Cloudflare (`t3x-home` Worker), plus +> `.github/workflows/t3x-deploy-home.yml` — all files upstream has never seen. +> `apps/marketing/` itself remains untouched. **Parallel-path hazard:** upstream keeps +> evolving `apps/marketing/`; the copy will not conflict but will silently drift. At each +> sync, skim `git log ..upstream/main -- apps/marketing` and port anything +> worth having (pricing changes, new pages, security-relevant fixes) by hand. +``` + +- [ ] **Step 2: Add the sync-runbook check item** + +In `docs/t3x/sync-agent-runbook.md`, find the checklist that re-checks the SEAMS table each sync and add one item, matching the list's formatting: + +```markdown +- Parallel path: `apps/t3x-home/` duplicates `apps/marketing/`. Check upstream's marketing + churn this cycle (`git log ..upstream/main --oneline -- apps/marketing`) and + port intentionally or record "nothing worth porting". +``` + +- [ ] **Step 3: Commit and open the PR** + +```bash +git add docs/t3x/SEAMS.md docs/t3x/sync-agent-runbook.md +git commit -m "docs(t3x): record the fork homepage on the seam ledger and sync runbook" +git push -u origin t3x/homepage +gh pr create -R radroid/t3code --base main --title "feat(t3x): fork homepage on Cloudflare Workers" \ + --body "Fork-owned copy of apps/marketing rebranded for T3X, downloads wired to the update-relay manifest, deployed as the t3x-home assets-only Worker with CI auto-deploy. apps/marketing untouched; no new seam-ledger rows. Live: https://t3x-home.businesses.workers.dev + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +- [ ] **Step 4: Final verification sweep** + +```bash +git diff origin/main...HEAD --stat -- apps/marketing # MUST be empty +curl -s https://t3x-home.businesses.workers.dev/ | grep -c "T3X" # >= 1 +``` + +If `apps/marketing` shows any diff, that is a plan violation — revert those hunks before merging. + +--- + +## Deferred (do not build now) + +- **Custom domain:** the site ships on `t3x-home.businesses.workers.dev`. If the user later buys a domain, add a `routes`/`custom_domain` entry to `wrangler.jsonc` — one-line change. +- **Replacing the upstream screenshot** with a fork-specific one showing the update toast / notifications. +- **Porting upstream marketing changes** — handled per-sync via the Task 7 runbook item, not here. diff --git a/docs/t3x/SEAMS.md b/docs/t3x/SEAMS.md index 4c51628e03ec..bbdfb49fdb16 100644 --- a/docs/t3x/SEAMS.md +++ b/docs/t3x/SEAMS.md @@ -2,8 +2,10 @@ **The authoritative list of every upstream-owned file this fork edits.** -Measured, not asserted: **37 upstream-owned files, +1957 / -912 lines**, against merge-base -`78f462c4e` (upstream v0.0.33, the 2026-08-10 sync). Everything else the fork adds lives in new +Measured, not asserted: **38 upstream-owned files, +1968 / -913 lines**, against merge-base +`78f462c4e` (upstream v0.0.33, the 2026-08-10 sync). The 38th, and the only deletion added since the +2026-08-10 baseline, is `scripts/build-desktop-artifact.ts` — one line for #70, deliberately shaped as +an env escape hatch so upstream's default and its tests survive it. Everything else the fork adds lives in new files upstream has never seen and cannot conflict. > **The file-list half has now survived two consecutive rebases unchanged** — same 37 files, same @@ -12,6 +14,12 @@ files upstream has never seen and cannot conflict. > `__root.tsx` +9/-0), so anything that displaced upstream content would move a deletion count. When > #5624 removed a line from `channels.ts` and `preload.ts`, the removal survived precisely because > the fork never re-adds — it only appends. +> +> **2026-08-11: the first exception, and it is a small one.** `scripts/build-desktop-artifact.ts` (#70) +> replaces one line — a constant's initialiser — so the fork's total is no longer +N/-0 on every shared +> file, and the invariant above weakens from "no deletions anywhere" to "one known deletion, at a known +> line". Keep it that way. The additive rule is what makes a clean rebase evidence of anything, so a +> second displacement should have to argue for itself in this file the way that one did. > **Read the two dependency rows with their note, not their number.** `pnpm-lock.yaml` sits at > +317 / -737 and so at risk **60078**, still the top row by a wide margin. That figure is the @@ -44,6 +52,7 @@ files upstream has never seen and cannot conflict. > silencing upstream's updater is done by building with `GITHUB_REPOSITORY: ""` rather than editing > `DesktopUpdates.ts`, and serialising the desktop build for #47 is done with > `vp run build:desktop --concurrency-limit 1` rather than editing `build-desktop-artifact.ts`. +> (That file has since taken one line for #70 — see below. #47 still does not need it.) > The integration landed on **existing** rows and grew six of them — `contracts/src/ipc.ts`, > `preload.ts`, `ipc/channels.ts`, `ipc/DesktopIpcHandlers.ts`, `main.ts`, `__root.tsx` — by +57 > lines in total. Each is the aggregator-shaped edit the rule above asks for: one import and one @@ -62,17 +71,32 @@ files upstream has never seen and cannot conflict. > `git log ..upstream/main -- apps/marketing` and port anything worth having (pricing > changes, new pages, security-relevant fixes) by hand. -> **macOS code signing adds no NEW rows either — and the reason is worth keeping.** Issue #70 (every -> update re-requesting every macOS permission) was diagnosed as needing a third signing mode inside -> `scripts/build-desktop-artifact.ts`, because that file forces `CSC_IDENTITY_AUTO_DISCOVERY=false` -> for unsigned builds. It did not. app-builder-lib consults that flag **only when no identity was -> named**: `findIdentity()` reads `qualifier || process.env.CSC_NAME` first and, when that is -> non-empty, goes straight to `security find-identity`. So exporting `CSC_NAME` around the existing -> unsigned build is the whole mechanism, and it lives in `.github/workflows/t3x-release.yml` and -> `scripts/t3x/` — a row on that hot upstream file was priced, considered, and then not needed. -> The general lesson: before spending a row to add a mode, check whether the mode's escape hatch is -> already an environment variable. `--concurrency-limit` for #47 and `GITHUB_REPOSITORY: ""` for the -> updater were the same shape of answer. +> **macOS code signing (#70) costs ONE line on `build-desktop-artifact.ts`, and not the one the issue +> predicted.** The issue expected a third signing mode in that file, because it forces +> `CSC_IDENTITY_AUTO_DISCOVERY=false` for unsigned builds. That turned out to be free: app-builder-lib +> consults the flag **only when no identity was named** — `findIdentity()` reads +> `qualifier || process.env.CSC_NAME` first and, when non-empty, goes straight to +> `security find-identity`. So the signing half is entirely fork-owned (`CSC_NAME` exported by +> `.github/workflows/t3x-release.yml` and `scripts/t3x/auto-build-desktop.sh`), at zero rows. +> +> The row is spent on the **second** cause instead, which no environment variable existed for: macOS +> stores one TCC permission row per `(service, bundle id)`, and the fork shared +> `com.t3tools.t3code` with upstream's nightly — so whichever app launched last owned the grants and +> the other was re-prompted, however well either was signed. `DESKTOP_APP_ID` is now +> `process.env.T3X_DESKTOP_APP_ID?.trim() || "com.t3tools.t3code"`, and the fork sets that variable to +> `dev.curlycloud.coil`. +> +> **The shape of the edit is the point.** A changed literal would have been +1/-1 and would have +> broken three upstream assertions in `build-desktop-artifact.test.ts`, adding a second row on a +> second upstream file. An env escape hatch keeps upstream's default, its tests, and its behaviour on +> an unset environment — the same answer as `--concurrency-limit` for #47 and `GITHUB_REPOSITORY: ""` +> for the updater. Before spending a row to change a value, check whether it can become a variable +> upstream would have accepted. +> +> The compensating control for a seam this quiet is in fork-owned tests: `mac-signature.test.ts` +> asserts the hook still exists in that file and that every build path sets it, and +> `verify-mac-signature.ts` fails any artifact whose signing identifier is not the expected one. A +> sync that reverts the line cannot ship silently. The churn and risk columns are measured against that same merge-base, over the 60 days preceding it. The window slides forward at every sync, so these figures move even when the fork does not. @@ -169,6 +193,7 @@ Sorted by risk, worst first. | `apps/desktop/src/preload.ts` | +29/-0 | 14 | **406** | `showNotification` + `onNotificationActivated` on the exposed bridge, plus the `t3xUpdate` bridge object (get / subscribe / restart / dismiss) | | `packages/contracts/src/settings.ts` | +7/-2 | 23 | **207** | `notifyOnNeedsInput` (**persisted schema**) + Claude `homePath` placeholder/description | | `apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift` | +33/-0 | 6 | **198** | Shift+Return newline vs. bare Return submit | +| `scripts/build-desktop-artifact.ts` | +11/-1 | 15 | **180** | Issue #70: `DESKTOP_APP_ID` reads `process.env.T3X_DESKTOP_APP_ID` before falling back to upstream's `com.t3tools.t3code`, so the fork's app owns its own TCC permission rows instead of sharing them with upstream's nightly. Ten of the eleven added lines are the comment explaining that. An env hook rather than a changed literal on purpose: the literal would also have broken three upstream assertions in `build-desktop-artifact.test.ts` and cost a second row. Guarded from the fork side by `scripts/t3x/mac-signature.test.ts` (the hook exists, every build path sets it) and by `scripts/t3x/verify-mac-signature.ts` (the shipped artifact carries the expected identifier) | | `apps/desktop/src/backend/DesktopBackendConfiguration.ts` | +29/-0 | 6 | **174** | Backend heap headroom (`NODE_OPTIONS`) | | `apps/desktop/src/backend/DesktopBackendConfiguration.test.ts` | +42/-0 | 4 | **168** | Heap-headroom assertions | | `apps/desktop/src/main.ts` | +9/-0 | 18 | **162** | `ElectronNotification` layer + the `T3xUpdateDelivery` layer | diff --git a/docs/t3x/mac-signing-runbook.md b/docs/t3x/mac-signing-runbook.md index 43e95e05381a..02b4aa1a85b7 100644 --- a/docs/t3x/mac-signing-runbook.md +++ b/docs/t3x/mac-signing-runbook.md @@ -124,8 +124,14 @@ Program; this does not. **Anyone else's Mac.** The private key lives on one machine and in this repo's secrets. A different person building this fork gets their own identity, hence their own prompts, once. -**The bundle id shared with upstream's build.** Both apps report -`CFBundleIdentifier = com.t3tools.t3code`: +**Windows installs made before this.** `appId` is also the NSIS product identity, so the first +Windows build after the bundle id changed installs alongside the old one instead of upgrading it. +Uninstall the old entry by hand once. macOS is unaffected: the updater targets the `.app` by name, +and the name did not change. + +## The second cause: a bundle id shared with upstream + +Stable signing was necessary but not sufficient, because both apps used to report the same bundle id: ``` $ mdls -name kMDItemCFBundleIdentifier "/Applications/T3 Code (Alpha).app" \ @@ -134,13 +140,29 @@ com.t3tools.t3code com.t3tools.t3code ``` -macOS keys a TCC row on `(service, client)` where `client` is that bundle id, so the two apps share -one row per permission and whichever launched most recently owns it. If you run both `T3 Code (Alpha)` -and upstream's `T3 Code (Nightly)`, expect a prompt when you switch — this fix cannot help with that, -because the two apps are the same app to macOS. Options, in increasing cost: stop keeping Nightly -installed, or give the fork its own bundle id (which means editing `DESKTOP_APP_ID` in -`scripts/build-desktop-artifact.ts` — an upstream-owned file, so a new `SEAMS.md` row, plus one more -round of prompts, plus a second copy of everything keyed to that id). +macOS stores **one TCC row per `(service, client)`**, where `client` is that bundle id. Two apps with +one id share one row per permission, so whichever launched most recently owned the grant and the other +was re-prompted — no matter how perfectly either was signed. Anyone running the fork's build next to +upstream's nightly was getting dialogs from this even with a stable certificate. + +So the fork now has its own: **`dev.curlycloud.coil`**, after `coil` (coil.curlycloud.dev). Set +through `T3X_DESKTOP_APP_ID`, which is the one upstream-owned line this whole change spends — +`DESKTOP_APP_ID` in `scripts/build-desktop-artifact.ts` reads it and falls back to upstream's value, +so upstream's own assertions on that constant still pass and an unset environment builds exactly what +upstream builds. See `SEAMS.md`. + +Two deliberate non-changes: + +- **`productName` stays `T3 Code (Alpha)`.** The updater refuses an install when the `.app` name inside + the dmg differs from the installed one (`resolveMacInstallTarget`), so renaming the app would break + the update path this is meant to make quiet. The rename stops at the bundle id (#71). +- **User data does not move.** `~/Library/Application Support/t3code` comes from a hardcoded + `userDataDirName`, not from the bundle id — threads, settings and sessions are untouched. + +Since it lands in the same release as the signing change, the two identity changes cost **one** +round of prompts between them, not two. `scripts/t3x/mac-signature.test.ts` asserts every build path +sets the variable and that the recorded requirement names this id, because a forgotten variable or a +sync that reverts the seam would silently put the fork back to sharing upstream's row. ## Why no upstream edit was needed diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index a30b6d4a90a2..76b689838e39 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -35,7 +35,17 @@ import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const; -const DESKTOP_APP_ID = "com.t3tools.t3code"; +// t3x fork seam (issue #70). An env escape hatch, not a changed default, so upstream's own +// assertions on this value keep passing and an unset environment builds exactly what upstream does. +// +// macOS stores one TCC permission row per (service, bundle id). This fork's build and upstream's +// nightly are frequently installed side by side and shared `com.t3tools.t3code`, so whichever +// launched last owned the Screen Recording / Accessibility / Files & Folders grants and the other +// was re-prompted. A distinct id for the fork is the only fix — the two apps are otherwise the same +// app to macOS. Set by .github/workflows/t3x-release.yml and scripts/t3x/auto-build-desktop.sh; +// scripts/t3x/verify-mac-signature.ts fails any artifact whose signing identifier is not the +// expected one, so an unset variable cannot ship silently. +const DESKTOP_APP_ID = process.env.T3X_DESKTOP_APP_ID?.trim() || "com.t3tools.t3code"; const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); diff --git a/scripts/t3x/auto-build-desktop.sh b/scripts/t3x/auto-build-desktop.sh index 5129b393fbe7..dbe5b1b6455d 100755 --- a/scripts/t3x/auto-build-desktop.sh +++ b/scripts/t3x/auto-build-desktop.sh @@ -318,6 +318,11 @@ acquire_lock() { # exactly — which is why this needs no upstream edit and no new SEAMS.md row. SETUP_SIGNING="$SCRIPT_DIR/setup-mac-signing.sh" +# Kept in one place so the build and the verifier cannot disagree about it. The single source of +# truth is DESKTOP_BUNDLE_IDENTIFIER in scripts/t3x/mac-signature.ts, and a test asserts this +# literal matches it. +DESKTOP_APP_ID="dev.curlycloud.coil" + # Prints the identity name, or nothing at all when this machine has none set up. Never fails: an # unsigned build is worse than a signed one but better than no build. signing_identity() { @@ -537,7 +542,13 @@ build_once() { return 1 fi log "running: pnpm dist:desktop:dmg:arm64" - if ! ( cd "$REPO" && CSC_NAME="$signing_id" pnpm dist:desktop:dmg:arm64 ); then + # T3X_DESKTOP_APP_ID: the fork's own bundle id (issue #70). macOS keys one permission row per + # (service, bundle id), and sharing `com.t3tools.t3code` with upstream's nightly meant whichever + # app launched last owned the grants. Must match DESKTOP_BUNDLE_IDENTIFIER in + # scripts/t3x/mac-signature.ts — a test asserts it, and verify_signature below fails a build + # whose signing identifier is anything else. + if ! ( cd "$REPO" && CSC_NAME="$signing_id" T3X_DESKTOP_APP_ID="$DESKTOP_APP_ID" \ + pnpm dist:desktop:dmg:arm64 ); then write_status "build-failed" "$cur" "" "pnpm dist:desktop:dmg:arm64 failed" log "BUILD FAILED for $cur" return 1 diff --git a/scripts/t3x/mac-signature.test.ts b/scripts/t3x/mac-signature.test.ts index 2664db772c23..d287bc82588c 100644 --- a/scripts/t3x/mac-signature.test.ts +++ b/scripts/t3x/mac-signature.test.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { + DESKTOP_APP_ID_ENV_VAR, DESKTOP_BUNDLE_IDENTIFIER, evaluateMacSignature, isCdhashKeyedRequirement, @@ -43,7 +44,7 @@ const ADHOC_REQUIREMENTS = `Executable=/Applications/T3 Code (Alpha).app/Content `; const SIGNED_DISPLAY = `Executable=/private/tmp/sigtest/A.app/Contents/MacOS/probe -Identifier=com.t3tools.t3code +Identifier=dev.curlycloud.coil Format=app bundle with Mach-O thin (arm64) CodeDirectory v=20500 size=286 flags=0x10000(runtime) hashes=2+3 location=embedded Hash type=sha256 size=32 @@ -62,7 +63,7 @@ Internal requirements count=1 size=192 * Stable across rebuilds because the certificate does not change — which is the entire fix. */ const SELF_SIGNED_REQUIREMENT = - 'identifier "com.t3tools.t3code" and certificate leaf = H"6dc6e7effe78c5b8406fde43b9afaaf5a85c8eba"'; + 'identifier "dev.curlycloud.coil" and certificate leaf = H"6dc6e7effe78c5b8406fde43b9afaaf5a85c8eba"'; describe("parseCodesignDisplay", () => { it("reads an ad-hoc bundle as having no certificate, no seal and an unbound Info.plist", () => { @@ -80,7 +81,7 @@ describe("parseCodesignDisplay", () => { it("reads a certificate-signed bundle, whose Sealed Resources line has no '=' after the label", () => { const display = parseCodesignDisplay(SIGNED_DISPLAY); - assert.strictEqual(display.identifier, "com.t3tools.t3code"); + assert.strictEqual(display.identifier, "dev.curlycloud.coil"); // `Signature size=4793` is not a `Signature=` field, and must not be read as one. assert.strictEqual(display.signature, undefined); assert.deepStrictEqual([...display.flags], ["runtime"]); @@ -212,7 +213,7 @@ describe("evaluateMacSignature", () => { it("rejects a signature that claims the wrong bundle id", () => { const verdict = evaluateMacSignature({ display: parseCodesignDisplay( - SIGNED_DISPLAY.replace("com.t3tools.t3code", "com.example.other"), + SIGNED_DISPLAY.replace(DESKTOP_BUNDLE_IDENTIFIER, "com.example.other"), ), requirement: SELF_SIGNED_REQUIREMENT, }); @@ -242,24 +243,78 @@ describe("normalizeRequirement", () => { }); }); -it.layer(NodeServices.layer)("mirrored upstream constants", (it) => { - /** - * The verifier checks the signature claims OUR bundle id, and that id is defined in - * scripts/build-desktop-artifact.ts — an upstream-owned file that does not export it. This test - * is the drift detector for that copy: if upstream renames the app id, this fails loudly instead - * of the verifier quietly asserting a bundle id nothing produces any more. - */ - it.effect("DESKTOP_BUNDLE_IDENTIFIER still matches DESKTOP_APP_ID upstream", () => +/** + * The bundle id has to agree in four places, and nothing at runtime would notice if it stopped. + * + * `scripts/build-desktop-artifact.ts` is upstream-owned and carries only the escape hatch — an + * upstream sync that reverts that one line leaves every fork build silently claiming + * `com.t3tools.t3code` again, which puts it back to sharing one TCC row with upstream's nightly. + * Equally, a build path that forgets to SET the variable ships the same regression. Both are cheap + * to assert here and expensive to notice in the wild: the symptom is permission dialogs, days later, + * on a machine that is not CI. + */ +it.layer(NodeServices.layer)("the fork's bundle id agrees everywhere", (it) => { + const readRepoFile = (...segments: readonly string[]) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const source = yield* fs.readFileString( - path.join(import.meta.dirname, "..", "build-desktop-artifact.ts"), + return yield* fs.readFileString(path.join(import.meta.dirname, "..", "..", ...segments)); + }); + + it.effect("build-desktop-artifact.ts still honours the T3X_DESKTOP_APP_ID override", () => + Effect.gen(function* () { + const source = yield* readRepoFile("scripts", "build-desktop-artifact.ts"); + + assert.match( + source, + /const DESKTOP_APP_ID = process\.env\.T3X_DESKTOP_APP_ID\?\.trim\(\) \|\| "com\.t3tools\.t3code"/, + "the fork's app-id seam is gone from build-desktop-artifact.ts — a sync probably reverted it", ); + }), + ); + + it.effect("the release workflow builds with it", () => + Effect.gen(function* () { + const workflow = yield* readRepoFile(".github", "workflows", "t3x-release.yml"); + const match = new RegExp(`${DESKTOP_APP_ID_ENV_VAR}: (\\S+)`).exec(workflow); - const match = /const DESKTOP_APP_ID = "([^"]+)"/.exec(source); - assert.ok(match, "DESKTOP_APP_ID is no longer declared as a string literal in that file"); + assert.ok(match, `${DESKTOP_APP_ID_ENV_VAR} is not set anywhere in t3x-release.yml`); assert.strictEqual(match[1], DESKTOP_BUNDLE_IDENTIFIER); }), ); + + it.effect("the local autobuild builds with it", () => + Effect.gen(function* () { + const script = yield* readRepoFile("scripts", "t3x", "auto-build-desktop.sh"); + + assert.match(script, new RegExp(`DESKTOP_APP_ID="${DESKTOP_BUNDLE_IDENTIFIER}"`)); + assert.match(script, new RegExp(`${DESKTOP_APP_ID_ENV_VAR}="\\$DESKTOP_APP_ID"`)); + }), + ); + + it.effect( + "the certificate setup signs its stub with it, so the recorded requirement matches", + () => + Effect.gen(function* () { + const script = yield* readRepoFile("scripts", "t3x", "setup-mac-signing.sh"); + + assert.match(script, new RegExp(`BUNDLE_ID="${DESKTOP_BUNDLE_IDENTIFIER}"`)); + }), + ); + + it.effect("the recorded designated requirement is the one this identifier produces", () => + Effect.gen(function* () { + const recorded = yield* readRepoFile( + "docs", + "t3x", + "mac-signing", + "designated-requirement.txt", + ); + + // Not a format check for its own sake. This file is what the release compares against, so a + // requirement naming a different bundle id would fail every build with a confusing diff. + assert.include(normalizeRequirement(recorded), `identifier "${DESKTOP_BUNDLE_IDENTIFIER}"`); + assert.strictEqual(isCdhashKeyedRequirement(recorded), false); + }), + ); }); diff --git a/scripts/t3x/mac-signature.ts b/scripts/t3x/mac-signature.ts index 737eccd92c1b..1c2aab8efaee 100644 --- a/scripts/t3x/mac-signature.ts +++ b/scripts/t3x/mac-signature.ts @@ -19,12 +19,27 @@ */ /** - * LOGIC MIRROR of `DESKTOP_APP_ID` in scripts/build-desktop-artifact.ts, which is upstream-owned - * and does not export it. `mac-signature.test.ts` reads that file and fails if the two drift, so a - * rename upstream surfaces as a red test rather than as a verifier that silently checks the wrong - * bundle id. + * The fork's own bundle id, and the second half of the #70 fix. + * + * macOS stores one TCC row per `(service, client)`, where `client` is the bundle id — so the fork's + * build and upstream's `T3 Code (Nightly)`, both `com.t3tools.t3code` and both commonly installed, + * shared one row per permission. Whichever launched last owned the grant and the other was + * re-prompted, which no amount of correct signing can fix. Renamed after `coil` + * (coil.curlycloud.dev), the fork's own home. + * + * Deliberately NOT a rename of `productName`: the updater refuses an install when the `.app` name + * inside the dmg differs from the installed one (`resolveMacInstallTarget`), so renaming the app + * would break the very update path this is meant to make quiet. The app stays `T3 Code (Alpha)`. + * + * Fed to the build through `T3X_DESKTOP_APP_ID`, which `scripts/build-desktop-artifact.ts` reads. + * `mac-signature.test.ts` asserts that hook still exists and that every build path sets it to this + * value — an upstream sync that reverts the seam, or a workflow that forgets the variable, would + * otherwise silently ship the old id and reset every permission again. */ -export const DESKTOP_BUNDLE_IDENTIFIER = "com.t3tools.t3code"; +export const DESKTOP_BUNDLE_IDENTIFIER = "dev.curlycloud.coil"; + +/** The environment variable the fork's build paths use to set {@link DESKTOP_BUNDLE_IDENTIFIER}. */ +export const DESKTOP_APP_ID_ENV_VAR = "T3X_DESKTOP_APP_ID"; /** * The fork's self-signed code-signing identity, created by scripts/t3x/setup-mac-signing.sh. diff --git a/scripts/t3x/setup-mac-signing.sh b/scripts/t3x/setup-mac-signing.sh index d227017c3389..944d278ffa3b 100755 --- a/scripts/t3x/setup-mac-signing.sh +++ b/scripts/t3x/setup-mac-signing.sh @@ -55,7 +55,11 @@ P12_PATH="$SIGNING_DIR/t3x-signing.p12" P12_PASSWORD_PATH="$SIGNING_DIR/p12-password" KEYCHAIN_PASSWORD_PATH="$SIGNING_DIR/keychain-password" VALIDITY_DAYS=3650 -BUNDLE_ID="com.t3tools.t3code" +# The fork's own bundle id (issue #70), mirroring DESKTOP_BUNDLE_IDENTIFIER in +# scripts/t3x/mac-signature.ts. It appears in the designated requirement, so the stub bundle signed +# by --print-requirement has to carry the SAME id as the shipped app or the recorded requirement +# would never match a real build. +BUNDLE_ID="dev.curlycloud.coil" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || printf '')" From 34f73e0e89dd477158d695f3733628c42b3038bb Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:55:46 -0400 Subject: [PATCH 3/5] fix(t3x): pin the designated requirement the fork's identity produces (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recorded from the identity itself, and proven stable before being trusted: two stub bundles with different contents (CDHash e7c414c8… vs 6e01fb0b…) signed with this certificate produce one byte-identical requirement, and both satisfy it under `codesign --verify --deep --strict`. identifier "dev.curlycloud.coil" and certificate leaf = H"267dc442f7…" Compare what shipped before — `cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"`, a different value on every build, which is the entire bug. This file is what turns "is it signed?" into "is it signed by the same thing as last time?". Rehearsed both ways against the real identity: a build signed with it verifies clean, and one signed with a different valid certificate is refused with the requirement diff, exit 1. Also cross-references #71: renaming the certificate would move this string and cost another round of prompts, and renaming productName makes the updater refuse the first renamed build by design. Co-Authored-By: Claude Opus 5 (1M context) --- docs/t3x/mac-signing-runbook.md | 6 ++++++ docs/t3x/mac-signing/designated-requirement.txt | 1 + 2 files changed, 7 insertions(+) create mode 100644 docs/t3x/mac-signing/designated-requirement.txt diff --git a/docs/t3x/mac-signing-runbook.md b/docs/t3x/mac-signing-runbook.md index 02b4aa1a85b7..9300607a4759 100644 --- a/docs/t3x/mac-signing-runbook.md +++ b/docs/t3x/mac-signing-runbook.md @@ -219,6 +219,12 @@ so a rotation that forgets them fails the next release instead of quietly re-pro ## Related +- **Issue #71 — renaming the fork to `coil`.** Read the notes on that issue before renaming anything + here. Two things it has to respect: renaming the signing certificate (`T3X Code Signing`, which a + sweep of `T3X` will find) changes the designated requirement and costs another round of prompts, + and renaming `productName` makes the updater refuse the first renamed build by design + (`resolveMacInstallTarget`, "would create a second app"). Renaming the _visible app_ is otherwise + free in permission terms — grants are keyed to the bundle id and the certificate, not the name. - `docs/t3x/auto-build-runbook.md` — the local build/install loop, which signs the same way. - Issue #41 — the autobuild relaunch race. Unrelated, adjacent. - Issue #72 / PR #78 (still open, branch `t3x/install-instructions`) — the first-launch install copy, diff --git a/docs/t3x/mac-signing/designated-requirement.txt b/docs/t3x/mac-signing/designated-requirement.txt new file mode 100644 index 000000000000..1de317d5d049 --- /dev/null +++ b/docs/t3x/mac-signing/designated-requirement.txt @@ -0,0 +1 @@ +identifier "dev.curlycloud.coil" and certificate leaf = H"267dc442f7391fdc632c010cdfc3b0d70b349d90" From a083509029a27b0504ad039f4ae24e26524365a6 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:56:10 -0400 Subject: [PATCH 4/5] =?UTF-8?q?docs(t3x):=20correct=20the=20runbook's=20se?= =?UTF-8?q?am=20claim=20=E2=80=94=20the=20bundle=20id=20spends=20one=20ups?= =?UTF-8?q?tream=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header still said 'zero upstream seams', which was true of the signing half and stopped being true when the bundle-id change landed in the same PR. A doc that contradicts SEAMS.md is worse than no doc. Co-Authored-By: Claude Opus 5 (1M context) --- docs/t3x/mac-signing-runbook.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/t3x/mac-signing-runbook.md b/docs/t3x/mac-signing-runbook.md index 9300607a4759..0f21a78f3b51 100644 --- a/docs/t3x/mac-signing-runbook.md +++ b/docs/t3x/mac-signing-runbook.md @@ -4,9 +4,11 @@ Issue #70. Every update used to re-ask for Screen Recording, Accessibility, Micr Folders and Local Network. That was not the updater misbehaving and not a quarantine problem — it was code signing, and the fix cost $0. -**Zero upstream seams.** Everything here lives in `scripts/t3x/`, `.github/workflows/t3x-release.yml` -and this directory. `scripts/build-desktop-artifact.ts` is not touched, so this adds no row to -`SEAMS.md` — see [Why no upstream edit was needed](#why-no-upstream-edit-was-needed). +**One upstream line, and not the one the issue predicted.** The signing half needs no upstream edit at +all — see [Why signing needed no upstream edit](#why-signing-needed-no-upstream-edit). The second half +(a bundle id of the fork's own, below) spends exactly one line in `scripts/build-desktop-artifact.ts`, +recorded as `SEAMS.md`'s 38th row. Everything else lives in `scripts/t3x/`, +`.github/workflows/t3x-release.yml` and this directory. ## The diagnosis, in two commands @@ -164,7 +166,7 @@ round of prompts between them, not two. `scripts/t3x/mac-signature.test.ts` asse sets the variable and that the recorded requirement names this id, because a forgotten variable or a sync that reverts the seam would silently put the fork back to sharing upstream's row. -## Why no upstream edit was needed +## Why signing needed no upstream edit `scripts/build-desktop-artifact.ts:1996` sets `CSC_IDENTITY_AUTO_DISCOVERY=false` whenever `--signed` is absent, and issue #70's plan concluded from that a third signing mode had to be added to the file. From a47c41a50a75317e7afb06ff55c520620f924572 Mon Sep 17 00:00:00 2001 From: Raj D <25481060+radroid@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:57:59 -0400 Subject: [PATCH 5/5] docs(t3x): note that the trusted certificate is listed twice, and why that is fine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `add-trusted-cert -k /Library/Keychains/System.keychain` copies the certificate into the System keychain as well as trusting it, so find-identity reports the identity from two keychains. Verified that codesign by name with no --keychain — electron-builder's exact call — still resolves and signs correctly. Co-Authored-By: Claude Opus 5 (1M context) --- docs/t3x/mac-signing-runbook.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/t3x/mac-signing-runbook.md b/docs/t3x/mac-signing-runbook.md index 0f21a78f3b51..246f4094ad24 100644 --- a/docs/t3x/mac-signing-runbook.md +++ b/docs/t3x/mac-signing-runbook.md @@ -70,6 +70,13 @@ success unless `security find-identity -v -p codesigning` lists the identity. On ad-hoc build. If the script is running somewhere with no terminal to answer on, it prints the exact command and stops rather than hanging on a `sudo` prompt. +Expect `security find-identity -v -p codesigning` to list `T3X Code Signing` **twice** afterwards, with +the same SHA-1 both times: `add-trusted-cert -k /Library/Keychains/System.keychain` copies the +certificate into the System keychain as well as trusting it, so it is visible from two keychains at +once. Verified harmless — `codesign -s "T3X Code Signing"` with no `--keychain` (which is exactly how +electron-builder signs) resolves it, signs, and produces the expected requirement. Two entries for two +_different_ certificates of the same name would be a real problem; two for one certificate is not. + Then the release workflow needs the private key, as two repository secrets: ```bash