@zhijiewang/openharness 2.20.0 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * MCP `elicitation/create` responder (audit B4).
3
+ *
4
+ * MCP servers can ask the client to elicit user input — for confirmations
5
+ * ("are you sure?"), for form fills, or for free-form text. The spec defines
6
+ * three response actions:
7
+ * - `accept` → user agreed; `content` may contain form values
8
+ * - `decline` → user explicitly said no
9
+ * - `cancel` → user dismissed without choosing (e.g. closed the prompt)
10
+ *
11
+ * Default behavior is **fail-safe decline** — when nothing decides, OH
12
+ * returns `{ action: "decline" }`. This keeps OH from accepting actions
13
+ * silently in headless / unattended mode. To accept, configure an
14
+ * `elicitation` hook that returns `permissionDecision: "allow"`, or wire an
15
+ * interactive handler via `setElicitationHandler` (the REPL will plug in
16
+ * when its UX support lands; until then the hook path is the supported
17
+ * extension point).
18
+ *
19
+ * Two hook events fire per elicitation:
20
+ * - `elicitation` — request received, before any decision
21
+ * - `elicitationResult` — final action + content, after decision is made
22
+ *
23
+ * Both carry the server name and message so audit hooks can log the
24
+ * full request/response pair.
25
+ */
26
+ export type ElicitationAction = "accept" | "decline" | "cancel";
27
+ export interface ElicitationRequest {
28
+ /** Server name — for hook context. Not part of the MCP wire format. */
29
+ serverName: string;
30
+ /** Human-readable message the server wants to show the user. */
31
+ message: string;
32
+ /** JSON Schema describing the structured content the server expects on accept. */
33
+ requestedSchema: unknown;
34
+ }
35
+ export interface ElicitationResponse {
36
+ action: ElicitationAction;
37
+ content?: Record<string, unknown>;
38
+ }
39
+ /**
40
+ * Optional interactive handler — called when no hook decided. The REPL is
41
+ * the natural caller; until that lands, leaving this unset means OH falls
42
+ * straight from the hook to the auto-decline default.
43
+ */
44
+ export type InteractiveElicitationHandler = (req: ElicitationRequest) => Promise<ElicitationResponse>;
45
+ /**
46
+ * Register / replace the interactive elicitation handler. Pass `undefined`
47
+ * to clear (for tests / REPL teardown). Idempotent.
48
+ */
49
+ export declare function setElicitationHandler(handler: InteractiveElicitationHandler | undefined): void;
50
+ /**
51
+ * Resolve an MCP `elicitation/create` request into an `ElicitationResponse`.
52
+ *
53
+ * Decision priority:
54
+ * 1. `elicitation` hook returns a decision → honor it (allow → accept, deny → decline)
55
+ * 2. Interactive handler is registered → delegate to it
56
+ * 3. Default → `{ action: "decline" }`
57
+ *
58
+ * Always fires the symmetric `elicitationResult` hook last, so audit hooks
59
+ * see the full request/response pair regardless of which branch decided.
60
+ *
61
+ * @internal Exported for tests; transport.ts is the production caller.
62
+ */
63
+ export declare function resolveElicitation(req: ElicitationRequest): Promise<ElicitationResponse>;
64
+ /** @internal Test-only reset. */
65
+ export declare function _resetElicitationForTest(): void;
66
+ //# sourceMappingURL=elicitation.d.ts.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * MCP `elicitation/create` responder (audit B4).
3
+ *
4
+ * MCP servers can ask the client to elicit user input — for confirmations
5
+ * ("are you sure?"), for form fills, or for free-form text. The spec defines
6
+ * three response actions:
7
+ * - `accept` → user agreed; `content` may contain form values
8
+ * - `decline` → user explicitly said no
9
+ * - `cancel` → user dismissed without choosing (e.g. closed the prompt)
10
+ *
11
+ * Default behavior is **fail-safe decline** — when nothing decides, OH
12
+ * returns `{ action: "decline" }`. This keeps OH from accepting actions
13
+ * silently in headless / unattended mode. To accept, configure an
14
+ * `elicitation` hook that returns `permissionDecision: "allow"`, or wire an
15
+ * interactive handler via `setElicitationHandler` (the REPL will plug in
16
+ * when its UX support lands; until then the hook path is the supported
17
+ * extension point).
18
+ *
19
+ * Two hook events fire per elicitation:
20
+ * - `elicitation` — request received, before any decision
21
+ * - `elicitationResult` — final action + content, after decision is made
22
+ *
23
+ * Both carry the server name and message so audit hooks can log the
24
+ * full request/response pair.
25
+ */
26
+ import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
27
+ let interactiveHandler;
28
+ /**
29
+ * Register / replace the interactive elicitation handler. Pass `undefined`
30
+ * to clear (for tests / REPL teardown). Idempotent.
31
+ */
32
+ export function setElicitationHandler(handler) {
33
+ interactiveHandler = handler;
34
+ }
35
+ /**
36
+ * Resolve an MCP `elicitation/create` request into an `ElicitationResponse`.
37
+ *
38
+ * Decision priority:
39
+ * 1. `elicitation` hook returns a decision → honor it (allow → accept, deny → decline)
40
+ * 2. Interactive handler is registered → delegate to it
41
+ * 3. Default → `{ action: "decline" }`
42
+ *
43
+ * Always fires the symmetric `elicitationResult` hook last, so audit hooks
44
+ * see the full request/response pair regardless of which branch decided.
45
+ *
46
+ * @internal Exported for tests; transport.ts is the production caller.
47
+ */
48
+ export async function resolveElicitation(req) {
49
+ const hookCtx = {
50
+ elicitationServer: req.serverName,
51
+ elicitationMessage: req.message.slice(0, 500),
52
+ // Schema can be large; cap at 2 KB so hooks don't OOM env vars.
53
+ elicitationSchema: JSON.stringify(req.requestedSchema).slice(0, 2_000),
54
+ };
55
+ let response;
56
+ const hookOutcome = await emitHookWithOutcome("elicitation", hookCtx);
57
+ if (hookOutcome.permissionDecision === "allow") {
58
+ response = { action: "accept", content: {} };
59
+ }
60
+ else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
61
+ response = { action: "decline" };
62
+ }
63
+ else if (interactiveHandler) {
64
+ try {
65
+ response = await interactiveHandler(req);
66
+ }
67
+ catch {
68
+ // Interactive handler crashed — fail-safe decline rather than swallow.
69
+ response = { action: "cancel" };
70
+ }
71
+ }
72
+ else {
73
+ // Headless default — never accept silently.
74
+ response = { action: "decline" };
75
+ }
76
+ emitHook("elicitationResult", {
77
+ elicitationServer: req.serverName,
78
+ elicitationMessage: req.message.slice(0, 500),
79
+ elicitationAction: response.action,
80
+ elicitationContent: response.content ? JSON.stringify(response.content).slice(0, 2_000) : undefined,
81
+ });
82
+ return response;
83
+ }
84
+ /** @internal Test-only reset. */
85
+ export function _resetElicitationForTest() {
86
+ interactiveHandler = undefined;
87
+ }
88
+ //# sourceMappingURL=elicitation.js.map
@@ -1,6 +1,33 @@
1
+ import type { McpServerConfig } from "../harness/config.js";
1
2
  import type { Tool } from "../Tool.js";
2
- /** Load MCP tools from .oh/config.yaml mcpServers list. Returns empty array if none configured. */
3
- export declare function loadMcpTools(): Promise<Tool[]>;
3
+ /**
4
+ * Parse a `--mcp-config <path>` file. Format:
5
+ * - `{ "mcpServers": [...] }` — Claude Code convention (preferred)
6
+ * - `[ ... ]` — bare array of server configs (also accepted)
7
+ * - `{ "name": ..., ... }` — single-server object (also accepted)
8
+ *
9
+ * Validation is shape-only: each entry must be an object with a `name`.
10
+ * Connection-time validation happens in `McpClient.connect`. Throws on
11
+ * malformed JSON or unrecognised top-level shape.
12
+ */
13
+ export declare function parseMcpConfigFile(path: string): McpServerConfig[];
14
+ export interface LoadMcpOptions {
15
+ /**
16
+ * MCP servers loaded from sources outside `.oh/config.yaml` — typically
17
+ * a `--mcp-config <path>` file. Merged with the config-file servers
18
+ * unless `strict` is set, in which case these REPLACE the config-file
19
+ * servers entirely.
20
+ */
21
+ extraServers?: import("../harness/config.js").McpServerConfig[];
22
+ /**
23
+ * When `true`, ignore `cfg.mcpServers` and use only `extraServers`.
24
+ * No-op when `extraServers` is undefined (the config-file servers
25
+ * still load). Mirrors Claude Code's `--strict-mcp-config`.
26
+ */
27
+ strict?: boolean;
28
+ }
29
+ /** Load MCP tools from .oh/config.yaml mcpServers list (and/or `--mcp-config` overrides). Returns empty array if none configured. */
30
+ export declare function loadMcpTools(opts?: LoadMcpOptions): Promise<Tool[]>;
4
31
  /** Disconnect all MCP clients (call on exit) */
5
32
  export declare function disconnectMcpClients(): void;
6
33
  /** Names of connected MCP servers */
@@ -1,7 +1,52 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { readOhConfig } from "../harness/config.js";
3
+ import { debug } from "../utils/debug.js";
2
4
  import { McpClient } from "./client.js";
3
5
  import { DeferredMcpTool } from "./DeferredMcpTool.js";
4
6
  import { McpTool } from "./McpTool.js";
7
+ /**
8
+ * Parse a `--mcp-config <path>` file. Format:
9
+ * - `{ "mcpServers": [...] }` — Claude Code convention (preferred)
10
+ * - `[ ... ]` — bare array of server configs (also accepted)
11
+ * - `{ "name": ..., ... }` — single-server object (also accepted)
12
+ *
13
+ * Validation is shape-only: each entry must be an object with a `name`.
14
+ * Connection-time validation happens in `McpClient.connect`. Throws on
15
+ * malformed JSON or unrecognised top-level shape.
16
+ */
17
+ export function parseMcpConfigFile(path) {
18
+ const raw = readFileSync(path, "utf8");
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(raw);
22
+ }
23
+ catch (err) {
24
+ throw new Error(`--mcp-config '${path}' is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
25
+ }
26
+ let servers;
27
+ if (Array.isArray(parsed)) {
28
+ servers = parsed;
29
+ }
30
+ else if (parsed && typeof parsed === "object" && "mcpServers" in parsed) {
31
+ const list = parsed.mcpServers;
32
+ if (!Array.isArray(list)) {
33
+ throw new Error(`--mcp-config '${path}': mcpServers must be an array`);
34
+ }
35
+ servers = list;
36
+ }
37
+ else if (parsed && typeof parsed === "object" && "name" in parsed) {
38
+ servers = [parsed];
39
+ }
40
+ else {
41
+ throw new Error(`--mcp-config '${path}': expected an mcpServers array, a bare array, or a single server object`);
42
+ }
43
+ for (const s of servers) {
44
+ if (!s || typeof s !== "object" || typeof s.name !== "string") {
45
+ throw new Error(`--mcp-config '${path}': every server entry must be an object with a 'name' string`);
46
+ }
47
+ }
48
+ return servers;
49
+ }
5
50
  const connectedClients = [];
6
51
  let exitHandlerInstalled = false;
7
52
  function installExitHandler() {
@@ -28,11 +73,20 @@ function installExitHandler() {
28
73
  }
29
74
  /** Threshold: servers with more tools than this use deferred loading */
30
75
  const DEFERRED_THRESHOLD = 10;
31
- /** Load MCP tools from .oh/config.yaml mcpServers list. Returns empty array if none configured. */
32
- export async function loadMcpTools() {
76
+ /** Load MCP tools from .oh/config.yaml mcpServers list (and/or `--mcp-config` overrides). Returns empty array if none configured. */
77
+ export async function loadMcpTools(opts = {}) {
33
78
  installExitHandler();
34
79
  const cfg = readOhConfig();
35
- const servers = cfg?.mcpServers ?? [];
80
+ const fromConfig = opts.strict ? [] : (cfg?.mcpServers ?? []);
81
+ const fromExtra = opts.extraServers ?? [];
82
+ // Dedup by name — extras win on conflict so --mcp-config can override a
83
+ // project-config entry without --strict.
84
+ const byName = new Map();
85
+ for (const s of fromConfig)
86
+ byName.set(s.name, s);
87
+ for (const s of fromExtra)
88
+ byName.set(s.name, s);
89
+ const servers = Array.from(byName.values());
36
90
  if (servers.length === 0)
37
91
  return [];
38
92
  const tools = [];
@@ -45,10 +99,12 @@ export async function loadMcpTools() {
45
99
  for (const result of results) {
46
100
  if (result.status === "rejected") {
47
101
  console.warn(`[mcp] Failed to connect: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
102
+ debug("mcp", "connect failed", result.reason);
48
103
  continue;
49
104
  }
50
105
  const { client, defs, server } = result.value;
51
106
  connectedClients.push(client);
107
+ debug("mcp", "connected", { server: server.name, tools: defs.length, deferred: defs.length > DEFERRED_THRESHOLD });
52
108
  if (defs.length > DEFERRED_THRESHOLD) {
53
109
  for (const def of defs) {
54
110
  tools.push(new DeferredMcpTool(client, def.name, def.description ?? "", server.riskLevel));
@@ -0,0 +1,36 @@
1
+ /**
2
+ * MCP `roots/list` responder (audit B3).
3
+ *
4
+ * The MCP spec lets a server ask the client "which file system roots are in
5
+ * scope?" via the `roots/list` request. This module owns OH's answer.
6
+ *
7
+ * Roots are computed at request time (no caching) so a `cd` inside the REPL
8
+ * or a future `--add-dir` flag flip is reflected immediately. The set is:
9
+ * - process.cwd() — always included
10
+ * - any directories supplied via `setExtraRoots()` — for `--add-dir` /
11
+ * `/add-dir` integrations once they're properly wired (audit A7 deferred).
12
+ *
13
+ * Pure module with one mutable Set; the SDK handler in `transport.ts` calls
14
+ * `getRoots()` at request time. Exported `setExtraRoots` lets later wiring
15
+ * extend the set without restarting the MCP connection.
16
+ */
17
+ export interface McpRoot {
18
+ uri: string;
19
+ name?: string;
20
+ }
21
+ /**
22
+ * Build the current root list. Always includes the process cwd. Extra roots
23
+ * (added via `setExtraRoots`) are deduplicated against the cwd. Each root is
24
+ * a `file://` URI per the MCP spec; `name` is the basename for readability.
25
+ */
26
+ export declare function getRoots(): McpRoot[];
27
+ /**
28
+ * Replace the extra-roots set. Empty array clears it. Idempotent — passing
29
+ * the same set twice is a no-op for downstream observers.
30
+ *
31
+ * @internal Public for tests + future `--add-dir` wiring.
32
+ */
33
+ export declare function setExtraRoots(paths: readonly string[]): void;
34
+ /** @internal Test-only reset. */
35
+ export declare function _resetRootsForTest(): void;
36
+ //# sourceMappingURL=roots.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * MCP `roots/list` responder (audit B3).
3
+ *
4
+ * The MCP spec lets a server ask the client "which file system roots are in
5
+ * scope?" via the `roots/list` request. This module owns OH's answer.
6
+ *
7
+ * Roots are computed at request time (no caching) so a `cd` inside the REPL
8
+ * or a future `--add-dir` flag flip is reflected immediately. The set is:
9
+ * - process.cwd() — always included
10
+ * - any directories supplied via `setExtraRoots()` — for `--add-dir` /
11
+ * `/add-dir` integrations once they're properly wired (audit A7 deferred).
12
+ *
13
+ * Pure module with one mutable Set; the SDK handler in `transport.ts` calls
14
+ * `getRoots()` at request time. Exported `setExtraRoots` lets later wiring
15
+ * extend the set without restarting the MCP connection.
16
+ */
17
+ import { pathToFileURL } from "node:url";
18
+ const extraRoots = new Set();
19
+ /**
20
+ * Build the current root list. Always includes the process cwd. Extra roots
21
+ * (added via `setExtraRoots`) are deduplicated against the cwd. Each root is
22
+ * a `file://` URI per the MCP spec; `name` is the basename for readability.
23
+ */
24
+ export function getRoots() {
25
+ const seen = new Set();
26
+ const out = [];
27
+ const push = (path) => {
28
+ if (!path || seen.has(path))
29
+ return;
30
+ seen.add(path);
31
+ const uri = pathToFileURL(path).toString();
32
+ const segments = path.split(/[\\/]/).filter(Boolean);
33
+ const name = segments[segments.length - 1] ?? path;
34
+ out.push({ uri, name });
35
+ };
36
+ push(process.cwd());
37
+ for (const p of extraRoots)
38
+ push(p);
39
+ return out;
40
+ }
41
+ /**
42
+ * Replace the extra-roots set. Empty array clears it. Idempotent — passing
43
+ * the same set twice is a no-op for downstream observers.
44
+ *
45
+ * @internal Public for tests + future `--add-dir` wiring.
46
+ */
47
+ export function setExtraRoots(paths) {
48
+ extraRoots.clear();
49
+ for (const p of paths)
50
+ extraRoots.add(p);
51
+ }
52
+ /** @internal Test-only reset. */
53
+ export function _resetRootsForTest() {
54
+ extraRoots.clear();
55
+ }
56
+ //# sourceMappingURL=roots.js.map
@@ -4,6 +4,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
4
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5
5
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
6
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7
+ import { ElicitRequestSchema, ListRootsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
+ import { resolveElicitation } from "./elicitation.js";
9
+ import { getRoots } from "./roots.js";
7
10
  const pkg = createRequire(import.meta.url)("../../package.json");
8
11
  export class RemoteAuthRequiredError extends Error {
9
12
  serverName;
@@ -136,7 +139,30 @@ function hasAwaitCallback(p) {
136
139
  */
137
140
  export async function buildClient(cfg, opts = {}) {
138
141
  const transport = await buildTransport(cfg, opts);
139
- const client = new Client(CLIENT_INFO, { capabilities: {} });
142
+ // Advertise the `roots` capability (audit B3) so MCP servers know they
143
+ // can ask OH which file system roots are in scope, and the `elicitation`
144
+ // capability (audit B4) so they can request user input. listChanged on
145
+ // roots is false — OH doesn't push notifications when the cwd changes;
146
+ // servers re-query on demand.
147
+ const client = new Client(CLIENT_INFO, {
148
+ capabilities: { roots: { listChanged: false }, elicitation: {} },
149
+ });
150
+ client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: getRoots() }));
151
+ // Elicitation handler — only the form-mode (requestedSchema) variant is
152
+ // supported. URL-mode elicitations decline by default — we don't open
153
+ // browsers from the MCP path. Cast `as never` lets the SDK's wide union
154
+ // accept our narrower response shape.
155
+ client.setRequestHandler(ElicitRequestSchema, async (request) => {
156
+ const params = request.params;
157
+ if (params.requestedSchema === undefined) {
158
+ return { action: "decline" };
159
+ }
160
+ return (await resolveElicitation({
161
+ serverName: cfg.name,
162
+ message: params.message,
163
+ requestedSchema: params.requestedSchema,
164
+ }));
165
+ });
140
166
  const timeoutMs = cfg.timeout ?? DEFAULT_TIMEOUT_MS;
141
167
  async function tryConnect() {
142
168
  let timer = null;
@@ -175,9 +201,25 @@ export async function buildClient(cfg, opts = {}) {
175
201
  catch {
176
202
  // best-effort
177
203
  }
178
- // Build a fresh transport + client for the authenticated retry
204
+ // Build a fresh transport + client for the authenticated retry — same
205
+ // capabilities + handlers as the initial client (audit B3 roots,
206
+ // audit B4 elicitation).
179
207
  const freshTransport = await buildTransport(cfg, opts);
180
- const freshClient = new Client(CLIENT_INFO, { capabilities: {} });
208
+ const freshClient = new Client(CLIENT_INFO, {
209
+ capabilities: { roots: { listChanged: false }, elicitation: {} },
210
+ });
211
+ freshClient.setRequestHandler(ListRootsRequestSchema, () => ({ roots: getRoots() }));
212
+ freshClient.setRequestHandler(ElicitRequestSchema, async (request) => {
213
+ const params = request.params;
214
+ if (params.requestedSchema === undefined) {
215
+ return { action: "decline" };
216
+ }
217
+ return (await resolveElicitation({
218
+ serverName: cfg.name,
219
+ message: params.message,
220
+ requestedSchema: params.requestedSchema,
221
+ }));
222
+ });
181
223
  let freshTimer = null;
182
224
  try {
183
225
  await Promise.race([
@@ -4,11 +4,35 @@
4
4
  import type { Provider, ProviderConfig } from "./base.js";
5
5
  /**
6
6
  * Create a provider from a model string like "ollama/llama3" or "gpt-4o".
7
+ *
8
+ * `opts.fallbackModel` (audit B2) is the CLI override path for the existing
9
+ * `fallbackProviders` config — when set, REPLACES the config-file fallbacks
10
+ * with a single entry derived from the model string. Mirrors Claude Code's
11
+ * `--fallback-model <model>` for one-shot CI runs that want a fallback
12
+ * without editing `.oh/config.yaml`. Format matches `modelArg`:
13
+ * `provider/model` or just `model` (provider guessed). When unset, the
14
+ * existing config-file path is unchanged.
7
15
  */
8
- export declare function createProvider(modelArg?: string, overrides?: Partial<ProviderConfig>): Promise<{
16
+ export declare function createProvider(modelArg?: string, overrides?: Partial<ProviderConfig>, opts?: {
17
+ fallbackModel?: string;
18
+ }): Promise<{
9
19
  provider: Provider;
10
20
  model: string;
11
21
  }>;
22
+ /**
23
+ * Parse `--fallback-model <value>` into the same shape as a `fallbackProviders[]`
24
+ * entry. Accepts `provider/model` (explicit) or just `model` (provider guessed
25
+ * via `guessProviderFromModel`, same as the primary modelArg). Exposed for
26
+ * tests.
27
+ *
28
+ * @internal
29
+ */
30
+ export declare function parseFallbackModel(raw: string): {
31
+ provider: string;
32
+ model?: string;
33
+ apiKey?: string;
34
+ baseUrl?: string;
35
+ };
12
36
  export { createProviderInstance, guessProviderFromModel };
13
37
  declare function createProviderInstance(name: string, config: ProviderConfig): Provider;
14
38
  declare function guessProviderFromModel(model: string): string;
@@ -10,8 +10,16 @@ import { OpenAIProvider } from "./openai.js";
10
10
  import { OpenRouterProvider } from "./openrouter.js";
11
11
  /**
12
12
  * Create a provider from a model string like "ollama/llama3" or "gpt-4o".
13
+ *
14
+ * `opts.fallbackModel` (audit B2) is the CLI override path for the existing
15
+ * `fallbackProviders` config — when set, REPLACES the config-file fallbacks
16
+ * with a single entry derived from the model string. Mirrors Claude Code's
17
+ * `--fallback-model <model>` for one-shot CI runs that want a fallback
18
+ * without editing `.oh/config.yaml`. Format matches `modelArg`:
19
+ * `provider/model` or just `model` (provider guessed). When unset, the
20
+ * existing config-file path is unchanged.
13
21
  */
14
- export async function createProvider(modelArg, overrides) {
22
+ export async function createProvider(modelArg, overrides, opts = {}) {
15
23
  let providerName = "ollama";
16
24
  let model = "llama3";
17
25
  if (modelArg) {
@@ -32,7 +40,9 @@ export async function createProvider(modelArg, overrides) {
32
40
  ...overrides,
33
41
  };
34
42
  const primary = createProviderInstance(providerName, config);
35
- const fallbackCfgs = readOhConfig()?.fallbackProviders ?? [];
43
+ const fallbackCfgs = opts.fallbackModel
44
+ ? [parseFallbackModel(opts.fallbackModel)]
45
+ : (readOhConfig()?.fallbackProviders ?? []);
36
46
  if (fallbackCfgs.length === 0) {
37
47
  return { provider: primary, model };
38
48
  }
@@ -48,6 +58,21 @@ export async function createProvider(modelArg, overrides) {
48
58
  const wrapped = createFallbackProvider(primary, fallbacks);
49
59
  return { provider: wrapped, model };
50
60
  }
61
+ /**
62
+ * Parse `--fallback-model <value>` into the same shape as a `fallbackProviders[]`
63
+ * entry. Accepts `provider/model` (explicit) or just `model` (provider guessed
64
+ * via `guessProviderFromModel`, same as the primary modelArg). Exposed for
65
+ * tests.
66
+ *
67
+ * @internal
68
+ */
69
+ export function parseFallbackModel(raw) {
70
+ if (raw.includes("/")) {
71
+ const [p, m] = raw.split("/", 2);
72
+ return { provider: p, model: m };
73
+ }
74
+ return { provider: guessProviderFromModel(raw), model: raw };
75
+ }
51
76
  export { createProviderInstance, guessProviderFromModel };
52
77
  function createProviderInstance(name, config) {
53
78
  switch (name) {
@@ -311,7 +311,7 @@ export async function* query(userMessage, config, existingMessages = []) {
311
311
  // Execute remaining tools not started during streaming
312
312
  const remaining = toolCalls.filter((tc) => !executedIds.has(tc.id));
313
313
  if (remaining.length > 0) {
314
- yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state);
314
+ yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state, config.permissionPromptTool);
315
315
  }
316
316
  state.lastTurnHadTools = toolCalls.length > 0;
317
317
  state.lastTurnToolCount = toolCalls.length;
@@ -11,7 +11,7 @@ type Batch = {
11
11
  calls: ToolCall[];
12
12
  };
13
13
  export declare function partitionToolCalls(toolCalls: ToolCall[], tools: Tools): Batch[];
14
- export declare function executeSingleTool(toolCall: ToolCall, tools: Tools, context: ToolContext, permissionMode: PermissionMode, askUser?: AskUserFn): Promise<ToolResult>;
15
- export declare function executeToolCalls(toolCalls: ToolCall[], tools: Tools, context: ToolContext, permissionMode: PermissionMode, askUser?: AskUserFn, state?: QueryLoopState): AsyncGenerator<StreamEvent, void>;
14
+ export declare function executeSingleTool(toolCall: ToolCall, tools: Tools, context: ToolContext, permissionMode: PermissionMode, askUser?: AskUserFn, permissionPromptTool?: string): Promise<ToolResult>;
15
+ export declare function executeToolCalls(toolCalls: ToolCall[], tools: Tools, context: ToolContext, permissionMode: PermissionMode, askUser?: AskUserFn, state?: QueryLoopState, permissionPromptTool?: string): AsyncGenerator<StreamEvent, void>;
16
16
  export {};
17
17
  //# sourceMappingURL=tools.d.ts.map
@@ -8,6 +8,42 @@ import { createToolResultMessage } from "../types/message.js";
8
8
  import { checkPermission } from "../types/permissions.js";
9
9
  const MAX_TOOL_RESULT_CHARS = 100_000;
10
10
  const TOOL_TIMEOUT_MS = 120_000;
11
+ /**
12
+ * Invoke the configured `--permission-prompt-tool` (audit B1). The tool is
13
+ * looked up by name in the active tool registry (so MCP tools wired through
14
+ * `loadMcpTools` are reachable). Failure modes — missing tool, exception
15
+ * during call, malformed JSON, unknown `behavior` — collapse into
16
+ * `behavior: "fallthrough"` so the caller can try the next branch
17
+ * (interactive prompt or headless deny). A broken permission tool must
18
+ * not lock the user out.
19
+ */
20
+ async function callPermissionPromptTool(toolName, tools, context, permissionedToolName, permissionedInput) {
21
+ const promptTool = findToolByName(tools, toolName);
22
+ if (!promptTool)
23
+ return { behavior: "fallthrough" };
24
+ let raw;
25
+ try {
26
+ raw = await promptTool.call({ tool_name: permissionedToolName, input: permissionedInput }, context);
27
+ }
28
+ catch {
29
+ return { behavior: "fallthrough" };
30
+ }
31
+ if (raw.isError)
32
+ return { behavior: "fallthrough" };
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(raw.output);
36
+ }
37
+ catch {
38
+ return { behavior: "fallthrough" };
39
+ }
40
+ if (parsed.behavior === "allow")
41
+ return { behavior: "allow" };
42
+ if (parsed.behavior === "deny") {
43
+ return parsed.message ? { behavior: "deny", message: parsed.message } : { behavior: "deny" };
44
+ }
45
+ return { behavior: "fallthrough" };
46
+ }
11
47
  export function partitionToolCalls(toolCalls, tools) {
12
48
  const batches = [];
13
49
  let currentConcurrent = [];
@@ -30,7 +66,7 @@ export function partitionToolCalls(toolCalls, tools) {
30
66
  }
31
67
  return batches;
32
68
  }
33
- export async function executeSingleTool(toolCall, tools, context, permissionMode, askUser) {
69
+ export async function executeSingleTool(toolCall, tools, context, permissionMode, askUser, permissionPromptTool) {
34
70
  const tool = findToolByName(tools, toolCall.toolName);
35
71
  if (!tool) {
36
72
  return { output: `Error: Unknown tool '${toolCall.toolName}'`, isError: true };
@@ -72,6 +108,34 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
72
108
  const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
73
109
  return denyAndEmit("hook", hookOutcome.reason ?? "hook denied", `Permission denied by hook${reason}`);
74
110
  }
111
+ else if (permissionPromptTool) {
112
+ // No hook decision → consult the configured MCP permission tool
113
+ // (audit B1). Mirrors Claude Code's --permission-prompt-tool. The
114
+ // tool returns JSON: { "behavior": "allow" | "deny", "message"?: string }.
115
+ // On any failure (tool missing, throws, malformed JSON, unknown
116
+ // behavior) we fall through to askUser / headless deny so a broken
117
+ // permission tool doesn't lock the user out.
118
+ const promptDecision = await callPermissionPromptTool(permissionPromptTool, tools, context, tool.name, parsed.data);
119
+ if (promptDecision.behavior === "allow") {
120
+ // Permission tool granted — proceed.
121
+ }
122
+ else if (promptDecision.behavior === "deny") {
123
+ return denyAndEmit("permission-prompt-tool", promptDecision.message ?? "denied", `Permission denied by ${permissionPromptTool}${promptDecision.message ? `: ${promptDecision.message}` : ""}`);
124
+ }
125
+ else if (askUser) {
126
+ // promptDecision.behavior === "fallthrough" — tool was unavailable
127
+ // or its response was malformed. Try the interactive prompt next.
128
+ const { formatToolArgs } = await import("../utils/tool-summary.js");
129
+ const description = formatToolArgs(tool.name, toolCall.arguments);
130
+ const allowed = await askUser(tool.name, description, tool.riskLevel);
131
+ if (!allowed) {
132
+ return denyAndEmit("user", "user declined", "Permission denied by user.");
133
+ }
134
+ }
135
+ else {
136
+ return denyAndEmit("headless", "permission-prompt-tool unavailable and no interactive prompt", `Permission denied: ${permissionPromptTool} did not produce a usable decision and no interactive prompt is available.`);
137
+ }
138
+ }
75
139
  else if (askUser) {
76
140
  // "ask" or no decision → interactive prompt when available
77
141
  const { formatToolArgs } = await import("../utils/tool-summary.js");
@@ -209,7 +273,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
209
273
  return { output: `Tool error: ${errMsg}`, isError: true };
210
274
  }
211
275
  }
212
- export async function* executeToolCalls(toolCalls, tools, context, permissionMode, askUser, state) {
276
+ export async function* executeToolCalls(toolCalls, tools, context, permissionMode, askUser, state, permissionPromptTool) {
213
277
  const batches = partitionToolCalls(toolCalls, tools);
214
278
  const outputChunks = [];
215
279
  const onOutputChunk = (callId, chunk) => {
@@ -218,7 +282,7 @@ export async function* executeToolCalls(toolCalls, tools, context, permissionMod
218
282
  const allToolNames = toolCalls.map((tc) => tc.toolName);
219
283
  for (const batch of batches) {
220
284
  if (batch.concurrent) {
221
- const results = await Promise.all(batch.calls.map((tc) => executeSingleTool(tc, tools, { ...context, callId: tc.id, onOutputChunk }, permissionMode, askUser)));
285
+ const results = await Promise.all(batch.calls.map((tc) => executeSingleTool(tc, tools, { ...context, callId: tc.id, onOutputChunk }, permissionMode, askUser, permissionPromptTool)));
222
286
  for (const chunk of outputChunks.splice(0))
223
287
  yield chunk;
224
288
  for (let i = 0; i < batch.calls.length; i++) {
@@ -230,7 +294,7 @@ export async function* executeToolCalls(toolCalls, tools, context, permissionMod
230
294
  }
231
295
  else {
232
296
  for (const tc of batch.calls) {
233
- const result = await executeSingleTool(tc, tools, { ...context, callId: tc.id, onOutputChunk }, permissionMode, askUser);
297
+ const result = await executeSingleTool(tc, tools, { ...context, callId: tc.id, onOutputChunk }, permissionMode, askUser, permissionPromptTool);
234
298
  for (const chunk of outputChunks.splice(0))
235
299
  yield chunk;
236
300
  yield { type: "tool_call_end", callId: tc.id, output: result.output, isError: result.isError };