e56ea246bf
Centralize ordered bot profiles and encrypted credentials in `bot-agents`. - Exposes one action input: `master-key`. - Uses Gitea’s automatic job token internally; dependent repositories provide no author/reviewer/profile inputs. - Keeps encrypted Claude and reviewer credentials in `profiles.yml`; tokenless Codex uses its persisted subscription login. - Falls back only on recognized quota exhaustion, with a distinct UID/home per profile and descendant cleanup before fallback. - Separates decryption, agent execution, and reviewer posting so privileged credentials never enter an agent process. - Installs `obra/superpowers` with the standard Skills CLI; removes custom discovery code. - Adds root-level isolation, rollback, symlink, fallback, posting, YAML, and self-workflow CI coverage. Checks: formatting, lint, all entrypoint type checks, 35 tests as root, diff check, secret scan, and repository diagnostics. Reviewed-on: #14 Co-authored-by: Danny Kim <temeddix@gmail.com> Co-committed-by: Danny Kim <temeddix@gmail.com>
69 lines
1.9 KiB
TypeScript
69 lines
1.9 KiB
TypeScript
import type { RuntimeProfile } from "./runtime.ts";
|
|
|
|
export function agentCommand(
|
|
profile: RuntimeProfile,
|
|
command: string,
|
|
args: string[],
|
|
options: Omit<Deno.CommandOptions, "args" | "uid" | "gid"> = {},
|
|
): Deno.Command {
|
|
return new Deno.Command(command, {
|
|
...options,
|
|
args,
|
|
uid: profile.uid,
|
|
gid: profile.gid,
|
|
});
|
|
}
|
|
|
|
export function isRunnableProfileProcess(
|
|
status: string,
|
|
uid: number,
|
|
): boolean {
|
|
const owner = status.match(/^Uid:\s+(\d+)/m)?.[1];
|
|
const state = status.match(/^State:\s+(\w)/m)?.[1];
|
|
return owner !== undefined && Number(owner) === uid && state !== "Z";
|
|
}
|
|
|
|
async function profileProcessIds(uid: number): Promise<number[]> {
|
|
const ids: number[] = [];
|
|
for await (const entry of Deno.readDir("/proc")) {
|
|
if (!entry.isDirectory || !/^\d+$/.test(entry.name)) continue;
|
|
const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch(
|
|
() => null,
|
|
);
|
|
if (status !== null && isRunnableProfileProcess(status, uid)) {
|
|
ids.push(Number(entry.name));
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
export async function killProfileProcesses(uid: number): Promise<void> {
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
const ids = await profileProcessIds(uid);
|
|
if (ids.length === 0) return;
|
|
for (const pid of ids) {
|
|
try {
|
|
Deno.kill(pid, "SIGKILL");
|
|
} catch (error) {
|
|
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
|
|
}
|
|
|
|
export async function readProfileFile(
|
|
profile: RuntimeProfile,
|
|
path: string,
|
|
): Promise<string> {
|
|
const stat = await Deno.lstat(path);
|
|
if (
|
|
!stat.isFile || stat.isSymlink || stat.uid !== profile.uid ||
|
|
(stat.mode! & 0o077) !== 0
|
|
) {
|
|
throw new Error(`${path}: expected a profile-owned regular private file`);
|
|
}
|
|
return await Deno.readTextFile(path);
|
|
}
|