Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions src/permission/queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, test, expect } from "bun:test";
import { EventEmitter } from "node:events";
import {
createPermissionRequestQueue,
wirePermissionGrantReconciliation,
} from "./queue.js";
import { isRequestCoveredByGrant } from "./gate.js";
import { createPathRestriction } from "./path-restriction.js";
import { createWorktreeRootsProvider } from "./worktree-roots.js";
import type { Approval, ApprovalOutcome, PermissionRequest } from "./types.js";

function request(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
return {
tool: "run_shell",
action: "Run",
subject: "bun install",
scopes: [],
cwd: process.cwd(),
...overrides,
};
}

// Mirrors the predicate PermissionGateOptions.onGrant hands callers: coverage
// judged with the gate's own path restriction and project workspace, not
// ones re-derived here.
function coversFor(approval: Approval, activeProviderModel?: string): (r: PermissionRequest) => boolean {
const cwd = process.cwd();
const rootsProvider = createWorktreeRootsProvider(cwd);
const isRestricted = createPathRestriction(cwd, rootsProvider).isRestricted;
const workspace = { resolvedCwd: cwd, roots: rootsProvider() };
return (r) =>
isRequestCoveredByGrant(r, approval, activeProviderModel, isRestricted, workspace);
}

describe("createPermissionRequestQueue", () => {
test("settle resolves the enqueued request and removes it", () => {
const queue = createPermissionRequestQueue();
const outcomes: ApprovalOutcome[] = [];
const id = queue.enqueue(request(), (o) => outcomes.push(o));
expect(queue.size()).toBe(1);

expect(queue.settle(id, { allow: true })).toBe(true);
expect(outcomes).toEqual([{ allow: true }]);
expect(queue.size()).toBe(0);
});

test("settle is a no-op once an id has already settled", () => {
const queue = createPermissionRequestQueue();
const outcomes: ApprovalOutcome[] = [];
const id = queue.enqueue(request(), (o) => outcomes.push(o));

expect(queue.settle(id, { allow: true })).toBe(true);
expect(queue.settle(id, { allow: false })).toBe(false);
expect(outcomes).toEqual([{ allow: true }]);
});

test("reconcile drains every queued request a grant now covers, in order", () => {
const queue = createPermissionRequestQueue();
const outcomes: ApprovalOutcome[] = [];
for (let i = 0; i < 3; i++) {
queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o));
}
// An unrelated request stays queued — the grant does not cover it.
queue.enqueue(request({ subject: "bun test" }), (o) => outcomes.push(o));

const covers = coversFor({ tool: "run_shell", pattern: "bun install" });
const settledIds = queue.reconcile(covers);

expect(settledIds).toHaveLength(3);
expect(outcomes).toEqual([{ allow: true }, { allow: true }, { allow: true }]);
expect(queue.size()).toBe(1);
expect(queue.list().map((e) => e.tool)).toEqual(["run_shell"]);
});

test("reconcile leaves requests from a different cwd queued for a project grant", () => {
const queue = createPermissionRequestQueue();
const outcomes: ApprovalOutcome[] = [];
const cwd = process.cwd();
queue.enqueue(
request({ subject: "bun install", cwd: `${cwd}/other-repo` }),
(o) => outcomes.push(o),
);

const covers = coversFor({ tool: "run_shell", pattern: "bun install", cwd });
queue.reconcile(covers);

expect(outcomes).toHaveLength(0);
expect(queue.size()).toBe(1);
});

test("reconcile is safe against settling mid-snapshot: no entry is skipped or double-visited", () => {
const queue = createPermissionRequestQueue();
let calls = 0;
for (let i = 0; i < 5; i++) {
queue.enqueue(request({ subject: "bun install" }), () => {
calls++;
});
}
queue.reconcile(coversFor({ tool: "run_shell", pattern: "bun install" }));
expect(calls).toBe(5);
expect(queue.size()).toBe(0);
});

test("drain denies everything still queued instead of leaving a resolve hanging", () => {
const queue = createPermissionRequestQueue();
const outcomes: ApprovalOutcome[] = [];
queue.enqueue(request(), (o) => outcomes.push(o));
queue.enqueue(request({ subject: "bun test" }), (o) => outcomes.push(o));

queue.drain();

expect(outcomes).toEqual([{ allow: false }, { allow: false }]);
expect(queue.size()).toBe(0);
});
});

describe("wirePermissionGrantReconciliation", () => {
test("reconciles a queue against permission.grant events on the emitter", async () => {
const emitter = new EventEmitter();
const queue = createPermissionRequestQueue();
const dispose = wirePermissionGrantReconciliation(emitter, queue);

const outcomes: ApprovalOutcome[] = [];
queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o));
queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o));

const approval: Approval = { tool: "run_shell", pattern: "bun install" };
emitter.emit("permission.grant", { approval, covers: coversFor(approval) });

expect(outcomes).toEqual([{ allow: true }, { allow: true }]);
expect(queue.size()).toBe(0);

dispose();
});

test("ignores a malformed grant payload instead of throwing", () => {
const emitter = new EventEmitter();
const queue = createPermissionRequestQueue();
const dispose = wirePermissionGrantReconciliation(emitter, queue);

queue.enqueue(request(), () => {
throw new Error("must not settle on a malformed payload");
});

expect(() => emitter.emit("permission.grant", { nope: true })).not.toThrow();
expect(queue.size()).toBe(1);

dispose();
});

test("dispose stops further reconciliation", () => {
const emitter = new EventEmitter();
const queue = createPermissionRequestQueue();
const dispose = wirePermissionGrantReconciliation(emitter, queue);
dispose();

const outcomes: ApprovalOutcome[] = [];
queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o));
const approval: Approval = { tool: "run_shell", pattern: "bun install" };
emitter.emit("permission.grant", { approval, covers: coversFor(approval) });

expect(outcomes).toHaveLength(0);
});
});
112 changes: 112 additions & 0 deletions src/permission/queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Headless queued-approval reconciliation. When a grant widens mid-run, the
* permission layer decides which already-queued requests it now covers and
* settles them without a prompt (see isRequestCoveredByGrant in gate.ts,
* which supplies the `covers` predicate this module drains against). A
* rendering surface only enqueues its pending requests and dispatches
* whatever settle calls come back — it never decides coverage itself.
*/

import type { EventEmitter } from "node:events";
import type { ApprovalOutcome, PermissionRequest } from "./types.js";

export type QueuedApprovalSummary = {
readonly id: number;
readonly tool: string;
readonly agentLabel?: string;
};

export type PermissionRequestQueue = {
/** Register a live request; the returned id is what settle/reconcile key on. */
enqueue: (request: PermissionRequest, resolve: (outcome: ApprovalOutcome) => void) => number;
/** Settle one entry (accept, deny, timeout, or abort). False once already settled. */
settle: (id: number, outcome: ApprovalOutcome) => boolean;
/** One line per still-queued request, for a queue-depth indicator. */
list: () => readonly QueuedApprovalSummary[];
/**
* Auto-settle every queued request a newly-minted grant now covers, without
* a prompt. Runs against a snapshot so settling mid-loop never skips or
* double-visits an entry. Returns the ids settled.
*/
reconcile: (covers: (request: PermissionRequest) => boolean) => readonly number[];
/** Deny and remove everything still queued (session teardown) so no awaited resolve is left hanging. */
drain: () => void;
size: () => number;
};

export function createPermissionRequestQueue(): PermissionRequestQueue {
const entries = new Map<
number,
{ request: PermissionRequest; resolve: (outcome: ApprovalOutcome) => void }
>();
let nextId = 1;

const settle = (id: number, outcome: ApprovalOutcome): boolean => {
const entry = entries.get(id);
if (entry === undefined) return false;
entries.delete(id);
entry.resolve(outcome);
return true;
};

return {
enqueue: (request, resolve) => {
const id = nextId++;
entries.set(id, { request, resolve });
return id;
},
settle,
list: () =>
[...entries.entries()].map(([id, entry]) => ({
id,
tool: entry.request.tool,
...(entry.request.agentLabel !== undefined ? { agentLabel: entry.request.agentLabel } : {}),
})),
reconcile: (covers) => {
const coveredIds = [...entries.entries()]
.filter(([, entry]) => covers(entry.request))
.map(([id]) => id);
return coveredIds.filter((id) => settle(id, { allow: true }));
},
drain: () => {
for (const id of [...entries.keys()]) settle(id, { allow: false });
},
size: () => entries.size,
};
}

export type PermissionGrantEvent = {
readonly approval: { readonly tool: string; readonly pattern: string };
readonly covers: (request: PermissionRequest) => boolean;
};

// `covers` is a function, which arktype cannot express in a schema, so this
// stays a plain runtime guard rather than the usual declarative boundary
// validator — the shape is still checked field by field.
function isPermissionGrantEvent(raw: unknown): raw is PermissionGrantEvent {
if (raw === null || typeof raw !== "object") return false;
const approval = (raw as Record<string, unknown>).approval;
const covers = (raw as Record<string, unknown>).covers;
if (approval === null || typeof approval !== "object") return false;
const a = approval as Record<string, unknown>;
return typeof a.tool === "string" && typeof a.pattern === "string" && typeof covers === "function";
}

/**
* Drain `queue` of any request a grant now covers whenever `permission.grant`
* fires (see PermissionGateOptions.onGrant for where that event originates).
* Any approval surface — TUI or headless — gets reconciliation for free by
* enqueuing its pending requests into a PermissionRequestQueue and calling
* this once, instead of reimplementing the walk.
*/
export function wirePermissionGrantReconciliation(
emitter: EventEmitter,
queue: PermissionRequestQueue,
): () => void {
const onGrant = (payload: unknown): void => {
if (!isPermissionGrantEvent(payload)) return;
queue.reconcile(payload.covers);
};
emitter.on("permission.grant", onGrant);
return () => emitter.off("permission.grant", onGrant);
}
Loading
Loading