@zhijiewang/openharness 2.21.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/dist/harness/api-key-helper.d.ts +32 -0
- package/dist/harness/api-key-helper.js +70 -0
- package/dist/harness/config.d.ts +25 -0
- package/dist/harness/credentials.d.ts +6 -4
- package/dist/harness/credentials.js +15 -4
- package/dist/harness/hooks.d.ts +11 -1
- package/dist/harness/hooks.js +10 -0
- package/dist/main.js +176 -63
- package/dist/mcp/elicitation.d.ts +66 -0
- package/dist/mcp/elicitation.js +88 -0
- 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/utils/install-method.d.ts +42 -0
- package/dist/utils/install-method.js +110 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -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 };
|
package/dist/query/types.d.ts
CHANGED
|
@@ -22,6 +22,16 @@ export type QueryConfig = {
|
|
|
22
22
|
gitCommitPerTool?: boolean;
|
|
23
23
|
/** For sub-agent invocations: the agent role name (feeds into the model router). */
|
|
24
24
|
role?: string;
|
|
25
|
+
/**
|
|
26
|
+
* MCP tool name (e.g. `mcp__myperm__check`) consulted when a tool needs
|
|
27
|
+
* approval and no permission hook gave a decision (audit B1). Mirrors
|
|
28
|
+
* Claude Code's `--permission-prompt-tool`. The tool is invoked with
|
|
29
|
+
* `{ tool_name, input }` and is expected to return a JSON string with
|
|
30
|
+
* shape `{ "behavior": "allow" | "deny", "message"?: string }`. Falls
|
|
31
|
+
* through to the interactive `askUser` prompt (or headless deny) when
|
|
32
|
+
* the tool is missing, throws, or returns malformed JSON.
|
|
33
|
+
*/
|
|
34
|
+
permissionPromptTool?: string;
|
|
25
35
|
};
|
|
26
36
|
export type TransitionReason = "next_turn" | "retry_network" | "retry_prompt_too_long" | "retry_max_output_tokens";
|
|
27
37
|
export type QueryLoopState = {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect how the running OH CLI was installed (audit B7) so `oh update` can
|
|
3
|
+
* print the appropriate upgrade command. Pure function — `detectInstallMethod`
|
|
4
|
+
* inspects the process's own filesystem path and current working directory;
|
|
5
|
+
* exported for unit testing and reuse.
|
|
6
|
+
*
|
|
7
|
+
* Detection rules, in order:
|
|
8
|
+
* - "local-clone" → `dist/main.js` lives inside a git repo whose root is
|
|
9
|
+
* the package itself (the user is running from a clone).
|
|
10
|
+
* Suggest `git pull && npm install && npm run build`.
|
|
11
|
+
* - "npm-global" → `dist/main.js` lives under a directory containing the
|
|
12
|
+
* segment `node_modules/@zhijiewang/openharness/`. This
|
|
13
|
+
* is the standard npm global install layout. Suggest
|
|
14
|
+
* `npm install -g @zhijiewang/openharness@latest`.
|
|
15
|
+
* - "npx-cache" → `dist/main.js` lives under a path containing
|
|
16
|
+
* `_npx/` (npx caches packages there). npx auto-fetches
|
|
17
|
+
* the latest by default; suggest re-running with
|
|
18
|
+
* `@latest` to bypass cache.
|
|
19
|
+
* - "unknown" → Couldn't classify. Print all three options and let
|
|
20
|
+
* the user choose.
|
|
21
|
+
*/
|
|
22
|
+
export type InstallMethod = "local-clone" | "npm-global" | "npx-cache" | "unknown";
|
|
23
|
+
export interface InstallMethodResult {
|
|
24
|
+
method: InstallMethod;
|
|
25
|
+
/** The detected install root, mostly for diagnostics. */
|
|
26
|
+
root: string;
|
|
27
|
+
/** Multi-line user-facing message describing the upgrade command. */
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Classify the install method given the running script's filesystem path.
|
|
32
|
+
* `mainPath` defaults to `import.meta.url`-derived path in the CLI; tests
|
|
33
|
+
* override it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function detectInstallMethod(mainPath: string): InstallMethodResult;
|
|
36
|
+
/**
|
|
37
|
+
* Default `mainPath` resolver — walks up from `process.argv[1]` to find the
|
|
38
|
+
* package root. Exported so tests can stub it. Falls back to argv[1] verbatim
|
|
39
|
+
* when nothing matches.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getDefaultMainPath(): string;
|
|
42
|
+
//# sourceMappingURL=install-method.d.ts.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect how the running OH CLI was installed (audit B7) so `oh update` can
|
|
3
|
+
* print the appropriate upgrade command. Pure function — `detectInstallMethod`
|
|
4
|
+
* inspects the process's own filesystem path and current working directory;
|
|
5
|
+
* exported for unit testing and reuse.
|
|
6
|
+
*
|
|
7
|
+
* Detection rules, in order:
|
|
8
|
+
* - "local-clone" → `dist/main.js` lives inside a git repo whose root is
|
|
9
|
+
* the package itself (the user is running from a clone).
|
|
10
|
+
* Suggest `git pull && npm install && npm run build`.
|
|
11
|
+
* - "npm-global" → `dist/main.js` lives under a directory containing the
|
|
12
|
+
* segment `node_modules/@zhijiewang/openharness/`. This
|
|
13
|
+
* is the standard npm global install layout. Suggest
|
|
14
|
+
* `npm install -g @zhijiewang/openharness@latest`.
|
|
15
|
+
* - "npx-cache" → `dist/main.js` lives under a path containing
|
|
16
|
+
* `_npx/` (npx caches packages there). npx auto-fetches
|
|
17
|
+
* the latest by default; suggest re-running with
|
|
18
|
+
* `@latest` to bypass cache.
|
|
19
|
+
* - "unknown" → Couldn't classify. Print all three options and let
|
|
20
|
+
* the user choose.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { dirname, join, sep } from "node:path";
|
|
24
|
+
/**
|
|
25
|
+
* Classify the install method given the running script's filesystem path.
|
|
26
|
+
* `mainPath` defaults to `import.meta.url`-derived path in the CLI; tests
|
|
27
|
+
* override it.
|
|
28
|
+
*/
|
|
29
|
+
export function detectInstallMethod(mainPath) {
|
|
30
|
+
// Normalize to forward slashes so the substring tests below work on Windows.
|
|
31
|
+
const normalized = mainPath.replace(/\\/g, "/");
|
|
32
|
+
// npx-cache: path contains `/_npx/` (Node's npx puts packages there)
|
|
33
|
+
if (normalized.includes("/_npx/")) {
|
|
34
|
+
return {
|
|
35
|
+
method: "npx-cache",
|
|
36
|
+
root: dirname(mainPath),
|
|
37
|
+
message: [
|
|
38
|
+
"You're running OH via npx (auto-fetched on each invocation).",
|
|
39
|
+
"To force the latest version on the next run, use:",
|
|
40
|
+
"",
|
|
41
|
+
" npx @zhijiewang/openharness@latest",
|
|
42
|
+
"",
|
|
43
|
+
"Or install globally to avoid the npx cache entirely:",
|
|
44
|
+
" npm install -g @zhijiewang/openharness@latest",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// local-clone: walk up to find a package.json whose name matches AND a .git dir
|
|
49
|
+
let dir = dirname(mainPath);
|
|
50
|
+
while (dir && dir !== dirname(dir)) {
|
|
51
|
+
const pkgPath = join(dir, "package.json");
|
|
52
|
+
if (existsSync(pkgPath)) {
|
|
53
|
+
const isClone = existsSync(join(dir, ".git"));
|
|
54
|
+
if (isClone) {
|
|
55
|
+
return {
|
|
56
|
+
method: "local-clone",
|
|
57
|
+
root: dir,
|
|
58
|
+
message: [
|
|
59
|
+
`Detected a local clone at: ${dir}`,
|
|
60
|
+
"Pull the latest and rebuild:",
|
|
61
|
+
"",
|
|
62
|
+
` cd ${dir}`,
|
|
63
|
+
" git pull && npm install && npm run build",
|
|
64
|
+
].join("\n"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// npm-global: the package.json belongs to OH and lives under a global
|
|
68
|
+
// node_modules directory.
|
|
69
|
+
if (normalized.includes("/node_modules/@zhijiewang/openharness/")) {
|
|
70
|
+
return {
|
|
71
|
+
method: "npm-global",
|
|
72
|
+
root: dir,
|
|
73
|
+
message: [
|
|
74
|
+
`Detected a global npm install at: ${dir}`,
|
|
75
|
+
"Upgrade with:",
|
|
76
|
+
"",
|
|
77
|
+
" npm install -g @zhijiewang/openharness@latest",
|
|
78
|
+
].join("\n"),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
dir = dirname(dir);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
method: "unknown",
|
|
87
|
+
root: dirname(mainPath),
|
|
88
|
+
message: [
|
|
89
|
+
"Could not determine how OH was installed. Pick the option that matches your setup:",
|
|
90
|
+
"",
|
|
91
|
+
" Global npm install: npm install -g @zhijiewang/openharness@latest",
|
|
92
|
+
" npx (one-shot): npx @zhijiewang/openharness@latest",
|
|
93
|
+
" Local clone: git pull && npm install && npm run build",
|
|
94
|
+
].join("\n"),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Default `mainPath` resolver — walks up from `process.argv[1]` to find the
|
|
99
|
+
* package root. Exported so tests can stub it. Falls back to argv[1] verbatim
|
|
100
|
+
* when nothing matches.
|
|
101
|
+
*/
|
|
102
|
+
export function getDefaultMainPath() {
|
|
103
|
+
const entry = process.argv[1] ?? "";
|
|
104
|
+
if (!entry)
|
|
105
|
+
return "";
|
|
106
|
+
// If argv[1] points at a `dist/main.js`, that's already the right anchor.
|
|
107
|
+
// Otherwise return as-is and let `detectInstallMethod` figure it out.
|
|
108
|
+
return entry.split(sep).join("/");
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=install-method.js.map
|