From ccc367c9e0a7c0f047cea3eb7d82d6fcf680c46c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 02:21:03 +0000 Subject: [PATCH 1/4] test(desktop): reproduce #31244 updates stay on for a macOS app on a read-only volume A macOS bundle on a read-only volume (mounted disk image, App Translocation) reports updates as idle, checks and downloads, and only fails in quitAndInstall. These tests reproduce #31244: the harness gains a readOnlyBundle option whose write-access checks fail with EROFS as Node reports it, and the test expects updates disabled with a reason and no check. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/updates/DesktopUpdates.test.ts | 21 +++++++++++++++++++ .../desktop/src/updates/updatesTestHarness.ts | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..9a92ef70b5d5e 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -42,6 +42,27 @@ describe("DesktopUpdates", () => { }), ); + it.effect("turns updates off when the macOS app runs from a read-only volume", () => { + const harness = makeHarness({ platform: "darwin", readOnlyBundle: true }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "disabled"); + assert.equal(state.enabled, false); + assert.isString(state.message); + + yield* TestClock.adjust(Duration.minutes(1)); + yield* flushCallbacks; + assert.equal(harness.checkCount(), 0); + }), + ).pipe(Effect.provide(harness.layer)); + }); + it.effect("keeps the disabled reason available after configuration and channel changes", () => { const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } }); const reason = "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 226f00f8cba89..cbf2109093d74 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -36,6 +36,8 @@ export interface UpdatesHarnessOptions { readonly env?: Record; readonly platform?: NodeJS.Platform; readonly packageType?: string | undefined; + /** Puts the app bundle on a read-only volume, as a mounted disk image or App Translocation. */ + readonly readOnlyBundle?: boolean; readonly installRecord?: string | undefined; readonly policy?: DesktopUpdatePolicy.UpdatePolicy; readonly appVersion?: string; @@ -242,6 +244,25 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const isInstallRecord = (path: string) => path.endsWith(DesktopUpdates.DESKTOP_UPDATE_INSTALL_RECORD_FILE); const layerFileSystem = FileSystem.layerNoop({ + // The harness bundle sits at "/", so the whole volume is the bundle's; Node reports EROFS. + access: (path, accessOptions) => + options.readOnlyBundle && accessOptions?.writable + ? Effect.fail( + PlatformError.systemError({ + module: "FileSystem", + method: "access", + _tag: "Unknown", + pathOrDescriptor: path, + syscall: "access", + cause: Object.assign(new Error(`EROFS: read-only file system, access '${path}'`), { + code: "EROFS", + errno: -30, + syscall: "access", + path, + }), + }), + ) + : Effect.void, readFileString: (path) => { const installRecord = isInstallRecord(path) ? (installRecords.get(path) ?? previousInstallRecord) From f878ba1f887f40ce273f42844007b32c13104e3d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 02:24:45 +0000 Subject: [PATCH 2/4] fix(desktop): turn updates off for a macOS app on a read-only volume A Mac build opened from its disk image, a read-only drive, or App Translocation checked for and downloaded updates, then failed inside quitAndInstall after the backends had already stopped: Squirrel.Mac refuses to replace a bundle on a read-only volume. At service construction, packaged macOS builds probe the .app bundle for write access. Only an EROFS failure counts. getAutoUpdateDisabledReason then reports updates as off, with a message telling the user to move T3 Code to the Applications folder. Other access failures leave updates on. Fixes pingdotgg/t3code#31244 AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/updates/DesktopUpdates.ts | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 6279e8b6141a8..e2057d67bb89e 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -335,6 +335,7 @@ function getAutoUpdateDisabledReason(args: { platform: NodeJS.Platform; appImage?: string | undefined; isDebPackage: boolean; + isReadOnlyMacBundle: boolean; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { @@ -353,6 +354,9 @@ function getAutoUpdateDisabledReason(args: { if (args.platform === "linux" && !args.appImage && !args.isDebPackage) { return "Automatic updates on Linux require the AppImage or the .deb package."; } + if (args.isReadOnlyMacBundle) { + return "Automatic updates are off because T3 Code is running from a read-only location, such as the disk image. Move T3 Code to the Applications folder and open it from there."; + } return null; } @@ -451,6 +455,27 @@ export const make = Effect.gen(function* () { ) : false; + // Squirrel.Mac refuses to install over a bundle on a read-only volume (a disk image or App + // Translocation), and only says so after the download, inside quitAndInstall. + const isReadOnlyMacBundle = + environment.platform === "darwin" && environment.isPackaged + ? yield* fileSystem + .access(environment.path.resolve(environment.resourcesPath, "..", ".."), { + writable: true, + }) + .pipe( + Effect.as(false), + Effect.catch(({ reason: { cause } }) => + Effect.succeed( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "EROFS", + ), + ), + ) + : false; + const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( Effect.map( (appUpdateYmlConfig) => @@ -468,6 +493,7 @@ export const make = Effect.gen(function* () { platform: environment.platform, appImage: Option.getOrUndefined(config.appImagePath), isDebPackage, + isReadOnlyMacBundle, disabledByEnv: config.disableAutoUpdate, hasUpdateFeedConfig: hasFeedConfig, }), From 066595809345b9712c96c18dae109c57ad32ef6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 02:36:46 +0000 Subject: [PATCH 3/4] test(desktop): a bundle the user cannot write still updates on macOS Covers the EACCES side of the read-only check and asserts the exact disabled message. Co-Authored-By: Claude Opus 5.5 --- .../src/updates/DesktopUpdates.test.ts | 49 +++++++++++-------- apps/desktop/src/updates/DesktopUpdates.ts | 2 +- .../desktop/src/updates/updatesTestHarness.ts | 21 ++++---- 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 9a92ef70b5d5e..5bc68d4d2cc0f 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -42,26 +42,35 @@ describe("DesktopUpdates", () => { }), ); - it.effect("turns updates off when the macOS app runs from a read-only volume", () => { - const harness = makeHarness({ platform: "darwin", readOnlyBundle: true }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "disabled"); - assert.equal(state.enabled, false); - assert.isString(state.message); - - yield* TestClock.adjust(Duration.minutes(1)); - yield* flushCallbacks; - assert.equal(harness.checkCount(), 0); - }), - ).pipe(Effect.provide(harness.layer)); - }); + it.effect("turns updates off only when the macOS app runs from a read-only volume", () => + Effect.gen(function* () { + const darwinUpdates = (bundleWriteError: "EROFS" | "EACCES") => { + const harness = makeHarness({ platform: "darwin", bundleWriteError }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* TestClock.adjust(Duration.minutes(1)); + yield* flushCallbacks; + return { state: yield* updates.getState, checks: harness.checkCount() }; + }), + ).pipe(Effect.provide(harness.layer)); + }; + + const readOnly = yield* darwinUpdates("EROFS"); + assert.equal(readOnly.state.status, "disabled"); + assert.equal( + readOnly.state.message, + "Automatic updates are off because T3 Code is running from a read-only location, such as a disk image. Move T3 Code to the Applications folder and open it from there.", + ); + assert.equal(readOnly.checks, 0); + + // ShipIt can ask for authorization over a bundle the user cannot write, so that still updates. + const notWritable = yield* darwinUpdates("EACCES"); + assert.notEqual(notWritable.state.status, "disabled"); + assert.equal(notWritable.checks, 1); + }), + ); it.effect("keeps the disabled reason available after configuration and channel changes", () => { const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index e2057d67bb89e..09a5c68007f23 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -355,7 +355,7 @@ function getAutoUpdateDisabledReason(args: { return "Automatic updates on Linux require the AppImage or the .deb package."; } if (args.isReadOnlyMacBundle) { - return "Automatic updates are off because T3 Code is running from a read-only location, such as the disk image. Move T3 Code to the Applications folder and open it from there."; + return "Automatic updates are off because T3 Code is running from a read-only location, such as a disk image. Move T3 Code to the Applications folder and open it from there."; } return null; } diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index cbf2109093d74..38b4e1ff1b6aa 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -36,8 +36,8 @@ export interface UpdatesHarnessOptions { readonly env?: Record; readonly platform?: NodeJS.Platform; readonly packageType?: string | undefined; - /** Puts the app bundle on a read-only volume, as a mounted disk image or App Translocation. */ - readonly readOnlyBundle?: boolean; + /** Fails write checks on the app bundle: EROFS on a disk image or under App Translocation. */ + readonly bundleWriteError?: "EROFS" | "EACCES"; readonly installRecord?: string | undefined; readonly policy?: DesktopUpdatePolicy.UpdatePolicy; readonly appVersion?: string; @@ -244,25 +244,26 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const isInstallRecord = (path: string) => path.endsWith(DesktopUpdates.DESKTOP_UPDATE_INSTALL_RECORD_FILE); const layerFileSystem = FileSystem.layerNoop({ - // The harness bundle sits at "/", so the whole volume is the bundle's; Node reports EROFS. - access: (path, accessOptions) => - options.readOnlyBundle && accessOptions?.writable + // The harness bundle sits at "/", so every write check is a check on the bundle's volume. + access: (path, accessOptions) => { + const code = options.bundleWriteError; + return code && accessOptions?.writable ? Effect.fail( PlatformError.systemError({ module: "FileSystem", method: "access", - _tag: "Unknown", + _tag: code === "EACCES" ? "PermissionDenied" : "Unknown", pathOrDescriptor: path, syscall: "access", - cause: Object.assign(new Error(`EROFS: read-only file system, access '${path}'`), { - code: "EROFS", - errno: -30, + cause: Object.assign(new Error(`${code}: access '${path}'`), { + code, syscall: "access", path, }), }), ) - : Effect.void, + : Effect.void; + }, readFileString: (path) => { const installRecord = isInstallRecord(path) ? (installRecords.get(path) ?? previousInstallRecord) From 8da4750f93b6a8cc925f7deba5ccf67cb30b6743 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Nov 2026 02:53:29 +0000 Subject: [PATCH 4/4] test(desktop): assert the read-only state, not its exact copy Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/updates/DesktopUpdates.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 5bc68d4d2cc0f..e675e4ae6bbe8 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -59,10 +59,7 @@ describe("DesktopUpdates", () => { const readOnly = yield* darwinUpdates("EROFS"); assert.equal(readOnly.state.status, "disabled"); - assert.equal( - readOnly.state.message, - "Automatic updates are off because T3 Code is running from a read-only location, such as a disk image. Move T3 Code to the Applications folder and open it from there.", - ); + assert.isString(readOnly.state.message); assert.equal(readOnly.checks, 0); // ShipIt can ask for authorization over a bundle the user cannot write, so that still updates.