mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
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
|
||
);
|
||
}
|