Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/klaus/src/runtime/android.ts

Raw
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<string | undefined> {
	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<string> {
	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<boolean> {
	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<string> | undefined;

export function resolveAndroidRuntime(): Promise<string> {
	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<string> {
	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;
	}
}