mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 11:49:50 +08:00
feat(vscodex): add remote Codex collaboration module
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user