repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/klaus/src/runtime/android.ts
Rawimport { 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;
}
}