File size: 12,041 Bytes
e249c6d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 | import { sha256Hex } from "../../infra/crypto-digest.js";
import type {
CliBackendLiveSessionCapability,
CliBackendLiveSessionCloseReason,
CliBackendLiveSessionHandle,
} from "../../plugins/cli-backend.types.js";
import { runCliCleanup } from "./cleanup.js";
import { createCliRunCurrentAssertion } from "./execution-target.js";
import { createCliFailoverError } from "./exit-error.js";
import { buildCliLiveSessionFingerprint } from "./live-session-fingerprint.js";
import { cliBackendLog } from "./log.js";
import type { PreparedCliRunContext } from "./types.js";
const MAX_LIVE_SESSIONS = 16;
type CliLiveSessionOwner = {
backendId: string;
agentAccountId?: string;
agentId?: string;
authProfileId?: string;
sessionId?: string;
sessionKey?: string;
};
type CliLiveSessionRecord = {
handle: CliBackendLiveSessionHandle;
owner: PreparedCliRunContext["preparedBackend"];
approvalGrants: Set<string>;
cleanup?: () => Promise<void>;
cleanupPromise?: Promise<void>;
capture?: {
token: string;
key: string;
revoke: () => void;
};
};
const liveSessions = new Map<string, CliLiveSessionRecord>();
const retiredSessionCleanup = new Map<string, Promise<void>>();
const retiringSessionHandles = new WeakSet<CliBackendLiveSessionHandle>();
function buildCliLiveRegistryKey(owner: CliLiveSessionOwner): string {
return `${owner.backendId}:${buildCliLiveOwnerKey(owner)}`;
}
/** Hashes the account/agent/auth/session tuple shared by queue and registry ownership. */
export function buildCliLiveOwnerKey(input: Omit<CliLiveSessionOwner, "backendId">): string {
return sha256Hex(
JSON.stringify({
agentAccountId: input.agentAccountId,
agentId: input.agentId,
authProfileId: input.authProfileId,
sessionId: input.sessionId,
sessionKey: input.sessionKey,
}),
);
}
function buildCliLiveSessionKey(context: PreparedCliRunContext): string {
return buildCliLiveRegistryKey({
backendId: context.backendResolved.id,
agentAccountId: context.params.agentAccountId,
agentId: context.params.agentId,
authProfileId: context.effectiveAuthProfileId,
sessionId: context.params.sessionId,
sessionKey: context.params.sessionKey,
});
}
/** Returns whether this owner still has an in-process plugin-owned session. */
export function hasCliLiveSession(owner: CliLiveSessionOwner): boolean {
return getCliLiveSessionGeneration(owner) !== undefined;
}
/** Returns the opaque generation of this owner's registered execution session. */
export function getCliLiveSessionGeneration(owner: CliLiveSessionOwner): string | undefined {
return liveSessions.get(buildCliLiveRegistryKey(owner))?.handle.generation;
}
/** Reads owner-private standing approvals only from this exact current live process. */
export function getCliLiveSessionApprovalGrants(
context: PreparedCliRunContext,
): Set<string> | undefined {
return liveSessions.get(buildCliLiveSessionKey(context))?.approvalGrants;
}
/** Closes the live execution session associated with a prepared run context, if one exists. */
export async function closeCliLiveSession(
context: PreparedCliRunContext,
reason: CliBackendLiveSessionCloseReason,
): Promise<void> {
await runCliCleanup(context.params, "cli-live-session-close", async () => {
await context.preparedBackend.closeLiveSession?.(reason);
});
}
/** Explicit fresh/fork execution owns replacement of the current idle process. */
export async function restartCliLiveSession(
context: PreparedCliRunContext,
signal = context.params.abortSignal,
): Promise<void> {
const assertActive = createCliRunCurrentAssertion(context.params, signal);
assertActive();
const key = buildCliLiveSessionKey(context);
const record = liveSessions.get(key);
const pendingCleanup = retiredSessionCleanup.get(key);
await runCliCleanup(
context.params,
"cli-live-session-close",
async () => {
assertActive();
await context.preparedBackend.closeLiveSession?.("restart");
await pendingCleanup;
},
"required",
);
if (record) {
await runCliCleanup(
context.params,
"cli-live-session-restart",
() => {
// One-shot cleanup schedules this callback; fence the actual close.
assertActive();
return closeRecord(record, "restart");
},
"required",
);
}
assertActive();
}
async function closeRecord(
record: CliLiveSessionRecord,
reason: CliBackendLiveSessionCloseReason,
): Promise<void> {
if (!record.cleanupPromise) {
record.handle.close(reason);
}
await (record.cleanupPromise ?? record.handle.waitForExit());
}
function retainCleanup(context: PreparedCliRunContext, record: CliLiveSessionRecord): void {
const owner = context.preparedBackend;
record.owner = owner;
owner.closeLiveSession = async (reason) => {
// Natural removal retains this exact cleanup promise. A later turn may
// borrow the live process, but the old turn cannot close that successor.
if (record.owner === owner || record.cleanupPromise) {
await closeRecord(record, reason);
}
};
}
function ensureCliLiveSessionCapacity(context: PreparedCliRunContext): void {
if (liveSessions.size < MAX_LIVE_SESSIONS) {
return;
}
for (const { handle } of liveSessions.values()) {
if (handle.isIdle()) {
handle.close("idle");
return;
}
}
throw createCliFailoverError("Too many CLI live sessions are active.", "rate_limit", {
provider: context.params.provider,
model: context.modelId,
sessionId: context.params.sessionId,
lane: context.params.lane,
});
}
/** Returns whether this prepared local plugin transport may retain its execution process. */
export function acceptsCliLiveSession(context: PreparedCliRunContext): boolean {
return (
context.executionTarget.kind === "plugin" &&
context.preparedBackend.backend.liveSession !== undefined &&
context.preparedBackend.backend.output === "jsonl" &&
context.preparedBackend.backend.input === "stdin"
);
}
/** Creates host-owned lifecycle authority without exposing owner keys or bearer material. */
export function createCliLiveSessionCapability(params: {
context: PreparedCliRunContext;
argv: readonly string[];
argv0?: string;
env: Record<string, string>;
captureKey?: string;
beginCapture: (captureKey: string | undefined) => void;
abortSignal: AbortSignal;
requiredGeneration?: string;
claimResources?: () => (() => Promise<void>) | undefined;
}): CliBackendLiveSessionCapability {
const ownerKey = buildCliLiveSessionKey(params.context);
const fingerprint = buildCliLiveSessionFingerprint({
context: params.context,
argv: params.argv,
argv0: params.argv0,
env: params.env,
});
const grant = params.context.preparedBackend.mcpClientGrantCapture;
if (Boolean(grant) !== Boolean(params.captureKey)) {
throw new Error("CLI live process and current turn disagree about MCP capture ownership.");
}
const requiredSessionError = (code: "cli_live_session_changed" | "cli_live_session_missing") =>
createCliFailoverError(
"Managed CLI live session is no longer reusable.",
"session_expired",
{
provider: params.context.params.provider,
model: params.context.modelId,
sessionId: params.context.params.sessionId,
lane: params.context.params.lane,
},
{ code },
);
const assertActive = createCliRunCurrentAssertion(params.context.params, params.abortSignal);
const requireRegisteredRecord = (handle: CliBackendLiveSessionHandle) => {
assertActive();
const record = liveSessions.get(ownerKey);
if (handle.fingerprint !== fingerprint || record?.handle !== handle) {
throw new Error("CLI live session no longer belongs to this admitted run.");
}
if (params.requiredGeneration && params.requiredGeneration !== handle.generation) {
throw requiredSessionError("cli_live_session_changed");
}
return record;
};
return Object.freeze({
fingerprint,
current: () => {
assertActive();
const handle = liveSessions.get(ownerKey)?.handle;
if (params.requiredGeneration && handle?.generation !== params.requiredGeneration) {
throw requiredSessionError(
handle ? "cli_live_session_changed" : "cli_live_session_missing",
);
}
if (params.requiredGeneration && handle?.fingerprint !== fingerprint) {
throw requiredSessionError("cli_live_session_changed");
}
return handle;
},
restart: async () => {
assertActive();
if (params.requiredGeneration) {
throw requiredSessionError("cli_live_session_changed");
}
await restartCliLiveSession(params.context, params.abortSignal);
},
register: (handle) => {
assertActive();
if (retiredSessionCleanup.has(ownerKey)) {
throw new Error("Previous CLI live session cleanup has not settled.");
}
if (params.requiredGeneration) {
throw requiredSessionError("cli_live_session_changed");
}
if (
handle.fingerprint !== fingerprint ||
!handle.generation.trim() ||
liveSessions.has(ownerKey) ||
// Owner keys stay private; one process handle must never cross owners.
retiringSessionHandles.has(handle) ||
Array.from(liveSessions.values()).some((record) => record.handle === handle)
) {
throw new Error("CLI live session registration does not match its admitted owner.");
}
ensureCliLiveSessionCapacity(params.context);
const cleanup = params.claimResources?.();
const record: CliLiveSessionRecord = {
handle,
owner: params.context.preparedBackend,
approvalGrants: new Set(),
...(cleanup ? { cleanup } : {}),
...(grant && params.captureKey
? {
capture: {
token: grant.transportToken,
key: params.captureKey,
revoke: grant.revokeProcessToken,
},
}
: {}),
};
liveSessions.set(ownerKey, record);
retainCleanup(params.context, record);
cliBackendLog.info(
`cli live session start: provider=${params.context.backendResolved.id} model=${params.context.normalizedModel} activeSessions=${liveSessions.size}`,
);
},
activate: (handle) => {
const record = requireRegisteredRecord(handle);
if (Boolean(record.capture) !== Boolean(grant)) {
throw new Error("CLI live session MCP topology changed across admitted turns.");
}
if (record.capture && grant) {
// Transfer the exact current admission before activating the original
// child capture header; copied bearers never carry authority alone.
grant.adoptProcessToken(record.capture.token);
requireRegisteredRecord(handle);
params.beginCapture(record.capture.key);
}
retainCleanup(params.context, record);
},
remove: (handle) => {
const record = liveSessions.get(ownerKey);
if (record?.handle !== handle) {
return;
}
record.capture?.revoke();
liveSessions.delete(ownerKey);
record.approvalGrants.clear();
retiringSessionHandles.add(handle);
// Native runtime artifacts remain process-owned until its child exits.
record.cleanupPromise = Promise.resolve()
.then(() => handle.waitForExit())
.then(() => record.cleanup?.())
.then(() => {
retiringSessionHandles.delete(handle);
if (retiredSessionCleanup.get(ownerKey) === record.cleanupPromise) {
retiredSessionCleanup.delete(ownerKey);
}
});
// A fresh prepared context must still join the retired owner after a timeout.
retiredSessionCleanup.set(ownerKey, record.cleanupPromise);
void record.cleanupPromise.catch((error: unknown) => {
cliBackendLog.warn(`cli live session cleanup failed: ${String(error)}`);
});
},
});
}
|