fix(agent): secure and refresh unrooted log exports (#2131)

## Summary
- Keep raw diagnostic staging in private cache and give each exported
archive a unique request directory.
- Remove that request directory after the CLI pull, including failed
transfers.
- Replace files from the previous extracted snapshot while preserving
unrelated destination files.
- Document that the old rooted-device fallback is not scrubbed.

Follow-up to #2061. The refreshed export tests now cover independent
archive outputs and stale snapshot replacement.

## Validation
- `wsl python3 -m unittest tools.tests.test_droiddeckctl` — 49 passed.
- `:app:testDebugUnitTest -x buildVkBridgeServer --tests
com.droiddeck.launcher.session.SessionLogShareZipTest.anArchiveCanBeWrittenOutsideTheShareFolder`
— passed.
- `git diff --check` — passed.
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-11-05 15:57:27 -05:00
commit 7a78d2b66b
9 files changed
+659 -98

No files matched your search

@@ -3,6 +3,7 @@ package com.droiddeck.launcher
import android.app.Application
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.session.CrashHandler
import com.droiddeck.launcher.session.EnhancedSessionLogShare
import com.droiddeck.launcher.session.GpuClockPin
class App : Application() {
@@ -16,6 +17,7 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
runCatching { EnhancedSessionLogShare.cleanupAbandonedStages(this) }
com.droiddeck.launcher.ui.Themes.loadGradient(this)
com.droiddeck.launcher.ui.BuildWatermark.install(this)
HomeApp.trackRecents(this)
@@ -25,6 +25,8 @@ import com.droiddeck.launcher.session.SessionState
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@@ -202,13 +204,38 @@ class AgentBridgeProvider : ContentProvider() {
private fun logDir(context: Context): File? =
SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
private fun cleanupExpiredLogExports(exports: File) {
val now = System.currentTimeMillis()
val canonicalRoot = exports.canonicalFile
exports.listFiles()?.forEach { candidate ->
val isUuid = runCatching { UUID.fromString(candidate.name).toString() == candidate.name }.getOrDefault(false)
if (!isUuid || !candidate.isDirectory || Files.isSymbolicLink(candidate.toPath())) return@forEach
if (candidate.canonicalFile.parentFile != canonicalRoot) return@forEach
if (now - candidate.lastModified() >= LOG_EXPORT_RETENTION_MS) candidate.deleteRecursively()
}
}
// The session folder is app-private, so adb pulls this copy from external files instead.
private fun logs(context: Context): JSONObject {
val dir = logDir(context)?.takeIf { it.isDirectory }
?: return error("NO_SESSION_LOGS", "No session artifact directory is available")
val zip = SessionLogShare.zipFolder(context, dir, File(context.getExternalFilesDir(null), "agent-logs"))
val externalFiles = context.getExternalFilesDir(null)
?: return error("NO_SESSION_LOGS", "External app storage is unavailable")
val exports = File(externalFiles, "agent-logs")
if ((!exports.exists() && !exports.mkdirs()) || !exports.isDirectory) {
return error("NO_SESSION_LOGS", "Could not create the session export directory")
}
cleanupExpiredLogExports(exports)
val output = File(exports, UUID.randomUUID().toString())
val zip = try {
SessionLogShare.zipFolder(context, dir, output)
} catch (error: Exception) {
output.deleteRecursively()
throw error
}
?: return error("NO_SESSION_LOGS", "The session artifact directory is empty")
return JSONObject().put("ok", true).put("command", "logs").put("sessionId", dir.name).put("zip", zip.absolutePath)
return JSONObject().put("ok", true).put("command", "logs").put("sessionId", dir.name)
.put("zip", zip.absolutePath).put("cleanupDir", output.absolutePath)
}
private fun state(context: Context): JSONObject {
@@ -365,6 +392,7 @@ class AgentBridgeProvider : ContentProvider() {
const val RESULT_JSON = "json"
const val EXTRA_REQUEST = "request"
const val SCHEMA = 2
private const val LOG_EXPORT_RETENTION_MS = 24 * 60 * 60 * 1000L
private val recoveryComplete = CountDownLatch(1)
fun awaitRecovery() {
@@ -11,6 +11,7 @@ import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.File
import java.io.FileOutputStream
import java.io.FileInputStream
@@ -18,15 +19,19 @@ import java.io.IOException
import java.nio.ByteBuffer
import java.nio.channels.Channels
import java.nio.channels.FileChannel
import java.nio.channels.FileLock
import java.nio.channels.OverlappingFileLockException
import java.nio.channels.SeekableByteChannel
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributes
import java.security.MessageDigest
import java.util.Locale
import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@@ -44,6 +49,24 @@ internal interface SecureSourceOpener {
internal object EnhancedSessionLogShare {
private enum class Category(val suffix: String) { SESSION("session"), GRAPHICS("graphics"), GAMES("games") }
internal class DiagnosticStageLease internal constructor(
val directory: File,
private val channel: FileChannel,
private val lock: FileLock,
) : Closeable {
override fun close() {
try {
lock.release()
} finally {
try {
channel.close()
} finally {
directory.deleteRecursively()
}
}
}
}
private data class Candidate(
val file: File,
val anchor: File,
@@ -64,6 +87,10 @@ internal object EnhancedSessionLogShare {
private data class Snapshot(val file: File, val sourceBytes: Long, val truncated: Boolean)
private class StageUnavailableException(cause: Throwable) : IOException("Diagnostic staging directory is unavailable", cause)
private class SourceReadException(cause: Throwable) : IOException("Could not read diagnostic source", cause)
private data class CollectionResult(
val candidates: List<Candidate>,
val truncated: Boolean,
@@ -89,6 +116,70 @@ internal object EnhancedSessionLogShare {
private val sourceOpenerOverride = object : ThreadLocal<SecureSourceOpener?>() {
override fun initialValue(): SecureSourceOpener? = null
}
private val stageLifecycleMonitor = Any()
internal fun acquireDiagnosticStage(context: Context, outputName: String): DiagnosticStageLease {
val cache = context.cacheDir
require(outputName.isNotBlank() && File(outputName).name == outputName)
return withStageLifecycleLock(cache) {
val stage = File(cache, "$outputName-${UUID.randomUUID()}.diagnostic-stage")
if (!stage.mkdir()) throw IOException("Could not create the diagnostic staging directory")
val channel = try {
FileChannel.open(File(stage, ".active").toPath(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)
} catch (error: Exception) {
stage.deleteRecursively()
throw IOException("Could not create the diagnostic staging lock", error)
}
try {
val lock = channel.tryLock()
?: throw IOException("Could not lock the diagnostic staging directory")
DiagnosticStageLease(stage, channel, lock)
} catch (error: Exception) {
channel.close()
stage.deleteRecursively()
throw IOException("Could not lock the diagnostic staging directory", error)
}
}
}
internal fun cleanupAbandonedStages(context: Context) {
val cache = context.cacheDir
val canonicalCache = cache.canonicalFile
withStageLifecycleLock(cache) {
cache.listFiles()?.forEach { stage ->
if (!stage.isDirectory || Files.isSymbolicLink(stage.toPath()) ||
stage.canonicalFile.parentFile != canonicalCache || !isDiagnosticStage(stage.name)) return@forEach
val active = File(stage, ".active")
if (Files.isSymbolicLink(active.toPath())) return@forEach
val channel = runCatching {
FileChannel.open(active.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)
}.getOrNull() ?: return@forEach
val abandoned = try {
channel.tryLock()?.let { lock -> lock.release(); true } ?: false
} catch (_: OverlappingFileLockException) {
false
} finally {
channel.close()
}
if (abandoned) stage.deleteRecursively()
}
}
}
private fun isDiagnosticStage(name: String): Boolean {
val suffix = ".diagnostic-stage"
if (!name.endsWith(suffix)) return false
val base = name.removeSuffix(suffix)
val uuidStart = base.length - 36
if (uuidStart < 2 || base[uuidStart - 1] != '-') return false
return runCatching { UUID.fromString(base.substring(uuidStart)).toString() == base.substring(uuidStart) }
.getOrDefault(false)
}
private fun <T> withStageLifecycleLock(cache: File, block: () -> T): T = synchronized(stageLifecycleMonitor) {
FileChannel.open(File(cache, ".diagnostic-stages.lock").toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)
.use { channel -> channel.lock().use { block() } }
}
internal fun setSourceOpenerForTest(opener: SecureSourceOpener?) {
if (opener == null) sourceOpenerOverride.remove() else sourceOpenerOverride.set(opener)
@@ -107,17 +198,18 @@ internal object EnhancedSessionLogShare {
return emptyList()
}
val out = SessionLogShare.shareDir(context).apply { deleteRecursively(); mkdirs() }
val stage = File(out, ".diagnostic-stage").apply { mkdirs() }
val redactionLearningComplete = LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
val discovery = collect(context, folder, limits.maxDiscoveredFiles, legacy = false)
val candidates = discovery.candidates
val totalCandidates = candidates.size.coerceAtLeast(1)
val share = limits.maxBytesTotal / Category.values().size
val budgets = Category.values().associateWith { category ->
Budget(if (category == Category.GAMES) limits.maxBytesTotal - share * 2 else share)
}
var completed = 0
val stageLease = acquireDiagnosticStage(context, out.name)
val stage = stageLease.directory
return try {
val redactionLearningComplete = LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
val discovery = collect(context, folder, limits.maxDiscoveredFiles, legacy = false)
val candidates = discovery.candidates
val totalCandidates = candidates.size.coerceAtLeast(1)
val share = limits.maxBytesTotal / Category.values().size
val budgets = Category.values().associateWith { category ->
Budget(if (category == Category.GAMES) limits.maxBytesTotal - share * 2 else share)
}
var completed = 0
Category.values().map { category ->
val budget = budgets.getValue(category)
val records = ArrayList<Record>()
@@ -150,7 +242,7 @@ internal object EnhancedSessionLogShare {
zip
}
} finally {
stage.deleteRecursively()
stageLease.close()
}
}
@@ -173,7 +265,8 @@ internal object EnhancedSessionLogShare {
out.deleteRecursively()
return null
}
val stage = File(out, ".diagnostic-stage").apply { mkdirs() }
val stageLease = acquireDiagnosticStage(context, out.name)
val stage = stageLease.directory
val records = ArrayList<Record>()
val budget = Budget(limits.maxBytesTotal)
val zip = File(out, "DroidDeck-${folder.name}.zip")
@@ -199,7 +292,7 @@ internal object EnhancedSessionLogShare {
}
zip
} finally {
stage.deleteRecursively()
stageLease.close()
}
}
@@ -227,10 +320,15 @@ internal object EnhancedSessionLogShare {
if (budget.remaining <= 0) {
return Record(candidate.entry, candidate.origin, "omitted", reason = "bundle-byte-limit")
}
val snapshot = runCatching {
if (!stage.isDirectory || !stage.canWrite()) throw StageUnavailableException(IOException("staging path is not writable"))
val snapshot = try {
snapshot(candidate, stage, minOf(limits.maxBytesPerFile, budget.remaining))
}.getOrElse {
return Record(candidate.entry, candidate.origin, "error", reason = it.javaClass.simpleName)
} catch (error: StageUnavailableException) {
throw error
} catch (error: Exception) {
if (!stage.isDirectory || !stage.canWrite()) throw StageUnavailableException(error)
val reportedError = (error as? SourceReadException)?.cause ?: error
return Record(candidate.entry, candidate.origin, "error", reason = reportedError.javaClass.simpleName)
} ?: return Record(candidate.entry, candidate.origin, "omitted", reason = "unsafe-or-unavailable-source")
if (snapshot.file.length() == 0L && candidate.linePrefix != null) {
snapshot.file.delete()
@@ -266,32 +364,44 @@ internal object EnhancedSessionLogShare {
val rootPath = candidate.anchor.toPath().toAbsolutePath().normalize()
val sourcePath = candidate.file.toPath().toAbsolutePath().normalize()
if (!sourcePath.startsWith(rootPath) || sourcePath == rootPath || limit < 1) return null
val target = File.createTempFile("diagnostic-", ".log", stage)
val target = try {
File.createTempFile("diagnostic-", ".log", stage)
} catch (error: Exception) {
throw StageUnavailableException(error)
}
try {
val relative = rootPath.relativize(sourcePath)
return withSecureChannel(rootPath, relative) { input ->
val size = input.size()
val size = readSource { input.size() }
candidate.linePrefix?.let { prefix ->
val truncated = filterChannel(input, target, prefix, size, TRACE_SCAN_BYTES, limit)
return@withSecureChannel Snapshot(target, size, truncated)
}
FileOutputStream(target).channel.use { output ->
if (size <= limit) {
copy(input, output, 0, size)
} else {
val marker = "\n<diagnostic file truncated: beginning and tail retained>\n".toByteArray()
val content = (limit - marker.size).coerceAtLeast(0)
copyCompleteHead(input, output, content / 2)
val markerBytes = minOf(marker.size.toLong(), limit - output.position()).toInt()
output.write(ByteBuffer.wrap(marker, 0, markerBytes))
copyCompleteTail(input, output, size, (limit - output.position()).coerceAtLeast(0))
writeStage {
FileOutputStream(target).channel.use { output ->
if (size <= limit) {
copy(input, output, 0, size)
} else {
val marker = "\n<diagnostic file truncated: beginning and tail retained>\n".toByteArray()
val content = (limit - marker.size).coerceAtLeast(0)
copyCompleteHead(input, output, content / 2)
val markerBytes = minOf(marker.size.toLong(), limit - output.position()).toInt()
output.write(ByteBuffer.wrap(marker, 0, markerBytes))
copyCompleteTail(input, output, size, (limit - output.position()).coerceAtLeast(0))
}
output.force(false)
}
output.force(false)
}
Snapshot(target, size, size > limit)
} ?: target.delete().let { null }
} catch (e: Exception) {
if (e is StageUnavailableException) {
target.delete()
throw e
}
val targetWasPresent = target.exists()
target.delete()
if (!stage.isDirectory || !stage.canWrite() || !targetWasPresent) throw StageUnavailableException(e)
throw e
}
}
@@ -307,48 +417,51 @@ internal object EnhancedSessionLogShare {
val wanted = prefix.toByteArray()
var written = 0L
var truncated = sourceSize > scanLimit
target.outputStream().buffered().use { output ->
fun scan(start: Long, count: Long, discardPartialFirstLine: Boolean) {
channel.position(start)
val input = BufferedInputStream(Channels.newInputStream(channel), COPY_BUFFER_BYTES)
val line = ByteArrayOutputStream(1024)
var oversized = false
var scanned = 0L
var discard = discardPartialFirstLine
fun emit() {
val bytes = line.toByteArray()
if (!discard && !oversized && bytes.size >= wanted.size && wanted.indices.all { bytes[it] == wanted[it] }) {
if (written + bytes.size + 1 <= outputLimit) {
output.write(bytes)
output.write('\n'.code)
written += bytes.size + 1
} else truncated = true
writeStage {
target.outputStream().buffered().use { output ->
fun scan(start: Long, count: Long, discardPartialFirstLine: Boolean) {
readSource { channel.position(start) }
val input = BufferedInputStream(Channels.newInputStream(channel), COPY_BUFFER_BYTES)
val line = ByteArrayOutputStream(1024)
var oversized = false
var scanned = 0L
var discard = discardPartialFirstLine
fun emit() {
val bytes = line.toByteArray()
if (!discard && !oversized && bytes.size >= wanted.size && wanted.indices.all { bytes[it] == wanted[it] }) {
if (written + bytes.size + 1 <= outputLimit) {
output.write(bytes)
output.write('\n'.code)
written += bytes.size + 1
} else truncated = true
}
line.reset()
oversized = false
discard = false
}
line.reset()
oversized = false
discard = false
}
while (scanned < count) {
val value = input.read()
if (value < 0) break
scanned++
if (value == '\n'.code) emit()
else if (!oversized && !discard) {
if (line.size() >= LogRedactor.MAX_LINE_LENGTH) {
oversized = true
line.reset()
} else line.write(value)
while (scanned < count) {
val value = readSource { input.read() }
if (value < 0) break
scanned++
if (value == '\n'.code) emit()
else if (!oversized && !discard) {
if (line.size() >= LogRedactor.MAX_LINE_LENGTH) {
oversized = true
line.reset()
} else line.write(value)
}
}
if (start + scanned >= sourceSize && (line.size() > 0 || oversized)) emit()
}
if (sourceSize <= scanLimit) {
scan(0, sourceSize, false)
} else {
val head = scanLimit / 2
val tail = scanLimit - head
scan(0, head, false)
scan((sourceSize - tail).coerceAtLeast(0), tail, true)
}
if (start + scanned >= sourceSize && (line.size() > 0 || oversized)) emit()
}
if (sourceSize <= scanLimit) {
scan(0, sourceSize, false)
} else {
val head = scanLimit / 2
val tail = scanLimit - head
scan(0, head, false)
scan((sourceSize - tail).coerceAtLeast(0), tail, true)
}
}
return truncated
@@ -361,6 +474,7 @@ internal object EnhancedSessionLogShare {
private fun <T> withAndroidSecureChannel(root: Path, relative: Path, block: (SeekableByteChannel) -> T): T? {
val opened = ArrayList<ParcelFileDescriptor>()
var invokingBlock = false
return try {
val parts = relative.map { it.toString() }
if (parts.isEmpty()) return null
@@ -373,11 +487,15 @@ internal object EnhancedSessionLogShare {
val stat = Os.fstat(leafFd)
if (!OsConstants.S_ISREG(stat.st_mode) || stat.st_nlink != 1L) return null
// Android's FileInputStream(FileDescriptor) is deliberately not the descriptor owner.
FileInputStream(leafFd).use { stream -> block(stream.channel) }
FileInputStream(leafFd).use { stream ->
invokingBlock = true
block(stream.channel)
}
} finally {
runCatching { Os.close(leafFd) }
}
} catch (_: Exception) {
} catch (error: Exception) {
if (invokingBlock) throw error
null
} finally {
opened.asReversed().forEach { runCatching { it.close() } }
@@ -406,16 +524,16 @@ internal object EnhancedSessionLogShare {
"/proc/self/fd/${directory.fd}/$name"
private fun copy(input: SeekableByteChannel, output: FileChannel, start: Long, count: Long) {
input.position(start)
readSource { input.position(start) }
var left = count
val buffer = ByteBuffer.allocate(64 * 1024)
while (left > 0) {
buffer.clear()
buffer.limit(minOf(buffer.capacity().toLong(), left).toInt())
val read = input.read(buffer)
val read = readSource { input.read(buffer) }
if (read < 0) break
buffer.flip()
while (buffer.hasRemaining()) output.write(buffer)
while (buffer.hasRemaining()) writeStage { output.write(buffer) }
left -= read
}
}
@@ -424,14 +542,14 @@ internal object EnhancedSessionLogShare {
if (limit <= 0) return
copy(input, output, 0, limit)
val copied = output.position()
input.position(0)
readSource { input.position(0) }
val buffer = ByteBuffer.allocate(64 * 1024)
var scanned = 0L
var lastComplete = 0L
while (scanned < copied) {
buffer.clear()
buffer.limit(minOf(buffer.capacity().toLong(), copied - scanned).toInt())
val read = input.read(buffer)
val read = readSource { input.read(buffer) }
if (read < 0) break
buffer.flip()
repeat(read) { index ->
@@ -439,8 +557,8 @@ internal object EnhancedSessionLogShare {
}
scanned += read
}
output.truncate(lastComplete)
output.position(lastComplete)
writeStage { output.truncate(lastComplete) }
writeStage { output.position(lastComplete) }
}
private fun copyCompleteTail(
@@ -452,10 +570,10 @@ internal object EnhancedSessionLogShare {
if (limit <= 0 || sourceSize <= 0) return
var start = (sourceSize - limit).coerceAtLeast(0)
if (start > 0) {
input.position(start)
readSource { input.position(start) }
val buffered = BufferedInputStream(Channels.newInputStream(input), COPY_BUFFER_BYTES)
while (start < sourceSize) {
val value = buffered.read()
val value = readSource { buffered.read() }
if (value < 0) return
start++
if (value == '\n'.code) break
@@ -464,6 +582,22 @@ internal object EnhancedSessionLogShare {
copy(input, output, start, (sourceSize - start).coerceAtMost(limit))
}
private inline fun <T> readSource(block: () -> T): T = try {
block()
} catch (error: Exception) {
throw SourceReadException(error)
}
private inline fun <T> writeStage(block: () -> T): T = try {
block()
} catch (error: SourceReadException) {
throw error
} catch (error: StageUnavailableException) {
throw error
} catch (error: Exception) {
throw StageUnavailableException(error)
}
private fun addManifest(
archive: ZipOutputStream,
sessionId: String,
@@ -0,0 +1,65 @@
package com.droiddeck.launcher.agent
import android.os.Bundle
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.installJvmSecureSourceOpener
import com.droiddeck.launcher.session.resetSecureSourceOpener
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], manifest = Config.NONE)
class AgentLogExportTest {
private val context get() = RuntimeEnvironment.getApplication()
private var previousLogDirectory: File? = null
@Before fun prepare() {
previousLogDirectory = SessionState.logDirectory
installJvmSecureSourceOpener()
}
@After fun cleanup() {
SessionState.logDirectory = previousLogDirectory
resetSecureSourceOpener()
}
@Test fun independentLogRequestsKeepBothExportDirectories() {
val session = File(context.cacheDir, "agent-session").apply { mkdirs() }
File(session, "session.log").writeText("booted\n")
SessionState.logDirectory = session
val provider = Robolectric.buildContentProvider(AgentBridgeProvider::class.java).create().get()
var firstDir: File? = null
var secondDir: File? = null
try {
fun requestLogs() = JSONObject(provider.call("logs", null, Bundle()).getString("json")!!)
val first = requestLogs()
val second = requestLogs()
firstDir = File(first.getString("cleanupDir"))
secondDir = File(second.getString("cleanupDir"))
val firstZip = File(first.getString("zip"))
val secondZip = File(second.getString("zip"))
assertNotEquals(firstDir, secondDir)
assertEquals(firstDir, firstZip.parentFile)
assertEquals(secondDir, secondZip.parentFile)
assertTrue(firstZip.isFile)
assertTrue(secondZip.isFile)
} finally {
firstDir?.deleteRecursively()
secondDir?.deleteRecursively()
session.deleteRecursively()
}
}
}
@@ -13,6 +13,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.IOException
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
@@ -497,4 +498,35 @@ class EnhancedSessionLogShareTest {
assertFalse(EnhancedSessionLogShare.hasSourceOpenerForTest())
SessionLogShare.clear(app)
}
@Test fun sourceReadFailureIsRecordedWithoutFailingTheArchive() {
val session = tmp.newFolder("2026-11-04-16-steam")
session.resolve("session.log").writeText("must not be shared\n")
EnhancedSessionLogShare.setSourceOpenerForTest(object : SecureSourceOpener {
override fun <T> withChannel(
root: java.nio.file.Path,
relative: java.nio.file.Path,
block: (java.nio.channels.SeekableByteChannel) -> T,
): T? = FileChannel.open(root.resolve(relative), StandardOpenOption.READ).use { channel ->
block(object : SeekableByteChannel by channel {
override fun read(dst: ByteBuffer): Int = throw IOException("injected source read failure")
})
}
})
try {
val sessionZip = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-session.zip") }
ZipFile(sessionZip).use { archive ->
assertFalse(archive.entries().toList().any { it.name.endsWith("session.log") })
val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json"))
.bufferedReader().readText()).getJSONArray("entries")
val record = (0 until manifest.length()).map { manifest.getJSONObject(it) }
.single { it.optString("entry").endsWith("session.log") }
assertEquals("error", record.getString("status"))
assertEquals("IOException", record.getString("reason"))
}
} finally {
resetSecureSourceOpener()
}
SessionLogShare.clear(app)
}
}
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.session
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.text.SimpleDateFormat
import java.util.UUID
import java.util.Date
import java.util.Locale
import java.util.zip.ZipFile
@@ -26,18 +27,35 @@ class SessionLogShareZipTest {
@After fun resetSourceOpener() = resetSecureSourceOpener()
@Test fun startupRemovesAbandonedDiagnosticStagesAndPreservesActiveSnapshots() {
val abandoned = File(app.cacheDir, "share-${UUID.randomUUID()}.diagnostic-stage").apply { mkdirs() }
val active = EnhancedSessionLogShare.acquireDiagnosticStage(app, "share")
try {
EnhancedSessionLogShare.cleanupAbandonedStages(app)
assertFalse(abandoned.exists())
assertTrue(active.directory.isDirectory)
} finally {
active.close()
}
}
@Test fun anArchiveCanBeWrittenOutsideTheShareFolder() {
SessionLogShare.clear(app)
val session = File(LinuxRuntime.logDir(app), "2026-11-03-01-steam").apply { mkdirs() }
val out = File(app.getExternalFilesDir(null), "agent-logs")
val out = File(app.getExternalFilesDir(null), "agent-logs/request-1")
val secondOut = File(app.getExternalFilesDir(null), "agent-logs/request-2")
try {
File(session, "session.log").writeText("booted\n")
val zip = SessionLogShare.zipFolder(app, session, out)!!
val secondZip = SessionLogShare.zipFolder(app, session, secondOut)!!
assertEquals(out, zip.parentFile)
assertFalse(File(app.cacheDir, "share").exists())
assertTrue(zip.isFile)
assertEquals(secondOut, secondZip.parentFile)
assertTrue(secondZip.isFile)
} finally {
out.deleteRecursively()
secondOut.deleteRecursively()
session.deleteRecursively()
}
}
+6
View File
@@ -78,6 +78,8 @@ text. This does not redact arbitrary authorized command results, CDP data, prefe
captures. Scenario directories are owner-only and JSON reports are written with mode 0600. Review
artifacts before sharing. `logs` downloads the archive Share logs builds (scrubbed, Steam logs over
8 MiB left out) from the app's external files folder, since adb cannot read the session folder itself.
When the installed app predates archive export, the CLI falls back to pulling the private session folder;
that legacy path is available only with root and is not scrubbed before download.
## Device selection and output
@@ -90,6 +92,10 @@ Every command writes one JSON object to stdout and its resolved serial to stderr
success, 2 invalid or rejected command, 3 ADB or device error, 4 session error, 5 timeout, 6
artifact or file error, 7 debugging tools disabled.
When upgrading the CLI, use a new destination for the first pull if the old destination contains
archives created by an older CLI without a `.droiddeckctl-managed-logs.json` manifest and their
filenames overlap. The upgraded CLI refuses to overwrite those files to preserve unrelated data.
## Commands
```sh
+134 -11
View File
@@ -18,7 +18,7 @@ import time
import uuid
import zipfile
import zlib
from pathlib import Path
from pathlib import Path, PurePosixPath
sys.path.insert(0, str(Path(__file__).resolve().parent))
from adb_device import DeviceResolutionError, resolve_serial
@@ -424,18 +424,106 @@ def start_session(adb: str, serial: str, request: dict) -> dict:
def unpack_logs(archive: Path, destination: Path, session_id: str) -> None:
"""Unpack a Share logs zip so the session's own files sit at the top of destination."""
destination = destination.resolve()
try:
with zipfile.ZipFile(archive) as z:
destination.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise ControlError(f"Could not unpack the session logs: {exc}", "LOG_PULL_FAILED") from exc
manifest = destination / ".droiddeckctl-managed-logs.json"
try:
previous = json.loads(manifest.read_text(encoding="utf-8"))
if not isinstance(previous, list) or any(not isinstance(name, str) for name in previous):
previous = []
except (OSError, ValueError):
previous = []
previous = set(previous)
previous.discard(manifest.name)
extracted = set()
def managed_target(name: str) -> Path | None:
relative = Path(name)
if relative.is_absolute() or not relative.parts or any(part in {".", ".."} for part in relative.parts):
return None
target = destination
for part in relative.parts:
target = target / part
if target.is_symlink():
return None
return target if target.resolve().is_relative_to(destination) else None
try:
with tempfile.TemporaryDirectory(prefix=".droiddeckctl-logs-", dir=destination) as temp, zipfile.ZipFile(archive) as z:
staged = Path(temp)
staged_files = staged / "new"
backups = staged / "old"
staged_files.mkdir()
for entry in z.infolist():
name = entry.filename
if name.startswith(session_id + "/"):
name = name[len(session_id) + 1:]
target = (destination / name).resolve()
if entry.is_dir() or not name or not target.is_relative_to(destination):
target = managed_target(name)
if entry.is_dir() or target is None or target == manifest:
continue
target.parent.mkdir(parents=True, exist_ok=True)
with z.open(entry) as source, target.open("wb") as copy:
relative = target.relative_to(destination).as_posix()
staged_file = staged_files / relative
staged_file.parent.mkdir(parents=True, exist_ok=True)
with z.open(entry) as source, staged_file.open("wb") as copy:
shutil.copyfileobj(source, copy)
extracted.add(relative)
for name in extracted - previous:
target = managed_target(name)
if target is None or target.exists():
raise ControlError(f"Refusing to overwrite an unrelated file: {name}", "LOG_PULL_FAILED")
if manifest.is_symlink() or (manifest.exists() and not manifest.is_file()):
raise ControlError("The managed log list is not a regular file", "LOG_PULL_FAILED")
backed_up = {}
installed = set()
manifest_backup = backups / manifest.name
manifest_fd, manifest_path = tempfile.mkstemp(
prefix=".droiddeckctl-managed-logs-", suffix=".tmp", dir=destination
)
temp_manifest = Path(manifest_path)
try:
with os.fdopen(manifest_fd, "w", encoding="utf-8") as manifest_file:
manifest_file.write(json.dumps(sorted(extracted), indent=2) + "\n")
except Exception:
temp_manifest.unlink(missing_ok=True)
raise
try:
for name in sorted(previous):
target = managed_target(name)
if target is not None and target.is_file():
backup = backups / name
backup.parent.mkdir(parents=True, exist_ok=True)
os.replace(target, backup)
backed_up[name] = backup
if manifest.is_file():
manifest_backup.parent.mkdir(parents=True, exist_ok=True)
os.replace(manifest, manifest_backup)
for name in sorted(extracted):
target = managed_target(name)
if target is None:
raise ControlError(f"A log destination became unsafe: {name}", "LOG_PULL_FAILED")
target.parent.mkdir(parents=True, exist_ok=True)
os.replace(staged_files / name, target)
installed.add(name)
os.replace(temp_manifest, manifest)
except Exception:
temp_manifest.unlink(missing_ok=True)
for name in installed:
target = managed_target(name)
if target is not None and target.is_file():
target.unlink()
if manifest_backup.is_file() and manifest.exists() and not manifest.is_symlink():
manifest.unlink()
for name, backup in backed_up.items():
target = managed_target(name)
if target is not None:
target.parent.mkdir(parents=True, exist_ok=True)
os.replace(backup, target)
if manifest_backup.is_file():
os.replace(manifest_backup, manifest)
raise
except (OSError, zipfile.BadZipFile) as exc:
raise ControlError(f"Could not unpack the session logs: {exc}", "LOG_PULL_FAILED") from exc
@@ -761,11 +849,37 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
raise ControlError("No session artifact directory is available", "NO_SESSION_LOGS")
destination = Path(args.destination or session["id"]).expanduser().resolve()
exported = provider_call(adb, serial, "logs", timeout=args.timeout + 40)
cleanup_error = None
if (exported.get("error") or {}).get("code") == "UNKNOWN_COMMAND":
# Older builds have no logs method; their private folder only pulls on a rooted device.
result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120)
else:
zip_path = checked(exported, "LOG_PULL_FAILED")["zip"]
remote_zip = PurePosixPath(zip_path)
if (not remote_zip.is_absolute() or ".." in remote_zip.parts or
not re.fullmatch(r"/[A-Za-z0-9_./-]+", zip_path)):
raise ControlError("The exported logs path is invalid", "LOG_PULL_FAILED")
requested_dir = exported.get("cleanupDir")
request_dir = PurePosixPath(requested_dir) if isinstance(requested_dir, str) else None
cleanup = ["shell", "rm", "-f", zip_path]
if (request_dir and request_dir.is_absolute() and ".." not in request_dir.parts and
request_dir == remote_zip.parent and request_dir.parent.name == "agent-logs" and
re.fullmatch(r"[0-9a-fA-F-]{36}", request_dir.name)):
cleanup = ["shell", "rm", "-rf", str(request_dir)]
def cleanup_export():
try:
cleaned = run_adb(adb, serial, *cleanup, timeout=30)
if cleaned.returncode == 0:
return None
detail = (cleaned.stderr or cleaned.stdout).decode(errors="replace").strip()
except Exception as error:
detail = str(error)
return ControlError(
"Could not remove the exported log archive; it may remain in shared storage" +
(f": {detail}" if detail else ""),
"LOG_CLEANUP_FAILED",
)
try:
if exported.get("sessionId", session["id"]) != session["id"]:
raise ControlError("A new session started while the logs were exported; run logs again",
@@ -773,14 +887,23 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
with tempfile.TemporaryDirectory() as temp:
local_zip = Path(temp) / "logs.zip"
result = run_adb(adb, serial, "pull", zip_path, str(local_zip), timeout=120)
if result.returncode == 0:
unpack_logs(local_zip, destination, session["id"])
finally:
if result.returncode != 0:
detail = (result.stderr or result.stdout).decode(errors="replace").strip()
raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED")
unpack_logs(local_zip, destination, session["id"])
except Exception:
cleanup_error = cleanup_export()
if cleanup_error:
print(cleanup_error, file=sys.stderr)
raise
else:
# The copy sits in shared storage, so it does not outlive the pull.
run_adb(adb, serial, "shell", "rm", "-f", zip_path, timeout=30)
cleanup_error = cleanup_export()
if result.returncode != 0:
detail = (result.stderr or result.stdout).decode(errors="replace").strip()
raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED")
if cleanup_error:
raise cleanup_error
return add_serial({"ok": True, "command": "logs", "sessionId": session["id"], "path": str(destination)}, serial)
if command == "screenshot":
@@ -1011,7 +1134,7 @@ EXIT_SESSION = {
"NO_STEAM_SESSION", "SESSION_NOT_READY", "AGENT_UNAVAILABLE", "LAUNCH_REJECTED", "NO_PAD", "NO_FRAMES",
}
EXIT_TIMEOUT = {"TIMEOUT", "ARTIFACT_TIMEOUT"}
EXIT_FILE = {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO", "RECORD_FAILED", "PUSH_FAILED",
EXIT_FILE = {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "LOG_CLEANUP_FAILED", "SCREENSHOT_FAILED", "FILE_IO", "RECORD_FAILED", "PUSH_FAILED",
"PACING_FAILED", "XR_CAPTURE_FAILED"}
EXIT_DENIED = {"AGENT_COMMANDS_DISABLED", "PROTECTED_PREFS", "PROTECTED_CONTROL"}
+158 -5
View File
@@ -76,6 +76,9 @@ FAKE_ADB = textwrap.dedent('''\
elif args[:1] == ["pull"]:
entry["denied"] = True
print(f"adb: error: failed to stat remote object '{args[1]}': Permission denied", file=sys.stderr)
elif args[:2] == ["shell", "rm"] and config.get("fail_cleanup"):
entry["denied"] = True
print("adb: error: cleanup denied", file=sys.stderr)
with open(config["log"], "a") as log:
log.write(json.dumps(entry) + "\\n")
sys.exit(1 if entry.get("denied") else 0)
@@ -138,7 +141,9 @@ CPUFREQ = textwrap.dedent('''\
def battery(tenths):
return f"Current Battery Service state:\n level: 80\n temperature: {tenths}\n technology: Li-poly"
PRIVATE_LOG_DIR = "/data/user/0/com.droiddeck.launcher/files/logs/sessions/s1"
EXPORTED_ZIP = "/storage/emulated/0/Android/data/com.droiddeck.launcher/files/agent-logs/DroidDeck-s1.zip"
AGENT_LOGS_DIR = "/storage/emulated/0/Android/data/com.droiddeck.launcher/files/agent-logs"
EXPORTED_DIR = AGENT_LOGS_DIR + "/123e4567-e89b-12d3-a456-426614174000"
EXPORTED_ZIP = EXPORTED_DIR + "/DroidDeck-s1.zip"
def share_zip(entries):
@@ -531,7 +536,8 @@ class DroiddeckctlTest(FakeAdbTest):
code, out = self.run_ctl("wait", "cool", "--below", "38")
self.assertEqual("THERMAL_FAILED", out["error"]["code"])
def test_logs_pull_the_exported_zip_instead_of_the_private_folder(self):
self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP}},
self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP,
"cleanupDir": EXPORTED_DIR}},
files={EXPORTED_ZIP: share_zip({"s1/session.log": "booted", "s1/steam/console.log": "c",
"stores/epic.log": "e", "../outside": "x"})})
destination = self.dir / "artifacts"
@@ -542,16 +548,163 @@ class DroiddeckctlTest(FakeAdbTest):
self.assertEqual("e", (destination / "stores" / "epic.log").read_text())
self.assertFalse((self.dir / "outside").exists())
self.assertEqual([["pull", EXPORTED_ZIP]], [c["args"][:2] for c in self.calls() if c["args"][:1] == ["pull"]])
self.assertIn(["shell", "rm", "-f", EXPORTED_ZIP], [c["args"] for c in self.calls()])
self.assertIn(["shell", "rm", "-rf", EXPORTED_DIR], [c["args"] for c in self.calls()])
def test_logs_of_a_session_that_started_meanwhile_are_refused(self):
self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s2", "zip": EXPORTED_ZIP}},
self.configure({"state": ended_state(), "logs": {"ok": True, "sessionId": "s2", "zip": EXPORTED_ZIP,
"cleanupDir": EXPORTED_DIR}},
files={EXPORTED_ZIP: share_zip({"s2/session.log": "other"})})
code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts"))
self.assertEqual(6, code)
self.assertEqual("LOG_PULL_FAILED", out["error"]["code"])
self.assertFalse((self.dir / "artifacts").exists())
self.assertIn(["shell", "rm", "-f", EXPORTED_ZIP], [c["args"] for c in self.calls()])
self.assertIn(["shell", "rm", "-rf", EXPORTED_DIR], [c["args"] for c in self.calls()])
def test_logs_from_a_legacy_provider_remove_only_the_export_zip(self):
legacy_zip = AGENT_LOGS_DIR + "/DroidDeck-s1.zip"
self.configure({"state": ended_state(),
"logs": {"ok": True, "sessionId": "s1", "zip": legacy_zip}},
files={legacy_zip: share_zip({"s1/session.log": "booted"})})
code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts"))
self.assertEqual(0, code, out)
commands = [c["args"] for c in self.calls()]
self.assertIn(["shell", "rm", "-f", legacy_zip], commands)
self.assertNotIn(["shell", "rm", "-rf", AGENT_LOGS_DIR], commands)
def test_logs_report_when_remote_export_cleanup_fails(self):
self.configure({"state": ended_state(),
"logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP,
"cleanupDir": EXPORTED_DIR}},
fail_cleanup=True, files={EXPORTED_ZIP: share_zip({"s1/session.log": "booted"})})
code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts"))
self.assertEqual(6, code, out)
self.assertEqual("LOG_CLEANUP_FAILED", out["error"]["code"])
self.assertIn("may remain in shared storage", out["error"]["message"])
def test_logs_keep_pull_failure_primary_when_remote_cleanup_fails(self):
self.configure({"state": ended_state(),
"logs": {"ok": True, "sessionId": "s1", "zip": EXPORTED_ZIP,
"cleanupDir": EXPORTED_DIR}}, fail_cleanup=True)
code, out = self.run_ctl("logs", "latest", str(self.dir / "artifacts"))
self.assertEqual(6, code)
self.assertEqual("LOG_PULL_FAILED", out["error"]["code"])
self.assertIn("Permission denied", out["error"]["message"])
self.assertIn(["shell", "rm", "-rf", EXPORTED_DIR], [call["args"] for call in self.calls()])
def test_logs_replace_the_previous_snapshot_and_preserve_unrelated_files(self):
destination = self.dir / "artifacts"
destination.mkdir()
(destination / "notes.txt").write_text("keep me")
first = self.dir / "first.zip"
with zipfile.ZipFile(first, "w") as archive:
archive.writestr("s1/session.log", "first")
archive.writestr("s1/steam/console.log", "old")
ctl.unpack_logs(first, destination, "s1")
self.assertEqual("old", (destination / "steam/console.log").read_text())
second = self.dir / "second.zip"
with zipfile.ZipFile(second, "w") as archive:
archive.writestr("s1/session.log", "second")
ctl.unpack_logs(second, destination, "s1")
self.assertEqual("second", (destination / "session.log").read_text())
self.assertFalse((destination / "steam/console.log").exists())
self.assertEqual("keep me", (destination / "notes.txt").read_text())
def test_logs_map_destination_creation_failure_to_pull_error(self):
destination = (self.dir / "artifacts").resolve()
archive = self.dir / "logs.zip"
real_mkdir = Path.mkdir
def fail_destination(path, *args, **kwargs):
if path == destination:
raise PermissionError("injected destination failure")
return real_mkdir(path, *args, **kwargs)
with unittest.mock.patch.object(Path, "mkdir", new=fail_destination):
with self.assertRaises(ctl.ControlError) as raised:
ctl.unpack_logs(archive, destination, "s1")
self.assertEqual("LOG_PULL_FAILED", raised.exception.code)
self.assertIn("injected destination failure", str(raised.exception))
def test_logs_do_not_follow_a_stale_managed_symlink(self):
if os.name == "nt":
self.skipTest("symlink creation is unavailable on this Windows host")
destination = self.dir / "artifacts"
destination.mkdir()
victim = destination / "notes.txt"
victim.write_text("keep me")
(destination / ".droiddeckctl-managed-logs.json").write_text(json.dumps(["old.log"]))
(destination / "old.log").symlink_to(victim)
archive = self.dir / "logs.zip"
with zipfile.ZipFile(archive, "w") as z:
z.writestr("s1/session.log", "current")
ctl.unpack_logs(archive, destination, "s1")
self.assertEqual("keep me", victim.read_text())
self.assertTrue((destination / "old.log").is_symlink())
self.assertEqual("current", (destination / "session.log").read_text())
def test_logs_roll_back_a_failed_manifest_commit_and_can_retry(self):
destination = (self.dir / "artifacts").resolve()
destination.mkdir()
(destination / "notes.txt").write_text("keep me")
first = self.dir / "first.zip"
with zipfile.ZipFile(first, "w") as z:
z.writestr("s1/session.log", "first")
z.writestr("s1/steam/console.log", "old")
ctl.unpack_logs(first, destination, "s1")
old_manifest = (destination / ".droiddeckctl-managed-logs.json").read_bytes()
second = self.dir / "second.zip"
with zipfile.ZipFile(second, "w") as z:
z.writestr("s1/session.log", "second")
z.writestr("s1/new.log", "new")
real_replace = ctl.os.replace
failed = False
def fail_manifest_once(source, target):
nonlocal failed
if Path(target) == destination / ".droiddeckctl-managed-logs.json" and not failed:
failed = True
raise OSError("injected manifest failure")
return real_replace(source, target)
with unittest.mock.patch.object(ctl.os, "replace", side_effect=fail_manifest_once):
with self.assertRaises(ctl.ControlError):
ctl.unpack_logs(second, destination, "s1")
self.assertEqual("first", (destination / "session.log").read_text())
self.assertEqual("old", (destination / "steam/console.log").read_text())
self.assertFalse((destination / "new.log").exists())
self.assertEqual(old_manifest, (destination / ".droiddeckctl-managed-logs.json").read_bytes())
ctl.unpack_logs(second, destination, "s1")
self.assertEqual("second", (destination / "session.log").read_text())
self.assertEqual("new", (destination / "new.log").read_text())
self.assertFalse((destination / "steam/console.log").exists())
self.assertEqual("keep me", (destination / "notes.txt").read_text())
def test_logs_preserve_the_old_manifest_when_its_backup_fails(self):
destination = (self.dir / "artifacts").resolve()
destination.mkdir()
archive = self.dir / "logs.zip"
with zipfile.ZipFile(archive, "w") as z:
z.writestr("s1/session.log", "current")
manifest = destination / ".droiddeckctl-managed-logs.json"
manifest.write_text(json.dumps(["old.log"]))
original = manifest.read_bytes()
real_replace = ctl.os.replace
def fail_manifest_backup(source, target):
if Path(source) == manifest:
raise OSError("injected manifest backup failure")
return real_replace(source, target)
with unittest.mock.patch.object(ctl.os, "replace", side_effect=fail_manifest_backup):
with self.assertRaises(ctl.ControlError):
ctl.unpack_logs(archive, destination, "s1")
self.assertEqual(original, manifest.read_bytes())
def test_logs_from_an_older_build_fall_back_to_the_private_folder(self):
self.configure({"state": ended_state()})