import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { Authorizer } from "#src/authority/authorizer"; import { AuthorizerRegistry } from "#src/authority/authorizer-registry"; import { AuthorizerSelection } from "#src/authority/authorizer-selection"; import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope"; import { ForwardedRequestServer } from "#src/authority/forwarded-request-server"; import type { PermissionPromptDecision } from "#src/authority/permission-dialog"; import type { ForwardedPermissionRequest, ForwardedPermissionResponse, } from "#src/authority/permission-forwarding"; import { PermissionPrompter, type PromptPermissionDetails, } from "#src/authority/permission-prompter"; import type { PermissionQuery } from "#src/service"; import { makeAuthorizerSelectionDeps, registerLink, } from "#test/helpers/authorizer-fixtures"; import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures"; import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures"; import { createForwardingTempDir, type ForwardingTempDir, makeForwardedAccessIntent, makeForwarderContext, makeServerDeps, makeSubagentRegistry, } from "#test/helpers/forwarding-fixtures"; import { makeCheckResult } from "#test/helpers/handler-fixtures"; import { makePromptPayload } from "#test/helpers/prompt-details-fixtures"; let temp: ForwardingTempDir | undefined; afterEach(() => { temp?.cleanup(); temp = undefined; vi.unstubAllEnvs(); }); function readResponse( dir: ForwardingTempDir, requestId: string, ): ForwardedPermissionResponse { const raw = readFileSync( join(dir.location.responsesDir, `${requestId}.json`), "utf-8", ); return JSON.parse(raw) as ForwardedPermissionResponse; } /** * An approving `AskEscalator` that records the details it was handed. * * The reconstructed `PromptPermissionDetails` is itself the subject of the * access-facts and bounded-delegation cases below: they assert its exact shape, * and hand it to the real delegation envelope — a collaborator the server never * touches, but one that reads the details the server builds. */ function makeCapturingEscalator() { const escalated: PromptPermissionDetails[] = []; return { escalate: vi.fn((details: PromptPermissionDetails) => { escalated.push(details); return Promise.resolve({ approved: true, state: "approved", decidedBy: DECIDED_BY_HUMAN, }); }), /** The details of the most recent escalation. */ lastDetails(): PromptPermissionDetails { const details = escalated.at(-1); if (!details) { throw new Error("no ask was escalated"); } return details; }, }; } /** Drive one forwarded ask to escalation and return the details the server built. */ async function escalateForwardedAsk( request: Partial, ): Promise { temp = createForwardingTempDir("parent-session"); temp.writeRequest(request); const escalator = makeCapturingEscalator(); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, policy: { resolve: vi.fn(() => makeCheckResult({ state: "ask" })) }, escalator, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); return escalator.lastDetails(); } describe("processInbox — recorded-authority resolution", () => { test("auto-approves and writes an approved response when the serving policy allows", async () => { temp = createForwardingTempDir("parent-session"); const accessIntent = makeForwardedAccessIntent({ matchValues: ["git status"], }); temp.writeRequest({ id: "req-allow", source: "tool_call", surface: "bash", value: "git status", accessIntent, }); const resolve = vi.fn(() => makeCheckResult({ state: "allow", matchedPattern: "git *", origin: "global", }), ); const escalate = vi.fn(); const logger = { review: vi.fn(), debug: vi.fn() }; const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, logger, policy: { resolve }, escalator: { escalate }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(resolve).toHaveBeenCalledWith(accessIntent); expect(escalate).not.toHaveBeenCalled(); // The serving node's own rule decided, and the response names it: the // child's log entry would otherwise say only that the parent approved. expect(readResponse(temp, "req-allow")).toMatchObject({ approved: true, state: "approved", decidedBy: { kind: "rule", surface: "bash", pattern: "git *", origin: "global", }, }); expect(logger.review).toHaveBeenCalledWith( "forwarded_permission.auto_approved", expect.objectContaining({ requestId: "req-allow", decidedBy: expect.objectContaining({ kind: "rule" }), }), ); }); test("auto-denies and writes a denied response when the serving policy denies", async () => { temp = createForwardingTempDir("parent-session"); const accessIntent = makeForwardedAccessIntent({ matchValues: ["rm -rf /"], }); temp.writeRequest({ id: "req-deny", source: "tool_call", surface: "bash", value: "rm -rf /", accessIntent, }); const resolve = vi.fn(() => makeCheckResult({ state: "deny", matchedPattern: "rm *", origin: "project", }), ); const escalate = vi.fn(); const logger = { review: vi.fn(), debug: vi.fn() }; const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, logger, policy: { resolve }, escalator: { escalate }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(resolve).toHaveBeenCalledWith(accessIntent); expect(escalate).not.toHaveBeenCalled(); expect(readResponse(temp, "req-deny")).toMatchObject({ approved: false, state: "denied", decidedBy: { kind: "rule", surface: "bash", pattern: "rm *", origin: "project", }, }); expect(logger.review).toHaveBeenCalledWith( "forwarded_permission.auto_denied", expect.objectContaining({ requestId: "req-deny", decidedBy: expect.objectContaining({ kind: "rule" }), }), ); }); test("relays the escalated decision's own decider onto the response", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-human", accessIntent: undefined }); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, escalator: { escalate: vi.fn().mockResolvedValue({ approved: true, state: "approved", decidedBy: { kind: "user", via: "dialog" }, }), }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); // This is the reported case: the child could not tell a human approval at // the parent from the parent's policy auto-approving on its behalf. expect(readResponse(temp, "req-human")).toMatchObject({ decidedBy: { kind: "user", via: "dialog" }, }); }); test("attributes a failed escalation to the error, not to a denial anyone made", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-boom", accessIntent: undefined }); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, escalator: { escalate: vi.fn().mockRejectedValue(new Error("prompt exploded")), }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(readResponse(temp, "req-boom")).toMatchObject({ approved: false, state: "denied", decidedBy: { kind: "gate_error", reason: "prompt exploded" }, }); }); test("escalates an ask through the AskEscalator with the forwarded provenance details", async () => { temp = createForwardingTempDir("parent-session"); const accessIntent = makeForwardedAccessIntent({ matchValues: ["git push"], }); temp.writeRequest({ id: "req-ask", source: "tool_call", surface: "bash", value: "git push", accessIntent, payload: makePromptPayload({ kind: "bash", request: { ...makePromptPayload().request, surface: "bash", toolName: "bash", value: "git push", }, }), }); const resolve = vi.fn(() => makeCheckResult({ state: "ask" })); const escalate = vi.fn().mockResolvedValue({ approved: true, state: "approved", decidedBy: DECIDED_BY_HUMAN, }); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, policy: { resolve }, escalator: { escalate }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(resolve).toHaveBeenCalledWith(accessIntent); expect(escalate).toHaveBeenCalledWith({ requestId: "req-ask", source: "tool_call", agentName: "Explore", surface: "bash", value: "git push", forwarding: { requesterAgentName: "Explore", requesterSessionId: "child-session", }, accessIntent: { surface: "bash", matchValues: ["git push"], boundaryValue: null, }, payload: { kind: "bash", request: { requester: { agentName: "Explore", forwarded: true, sessionId: "child-session", }, surface: "bash", toolName: "bash", invokedToolName: null, value: "git push", matchedPattern: null, commandContext: null, executedUnit: null, }, evidence: [], annotations: [], }, }); expect(readResponse(temp, "req-ask")).toMatchObject({ approved: true, state: "approved", }); }); test("keeps requester cwd and principal out of the escalated payload (#635)", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-ask", surface: "bash", value: "git push", accessIntent: makeForwardedAccessIntent({ requesterCwd: "/child/cwd", principal: { sessionId: "child-session", agentName: "Explore" }, }), }); const escalate = vi.fn().mockResolvedValue({ approved: true, state: "approved", decidedBy: DECIDED_BY_HUMAN, }); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, policy: { resolve: () => makeCheckResult({ state: "ask" }) }, escalator: { escalate }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); // The payload is a disclosure boundary too: requester identity reaches an // Authorizer through `details.forwarding`, never smuggled in as evidence. const serialized = JSON.stringify(escalate.mock.calls[0][0].payload); expect(serialized).not.toContain("/child/cwd"); expect(serialized).not.toContain("principal"); }); test("floors a request with no fields at all (fully legacy) to escalation without consulting the policy", async () => { temp = createForwardingTempDir("parent-session"); // Legacy / version-skew request: no source/surface/value/accessIntent. temp.writeRequest({ id: "req-legacy" }); const resolve = vi.fn(() => makeCheckResult({ state: "allow" })); const escalate = vi.fn().mockResolvedValue({ approved: true, state: "approved", decidedBy: DECIDED_BY_HUMAN, }); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, policy: { resolve }, escalator: { escalate }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(resolve).not.toHaveBeenCalled(); expect(escalate).toHaveBeenCalledWith( expect.objectContaining({ requestId: "req-legacy", source: "tool_call", surface: null, value: null, }), ); }); test("floors a version-skew request with display fields but no accessIntent to escalation without consulting the policy", async () => { temp = createForwardingTempDir("parent-session"); // An older child populated the display fields but never computed the // structured intent (ADR 0008 §4: accessIntent is the sole resolution // path — a request missing it floors to `ask`, never a silent grant). temp.writeRequest({ id: "req-skew", source: "tool_call", surface: "bash", value: "git push", }); const resolve = vi.fn(() => makeCheckResult({ state: "allow" })); const escalate = vi.fn().mockResolvedValue({ approved: true, state: "approved", decidedBy: DECIDED_BY_HUMAN, }); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, policy: { resolve }, escalator: { escalate }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(resolve).not.toHaveBeenCalled(); expect(escalate).toHaveBeenCalledWith( expect.objectContaining({ requestId: "req-skew", surface: "bash", value: "git push", }), ); }); test("denies when the escalator rejects", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-throw", surface: "bash", value: "git push", accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }), }); const resolve = vi.fn(() => makeCheckResult({ state: "ask" })); const escalate = vi.fn().mockRejectedValue(new Error("ui gone")); const logger = { review: vi.fn(), debug: vi.fn() }; const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, logger, policy: { resolve }, escalator: { escalate }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(readResponse(temp, "req-throw")).toMatchObject({ approved: false, state: "denied", }); expect(logger.review).toHaveBeenCalledWith( "permission_forwarding.error", expect.objectContaining({ message: expect.stringContaining("escalate"), }), ); }); }); describe("processInbox — child-fixed access facts on the escalated ask", () => { test("carries the request's access facts onto the escalated ask details", async () => { const details = await escalateForwardedAsk({ id: "req-path-facts", source: "tool_call", // The display projection is the child's *tool* name, which is what the UI // shows — never the gate surface the rule fired on. surface: "write", value: "/worktree/issue-42/src/foo.ts", accessIntent: makeForwardedAccessIntent({ surface: "path", matchValues: [ "/worktree/issue-42/src/foo.ts", "src/foo.ts", "/canonical/src/foo.ts", ], boundaryValue: "/canonical/src/foo.ts", }), }); // Exactly the three fact fields: `requesterCwd` and `principal` stay on the // wire object and never reach an Authorizer. A link that needs requester // identity reads `details.forwarding`. expect(details.accessIntent).toEqual({ surface: "path", matchValues: [ "/worktree/issue-42/src/foo.ts", "src/foo.ts", "/canonical/src/foo.ts", ], boundaryValue: "/canonical/src/foo.ts", }); }); test("omits accessIntent entirely for a version-skew request that carried none", async () => { const details = await escalateForwardedAsk({ id: "req-skew-facts", source: "tool_call", surface: "bash", value: "git push", }); // Absence, not an explicit `undefined`: the delegation envelope's // `accessIntent?.surface ?? surface` fallback reads the display surface only // when the key is genuinely absent. expect(details).not.toHaveProperty("accessIntent"); }); }); describe("processInbox — the child's payload on the escalated ask", () => { test("escalates the child's own payload with the requester re-stamped as forwarded", async () => { const childPayload = 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: "full command", text: "git push --force", detail: null }, ], }); const details = await escalateForwardedAsk({ id: "req-child-payload", requesterAgentName: "Explore", requesterSessionId: "child-session", source: "tool_call", surface: "bash", value: "git push", payload: childPayload, }); // The child's kind and facts pass through untouched — a forwarded bash ask // renders `command : …` exactly as a local one does. Only the requester is // re-stamped: the serving node is the only party that knows the ask arrived // over the wire, and the request's own provenance is authoritative (#292). expect(details.payload).toEqual({ ...childPayload, request: { ...childPayload.request, requester: { agentName: "Explore", forwarded: true, sessionId: "child-session", }, }, }); }); test("escalates a degraded forwarded payload for a request carrying none", async () => { const details = await escalateForwardedAsk({ id: "req-skew-payload", requesterAgentName: "scout", requesterSessionId: "child-session", source: "tool_call", surface: "read", value: "/tmp/x", // `JSON.stringify` drops the key, so the written request genuinely // carries no payload — an older child's request. payload: undefined, }); // `kind: "forwarded"` now means exactly one thing: this ask arrived without // a payload, so it is rendered from the display fields it does carry. expect(details.payload).toEqual({ kind: "forwarded", request: { requester: { agentName: "scout", forwarded: true, sessionId: "child-session", }, surface: "read", toolName: null, invokedToolName: null, value: "/tmp/x", matchedPattern: null, commandContext: null, executedUnit: null, }, evidence: [], annotations: [], }); }); }); describe("processInbox — bounded delegation over forwarded asks", () => { const query: PermissionQuery = { checkPermission: vi.fn(), getToolPermission: vi.fn(), }; const log = makeAuthorizerLog(); const allowingLink: Authorizer["authorize"] = () => Promise.resolve({ kind: "allow" }); test("caps a link's allow on a forwarded path ask to defer", async () => { const details = await escalateForwardedAsk({ id: "req-envelope-path", source: "tool_call", surface: "write", value: "/worktree/issue-42/.ssh/config", accessIntent: makeForwardedAccessIntent({ surface: "path", matchValues: ["/worktree/issue-42/.ssh/config"], boundaryValue: "/worktree/issue-42/.ssh/config", }), }); const enclosed = encloseInDelegationEnvelope(allowingLink); // The gate surface, not the displayed tool name, decides exclusion — so a // forwarded path ask is capped exactly like the same ask made locally. expect(await enclosed(details, query, log)).toEqual({ kind: "defer" }); }); test("passes a link's allow on a forwarded bash ask through", async () => { const details = await escalateForwardedAsk({ id: "req-envelope-bash", source: "tool_call", surface: "bash", value: "npm test", accessIntent: makeForwardedAccessIntent({ matchValues: ["npm test"] }), }); const enclosed = encloseInDelegationEnvelope(allowingLink); expect(await enclosed(details, query, log)).toEqual({ kind: "allow" }); }); }); describe("processInbox — the serving node's chain adjudicates a forwarded ask", () => { /** A serving node with UI: its terminal is the human prompt. */ function makeServingCtx(): ExtensionContext { return { hasUI: true, mode: "tui", ui: { select: vi.fn(), input: vi.fn(), custom: vi.fn() }, } as unknown as ExtensionContext; } test("a link registered on the serving node decides the forwarded ask before its terminal", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-chain", source: "tool_call", surface: "bash", value: "rm -rf /tmp/scratch", accessIntent: makeForwardedAccessIntent({ matchValues: ["rm -rf /tmp/scratch"], }), }); const registry = new AuthorizerRegistry(); registerLink(registry, "model-judge", { kind: "deny", reason: "destructive", }); const requestPermissionDecision = vi.fn().mockResolvedValue({ approved: true, state: "approved", decidedBy: DECIDED_BY_HUMAN, }); const logger = makeAuthorizerLog(); // The real chain owner, wired exactly as index.ts wires it: the same // AuthorizerSelection is both the gate's AskEscalator and the forwarded // request server's, so a child's ask is judged by the serving node's chain. const selection = new AuthorizerSelection( makeAuthorizerSelectionDeps({ prompter: new PermissionPrompter({ logger }), authorizerRegistry: registry, getAuthorizerChain: () => ["model-judge"], requestPermissionDecision, logger, }), ); selection.activate(makeServingCtx()); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, logger, policy: { resolve: vi.fn(() => makeCheckResult({ state: "ask" })) }, escalator: selection, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); // The link's deny is what the child receives, and the human terminal was // never reached — the chain adjudicated the forwarded ask. The child can // now see that from its own record: the link is named on the wire. expect(readResponse(temp, "req-chain")).toEqual({ approved: false, state: "denied_with_reason", denialReason: "destructive", responderSessionId: "parent-session", respondedAt: expect.any(Number), decidedBy: { kind: "authorizer", name: "model-judge", verdict: "deny", reason: "destructive", }, }); expect(requestPermissionDecision).not.toHaveBeenCalled(); }); }); describe("processInbox — grant-scope selection", () => { test("records a whole-session grant into the serving recorder and translates the response to a plain approve", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-whole", source: "tool_call", surface: "bash", value: "git push", accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }), sessionApproval: { surface: "bash", patterns: ["git *"] }, }); const resolve = vi.fn(() => makeCheckResult({ state: "ask" })); const escalate = vi.fn().mockResolvedValue({ approved: true, state: "approved_for_serving_session", decidedBy: { kind: "user", via: "dialog" }, }); const recordSessionApproval = vi.fn(); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, policy: { resolve }, escalator: { escalate }, recorder: { recordSessionApproval }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(recordSessionApproval).toHaveBeenCalledWith( expect.objectContaining({ surface: "bash", patterns: ["git *"] }), ); // Translated: the child receives a plain approve and records nothing. // The translation rewrites the scope, never the decider. expect(readResponse(temp, "req-whole")).toMatchObject({ approved: true, state: "approved", decidedBy: { kind: "user", via: "dialog" }, }); }); test("offers the request's sessionApproval to the escalated dialog details", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-scope-details", source: "tool_call", surface: "bash", value: "git push", accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }), sessionApproval: { surface: "bash", patterns: ["git *"] }, }); const resolve = vi.fn(() => makeCheckResult({ state: "ask" })); const escalate = vi.fn().mockResolvedValue({ approved: true, state: "approved", decidedBy: DECIDED_BY_HUMAN, }); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, policy: { resolve }, escalator: { escalate }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(escalate).toHaveBeenCalledWith( expect.objectContaining({ sessionApproval: { surface: "bash", patterns: ["git *"] }, }), ); }); test("passes a subagent-only grant through untouched without recording on the serving node", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-subagent", source: "tool_call", surface: "bash", value: "git push", accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }), sessionApproval: { surface: "bash", patterns: ["git *"] }, }); const resolve = vi.fn(() => makeCheckResult({ state: "ask" })); const escalate = vi.fn().mockResolvedValue({ approved: true, state: "approved_for_session", decidedBy: DECIDED_BY_HUMAN, }); const recordSessionApproval = vi.fn(); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, policy: { resolve }, escalator: { escalate }, recorder: { recordSessionApproval }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(recordSessionApproval).not.toHaveBeenCalled(); // Passed through: the child records its own pattern (today's behavior). expect(readResponse(temp, "req-subagent")).toMatchObject({ approved: true, state: "approved_for_session", }); }); }); describe("processInbox — one-hop canary", () => { test("warns when the requester is a registered subagent whose parent is not this serving session", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-hop", surface: "bash", value: "git push" }); const logger = { review: vi.fn(), debug: vi.fn() }; const registry = makeSubagentRegistry("child-session", { parentSessionId: "some-other-session", }); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, logger, registry }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(logger.review).toHaveBeenCalledWith( "permission_forwarding.warning", expect.objectContaining({ message: expect.stringContaining("one-hop"), }), ); }); test("stays silent for an unregistered (external file-based) requester", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-ext", surface: "bash", value: "git push" }); const logger = { review: vi.fn(), debug: vi.fn() }; const registry = makeSubagentRegistry("child-session"); // no entry const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, logger, registry }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(logger.review).not.toHaveBeenCalledWith( "permission_forwarding.warning", expect.anything(), ); }); test("stays silent for a registered one-hop child whose parent is this serving session", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-ok", surface: "bash", value: "git push" }); const logger = { review: vi.fn(), debug: vi.fn() }; const registry = makeSubagentRegistry("child-session", { parentSessionId: "parent-session", }); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, logger, registry }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(logger.review).not.toHaveBeenCalledWith( "permission_forwarding.warning", expect.anything(), ); }); }); describe("processInbox — inbox mechanics", () => { test("recreates a missing responses/ directory and still writes the response", async () => { // Simulate the race: requests/ exists with a pending file, but // responses/ was removed by a concurrent cleanup pass (#398). temp = createForwardingTempDir("parent-session", { createResponsesDir: false, }); temp.writeRequest({ id: "req-race", surface: "bash", value: "cat x", accessIntent: makeForwardedAccessIntent({ matchValues: ["cat x"] }), }); const logger = { review: vi.fn(), debug: vi.fn() }; const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, logger, policy: { resolve: vi.fn(() => makeCheckResult({ state: "allow" })) }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(logger.review).not.toHaveBeenCalledWith( "permission_forwarding.error", expect.anything(), ); expect(readResponse(temp, "req-race")).toMatchObject({ approved: true, state: "approved", }); }); test("ignores and deletes a request targeting a different session", async () => { temp = createForwardingTempDir("parent-session"); temp.writeRequest({ id: "req-mismatch", targetSessionId: "other-session", surface: "bash", value: "git push", accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }), }); const resolve = vi.fn(() => makeCheckResult({ state: "allow" })); const server = new ForwardedRequestServer( makeServerDeps({ forwardingDir: temp.forwardingDir, policy: { resolve }, }), ); await server.processInbox( makeForwarderContext({ hasUI: true, sessionId: "parent-session" }), ); expect(resolve).not.toHaveBeenCalled(); }); });