From 7492686072133e9d783f69d0b23cee663c801c42 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sat, 7 Nov 2026 20:57:10 +0000 Subject: [PATCH 1/4] fix(mobile): a half-deleted phone runtime no longer looks installed Remove and Reinstall deleted the runtime folder in place, so a delete that stopped partway left the "installed" markers beside an Ubuntu missing /usr/bin/env. The app then showed setup as done and every proot call failed. Remove now moves the folder aside before deleting it, installed also checks that the guest's /usr/bin/env resolves, a guest command refuses to start on a damaged install, and the delete chmods each directory before listing it. Co-Authored-By: Claude Opus 5.5 --- .../modules/t3deviceruntime/DeviceRuntime.kt | 80 ++++++++++++------- .../t3deviceruntime/T3DeviceRuntimeModule.kt | 7 +- .../DeviceRuntimeSetupScreen.tsx | 7 +- 3 files changed, 62 insertions(+), 32 deletions(-) diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/DeviceRuntime.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/DeviceRuntime.kt index 1037af90e838e..d7cd4c792e9bd 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/DeviceRuntime.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/DeviceRuntime.kt @@ -18,11 +18,8 @@ import java.io.InputStream import java.io.OutputStream import java.net.HttpURLConnection import java.net.URL -import java.nio.file.FileVisitResult import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.LinkOption import java.security.MessageDigest import java.util.concurrent.TimeUnit import java.util.zip.GZIPInputStream @@ -33,6 +30,7 @@ private const val CLAUDE_KEY_FINGERPRINT = "31DDDE24DDFAB679F42D7BD2BAA929FF1A7E private const val CONNECT_ATTEMPTS = 3 private const val TERMINATE_GRACE_SECONDS = 5L private const val PID_POLL_MS = 100L +private const val MAX_SYMLINKS = 8 internal const val TAG = "T3DeviceRuntime" const val GUEST_PATH = "/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -101,6 +99,7 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U val proot = File(libDir, "libproot.so") private val loader = File(libDir, "libproot-loader.so") val home = File(context.filesDir, "device-runtime") + private val trash = File(context.filesDir, "device-runtime.trash") val rootfs = File(home, "ubuntu") private val installedMarker = File(home, "ubuntu.installed") private val architecture = ubuntuArchitecture(libDir.name) @@ -116,8 +115,26 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U @Volatile private var guest: GuestProcess? = null @Volatile private var cancelled = false + /** Also checks the program proot starts first, which an interrupted delete can take. */ val ubuntuInstalled: Boolean - get() = installedMarker.isFile && installedMarker.readText().trim() == ubuntuTarball + get() = installedMarker.isFile && installedMarker.readText().trim() == ubuntuTarball && + guestFileExists("usr/bin/env") + + // Follows symlinks as the guest sees them. The extractor's hard links point at guest paths; + // the ones proot emulates point at host paths inside the rootfs. + private fun guestFileExists(path: String): Boolean { + var file = File(rootfs, path).toPath() + repeat(MAX_SYMLINKS) { + if (!Files.isSymbolicLink(file)) return Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) + val target = Files.readSymbolicLink(file).toString() + file = when { + target.startsWith(rootfs.path + "/") -> File(target).toPath() + target.startsWith("/") -> File(rootfs, target).toPath() + else -> file.resolveSibling(target) + } + } + return false + } fun cancel() { cancelled = true @@ -147,9 +164,10 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U throw IOException("$ubuntuTarball has SHA-256 $actual, expected $expected") } val staging = File(home, "ubuntu.staging") - deleteTree(staging) - deleteTree(rootfs) installedMarker.delete() + discard(staging) + discard(rootfs) + clearTrash() staging.mkdirs() log("Extracting $ubuntuTarball") tarball.inputStream().buffered(1 shl 16).use { extractTar(GZIPInputStream(it, 1 shl 16), staging) } @@ -205,7 +223,7 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U } fakeProc.mkdirs() val sdcard = File(rootfs, "sdcard").toPath() - if (!Files.exists(sdcard, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + if (!Files.exists(sdcard, LinkOption.NOFOLLOW_LINKS)) { Os.symlink("/storage/emulated/0", sdcard.toString()) } } @@ -275,6 +293,7 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U /** Starts a command in the guest with stderr merged into stdout. */ fun start(guestCommand: List, environment: Map = emptyMap()): GuestProcess { checkCancelled() + if (!ubuntuInstalled) throw IOException("Ubuntu on this phone is missing or damaged. Install it again.") writeGuestConfig() prootTmp.mkdirs() hardLinks.mkdirs() @@ -329,8 +348,24 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U /** Deletes Ubuntu and everything installed in it, including the server's data. */ fun remove() { - deleteTree(home) + discard(home) deleteTree(prootTmp) + clearTrash() + } + + // Moved aside before deleting, so a delete that stops partway can't leave an install that + // still looks whole. + private fun discard(file: File) { + if (!Files.exists(file.toPath(), LinkOption.NOFOLLOW_LINKS)) return + trash.mkdirs() + if (!file.renameTo(File(trash, "${file.name}.${System.nanoTime()}"))) { + throw IOException("Couldn't move ${file.path} aside to delete it") + } + } + + // Leftovers only take space; the next remove or install tries again. + private fun clearTrash() { + runCatching { deleteTree(trash) }.onFailure { Log.w(TAG, "Couldn't delete ${trash.path}", it) } } // Phones drop DNS and connections while they switch networks, so connecting retries. @@ -521,27 +556,14 @@ fun openPgpFingerprint(armored: String): String { fun megabytes(bytes: Long): String = "%.1f MB".format(bytes / 1_000_000.0) -/** Deletes a tree without following symlinks, making read-only directories writable first. */ +/** Deletes a tree without following symlinks, first giving its owner every directory in full. */ fun deleteTree(root: File) { - val start = root.toPath() - if (!Files.exists(start, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return - Files.walkFileTree(start, object : SimpleFileVisitor() { - override fun preVisitDirectory(dir: Path, attributes: BasicFileAttributes): FileVisitResult { - dir.toFile().setWritable(true, true) - dir.toFile().setExecutable(true, true) - return FileVisitResult.CONTINUE - } - - override fun visitFile(file: Path, attributes: BasicFileAttributes): FileVisitResult { - Files.delete(file) - return FileVisitResult.CONTINUE - } - - override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { - Files.delete(dir) - return FileVisitResult.CONTINUE - } - }) + val path = root.toPath() + if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + Os.chmod(root.path, OWNER_ALL) + Files.newDirectoryStream(path).use { it.toList() }.forEach { deleteTree(it.toFile()) } + } + Files.deleteIfExists(path) } private fun ByteArray.int32(at: Int): Int = diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/T3DeviceRuntimeModule.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/T3DeviceRuntimeModule.kt index 2d9073b923827..b7d65c89b096c 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/T3DeviceRuntimeModule.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/T3DeviceRuntimeModule.kt @@ -55,7 +55,7 @@ class T3DeviceRuntimeModule : Module() { mapOf( "supported" to runtime.supported, "storageAccess" to hasStorageAccess(requireContext()), - "installed" to installedMarker(runtime).isFile, + "installed" to installed(runtime), "serverRunning" to DeviceServer.running, "job" to jobState, "login" to loginState, @@ -138,7 +138,7 @@ class T3DeviceRuntimeModule : Module() { // The server keeps running its old build until the app restarts it. AsyncFunction("update") { promise: Promise -> val context = requireContext() - if (!installedMarker(DeviceRuntime(context) {}).isFile) { + if (!installed(DeviceRuntime(context) {})) { throw CodedException("ERR_DEVICE_RUNTIME_MISSING", "The on-device runtime isn't installed.", null) } startJob(context, "update", UPDATE_STEPS, promise) { runtime, step, script -> @@ -235,6 +235,8 @@ class T3DeviceRuntimeModule : Module() { private fun installedMarker(runtime: DeviceRuntime) = File(runtime.home, "setup.done") + private fun installed(runtime: DeviceRuntime) = installedMarker(runtime).isFile && runtime.ubuntuInstalled + private fun inBackground(promise: Promise, body: () -> T) { work.execute { try { @@ -266,6 +268,7 @@ class T3DeviceRuntimeModule : Module() { jobs.execute { var failure: String? = null try { + if (kind == "install") installedMarker(runtime).delete() val script = context.assets.open("device-runtime-setup.sh").bufferedReader().readText() for ((index, step) in steps.withIndex()) { setJobState(kind, true, step, index, steps.size, null) diff --git a/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx index 0b83e9542fe4d..6474e766ba18b 100644 --- a/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx +++ b/apps/mobile/src/features/device-runtime/DeviceRuntimeSetupScreen.tsx @@ -217,7 +217,12 @@ export function DeviceRuntimeSetupScreen() { onCancel={() => native.cancelJob()} /> ), - signIn: , + signIn: ( + + ), connect: ( <> Starts T3 Code's server on this phone and adds it to your environments. From cf57c2eda6c83cecd486fc4cf8e91d2c22baef5f Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 8 Nov 2026 02:09:39 +0000 Subject: [PATCH 2/4] fix(mobile): phone setup no longer fails upgrading hard-linked packages proot resolves the rootfs to /data/data/... but took PROOT_L2S_DIR as given, under the /data/user/0 symlink. Its faked hard links then pointed at a path the guest can't follow, so dpkg failed upgrading perl-base (error setting ownership of /usr/bin/perl5.40.1.dpkg-new). Reproduced with the same proot on Linux by putting the rootfs behind a symlink; resolving filesDir fixes it. Co-Authored-By: Claude Opus 5.5 --- .../java/expo/modules/t3deviceruntime/DeviceRuntime.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/DeviceRuntime.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/DeviceRuntime.kt index d7cd4c792e9bd..0ad9f67b917e5 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/DeviceRuntime.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/DeviceRuntime.kt @@ -98,8 +98,11 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U private val libDir = File(context.applicationInfo.nativeLibraryDir) val proot = File(libDir, "libproot.so") private val loader = File(libDir, "libproot-loader.so") - val home = File(context.filesDir, "device-runtime") - private val trash = File(context.filesDir, "device-runtime.trash") + // filesDir is /data/user/0/..., a symlink to /data/data/.... proot resolves the rootfs but + // not PROOT_L2S_DIR, and its faked hard links then point where the guest can't follow. + private val filesDir = context.filesDir.canonicalFile + val home = File(filesDir, "device-runtime") + private val trash = File(filesDir, "device-runtime.trash") val rootfs = File(home, "ubuntu") private val installedMarker = File(home, "ubuntu.installed") private val architecture = ubuntuArchitecture(libDir.name) From c9001d4b6e41b0989f69bd91b101dcf8c8605f73 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 8 Nov 2026 03:05:40 +0000 Subject: [PATCH 3/4] fix(mobile): phone sharing no longer stays stuck stopping after a reinstall A saved "stopping" record named the old phone server, whose connection was forgotten when Ubuntu was reinstalled. Every retry found no connection and gave up, so the toggle stayed on "Still stopping" with nothing that could clear it. The phone runs one server, so when another one answers under a new id the old tunnel is gone with its Ubuntu: the record moves on to removing the T3 Connect listing. Co-Authored-By: Claude Opus 5.5 --- .../phone/phone-sharing-controller.test.ts | 20 ++++++++++++++-- .../phone/phone-sharing-controller.ts | 23 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts index dbf6cc403a345..1f586d74f901d 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.test.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.test.ts @@ -35,6 +35,7 @@ function setup(initial: PhoneSharingRecord | null = null, hydrated = async () => deregisterFailure: null as string | null, saveFailure: null as string | null, modeFailure: null as string | null, + phoneEnvironmentId: environmentId as EnvironmentId | null, }; const setSharingMode = vi.fn(async () => state.modeFailure); const stopOnServer = vi.fn(async () => { @@ -50,8 +51,8 @@ function setup(initial: PhoneSharingRecord | null = null, hydrated = async () => if (state.saveFailure !== null) throw new Error(state.saveFailure); state.record = record; }, - phoneEnvironmentId: () => environmentId, - connectionFor: () => (state.connected ? connection : null), + phoneEnvironmentId: () => state.phoneEnvironmentId, + connectionFor: (id) => (state.connected && id === environmentId ? connection : null), signedInAccount: () => state.account, readSharing: async () => (state.connected ? state.sharing : null), ensureRelayClient: async () => null, @@ -306,6 +307,21 @@ describe("createPhoneSharingController", () => { expect(state.record).toBeNull(); }); + it("lets go of a stop or share whose server Ubuntu's reinstall replaced", async () => { + for (const phase of ["stopping", "on"] as const) { + const old = EnvironmentId.make("old-phone"); + const { state, controller, stopOnServer, deregister } = setup({ + environmentId: old, + accountId: "user_a", + phase, + }); + expect(await controller.reconcile()).toBeNull(); + expect(stopOnServer).not.toHaveBeenCalled(); + expect(deregister).toHaveBeenCalledExactlyOnceWith("user_a", old); + expect(state.record).toBeNull(); + } + }); + it("keeps the listing's record through a release while signed out", async () => { const { state, controller } = setup(on); state.connected = false; diff --git a/apps/mobile/src/features/phone/phone-sharing-controller.ts b/apps/mobile/src/features/phone/phone-sharing-controller.ts index 456c0830e4f7f..563085a680ab9 100644 --- a/apps/mobile/src/features/phone/phone-sharing-controller.ts +++ b/apps/mobile/src/features/phone/phone-sharing-controller.ts @@ -58,10 +58,27 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { return run; }; + /** + * The phone runs one server, so another one answering under a new id means Ubuntu was + * reinstalled, and the old server's tunnel went with it. Only its listing can be left. + */ + const serverReplaced = (record: PhoneSharingRecord) => { + const current = effects.phoneEnvironmentId(); + return ( + current !== null && + current !== record.environmentId && + effects.connectionFor(record.environmentId) === null + ); + }; + /** Works a stopping or listed record as far as it can go. Null means nothing is left to do. */ const settle = async (): Promise => { let record = effects.loadRecord(); if (record === null || record.phase === "on") return null; + if (record.phase === "stopping" && serverReplaced(record)) { + record = { ...record, phase: "listed" }; + await effects.saveRecord(record); + } if (record.phase === "stopping") { const connection = effects.connectionFor(record.environmentId); if (connection === null) return STOP_UNFINISHED; @@ -176,7 +193,11 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) { const account = effects.signedInAccount(); const connection = effects.connectionFor(record.environmentId); const sharing = connection === null ? null : await effects.readSharing(connection); - if ((account !== null && account !== record.accountId) || sharing?.shared === false) { + if ( + (account !== null && account !== record.accountId) || + sharing?.shared === false || + serverReplaced(record) + ) { await effects.saveRecord({ ...record, phase: "stopping" }); } } From 100f1d8d9f89936fc41cf9070f5f42e11464ff81 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sun, 8 Nov 2026 03:07:15 +0000 Subject: [PATCH 4/4] fix(server): Claude installed with apt updates through apt `claude update` refuses an apt install ("Claude is managed by a package manager") and exits 0, so Update Claude reported success and still found the old version. The version check also used the latest channel, while the apt repository follows stable, so the install always looked outdated. A claude that dpkg owns now checks the channel its apt source names. As root, as on the phone, Update Claude refreshes only Claude's apt list and upgrades the package; otherwise it shows the sudo apt-get command. Co-Authored-By: Claude Opus 5.5 --- .../Drivers/ClaudeMaintenance.test.ts | 103 +++++++++++++++--- .../src/provider/Drivers/ClaudeMaintenance.ts | 63 ++++++++++- 2 files changed, 150 insertions(+), 16 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts index 7897fe9b75c5d..1188a1a973845 100644 --- a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts @@ -45,13 +45,16 @@ const fixture = Effect.fn("ClaudeMaintenance.test.fixture")(function* ( npm = false, platform: NodeJS.Platform = "linux", programFiles?: string, + apt?: { readonly uid: number; readonly sources: Record }, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped(); const binaryPath = npm ? path.join(directory, "lib/node_modules/@anthropic-ai/claude-code/cli.js") - : path.join(directory, ".local/bin", platform === "win32" ? "claude.exe" : "claude"); + : apt + ? path.join(directory, "usr/bin/claude") + : path.join(directory, ".local/bin", platform === "win32" ? "claude.exe" : "claude"); yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); yield* fs.chmod(binaryPath, 0o755); @@ -66,6 +69,16 @@ const fixture = Effect.fn("ClaudeMaintenance.test.fixture")(function* ( ? "/Library/Application Support/ClaudeCode" : "/etc/claude-code"; let installationReads = 0; + const aptFiles = new Map( + apt + ? [ + ["/var/lib/dpkg/info/claude-code.list", `/usr\n/usr/bin\n${binaryPath}\n`], + ...Object.entries(apt.sources).map( + ([name, source]) => [`/etc/apt/sources.list.d/${name}`, source] as const, + ), + ] + : [], + ); const testFs = FileSystem.makeNoop({ ...fs, realPath: (file) => { @@ -73,19 +86,23 @@ const fixture = Effect.fn("ClaudeMaintenance.test.fixture")(function* ( return fs.realPath(file); }, readFileString: (file, ...args) => - file.replaceAll("\\", "/").startsWith(`${managedDirectory}/`) - ? managed.has(file.replaceAll("\\", "/")) - ? Effect.succeed(managed.get(file.replaceAll("\\", "/"))!) - : fs.readFileString(path.join(directory, "missing")) - : fs.readFileString(file, ...args), + aptFiles.has(file) + ? Effect.succeed(aptFiles.get(file)!) + : file.replaceAll("\\", "/").startsWith(`${managedDirectory}/`) + ? managed.has(file.replaceAll("\\", "/")) + ? Effect.succeed(managed.get(file.replaceAll("\\", "/"))!) + : fs.readFileString(path.join(directory, "missing")) + : fs.readFileString(file, ...args), readDirectory: (file) => - file.replaceAll("\\", "/") === `${managedDirectory}/managed-settings.d` - ? Effect.succeed( - [...managed.keys()] - .filter((key) => key.includes("/managed-settings.d/")) - .map((key) => path.basename(key)), - ) - : fs.readDirectory(file), + file === "/etc/apt/sources.list.d" && apt + ? Effect.succeed(Object.keys(apt.sources)) + : file.replaceAll("\\", "/") === `${managedDirectory}/managed-settings.d` + ? Effect.succeed( + [...managed.keys()] + .filter((key) => key.includes("/managed-settings.d/")) + .map((key) => path.basename(key)), + ) + : fs.readDirectory(file), }); return { directory, @@ -101,6 +118,7 @@ const fixture = Effect.fn("ClaudeMaintenance.test.fixture")(function* ( ), Effect.provideService(FileSystem.FileSystem, testFs), Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.UserId, apt?.uid ?? 1000), Effect.provideService(HostProcess.Environment, { PATH: "", CLAUDE_CONFIG_DIR: configDirectory, @@ -374,6 +392,65 @@ it.layer(NodeServices.layer)("ClaudeMaintenance", (it) => { }).pipe(Effect.scoped), ); + it.effect.each([ + { name: "as root", uid: 0 }, + { name: "as a user", uid: 1000 }, + ])("upgrades an apt install through apt $name, on its repository's channel", (row) => + Effect.gen(function* () { + const f = yield* fixture(false, "linux", undefined, { + uid: row.uid, + sources: { + "ubuntu.sources": "Types: deb\nURIs: http://archive.ubuntu.com/ubuntu\n", + "claude-code.list": + "deb [signed-by=/etc/apt/keyrings/claude-code.asc] https://downloads.claude.ai/claude-code/apt/stable stable main\n", + }, + }); + yield* f.fs.writeFileString(f.settingsPath, '{"autoUpdatesChannel":"latest"}'); + const requests: string[] = []; + const client = HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(request.url.endsWith("/stable") ? "2.1.314" : "2.1.319"), + ), + ); + }); + const resolve = yield* f.makeResolution.pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + const capabilities = yield* resolve(); + const enriched = yield* enrichProviderSnapshotWithVersionAdvisory( + snapshot, + capabilities, + ).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(enriched.versionAdvisory).toMatchObject({ + status: "current", + latestVersion: "2.1.314", + }); + expect(requests).toEqual(["https://downloads.claude.ai/claude-code-releases/stable"]); + if (row.uid === 0) { + expect(capabilities.update).toMatchObject({ + executable: "/bin/sh", + lockKey: "claudeAgent-apt", + env: { DEBIAN_FRONTEND: "noninteractive" }, + }); + expect(capabilities.update?.args).toEqual([ + "-c", + "apt-get update -o Dir::Etc::SourceList=/etc/apt/sources.list.d/claude-code.list -o Dir::Etc::SourceParts=- -o APT::Get::List-Cleanup=0 && apt-get install -y --only-upgrade claude-code", + ]); + } else { + expect(capabilities.update).toBeNull(); + expect(capabilities.manualCommand).toBe( + "sudo apt-get update && sudo apt-get install -y --only-upgrade claude-code", + ); + } + }).pipe(Effect.scoped), + ); + it.effect("bounds stalled feed bodies and skips disabled update checks", () => Effect.gen(function* () { const f = yield* fixture(); diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts index d9975762647d4..303fc5921001a 100644 --- a/apps/server/src/provider/Drivers/ClaudeMaintenance.ts +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts @@ -1,7 +1,9 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, makePackageManagedProviderMaintenanceResolver, + makeProviderMaintenanceCapabilities, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, type ProviderMaintenanceCapabilities, @@ -23,6 +25,10 @@ const UpdateChannelSettings = fromLenientJson( Schema.Struct({ autoUpdatesChannel: Schema.optional(Schema.Literals(["latest", "stable"])) }), ); const NativeVersion = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+$/)); +const APT_PACKAGE_FILES = "/var/lib/dpkg/info/claude-code.list"; +const APT_SOURCES = "/etc/apt/sources.list.d"; +const APT_SOURCE_CHANNEL = /downloads\.claude\.ai\/claude-code\/apt\/(latest|stable)\b/; +const APT_UPGRADE = "apt-get install -y --only-upgrade claude-code"; const update = makePackageManagedProviderMaintenanceResolver({ provider: ProviderDriverKind.make("claudeAgent"), @@ -73,15 +79,36 @@ const readUpdateChannel = Effect.fn("ClaudeMaintenance.readUpdateChannel")(funct return channels.findLast((channel) => channel !== undefined) ?? "latest"; }); +/** Anthropic's apt package refuses `claude update`, and its repository picks the channel. */ +const readAptInstall = Effect.fn("ClaudeMaintenance.readAptInstall")(function* ( + executable: string, +) { + if ((yield* HostProcess.Platform) !== "linux") return null; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const packageFiles = yield* fs + .readFileString(APT_PACKAGE_FILES) + .pipe(Effect.orElseSucceed(() => "")); + if (!packageFiles.split("\n").includes(executable)) return null; + const names = yield* fs.readDirectory(APT_SOURCES).pipe(Effect.orElseSucceed(() => [])); + for (const name of names.filter((name) => /^[\w.-]+$/.test(name)).toSorted()) { + const sourceFile = path.join(APT_SOURCES, name); + const source = yield* fs.readFileString(sourceFile).pipe(Effect.orElseSucceed(() => "")); + const channel = APT_SOURCE_CHANNEL.exec(source)?.[1]; + if (channel === "latest" || channel === "stable") return { executable, sourceFile, channel }; + } + return { executable, sourceFile: null, channel: "stable" as const }; +}); + const resolveClaudeMaintenance = Effect.fn("ClaudeMaintenance.resolve")(function* ( capabilities: ProviderMaintenanceCapabilities, ) { if (capabilities.update?.lockKey !== "claudeAgent-native") return capabilities; - const channel = yield* readUpdateChannel(); + const apt = yield* readAptInstall(capabilities.update.executable); + const channel = apt?.channel ?? (yield* readUpdateChannel()); const client = yield* HttpClient.HttpClient; const url = `${NATIVE_RELEASES}/${channel}`; - return { - ...capabilities, + const latest = { latestVersion: null, latestVersionCacheKey: url, lookupLatestVersion: client.get(url).pipe( @@ -94,6 +121,36 @@ const resolveClaudeMaintenance = Effect.fn("ClaudeMaintenance.resolve")(function Effect.orElseSucceed(() => null), ), }; + if (apt === null) return { ...capabilities, ...latest }; + if ((yield* HostProcess.UserId) !== 0) { + return { + ...makeManualOnlyProviderMaintenanceCapabilities({ + provider: capabilities.provider, + packageName: capabilities.packageName, + installPath: apt.executable, + manualCommand: `sudo apt-get update && sudo ${APT_UPGRADE}`, + }), + ...latest, + }; + } + // Only Claude's list, as Ubuntu's indexes take long to fetch and the phone deletes them. + const refresh = + apt.sourceFile === null + ? "apt-get update" + : `apt-get update -o Dir::Etc::SourceList=${apt.sourceFile} -o Dir::Etc::SourceParts=- -o APT::Get::List-Cleanup=0`; + const script = `${refresh} && ${APT_UPGRADE}`; + return { + ...makeProviderMaintenanceCapabilities({ + provider: capabilities.provider, + packageName: capabilities.packageName, + updateExecutable: "/bin/sh", + updateArgs: ["-c", script], + updateLockKey: "claudeAgent-apt", + updateCommand: script, + env: { DEBIAN_FRONTEND: "noninteractive" }, + }), + ...latest, + }; }); export const makeClaudeMaintenanceResolution = Effect.fn("ClaudeMaintenance.make")(function* (