mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(anthropic): show Claude questions as interactive prompts (#128729)
Route Claude Agent SDK AskUserQuestion through OpenClaw's shared structured-input flow across the Control UI and existing channel fallback surfaces. Fixes #81099.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SDKUserMessage as ClaudeAgentSdkUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { CliBackendExecuteContext } from "openclaw/plugin-sdk/cli-backend";
|
||||
|
||||
export function splitClaudeToolNames(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function createClaudeAgentSdkUserMessage(
|
||||
context: CliBackendExecuteContext,
|
||||
): ClaudeAgentSdkUserMessage {
|
||||
return {
|
||||
type: "user",
|
||||
message: { role: "user", content: context.prompt },
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
...(context.sessionId ? { session_id: context.sessionId } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { CliBackendExecuteContext } from "openclaw/plugin-sdk/cli-backend";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createClaudeAgentSdkUserInputAuthorizer } from "./agent-sdk-user-input.js";
|
||||
|
||||
function createContext(
|
||||
requestUserInput: CliBackendExecuteContext["requestUserInput"],
|
||||
): CliBackendExecuteContext {
|
||||
return {
|
||||
command: "/usr/local/bin/claude",
|
||||
args: [],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
prompt: "test",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
systemPrompt: "test",
|
||||
useResume: false,
|
||||
timeoutMs: 30_000,
|
||||
requestToolPermission: vi.fn(async () => ({
|
||||
behavior: "allow" as const,
|
||||
updatedInput: {},
|
||||
})),
|
||||
requestUserInput,
|
||||
};
|
||||
}
|
||||
|
||||
const input = {
|
||||
questions: [
|
||||
{
|
||||
header: "Test stack",
|
||||
question: "Which test runner should we use?",
|
||||
options: [
|
||||
{ label: "Vitest", description: "Use the existing test stack." },
|
||||
{ label: "Node test", description: "Use the built-in runner." },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
header: "Proof",
|
||||
question: "Which proof should we collect?",
|
||||
options: [
|
||||
{ label: "Unit tests", description: "Exercise the adapter." },
|
||||
{ label: "UI proof", description: "Capture the Control UI." },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("Claude Agent SDK user input adapter", () => {
|
||||
it("maps Claude questions and answers while deduplicating the SDK callbacks", async () => {
|
||||
const requestUserInput = vi.fn(async () => ({
|
||||
status: "answered" as const,
|
||||
answers: {
|
||||
question_1: ["Vitest"],
|
||||
question_2: ["Unit tests", "UI proof"],
|
||||
},
|
||||
}));
|
||||
const authorizer = createClaudeAgentSdkUserInputAuthorizer(createContext(requestUserInput));
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const first = authorizer.authorize({ input, signal, toolUseId: "claude-question-1" });
|
||||
const second = authorizer.authorize({ input, signal, toolUseId: "claude-question-1" });
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...input,
|
||||
answers: {
|
||||
"Which test runner should we use?": "Vitest",
|
||||
"Which proof should we collect?": "Unit tests, UI proof",
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(second).resolves.toEqual(await first);
|
||||
expect(requestUserInput).toHaveBeenCalledOnce();
|
||||
expect(requestUserInput).toHaveBeenCalledWith({
|
||||
toolName: "AskUserQuestion",
|
||||
intro: "Claude needs input:",
|
||||
toolCallId: "claude-question-1",
|
||||
abortSignal: signal,
|
||||
questions: [
|
||||
{
|
||||
id: "question_1",
|
||||
header: "Test stack",
|
||||
question: "Which test runner should we use?",
|
||||
multiSelect: false,
|
||||
isOther: true,
|
||||
options: [
|
||||
{ label: "Vitest", description: "Use the existing test stack." },
|
||||
{ label: "Node test", description: "Use the built-in runner." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "question_2",
|
||||
header: "Proof",
|
||||
question: "Which proof should we collect?",
|
||||
multiSelect: true,
|
||||
isOther: true,
|
||||
options: [
|
||||
{ label: "Unit tests", description: "Exercise the adapter." },
|
||||
{ label: "UI proof", description: "Capture the Control UI." },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns actionable denial guidance when the operator skips", async () => {
|
||||
const authorizer = createClaudeAgentSdkUserInputAuthorizer(
|
||||
createContext(
|
||||
vi.fn(async () => ({
|
||||
status: "cancelled" as const,
|
||||
message: "The operator skipped this question.",
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
authorizer.authorize({ input, signal: new AbortController().signal }),
|
||||
).resolves.toEqual({
|
||||
behavior: "deny",
|
||||
message: "The operator skipped this question. Continue with your best judgment.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed questions before invoking the host", async () => {
|
||||
const requestUserInput = vi.fn();
|
||||
const authorizer = createClaudeAgentSdkUserInputAuthorizer(createContext(requestUserInput));
|
||||
|
||||
await expect(
|
||||
authorizer.authorize({
|
||||
input: { questions: [{ header: "Too long for Claude", question: "Missing options" }] },
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
behavior: "deny",
|
||||
message: "OpenClaw rejected malformed Claude user questions.",
|
||||
});
|
||||
expect(requestUserInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PermissionResult as ClaudeAgentSdkPermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
CliBackendExecuteContext,
|
||||
CliBackendUserInputQuestion,
|
||||
} from "openclaw/plugin-sdk/cli-backend";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export function createClaudeAgentSdkUserInputAuthorizer(context: CliBackendExecuteContext) {
|
||||
const requests = new Map<string, Promise<ClaudeAgentSdkPermissionResult>>();
|
||||
return {
|
||||
authorize(params: {
|
||||
input: Record<string, unknown>;
|
||||
signal: AbortSignal;
|
||||
toolUseId?: string;
|
||||
}): Promise<ClaudeAgentSdkPermissionResult> {
|
||||
const existing = params.toolUseId ? requests.get(params.toolUseId) : undefined;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const request = runClaudeUserInput(context, params);
|
||||
if (params.toolUseId) {
|
||||
requests.set(params.toolUseId, request);
|
||||
}
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runClaudeUserInput(
|
||||
context: CliBackendExecuteContext,
|
||||
params: {
|
||||
input: Record<string, unknown>;
|
||||
signal: AbortSignal;
|
||||
toolUseId?: string;
|
||||
},
|
||||
): Promise<ClaudeAgentSdkPermissionResult> {
|
||||
const questions = readClaudeUserInputQuestions(params.input);
|
||||
if (!questions) {
|
||||
return { behavior: "deny", message: "OpenClaw rejected malformed Claude user questions." };
|
||||
}
|
||||
const result = await context.requestUserInput({
|
||||
toolName: "AskUserQuestion",
|
||||
questions,
|
||||
intro: "Claude needs input:",
|
||||
...(params.toolUseId ? { toolCallId: params.toolUseId } : {}),
|
||||
abortSignal: params.signal,
|
||||
});
|
||||
if (result.status !== "answered") {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: `${result.message} Continue with your best judgment.`,
|
||||
};
|
||||
}
|
||||
const answers: Record<string, string> = {};
|
||||
questions.forEach((question) => {
|
||||
answers[question.question] = (result.answers[question.id] ?? []).join(", ");
|
||||
});
|
||||
return { behavior: "allow", updatedInput: { ...params.input, answers } };
|
||||
}
|
||||
|
||||
function readClaudeUserInputQuestions(
|
||||
input: Record<string, unknown>,
|
||||
): CliBackendUserInputQuestion[] | undefined {
|
||||
const rawQuestions = input.questions;
|
||||
if (!Array.isArray(rawQuestions) || rawQuestions.length < 1 || rawQuestions.length > 4) {
|
||||
return undefined;
|
||||
}
|
||||
const questions: CliBackendUserInputQuestion[] = [];
|
||||
for (const [index, rawQuestion] of rawQuestions.entries()) {
|
||||
if (!isRecord(rawQuestion)) {
|
||||
return undefined;
|
||||
}
|
||||
const question = readBoundedText(rawQuestion.question, 4_096);
|
||||
const header = readBoundedText(rawQuestion.header, 12);
|
||||
const rawOptions = rawQuestion.options;
|
||||
if (
|
||||
!question ||
|
||||
!header ||
|
||||
!Array.isArray(rawOptions) ||
|
||||
rawOptions.length < 2 ||
|
||||
rawOptions.length > 4 ||
|
||||
typeof rawQuestion.multiSelect !== "boolean"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const options: Array<{ label: string; description?: string }> = [];
|
||||
for (const rawOption of rawOptions) {
|
||||
if (!isRecord(rawOption)) {
|
||||
return undefined;
|
||||
}
|
||||
const label = readBoundedText(rawOption.label, 256);
|
||||
const description = readBoundedText(rawOption.description, 1_024);
|
||||
if (!label || !description) {
|
||||
return undefined;
|
||||
}
|
||||
options.push({ label, description });
|
||||
}
|
||||
questions.push({
|
||||
id: `question_${index + 1}`,
|
||||
header,
|
||||
question,
|
||||
multiSelect: rawQuestion.multiSelect,
|
||||
isOther: true,
|
||||
options,
|
||||
});
|
||||
}
|
||||
return questions;
|
||||
}
|
||||
|
||||
function readBoundedText(value: unknown, maxLength: number): string | undefined {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import type { PermissionResult as ClaudeAgentSdkPermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { CliBackendExecuteContext } from "openclaw/plugin-sdk/cli-backend";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { executeClaudeAgentSdk } from "./agent-sdk.runtime.js";
|
||||
|
||||
const { queryMock } = vi.hoisted(() => ({ queryMock: vi.fn() }));
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ query: queryMock }));
|
||||
|
||||
const SUCCESS_RESULT = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
result: "ok",
|
||||
session_id: "a174e16f-b6e9-48da-ad5a-c437dfc2f9b4",
|
||||
};
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<CliBackendExecuteContext> = {},
|
||||
): CliBackendExecuteContext {
|
||||
return {
|
||||
command: "/usr/local/bin/claude",
|
||||
args: ["-p"],
|
||||
cwd: "/tmp/openclaw-workspace",
|
||||
env: { PATH: "/usr/local/bin:/usr/bin" },
|
||||
prompt: "Remember the launch code.",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
systemPrompt: "Follow the OpenClaw execution policy.",
|
||||
useResume: false,
|
||||
timeoutMs: 30_000,
|
||||
executionMode: "agent",
|
||||
requestToolPermission: vi.fn(async () => ({
|
||||
behavior: "deny" as const,
|
||||
message: "OpenClaw denied this action.",
|
||||
})),
|
||||
requestUserInput: vi.fn(async () => ({
|
||||
status: "cancelled" as const,
|
||||
message: "OpenClaw cancelled this question.",
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function useSdkMessages(
|
||||
messages: ReadonlyArray<Record<string, unknown>> = [SUCCESS_RESULT],
|
||||
onQuery?: (options: Record<string, unknown>) => Promise<void>,
|
||||
) {
|
||||
queryMock.mockImplementation(({ options }: { options: Record<string, unknown> }) => {
|
||||
const stream = (async function* () {
|
||||
await onQuery?.(options);
|
||||
yield* messages;
|
||||
})();
|
||||
return Object.assign(stream, { close: vi.fn() });
|
||||
});
|
||||
}
|
||||
|
||||
async function collect(context: CliBackendExecuteContext): Promise<void> {
|
||||
for await (const record of executeClaudeAgentSdk(context)) {
|
||||
void record;
|
||||
}
|
||||
}
|
||||
|
||||
function sdkOptions(): Record<string, unknown> {
|
||||
const call = queryMock.mock.calls[0]?.[0] as { options?: Record<string, unknown> } | undefined;
|
||||
expect(call?.options).toBeDefined();
|
||||
return call?.options ?? {};
|
||||
}
|
||||
|
||||
type SdkNativeToolCallback = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
details: { signal: AbortSignal; toolUseID: string; requestId?: string },
|
||||
) => Promise<ClaudeAgentSdkPermissionResult>;
|
||||
|
||||
function sdkNativeTool(options: Record<string, unknown>): SdkNativeToolCallback {
|
||||
return options.canUseTool as SdkNativeToolCallback;
|
||||
}
|
||||
|
||||
type SdkPreToolUseCallback = (
|
||||
input: {
|
||||
hook_event_name: "PreToolUse";
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
tool_use_id: string;
|
||||
},
|
||||
toolUseId: string | undefined,
|
||||
options: { signal: AbortSignal },
|
||||
) => Promise<unknown>;
|
||||
|
||||
function sdkPreToolUse(options: Record<string, unknown>): SdkPreToolUseCallback {
|
||||
const hooks = options.hooks as { PreToolUse?: Array<{ hooks?: SdkPreToolUseCallback[] }> };
|
||||
const callback = hooks.PreToolUse?.[0]?.hooks?.[0];
|
||||
if (!callback) {
|
||||
throw new Error("Claude Agent SDK did not register its native permission hook.");
|
||||
}
|
||||
return callback;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
queryMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Anthropic Agent SDK native permission bridge", () => {
|
||||
it("routes AskUserQuestion through structured input instead of tool approval", async () => {
|
||||
const requestToolPermission = vi.fn();
|
||||
const requestUserInput = vi.fn(async () => ({
|
||||
status: "answered" as const,
|
||||
answers: { question_1: ["Shared flow"] },
|
||||
}));
|
||||
let decision: unknown;
|
||||
useSdkMessages([SUCCESS_RESULT], async (options) => {
|
||||
decision = await sdkNativeTool(options)(
|
||||
"AskUserQuestion",
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
header: "Approach",
|
||||
question: "Which implementation should Claude use?",
|
||||
options: [
|
||||
{ label: "Shared flow", description: "Use OpenClaw's existing question flow." },
|
||||
{ label: "Claude-only", description: "Build a provider-specific path." },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "ask-user-question",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await collect(createContext({ requestToolPermission, requestUserInput }));
|
||||
|
||||
expect(decision).toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
questions: expect.any(Array),
|
||||
answers: { "Which implementation should Claude use?": "Shared flow" },
|
||||
},
|
||||
});
|
||||
expect(requestToolPermission).not.toHaveBeenCalled();
|
||||
expect(requestUserInput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("enforces native tool policy before user settings can shadow the permission callback", async () => {
|
||||
const requestToolPermission = vi.fn(async () => ({
|
||||
behavior: "deny" as const,
|
||||
message: "The session policy denied native execution.",
|
||||
}));
|
||||
let nativeDecision: unknown;
|
||||
let gatewayDecision: unknown;
|
||||
let malformedDecision: unknown;
|
||||
useSdkMessages([SUCCESS_RESULT], async (options) => {
|
||||
const hook = sdkPreToolUse(options);
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
nativeDecision = await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "cat private.txt" },
|
||||
tool_use_id: "native-tool-shadowed",
|
||||
},
|
||||
"native-tool-shadowed",
|
||||
{ signal },
|
||||
);
|
||||
gatewayDecision = await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "mcp__openclaw__message",
|
||||
tool_input: { action: "send" },
|
||||
tool_use_id: "gateway-tool-owned",
|
||||
},
|
||||
"gateway-tool-owned",
|
||||
{ signal },
|
||||
);
|
||||
malformedDecision = await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: "not-an-object",
|
||||
tool_use_id: "malformed-native-tool",
|
||||
},
|
||||
"malformed-native-tool",
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
|
||||
await collect(createContext({ requestToolPermission }));
|
||||
|
||||
expect(nativeDecision).toEqual({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
permissionDecision: "deny",
|
||||
permissionDecisionReason: "The session policy denied native execution.",
|
||||
},
|
||||
});
|
||||
expect(gatewayDecision).toEqual({ continue: true });
|
||||
expect(malformedDecision).toEqual({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
permissionDecision: "deny",
|
||||
permissionDecisionReason: "OpenClaw rejected malformed native tool input.",
|
||||
},
|
||||
});
|
||||
expect(requestToolPermission).toHaveBeenCalledOnce();
|
||||
expect(requestToolPermission).toHaveBeenCalledWith({
|
||||
toolName: "Bash",
|
||||
toolInput: { command: "cat private.txt" },
|
||||
toolCallId: "native-tool-shadowed",
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps bypass-shaped backend arguments behind the host permission callback", async () => {
|
||||
const requestToolPermission = vi.fn(async () => ({
|
||||
behavior: "deny" as const,
|
||||
message: "The session policy denied native execution.",
|
||||
}));
|
||||
let decision: unknown;
|
||||
useSdkMessages([SUCCESS_RESULT], async (options) => {
|
||||
decision = await sdkNativeTool(options)(
|
||||
"Bash",
|
||||
{ command: "cat private.txt" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "native-tool-bypass",
|
||||
requestId: "approval-bypass",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await collect(
|
||||
createContext({
|
||||
args: ["-p", "--permission-mode", "bypassPermissions"],
|
||||
requestToolPermission,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sdkOptions().permissionMode).toBe("default");
|
||||
expect(sdkOptions()).not.toHaveProperty("allowDangerouslySkipPermissions");
|
||||
expect(decision).toEqual({
|
||||
behavior: "deny",
|
||||
message: "The session policy denied native execution.",
|
||||
});
|
||||
expect(requestToolPermission).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "forwards allowed decisions and exact host inputs",
|
||||
resolve: async () => ({
|
||||
behavior: "allow" as const,
|
||||
updatedInput: { command: "echo approved" },
|
||||
}),
|
||||
expected: { behavior: "allow", updatedInput: { command: "echo approved" } },
|
||||
},
|
||||
{
|
||||
name: "preserves a denied host decision",
|
||||
resolve: async () => ({
|
||||
behavior: "deny" as const,
|
||||
message: "OpenClaw exec policy denied this action.",
|
||||
}),
|
||||
expected: { behavior: "deny", message: "OpenClaw exec policy denied this action." },
|
||||
},
|
||||
{
|
||||
name: "fails closed when the host approval owner is unavailable",
|
||||
resolve: async () => {
|
||||
throw new Error("The Gateway approval owner is unavailable.");
|
||||
},
|
||||
expected: { behavior: "deny", message: "OpenClaw could not authorize this tool call." },
|
||||
},
|
||||
])("$name and fences the retained callback after closure", async ({ resolve, expected }) => {
|
||||
const requestToolPermission = vi.fn(resolve);
|
||||
const signal = new AbortController().signal;
|
||||
const input = { command: "echo approved" };
|
||||
let decision: unknown;
|
||||
let callback: SdkNativeToolCallback | undefined;
|
||||
useSdkMessages([SUCCESS_RESULT], async (options) => {
|
||||
callback = sdkNativeTool(options);
|
||||
decision = await callback("Bash", input, {
|
||||
signal,
|
||||
toolUseID: "native-tool-1",
|
||||
requestId: "approval-1",
|
||||
});
|
||||
});
|
||||
|
||||
await collect(createContext({ requestToolPermission }));
|
||||
|
||||
expect(decision).toEqual(expected);
|
||||
expect(requestToolPermission).toHaveBeenCalledWith({
|
||||
toolName: "Bash",
|
||||
toolInput: input,
|
||||
toolCallId: "native-tool-1",
|
||||
abortSignal: signal,
|
||||
});
|
||||
await expect(
|
||||
callback?.(
|
||||
"Bash",
|
||||
{ command: "echo stale" },
|
||||
{
|
||||
signal,
|
||||
toolUseID: "native-tool-stale",
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
behavior: "deny",
|
||||
message: "The OpenClaw run is no longer active.",
|
||||
});
|
||||
expect(requestToolPermission).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,10 @@ function createContext(
|
||||
behavior: "deny" as const,
|
||||
message: "OpenClaw denied this action.",
|
||||
})),
|
||||
requestUserInput: vi.fn(async () => ({
|
||||
status: "cancelled" as const,
|
||||
message: "OpenClaw cancelled this question.",
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -865,174 +869,6 @@ describe("Anthropic Agent SDK runtime ownership", () => {
|
||||
expect(sdkOptions().allowedTools).not.toContain("Edit");
|
||||
});
|
||||
|
||||
it("enforces native tool policy before user settings can shadow the permission callback", async () => {
|
||||
const requestToolPermission = vi.fn(async () => ({
|
||||
behavior: "deny" as const,
|
||||
message: "The session policy denied native execution.",
|
||||
}));
|
||||
let nativeDecision: unknown;
|
||||
let gatewayDecision: unknown;
|
||||
let malformedDecision: unknown;
|
||||
useSdkMessages([SUCCESS_RESULT], async (options) => {
|
||||
const hook = sdkPreToolUse(options);
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
nativeDecision = await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "cat private.txt" },
|
||||
tool_use_id: "native-tool-shadowed",
|
||||
},
|
||||
"native-tool-shadowed",
|
||||
{ signal },
|
||||
);
|
||||
gatewayDecision = await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "mcp__openclaw__message",
|
||||
tool_input: { action: "send" },
|
||||
tool_use_id: "gateway-tool-owned",
|
||||
},
|
||||
"gateway-tool-owned",
|
||||
{ signal },
|
||||
);
|
||||
malformedDecision = await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: "not-an-object",
|
||||
tool_use_id: "malformed-native-tool",
|
||||
},
|
||||
"malformed-native-tool",
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
|
||||
await collect(createContext({ requestToolPermission }));
|
||||
|
||||
expect(nativeDecision).toEqual({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
permissionDecision: "deny",
|
||||
permissionDecisionReason: "The session policy denied native execution.",
|
||||
},
|
||||
});
|
||||
expect(gatewayDecision).toEqual({ continue: true });
|
||||
expect(malformedDecision).toEqual({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
permissionDecision: "deny",
|
||||
permissionDecisionReason: "OpenClaw rejected malformed native tool input.",
|
||||
},
|
||||
});
|
||||
expect(requestToolPermission).toHaveBeenCalledOnce();
|
||||
expect(requestToolPermission).toHaveBeenCalledWith({
|
||||
toolName: "Bash",
|
||||
toolInput: { command: "cat private.txt" },
|
||||
toolCallId: "native-tool-shadowed",
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps bypass-shaped backend arguments behind the host permission callback", async () => {
|
||||
const requestToolPermission = vi.fn(async () => ({
|
||||
behavior: "deny" as const,
|
||||
message: "The session policy denied native execution.",
|
||||
}));
|
||||
let decision: unknown;
|
||||
useSdkMessages([SUCCESS_RESULT], async (options) => {
|
||||
decision = await sdkNativeTool(options)(
|
||||
"Bash",
|
||||
{ command: "cat private.txt" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "native-tool-bypass",
|
||||
requestId: "approval-bypass",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await collect(
|
||||
createContext({
|
||||
args: ["-p", "--permission-mode", "bypassPermissions"],
|
||||
requestToolPermission,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sdkOptions().permissionMode).toBe("default");
|
||||
expect(sdkOptions()).not.toHaveProperty("allowDangerouslySkipPermissions");
|
||||
expect(decision).toEqual({
|
||||
behavior: "deny",
|
||||
message: "The session policy denied native execution.",
|
||||
});
|
||||
expect(requestToolPermission).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "forwards allowed decisions and exact host inputs",
|
||||
resolve: async () => ({
|
||||
behavior: "allow" as const,
|
||||
updatedInput: { command: "echo approved" },
|
||||
}),
|
||||
expected: { behavior: "allow", updatedInput: { command: "echo approved" } },
|
||||
},
|
||||
{
|
||||
name: "preserves a denied host decision",
|
||||
resolve: async () => ({
|
||||
behavior: "deny" as const,
|
||||
message: "OpenClaw exec policy denied this action.",
|
||||
}),
|
||||
expected: { behavior: "deny", message: "OpenClaw exec policy denied this action." },
|
||||
},
|
||||
{
|
||||
name: "fails closed when the host approval owner is unavailable",
|
||||
resolve: async () => {
|
||||
throw new Error("The Gateway approval owner is unavailable.");
|
||||
},
|
||||
expected: { behavior: "deny", message: "OpenClaw could not authorize this tool call." },
|
||||
},
|
||||
])("$name and fences the retained callback after closure", async ({ resolve, expected }) => {
|
||||
const requestToolPermission = vi.fn(resolve);
|
||||
const signal = new AbortController().signal;
|
||||
const input = { command: "echo approved" };
|
||||
let decision: unknown;
|
||||
let callback: SdkNativeToolCallback | undefined;
|
||||
useSdkMessages([SUCCESS_RESULT], async (options) => {
|
||||
callback = sdkNativeTool(options);
|
||||
decision = await callback("Bash", input, {
|
||||
signal,
|
||||
toolUseID: "native-tool-1",
|
||||
requestId: "approval-1",
|
||||
});
|
||||
});
|
||||
|
||||
await collect(createContext({ requestToolPermission }));
|
||||
|
||||
expect(decision).toEqual(expected);
|
||||
expect(requestToolPermission).toHaveBeenCalledWith({
|
||||
toolName: "Bash",
|
||||
toolInput: input,
|
||||
toolCallId: "native-tool-1",
|
||||
abortSignal: signal,
|
||||
});
|
||||
await expect(
|
||||
callback?.(
|
||||
"Bash",
|
||||
{ command: "echo stale" },
|
||||
{
|
||||
signal,
|
||||
toolUseID: "native-tool-stale",
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
behavior: "deny",
|
||||
message: "The OpenClaw run is no longer active.",
|
||||
});
|
||||
expect(requestToolPermission).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([429, 529])(
|
||||
"yields an HTTP %i error-marked success before surfacing the SDK's later exit error",
|
||||
async (apiErrorStatus) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
Options as ClaudeAgentSdkOptions,
|
||||
PermissionResult as ClaudeAgentSdkPermissionResult,
|
||||
Query as ClaudeAgentSdkQuery,
|
||||
SDKUserMessage as ClaudeAgentSdkUserMessage,
|
||||
SpawnOptions as ClaudeAgentSdkSpawnOptions,
|
||||
SpawnedProcess as ClaudeAgentSdkSpawnedProcess,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
@@ -17,6 +16,11 @@ import type {
|
||||
} from "openclaw/plugin-sdk/cli-backend";
|
||||
import { killProcessTree } from "openclaw/plugin-sdk/process-runtime";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
createClaudeAgentSdkUserMessage,
|
||||
splitClaudeToolNames,
|
||||
} from "./agent-sdk-runtime-helpers.js";
|
||||
import { createClaudeAgentSdkUserInputAuthorizer } from "./agent-sdk-user-input.js";
|
||||
|
||||
const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] satisfies NonNullable<
|
||||
ClaudeAgentSdkOptions["effort"]
|
||||
@@ -75,6 +79,7 @@ type ClaudeAgentSdkSecretInput = {
|
||||
type ClaudeAgentSdkTurn = {
|
||||
context: CliBackendExecuteContext;
|
||||
controller: AbortController;
|
||||
userInput: ReturnType<typeof createClaudeAgentSdkUserInputAuthorizer>;
|
||||
};
|
||||
|
||||
type ClaudeAgentSdkLiveTurn = ClaudeAgentSdkTurn & {
|
||||
@@ -99,13 +104,6 @@ type ClaudeAgentSdkSession = {
|
||||
|
||||
const claudeAgentSdkSessions = new WeakMap<CliBackendLiveSessionHandle, ClaudeAgentSdkSession>();
|
||||
|
||||
function splitClaudeToolNames(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function spawnClaudeAgentSdkProcess(
|
||||
options: ClaudeAgentSdkSpawnOptions,
|
||||
secretInput?: ClaudeAgentSdkSecretInput,
|
||||
@@ -178,12 +176,19 @@ async function authorizeClaudeAgentSdkTool(params: {
|
||||
return { behavior: "deny", message: "The OpenClaw run is no longer active." };
|
||||
}
|
||||
try {
|
||||
const decision = await turn.context.requestToolPermission({
|
||||
toolName: params.toolName,
|
||||
toolInput: params.input,
|
||||
...(params.toolUseId ? { toolCallId: params.toolUseId } : {}),
|
||||
abortSignal: params.signal,
|
||||
});
|
||||
const decision =
|
||||
params.toolName === "AskUserQuestion"
|
||||
? await turn.userInput.authorize({
|
||||
input: params.input,
|
||||
signal: params.signal,
|
||||
...(params.toolUseId ? { toolUseId: params.toolUseId } : {}),
|
||||
})
|
||||
: await turn.context.requestToolPermission({
|
||||
toolName: params.toolName,
|
||||
toolInput: params.input,
|
||||
...(params.toolUseId ? { toolCallId: params.toolUseId } : {}),
|
||||
abortSignal: params.signal,
|
||||
});
|
||||
if (params.currentTurn() !== turn || params.signal.aborted || turn.controller.signal.aborted) {
|
||||
return { behavior: "deny", message: "The OpenClaw run is no longer active." };
|
||||
}
|
||||
@@ -445,18 +450,6 @@ function resolveClaudeAgentSdkOptions(
|
||||
return options;
|
||||
}
|
||||
|
||||
function createClaudeAgentSdkUserMessage(
|
||||
context: CliBackendExecuteContext,
|
||||
): ClaudeAgentSdkUserMessage {
|
||||
return {
|
||||
type: "user",
|
||||
message: { role: "user", content: context.prompt },
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
...(context.sessionId ? { session_id: context.sessionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function closeClaudeAgentSdkSession(
|
||||
session: ClaudeAgentSdkSession,
|
||||
_reason: CliBackendLiveSessionCloseReason,
|
||||
@@ -608,6 +601,7 @@ async function* executeClaudeAgentSdkLiveTurn(
|
||||
const turn: ClaudeAgentSdkLiveTurn = {
|
||||
context,
|
||||
controller: new AbortController(),
|
||||
userInput: createClaudeAgentSdkUserInputAuthorizer(context),
|
||||
events: new PassThrough({ objectMode: true }),
|
||||
sawTerminalResult: false,
|
||||
};
|
||||
@@ -659,7 +653,6 @@ async function* executeClaudeAgentSdkLiveTurn(
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute Claude Code through Anthropic's maintained SDK transport and private auth boundary. */
|
||||
export async function* executeClaudeAgentSdk(
|
||||
context: CliBackendExecuteContext,
|
||||
secretInput?: ClaudeAgentSdkSecretInput,
|
||||
@@ -674,6 +667,7 @@ export async function* executeClaudeAgentSdk(
|
||||
let activeTurn: ClaudeAgentSdkTurn | undefined = {
|
||||
context,
|
||||
controller,
|
||||
userInput: createClaudeAgentSdkUserInputAuthorizer(context),
|
||||
};
|
||||
let sawTerminalResult = false;
|
||||
const abort = () => controller.abort();
|
||||
|
||||
@@ -219,10 +219,101 @@ describe("plugin-owned CLI execution host boundary", () => {
|
||||
useResume: false,
|
||||
env: { PATH: "/bin:/usr/bin", OPENCLAW_TEST_MARKER: "host-owned" },
|
||||
requestToolPermission: expect.any(Function),
|
||||
requestUserInput: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs plugin user questions through the shared Gateway question flow", async () => {
|
||||
const { context } = await createExecution({
|
||||
runId: "plugin-user-input",
|
||||
nativeTools: ["AskUserQuestion"],
|
||||
});
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
context.params.onBlockReply = onBlockReply;
|
||||
const requests = new Map<string, { questions: Array<{ questionId: string }> }>();
|
||||
mockCallGatewayTool.mockImplementation(async (method, _opts, rawParams) => {
|
||||
const params = rawParams as { id: string; questions?: Array<{ questionId: string }> };
|
||||
if (method === "question.request") {
|
||||
requests.set(params.id, { questions: params.questions ?? [] });
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
const request = requests.get(params.id);
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 5);
|
||||
});
|
||||
return {
|
||||
status: "answered",
|
||||
answers: {
|
||||
answers: Object.fromEntries(
|
||||
(request?.questions ?? []).map((question) => [
|
||||
question.questionId,
|
||||
[question.questionId],
|
||||
]),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
throw new Error(`Unexpected Gateway method: ${method}`);
|
||||
});
|
||||
let result: unknown;
|
||||
|
||||
await runPlugin(context, async function* (execution) {
|
||||
result = await execution.requestUserInput({
|
||||
toolName: "AskUserQuestion",
|
||||
toolCallId: "claude-question",
|
||||
questions: [
|
||||
{
|
||||
id: "one",
|
||||
header: "One",
|
||||
question: "First question?",
|
||||
isOther: true,
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
{
|
||||
id: "two",
|
||||
header: "Two",
|
||||
question: "Second question?",
|
||||
isOther: true,
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
{
|
||||
id: "three",
|
||||
header: "Three",
|
||||
question: "Third question?",
|
||||
isOther: true,
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
{
|
||||
id: "four",
|
||||
header: "Four",
|
||||
question: "Fourth question?",
|
||||
isOther: true,
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
yield SUCCESS_RESULT;
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "answered",
|
||||
answers: {
|
||||
one: ["one"],
|
||||
two: ["two"],
|
||||
three: ["three"],
|
||||
four: ["four"],
|
||||
},
|
||||
});
|
||||
expect([...requests.keys()]).toEqual(["claude-question:0", "claude-question:1"]);
|
||||
expect([...requests.values()].map((request) => request.questions.length)).toEqual([3, 1]);
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("restarts true fresh sessions while preserving legitimate no-resume warm reuse", async () => {
|
||||
const reseed = await createExecution({ runId: "plugin-fresh-reseed" });
|
||||
reseed.context.openClawHistoryPrompt = "Previously recorded bounded conversation.";
|
||||
|
||||
@@ -8,12 +8,17 @@ import type {
|
||||
CliBackendExecute,
|
||||
CliBackendToolPermissionRequest,
|
||||
CliBackendToolPermissionResult,
|
||||
CliBackendUserInputRequest,
|
||||
CliBackendUserInputResult,
|
||||
} from "../../plugins/cli-backend.types.js";
|
||||
import type { RunExit, TerminationReason } from "../../process/supervisor/types.js";
|
||||
import { resolveAdmittedRunActiveAssertion } from "../admitted-run-context.js";
|
||||
import type { CliTerminalInterruption } from "../cli-output-contracts.js";
|
||||
import { resolveExecDefaults } from "../exec-defaults.js";
|
||||
import { isSignalTimeoutReason, type FailoverError } from "../failover-error.js";
|
||||
import { runStructuredInput } from "../harness/structured-input-execution.js";
|
||||
import { compileStructuredInputQuestions } from "../harness/structured-input.js";
|
||||
import { callGatewayTool } from "../tools/gateway.js";
|
||||
import {
|
||||
closeCliLiveSession,
|
||||
createCliLiveSessionCapability,
|
||||
@@ -121,6 +126,102 @@ function createPluginToolPermissionHandler(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function cancelUserInput(message: string): CliBackendUserInputResult {
|
||||
return { status: "cancelled", message };
|
||||
}
|
||||
|
||||
function createPluginUserInputHandler(params: {
|
||||
context: PreparedCliRunContext;
|
||||
abortSignal: AbortSignal;
|
||||
onPendingInput: (delta: 1 | -1) => void;
|
||||
}): (request: CliBackendUserInputRequest) => Promise<CliBackendUserInputResult> {
|
||||
const run = params.context.params;
|
||||
return async (request) => {
|
||||
const signal = request.abortSignal
|
||||
? AbortSignal.any([params.abortSignal, request.abortSignal])
|
||||
: params.abortSignal;
|
||||
const assertActive = resolveAdmittedRunActiveAssertion(run.admittedRunContext, signal);
|
||||
if (!assertActive) {
|
||||
return cancelUserInput(
|
||||
"OpenClaw cancelled operator input: the admitted run is no longer active.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertActive();
|
||||
} catch {
|
||||
return cancelUserInput(
|
||||
"OpenClaw cancelled operator input: the admitted run is no longer active.",
|
||||
);
|
||||
}
|
||||
|
||||
const toolName = request.toolName.trim();
|
||||
if (
|
||||
!toolName ||
|
||||
(run.cliToolAvailability && !run.cliToolAvailability.native.includes(toolName))
|
||||
) {
|
||||
return cancelUserInput(
|
||||
toolName
|
||||
? `OpenClaw cancelled operator input from ${toolName}: it is unavailable to this run.`
|
||||
: "OpenClaw cancelled an unnamed operator input request.",
|
||||
);
|
||||
}
|
||||
if (request.questions.length === 0 || request.questions.length > 12) {
|
||||
return cancelUserInput("OpenClaw cancelled an invalid operator input request.");
|
||||
}
|
||||
|
||||
params.onPendingInput(1);
|
||||
try {
|
||||
const result = await runStructuredInput({
|
||||
input: compileStructuredInputQuestions({
|
||||
questions: request.questions.map((question) => ({
|
||||
...question,
|
||||
isSecret: false,
|
||||
})),
|
||||
intro: request.intro?.trim() || "Agent needs input:",
|
||||
}),
|
||||
sessionKey: run.sessionKey ?? run.sessionId,
|
||||
agentId: run.agentId,
|
||||
runId: run.runId,
|
||||
timeoutMs: run.timeoutMs,
|
||||
gatewayCall: callGatewayTool,
|
||||
delivery: {
|
||||
onBlockReply: run.onBlockReply,
|
||||
onPartialReply: run.onPartialReply,
|
||||
},
|
||||
signal,
|
||||
isActive: () => {
|
||||
try {
|
||||
assertActive();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
questionId: request.toolCallId ? (batch) => `${request.toolCallId}:${batch}` : undefined,
|
||||
});
|
||||
try {
|
||||
assertActive();
|
||||
} catch {
|
||||
return cancelUserInput(
|
||||
"OpenClaw cancelled operator input: the admitted run closed before the answer was committed.",
|
||||
);
|
||||
}
|
||||
return result.status === "answered"
|
||||
? { status: "answered", answers: result.answers }
|
||||
: cancelUserInput(
|
||||
result.message ??
|
||||
"OpenClaw cancelled operator input; continue with your best judgment.",
|
||||
);
|
||||
} catch {
|
||||
return cancelUserInput(
|
||||
"OpenClaw could not collect operator input; continue with your best judgment.",
|
||||
);
|
||||
} finally {
|
||||
params.onPendingInput(-1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function waitForIteratorValue<T>(
|
||||
iterator: AsyncIterator<T>,
|
||||
signal: AbortSignal,
|
||||
@@ -329,6 +430,13 @@ export async function executePluginOwnedProcess(params: {
|
||||
outstanding.approvals = Math.max(0, outstanding.approvals + delta);
|
||||
},
|
||||
}),
|
||||
requestUserInput: createPluginUserInputHandler({
|
||||
context: params.context,
|
||||
abortSignal: signal,
|
||||
onPendingInput: (delta) => {
|
||||
outstanding.approvals = Math.max(0, outstanding.approvals + delta);
|
||||
},
|
||||
}),
|
||||
});
|
||||
iterator = execution[Symbol.asyncIterator]();
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
* Shared types for preparing and executing CLI-backed agent runs.
|
||||
*/
|
||||
import type {
|
||||
BlockReplyContext,
|
||||
PartialReplyPayload,
|
||||
SourceReplyDeliveryMode,
|
||||
TaskSuggestionDeliveryMode,
|
||||
} from "../../auto-reply/get-reply-options.types.js";
|
||||
@@ -47,6 +49,7 @@ import type { ResolvedCliBackend } from "../cli-backends.js";
|
||||
import type { CliSessionReuseResult } from "../cli-session.js";
|
||||
import type { ContextWindowInfo } from "../context-window-guard.js";
|
||||
import type { FailoverReason } from "../embedded-agent-helpers.js";
|
||||
import type { BlockReplyPayload } from "../embedded-agent-payloads.js";
|
||||
import type { EmbeddedAgentExecutionPhase } from "../embedded-agent-runner/execution-phase.js";
|
||||
import type {
|
||||
CurrentInboundPromptContext,
|
||||
@@ -257,6 +260,8 @@ export type RunCliAgentParams = {
|
||||
};
|
||||
disableTools?: boolean;
|
||||
abortSignal?: AbortSignal;
|
||||
onPartialReply?: (payload: PartialReplyPayload) => boolean | void | Promise<boolean | void>;
|
||||
onBlockReply?: (payload: BlockReplyPayload, context?: BlockReplyContext) => void | Promise<void>;
|
||||
onExecutionStarted?: () => void;
|
||||
onExecutionPhase?: (info: {
|
||||
phase: EmbeddedAgentExecutionPhase;
|
||||
|
||||
@@ -243,6 +243,8 @@ async function runEmbeddedAgentViaCliBackend(
|
||||
bootstrapContextMode: params.bootstrapContextMode,
|
||||
bootstrapContextRunKind: params.bootstrapContextRunKind,
|
||||
abortSignal: params.abortSignal,
|
||||
onBlockReply: params.onBlockReply,
|
||||
onPartialReply: params.onPartialReply,
|
||||
onExecutionPhase: params.onExecutionPhase,
|
||||
cliToolAvailability,
|
||||
// One-shot helper run: fresh CLI process, no warm live session left
|
||||
|
||||
@@ -27,6 +27,10 @@ export type {
|
||||
CliBackendToolPermissionRequest,
|
||||
CliBackendToolPermissionResult,
|
||||
CliBackendThinkingLevel,
|
||||
CliBackendUserInputOption,
|
||||
CliBackendUserInputQuestion,
|
||||
CliBackendUserInputRequest,
|
||||
CliBackendUserInputResult,
|
||||
} from "../plugins/cli-backend.types.js";
|
||||
export type { CliBackendRuntimeArtifactPolicy } from "../plugins/cli-backend.types.js";
|
||||
export { CliBackendAuthProfilePreparationError } from "../plugins/cli-backend-errors.js";
|
||||
|
||||
@@ -172,6 +172,33 @@ export type CliBackendToolPermissionResult =
|
||||
| { behavior: "allow"; updatedInput: Record<string, unknown> }
|
||||
| { behavior: "deny"; message: string };
|
||||
|
||||
export type CliBackendUserInputOption = {
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type CliBackendUserInputQuestion = {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
multiSelect?: boolean;
|
||||
isOther?: boolean;
|
||||
options?: readonly CliBackendUserInputOption[] | null;
|
||||
};
|
||||
|
||||
/** Structured operator input requested by a plugin-owned native runtime. */
|
||||
export type CliBackendUserInputRequest = {
|
||||
toolName: string;
|
||||
questions: readonly CliBackendUserInputQuestion[];
|
||||
intro?: string;
|
||||
toolCallId?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type CliBackendUserInputResult =
|
||||
| { status: "answered"; answers: Record<string, string[]> }
|
||||
| { status: "cancelled"; message: string };
|
||||
|
||||
/** Lifecycle reasons accepted by a plugin-owned reusable execution process. */
|
||||
export type CliBackendLiveSessionCloseReason =
|
||||
| "idle"
|
||||
@@ -219,6 +246,8 @@ export type CliBackendExecuteContext = {
|
||||
requestToolPermission: (
|
||||
request: CliBackendToolPermissionRequest,
|
||||
) => Promise<CliBackendToolPermissionResult>;
|
||||
/** Closure-bound structured-input capability; retained copies fail after the run closes. */
|
||||
requestUserInput: (request: CliBackendUserInputRequest) => Promise<CliBackendUserInputResult>;
|
||||
};
|
||||
|
||||
/** Plugin-owned runtime yielding the backend's existing structured stream records. */
|
||||
|
||||
Reference in New Issue
Block a user