Files
Aether/aether-vscodex/vscode-extension/src/relayHost.ts
T

651 lines
30 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
AgentAdapter,
AgentEvent,
approvalDecisionKind,
approvalDecisionKindForMethod,
asJsonObject,
asJsonValue,
Disposable,
hasApprovalDecisionField,
isRecord,
JsonObject,
JsonRpcId,
Logger,
JsonValue,
RelayActor,
RelayCommandFrame,
RelayEventFrame,
RelayFrame,
RelayTransport,
} from "./protocol";
export interface RelayHostOptions {
adapter: AgentAdapter;
relay: RelayTransport;
sessionId?: string;
actor?: RelayActor;
/** Capabilities enforced locally even when relay authorization is bypassed. */
capabilities?: Iterable<string>;
logger?: Logger;
/** Emit a handshake on transports that do not implement one themselves. */
sendHandshake?: boolean;
}
/**
* Maps relay commands to the app-server AgentAdapter and publishes normalized
* adapter events. This is the policy boundary for the VS Code host.
*/
export class RelayHost {
private readonly adapter: AgentAdapter;
private readonly relay: RelayTransport;
private readonly options: RelayHostOptions;
private readonly subscriptions: Disposable[] = [];
private readonly commandResults = new Map<string, RelayEventFrame>();
private readonly inFlightCommands = new Set<string>();
private readonly capabilities: Set<string>;
private eventSeq = 0;
private sessionId: string;
private started = false;
private adapterReady = false;
constructor(options: RelayHostOptions);
constructor(adapter: AgentAdapter, relay: RelayTransport, options?: Omit<RelayHostOptions, "adapter" | "relay">);
constructor(
optionsOrAdapter: RelayHostOptions | AgentAdapter,
relayArg?: RelayTransport,
legacyOptions: Omit<RelayHostOptions, "adapter" | "relay"> = {},
) {
if (isAgentAdapter(optionsOrAdapter)) {
this.adapter = optionsOrAdapter;
if (!relayArg) throw new Error("RelayHost requires a relay transport");
this.relay = relayArg;
this.options = { ...legacyOptions, adapter: this.adapter, relay: this.relay };
} else {
this.options = optionsOrAdapter;
this.adapter = optionsOrAdapter.adapter;
this.relay = optionsOrAdapter.relay;
}
this.capabilities = new Set(this.options.capabilities ?? [
"read_output",
"send_task_input",
"cancel_task",
"approve_low_risk",
]);
this.sessionId = this.options.sessionId ?? `sess_${randomUUID()}`;
}
get id(): string {
return this.sessionId;
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
this.subscriptions.push(this.adapter.onEvent((event) => {
if (event.type === "connection.opened") this.adapterReady = true;
if (event.type === "connection.closed") this.adapterReady = false;
this.publishAgentEvent(event);
}));
this.subscriptions.push(this.relay.onMessage((frame) => {
void this.handleFrame(frame).catch((error) => {
this.options.logger?.warn?.("Invalid relay frame", error);
if (isRecord(frame) && typeof frame.commandId === "string") {
this.sendCommandResult(frame.commandId, false, undefined, error instanceof Error ? error.message : String(error), typeof frame.method === "string" ? frame.method : typeof frame.type === "string" ? frame.type : undefined);
}
});
}));
if (this.relay.onClose) this.subscriptions.push(this.relay.onClose((error) => {
// A relay disconnect must not leave an app-server request waiting for a
// browser that can no longer answer. The adapter's local deny path is
// deliberately fail-closed. Do not publish `connection.closed` here:
// that event describes the app-server process, while this callback only
// describes the outbound transport and is followed by connection.opened
// on a successful reconnect.
void this.adapter.denyPending?.("relay disconnected");
this.options.logger?.debug?.("Relay transport closed", error?.message ?? "");
}));
if (this.relay.onOpen) this.subscriptions.push(this.relay.onOpen(() => {
// RelayClient fires onOpen only after auth.ok. On reconnect the adapter
// is already initialized, so the synthetic event restores relay state;
// during initial startup the adapter event below is authoritative.
if (this.adapterReady) {
this.publishConnectionEvent("connection.opened");
void this.publishSnapshot();
}
}));
const configurableRelay = this.relay as RelayTransport & { setSessionId?: (sessionId: string) => void };
configurableRelay.setSessionId?.(this.sessionId);
try {
await this.relay.connect();
if (this.options.sendHandshake !== false && !transportHandlesHandshake(this.relay)) {
this.safeSend({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: this.sessionId });
}
// Start app-server only after the relay handshake is queued/sent. This
// keeps standalone stdout frames protocol-ordered and prevents an early
// notification from racing the host hello.
await this.adapter.start();
if (!this.adapterReady) {
this.adapterReady = true;
this.publishConnectionEvent("connection.opened");
}
await this.publishSnapshot();
} catch (error) {
this.started = false;
this.adapterReady = false;
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
this.relay.close();
await this.adapter.dispose().catch(() => undefined);
throw error;
}
}
async stop(): Promise<void> {
if (!this.started) return;
this.started = false;
this.adapterReady = false;
this.inFlightCommands.clear();
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
this.relay.close();
await this.adapter.dispose();
}
/** Public for unit tests and local stdin bridges. */
async handleFrame(frame: RelayFrame): Promise<void> {
if (!isRecord(frame)) return;
if (frame.kind === "command" || isCommandLike(frame)) {
await this.handleCommand(frame as unknown as RelayCommandFrame);
return;
}
if (frame.kind === "event" && typeof frame.seq === "number") {
this.safeSend({ v: 1, kind: "ack", sessionId: frame.sessionId, seq: frame.seq });
}
}
private async handleCommand(frame: RelayCommandFrame): Promise<void> {
const command = normalizeCommand(frame);
const commandId = command.commandId;
if (commandId) {
const previous = this.commandResults.get(commandId);
if (previous) {
this.safeSend(previous);
return;
}
if (this.inFlightCommands.has(commandId)) {
this.safeSend({
v: 1,
kind: "event",
// Do not call this `command.accepted`: the relay treats that event
// as the terminal result for its pending command. A retry while the
// original operation is running is only an informational event.
type: "command.pending",
id: `evt_${randomUUID()}`,
sessionId: this.sessionId,
seq: ++this.eventSeq,
ts: new Date().toISOString(),
actor: this.options.actor ?? { id: "host", role: "host" },
payload: { commandId, duplicate: true, pending: true },
});
return;
}
this.inFlightCommands.add(commandId);
}
const role = frame.actor?.role ?? "operator";
const denied = authorize(command.type, role, this.capabilities);
if (denied) {
// A viewer may not force a pending approval to deny (that would turn a
// read-only role into a denial-of-service primitive). Authorized roles
// can still be rejected by local capability/policy checks, in which
// case denying the app-server request is the safe terminal action.
if (role === "owner" || role === "operator" || role === "approver" || role === "host") {
await this.denyApprovalIfNeeded(command.type, command.payload, denied);
}
this.sendCommandResult(commandId, false, undefined, denied, command.type);
if (commandId) this.inFlightCommands.delete(commandId);
return;
}
try {
const result = await this.executeCommand(command.type, command.payload);
this.sendCommandResult(commandId, true, result, undefined, command.type);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.options.logger?.warn?.(`Relay command ${command.type} failed`, error);
await this.denyApprovalIfNeeded(command.type, command.payload, message);
this.sendCommandResult(commandId, false, undefined, message, command.type);
} finally {
if (commandId) this.inFlightCommands.delete(commandId);
}
}
private async denyApprovalIfNeeded(type: string, payload: JsonObject, reason: string): Promise<void> {
const command = canonicalCommandType(type);
if (command !== "approval.respond" && command !== "input.respond" && command !== "server.request.respond") return;
const requestId = payload.requestId;
if (requestId === undefined || (typeof requestId !== "string" && typeof requestId !== "number")) return;
try {
await this.adapter.respondApproval(requestId, "deny", reason);
} catch {
// The request may already have expired or been resolved. Keep the
// original command rejection as the observable result.
}
}
private async executeCommand(type: string, payload: JsonObject): Promise<unknown> {
switch (canonicalCommandType(type)) {
case "control.mode.get": {
const mode = this.adapter.getControlMode?.();
if (mode) return { mode };
const snapshot = await this.adapter.snapshot();
const controlMode = snapshot.metadata?.controlMode;
if (controlMode !== "sync" && controlMode !== "async") {
throw new Error("adapter does not expose a control mode");
}
return { mode: controlMode };
}
case "control.mode.set":
if (!this.adapter.setControlMode) throw new Error("adapter does not support control mode switching");
return this.adapter.setControlMode(payload);
case "thread.start":
if (!this.adapter.startThread) throw new Error("adapter does not support thread/start");
return this.adapter.startThread(payload);
case "session.new":
if (!this.adapter.newSession) throw new Error("adapter does not support session/new");
return this.adapter.newSession(payload);
case "thread.settings.update":
if (!this.adapter.updateThreadSettings) throw new Error("adapter does not support thread/settings/update");
return this.adapter.updateThreadSettings(payload);
case "session.list":
if (!this.adapter.listSessions) throw new Error("adapter does not support session/list");
return this.adapter.listSessions(payload);
case "session.select":
if (!this.adapter.selectSession) throw new Error("adapter does not support session/select");
return this.adapter.selectSession(payload);
case "turn.start":
if (this.adapter.startTurn) return this.adapter.startTurn(payload);
return this.adapter.sendInput(extractCommandText(payload), payload);
case "turn.steer":
if (this.adapter.steerTurn) return this.adapter.steerTurn(payload);
return this.adapter.sendInput(extractCommandText(payload), payload);
case "turn.interrupt":
if (this.adapter.interruptTurn) return this.adapter.interruptTurn(payload);
return this.adapter.cancel(typeof payload.turnId === "string" ? payload.turnId : undefined, payload);
case "task.input": {
const text = typeof payload.text === "string" ? payload.text : typeof payload.message === "string" ? payload.message : undefined;
if (!text) throw new Error("task.input requires payload.text");
return this.adapter.sendInput(text, payload);
}
case "task.cancel": {
const taskId = typeof payload.taskId === "string" ? payload.taskId : typeof payload.turnId === "string" ? payload.turnId : undefined;
return this.adapter.cancel(taskId, payload);
}
case "approval.respond": {
const requestId = payload.requestId;
if (typeof requestId !== "string" && typeof requestId !== "number") throw new Error("approval.respond requires requestId");
const requestedValue = payload.decision;
const decision = approvalDecisionKind(requestedValue);
if (!decision) throw new Error("decision must be a recognized allow, deny, or cancel value");
const snapshot = await this.adapter.snapshot();
// JSON-RPC distinguishes numeric and string ids. Keep the lookup
// type-safe so id `1` cannot accidentally authorize response `"1"`.
const approval = snapshot.pendingApprovals.find((item) => item.requestId === requestId);
const method = approval?.method ?? (typeof payload.method === "string" ? payload.method : undefined);
const response = payload.response ?? implicitApprovalResponse(requestedValue, decision, method, payload.scope);
if (decision === "allow") {
if (approval && typeof payload.commandHash === "string" && payload.commandHash !== approval.commandHash) {
throw new Error("approval commandHash does not match the pending request");
}
if (approval?.risk === "high" && !this.capabilities.has("approve_high_risk") && !this.capabilities.has("*")) {
throw new Error("host policy requires approve_high_risk for this approval");
}
}
validateApprovalResponse(decision, response, method);
return this.adapter.respondApproval(
requestId,
decision,
typeof payload.reason === "string" ? payload.reason : undefined,
response,
);
}
case "input.respond":
case "server.request.respond": {
const requestId = payload.requestId;
if (typeof requestId !== "string" && typeof requestId !== "number") throw new Error(`${type} requires requestId`);
const response = payload.response ?? (payload.answers !== undefined ? payload.answers : undefined);
// Tool-input requests do not carry an allow/deny field in their wire
// response, while MCP elicitation uses `action`. Prefer an explicit
// response action when present; otherwise honor the relay decision and
// fail closed when a denial has no custom response.
if (response !== undefined && !isRecord(response)) {
throw new Error("input response must be a JSON object");
}
const responseDecision = isRecord(response)
? explicitResponseDecision(response)
: undefined;
const requestedDecision = payload.decision === undefined
? undefined
: approvalDecisionKind(payload.decision);
if (payload.decision !== undefined && !requestedDecision) {
throw new Error("decision must be allow, deny, or cancel");
}
if (responseDecision && requestedDecision
&& responseDecision !== requestedDecision
// RelayHost uses `decision: "allow"` as a generic envelope for
// MCP/input responses; the nested action remains authoritative in
// that one compatibility case.
&& requestedDecision !== "allow") {
throw new Error(`input response implies ${responseDecision}, but decision is ${requestedDecision}`);
}
// For MCP, `response.action` is the actual app-server decision and is
// authoritative even if a relay uses `decision: "allow"` as a generic
// input-response envelope. With no custom response, an explicit relay
// decision (or the fail-closed deny default) controls the result.
const decision = responseDecision ?? requestedDecision ?? (response === undefined ? "deny" : "allow");
const responseForAdapter = requestedDecision && requestedDecision !== "allow" && !responseDecision
? undefined
: response;
return this.adapter.respondApproval(
requestId,
decision,
typeof payload.reason === "string" ? payload.reason : undefined,
responseForAdapter,
);
}
case "session.snapshot":
case "snapshot":
return this.adapter.snapshot();
case "ping":
return { pong: true, ts: new Date().toISOString() };
default:
throw new Error(`unsupported relay command: ${type}`);
}
}
private sendCommandResult(commandId: string | undefined, ok: boolean, result?: unknown, error?: string, method?: string): void {
const frame: RelayEventFrame = {
v: 1,
kind: "event",
type: ok ? "command.accepted" : "command.rejected",
id: `evt_${randomUUID()}`,
sessionId: this.sessionId,
seq: ++this.eventSeq,
ts: new Date().toISOString(),
actor: this.options.actor ?? { id: "host", role: "host" },
payload: {
...(commandId ? { commandId } : {}),
...(method ? { method } : {}),
ok,
...(ok ? { result: asJsonValue(result) } : { error: error ?? "command rejected" }),
},
};
if (commandId) {
this.commandResults.set(commandId, frame);
if (this.commandResults.size > 1000) this.commandResults.delete(this.commandResults.keys().next().value as string);
}
this.safeSend(frame);
}
private publishAgentEvent(event: AgentEvent): void {
if (event.threadId && this.sessionId.startsWith("sess_")) {
// Keep a stable relay session id while exposing the app-server thread id
// in the payload; a relay session may contain more than one thread.
}
const payload: JsonObject = {
...event.payload,
...(event.status ? { executionStatus: asJsonValue(event.status) } : {}),
...(event.threadId ? { threadId: event.threadId } : {}),
...(event.turnId ? { turnId: event.turnId } : {}),
...(event.requestId !== undefined ? { requestId: asJsonValue(event.requestId) } : {}),
...(event.raw !== undefined ? { raw: event.raw } : {}),
};
const frame: RelayEventFrame = {
v: 1,
kind: "event",
type: event.type,
id: `evt_${randomUUID()}`,
sessionId: this.sessionId,
seq: ++this.eventSeq,
ts: new Date().toISOString(),
actor: this.options.actor ?? { id: "host", role: "host" },
payload,
...(event.status ? { status: { ...event.status, activeFlags: [...event.status.activeFlags] } } : {}),
};
try {
this.safeSend(frame);
} catch (error) {
this.options.logger?.warn?.("Unable to publish relay event", error);
}
}
private safeSend(frame: RelayFrame): void {
try {
this.relay.send(frame);
} catch (error) {
this.options.logger?.warn?.("Unable to send relay frame", error);
}
}
private publishConnectionEvent(type: string, error?: Error): void {
if (!this.started && type === "connection.closed") return;
this.publishAgentEvent({ type, payload: error ? { message: error.message } : {} });
}
private async publishSnapshot(): Promise<void> {
try {
const snapshot = await this.adapter.snapshot();
this.publishAgentEvent({
type: "session.snapshot",
threadId: snapshot.threadId ?? undefined,
turnId: snapshot.turnId ?? undefined,
payload: asJsonObject(snapshot),
status: snapshot.status,
});
} catch (error) {
this.options.logger?.warn?.("Unable to publish adapter snapshot", error);
}
}
}
interface NormalizedCommand {
type: string;
commandId?: string;
payload: JsonObject;
}
function normalizeCommand(frame: RelayCommandFrame): NormalizedCommand {
const nested = isRecord(frame.command) ? frame.command : undefined;
const type = typeof nested?.type === "string"
? nested.type
: typeof frame.method === "string"
? frame.method
: frame.type === "command"
? ""
: frame.type;
const commandId = typeof frame.commandId === "string"
? frame.commandId
: typeof nested?.commandId === "string"
? nested.commandId
: typeof frame.id === "string"
? frame.id
: undefined;
if (!type) throw new Error("relay command has no type");
if (isRecord(nested?.payload)) return { type, commandId, payload: asJsonObject(nested.payload) };
if (isRecord(frame.payload)) return { type, commandId, payload: asJsonObject(frame.payload) };
if (isRecord(frame.params)) return { type, commandId, payload: asJsonObject(frame.params) };
const payload: JsonObject = {};
for (const [key, value] of Object.entries(frame)) {
if (["v", "kind", "type", "method", "params", "commandId", "id", "sessionId", "actor", "command"].includes(key)) continue;
if (value !== undefined) payload[key] = asJsonValue(value);
}
return { type, commandId, payload };
}
function canonicalCommandType(type: string): string {
const normalized = type.trim().replace(/\//g, ".").replace(/\s+/g, ".").toLowerCase();
if (normalized === "control.mode.get" || normalized === "controlmode.get" || normalized === "controlmodeget" || normalized === "mode.get" || normalized === "modeget") return "control.mode.get";
if (normalized === "control.mode.set" || normalized === "controlmode.set" || normalized === "controlmodeset" || normalized === "mode.set" || normalized === "modeset") return "control.mode.set";
if (normalized === "thread.start" || normalized === "threadstart") return "thread.start";
if (normalized === "session.new" || normalized === "sessionnew" || normalized === "thread.new" || normalized === "threadnew") return "session.new";
if (normalized === "thread.settings.update" || normalized === "threadsettings.update" || normalized === "threadsettingsupdate") return "thread.settings.update";
if (normalized === "session.list" || normalized === "thread.list" || normalized === "sessionlist" || normalized === "threadlist") return "session.list";
if (normalized === "session.select" || normalized === "session.switch" || normalized === "thread.select" || normalized === "thread.attach" || normalized === "sessionswitch" || normalized === "threadselect") return "session.select";
if (normalized === "turn.start" || normalized === "turnstart") return "turn.start";
if (normalized === "turn.steer" || normalized === "turnsteer") return "turn.steer";
if (normalized === "turn.interrupt" || normalized === "turninterrupt") return "turn.interrupt";
if (normalized === "approval.respond" || normalized === "approvalrespond") return "approval.respond";
if (normalized === "task.input" || normalized === "taskinput") return "task.input";
if (normalized === "task.cancel" || normalized === "taskcancel") return "task.cancel";
if (normalized === "input.respond" || normalized === "inputrespond") return "input.respond";
if (normalized === "server.request.respond" || normalized === "serverrequest.respond") return "server.request.respond";
if (normalized === "session.snapshot" || normalized === "snapshot") return "session.snapshot";
return normalized;
}
function authorize(type: string, role: string, capabilities: Set<string>): string | undefined {
const command = canonicalCommandType(type);
const readOnly = command === "session.snapshot" || command === "snapshot" || command === "session.list" || command === "control.mode.get" || command === "ping";
if (readOnly) return undefined;
if (role === "viewer") return "viewer role cannot issue control commands";
if (role !== "owner" && role !== "operator" && role !== "approver" && role !== "host") return `role ${role} is not authorized`;
if ((command === "approval.respond" || command === "input.respond" || command === "server.request.respond") && role !== "owner" && role !== "operator" && role !== "approver" && role !== "host") {
return "role is not authorized to resolve approvals";
}
const required = command === "approval.respond" ? "approve_low_risk" : command === "task.cancel" || command === "turn.interrupt" ? "cancel_task" : command === "task.input" || command.startsWith("turn.") || command === "thread.start" || command === "thread.settings.update" || command === "session.select" || command === "session.new" || command === "control.mode.set" ? "send_task_input" : undefined;
if (required && !capabilities.has(required) && !capabilities.has("*") && role !== "owner" && role !== "host") return `missing capability: ${required}`;
return undefined;
}
function isCommandLike(frame: Record<string, unknown>): boolean {
if (frame.kind === "command") return true;
if (frame.type === "command" && typeof frame.method === "string") return true;
if (frame.kind !== undefined) return false;
if (typeof frame.method === "string") return true;
if (typeof frame.commandId !== "string") return false;
return KNOWN_COMMAND_TYPES.has(String(frame.type).trim().replace(/\//g, ".").toLowerCase());
}
const KNOWN_COMMAND_TYPES = new Set([
"control.mode.get",
"control.mode.set",
"thread.start",
"session.new",
"thread.settings.update",
"session.list",
"session.select",
"turn.start",
"turn.steer",
"turn.interrupt",
"approval.respond",
"task.input",
"task.cancel",
"input.respond",
"server.request.respond",
"session.snapshot",
"snapshot",
"ping",
]);
function isAgentAdapter(value: unknown): value is AgentAdapter {
return isRecord(value) && typeof value.start === "function" && typeof value.onEvent === "function" && typeof value.sendInput === "function" && typeof value.cancel === "function" && typeof value.respondApproval === "function" && typeof value.snapshot === "function";
}
function extractCommandText(payload: JsonObject): string {
if (typeof payload.text === "string") return payload.text;
if (typeof payload.message === "string") return payload.message;
if (typeof payload.prompt === "string") return payload.prompt;
if (Array.isArray(payload.input)) {
const first = payload.input[0];
if (isRecord(first) && typeof first.text === "string") return first.text;
}
throw new Error("turn command requires text or input");
}
function validateApprovalResponse(
decision: "allow" | "deny" | "cancel",
response: JsonValue | undefined,
method?: string,
): void {
if (response === undefined) return;
if (!isRecord(response)) throw new Error("approval response must be a JSON object");
const hasDecision = Object.prototype.hasOwnProperty.call(response, "decision");
const hasAction = Object.prototype.hasOwnProperty.call(response, "action");
if (hasDecision || hasAction) {
const decisionKind = hasDecision ? approvalDecisionKindForMethod(response.decision, method) : undefined;
const actionKind = hasAction ? approvalDecisionKindForMethod(response.action, method) : undefined;
if (hasDecision && !decisionKind) throw new Error("unsupported approval response decision");
if (hasAction && !actionKind) throw new Error("unsupported approval response action");
if (decisionKind && actionKind && decisionKind !== actionKind) {
throw new Error("approval response decision and action conflict");
}
const implied = decisionKind ?? actionKind;
if (implied && implied !== decision) {
throw new Error(`approval response implies ${implied}, but decision is ${decision}`);
}
return;
}
// Permissions approvals intentionally carry a profile rather than a
// decision field. Keep the profile shape narrow; malformed/unknown objects
// must not be interpreted as an approval.
if (method === "item/permissions/requestApproval"
&& isRecord(response.permissions)
&& (response.scope === "turn" || response.scope === "session")
&& (response.strictAutoReview === undefined || typeof response.strictAutoReview === "boolean")
&& Object.keys(response).every((key) => key === "permissions" || key === "scope" || key === "strictAutoReview")) {
return;
}
throw new Error("approval response has no recognized decision or permission profile");
}
/**
* Convert a relay's compact outer decision into a wire response only when it
* carries a non-canonical app-server value. Canonical `allow`/`deny`/`cancel`
* remain undefined so the adapter can choose the method-specific default.
*/
function implicitApprovalResponse(
requestedValue: JsonValue,
decision: "allow" | "deny" | "cancel",
method?: string,
scope?: JsonValue,
): JsonValue | undefined {
// Permission approvals have a profile response, not a decision wrapper.
// Let the adapter construct the requested turn-scoped profile by default;
// callers that need session scope must provide the full profile explicitly.
if (method === "item/permissions/requestApproval") return undefined;
if (requestedValue === "allow" || requestedValue === "deny" || requestedValue === "cancel") {
if (requestedValue === "allow" && scope === "session") {
if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: "approved_for_session" };
if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") return { decision: "acceptForSession" };
}
return undefined;
}
// The generic classifier has already rejected unknown/conflicting values.
// Preserve recognized legacy/v2 tags exactly under the app-server wrapper.
if (decision === "allow" || decision === "deny" || decision === "cancel") {
return { decision: requestedValue };
}
return undefined;
}
function explicitResponseDecision(response: Record<string, unknown>): "allow" | "deny" | "cancel" | undefined {
if (!hasApprovalDecisionField(response)) return undefined;
const hasDecision = Object.prototype.hasOwnProperty.call(response, "decision");
const hasAction = Object.prototype.hasOwnProperty.call(response, "action");
const decision = hasDecision ? approvalDecisionKind(response.decision) : undefined;
const action = hasAction ? approvalDecisionKind(response.action) : undefined;
if (hasDecision && !decision) throw new Error("unsupported input response decision");
if (hasAction && !action) throw new Error("unsupported input response action");
if (decision && action && decision !== action) throw new Error("input response decision and action conflict");
return decision ?? action;
}
function transportHandlesHandshake(transport: RelayTransport): boolean {
return Boolean((transport as RelayTransport & { handlesHandshake?: boolean }).handlesHandshake);
}