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,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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user