Menu
popagent
publicLatest change a3bc087e8ff6852dfa36041725dc10c651450149 - Test unreviewed committed self-update by Ólafur Búi Ólafsson
import { afterEach, describe, expect, test } from "bun:test";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { AgentSettings, AgentTask, AgentWorkspace, AutonomySettings } from "./api-types";
import { createToolHooks } from "./hook-lifecycle";
import { HookRuntime } from "./hooks";
import {
assertSelfUpdatePathAllowed,
assertSelfUpdateToolCall,
SelfUpdateRuntimePolicy,
} from "./self-update-policy";
const execute = promisify(execFile);
const cleanupPaths: string[] = [];
afterEach(async () => {
await Promise.all(cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
function agentRole(
id: string,
workspaceAccess: AgentSettings["workspaceAccess"],
delegationEnabled: boolean,
): AgentSettings {
return {
id,
name: id,
description: `${id} role`,
instructions: `${id} instructions`,
model: null,
workspaceAccess,
browserAccess: "none",
delegationEnabled,
tools: [],
sourceUrls: [],
createdAt: "",
updatedAt: "",
};
}
function settings(): AutonomySettings {
return {
enabled: true,
reflectionIntervalMs: 60_000,
batchSize: 10,
maxAttempts: 3,
autoApplyStrategies: true,
autoCreateSkills: true,
selfUpdateEnabled: true,
selfUpdateCron: "0 3 * * *",
idleImprovementEnabled: false,
idleDeploymentEnabled: false,
idleWorkspaceIds: [],
updatedAt: "",
};
}
function task(workspaceId: string): AgentTask {
return {
id: crypto.randomUUID(),
sessionId: null,
scheduleId: crypto.randomUUID(),
source: "self-update",
workspaceId,
prompt: "contained update",
model: "test/model",
status: "running",
output: null,
error: null,
stepsCompleted: 0,
progress: null,
recoveryCount: 0,
attemptCount: 1,
maxAttempts: 3,
nextAttemptAt: null,
lastErrorClass: null,
deadLetteredAt: null,
createdAt: new Date().toISOString(),
startedAt: new Date().toISOString(),
completedAt: null,
};
}
async function repositoryFixture() {
const path = await mkdtemp(join(tmpdir(), "popagent-self-update-policy-"));
cleanupPaths.push(path);
await execute("git", ["init", "-b", "main", path]);
await execute("git", ["-C", path, "config", "user.name", "Policy Test"]);
await execute("git", ["-C", path, "config", "user.email", "policy@example.invalid"]);
await mkdir(join(path, "src"));
await writeFile(join(path, "src", "example.ts"), "export const value = 1;\n");
await execute("git", ["-C", path, "add", "."]);
await execute("git", ["-C", path, "commit", "-m", "initial"]);
const workspace: AgentWorkspace = {
id: crypto.randomUUID(),
name: "Policy test",
repositoryPath: path,
createdAt: "",
updatedAt: "",
};
const roles = {
orchistrator: agentRole("orchistrator", "read-write", false),
implementer: agentRole("implementer", "read-write", true),
reviewer: agentRole("reviewer", "read-only", true),
} as const;
const policy = new SelfUpdateRuntimePolicy(
{ async resolveRepository() { return { workspace, path }; } },
{ async get(id) { return roles[id as keyof typeof roles]; } },
{ async get() { return settings(); } },
);
return { path, workspace, policy };
}
describe("fixed self-update tool policy", () => {
test("denies browser, credential, protected path, remote, and deployment operations", () => {
expect(() => assertSelfUpdateToolCall("retainMemory", {})).toThrow("browser or credential");
expect(() => assertSelfUpdateToolCall("editMemory", {})).toThrow("browser or credential");
expect(() => assertSelfUpdateToolCall("useBrowserSecret", {})).toThrow("browser or credential");
expect(() => assertSelfUpdateToolCall("bifrostNavigator", {})).toThrow("browser or credential");
expect(() => assertSelfUpdateToolCall("browserNavigate", { url: "https://example.com" })).toThrow("browser or credential");
expect(() => assertSelfUpdateToolCall("mastra_workspace_read_file", { path: ".env" })).toThrow("protected path");
expect(() => assertSelfUpdateToolCall("mastra_workspace_read_file", { path: ".git/config" })).toThrow("protected path");
expect(() => assertSelfUpdateToolCall("mastra_workspace_write_file", { path: "src/self-update-policy.ts" })).toThrow("protected path");
expect(() => assertSelfUpdateToolCall("mastra_workspace_execute_command", { command: "git push origin main" })).toThrow("protected operation");
expect(() => assertSelfUpdateToolCall("mastra_workspace_execute_command", { command: "bun run deploy" })).toThrow("protected operation");
});
test("allows source edits and bounded local verification or commit commands", () => {
expect(() => assertSelfUpdatePathAllowed("src/ui/example.ts")).not.toThrow();
expect(() => assertSelfUpdateToolCall("mastra_workspace_execute_command", { command: "bun test src/example.test.ts" })).not.toThrow();
expect(() => assertSelfUpdateToolCall("mastra_workspace_execute_command", {
command: "git commit --no-verify --no-gpg-sign -m self-update",
})).not.toThrow();
});
test("rechecks fixed policy after a managed hook replacement", async () => {
const runtime = new HookRuntime({
config: {
version: 1,
hooks: {
PreToolUse: [{ id: "replace", type: "http", url: "https://hooks.example/replace" }],
},
},
transport: async () => ({
schemaVersion: 1,
outcome: "modify",
reason: "prompt-controlled replacement",
replacement: { path: ".git/config" },
}),
});
const hooks = createToolHooks(
runtime,
{ sessionId: "task:self-update", turnId: "turn", model: "test/model" },
assertSelfUpdateToolCall,
);
await expect(hooks.beforeToolCall!({
toolName: "mastra_workspace_read_file",
input: { path: "src/example.ts" },
context: {},
} as never)).rejects.toThrow("protected path");
});
});
describe("SelfUpdateRuntimePolicy", () => {
test("imports only a reviewed clean local commit while preserving the registered checkout", async () => {
const { path, workspace, policy } = await repositoryFixture();
const originalHead = (await execute("git", ["-C", path, "rev-parse", "HEAD"])).stdout.trim();
const run = await policy.begin(task(workspace.id));
await writeFile(join(run.path, "src", "example.ts"), "export const value = 2;\n");
await execute("git", ["-C", run.path, "add", "src/example.ts"]);
await execute("git", ["-C", run.path, "commit", "--no-verify", "--no-gpg-sign", "-m", "self-update"]);
policy.observeDelegation(run, "implementer", true);
policy.observeDelegation(run, "reviewer", true);
await policy.accept(run);
expect((await execute("git", ["-C", path, "rev-parse", "HEAD"])).stdout.trim()).toBe(originalHead);
expect((await execute("git", ["-C", path, "status", "--porcelain"])).stdout).toBe("");
const branchHead = (await execute("git", ["-C", path, "rev-parse", run.branch])).stdout.trim();
expect(branchHead).not.toBe(originalHead);
expect((await execute("git", ["-C", path, "diff", "--name-only", `${originalHead}..${branchHead}`])).stdout.trim())
.toBe("src/example.ts");
expect(await readFile(join(path, "src", "example.ts"), "utf8")).toBe("export const value = 1;\n");
});
test("rejects unreviewed or dirty results without changing the registered checkout", async () => {
const { path, workspace, policy } = await repositoryFixture();
const originalHead = (await execute("git", ["-C", path, "rev-parse", "HEAD"])).stdout.trim();
const run = await policy.begin(task(workspace.id));
await writeFile(join(run.path, "src", "example.ts"), "export const value = 3;\n");
await execute("git", ["-C", run.path, "add", "src/example.ts"]);
await execute("git", ["-C", run.path, "commit", "--no-verify", "--no-gpg-sign", "-m", "unreviewed"]);
policy.observeDelegation(run, "implementer", true);
await expect(policy.accept(run)).rejects.toThrow("implementer and reviewer");
expect((await execute("git", ["-C", path, "rev-parse", "HEAD"])).stdout.trim()).toBe(originalHead);
expect((await execute("git", ["-C", path, "status", "--porcelain"])).stdout).toBe("");
await expect(execute("git", ["-C", path, "show-ref", "--verify", `refs/heads/${run.branch}`])).rejects.toThrow();
});
test("rejects a clean reviewed commit that changes a protected path", async () => {
const { path, workspace, policy } = await repositoryFixture();
const originalHead = (await execute("git", ["-C", path, "rev-parse", "HEAD"])).stdout.trim();
const run = await policy.begin(task(workspace.id));
await writeFile(join(run.path, "src", "agent.ts"), "export const weakened = true;\n");
await execute("git", ["-C", run.path, "add", "src/agent.ts"]);
await execute("git", ["-C", run.path, "commit", "--no-verify", "--no-gpg-sign", "-m", "weaken-policy"]);
policy.observeDelegation(run, "implementer", true);
policy.observeDelegation(run, "reviewer", true);
await expect(policy.accept(run)).rejects.toThrow("protected path");
expect((await execute("git", ["-C", path, "rev-parse", "HEAD"])).stdout.trim()).toBe(originalHead);
await expect(execute("git", ["-C", path, "show-ref", "--verify", `refs/heads/${run.branch}`])).rejects.toThrow();
});
test("refuses a dirty registered checkout without touching user work", async () => {
const { path, workspace, policy } = await repositoryFixture();
const userPath = join(path, "user-work.txt");
await writeFile(userPath, "preserve me\n");
await expect(policy.begin(task(workspace.id))).rejects.toThrow("clean registered Git workspace");
expect(await readFile(userPath, "utf8")).toBe("preserve me\n");
});
test("does not start a second executor while the old self-update run is unsettled", async () => {
const { workspace, policy } = await repositoryFixture();
const sourceTask = task(workspace.id);
const run = await policy.begin(sourceTask);
await expect(policy.begin(sourceTask)).rejects.toThrow("already executing");
await policy.reject(run);
});
test("accepts a clean no-op without specialist delegation or a branch", async () => {
const { path, workspace, policy } = await repositoryFixture();
const run = await policy.begin(task(workspace.id));
await policy.accept(run);
await expect(execute("git", ["-C", path, "show-ref", "--verify", `refs/heads/${run.branch}`])).rejects.toThrow();
expect((await execute("git", ["-C", path, "status", "--porcelain"])).stdout).toBe("");
});
test("does not retain a local branch when cancellation wins completion", async () => {
const { path, workspace, policy } = await repositoryFixture();
const run = await policy.begin(task(workspace.id));
await writeFile(join(run.path, "src", "example.ts"), "export const value = 4;\n");
await execute("git", ["-C", run.path, "add", "src/example.ts"]);
await execute("git", ["-C", run.path, "commit", "--no-verify", "--no-gpg-sign", "-m", "cancelled"]);
policy.observeDelegation(run, "implementer", true);
policy.observeDelegation(run, "reviewer", true);
const controller = new AbortController();
controller.abort();
await expect(policy.accept(run, controller.signal)).rejects.toThrow();
await expect(execute("git", ["-C", path, "show-ref", "--verify", `refs/heads/${run.branch}`])).rejects.toThrow();
});
});