feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,961 @@
import {
chmodSync,
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test, vi } from "vitest";
import { ParentAuthorizer } from "#src/authority/approval-escalator";
import {
type ForwardedPermissionRequest,
PERMISSION_FORWARDING_SERVING_GRACE_MS,
} from "#src/authority/permission-forwarding";
import { ServingSessionRegistry } from "#src/authority/serving-registry";
import {
createForwardingTempDir,
makeForwarderContext,
makeLivenessJudge,
makeParentAuthorizerDeps,
makeSubagentRegistry,
publishServingHeartbeat,
} from "#test/helpers/forwarding-fixtures";
import {
makePromptDetails,
makePromptPayload,
} from "#test/helpers/prompt-details-fixtures";
// ── Local poll helper ────────────────────────────────────────────────────
//
// The reverse direction of `ForwardingTempDir.writeRequest`: waits for the
// request file ParentAuthorizer.authorize writes, so the test can respond
// as the parent session would. Real timers/filesystem, matching how
// composition-root.test.ts's forwarding round trip already behaves.
async function waitForRequestFile(
requestsDir: string,
): Promise<ForwardedPermissionRequest> {
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
let files: string[] = [];
try {
files = readdirSync(requestsDir).filter((f) => f.endsWith(".json"));
} catch {
files = [];
}
const requestFile = files[0];
if (requestFile) {
return JSON.parse(
readFileSync(join(requestsDir, requestFile), "utf-8"),
) as ForwardedPermissionRequest;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for a request file in ${requestsDir}`);
}
// ── ParentAuthorizer ──────────────────────────────────────────────────────
/**
* Drive one forwarded exchange to completion: escalate, wait for the request
* file, answer it with `response`, and resolve.
*/
async function exchangeWith(
temp: ReturnType<typeof createForwardingTempDir>,
response: Record<string, unknown>,
) {
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
}),
);
const decisionPromise = authorizer.authorize(
makePromptDetails({ requestId: "perm-child-request" }),
);
const request = await waitForRequestFile(temp.location.requestsDir);
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify(response),
"utf-8",
);
return decisionPromise;
}
describe("ParentAuthorizer provenance relay", () => {
test("nests the responder's own decider under the forwarding hop", async () => {
const temp = createForwardingTempDir("parent-session");
try {
// The reported case: a human at the parent, or the parent's policy?
// The child's own terminal entry has to answer that.
await expect(
exchangeWith(temp, {
approved: true,
state: "approved",
responderSessionId: "parent-session",
decidedBy: { kind: "user", via: "dialog" },
}),
).resolves.toMatchObject({
decidedBy: {
kind: "forwarded",
responderSessionId: "parent-session",
decision: { kind: "user", via: "dialog" },
},
});
} finally {
temp.cleanup();
}
});
test("still names the responding session when an older parent sends no decider", async () => {
const temp = createForwardingTempDir("parent-session");
try {
await expect(
exchangeWith(temp, {
approved: true,
state: "approved",
responderSessionId: "parent-session",
}),
).resolves.toMatchObject({
decidedBy: {
kind: "forwarded",
responderSessionId: "parent-session",
decision: null,
},
});
} finally {
temp.cleanup();
}
});
test("discards a malformed decider rather than relaying a corrupt one", async () => {
const temp = createForwardingTempDir("parent-session");
try {
await expect(
exchangeWith(temp, {
approved: true,
state: "approved",
responderSessionId: "parent-session",
decidedBy: { kind: "user", via: "smoke-signal" },
}),
).resolves.toMatchObject({
decidedBy: {
kind: "forwarded",
responderSessionId: "parent-session",
decision: null,
},
});
} finally {
temp.cleanup();
}
});
});
describe("ParentAuthorizer", () => {
test("writes a forwarded request carrying the display fields and resolves with the parent's response", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const registry = makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
});
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry,
}),
);
const decisionPromise = authorizer.authorize(
makePromptDetails({
requestId: "perm-child-request",
agentName: "Explore",
toolName: "bash",
command: "git push",
}),
);
const request = await waitForRequestFile(temp.location.requestsDir);
expect(request.targetSessionId).toBe("parent-session");
expect(request.requesterSessionId).toBe("child-session");
expect(request.source).toBe("tool_call");
expect(request.surface).toBe("bash");
expect(request.value).toBe("git push");
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: "parent-session",
}),
"utf-8",
);
// toMatchObject: the response also carries a live respondedAt timestamp
// and the responderSessionId/denialReason passthrough fields.
await expect(decisionPromise).resolves.toMatchObject({
approved: true,
state: "approved",
});
} finally {
temp.cleanup();
}
});
test("persists the details' sessionApproval suggestion onto the forwarded request", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const registry = makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
});
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry,
}),
);
const decisionPromise = authorizer.authorize(
makePromptDetails({
requestId: "perm-child-request",
agentName: "Explore",
toolName: "bash",
command: "git push",
sessionApproval: { surface: "bash", patterns: ["git *"] },
}),
);
const request = await waitForRequestFile(temp.location.requestsDir);
expect(request.sessionApproval).toEqual({
surface: "bash",
patterns: ["git *"],
});
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: "parent-session",
}),
"utf-8",
);
await decisionPromise;
} finally {
temp.cleanup();
}
});
test("stamps the child-fixed access intent with requester identity onto the forwarded request", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const registry = makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
});
const authorizer = new ParentAuthorizer(
makeForwarderContext({
hasUI: false,
sessionId: "child-session",
cwd: "/worktree/issue-42",
}),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry,
}),
);
const decisionPromise = authorizer.authorize(
makePromptDetails({
requestId: "perm-child-request",
agentName: "Explore",
toolName: "read",
path: "src/foo.ts",
accessIntent: {
surface: "path",
matchValues: ["/worktree/issue-42/src/foo.ts", "src/foo.ts"],
boundaryValue: "/worktree/issue-42/src/foo.ts",
},
}),
);
const request = await waitForRequestFile(temp.location.requestsDir);
// The display fields still ride the same request alongside the structured
// intent (the #292/#557 non-degraded-broadcast contract must not regress).
expect(request.source).toBe("tool_call");
expect(request.surface).toBe("read");
expect(request.value).toBe("src/foo.ts");
// requesterCwd comes from ctx.cwd; principal mirrors the request's own
// computed identity fields (sessionId, requesterAgentName).
expect(request.accessIntent).toEqual({
surface: "path",
matchValues: ["/worktree/issue-42/src/foo.ts", "src/foo.ts"],
boundaryValue: "/worktree/issue-42/src/foo.ts",
requesterCwd: "/worktree/issue-42",
principal: {
sessionId: request.requesterSessionId,
agentName: request.requesterAgentName,
},
});
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: "parent-session",
}),
"utf-8",
);
await decisionPromise;
} finally {
temp.cleanup();
}
});
test("relays the details' prompt payload onto the forwarded request", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const registry = makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
});
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry,
}),
);
const payload = makePromptPayload({
kind: "bash",
request: {
requester: {
agentName: "Explore",
forwarded: false,
sessionId: null,
},
surface: "bash",
toolName: "bash",
invokedToolName: null,
value: "git push",
matchedPattern: "git *",
commandContext: null,
executedUnit: null,
},
evidence: [{ label: "command", text: "git push", detail: null }],
});
const decisionPromise = authorizer.authorize(
makePromptDetails({
requestId: "perm-child-request",
agentName: "Explore",
toolName: "bash",
command: "git push",
payload,
}),
);
const request = await waitForRequestFile(temp.location.requestsDir);
expect(request.payload).toEqual(payload);
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: "parent-session",
}),
"utf-8",
);
await decisionPromise;
} finally {
temp.cleanup();
}
});
test("omits accessIntent from the request when the details carry none", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const registry = makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
});
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry,
}),
);
const decisionPromise = authorizer.authorize(
makePromptDetails({
requestId: "perm-child-request",
agentName: "Explore",
toolName: "read",
}),
);
const request = await waitForRequestFile(temp.location.requestsDir);
expect(request.accessIntent).toBeUndefined();
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: "parent-session",
}),
"utf-8",
);
await decisionPromise;
} finally {
temp.cleanup();
}
});
test("omits sessionApproval from the request when the details carry none", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const registry = makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
});
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry,
}),
);
const decisionPromise = authorizer.authorize(
makePromptDetails({
requestId: "perm-child-request",
agentName: "Explore",
toolName: "read",
}),
);
const request = await waitForRequestFile(temp.location.requestsDir);
expect(request.sessionApproval).toBeUndefined();
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: "parent-session",
}),
"utf-8",
);
await decisionPromise;
} finally {
temp.cleanup();
}
});
test("returns denied when the response marks the request denied", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const registry = makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
});
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry,
}),
);
const decisionPromise = authorizer.authorize(
makePromptDetails({
requestId: "perm-child-request",
agentName: "Explore",
toolName: "read",
}),
);
const request = await waitForRequestFile(temp.location.requestsDir);
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: false,
state: "denied",
responderSessionId: "parent-session",
}),
"utf-8",
);
// toMatchObject: see the approved-path test for why this isn't toEqual.
await expect(decisionPromise).resolves.toMatchObject({
approved: false,
state: "denied",
});
} finally {
temp.cleanup();
}
});
test("adopts the requester's request id as the forwarded request id", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
}),
);
const decisionPromise = authorizer.authorize(
makePromptDetails({ requestId: "perm-child-request" }),
);
const request = await waitForRequestFile(temp.location.requestsDir);
expect(request.id).toBe("perm-child-request");
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({ approved: true, state: "approved" }),
"utf-8",
);
await decisionPromise;
} finally {
temp.cleanup();
}
});
test("mints a fresh id when the requester's is not filename-safe", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
}),
);
const decisionPromise = authorizer.authorize(
makePromptDetails({ requestId: "../../escape" }),
);
const request = await waitForRequestFile(temp.location.requestsDir);
expect(request.id).toMatch(/^perm-/);
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({ approved: true, state: "approved" }),
"utf-8",
);
await decisionPromise;
} finally {
temp.cleanup();
}
});
});
// ── Abandonment ─────────────────────────────────────────────────────
//
// Every path where ParentAuthorizer gives up without a human having ruled must
// be distinguishable from a user denial — `confirmationUnavailable` selects the
// "no authority could answer" block message, and `denialReason` says which
// path (#719).
const forwardedAsk = makePromptDetails({
requestId: "perm-child-request",
agentName: "Explore",
toolName: "bash",
});
/**
* The shape every abandonment resolves to.
*
* `denialReason` and the provenance `reason` are the same value by
* construction: the string that names which path gave up is the string the
* record attributes it to, so the two cannot drift (#726).
*/
function unavailableDecision(denialReason: unknown) {
return {
approved: false,
state: "denied",
confirmationUnavailable: true,
denialReason,
decidedBy: { kind: "unavailable", reason: denialReason },
};
}
describe("ParentAuthorizer abandonment", () => {
test("reports an unresolvable target as unavailable, not user-denied", async () => {
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
registry: makeSubagentRegistry("child-session"),
}),
);
await expect(authorizer.authorize({ ...forwardedAsk })).resolves.toEqual(
unavailableDecision(
"Could not resolve a parent session to forward this permission request to",
),
);
});
test("reports unusable forwarding directories as unavailable", async () => {
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-blocked-"));
try {
// A file where the forwarding root must be a directory: every mkdir
// beneath it fails with ENOTDIR.
const forwardingDir = join(root, "forwarding");
writeFileSync(forwardingDir, "not a directory", "utf-8");
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
}),
);
await expect(authorizer.authorize({ ...forwardedAsk })).resolves.toEqual(
unavailableDecision(
"Permission forwarding directories could not be prepared for session 'parent-session'",
),
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("reports an unwritable request as unavailable", async () => {
const temp = createForwardingTempDir("parent-session");
try {
// Deny writes into requests/ so writeJsonFileAtomic's temp write fails.
chmodSync(temp.location.requestsDir, 0o500);
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
}),
);
await expect(authorizer.authorize({ ...forwardedAsk })).resolves.toEqual(
unavailableDecision(
"The forwarded permission request could not be written",
),
);
// The directories it created for an exchange that never happened are
// cleaned up, so the chmod'd directory is already gone.
expect(existsSync(temp.location.requestsDir)).toBe(false);
} finally {
if (existsSync(temp.location.requestsDir)) {
chmodSync(temp.location.requestsDir, 0o700);
}
temp.cleanup();
}
});
test("reports an unreadable response as unavailable, not as the parent's denial", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
}),
);
const decisionPromise = authorizer.authorize({ ...forwardedAsk });
const request = await waitForRequestFile(temp.location.requestsDir);
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
"{ not json",
"utf-8",
);
await expect(decisionPromise).resolves.toEqual(
unavailableDecision(
"The parent session's permission response could not be read",
),
);
} finally {
temp.cleanup();
}
});
test("reports an unanswered request as timed out, not user-denied", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
getTimeoutMs: () => 400,
}),
);
await expect(authorizer.authorize({ ...forwardedAsk })).resolves.toEqual(
unavailableDecision(
"Session 'parent-session' did not answer within 0.4s",
),
);
} finally {
temp.cleanup();
}
});
test("abandons quickly when an in-process target is not serving", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
// Nobody has marked themselves as serving.
serving: makeLivenessJudge({ forwardingDir: temp.forwardingDir }),
getTimeoutMs: () => 60_000,
}),
);
const started = Date.now();
await expect(authorizer.authorize({ ...forwardedAsk })).resolves.toEqual(
unavailableDecision(
"Session 'parent-session' is not serving forwarded permission requests",
),
);
expect(Date.now() - started).toBeLessThan(60_000);
} finally {
temp.cleanup();
}
});
test("keeps waiting while the in-process target is serving", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const registry = new ServingSessionRegistry();
registry.markServing("parent-session");
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
serving: makeLivenessJudge({
forwardingDir: temp.forwardingDir,
registry,
}),
getTimeoutMs: () => 60_000,
}),
);
const decisionPromise = authorizer.authorize({ ...forwardedAsk });
const request = await waitForRequestFile(temp.location.requestsDir);
// Well past the unserved grace window: a serving target must not be
// abandoned no matter how long the human deliberates.
await new Promise((resolve) =>
setTimeout(resolve, PERMISSION_FORWARDING_SERVING_GRACE_MS + 250),
);
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: "parent-session",
}),
"utf-8",
);
await expect(decisionPromise).resolves.toMatchObject({
approved: true,
state: "approved",
});
} finally {
temp.cleanup();
}
});
test("abandons quickly when an out-of-process target published no heartbeat", async () => {
const temp = createForwardingTempDir("parent-session");
try {
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", "parent-session");
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
// No registry entry, so the target resolves from the environment: a
// parent in another process, reachable only through the filesystem.
registry: makeSubagentRegistry("child-session"),
serving: makeLivenessJudge({ forwardingDir: temp.forwardingDir }),
getTimeoutMs: () => 60_000,
}),
);
const started = Date.now();
await expect(authorizer.authorize({ ...forwardedAsk })).resolves.toEqual(
unavailableDecision(
"Session 'parent-session' is not serving forwarded permission requests",
),
);
expect(Date.now() - started).toBeLessThan(60_000);
} finally {
vi.unstubAllEnvs();
temp.cleanup();
}
});
test("abandons quickly when an out-of-process target's process is gone", async () => {
const temp = createForwardingTempDir("parent-session");
try {
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", "parent-session");
publishServingHeartbeat(temp.forwardingDir, "parent-session", 4242);
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session"),
serving: makeLivenessJudge({
forwardingDir: temp.forwardingDir,
isProcessAlive: () => false,
}),
getTimeoutMs: () => 60_000,
}),
);
await expect(authorizer.authorize({ ...forwardedAsk })).resolves.toEqual(
unavailableDecision(
"Session 'parent-session' is not serving forwarded permission requests",
),
);
} finally {
vi.unstubAllEnvs();
temp.cleanup();
}
});
test("keeps waiting while an out-of-process target's heartbeat is fresh", async () => {
const temp = createForwardingTempDir("parent-session");
try {
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", "parent-session");
publishServingHeartbeat(temp.forwardingDir, "parent-session");
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session"),
serving: makeLivenessJudge({ forwardingDir: temp.forwardingDir }),
getTimeoutMs: () => 60_000,
}),
);
const decisionPromise = authorizer.authorize({ ...forwardedAsk });
const request = await waitForRequestFile(temp.location.requestsDir);
// Well past the grace window: a live parent must not be abandoned no
// matter how long the human deliberates.
await new Promise((resolve) =>
setTimeout(resolve, PERMISSION_FORWARDING_SERVING_GRACE_MS + 250),
);
writeFileSync(
join(temp.location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: "parent-session",
}),
"utf-8",
);
await expect(decisionPromise).resolves.toMatchObject({
approved: true,
state: "approved",
});
} finally {
vi.unstubAllEnvs();
temp.cleanup();
}
});
test("records which channel answered and what it saw when it gives up", async () => {
const temp = createForwardingTempDir("parent-session");
try {
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", "parent-session");
publishServingHeartbeat(temp.forwardingDir, "other-parent");
const logger = { review: vi.fn(), debug: vi.fn() };
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session"),
serving: makeLivenessJudge({ forwardingDir: temp.forwardingDir }),
getTimeoutMs: () => 60_000,
logger,
}),
);
await authorizer.authorize({ ...forwardedAsk });
expect(logger.review).toHaveBeenCalledWith(
"forwarded_permission.no_serving_session",
expect.objectContaining({
requesterSessionId: "child-session",
targetSessionId: "parent-session",
servingChannel: "heartbeat",
servingState: "absent",
servingSessionIds: ["other-parent"],
}),
);
} finally {
vi.unstubAllEnvs();
temp.cleanup();
}
});
test("deletes the request it abandoned so the parent cannot answer it later", async () => {
const temp = createForwardingTempDir("parent-session");
try {
const authorizer = new ParentAuthorizer(
makeForwarderContext({ hasUI: false, sessionId: "child-session" }),
makeParentAuthorizerDeps({
forwardingDir: temp.forwardingDir,
registry: makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
}),
getTimeoutMs: () => 400,
}),
);
await authorizer.authorize({ ...forwardedAsk });
expect(existsSync(temp.location.requestsDir)).toBe(false);
} finally {
temp.cleanup();
}
});
});
@@ -0,0 +1,224 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthorizerVerdict } from "#src/authority/authorizer";
import { composeAuthorizerChain } from "#src/authority/authorizer-chain";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import type { AuthorizerLog, PermissionQuery } from "#src/service";
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import { makePromptDetails as makeDetails } from "#test/helpers/prompt-details-fixtures";
/** A shared review-log seam; identity-comparable for injection assertions. */
const log = makeAuthorizerLog();
// ── Helpers ─────────────────────────────────────────────────────────────────
/** A narrow PermissionQuery stub; identity-comparable for injection assertions. */
function makeQuery(): PermissionQuery {
return {
checkPermission: vi.fn(),
getToolPermission: vi.fn(),
};
}
/**
* A terminal stub returning a fixed decision; exposes the vi.fn for assertions.
*
* The default is filler for the tests that assert the terminal is never
* reached; a test whose subject is the terminal's own decision passes one.
*/
function makeTerminal(
decision: PermissionPromptDecision = {
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
},
) {
return {
authorize: vi
.fn<
(details: PromptPermissionDetails) => Promise<PermissionPromptDecision>
>()
.mockResolvedValue(decision),
};
}
/** A non-terminal link stub returning a fixed verdict, under a given name. */
function makeLink(verdict: AuthorizerVerdict, name = "link") {
return {
name,
authorize: vi
.fn<
(
details: PromptPermissionDetails,
query: PermissionQuery,
log: AuthorizerLog,
) => Promise<AuthorizerVerdict>
>()
.mockResolvedValue(verdict),
};
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe("composeAuthorizerChain", () => {
it("returns the terminal instance itself when there are no links", () => {
const terminal = makeTerminal();
const composed = composeAuthorizerChain([], terminal, makeQuery(), log);
// Identity is a behavioral invariant: escalate hands the real terminal to
// the prompter, so `expect.any(LocalUserAuthorizer)` still holds.
expect(composed).toBe(terminal);
});
it("maps an allow verdict to an approved decision and injects the query", async () => {
const terminal = makeTerminal({
approved: false,
state: "denied",
decidedBy: DECIDED_BY_HUMAN,
});
const link = makeLink({ kind: "allow" }, "model-judge");
const query = makeQuery();
const details = makeDetails();
const composed = composeAuthorizerChain([link], terminal, query, log);
const decision = await composed.authorize(details);
expect(decision).toEqual({
approved: true,
state: "approved",
decidedBy: {
kind: "authorizer",
name: "model-judge",
verdict: "allow",
reason: null,
},
});
// The chain injects the session-scoped query and the review-log seam into
// each link (ADR 0007 §3).
expect(link.authorize).toHaveBeenCalledWith(details, query, log);
expect(terminal.authorize).not.toHaveBeenCalled();
});
it("maps a deny verdict with a reason to a denied_with_reason decision", async () => {
const terminal = makeTerminal();
const link = makeLink(
{ kind: "deny", reason: "wrong path; use pi-packages" },
"model-judge",
);
const composed = composeAuthorizerChain([link], terminal, makeQuery(), log);
const decision = await composed.authorize(makeDetails());
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "wrong path; use pi-packages",
decidedBy: {
kind: "authorizer",
name: "model-judge",
verdict: "deny",
reason: "wrong path; use pi-packages",
},
});
expect(terminal.authorize).not.toHaveBeenCalled();
});
it("maps a deny verdict without a reason to a plain denied decision", async () => {
const terminal = makeTerminal();
const link = makeLink({ kind: "deny" }, "guard");
const composed = composeAuthorizerChain([link], terminal, makeQuery(), log);
const decision = await composed.authorize(makeDetails());
expect(decision).toEqual({
approved: false,
state: "denied",
decidedBy: {
kind: "authorizer",
name: "guard",
verdict: "deny",
reason: null,
},
});
});
it("falls through a defer verdict to the terminal", async () => {
const terminalDecision: PermissionPromptDecision = {
approved: false,
state: "denied",
decidedBy: DECIDED_BY_HUMAN,
confirmationUnavailable: true,
};
const terminal = makeTerminal(terminalDecision);
const link = makeLink({ kind: "defer" });
const query = makeQuery();
const details = makeDetails();
const composed = composeAuthorizerChain([link], terminal, query, log);
const decision = await composed.authorize(details);
expect(decision).toEqual(terminalDecision);
expect(link.authorize).toHaveBeenCalledWith(details, query, log);
expect(terminal.authorize).toHaveBeenCalledWith(details);
});
it("tries links in order and the first non-defer verdict wins", async () => {
const terminal = makeTerminal();
const first = makeLink({ kind: "defer" }, "first");
const second = makeLink({ kind: "deny", reason: "no" }, "second");
const third = makeLink({ kind: "allow" }, "third");
const composed = composeAuthorizerChain(
[first, second, third],
terminal,
makeQuery(),
log,
);
const decision = await composed.authorize(makeDetails());
// The deciding link is named, not merely the consulted set: a deferring
// link ahead of it decided nothing and must not be credited.
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "no",
decidedBy: {
kind: "authorizer",
name: "second",
verdict: "deny",
reason: "no",
},
});
expect(third.authorize).not.toHaveBeenCalled();
expect(terminal.authorize).not.toHaveBeenCalled();
});
it("reaches the terminal when every link defers", async () => {
const terminal = makeTerminal({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
const first = makeLink({ kind: "defer" }, "first");
const second = makeLink({ kind: "defer" }, "second");
const composed = composeAuthorizerChain(
[first, second],
terminal,
makeQuery(),
log,
);
const decision = await composed.authorize(makeDetails());
// The terminal's own decision passes through unchanged — a link that
// deferred is not the decider.
expect(decision).toEqual({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
expect(terminal.authorize).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,71 @@
import { describe, expect, test } from "vitest";
import type { Authorizer } from "#src/authority/authorizer";
import { AuthorizerRegistry } from "#src/authority/authorizer-registry";
const noopLink: Authorizer["authorize"] = () =>
Promise.resolve({ kind: "defer" });
describe("AuthorizerRegistry", () => {
describe("register", () => {
test("stores a link so get() returns it", () => {
const registry = new AuthorizerRegistry();
registry.register("model-judge", noopLink);
expect(registry.get("model-judge")).toBe(noopLink);
});
test("returns a disposer that removes the link", () => {
const registry = new AuthorizerRegistry();
const dispose = registry.register("model-judge", noopLink);
dispose();
expect(registry.get("model-judge")).toBeUndefined();
});
test("throws when a link is already registered for the same name", () => {
const registry = new AuthorizerRegistry();
registry.register("model-judge", noopLink);
expect(() =>
registry.register("model-judge", () =>
Promise.resolve({ kind: "defer" }),
),
).toThrow("model-judge");
});
test("allows registering different names independently", () => {
const registry = new AuthorizerRegistry();
const linkA: Authorizer["authorize"] = () =>
Promise.resolve({ kind: "allow" });
const linkB: Authorizer["authorize"] = () =>
Promise.resolve({ kind: "deny" });
registry.register("judge-a", linkA);
registry.register("judge-b", linkB);
expect(registry.get("judge-a")).toBe(linkA);
expect(registry.get("judge-b")).toBe(linkB);
});
});
describe("disposer identity guard", () => {
test("stale disposer does not evict a later registration", () => {
const registry = new AuthorizerRegistry();
const first: Authorizer["authorize"] = () =>
Promise.resolve({ kind: "defer" });
const second: Authorizer["authorize"] = () =>
Promise.resolve({ kind: "allow" });
const disposeFirst = registry.register("model-judge", first);
disposeFirst(); // removes first
registry.register("model-judge", second); // second registration is valid
disposeFirst(); // stale disposer again — must not remove second
expect(registry.get("model-judge")).toBe(second);
});
});
describe("get", () => {
test("returns undefined for an unregistered name", () => {
const registry = new AuthorizerRegistry();
expect(registry.get("unknown")).toBeUndefined();
});
});
});
@@ -0,0 +1,500 @@
/**
* Unit tests for AuthorizerSelection.
*
* AuthorizerSelection owns the stored ExtensionContext and is the sole
* implementation of the AskEscalator role. These tests verify the
* escalate/reject contract across activation state.
*/
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { ParentAuthorizer } from "#src/authority/approval-escalator";
import type { Authorizer } from "#src/authority/authorizer";
import { AuthorizerRegistry } from "#src/authority/authorizer-registry";
import { AuthorizerSelection } from "#src/authority/authorizer-selection";
import { LocalUserAuthorizer } from "#src/authority/local-user-authorizer";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import {
makeAuthorizerSelectionDeps as makeDeps,
makeDetection,
makeInvokingPrompter,
makePrompterApi,
registerLink as register,
} from "#test/helpers/authorizer-fixtures";
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import { makePromptDetails as makeDetails } from "#test/helpers/prompt-details-fixtures";
// ── Test helpers ──────────────────────────────────────────────────────────
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
return {
cwd: "/test/project",
hasUI: true,
ui: {
setStatus: vi.fn(),
notify: vi.fn(),
select: vi.fn(),
input: vi.fn(),
},
sessionManager: {
getEntries: vi.fn().mockReturnValue([]),
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
getSessionId: vi.fn().mockReturnValue(null),
addEntry: vi.fn(),
},
...overrides,
} as unknown as ExtensionContext;
}
/** Details whose gate-computed surface drives the delegation envelope. */
function makeDetailsOn(surface: string): PromptPermissionDetails {
return makeDetails({
accessIntent: { surface, matchValues: ["/v"], boundaryValue: null },
});
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe("AuthorizerSelection", () => {
describe("escalate", () => {
it("rejects before activate", async () => {
const selection = new AuthorizerSelection(makeDeps());
await expect(selection.escalate(makeDetails())).rejects.toThrow(
"escalate called before the session was activated",
);
});
it("delegates to deps.prompter.prompt with the selected authorizer", async () => {
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(makeDeps({ prompter }));
const ctx = makeCtx({ hasUI: true });
selection.activate(ctx);
const details = makeDetails();
const result = await selection.escalate(details);
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(LocalUserAuthorizer),
details,
);
expect(result).toEqual({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
});
it("uses the most recently selected authorizer", async () => {
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(makeDeps({ prompter }));
selection.activate(makeCtx({ hasUI: false }));
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetails());
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(LocalUserAuthorizer),
expect.anything(),
);
});
it("rejects after deactivate", async () => {
const selection = new AuthorizerSelection(makeDeps());
selection.activate(makeCtx());
selection.deactivate();
await expect(selection.escalate(makeDetails())).rejects.toThrow(
"escalate called before the session was activated",
);
});
it("returns the prompter decision", async () => {
const decision: PermissionPromptDecision = {
approved: false,
state: "denied",
decidedBy: DECIDED_BY_HUMAN,
denialReason: "user declined",
};
const prompter = makePrompterApi();
prompter.prompt.mockResolvedValue(decision);
const selection = new AuthorizerSelection(makeDeps({ prompter }));
selection.activate(makeCtx());
const result = await selection.escalate(makeDetails());
expect(result).toEqual(decision);
});
});
describe("lifecycle", () => {
it("activate then deactivate rejects a subsequent escalate", async () => {
const selection = new AuthorizerSelection(makeDeps());
selection.activate(makeCtx());
selection.deactivate();
await expect(selection.escalate(makeDetails())).rejects.toThrow(
"escalate called before the session was activated",
);
});
it("multiple activate calls escalate against the most recent context", async () => {
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(makeDeps({ prompter }));
selection.activate(makeCtx({ cwd: "/old" }));
selection.activate(makeCtx({ cwd: "/new" }));
await selection.escalate(makeDetails());
expect(prompter.prompt).toHaveBeenCalledOnce();
});
});
describe("chain resolution", () => {
it("consults a configured link before the terminal", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "deny", reason: "typo path" });
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
}),
);
selection.activate(makeCtx({ hasUI: true }));
const decision = await selection.escalate(makeDetailsOn("bash"));
// The link decided (deny_with_reason); the LocalUserAuthorizer terminal
// was never reached (it would have approved by default).
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "typo path",
decidedBy: {
kind: "authorizer",
name: "judge",
verdict: "deny",
reason: "typo path",
},
});
});
it("injects the session review-log seam into each link (ADR 0007 §3)", async () => {
const logger = makeAuthorizerLog();
const link = vi
.fn<Authorizer["authorize"]>()
.mockResolvedValue({ kind: "defer" });
const registry = new AuthorizerRegistry();
registry.register("judge", link);
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetailsOn("bash"));
// The link is handed the session logger as its third argument, so it can
// record a decision trail to the shared review log.
expect(link).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
logger,
);
});
it("resolves links in config order (first non-defer wins)", async () => {
const registry = new AuthorizerRegistry();
register(registry, "a", { kind: "deny", reason: "a-wins" });
register(registry, "b", { kind: "deny", reason: "b-wins" });
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["a", "b"],
}),
);
selection.activate(makeCtx({ hasUI: true }));
const decision = await selection.escalate(makeDetailsOn("bash"));
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "a-wins",
decidedBy: {
kind: "authorizer",
name: "a",
verdict: "deny",
reason: "a-wins",
},
});
});
it("skips an unregistered configured name with a warning", async () => {
const registry = new AuthorizerRegistry();
register(registry, "present", {
kind: "deny",
reason: "present-decided",
});
const logger = makeAuthorizerLog();
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["missing", "present"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
const decision = await selection.escalate(makeDetailsOn("bash"));
// The unregistered "missing" link is skipped fail-safe; "present"
// decides, and is the name credited — the skipped one is not.
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "present-decided",
decidedBy: {
kind: "authorizer",
name: "present",
verdict: "deny",
reason: "present-decided",
},
});
expect(logger.review).toHaveBeenCalledWith(
"authorizer_chain_unregistered_link",
{ requestId: "req-1", name: "missing" },
);
});
it("records the resolved link names on the ask", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "defer" });
const logger = makeAuthorizerLog();
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetailsOn("bash"));
// Positive evidence the link was consulted: a link that defers decides
// nothing and would otherwise leave no trace of having run.
expect(logger.review).toHaveBeenCalledWith("authorizer_chain_resolved", {
requestId: "req-1",
links: ["judge"],
});
});
it("records only the names it could resolve", async () => {
const registry = new AuthorizerRegistry();
register(registry, "present", { kind: "defer" });
const logger = makeAuthorizerLog();
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["missing", "present"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetailsOn("bash"));
expect(logger.review).toHaveBeenCalledWith("authorizer_chain_resolved", {
requestId: "req-1",
links: ["present"],
});
});
it("records no consultation when no configured name resolved", async () => {
const logger = makeAuthorizerLog();
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
getAuthorizerChain: () => ["missing"],
logger,
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetailsOn("bash"));
// Nothing ran, so there is no consultation to record; the per-name
// warning already reports the skip.
expect(logger.review).not.toHaveBeenCalledWith(
"authorizer_chain_resolved",
expect.anything(),
);
});
it("caps a link's allow on an excluded surface, falling through to the terminal", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "allow" });
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
}),
);
// No UI, not a subagent → the terminal is DenyingAuthorizer.
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(
makeDetailsOn("external_directory"),
);
// The envelope downgraded the link's allow to defer, so the terminal
// (denying) owns the decision — the allow did not leak through.
expect(decision.approved).toBe(false);
});
it("lets a link's allow through on a non-excluded surface", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "allow" });
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
}),
);
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(makeDetailsOn("bash"));
// bash is not excluded, so the link's allow stands (a non-persistent
// approved grant) — the denying terminal is never reached.
expect(decision).toEqual({
approved: true,
state: "approved",
decidedBy: {
kind: "authorizer",
name: "judge",
verdict: "allow",
reason: null,
},
});
});
it("a registered but un-named link grants no authority (terminal identity)", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "allow" });
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(
makeDeps({
prompter,
authorizerRegistry: registry,
getAuthorizerChain: () => [], // not named → opt-in withheld
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(makeDetails());
// Empty chain ⇒ the selected value is the terminal instance itself.
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(LocalUserAuthorizer),
expect.anything(),
);
});
});
describe("chain delegation on a relaying node", () => {
/** A no-UI subagent node: its terminal relays the ask to the serving node. */
function makeRelayingSelection(
overrides: Parameters<typeof makeDeps>[0] = {},
): AuthorizerSelection {
const selection = new AuthorizerSelection(
makeDeps({ detection: makeDetection(true), ...overrides }),
);
selection.activate(makeCtx({ hasUI: false }));
return selection;
}
it("composes no links, so the ask reaches the relaying terminal unchanged", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "deny", reason: "judged locally" });
const prompter = makePrompterApi();
const selection = makeRelayingSelection({
prompter,
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
});
const details = makeDetailsOn("bash");
await selection.escalate(details);
// Zero links ⇒ the composed chain *is* the terminal instance, so the
// registered link never ran: the serving node adjudicates this ask.
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(ParentAuthorizer),
details,
);
});
it("records the delegated chain instead of the resolved one", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "deny", reason: "judged locally" });
const logger = makeAuthorizerLog();
const selection = makeRelayingSelection({
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
logger,
});
await selection.escalate(makeDetailsOn("bash"));
expect(logger.review).toHaveBeenCalledWith("authorizer_chain_delegated", {
requestId: "req-1",
links: ["judge"],
});
expect(logger.review).not.toHaveBeenCalledWith(
"authorizer_chain_resolved",
expect.anything(),
);
});
it("does not report an unregistrable link as an unregistered one", async () => {
const logger = makeAuthorizerLog();
const selection = makeRelayingSelection({
getAuthorizerChain: () => ["model-judge"],
logger,
});
await selection.escalate(makeDetailsOn("bash"));
// A child cannot host a link at all (#699), so its absence is the design,
// not the misconfiguration `authorizer_chain_unregistered_link` reports.
expect(logger.review).not.toHaveBeenCalledWith(
"authorizer_chain_unregistered_link",
expect.anything(),
);
});
it("records nothing when no chain is configured", async () => {
const logger = makeAuthorizerLog();
const selection = makeRelayingSelection({ logger });
await selection.escalate(makeDetailsOn("bash"));
expect(logger.review).not.toHaveBeenCalledWith(
"authorizer_chain_delegated",
expect.anything(),
);
});
});
});
@@ -0,0 +1,91 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { ParentAuthorizer } from "#src/authority/approval-escalator";
import { selectAuthorizer } from "#src/authority/authorizer";
import { DenyingAuthorizer } from "#src/authority/denying-authorizer";
import { LocalUserAuthorizer } from "#src/authority/local-user-authorizer";
import {
makeAuthorizerSelectionDeps as makeDeps,
makeDetection,
} from "#test/helpers/authorizer-fixtures";
// ── Helpers ─────────────────────────────────────────────────────────────────
function makeCtx(hasUI: boolean): ExtensionContext {
return {
hasUI,
mode: "tui",
ui: { select: vi.fn(), input: vi.fn(), custom: vi.fn() },
sessionManager: {
getSessionId: vi.fn().mockReturnValue("session-1"),
getSessionDir: vi.fn().mockReturnValue("/sessions/session-1"),
getEntries: vi.fn().mockReturnValue([]),
},
} as unknown as ExtensionContext;
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe("selectAuthorizer", () => {
describe("terminal dispatch", () => {
it("selects LocalUserAuthorizer when the context has UI", () => {
const authority = selectAuthorizer(makeCtx(true), makeDeps());
expect(authority.terminal).toBeInstanceOf(LocalUserAuthorizer);
});
it("selects LocalUserAuthorizer even when the context is also a subagent", () => {
const authority = selectAuthorizer(
makeCtx(true),
makeDeps({ detection: makeDetection(true) }),
);
expect(authority.terminal).toBeInstanceOf(LocalUserAuthorizer);
});
it("selects ParentAuthorizer when there is no UI but the context is a subagent", () => {
const authority = selectAuthorizer(
makeCtx(false),
makeDeps({ detection: makeDetection(true) }),
);
expect(authority.terminal).toBeInstanceOf(ParentAuthorizer);
});
it("selects DenyingAuthorizer when there is no UI and no subagent", () => {
const authority = selectAuthorizer(
makeCtx(false),
makeDeps({ detection: makeDetection(false) }),
);
expect(authority.terminal).toBeInstanceOf(DenyingAuthorizer);
});
});
describe("chain role", () => {
it("adjudicates locally when the terminal is the human", () => {
const authority = selectAuthorizer(makeCtx(true), makeDeps());
expect(authority.adjudicatesLocally).toBe(true);
});
it("adjudicates locally when a subagent has its own UI", () => {
const authority = selectAuthorizer(
makeCtx(true),
makeDeps({ detection: makeDetection(true) }),
);
expect(authority.adjudicatesLocally).toBe(true);
});
it("relays instead of adjudicating when the terminal forwards to a serving node", () => {
const authority = selectAuthorizer(
makeCtx(false),
makeDeps({ detection: makeDetection(true) }),
);
expect(authority.adjudicatesLocally).toBe(false);
});
it("adjudicates locally when the terminal denies for want of authority", () => {
const authority = selectAuthorizer(
makeCtx(false),
makeDeps({ detection: makeDetection(false) }),
);
expect(authority.adjudicatesLocally).toBe(true);
});
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { collapsePastedNewlines } from "#src/authority/bracketed-paste";
/** How the terminal hands a paste to a focused component (pi-tui `terminal.ts`). */
function pasteChunk(content: string): string {
return `\u001b[200~${content}\u001b[201~`;
}
describe("collapsePastedNewlines", () => {
it("collapses a line break into a single space", () => {
expect(collapsePastedNewlines(pasteChunk("one\ntwo"))).toBe(
pasteChunk("one two"),
);
});
it("collapses a CRLF line break into a single space", () => {
expect(collapsePastedNewlines(pasteChunk("one\r\ntwo"))).toBe(
pasteChunk("one two"),
);
});
it("collapses a run of blank lines into a single space", () => {
expect(collapsePastedNewlines(pasteChunk("one\n\n\ntwo"))).toBe(
pasteChunk("one two"),
);
});
it("leaves a single-line paste byte-identical", () => {
expect(collapsePastedNewlines(pasteChunk("no line breaks here"))).toBe(
pasteChunk("no line breaks here"),
);
});
it("leaves the paste markers in place so the editor still sees a paste", () => {
const collapsed = collapsePastedNewlines(pasteChunk("a\nb"));
expect(collapsed.startsWith("\u001b[200~")).toBe(true);
expect(collapsed.endsWith("\u001b[201~")).toBe(true);
});
it("returns ordinary keystroke data unchanged", () => {
expect(collapsePastedNewlines("\r")).toBe("\r");
expect(collapsePastedNewlines("a")).toBe("a");
});
it("leaves a chunk missing its end marker unchanged", () => {
// The terminal never splits a paste across calls, so this shape is not a
// paste to interpret; passing it through lets the editor buffer it.
expect(collapsePastedNewlines("\u001b[200~one\ntwo")).toBe(
"\u001b[200~one\ntwo",
);
});
it("collapses only inside the markers, not text typed after the paste", () => {
expect(collapsePastedNewlines(`${pasteChunk("a\nb")}c\nd`)).toBe(
`${pasteChunk("a b")}c\nd`,
);
});
});
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
asDecisionSource,
type DecisionSource,
MAX_DECISION_SOURCE_DEPTH,
} from "#src/authority/decision-source";
/** Wrap `inner` in `depth` nested `forwarded` frames. */
function nest(depth: number, inner: DecisionSource): DecisionSource {
let source = inner;
for (let i = 0; i < depth; i++) {
source = {
kind: "forwarded",
responderSessionId: `session-${i}`,
decision: source,
};
}
return source;
}
describe("asDecisionSource", () => {
describe("round-trips every variant", () => {
const variants: readonly DecisionSource[] = [
{ kind: "user", via: "dialog" },
{ kind: "user", via: "select" },
{
kind: "authorizer",
name: "model-judge",
verdict: "deny",
reason: "reads outside the project",
},
{
kind: "authorizer",
name: "model-judge",
verdict: "allow",
reason: null,
},
{
kind: "rule",
surface: "external_directory",
pattern: "/tmp/*",
origin: "global",
},
{ kind: "rule", surface: "bash", pattern: null, origin: null },
{
kind: "session_approval",
surface: "external_directory",
pattern: "/tmp/*",
},
{ kind: "session_approval", surface: "bash", pattern: null },
{ kind: "yolo", pattern: "<opaque-bash-wrapper>" },
{ kind: "yolo", pattern: null },
{ kind: "infrastructure_read" },
{
kind: "unavailable",
reason: "Session 'abc' did not answer within 600s",
},
{ kind: "gate_error", reason: "boom" },
{
kind: "forwarded",
responderSessionId: "019ff969-c34c-70be-9034-fae19c852932",
decision: { kind: "user", via: "dialog" },
},
{ kind: "forwarded", responderSessionId: null, decision: null },
];
for (const variant of variants) {
it(`admits ${variant.kind} (${JSON.stringify(variant)})`, () => {
expect(asDecisionSource(JSON.parse(JSON.stringify(variant)))).toEqual(
variant,
);
});
}
});
describe("rejects malformed input", () => {
it.each([
["null", null],
["a string", "user"],
["an array", [{ kind: "user", via: "dialog" }]],
["an unknown kind", { kind: "telepathy" }],
["a missing kind", { via: "dialog" }],
["an unknown user surface", { kind: "user", via: "smoke-signal" }],
["a missing user surface", { kind: "user" }],
[
"an unknown authorizer verdict",
{ kind: "authorizer", name: "j", verdict: "defer", reason: null },
],
[
"a missing authorizer name",
{ kind: "authorizer", verdict: "allow", reason: null },
],
[
"a non-string authorizer name",
{ kind: "authorizer", name: 7, verdict: "allow", reason: null },
],
["a missing rule surface", { kind: "rule", pattern: null, origin: null }],
[
"a non-nullable-string rule pattern",
{ kind: "rule", surface: "bash", pattern: 7, origin: null },
],
[
"a missing session_approval surface",
{ kind: "session_approval", pattern: null },
],
["a missing yolo pattern", { kind: "yolo" }],
["a missing unavailable reason", { kind: "unavailable" }],
["a non-string gate_error reason", { kind: "gate_error", reason: null }],
[
"a missing forwarded decision",
{ kind: "forwarded", responderSessionId: "s" },
],
[
"a non-nullable-string responderSessionId",
{ kind: "forwarded", responderSessionId: 7, decision: null },
],
])("rejects %s", (_label, value) => {
expect(asDecisionSource(value)).toBeUndefined();
});
it("rejects the whole value when a nested decision is malformed", () => {
// All-or-nothing, like `asPromptPayload`: a half-parsed provenance record
// would assert a decider that never decided.
expect(
asDecisionSource({
kind: "forwarded",
responderSessionId: "session-1",
decision: { kind: "user", via: "smoke-signal" },
}),
).toBeUndefined();
});
it("drops unknown properties rather than rejecting", () => {
expect(
asDecisionSource({ kind: "user", via: "dialog", clicks: 2 }),
).toEqual({ kind: "user", via: "dialog" });
});
});
describe("bounds nesting depth", () => {
it("admits nesting up to the bound", () => {
const source = nest(MAX_DECISION_SOURCE_DEPTH, {
kind: "user",
via: "dialog",
});
expect(asDecisionSource(JSON.parse(JSON.stringify(source)))).toEqual(
source,
);
});
it("rejects nesting past the bound", () => {
const source = nest(MAX_DECISION_SOURCE_DEPTH + 1, {
kind: "user",
via: "dialog",
});
expect(
asDecisionSource(JSON.parse(JSON.stringify(source))),
).toBeUndefined();
});
});
});
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from "vitest";
import type { Authorizer, AuthorizerVerdict } from "#src/authority/authorizer";
import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope";
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import type { PermissionQuery } from "#src/service";
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
import { makePromptDetails } from "#test/helpers/prompt-details-fixtures";
function makeQuery(): PermissionQuery {
return { checkPermission: vi.fn(), getToolPermission: vi.fn() };
}
/** Build details whose gate-computed surface is `accessIntentSurface`. */
function makeDetails(
accessIntentSurface: string | undefined,
displaySurface?: string | null,
toolName?: string,
): PromptPermissionDetails {
return makePromptDetails({
surface: displaySurface,
toolName,
accessIntent:
accessIntentSurface === undefined
? undefined
: {
surface: accessIntentSurface,
matchValues: ["/some/value"],
boundaryValue: null,
},
});
}
/** A link whose fixed verdict the envelope may cap. */
function makeLink(verdict: AuthorizerVerdict): Authorizer["authorize"] {
return vi.fn<Authorizer["authorize"]>().mockResolvedValue(verdict);
}
describe("encloseInDelegationEnvelope", () => {
const query = makeQuery();
const log = makeAuthorizerLog();
describe("caps an allow verdict on an excluded surface to defer", () => {
it("downgrades an allow on external_directory", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(
makeDetails("external_directory"),
query,
log,
);
expect(verdict).toEqual({ kind: "defer" });
});
it.each(["write", "edit", "bash", "custom-tool"] as const)(
"downgrades an allow on external_directory for %s",
async (toolName) => {
const enclosed = encloseInDelegationEnvelope(
makeLink({ kind: "allow" }),
);
const verdict = await enclosed(
makeDetails("external_directory", undefined, toolName),
query,
log,
);
expect(verdict).toEqual({ kind: "defer" });
},
);
it("downgrades an allow on the path surface", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails("path"), query, log);
expect(verdict).toEqual({ kind: "defer" });
});
it("downgrades an allow when the surface is undetermined (fail-safe)", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails(undefined, null), query, log);
expect(verdict).toEqual({ kind: "defer" });
});
});
describe("passes verdicts through unchanged", () => {
it("keeps an allow on a non-excluded surface (bash)", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails("bash"), query, log);
expect(verdict).toEqual({ kind: "allow" });
});
it("keeps an allow on a per-tool surface (read)", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails("read"), query, log);
expect(verdict).toEqual({ kind: "allow" });
});
it("keeps an allow on external_directory for the built-in read tool", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(
makeDetails("external_directory", undefined, "read"),
query,
log,
);
expect(verdict).toEqual({ kind: "allow" });
});
it("never caps a deny, even on an excluded surface", async () => {
const enclosed = encloseInDelegationEnvelope(
makeLink({ kind: "deny", reason: "wrong path" }),
);
const verdict = await enclosed(
makeDetails("external_directory"),
query,
log,
);
expect(verdict).toEqual({ kind: "deny", reason: "wrong path" });
});
it("never caps a defer", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "defer" }));
const verdict = await enclosed(makeDetails("path"), query, log);
expect(verdict).toEqual({ kind: "defer" });
});
});
it("prefers the gate-computed accessIntent surface over the display surface", async () => {
// accessIntent.surface (external_directory) is authoritative even when the
// display-surface override says otherwise.
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(
makeDetails("external_directory", "bash"),
query,
log,
);
expect(verdict).toEqual({ kind: "defer" });
});
it("forwards details, the injected query, and the review-log seam to the wrapped link", async () => {
const link = makeLink({ kind: "defer" });
const enclosed = encloseInDelegationEnvelope(link);
const details = makeDetails("bash");
await enclosed(details, query, log);
expect(link).toHaveBeenCalledWith(details, query, log);
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { TerminalAuthorizer } from "#src/authority/authorizer";
import { DenyingAuthorizer } from "#src/authority/denying-authorizer";
import { makePromptDetails } from "#test/helpers/prompt-details-fixtures";
describe("DenyingAuthorizer", () => {
it("denies with the confirmation-unavailable marker, regardless of details", async () => {
const authorizer: TerminalAuthorizer = new DenyingAuthorizer();
const decision = await authorizer.authorize(
makePromptDetails({ agentName: "test-agent" }),
);
expect(decision).toEqual({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: {
kind: "unavailable",
reason: "No live authority was reachable for this session",
},
});
});
it("denies the same way for a skill-sourced request", async () => {
const authorizer: TerminalAuthorizer = new DenyingAuthorizer();
const decision = await authorizer.authorize(
makePromptDetails({
requestId: "req-2",
source: "skill_input",
skillName: "deploy-helper",
}),
);
expect(decision).toEqual({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: {
kind: "unavailable",
reason: "No live authority was reachable for this session",
},
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,575 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
cleanupPermissionForwardingLocationIfEmpty,
ensureDirectoryExists,
formatUnknownErrorMessage,
isErrnoCode,
logPermissionForwardingError,
logPermissionForwardingWarning,
readForwardedPermissionRequest,
readForwardedPermissionResponse,
tryRemoveDirectoryIfEmpty,
writeJsonFileAtomic,
} from "#src/authority/forwarding-io";
import {
createPermissionForwardingLocation,
type ForwardedAccessIntent,
type ForwardedPermissionRequest,
} from "#src/authority/permission-forwarding";
import type { DebugReviewLogger } from "#src/session-logger";
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
// ── helpers ────────────────────────────────────────────────────────────────
function makeLogger(): DebugReviewLogger {
return {
review: vi.fn(),
debug: vi.fn(),
};
}
// ── formatUnknownErrorMessage ──────────────────────────────────────────────
describe("formatUnknownErrorMessage", () => {
it("returns the error message for Error instances", () => {
expect(formatUnknownErrorMessage(new Error("oops"))).toBe("oops");
});
it("converts non-Error values to string", () => {
expect(formatUnknownErrorMessage("raw string")).toBe("raw string");
expect(formatUnknownErrorMessage(42)).toBe("42");
});
it("falls back to String(error) for Error with empty message", () => {
// error.message is falsy (""), so the function falls through to String(error)
const e = new Error("");
expect(formatUnknownErrorMessage(e)).toBe("Error");
});
});
// ── isErrnoCode ────────────────────────────────────────────────────────────
describe("isErrnoCode", () => {
it("returns true when code matches", () => {
expect(isErrnoCode({ code: "ENOENT" }, "ENOENT")).toBe(true);
});
it("returns false when code does not match", () => {
expect(isErrnoCode({ code: "EACCES" }, "ENOENT")).toBe(false);
});
it("returns false for null", () => {
expect(isErrnoCode(null, "ENOENT")).toBe(false);
});
it("returns false when no code property", () => {
expect(isErrnoCode({}, "ENOENT")).toBe(false);
});
});
// ── logPermissionForwardingWarning ─────────────────────────────────────────
describe("logPermissionForwardingWarning", () => {
it("calls logger.review with the warning event", () => {
const logger = makeLogger();
logPermissionForwardingWarning(logger, "something went wrong");
expect(logger.review).toHaveBeenCalledWith(
"permission_forwarding.warning",
{ message: "something went wrong" },
);
});
it("calls logger.debug with the warning event", () => {
const logger = makeLogger();
logPermissionForwardingWarning(logger, "something went wrong");
expect(logger.debug).toHaveBeenCalledWith("permission_forwarding.warning", {
message: "something went wrong",
});
});
it("includes formatted error when an error is provided", () => {
const logger = makeLogger();
logPermissionForwardingWarning(logger, "bad thing", new Error("fs fail"));
expect(logger.review).toHaveBeenCalledWith(
"permission_forwarding.warning",
{ message: "bad thing", error: "fs fail" },
);
});
it("does not throw when logger is null", () => {
expect(() => logPermissionForwardingWarning(null, "ignored")).not.toThrow();
});
it("does not call anything when logger is null", () => {
// Verify the null-logger path is a true no-op — cannot easily spy on null,
// but we can verify the call succeeds silently.
expect(() =>
logPermissionForwardingWarning(null, "msg", new Error("err")),
).not.toThrow();
});
});
// ── logPermissionForwardingError ───────────────────────────────────────────
describe("logPermissionForwardingError", () => {
it("calls logger.review with the error event", () => {
const logger = makeLogger();
logPermissionForwardingError(logger, "critical failure");
expect(logger.review).toHaveBeenCalledWith("permission_forwarding.error", {
message: "critical failure",
});
});
it("calls logger.debug with the error event", () => {
const logger = makeLogger();
logPermissionForwardingError(logger, "critical failure");
expect(logger.debug).toHaveBeenCalledWith("permission_forwarding.error", {
message: "critical failure",
});
});
it("includes formatted error when an error is provided", () => {
const logger = makeLogger();
logPermissionForwardingError(logger, "io error", new Error("ENOENT"));
expect(logger.review).toHaveBeenCalledWith("permission_forwarding.error", {
message: "io error",
error: "ENOENT",
});
});
it("does not throw when logger is null", () => {
expect(() => logPermissionForwardingError(null, "ignored")).not.toThrow();
});
});
// ── file permissions ───────────────────────────────────────────────────────
describe("forwarding artifact permissions", () => {
let root: string;
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it("writes a forwarded request owner-only", () => {
root = mkdtempSync(join(tmpdir(), "io-modes-"));
const filePath = join(root, "req.json");
writeJsonFileAtomic(null, filePath, { id: "req-1" });
expect(statSync(filePath).mode & 0o777).toBe(0o600);
});
it("creates a forwarding directory owner-only", () => {
root = mkdtempSync(join(tmpdir(), "io-modes-"));
const dirPath = join(root, "sessions", "parent", "requests");
expect(ensureDirectoryExists(null, dirPath, "requests")).toBe(true);
expect(statSync(dirPath).mode & 0o777).toBe(0o700);
});
});
// ── readForwardedPermissionRequest ─────────────────────────────────────────
describe("readForwardedPermissionRequest — accessIntent field", () => {
let root: string;
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function baseRequest(): ForwardedPermissionRequest {
return {
id: "req-1",
createdAt: 1000,
requesterSessionId: "child-session",
targetSessionId: "parent-session",
requesterAgentName: "researcher",
surface: "read",
};
}
function writeAndRead(raw: unknown): ForwardedPermissionRequest | null {
root = mkdtempSync(join(tmpdir(), "io-read-"));
const filePath = join(root, "req.json");
writeJsonFileAtomic(null, filePath, raw);
return readForwardedPermissionRequest(null, filePath);
}
it("round-trips a well-formed access intent (path surface)", () => {
const accessIntent: ForwardedAccessIntent = {
surface: "path",
matchValues: ["/worktree/issue-42/src/foo.ts", "src/foo.ts"],
boundaryValue: "/worktree/issue-42/src/foo.ts",
requesterCwd: "/worktree/issue-42",
principal: { sessionId: "child-session", agentName: "researcher" },
};
const parsed = writeAndRead({ ...baseRequest(), accessIntent });
expect(parsed?.accessIntent).toEqual(accessIntent);
});
it("round-trips a non-path access intent (skill surface, null boundary)", () => {
const accessIntent: ForwardedAccessIntent = {
surface: "skill",
matchValues: ["deep-research"],
boundaryValue: null,
requesterCwd: "/repo",
principal: { sessionId: "child-session", agentName: "unknown" },
};
const parsed = writeAndRead({ ...baseRequest(), accessIntent });
expect(parsed?.accessIntent).toEqual(accessIntent);
});
it("carries only strings on matchValues (the ADR-0002 wire boundary)", () => {
const accessIntent: ForwardedAccessIntent = {
surface: "external_directory",
matchValues: ["/etc/hosts", "/private/etc/hosts"],
boundaryValue: "/private/etc/hosts",
requesterCwd: "/repo",
principal: { sessionId: "child-session", agentName: "researcher" },
};
const parsed = writeAndRead({ ...baseRequest(), accessIntent });
expect(
parsed?.accessIntent?.matchValues.every((v) => typeof v === "string"),
).toBe(true);
expect(
parsed?.accessIntent?.boundaryValue === null ||
typeof parsed?.accessIntent?.boundaryValue === "string",
).toBe(true);
});
it("reads a request with no access intent as undefined (version skew)", () => {
const parsed = writeAndRead(baseRequest());
expect(parsed?.accessIntent).toBeUndefined();
// Display/routing fields still reconstruct.
expect(parsed?.surface).toBe("read");
expect(parsed?.requesterAgentName).toBe("researcher");
});
it("drops a malformed access intent to undefined (non-string match value)", () => {
const parsed = writeAndRead({
...baseRequest(),
accessIntent: {
surface: "path",
matchValues: ["/ok", 42],
boundaryValue: null,
requesterCwd: "/repo",
principal: { sessionId: "child-session", agentName: "researcher" },
},
});
expect(parsed?.accessIntent).toBeUndefined();
});
it("drops a malformed access intent to undefined (missing principal)", () => {
const parsed = writeAndRead({
...baseRequest(),
accessIntent: {
surface: "path",
matchValues: ["/ok"],
boundaryValue: null,
requesterCwd: "/repo",
},
});
expect(parsed?.accessIntent).toBeUndefined();
});
});
describe("readForwardedPermissionRequest — payload field", () => {
let root: string;
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function baseRequest(): ForwardedPermissionRequest {
return {
id: "req-1",
createdAt: 1000,
requesterSessionId: "child-session",
targetSessionId: "parent-session",
requesterAgentName: "researcher",
};
}
function writeAndRead(raw: unknown): ForwardedPermissionRequest | null {
root = mkdtempSync(join(tmpdir(), "io-payload-"));
const filePath = join(root, "req.json");
writeJsonFileAtomic(null, filePath, raw);
return readForwardedPermissionRequest(null, filePath);
}
it("round-trips the child's complete prompt payload", () => {
const payload = makePromptPayload({
kind: "bash",
request: {
requester: { agentName: "Explore", forwarded: false, sessionId: null },
surface: "bash",
toolName: "bash",
invokedToolName: null,
value: "git push",
matchedPattern: "git *",
commandContext: null,
executedUnit: null,
},
evidence: [{ label: "command", text: "git push", detail: null }],
});
const parsed = writeAndRead({ ...baseRequest(), payload });
expect(parsed?.payload).toEqual(payload);
});
it("reads a request with no payload as undefined (version skew)", () => {
const parsed = writeAndRead(baseRequest());
expect(parsed?.payload).toBeUndefined();
expect(parsed?.requesterAgentName).toBe("researcher");
});
it("drops a payload with an unrecognized kind", () => {
const parsed = writeAndRead({
...baseRequest(),
payload: { ...makePromptPayload(), kind: "telepathy" },
});
expect(parsed?.payload).toBeUndefined();
});
it("drops a payload whose request facts are malformed", () => {
const payload = makePromptPayload();
const parsed = writeAndRead({
...baseRequest(),
payload: { ...payload, request: { ...payload.request, value: 42 } },
});
expect(parsed?.payload).toBeUndefined();
});
it("drops a payload whose requester is malformed", () => {
const payload = makePromptPayload();
const parsed = writeAndRead({
...baseRequest(),
payload: {
...payload,
request: { ...payload.request, requester: { forwarded: true } },
},
});
expect(parsed?.payload).toBeUndefined();
});
it("drops a payload whose evidence entries are malformed", () => {
const parsed = writeAndRead({
...baseRequest(),
payload: {
...makePromptPayload(),
evidence: [{ label: "command", text: null, detail: null }],
},
});
expect(parsed?.payload).toBeUndefined();
});
it("drops a payload whose annotations are malformed", () => {
const parsed = writeAndRead({
...baseRequest(),
payload: { ...makePromptPayload(), annotations: [{ source: "judge" }] },
});
expect(parsed?.payload).toBeUndefined();
});
it("accepts a legacy message-only request and reconstructs no message", () => {
// An older child writes `message` and no `payload`. The required-core gate
// no longer demands the field, so the request is served (from its display
// fields) rather than rejected outright — and the sentence is not salvaged.
const parsed = writeAndRead({
...baseRequest(),
message: "Allow this path access?",
surface: "read",
value: "/tmp/x",
});
expect(parsed).not.toHaveProperty("message");
expect(parsed?.surface).toBe("read");
expect(parsed?.value).toBe("/tmp/x");
expect(parsed?.payload).toBeUndefined();
});
});
describe("readForwardedPermissionResponse — decidedBy field", () => {
let root: string;
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function writeAndRead(raw: unknown) {
root = mkdtempSync(join(tmpdir(), "io-decided-by-"));
const filePath = join(root, "res.json");
writeJsonFileAtomic(null, filePath, raw);
return readForwardedPermissionResponse(null, filePath);
}
function baseResponse() {
return {
approved: true,
state: "approved",
responderSessionId: "parent-session",
respondedAt: 1000,
};
}
it("round-trips the responder's decider", () => {
const decidedBy = { kind: "user", via: "dialog" } as const;
// The reader rebuilds an allowlist of known fields, so an added one is
// silently dropped until it is listed — which is invisible to tsc.
expect(writeAndRead({ ...baseResponse(), decidedBy })?.decidedBy).toEqual(
decidedBy,
);
});
it("round-trips a nested forwarded decider from a relay hop", () => {
const decidedBy = {
kind: "forwarded",
responderSessionId: "root-session",
decision: {
kind: "rule",
surface: "bash",
pattern: "*",
origin: "global",
},
} as const;
expect(writeAndRead({ ...baseResponse(), decidedBy })?.decidedBy).toEqual(
decidedBy,
);
});
it("drops a malformed decider without rejecting the response", () => {
const parsed = writeAndRead({
...baseResponse(),
decidedBy: { kind: "user", via: "smoke-signal" },
});
// The decision itself still has to reach the child; only its unusable
// provenance is discarded.
expect(parsed?.approved).toBe(true);
expect(parsed?.decidedBy).toBeUndefined();
});
it("leaves decidedBy absent for an older responder", () => {
expect(writeAndRead(baseResponse())?.decidedBy).toBeUndefined();
});
});
// ── tryRemoveDirectoryIfEmpty ──────────────────────────────────────────────
describe("tryRemoveDirectoryIfEmpty", () => {
let root: string;
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it("returns true when the directory does not exist", () => {
root = mkdtempSync(join(tmpdir(), "io-test-"));
const absent = join(root, "nonexistent");
expect(tryRemoveDirectoryIfEmpty(null, absent, "test")).toBe(true);
});
it("returns true and removes an empty directory", () => {
root = mkdtempSync(join(tmpdir(), "io-test-"));
const dir = join(root, "empty");
mkdirSync(dir);
expect(tryRemoveDirectoryIfEmpty(null, dir, "test")).toBe(true);
expect(existsSync(dir)).toBe(false);
});
it("returns false and leaves a non-empty directory in place", () => {
root = mkdtempSync(join(tmpdir(), "io-test-"));
const dir = join(root, "nonempty");
mkdirSync(dir);
writeFileSync(join(dir, "file.json"), "{}", "utf-8");
expect(tryRemoveDirectoryIfEmpty(null, dir, "test")).toBe(false);
expect(existsSync(dir)).toBe(true);
});
});
// ── cleanupPermissionForwardingLocationIfEmpty ─────────────────────────────
describe("cleanupPermissionForwardingLocationIfEmpty", () => {
let root: string;
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it("preserves responses/ when requests/ is non-empty (the concurrent-request race)", () => {
root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
const forwardingDir = join(root, "forwarding");
const location = createPermissionForwardingLocation(
forwardingDir,
"parent-session",
);
// Simulate: requests/ has a pending file, responses/ is momentarily empty
mkdirSync(location.requestsDir, { recursive: true });
mkdirSync(location.responsesDir, { recursive: true });
writeFileSync(join(location.requestsDir, "req-b.json"), "{}", "utf-8");
// responses/ is empty (sibling subagent A already cleaned up its response)
cleanupPermissionForwardingLocationIfEmpty(null, location);
// requests/ is non-empty → should NOT be removed
expect(existsSync(location.requestsDir)).toBe(true);
// responses/ must survive — removing it causes the ENOENT write loop
expect(existsSync(location.responsesDir)).toBe(true);
// sessionRoot must also survive while subdirs are present
expect(existsSync(location.sessionRootDir)).toBe(true);
});
it("removes both subdirs and sessionRoot when both are empty (normal serial cleanup)", () => {
root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
const forwardingDir = join(root, "forwarding");
const location = createPermissionForwardingLocation(
forwardingDir,
"parent-session",
);
mkdirSync(location.requestsDir, { recursive: true });
mkdirSync(location.responsesDir, { recursive: true });
// Both empty — normal end-of-lifecycle state
cleanupPermissionForwardingLocationIfEmpty(null, location);
expect(existsSync(location.requestsDir)).toBe(false);
expect(existsSync(location.responsesDir)).toBe(false);
expect(existsSync(location.sessionRootDir)).toBe(false);
});
it("leaves responses/ in place when it is non-empty even if requests/ is empty", () => {
root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
const forwardingDir = join(root, "forwarding");
const location = createPermissionForwardingLocation(
forwardingDir,
"parent-session",
);
mkdirSync(location.requestsDir, { recursive: true });
mkdirSync(location.responsesDir, { recursive: true });
writeFileSync(join(location.responsesDir, "resp.json"), "{}", "utf-8");
// requests/ is empty, responses/ has a stale response
cleanupPermissionForwardingLocationIfEmpty(null, location);
// requests/ is empty so it gets removed
expect(existsSync(location.requestsDir)).toBe(false);
// responses/ is non-empty → survives
expect(existsSync(location.responsesDir)).toBe(true);
// sessionRoot survives because responses/ is still present
expect(existsSync(location.sessionRootDir)).toBe(true);
});
});
@@ -0,0 +1,514 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ForwardingLivenessJudge,
type HeartbeatState,
SERVING_HEARTBEAT_REFRESH_MS,
SERVING_HEARTBEAT_STALE_MS,
type ServingHeartbeat,
ServingHeartbeatStore,
servingHeartbeatDir,
servingHeartbeatPath,
} from "#src/authority/forwarding-liveness";
import {
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
type PermissionForwardingTarget,
} from "#src/authority/permission-forwarding";
let root: string;
let forwardingDir: string;
let clock: number;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "forwarding-liveness-"));
forwardingDir = join(root, "forwarding");
clock = 1_700_000_000_000;
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function makeStore(
overrides: Partial<
ConstructorParameters<typeof ServingHeartbeatStore>[0]
> = {},
) {
const logger = { review: vi.fn(), debug: vi.fn() };
const store = new ServingHeartbeatStore({
forwardingDir,
logger,
now: () => clock,
pid: 4242,
...overrides,
});
return { store, logger };
}
function readRecord(sessionId: string): ServingHeartbeat {
return JSON.parse(
readFileSync(servingHeartbeatPath(forwardingDir, sessionId), "utf-8"),
) as ServingHeartbeat;
}
describe("timing constants", () => {
it("refreshes less often than the inbox is polled, so a per-tick call is cheap", () => {
expect(SERVING_HEARTBEAT_REFRESH_MS).toBeGreaterThan(
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
);
});
it("tolerates several missed refreshes before calling a record stale", () => {
expect(SERVING_HEARTBEAT_STALE_MS).toBeGreaterThan(
SERVING_HEARTBEAT_REFRESH_MS * 2,
);
});
});
describe("servingHeartbeatPath", () => {
it("places the record beside the sessions tree, not inside it", () => {
expect(servingHeartbeatDir(forwardingDir)).toBe(
join(forwardingDir, "serving"),
);
expect(servingHeartbeatPath(forwardingDir, "sess-1")).toBe(
join(forwardingDir, "serving", "sess-1.json"),
);
});
it("encodes a session id that would otherwise escape the directory", () => {
expect(servingHeartbeatPath(forwardingDir, "a/../b")).toBe(
join(forwardingDir, "serving", "a%2F..%2Fb.json"),
);
});
});
describe("ServingHeartbeatStore.markServing", () => {
it("publishes the session id, the serving process, and the write time", () => {
const { store } = makeStore();
store.markServing("sess-1");
expect(readRecord("sess-1")).toEqual({
sessionId: "sess-1",
pid: 4242,
updatedAt: clock,
});
});
it("creates the record owner-only inside an owner-only directory", () => {
const { store } = makeStore();
store.markServing("sess-1");
expect(
statSync(servingHeartbeatPath(forwardingDir, "sess-1")).mode & 0o777,
).toBe(0o600);
expect(statSync(servingHeartbeatDir(forwardingDir)).mode & 0o777).toBe(
0o700,
);
});
it("does not rewrite within the refresh window, so a per-tick caller is cheap", () => {
const { store } = makeStore();
store.markServing("sess-1");
clock += SERVING_HEARTBEAT_REFRESH_MS - 1;
store.markServing("sess-1");
expect(readRecord("sess-1").updatedAt).toBe(
clock - (SERVING_HEARTBEAT_REFRESH_MS - 1),
);
});
it("rewrites once the refresh window has elapsed", () => {
const { store } = makeStore();
store.markServing("sess-1");
clock += SERVING_HEARTBEAT_REFRESH_MS;
store.markServing("sess-1");
expect(readRecord("sess-1").updatedAt).toBe(clock);
});
it("rewrites immediately for a different session id", () => {
const { store } = makeStore();
store.markServing("sess-1");
clock += 1;
store.markServing("sess-2");
expect(readRecord("sess-2").updatedAt).toBe(clock);
expect(readRecord("sess-1").updatedAt).toBe(clock - 1);
});
it("republishes at the next refresh boundary when the record was removed underneath it", () => {
// The gap is bounded by the refresh window, which is shorter than the
// grace a forwarding child waits out — so a pruned or externally deleted
// record cannot make a live session look unserved for long enough to
// abandon a request.
const { store } = makeStore();
store.markServing("sess-1");
rmSync(servingHeartbeatPath(forwardingDir, "sess-1"));
clock += SERVING_HEARTBEAT_REFRESH_MS;
store.markServing("sess-1");
expect(readRecord("sess-1").updatedAt).toBe(clock);
});
it("reports an unusable directory instead of throwing out of the poll timer", () => {
writeFileSync(join(root, "blocker"), "not a directory", "utf-8");
const { store, logger } = makeStore({
forwardingDir: join(root, "blocker", "forwarding"),
});
expect(() => {
store.markServing("sess-1");
}).not.toThrow();
expect(logger.review).toHaveBeenCalledWith(
"permission_forwarding.error",
expect.objectContaining({ message: expect.stringContaining("serving") }),
);
});
});
describe("ServingHeartbeatStore.clearServing", () => {
it("withdraws the record", () => {
const { store } = makeStore();
store.markServing("sess-1");
store.clearServing("sess-1");
expect(existsSync(servingHeartbeatPath(forwardingDir, "sess-1"))).toBe(
false,
);
});
it("leaves the directory in place, so a sibling session's write cannot race it", () => {
const { store } = makeStore();
store.markServing("sess-1");
store.clearServing("sess-1");
expect(existsSync(servingHeartbeatDir(forwardingDir))).toBe(true);
});
it("leaves a sibling session's record untouched", () => {
const { store } = makeStore();
store.markServing("sess-1");
store.markServing("sess-2");
store.clearServing("sess-1");
expect(readRecord("sess-2").sessionId).toBe("sess-2");
});
it("is a no-op for a session that was never marked", () => {
const { store, logger } = makeStore();
expect(() => {
store.clearServing("sess-1");
}).not.toThrow();
expect(logger.review).not.toHaveBeenCalled();
});
it("republishes after a withdrawal rather than staying throttled", () => {
const { store } = makeStore();
store.markServing("sess-1");
store.clearServing("sess-1");
clock += 1;
store.markServing("sess-1");
expect(readRecord("sess-1").updatedAt).toBe(clock);
});
});
/** Publishes a record directly, standing in for another process's session. */
function publishRecord(
sessionId: string,
overrides: Partial<ServingHeartbeat> = {},
): void {
mkdirSync(servingHeartbeatDir(forwardingDir), { recursive: true });
writeFileSync(
servingHeartbeatPath(forwardingDir, sessionId),
JSON.stringify({ sessionId, pid: 4242, updatedAt: clock, ...overrides }),
"utf-8",
);
}
/** Publishes an unusable record, standing in for a truncated or foreign write. */
function publishRaw(sessionId: string, contents: string): void {
mkdirSync(servingHeartbeatDir(forwardingDir), { recursive: true });
writeFileSync(
servingHeartbeatPath(forwardingDir, sessionId),
contents,
"utf-8",
);
}
/** Only pid 4242 is running, unless a test says otherwise. */
const onlyOwnPidAlive = (pid: number): boolean => pid === 4242;
describe("ServingHeartbeatStore.read", () => {
it("reports absent when the session has published nothing", () => {
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.read("sess-1")).toBe("absent");
});
it("reports alive for a fresh record whose process is running", () => {
publishRecord("sess-1");
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.read("sess-1")).toBe("alive");
});
it("reports alive one tick short of the staleness window", () => {
publishRecord("sess-1");
clock += SERVING_HEARTBEAT_STALE_MS - 1;
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.read("sess-1")).toBe("alive");
});
it("reports stale once the record outlives the staleness window", () => {
publishRecord("sess-1");
clock += SERVING_HEARTBEAT_STALE_MS;
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.read("sess-1")).toBe("stale");
});
it("reports dead_pid when the recorded process is gone", () => {
publishRecord("sess-1", { pid: 9999 });
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.read("sess-1")).toBe("dead_pid");
});
it("names the dead process rather than the age, when the record is both", () => {
publishRecord("sess-1", { pid: 9999 });
clock += SERVING_HEARTBEAT_STALE_MS;
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.read("sess-1")).toBe("dead_pid");
});
it("reports absent for an unparseable record", () => {
publishRaw("sess-1", "{ truncated");
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.read("sess-1")).toBe("absent");
});
it("reports absent rather than probing a pid that names no process", () => {
// `process.kill(0, 0)` addresses the caller's own process group, so a
// malformed record must never reach the liveness probe.
publishRecord("sess-1", { pid: 0 });
const isProcessAlive = vi.fn(onlyOwnPidAlive);
const { store } = makeStore({ isProcessAlive });
expect(store.read("sess-1")).toBe("absent");
expect(isProcessAlive).not.toHaveBeenCalled();
});
it("does not flood the log while a child polls an unreadable record", () => {
publishRaw("sess-1", "{ truncated");
const { store, logger } = makeStore({ isProcessAlive: onlyOwnPidAlive });
store.read("sess-1");
store.read("sess-1");
expect(logger.review).not.toHaveBeenCalled();
});
});
describe("ServingHeartbeatStore.servingIds", () => {
it("is empty when nothing has been published", () => {
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.servingIds()).toEqual([]);
});
it("lists the sessions whose records read as alive", () => {
publishRecord("sess-1");
publishRecord("sess-2");
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect([...store.servingIds()].sort()).toEqual(["sess-1", "sess-2"]);
});
it("omits a session whose process is gone", () => {
publishRecord("sess-1");
publishRecord("sess-2", { pid: 9999 });
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.servingIds()).toEqual(["sess-1"]);
});
it("reports the session's own id, not its encoded filename", () => {
publishRecord("a/b");
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
expect(store.servingIds()).toEqual(["a/b"]);
});
});
describe("ServingHeartbeatStore pruning", () => {
it("removes a record left behind by a process that is gone", () => {
publishRecord("dead-session", { pid: 9999 });
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
store.markServing("sess-1");
expect(
existsSync(servingHeartbeatPath(forwardingDir, "dead-session")),
).toBe(false);
});
it("removes a record no reader could use", () => {
publishRaw("corrupt-session", "{ truncated");
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
store.markServing("sess-1");
expect(
existsSync(servingHeartbeatPath(forwardingDir, "corrupt-session")),
).toBe(false);
});
it("keeps a stale record whose process is still running", () => {
// Being behind on refreshes is not proof of death, and the reader already
// reports it as stale without the record having to be removed.
publishRecord("slow-session", {
updatedAt: clock - SERVING_HEARTBEAT_STALE_MS,
});
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
store.markServing("sess-1");
expect(
existsSync(servingHeartbeatPath(forwardingDir, "slow-session")),
).toBe(true);
});
it("publishes its own record alongside the sweep", () => {
publishRecord("dead-session", { pid: 9999 });
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
store.markServing("sess-1");
expect(readRecord("sess-1").sessionId).toBe("sess-1");
});
it("sweeps once per session rather than on every refresh", () => {
const { store } = makeStore({ isProcessAlive: onlyOwnPidAlive });
store.markServing("sess-1");
publishRecord("dead-session", { pid: 9999 });
clock += SERVING_HEARTBEAT_REFRESH_MS;
store.markServing("sess-1");
expect(
existsSync(servingHeartbeatPath(forwardingDir, "dead-session")),
).toBe(true);
});
});
const REGISTRY_TARGET: PermissionForwardingTarget = {
sessionId: "parent",
source: "registry",
};
const ENV_TARGET: PermissionForwardingTarget = {
sessionId: "parent",
source: "env",
};
const SELF_TARGET: PermissionForwardingTarget = {
sessionId: "parent",
source: "self",
};
function makeRegistry(marked: string[] = []) {
return {
isServing: vi.fn((sessionId: string) => marked.includes(sessionId)),
servingIds: vi.fn((): readonly string[] => marked),
};
}
function makeHeartbeats(state: HeartbeatState, ids: string[] = []) {
return {
read: vi.fn((): HeartbeatState => state),
servingIds: vi.fn((): readonly string[] => ids),
};
}
describe("ForwardingLivenessJudge.isServing", () => {
it("answers an in-process target from the registry", () => {
const judge = new ForwardingLivenessJudge({
registry: makeRegistry(["parent"]),
heartbeats: makeHeartbeats("absent"),
});
expect(judge.isServing(REGISTRY_TARGET)).toBe(true);
});
it("reports an unmarked in-process target as not serving", () => {
const judge = new ForwardingLivenessJudge({
registry: makeRegistry(),
heartbeats: makeHeartbeats("alive"),
});
expect(judge.isServing(REGISTRY_TARGET)).toBe(false);
});
it("answers an out-of-process target from the filesystem heartbeat", () => {
const judge = new ForwardingLivenessJudge({
registry: makeRegistry(),
heartbeats: makeHeartbeats("alive"),
});
expect(judge.isServing(ENV_TARGET)).toBe(true);
});
it.each([
"absent",
"stale",
"dead_pid",
] as const)("reports an out-of-process target as not serving when its heartbeat is %s", (state) => {
const judge = new ForwardingLivenessJudge({
registry: makeRegistry(["parent"]),
heartbeats: makeHeartbeats(state),
});
expect(judge.isServing(ENV_TARGET)).toBe(false);
});
it("declines to judge a session that owns the inbox it is forwarding to", () => {
const judge = new ForwardingLivenessJudge({
registry: makeRegistry(),
heartbeats: makeHeartbeats("absent"),
});
expect(judge.isServing(SELF_TARGET)).toBeNull();
});
it("does not touch the filesystem for an in-process target", () => {
const heartbeats = makeHeartbeats("absent");
const judge = new ForwardingLivenessJudge({
registry: makeRegistry(["parent"]),
heartbeats,
});
judge.isServing(REGISTRY_TARGET);
expect(heartbeats.read).not.toHaveBeenCalled();
});
it("does not consult the registry for an out-of-process target", () => {
// Its parent lives in another process, so an absent mark would say nothing
// — reading one would fast-fail every out-of-process child.
const registry = makeRegistry();
const judge = new ForwardingLivenessJudge({
registry,
heartbeats: makeHeartbeats("alive"),
});
judge.isServing(ENV_TARGET);
expect(registry.isServing).not.toHaveBeenCalled();
});
});
describe("ForwardingLivenessJudge.describe", () => {
it("names the registry channel and the ids it observed", () => {
const judge = new ForwardingLivenessJudge({
registry: makeRegistry(["other-parent"]),
heartbeats: makeHeartbeats("alive", ["unrelated"]),
});
expect(judge.describe(REGISTRY_TARGET)).toEqual({
channel: "registry",
state: null,
servingIds: ["other-parent"],
});
});
it("names the heartbeat channel, the state it read, and the ids it observed", () => {
const judge = new ForwardingLivenessJudge({
registry: makeRegistry(["unrelated"]),
heartbeats: makeHeartbeats("dead_pid", ["other-parent"]),
});
expect(judge.describe(ENV_TARGET)).toEqual({
channel: "heartbeat",
state: "dead_pid",
servingIds: ["other-parent"],
});
});
it("reports no channel for a target it does not judge", () => {
const judge = new ForwardingLivenessJudge({
registry: makeRegistry(["unrelated"]),
heartbeats: makeHeartbeats("alive", ["unrelated"]),
});
expect(judge.describe(SELF_TARGET)).toEqual({
channel: "none",
state: null,
servingIds: [],
});
});
});
@@ -0,0 +1,324 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ForwardingManager } from "#src/authority/forwarding-manager";
import {
type ServingAnnouncer,
ServingSessionRegistry,
} from "#src/authority/serving-registry";
import type { SubagentDetector } from "#src/authority/subagent-detection";
// ── Mocks ─────────────────────────────────────────────────────────────────
const mockProcessInbox = vi.fn((): Promise<void> => Promise.resolve());
const mockIsSubagent = vi.fn((): boolean => false);
const mockReview = vi.fn();
// ── Helpers ───────────────────────────────────────────────────────────────
function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
return {
hasUI: overrides.hasUI ?? true,
sessionManager: {
getSessionId: vi.fn().mockReturnValue(overrides.sessionId ?? "sess-1"),
},
cwd: "/project",
} as unknown as import("@earendil-works/pi-coding-agent").ExtensionContext;
}
function makeForwarder() {
return { processInbox: mockProcessInbox };
}
function makeDetection(): SubagentDetector {
return { isSubagent: mockIsSubagent };
}
/** A `ServingAnnouncer` whose calls can be counted, for the refresh tests. */
function makeAnnouncer() {
return { markServing: vi.fn(), clearServing: vi.fn() };
}
function makeManager(serving: ServingAnnouncer = new ServingSessionRegistry()) {
return new ForwardingManager({
detection: makeDetection(),
forwarder: makeForwarder(),
serving,
logger: { review: mockReview, debug: vi.fn() },
});
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe("ForwardingManager", () => {
beforeEach(() => {
vi.useFakeTimers();
mockIsSubagent.mockReset();
mockIsSubagent.mockReturnValue(false);
mockProcessInbox.mockReset();
mockProcessInbox.mockResolvedValue(undefined);
mockReview.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
describe("stop()", () => {
it("is a no-op when not started", () => {
const manager = makeManager();
expect(() => manager.stop()).not.toThrow();
});
it("clears the timer and processing state after start()", async () => {
const manager = makeManager();
const ctx = makeCtx();
manager.start(ctx);
manager.stop();
// After stop, the timer fires no more callbacks.
mockProcessInbox.mockClear();
await vi.advanceTimersByTimeAsync(500);
expect(mockProcessInbox).not.toHaveBeenCalled();
});
});
describe("start()", () => {
it("does not start polling when hasUI is false", async () => {
const manager = makeManager();
const ctx = makeCtx({ hasUI: false });
manager.start(ctx);
await vi.advanceTimersByTimeAsync(500);
expect(mockProcessInbox).not.toHaveBeenCalled();
});
it("stops any existing poll and does not start a new one when hasUI is false", async () => {
const manager = makeManager();
const uiCtx = makeCtx({ hasUI: true });
const noUiCtx = makeCtx({ hasUI: false });
manager.start(uiCtx);
// Now stop the polling by calling start() with no-UI ctx.
manager.start(noUiCtx);
mockProcessInbox.mockClear();
await vi.advanceTimersByTimeAsync(500);
expect(mockProcessInbox).not.toHaveBeenCalled();
});
it("does not start polling when the detector reports a subagent context", async () => {
mockIsSubagent.mockReturnValue(true);
const manager = makeManager();
const ctx = makeCtx();
manager.start(ctx);
await vi.advanceTimersByTimeAsync(500);
expect(mockProcessInbox).not.toHaveBeenCalled();
});
it("stops any existing poll when called with a subagent context", async () => {
mockIsSubagent.mockReturnValueOnce(false);
const manager = makeManager();
const ctx1 = makeCtx();
manager.start(ctx1);
// Second call with a subagent context.
mockIsSubagent.mockReturnValue(true);
const ctx2 = makeCtx();
manager.start(ctx2);
mockProcessInbox.mockClear();
await vi.advanceTimersByTimeAsync(500);
expect(mockProcessInbox).not.toHaveBeenCalled();
});
it("starts polling and calls processInbox on tick", async () => {
const manager = makeManager();
const ctx = makeCtx();
manager.start(ctx);
await vi.advanceTimersByTimeAsync(250);
expect(mockProcessInbox).toHaveBeenCalledWith(ctx);
});
it("is idempotent — calling start() twice does not create a second timer", async () => {
const manager = makeManager();
const ctx = makeCtx();
manager.start(ctx);
manager.start(ctx);
await vi.advanceTimersByTimeAsync(250);
// Only one tick should fire per interval, not two.
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
});
it("updates the context when called again while already running", async () => {
const manager = makeManager();
const ctx1 = makeCtx({ sessionId: "sess-1" });
const ctx2 = makeCtx({ sessionId: "sess-2" });
manager.start(ctx1);
manager.start(ctx2);
await vi.advanceTimersByTimeAsync(250);
// The process call should use the newer context.
expect(mockProcessInbox).toHaveBeenCalledWith(ctx2);
});
it("skips a tick while processing is in progress", async () => {
// Make processInbox hang so processing=true persists.
let resolveProcess: () => void;
mockProcessInbox.mockReturnValue(
new Promise<void>((resolve) => {
resolveProcess = resolve;
}),
);
const manager = makeManager();
const ctx = makeCtx();
manager.start(ctx);
// First tick starts processing.
await vi.advanceTimersByTimeAsync(250);
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
// Second tick is skipped because processing flag is still true.
await vi.advanceTimersByTimeAsync(250);
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
// Resolve and a third tick should fire.
resolveProcess!();
await vi.advanceTimersByTimeAsync(250);
expect(mockProcessInbox).toHaveBeenCalledTimes(2);
});
it("consults the detector with the current context", () => {
const manager = makeManager();
const ctx = makeCtx();
manager.start(ctx);
expect(mockIsSubagent).toHaveBeenCalledWith(ctx);
});
});
describe("serving announcement", () => {
it("marks the polled session as serving", () => {
const serving = new ServingSessionRegistry();
makeManager(serving).start(makeCtx({ sessionId: "sess-1" }));
expect(serving.servingIds()).toEqual(["sess-1"]);
});
it("logs the polled session id once per session", () => {
const manager = makeManager();
const ctx = makeCtx({ sessionId: "sess-1" });
manager.start(ctx);
manager.start(ctx);
expect(mockReview).toHaveBeenCalledExactlyOnceWith(
"forwarded_permission.serving_started",
{ sessionId: "sess-1" },
);
});
it("clears the mark on stop()", () => {
const serving = new ServingSessionRegistry();
const manager = makeManager(serving);
manager.start(makeCtx({ sessionId: "sess-1" }));
manager.stop();
expect(serving.servingIds()).toEqual([]);
});
it("logs serving_stopped only when it was serving", () => {
const manager = makeManager();
manager.stop();
expect(mockReview).not.toHaveBeenCalled();
manager.start(makeCtx({ sessionId: "sess-1" }));
mockReview.mockClear();
manager.stop();
expect(mockReview).toHaveBeenCalledExactlyOnceWith(
"forwarded_permission.serving_stopped",
{ sessionId: "sess-1" },
);
});
it("moves the mark when the session id changes", () => {
const serving = new ServingSessionRegistry();
const manager = makeManager(serving);
manager.start(makeCtx({ sessionId: "sess-1" }));
manager.start(makeCtx({ sessionId: "sess-2" }));
expect(serving.servingIds()).toEqual(["sess-2"]);
});
it("clears the mark when a later context no longer qualifies", () => {
const serving = new ServingSessionRegistry();
const manager = makeManager(serving);
manager.start(makeCtx({ sessionId: "sess-1" }));
manager.start(makeCtx({ sessionId: "sess-1", hasUI: false }));
expect(serving.servingIds()).toEqual([]);
});
it("never marks a session it does not poll", () => {
const serving = new ServingSessionRegistry();
makeManager(serving).start(
makeCtx({ sessionId: "sess-1", hasUI: false }),
);
expect(serving.servingIds()).toEqual([]);
});
});
describe("serving refresh", () => {
it("re-announces on every poll tick, so the announcement cannot decay", async () => {
const serving = makeAnnouncer();
makeManager(serving).start(makeCtx({ sessionId: "sess-1" }));
serving.markServing.mockClear();
await vi.advanceTimersByTimeAsync(750);
expect(serving.markServing).toHaveBeenCalledTimes(3);
expect(serving.markServing).toHaveBeenCalledWith("sess-1");
});
it("re-announces while a drain is still in flight", async () => {
// A human deliberating at a forwarded dialog holds `processInbox` open
// for as long as they take. That session is serving, and must not read as
// gone to another child while it waits — so the refresh cannot sit behind
// the processing guard.
mockProcessInbox.mockReturnValue(new Promise<void>(() => undefined));
const serving = makeAnnouncer();
makeManager(serving).start(makeCtx({ sessionId: "sess-1" }));
await vi.advanceTimersByTimeAsync(250);
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
serving.markServing.mockClear();
await vi.advanceTimersByTimeAsync(750);
expect(serving.markServing).toHaveBeenCalledTimes(3);
});
it("adds no review entry per refresh", async () => {
makeManager(makeAnnouncer()).start(makeCtx({ sessionId: "sess-1" }));
mockReview.mockClear();
await vi.advanceTimersByTimeAsync(1000);
expect(mockReview).not.toHaveBeenCalled();
});
it("stops re-announcing once stopped", async () => {
const serving = makeAnnouncer();
const manager = makeManager(serving);
manager.start(makeCtx({ sessionId: "sess-1" }));
manager.stop();
serving.markServing.mockClear();
await vi.advanceTimersByTimeAsync(750);
expect(serving.markServing).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,303 @@
import { describe, expect, it, vi } from "vitest";
import { LocalUserAuthorizer } from "#src/authority/local-user-authorizer";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type { requestPermissionDecision } from "#src/authority/permission-prompt-component";
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import {
makePromptDetails,
makePromptPayload,
} from "#test/helpers/prompt-details-fixtures";
import { makePromptPreferences } from "#test/helpers/prompt-view-fixtures";
// ── Helpers ─────────────────────────────────────────────────────────────────
/**
* This file's semantic defaults over the shared structural fixture: several
* cases assert `agentName` and `toolName` on a no-override call.
*/
function makeDetails(
overrides?: Partial<PromptPermissionDetails>,
): PromptPermissionDetails {
return makePromptDetails({
requestId: "req-123",
agentName: "test-agent",
toolName: "read",
...overrides,
});
}
/** A `PermissionPromptUi` double; the tool-expansion accessors go unused here. */
function makePromptUi() {
return {
select: vi.fn(),
input: vi.fn(),
custom: vi.fn(),
getToolsExpanded: vi.fn(() => false),
setToolsExpanded: vi.fn(),
};
}
function makeDeps(
overrides: {
requestPermissionDecision?: typeof requestPermissionDecision;
} = {},
) {
const events = {
emit: vi.fn(),
on: vi.fn().mockReturnValue(() => undefined),
};
const ui = makePromptUi();
const decisionFn =
overrides.requestPermissionDecision ??
vi.fn<typeof requestPermissionDecision>().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
return {
deps: {
ui,
mode: "tui" as const,
events,
getPromptPreferences: () => makePromptPreferences(),
requestPermissionDecision: decisionFn,
},
events,
ui,
decisionFn,
};
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe("LocalUserAuthorizer", () => {
it("emits a UI prompt event with normalized surface and value", async () => {
const { deps, events } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
await authorizer.authorize(
makeDetails({
toolName: "bash",
command: "git push",
toolInputPreview: "git push",
}),
);
expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
requestId: "req-123",
source: "tool_call",
surface: "bash",
value: "git push",
agentName: "test-agent",
request: makePromptPayload().request,
forwarding: null,
});
});
it("normalizes skill prompt events to the skill surface", async () => {
const { deps, events } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
await authorizer.authorize(
makeDetails({
source: "skill_input",
toolName: undefined,
skillName: "deploy-helper",
}),
);
expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
requestId: "req-123",
source: "skill_input",
surface: "skill",
value: "deploy-helper",
agentName: "test-agent",
request: makePromptPayload().request,
forwarding: null,
});
});
it("calls requestPermissionDecision with the threaded view, title, and payload", async () => {
const { deps, ui, decisionFn } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
const details = makeDetails();
await authorizer.authorize(details);
expect(decisionFn).toHaveBeenCalledWith(
{ mode: "tui", ui, ...makePromptPreferences() },
"Permission Required",
details.payload,
undefined,
);
});
it("passes the sessionLabel option when present", async () => {
const { deps, decisionFn } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
await authorizer.authorize(
makeDetails({ sessionLabel: "Yes, for 'read' tool" }),
);
expect(decisionFn).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
expect.anything(),
{ sessionLabel: "Yes, for 'read' tool" },
);
});
it("emits the UI event before calling requestPermissionDecision", async () => {
const calls: string[] = [];
const events = {
emit: vi.fn(() => {
calls.push("emit");
}),
on: vi.fn().mockReturnValue(() => undefined),
};
const ui = makePromptUi();
const decisionFn = vi.fn<typeof requestPermissionDecision>(() => {
calls.push("dialog");
return Promise.resolve({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
});
const authorizer = new LocalUserAuthorizer({
ui,
mode: "tui",
events,
getPromptPreferences: () => makePromptPreferences(),
requestPermissionDecision: decisionFn,
});
await authorizer.authorize(makeDetails());
expect(calls).toEqual(["emit", "dialog"]);
});
describe("forwarded provenance", () => {
it("emits a non-degraded forwarded event with populated forwarding and the child's display projection", async () => {
const { deps, events } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
await authorizer.authorize(
makeDetails({
source: "tool_call",
agentName: "Explore",
surface: "bash",
value: "git push",
forwarding: {
requesterAgentName: "Explore",
requesterSessionId: "child-session",
},
}),
);
expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
requestId: "req-123",
source: "tool_call",
surface: "bash",
value: "git push",
agentName: "Explore",
request: makePromptPayload().request,
forwarding: {
requesterAgentName: "Explore",
requesterSessionId: "child-session",
},
});
});
it("uses the '(Subagent)' dialog title when the ask is forwarded", async () => {
const { deps, ui, decisionFn } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
const details = makeDetails({
forwarding: {
requesterAgentName: "Explore",
requesterSessionId: "child-session",
},
});
await authorizer.authorize(details);
expect(decisionFn).toHaveBeenCalledWith(
{ mode: "tui", ui, ...makePromptPreferences() },
"Permission Required (Subagent)",
details.payload,
undefined,
);
});
it("offers a sessionScope when the forwarded ask carries a suggestion", async () => {
const { deps, decisionFn } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
await authorizer.authorize(
makeDetails({
toolName: "bash",
command: "git push",
forwarding: {
requesterAgentName: "Explore",
requesterSessionId: "child-session",
},
sessionApproval: { surface: "bash", patterns: ["git *"] },
}),
);
expect(decisionFn).toHaveBeenCalledWith(
expect.anything(),
"Permission Required (Subagent)",
expect.anything(),
{
sessionScope: {
subagentLabel: "This subagent ('Explore') only",
servingSessionLabel:
'The whole session — allow bash "git *" for parent and all subagents',
},
},
);
});
it("offers no sessionScope for a forwarded ask without a suggestion", async () => {
const { deps, decisionFn } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
await authorizer.authorize(
makeDetails({
forwarding: {
requesterAgentName: "Explore",
requesterSessionId: "child-session",
},
}),
);
expect(decisionFn).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
expect.anything(),
undefined,
);
});
});
it("returns the decision from requestPermissionDecision", async () => {
const decision: PermissionPromptDecision = {
approved: false,
state: "denied",
decidedBy: DECIDED_BY_HUMAN,
};
const { deps } = makeDeps({
requestPermissionDecision: vi
.fn<typeof requestPermissionDecision>()
.mockResolvedValue(decision),
});
const authorizer = new LocalUserAuthorizer(deps);
const result = await authorizer.authorize(makeDetails());
expect(result).toEqual(decision);
});
});
@@ -0,0 +1,299 @@
import { describe, expect, it, vi } from "vitest";
import {
createDeniedPermissionDecision,
isPermissionDecisionState,
normalizePermissionDenialReason,
type PermissionDecisionUi,
requestPermissionDecisionFromUi,
} from "#src/authority/permission-dialog";
describe("isPermissionDecisionState", () => {
it("accepts approved", () => {
expect(isPermissionDecisionState("approved")).toBe(true);
});
it("accepts denied", () => {
expect(isPermissionDecisionState("denied")).toBe(true);
});
it("accepts denied_with_reason", () => {
expect(isPermissionDecisionState("denied_with_reason")).toBe(true);
});
it("accepts approved_for_session", () => {
expect(isPermissionDecisionState("approved_for_session")).toBe(true);
});
it("accepts approved_for_serving_session", () => {
expect(isPermissionDecisionState("approved_for_serving_session")).toBe(
true,
);
});
it("rejects unknown strings", () => {
expect(isPermissionDecisionState("unknown")).toBe(false);
});
it("rejects non-strings", () => {
expect(isPermissionDecisionState(42)).toBe(false);
expect(isPermissionDecisionState(null)).toBe(false);
});
});
describe("requestPermissionDecisionFromUi", () => {
it("returns approved when user selects Yes", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("Yes"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: true, state: "approved" });
});
it("returns approved_for_session when user selects session option", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("Yes, for this session"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: true, state: "approved_for_session" });
});
it("returns denied when user selects No", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("No"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: false, state: "denied" });
});
it("returns denied_with_reason when user provides reason", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("No, provide reason"),
input: vi.fn().mockResolvedValue("not now"),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "not now",
});
});
it("returns denied when user selects deny-with-reason but gives empty input", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue("No, provide reason"),
input: vi.fn().mockResolvedValue(""),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: false, state: "denied" });
});
it("returns denied when user dismisses dialog (undefined)", async () => {
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue(undefined),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
);
expect(result).toEqual({ approved: false, state: "denied" });
});
it("passes four options to ui.select", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = {
select: selectFn,
input: vi.fn(),
};
await requestPermissionDecisionFromUi(ui, "Title", "Message");
const options = selectFn.mock.calls[0][1] as string[];
expect(options).toEqual([
"Yes",
"Yes, for this session",
"No",
"No, provide reason",
]);
});
it("uses custom sessionLabel when provided", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = {
select: selectFn,
input: vi.fn(),
};
await requestPermissionDecisionFromUi(ui, "Title", "Message", {
sessionLabel: 'Yes, allow "git *" for this session',
});
const options = selectFn.mock.calls[0][1] as string[];
expect(options[1]).toBe('Yes, allow "git *" for this session');
});
it("still returns approved_for_session when user selects the custom session label", async () => {
const customLabel = 'Yes, allow "git *" for this session';
const ui: PermissionDecisionUi = {
select: vi.fn().mockResolvedValue(customLabel),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionLabel: customLabel },
);
expect(result).toEqual({ approved: true, state: "approved_for_session" });
});
it("falls back to default session label when no options provided", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = {
select: selectFn,
input: vi.fn(),
};
await requestPermissionDecisionFromUi(ui, "Title", "Message");
const options = selectFn.mock.calls[0][1] as string[];
expect(options[1]).toBe("Yes, for this session");
});
describe("sessionScope two-step (forwarded asks)", () => {
const sessionScope = {
subagentLabel: "This subagent only",
servingSessionLabel: "The whole session",
};
it("opens a second scope select after the session option is chosen", async () => {
const selectFn = vi
.fn()
.mockResolvedValueOnce("Yes, for this session")
.mockResolvedValueOnce("This subagent only");
const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() };
await requestPermissionDecisionFromUi(ui, "Title", "Message", {
sessionScope,
});
expect(selectFn).toHaveBeenCalledTimes(2);
const scopeOptions = selectFn.mock.calls[1][1] as string[];
expect(scopeOptions).toEqual(["This subagent only", "The whole session"]);
});
it("maps the subagent scope to approved_for_session", async () => {
const ui: PermissionDecisionUi = {
select: vi
.fn()
.mockResolvedValueOnce("Yes, for this session")
.mockResolvedValueOnce("This subagent only"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionScope },
);
expect(result).toEqual({ approved: true, state: "approved_for_session" });
});
it("maps the whole-session scope to approved_for_serving_session", async () => {
const ui: PermissionDecisionUi = {
select: vi
.fn()
.mockResolvedValueOnce("Yes, for this session")
.mockResolvedValueOnce("The whole session"),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionScope },
);
expect(result).toEqual({
approved: true,
state: "approved_for_serving_session",
});
});
it("defaults to the least-privilege subagent scope when the scope select is cancelled", async () => {
const ui: PermissionDecisionUi = {
select: vi
.fn()
.mockResolvedValueOnce("Yes, for this session")
.mockResolvedValueOnce(undefined),
input: vi.fn(),
};
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionScope },
);
expect(result).toEqual({ approved: true, state: "approved_for_session" });
});
it("does not open the scope select when the user picks plain Yes", async () => {
const selectFn = vi.fn().mockResolvedValueOnce("Yes");
const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() };
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{ sessionScope },
);
expect(selectFn).toHaveBeenCalledTimes(1);
expect(result).toEqual({ approved: true, state: "approved" });
});
});
});
describe("normalizePermissionDenialReason", () => {
it("returns trimmed string for non-empty input", () => {
expect(normalizePermissionDenialReason(" reason ")).toBe("reason");
});
it("returns undefined for empty string", () => {
expect(normalizePermissionDenialReason("")).toBeUndefined();
});
it("returns undefined for non-string", () => {
expect(normalizePermissionDenialReason(42)).toBeUndefined();
});
});
describe("createDeniedPermissionDecision", () => {
it("returns denied_with_reason when reason provided", () => {
expect(createDeniedPermissionDecision("nope")).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "nope",
});
});
it("returns denied when no reason", () => {
expect(createDeniedPermissionDecision()).toEqual({
approved: false,
state: "denied",
});
});
});
@@ -0,0 +1,311 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createPermissionForwardingLocation,
isForwardedPermissionRequestForSession,
resolvePermissionForwardingTarget,
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
SUBAGENT_PARENT_SESSION_ENV_KEY,
} from "#src/authority/permission-forwarding";
import { makeSubagentRegistry } from "#test/helpers/forwarding-fixtures";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("SUBAGENT_PARENT_SESSION_ENV_CANDIDATES", () => {
test("is an array containing PI_AGENT_ROUTER_PARENT_SESSION_ID", () => {
expect(Array.isArray(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES)).toBe(true);
expect(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES).toContain(
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
);
});
test("contains PI_SUBAGENT_PARENT_SESSION for CLI-based subagent extensions", () => {
expect(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES).toContain(
"PI_SUBAGENT_PARENT_SESSION",
);
});
test("deprecated SUBAGENT_PARENT_SESSION_ENV_KEY equals the first candidate", () => {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- test verifying the deprecated alias
expect(SUBAGENT_PARENT_SESSION_ENV_KEY).toBe(
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0],
);
});
});
describe("resolvePermissionForwardingTarget", () => {
test("hasUI=true returns the current session ID as its own target", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: true,
isSubagent: false,
currentSessionId: "parent-session-abc",
env: {},
}),
).toEqual({ sessionId: "parent-session-abc", source: "self" });
});
test("hasUI=true with isSubagent=true still returns current session ID", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: true,
isSubagent: true,
currentSessionId: "session-xyz",
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "other" },
}),
).toEqual({ sessionId: "session-xyz", source: "self" });
});
test("hasUI=false, isSubagent=false returns null", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: false,
currentSessionId: "session-xyz",
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session-abc" },
}),
).toBeNull();
});
test("isSubagent=true, no candidates set returns null", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "session-xyz",
env: {},
}),
).toBeNull();
});
test("isSubagent=true, PI_AGENT_ROUTER_PARENT_SESSION_ID set returns its value", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "session-xyz",
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session-abc" },
}),
).toEqual({ sessionId: "parent-session-abc", source: "env" });
});
test("isSubagent=true, PI_SUBAGENT_PARENT_SESSION resolves when PI_AGENT_ROUTER_PARENT_SESSION_ID is absent", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "session-xyz",
env: {
PI_SUBAGENT_PARENT_SESSION: "parent-from-convention",
},
}),
).toEqual({ sessionId: "parent-from-convention", source: "env" });
});
test("isSubagent=true, PI_AGENT_ROUTER_PARENT_SESSION_ID takes precedence over PI_SUBAGENT_PARENT_SESSION", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "session-xyz",
env: {
PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-router",
PI_SUBAGENT_PARENT_SESSION: "parent-from-convention",
},
}),
).toEqual({ sessionId: "parent-from-router", source: "env" });
});
test("isSubagent=true, candidate value is empty string returns null", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "session-xyz",
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "" },
}),
).toBeNull();
});
test("isSubagent=true, candidate value is 'unknown' returns null", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "session-xyz",
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "unknown" },
}),
).toBeNull();
});
test("env defaults to process.env when omitted", () => {
vi.stubEnv("PI_AGENT_ROUTER_PARENT_SESSION_ID", "env-session-abc");
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
}),
).toEqual({ sessionId: "env-session-abc", source: "env" });
});
});
describe("resolvePermissionForwardingTarget — registry resolution", () => {
const childSessionId = "child-session-abc";
test("returns parentSessionId from registry when env vars are absent", () => {
const registry = makeSubagentRegistry(childSessionId, {
parentSessionId: "parent-from-registry",
});
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
sessionId: childSessionId,
registry,
env: {},
}),
).toEqual({ sessionId: "parent-from-registry", source: "registry" });
});
test("registry takes priority over env vars", () => {
const registry = makeSubagentRegistry(childSessionId, {
parentSessionId: "parent-from-registry",
});
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
sessionId: childSessionId,
registry,
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
}),
).toEqual({ sessionId: "parent-from-registry", source: "registry" });
});
test("falls through to env vars when registry entry has no parentSessionId", () => {
const registry = makeSubagentRegistry(childSessionId, {}); // no parentSessionId
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
sessionId: childSessionId,
registry,
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
}),
).toEqual({ sessionId: "parent-from-env", source: "env" });
});
test("falls through to env vars when sessionId is not in registry", () => {
const registry = makeSubagentRegistry(childSessionId); // empty
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
sessionId: childSessionId,
registry,
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
}),
).toEqual({ sessionId: "parent-from-env", source: "env" });
});
test("returns null when registry entry has no parentSessionId and no env vars set", () => {
const registry = makeSubagentRegistry(childSessionId, {}); // no parentSessionId
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
sessionId: childSessionId,
registry,
env: {},
}),
).toBeNull();
});
test("omitting registry preserves existing behaviour", () => {
expect(
resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
sessionId: childSessionId,
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
}),
).toEqual({ sessionId: "parent-from-env", source: "env" });
});
});
// ---------------------------------------------------------------------------
// Moved from permission-system.test.ts catch-all (#342)
// ---------------------------------------------------------------------------
test("Permission forwarding resolves the parent interactive session from subagent runtime env", () => {
const target = resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "child-session",
env: {
PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session",
},
});
expect(target).toEqual({ sessionId: "parent-session", source: "env" });
});
test("Permission forwarding does not guess a target session when subagent runtime env is missing", () => {
const target = resolvePermissionForwardingTarget({
hasUI: false,
isSubagent: true,
currentSessionId: "child-session",
env: {},
});
expect(target).toBe(null);
});
test("Permission forwarding uses session-scoped directories per interactive session", () => {
const forwardingRoot = join(tmpdir(), "pi-permission-system-forwarding-root");
const sessionA = createPermissionForwardingLocation(
forwardingRoot,
"session-a",
);
const sessionB = createPermissionForwardingLocation(
forwardingRoot,
"session-b",
);
expect(sessionA.sessionRootDir).not.toBe(sessionB.sessionRootDir);
expect(sessionA.requestsDir).not.toBe(sessionB.requestsDir);
expect(sessionA.responsesDir).not.toBe(sessionB.responsesDir);
});
test("Permission forwarding request routing only matches the intended UI session", () => {
expect(
isForwardedPermissionRequestForSession(
{ targetSessionId: "session-a" },
"session-a",
),
).toBe(true);
expect(
isForwardedPermissionRequestForSession(
{ targetSessionId: "session-a" },
"session-b",
),
).toBe(false);
});
test("Permission forwarding rejects unresolved sentinel session ids", () => {
const target = resolvePermissionForwardingTarget({
hasUI: true,
isSubagent: false,
currentSessionId: "unknown",
});
expect(target).toBe(null);
});
@@ -0,0 +1,595 @@
import { visibleWidth } from "@earendil-works/pi-tui";
import { describe, expect, it, vi } from "vitest";
import type {
RequestPermissionOptions,
UnattributedDecision,
} from "#src/authority/permission-dialog";
import {
type PermissionPromptUi,
type PermissionPromptView,
presentInlinePermissionPrompt,
requestPermissionDecision,
} from "#src/authority/permission-prompt-component";
import { DEFAULT_RENDER_BUDGET } from "#src/presentation/dialog-renderer";
import type { PromptPayload } from "#src/presentation/prompt-payload";
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
import { makePromptPreferences } from "#test/helpers/prompt-view-fixtures";
// ── Fake TUI view harness ────────────────────────────────────────────────────
function plainTheme() {
return {
fg(_color: string, text: string) {
return text;
},
bg(_color: string, text: string) {
return text;
},
};
}
interface CapturedComponent {
render(width: number): string[];
handleInput(data: string): void;
}
type PromptFactory = (
tui: { requestRender: () => void },
theme: ReturnType<typeof plainTheme>,
keybindings: { matches(data: string, action: string): boolean },
done: (decision: UnattributedDecision) => void,
) => CapturedComponent;
/** Pi's default binding for the `app.tools.expand` action. */
const CTRL_O = "\u000f";
function makeFakeView(
doublePressToConfirm: boolean,
expandKey = CTRL_O,
budget = DEFAULT_RENDER_BUDGET,
) {
const captured: {
component?: CapturedComponent;
options?: unknown;
} = {};
let toolsExpanded = false;
const getToolsExpanded = vi.fn(() => toolsExpanded);
const setToolsExpanded = vi.fn((expanded: boolean) => {
toolsExpanded = expanded;
});
const custom = (
factory: PromptFactory,
options: unknown,
): Promise<UnattributedDecision> => {
captured.options = options;
return new Promise<UnattributedDecision>((resolve) => {
captured.component = factory(
{ requestRender: vi.fn() },
plainTheme(),
{
matches: (data, action) =>
action === "app.tools.expand" && data === expandKey,
},
resolve,
);
});
};
const view = makeView(
"tui",
doublePressToConfirm,
{
select: vi.fn(),
input: vi.fn(),
custom,
getToolsExpanded,
setToolsExpanded,
},
budget,
);
return { view, captured, getToolsExpanded, setToolsExpanded };
}
/**
* The view the dispatcher and the inline component take.
*
* Typed as `PermissionPromptView` so a field added to it is a compile error
* here; the cast is confined to the `ui` double, whose generic `custom` a
* plain `vi.fn()` cannot satisfy.
*/
function makeView(
mode: PermissionPromptView["mode"],
doublePressToConfirm: boolean,
ui: unknown,
budget = DEFAULT_RENDER_BUDGET,
): PermissionPromptView {
return {
mode,
ui: ui as PermissionPromptUi,
...makePromptPreferences({ doublePressToConfirm, budget }),
};
}
const ARROW_DOWN = "\u001b[B";
const ENTER = "\r";
const ESCAPE = "\u001b";
/** How the terminal delivers a paste: one chunk, markers included. */
function paste(content: string): string {
return `\u001b[200~${content}\u001b[201~`;
}
/** A path ask; `path : /repo/secret.txt` is its decision-relevant line. */
function makeAsk(value = "/repo/secret.txt"): PromptPayload {
return makePromptPayload({
kind: "path",
request: {
...makePromptPayload().request,
surface: "path",
toolName: "read",
value,
matchedPattern: null,
},
});
}
const ASK = makeAsk();
/** Title, blank separator, four decision options, blank, hint. */
const DECISION_CHROME_ROWS = 8;
async function runPrompt(
doublePressToConfirm: boolean,
keys: string[],
options?: RequestPermissionOptions,
): Promise<UnattributedDecision> {
const { view, captured } = makeFakeView(doublePressToConfirm);
const promise = presentInlinePermissionPrompt(
view,
"Permission Required",
ASK,
options,
);
for (const key of keys) {
captured.component?.handleInput(key);
}
return promise;
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe("presentInlinePermissionPrompt", () => {
it("renders inline (not as an overlay) with the request facts and hotkey labels", () => {
const { view, captured } = makeFakeView(true);
void presentInlinePermissionPrompt(view, "Permission Required", ASK);
expect(captured.options).toEqual({ overlay: false });
const text = captured.component?.render(80).join("\n") ?? "";
expect(text).toContain("tool : read");
expect(text).toContain("path : /repo/secret.txt");
expect(text).toContain("Yes");
expect(text).toContain("No, provide reason");
expect(text).toContain("y");
expect(text).toContain("r");
});
it("clips every rendered line to the terminal width", () => {
const { view, captured } = makeFakeView(true);
void presentInlinePermissionPrompt(
view,
"Permission Required",
makeAsk(`~/.pi/agent/sessions/${"a".repeat(300)}`),
);
const width = 40;
const lines = captured.component?.render(width) ?? [];
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
});
describe("double-press to confirm (enabled)", () => {
it("resolves approved on y, y", async () => {
expect(await runPrompt(true, ["y", "y"])).toEqual({
approved: true,
state: "approved",
});
});
it("does not resolve on a single armed press", async () => {
const { view, captured } = makeFakeView(true);
const promise = presentInlinePermissionPrompt(
view,
"Permission Required",
ASK,
);
let settled = false;
void promise.then(() => {
settled = true;
});
captured.component?.handleInput("y");
await Promise.resolve();
expect(settled).toBe(false);
const text = captured.component?.render(80).join("\n") ?? "";
expect(text).toContain("Press y again to approve.");
});
it("resolves denied on n, n", async () => {
expect(await runPrompt(true, ["n", "n"])).toEqual({
approved: false,
state: "denied",
});
});
});
describe("double-press to confirm (disabled)", () => {
it("resolves approved on a single y", async () => {
expect(await runPrompt(false, ["y"])).toEqual({
approved: true,
state: "approved",
});
});
});
describe("navigation and escape", () => {
it("resolves the highlighted option on enter", async () => {
// y -> s -> n, then enter
expect(await runPrompt(true, [ARROW_DOWN, ARROW_DOWN, ENTER])).toEqual({
approved: false,
state: "denied",
});
});
it("denies on escape at the decision step", async () => {
expect(await runPrompt(true, [ESCAPE])).toEqual({
approved: false,
state: "denied",
});
});
it("never decides on a stray paste at the decision step", async () => {
const { view, captured } = makeFakeView(false);
const promise = presentInlinePermissionPrompt(view, "Title", ASK);
let settled = false;
void promise.then(() => {
settled = true;
});
captured.component?.handleInput(paste("y"));
captured.component?.handleInput(paste("some copied text"));
await Promise.resolve();
expect(settled).toBe(false);
captured.component?.handleInput("n");
expect(await promise).toEqual({ approved: false, state: "denied" });
});
});
describe("deny with reason", () => {
it("collects a typed reason and resolves denied_with_reason", async () => {
const decision = await runPrompt(false, ["r", "n", "o", "p", "e", ENTER]);
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "nope",
});
});
it("rejects an empty reason and shows an error, then accepts a real one", async () => {
const { view, captured } = makeFakeView(false);
const promise = presentInlinePermissionPrompt(view, "T", ASK);
captured.component?.handleInput("r"); // opens reason step
captured.component?.handleInput(ENTER); // empty submit -> rejected
const text = captured.component?.render(80).join("\n") ?? "";
expect(text).toContain("A reason is required.");
captured.component?.handleInput("x");
captured.component?.handleInput(ENTER);
expect(await promise).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "x",
});
});
it("supports backspace while editing the reason", async () => {
const decision = await runPrompt(false, [
"r",
"a",
"b",
"\u007f", // backspace removes "b"
ENTER,
]);
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "a",
});
});
it("accepts pasted text into the reason", async () => {
expect(
await runPrompt(false, ["r", paste("pasted text"), ENTER]),
).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "pasted text",
});
});
it("flattens a multi-line paste into one readable line", async () => {
expect(
await runPrompt(false, [
"r",
paste("denied because it touches\n~/.ssh"),
ENTER,
]),
).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "denied because it touches ~/.ssh",
});
});
it("keeps a pasted reason on one row, however long it is", () => {
const { view, captured } = makeFakeView(false);
void presentInlinePermissionPrompt(view, "Title", ASK);
captured.component?.handleInput("r");
const before = captured.component?.render(40) ?? [];
// "q" appears nowhere else in this render; "x" would match `secret.txt`.
captured.component?.handleInput(paste("q".repeat(500)));
const after = captured.component?.render(40) ?? [];
expect(after).toHaveLength(before.length);
expect(after.join("\n")).toContain("qqq");
for (const line of after) {
expect(visibleWidth(line)).toBeLessThanOrEqual(40);
}
});
it("drops the expand key instead of typing it into the reason", async () => {
const { view, captured, setToolsExpanded } = makeFakeView(false);
const promise = presentInlinePermissionPrompt(view, "Title", ASK);
captured.component?.handleInput("r");
captured.component?.handleInput("a");
captured.component?.handleInput(CTRL_O);
captured.component?.handleInput(ENTER);
expect(await promise).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "a",
});
expect(setToolsExpanded).not.toHaveBeenCalled();
});
it("navigates back to the decision step on escape from the reason step", async () => {
// r opens reason, esc returns to decision, then n deny
expect(await runPrompt(false, ["r", ESCAPE, "n"])).toEqual({
approved: false,
state: "denied",
});
});
});
describe("requestPermissionDecision dispatch", () => {
it("renders the inline dialog in TUI mode", async () => {
const { view, captured } = makeFakeView(true);
const promise = requestPermissionDecision(view, "Title", ASK);
expect(captured.component).toBeDefined();
captured.component?.handleInput("y");
captured.component?.handleInput("y");
expect(await promise).toEqual({
approved: true,
state: "approved",
decidedBy: { kind: "user", via: "dialog" },
});
});
it("bounds a pathological forwarded ask instead of filling the viewport", () => {
const { view, captured } = makeFakeView(true);
const body = Array.from(
{ length: 200 },
() => "- a finding line about some module in the codebase",
).join("\n");
const command = `@'\n${body}\n'@ | Out-File -FilePath report.md`;
void presentInlinePermissionPrompt(
view,
"Permission Required (Subagent)",
makePromptPayload({
kind: "forwarded",
request: {
...makePromptPayload().request,
requester: {
agentName: "scout",
forwarded: true,
sessionId: "abc123",
},
surface: "bash",
toolName: null,
value: command,
matchedPattern: null,
},
evidence: [{ label: "requested", text: command, detail: null }],
}),
);
const lines = captured.component?.render(120) ?? [];
// The same ask renders 205 rows through the unbounded flat message.
expect(lines.length).toBeLessThanOrEqual(
DEFAULT_RENDER_BUDGET.maxRows + DECISION_CHROME_ROWS,
);
expect(lines).toContain("subagent : scout · session abc123");
});
it("falls back to the select flow outside TUI mode", async () => {
const custom = vi.fn();
const select = vi.fn().mockResolvedValue("Yes");
const view = makeView("rpc", true, {
select,
input: vi.fn(),
custom,
});
const decision = await requestPermissionDecision(view, "Title", ASK);
expect(custom).not.toHaveBeenCalled();
expect(select).toHaveBeenCalledWith(
"Title\ntool : read\npath : /repo/secret.txt",
expect.any(Array),
);
expect(decision).toEqual({
approved: true,
state: "approved",
decidedBy: { kind: "user", via: "select" },
});
});
it("attributes a denial to the surface the human answered on", async () => {
const select = vi.fn().mockResolvedValue("No");
const view = makeView("rpc", true, {
select,
input: vi.fn(),
custom: vi.fn(),
});
const decision = await requestPermissionDecision(view, "Title", ASK);
// The denial is the human's, and which surface they used is what
// separates "the operator declined" from "a prompt they never saw".
expect(decision).toEqual({
approved: false,
state: "denied",
decidedBy: { kind: "user", via: "select" },
});
});
});
describe("approve-for-session scope (forwarded asks)", () => {
const options: RequestPermissionOptions = {
sessionScope: {
subagentLabel: "This subagent only",
servingSessionLabel: "The whole session",
},
};
it("commits the subagent scope by default", async () => {
expect(await runPrompt(false, ["s", ENTER], options)).toEqual({
approved: true,
state: "approved_for_session",
});
});
it("commits the serving-session scope when the second option is chosen", async () => {
expect(await runPrompt(false, ["s", ARROW_DOWN, ENTER], options)).toEqual(
{ approved: true, state: "approved_for_serving_session" },
);
});
});
describe("tool expansion", () => {
const scopeOptions: RequestPermissionOptions = {
sessionScope: {
subagentLabel: "This subagent only",
servingSessionLabel: "The whole session",
},
};
it("toggles tool expansion without settling the decision", async () => {
const { view, captured, getToolsExpanded, setToolsExpanded } =
makeFakeView(true);
const promise = presentInlinePermissionPrompt(view, "Title", ASK);
let settled = false;
void promise.then(() => {
settled = true;
});
captured.component?.handleInput(CTRL_O);
await Promise.resolve();
expect(setToolsExpanded).toHaveBeenNthCalledWith(1, true);
expect(settled).toBe(false);
captured.component?.handleInput(CTRL_O);
await Promise.resolve();
expect(setToolsExpanded).toHaveBeenNthCalledWith(2, false);
expect(settled).toBe(false);
expect(getToolsExpanded).toHaveBeenCalledTimes(2);
captured.component?.handleInput("y");
captured.component?.handleInput("y");
// Unattributed: the inline component states the outcome, and the
// dispatcher above it names the surface the human answered on.
expect(await promise).toEqual({ approved: true, state: "approved" });
});
it("toggles during the scope step without committing the grant", async () => {
const { view, captured, setToolsExpanded } = makeFakeView(false);
const promise = presentInlinePermissionPrompt(
view,
"Title",
ASK,
scopeOptions,
);
captured.component?.handleInput("s"); // decision -> scope
captured.component?.handleInput(CTRL_O);
expect(setToolsExpanded).toHaveBeenNthCalledWith(1, true);
captured.component?.handleInput(ENTER);
expect(await promise).toEqual({
approved: true,
state: "approved_for_session",
});
});
it("expands the dialog to the complete request and back", () => {
const { view, captured, setToolsExpanded } = makeFakeView(true, CTRL_O, {
maxRows: 24,
fieldMaxWidth: 10,
});
void presentInlinePermissionPrompt(
view,
"Title",
makeAsk("/repo/a/very/long/secret.txt"),
);
const bounded = captured.component?.render(120) ?? [];
expect(bounded).toContain("path : /repo/a/ve…");
expect(bounded.at(-1)).toContain("ctrl+o full request");
captured.component?.handleInput(CTRL_O);
const expanded = captured.component?.render(120) ?? [];
expect(expanded).toContain("path : /repo/a/very/long/secret.txt");
expect(expanded.at(-1)).toContain("ctrl+o collapse");
// The host's own tool expansion still follows the same keystroke (#642).
expect(setToolsExpanded).toHaveBeenCalledWith(true);
captured.component?.handleInput(CTRL_O);
expect(captured.component?.render(120)).toEqual(bounded);
});
it("advertises the affordance only when the render left something out", () => {
const { view, captured } = makeFakeView(true);
void presentInlinePermissionPrompt(view, "Title", ASK);
expect(captured.component?.render(120).at(-1)).not.toContain("ctrl+o");
});
it("does not intercept the expand key while a denial reason is typed", async () => {
// Bound to a printable key on purpose: the default Ctrl+O is a control
// character the reason editor rejects anyway, so it cannot discriminate.
const { view, captured, setToolsExpanded } = makeFakeView(false, "e");
const promise = presentInlinePermissionPrompt(view, "Title", ASK);
captured.component?.handleInput("r"); // decision -> reason
captured.component?.handleInput("e"); // typed literally, not an app action
captured.component?.handleInput(ENTER);
expect(await promise).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "e",
});
expect(setToolsExpanded).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,384 @@
import { describe, expect, it } from "vitest";
import {
initialPromptState,
type PromptModelConfig,
reducePrompt,
} from "#src/authority/permission-prompt-decision";
// ── Helpers ─────────────────────────────────────────────────────────────────
function makeConfig(
overrides: Partial<PromptModelConfig> = {},
): PromptModelConfig {
return {
doublePressToConfirm: true,
sessionLabel: "Yes, for this session",
...overrides,
};
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe("reducePrompt", () => {
describe("initial state", () => {
it("starts on the decision step highlighting approve with nothing armed", () => {
const state = initialPromptState(makeConfig());
expect(state).toEqual({
step: "decision",
highlightedKey: "y",
armedKey: undefined,
hint: "",
reasonError: undefined,
scopeServing: false,
});
});
});
describe("double-press to confirm (enabled)", () => {
it("arms the option on the first hotkey press without deciding", () => {
const config = makeConfig();
const outcome = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "y",
});
expect(outcome).toEqual({
kind: "render",
state: {
step: "decision",
highlightedKey: "y",
armedKey: "y",
hint: "Press y again to approve.",
reasonError: undefined,
scopeServing: false,
},
});
});
it("commits the decision on the confirming second press of the same key", () => {
const config = makeConfig();
const armed = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "y",
});
if (armed.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, armed.state, {
type: "hotkey",
key: "y",
});
expect(outcome).toEqual({
kind: "decision",
decision: { approved: true, state: "approved" },
});
});
it("re-arms when a different hotkey is pressed", () => {
const config = makeConfig();
const armedY = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "y",
});
if (armedY.kind !== "render") throw new Error("expected render");
const armedN = reducePrompt(config, armedY.state, {
type: "hotkey",
key: "n",
});
expect(armedN).toEqual({
kind: "render",
state: {
step: "decision",
highlightedKey: "n",
armedKey: "n",
hint: "Press n again to deny.",
reasonError: undefined,
scopeServing: false,
},
});
});
it("commits deny on the second press of n", () => {
const config = makeConfig();
const armed = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "n",
});
if (armed.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, armed.state, {
type: "hotkey",
key: "n",
});
expect(outcome).toEqual({
kind: "decision",
decision: { approved: false, state: "denied" },
});
});
});
describe("double-press to confirm (disabled)", () => {
it("commits immediately on the first hotkey press", () => {
const config = makeConfig({ doublePressToConfirm: false });
const outcome = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "y",
});
expect(outcome).toEqual({
kind: "decision",
decision: { approved: true, state: "approved" },
});
});
});
describe("navigation and enter", () => {
it("moves the highlight and clears any armed key without deciding", () => {
const config = makeConfig();
const armed = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "y",
});
if (armed.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, armed.state, {
type: "nav",
direction: "down",
});
expect(outcome).toEqual({
kind: "render",
state: {
step: "decision",
highlightedKey: "s",
armedKey: undefined,
hint: "",
reasonError: undefined,
scopeServing: false,
},
});
});
it("wraps the highlight from the last option back to the first", () => {
const config = makeConfig();
let state = initialPromptState(config);
for (const _ of [0, 1, 2, 3]) {
const outcome = reducePrompt(config, state, {
type: "nav",
direction: "up",
});
if (outcome.kind !== "render") throw new Error("expected render");
state = outcome.state;
}
// up from y wraps to r, then walks r→n→s→y over four presses
expect(state.highlightedKey).toBe("y");
});
it("confirms the highlighted option in a single enter press even when double-press is enabled", () => {
const config = makeConfig();
const down = reducePrompt(config, initialPromptState(config), {
type: "nav",
direction: "down",
});
if (down.kind !== "render") throw new Error("expected render");
// highlight is now s; move once more to n
const down2 = reducePrompt(config, down.state, {
type: "nav",
direction: "down",
});
if (down2.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, down2.state, { type: "confirm" });
expect(outcome).toEqual({
kind: "decision",
decision: { approved: false, state: "denied" },
});
});
});
describe("escape", () => {
it("denies from the decision step", () => {
const config = makeConfig();
const outcome = reducePrompt(config, initialPromptState(config), {
type: "cancel",
});
expect(outcome).toEqual({
kind: "decision",
decision: { approved: false, state: "denied" },
});
});
});
describe("deny with reason", () => {
it("opens the reason step on confirming r (double-press enabled)", () => {
const config = makeConfig();
const armed = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "r",
});
if (armed.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, armed.state, {
type: "hotkey",
key: "r",
});
expect(outcome).toEqual({
kind: "render",
state: {
step: "reason",
highlightedKey: "r",
armedKey: undefined,
hint: "",
reasonError: undefined,
scopeServing: false,
},
});
});
it("opens the reason step immediately when double-press is disabled", () => {
const config = makeConfig({ doublePressToConfirm: false });
const outcome = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "r",
});
expect(outcome.kind).toBe("render");
if (outcome.kind !== "render") throw new Error("expected render");
expect(outcome.state.step).toBe("reason");
});
it("rejects an empty reason and keeps the reason step open", () => {
const config = makeConfig({ doublePressToConfirm: false });
const opened = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "r",
});
if (opened.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, opened.state, {
type: "submitReason",
draft: " ",
});
expect(outcome).toEqual({
kind: "render",
state: {
step: "reason",
highlightedKey: "r",
armedKey: undefined,
hint: "",
reasonError: "A reason is required.",
scopeServing: false,
},
});
});
it("commits a denied_with_reason decision for a non-empty reason", () => {
const config = makeConfig({ doublePressToConfirm: false });
const opened = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "r",
});
if (opened.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, opened.state, {
type: "submitReason",
draft: " not now ",
});
expect(outcome).toEqual({
kind: "decision",
decision: {
approved: false,
state: "denied_with_reason",
denialReason: "not now",
},
});
});
it("navigates back to the decision step on escape from the reason step", () => {
const config = makeConfig({ doublePressToConfirm: false });
const opened = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "r",
});
if (opened.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, opened.state, { type: "cancel" });
expect(outcome).toEqual({
kind: "render",
state: {
step: "decision",
highlightedKey: "r",
armedKey: undefined,
hint: "",
reasonError: undefined,
scopeServing: false,
},
});
});
});
describe("approve-for-session scope (forwarded asks)", () => {
const sessionScope = {
subagentLabel: "This subagent only",
servingSessionLabel: "The whole session",
};
it("opens the scope step when s is confirmed and a sessionScope is offered", () => {
const config = makeConfig({ doublePressToConfirm: false, sessionScope });
const outcome = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "s",
});
expect(outcome.kind).toBe("render");
if (outcome.kind !== "render") throw new Error("expected render");
expect(outcome.state.step).toBe("scope");
expect(outcome.state.scopeServing).toBe(false);
});
it("commits the least-privilege subagent scope by default", () => {
const config = makeConfig({ doublePressToConfirm: false, sessionScope });
const opened = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "s",
});
if (opened.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, opened.state, { type: "confirm" });
expect(outcome).toEqual({
kind: "decision",
decision: { approved: true, state: "approved_for_session" },
});
});
it("commits the serving-session scope when the second option is chosen", () => {
const config = makeConfig({ doublePressToConfirm: false, sessionScope });
const opened = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "s",
});
if (opened.kind !== "render") throw new Error("expected render");
const moved = reducePrompt(config, opened.state, {
type: "nav",
direction: "down",
});
if (moved.kind !== "render") throw new Error("expected render");
expect(moved.state.scopeServing).toBe(true);
const outcome = reducePrompt(config, moved.state, { type: "confirm" });
expect(outcome).toEqual({
kind: "decision",
decision: { approved: true, state: "approved_for_serving_session" },
});
});
it("navigates back to the decision step on escape from the scope step", () => {
const config = makeConfig({ doublePressToConfirm: false, sessionScope });
const opened = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "s",
});
if (opened.kind !== "render") throw new Error("expected render");
const outcome = reducePrompt(config, opened.state, { type: "cancel" });
expect(outcome.kind).toBe("render");
if (outcome.kind !== "render") throw new Error("expected render");
expect(outcome.state.step).toBe("decision");
});
it("commits approved_for_session directly when no sessionScope is offered", () => {
const config = makeConfig({ doublePressToConfirm: false });
const outcome = reducePrompt(config, initialPromptState(config), {
type: "hotkey",
key: "s",
});
expect(outcome).toEqual({
kind: "decision",
decision: { approved: true, state: "approved_for_session" },
});
});
});
});
@@ -0,0 +1,340 @@
import { describe, expect, it, vi } from "vitest";
import type { TerminalAuthorizer } from "#src/authority/authorizer";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import {
PermissionPrompter,
type PermissionPrompterDeps,
type PromptPermissionDetails,
} from "#src/authority/permission-prompter";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import {
makePromptDetails,
makePromptPayload,
} from "#test/helpers/prompt-details-fixtures";
// ── Helpers ─────────────────────────────────────────────────────────────────
/**
* A terminal stub returning a fixed decision.
*
* The default is filler for the tests whose subject is the review entry's
* shape rather than the outcome; a test asserting a particular outcome passes
* its own decision.
*/
function makeAuthorizer(
decision: PermissionPromptDecision = {
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
},
): TerminalAuthorizer {
return {
authorize: vi
.fn<TerminalAuthorizer["authorize"]>()
.mockResolvedValue(decision),
};
}
/**
* This file's semantic defaults over the shared structural fixture: the review
* entries assert `agentName` and `toolName` on a no-override call.
*/
function makeDetails(
overrides?: Partial<PromptPermissionDetails>,
): PromptPermissionDetails {
return makePromptDetails({
requestId: "req-123",
agentName: "test-agent",
toolName: "read",
...overrides,
});
}
function makeDeps(
overrides?: Partial<PermissionPrompterDeps>,
): PermissionPrompterDeps {
return {
logger: { review: vi.fn() },
...overrides,
};
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("PermissionPrompter", () => {
describe("prompt flow", () => {
it("logs permission_request.waiting before the outcome", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer();
await prompter.prompt(authorizer, makeDetails());
const calls = logger.review.mock.calls.map((c) => c[0] as string);
expect(
calls.indexOf("permission_request.waiting"),
).toBeGreaterThanOrEqual(0);
expect(calls.indexOf("permission_request.waiting")).toBeLessThan(
calls.indexOf("permission_request.approved"),
);
});
it("calls authorizer.authorize with the details", async () => {
const authorizer = makeAuthorizer();
const prompter = new PermissionPrompter(makeDeps());
const details = makeDetails();
await prompter.prompt(authorizer, details);
expect(authorizer.authorize).toHaveBeenCalledWith(details);
});
it("logs permission_request.approved when the authorizer approves", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
await prompter.prompt(authorizer, makeDetails());
expect(logger.review).toHaveBeenCalledWith(
"permission_request.approved",
expect.objectContaining({
requestId: "req-123",
resolution: "approved",
}),
);
});
it("logs permission_request.denied when the authorizer denies", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer({
approved: false,
state: "denied",
decidedBy: DECIDED_BY_HUMAN,
});
await prompter.prompt(authorizer, makeDetails());
expect(logger.review).toHaveBeenCalledWith(
"permission_request.denied",
expect.objectContaining({
requestId: "req-123",
resolution: "denied",
}),
);
});
it("logs confirmation_unavailable resolution when the decision carries the marker", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: DECIDED_BY_HUMAN,
});
await prompter.prompt(authorizer, makeDetails());
expect(logger.review).toHaveBeenCalledWith(
"permission_request.denied",
expect.objectContaining({
resolution: "confirmation_unavailable",
}),
);
});
it("logs permission_request.denied with denialReason when present", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer({
approved: false,
state: "denied_with_reason",
denialReason: "too sensitive",
decidedBy: DECIDED_BY_HUMAN,
});
await prompter.prompt(authorizer, makeDetails());
expect(logger.review).toHaveBeenCalledWith(
"permission_request.denied",
expect.objectContaining({
denialReason: "too sensitive",
}),
);
});
it("records who decided on the outcome entry", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer({
approved: true,
state: "approved",
decidedBy: { kind: "user", via: "dialog" },
});
await prompter.prompt(authorizer, makeDetails());
expect(logger.review).toHaveBeenCalledWith(
"permission_request.approved",
expect.objectContaining({
decidedBy: { kind: "user", via: "dialog" },
}),
);
});
it("records the decider on a denial too", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: { kind: "unavailable", reason: "nobody was home" },
});
await prompter.prompt(authorizer, makeDetails());
expect(logger.review).toHaveBeenCalledWith(
"permission_request.denied",
expect.objectContaining({
decidedBy: { kind: "unavailable", reason: "nobody was home" },
}),
);
});
it("leaves the waiting entry unattributed — nothing has decided yet", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
await prompter.prompt(makeAuthorizer(), makeDetails());
const waiting = logger.review.mock.calls.find(
(call) => call[0] === "permission_request.waiting",
);
expect(waiting?.[1]).not.toHaveProperty("decidedBy");
});
it("returns the decision from the authorizer", async () => {
const decision: PermissionPromptDecision = {
approved: false,
state: "denied_with_reason",
decidedBy: DECIDED_BY_HUMAN,
denialReason: "sensitive",
};
const authorizer = makeAuthorizer(decision);
const prompter = new PermissionPrompter(makeDeps());
const result = await prompter.prompt(authorizer, makeDetails());
expect(result).toEqual(decision);
});
});
// ── Review log field coverage ────────────────────────────────────────────
describe("review log fields", () => {
it("includes all standard fields in the waiting log entry", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer();
const details = makeDetails({
toolCallId: "tc-1",
skillName: "librarian",
path: "/src/foo.ts",
command: "git status",
target: "server:tool",
toolInputPreview: "{ path: '...' }",
});
await prompter.prompt(authorizer, details);
expect(logger.review).toHaveBeenCalledWith(
"permission_request.waiting",
expect.objectContaining({
requestId: "req-123",
source: "tool_call",
agentName: "test-agent",
toolCallId: "tc-1",
toolName: "read",
skillName: "librarian",
path: "/src/foo.ts",
command: "git status",
target: "server:tool",
toolInputPreview: "{ path: '...' }",
}),
);
});
it("uses null for optional fields not present in details", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer();
await prompter.prompt(authorizer, makeDetails());
expect(logger.review).toHaveBeenCalledWith(
"permission_request.waiting",
expect.objectContaining({
toolCallId: null,
skillName: null,
path: null,
command: null,
target: null,
toolInputPreview: null,
}),
);
});
it("records the payload's request facts rather than its prompt wording", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer();
await prompter.prompt(
authorizer,
makeDetails({
payload: makePromptPayload({
kind: "bash",
request: {
...makePromptPayload().request,
surface: "bash",
toolName: "bash",
value: "rm -rf build",
matchedPattern: "rm *",
},
}),
}),
);
expect(logger.review).toHaveBeenCalledWith(
"permission_request.waiting",
expect.objectContaining({ surface: "bash", matchedPattern: "rm *" }),
);
});
it("persists neither the payload nor the prompt sentence", async () => {
const logger = { review: vi.fn() };
const prompter = new PermissionPrompter(makeDeps({ logger }));
const authorizer = makeAuthorizer();
await prompter.prompt(authorizer, makeDetails());
// ADR 0010 bounds what the logs accumulate; a complete payload written on
// every ask would defeat that bound, and a prompt sentence made the log's
// growth a side effect of how the prompt happened to be worded.
const [, entry] = logger.review.mock.calls[0] as [
string,
Record<string, unknown>,
];
expect(entry).not.toHaveProperty("payload");
expect(entry).not.toHaveProperty("message");
expect(entry).not.toHaveProperty("evidence");
});
});
});
@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
composeServingAnnouncers,
getServingSessionRegistry,
SERVING_SESSION_REGISTRY_KEY,
ServingSessionRegistry,
} from "#src/authority/serving-registry";
/** The accessor caches on `globalThis`; drop the slot between tests. */
function clearGlobalRegistry(): void {
const store = globalThis as Record<symbol, unknown>;
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property
delete store[SERVING_SESSION_REGISTRY_KEY];
}
afterEach(clearGlobalRegistry);
describe("ServingSessionRegistry", () => {
describe("isServing", () => {
it("reports an unmarked session as not serving", () => {
const registry = new ServingSessionRegistry();
expect(registry.isServing("sess-1")).toBe(false);
});
it("reports a marked session as serving", () => {
const registry = new ServingSessionRegistry();
registry.markServing("sess-1");
expect(registry.isServing("sess-1")).toBe(true);
});
it("does not report a sibling session as serving", () => {
const registry = new ServingSessionRegistry();
registry.markServing("sess-1");
expect(registry.isServing("sess-2")).toBe(false);
});
});
describe("markServing", () => {
it("is idempotent", () => {
const registry = new ServingSessionRegistry();
registry.markServing("sess-1");
registry.markServing("sess-1");
expect(registry.servingIds()).toEqual(["sess-1"]);
});
it("keeps concurrent sessions independent", () => {
const registry = new ServingSessionRegistry();
registry.markServing("sess-1");
registry.markServing("sess-2");
registry.clearServing("sess-1");
expect(registry.servingIds()).toEqual(["sess-2"]);
});
});
describe("clearServing", () => {
it("stops reporting the session as serving", () => {
const registry = new ServingSessionRegistry();
registry.markServing("sess-1");
registry.clearServing("sess-1");
expect(registry.isServing("sess-1")).toBe(false);
});
it("is a no-op for an unmarked session", () => {
const registry = new ServingSessionRegistry();
registry.clearServing("sess-1");
expect(registry.servingIds()).toEqual([]);
});
});
describe("servingIds", () => {
it("is empty for a fresh registry", () => {
expect(new ServingSessionRegistry().servingIds()).toEqual([]);
});
});
});
describe("composeServingAnnouncers", () => {
function makeAnnouncer() {
return { markServing: vi.fn(), clearServing: vi.fn() };
}
it("marks on every channel a serving session publishes to", () => {
const first = makeAnnouncer();
const second = makeAnnouncer();
composeServingAnnouncers(first, second).markServing("sess-1");
expect(first.markServing).toHaveBeenCalledExactlyOnceWith("sess-1");
expect(second.markServing).toHaveBeenCalledExactlyOnceWith("sess-1");
});
it("clears on every channel", () => {
const first = makeAnnouncer();
const second = makeAnnouncer();
composeServingAnnouncers(first, second).clearServing("sess-1");
expect(first.clearServing).toHaveBeenCalledExactlyOnceWith("sess-1");
expect(second.clearServing).toHaveBeenCalledExactlyOnceWith("sess-1");
});
it("is a no-op with no channels", () => {
expect(() => {
composeServingAnnouncers().markServing("sess-1");
}).not.toThrow();
});
});
describe("getServingSessionRegistry", () => {
it("returns the same process-global instance on repeated calls", () => {
expect(getServingSessionRegistry()).toBe(getServingSessionRegistry());
});
it("shares marks across callers, as separate jiti instances require", () => {
getServingSessionRegistry().markServing("parent-session");
expect(getServingSessionRegistry().isServing("parent-session")).toBe(true);
});
it("creates a fresh registry once the global slot is cleared", () => {
const first = getServingSessionRegistry();
first.markServing("parent-session");
clearGlobalRegistry();
expect(getServingSessionRegistry()).not.toBe(first);
expect(getServingSessionRegistry().isServing("parent-session")).toBe(false);
});
});
@@ -0,0 +1,547 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { SUBAGENT_ENV_HINT_KEYS } from "#src/authority/permission-forwarding";
import {
isRegisteredSubagentChild,
isSubagentExecutionContext,
normalizeFilesystemPath,
type SubagentDetectionContext,
} from "#src/authority/subagent-context";
import { SubagentSessionRegistry } from "#src/authority/subagent-registry";
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
function makeCtx(
sessionDir: string | null,
sessionId: string = "",
): SubagentDetectionContext {
return {
sessionManager: {
getSessionDir: vi.fn(() => sessionDir ?? ""),
getSessionId: vi.fn(() => sessionId),
},
};
}
describe("isRegisteredSubagentChild", () => {
const childSessionId = "child-session-abc";
test("returns true when the session id is registered", () => {
const registry = new SubagentSessionRegistry();
registry.register(childSessionId, {});
expect(
isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
).toBe(true);
});
test("returns false when the session id is not registered", () => {
const registry = new SubagentSessionRegistry();
expect(
isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
).toBe(false);
});
test("returns false when the session id is empty", () => {
const registry = new SubagentSessionRegistry();
registry.register("", {});
expect(isRegisteredSubagentChild(makeCtx(null, ""), registry)).toBe(false);
});
test("returns false when getSessionId throws", () => {
const registry = new SubagentSessionRegistry();
registry.register(childSessionId, {});
const ctx: SubagentDetectionContext = {
sessionManager: {
getSessionDir: vi.fn(() => ""),
getSessionId: vi.fn(() => {
throw new Error("session id unavailable");
}),
},
};
expect(isRegisteredSubagentChild(ctx, registry)).toBe(false);
});
});
describe("normalizeFilesystemPath", () => {
test("normalizes a simple absolute path", () => {
expect(normalizeFilesystemPath("/projects/my-app", posixPathFlavor)).toBe(
"/projects/my-app",
);
});
test("collapses redundant separators", () => {
expect(normalizeFilesystemPath("/projects//my-app", posixPathFlavor)).toBe(
"/projects/my-app",
);
});
test("resolves . and .. segments", () => {
expect(
normalizeFilesystemPath("/projects/my-app/../other", posixPathFlavor),
).toBe("/projects/other");
});
test("win32: lowercases and normalizes with win32 separators", () => {
expect(
normalizeFilesystemPath("C:\\Projects\\My-App", win32PathFlavor),
).toBe("c:\\projects\\my-app");
});
test("posix: leaves case untouched", () => {
expect(normalizeFilesystemPath("/Projects/My-App", posixPathFlavor)).toBe(
"/Projects/My-App",
);
});
});
describe("isSubagentExecutionContext — injected platform (#510)", () => {
test("win32: detects a subagent session dir case-insensitively", () => {
const subagentRoot = "C:\\Sessions\\Subagents";
const sessionDir = "c:\\sessions\\subagents\\child";
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
win32PathFlavor,
),
).toBe(true);
});
test("posix: the same mixed-case dir is not a subagent context", () => {
const subagentRoot = "/Sessions/Subagents";
const sessionDir = "/sessions/subagents/child";
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
posixPathFlavor,
),
).toBe(false);
});
});
describe("isSubagentExecutionContext — env hint detection", () => {
test("returns true when PI_IS_SUBAGENT is set", () => {
vi.stubEnv("PI_IS_SUBAGENT", "true");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("returns true when PI_SUBAGENT_SESSION_ID is set", () => {
vi.stubEnv("PI_SUBAGENT_SESSION_ID", "abc123");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("returns true when PI_AGENT_ROUTER_SUBAGENT is set", () => {
vi.stubEnv("PI_AGENT_ROUTER_SUBAGENT", "1");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
// nicobailon/pi-subagents keys
test("returns true when PI_SUBAGENT_CHILD is set", () => {
vi.stubEnv("PI_SUBAGENT_CHILD", "1");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("returns true when PI_SUBAGENT_RUN_ID is set", () => {
vi.stubEnv("PI_SUBAGENT_RUN_ID", "run-abc");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("returns true when PI_SUBAGENT_CHILD_AGENT is set", () => {
vi.stubEnv("PI_SUBAGENT_CHILD_AGENT", "worker");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("returns true when PI_SUBAGENT_DEPTH is set", () => {
vi.stubEnv("PI_SUBAGENT_DEPTH", "1");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("returns true when PI_SUBAGENT_DEPTH is zero (depth-0 is still a subagent context)", () => {
vi.stubEnv("PI_SUBAGENT_DEPTH", "0");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
// HazAT/pi-interactive-subagents keys
test("returns true when PI_SUBAGENT_NAME is set", () => {
vi.stubEnv("PI_SUBAGENT_NAME", "my-agent");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("returns true when PI_SUBAGENT_ID is set", () => {
vi.stubEnv("PI_SUBAGENT_ID", "id-xyz");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("returns true when PI_SUBAGENT_SESSION is set", () => {
vi.stubEnv("PI_SUBAGENT_SESSION", "session-xyz");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("returns true when PI_SUBAGENT_ACTIVITY_FILE is set", () => {
vi.stubEnv("PI_SUBAGENT_ACTIVITY_FILE", "/tmp/activity.json");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(true);
});
test("covers all declared SUBAGENT_ENV_HINT_KEYS", () => {
// Verify the keys we test match what the module declares.
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_IS_SUBAGENT");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION_ID");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_AGENT_ROUTER_SUBAGENT");
// nicobailon/pi-subagents
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_RUN_ID");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD_AGENT");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_DEPTH");
// HazAT/pi-interactive-subagents
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_NAME");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ID");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION");
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ACTIVITY_FILE");
});
test("returns false when env hint value is empty string", () => {
vi.stubEnv("PI_IS_SUBAGENT", "");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(false);
});
test("returns false when env hint value is whitespace only", () => {
vi.stubEnv("PI_IS_SUBAGENT", " ");
expect(
isSubagentExecutionContext(
makeCtx(null),
"/sessions/subagents",
posixPathFlavor,
),
).toBe(false);
});
});
describe("isSubagentExecutionContext — session dir detection", () => {
const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
test("returns true when session dir is within subagent root", () => {
const sessionDir = `${subagentRoot}/session-abc`;
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
posixPathFlavor,
),
).toBe(true);
});
test("returns true when session dir equals subagent root", () => {
expect(
isSubagentExecutionContext(
makeCtx(subagentRoot),
subagentRoot,
posixPathFlavor,
),
).toBe(true);
});
test("returns false when session dir is outside subagent root", () => {
const sessionDir = "/home/user/.pi/agent/sessions/main-session";
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
posixPathFlavor,
),
).toBe(false);
});
test("returns false when session dir is a sibling with shared prefix", () => {
// "/sessions/subagents-extra" should not match root "/sessions/subagents"
const sessionDir = `${subagentRoot}-extra/session-abc`;
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
posixPathFlavor,
),
).toBe(false);
});
test("returns false when a `..` segment escapes the subagent root", () => {
// Normalizes to /home/user/.pi/agent/sessions/evil/session-abc — outside.
const sessionDir = `${subagentRoot}/../evil/session-abc`;
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
posixPathFlavor,
),
).toBe(false);
});
test("returns true when a `..` segment resolves back inside the root", () => {
// Normalizes to /home/user/.pi/agent/sessions/subagents/session-abc — inside.
const sessionDir = `${subagentRoot}/nested/../session-abc`;
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
posixPathFlavor,
),
).toBe(true);
});
test("returns false when session dir is under a different root", () => {
const sessionDir = "/var/other/subagents/session-abc";
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
posixPathFlavor,
),
).toBe(false);
});
test("returns false when getSessionDir returns null", () => {
expect(
isSubagentExecutionContext(makeCtx(null), subagentRoot, posixPathFlavor),
).toBe(false);
});
test("returns false when getSessionDir returns empty string", () => {
expect(
isSubagentExecutionContext(makeCtx(""), subagentRoot, posixPathFlavor),
).toBe(false);
});
});
describe("isSubagentExecutionContext — session dir detection (win32 flavor)", () => {
const subagentRoot = "C:\\Users\\dev\\.pi\\agent\\sessions\\subagents";
test("returns true when session dir is within subagent root", () => {
const sessionDir = `${subagentRoot}\\session-abc`;
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
win32PathFlavor,
),
).toBe(true);
});
test("returns true when session dir equals subagent root (case-insensitive)", () => {
expect(
isSubagentExecutionContext(
makeCtx(subagentRoot.toUpperCase()),
subagentRoot,
win32PathFlavor,
),
).toBe(true);
});
test("returns false when session dir is a sibling with shared prefix", () => {
const sessionDir = `${subagentRoot}-extra\\session-abc`;
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
win32PathFlavor,
),
).toBe(false);
});
test("returns false when a `..` segment escapes the subagent root", () => {
const sessionDir = `${subagentRoot}\\..\\evil\\session-abc`;
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
win32PathFlavor,
),
).toBe(false);
});
test("returns false when session dir is on a different drive", () => {
const sessionDir =
"D:\\Users\\dev\\.pi\\agent\\sessions\\subagents\\session-abc";
expect(
isSubagentExecutionContext(
makeCtx(sessionDir),
subagentRoot,
win32PathFlavor,
),
).toBe(false);
});
});
describe("isSubagentExecutionContext — registry detection", () => {
const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
const outsideDir =
"/home/user/projects/my-app/.pi/agent/sessions/parent/tasks";
const childSessionId = "child-session-abc";
test("returns true when session id is registered (no env vars, dir outside filesystem root)", () => {
const registry = new SubagentSessionRegistry();
registry.register(childSessionId, {});
expect(
isSubagentExecutionContext(
makeCtx(outsideDir, childSessionId),
subagentRoot,
posixPathFlavor,
registry,
),
).toBe(true);
});
test("returns true when registered session has a parentSessionId", () => {
const registry = new SubagentSessionRegistry();
registry.register(childSessionId, { parentSessionId: "parent-123" });
expect(
isSubagentExecutionContext(
makeCtx(outsideDir, childSessionId),
subagentRoot,
posixPathFlavor,
registry,
),
).toBe(true);
});
test("returns false when registry is provided but session id is not registered", () => {
const registry = new SubagentSessionRegistry();
expect(
isSubagentExecutionContext(
makeCtx(outsideDir, childSessionId),
subagentRoot,
posixPathFlavor,
registry,
),
).toBe(false);
});
test("returns false when session id is empty and registry has no matching entry", () => {
const registry = new SubagentSessionRegistry();
expect(
isSubagentExecutionContext(
makeCtx(null, ""),
subagentRoot,
posixPathFlavor,
registry,
),
).toBe(false);
});
test("registry check takes priority over env var detection", () => {
// Registry says registered; env var not set — should still return true.
const registry = new SubagentSessionRegistry();
registry.register(childSessionId, {});
// Confirm no env var is set
expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
expect(
isSubagentExecutionContext(
makeCtx(outsideDir, childSessionId),
subagentRoot,
posixPathFlavor,
registry,
),
).toBe(true);
});
test("unregistered session falls through to env var detection", () => {
vi.stubEnv("PI_IS_SUBAGENT", "true");
const registry = new SubagentSessionRegistry(); // empty — childSessionId not registered
// Env var present → still true even without registry entry
expect(
isSubagentExecutionContext(
makeCtx(outsideDir, childSessionId),
subagentRoot,
posixPathFlavor,
registry,
),
).toBe(true);
});
test("no registry passed — existing behaviour unchanged", () => {
// Ensure the parameter is truly optional (no registry arg)
expect(
isSubagentExecutionContext(makeCtx(null), subagentRoot, posixPathFlavor),
).toBe(false);
});
});
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { SubagentDetectionContext } from "#src/authority/subagent-context";
import { SubagentDetection } from "#src/authority/subagent-detection";
import { SubagentSessionRegistry } from "#src/authority/subagent-registry";
import { posixPathFlavor } from "#src/path/path-flavor";
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
function makeCtx(
sessionDir: string | null,
sessionId: string = "",
): SubagentDetectionContext {
return {
sessionManager: {
getSessionDir: vi.fn(() => sessionDir ?? ""),
getSessionId: vi.fn(() => sessionId),
},
};
}
const subagentSessionsDir = "/agent/subagent-sessions";
describe("SubagentDetection", () => {
describe("isSubagent", () => {
test("returns true for a registered in-process child (registry source)", () => {
const registry = new SubagentSessionRegistry();
registry.register("child-1", {});
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry,
});
expect(detection.isSubagent(makeCtx(null, "child-1"))).toBe(true);
});
test("returns true when a subagent env hint is set (env source)", () => {
vi.stubEnv("PI_IS_SUBAGENT", "1");
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry: new SubagentSessionRegistry(),
});
expect(detection.isSubagent(makeCtx("/somewhere/else"))).toBe(true);
});
test("returns true when the session dir is nested under subagentSessionsDir (filesystem source)", () => {
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry: new SubagentSessionRegistry(),
});
expect(
detection.isSubagent(makeCtx(`${subagentSessionsDir}/child-1`)),
).toBe(true);
});
test("returns false when no source matches", () => {
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry: new SubagentSessionRegistry(),
});
expect(detection.isSubagent(makeCtx("/projects/my-app"))).toBe(false);
});
});
describe("isRegisteredChild", () => {
test("returns true when the session id is registered", () => {
const registry = new SubagentSessionRegistry();
registry.register("child-1", {});
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry,
});
expect(detection.isRegisteredChild(makeCtx(null, "child-1"))).toBe(true);
});
test("returns false when the session id is not registered", () => {
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
registry: new SubagentSessionRegistry(),
});
expect(detection.isRegisteredChild(makeCtx(null, "child-1"))).toBe(false);
});
test("returns false when constructed without a registry", () => {
const detection = new SubagentDetection({
subagentSessionsDir,
flavor: posixPathFlavor,
});
expect(detection.isRegisteredChild(makeCtx(null, "child-1"))).toBe(false);
});
});
});
@@ -0,0 +1,132 @@
import { createEventBus } from "@earendil-works/pi-coding-agent";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
SUBAGENT_CHILD_DISPOSED,
SUBAGENT_CHILD_SESSION_CREATED,
subscribeSubagentLifecycle,
} from "#src/authority/subagent-lifecycle-events";
import { SubagentSessionRegistry } from "#src/authority/subagent-registry";
describe("subscribeSubagentLifecycle", () => {
let registry: SubagentSessionRegistry;
beforeEach(() => {
registry = new SubagentSessionRegistry();
});
it("registers a child session on session-created", () => {
const bus = createEventBus();
subscribeSubagentLifecycle(bus, registry);
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: "child-session-abc",
parentSessionId: "parent-42",
});
expect(registry.get("child-session-abc")).toEqual({
parentSessionId: "parent-42",
});
});
it("populates the registry synchronously — before emit() returns", () => {
// Guards the pre-bindExtensions ordering: the core emits session-created
// on the same synchronous call stack right before bindExtensions(), so the
// handler must complete before emit() returns. A real EventEmitter-backed
// bus dispatches synchronously; this fails loudly if the handler ever
// becomes async (awaiting before registry.register).
const bus = createEventBus();
subscribeSubagentLifecycle(bus, registry);
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: "child-session-sync",
});
// No await between emit and this assertion.
expect(registry.has("child-session-sync")).toBe(true);
});
it("omits parentSessionId when the event does not carry one", () => {
const bus = createEventBus();
subscribeSubagentLifecycle(bus, registry);
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: "child-session-xyz",
});
expect(registry.get("child-session-xyz")).toEqual({
parentSessionId: undefined,
});
});
it("unregisters a child session on disposed", () => {
const bus = createEventBus();
subscribeSubagentLifecycle(bus, registry);
registry.register("child-session-abc", { parentSessionId: "parent-42" });
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
expect(registry.has("child-session-abc")).toBe(false);
});
it("detaches both handlers when the returned unsubscribe is called", () => {
const bus = createEventBus();
const unsubscribe = subscribeSubagentLifecycle(bus, registry);
unsubscribe();
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: "child-session-abc",
});
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
expect(registry.has("child-session-abc")).toBe(false);
});
it("subscribes to a fake bus on the exact channel names", () => {
const handlers = new Map<string, (data: unknown) => void>();
const bus = {
on: vi.fn((channel: string, handler: (data: unknown) => void) => {
handlers.set(channel, handler);
return () => handlers.delete(channel);
}),
};
subscribeSubagentLifecycle(bus, registry);
expect(bus.on).toHaveBeenCalledTimes(2);
expect(handlers.has("subagents:child:session-created")).toBe(true);
expect(handlers.has("subagents:child:disposed")).toBe(true);
});
it("exposes the canonical channel-name strings", () => {
expect(SUBAGENT_CHILD_SESSION_CREATED).toBe(
"subagents:child:session-created",
);
expect(SUBAGENT_CHILD_DISPOSED).toBe("subagents:child:disposed");
});
// ── #298 regression: concurrent siblings must be independent ──────────────
it("disposing one sibling does not evict the other (collision regression)", () => {
const bus = createEventBus();
subscribeSubagentLifecycle(bus, registry);
// Two concurrent children of the same parent register under distinct ids.
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: "child-A",
parentSessionId: "parent-P",
});
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: "child-B",
parentSessionId: "parent-P",
});
// Sibling A finishes first.
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-A" });
// B must still be detected as a registered subagent.
expect(registry.has("child-A")).toBe(false);
expect(registry.has("child-B")).toBe(true);
expect(registry.get("child-B")?.parentSessionId).toBe("parent-P");
});
});
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, test } from "vitest";
import {
getSubagentSessionRegistry,
type SubagentSessionInfo,
SubagentSessionRegistry,
} from "#src/authority/subagent-registry";
const REGISTRY_KEY = Symbol.for(
"@gotgenes/pi-permission-system:subagent-registry",
);
function makeInfo(
overrides: Partial<SubagentSessionInfo> = {},
): SubagentSessionInfo {
return { ...overrides };
}
describe("SubagentSessionRegistry", () => {
test("has() returns false for an unregistered key", () => {
const registry = new SubagentSessionRegistry();
expect(registry.has("session-abc")).toBe(false);
});
test("get() returns undefined for an unregistered key", () => {
const registry = new SubagentSessionRegistry();
expect(registry.get("session-abc")).toBeUndefined();
});
test("has() returns true after register()", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo());
expect(registry.has("session-abc")).toBe(true);
});
test("get() returns the registered info after register()", () => {
const registry = new SubagentSessionRegistry();
const info = makeInfo({ parentSessionId: "parent-123" });
registry.register("session-abc", info);
expect(registry.get("session-abc")).toEqual(info);
});
test("register() stores entry without parentSessionId", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo());
expect(registry.get("session-abc")).toEqual({});
});
test("has() returns false after unregister()", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo());
registry.unregister("session-abc");
expect(registry.has("session-abc")).toBe(false);
});
test("get() returns undefined after unregister()", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo());
registry.unregister("session-abc");
expect(registry.get("session-abc")).toBeUndefined();
});
test("unregister() is a no-op for an unknown key", () => {
const registry = new SubagentSessionRegistry();
expect(() => registry.unregister("session-nonexistent")).not.toThrow();
});
test("register() overwrites a previous entry for the same key", () => {
const registry = new SubagentSessionRegistry();
registry.register("session-abc", makeInfo({ parentSessionId: "parent-1" }));
registry.register("session-abc", makeInfo({ parentSessionId: "parent-2" }));
expect(registry.get("session-abc")?.parentSessionId).toBe("parent-2");
});
// ── #298 regression: concurrent siblings must be independent ──────────────
test("two sibling session ids are registered independently", () => {
const registry = new SubagentSessionRegistry();
registry.register(
"child-session-A",
makeInfo({ parentSessionId: "parent-P" }),
);
registry.register(
"child-session-B",
makeInfo({ parentSessionId: "parent-P" }),
);
expect(registry.has("child-session-A")).toBe(true);
expect(registry.has("child-session-B")).toBe(true);
});
test("disposing one sibling does not evict the other (collision regression)", () => {
const registry = new SubagentSessionRegistry();
registry.register(
"child-session-A",
makeInfo({ parentSessionId: "parent-P" }),
);
registry.register(
"child-session-B",
makeInfo({ parentSessionId: "parent-P" }),
);
// Sibling A finishes — should not affect B.
registry.unregister("child-session-A");
expect(registry.has("child-session-A")).toBe(false);
expect(registry.has("child-session-B")).toBe(true);
expect(registry.get("child-session-B")?.parentSessionId).toBe("parent-P");
});
});
// ── process-global accessor ────────────────────────────────────────────────
describe("getSubagentSessionRegistry (process-global accessor)", () => {
afterEach(() => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
delete (globalThis as Record<symbol, unknown>)[REGISTRY_KEY];
});
test("returns a SubagentSessionRegistry instance", () => {
const registry = getSubagentSessionRegistry();
expect(registry).toBeInstanceOf(SubagentSessionRegistry);
});
test("returns the same instance on repeated calls", () => {
const first = getSubagentSessionRegistry();
const second = getSubagentSessionRegistry();
expect(first).toBe(second);
});
test("state registered through one call is visible through another call", () => {
const writer = getSubagentSessionRegistry();
writer.register("child-session-xyz", {
parentSessionId: "parent-abc",
});
const reader = getSubagentSessionRegistry();
expect(reader.has("child-session-xyz")).toBe(true);
expect(reader.get("child-session-xyz")?.parentSessionId).toBe("parent-abc");
});
test("starts empty on first call", () => {
const registry = getSubagentSessionRegistry();
expect(registry.has("any-session-id")).toBe(false);
});
});