@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.
- package/README.md +21 -1
- package/README.zh-CN.md +21 -1
- package/dist/commands/ai.js +10 -0
- package/dist/commands/session.d.ts +18 -1
- package/dist/commands/session.js +82 -2
- package/dist/commands/settings.d.ts +1 -1
- package/dist/commands/settings.js +71 -1
- package/dist/harness/api-key-helper.d.ts +32 -0
- package/dist/harness/api-key-helper.js +70 -0
- package/dist/harness/config.d.ts +38 -0
- package/dist/harness/credentials.d.ts +6 -4
- package/dist/harness/credentials.js +15 -4
- package/dist/harness/hooks.d.ts +22 -1
- package/dist/harness/hooks.js +37 -0
- package/dist/main.js +361 -108
- package/dist/mcp/elicitation.d.ts +66 -0
- package/dist/mcp/elicitation.js +88 -0
- package/dist/mcp/loader.d.ts +29 -2
- package/dist/mcp/loader.js +59 -3
- package/dist/mcp/roots.d.ts +36 -0
- package/dist/mcp/roots.js +56 -0
- package/dist/mcp/transport.js +45 -3
- package/dist/providers/index.d.ts +25 -1
- package/dist/providers/index.js +27 -2
- package/dist/query/index.js +1 -1
- package/dist/query/tools.d.ts +2 -2
- package/dist/query/tools.js +68 -4
- package/dist/query/types.d.ts +10 -0
- package/dist/tools/EnterWorktreeTool/index.js +4 -0
- package/dist/tools/ExitWorktreeTool/index.js +7 -0
- package/dist/utils/debug.d.ts +63 -0
- package/dist/utils/debug.js +122 -0
- package/dist/utils/install-method.d.ts +42 -0
- package/dist/utils/install-method.js +110 -0
- package/package.json +1 -1
|
@@ -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
|
package/dist/mcp/loader.d.ts
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
|
+
import type { McpServerConfig } from "../harness/config.js";
|
|
1
2
|
import type { Tool } from "../Tool.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
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 */
|
package/dist/mcp/loader.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/mcp/transport.js
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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
|
|
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;
|
package/dist/providers/index.js
CHANGED
|
@@ -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 =
|
|
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) {
|
package/dist/query/index.js
CHANGED
|
@@ -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;
|
package/dist/query/tools.d.ts
CHANGED
|
@@ -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
|
package/dist/query/tools.js
CHANGED
|
@@ -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 };
|