Files
openclaw/extensions/copilot/src/user-input-bridge.ts
T
Josh Avant 73a9eed95b refactor(audit): add canonical admitted-run context (#120534)
* feat(audit): carry canonical admitted execution context

* fix(agents): preserve admitted context across retries

* fix(worker): fence legacy launch dialect

* test(gateway): track approval temp dirs

* fix(plugin-sdk): preserve harness attempt compatibility

* fix: close delegated run authority at owner boundaries

* fix: internalize delegated authority validators

* refactor: split delegated authority proof surfaces

* refactor: centralize command admission identity

* test: claim runtime tool authority

* fix(gateway): keep lifecycle cleanup within static budgets

* fix(agents): revalidate harness policy authority

* fix(agents): fence awaited approval capability results

* test(copilot): supply required harness capability fixtures

* fix(agent): preserve scoped embedded run admission

* fix(agent): preserve keyless and worker authority

* test(agent): bind incomplete-turn authority

* docs: preserve execution authority invariants

* chore(plugin-sdk): regenerate API baseline

* fix(gateway): notify pending claim closure

* fix(gateway): revalidate delegated tool authority

* fix(plugin-sdk): keep source guard internal

* fix: close delegated authority races

* fix: revalidate delegated side effects

* fix: close harness authority projection gaps

* fix: align authority integration types

* fix: isolate settled harness finalization

* fix: fence recovery identity finalization

* fix: preserve committed session worktrees

* fix: preserve worker placement agent identity

* fix: fence active harness tool work

* fix(plugins): restore embedded run admission owner

* chore(plugin-sdk): compose integrated surface budgets

* fix(copilot): keep finalization attempt type internal

* fix(plugins): complete admission owner type imports

* test(harness): use settled finalization attempt shape

* fix(security): retain exact side-run and approval authority

* fix(security): preserve protected authority through terminal sweep

* fix(agents): follow moved recovery store owner

* fix(ci): align integrated authority owners with gates

* fix(plugins): distinguish embedded agent adapter export

* chore(plugin-sdk): regenerate API baseline after rolling integration

* refactor(gateway): keep session authority within owner budgets

* fix(gateway): keep session helpers private

* docs(plugin-sdk): name the V2 parameter subpath

* chore(integration): reconcile worker and SDK surfaces

* docs(plugin-sdk): require the V2 host API floor

* chore(plugin-sdk): regenerate after proxy-auth integration
2026-08-10 23:15:20 -05:00

152 lines
5.0 KiB
TypeScript

import type { SessionConfig } from "@github/copilot-sdk";
import {
callGatewayTool,
embeddedAgentLog,
runAgentHarnessGatewayQuestion,
type AgentHarnessQuestionGatewayCall,
type AgentHarnessUserInputQuestion,
type EmbeddedRunAttemptParamsV2,
} from "openclaw/plugin-sdk/agent-harness-runtime";
type CopilotUserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
type CopilotUserInputRequest = Parameters<CopilotUserInputHandler>[0];
type CopilotUserInputResponse = Awaited<ReturnType<CopilotUserInputHandler>>;
export type CopilotUserInputBridge = {
onUserInputRequest: CopilotUserInputHandler;
cancelPending: () => void;
};
const COPILOT_USER_INPUT_QUESTION_ID = "answer";
const DEFAULT_USER_INPUT_TIMEOUT_MS = 15 * 60_000;
export function createCopilotUserInputBridge(params: {
paramsForRun: EmbeddedRunAttemptParamsV2;
signal?: AbortSignal;
gatewayCall?: AgentHarnessQuestionGatewayCall;
}): CopilotUserInputBridge {
let pending: AbortController | undefined;
const gatewayCall = params.gatewayCall ?? callGatewayTool;
return {
async onUserInputRequest(request) {
pending?.abort(new Error("Copilot user input request replaced"));
const abort = new AbortController();
pending = abort;
const abortFromRun = () => abort.abort(params.signal?.reason);
params.signal?.addEventListener("abort", abortFromRun, { once: true });
if (params.signal?.aborted) {
abortFromRun();
}
try {
const question = toQuestion(request);
const result = await runAgentHarnessGatewayQuestion({
questions: [question],
sessionKey: params.paramsForRun.sessionKey ?? params.paramsForRun.sessionId,
agentId: params.paramsForRun.agentId,
runId: params.paramsForRun.runId,
timeoutMs: params.paramsForRun.timeoutMs ?? DEFAULT_USER_INPUT_TIMEOUT_MS,
gatewayCall,
delivery: params.paramsForRun,
promptOptions: {
intro: "Copilot needs input:",
formatText: formatCopilotDisplayText,
},
signal: abort.signal,
});
if (result.status !== "answered") {
return emptyCopilotUserInputResponse();
}
const selected = result.answers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.[0] ?? "";
return {
answer: selected,
wasFreeform: !isChoiceAnswer(question, selected),
};
} catch (error) {
embeddedAgentLog.warn("failed to bridge copilot user input through gateway", { error });
return emptyCopilotUserInputResponse();
} finally {
params.signal?.removeEventListener("abort", abortFromRun);
if (pending === abort) {
pending = undefined;
}
}
},
cancelPending() {
pending?.abort(new Error("Copilot user input request cancelled"));
},
};
}
function toQuestion(request: CopilotUserInputRequest): AgentHarnessUserInputQuestion {
return {
id: COPILOT_USER_INPUT_QUESTION_ID,
header: "Copilot",
question: request.question,
isOther: request.allowFreeform !== false,
isSecret: false,
options:
request.choices && request.choices.length > 0
? request.choices.map((choice: string) => ({ label: choice }))
: null,
};
}
function emptyCopilotUserInputResponse(): CopilotUserInputResponse {
return { answer: "", wasFreeform: true };
}
function isChoiceAnswer(question: AgentHarnessUserInputQuestion, answer: string): boolean {
return Boolean(
answer &&
question.options?.some((option) => option.label.toLowerCase() === answer.toLowerCase()),
);
}
function formatCopilotDisplayText(value: string): string {
const safe = sanitizeCopilotDisplayText(value).trim();
return escapeCopilotChatText(safe || "<unknown>");
}
function sanitizeCopilotDisplayText(value: string): string {
let safe = "";
for (const character of value) {
const codePoint = character.codePointAt(0);
safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character;
}
return safe;
}
function escapeCopilotChatText(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("@", "\uff20")
.replaceAll("`", "\uff40")
.replaceAll("[", "\uff3b")
.replaceAll("]", "\uff3d")
.replaceAll("(", "\uff08")
.replaceAll(")", "\uff09")
.replaceAll("*", "\u2217")
.replaceAll("_", "\uff3f")
.replaceAll("~", "\uff5e")
.replaceAll("|", "\uff5c");
}
function isUnsafeDisplayCodePoint(codePoint: number): boolean {
return (
codePoint <= 0x001f ||
(codePoint >= 0x007f && codePoint <= 0x009f) ||
codePoint === 0x00ad ||
codePoint === 0x061c ||
codePoint === 0x180e ||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2060 && codePoint <= 0x206f) ||
codePoint === 0xfeff ||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
(codePoint >= 0xe0000 && codePoint <= 0xe007f)
);
}