fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70) - #85

Merged
radroid merged 5 commits into
mainfrom
t3x/mac-signing
Aug 12, 2026
Merged

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70)#85
radroid merged 5 commits into
mainfrom
t3x/mac-signing

Conversation

@radroid

Copy link
Copy Markdown
Owner

Closes#70.

The bug, in two commands

$ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # the fork, before
# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"
$ codesign -d --requirements - "/Applications/T3 Code (Nightly).app" # upstream, for contrast
designated => identifier "com.t3tools.t3code" and anchor apple generic and … subject.OU = ARK85ZXQ4Z

macOS keys every permission grant to the app's designated requirement. With no certificate to
name, codesign falls back to the binary's cdhash, which changes when any byte of the app changes —
so every update was a brand-new app to macOS, and Screen Recording, Accessibility, Microphone,
Files & Folders and Local Network were re-requested from scratch every time.

The fix is not "sign it" but "sign it with an identity that does not move", which is why 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.

What landed

  • scripts/t3x/setup-mac-signing.sh — creates the identity (self-signed, 10 years, in its own
    keychain so set-key-partition-list can run non-interactively and no GUI keychain dialog can wedge
    an unattended build), trusts it, self-verifies by signing a throwaway bundle, and exports the p12
    for CI. Idempotent. Prints the exact sudo command instead of hanging when there is no terminal.
  • t3x-release.yml — imports the p12 secret on the mac runner and exports CSC_NAME.
  • auto-build-desktop.sh — picks the same identity up locally, so the release path and the local
    autobuild produce one identical requirement instead of two.
  • scripts/t3x/verify-mac-signature.ts (+ 17 tests) — asserts the claim on the .app inside the
    shipped .dmg. Fails a changed identity as well as a missing one: a build signed by a different
    certificate is perfectly signed and still costs a full round of dialogs, so the requirement is
    pinned in docs/t3x/mac-signing/designated-requirement.txt and compared byte for byte.
  • docs/t3x/mac-signing-runbook.md — setup, verification, rotation, and what this does not fix.

Zero new SEAMS.md rows

#70's plan called for a third signing mode inside scripts/build-desktop-artifact.ts — a row on a hot
upstream file. It turned out to be unnecessary. CSC_IDENTITY_AUTO_DISCOVERY=false is only consulted
when no identity was named:

// app-builder-lib/out/codeSign/macCodeSign.jsletidentity=qualifier||process.env.CSC_NAME;if(isEmptyOrSpaces(identity)){/* only here does the flag apply */}return_findIdentity(certType,identity.trim(),keychain);

So exporting CSC_NAME around the existing unsigned build is the whole mechanism. An empty
CSC_NAME is identical to no CSC_NAME, which is what makes it safe to wire in unconditionally.

Verification

  • The mechanism was proven before it was wired up: two bundles with different contents (hence
    different cdhashes) signed with one certificate produce a byte-identical designated
    requirement, both codesign --verify --deep --strict clean.
  • The verifier was run against the real ad-hoc app (exit 1, with all five defects named), against a
    correctly signed .app inside a .dmg (exit 0, mounts and detaches cleanly), and with
    --allow-unsigned (exit 0, warns).
  • scripts package: 218 tests pass, typecheck and lint clean. Release-workflow YAML parses and the
    new steps are ordered correctly.
  • Not verifiable in CI: the acceptance test itself — two signed builds installed in sequence with no
    dialog on the second. That needs a real machine and a real update, and is the next step after merge.

Expect one more round of prompts

The first signed build moves the identity from a cdhash to a certificate, so existing grants no
longer match and macOS asks once more. That install looks exactly like the bug it fixes — the fix
is judged on the second signed install.

Known, separate: the bundle id is shared with upstream's build

Both /Applications/T3 Code (Alpha).app and upstream's T3 Code (Nightly).app report
CFBundleIdentifier = com.t3tools.t3code, and TCC stores one row per (service, bundle id). So
switching between the two apps re-prompts no matter how well either is signed. Documented in the
runbook with the options; not fixed here, because giving the fork its own bundle id means editing
DESKTOP_APP_ID in an upstream-owned file — a real SEAMS row, and one more permission reset.

🤖 Generated with Claude Code

…rants survive updates (#70)
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) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2699b649-38b7-4193-907e-60721797d534

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ws with upstream (#70)
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) <noreply@anthropic.com>
radroidand others added 3 commits August 11, 2026 21:55
…#70)
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) <noreply@anthropic.com>
…e upstream line
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) <noreply@anthropic.com>
… that is fine
`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) <noreply@anthropic.com>
@radroid
radroid merged commit 456b7bd into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/mac-signing branch August 12, 2026 02:06
radroid added a commit that referenced this pull request Aug 12, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 12, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS re-prompts for every permission after each update, because the fork build is ad-hoc signed with a new identity every time

1 participant

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70) - #85

Merged
radroid merged 5 commits into
mainfrom
t3x/mac-signing
Aug 12, 2026
Merged

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70)#85
radroid merged 5 commits into
mainfrom
t3x/mac-signing

Conversation

@radroid

Copy link
Copy Markdown
Owner

Closes#70.

The bug, in two commands

$ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # the fork, before
# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"
$ codesign -d --requirements - "/Applications/T3 Code (Nightly).app" # upstream, for contrast
designated => identifier "com.t3tools.t3code" and anchor apple generic and … subject.OU = ARK85ZXQ4Z

macOS keys every permission grant to the app's designated requirement. With no certificate to
name, codesign falls back to the binary's cdhash, which changes when any byte of the app changes —
so every update was a brand-new app to macOS, and Screen Recording, Accessibility, Microphone,
Files & Folders and Local Network were re-requested from scratch every time.

The fix is not "sign it" but "sign it with an identity that does not move", which is why 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.

What landed

  • scripts/t3x/setup-mac-signing.sh — creates the identity (self-signed, 10 years, in its own
    keychain so set-key-partition-list can run non-interactively and no GUI keychain dialog can wedge
    an unattended build), trusts it, self-verifies by signing a throwaway bundle, and exports the p12
    for CI. Idempotent. Prints the exact sudo command instead of hanging when there is no terminal.
  • t3x-release.yml — imports the p12 secret on the mac runner and exports CSC_NAME.
  • auto-build-desktop.sh — picks the same identity up locally, so the release path and the local
    autobuild produce one identical requirement instead of two.
  • scripts/t3x/verify-mac-signature.ts (+ 17 tests) — asserts the claim on the .app inside the
    shipped .dmg. Fails a changed identity as well as a missing one: a build signed by a different
    certificate is perfectly signed and still costs a full round of dialogs, so the requirement is
    pinned in docs/t3x/mac-signing/designated-requirement.txt and compared byte for byte.
  • docs/t3x/mac-signing-runbook.md — setup, verification, rotation, and what this does not fix.

Zero new SEAMS.md rows

#70's plan called for a third signing mode inside scripts/build-desktop-artifact.ts — a row on a hot
upstream file. It turned out to be unnecessary. CSC_IDENTITY_AUTO_DISCOVERY=false is only consulted
when no identity was named:

// app-builder-lib/out/codeSign/macCodeSign.jsletidentity=qualifier||process.env.CSC_NAME;if(isEmptyOrSpaces(identity)){/* only here does the flag apply */}return_findIdentity(certType,identity.trim(),keychain);

So exporting CSC_NAME around the existing unsigned build is the whole mechanism. An empty
CSC_NAME is identical to no CSC_NAME, which is what makes it safe to wire in unconditionally.

Verification

  • The mechanism was proven before it was wired up: two bundles with different contents (hence
    different cdhashes) signed with one certificate produce a byte-identical designated
    requirement, both codesign --verify --deep --strict clean.
  • The verifier was run against the real ad-hoc app (exit 1, with all five defects named), against a
    correctly signed .app inside a .dmg (exit 0, mounts and detaches cleanly), and with
    --allow-unsigned (exit 0, warns).
  • scripts package: 218 tests pass, typecheck and lint clean. Release-workflow YAML parses and the
    new steps are ordered correctly.
  • Not verifiable in CI: the acceptance test itself — two signed builds installed in sequence with no
    dialog on the second. That needs a real machine and a real update, and is the next step after merge.

Expect one more round of prompts

The first signed build moves the identity from a cdhash to a certificate, so existing grants no
longer match and macOS asks once more. That install looks exactly like the bug it fixes — the fix
is judged on the second signed install.

Known, separate: the bundle id is shared with upstream's build

Both /Applications/T3 Code (Alpha).app and upstream's T3 Code (Nightly).app report
CFBundleIdentifier = com.t3tools.t3code, and TCC stores one row per (service, bundle id). So
switching between the two apps re-prompts no matter how well either is signed. Documented in the
runbook with the options; not fixed here, because giving the fork its own bundle id means editing
DESKTOP_APP_ID in an upstream-owned file — a real SEAMS row, and one more permission reset.

🤖 Generated with Claude Code

…rants survive updates (#70)
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) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2699b649-38b7-4193-907e-60721797d534

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ws with upstream (#70)
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) <noreply@anthropic.com>
radroidand others added 3 commits August 11, 2026 21:55
…#70)
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) <noreply@anthropic.com>
…e upstream line
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) <noreply@anthropic.com>
… that is fine
`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) <noreply@anthropic.com>
@radroid
radroid merged commit 456b7bd into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/mac-signing branch August 12, 2026 02:06
radroid added a commit that referenced this pull request Aug 12, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 12, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS re-prompts for every permission after each update, because the fork build is ad-hoc signed with a new identity every time

1 participant

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70) - #85

Merged
radroid merged 5 commits into
mainfrom
t3x/mac-signing
Aug 12, 2026
Merged

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70)#85
radroid merged 5 commits into
mainfrom
t3x/mac-signing

Conversation

@radroid

Copy link
Copy Markdown
Owner

Closes#70.

The bug, in two commands

$ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # the fork, before
# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"
$ codesign -d --requirements - "/Applications/T3 Code (Nightly).app" # upstream, for contrast
designated => identifier "com.t3tools.t3code" and anchor apple generic and … subject.OU = ARK85ZXQ4Z

macOS keys every permission grant to the app's designated requirement. With no certificate to
name, codesign falls back to the binary's cdhash, which changes when any byte of the app changes —
so every update was a brand-new app to macOS, and Screen Recording, Accessibility, Microphone,
Files & Folders and Local Network were re-requested from scratch every time.

The fix is not "sign it" but "sign it with an identity that does not move", which is why 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.

What landed

  • scripts/t3x/setup-mac-signing.sh — creates the identity (self-signed, 10 years, in its own
    keychain so set-key-partition-list can run non-interactively and no GUI keychain dialog can wedge
    an unattended build), trusts it, self-verifies by signing a throwaway bundle, and exports the p12
    for CI. Idempotent. Prints the exact sudo command instead of hanging when there is no terminal.
  • t3x-release.yml — imports the p12 secret on the mac runner and exports CSC_NAME.
  • auto-build-desktop.sh — picks the same identity up locally, so the release path and the local
    autobuild produce one identical requirement instead of two.
  • scripts/t3x/verify-mac-signature.ts (+ 17 tests) — asserts the claim on the .app inside the
    shipped .dmg. Fails a changed identity as well as a missing one: a build signed by a different
    certificate is perfectly signed and still costs a full round of dialogs, so the requirement is
    pinned in docs/t3x/mac-signing/designated-requirement.txt and compared byte for byte.
  • docs/t3x/mac-signing-runbook.md — setup, verification, rotation, and what this does not fix.

Zero new SEAMS.md rows

#70's plan called for a third signing mode inside scripts/build-desktop-artifact.ts — a row on a hot
upstream file. It turned out to be unnecessary. CSC_IDENTITY_AUTO_DISCOVERY=false is only consulted
when no identity was named:

// app-builder-lib/out/codeSign/macCodeSign.jsletidentity=qualifier||process.env.CSC_NAME;if(isEmptyOrSpaces(identity)){/* only here does the flag apply */}return_findIdentity(certType,identity.trim(),keychain);

So exporting CSC_NAME around the existing unsigned build is the whole mechanism. An empty
CSC_NAME is identical to no CSC_NAME, which is what makes it safe to wire in unconditionally.

Verification

  • The mechanism was proven before it was wired up: two bundles with different contents (hence
    different cdhashes) signed with one certificate produce a byte-identical designated
    requirement, both codesign --verify --deep --strict clean.
  • The verifier was run against the real ad-hoc app (exit 1, with all five defects named), against a
    correctly signed .app inside a .dmg (exit 0, mounts and detaches cleanly), and with
    --allow-unsigned (exit 0, warns).
  • scripts package: 218 tests pass, typecheck and lint clean. Release-workflow YAML parses and the
    new steps are ordered correctly.
  • Not verifiable in CI: the acceptance test itself — two signed builds installed in sequence with no
    dialog on the second. That needs a real machine and a real update, and is the next step after merge.

Expect one more round of prompts

The first signed build moves the identity from a cdhash to a certificate, so existing grants no
longer match and macOS asks once more. That install looks exactly like the bug it fixes — the fix
is judged on the second signed install.

Known, separate: the bundle id is shared with upstream's build

Both /Applications/T3 Code (Alpha).app and upstream's T3 Code (Nightly).app report
CFBundleIdentifier = com.t3tools.t3code, and TCC stores one row per (service, bundle id). So
switching between the two apps re-prompts no matter how well either is signed. Documented in the
runbook with the options; not fixed here, because giving the fork its own bundle id means editing
DESKTOP_APP_ID in an upstream-owned file — a real SEAMS row, and one more permission reset.

🤖 Generated with Claude Code

…rants survive updates (#70)
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) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2699b649-38b7-4193-907e-60721797d534

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ws with upstream (#70)
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) <noreply@anthropic.com>
radroidand others added 3 commits August 11, 2026 21:55
…#70)
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) <noreply@anthropic.com>
…e upstream line
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) <noreply@anthropic.com>
… that is fine
`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) <noreply@anthropic.com>
@radroid
radroid merged commit 456b7bd into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/mac-signing branch August 12, 2026 02:06
radroid added a commit that referenced this pull request Aug 12, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 12, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS re-prompts for every permission after each update, because the fork build is ad-hoc signed with a new identity every time

1 participant

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70) - #85

Merged
radroid merged 5 commits into
mainfrom
t3x/mac-signing
Aug 12, 2026
Merged

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70)#85
radroid merged 5 commits into
mainfrom
t3x/mac-signing

Conversation

@radroid

Copy link
Copy Markdown
Owner

Closes#70.

The bug, in two commands

$ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # the fork, before
# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"
$ codesign -d --requirements - "/Applications/T3 Code (Nightly).app" # upstream, for contrast
designated => identifier "com.t3tools.t3code" and anchor apple generic and … subject.OU = ARK85ZXQ4Z

macOS keys every permission grant to the app's designated requirement. With no certificate to
name, codesign falls back to the binary's cdhash, which changes when any byte of the app changes —
so every update was a brand-new app to macOS, and Screen Recording, Accessibility, Microphone,
Files & Folders and Local Network were re-requested from scratch every time.

The fix is not "sign it" but "sign it with an identity that does not move", which is why 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.

What landed

  • scripts/t3x/setup-mac-signing.sh — creates the identity (self-signed, 10 years, in its own
    keychain so set-key-partition-list can run non-interactively and no GUI keychain dialog can wedge
    an unattended build), trusts it, self-verifies by signing a throwaway bundle, and exports the p12
    for CI. Idempotent. Prints the exact sudo command instead of hanging when there is no terminal.
  • t3x-release.yml — imports the p12 secret on the mac runner and exports CSC_NAME.
  • auto-build-desktop.sh — picks the same identity up locally, so the release path and the local
    autobuild produce one identical requirement instead of two.
  • scripts/t3x/verify-mac-signature.ts (+ 17 tests) — asserts the claim on the .app inside the
    shipped .dmg. Fails a changed identity as well as a missing one: a build signed by a different
    certificate is perfectly signed and still costs a full round of dialogs, so the requirement is
    pinned in docs/t3x/mac-signing/designated-requirement.txt and compared byte for byte.
  • docs/t3x/mac-signing-runbook.md — setup, verification, rotation, and what this does not fix.

Zero new SEAMS.md rows

#70's plan called for a third signing mode inside scripts/build-desktop-artifact.ts — a row on a hot
upstream file. It turned out to be unnecessary. CSC_IDENTITY_AUTO_DISCOVERY=false is only consulted
when no identity was named:

// app-builder-lib/out/codeSign/macCodeSign.jsletidentity=qualifier||process.env.CSC_NAME;if(isEmptyOrSpaces(identity)){/* only here does the flag apply */}return_findIdentity(certType,identity.trim(),keychain);

So exporting CSC_NAME around the existing unsigned build is the whole mechanism. An empty
CSC_NAME is identical to no CSC_NAME, which is what makes it safe to wire in unconditionally.

Verification

  • The mechanism was proven before it was wired up: two bundles with different contents (hence
    different cdhashes) signed with one certificate produce a byte-identical designated
    requirement, both codesign --verify --deep --strict clean.
  • The verifier was run against the real ad-hoc app (exit 1, with all five defects named), against a
    correctly signed .app inside a .dmg (exit 0, mounts and detaches cleanly), and with
    --allow-unsigned (exit 0, warns).
  • scripts package: 218 tests pass, typecheck and lint clean. Release-workflow YAML parses and the
    new steps are ordered correctly.
  • Not verifiable in CI: the acceptance test itself — two signed builds installed in sequence with no
    dialog on the second. That needs a real machine and a real update, and is the next step after merge.

Expect one more round of prompts

The first signed build moves the identity from a cdhash to a certificate, so existing grants no
longer match and macOS asks once more. That install looks exactly like the bug it fixes — the fix
is judged on the second signed install.

Known, separate: the bundle id is shared with upstream's build

Both /Applications/T3 Code (Alpha).app and upstream's T3 Code (Nightly).app report
CFBundleIdentifier = com.t3tools.t3code, and TCC stores one row per (service, bundle id). So
switching between the two apps re-prompts no matter how well either is signed. Documented in the
runbook with the options; not fixed here, because giving the fork its own bundle id means editing
DESKTOP_APP_ID in an upstream-owned file — a real SEAMS row, and one more permission reset.

🤖 Generated with Claude Code

…rants survive updates (#70)
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) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2699b649-38b7-4193-907e-60721797d534

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ws with upstream (#70)
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) <noreply@anthropic.com>
radroidand others added 3 commits August 11, 2026 21:55
…#70)
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) <noreply@anthropic.com>
…e upstream line
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) <noreply@anthropic.com>
… that is fine
`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) <noreply@anthropic.com>
@radroid
radroid merged commit 456b7bd into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/mac-signing branch August 12, 2026 02:06
radroid added a commit that referenced this pull request Aug 12, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 12, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS re-prompts for every permission after each update, because the fork build is ad-hoc signed with a new identity every time

1 participant

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70) - #85

Merged
radroid merged 5 commits into
mainfrom
t3x/mac-signing
Aug 12, 2026
Merged

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70)#85
radroid merged 5 commits into
mainfrom
t3x/mac-signing

Conversation

@radroid

Copy link
Copy Markdown
Owner

Closes#70.

The bug, in two commands

$ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # the fork, before
# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"
$ codesign -d --requirements - "/Applications/T3 Code (Nightly).app" # upstream, for contrast
designated => identifier "com.t3tools.t3code" and anchor apple generic and … subject.OU = ARK85ZXQ4Z

macOS keys every permission grant to the app's designated requirement. With no certificate to
name, codesign falls back to the binary's cdhash, which changes when any byte of the app changes —
so every update was a brand-new app to macOS, and Screen Recording, Accessibility, Microphone,
Files & Folders and Local Network were re-requested from scratch every time.

The fix is not "sign it" but "sign it with an identity that does not move", which is why 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.

What landed

  • scripts/t3x/setup-mac-signing.sh — creates the identity (self-signed, 10 years, in its own
    keychain so set-key-partition-list can run non-interactively and no GUI keychain dialog can wedge
    an unattended build), trusts it, self-verifies by signing a throwaway bundle, and exports the p12
    for CI. Idempotent. Prints the exact sudo command instead of hanging when there is no terminal.
  • t3x-release.yml — imports the p12 secret on the mac runner and exports CSC_NAME.
  • auto-build-desktop.sh — picks the same identity up locally, so the release path and the local
    autobuild produce one identical requirement instead of two.
  • scripts/t3x/verify-mac-signature.ts (+ 17 tests) — asserts the claim on the .app inside the
    shipped .dmg. Fails a changed identity as well as a missing one: a build signed by a different
    certificate is perfectly signed and still costs a full round of dialogs, so the requirement is
    pinned in docs/t3x/mac-signing/designated-requirement.txt and compared byte for byte.
  • docs/t3x/mac-signing-runbook.md — setup, verification, rotation, and what this does not fix.

Zero new SEAMS.md rows

#70's plan called for a third signing mode inside scripts/build-desktop-artifact.ts — a row on a hot
upstream file. It turned out to be unnecessary. CSC_IDENTITY_AUTO_DISCOVERY=false is only consulted
when no identity was named:

// app-builder-lib/out/codeSign/macCodeSign.jsletidentity=qualifier||process.env.CSC_NAME;if(isEmptyOrSpaces(identity)){/* only here does the flag apply */}return_findIdentity(certType,identity.trim(),keychain);

So exporting CSC_NAME around the existing unsigned build is the whole mechanism. An empty
CSC_NAME is identical to no CSC_NAME, which is what makes it safe to wire in unconditionally.

Verification

  • The mechanism was proven before it was wired up: two bundles with different contents (hence
    different cdhashes) signed with one certificate produce a byte-identical designated
    requirement, both codesign --verify --deep --strict clean.
  • The verifier was run against the real ad-hoc app (exit 1, with all five defects named), against a
    correctly signed .app inside a .dmg (exit 0, mounts and detaches cleanly), and with
    --allow-unsigned (exit 0, warns).
  • scripts package: 218 tests pass, typecheck and lint clean. Release-workflow YAML parses and the
    new steps are ordered correctly.
  • Not verifiable in CI: the acceptance test itself — two signed builds installed in sequence with no
    dialog on the second. That needs a real machine and a real update, and is the next step after merge.

Expect one more round of prompts

The first signed build moves the identity from a cdhash to a certificate, so existing grants no
longer match and macOS asks once more. That install looks exactly like the bug it fixes — the fix
is judged on the second signed install.

Known, separate: the bundle id is shared with upstream's build

Both /Applications/T3 Code (Alpha).app and upstream's T3 Code (Nightly).app report
CFBundleIdentifier = com.t3tools.t3code, and TCC stores one row per (service, bundle id). So
switching between the two apps re-prompts no matter how well either is signed. Documented in the
runbook with the options; not fixed here, because giving the fork its own bundle id means editing
DESKTOP_APP_ID in an upstream-owned file — a real SEAMS row, and one more permission reset.

🤖 Generated with Claude Code

…rants survive updates (#70)
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) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2699b649-38b7-4193-907e-60721797d534

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ws with upstream (#70)
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) <noreply@anthropic.com>
radroidand others added 3 commits August 11, 2026 21:55
…#70)
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) <noreply@anthropic.com>
…e upstream line
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) <noreply@anthropic.com>
… that is fine
`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) <noreply@anthropic.com>
@radroid
radroid merged commit 456b7bd into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/mac-signing branch August 12, 2026 02:06
radroid added a commit that referenced this pull request Aug 12, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 12, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS re-prompts for every permission after each update, because the fork build is ad-hoc signed with a new identity every time

1 participant

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70) - #85

Merged
radroid merged 5 commits into
mainfrom
t3x/mac-signing
Aug 12, 2026
Merged

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70)#85
radroid merged 5 commits into
mainfrom
t3x/mac-signing

Conversation

@radroid

Copy link
Copy Markdown
Owner

Closes#70.

The bug, in two commands

$ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # the fork, before
# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"
$ codesign -d --requirements - "/Applications/T3 Code (Nightly).app" # upstream, for contrast
designated => identifier "com.t3tools.t3code" and anchor apple generic and … subject.OU = ARK85ZXQ4Z

macOS keys every permission grant to the app's designated requirement. With no certificate to
name, codesign falls back to the binary's cdhash, which changes when any byte of the app changes —
so every update was a brand-new app to macOS, and Screen Recording, Accessibility, Microphone,
Files & Folders and Local Network were re-requested from scratch every time.

The fix is not "sign it" but "sign it with an identity that does not move", which is why 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.

What landed

  • scripts/t3x/setup-mac-signing.sh — creates the identity (self-signed, 10 years, in its own
    keychain so set-key-partition-list can run non-interactively and no GUI keychain dialog can wedge
    an unattended build), trusts it, self-verifies by signing a throwaway bundle, and exports the p12
    for CI. Idempotent. Prints the exact sudo command instead of hanging when there is no terminal.
  • t3x-release.yml — imports the p12 secret on the mac runner and exports CSC_NAME.
  • auto-build-desktop.sh — picks the same identity up locally, so the release path and the local
    autobuild produce one identical requirement instead of two.
  • scripts/t3x/verify-mac-signature.ts (+ 17 tests) — asserts the claim on the .app inside the
    shipped .dmg. Fails a changed identity as well as a missing one: a build signed by a different
    certificate is perfectly signed and still costs a full round of dialogs, so the requirement is
    pinned in docs/t3x/mac-signing/designated-requirement.txt and compared byte for byte.
  • docs/t3x/mac-signing-runbook.md — setup, verification, rotation, and what this does not fix.

Zero new SEAMS.md rows

#70's plan called for a third signing mode inside scripts/build-desktop-artifact.ts — a row on a hot
upstream file. It turned out to be unnecessary. CSC_IDENTITY_AUTO_DISCOVERY=false is only consulted
when no identity was named:

// app-builder-lib/out/codeSign/macCodeSign.jsletidentity=qualifier||process.env.CSC_NAME;if(isEmptyOrSpaces(identity)){/* only here does the flag apply */}return_findIdentity(certType,identity.trim(),keychain);

So exporting CSC_NAME around the existing unsigned build is the whole mechanism. An empty
CSC_NAME is identical to no CSC_NAME, which is what makes it safe to wire in unconditionally.

Verification

  • The mechanism was proven before it was wired up: two bundles with different contents (hence
    different cdhashes) signed with one certificate produce a byte-identical designated
    requirement, both codesign --verify --deep --strict clean.
  • The verifier was run against the real ad-hoc app (exit 1, with all five defects named), against a
    correctly signed .app inside a .dmg (exit 0, mounts and detaches cleanly), and with
    --allow-unsigned (exit 0, warns).
  • scripts package: 218 tests pass, typecheck and lint clean. Release-workflow YAML parses and the
    new steps are ordered correctly.
  • Not verifiable in CI: the acceptance test itself — two signed builds installed in sequence with no
    dialog on the second. That needs a real machine and a real update, and is the next step after merge.

Expect one more round of prompts

The first signed build moves the identity from a cdhash to a certificate, so existing grants no
longer match and macOS asks once more. That install looks exactly like the bug it fixes — the fix
is judged on the second signed install.

Known, separate: the bundle id is shared with upstream's build

Both /Applications/T3 Code (Alpha).app and upstream's T3 Code (Nightly).app report
CFBundleIdentifier = com.t3tools.t3code, and TCC stores one row per (service, bundle id). So
switching between the two apps re-prompts no matter how well either is signed. Documented in the
runbook with the options; not fixed here, because giving the fork its own bundle id means editing
DESKTOP_APP_ID in an upstream-owned file — a real SEAMS row, and one more permission reset.

🤖 Generated with Claude Code

…rants survive updates (#70)
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) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2699b649-38b7-4193-907e-60721797d534

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ws with upstream (#70)
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) <noreply@anthropic.com>
radroidand others added 3 commits August 11, 2026 21:55
…#70)
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) <noreply@anthropic.com>
…e upstream line
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) <noreply@anthropic.com>
… that is fine
`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) <noreply@anthropic.com>
@radroid
radroid merged commit 456b7bd into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/mac-signing branch August 12, 2026 02:06
radroid added a commit that referenced this pull request Aug 12, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 12, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS re-prompts for every permission after each update, because the fork build is ad-hoc signed with a new identity every time

1 participant

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70) - #85

Merged
radroid merged 5 commits into
mainfrom
t3x/mac-signing
Aug 12, 2026
Merged

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70)#85
radroid merged 5 commits into
mainfrom
t3x/mac-signing

Conversation

@radroid

Copy link
Copy Markdown
Owner

Closes#70.

The bug, in two commands

$ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # the fork, before
# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"
$ codesign -d --requirements - "/Applications/T3 Code (Nightly).app" # upstream, for contrast
designated => identifier "com.t3tools.t3code" and anchor apple generic and … subject.OU = ARK85ZXQ4Z

macOS keys every permission grant to the app's designated requirement. With no certificate to
name, codesign falls back to the binary's cdhash, which changes when any byte of the app changes —
so every update was a brand-new app to macOS, and Screen Recording, Accessibility, Microphone,
Files & Folders and Local Network were re-requested from scratch every time.

The fix is not "sign it" but "sign it with an identity that does not move", which is why 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.

What landed

  • scripts/t3x/setup-mac-signing.sh — creates the identity (self-signed, 10 years, in its own
    keychain so set-key-partition-list can run non-interactively and no GUI keychain dialog can wedge
    an unattended build), trusts it, self-verifies by signing a throwaway bundle, and exports the p12
    for CI. Idempotent. Prints the exact sudo command instead of hanging when there is no terminal.
  • t3x-release.yml — imports the p12 secret on the mac runner and exports CSC_NAME.
  • auto-build-desktop.sh — picks the same identity up locally, so the release path and the local
    autobuild produce one identical requirement instead of two.
  • scripts/t3x/verify-mac-signature.ts (+ 17 tests) — asserts the claim on the .app inside the
    shipped .dmg. Fails a changed identity as well as a missing one: a build signed by a different
    certificate is perfectly signed and still costs a full round of dialogs, so the requirement is
    pinned in docs/t3x/mac-signing/designated-requirement.txt and compared byte for byte.
  • docs/t3x/mac-signing-runbook.md — setup, verification, rotation, and what this does not fix.

Zero new SEAMS.md rows

#70's plan called for a third signing mode inside scripts/build-desktop-artifact.ts — a row on a hot
upstream file. It turned out to be unnecessary. CSC_IDENTITY_AUTO_DISCOVERY=false is only consulted
when no identity was named:

// app-builder-lib/out/codeSign/macCodeSign.jsletidentity=qualifier||process.env.CSC_NAME;if(isEmptyOrSpaces(identity)){/* only here does the flag apply */}return_findIdentity(certType,identity.trim(),keychain);

So exporting CSC_NAME around the existing unsigned build is the whole mechanism. An empty
CSC_NAME is identical to no CSC_NAME, which is what makes it safe to wire in unconditionally.

Verification

  • The mechanism was proven before it was wired up: two bundles with different contents (hence
    different cdhashes) signed with one certificate produce a byte-identical designated
    requirement, both codesign --verify --deep --strict clean.
  • The verifier was run against the real ad-hoc app (exit 1, with all five defects named), against a
    correctly signed .app inside a .dmg (exit 0, mounts and detaches cleanly), and with
    --allow-unsigned (exit 0, warns).
  • scripts package: 218 tests pass, typecheck and lint clean. Release-workflow YAML parses and the
    new steps are ordered correctly.
  • Not verifiable in CI: the acceptance test itself — two signed builds installed in sequence with no
    dialog on the second. That needs a real machine and a real update, and is the next step after merge.

Expect one more round of prompts

The first signed build moves the identity from a cdhash to a certificate, so existing grants no
longer match and macOS asks once more. That install looks exactly like the bug it fixes — the fix
is judged on the second signed install.

Known, separate: the bundle id is shared with upstream's build

Both /Applications/T3 Code (Alpha).app and upstream's T3 Code (Nightly).app report
CFBundleIdentifier = com.t3tools.t3code, and TCC stores one row per (service, bundle id). So
switching between the two apps re-prompts no matter how well either is signed. Documented in the
runbook with the options; not fixed here, because giving the fork its own bundle id means editing
DESKTOP_APP_ID in an upstream-owned file — a real SEAMS row, and one more permission reset.

🤖 Generated with Claude Code

…rants survive updates (#70)
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) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2699b649-38b7-4193-907e-60721797d534

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ws with upstream (#70)
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) <noreply@anthropic.com>
radroidand others added 3 commits August 11, 2026 21:55
…#70)
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) <noreply@anthropic.com>
…e upstream line
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) <noreply@anthropic.com>
… that is fine
`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) <noreply@anthropic.com>
@radroid
radroid merged commit 456b7bd into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/mac-signing branch August 12, 2026 02:06
radroid added a commit that referenced this pull request Aug 12, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 12, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS re-prompts for every permission after each update, because the fork build is ad-hoc signed with a new identity every time

1 participant

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70) - #85

Merged
radroid merged 5 commits into
mainfrom
t3x/mac-signing
Aug 12, 2026
Merged

fix(t3x): sign the macOS build with a stable identity so permission grants survive updates (#70)#85
radroid merged 5 commits into
mainfrom
t3x/mac-signing

Conversation

@radroid

Copy link
Copy Markdown
Owner

Closes#70.

The bug, in two commands

$ codesign -d --requirements - "/Applications/T3 Code (Alpha).app" # the fork, before
# designated => cdhash H"d48d810e7b110d8d70a793f827dd23a7b2506405"
$ codesign -d --requirements - "/Applications/T3 Code (Nightly).app" # upstream, for contrast
designated => identifier "com.t3tools.t3code" and anchor apple generic and … subject.OU = ARK85ZXQ4Z

macOS keys every permission grant to the app's designated requirement. With no certificate to
name, codesign falls back to the binary's cdhash, which changes when any byte of the app changes —
so every update was a brand-new app to macOS, and Screen Recording, Accessibility, Microphone,
Files & Folders and Local Network were re-requested from scratch every time.

The fix is not "sign it" but "sign it with an identity that does not move", which is why 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.

What landed

  • scripts/t3x/setup-mac-signing.sh — creates the identity (self-signed, 10 years, in its own
    keychain so set-key-partition-list can run non-interactively and no GUI keychain dialog can wedge
    an unattended build), trusts it, self-verifies by signing a throwaway bundle, and exports the p12
    for CI. Idempotent. Prints the exact sudo command instead of hanging when there is no terminal.
  • t3x-release.yml — imports the p12 secret on the mac runner and exports CSC_NAME.
  • auto-build-desktop.sh — picks the same identity up locally, so the release path and the local
    autobuild produce one identical requirement instead of two.
  • scripts/t3x/verify-mac-signature.ts (+ 17 tests) — asserts the claim on the .app inside the
    shipped .dmg. Fails a changed identity as well as a missing one: a build signed by a different
    certificate is perfectly signed and still costs a full round of dialogs, so the requirement is
    pinned in docs/t3x/mac-signing/designated-requirement.txt and compared byte for byte.
  • docs/t3x/mac-signing-runbook.md — setup, verification, rotation, and what this does not fix.

Zero new SEAMS.md rows

#70's plan called for a third signing mode inside scripts/build-desktop-artifact.ts — a row on a hot
upstream file. It turned out to be unnecessary. CSC_IDENTITY_AUTO_DISCOVERY=false is only consulted
when no identity was named:

// app-builder-lib/out/codeSign/macCodeSign.jsletidentity=qualifier||process.env.CSC_NAME;if(isEmptyOrSpaces(identity)){/* only here does the flag apply */}return_findIdentity(certType,identity.trim(),keychain);

So exporting CSC_NAME around the existing unsigned build is the whole mechanism. An empty
CSC_NAME is identical to no CSC_NAME, which is what makes it safe to wire in unconditionally.

Verification

  • The mechanism was proven before it was wired up: two bundles with different contents (hence
    different cdhashes) signed with one certificate produce a byte-identical designated
    requirement, both codesign --verify --deep --strict clean.
  • The verifier was run against the real ad-hoc app (exit 1, with all five defects named), against a
    correctly signed .app inside a .dmg (exit 0, mounts and detaches cleanly), and with
    --allow-unsigned (exit 0, warns).
  • scripts package: 218 tests pass, typecheck and lint clean. Release-workflow YAML parses and the
    new steps are ordered correctly.
  • Not verifiable in CI: the acceptance test itself — two signed builds installed in sequence with no
    dialog on the second. That needs a real machine and a real update, and is the next step after merge.

Expect one more round of prompts

The first signed build moves the identity from a cdhash to a certificate, so existing grants no
longer match and macOS asks once more. That install looks exactly like the bug it fixes — the fix
is judged on the second signed install.

Known, separate: the bundle id is shared with upstream's build

Both /Applications/T3 Code (Alpha).app and upstream's T3 Code (Nightly).app report
CFBundleIdentifier = com.t3tools.t3code, and TCC stores one row per (service, bundle id). So
switching between the two apps re-prompts no matter how well either is signed. Documented in the
runbook with the options; not fixed here, because giving the fork its own bundle id means editing
DESKTOP_APP_ID in an upstream-owned file — a real SEAMS row, and one more permission reset.

🤖 Generated with Claude Code

…rants survive updates (#70)
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) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2699b649-38b7-4193-907e-60721797d534

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ws with upstream (#70)
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) <noreply@anthropic.com>
radroidand others added 3 commits August 11, 2026 21:55
…#70)
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) <noreply@anthropic.com>
…e upstream line
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) <noreply@anthropic.com>
… that is fine
`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) <noreply@anthropic.com>
@radroid
radroid merged commit 456b7bd into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/mac-signing branch August 12, 2026 02:06
radroid added a commit that referenced this pull request Aug 12, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 12, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 17, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…ures (#71)
The rename is mechanical; the risk is not. Four of the strings involved are
load-bearing for something that outlives the merge, and each fails without an
error surface:
- the build counter is github.run_number, keyed to a WORKFLOW FILENAME, so
renaming t3x-release.yml restarts it at 1 against clients sitting at 26 and
every one of them skips forever. The issue attributes this hazard to the
version string; that part is genuinely safe. The filename is not.
- the relay hostname is compiled into builds already on disk, so the old
Worker has to serve permanently. The shim proxies rather than redirects:
relayClient talks through Effect's HttpClient and treats any non-2xx as
bad-status, and redirect behaviour on a live SSE stream cannot be tested
against builds already in the field.
- a LaunchAgent plist hardcodes scripts/t3x/auto-build-desktop.sh, so the
rename stops the nightly build with no log line. Same failure class as #41.
- app.setName() sets the safeStorage Keychain service name, so saved
environment tokens stop decrypting. Threads are unaffected -- userDataDirName
is a hardcoded literal, not derived -- which is why #85 moved nobody's data.
Also records the two deliberate displacement edits (APP_BASE_NAME, productName)
that break the fork's +N/-0 seam invariant, so a later sync reads them as
recorded rather than as drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
…tificate (#71)
APP_BASE_NAME T3 Code -> T3 Coil (DesktopEnvironment.ts)
productName T3 Code (Alpha) -> T3 Coil (Alpha) (apps/desktop/package.json)
bundle id dev.curlycloud.coil -> dev.curlycloud.t3coil
signing certificate T3X Code Signing -> T3 Coil Code Signing
APP_BASE_NAME is the one that matters: displayName is derived from it, and that reaches
app.setName(), the About panel, every window title, the Linux .desktop entry, and — through
getAppBranding() — the web UI, which reads the injected value rather than hardcoding one. So
renaming one literal renames the app almost everywhere it is computed.
Almost. 31 upstream-owned files still say "T3 Code" in body copy ("T3 Code needs the relay
client..."). Renaming those means displacement edits on 31 more upstream files for prose, which is
a bad trade against the sync cost; left alone deliberately, not missed.
Three things that look like they should have moved and must not:
- legacyUserDataDirName stays "T3 Code (Alpha)". It names a directory that already exists on disk
and is a hardcoded literal, not derived from APP_BASE_NAME. That is why #85 moved nobody's
threads and why this does not either. DesktopAppIdentity.test.ts now asserts both spellings a
few lines apart, with a comment, because the pair looks like a typo and is not.
- branding.test.ts's injected-branding case keeps "T3 Code" fixtures. It asserts that injection
WINS over the module constant; matching the fixture to the constant would make it pass whether
injection works or not.
- resolveDesktopProductName's "T3 Code (Nightly)" is upstream's literal on a branch this fork
never reaches, since nothing here builds a -nightly.<d>.<d> version.
The bundle id and certificate each invalidate every macOS TCC grant, because the designated
requirement names both. Done together so the user pays one round of permission dialogs rather than
two. The certificate half is NOT complete in code alone: a new name means a new certificate, so
setup-mac-signing.sh --rotate has to run before this merges, and designated-requirement.txt has to
be re-recorded from it. What is committed here has the new identifier and the CURRENT leaf hash,
which is exactly right until the rotation happens and loudly wrong after it — the release compares
that file byte for byte.
Also renames the pnpm-lock.yaml importer for the relay directory move in the previous commit;
without it `pnpm install --frozen-lockfile` fails, which is how it was caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
radroid added a commit that referenced this pull request Aug 18, 2026
The first-update note said the builds were unsigned and to expect screen-recording
and automation prompts. All three claims were wrong.
They have been signed since #70/PR #85 — verified on the installed 0.0.33-coil.105,
whose designated requirement matches the recorded one byte-for-byte and still
satisfies itself after an in-app update. The app requests neither screen recording
nor automation: there is no desktopCapturer, getDisplayMedia, osascript or Apple
Event use anywhere in apps/desktop/src.
The dialog users actually get is App Management — "wants access to data from other
apps" — and it has a different cause. macOS blocks one app modifying another's
bundle unless both are signed by the same development team, and installing an
update is exactly that: the swap replaces the bundle in /Applications. The builds
carry a self-signed certificate with no team identifier, so there is no team to
match.
This mattered more than a wording nit. The stale note kept describing the symptom
of a bug PR #85 had already fixed, so it pointed the diagnosis at the signature
and away from the missing team — which is where it went, until the dialog was
read literally.
The new copy quotes the dialog's own words, because recognition is the entire job
of a note shown once, and says it is asked once. That last part is now observed,
not assumed: allowing it survived builds 102 to 105, which is what the stable
designated requirement predicted.
NSUpdateSecurityPolicy is recorded as a dead end in the comment — its
AllowProcesses map is keyed by team identifier, so it needs the one thing this
build lacks. Removing the dialog for good means a paid Developer ID.
Tests now pin the dialog's wording and assert the note does NOT say "unsigned",
so this cannot quietly rot back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS re-prompts for every permission after each update, because the fork build is ad-hoc signed with a new identity every time

1 participant

@radroid