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,464 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
getActiveAgentName,
|
||||
getActiveAgentNameFromSystemPrompt,
|
||||
} from "#src/active-agent";
|
||||
import {
|
||||
type ForwarderContext,
|
||||
getCwd,
|
||||
getSessionId,
|
||||
} from "#src/authority/forwarder-context";
|
||||
import {
|
||||
cleanupPermissionForwardingLocationIfEmpty,
|
||||
ensurePermissionForwardingLocation,
|
||||
logPermissionForwardingError,
|
||||
logPermissionForwardingWarning,
|
||||
readForwardedPermissionResponse,
|
||||
safeDeleteFile,
|
||||
sleep,
|
||||
writeJsonFileAtomic,
|
||||
} from "#src/authority/forwarding-io";
|
||||
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import {
|
||||
type ForwardedAccessFacts,
|
||||
type ForwardedPermissionRequest,
|
||||
type ForwardedPermissionResponse,
|
||||
type ForwardedPromptDisplay,
|
||||
type ForwardedSessionApproval,
|
||||
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
||||
PERMISSION_FORWARDING_SERVING_GRACE_MS,
|
||||
type PermissionForwardingLocation,
|
||||
type PermissionForwardingTarget,
|
||||
resolvePermissionForwardingTarget,
|
||||
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import { createPermissionRequestId } from "#src/permission-request-id";
|
||||
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
import { toRecord } from "#src/value-guards";
|
||||
import type { TerminalAuthorizer } from "./authorizer";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
|
||||
// ── Module-private helpers ────────────────────────────────────────────────
|
||||
|
||||
function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
||||
const getSystemPrompt = toRecord(ctx).getSystemPrompt;
|
||||
if (typeof getSystemPrompt !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
|
||||
const systemPrompt = getSystemPrompt.call(ctx);
|
||||
return typeof systemPrompt === "string" ? systemPrompt : undefined;
|
||||
} catch (error) {
|
||||
// No deps available in this helper — warning silently dropped.
|
||||
logPermissionForwardingWarning(
|
||||
null,
|
||||
"Failed to read context system prompt for forwarded permission metadata",
|
||||
error,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ParentAuthorizer ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The facts a forwarded request relays unchanged from the child's ask: the
|
||||
* prompt payload, the optional display projection, and the optional
|
||||
* session-approval suggestion.
|
||||
*
|
||||
* Bundled into one object so the two-hop private chain
|
||||
* (`waitForForwardedApproval` → `buildForwardedRequest`) threads a single
|
||||
* relayed value instead of three positional optionals.
|
||||
*/
|
||||
interface ForwardedRequestFacts {
|
||||
/**
|
||||
* The requester's own permission request id, adopted as the forwarded
|
||||
* request's id so one id runs from the child's gate to the serving node's
|
||||
* decision instead of a third being minted here.
|
||||
*/
|
||||
requestId: string;
|
||||
/** The child's complete prompt payload, relayed for the serving node to render. */
|
||||
payload: PromptPayload;
|
||||
display?: ForwardedPromptDisplay;
|
||||
sessionApproval?: ForwardedSessionApproval;
|
||||
/** The child-fixed access facts; the edge completes them into a `ForwardedAccessIntent`. */
|
||||
accessIntent?: ForwardedAccessFacts;
|
||||
}
|
||||
|
||||
/** Constructor config for {@link ParentAuthorizer}. */
|
||||
export interface ParentAuthorizerDeps {
|
||||
forwardingDir: string;
|
||||
/** In-process subagent session registry for forwarding target resolution. */
|
||||
registry?: SubagentSessionRegistry;
|
||||
/** Whether the resolved target is draining its inbox, on whichever channel can say. */
|
||||
serving: TargetServingLookup;
|
||||
/** How long to wait for the target's answer, read live so config edits apply. */
|
||||
getTimeoutMs: () => number;
|
||||
logger: DebugReviewLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny because no authority ever ruled — the request was never delivered,
|
||||
* never answered, or answered unreadably.
|
||||
*
|
||||
* `confirmationUnavailable` is what keeps this out of the "User denied …"
|
||||
* message (#719): a user who was never asked denied nothing. `denialReason`
|
||||
* names which path gave up, and the gate renders it to the model.
|
||||
*
|
||||
* The provenance record reuses that same string rather than restating it, so
|
||||
* what the model is told and what the log attributes cannot drift (#726).
|
||||
*/
|
||||
function abandon(denialReason: string): PermissionPromptDecision {
|
||||
return {
|
||||
approved: false,
|
||||
state: "denied",
|
||||
confirmationUnavailable: true,
|
||||
denialReason,
|
||||
decidedBy: { kind: "unavailable", reason: denialReason },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the responder's answer, recording the hop it came through.
|
||||
*
|
||||
* The requester's own terminal entry has to answer two questions, and they are
|
||||
* different: *which session* answered, and *what within it* decided. Nesting
|
||||
* keeps both rather than flattening the responder's source into this node's
|
||||
* record, where it would read as a local decision (#726).
|
||||
*
|
||||
* A responder that sent no usable source yields `decision: null` — the hop is
|
||||
* still a fact, and an older parent is not an error.
|
||||
*/
|
||||
function relayDecision(
|
||||
response: ForwardedPermissionResponse,
|
||||
): PermissionPromptDecision {
|
||||
return {
|
||||
...response,
|
||||
decidedBy: {
|
||||
kind: "forwarded",
|
||||
responderSessionId: response.responderSessionId,
|
||||
decision: response.decidedBy ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Ids this node is willing to use as a request/response filename. */
|
||||
const FILENAME_SAFE_REQUEST_ID = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/**
|
||||
* The id to write on the forwarded request: the requester's own, or a fresh
|
||||
* mint when that id could not safely name a file.
|
||||
*
|
||||
* At a relay hop the adopted id came from a request file on disk, which the
|
||||
* tolerant reader validates only as a string — so this is the boundary that
|
||||
* keeps an inbound id from choosing an outbound path.
|
||||
*/
|
||||
function forwardableRequestId(requesterRequestId: string): string {
|
||||
return FILENAME_SAFE_REQUEST_ID.test(requesterRequestId)
|
||||
? requesterRequestId
|
||||
: createPermissionRequestId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizer for a subagent session: escalate the ask up the tree to the
|
||||
* parent's authority.
|
||||
*
|
||||
* Owns the escalation-up role of the forwarded-permission behavior: builds
|
||||
* and persists a request file, then polls for the parent session's
|
||||
* response. `ctx` is bound once at construction — `selectAuthorizer` only
|
||||
* constructs a `ParentAuthorizer` for a context it has already confirmed has
|
||||
* no UI and is a subagent, so `authorize` never re-derives that dispatch
|
||||
* (formerly `ApprovalEscalator.requestApproval`'s `hasUI` / `!isSubagent`
|
||||
* arms, both dead once every caller routes through `selectAuthorizer`).
|
||||
*/
|
||||
export class ParentAuthorizer implements TerminalAuthorizer {
|
||||
private readonly forwardingDir: string;
|
||||
private readonly registry: SubagentSessionRegistry | undefined;
|
||||
private readonly serving: TargetServingLookup;
|
||||
private readonly getTimeoutMs: () => number;
|
||||
private readonly logger: DebugReviewLogger;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: ForwarderContext,
|
||||
deps: ParentAuthorizerDeps,
|
||||
) {
|
||||
this.forwardingDir = deps.forwardingDir;
|
||||
this.registry = deps.registry;
|
||||
this.serving = deps.serving;
|
||||
this.getTimeoutMs = deps.getTimeoutMs;
|
||||
this.logger = deps.logger;
|
||||
}
|
||||
|
||||
authorize(
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const uiPrompt = buildUiPrompt(details);
|
||||
return this.waitForForwardedApproval(this.ctx, {
|
||||
requestId: details.requestId,
|
||||
payload: details.payload,
|
||||
display: {
|
||||
source: uiPrompt.source,
|
||||
surface: uiPrompt.surface,
|
||||
value: uiPrompt.value,
|
||||
},
|
||||
sessionApproval: details.sessionApproval,
|
||||
accessIntent: details.accessIntent,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Private methods ────────────────────────────────────────────────────
|
||||
|
||||
private async waitForForwardedApproval(
|
||||
ctx: ForwarderContext,
|
||||
facts: ForwardedRequestFacts,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const requesterSessionId = getSessionId(ctx);
|
||||
const target = resolvePermissionForwardingTarget({
|
||||
hasUI: ctx.hasUI,
|
||||
// Invariant: selectAuthorizer only selects ParentAuthorizer for a
|
||||
// no-UI subagent context, so this is always true — no detection dep
|
||||
// needed to re-derive it here.
|
||||
isSubagent: true,
|
||||
currentSessionId: requesterSessionId,
|
||||
env: process.env,
|
||||
sessionId: requesterSessionId,
|
||||
registry: this.registry,
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Permission forwarding target session could not be resolved. ` +
|
||||
`Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
|
||||
`If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
|
||||
`ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
|
||||
`(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
|
||||
);
|
||||
return abandon(
|
||||
"Could not resolve a parent session to forward this permission request to",
|
||||
);
|
||||
}
|
||||
|
||||
const location = ensurePermissionForwardingLocation(
|
||||
this.logger,
|
||||
this.forwardingDir,
|
||||
target.sessionId,
|
||||
);
|
||||
if (!location) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Permission forwarding is unavailable because session-scoped directories could not be prepared for '${target.sessionId}'`,
|
||||
);
|
||||
return abandon(
|
||||
`Permission forwarding directories could not be prepared for session '${target.sessionId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const request = this.buildForwardedRequest(
|
||||
ctx,
|
||||
facts,
|
||||
requesterSessionId,
|
||||
target.sessionId,
|
||||
);
|
||||
const requestPath = join(location.requestsDir, `${request.id}.json`);
|
||||
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
||||
|
||||
this.logger.review("forwarded_permission.request_created", {
|
||||
requestId: request.id,
|
||||
requesterAgentName: request.requesterAgentName,
|
||||
requesterSessionId: request.requesterSessionId,
|
||||
targetSessionId: target.sessionId,
|
||||
requestPath,
|
||||
responsePath,
|
||||
});
|
||||
|
||||
try {
|
||||
writeJsonFileAtomic(this.logger, requestPath, request);
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Failed to write forwarded permission request '${requestPath}'`,
|
||||
error,
|
||||
);
|
||||
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
||||
return abandon("The forwarded permission request could not be written");
|
||||
}
|
||||
|
||||
return this.pollForForwardedResponse(
|
||||
location,
|
||||
request,
|
||||
requestPath,
|
||||
responsePath,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
private buildForwardedRequest(
|
||||
ctx: ForwarderContext,
|
||||
facts: ForwardedRequestFacts,
|
||||
requesterSessionId: string,
|
||||
targetSessionId: string,
|
||||
): ForwardedPermissionRequest {
|
||||
const requestId = forwardableRequestId(facts.requestId);
|
||||
const requesterAgentName =
|
||||
getActiveAgentName(ctx) ??
|
||||
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
|
||||
"unknown";
|
||||
// Complete the child-fixed facts into a full ForwardedAccessIntent: the
|
||||
// gate fixed the access facts; the edge stamps the requester identity it
|
||||
// alone knows (cwd + principal). The parent resolves against this intent
|
||||
// and never re-derives the match set (ADR 0008).
|
||||
const accessIntent = facts.accessIntent
|
||||
? {
|
||||
...facts.accessIntent,
|
||||
requesterCwd: getCwd(ctx),
|
||||
principal: {
|
||||
sessionId: requesterSessionId,
|
||||
agentName: requesterAgentName,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id: requestId,
|
||||
createdAt: Date.now(),
|
||||
requesterSessionId,
|
||||
targetSessionId,
|
||||
requesterAgentName,
|
||||
payload: facts.payload,
|
||||
...(facts.display
|
||||
? {
|
||||
source: facts.display.source,
|
||||
surface: facts.display.surface,
|
||||
value: facts.display.value,
|
||||
}
|
||||
: {}),
|
||||
...(facts.sessionApproval
|
||||
? { sessionApproval: facts.sessionApproval }
|
||||
: {}),
|
||||
...(accessIntent ? { accessIntent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async pollForForwardedResponse(
|
||||
location: PermissionForwardingLocation,
|
||||
request: ForwardedPermissionRequest,
|
||||
requestPath: string,
|
||||
responsePath: string,
|
||||
target: PermissionForwardingTarget,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const { id: requestId, requesterAgentName, targetSessionId } = request;
|
||||
const timeoutMs = this.getTimeoutMs();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let unservedSince: number | null = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(responsePath)) {
|
||||
const response = readForwardedPermissionResponse(
|
||||
this.logger,
|
||||
responsePath,
|
||||
);
|
||||
const relayed = response ? relayDecision(response) : null;
|
||||
this.logger.review("forwarded_permission.response_received", {
|
||||
requestId,
|
||||
approved: response?.approved ?? null,
|
||||
state: response?.state ?? null,
|
||||
denialReason: response?.denialReason ?? null,
|
||||
responderSessionId: response?.responderSessionId ?? null,
|
||||
targetSessionId,
|
||||
responsePath,
|
||||
decidedBy: relayed?.decidedBy,
|
||||
});
|
||||
this.discardRequest(location, requestPath, responsePath);
|
||||
return (
|
||||
relayed ??
|
||||
abandon("The parent session's permission response could not be read")
|
||||
);
|
||||
}
|
||||
|
||||
unservedSince = this.checkServingLiveness(target, unservedSince);
|
||||
if (
|
||||
unservedSince !== null &&
|
||||
Date.now() - unservedSince >= PERMISSION_FORWARDING_SERVING_GRACE_MS
|
||||
) {
|
||||
const observation = this.serving.describe(target);
|
||||
this.logger.review("forwarded_permission.no_serving_session", {
|
||||
requestId,
|
||||
requesterSessionId: request.requesterSessionId,
|
||||
targetSessionId,
|
||||
// Which channel answered, and what it saw: the difference between a
|
||||
// parent that exited, one that was killed, and one polling under a
|
||||
// different session id is the whole diagnosis of a stalled forward.
|
||||
servingChannel: observation.channel,
|
||||
servingState: observation.state,
|
||||
servingSessionIds: observation.servingIds,
|
||||
});
|
||||
this.discardRequest(location, requestPath);
|
||||
return abandon(
|
||||
`Session '${target.sessionId}' is not serving forwarded permission requests`,
|
||||
);
|
||||
}
|
||||
|
||||
await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
logPermissionForwardingWarning(
|
||||
this.logger,
|
||||
`Timed out waiting for forwarded permission response '${responsePath}'`,
|
||||
);
|
||||
this.logger.review("forwarded_permission.response_timed_out", {
|
||||
requestId,
|
||||
requesterAgentName,
|
||||
targetSessionId,
|
||||
responsePath,
|
||||
});
|
||||
this.discardRequest(location, requestPath);
|
||||
return abandon(
|
||||
`Session '${target.sessionId}' did not answer within ${timeoutMs / 1000}s`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track how long the target has looked unserved, or `null` while it looks fine.
|
||||
*
|
||||
* Which channel can answer for this target is the judge's decision, not this
|
||||
* one's: a target it cannot judge answers `null`, which resets the window
|
||||
* exactly as "serving" does, so an unjudgeable target waits out the timeout.
|
||||
*/
|
||||
private checkServingLiveness(
|
||||
target: PermissionForwardingTarget,
|
||||
unservedSince: number | null,
|
||||
): number | null {
|
||||
return this.serving.isServing(target) === false
|
||||
? (unservedSince ?? Date.now())
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this exchange's files and, if nothing else is pending, its directories.
|
||||
*
|
||||
* Deleting the request is what makes an abandonment final: a request left
|
||||
* behind would be answered by the parent long after the child gave up.
|
||||
*/
|
||||
private discardRequest(
|
||||
location: PermissionForwardingLocation,
|
||||
requestPath: string,
|
||||
responsePath?: string,
|
||||
): void {
|
||||
if (responsePath) {
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
responsePath,
|
||||
"forwarded permission response",
|
||||
);
|
||||
}
|
||||
safeDeleteFile(this.logger, requestPath, "forwarded permission request");
|
||||
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
||||
import type {
|
||||
AuthorizerVerdict,
|
||||
NamedAuthorizer,
|
||||
TerminalAuthorizer,
|
||||
} from "./authorizer";
|
||||
import {
|
||||
createDeniedPermissionDecision,
|
||||
type PermissionPromptDecision,
|
||||
} from "./permission-dialog";
|
||||
|
||||
/**
|
||||
* Compose the live-authority chain (ADR 0007): try each non-terminal `link`
|
||||
* in order, and on `defer` fall through to the next link, ending at the
|
||||
* context-selected `terminal` that always decides.
|
||||
*
|
||||
* The signature is the type-level terminal-cannot-defer invariant: `links` are
|
||||
* deferring {@link NamedAuthorizer}s while `terminal` is a
|
||||
* {@link TerminalAuthorizer} (returns a full decision), so a deferring link
|
||||
* cannot occupy the terminal slot.
|
||||
*
|
||||
* Each link is handed the session-scoped `query` and the review-log `log` at
|
||||
* `authorize` time (ADR 0007 §3) so it queries the deterministic engine at gate
|
||||
* parity and records its decision trail; the terminal receives neither. With
|
||||
* zero links the composed chain **is** the terminal instance (identity), so
|
||||
* behavior is byte-identical to the pre-chain spine — the empty-links case that
|
||||
* ships until a link registers.
|
||||
*/
|
||||
export function composeAuthorizerChain(
|
||||
links: readonly NamedAuthorizer[],
|
||||
terminal: TerminalAuthorizer,
|
||||
query: PermissionQuery,
|
||||
log: AuthorizerLog,
|
||||
): TerminalAuthorizer {
|
||||
if (links.length === 0) {
|
||||
return terminal;
|
||||
}
|
||||
return {
|
||||
async authorize(details) {
|
||||
for (const link of links) {
|
||||
const verdict = await link.authorize(details, query, log);
|
||||
const decision = decideFromVerdict(link.name, verdict);
|
||||
if (decision) {
|
||||
return decision;
|
||||
}
|
||||
// `defer` \u2014 try the next link.
|
||||
}
|
||||
return terminal.authorize(details);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a link's decisive verdict to a decision; `defer` yields `null`.
|
||||
*
|
||||
* The deciding link is named on the decision, not merely counted among the
|
||||
* consulted set the selection already records: a link ahead of it that
|
||||
* deferred decided nothing and must not be credited (#726).
|
||||
*/
|
||||
function decideFromVerdict(
|
||||
name: string,
|
||||
verdict: AuthorizerVerdict,
|
||||
): PermissionPromptDecision | null {
|
||||
switch (verdict.kind) {
|
||||
case "allow":
|
||||
// A link grant is non-persistent (state `approved`, never
|
||||
// `approved_for_session`), per ADR 0007's off-by-default envelope.
|
||||
return {
|
||||
approved: true,
|
||||
state: "approved",
|
||||
decidedBy: decidedByLink(name, "allow", null),
|
||||
};
|
||||
case "deny":
|
||||
return {
|
||||
...createDeniedPermissionDecision(verdict.reason),
|
||||
decidedBy: decidedByLink(name, "deny", verdict.reason ?? null),
|
||||
};
|
||||
case "defer":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decidedByLink(
|
||||
name: string,
|
||||
verdict: "allow" | "deny",
|
||||
reason: string | null,
|
||||
): DecisionSource {
|
||||
return { kind: "authorizer", name, verdict, reason };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Registry for named live-authority chain links (ADR 0007 §4).
|
||||
*
|
||||
* A downstream extension offers a named `Authorizer` link via
|
||||
* `PermissionsService.registerAuthorizer`; this registry stores the link's
|
||||
* `authorize` callback so composition can bind names to capabilities. One link
|
||||
* per name; duplicate registration throws.
|
||||
*
|
||||
* Registration alone grants no authority — a link decides nothing until the
|
||||
* operator names it in the `authorizerChain` config (the opt-in activation
|
||||
* model). `AuthorizerSelection` owns that config-order resolution; this registry
|
||||
* is storage only.
|
||||
*/
|
||||
|
||||
import type { Authorizer } from "./authorizer";
|
||||
|
||||
/**
|
||||
* Read-only lookup used by chain composition (ISP — exposes only the read side,
|
||||
* not the registration surface).
|
||||
*/
|
||||
export interface AuthorizerLookup {
|
||||
get(name: string): Authorizer["authorize"] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration side of the registry (ISP — exposes only the write surface,
|
||||
* mirroring the read-only {@link AuthorizerLookup}).
|
||||
*/
|
||||
export interface AuthorizerRegistrar {
|
||||
register(name: string, authorize: Authorizer["authorize"]): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent registry mapping link names to their `authorize` callbacks.
|
||||
*
|
||||
* Owned by the extension factory (`index.ts`) so it survives across session
|
||||
* activations. Exposed to sibling extensions via
|
||||
* `PermissionsService.registerAuthorizer` and consulted by
|
||||
* `AuthorizerSelection` during chain resolution.
|
||||
*/
|
||||
export class AuthorizerRegistry
|
||||
implements AuthorizerLookup, AuthorizerRegistrar
|
||||
{
|
||||
private readonly links = new Map<string, Authorizer["authorize"]>();
|
||||
|
||||
/**
|
||||
* Register a link under `name`.
|
||||
*
|
||||
* Throws if a link is already registered for that name — keeps resolution
|
||||
* deterministic (a pi-permission-system package priority). Returns a disposer
|
||||
* that removes the link; the disposer is identity-guarded so a stale call
|
||||
* cannot evict a later registration.
|
||||
*/
|
||||
register(name: string, authorize: Authorizer["authorize"]): () => void {
|
||||
if (this.links.has(name)) {
|
||||
throw new Error(`An authorizer is already registered for '${name}'.`);
|
||||
}
|
||||
this.links.set(name, authorize);
|
||||
return () => {
|
||||
if (this.links.get(name) === authorize) {
|
||||
this.links.delete(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
get(name: string): Authorizer["authorize"] | undefined {
|
||||
return this.links.get(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type { PermissionQuery } from "#src/service";
|
||||
import {
|
||||
type AuthorizerSelectionDeps,
|
||||
type NamedAuthorizer,
|
||||
type SelectedAuthority,
|
||||
selectAuthorizer,
|
||||
} from "./authorizer";
|
||||
import { composeAuthorizerChain } from "./authorizer-chain";
|
||||
import type { AuthorizerLookup } from "./authorizer-registry";
|
||||
import { encloseInDelegationEnvelope } from "./delegation-envelope";
|
||||
import type {
|
||||
PermissionPrompterApi,
|
||||
PromptPermissionDetails,
|
||||
} from "./permission-prompter";
|
||||
|
||||
/**
|
||||
* The lifecycle slice of the selection owner that PermissionSession drives.
|
||||
*
|
||||
* PermissionSession calls activate/deactivate to keep the selection's stored
|
||||
* context in sync with its own — the same pattern the former
|
||||
* PromptingGatewayLifecycle used.
|
||||
*/
|
||||
export interface AuthorizerSelectionLifecycle {
|
||||
activate(ctx: ExtensionContext): void;
|
||||
deactivate(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ask-escalation seam `GateRunner` depends on: escalate a single ask to
|
||||
* the session's selected `Authorizer` and return its decision.
|
||||
*
|
||||
* Replaces the two-method `GatePrompter` role (#556). There is no
|
||||
* "can anyone answer" pre-check: absent authority is the `DenyingAuthorizer`,
|
||||
* which answers by denying with a `confirmationUnavailable` marker.
|
||||
*/
|
||||
export interface AskEscalator {
|
||||
escalate(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context-owning selection root for the Authorizer spine.
|
||||
*
|
||||
* The rewrite of `PromptingGateway`: owns the stored `ExtensionContext`, runs
|
||||
* `selectAuthorizer` once per activation, and implements `AskEscalator` by
|
||||
* delegating to the selected `Authorizer` via `PermissionPrompter`.
|
||||
*
|
||||
* `selectAuthorizer` encodes the liveness decision in *which* `Authorizer` it
|
||||
* returns (`LocalUserAuthorizer` / `ParentAuthorizer` when authority is
|
||||
* reachable, `DenyingAuthorizer` otherwise), so no separate confirmability
|
||||
* predicate survives (#556 dissolved `canConfirm()`).
|
||||
*/
|
||||
export class AuthorizerSelection
|
||||
implements AskEscalator, AuthorizerSelectionLifecycle
|
||||
{
|
||||
private authority: SelectedAuthority | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly deps: AuthorizerSelectionDeps & {
|
||||
prompter: PermissionPrompterApi;
|
||||
/** The session-scoped query injected into each chain link (ADR 0007 §3). */
|
||||
getPermissionQuery: () => PermissionQuery;
|
||||
/** Read-only lookup of registered links by name. */
|
||||
authorizerRegistry: AuthorizerLookup;
|
||||
/** The operator's configured link names, read live per ask. */
|
||||
getAuthorizerChain: () => string[];
|
||||
},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Select the live authority for `ctx` and store it. The non-terminal
|
||||
* chain is composed per ask in {@link escalate}, not here: ADR 0007 §4 lets a
|
||||
* link register in a `permissions:ready` handler that may fire after
|
||||
* activation, so link resolution is deferred to the session's first ask.
|
||||
*/
|
||||
activate(ctx: ExtensionContext): void {
|
||||
this.authority = selectAuthorizer(ctx, this.deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chain links for this ask.
|
||||
*
|
||||
* A node that adjudicates locally resolves its configured names; a relaying
|
||||
* node resolves none. Its terminal hands the ask to a serving node, which
|
||||
* resolves the request against its own recorded authority and escalates it
|
||||
* through *its* chain over the same child-fixed facts (#635) — so running
|
||||
* links here would adjudicate one ask twice, and a relaying node cannot host
|
||||
* a link in the first place (#699). The delegation is recorded rather than
|
||||
* reported as a fail-safe skip: an absent link is the design here, not the
|
||||
* misconfiguration `authorizer_chain_unregistered_link` exists to surface.
|
||||
*/
|
||||
private linksFor(
|
||||
authority: SelectedAuthority,
|
||||
requestId: string,
|
||||
): NamedAuthorizer[] {
|
||||
const configured = this.deps.getAuthorizerChain();
|
||||
if (configured.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!authority.adjudicatesLocally) {
|
||||
this.deps.logger.review("authorizer_chain_delegated", {
|
||||
requestId,
|
||||
links: configured,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
return this.resolveConfiguredLinks(configured, requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the operator's `authorizerChain` names to registered links, in
|
||||
* config order (ADR 0007 invariant 1). An unregistered name is skipped with a
|
||||
* warning (invariant 2 — more prompting, never less); each resolved link is
|
||||
* wrapped in the bounded-delegation envelope so an `allow` on an excluded
|
||||
* surface cannot exceed the operator's policy.
|
||||
*
|
||||
* The resolved names are recorded against the ask before any link runs — a
|
||||
* link that defers decides nothing and would otherwise leave no evidence it
|
||||
* was consulted at all, which is what makes "the judge never ran" and "the
|
||||
* judge ran and deferred" indistinguishable in the review log.
|
||||
*/
|
||||
private resolveConfiguredLinks(
|
||||
configured: readonly string[],
|
||||
requestId: string,
|
||||
): NamedAuthorizer[] {
|
||||
const links: NamedAuthorizer[] = [];
|
||||
const resolved: string[] = [];
|
||||
for (const name of configured) {
|
||||
const authorize = this.deps.authorizerRegistry.get(name);
|
||||
if (authorize === undefined) {
|
||||
this.deps.logger.review("authorizer_chain_unregistered_link", {
|
||||
requestId,
|
||||
name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
resolved.push(name);
|
||||
links.push({ name, authorize: encloseInDelegationEnvelope(authorize) });
|
||||
}
|
||||
if (resolved.length > 0) {
|
||||
this.deps.logger.review("authorizer_chain_resolved", {
|
||||
requestId,
|
||||
links: resolved,
|
||||
});
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
/** Clear the stored selection. */
|
||||
deactivate(): void {
|
||||
this.authority = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalate an ask through the composed chain and return its decision.
|
||||
*
|
||||
* Resolves this ask's links freshly (so a link registered any time before
|
||||
* this first ask is honored) and composes them ahead of the selected
|
||||
* terminal. With zero links — no chain configured, or a relaying node that
|
||||
* delegates adjudication to the serving node — the composed value **is** the
|
||||
* terminal instance, so behavior is identical to a bare terminal escalation.
|
||||
*
|
||||
* Rejects if no terminal has been selected — i.e. before the session was
|
||||
* activated. Implements {@link AskEscalator}.
|
||||
*/
|
||||
escalate(
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const authority = this.authority;
|
||||
if (authority === null) {
|
||||
return Promise.reject(
|
||||
new Error("escalate called before the session was activated"),
|
||||
);
|
||||
}
|
||||
const chain = composeAuthorizerChain(
|
||||
this.linksFor(authority, details.requestId),
|
||||
authority.terminal,
|
||||
this.deps.getPermissionQuery(),
|
||||
this.deps.logger,
|
||||
);
|
||||
return this.deps.prompter.prompt(chain, details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type {
|
||||
PromptPreferences,
|
||||
requestPermissionDecision,
|
||||
} from "#src/authority/permission-prompt-component";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import type { PermissionEventBus } from "#src/permission-events";
|
||||
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
import { ParentAuthorizer } from "./approval-escalator";
|
||||
import { DenyingAuthorizer } from "./denying-authorizer";
|
||||
import { LocalUserAuthorizer } from "./local-user-authorizer";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
import type { SubagentDetector } from "./subagent-detection";
|
||||
|
||||
/**
|
||||
* A non-terminal chain link's ruling on an `ask`: decide (`allow`/`deny`) or
|
||||
* pass the ask on to the next link (`defer`). A `deny` carries an optional
|
||||
* teaching `reason` the invoking model sees, so it can self-correct.
|
||||
*/
|
||||
export type AuthorizerVerdict =
|
||||
| { kind: "allow" }
|
||||
| { kind: "deny"; reason?: string }
|
||||
| { kind: "defer" };
|
||||
|
||||
/**
|
||||
* A non-terminal link in the live-authority chain: reviews an `ask` and may
|
||||
* decide it or defer to the next link (ADR 0007). The chain injects a narrow,
|
||||
* session-scoped {@link PermissionQuery} at `authorize` time (§3), so a link
|
||||
* queries the deterministic engine at gate parity rather than reaching for the
|
||||
* cross-extension service via `Symbol.for()`. It also injects an
|
||||
* {@link AuthorizerLog} so a link can record its decision trail to the shared
|
||||
* permission review log (same §3 injection pattern).
|
||||
*/
|
||||
export interface Authorizer {
|
||||
authorize(
|
||||
details: PromptPermissionDetails,
|
||||
query: PermissionQuery,
|
||||
log: AuthorizerLog,
|
||||
): Promise<AuthorizerVerdict>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved chain link together with the operator-configured name it came
|
||||
* from.
|
||||
*
|
||||
* `AuthorizerRegistry` already keys links by name, and `AuthorizerSelection`
|
||||
* has the name in scope when it resolves the operator's `authorizerChain`; the
|
||||
* name is carried through composition so a decision record can say *which*
|
||||
* link decided rather than only which links were consulted.
|
||||
*/
|
||||
export interface NamedAuthorizer extends Authorizer {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal link: on `ask`, rules on a single request and is told the
|
||||
* decision. Structurally cannot defer — it always returns a full
|
||||
* {@link PermissionPromptDecision}, which is the type-level enforcement of
|
||||
* ADR 0007's terminal-cannot-defer invariant.
|
||||
*
|
||||
* One method, one responsibility. `DenyingAuthorizer` ignores `details`;
|
||||
* `LocalUserAuthorizer` renders `payload` for the human and derives the UI
|
||||
* event from the request facts; `ParentAuthorizer` ships `payload` over the
|
||||
* wire so the serving node renders it under its own budget.
|
||||
*/
|
||||
export interface TerminalAuthorizer {
|
||||
authorize(
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node's live-authority selection: who decides this node's asks, and
|
||||
* whether this node adjudicates them with its own chain.
|
||||
*
|
||||
* The chain role is the selection's product, not a discriminator a consumer
|
||||
* re-derives: `selectAuthorizer` tests `hasUI` before `isSubagent`, so a
|
||||
* subagent that has its own UI decides locally, and re-deriving the role from
|
||||
* `detection.isSubagent(ctx)` alone would get that case wrong.
|
||||
*/
|
||||
export interface SelectedAuthority {
|
||||
/** The terminal that decides this node's asks, or relays them upward. */
|
||||
readonly terminal: TerminalAuthorizer;
|
||||
/**
|
||||
* False when the terminal relays the ask to a serving node
|
||||
* (`ParentAuthorizer`): that node resolves the request against its own
|
||||
* recorded authority and escalates it through *its* chain over the same
|
||||
* child-fixed facts (#635), so resolving links here would adjudicate one ask
|
||||
* twice.
|
||||
*/
|
||||
readonly adjudicatesLocally: boolean;
|
||||
}
|
||||
|
||||
/** Construction inputs for {@link selectAuthorizer}. */
|
||||
export interface AuthorizerSelectionDeps {
|
||||
/** Single owner of subagent detection; the ParentAuthorizer-selection predicate. */
|
||||
detection: SubagentDetector;
|
||||
/** Event bus used by `LocalUserAuthorizer` for the `permissions:ui_prompt` broadcast. */
|
||||
events: PermissionEventBus;
|
||||
/** Read live at prompt time; threaded into `LocalUserAuthorizer`. */
|
||||
getPromptPreferences: () => PromptPreferences;
|
||||
/** Injected for testability; production callers pass the real function. */
|
||||
requestPermissionDecision: typeof requestPermissionDecision;
|
||||
/** Forwarding directory `ParentAuthorizer` reads/writes request and response files under. */
|
||||
forwardingDir: string;
|
||||
/** In-process subagent session registry for forwarding target resolution. */
|
||||
registry?: SubagentSessionRegistry;
|
||||
/** Whether a forwarding target is draining its inbox, on whichever channel can say. */
|
||||
serving: TargetServingLookup;
|
||||
/** The forwarding timeout, read live so a config edit applies to the next ask. */
|
||||
getForwardingTimeoutMs: () => number;
|
||||
logger: DebugReviewLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the live authority for the current context: the single owner of the
|
||||
* three-way `hasUI` / `isSubagent` / deny dispatch, and of the chain role that
|
||||
* dispatch implies.
|
||||
*
|
||||
* Evaluated once per session activation (`AuthorizerSelection.activate`),
|
||||
* replacing the re-derivation of the same predicates across
|
||||
* `PromptingGateway`, `PermissionPrompter`, and `ApprovalEscalator`.
|
||||
*/
|
||||
export function selectAuthorizer(
|
||||
ctx: ExtensionContext,
|
||||
deps: AuthorizerSelectionDeps,
|
||||
): SelectedAuthority {
|
||||
if (ctx.hasUI) {
|
||||
return {
|
||||
terminal: new LocalUserAuthorizer({
|
||||
ui: ctx.ui,
|
||||
mode: ctx.mode,
|
||||
events: deps.events,
|
||||
getPromptPreferences: deps.getPromptPreferences,
|
||||
requestPermissionDecision: deps.requestPermissionDecision,
|
||||
}),
|
||||
adjudicatesLocally: true,
|
||||
};
|
||||
}
|
||||
if (deps.detection.isSubagent(ctx)) {
|
||||
return {
|
||||
terminal: new ParentAuthorizer(ctx, {
|
||||
forwardingDir: deps.forwardingDir,
|
||||
registry: deps.registry,
|
||||
serving: deps.serving,
|
||||
getTimeoutMs: deps.getForwardingTimeoutMs,
|
||||
logger: deps.logger,
|
||||
}),
|
||||
adjudicatesLocally: false,
|
||||
};
|
||||
}
|
||||
return { terminal: new DenyingAuthorizer(), adjudicatesLocally: true };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Bracketed-paste normalization for the inline permission dialog's reason field.
|
||||
*
|
||||
* A terminal in bracketed-paste mode wraps pasted text in these markers, and
|
||||
* the TUI hands the wrapped chunk to the focused component in a single call.
|
||||
*/
|
||||
|
||||
const PASTE_START = "\u001b[200~";
|
||||
const PASTE_END = "\u001b[201~";
|
||||
const NEWLINE_RUN = /[\r\n]+/g;
|
||||
|
||||
/**
|
||||
* Collapse newline runs inside a bracketed-paste chunk to single spaces.
|
||||
*
|
||||
* The framework line editor deletes newlines outright, which joins the words
|
||||
* on either side of a line break; a reason pasted from a multi-line source
|
||||
* should stay readable in the single-line field. The markers are preserved so
|
||||
* the editor still recognizes the chunk as a paste, and anything that is not
|
||||
* a complete paste chunk is returned unchanged.
|
||||
*/
|
||||
export function collapsePastedNewlines(data: string): string {
|
||||
const start = data.indexOf(PASTE_START);
|
||||
if (start === -1) {
|
||||
return data;
|
||||
}
|
||||
const contentStart = start + PASTE_START.length;
|
||||
const contentEnd = data.indexOf(PASTE_END, contentStart);
|
||||
if (contentEnd === -1) {
|
||||
return data;
|
||||
}
|
||||
const content = data
|
||||
.slice(contentStart, contentEnd)
|
||||
.replace(NEWLINE_RUN, " ");
|
||||
return data.slice(0, contentStart) + content + data.slice(contentEnd);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* What decided a permission request, recorded at the site that decided it.
|
||||
*
|
||||
* The decision paths are already distinct in the code — a session hit, a yolo
|
||||
* grant, an infrastructure read, a config rule, a chain link, a human at a
|
||||
* dialog, an unreachable authority — and each one knows what it is at the
|
||||
* moment it decides. This is that fact, carried to the record instead of being
|
||||
* discarded and re-guessed from an event name downstream.
|
||||
*
|
||||
* Every variant is **self-contained**: it repeats the detail that made it
|
||||
* decisive rather than leaning on a sibling log column. That duplicates
|
||||
* `surface` and the pattern on a local review line, and it is the only shape
|
||||
* that survives the forwarding hop, where the response file has no such
|
||||
* columns to lean on.
|
||||
*/
|
||||
|
||||
/** Which human-facing surface the operator answered on. */
|
||||
export type UserDecisionSurface = "dialog" | "select";
|
||||
|
||||
export type DecisionSource =
|
||||
/** A human ruled, at the inline dialog or the `select`/`input` fallback. */
|
||||
| { kind: "user"; via: UserDecisionSurface }
|
||||
/** A registered `authorizerChain` link ruled; `name` is the configured name. */
|
||||
| {
|
||||
kind: "authorizer";
|
||||
name: string;
|
||||
verdict: "allow" | "deny";
|
||||
reason: string | null;
|
||||
}
|
||||
/** Recorded authority: a rule in the composed ruleset matched. */
|
||||
| {
|
||||
kind: "rule";
|
||||
surface: string;
|
||||
pattern: string | null;
|
||||
origin: string | null;
|
||||
}
|
||||
/** A session-scoped grant the operator made earlier in this session. */
|
||||
| { kind: "session_approval"; surface: string; pattern: string | null }
|
||||
/**
|
||||
* `yoloMode`. `pattern` preserves the ask's matched rule — including a
|
||||
* synthetic sentinel such as `<opaque-bash-wrapper>` — which is what makes a
|
||||
* yolo grant over a synthesized ask legible.
|
||||
*/
|
||||
| { kind: "yolo"; pattern: string | null }
|
||||
/** A Pi infrastructure read, allowed by containment rather than by a rule. */
|
||||
| { kind: "infrastructure_read" }
|
||||
/**
|
||||
* No authority ever ruled: none was reachable, or the forwarding path gave
|
||||
* up before reaching one. `reason` names which path gave up.
|
||||
*/
|
||||
| { kind: "unavailable"; reason: string }
|
||||
/** A gate threw, and the boundary blocked rather than allowed. */
|
||||
| { kind: "gate_error"; reason: string }
|
||||
/**
|
||||
* Another session decided. Recursive by design: the requesting side records
|
||||
* both that the decider was elsewhere and what, within that session, decided
|
||||
* — which is the distinction an audit of a forwarded ask needs.
|
||||
*
|
||||
* `decision` is `null` when the responder sent none (an older parent).
|
||||
*/
|
||||
| {
|
||||
kind: "forwarded";
|
||||
responderSessionId: string | null;
|
||||
decision: DecisionSource | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How deep a `forwarded` chain may nest before {@link asDecisionSource} gives
|
||||
* up.
|
||||
*
|
||||
* Forwarding is depth-1 by invariant (child → root) and a relay hop makes it
|
||||
* two, so this is headroom rather than a working limit. It exists because the
|
||||
* value is read off disk: a recursive reader over a file another process wrote
|
||||
* is a stack-overflow surface, and the fail-closed answer is to stop.
|
||||
*/
|
||||
export const MAX_DECISION_SOURCE_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* Narrow an unknown value to a {@link DecisionSource}, or `undefined`.
|
||||
*
|
||||
* Lives beside its type so a new variant updates the guard next door, following
|
||||
* `asPromptPayload` and `isPermissionDecisionState`. All-or-nothing: a
|
||||
* malformed field — at any nesting level — yields `undefined` rather than a
|
||||
* half-parsed record, because a provenance record that names a decider who did
|
||||
* not decide is worse than one that names none.
|
||||
*/
|
||||
export function asDecisionSource(value: unknown): DecisionSource | undefined {
|
||||
return narrowSource(value, MAX_DECISION_SOURCE_DEPTH);
|
||||
}
|
||||
|
||||
function narrowSource(
|
||||
value: unknown,
|
||||
depthBudget: number,
|
||||
): DecisionSource | undefined {
|
||||
const candidate = asObject(value);
|
||||
if (!candidate) return undefined;
|
||||
|
||||
switch (candidate.kind) {
|
||||
case "user":
|
||||
return narrowUser(candidate);
|
||||
case "authorizer":
|
||||
return narrowAuthorizer(candidate);
|
||||
case "rule":
|
||||
return narrowRule(candidate);
|
||||
case "session_approval":
|
||||
return narrowSessionApproval(candidate);
|
||||
case "yolo":
|
||||
return isNullableString(candidate.pattern)
|
||||
? { kind: "yolo", pattern: candidate.pattern }
|
||||
: undefined;
|
||||
case "infrastructure_read":
|
||||
return { kind: "infrastructure_read" };
|
||||
case "unavailable":
|
||||
return typeof candidate.reason === "string"
|
||||
? { kind: "unavailable", reason: candidate.reason }
|
||||
: undefined;
|
||||
case "gate_error":
|
||||
return typeof candidate.reason === "string"
|
||||
? { kind: "gate_error", reason: candidate.reason }
|
||||
: undefined;
|
||||
case "forwarded":
|
||||
return narrowForwarded(candidate, depthBudget);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function narrowUser(
|
||||
candidate: Record<string, unknown>,
|
||||
): DecisionSource | undefined {
|
||||
const via = USER_DECISION_SURFACES.find((entry) => entry === candidate.via);
|
||||
return via ? { kind: "user", via } : undefined;
|
||||
}
|
||||
|
||||
function narrowAuthorizer(
|
||||
candidate: Record<string, unknown>,
|
||||
): DecisionSource | undefined {
|
||||
const verdict = AUTHORIZER_VERDICTS.find(
|
||||
(entry) => entry === candidate.verdict,
|
||||
);
|
||||
if (
|
||||
!verdict ||
|
||||
typeof candidate.name !== "string" ||
|
||||
!isNullableString(candidate.reason)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "authorizer",
|
||||
name: candidate.name,
|
||||
verdict,
|
||||
reason: candidate.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function narrowRule(
|
||||
candidate: Record<string, unknown>,
|
||||
): DecisionSource | undefined {
|
||||
if (
|
||||
typeof candidate.surface !== "string" ||
|
||||
!isNullableString(candidate.pattern) ||
|
||||
!isNullableString(candidate.origin)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "rule",
|
||||
surface: candidate.surface,
|
||||
pattern: candidate.pattern,
|
||||
origin: candidate.origin,
|
||||
};
|
||||
}
|
||||
|
||||
function narrowSessionApproval(
|
||||
candidate: Record<string, unknown>,
|
||||
): DecisionSource | undefined {
|
||||
if (
|
||||
typeof candidate.surface !== "string" ||
|
||||
!isNullableString(candidate.pattern)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "session_approval",
|
||||
surface: candidate.surface,
|
||||
pattern: candidate.pattern,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The inner decision is narrowed against a decremented budget, so a chain
|
||||
* deeper than {@link MAX_DECISION_SOURCE_DEPTH} is rejected whole rather than
|
||||
* truncated — a truncated chain would silently attribute the decision to the
|
||||
* last frame that fit.
|
||||
*/
|
||||
function narrowForwarded(
|
||||
candidate: Record<string, unknown>,
|
||||
depthBudget: number,
|
||||
): DecisionSource | undefined {
|
||||
if (depthBudget <= 0 || !isNullableString(candidate.responderSessionId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (candidate.decision === null) {
|
||||
return {
|
||||
kind: "forwarded",
|
||||
responderSessionId: candidate.responderSessionId,
|
||||
decision: null,
|
||||
};
|
||||
}
|
||||
const decision = narrowSource(candidate.decision, depthBudget - 1);
|
||||
return decision
|
||||
? {
|
||||
kind: "forwarded",
|
||||
responderSessionId: candidate.responderSessionId,
|
||||
decision,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const USER_DECISION_SURFACES = [
|
||||
"dialog",
|
||||
"select",
|
||||
] as const satisfies readonly UserDecisionSurface[];
|
||||
|
||||
const AUTHORIZER_VERDICTS = ["allow", "deny"] as const;
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The bounded-delegation enforcement checkpoint (ADR 0007 §5).
|
||||
*
|
||||
* The chain owner caps every registered link's verdict so a buggy or over-eager
|
||||
* external judge can never exceed the operator's policy: a link's `allow` on an
|
||||
* excluded surface is downgraded to `defer`, letting the `ask` fall through to
|
||||
* the terminal (a prompt) instead. The checkpoint only ever *tightens* a
|
||||
* verdict — it never turns a `defer`/`deny` into an `allow`.
|
||||
*
|
||||
* The excluded set is the whole `path` surface plus `external_directory`, with
|
||||
* one bundle-maintained exception: the built-in `read` tool may accept a link's
|
||||
* `allow` for an external-directory ask. Mutating tools, bash, extension tools,
|
||||
* and unknown tools remain capped to the terminal human authority. A finer
|
||||
* secret-shaped-`path` exclusion remains deferred; `path` stays fully excluded.
|
||||
*/
|
||||
|
||||
import type { Authorizer } from "./authorizer";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
|
||||
/** Surfaces on which a link may never grant an `allow` (ADR 0007 §5). */
|
||||
export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
|
||||
"external_directory",
|
||||
"path",
|
||||
]);
|
||||
|
||||
/** Read-only external-directory access explicitly delegable in my-pi. */
|
||||
const DELEGABLE_EXTERNAL_DIRECTORY_TOOLS: ReadonlySet<string> = new Set(["read"]);
|
||||
|
||||
/**
|
||||
* Wrap a link's `authorize` so an `allow` on an excluded surface is capped to
|
||||
* `defer`. All other verdicts, and `allow`s on non-excluded surfaces, pass
|
||||
* through unchanged. `details`, the injected `query`, and the review-log `log`
|
||||
* are forwarded as-is.
|
||||
*/
|
||||
export function encloseInDelegationEnvelope(
|
||||
authorize: Authorizer["authorize"],
|
||||
): Authorizer["authorize"] {
|
||||
return async (details, query, log) => {
|
||||
const verdict = await authorize(details, query, log);
|
||||
if (verdict.kind === "allow" && isExcludedSurface(details)) {
|
||||
return { kind: "defer" };
|
||||
}
|
||||
return verdict;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an allow verdict exceeds the delegation envelope. The gate-computed
|
||||
* surface is authoritative. `path` remains fully excluded; external-directory
|
||||
* access is excluded unless it comes from the built-in `read` tool. Unknown
|
||||
* surfaces fail safe to the terminal authority.
|
||||
*/
|
||||
function isExcludedSurface(details: PromptPermissionDetails): boolean {
|
||||
const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
|
||||
if (surface === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (surface === "external_directory") {
|
||||
return !(
|
||||
details.toolName !== undefined &&
|
||||
DELEGABLE_EXTERNAL_DIRECTORY_TOOLS.has(details.toolName)
|
||||
);
|
||||
}
|
||||
return DELEGATION_EXCLUDED_SURFACES.has(surface);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type { TerminalAuthorizer } from "./authorizer";
|
||||
|
||||
/** Why this authorizer denies; the provenance record's `reason` (#726). */
|
||||
const NO_AUTHORITY_REASON = "No live authority was reachable for this session";
|
||||
|
||||
/**
|
||||
* Least-privilege Authorizer: no authority is reachable for this session
|
||||
* (no UI, not a subagent), so every ask is denied.
|
||||
*
|
||||
* The denial carries the `confirmationUnavailable` marker so the ask path can
|
||||
* distinguish "nobody could answer" from an interactive user denial when it
|
||||
* derives the review-entry and decision-event resolution.
|
||||
*/
|
||||
export class DenyingAuthorizer implements TerminalAuthorizer {
|
||||
authorize(): Promise<PermissionPromptDecision> {
|
||||
return Promise.resolve({
|
||||
approved: false,
|
||||
state: "denied",
|
||||
confirmationUnavailable: true,
|
||||
decidedBy: { kind: "unavailable", reason: NO_AUTHORITY_REASON },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { join } from "node:path";
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import {
|
||||
type ForwarderContext,
|
||||
getSessionId,
|
||||
} from "#src/authority/forwarder-context";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import {
|
||||
type ForwardedAccessFacts,
|
||||
type ForwardedAccessIntent,
|
||||
type ForwardedPermissionRequest,
|
||||
type ForwardedPermissionResponse,
|
||||
isForwardedPermissionRequestForSession,
|
||||
type PermissionForwardingLocation,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
|
||||
import { SessionApproval } from "#src/session-approval";
|
||||
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import type { AskEscalator } from "./authorizer-selection";
|
||||
import {
|
||||
cleanupPermissionForwardingLocationIfEmpty,
|
||||
ensureDirectoryExists,
|
||||
formatUnknownErrorMessage,
|
||||
getExistingPermissionForwardingLocation,
|
||||
listRequestFiles,
|
||||
logPermissionForwardingError,
|
||||
logPermissionForwardingWarning,
|
||||
readForwardedPermissionRequest,
|
||||
safeDeleteFile,
|
||||
writeJsonFileAtomic,
|
||||
} from "./forwarding-io";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
|
||||
/**
|
||||
* Narrow seam describing what `ForwardingManager` needs from the server: a
|
||||
* single method that drains this session's forwarded-permission inbox.
|
||||
*
|
||||
* Depending on the interface (not the concrete `ForwardedRequestServer`)
|
||||
* keeps the manager's unit tests free of casts — they inject a plain
|
||||
* `{ processInbox: vi.fn() }` mock.
|
||||
*/
|
||||
export interface InboxProcessor {
|
||||
processInbox(ctx: ForwarderContext): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded-authority view the serving node resolves a forwarded request
|
||||
* against: answer one {@link ForwardedAccessIntent} query on the serving
|
||||
* session's composed ruleset, agent-scoped to the requester
|
||||
* (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are
|
||||
* used as-is, never re-derived through this session's `PathNormalizer`/cwd.
|
||||
*
|
||||
* Narrow by design (ISP): the server needs one decision, not the whole
|
||||
* resolver. The composition root satisfies it with
|
||||
* `buildResolvedIntentFromMatchValues` plus `resolver.resolve`, the same
|
||||
* `resolve` entry point `LocalPermissionsService` composes.
|
||||
*/
|
||||
export interface ServingPolicy {
|
||||
resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
|
||||
}
|
||||
|
||||
/** Constructor config for `ForwardedRequestServer`. */
|
||||
export interface ForwardedRequestServerDeps {
|
||||
forwardingDir: string;
|
||||
logger: DebugReviewLogger;
|
||||
/** Recorded-authority resolution for a forwarded `ForwardedAccessIntent`. */
|
||||
policy: ServingPolicy;
|
||||
/** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
|
||||
escalator: AskEscalator;
|
||||
/**
|
||||
* The serving session's `SessionRules`. Records a whole-session grant when a
|
||||
* human approves a forwarded request for the entire serving session.
|
||||
*/
|
||||
recorder: SessionApprovalRecorder;
|
||||
/** In-process subagent registry, read only by the one-hop canary. */
|
||||
registry?: SubagentSessionRegistry;
|
||||
}
|
||||
|
||||
// ── Module-private helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a forwarded request onto the escalated ask's details, carrying the
|
||||
* forwarded provenance (requester agent/session + the child's original display
|
||||
* projection) so `LocalUserAuthorizer` emits a non-degraded broadcast (#292),
|
||||
* plus the child-fixed access facts so the serving node's `Authorizer` chain
|
||||
* judges a forwarded ask on the same evidence as a local one (ADR 0008; #635).
|
||||
*
|
||||
* The display `surface` and the fact `surface` are distinct and both belong
|
||||
* here: the former is the child's tool name (what the UI shows), the latter the
|
||||
* gate surface the rule fired on (what the bounded-delegation checkpoint
|
||||
* excludes on).
|
||||
*/
|
||||
function buildForwardedAskDetails(
|
||||
request: ForwardedPermissionRequest,
|
||||
): PromptPermissionDetails {
|
||||
const payload = buildForwardedAskPayload(request);
|
||||
return {
|
||||
requestId: request.id,
|
||||
source: request.source ?? "tool_call",
|
||||
agentName: request.requesterAgentName || null,
|
||||
payload,
|
||||
surface: request.surface ?? null,
|
||||
value: request.value ?? null,
|
||||
forwarding: {
|
||||
requesterAgentName: request.requesterAgentName || null,
|
||||
requesterSessionId: request.requesterSessionId || null,
|
||||
},
|
||||
// Carries the child's suggestion so LocalUserAuthorizer can offer the
|
||||
// whole-session grant scope; absent for a legacy/version-skew request.
|
||||
...(request.sessionApproval
|
||||
? { sessionApproval: request.sessionApproval }
|
||||
: {}),
|
||||
// Absent for a version-skew request that carried no intent — which the
|
||||
// delegation envelope reads as "surface undetermined" and fail-safes to
|
||||
// excluded, so absence must stay absence rather than become `undefined`.
|
||||
...(request.accessIntent
|
||||
? { accessIntent: toAccessFacts(request.accessIntent) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the wire intent down to the child-fixed access facts an `Authorizer`
|
||||
* may see.
|
||||
*
|
||||
* Field-by-field rather than a spread, because this is a disclosure boundary:
|
||||
* `requesterCwd` and `principal` are requester identity for the serving node's
|
||||
* own resolution (ADR 0008 §3) and stay off the ask details. A link that needs
|
||||
* requester identity reads `details.forwarding`. `ForwardedAccessIntent`
|
||||
* extends `ForwardedAccessFacts`, so a spread would type-check while widening
|
||||
* disclosure at runtime; the explicit return type makes any future field on
|
||||
* `ForwardedAccessFacts` a compile error here until it is deliberately
|
||||
* projected or deliberately withheld.
|
||||
*/
|
||||
function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
|
||||
return {
|
||||
surface: intent.surface,
|
||||
matchValues: intent.matchValues,
|
||||
boundaryValue: intent.boundaryValue,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ForwardedRequestServer ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Owner of the serving-down role of the forwarded-permission behavior:
|
||||
* draining this session's forwarded-permission inbox and answering each
|
||||
* request the same way the session resolves a local action — resolving its
|
||||
* `ForwardedAccessIntent` against recorded authority (`ServingPolicy`), then
|
||||
* escalation to its selected `Authorizer` (`AskEscalator`) on `ask` (ADR
|
||||
* 0008).
|
||||
*/
|
||||
export class ForwardedRequestServer implements InboxProcessor {
|
||||
private readonly forwardingDir: string;
|
||||
private readonly logger: DebugReviewLogger;
|
||||
private readonly policy: ServingPolicy;
|
||||
private readonly escalator: AskEscalator;
|
||||
private readonly recorder: SessionApprovalRecorder;
|
||||
private readonly registry: SubagentSessionRegistry | undefined;
|
||||
|
||||
constructor(deps: ForwardedRequestServerDeps) {
|
||||
this.forwardingDir = deps.forwardingDir;
|
||||
this.logger = deps.logger;
|
||||
this.policy = deps.policy;
|
||||
this.escalator = deps.escalator;
|
||||
this.recorder = deps.recorder;
|
||||
this.registry = deps.registry;
|
||||
}
|
||||
|
||||
/** Drain and respond to this session's forwarded-permission inbox. */
|
||||
async processInbox(ctx: ForwarderContext): Promise<void> {
|
||||
const currentSessionId = getSessionId(ctx);
|
||||
const location = getExistingPermissionForwardingLocation(
|
||||
this.forwardingDir,
|
||||
currentSessionId,
|
||||
);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestFiles = listRequestFiles(this.logger, location.requestsDir);
|
||||
if (requestFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defensively recreate responses/ before writing any response — a
|
||||
// concurrent cleanup pass may have removed it between the requestsDir
|
||||
// existence check above and the write inside processSingleForwardedRequest
|
||||
// (the ENOENT write loop reported in issue #398).
|
||||
if (
|
||||
!ensureDirectoryExists(
|
||||
this.logger,
|
||||
location.responsesDir,
|
||||
"permission forwarding responses",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const fileName of requestFiles) {
|
||||
const requestPath = join(location.requestsDir, fileName);
|
||||
const request = readForwardedPermissionRequest(this.logger, requestPath);
|
||||
if (!request) {
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
requestPath,
|
||||
`${location.label} forwarded permission request`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.processSingleForwardedRequest(
|
||||
request,
|
||||
location,
|
||||
requestPath,
|
||||
currentSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
||||
}
|
||||
|
||||
// ── Private methods ────────────────────────────────────────────────────
|
||||
|
||||
private async processSingleForwardedRequest(
|
||||
request: ForwardedPermissionRequest,
|
||||
location: PermissionForwardingLocation,
|
||||
requestPath: string,
|
||||
currentSessionId: string,
|
||||
): Promise<void> {
|
||||
if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
|
||||
logPermissionForwardingWarning(
|
||||
this.logger,
|
||||
`Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
|
||||
);
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
requestPath,
|
||||
`${location.label} forwarded permission request`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.warnOnMultiHop(request, currentSessionId);
|
||||
|
||||
const forwardedPermissionLogDetails = {
|
||||
requestId: request.id,
|
||||
source: location.label,
|
||||
requesterAgentName: request.requesterAgentName,
|
||||
requesterSessionId: request.requesterSessionId,
|
||||
targetSessionId: request.targetSessionId,
|
||||
requestPath,
|
||||
};
|
||||
|
||||
const decision = await this.resolveDecision(
|
||||
request,
|
||||
forwardedPermissionLogDetails,
|
||||
);
|
||||
|
||||
this.recordForwardedDecision(
|
||||
request,
|
||||
location,
|
||||
requestPath,
|
||||
currentSessionId,
|
||||
this.applyGrantScope(request, decision, forwardedPermissionLogDetails),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the human's grant-scope choice on a forwarded approval.
|
||||
*
|
||||
* A whole-session grant (`approved_for_serving_session`) records the child's
|
||||
* suggested pattern into this serving node's `SessionRules` — the single
|
||||
* source of truth for the scope — and is then translated to a plain
|
||||
* `approved` so the child records nothing (its next identical action
|
||||
* re-forwards and resolves as recorded authority). Every other decision
|
||||
* passes through unchanged (`approved_for_session` → the child records).
|
||||
*
|
||||
* The translation rewrites the grant's *scope*, never its decider: the human
|
||||
* who chose the wider scope is still the one who decided (#726).
|
||||
*/
|
||||
private applyGrantScope(
|
||||
request: ForwardedPermissionRequest,
|
||||
decision: PermissionPromptDecision,
|
||||
logDetails: Record<string, unknown>,
|
||||
): PermissionPromptDecision {
|
||||
if (decision.state !== "approved_for_serving_session") {
|
||||
return decision;
|
||||
}
|
||||
if (request.sessionApproval) {
|
||||
this.recorder.recordSessionApproval(
|
||||
SessionApproval.multiple(
|
||||
request.sessionApproval.surface,
|
||||
request.sessionApproval.patterns,
|
||||
),
|
||||
);
|
||||
this.logger.review("forwarded_permission.session_recorded", {
|
||||
...logDetails,
|
||||
surface: request.sessionApproval.surface,
|
||||
patterns: request.sessionApproval.patterns,
|
||||
});
|
||||
}
|
||||
return {
|
||||
approved: true,
|
||||
state: "approved",
|
||||
decidedBy: decision.decidedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the served decision: write the response file the child polls for,
|
||||
* log the outcome, and delete the drained request. The symmetric "respond"
|
||||
* half to {@link resolveDecision}'s "decide" half.
|
||||
*/
|
||||
private recordForwardedDecision(
|
||||
request: ForwardedPermissionRequest,
|
||||
location: PermissionForwardingLocation,
|
||||
requestPath: string,
|
||||
currentSessionId: string,
|
||||
decision: PermissionPromptDecision,
|
||||
): void {
|
||||
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
||||
this.logger.review(
|
||||
decision.approved
|
||||
? "forwarded_permission.approved"
|
||||
: "forwarded_permission.denied",
|
||||
{
|
||||
requestId: request.id,
|
||||
source: location.label,
|
||||
requesterAgentName: request.requesterAgentName,
|
||||
requesterSessionId: request.requesterSessionId,
|
||||
targetSessionId: request.targetSessionId,
|
||||
responsePath,
|
||||
resolution: decision.state,
|
||||
denialReason: decision.denialReason ?? null,
|
||||
decidedBy: decision.decidedBy,
|
||||
},
|
||||
);
|
||||
try {
|
||||
writeJsonFileAtomic(this.logger, responsePath, {
|
||||
approved: decision.approved,
|
||||
state: decision.state,
|
||||
denialReason: decision.denialReason,
|
||||
responderSessionId: currentSessionId,
|
||||
respondedAt: Date.now(),
|
||||
// Carried onto the wire so the requester can name what decided inside
|
||||
// this session, not merely that this session answered (#726).
|
||||
decidedBy: decision.decidedBy,
|
||||
} satisfies ForwardedPermissionResponse);
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Failed to write ${location.label} forwarded permission response '${responsePath}'`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
requestPath,
|
||||
`${location.label} forwarded permission request`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the request the same way the session resolves a local action:
|
||||
* recorded authority first (a request carrying an `accessIntent` — the
|
||||
* child-fixed facts, ADR 0008 §2 — resolves against the serving node's
|
||||
* composed ruleset — `allow`, including yolo-rewritten, auto-approves;
|
||||
* `deny` auto-denies), then escalate `ask` (or a request missing
|
||||
* `accessIntent`, the version-skew floor, ADR 0008 §4) to the selected
|
||||
* `Authorizer`.
|
||||
*/
|
||||
private async resolveDecision(
|
||||
request: ForwardedPermissionRequest,
|
||||
logDetails: Record<string, unknown>,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const check = request.accessIntent
|
||||
? this.policy.resolve(request.accessIntent)
|
||||
: null;
|
||||
|
||||
if (check && check.state !== "ask") {
|
||||
// The rule is carried in full rather than left to the event name: the
|
||||
// response file has no surface, pattern, or origin column for the
|
||||
// requester's record to lean on.
|
||||
const decidedBy: DecisionSource = {
|
||||
kind: "rule",
|
||||
surface: request.accessIntent?.surface ?? check.toolName,
|
||||
pattern: check.matchedPattern ?? null,
|
||||
origin: check.origin,
|
||||
};
|
||||
const approved = check.state === "allow";
|
||||
this.logger.review(
|
||||
approved
|
||||
? "forwarded_permission.auto_approved"
|
||||
: "forwarded_permission.auto_denied",
|
||||
{ ...logDetails, decidedBy },
|
||||
);
|
||||
return approved
|
||||
? { approved: true, state: "approved", decidedBy }
|
||||
: { approved: false, state: "denied", decidedBy };
|
||||
}
|
||||
|
||||
this.logger.review("forwarded_permission.prompted", logDetails);
|
||||
try {
|
||||
return await this.escalator.escalate(buildForwardedAskDetails(request));
|
||||
} catch (error) {
|
||||
const reason = formatUnknownErrorMessage(error);
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Failed to escalate forwarded permission request '${request.id}'`,
|
||||
error,
|
||||
);
|
||||
// Nobody denied this; the escalation broke and the node failed closed.
|
||||
return {
|
||||
approved: false,
|
||||
state: "denied",
|
||||
decidedBy: { kind: "gate_error", reason },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-hop canary: forwarding is depth-1 (child → root). If the requester is
|
||||
* itself a registered subagent whose parent is not this serving session, the
|
||||
* request came through more than one hop (or was misrouted) — resolution is
|
||||
* still well-defined, so keep serving, but warn loudly so a future
|
||||
* recursion-guard break is visible rather than silent. Unregistered
|
||||
* (external file-based) requesters have no recorded parent and are silent.
|
||||
*/
|
||||
private warnOnMultiHop(
|
||||
request: ForwardedPermissionRequest,
|
||||
currentSessionId: string,
|
||||
): void {
|
||||
const requesterInfo = this.registry?.get(request.requesterSessionId);
|
||||
if (
|
||||
requesterInfo?.parentSessionId &&
|
||||
requesterInfo.parentSessionId !== currentSessionId
|
||||
) {
|
||||
logPermissionForwardingWarning(
|
||||
this.logger,
|
||||
`Forwarded permission request '${request.id}' violates the one-hop ` +
|
||||
`invariant: requester '${request.requesterSessionId}' is a registered ` +
|
||||
`subagent whose parent '${requesterInfo.parentSessionId}' is not this ` +
|
||||
`serving session '${currentSessionId}' (multi-hop or misrouted).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { SessionEntryView } from "#src/active-agent";
|
||||
import type { PermissionDecisionUi } from "#src/authority/permission-dialog";
|
||||
|
||||
/**
|
||||
* Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
|
||||
* dialog UI surface, and the three session-manager readers `getSessionId`
|
||||
* and the `active-agent` helpers use.
|
||||
*
|
||||
* A full `ExtensionContext` satisfies this structurally, so production
|
||||
* callers pass `ctx` unchanged.
|
||||
*/
|
||||
export interface ForwarderContext {
|
||||
hasUI: boolean;
|
||||
ui: PermissionDecisionUi;
|
||||
/** The session's working directory, stamped onto a forwarded request as the requester cwd. */
|
||||
cwd: string;
|
||||
sessionManager: {
|
||||
getSessionId(): string;
|
||||
getSessionDir(): string;
|
||||
getEntries(): readonly SessionEntryView[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the current session cwd off `ctx`. */
|
||||
export function getCwd(ctx: ForwarderContext): string {
|
||||
return ctx.cwd;
|
||||
}
|
||||
|
||||
/** Reads the current session id off `ctx`, falling back to `"unknown"`. */
|
||||
export function getSessionId(ctx: ForwarderContext): string {
|
||||
try {
|
||||
const sessionId = ctx.sessionManager.getSessionId();
|
||||
if (typeof sessionId === "string" && sessionId.trim()) {
|
||||
return sessionId.trim();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
|
||||
import { asDecisionSource } from "#src/authority/decision-source";
|
||||
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
||||
import {
|
||||
createPermissionForwardingLocation,
|
||||
type ForwardedAccessIntent,
|
||||
type ForwardedPermissionRequest,
|
||||
type ForwardedPermissionResponse,
|
||||
type ForwardedSessionApproval,
|
||||
type PermissionForwardingLocation,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import {
|
||||
OWNER_ONLY_DIRECTORY_MODE,
|
||||
OWNER_ONLY_FILE_MODE,
|
||||
} from "#src/log-file-permissions";
|
||||
import type { PermissionUiPromptSource } from "#src/permission-events";
|
||||
import { asPromptPayload } from "#src/presentation/prompt-payload";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
|
||||
/** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
|
||||
const UI_PROMPT_SOURCES = [
|
||||
"tool_call",
|
||||
"skill_input",
|
||||
"skill_read",
|
||||
] as const satisfies readonly PermissionUiPromptSource[];
|
||||
|
||||
/** Narrow an unknown value to a valid prompt source, or `undefined`. */
|
||||
function asUiPromptSource(
|
||||
value: unknown,
|
||||
): PermissionUiPromptSource | undefined {
|
||||
return UI_PROMPT_SOURCES.find((source) => source === value);
|
||||
}
|
||||
|
||||
/** Narrow an unknown value to a nullable display string, or `undefined`. */
|
||||
function asNullableDisplayString(value: unknown): string | null | undefined {
|
||||
if (value === null || typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
|
||||
*
|
||||
* Tolerant read: the child's session-approval suggestion is optional (absent
|
||||
* on an older child) and only accepted when well-formed — a non-empty surface
|
||||
* and an all-string patterns array.
|
||||
*/
|
||||
function asForwardedSessionApproval(
|
||||
value: unknown,
|
||||
): ForwardedSessionApproval | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = value as Partial<ForwardedSessionApproval>;
|
||||
if (
|
||||
typeof candidate.surface !== "string" ||
|
||||
candidate.surface.length === 0 ||
|
||||
!Array.isArray(candidate.patterns) ||
|
||||
!candidate.patterns.every((pattern) => typeof pattern === "string")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { surface: candidate.surface, patterns: [...candidate.patterns] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an unknown value to a `ForwardedAccessIntent`, or `undefined`.
|
||||
*
|
||||
* Tolerant read: the child-fixed access intent is optional (absent on an older
|
||||
* child) and only accepted when fully well-formed — a string `surface`, an
|
||||
* all-string `matchValues` array, a `string | null` `boundaryValue`, a string
|
||||
* `requesterCwd`, and a `principal` with string `sessionId`/`agentName`. Any
|
||||
* malformed shape → `undefined`, so the serving node floors to `ask` (Step 3)
|
||||
* rather than resolving against corrupt facts.
|
||||
*/
|
||||
function asForwardedAccessIntent(
|
||||
value: unknown,
|
||||
): ForwardedAccessIntent | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = value as {
|
||||
surface?: unknown;
|
||||
matchValues?: unknown;
|
||||
boundaryValue?: unknown;
|
||||
requesterCwd?: unknown;
|
||||
principal?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof candidate.surface !== "string" ||
|
||||
!Array.isArray(candidate.matchValues) ||
|
||||
!candidate.matchValues.every((entry) => typeof entry === "string") ||
|
||||
!(
|
||||
candidate.boundaryValue === null ||
|
||||
typeof candidate.boundaryValue === "string"
|
||||
) ||
|
||||
typeof candidate.requesterCwd !== "string" ||
|
||||
typeof candidate.principal !== "object" ||
|
||||
candidate.principal === null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const principal = candidate.principal as {
|
||||
sessionId?: unknown;
|
||||
agentName?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof principal.sessionId !== "string" ||
|
||||
typeof principal.agentName !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
surface: candidate.surface,
|
||||
matchValues: [...candidate.matchValues],
|
||||
boundaryValue: candidate.boundaryValue,
|
||||
requesterCwd: candidate.requesterCwd,
|
||||
principal: {
|
||||
sessionId: principal.sessionId,
|
||||
agentName: principal.agentName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUnknownErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function isErrnoCode(error: unknown, code: string): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as { code?: string }).code === code,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning to both the review and debug logs.
|
||||
* Pass `null` for `logger` to silently no-op (e.g. in unit tests without IO).
|
||||
*/
|
||||
export function logPermissionForwardingWarning(
|
||||
logger: DebugReviewLogger | null,
|
||||
message: string,
|
||||
error?: unknown,
|
||||
): void {
|
||||
const details =
|
||||
typeof error === "undefined"
|
||||
? { message }
|
||||
: { message, error: formatUnknownErrorMessage(error) };
|
||||
|
||||
logger?.review("permission_forwarding.warning", details);
|
||||
logger?.debug("permission_forwarding.warning", details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error to both the review and debug logs.
|
||||
* Pass `null` for `logger` to silently no-op (e.g. in unit tests without IO).
|
||||
*/
|
||||
export function logPermissionForwardingError(
|
||||
logger: DebugReviewLogger | null,
|
||||
message: string,
|
||||
error?: unknown,
|
||||
): void {
|
||||
const details =
|
||||
typeof error === "undefined"
|
||||
? { message }
|
||||
: { message, error: formatUnknownErrorMessage(error) };
|
||||
|
||||
logger?.review("permission_forwarding.error", details);
|
||||
logger?.debug("permission_forwarding.error", details);
|
||||
}
|
||||
|
||||
export function ensureDirectoryExists(
|
||||
logger: DebugReviewLogger | null,
|
||||
path: string,
|
||||
description: string,
|
||||
): boolean {
|
||||
try {
|
||||
mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
logger,
|
||||
`Failed to create ${description} directory '${path}'`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPermissionForwardingLocationForSession(
|
||||
forwardingDir: string,
|
||||
sessionId: string,
|
||||
): PermissionForwardingLocation {
|
||||
return createPermissionForwardingLocation(forwardingDir, sessionId);
|
||||
}
|
||||
|
||||
export function ensurePermissionForwardingLocation(
|
||||
logger: DebugReviewLogger | null,
|
||||
forwardingDir: string,
|
||||
sessionId: string,
|
||||
): PermissionForwardingLocation | null {
|
||||
let location: PermissionForwardingLocation;
|
||||
try {
|
||||
location = getPermissionForwardingLocationForSession(
|
||||
forwardingDir,
|
||||
sessionId,
|
||||
);
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
logger,
|
||||
"Failed to resolve permission forwarding location",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionRootReady = ensureDirectoryExists(
|
||||
logger,
|
||||
location.sessionRootDir,
|
||||
"permission forwarding session root",
|
||||
);
|
||||
const requestsReady = ensureDirectoryExists(
|
||||
logger,
|
||||
location.requestsDir,
|
||||
"permission forwarding requests",
|
||||
);
|
||||
const responsesReady = ensureDirectoryExists(
|
||||
logger,
|
||||
location.responsesDir,
|
||||
"permission forwarding responses",
|
||||
);
|
||||
|
||||
return sessionRootReady && requestsReady && responsesReady ? location : null;
|
||||
}
|
||||
|
||||
export function getExistingPermissionForwardingLocation(
|
||||
forwardingDir: string,
|
||||
sessionId: string,
|
||||
): PermissionForwardingLocation | null {
|
||||
let location: PermissionForwardingLocation;
|
||||
try {
|
||||
location = getPermissionForwardingLocationForSession(
|
||||
forwardingDir,
|
||||
sessionId,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return existsSync(location.requestsDir) ? location : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to remove a directory if it is empty.
|
||||
*
|
||||
* Returns `true` when the directory is absent after the call (successfully
|
||||
* removed, or never existed). Returns `false` when the directory still exists
|
||||
* (non-empty, or a filesystem error prevented removal).
|
||||
*/
|
||||
export function tryRemoveDirectoryIfEmpty(
|
||||
logger: DebugReviewLogger | null,
|
||||
path: string,
|
||||
description: string,
|
||||
): boolean {
|
||||
if (!existsSync(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(path);
|
||||
} catch (error) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to inspect ${description} directory '${path}'`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
rmdirSync(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isErrnoCode(error, "ENOENT")) {
|
||||
return true;
|
||||
}
|
||||
if (isErrnoCode(error, "ENOTEMPTY")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to remove empty ${description} directory '${path}'`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupPermissionForwardingLocationIfEmpty(
|
||||
logger: DebugReviewLogger | null,
|
||||
location: PermissionForwardingLocation,
|
||||
): void {
|
||||
// Only remove responses/ when requests/ is already gone — removing responses/
|
||||
// while a request is still pending causes the ENOENT write loop (issue #398).
|
||||
const requestsGone = tryRemoveDirectoryIfEmpty(
|
||||
logger,
|
||||
location.requestsDir,
|
||||
`${location.label} permission forwarding requests`,
|
||||
);
|
||||
if (requestsGone) {
|
||||
tryRemoveDirectoryIfEmpty(
|
||||
logger,
|
||||
location.responsesDir,
|
||||
`${location.label} permission forwarding responses`,
|
||||
);
|
||||
}
|
||||
tryRemoveDirectoryIfEmpty(
|
||||
logger,
|
||||
location.sessionRootDir,
|
||||
`${location.label} permission forwarding session root`,
|
||||
);
|
||||
}
|
||||
|
||||
export function safeDeleteFile(
|
||||
logger: DebugReviewLogger | null,
|
||||
filePath: string,
|
||||
description: string,
|
||||
): void {
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
} catch (error) {
|
||||
if (isErrnoCode(error, "ENOENT")) {
|
||||
return;
|
||||
}
|
||||
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to delete ${description} file '${filePath}'`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonFileAtomic(
|
||||
logger: DebugReviewLogger | null,
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
|
||||
try {
|
||||
// `rename` preserves the temp file's mode, so setting it here is enough —
|
||||
// a response overwriting an existing file also comes through a fresh temp.
|
||||
writeFileSync(tempPath, JSON.stringify(value), {
|
||||
encoding: "utf-8",
|
||||
mode: OWNER_ONLY_FILE_MODE,
|
||||
});
|
||||
renameSync(tempPath, filePath);
|
||||
} catch (error) {
|
||||
safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function readForwardedPermissionRequest(
|
||||
logger: DebugReviewLogger | null,
|
||||
filePath: string,
|
||||
): ForwardedPermissionRequest | null {
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
|
||||
!parsed ||
|
||||
typeof parsed.id !== "string" ||
|
||||
typeof parsed.createdAt !== "number" ||
|
||||
typeof parsed.requesterSessionId !== "string" ||
|
||||
typeof parsed.targetSessionId !== "string" ||
|
||||
typeof parsed.requesterAgentName !== "string"
|
||||
) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Ignoring invalid forwarded permission request format in '${filePath}'`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: parsed.id,
|
||||
createdAt: parsed.createdAt,
|
||||
requesterSessionId: parsed.requesterSessionId,
|
||||
targetSessionId: parsed.targetSessionId,
|
||||
requesterAgentName: parsed.requesterAgentName,
|
||||
// Tolerant read: the payload and display fields are optional and may be
|
||||
// absent (older child) or malformed; reconstruct only the well-formed
|
||||
// ones. An older child's `message` is deliberately not salvaged — a
|
||||
// skewed ask renders from the fields it does carry (ADR 0011 §9).
|
||||
payload: asPromptPayload(parsed.payload),
|
||||
source: asUiPromptSource(parsed.source),
|
||||
surface: asNullableDisplayString(parsed.surface),
|
||||
value: asNullableDisplayString(parsed.value),
|
||||
sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
|
||||
accessIntent: asForwardedAccessIntent(parsed.accessIntent),
|
||||
};
|
||||
} catch (error) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to read forwarded permission request '${filePath}'`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readForwardedPermissionResponse(
|
||||
logger: DebugReviewLogger | null,
|
||||
filePath: string,
|
||||
): ForwardedPermissionResponse | null {
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
|
||||
!parsed ||
|
||||
typeof parsed.approved !== "boolean" ||
|
||||
!isPermissionDecisionState(parsed.state) ||
|
||||
typeof parsed.responderSessionId !== "string"
|
||||
) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Ignoring invalid forwarded permission response format in '${filePath}'`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
approved: parsed.approved,
|
||||
state: parsed.state,
|
||||
denialReason:
|
||||
typeof parsed.denialReason === "string"
|
||||
? parsed.denialReason
|
||||
: undefined,
|
||||
responderSessionId: parsed.responderSessionId,
|
||||
respondedAt:
|
||||
typeof parsed.respondedAt === "number"
|
||||
? parsed.respondedAt
|
||||
: Date.now(),
|
||||
// Tolerant like the request's `accessIntent`: an unusable provenance
|
||||
// record is dropped, but the decision itself still has to reach the
|
||||
// requester, so it never rejects the response.
|
||||
decidedBy: asDecisionSource(parsed.decidedBy),
|
||||
};
|
||||
} catch (error) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to read forwarded permission response '${filePath}'`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listRequestFiles(
|
||||
logger: DebugReviewLogger | null,
|
||||
requestsDir: string,
|
||||
): string[] {
|
||||
try {
|
||||
return readdirSync(requestsDir)
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.sort();
|
||||
} catch (error) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to read permission forwarding requests from '${requestsDir}'`,
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* forwarding-liveness.ts — Is anyone draining a forwarded-permission inbox?
|
||||
*
|
||||
* The in-process answer already exists: a serving session marks itself in the
|
||||
* process-global `ServingSessionRegistry`, and an in-process child abandons a
|
||||
* target that has looked unmarked for the grace window instead of waiting out
|
||||
* the full forwarding timeout (#719).
|
||||
*
|
||||
* A child spawned as a separate `pi` process shares no `globalThis` with its
|
||||
* parent, so that mark is invisible to it and it keeps waiting the full ten
|
||||
* minutes — every `ask` forwarded to a session that has already exited costs
|
||||
* the whole timeout and ends in a denial nobody made (#735 scenario 1).
|
||||
*
|
||||
* The filesystem is the only channel those two processes share, so the serving
|
||||
* session publishes a heartbeat there: one record per serving session,
|
||||
* refreshed while it polls and withdrawn when it stops.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ensureDirectoryExists,
|
||||
isErrnoCode,
|
||||
logPermissionForwardingError,
|
||||
safeDeleteFile,
|
||||
writeJsonFileAtomic,
|
||||
} from "#src/authority/forwarding-io";
|
||||
import type { PermissionForwardingTarget } from "#src/authority/permission-forwarding";
|
||||
import {
|
||||
encodeSessionIdForPath,
|
||||
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import type {
|
||||
ServingAnnouncer,
|
||||
ServingLookup,
|
||||
} from "#src/authority/serving-registry";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
|
||||
/**
|
||||
* How often a serving session rewrites its heartbeat — four poll ticks.
|
||||
*
|
||||
* Longer than the poll interval so `ForwardingManager` can announce on every
|
||||
* tick without four filesystem writes a second, and short enough that a record
|
||||
* deleted underneath its owner reappears well inside the grace window a
|
||||
* forwarding child waits out before abandoning.
|
||||
*/
|
||||
export const SERVING_HEARTBEAT_REFRESH_MS =
|
||||
4 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
|
||||
|
||||
/**
|
||||
* How long a heartbeat may go unrefreshed before its writer is presumed gone —
|
||||
* five refreshes.
|
||||
*
|
||||
* Generous because a delayed Node timer is not a dead session, and because the
|
||||
* case this threshold exists for (a process that is alive but no longer
|
||||
* polling) is the rare one: an exited session withdraws its record and a killed
|
||||
* one is caught by the recorded pid, neither of which waits for staleness.
|
||||
*/
|
||||
export const SERVING_HEARTBEAT_STALE_MS = 5 * SERVING_HEARTBEAT_REFRESH_MS;
|
||||
|
||||
/** What a serving session publishes while it drains its forwarded-permission inbox. */
|
||||
export interface ServingHeartbeat {
|
||||
sessionId: string;
|
||||
/** The serving process, so a killed session is detectable without waiting out staleness. */
|
||||
pid: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a session's heartbeat reads right now.
|
||||
*
|
||||
* Only `"alive"` means someone is draining the inbox. The other three are the
|
||||
* ways a target can be unserved, kept apart because they are the diagnosis a
|
||||
* stalled forward needs: `"absent"` is a session that exited (or never served,
|
||||
* or runs a version that does not publish), `"dead_pid"` one that was killed,
|
||||
* and `"stale"` one whose process survives but stopped polling.
|
||||
*/
|
||||
export type HeartbeatState = "alive" | "absent" | "stale" | "dead_pid";
|
||||
|
||||
/**
|
||||
* Read side of the heartbeat channel, consumed by a forwarding child.
|
||||
*
|
||||
* Separate from the announce seam because the two have no caller in common: a
|
||||
* serving session only publishes, and a forwarding child only reads (ISP).
|
||||
*/
|
||||
export interface HeartbeatReader {
|
||||
read(sessionId: string): HeartbeatState;
|
||||
/** Every session whose record reads as alive, for the abandonment diagnostic. */
|
||||
servingIds(): readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-side seam: is the session a forwarding target names being drained?
|
||||
*
|
||||
* Keyed on the target rather than a session id because the answer depends on
|
||||
* how the target was resolved. An in-process child and its parent share a
|
||||
* `globalThis`, so the registry answers for them; an out-of-process pair shares
|
||||
* only the filesystem; and a session that owns the inbox it is forwarding to is
|
||||
* not a case either channel describes.
|
||||
*
|
||||
* Consolidating that into one collaborator is what keeps `ParentAuthorizer`
|
||||
* from holding two lookups and re-deciding which one applies — the decision has
|
||||
* one home, and a third channel would not reach the poll loop.
|
||||
*/
|
||||
export interface TargetServingLookup {
|
||||
/** `true` serving, `false` not serving, `null` when the target carries no signal. */
|
||||
isServing(target: PermissionForwardingTarget): boolean | null;
|
||||
/** What the judge observed, for the review entry a child writes when it gives up. */
|
||||
describe(target: PermissionForwardingTarget): ServingObservation;
|
||||
}
|
||||
|
||||
/** What answered a liveness question, and what it saw. */
|
||||
export interface ServingObservation {
|
||||
channel: "registry" | "heartbeat" | "none";
|
||||
/** The heartbeat state behind a `"heartbeat"` answer; `null` on the other channels. */
|
||||
state: HeartbeatState | null;
|
||||
servingIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Constructor config for {@link ForwardingLivenessJudge}. */
|
||||
export interface ForwardingLivenessJudgeDeps {
|
||||
/** Answers for a target the requester shares a process with. */
|
||||
registry: ServingLookup;
|
||||
/** Answers for a target in another process. */
|
||||
heartbeats: HeartbeatReader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a liveness question to the channel that can answer it.
|
||||
*
|
||||
* The routing key is `PermissionForwardingTarget.source`, which the resolver
|
||||
* already produces — so "in-process" is decided once, where the target is
|
||||
* found, rather than re-derived here (#719).
|
||||
*/
|
||||
export class ForwardingLivenessJudge implements TargetServingLookup {
|
||||
constructor(private readonly deps: ForwardingLivenessJudgeDeps) {}
|
||||
|
||||
isServing(target: PermissionForwardingTarget): boolean | null {
|
||||
switch (target.source) {
|
||||
case "registry":
|
||||
return this.deps.registry.isServing(target.sessionId);
|
||||
case "env":
|
||||
return this.deps.heartbeats.read(target.sessionId) === "alive";
|
||||
case "self":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe(target: PermissionForwardingTarget): ServingObservation {
|
||||
switch (target.source) {
|
||||
case "registry":
|
||||
return {
|
||||
channel: "registry",
|
||||
state: null,
|
||||
servingIds: this.deps.registry.servingIds(),
|
||||
};
|
||||
case "env":
|
||||
return {
|
||||
channel: "heartbeat",
|
||||
state: this.deps.heartbeats.read(target.sessionId),
|
||||
servingIds: this.deps.heartbeats.servingIds(),
|
||||
};
|
||||
case "self":
|
||||
return { channel: "none", state: null, servingIds: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SERVING_HEARTBEAT_DIRECTORY_NAME = "serving";
|
||||
|
||||
/**
|
||||
* Where serving heartbeats live: beside the `sessions/` tree, never inside it.
|
||||
*
|
||||
* A heartbeat under `sessions/<id>/` would make that session root permanently
|
||||
* non-empty, entangling liveness with the request/response cleanup whose
|
||||
* removal ordering already produced an ENOENT write loop (#398). Kept disjoint,
|
||||
* that logic stays untouched and "who is serving" is a single directory read.
|
||||
*/
|
||||
export function servingHeartbeatDir(forwardingDir: string): string {
|
||||
return join(forwardingDir, SERVING_HEARTBEAT_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
/** The heartbeat record for `sessionId`, under {@link servingHeartbeatDir}. */
|
||||
export function servingHeartbeatPath(
|
||||
forwardingDir: string,
|
||||
sessionId: string,
|
||||
): string {
|
||||
return join(
|
||||
servingHeartbeatDir(forwardingDir),
|
||||
`${encodeSessionIdForPath(sessionId)}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Constructor config for {@link ServingHeartbeatStore}. */
|
||||
export interface ServingHeartbeatStoreDeps {
|
||||
forwardingDir: string;
|
||||
logger: DebugReviewLogger;
|
||||
/** Injected so the refresh throttle and staleness are testable without sleeping. */
|
||||
now?: () => number;
|
||||
/** The process to record. Injected so a test can publish a pid it controls. */
|
||||
pid?: number;
|
||||
/** Injected so a test can decide which pids are running. */
|
||||
isProcessAlive?: (pid: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes this session's serving heartbeat to the filesystem.
|
||||
*
|
||||
* Satisfies the same {@link ServingAnnouncer} seam as `ServingSessionRegistry`,
|
||||
* so `ForwardingManager` announces to both channels through one collaborator
|
||||
* and neither knows the other exists.
|
||||
*
|
||||
* `markServing` is idempotent by that seam's contract and internally throttled,
|
||||
* so the caller may announce on every poll tick. Nothing here throws: it runs
|
||||
* from a timer, and a filesystem failure must degrade to the pre-existing
|
||||
* timeout rather than break the poll loop.
|
||||
*/
|
||||
export class ServingHeartbeatStore
|
||||
implements ServingAnnouncer, HeartbeatReader
|
||||
{
|
||||
private readonly forwardingDir: string;
|
||||
private readonly logger: DebugReviewLogger;
|
||||
private readonly now: () => number;
|
||||
private readonly pid: number;
|
||||
private readonly isProcessAlive: (pid: number) => boolean;
|
||||
private published: { sessionId: string; at: number } | null = null;
|
||||
private hasSweptDeadRecords = false;
|
||||
|
||||
constructor(deps: ServingHeartbeatStoreDeps) {
|
||||
this.forwardingDir = deps.forwardingDir;
|
||||
this.logger = deps.logger;
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.pid = deps.pid ?? process.pid;
|
||||
this.isProcessAlive = deps.isProcessAlive ?? isRunningProcess;
|
||||
}
|
||||
|
||||
/** Publish (or refresh) `sessionId`'s heartbeat. Throttled; never throws. */
|
||||
markServing(sessionId: string): void {
|
||||
const at = this.now();
|
||||
if (this.isThrottled(sessionId, at)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directory = servingHeartbeatDir(this.forwardingDir);
|
||||
if (
|
||||
!ensureDirectoryExists(
|
||||
this.logger,
|
||||
directory,
|
||||
"permission forwarding serving heartbeat",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.sweepDeadRecordsOnce();
|
||||
|
||||
const heartbeat: ServingHeartbeat = {
|
||||
sessionId,
|
||||
pid: this.pid,
|
||||
updatedAt: at,
|
||||
};
|
||||
try {
|
||||
writeJsonFileAtomic(
|
||||
this.logger,
|
||||
servingHeartbeatPath(this.forwardingDir, sessionId),
|
||||
heartbeat,
|
||||
);
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Failed to publish the serving heartbeat for session '${sessionId}'`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.published = { sessionId, at };
|
||||
}
|
||||
|
||||
/** Withdraw `sessionId`'s heartbeat, leaving the directory for its siblings. */
|
||||
clearServing(sessionId: string): void {
|
||||
if (this.published?.sessionId === sessionId) {
|
||||
this.published = null;
|
||||
}
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
servingHeartbeatPath(this.forwardingDir, sessionId),
|
||||
"permission forwarding serving heartbeat",
|
||||
);
|
||||
}
|
||||
|
||||
/** How `sessionId`'s heartbeat reads right now. */
|
||||
read(sessionId: string): HeartbeatState {
|
||||
const record = this.readRecord(
|
||||
servingHeartbeatPath(this.forwardingDir, sessionId),
|
||||
);
|
||||
return record === null ? "absent" : this.classify(record);
|
||||
}
|
||||
|
||||
/** Every session whose record reads as alive. */
|
||||
servingIds(): readonly string[] {
|
||||
const ids: string[] = [];
|
||||
for (const { record } of this.listRecords()) {
|
||||
if (record !== null && this.classify(record) === "alive") {
|
||||
ids.push(record.sessionId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ── Private methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Delete the records of processes that are provably gone, once per session.
|
||||
*
|
||||
* Without this the directory grows one record per session that was killed
|
||||
* rather than shut down, forever. Bounded to a single directory read at the
|
||||
* first announcement, and safe under pid reuse: a wrongly swept owner
|
||||
* republishes within the refresh window, which is shorter than the grace a
|
||||
* forwarding child waits out.
|
||||
*
|
||||
* Only a dead pid is proof. A record that is merely stale belongs to a
|
||||
* process that still exists, and the reader already reports it as stale
|
||||
* without anyone having to remove it.
|
||||
*/
|
||||
private sweepDeadRecordsOnce(): void {
|
||||
if (this.hasSweptDeadRecords) {
|
||||
return;
|
||||
}
|
||||
this.hasSweptDeadRecords = true;
|
||||
for (const { path, record } of this.listRecords()) {
|
||||
if (record !== null && this.isProcessAlive(record.pid)) {
|
||||
continue;
|
||||
}
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
path,
|
||||
"abandoned permission forwarding serving heartbeat",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every published record, paired with its path; unusable ones read as `null`. */
|
||||
private listRecords(): {
|
||||
path: string;
|
||||
record: ServingHeartbeat | null;
|
||||
}[] {
|
||||
const directory = servingHeartbeatDir(this.forwardingDir);
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(directory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => {
|
||||
const path = join(directory, name);
|
||||
return { path, record: this.readRecord(path) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a record, or `null` when it is missing or unusable.
|
||||
*
|
||||
* Silent by design: a forwarding child calls this on every poll tick, so a
|
||||
* warning per unreadable read would flood the review log at four lines a
|
||||
* second. The unusability is already reported once, as the `absent` state on
|
||||
* the abandonment entry.
|
||||
*/
|
||||
private readRecord(path: string): ServingHeartbeat | null {
|
||||
try {
|
||||
return asServingHeartbeat(JSON.parse(readFileSync(path, "utf-8")));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Which of the four states a well-formed record is in. */
|
||||
private classify(record: ServingHeartbeat): HeartbeatState {
|
||||
if (!this.isProcessAlive(record.pid)) {
|
||||
return "dead_pid";
|
||||
}
|
||||
return this.now() - record.updatedAt >= SERVING_HEARTBEAT_STALE_MS
|
||||
? "stale"
|
||||
: "alive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the record on disk is recent enough to leave alone.
|
||||
*
|
||||
* Time alone, with no existence probe: an existence check would cost a
|
||||
* syscall on every poll tick to save at most one refresh window, and a record
|
||||
* removed underneath its owner reappears inside the grace window anyway.
|
||||
*/
|
||||
private isThrottled(sessionId: string, at: number): boolean {
|
||||
return (
|
||||
this.published !== null &&
|
||||
this.published.sessionId === sessionId &&
|
||||
at - this.published.at < SERVING_HEARTBEAT_REFRESH_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Module-private helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Narrow a parsed record, or `undefined`.
|
||||
*
|
||||
* `pid` must be a positive integer specifically: `process.kill(0, 0)` addresses
|
||||
* the caller's own process group and `kill(-n)` a foreign one, so a malformed
|
||||
* record must be rejected before it can reach the liveness probe.
|
||||
*/
|
||||
function asServingHeartbeat(value: unknown): ServingHeartbeat | null {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as Partial<ServingHeartbeat>;
|
||||
if (
|
||||
typeof candidate.sessionId !== "string" ||
|
||||
candidate.sessionId.length === 0 ||
|
||||
typeof candidate.pid !== "number" ||
|
||||
!Number.isInteger(candidate.pid) ||
|
||||
candidate.pid <= 0 ||
|
||||
typeof candidate.updatedAt !== "number" ||
|
||||
!Number.isFinite(candidate.updatedAt)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sessionId: candidate.sessionId,
|
||||
pid: candidate.pid,
|
||||
updatedAt: candidate.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `pid` names a running process.
|
||||
*
|
||||
* Signal `0` performs the permission and existence checks without delivering
|
||||
* anything. `EPERM` means the process exists under another user — reported as
|
||||
* alive, the direction that falls back to the timeout rather than abandoning a
|
||||
* request someone may still answer.
|
||||
*/
|
||||
function isRunningProcess(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return isErrnoCode(error, "EPERM");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
import type { InboxProcessor } from "./forwarded-request-server";
|
||||
import { getSessionId } from "./forwarder-context";
|
||||
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
||||
import type { ServingAnnouncer } from "./serving-registry";
|
||||
import type { SubagentDetector } from "./subagent-detection";
|
||||
|
||||
/**
|
||||
* Narrow interface for the forwarding lifecycle used by `PermissionSession`.
|
||||
* `ForwardingManager` satisfies it; tests can provide a plain object mock.
|
||||
*/
|
||||
export interface ForwardingController {
|
||||
start(ctx: ExtensionContext): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/** Constructor config for {@link ForwardingManager}. */
|
||||
export interface ForwardingManagerDeps {
|
||||
/** Single owner of subagent detection; gates whether this session may serve. */
|
||||
detection: SubagentDetector;
|
||||
/** Drains this session's forwarded-permission inbox on each tick. */
|
||||
forwarder: InboxProcessor;
|
||||
/** Publishes that this session is draining its inbox, for forwarding children. */
|
||||
serving: ServingAnnouncer;
|
||||
logger: DebugReviewLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the forwarded-permission polling lifecycle.
|
||||
*
|
||||
* Owns the timer, current context, and processing-lock state that previously
|
||||
* lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
|
||||
* session event that may activate forwarding; call `stop()` on session
|
||||
* shutdown.
|
||||
*
|
||||
* While polling, it publishes the session id it polls to the `ServingAnnouncer`
|
||||
* so a forwarding child can tell that someone is draining the inbox it wrote
|
||||
* into — and the review log records that id, so a child forwarding to a
|
||||
* *different* id is visible as a one-line diff against its
|
||||
* `forwarded_permission.request_created` entry (#719).
|
||||
*/
|
||||
export class ForwardingManager {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private context: ExtensionContext | null = null;
|
||||
private processing = false;
|
||||
private servingSessionId: string | null = null;
|
||||
|
||||
constructor(private readonly deps: ForwardingManagerDeps) {}
|
||||
|
||||
/**
|
||||
* Start polling if `ctx` has UI and is not a subagent execution context.
|
||||
* No-op (timer stays running) if already polling — updates the stored
|
||||
* context so the next tick uses the latest session.
|
||||
* Stops any existing poll when the context does not qualify for forwarding.
|
||||
*/
|
||||
start(ctx: ExtensionContext): void {
|
||||
if (!ctx.hasUI || this.deps.detection.isSubagent(ctx)) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
this.context = ctx;
|
||||
this.announceServing(getSessionId(ctx));
|
||||
if (this.timer) {
|
||||
return;
|
||||
}
|
||||
this.timer = setInterval(() => {
|
||||
// Ahead of the processing guard: a session whose human is deliberating at
|
||||
// a forwarded dialog holds `processInbox` open for as long as they take,
|
||||
// and it is serving throughout. Refreshing behind the guard would let its
|
||||
// announcement decay exactly when it is most demonstrably alive, and
|
||||
// every other forwarding child would give up on it.
|
||||
this.refreshServing();
|
||||
if (!this.context || this.processing) {
|
||||
return;
|
||||
}
|
||||
this.processing = true;
|
||||
void this.deps.forwarder.processInbox(this.context).finally(() => {
|
||||
this.processing = false;
|
||||
});
|
||||
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop polling and clear all internal state. */
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.withdrawServing();
|
||||
this.context = null;
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
// ── Private methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Publish `sessionId` as the served session, replacing any previous one.
|
||||
*
|
||||
* A no-op when the id is unchanged, since `start` runs on every
|
||||
* `before_agent_start`, `input`, and `tool_call` — the announcement must not
|
||||
* cost a log line per turn.
|
||||
*/
|
||||
private announceServing(sessionId: string): void {
|
||||
if (this.servingSessionId === sessionId) {
|
||||
return;
|
||||
}
|
||||
this.withdrawServing();
|
||||
this.servingSessionId = sessionId;
|
||||
this.deps.serving.markServing(sessionId);
|
||||
this.deps.logger.review("forwarded_permission.serving_started", {
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-announce the served session, keeping a decayable channel current.
|
||||
*
|
||||
* Separate from {@link announceServing} because that one detects a change to
|
||||
* write its log line, and this one deliberately writes none — four review
|
||||
* entries a second would drown the log the announcement exists to make
|
||||
* readable.
|
||||
*/
|
||||
private refreshServing(): void {
|
||||
if (this.servingSessionId === null) {
|
||||
return;
|
||||
}
|
||||
this.deps.serving.markServing(this.servingSessionId);
|
||||
}
|
||||
|
||||
/** Withdraw the published session, if any. */
|
||||
private withdrawServing(): void {
|
||||
const sessionId = this.servingSessionId;
|
||||
if (sessionId === null) {
|
||||
return;
|
||||
}
|
||||
this.servingSessionId = null;
|
||||
this.deps.serving.clearServing(sessionId);
|
||||
this.deps.logger.review("forwarded_permission.serving_stopped", {
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
PermissionPromptDecision,
|
||||
RequestPermissionOptions,
|
||||
} from "#src/authority/permission-dialog";
|
||||
import type {
|
||||
PermissionPromptUi,
|
||||
PromptPreferences,
|
||||
requestPermissionDecision,
|
||||
} from "#src/authority/permission-prompt-component";
|
||||
import { buildForwardedScopeLabels } from "#src/pattern-suggest";
|
||||
import {
|
||||
emitUiPromptEvent,
|
||||
type PermissionEventBus,
|
||||
} from "#src/permission-events";
|
||||
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
||||
import type { TerminalAuthorizer } from "./authorizer";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
|
||||
/** Dependencies required by {@link LocalUserAuthorizer}. */
|
||||
export interface LocalUserAuthorizerDeps {
|
||||
/** The active session's UI surface (select/input plus the inline `custom` dialog). */
|
||||
ui: PermissionPromptUi;
|
||||
/** The session run mode; the dispatcher renders the inline dialog only in `"tui"`. */
|
||||
mode: ExtensionContext["mode"];
|
||||
/** Event bus used for the `permissions:ui_prompt` broadcast. */
|
||||
events: PermissionEventBus;
|
||||
/** Read live at prompt time so a settings-modal toggle takes effect on the next prompt. */
|
||||
getPromptPreferences: () => PromptPreferences;
|
||||
/** Injected for testability; production callers pass the real function. */
|
||||
requestPermissionDecision: typeof requestPermissionDecision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizer for a session with an active UI: prompt the human here.
|
||||
*
|
||||
* Emits the `permissions:ui_prompt` broadcast (moved here from
|
||||
* `PermissionPrompter`'s `ctx.hasUI` arm) before showing the dialog, so
|
||||
* observers know a decision is imminent. This is the single emit site: a
|
||||
* forwarded ask carries its provenance on `details.forwarding`, which this
|
||||
* class renders (populated `forwarding` context + "(Subagent)" title) so the
|
||||
* broadcast stays non-degraded (#292) without a second emission path.
|
||||
*/
|
||||
export class LocalUserAuthorizer implements TerminalAuthorizer {
|
||||
constructor(private readonly deps: LocalUserAuthorizerDeps) {}
|
||||
|
||||
authorize(
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const uiPrompt = buildUiPrompt(details);
|
||||
emitUiPromptEvent(this.deps.events, uiPrompt);
|
||||
return this.deps.requestPermissionDecision(
|
||||
{
|
||||
mode: this.deps.mode,
|
||||
ui: this.deps.ui,
|
||||
...this.deps.getPromptPreferences(),
|
||||
},
|
||||
details.forwarding
|
||||
? "Permission Required (Subagent)"
|
||||
: "Permission Required",
|
||||
details.payload,
|
||||
buildRequestOptions(details),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A forwarded ask carrying a session-approval suggestion offers the scope
|
||||
* choice (subagent vs whole session); any other ask keeps its single
|
||||
* "for this session" option (custom label when the gate supplied one).
|
||||
*/
|
||||
function buildRequestOptions(
|
||||
details: PromptPermissionDetails,
|
||||
): RequestPermissionOptions | undefined {
|
||||
const pattern = details.sessionApproval?.patterns[0];
|
||||
if (details.forwarding && details.sessionApproval && pattern) {
|
||||
return {
|
||||
sessionScope: buildForwardedScopeLabels(
|
||||
details.forwarding.requesterAgentName,
|
||||
details.sessionApproval.surface,
|
||||
pattern,
|
||||
),
|
||||
};
|
||||
}
|
||||
return details.sessionLabel
|
||||
? { sessionLabel: details.sessionLabel }
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
|
||||
export type PermissionDecisionState =
|
||||
| "approved"
|
||||
| "approved_for_session"
|
||||
| "approved_for_serving_session"
|
||||
| "denied"
|
||||
| "denied_with_reason";
|
||||
|
||||
export type PermissionPromptDecision = {
|
||||
approved: boolean;
|
||||
state: PermissionDecisionState;
|
||||
denialReason?: string;
|
||||
/**
|
||||
* True when the decision was made automatically by yolo mode rather than
|
||||
* by an interactive user prompt. Used by handlers to emit "auto_approved"
|
||||
* rather than "user_approved" in the permissions:decision broadcast.
|
||||
*/
|
||||
autoApproved?: true;
|
||||
/**
|
||||
* True when no human ever ruled on this ask: either no live authority was
|
||||
* reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
|
||||
* forwarding path gave up before reaching one (`ParentAuthorizer` — target
|
||||
* unresolvable, request undeliverable, target not serving, or no answer
|
||||
* within the timeout). Consumed by deriveResolution (the decision-event
|
||||
* resolution), the gate (block reason), and PermissionPrompter (review-entry
|
||||
* resolution) to emit "confirmation_unavailable" rather than a plain user
|
||||
* denial — a user who was never asked denied nothing (#719).
|
||||
*/
|
||||
confirmationUnavailable?: true;
|
||||
/**
|
||||
* What decided this request, stamped by the site that decided it.
|
||||
*
|
||||
* Required: every decision names its decider, and the type is what
|
||||
* guarantees it rather than a convention each producer has to remember — the
|
||||
* same discipline `PromptPermissionDetails.payload` carries (#726).
|
||||
*/
|
||||
decidedBy: DecisionSource;
|
||||
};
|
||||
|
||||
/**
|
||||
* A decision before its decider is known.
|
||||
*
|
||||
* The inner producers — the dialog's decision model, the `select`/`input`
|
||||
* fallback, the verdict mapper — state the outcome; which decider to attribute
|
||||
* it to is settled one layer up, at the site that chose the producer. The same
|
||||
* shape `GateBypass.decision` uses for the request id: a producer emits only
|
||||
* what it knows.
|
||||
*/
|
||||
export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
|
||||
|
||||
export interface PermissionDecisionUi {
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
const APPROVE_OPTION = "Yes";
|
||||
const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
|
||||
const DENY_OPTION = "No";
|
||||
const DENY_WITH_REASON_OPTION = "No, provide reason";
|
||||
|
||||
export function normalizePermissionDenialReason(
|
||||
value: unknown,
|
||||
): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function createDeniedPermissionDecision(
|
||||
denialReason?: string,
|
||||
): UnattributedDecision {
|
||||
const normalizedReason = normalizePermissionDenialReason(denialReason);
|
||||
return normalizedReason
|
||||
? {
|
||||
approved: false,
|
||||
state: "denied_with_reason",
|
||||
denialReason: normalizedReason,
|
||||
}
|
||||
: {
|
||||
approved: false,
|
||||
state: "denied",
|
||||
};
|
||||
}
|
||||
|
||||
export function isPermissionDecisionState(
|
||||
value: unknown,
|
||||
): value is PermissionDecisionState {
|
||||
return (
|
||||
value === "approved" ||
|
||||
value === "approved_for_session" ||
|
||||
value === "approved_for_serving_session" ||
|
||||
value === "denied" ||
|
||||
value === "denied_with_reason"
|
||||
);
|
||||
}
|
||||
|
||||
export interface RequestPermissionOptions {
|
||||
/** Override the "for this session" option label (e.g. to show the suggested pattern). */
|
||||
sessionLabel?: string;
|
||||
/**
|
||||
* Forwarded asks only: when set, choosing the "for this session" option opens
|
||||
* a second select asking whether the grant applies to the requesting subagent
|
||||
* only (the least-privilege default) or the whole serving session.
|
||||
*/
|
||||
sessionScope?: {
|
||||
subagentLabel: string;
|
||||
servingSessionLabel: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestPermissionDecisionFromUi(
|
||||
ui: PermissionDecisionUi,
|
||||
title: string,
|
||||
message: string,
|
||||
options?: RequestPermissionOptions,
|
||||
): Promise<UnattributedDecision> {
|
||||
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
|
||||
const decisionOptions = [
|
||||
APPROVE_OPTION,
|
||||
sessionOption,
|
||||
DENY_OPTION,
|
||||
DENY_WITH_REASON_OPTION,
|
||||
] as const;
|
||||
|
||||
const selected = await ui.select(`${title}\n${message}`, [
|
||||
...decisionOptions,
|
||||
]);
|
||||
|
||||
if (selected === APPROVE_OPTION) {
|
||||
return {
|
||||
approved: true,
|
||||
state: "approved",
|
||||
};
|
||||
}
|
||||
|
||||
if (selected === sessionOption) {
|
||||
if (options?.sessionScope) {
|
||||
const scope = await ui.select(`${title}\nApply this session grant to:`, [
|
||||
options.sessionScope.subagentLabel,
|
||||
options.sessionScope.servingSessionLabel,
|
||||
]);
|
||||
return {
|
||||
approved: true,
|
||||
// A cancelled scope select (undefined) falls back to the
|
||||
// least-privilege subagent scope.
|
||||
state:
|
||||
scope === options.sessionScope.servingSessionLabel
|
||||
? "approved_for_serving_session"
|
||||
: "approved_for_session",
|
||||
};
|
||||
}
|
||||
return {
|
||||
approved: true,
|
||||
state: "approved_for_session",
|
||||
};
|
||||
}
|
||||
|
||||
if (selected === DENY_WITH_REASON_OPTION) {
|
||||
const denialReason = normalizePermissionDenialReason(
|
||||
await ui.input(
|
||||
`${title}\nShare why this request was denied (optional).`,
|
||||
"Reason shown back to the agent",
|
||||
),
|
||||
);
|
||||
|
||||
return createDeniedPermissionDecision(denialReason);
|
||||
}
|
||||
|
||||
return createDeniedPermissionDecision();
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { join } from "node:path";
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import type { PermissionUiPromptSource } from "#src/permission-events";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
import type { PermissionDecisionState } from "./permission-dialog";
|
||||
import type { SubagentSessionRegistry } from "./subagent-registry";
|
||||
|
||||
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
|
||||
export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
/**
|
||||
* How long an in-process forwarding target may go unserved before the child
|
||||
* gives up on it — eight poll ticks.
|
||||
*
|
||||
* A window rather than a single check because `ForwardingManager` withdraws and
|
||||
* re-announces across a session switch, and a request that arrives inside that
|
||||
* gap is about to be picked up. Not configurable: the operator-facing knob is
|
||||
* the overall timeout, and this only decides how fast a hopeless wait ends.
|
||||
*/
|
||||
export const PERMISSION_FORWARDING_SERVING_GRACE_MS =
|
||||
8 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
|
||||
export const SUBAGENT_ENV_HINT_KEYS = [
|
||||
// pi-agent-router (original)
|
||||
"PI_IS_SUBAGENT",
|
||||
"PI_SUBAGENT_SESSION_ID",
|
||||
"PI_AGENT_ROUTER_SUBAGENT",
|
||||
// nicobailon/pi-subagents
|
||||
"PI_SUBAGENT_CHILD",
|
||||
"PI_SUBAGENT_RUN_ID",
|
||||
"PI_SUBAGENT_CHILD_AGENT",
|
||||
"PI_SUBAGENT_DEPTH",
|
||||
// HazAT/pi-interactive-subagents
|
||||
"PI_SUBAGENT_NAME",
|
||||
"PI_SUBAGENT_ID",
|
||||
"PI_SUBAGENT_SESSION",
|
||||
"PI_SUBAGENT_ACTIVITY_FILE",
|
||||
] as const;
|
||||
/** Ordered list of env var names to check for the parent session ID. First match wins. */
|
||||
export const SUBAGENT_PARENT_SESSION_ENV_CANDIDATES: readonly string[] = [
|
||||
// pi-agent-router (original)
|
||||
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
|
||||
// Shared convention for CLI-based subagent extensions
|
||||
// (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.)
|
||||
"PI_SUBAGENT_PARENT_SESSION",
|
||||
] as const;
|
||||
|
||||
/** @deprecated Use SUBAGENT_PARENT_SESSION_ENV_CANDIDATES */
|
||||
export const SUBAGENT_PARENT_SESSION_ENV_KEY =
|
||||
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0];
|
||||
|
||||
const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
|
||||
const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
|
||||
const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
|
||||
|
||||
/**
|
||||
* Display fields relayed from a forwarding child to the parent UI so the parent
|
||||
* can emit a non-degraded `permissions:ui_prompt` event.
|
||||
*
|
||||
* Carried separately from the prompt payload because the parent reconstructs
|
||||
* the original event from the escalated ask's details (`buildUiPrompt`), not
|
||||
* from the payload's own facts.
|
||||
*/
|
||||
export interface ForwardedPromptDisplay {
|
||||
source: PermissionUiPromptSource;
|
||||
surface: string | null;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The child's session-approval suggestion, relayed to the serving node so a
|
||||
* human who grants "the whole session" records the same pattern the child
|
||||
* would have recorded locally.
|
||||
*
|
||||
* A plain data shape (not the `SessionApproval` value object) so it serializes
|
||||
* onto the forwarded request; the serving node rebuilds a `SessionApproval`
|
||||
* from it via `SessionApproval.multiple`.
|
||||
*/
|
||||
export interface ForwardedSessionApproval {
|
||||
surface: string;
|
||||
patterns: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The child-fixed facts a gate emits: the surface it evaluated and the match
|
||||
* set it computed. `requesterCwd` and `principal` are stamped at the escalation
|
||||
* edge (`ParentAuthorizer`), so a gate carries only what it alone can produce.
|
||||
*
|
||||
* Strings only — an `AccessPath` never crosses onto the wire
|
||||
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
||||
*/
|
||||
export interface ForwardedAccessFacts {
|
||||
/** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */
|
||||
surface: string;
|
||||
/**
|
||||
* The child-fixed match set. Path surface: `AccessPath.matchValues()`
|
||||
* (absolute ∪ cwd-relative ∪ canonical), computed at the child. Non-path
|
||||
* surface: the already-portable single value as a one-element array.
|
||||
*/
|
||||
matchValues: string[];
|
||||
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
|
||||
boundaryValue: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The forwarded-wire access intent (ADR 0008 §2): the child-fixed access facts
|
||||
* plus the requester identity the escalation edge stamps.
|
||||
*
|
||||
* The serving node resolves against this intent directly (Step 3, [#597]),
|
||||
* using `matchValues` as-is — it never re-derives a path through its own
|
||||
* `PathNormalizer`/cwd. See
|
||||
* `docs/decisions/0008-cross-session-access-intent.md`.
|
||||
*/
|
||||
export interface ForwardedAccessIntent extends ForwardedAccessFacts {
|
||||
/** The requester's cwd, for provenance/disclosure — never for parent re-derivation. */
|
||||
requesterCwd: string;
|
||||
/** Who is requesting. */
|
||||
principal: {
|
||||
sessionId: string;
|
||||
agentName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ForwardedPermissionRequest = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
requesterSessionId: string;
|
||||
targetSessionId: string;
|
||||
requesterAgentName: string;
|
||||
/**
|
||||
* The child's complete prompt payload (ADR 0011 §2), so the serving node
|
||||
* renders the child's own facts under the *parent's* budget rather than
|
||||
* relaying a sentence the child assembled under its own configuration.
|
||||
*
|
||||
* Optional for version-skew tolerance: an older child omits it, and the
|
||||
* serving node renders from the display fields it does carry (ADR 0011 §9).
|
||||
*/
|
||||
payload?: PromptPayload;
|
||||
/**
|
||||
* Original prompt display fields, persisted so the parent emits a
|
||||
* non-degraded event. Optional for version-skew tolerance: a parent on a
|
||||
* newer version may read a request written by an older child during an
|
||||
* upgrade, in which case the reader defaults `source` to `"tool_call"`.
|
||||
*/
|
||||
source?: PermissionUiPromptSource;
|
||||
surface?: string | null;
|
||||
value?: string | null;
|
||||
/**
|
||||
* The child's session-approval suggestion. Present when the child computed a
|
||||
* "for this session" pattern for the ask; lets the serving node record a
|
||||
* whole-session grant. Optional for version-skew tolerance (an older child
|
||||
* omits it, and the serving dialog then offers no scope choice).
|
||||
*/
|
||||
sessionApproval?: ForwardedSessionApproval;
|
||||
/**
|
||||
* The child-fixed access intent (ADR 0008 §2). Optional for version-skew
|
||||
* tolerance: an older child omits it, and the serving node floors to `ask`
|
||||
* (Step 3). Present on a current child's request for every gate surface.
|
||||
*/
|
||||
accessIntent?: ForwardedAccessIntent;
|
||||
};
|
||||
|
||||
export type ForwardedPermissionResponse = {
|
||||
approved: boolean;
|
||||
state: PermissionDecisionState;
|
||||
denialReason?: string;
|
||||
responderSessionId: string;
|
||||
respondedAt: number;
|
||||
/**
|
||||
* What decided, inside the responding session (#726).
|
||||
*
|
||||
* `responderSessionId` names *where* the decision was made; this names
|
||||
* *what* made it, which is the difference between a human at the parent's
|
||||
* dialog and the parent's policy answering on their behalf.
|
||||
*
|
||||
* Optional for version-skew tolerance: an older responder omits it, and the
|
||||
* requester records the hop with a `null` inner decision rather than
|
||||
* rejecting the answer.
|
||||
*/
|
||||
decidedBy?: DecisionSource;
|
||||
};
|
||||
|
||||
export type PermissionForwardingLocation = {
|
||||
sessionId: string;
|
||||
sessionRootDir: string;
|
||||
requestsDir: string;
|
||||
responsesDir: string;
|
||||
label: "primary";
|
||||
};
|
||||
|
||||
export function normalizePermissionForwardingSessionId(
|
||||
value: unknown,
|
||||
): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "unknown") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a session id safe to name a path segment.
|
||||
*
|
||||
* Exported because the forwarding tree has two layouts keyed by session id —
|
||||
* `sessions/<id>/` and the serving-heartbeat records beside it — and a second
|
||||
* encoding would be a silent way for the two to disagree about which file
|
||||
* belongs to which session.
|
||||
*/
|
||||
export function encodeSessionIdForPath(sessionId: string): string {
|
||||
return encodeURIComponent(sessionId);
|
||||
}
|
||||
|
||||
export function createPermissionForwardingLocation(
|
||||
forwardingRootDir: string,
|
||||
sessionId: string,
|
||||
): PermissionForwardingLocation {
|
||||
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
|
||||
if (!normalizedSessionId) {
|
||||
throw new Error(
|
||||
"Permission forwarding session id must be a non-empty string.",
|
||||
);
|
||||
}
|
||||
|
||||
const sessionRootDir = join(
|
||||
forwardingRootDir,
|
||||
SESSION_FORWARDING_ROOT_DIRECTORY_NAME,
|
||||
encodeSessionIdForPath(normalizedSessionId),
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: normalizedSessionId,
|
||||
sessionRootDir,
|
||||
requestsDir: join(
|
||||
sessionRootDir,
|
||||
SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME,
|
||||
),
|
||||
responsesDir: join(
|
||||
sessionRootDir,
|
||||
SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME,
|
||||
),
|
||||
label: "primary",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How a forwarding target was resolved.
|
||||
*
|
||||
* `"registry"` is the load-bearing value: it means the requester is an
|
||||
* **in-process** child of `sessionId`, so the two share a `globalThis` and the
|
||||
* requester may consult the serving-session registry to decide whether anyone
|
||||
* is draining its inbox. `"env"` means the target lives in another process,
|
||||
* where that signal is unavailable; `"self"` is the UI host owning its own
|
||||
* forwarding location.
|
||||
*/
|
||||
export type PermissionForwardingTargetSource = "self" | "registry" | "env";
|
||||
|
||||
/** The resolved forwarding target together with how it was found. */
|
||||
export interface PermissionForwardingTarget {
|
||||
sessionId: string;
|
||||
source: PermissionForwardingTargetSource;
|
||||
}
|
||||
|
||||
export function resolvePermissionForwardingTarget(options: {
|
||||
hasUI: boolean;
|
||||
isSubagent: boolean;
|
||||
currentSessionId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Child session id for registry lookup. */
|
||||
sessionId?: string;
|
||||
/** In-process subagent session registry (checked before env vars). */
|
||||
registry?: SubagentSessionRegistry;
|
||||
}): PermissionForwardingTarget | null {
|
||||
if (options.hasUI) {
|
||||
const own = normalizePermissionForwardingSessionId(
|
||||
options.currentSessionId,
|
||||
);
|
||||
return own === null ? null : { sessionId: own, source: "self" };
|
||||
}
|
||||
|
||||
if (!options.isSubagent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Registry — in-process subagents register parentSessionId explicitly.
|
||||
if (options.registry && options.sessionId) {
|
||||
const entry = options.registry.get(options.sessionId);
|
||||
const resolved = normalizePermissionForwardingSessionId(
|
||||
entry?.parentSessionId,
|
||||
);
|
||||
if (resolved) return { sessionId: resolved, source: "registry" };
|
||||
}
|
||||
|
||||
// 2. Env vars — process-based subagent extensions.
|
||||
const env = options.env ?? process.env;
|
||||
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
|
||||
const resolved = normalizePermissionForwardingSessionId(env[key]);
|
||||
if (resolved) return { sessionId: resolved, source: "env" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isForwardedPermissionRequestForSession(
|
||||
request: Pick<ForwardedPermissionRequest, "targetSessionId">,
|
||||
sessionId: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedRequestSessionId = normalizePermissionForwardingSessionId(
|
||||
request.targetSessionId,
|
||||
);
|
||||
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
|
||||
return (
|
||||
normalizedRequestSessionId !== null &&
|
||||
normalizedRequestSessionId === normalizedSessionId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import type {
|
||||
ExtensionContext,
|
||||
ExtensionUIContext,
|
||||
KeybindingsManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { type Component, Input, matchesKey } from "@earendil-works/pi-tui";
|
||||
import { collapsePastedNewlines } from "#src/authority/bracketed-paste";
|
||||
import type {
|
||||
DecisionSource,
|
||||
UserDecisionSurface,
|
||||
} from "#src/authority/decision-source";
|
||||
import {
|
||||
type PermissionPromptDecision,
|
||||
type RequestPermissionOptions,
|
||||
requestPermissionDecisionFromUi,
|
||||
type UnattributedDecision,
|
||||
} from "#src/authority/permission-dialog";
|
||||
import {
|
||||
initialPromptState,
|
||||
type PromptEvent,
|
||||
type PromptKey,
|
||||
type PromptModelConfig,
|
||||
type PromptViewState,
|
||||
reducePrompt,
|
||||
} from "#src/authority/permission-prompt-decision";
|
||||
import {
|
||||
completeViewBudget,
|
||||
type DialogView,
|
||||
type RenderBudget,
|
||||
renderPromptDialog,
|
||||
} from "#src/presentation/dialog-renderer";
|
||||
import { fitLinesToWidth } from "#src/presentation/line-fitting";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
|
||||
/**
|
||||
* Inline `ctx.ui.custom` permission dialog for TUI sessions.
|
||||
*
|
||||
* All interaction logic lives in the pure {@link reducePrompt} model; this
|
||||
* module is the thin adapter that renders the model's state to lines, maps raw
|
||||
* keystrokes to {@link PromptEvent}s, and resolves the `ctx.ui.custom` promise
|
||||
* with the committed {@link PermissionPromptDecision}. The component renders
|
||||
* inline (never as an overlay).
|
||||
*/
|
||||
|
||||
/** The subset of the session UI surface the inline dialog needs. */
|
||||
export type PermissionPromptUi = Pick<
|
||||
ExtensionUIContext,
|
||||
"select" | "input" | "custom" | "getToolsExpanded" | "setToolsExpanded"
|
||||
>;
|
||||
|
||||
/** The keybindings surface the dialog consults; only `matches` is read (ISP). */
|
||||
type PromptKeybindings = Pick<KeybindingsManager, "matches">;
|
||||
|
||||
/** The resolved presentation context selected once per activation. */
|
||||
export interface PermissionPromptView extends PromptPreferences {
|
||||
mode: ExtensionContext["mode"];
|
||||
ui: PermissionPromptUi;
|
||||
}
|
||||
|
||||
/** Live prompt-behavior preferences read at prompt time (see `doublePressToConfirm`). */
|
||||
export interface PromptPreferences {
|
||||
doublePressToConfirm: boolean;
|
||||
/** How much room a render has; the terminal width is added per frame. */
|
||||
budget: RenderBudget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a permission ask to the inline keybind dialog in TUI mode, or the
|
||||
* `select()`/`input()` flow otherwise (RPC / frontend — the #519 constraint).
|
||||
*
|
||||
* The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
|
||||
* one place so the fallback and the inline component never both render.
|
||||
*
|
||||
* It is therefore also the one place that knows which surface the human
|
||||
* answered on, so it is where the decision is attributed to that surface
|
||||
* (#726). Having the dialog model and the fallback each name themselves would
|
||||
* be two sites that must agree with this branch.
|
||||
*/
|
||||
export async function requestPermissionDecision(
|
||||
view: PermissionPromptView,
|
||||
title: string,
|
||||
payload: PromptPayload,
|
||||
options?: RequestPermissionOptions,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
if (view.mode === "tui") {
|
||||
return attributeToHuman(
|
||||
await presentInlinePermissionPrompt(view, title, payload, options),
|
||||
"dialog",
|
||||
);
|
||||
}
|
||||
// The fallback renders once and cannot re-render, so it neither paints nor
|
||||
// offers an expansion; it substitutes a nominal width for the terminal size
|
||||
// it is never told, and the host's own select wraps from there.
|
||||
const rendered = renderPromptDialog(payload, {
|
||||
...view.budget,
|
||||
width: FALLBACK_RENDER_WIDTH,
|
||||
});
|
||||
return attributeToHuman(
|
||||
await requestPermissionDecisionFromUi(
|
||||
view.ui,
|
||||
title,
|
||||
rendered.lines.join("\n"),
|
||||
options,
|
||||
),
|
||||
"select",
|
||||
);
|
||||
}
|
||||
|
||||
function attributeToHuman(
|
||||
decision: UnattributedDecision,
|
||||
via: UserDecisionSurface,
|
||||
): PermissionPromptDecision {
|
||||
const decidedBy: DecisionSource = { kind: "user", via };
|
||||
return { ...decision, decidedBy };
|
||||
}
|
||||
|
||||
/** The width the `select`/`input` fallback renders against. */
|
||||
const FALLBACK_RENDER_WIDTH = 80;
|
||||
|
||||
/** Minimal theme surface the dialog uses; satisfied by the real SDK theme. */
|
||||
interface PromptTheme {
|
||||
fg(color: string, text: string): string;
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_LABEL = "Yes, for this session";
|
||||
|
||||
const OPTION_LABELS: Record<PromptKey, string> = {
|
||||
y: "Yes",
|
||||
s: DEFAULT_SESSION_LABEL,
|
||||
n: "No",
|
||||
r: "No, provide reason",
|
||||
};
|
||||
|
||||
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
||||
|
||||
export function presentInlinePermissionPrompt(
|
||||
view: PermissionPromptView,
|
||||
title: string,
|
||||
payload: PromptPayload,
|
||||
options?: RequestPermissionOptions,
|
||||
): Promise<UnattributedDecision> {
|
||||
const config: PromptModelConfig = {
|
||||
doublePressToConfirm: view.doublePressToConfirm,
|
||||
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
||||
sessionScope: options?.sessionScope,
|
||||
};
|
||||
return view.ui.custom<UnattributedDecision>(
|
||||
(tui, theme, keybindings, done) =>
|
||||
new PermissionPromptComponent(
|
||||
theme,
|
||||
config,
|
||||
title,
|
||||
payload,
|
||||
view.budget,
|
||||
(data) => handleToolsExpandAction(data, keybindings, view.ui),
|
||||
() => {
|
||||
tui.requestRender();
|
||||
},
|
||||
done,
|
||||
),
|
||||
{ overlay: false },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward Pi's tool-expansion action while the dialog holds keyboard focus.
|
||||
*
|
||||
* A focused `ctx.ui.custom` component consumes every keystroke, so `Ctrl+O`
|
||||
* would otherwise be dead for the duration of an ask — exactly when the user
|
||||
* most needs to see the full pending tool invocation. Returns `true` when the
|
||||
* keystroke was the action (and was handled), so the caller stops before
|
||||
* mapping it to a {@link PromptEvent}; expansion is a display concern and must
|
||||
* never reach the decision model.
|
||||
*
|
||||
* Deliberately does not request a render: `setToolsExpanded` re-renders the
|
||||
* host itself, and the dialog's own lines are unaffected by tool expansion.
|
||||
*/
|
||||
function handleToolsExpandAction(
|
||||
data: string,
|
||||
keybindings: PromptKeybindings,
|
||||
ui: PermissionPromptUi,
|
||||
): boolean {
|
||||
if (!keybindings.matches(data, "app.tools.expand")) {
|
||||
return false;
|
||||
}
|
||||
ui.setToolsExpanded(!ui.getToolsExpanded());
|
||||
return true;
|
||||
}
|
||||
|
||||
class PermissionPromptComponent implements Component {
|
||||
private state: PromptViewState;
|
||||
/** The denial-reason line editor, rebuilt each time the step is entered. */
|
||||
private reason: Input;
|
||||
/** Whether the operator asked to see the complete request (ADR 0011 §4). */
|
||||
private expanded = false;
|
||||
|
||||
constructor(
|
||||
private readonly theme: PromptTheme,
|
||||
private readonly config: PromptModelConfig,
|
||||
private readonly title: string,
|
||||
private readonly payload: PromptPayload,
|
||||
private readonly budget: RenderBudget,
|
||||
private readonly handleAppAction: (data: string) => boolean,
|
||||
private readonly requestRender: () => void,
|
||||
private readonly done: (decision: UnattributedDecision) => void,
|
||||
) {
|
||||
this.state = initialPromptState(config);
|
||||
this.reason = this.createReasonEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh editor per visit to the reason step.
|
||||
*
|
||||
* The framework editor carries an undo stack and a kill ring, so reusing one
|
||||
* instance would let a reason the operator backed out of be restored into a
|
||||
* later ask.
|
||||
*/
|
||||
private createReasonEditor(): Input {
|
||||
const editor = new Input();
|
||||
// Emits pi-tui's zero-width cursor marker, which positions the hardware
|
||||
// cursor for IME composition.
|
||||
editor.focused = true;
|
||||
editor.onSubmit = (draft) => {
|
||||
this.apply({ type: "submitReason", draft });
|
||||
};
|
||||
editor.onEscape = () => {
|
||||
this.apply({ type: "cancel" });
|
||||
};
|
||||
return editor;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached rendering state to clear.
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return fitLinesToWidth(this.renderStep(width), width);
|
||||
}
|
||||
|
||||
private renderStep(width: number): string[] {
|
||||
switch (this.state.step) {
|
||||
case "decision":
|
||||
return this.renderDecision(width);
|
||||
case "reason":
|
||||
return this.renderReason(width);
|
||||
case "scope":
|
||||
return this.renderScope();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ask itself, bounded to the budget at this frame's width.
|
||||
*
|
||||
* Rendered per frame rather than once, because the row budget is a function
|
||||
* of the width the host gives us, which a resize changes.
|
||||
*/
|
||||
private renderAsk(width: number): DialogView {
|
||||
return renderPromptDialog(
|
||||
this.payload,
|
||||
this.expanded ? completeViewBudget(width) : { ...this.budget, width },
|
||||
(text) => this.theme.fg("warning", text),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key hints, naming the expansion only when it would do something.
|
||||
*
|
||||
* An affordance advertised when there is nothing to expand is noise; one
|
||||
* left unadvertised when the render dropped something is a decision made
|
||||
* without the evidence.
|
||||
*/
|
||||
private hint(view: DialogView): string {
|
||||
const keys = [
|
||||
"↑/↓ move",
|
||||
"enter confirm",
|
||||
"esc deny",
|
||||
"press a letter, then again to confirm",
|
||||
];
|
||||
if (this.expanded) {
|
||||
keys.push("ctrl+o collapse");
|
||||
} else if (view.elided) {
|
||||
keys.push("ctrl+o full request");
|
||||
}
|
||||
return this.theme.fg("muted", keys.join(" · "));
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.state.step === "reason") {
|
||||
this.handleReasonInput(data);
|
||||
return;
|
||||
}
|
||||
if (this.handleAppAction(data)) {
|
||||
// One "expand" for the operator: the host expands its pending tool call
|
||||
// and the dialog expands its own render, on the same keystroke.
|
||||
this.expanded = !this.expanded;
|
||||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
const event = this.toEvent(data);
|
||||
if (event) {
|
||||
this.apply(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the keystroke to the framework line editor.
|
||||
*
|
||||
* Delegating is what makes the field accept a paste: a paste arrives as one
|
||||
* multi-character chunk wrapped in bracketed-paste markers, which the editor
|
||||
* understands and a per-character reader cannot. Submit and cancel come back
|
||||
* through the editor's callbacks, so the decision model still owns them.
|
||||
*/
|
||||
private handleReasonInput(data: string): void {
|
||||
this.reason.handleInput(collapsePastedNewlines(data));
|
||||
// The editor mutates its own buffer silently; only the dialog can repaint.
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
private toEvent(data: string): PromptEvent | undefined {
|
||||
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
||||
return { type: "nav", direction: "up" };
|
||||
}
|
||||
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
||||
return { type: "nav", direction: "down" };
|
||||
}
|
||||
if (matchesKey(data, "enter")) {
|
||||
return { type: "confirm" };
|
||||
}
|
||||
if (matchesKey(data, "escape")) {
|
||||
return { type: "cancel" };
|
||||
}
|
||||
if (this.state.step === "decision") {
|
||||
const key = OPTION_ORDER.find((option) => matchesKey(data, option));
|
||||
if (key) {
|
||||
return { type: "hotkey", key };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private apply(event: PromptEvent): void {
|
||||
const outcome = reducePrompt(this.config, this.state, event);
|
||||
if (outcome.kind === "decision") {
|
||||
this.done(outcome.decision);
|
||||
return;
|
||||
}
|
||||
if (outcome.state.step === "reason" && this.state.step !== "reason") {
|
||||
this.reason = this.createReasonEditor();
|
||||
}
|
||||
this.state = outcome.state;
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
private renderDecision(width: number): string[] {
|
||||
const ask = this.renderAsk(width);
|
||||
const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
|
||||
for (const key of OPTION_ORDER) {
|
||||
const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
|
||||
const selected = this.state.highlightedKey === key;
|
||||
const marker = selected ? "▶" : " ";
|
||||
const row = `${marker} (${key}) ${label}`;
|
||||
lines.push(selected ? this.theme.fg("accent", row) : row);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(this.state.hint || this.hint(ask));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderReason(width: number): string[] {
|
||||
const lines = [
|
||||
this.theme.fg("accent", this.title),
|
||||
...this.renderAsk(width).lines,
|
||||
"",
|
||||
"Reason (required):",
|
||||
// Exactly one row, whatever its length: the editor scrolls horizontally.
|
||||
...this.reason.render(width),
|
||||
];
|
||||
if (this.state.reasonError) {
|
||||
lines.push(this.theme.fg("error", this.state.reasonError));
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(this.theme.fg("muted", "enter submit · esc back"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderScope(): string[] {
|
||||
const scope = this.config.sessionScope;
|
||||
const subagentLabel = scope?.subagentLabel ?? "This subagent only";
|
||||
const servingLabel = scope?.servingSessionLabel ?? "The whole session";
|
||||
const rows: Array<{ label: string; serving: boolean }> = [
|
||||
{ label: subagentLabel, serving: false },
|
||||
{ label: servingLabel, serving: true },
|
||||
];
|
||||
const lines = [
|
||||
this.theme.fg("accent", this.title),
|
||||
"Apply this session grant to:",
|
||||
"",
|
||||
];
|
||||
for (const row of rows) {
|
||||
const selected = this.state.scopeServing === row.serving;
|
||||
const marker = selected ? "▶" : " ";
|
||||
const text = `${marker} ${row.label}`;
|
||||
lines.push(selected ? this.theme.fg("accent", text) : text);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(this.theme.fg("muted", "↑/↓ move · enter confirm · esc back"));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
createDeniedPermissionDecision,
|
||||
normalizePermissionDenialReason,
|
||||
type RequestPermissionOptions,
|
||||
type UnattributedDecision,
|
||||
} from "#src/authority/permission-dialog";
|
||||
|
||||
/**
|
||||
* Pure decision model for the inline keybind permission dialog.
|
||||
*
|
||||
* The interaction logic — which hotkey produces which decision, double-press
|
||||
* arming, step transitions, and reason validation — lives here with no SDK or
|
||||
* TUI imports, so it is unit-testable directly. The `ctx.ui.custom` component
|
||||
* ({@link file://./permission-prompt-component.ts}) is a thin adapter that
|
||||
* forwards keystrokes to {@link reducePrompt} and renders the returned state.
|
||||
*/
|
||||
|
||||
/** The four decision hotkeys, in display order. */
|
||||
export type PromptKey = "y" | "s" | "n" | "r";
|
||||
|
||||
/** Which sub-view the dialog is showing. */
|
||||
export type PromptStep = "decision" | "reason" | "scope";
|
||||
|
||||
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
||||
|
||||
const OPTION_VERBS: Record<PromptKey, string> = {
|
||||
y: "approve",
|
||||
s: "approve for this session",
|
||||
n: "deny",
|
||||
r: "deny with a reason",
|
||||
};
|
||||
|
||||
/** Static configuration for a single prompt presentation. */
|
||||
export interface PromptModelConfig {
|
||||
/** When true, a letter hotkey arms first and commits only on a second press. */
|
||||
doublePressToConfirm: boolean;
|
||||
/** Label shown beside the approve-for-session option. */
|
||||
sessionLabel: string;
|
||||
/**
|
||||
* Forwarded asks only: when set, confirming `s` opens a second step choosing
|
||||
* whether the grant applies to the requesting subagent only (least-privilege
|
||||
* default) or the whole serving session.
|
||||
*/
|
||||
sessionScope?: NonNullable<RequestPermissionOptions["sessionScope"]>;
|
||||
}
|
||||
|
||||
/** The re-render view state the component draws from. */
|
||||
export interface PromptViewState {
|
||||
step: PromptStep;
|
||||
highlightedKey: PromptKey;
|
||||
/** Set only while awaiting the confirming second press of a hotkey. */
|
||||
armedKey?: PromptKey;
|
||||
/** "Press y again to approve." while armed; empty otherwise. */
|
||||
hint: string;
|
||||
/** Set when an empty reason submit is rejected. */
|
||||
reasonError?: string;
|
||||
/** Scope step: false = subagent-only (default), true = whole serving session. */
|
||||
scopeServing: boolean;
|
||||
}
|
||||
|
||||
/** An input event the reducer understands. */
|
||||
export type PromptEvent =
|
||||
| { type: "nav"; direction: "up" | "down" }
|
||||
| { type: "hotkey"; key: PromptKey }
|
||||
| { type: "confirm" }
|
||||
| { type: "cancel" }
|
||||
| { type: "submitReason"; draft: string };
|
||||
|
||||
/** Either a re-render or a terminal decision. */
|
||||
export type PromptOutcome =
|
||||
| { kind: "render"; state: PromptViewState }
|
||||
| { kind: "decision"; decision: UnattributedDecision };
|
||||
|
||||
export function initialPromptState(
|
||||
_config: PromptModelConfig,
|
||||
): PromptViewState {
|
||||
return {
|
||||
step: "decision",
|
||||
highlightedKey: "y",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
scopeServing: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the dialog by one input event, returning either the next view state
|
||||
* to render or the committed {@link UnattributedDecision}.
|
||||
*
|
||||
* The model states the outcome and not the decider: which human surface this
|
||||
* is gets attributed by the dispatcher that chose to render this dialog, so
|
||||
* the two cannot disagree about the surface.
|
||||
*/
|
||||
export function reducePrompt(
|
||||
config: PromptModelConfig,
|
||||
state: PromptViewState,
|
||||
event: PromptEvent,
|
||||
): PromptOutcome {
|
||||
switch (state.step) {
|
||||
case "decision":
|
||||
return reduceDecisionStep(config, state, event);
|
||||
case "reason":
|
||||
return reduceReasonStep(state, event);
|
||||
case "scope":
|
||||
return reduceScopeStep(state, event);
|
||||
}
|
||||
}
|
||||
|
||||
function reduceDecisionStep(
|
||||
config: PromptModelConfig,
|
||||
state: PromptViewState,
|
||||
event: PromptEvent,
|
||||
): PromptOutcome {
|
||||
switch (event.type) {
|
||||
case "nav":
|
||||
return render({
|
||||
...state,
|
||||
highlightedKey: shiftKey(state.highlightedKey, event.direction),
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
});
|
||||
case "hotkey":
|
||||
return pressHotkey(config, state, event.key);
|
||||
case "confirm":
|
||||
return commit(config, state, state.highlightedKey);
|
||||
case "cancel":
|
||||
return { kind: "decision", decision: createDeniedPermissionDecision() };
|
||||
case "submitReason":
|
||||
return render(state);
|
||||
}
|
||||
}
|
||||
|
||||
function pressHotkey(
|
||||
config: PromptModelConfig,
|
||||
state: PromptViewState,
|
||||
key: PromptKey,
|
||||
): PromptOutcome {
|
||||
if (!config.doublePressToConfirm || state.armedKey === key) {
|
||||
return commit(config, state, key);
|
||||
}
|
||||
return render({
|
||||
...state,
|
||||
highlightedKey: key,
|
||||
armedKey: key,
|
||||
hint: `Press ${key} again to ${OPTION_VERBS[key]}.`,
|
||||
});
|
||||
}
|
||||
|
||||
function commit(
|
||||
config: PromptModelConfig,
|
||||
state: PromptViewState,
|
||||
key: PromptKey,
|
||||
): PromptOutcome {
|
||||
switch (key) {
|
||||
case "y":
|
||||
return {
|
||||
kind: "decision",
|
||||
decision: { approved: true, state: "approved" },
|
||||
};
|
||||
case "n":
|
||||
return { kind: "decision", decision: createDeniedPermissionDecision() };
|
||||
case "r":
|
||||
return render({
|
||||
...state,
|
||||
step: "reason",
|
||||
highlightedKey: "r",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
});
|
||||
case "s":
|
||||
if (config.sessionScope) {
|
||||
return render({
|
||||
...state,
|
||||
step: "scope",
|
||||
highlightedKey: "s",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
scopeServing: false,
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "decision",
|
||||
decision: { approved: true, state: "approved_for_session" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function reduceReasonStep(
|
||||
state: PromptViewState,
|
||||
event: PromptEvent,
|
||||
): PromptOutcome {
|
||||
if (event.type === "cancel") {
|
||||
return render({
|
||||
...state,
|
||||
step: "decision",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
});
|
||||
}
|
||||
if (event.type === "submitReason") {
|
||||
const reason = normalizePermissionDenialReason(event.draft);
|
||||
if (reason === undefined) {
|
||||
return render({
|
||||
...state,
|
||||
reasonError: "A reason is required.",
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "decision",
|
||||
decision: createDeniedPermissionDecision(reason),
|
||||
};
|
||||
}
|
||||
return render(state);
|
||||
}
|
||||
|
||||
function reduceScopeStep(
|
||||
state: PromptViewState,
|
||||
event: PromptEvent,
|
||||
): PromptOutcome {
|
||||
switch (event.type) {
|
||||
case "nav":
|
||||
return render({ ...state, scopeServing: event.direction === "down" });
|
||||
case "confirm":
|
||||
return {
|
||||
kind: "decision",
|
||||
decision: {
|
||||
approved: true,
|
||||
state: state.scopeServing
|
||||
? "approved_for_serving_session"
|
||||
: "approved_for_session",
|
||||
},
|
||||
};
|
||||
case "cancel":
|
||||
return render({
|
||||
...state,
|
||||
step: "decision",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
});
|
||||
default:
|
||||
return render(state);
|
||||
}
|
||||
}
|
||||
|
||||
function shiftKey(current: PromptKey, direction: "up" | "down"): PromptKey {
|
||||
const index = OPTION_ORDER.indexOf(current);
|
||||
const delta = direction === "down" ? 1 : -1;
|
||||
const next = (index + delta + OPTION_ORDER.length) % OPTION_ORDER.length;
|
||||
return OPTION_ORDER[next] ?? current;
|
||||
}
|
||||
|
||||
function render(state: PromptViewState): PromptOutcome {
|
||||
return { kind: "render", state };
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type {
|
||||
ForwardedAccessFacts,
|
||||
ForwardedSessionApproval,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
|
||||
import type { ReviewLogger } from "#src/session-logger";
|
||||
import type { TerminalAuthorizer } from "./authorizer";
|
||||
|
||||
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
||||
|
||||
/**
|
||||
* Provenance of a forwarded ask: who is really asking, one hop below.
|
||||
*
|
||||
* Present on {@link PromptPermissionDetails} only when the ask was forwarded
|
||||
* from a subagent. Structurally identical to the event's `ForwardedPromptContext`
|
||||
* so the details flow straight into `buildUiPrompt`, but declared here to keep
|
||||
* the prompter layer free of an events-module import.
|
||||
*/
|
||||
export interface ForwardedAskProvenance {
|
||||
requesterAgentName: string | null;
|
||||
requesterSessionId: string | null;
|
||||
}
|
||||
|
||||
/** Details passed when prompting the user for a permission decision. */
|
||||
export interface PromptPermissionDetails {
|
||||
requestId: string;
|
||||
source: PermissionReviewSource;
|
||||
agentName: string | null;
|
||||
/**
|
||||
* The complete structured description of this ask (ADR 0011 §2).
|
||||
*
|
||||
* Required: every ask carries one, and the type is what guarantees it rather
|
||||
* than a convention each gate has to remember. Every consumer — the dialog,
|
||||
* the wire, the broadcast, the review log, the agent-facing denial text — is
|
||||
* a render over it, so no two of them can disagree.
|
||||
*/
|
||||
payload: PromptPayload;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
skillName?: string;
|
||||
path?: string;
|
||||
command?: string;
|
||||
target?: string;
|
||||
toolInputPreview?: string;
|
||||
/** Override label for the "for this session" dialog option. */
|
||||
sessionLabel?: string;
|
||||
/** Explicit display-surface override (a forwarded ask carries the child's original). */
|
||||
surface?: string | null;
|
||||
/** Explicit display-value override (a forwarded ask carries the child's original). */
|
||||
value?: string | null;
|
||||
/** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
|
||||
forwarding?: ForwardedAskProvenance;
|
||||
/**
|
||||
* The session-approval suggestion for this ask. On the child's escalation it
|
||||
* rides into the forwarded request; on the serving node it lets the dialog
|
||||
* offer a whole-session grant scope. Absent when the gate computed no
|
||||
* suggestion.
|
||||
*/
|
||||
sessionApproval?: ForwardedSessionApproval;
|
||||
/**
|
||||
* The child-fixed access facts the raising gate computed (surface + match
|
||||
* set). Rides through the runner to the escalation edge, which completes
|
||||
* them into a `ForwardedAccessIntent` by stamping `requesterCwd` and
|
||||
* `principal`. On a serving node these facts are projected back off the
|
||||
* forwarded request, so a forwarded ask reaches the `Authorizer` chain with
|
||||
* the same evidence as a local one; only a version-skew request that carried
|
||||
* no intent leaves this absent.
|
||||
*/
|
||||
accessIntent?: ForwardedAccessFacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow seam onto {@link PermissionPrompter}.
|
||||
*
|
||||
* Kept separate from the concrete class so consumers (e.g. `AuthorizerSelection`)
|
||||
* can inject a plain `{ prompt: vi.fn() }` mock in tests — a private field on
|
||||
* the concrete class would create a nominal brand that a structural mock
|
||||
* cannot satisfy without a cast.
|
||||
*/
|
||||
export interface PermissionPrompterApi {
|
||||
prompt(
|
||||
authorizer: TerminalAuthorizer,
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision>;
|
||||
}
|
||||
|
||||
/** Dependencies required by {@link PermissionPrompter}. */
|
||||
export interface PermissionPrompterDeps {
|
||||
/** Write structured entries to the permission review log. */
|
||||
logger: ReviewLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brackets the ask-path flow with review-log entries and delegates the
|
||||
* live decision to the selected {@link TerminalAuthorizer}:
|
||||
* 1. Review-log "waiting" entry.
|
||||
* 2. `authorizer.authorize(details)`.
|
||||
* 3. Review-log "approved" / "denied" entry.
|
||||
*
|
||||
* The UI/forwarding branching this class previously owned now lives on the
|
||||
* individual `Authorizer` implementations (`LocalUserAuthorizer`,
|
||||
* `ParentAuthorizer`, `DenyingAuthorizer`) — this class no longer threads
|
||||
* `ExtensionContext` per call.
|
||||
*
|
||||
* Yolo-mode auto-approval happens upstream: at the composition stage
|
||||
* (`PermissionManager.check`'s `rewriteAsksToYolo`) for a rule-driven ask, and
|
||||
* at `GateRunner`'s auto-approve fast path (`resolveYoloGrant`) for an ask
|
||||
* synthesized after resolution, which no rule rewrite can reach (#712) — an
|
||||
* `ask` never reaches this class under yolo, so it has no yolo-mode knowledge.
|
||||
*/
|
||||
export class PermissionPrompter implements PermissionPrompterApi {
|
||||
constructor(private readonly deps: PermissionPrompterDeps) {}
|
||||
|
||||
async prompt(
|
||||
authorizer: TerminalAuthorizer,
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
this.writeReviewEntry("permission_request.waiting", details);
|
||||
|
||||
const decision = await authorizer.authorize(details);
|
||||
|
||||
this.writeReviewEntry(
|
||||
decision.approved
|
||||
? "permission_request.approved"
|
||||
: "permission_request.denied",
|
||||
{
|
||||
...details,
|
||||
resolution: decision.confirmationUnavailable
|
||||
? "confirmation_unavailable"
|
||||
: decision.state,
|
||||
denialReason: decision.denialReason,
|
||||
decidedBy: decision.decidedBy,
|
||||
},
|
||||
);
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The `waiting` entry carries no `decidedBy` — nothing has decided yet, and
|
||||
* a `null` there would read as "decided by nobody" rather than "not yet".
|
||||
*/
|
||||
private writeReviewEntry(
|
||||
event: string,
|
||||
details: PromptPermissionDetails & {
|
||||
resolution?: string;
|
||||
denialReason?: string;
|
||||
decidedBy?: DecisionSource;
|
||||
},
|
||||
): void {
|
||||
this.deps.logger.review(event, {
|
||||
...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
|
||||
requestId: details.requestId,
|
||||
source: details.source,
|
||||
agentName: details.agentName,
|
||||
...renderReviewLogFacts(details.payload),
|
||||
toolCallId: details.toolCallId ?? null,
|
||||
toolName: details.toolName ?? null,
|
||||
skillName: details.skillName ?? null,
|
||||
path: details.path ?? null,
|
||||
command: details.command ?? null,
|
||||
target: details.target ?? null,
|
||||
toolInputPreview: details.toolInputPreview ?? null,
|
||||
resolution: details.resolution ?? null,
|
||||
denialReason: details.denialReason ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* serving-registry.ts — Which sessions are draining a forwarded-permission inbox.
|
||||
*
|
||||
* A session with a UI that is not itself a subagent polls its own
|
||||
* `<forwardingDir>/sessions/<id>/requests/` directory (see `ForwardingManager`)
|
||||
* and answers whatever a child forwards to it. Nothing else in the process can
|
||||
* observe that, so a child whose parent is *not* polling has no way to tell
|
||||
* "a human is being asked" from "nobody is home", and waits out the full
|
||||
* forwarding timeout before denying (#719).
|
||||
*
|
||||
* This registry publishes that fact: the polling session marks itself while it
|
||||
* polls, and a forwarding child checks whether its resolved target is marked.
|
||||
*
|
||||
* The single instance is stored on `globalThis` (via `Symbol.for()`) for the
|
||||
* same reason `SubagentSessionRegistry` is: each session's `ResourceLoader`
|
||||
* creates its own jiti instance and its own event bus, so the parent's
|
||||
* permission-system instance and an in-process child's instance share no
|
||||
* module state — only process globals. See `getServingSessionRegistry()`.
|
||||
*
|
||||
* The signal is meaningful only for an **in-process** child (one that resolved
|
||||
* its target through `SubagentSessionRegistry`, i.e. a forwarding target with
|
||||
* `source: "registry"`). A child in another process shares no `globalThis` with
|
||||
* its parent and must not read anything into an absent mark.
|
||||
*/
|
||||
|
||||
/** Process-global key for the shared registry slot. Exported for test teardown. */
|
||||
export const SERVING_SESSION_REGISTRY_KEY = Symbol.for(
|
||||
"@gotgenes/pi-permission-system:serving-registry",
|
||||
);
|
||||
|
||||
/**
|
||||
* Announce-side seam: the polling session marks and clears itself.
|
||||
*
|
||||
* `ForwardingManager` depends on this rather than the concrete registry so it
|
||||
* neither reads the store nor gains a query it has no business making (ISP).
|
||||
*/
|
||||
export interface ServingAnnouncer {
|
||||
/**
|
||||
* Record that `sessionId` is polling its inbox.
|
||||
*
|
||||
* Idempotent, and called on every poll tick rather than once per session: an
|
||||
* announcement that can decay (the filesystem heartbeat) has to be kept
|
||||
* current, and one that cannot (this registry) costs a set insertion to say
|
||||
* so again.
|
||||
*/
|
||||
markServing(sessionId: string): void;
|
||||
clearServing(sessionId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan an announcement out to every channel a serving session publishes on.
|
||||
*
|
||||
* A session announces to the process-global registry (for its in-process
|
||||
* children) and to the filesystem (for children in other processes). Composing
|
||||
* them keeps `ForwardingManager` holding one collaborator, so adding or
|
||||
* removing a channel never reaches the poll loop.
|
||||
*/
|
||||
export function composeServingAnnouncers(
|
||||
...announcers: readonly ServingAnnouncer[]
|
||||
): ServingAnnouncer {
|
||||
return {
|
||||
markServing(sessionId: string): void {
|
||||
for (const announcer of announcers) {
|
||||
announcer.markServing(sessionId);
|
||||
}
|
||||
},
|
||||
clearServing(sessionId: string): void {
|
||||
for (const announcer of announcers) {
|
||||
announcer.clearServing(sessionId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-side seam: a forwarding child asks whether its target is draining.
|
||||
*
|
||||
* `servingIds()` exists for the diagnostic review entry a child writes when it
|
||||
* abandons an unserved request — the mismatch between the id it forwarded to
|
||||
* and the ids actually being served is the whole diagnosis.
|
||||
*/
|
||||
export interface ServingLookup {
|
||||
isServing(sessionId: string): boolean;
|
||||
servingIds(): readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of sessions currently draining a forwarded-permission inbox.
|
||||
*
|
||||
* A process-global singleton — obtain it via {@link getServingSessionRegistry},
|
||||
* never `new` (see that accessor for why). Written exclusively by the owning
|
||||
* session's `ForwardingManager`, keyed by that session's own id, so one
|
||||
* session's shutdown cannot clear another's mark.
|
||||
*
|
||||
* A mark left behind by a session that died without `session_shutdown` makes a
|
||||
* child wait out the full timeout instead of abandoning early — the same
|
||||
* behavior as before this signal existed, which is the safe direction to fail.
|
||||
*/
|
||||
export class ServingSessionRegistry implements ServingAnnouncer, ServingLookup {
|
||||
private readonly serving = new Set<string>();
|
||||
|
||||
/** Record that `sessionId` is polling its inbox. Idempotent. */
|
||||
markServing(sessionId: string): void {
|
||||
this.serving.add(sessionId);
|
||||
}
|
||||
|
||||
/** Record that `sessionId` has stopped polling. No-op if unmarked. */
|
||||
clearServing(sessionId: string): void {
|
||||
this.serving.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Return `true` when `sessionId` is currently polling its inbox. */
|
||||
isServing(sessionId: string): boolean {
|
||||
return this.serving.has(sessionId);
|
||||
}
|
||||
|
||||
/** Every currently-serving session id, for diagnostics. */
|
||||
servingIds(): readonly string[] {
|
||||
return [...this.serving];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the process-global ServingSessionRegistry, creating it on first call.
|
||||
*
|
||||
* Intentionally has no teardown hook: a child's `session_shutdown` must not be
|
||||
* able to wipe the parent's mark. Entries are added and removed exclusively by
|
||||
* the owning session's `ForwardingManager`.
|
||||
*/
|
||||
export function getServingSessionRegistry(): ServingSessionRegistry {
|
||||
const store = globalThis as Record<symbol, unknown>;
|
||||
const existing = store[SERVING_SESSION_REGISTRY_KEY] as
|
||||
| ServingSessionRegistry
|
||||
| undefined;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const registry = new ServingSessionRegistry();
|
||||
store[SERVING_SESSION_REGISTRY_KEY] = registry;
|
||||
return registry;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { SUBAGENT_ENV_HINT_KEYS } from "#src/authority/permission-forwarding";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
/**
|
||||
* Narrow context for subagent detection — the only session-manager readers
|
||||
* {@link isSubagentExecutionContext} and {@link isRegisteredSubagentChild}
|
||||
* consume. A full `ExtensionContext` satisfies this structurally.
|
||||
*/
|
||||
export interface SubagentDetectionContext {
|
||||
sessionManager: {
|
||||
getSessionId(): string;
|
||||
getSessionDir(): string;
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFilesystemPath(
|
||||
pathValue: string,
|
||||
flavor: PathFlavor,
|
||||
): string {
|
||||
return flavor.fold(flavor.impl.normalize(pathValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` when `ctx` belongs to an in-process subagent child registered
|
||||
* in `registry` by its session id.
|
||||
*
|
||||
* This is the only signal that identifies an **in-process** child (one sharing
|
||||
* the parent's `globalThis`); env-hint and filesystem heuristics identify
|
||||
* **process-based** subagents instead. The composition root uses this to decide
|
||||
* whether the instance owns the process-global service slot — a registered
|
||||
* child must not publish over its parent.
|
||||
*/
|
||||
export function isRegisteredSubagentChild(
|
||||
ctx: SubagentDetectionContext,
|
||||
registry: SubagentSessionRegistry,
|
||||
): boolean {
|
||||
try {
|
||||
const sessionId = ctx.sessionManager.getSessionId();
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
return registry.has(sessionId);
|
||||
} catch {
|
||||
// getSessionId() unavailable — treat as not-a-registered-child.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSubagentExecutionContext(
|
||||
ctx: SubagentDetectionContext,
|
||||
subagentSessionsDir: string,
|
||||
flavor: PathFlavor,
|
||||
registry?: SubagentSessionRegistry,
|
||||
): boolean {
|
||||
// 1. Explicit registry — in-process subagent extensions register by child
|
||||
// session id before bindExtensions(); checked first so it takes priority
|
||||
// over heuristics. Each concurrent sibling has a unique session id, so
|
||||
// one sibling's disposed event cannot affect another's registration.
|
||||
if (registry && isRegisteredSubagentChild(ctx, registry)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionDir = ctx.sessionManager.getSessionDir();
|
||||
|
||||
// 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
|
||||
// HazAT/pi-interactive-subagents, pi-agent-router, etc.).
|
||||
for (const key of SUBAGENT_ENV_HINT_KEYS) {
|
||||
const value = process.env[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Filesystem path — fallback heuristic for extensions that store sessions
|
||||
// under a known subagent root directory.
|
||||
if (!sessionDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedSessionDir = normalizeFilesystemPath(sessionDir, flavor);
|
||||
const normalizedSubagentRoot = normalizeFilesystemPath(
|
||||
subagentSessionsDir,
|
||||
flavor,
|
||||
);
|
||||
return flavor.isWithin(normalizedSessionDir, normalizedSubagentRoot);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
isRegisteredSubagentChild,
|
||||
isSubagentExecutionContext,
|
||||
type SubagentDetectionContext,
|
||||
} from "#src/authority/subagent-context";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
/**
|
||||
* Narrow seam for the ask-path consumers: "is the current session a subagent?"
|
||||
*
|
||||
* `selectAuthorizer`/`AuthorizerSelection` and `ForwardingManager` depend on
|
||||
* this single-method view so their unit tests inject a one-field fake without
|
||||
* casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
|
||||
*/
|
||||
export interface SubagentDetector {
|
||||
isSubagent(ctx: SubagentDetectionContext): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow seam for the service-publication guard (#302): "is the current
|
||||
* session a registered in-process child?"
|
||||
*
|
||||
* `PermissionServiceLifecycle` depends on this single-method view so a
|
||||
* registered child never publishes over its parent's process-global slot.
|
||||
*/
|
||||
export interface RegisteredChildDetector {
|
||||
isRegisteredChild(ctx: SubagentDetectionContext): boolean;
|
||||
}
|
||||
|
||||
/** Composition-root inputs for {@link SubagentDetection}. */
|
||||
export interface SubagentDetectionDeps {
|
||||
subagentSessionsDir: string;
|
||||
flavor: PathFlavor;
|
||||
registry?: SubagentSessionRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single owner of subagent detection.
|
||||
*
|
||||
* Constructed once in the composition root with the detection inputs
|
||||
* (`subagentSessionsDir`, `flavor`, `registry`) and shared across every
|
||||
* consumer, replacing the dep triple those consumers previously threaded
|
||||
* individually. Delegates to the pure detection functions in
|
||||
* {@link ./subagent-context}, holding only the deps.
|
||||
*/
|
||||
export class SubagentDetection
|
||||
implements SubagentDetector, RegisteredChildDetector
|
||||
{
|
||||
constructor(private readonly deps: SubagentDetectionDeps) {}
|
||||
|
||||
isSubagent(ctx: SubagentDetectionContext): boolean {
|
||||
return isSubagentExecutionContext(
|
||||
ctx,
|
||||
this.deps.subagentSessionsDir,
|
||||
this.deps.flavor,
|
||||
this.deps.registry,
|
||||
);
|
||||
}
|
||||
|
||||
isRegisteredChild(ctx: SubagentDetectionContext): boolean {
|
||||
return this.deps.registry
|
||||
? isRegisteredSubagentChild(ctx, this.deps.registry)
|
||||
: false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
|
||||
* lifecycle events and keep the SubagentSessionRegistry in sync.
|
||||
*
|
||||
* @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
|
||||
* event bus (ADR 0002): it no longer calls this package's service directly.
|
||||
* We register the child on `session-created` and unregister it on `disposed`.
|
||||
*
|
||||
* The channel names and payload shapes are declared independently here (the two
|
||||
* packages must not depend on each other under jiti) and MUST match the
|
||||
* publisher in `@gotgenes/pi-subagents` (`src/lifecycle/child-lifecycle.ts`).
|
||||
*
|
||||
* The `session-created` handler MUST stay synchronous: the core emits it on the
|
||||
* same synchronous call stack immediately before `bindExtensions()`, and the
|
||||
* event bus dispatches listeners synchronously, so a synchronous handler lands
|
||||
* the registry entry before binding proceeds. Introducing an `await` before
|
||||
* `registry.register(...)` would break the pre-bind ordering.
|
||||
*/
|
||||
|
||||
import type { SubagentSessionRegistry } from "./subagent-registry";
|
||||
|
||||
/** Emitted by the core after session creation, before `bindExtensions()`. */
|
||||
export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
|
||||
|
||||
/** Emitted by the core in the run's `finally` (success and error). */
|
||||
export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
|
||||
|
||||
/** Minimal event-bus surface this module needs (subscribe only). */
|
||||
interface LifecycleEventBus {
|
||||
on(channel: string, handler: (data: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
/** Fields read from the `session-created` payload (ISP). */
|
||||
interface ChildSessionCreatedEvent {
|
||||
/** Child session id — the registry key. Must match the publisher. */
|
||||
sessionId: string;
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
/** Fields read from the `disposed` payload (ISP). */
|
||||
interface ChildDisposedEvent {
|
||||
/** Child session id — the registry key. Must match the publisher. */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the subagent child lifecycle.
|
||||
*
|
||||
* @returns an unsubscribe that detaches both handlers (call during
|
||||
* `session_shutdown`).
|
||||
*/
|
||||
export function subscribeSubagentLifecycle(
|
||||
events: LifecycleEventBus,
|
||||
registry: SubagentSessionRegistry,
|
||||
): () => void {
|
||||
const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
|
||||
const event = data as ChildSessionCreatedEvent;
|
||||
registry.register(event.sessionId, {
|
||||
parentSessionId: event.parentSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
|
||||
const event = data as ChildDisposedEvent;
|
||||
registry.unregister(event.sessionId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubCreated();
|
||||
unsubDisposed();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* subagent-registry.ts — In-process subagent session registry.
|
||||
*
|
||||
* In-process subagent extensions (e.g. `@gotgenes/pi-subagents`) register
|
||||
* each child session here before calling `bindExtensions()` so that
|
||||
* `isSubagentExecutionContext()` and permission-forwarding target resolution
|
||||
* can detect them without relying on environment variables or filesystem
|
||||
* heuristics.
|
||||
*
|
||||
* The registry is keyed by the child's **session id**, which is unique per
|
||||
* child and available to both producer (via `sessionManager.getSessionId()`
|
||||
* after `newSession()` in `create-subagent-session.ts`) and consumer (via
|
||||
* `ctx.sessionManager.getSessionId()`). Two concurrent siblings of the same
|
||||
* parent therefore occupy distinct keys, so one sibling's `disposed` event
|
||||
* cannot evict the entry the others depend on.
|
||||
*
|
||||
* The single registry instance is stored on `globalThis` (via `Symbol.for()`)
|
||||
* so that the parent's permission-system instance (which registers children
|
||||
* on the parent's event bus) and each child's separate jiti instance (which
|
||||
* reads the registry to detect itself and resolve its forwarding target) share
|
||||
* one store across per-session event buses. See `getSubagentSessionRegistry()`.
|
||||
*
|
||||
* When a future code path needs the child's agent name, read it from
|
||||
* `tcc.agentName` (resolved from the `<active_agent>` system-prompt tag) —
|
||||
* not from this registry.
|
||||
*/
|
||||
|
||||
/** Process-global key for the shared registry slot. */
|
||||
const SUBAGENT_SESSION_REGISTRY_KEY = Symbol.for(
|
||||
"@gotgenes/pi-permission-system:subagent-registry",
|
||||
);
|
||||
|
||||
/**
|
||||
* Return the process-global SubagentSessionRegistry, creating it on first call.
|
||||
*
|
||||
* Backed by `globalThis` + `Symbol.for()` so the parent's permission-system
|
||||
* instance (which registers children on the parent event bus) and each child's
|
||||
* separate jiti instance (which reads the registry to detect itself and resolve
|
||||
* its forwarding target) share one store across per-session event buses.
|
||||
*
|
||||
* Intentionally has no shutdown/unpublish hook — a child's `session_shutdown`
|
||||
* must not be able to wipe the parent's registrations. Entries are added and
|
||||
* removed exclusively by the parent's `subagents:child:session-created` /
|
||||
* `subagents:child:disposed` subscription.
|
||||
*/
|
||||
export function getSubagentSessionRegistry(): SubagentSessionRegistry {
|
||||
const store = globalThis as Record<symbol, unknown>;
|
||||
const existing = store[SUBAGENT_SESSION_REGISTRY_KEY] as
|
||||
| SubagentSessionRegistry
|
||||
| undefined;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const registry = new SubagentSessionRegistry();
|
||||
store[SUBAGENT_SESSION_REGISTRY_KEY] = registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
/** Signal stored per registered in-process subagent session. */
|
||||
export interface SubagentSessionInfo {
|
||||
/** Parent session ID for permission forwarding. Omit when unknown. */
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of active in-process subagent sessions.
|
||||
*
|
||||
* A process-global singleton — obtain it via `getSubagentSessionRegistry()`,
|
||||
* never `new` (see that accessor for why). Written exclusively by
|
||||
* `subscribeSubagentLifecycle` via the `subagents:child:session-created` /
|
||||
* `subagents:child:disposed` event subscription (ADR 0002 — the core
|
||||
* publishes, consumers observe).
|
||||
*
|
||||
* Keyed by child session id. Each concurrent child of the same parent receives
|
||||
* a unique session id from `sessionManager.newSession()`, so siblings occupy
|
||||
* distinct keys and one sibling's `disposed` cannot evict another's entry.
|
||||
*/
|
||||
export class SubagentSessionRegistry {
|
||||
private readonly sessions = new Map<string, SubagentSessionInfo>();
|
||||
|
||||
/**
|
||||
* Register an in-process subagent session.
|
||||
*
|
||||
* If a previous entry exists for `sessionId`, it is overwritten
|
||||
* (last-write-wins; single-writer expected per key).
|
||||
*/
|
||||
register(sessionId: string, info: SubagentSessionInfo): void {
|
||||
this.sessions.set(sessionId, info);
|
||||
}
|
||||
|
||||
/** Remove a previously registered session. No-op if the key is absent. */
|
||||
unregister(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Return the registered info for `sessionId`, or `undefined` if absent. */
|
||||
get(sessionId: string): SubagentSessionInfo | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/** Return `true` when `sessionId` has a registered entry. */
|
||||
has(sessionId: string): boolean {
|
||||
return this.sessions.has(sessionId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user