mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user