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..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 @@ -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" @@ -100,7 +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") + // 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) @@ -116,8 +118,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 +167,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 +226,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 +296,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 +351,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 +559,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. 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" }); } } 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* (