import { execFile } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { createReadStream } from "node:fs"; import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { dbg, diagnosticKind } from "../debug.js"; import { findExecutable } from "../pi-ext-executable.ts"; import { SDK_VERSION } from "../protocol.js"; const execFileAsync = promisify(execFile); const PACKAGE = "@anthropic-ai/claude-agent-sdk-linux-arm64"; const EXPECTED_SHA256 = "7bbed5a9b0fc2e4ec67bad3490d06ca91b86d6b037d47520b7898951757d1b8a"; function stateRoot(): string { const root = join( process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "pi", "klaus", "runtime", ); dbg?.("runtime.android.stateRoot"); return root; } async function executableOnPath(name: string): Promise { dbg?.("runtime.android.executableOnPath.start"); const executable = findExecutable([name]); dbg?.( executable ? "runtime.android.executableOnPath.found" : "runtime.android.executableOnPath.missing", ); return executable; } async function sha256(path: string): Promise { const hash = createHash("sha256"); for await (const chunk of createReadStream(path)) hash.update(chunk); return hash.digest("hex"); } function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } async function validLauncher( path: string, generation: string, grun: string, ): Promise { dbg?.("runtime.android.validLauncher.start"); try { const text = await readFile(path, "utf8"); const claude = join(generation, "package", "claude"); if ( !text.includes(`klaus-runtime-${SDK_VERSION}-v2`) || !text.includes(shellQuote(grun)) || (await sha256(claude)) !== EXPECTED_SHA256 ) { return false; } await execFileAsync(path, ["--version"], { timeout: 30_000, maxBuffer: 64 * 1024, }); dbg?.("runtime.android.validLauncher.valid"); return true; } catch (error) { dbg?.("runtime.android.validLauncher.invalid", { kind: diagnosticKind(error), }); return false; } } let resolution: Promise | undefined; export function resolveAndroidRuntime(): Promise { dbg?.("runtime.android.resolve", { cached: Boolean(resolution) }); resolution ??= prepareAndroidRuntime().catch((error) => { dbg?.("runtime.android.resolve.error", { kind: diagnosticKind(error) }); resolution = undefined; throw error; }); return resolution; } async function prepareAndroidRuntime(): Promise { dbg?.("runtime.android.prepare.start"); if (process.arch !== "arm64") { throw new Error(`Klaus does not support Android ${process.arch}.`); } const grun = await executableOnPath("grun"); if (!grun) { throw new Error( "Klaus requires glibc-runner on Android: pkg install glibc-repo && pkg install glibc-runner", ); } const npm = await executableOnPath("npm"); if (!npm) throw new Error("Klaus Android setup requires npm on PATH."); const tar = await executableOnPath("tar"); if (!tar) throw new Error("Klaus Android setup requires tar on PATH."); const root = stateRoot(); const generation = join(root, SDK_VERSION); const launcher = join(generation, "claude-klaus"); if (await validLauncher(launcher, generation, grun)) { dbg?.("runtime.android.prepare.reuse"); return launcher; } await mkdir(root, { recursive: true, mode: 0o700 }); const staging = await mkdtemp(join(root, `.install-${SDK_VERSION}-`)); dbg?.("runtime.android.prepare.install"); try { const npmrc = join(staging, ".npmrc"); await writeFile(npmrc, "", { mode: 0o600 }); const npmEnv = Object.fromEntries( Object.entries(process.env).filter( ([key]) => !key.toLowerCase().startsWith("npm_") && key !== "INIT_CWD", ), ); await execFileAsync( npm, [ "pack", "--userconfig", npmrc, "--ignore-scripts", "--pack-destination", staging, `${PACKAGE}@${SDK_VERSION}`, ], { cwd: staging, env: npmEnv, timeout: 10 * 60_000, maxBuffer: 1024 * 1024, }, ); const archive = (await readdir(staging)).find((name) => name.endsWith(".tgz"), ); if (!archive) throw new Error("Klaus Android runtime archive was not created."); await execFileAsync(tar, ["-xzf", join(staging, archive), "-C", staging], { timeout: 10 * 60_000, maxBuffer: 64 * 1024, }); const claude = join(staging, "package", "claude"); if ((await sha256(claude)) !== EXPECTED_SHA256) { throw new Error("Klaus Android runtime checksum mismatch."); } const stagedLauncher = join(staging, "claude-klaus"); const probeLauncher = join(staging, "claude-probe"); await writeFile( probeLauncher, `#!/data/data/com.termux/files/usr/bin/sh\nexec ${shellQuote(grun)} ${shellQuote(claude)} "$@"\n`, { mode: 0o700 }, ); await chmod(probeLauncher, 0o700); await execFileAsync(probeLauncher, ["--version"], { timeout: 30_000, maxBuffer: 64 * 1024, }); await writeFile( stagedLauncher, `#!/data/data/com.termux/files/usr/bin/sh\n# klaus-runtime-${SDK_VERSION}-v2\nexec ${shellQuote(grun)} ${shellQuote(join(generation, "package", "claude"))} "$@"\n`, { mode: 0o700 }, ); await chmod(stagedLauncher, 0o700); const backup = `${generation}.old-${randomUUID()}`; let hasBackup = false; try { await rename(generation, backup); hasBackup = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } try { await rename(staging, generation); } catch (error) { if (hasBackup) await rename(backup, generation); throw error; } if (hasBackup) await rm(backup, { recursive: true, force: true }); dbg?.("runtime.android.prepare.installed"); return launcher; } catch (error) { dbg?.("runtime.android.prepare.error", { kind: diagnosticKind(error) }); await rm(staging, { recursive: true, force: true }); throw error; } }