Menu
popagent
publicLatest change e0970dab600e00afab3d85491d7db6318b584557 - Prevent false autonomous containment failures by AkurAI Build
import { createHash } from "node:crypto";
import { execFile, spawn } from "node:child_process";
import { access, mkdir, rm, symlink } from "node:fs/promises";
import { isAbsolute, join, posix, relative, resolve } from "node:path";
import { agentSettings, type AgentSettingsStore } from "./agent-settings";
import { agentWorkspaces, type AgentWorkspaceStore } from "./agent-workspaces";
import type { AgentSettings, AgentTask, AgentTaskSource } from "./api-types";
import { autonomySettings, type AutonomySettingsStore } from "./autonomy-settings";
import { agentWorkspaceRuntime } from "./workspace";
import { appLogger } from "./observability";
const SELF_UPDATE_BRANCH_PREFIX = "autonomous/self-update/";
const BUILD_MAINTENANCE_BRANCH_PREFIX = "autonomous/build-maintenance/";
const ZERO_OBJECT_ID = "0".repeat(40);
export const SELF_UPDATE_REVIEW_DIFF_MAX_BYTES = 64_000;
const SELF_UPDATE_COMMAND_OUTPUT_MAX_BYTES = 16_000;
const PROTECTED_PATHS: Record<string, true> = {
".agents": true,
".akurai.yml": true,
".circleci": true,
".env": true,
".github": true,
".gitlab-ci.yml": true,
".gitattributes": true,
".gitmodules": true,
".gitignore": true,
".env.example": true,
"bun.lock": true,
"bunfig.toml": true,
"deploy.sh": true,
"docker-compose.yml": true,
"package.json": true,
"src/agent-context.ts": true,
"src/agent-execution.ts": true,
"src/agent-role-seed.sql": true,
"src/agent-runtime-settings.sql": true,
"src/agent-runtime-settings.ts": true,
"src/agent-settings.ts": true,
"src/agent.ts": true,
"src/api-types.ts": true,
"src/agent-skills.ts": true,
"src/autonomy-settings.ts": true,
"src/auth.ts": true,
"src/browser-policy.ts": true,
"src/browser-recordings.ts": true,
"src/browser-sessions.ts": true,
"src/browser-settings.sql": true,
"src/browser-settings.ts": true,
"src/browser-profiles.ts": true,
"src/evolution-runtime.ts": true,
"src/evolution-store.ts": true,
"src/evolution.sql": true,
"src/hook-lifecycle.ts": true,
"src/hooks.ts": true,
"src/models.ts": true,
"src/secret-bindings.sql": true,
"src/secrets.ts": true,
"src/self-update-policy.ts": true,
"src/self-update-scheduler.ts": true,
"src/self-update-workflow.ts": true,
"src/server.ts": true,
"src/sessions.ts": true,
"src/tasks.ts": true,
"src/tools/akurai-build.ts": true,
"src/tools/bifrost.ts": true,
"src/tools/secrets.ts": true,
"src/tools/memory.ts": true,
"src/workspace.ts": true,
"tsconfig.json": true,
};
const PROTECTED_PREFIXES = [
".agents/",
".circleci/",
".git/",
".github/",
".gitlab/",
"systemd/",
"src/agent-",
"src/browser-",
"src/evolution-",
"src/settings-",
"src/tools/",
"src/server/",
"src/deploy/",
];
const CREDENTIAL_BASENAMES: Record<string, true> = {
".netrc": true,
".npmrc": true,
".pypirc": true,
"auth.json": true,
credentials: true,
"credentials.json": true,
id_dsa: true,
id_ed25519: true,
id_rsa: true,
"service-account.json": true,
};
export type GitSnapshot = {
head: string;
branch: string;
remotes: string;
};
export type SelfUpdateRun = {
taskId: string;
workspaceId: string;
repositoryPath: string;
path: string;
branch: string;
snapshot: GitSnapshot;
cloneRemotes: string;
delegations: Set<string>;
reviewedDiffDigest?: string;
deploymentEnabled: boolean;
source?: Extract<AgentTaskSource, "self-update" | "build-maintenance">;
};
type RoleRepository = Pick<AgentSettingsStore, "get">;
type WorkspaceRepository = Pick<AgentWorkspaceStore, "resolveRepository">;
type AutonomyRepository = Pick<AutonomySettingsStore, "get">;
export class SelfUpdatePolicyError extends Error {
override readonly name = "SelfUpdatePolicyError";
}
function protectedPath(path: string): boolean {
const slashPath = path.replaceAll("\\", "/");
if (isAbsolute(path) || slashPath.startsWith("/")) return true;
const normalized = posix.normalize(slashPath).replace(/^\.\//, "");
if (normalized === ".." || normalized.startsWith("../")) return true;
const lower = normalized.toLowerCase();
const base = posix.basename(lower);
return lower === ".git"
|| lower.startsWith(".env.")
|| (base.startsWith("agents") && base.endsWith(".md"))
|| Boolean(PROTECTED_PATHS[lower])
|| PROTECTED_PREFIXES.some((prefix) => lower.startsWith(prefix))
|| /(^|\/)(?:\.aws|\.ssh|credentials?|secrets?|tokens?)(?:[./_-]|$)/.test(lower)
|| /\.(?:kdbx|key|p12|pfx|pem)$/.test(lower)
|| Boolean(CREDENTIAL_BASENAMES[base]);
}
export function assertSelfUpdatePathAllowed(path: string): void {
if (protectedPath(path)) {
throw new SelfUpdatePolicyError("Self-update containment denied a protected path");
}
}
export function normalizeSelfUpdateToolInput(input: unknown, workspacePath: string): void {
if (!input || typeof input !== "object" || Array.isArray(input)) return;
const values = input as Record<string, unknown>;
for (const key of ["path", "cwd"] as const) {
const value = values[key];
if (typeof value !== "string" || !isAbsolute(value)) continue;
const contained = relative(workspacePath, value);
if (contained === ".." || contained.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(contained)) {
throw new SelfUpdatePolicyError("Self-update containment denied a protected path");
}
values[key] = contained || ".";
}
}
function assertGitCommand(tokens: string[]): void {
const operation = tokens[1];
const args = tokens.slice(2);
if (operation === "status") {
if (args.every((arg) => ["--short", "--porcelain", "--porcelain=v1", "--branch"].includes(arg))) return;
} else if (operation === "diff") {
if (args.every((arg) => ["--stat", "--name-only", "--cached", "--check", "--no-ext-diff"].includes(arg))) return;
} else if (operation === "log") {
if (args.every((arg) => arg === "--oneline" || arg === "--decorate" || /^-n\d+$/.test(arg) || /^--max-count=\d+$/.test(arg))) return;
} else if (operation === "branch") {
if (args.every((arg) => arg === "--show-current" || arg === "--list")) return;
} else if (operation === "rev-parse") {
if (args.length === 1 && ["HEAD", "--show-toplevel"].includes(args[0]!)) return;
if (args.length === 2 && args[0] === "--abbrev-ref" && args[1] === "HEAD") return;
} else if (operation === "add") {
const paths = args.filter((arg) => arg !== "-A" && arg !== "--all" && arg !== "--");
if (paths.some((path) => path.startsWith("-"))) {
throw new SelfUpdatePolicyError("Self-update containment denied a Git add option");
}
if (paths.length || args.includes("-A") || args.includes("--all")) {
for (const path of paths) assertSelfUpdatePathAllowed(path);
return;
}
} else if (operation === "commit") {
const forbidden = args.some((arg) => ["--amend", "-a", "--all", "--reuse-message", "-C", "-c"].includes(arg));
const messageIndex = args.indexOf("-m");
if (!forbidden
&& args.includes("--no-verify")
&& args.includes("--no-gpg-sign")
&& messageIndex >= 0
&& Boolean(args[messageIndex + 1])) return;
}
throw new SelfUpdatePolicyError("Self-update containment denied a Git operation");
}
function assertVerificationCommand(tokens: string[]): void {
const [executable, operation, target] = tokens;
if (executable === "bun" && operation === "test") return;
if (executable === "bunx" && ["biome", "eslint", "prettier", "svelte-check", "tsc", "vite"].includes(operation ?? "")) return;
if (["npm", "pnpm", "yarn"].includes(executable ?? "")) {
const script = operation === "run" ? target : operation;
if (["build", "check", "lint", "test", "typecheck"].includes(script ?? "")) return;
}
if (executable === "cargo" && ["check", "clippy", "fmt", "test"].includes(operation ?? "")) return;
if (executable === "go" && ["build", "test", "vet"].includes(operation ?? "")) return;
if (executable === "python" && operation === "-m" && ["mypy", "pytest", "ruff"].includes(target ?? "")) return;
if (["mypy", "pytest", "ruff"].includes(executable ?? "")) return;
if (executable === "deno" && ["check", "fmt", "lint", "test"].includes(operation ?? "")) return;
if (executable === "dotnet" && ["build", "test"].includes(operation ?? "")) return;
if (["gradle", "mvn"].includes(executable ?? "") && ["build", "check", "test", "verify"].includes(operation ?? "")) return;
if (executable === "make" && ["build", "check", "lint", "test"].includes(operation ?? "")) return;
if (executable === "cmake" && operation === "--build") return;
if (executable === "ninja") return;
throw new SelfUpdatePolicyError("Self-update containment denied a command");
}
function assertSelfUpdateCommand(command: string): void {
if (!command.trim() || /[\n\r;&|<>`$\\"']/.test(command)) {
throw new SelfUpdatePolicyError("Self-update containment requires one literal command");
}
const lower = command.toLowerCase();
if (/(^|\s)(?:\.env(?:\.|\s|$)|\.git(?:\/|\s|$)|credential|secret|deploy|publish|push|remote|systemctl|docker|ssh|curl|wget|gh)(?:\s|\/|$)/.test(lower)) {
throw new SelfUpdatePolicyError("Self-update containment denied a protected operation");
}
const tokens = command.trim().split(/\s+/);
if (tokens[0] === "git") assertGitCommand(tokens);
else assertVerificationCommand(tokens);
}
function allowedInstructionRead(toolName: string, path: string): boolean {
if (toolName !== "mastra_workspace_read_file") return false;
const normalized = posix.normalize(path.replaceAll("\\", "/")).replace(/^\.\//, "");
return /^agents(?:_[a-z0-9_-]+)?\.md$/i.test(posix.basename(normalized))
|| /^\.agents\/skills\/[^/]+\/skill\.md$/i.test(normalized);
}
function allowedProtectedRead(toolName: string, path: string): boolean {
if (toolName !== "mastra_workspace_read_file") return false;
const normalized = posix.normalize(path.replaceAll("\\", "/")).replace(/^\.\//, "").toLowerCase();
return normalized === "package.json" || normalized.startsWith("src/");
}
export function assertSelfUpdateToolCall(toolName: string, input: unknown): void {
const lowerName = toolName.toLowerCase();
if (toolName === "retainMemory"
|| toolName === "editMemory"
|| toolName === "useBrowserSecret"
|| toolName === "bifrostNavigator"
|| /browser|playwright|navigate|screenshot/.test(lowerName)) {
throw new SelfUpdatePolicyError("Self-update containment denied a browser or credential tool");
}
if (!input || typeof input !== "object" || Array.isArray(input)) return;
const values = input as Record<string, unknown>;
if (typeof values.path === "string"
&& !allowedInstructionRead(toolName, values.path)
&& !allowedProtectedRead(toolName, values.path)) {
assertSelfUpdatePathAllowed(values.path);
}
if (typeof values.cwd === "string" && values.cwd !== ".") assertSelfUpdatePathAllowed(values.cwd);
if (toolName === "mastra_workspace_list_files" && values.showHidden === true) {
throw new SelfUpdatePolicyError("Self-update containment denied hidden-file listing");
}
if (toolName === "mastra_workspace_grep" && values.includeHidden === true) {
throw new SelfUpdatePolicyError("Self-update containment denied hidden-file search");
}
if (toolName === "mastra_workspace_execute_command") {
if (typeof values.command !== "string") {
throw new SelfUpdatePolicyError("Self-update command is missing");
}
assertSelfUpdateCommand(values.command);
}
}
type ProcessOptions = {
cwd?: string;
signal?: AbortSignal;
timeout?: number;
maxBytes?: number;
allowedExitCodes?: number[];
env?: NodeJS.ProcessEnv;
};
type ProcessResult = {
stdout: Buffer;
stderr: Buffer;
code: number | null;
};
function runProcess(command: string, args: string[], options: ProcessOptions = {}): Promise<ProcessResult> {
options.signal?.throwIfAborted();
const maxBytes = options.maxBytes ?? SELF_UPDATE_REVIEW_DIFF_MAX_BYTES;
const { promise, resolve: resolveOutput, reject } = Promise.withResolvers<ProcessResult>();
const child = spawn(command, args, {
cwd: options.cwd,
env: {
PATH: process.env.PATH,
HOME: "/nonexistent",
GIT_CONFIG_NOSYSTEM: "1",
GIT_TERMINAL_PROMPT: "0",
GIT_OPTIONAL_LOCKS: "0",
...options.env,
},
stdio: ["ignore", "pipe", "pipe"],
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let exceeded = false;
let aborted = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const append = (target: Buffer[], chunk: Buffer, current: number) => {
const next = current + chunk.byteLength;
if (next > maxBytes) {
exceeded = true;
child.kill("SIGKILL");
return current;
}
target.push(chunk);
return next;
};
const abort = () => {
aborted = true;
child.kill("SIGTERM");
};
options.signal?.addEventListener("abort", abort, { once: true });
if (options.timeout !== undefined) {
timer = setTimeout(() => child.kill("SIGKILL"), options.timeout);
}
child.stdout.on("data", (chunk: Buffer | string) => {
stdoutBytes = append(stdout, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), stdoutBytes);
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderrBytes = append(stderr, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), stderrBytes);
});
child.once("error", reject);
child.once("close", (code) => {
clearTimeout(timer);
options.signal?.removeEventListener("abort", abort);
if (exceeded) {
reject(new SelfUpdatePolicyError("Self-update command output exceeded its byte bound"));
} else if (aborted || options.signal?.aborted) {
reject(options.signal?.reason ?? new DOMException("The operation was aborted", "AbortError"));
} else if (!options.allowedExitCodes?.includes(code ?? -1) && code !== 0) {
reject(new SelfUpdatePolicyError("Self-update contained command failed"));
} else {
resolveOutput({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), code });
}
});
return promise;
}
function processOutput(command: string, args: string[], cwd?: string): Promise<Buffer> {
return runProcess(command, args, { cwd }).then(({ stdout }) => stdout);
}
const SAFE_GIT_CONFIG = [
"-c", "core.hooksPath=/dev/null",
"-c", "credential.helper=",
"-c", "core.pager=cat",
"-c", "diff.external=",
];
function git(
path: string,
args: string[],
options: Omit<ProcessOptions, "cwd"> = {},
): Promise<Buffer> {
return runProcess("git", [...SAFE_GIT_CONFIG, "-C", path, ...args], {
...options,
maxBytes: options.maxBytes ?? SELF_UPDATE_REVIEW_DIFF_MAX_BYTES,
}).then(({ stdout }) => stdout);
}
async function gitSucceeds(path: string, args: string[]): Promise<boolean> {
try {
await git(path, args);
return true;
} catch {
return false;
}
}
function digest(value: Buffer): string {
return createHash("sha256").update(value).digest("hex");
}
export type SelfUpdateInspection = {
changedPaths: string[];
diffCheck: string;
diffEvidence: string;
diffDigest: string;
clean: boolean;
};
async function reviewDiff(path: string): Promise<Buffer> {
const indexPath = join(path, ".git", `popagent-review-${crypto.randomUUID()}.index`);
const env = { GIT_INDEX_FILE: indexPath };
try {
await git(path, ["read-tree", "HEAD"], { env });
await git(path, ["add", "--all"], { env });
return await git(path, [
"diff",
"--cached",
"--binary",
"--no-ext-diff",
"--no-renames",
], { env, maxBytes: SELF_UPDATE_REVIEW_DIFF_MAX_BYTES });
} finally {
await rm(indexPath, { force: true }).catch(() => undefined);
}
}
async function snapshot(path: string): Promise<GitSnapshot> {
const [head, branch, status, remotes] = await Promise.all([
git(path, ["rev-parse", "HEAD"]),
git(path, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
git(path, ["status", "--porcelain=v1", "--untracked-files=all"]),
git(path, ["remote", "-v"]),
]);
if (status.length) throw new SelfUpdatePolicyError("Self-update requires a clean registered Git workspace");
return {
head: head.toString("utf8").trim(),
branch: branch.toString("utf8").trim(),
remotes: digest(remotes),
};
}
function assertEligibleRoles(
supervisor: AgentSettings | undefined,
implementer: AgentSettings | undefined,
reviewer: AgentSettings | undefined,
): void {
if (supervisor?.workspaceAccess !== "read-write"
|| implementer?.workspaceAccess !== "read-write"
|| !implementer.delegationEnabled
|| reviewer?.workspaceAccess === "none"
|| !reviewer?.delegationEnabled) {
throw new SelfUpdatePolicyError("Self-update agent roles are not eligible");
}
}
function assertBuildMaintenanceRoles(
maintainer: AgentSettings | undefined,
reviewer: AgentSettings | undefined,
): void {
if (maintainer?.workspaceAccess !== "read-write"
|| !maintainer.delegationEnabled
|| reviewer?.workspaceAccess === "none"
|| !reviewer?.delegationEnabled) {
throw new SelfUpdatePolicyError("Build maintenance agent roles are not eligible");
}
}
export class SelfUpdateRuntimePolicy {
private readonly activeTasks = new Set<string>();
constructor(
private readonly workspaces: WorkspaceRepository = agentWorkspaces,
private readonly roles: RoleRepository = agentSettings,
private readonly settings: AutonomyRepository = autonomySettings,
) {}
async begin(task: AgentTask): Promise<SelfUpdateRun> {
if (task.source !== "self-update" && task.source !== "build-maintenance") {
throw new SelfUpdatePolicyError("Autonomous task source is invalid");
}
const source = task.source;
if (this.activeTasks.has(task.id)) throw new SelfUpdatePolicyError("Autonomous task is already executing");
this.activeTasks.add(task.id);
const root = resolve(
process.env.POPAGENT_DATA_DIR ?? join(process.cwd(), "data"),
source === "build-maintenance" ? "build-maintenance" : "self-update",
);
const path = join(root, createHash("sha256").update(task.id).digest("hex"));
try {
const [currentSettings, supervisor, implementer, buildMaintainer, reviewer, repository] = await Promise.all([
this.settings.get(),
this.roles.get("orchistrator"),
this.roles.get("implementer"),
this.roles.get("build-maintainer"),
this.roles.get("reviewer"),
this.workspaces.resolveRepository(task.workspaceId),
]);
if (!currentSettings.enabled
|| (source === "self-update" && !currentSettings.selfUpdateEnabled)
|| (source === "build-maintenance" && !currentSettings.idleImprovementEnabled)) {
throw new SelfUpdatePolicyError(
source === "build-maintenance" ? "Build maintenance is disabled" : "Self-update is disabled",
);
}
if (source === "build-maintenance"
&& repository.workspace.name.trim().toLowerCase() !== "akurai-build"
&& !/(?:^|[\\/])akurai-build$/.test(repository.workspace.repositoryPath.trim().toLowerCase())) {
throw new SelfUpdatePolicyError("Build maintenance requires the AkurAI-Build workspace");
}
if (source === "build-maintenance") assertBuildMaintenanceRoles(buildMaintainer, reviewer);
else assertEligibleRoles(supervisor, implementer, reviewer);
const captured = await snapshot(repository.path);
const prefix = source === "build-maintenance"
? BUILD_MAINTENANCE_BRANCH_PREFIX
: SELF_UPDATE_BRANCH_PREFIX;
const branch = `${prefix}${task.id.replace(/[^a-zA-Z0-9._-]/g, "-")}-${crypto.randomUUID().slice(0, 8)}`;
await mkdir(root, { recursive: true, mode: 0o700 });
await rm(path, { recursive: true, force: true });
await processOutput("git", [
...SAFE_GIT_CONFIG,
"clone",
"--no-hardlinks",
"--no-checkout",
"--",
repository.path,
path,
]);
await git(path, ["checkout", "--no-track", "-b", branch, captured.head]);
await git(path, ["config", "user.name", source === "build-maintenance" ? "Popagent Build Maintenance" : "Popagent Self Update"]);
await git(path, ["config", "user.email", source === "build-maintenance" ? "build-maintenance@localhost" : "self-update@localhost"]);
await git(path, ["config", "commit.gpgsign", "false"]);
const dependencyPath = join(repository.path, "node_modules");
if (await gitSucceeds(path, ["check-ignore", "--quiet", "node_modules"])) {
try {
await access(dependencyPath);
await symlink(dependencyPath, join(path, "node_modules"), "dir");
} catch {
// Verification remains available for dependency-free repositories.
}
}
await git(path, ["config", "core.hooksPath", "/dev/null"]);
return {
taskId: task.id,
workspaceId: task.workspaceId,
repositoryPath: repository.path,
path,
branch,
snapshot: captured,
cloneRemotes: digest(await git(path, ["remote", "-v"])),
delegations: new Set<string>(),
deploymentEnabled: source === "self-update" && currentSettings.idleDeploymentEnabled,
source,
};
} catch (error) {
await rm(path, { recursive: true, force: true }).catch(() => undefined);
this.activeTasks.delete(task.id);
throw error;
}
}
observeDelegation(run: SelfUpdateRun, agentId: string, success: boolean, diffDigest?: string): void {
const required = run.source === "build-maintenance" ? "build-maintainer" : "implementer";
if (success && agentId === required) {
run.delegations.add(agentId);
}
if (success && agentId === "reviewer" && diffDigest === run.reviewedDiffDigest) {
run.delegations.add(agentId);
}
}
async inspect(run: SelfUpdateRun): Promise<SelfUpdateInspection> {
const [status, diffCheck, diff] = await Promise.all([
git(run.path, ["status", "--porcelain=v1", "--untracked-files=all"]),
git(run.path, ["diff", "--check"]),
reviewDiff(run.path),
]);
const changedPaths = status
.toString("utf8")
.split("\n")
.map((line) => line.slice(3).trim())
.filter(Boolean)
.flatMap((line) => line.includes(" -> ") ? line.split(" -> ") : [line]);
for (const changedPath of changedPaths) assertSelfUpdatePathAllowed(changedPath);
if (diff.byteLength > SELF_UPDATE_REVIEW_DIFF_MAX_BYTES) {
throw new SelfUpdatePolicyError("Self-update diff exceeds the review byte bound");
}
return {
changedPaths,
diffCheck: diffCheck.toString("utf8").trim().slice(0, 4_000),
diffEvidence: diff.toString("utf8"),
diffDigest: digest(diff),
clean: changedPaths.length === 0,
};
}
async verify(run: SelfUpdateRun, command: string, signal?: AbortSignal): Promise<string> {
assertSelfUpdateCommand(command);
signal?.throwIfAborted();
const tokens = command.trim().split(/\s+/);
if (tokens[0] === "git") {
return (await git(run.path, tokens.slice(1), {
signal,
maxBytes: SELF_UPDATE_COMMAND_OUTPUT_MAX_BYTES,
})).toString("utf8").trim().slice(0, 4_000);
}
const workspace = await agentWorkspaceRuntime.resolveAutonomous(
run.workspaceId,
run.path,
false,
run.source ?? "self-update",
);
const result = await workspace.sandbox?.executeCommand?.(tokens[0]!, tokens.slice(1), {
timeout: 30_000,
abortSignal: signal,
maxRetainedBytes: SELF_UPDATE_COMMAND_OUTPUT_MAX_BYTES,
env: {
HOME: "/nonexistent",
NODE_ENV: "test",
},
});
if (!result) throw new SelfUpdatePolicyError("Self-update verification sandbox is unavailable");
if (!result.success) throw new SelfUpdatePolicyError("Self-update verification command failed");
const stdout = Buffer.from(result.stdout, "utf8").subarray(0, SELF_UPDATE_COMMAND_OUTPUT_MAX_BYTES).toString("utf8");
const stderr = Buffer.from(result.stderr, "utf8").subarray(0, SELF_UPDATE_COMMAND_OUTPUT_MAX_BYTES).toString("utf8");
return `${stdout}${stderr ? `\nstderr:\n${stderr}` : ""}`.trim().slice(0, 4_000);
}
async commit(run: SelfUpdateRun, expectedDiffDigest: string): Promise<string> {
const inspection = await this.inspect(run);
if (inspection.clean) throw new SelfUpdatePolicyError("Self-update commit requires a source change");
if (inspection.diffDigest !== expectedDiffDigest) {
throw new SelfUpdatePolicyError("Self-update diff changed after review");
}
await git(run.path, ["add", "--all"]);
const beforeCommit = await this.inspect(run);
if (beforeCommit.diffDigest !== expectedDiffDigest) {
throw new SelfUpdatePolicyError("Self-update diff changed before commit");
}
await git(run.path, [
"commit",
"--no-verify",
"--no-gpg-sign",
"-m",
"Autonomous self-update",
]);
return (await git(run.path, ["rev-parse", "HEAD"])).toString("utf8").trim();
}
async accept(run: SelfUpdateRun, signal?: AbortSignal): Promise<void> {
let importedHead: string | undefined;
try {
const source = run.source ?? "self-update";
const label = source === "build-maintenance" ? "Build maintenance" : "Self-update";
const [currentSettings, supervisor, implementer, buildMaintainer, reviewer, repository] = await Promise.all([
this.settings.get(),
this.roles.get("orchistrator"),
this.roles.get("implementer"),
this.roles.get("build-maintainer"),
this.roles.get("reviewer"),
this.workspaces.resolveRepository(run.workspaceId),
]);
if (!currentSettings.enabled
|| (source === "self-update" && !currentSettings.selfUpdateEnabled)
|| (source === "build-maintenance" && !currentSettings.idleImprovementEnabled)) {
throw new SelfUpdatePolicyError(
source === "build-maintenance" ? "Build maintenance was disabled before completion" : "Self-update was disabled before completion",
);
}
if (source === "build-maintenance") assertBuildMaintenanceRoles(buildMaintainer, reviewer);
else assertEligibleRoles(supervisor, implementer, reviewer);
if (resolve(repository.path) !== resolve(run.repositoryPath)) {
throw new SelfUpdatePolicyError(
source === "build-maintenance"
? "Build maintenance workspace registration changed during execution"
: "Self-update workspace registration changed during execution",
);
}
const [current, cloneStatus, cloneBranch, cloneHead, cloneRemotes] = await Promise.all([
snapshot(run.repositoryPath),
git(run.path, ["status", "--porcelain=v1", "--untracked-files=all"]),
git(run.path, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
git(run.path, ["rev-parse", "HEAD"]),
git(run.path, ["remote", "-v"]),
]);
if (current.head !== run.snapshot.head
|| current.branch !== run.snapshot.branch
|| current.remotes !== run.snapshot.remotes) {
throw new SelfUpdatePolicyError(
source === "build-maintenance"
? "Registered workspace changed during build maintenance"
: "Registered workspace changed during self-update",
);
}
if (cloneStatus.length
|| cloneBranch.toString("utf8").trim() !== run.branch
|| digest(cloneRemotes) !== run.cloneRemotes) {
throw new SelfUpdatePolicyError(
source === "build-maintenance"
? "Build maintenance checkout failed completion containment"
: "Self-update checkout failed completion containment",
);
}
const finalHead = cloneHead.toString("utf8").trim();
if (finalHead === run.snapshot.head) return;
const requiredDelegation = source === "build-maintenance" ? "build-maintainer" : "implementer";
if (!run.reviewedDiffDigest
|| !run.delegations.has(requiredDelegation)
|| !run.delegations.has("reviewer")) {
throw new SelfUpdatePolicyError(
source === "build-maintenance"
? "Build maintenance requires successful Build Maintainer and reviewer delegation"
: "Self-update requires successful implementer and reviewer delegation",
);
}
if (await gitSucceeds(run.repositoryPath, ["cat-file", "-e", `${finalHead}^{commit}`])) {
throw new SelfUpdatePolicyError(`${label} result is not a new local commit`);
}
if (!(await gitSucceeds(run.path, ["merge-base", "--is-ancestor", run.snapshot.head, finalHead]))) {
throw new SelfUpdatePolicyError(`${label} commit does not descend from the captured HEAD`);
}
const changed = (await git(run.path, [
"diff",
"--no-renames",
"--name-only",
"-z",
`${run.snapshot.head}..${finalHead}`,
]))
.toString("utf8")
.split("\0")
.filter(Boolean);
if (!changed.length) throw new SelfUpdatePolicyError(`${label} commit has no source change`);
for (const path of changed) assertSelfUpdatePathAllowed(path);
const committedDiff = await git(run.path, [
"diff",
"--binary",
"--no-ext-diff",
"--no-renames",
`${run.snapshot.head}..${finalHead}`,
], { maxBytes: SELF_UPDATE_REVIEW_DIFF_MAX_BYTES });
if (digest(committedDiff) !== run.reviewedDiffDigest) {
throw new SelfUpdatePolicyError(`${label} diff changed after review`);
}
if (await gitSucceeds(run.repositoryPath, ["show-ref", "--verify", "--quiet", `refs/heads/${run.branch}`])) {
throw new SelfUpdatePolicyError(`${label} branch already exists`);
}
await git(run.repositoryPath, ["fetch", "--no-tags", "--no-write-fetch-head", run.path, finalHead]);
signal?.throwIfAborted();
const importedDiff = await git(run.path, [
"diff",
"--binary",
"--no-ext-diff",
"--no-renames",
`${run.snapshot.head}..${finalHead}`,
], { maxBytes: SELF_UPDATE_REVIEW_DIFF_MAX_BYTES });
if (digest(importedDiff) !== run.reviewedDiffDigest) {
throw new SelfUpdatePolicyError(`${label} diff changed before import`);
}
await git(run.repositoryPath, ["update-ref", `refs/heads/${run.branch}`, finalHead, ZERO_OBJECT_ID]);
importedHead = finalHead;
signal?.throwIfAborted();
if (run.deploymentEnabled) {
try {
await processOutput(join(run.repositoryPath, "deploy.sh"), ["publish"], run.repositoryPath);
} catch (error) {
await git(run.repositoryPath, ["reset", "--hard", run.snapshot.head]);
throw error;
}
execFile(join(run.repositoryPath, "deploy.sh"), ["deploy"], {
cwd: run.repositoryPath,
windowsHide: true,
}, (error) => {
if (error) appLogger().error("self_update.deploy_failed", { taskId: run.taskId, error: error.message });
}).unref();
}
} catch (error) {
if (importedHead) {
await git(run.repositoryPath, ["update-ref", "-d", `refs/heads/${run.branch}`, importedHead]).catch(() => undefined);
}
throw error;
} finally {
await this.release(run);
}
}
async reject(run: SelfUpdateRun): Promise<void> {
await this.release(run);
}
private async release(run: SelfUpdateRun): Promise<void> {
await rm(run.path, { recursive: true, force: true });
this.activeTasks.delete(run.taskId);
}
}
export const selfUpdateRuntimePolicy = new SelfUpdateRuntimePolicy();