feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,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
);
}