Centralize encrypted bot profiles (#14)
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>
This commit was merged in pull request #14.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user