diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f0dc504..2f27efa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,7 +10,7 @@ body: id: version attributes: label: T4 Code version - placeholder: "0.1.25" + placeholder: "0.1.26" validations: required: true - type: dropdown diff --git a/README.md b/README.md index fd40fe8..f428938 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c ![T4 Code main window](docs/assets/t4-code-main.png) -[**Download v0.1.25**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.25) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) +[**Download v0.1.26**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.26) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) ## Requirements -T4 Code needs an OMP build with desktop appserver support. For v0.1.25, use the public integration build below. +T4 Code needs an OMP build with desktop appserver support. For v0.1.26, use the public integration build below. -T4 Code v0.1.25 was verified with OMP 17.0.5 built from [`772e5e41`](https://github.com/lyc-aon/oh-my-pi/commit/772e5e41eb1537177349247add96a851721c5bfa), tagged [`t4code-17.0.5-appserver-5`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-5). That public integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It adds faster appserver startup, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, macOS system-temp aliases, workspace-native build artifacts, retry-safe release metadata, lock-aware session observation, complete transcript reconciliation, missing-lock-only promotion, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.6.1 from integration commit [`e3e15c03`](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. +T4 Code v0.1.26 was verified with OMP 17.0.5 built from [`772e5e41`](https://github.com/lyc-aon/oh-my-pi/commit/772e5e41eb1537177349247add96a851721c5bfa), tagged [`t4code-17.0.5-appserver-5`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-5). That public integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It adds faster appserver startup, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, macOS system-temp aliases, workspace-native build artifacts, retry-safe release metadata, lock-aware session observation, complete transcript reconciliation, missing-lock-only promotion, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.6.1 from integration commit [`e3e15c03`](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. The current source tree advances the vendored contract to `@oh-my-pi/app-wire` 0.6.1 from integration commit [`e3e15c03`](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. It supplies bounded cross-session transcript search, historical context, and the browser-preview wire contract. @@ -20,11 +20,12 @@ The current source tree advances the vendored contract to `@oh-my-pi/app-wire` 0 | Linux | x86_64 | `.deb`, AppImage | | macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**signed and notarized**) | -No Windows build and no Intel Mac build in v0.1.25. The iOS TestFlight build is coming soon. +No Windows build and no Intel Mac build in v0.1.26. The iOS TestFlight build is coming soon. -## What changed in v0.1.25 +## What changed in v0.1.26 -- Signed Mac builds now install their bundled OMP backend correctly. The app verifies the backend's exact Developer ID certificate before copying the signed bytes, while packaging still verifies the original public OMP download hash. +- Signed Mac builds now start their bundled OMP backend correctly. Only the OMP executable receives permission to load OMP's native module; the T4 Code app and Electron helpers keep normal library validation enabled. +- The app verifies the backend's exact Developer ID certificate before copying the signed bytes, while packaging still verifies the original public OMP download hash. - macOS downloads remain signed with the project's pinned Developer ID identity, notarized by Apple, stapled, and checked by Gatekeeper before publication. - The new attention inbox gathers sessions that need a decision, confirmation, or reply, while keeping the host's state authoritative. - Session transport health now explains reconnecting, delayed, and degraded connections instead of reducing them to a generic disconnected state. @@ -47,7 +48,7 @@ No Windows build and no Intel Mac build in v0.1.25. The iOS TestFlight build is ### Android 1. On the Android phone, sign in to Tailscale with an account that can reach the T4 Code host. -2. Download [`T4-Code-0.1.25-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.25/T4-Code-0.1.25-android.apk). +2. Download [`T4-Code-0.1.26-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.26/T4-Code-0.1.26-android.apk). 3. If Android asks, allow your browser or file manager to install unknown apps, then install the APK. 4. Open T4 Code and enter the host's HTTPS Tailscale address, including its port. The app saves the address; you can add more hosts later and switch between them. @@ -56,8 +57,8 @@ The APK does not contain an appserver or expose one to the public internet. It c ### Linux (Debian/Ubuntu) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.25/T4-Code-0.1.25-linux-amd64.deb -sudo apt install ./T4-Code-0.1.25-linux-amd64.deb +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.26/T4-Code-0.1.26-linux-amd64.deb +sudo apt install ./T4-Code-0.1.26-linux-amd64.deb ``` Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically. @@ -65,14 +66,14 @@ Use `apt install` rather than `dpkg -i` so system dependencies resolve automatic ### Linux (AppImage) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.25/T4-Code-0.1.25-linux-x86_64.AppImage -chmod +x T4-Code-0.1.25-linux-x86_64.AppImage -./T4-Code-0.1.25-linux-x86_64.AppImage +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.26/T4-Code-0.1.26-linux-x86_64.AppImage +chmod +x T4-Code-0.1.26-linux-x86_64.AppImage +./T4-Code-0.1.26-linux-x86_64.AppImage ``` ### macOS (Apple Silicon) -1. Download [`T4-Code-0.1.25-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.25/T4-Code-0.1.25-mac-arm64.dmg) (or [`T4-Code-0.1.25-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.25/T4-Code-0.1.25-mac-arm64.zip)). +1. Download [`T4-Code-0.1.26-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.26/T4-Code-0.1.26-mac-arm64.dmg) (or [`T4-Code-0.1.26-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.26/T4-Code-0.1.26-mac-arm64.zip)). 2. Drag `T4 Code.app` into `/Applications`. 3. Open T4 Code normally. The release workflow verifies the pinned publisher, hardened runtime, secure timestamp, Apple notarization, stapled ticket, and Gatekeeper acceptance before publication. diff --git a/SECURITY.md b/SECURITY.md index d80214d..a61b01b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,5 +26,5 @@ We read every report and will reply to tell you what happens next. This is a sma - T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at . - Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome. -- The macOS v0.1.25 build is signed with Apple Developer ID and notarized by Apple. Reports of certificate, Team ID, hardened-runtime, timestamp, Gatekeeper, or stapled-ticket drift are security-relevant. +- The macOS v0.1.26 build is signed with Apple Developer ID and notarized by Apple. Reports of certificate, Team ID, hardened-runtime, timestamp, Gatekeeper, or stapled-ticket drift are security-relevant. - Starting with v0.1.24, the release workflow requires the pinned Developer ID identity, hardened runtime, Apple notarization, a stapled ticket, and a successful Gatekeeper assessment before publishing macOS artifacts. diff --git a/apps/desktop/build/entitlements.omp-runtime.plist b/apps/desktop/build/entitlements.omp-runtime.plist new file mode 100644 index 0000000..f2eb2ec --- /dev/null +++ b/apps/desktop/build/entitlements.omp-runtime.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b42b431..e0edc90 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/desktop", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/target-manager.ts b/apps/desktop/src/target-manager.ts index 453bb41..845f798 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -383,7 +383,7 @@ export class DesktopTargetManager { capabilities: requestedCapabilities, requestedFeatures: REQUESTED_FEATURES, compatibilityRequestedFeatures: COMPATIBILITY_FEATURES, - client: { name: "T4 Code", version: "0.1.25", build: "desktop", platform: process.platform }, + client: { name: "T4 Code", version: "0.1.26", build: "desktop", platform: process.platform }, reconnect: { baseMs: 250, maxMs: 10_000 }, }; const client = createOmpClient(clientOptions); diff --git a/apps/mobile/capacitor.config.json b/apps/mobile/capacitor.config.json index 258909e..7a1b395 100644 --- a/apps/mobile/capacitor.config.json +++ b/apps/mobile/capacitor.config.json @@ -3,7 +3,7 @@ "appName": "T4 Code", "webDir": "dist", "loggingBehavior": "debug", - "appendUserAgent": " T4CodeMobile/0.1.25", + "appendUserAgent": " T4CodeMobile/0.1.26", "android": { "path": "android", "minWebViewVersion": 60, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 359c477..a08d1b8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/mobile", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "scripts": { diff --git a/apps/site/package.json b/apps/site/package.json index c00c637..e834fea 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/site", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "scripts": { diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index 6080e51..ca9671d 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -13,8 +13,8 @@ export const OMP_UPSTREAM_TAG = "v17.0.5"; export const OMP_UPSTREAM_COMMIT = "9fd6e97113f5ed3a847e66d346970efdf8afcad9"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; export const APP_WIRE_VERSION = "0.6.1"; -export const RELEASE_TAG = "v0.1.25"; -export const RELEASE_VERSION = "0.1.25"; +export const RELEASE_TAG = "v0.1.26"; +export const RELEASE_VERSION = "0.1.26"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; export const RELEASE_MANIFEST_URL = `${SITE_URL}/releases/latest.json`; @@ -49,11 +49,11 @@ function asset( } export const RELEASE_ASSETS: readonly ReleaseAsset[] = [ - asset("android", "apk", "universal", "T4-Code-0.1.25-android.apk", "Android APK"), - asset("linux", "deb", "x86_64", "T4-Code-0.1.25-linux-amd64.deb", "Linux .deb"), - asset("linux", "appimage", "x86_64", "T4-Code-0.1.25-linux-x86_64.AppImage", "Linux AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-0.1.25-mac-arm64.dmg", "macOS .dmg"), - asset("mac", "zip", "arm64", "T4-Code-0.1.25-mac-arm64.zip", "macOS .zip"), + asset("android", "apk", "universal", "T4-Code-0.1.26-android.apk", "Android APK"), + asset("linux", "deb", "x86_64", "T4-Code-0.1.26-linux-amd64.deb", "Linux .deb"), + asset("linux", "appimage", "x86_64", "T4-Code-0.1.26-linux-x86_64.AppImage", "Linux AppImage"), + asset("mac", "dmg", "arm64", "T4-Code-0.1.26-mac-arm64.dmg", "macOS .dmg"), + asset("mac", "zip", "arm64", "T4-Code-0.1.26-mac-arm64.zip", "macOS .zip"), ]; export function assetsFor(platform: Platform): readonly ReleaseAsset[] { diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index 5b335b1..17e524c 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -1,4 +1,4 @@ -// Release contract guard: exact v0.1.25 asset names and URLs, and the +// Release contract guard: exact v0.1.26 asset names and URLs, and the // platform-detection rule the hero download button relies on. import { describe, expect, it } from "vite-plus/test"; import { @@ -20,13 +20,13 @@ import { } from "../src/release.ts"; describe("release assets", () => { - it("carries the five contracted v0.1.25 filenames", () => { + it("carries the five contracted v0.1.26 filenames", () => { expect(RELEASE_ASSETS.map((a) => a.filename)).toEqual([ - "T4-Code-0.1.25-android.apk", - "T4-Code-0.1.25-linux-amd64.deb", - "T4-Code-0.1.25-linux-x86_64.AppImage", - "T4-Code-0.1.25-mac-arm64.dmg", - "T4-Code-0.1.25-mac-arm64.zip", + "T4-Code-0.1.26-android.apk", + "T4-Code-0.1.26-linux-amd64.deb", + "T4-Code-0.1.26-linux-x86_64.AppImage", + "T4-Code-0.1.26-mac-arm64.dmg", + "T4-Code-0.1.26-mac-arm64.zip", ]); }); @@ -38,8 +38,8 @@ describe("release assets", () => { it("targets the public LycaonLLC repo", () => { expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code"); - expect(RELEASE_TAG).toBe("v0.1.25"); - expect(RELEASE_VERSION).toBe("0.1.25"); + expect(RELEASE_TAG).toBe("v0.1.26"); + expect(RELEASE_VERSION).toBe("0.1.26"); expect(RELEASE_MANIFEST_URL).toBe("https://t4code.net/releases/latest.json"); }); diff --git a/apps/web/package.json b/apps/web/package.json index a9329a2..67057f2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/web", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/platform/browser-shell-port.ts b/apps/web/src/platform/browser-shell-port.ts index 792679e..b240e64 100644 --- a/apps/web/src/platform/browser-shell-port.ts +++ b/apps/web/src/platform/browser-shell-port.ts @@ -305,7 +305,7 @@ export function createBrowserShellPort( }, client: { name: "T4 Code", - version: "0.1.25", + version: "0.1.26", build: mobilePlatform ?? "browser", platform: mobilePlatform ?? (platform === "darwin" ? "darwin" : "linux"), }, diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index c6504ba..395ba4b 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -181,6 +181,6 @@ }, "desktop": { "package": "@t4-code/protocol", - "version": "0.1.25" + "version": "0.1.26" } } diff --git a/docs/CURRENT_RELEASE_NOTES.md b/docs/CURRENT_RELEASE_NOTES.md index ea8a9d4..cb7dfb1 100644 --- a/docs/CURRENT_RELEASE_NOTES.md +++ b/docs/CURRENT_RELEASE_NOTES.md @@ -1,8 +1,10 @@ -## Automatic backend installation on signed Macs +## Signed Mac backend startup -T4 Code v0.1.25 fixes first-launch backend installation in signed Mac builds. Apple signing adds a cryptographic signature to the bundled OMP executable, which changes its file hash after the original public download was verified. The app now accepts either the exact original download or an executable signed with the project's exact Developer ID certificate, then copies and rechecks the actual signed bytes atomically. +T4 Code v0.1.26 lets the signed, bundled OMP backend load OMP's native module on macOS. The permission is applied only to the OMP executable inside the app. The top-level T4 Code app and its Electron helpers keep normal library validation enabled. -The protected release job still verifies the original OMP download's pinned size and SHA-256 hash before packaging. It then reopens the DMG and ZIP and checks their certificate and Team ID, hardened runtime, secure timestamp, stapled notarization ticket, and Gatekeeper result before publication. Signing secrets are never bundled into the app. +The protected release job verifies this boundary in both the DMG and ZIP before publication. It also checks the original OMP download's pinned size and SHA-256 hash, the project's exact Developer ID certificate and Team ID, hardened runtime, secure timestamp, stapled notarization ticket, and Gatekeeper result. Signing secrets are never bundled into the app. + +The v0.1.25 signed-backend integrity fix remains in place: the app accepts either the exact original OMP download or an executable signed with the project's pinned Developer ID certificate, then copies and rechecks the actual signed bytes atomically. ## One inbox for sessions that need attention @@ -24,7 +26,7 @@ Session-linked browser previews now open in a dedicated workspace. The client pr ## Runtime provenance -T4 Code v0.1.25 vendors app-wire 0.6.1 from integration commit [e3e15c03](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. The client contract remains `omp-app/1`. +T4 Code v0.1.26 vendors app-wire 0.6.1 from integration commit [e3e15c03](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. The client contract remains `omp-app/1`. The verified OMP 17.0.5 runtime is built from commit [772e5e41](https://github.com/lyc-aon/oh-my-pi/commit/772e5e41eb1537177349247add96a851721c5bfa) and tagged [t4code-17.0.5-appserver-5](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-5). It provides the appserver used by the desktop and remote workflows, including faster startup, cross-session attention and transcript search, and the complete negotiated browser-preview command surface. Unsupported optional capabilities remain hidden when the host does not advertise them. diff --git a/e2e/site-mobile-docs.spec.ts b/e2e/site-mobile-docs.spec.ts index f29c9aa..46f188e 100644 --- a/e2e/site-mobile-docs.spec.ts +++ b/e2e/site-mobile-docs.spec.ts @@ -130,7 +130,7 @@ test("offers the Android APK without hiding desktop downloads", async ({ page }) await expect(androidDownload).toBeVisible(); await expect(androidDownload).toHaveAttribute( "href", - "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.25/T4-Code-0.1.25-android.apk", + "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.26/T4-Code-0.1.26-android.apk", ); await expect(page.getByRole("link", { name: /Linux/u }).first()).toBeVisible(); await expect(page.getByRole("link", { name: /macOS/u }).first()).toBeVisible(); diff --git a/electron-builder.config.mjs b/electron-builder.config.mjs index d6e5925..bbead6c 100644 --- a/electron-builder.config.mjs +++ b/electron-builder.config.mjs @@ -54,6 +54,7 @@ const config = { entitlementsInherit: signedMacBuild ? "apps/desktop/build/entitlements.mac.plist" : undefined, + sign: signedMacBuild ? "scripts/sign-macos.mjs" : undefined, notarize: signedMacBuild, // The first signed release remains an explicit GitHub download. Keep the // updater feed disabled until signed-to-signed update migration has its diff --git a/package.json b/package.json index 14ecb6d..2dc07a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/root", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "scripts": { @@ -41,6 +41,7 @@ }, "devDependencies": { "@electron/asar": "3.4.1", + "@electron/osx-sign": "1.3.3", "@playwright/test": "1.61.1", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/client/package.json b/packages/client/package.json index 06d3a11..d716bac 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/client", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "exports": { diff --git a/packages/client/src/omp-client-frames.ts b/packages/client/src/omp-client-frames.ts index d21ceaa..b24e0df 100644 --- a/packages/client/src/omp-client-frames.ts +++ b/packages/client/src/omp-client-frames.ts @@ -84,7 +84,7 @@ export function sendClientHello( } catch { fatal(); return; } const encoded = encodeOutgoingMessage(provider, { kind: "hello", - client: options.client ?? { name: "t4-code", version: "0.1.25", build: "client", platform: "electron" }, + client: options.client ?? { name: "t4-code", version: "0.1.26", build: "client", platform: "electron" }, requestedFeatures: [...(options.requestedFeatures ?? ["resume"])], savedCursors, ...(options.capabilities === undefined ? {} : { capabilities: options.capabilities }), diff --git a/packages/fixture-server/package.json b/packages/fixture-server/package.json index 207c932..c4c4131 100644 --- a/packages/fixture-server/package.json +++ b/packages/fixture-server/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/fixture-server", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "exports": { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index eb964a5..0179b59 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/protocol", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "exports": { diff --git a/packages/protocol/test/fixtures/platform-boundaries.ts b/packages/protocol/test/fixtures/platform-boundaries.ts index 5665344..ebf054f 100644 --- a/packages/protocol/test/fixtures/platform-boundaries.ts +++ b/packages/protocol/test/fixtures/platform-boundaries.ts @@ -25,7 +25,7 @@ export const androidUpdateFixtures = Object.freeze({ }, { currentVersion: "0.1.22", - latestVersion: "0.1.25", + latestVersion: "0.1.26", checkedAt: 1_721_234_567_890, phase: "available", revision: 7, @@ -33,7 +33,7 @@ export const androidUpdateFixtures = Object.freeze({ }, { currentVersion: "0.1.22", - latestVersion: "0.1.25", + latestVersion: "0.1.26", phase: "installer", revision: 8, message: "Installer opened.\nReview Android's prompt.", diff --git a/packages/remote/package.json b/packages/remote/package.json index 9f2b0b6..76d6df5 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/remote", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "exports": { diff --git a/packages/service-manager/package.json b/packages/service-manager/package.json index 2dbbe02..cf0791f 100644 --- a/packages/service-manager/package.json +++ b/packages/service-manager/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/service-manager", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "exports": { diff --git a/packages/ui/package.json b/packages/ui/package.json index b8eab63..4c91d5d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/ui", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "exports": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fcbec4..5880080 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@electron/asar': specifier: 3.4.1 version: 3.4.1 + '@electron/osx-sign': + specifier: 1.3.3 + version: 1.3.3 '@playwright/test': specifier: 1.61.1 version: 1.61.1 @@ -4179,12 +4182,12 @@ snapshots: - utf-8-validate - vite - '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9)': + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) - vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw @@ -4208,7 +4211,7 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9)': + '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)))': dependencies: '@blazediff/core': 1.9.1 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) @@ -4217,7 +4220,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -6488,8 +6491,8 @@ snapshots: dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))) '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 @@ -6502,7 +6505,7 @@ snapshots: oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) oxlint-tsgolint: 0.24.0 vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' - vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -6570,7 +6573,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): + vitest@4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) @@ -6594,7 +6597,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) transitivePeerDependencies: - msw diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 2100397..729ebfe 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -89,7 +89,7 @@ test("keeps verified and published runtime records aligned after promotion", () test("rejects a tag that differs from the package version", () => { assert.ok( collectReleaseConsistencyErrors(files, "v9.9.9").some((error) => - error.includes("release tag v9.9.9 does not match v0.1.25"), + error.includes("release tag v9.9.9 does not match v0.1.26"), ), ); }); @@ -118,7 +118,7 @@ test("tagged releases reject published provenance drift", () => { for (const [field, mutate] of appWireCases) { const drifted = changedRuntime("publishedAppWire", mutate); assert.ok( - collectReleaseConsistencyErrors(drifted, "v0.1.25").some((error) => + collectReleaseConsistencyErrors(drifted, "v0.1.26").some((error) => error.includes( `published app-wire ${field} must match current app-wire for tagged releases`, ), @@ -161,7 +161,7 @@ test("tagged releases reject published provenance drift", () => { for (const [field, mutate] of runtimeCases) { const drifted = changedRuntime("publishedRuntime", mutate); assert.ok( - collectReleaseConsistencyErrors(drifted, "v0.1.25").some((error) => + collectReleaseConsistencyErrors(drifted, "v0.1.26").some((error) => error.includes( `published runtime ${field} must match current verified runtime for tagged releases`, ), @@ -173,7 +173,7 @@ test("tagged releases reject published provenance drift", () => { runtime.artifactSha256 = "0".repeat(64); }); assert.ok( - collectReleaseConsistencyErrors(extended, "v0.1.25").some((error) => + collectReleaseConsistencyErrors(extended, "v0.1.26").some((error) => error.includes( "published runtime must exactly match current verified runtime for tagged releases", ), @@ -183,15 +183,15 @@ test("tagged releases reject published provenance drift", () => { test("rejects workspace, site, README, and runtime version drift", () => { const cases = [ - ["apps/web/package.json", (text) => text.replace('"version": "0.1.25"', '"version": "0.1.3"')], + ["apps/web/package.json", (text) => text.replace('"version": "0.1.26"', '"version": "0.1.3"')], [ "apps/site/src/release.ts", - (text) => text.replace('RELEASE_TAG = "v0.1.25"', 'RELEASE_TAG = "v0.1.3"'), + (text) => text.replace('RELEASE_TAG = "v0.1.26"', 'RELEASE_TAG = "v0.1.3"'), ], - ["README.md", (text) => text.replace("Download v0.1.25", "Download v0.1.3")], + ["README.md", (text) => text.replace("Download v0.1.26", "Download v0.1.3")], [ "apps/desktop/src/target-manager.ts", - (text) => text.replace('version: "0.1.25"', 'version: "0.1.3"'), + (text) => text.replace('version: "0.1.26"', 'version: "0.1.3"'), ], [ "apps/site/src/docs/content.ts", @@ -461,7 +461,7 @@ test("rejects stale README release URLs while allowing historical prose", () => const staleLink = changed("README.md", (text) => `${text}\n[Old release](${oldReleaseUrl})\n`); assert.ok( collectReleaseConsistencyErrors(staleLink).some((error) => - error.includes("release URL for v0.1.3; expected v0.1.25"), + error.includes("release URL for v0.1.3; expected v0.1.26"), ), ); assert.deepEqual(collectReleaseConsistencyErrors(files), []); diff --git a/scripts/inspect-macos-release.mjs b/scripts/inspect-macos-release.mjs index 13cf7aa..521eb12 100644 --- a/scripts/inspect-macos-release.mjs +++ b/scripts/inspect-macos-release.mjs @@ -103,6 +103,21 @@ export function validateMacosSignatureReport(report, contract) { return Object.freeze({ ...report }); } +function hasEnabledEntitlement(output, entitlement) { + const escaped = entitlement.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return new RegExp(`\\s*${escaped}\\s*\\s*`, "u").test(String(output)); +} + +export function validateMacosLibraryValidationBoundary(appEntitlements, runtimeEntitlements) { + const entitlement = "com.apple.security.cs.disable-library-validation"; + if (hasEnabledEntitlement(appEntitlements, entitlement)) { + throw new Error("top-level T4 Code app must keep library validation enabled"); + } + if (!hasEnabledEntitlement(runtimeEntitlements, entitlement)) { + throw new Error("bundled OMP runtime must disable library validation"); + } +} + function run(command, args, options = {}) { const result = spawnSync(command, args, { encoding: "utf8", @@ -142,6 +157,11 @@ function inspectApp(appPath, contract, certificatePrefix) { certificateSha256: createHash("sha256").update(leafCertificate).digest("hex"), }; validateMacosSignatureReport(report, contract); + const runtimePath = join(appPath, "Contents", "Resources", "runtime", "omp"); + run("codesign", ["--verify", "--strict", "--verbose=2", runtimePath]); + const appEntitlements = run("codesign", ["--display", "--entitlements", ":-", appPath]); + const runtimeEntitlements = run("codesign", ["--display", "--entitlements", ":-", runtimePath]); + validateMacosLibraryValidationBoundary(appEntitlements, runtimeEntitlements); run("spctl", ["--assess", "--type", "execute", "--verbose=4", appPath]); run("xcrun", ["stapler", "validate", appPath]); return report; diff --git a/scripts/inspect-macos-release.test.mjs b/scripts/inspect-macos-release.test.mjs index 510b0b9..0e2acde 100644 --- a/scripts/inspect-macos-release.test.mjs +++ b/scripts/inspect-macos-release.test.mjs @@ -4,6 +4,7 @@ import { resolve } from "node:path"; import { test } from "node:test"; import { parseCodesignDisplay, + validateMacosLibraryValidationBoundary, validateMacosIdentityContract, validateMacosSignatureReport, } from "./inspect-macos-release.mjs"; @@ -30,6 +31,25 @@ test("macOS release identity pins the public Developer ID contract", () => { assert.equal(identity.notarizationRequired, true); }); +test("library validation is relaxed for the bundled runtime but not the app", () => { + const relaxed = ` + com.apple.security.cs.disable-library-validation + `; + const hardened = ` + com.apple.security.cs.allow-jit + `; + + assert.doesNotThrow(() => validateMacosLibraryValidationBoundary(hardened, relaxed)); + assert.throws( + () => validateMacosLibraryValidationBoundary(relaxed, relaxed), + /top-level T4 Code app must keep library validation enabled/u, + ); + assert.throws( + () => validateMacosLibraryValidationBoundary(hardened, hardened), + /bundled OMP runtime must disable library validation/u, + ); +}); + test("codesign display parser preserves identity, runtime, and timestamp", () => { assert.deepEqual(parseCodesignDisplay(displayFixture), { identifier: "com.lycaonsolutions.t4code", diff --git a/scripts/packaging.test.mjs b/scripts/packaging.test.mjs index 5464e0a..1395255 100644 --- a/scripts/packaging.test.mjs +++ b/scripts/packaging.test.mjs @@ -5,6 +5,11 @@ import { test } from "node:test"; import { join, resolve } from "node:path"; import config from "../electron-builder.config.mjs"; import { validateMacosIdentityContract } from "./inspect-macos-release.mjs"; +import { + createT4MacOptionsForFile, + isBundledOmpRuntime, + normalizeMacSignOptions, +} from "./sign-macos.mjs"; import { createPackage } from "@electron/asar"; import { runPreflight, validatePreloadArtifact, validateWebIndex } from "./package-preflight.mjs"; import { inspectPackage, locateAppRoot } from "./inspect-package.mjs"; @@ -93,6 +98,7 @@ test("signed macOS packaging is explicit, credentialed, and release-gated", asyn assert.equal(signedConfig.mac.notarize, true); assert.equal(signedConfig.mac.entitlements, "apps/desktop/build/entitlements.mac.plist"); assert.equal(signedConfig.mac.entitlementsInherit, "apps/desktop/build/entitlements.mac.plist"); + assert.equal(signedConfig.mac.sign, "scripts/sign-macos.mjs"); assert.deepEqual(signedConfig.mac.publish, []); } finally { if (previousSignedBuild === undefined) delete process.env.T4_MACOS_SIGNED_BUILD; @@ -116,6 +122,35 @@ test("signed macOS packaging is explicit, credentialed, and release-gated", asyn assert.doesNotMatch(releaseWorkflow, /Build unsigned macOS packages/u); }); +test("signed macOS packaging relaxes library validation only for the bundled OMP runtime", () => { + const appPath = "/tmp/T4 Code.app"; + const inherited = () => ({ + entitlements: "apps/desktop/build/entitlements.mac.plist", + hardenedRuntime: true, + }); + const optionsForFile = createT4MacOptionsForFile(inherited); + const runtimePath = `${appPath}/Contents/Resources/runtime/omp`; + const helperPath = `${appPath}/Contents/Frameworks/T4 Code Helper.app`; + + assert.equal(isBundledOmpRuntime(runtimePath), true); + assert.equal(isBundledOmpRuntime(`${runtimePath}.backup`), false); + assert.deepEqual(optionsForFile(helperPath), inherited()); + assert.deepEqual(optionsForFile(runtimePath), { + ...inherited(), + entitlements: "apps/desktop/build/entitlements.omp-runtime.plist", + }); +}); + +test("macOS signing accepts current and legacy electron-builder callback shapes", () => { + const current = { app: "/tmp/current.app", identity: "certificate" }; + assert.equal(normalizeMacSignOptions(current), current); + assert.deepEqual( + normalizeMacSignOptions({ path: "/tmp/legacy.app", options: { identity: "certificate" } }), + { app: "/tmp/legacy.app", identity: "certificate" }, + ); + assert.throws(() => normalizeMacSignOptions({}), /did not provide an application path/u); +}); + test("Android release identity is public, pinned, and wired into the release workflow", () => { assert.doesNotThrow(() => validateIdentityContract(androidIdentity)); assert.equal(androidIdentity.applicationId, "com.lycaonsolutions.t4code"); diff --git a/scripts/sign-macos.mjs b/scripts/sign-macos.mjs new file mode 100644 index 0000000..b00ab35 --- /dev/null +++ b/scripts/sign-macos.mjs @@ -0,0 +1,31 @@ +import { sign } from "@electron/osx-sign"; + +export const OMP_RUNTIME_ENTITLEMENTS = "apps/desktop/build/entitlements.omp-runtime.plist"; + +export function isBundledOmpRuntime(filePath) { + return /[/\\]Contents[/\\]Resources[/\\]runtime[/\\]omp$/u.test(filePath); +} + +export function createT4MacOptionsForFile(baseOptionsForFile) { + return (filePath) => { + const base = baseOptionsForFile?.(filePath) ?? {}; + if (!isBundledOmpRuntime(filePath)) return base; + return { ...base, entitlements: OMP_RUNTIME_ENTITLEMENTS }; + }; +} + +export function normalizeMacSignOptions(input) { + if (typeof input?.app === "string") return input; + if (typeof input?.path === "string" && input.options && typeof input.options === "object") { + return { ...input.options, app: input.path }; + } + throw new Error("macOS signing callback did not provide an application path"); +} + +export default async function signT4MacApp(input) { + const options = normalizeMacSignOptions(input); + await sign({ + ...options, + optionsForFile: createT4MacOptionsForFile(options.optionsForFile), + }); +}