@zhijiewang/openharness 2.11.0 → 2.13.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 +3 -0
- package/dist/commands/index.d.ts +1 -1
- package/dist/commands/index.js +1 -1
- package/dist/commands/info.js +69 -7
- package/dist/commands/mcp-auth.d.ts +11 -0
- package/dist/commands/mcp-auth.js +57 -0
- package/dist/commands/types.d.ts +1 -1
- package/dist/components/REPL.js +10 -3
- package/dist/harness/config.d.ts +5 -0
- package/dist/harness/hooks.d.ts +35 -1
- package/dist/harness/hooks.js +204 -35
- package/dist/harness/submit-handler.js +37 -3
- package/dist/mcp/client.d.ts +5 -1
- package/dist/mcp/client.js +37 -4
- package/dist/mcp/oauth-storage.d.ts +23 -0
- package/dist/mcp/oauth-storage.js +58 -0
- package/dist/mcp/oauth.d.ts +79 -0
- package/dist/mcp/oauth.js +257 -0
- package/dist/mcp/transport.d.ts +13 -2
- package/dist/mcp/transport.js +76 -16
- package/dist/providers/fallback.js +19 -7
- package/dist/providers/index.js +18 -2
- package/dist/providers/router.d.ts +4 -0
- package/dist/providers/router.js +19 -0
- package/dist/query/index.js +33 -1
- package/dist/query/tools.js +49 -11
- package/dist/query/types.d.ts +6 -0
- package/dist/tools/AgentTool/index.js +2 -2
- package/dist/tools/ScheduleWakeupTool/index.d.ts +2 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -317,6 +317,8 @@ hooks:
|
|
|
317
317
|
|
|
318
318
|
Use `match` to restrict a hook to a specific tool name (e.g., `match: Bash` only triggers for the Bash tool).
|
|
319
319
|
|
|
320
|
+
See [docs/hooks.md](docs/hooks.md) for the full event reference including the new `userPromptSubmit`, `permissionRequest`, and `postToolUseFailure` events.
|
|
321
|
+
|
|
320
322
|
## Cybergotchi
|
|
321
323
|
|
|
322
324
|
OpenHarness ships with a Tamagotchi-style companion that lives in the side panel. It reacts to your session in real time — celebrating streaks, complaining when tools fail, and getting hungry if you ignore it.
|
|
@@ -378,6 +380,7 @@ mcpServers:
|
|
|
378
380
|
```
|
|
379
381
|
|
|
380
382
|
See [docs/mcp-servers.md](docs/mcp-servers.md) for the full reference.
|
|
383
|
+
See [docs/mcp-servers.md](docs/mcp-servers.md#authentication) for OAuth 2.1 setup (auto-triggered on 401; `/mcp-login` and `/mcp-logout` commands available).
|
|
381
384
|
|
|
382
385
|
**MCP Server Registry** — browse and install from a curated catalog:
|
|
383
386
|
|
package/dist/commands/index.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { CommandContext, CommandResult } from "./types.js";
|
|
|
17
17
|
/**
|
|
18
18
|
* Check if input is a slash command. If so, execute it.
|
|
19
19
|
*/
|
|
20
|
-
export declare function processSlashCommand(input: string, context: CommandContext): CommandResult | null
|
|
20
|
+
export declare function processSlashCommand(input: string, context: CommandContext): Promise<CommandResult | null>;
|
|
21
21
|
/**
|
|
22
22
|
* Get all registered command names (for autocomplete/display).
|
|
23
23
|
*/
|
package/dist/commands/index.js
CHANGED
|
@@ -34,7 +34,7 @@ registerSkillCommands(register);
|
|
|
34
34
|
/**
|
|
35
35
|
* Check if input is a slash command. If so, execute it.
|
|
36
36
|
*/
|
|
37
|
-
export function processSlashCommand(input, context) {
|
|
37
|
+
export async function processSlashCommand(input, context) {
|
|
38
38
|
const trimmed = input.trim();
|
|
39
39
|
if (!trimmed.startsWith("/"))
|
|
40
40
|
return null;
|
package/dist/commands/info.js
CHANGED
|
@@ -8,7 +8,11 @@ import { gitBranch, isGitRepo, isInMergeOrRebase } from "../git/index.js";
|
|
|
8
8
|
import { readOhConfig } from "../harness/config.js";
|
|
9
9
|
import { estimateMessageTokens } from "../harness/context-warning.js";
|
|
10
10
|
import { getContextWindow } from "../harness/cost.js";
|
|
11
|
+
import { normalizeMcpConfig } from "../mcp/config-normalize.js";
|
|
11
12
|
import { connectedMcpServers } from "../mcp/loader.js";
|
|
13
|
+
import { getAuthStatus } from "../mcp/oauth.js";
|
|
14
|
+
import { getRouteSelection } from "../providers/router.js";
|
|
15
|
+
import { mcpLoginHandler, mcpLogoutHandler } from "./mcp-auth.js";
|
|
12
16
|
export function registerInfoCommands(register, getCommandMap) {
|
|
13
17
|
register("help", "Show available commands", () => {
|
|
14
18
|
const categories = {
|
|
@@ -39,7 +43,10 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
39
43
|
"doctor",
|
|
40
44
|
"context",
|
|
41
45
|
"mcp",
|
|
46
|
+
"mcp-login",
|
|
47
|
+
"mcp-logout",
|
|
42
48
|
"mcp-registry",
|
|
49
|
+
"router",
|
|
43
50
|
"init",
|
|
44
51
|
"bug",
|
|
45
52
|
"feedback",
|
|
@@ -387,19 +394,50 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
387
394
|
];
|
|
388
395
|
return { output: lines.join("\n"), handled: true };
|
|
389
396
|
});
|
|
390
|
-
register("mcp", "Show MCP server status", () => {
|
|
391
|
-
const
|
|
392
|
-
if (
|
|
397
|
+
register("mcp", "Show MCP server status", async () => {
|
|
398
|
+
const connected = connectedMcpServers();
|
|
399
|
+
if (connected.length === 0) {
|
|
393
400
|
return {
|
|
394
401
|
output: "No MCP servers connected.\nConfigure in .oh/config.yaml under mcpServers.\nRun /mcp-registry to browse available servers.",
|
|
395
402
|
handled: true,
|
|
396
403
|
};
|
|
397
404
|
}
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
405
|
+
const cfg = readOhConfig();
|
|
406
|
+
const servers = cfg?.mcpServers ?? [];
|
|
407
|
+
const storageDir = join(homedir(), ".oh", "credentials", "mcp");
|
|
408
|
+
const lines = [`MCP Servers (${connected.length} connected):`, ""];
|
|
409
|
+
for (const name of connected) {
|
|
410
|
+
const entry = servers.find((s) => s.name === name);
|
|
411
|
+
if (!entry) {
|
|
412
|
+
lines.push(` ${name.padEnd(20)} unknown —`);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const normalized = normalizeMcpConfig(entry, process.env);
|
|
416
|
+
if (normalized.kind === "error") {
|
|
417
|
+
lines.push(` ${name.padEnd(20)} error ${normalized.message}`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const kind = normalized.cfg.type;
|
|
421
|
+
const status = await getAuthStatus(normalized.cfg, storageDir);
|
|
422
|
+
let statusText;
|
|
423
|
+
switch (status) {
|
|
424
|
+
case "n/a":
|
|
425
|
+
statusText = "—";
|
|
426
|
+
break;
|
|
427
|
+
case "none":
|
|
428
|
+
statusText = "not authenticated";
|
|
429
|
+
break;
|
|
430
|
+
case "authenticated":
|
|
431
|
+
statusText = "authenticated";
|
|
432
|
+
break;
|
|
433
|
+
case "expired":
|
|
434
|
+
statusText = "expired (re-authenticate with /mcp-login)";
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
lines.push(` ${name.padEnd(20)} ${kind.padEnd(6)} ${statusText}`);
|
|
401
438
|
}
|
|
402
|
-
lines.push("
|
|
439
|
+
lines.push("");
|
|
440
|
+
lines.push("Run /mcp-registry to browse and add more servers.");
|
|
403
441
|
return { output: lines.join("\n"), handled: true };
|
|
404
442
|
});
|
|
405
443
|
register("mcp-registry", "Browse and add MCP servers from the curated registry", (args) => {
|
|
@@ -426,6 +464,30 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
426
464
|
}
|
|
427
465
|
return { output: `Found ${results.length} servers:\n\n${formatRegistry(results)}`, handled: true };
|
|
428
466
|
});
|
|
467
|
+
register("mcp-login", "Authenticate to a remote MCP server via OAuth", async (args) => {
|
|
468
|
+
return mcpLoginHandler(args);
|
|
469
|
+
});
|
|
470
|
+
register("mcp-logout", "Wipe local OAuth tokens for an MCP server", async (args) => {
|
|
471
|
+
return mcpLogoutHandler(args);
|
|
472
|
+
});
|
|
473
|
+
register("router", "Show the model router state", (_args, ctx) => {
|
|
474
|
+
const cfg = readOhConfig()?.modelRouter;
|
|
475
|
+
const defaultModel = ctx.model ?? "unknown";
|
|
476
|
+
if (!cfg || (!cfg.fast && !cfg.balanced && !cfg.powerful)) {
|
|
477
|
+
return { output: `Router: off (single model: ${defaultModel})`, handled: true };
|
|
478
|
+
}
|
|
479
|
+
const last = ctx.sessionId ? getRouteSelection(ctx.sessionId) : undefined;
|
|
480
|
+
const lines = [
|
|
481
|
+
"Model router:",
|
|
482
|
+
` fast ${cfg.fast ?? `(default: ${defaultModel})`}`,
|
|
483
|
+
` balanced ${cfg.balanced ?? `(default: ${defaultModel})`}`,
|
|
484
|
+
` powerful ${cfg.powerful ?? `(default: ${defaultModel})`}`,
|
|
485
|
+
];
|
|
486
|
+
if (last) {
|
|
487
|
+
lines.push("", `Last selection: ${last.tier} — "${last.reason}"`);
|
|
488
|
+
}
|
|
489
|
+
return { output: lines.join("\n"), handled: true };
|
|
490
|
+
});
|
|
429
491
|
register("init", "Initialize project with .oh/ config", () => {
|
|
430
492
|
const ohDir = join(process.cwd(), ".oh");
|
|
431
493
|
if (existsSync(ohDir)) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type CommandResult = {
|
|
2
|
+
output: string;
|
|
3
|
+
handled: true;
|
|
4
|
+
};
|
|
5
|
+
export declare function mcpLogoutHandler(name: string, opts?: {
|
|
6
|
+
storageDir?: string;
|
|
7
|
+
}): Promise<CommandResult>;
|
|
8
|
+
export declare function mcpLoginHandler(name: string, opts?: {
|
|
9
|
+
storageDir?: string;
|
|
10
|
+
}): Promise<CommandResult>;
|
|
11
|
+
//# sourceMappingURL=mcp-auth.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readOhConfig } from "../harness/config.js";
|
|
4
|
+
import { McpClient } from "../mcp/client.js";
|
|
5
|
+
import { normalizeMcpConfig } from "../mcp/config-normalize.js";
|
|
6
|
+
import { clearTokens } from "../mcp/oauth.js";
|
|
7
|
+
import { loadCredentials } from "../mcp/oauth-storage.js";
|
|
8
|
+
function defaultStorageDir() {
|
|
9
|
+
return join(homedir(), ".oh", "credentials", "mcp");
|
|
10
|
+
}
|
|
11
|
+
export async function mcpLogoutHandler(name, opts = {}) {
|
|
12
|
+
const storageDir = opts.storageDir ?? defaultStorageDir();
|
|
13
|
+
const trimmed = name.trim();
|
|
14
|
+
if (!trimmed) {
|
|
15
|
+
return { output: "Usage: /mcp-logout <server-name>", handled: true };
|
|
16
|
+
}
|
|
17
|
+
const existing = await loadCredentials(storageDir, trimmed);
|
|
18
|
+
if (!existing) {
|
|
19
|
+
return { output: `No credentials stored for '${trimmed}'.`, handled: true };
|
|
20
|
+
}
|
|
21
|
+
await clearTokens(storageDir, trimmed);
|
|
22
|
+
return {
|
|
23
|
+
output: `Local token for '${trimmed}' wiped. Server-side session may remain valid until expiry.`,
|
|
24
|
+
handled: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export async function mcpLoginHandler(name, opts = {}) {
|
|
28
|
+
const storageDir = opts.storageDir ?? defaultStorageDir();
|
|
29
|
+
const trimmed = name.trim();
|
|
30
|
+
if (!trimmed) {
|
|
31
|
+
return { output: "Usage: /mcp-login <server-name>", handled: true };
|
|
32
|
+
}
|
|
33
|
+
const cfg = readOhConfig();
|
|
34
|
+
const servers = cfg?.mcpServers ?? [];
|
|
35
|
+
const entry = servers.find((s) => s.name === trimmed);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
return { output: `No MCP server named '${trimmed}' in .oh/config.yaml.`, handled: true };
|
|
38
|
+
}
|
|
39
|
+
const normalized = normalizeMcpConfig(entry, process.env);
|
|
40
|
+
if (normalized.kind === "error") {
|
|
41
|
+
return { output: `Invalid config for '${trimmed}': ${normalized.message}`, handled: true };
|
|
42
|
+
}
|
|
43
|
+
if (normalized.cfg.type === "stdio") {
|
|
44
|
+
return { output: `Server '${trimmed}' is stdio; OAuth is not applicable.`, handled: true };
|
|
45
|
+
}
|
|
46
|
+
await clearTokens(storageDir, trimmed);
|
|
47
|
+
try {
|
|
48
|
+
const client = await McpClient.connect(entry, { storageDir });
|
|
49
|
+
client.disconnect();
|
|
50
|
+
return { output: `\u2713 Authenticated to '${trimmed}'.`, handled: true };
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
return { output: `Authentication failed for '${trimmed}': ${msg}`, handled: true };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=mcp-auth.js.map
|
package/dist/commands/types.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type CommandResult = {
|
|
|
22
22
|
/** If set, toggle fast mode */
|
|
23
23
|
toggleFastMode?: boolean;
|
|
24
24
|
};
|
|
25
|
-
export type CommandHandler = (args: string, context: CommandContext) => CommandResult
|
|
25
|
+
export type CommandHandler = (args: string, context: CommandContext) => CommandResult | Promise<CommandResult>;
|
|
26
26
|
export type CommandContext = {
|
|
27
27
|
messages: Message[];
|
|
28
28
|
model: string;
|
package/dist/components/REPL.js
CHANGED
|
@@ -405,8 +405,14 @@ export default function REPL({ provider, tools, permissionMode, systemPrompt, mo
|
|
|
405
405
|
totalOutputTokens: costRef.current.totalOutputTokens,
|
|
406
406
|
sessionId,
|
|
407
407
|
};
|
|
408
|
-
|
|
409
|
-
|
|
408
|
+
void processSlashCommand(trimmed, ctx).then((result) => {
|
|
409
|
+
if (!result) {
|
|
410
|
+
const userMsg = createUserMessage(input);
|
|
411
|
+
setMessages((prev) => [...prev, userMsg]);
|
|
412
|
+
pendingPromptRef.current = input;
|
|
413
|
+
setSubmitCount((c) => c + 1);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
410
416
|
if (result.openCybergotchiSetup) {
|
|
411
417
|
setShowCybergotchiSetup(true);
|
|
412
418
|
return;
|
|
@@ -446,7 +452,8 @@ export default function REPL({ provider, tools, permissionMode, systemPrompt, mo
|
|
|
446
452
|
setSubmitCount((c) => c + 1);
|
|
447
453
|
return;
|
|
448
454
|
}
|
|
449
|
-
}
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
450
457
|
}
|
|
451
458
|
const userMsg = createUserMessage(input);
|
|
452
459
|
setMessages((prev) => [...prev, userMsg]);
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -17,11 +17,13 @@ export type McpHttpConfig = McpCommonConfig & {
|
|
|
17
17
|
type: "http";
|
|
18
18
|
url: string;
|
|
19
19
|
headers?: Record<string, string>;
|
|
20
|
+
auth?: "oauth" | "none";
|
|
20
21
|
};
|
|
21
22
|
export type McpSseConfig = McpCommonConfig & {
|
|
22
23
|
type: "sse";
|
|
23
24
|
url: string;
|
|
24
25
|
headers?: Record<string, string>;
|
|
26
|
+
auth?: "oauth" | "none";
|
|
25
27
|
};
|
|
26
28
|
export type McpServerConfig = McpStdioConfig | McpHttpConfig | McpSseConfig;
|
|
27
29
|
export type HookDef = {
|
|
@@ -49,6 +51,9 @@ export type HooksConfig = {
|
|
|
49
51
|
sessionEnd?: HookDef[];
|
|
50
52
|
preToolUse?: HookDef[];
|
|
51
53
|
postToolUse?: HookDef[];
|
|
54
|
+
postToolUseFailure?: HookDef[];
|
|
55
|
+
userPromptSubmit?: HookDef[];
|
|
56
|
+
permissionRequest?: HookDef[];
|
|
52
57
|
fileChanged?: HookDef[];
|
|
53
58
|
cwdChanged?: HookDef[];
|
|
54
59
|
subagentStart?: HookDef[];
|
package/dist/harness/hooks.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - prompt: LLM yes/no check via provider.complete()
|
|
11
11
|
*/
|
|
12
12
|
import type { HookDef } from "./config.js";
|
|
13
|
-
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
|
|
13
|
+
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "postToolUseFailure" | "userPromptSubmit" | "permissionRequest" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
|
|
14
14
|
export type HookContext = {
|
|
15
15
|
toolName?: string;
|
|
16
16
|
toolArgs?: string;
|
|
@@ -30,6 +30,14 @@ export type HookContext = {
|
|
|
30
30
|
agentId?: string;
|
|
31
31
|
/** For notification: the message */
|
|
32
32
|
message?: string;
|
|
33
|
+
/** For userPromptSubmit: the raw prompt text the user is about to submit */
|
|
34
|
+
prompt?: string;
|
|
35
|
+
/** For postToolUseFailure: short error label ("TimeoutError", "ExecutionError", "ReportedError") */
|
|
36
|
+
toolError?: string;
|
|
37
|
+
/** For postToolUseFailure: full error message */
|
|
38
|
+
errorMessage?: string;
|
|
39
|
+
/** For permissionRequest: the decision OH would take absent the hook ("ask", "allow", "deny") — informational */
|
|
40
|
+
permissionAction?: "ask" | "allow" | "deny";
|
|
33
41
|
};
|
|
34
42
|
/** Clear hook cache (call after config changes) */
|
|
35
43
|
export declare function invalidateHookCache(): void;
|
|
@@ -58,4 +66,30 @@ export declare function emitHook(event: HookEvent, ctx?: HookContext): boolean;
|
|
|
58
66
|
* Supports all hook types (command, HTTP, prompt).
|
|
59
67
|
*/
|
|
60
68
|
export declare function emitHookAsync(event: HookEvent, ctx?: HookContext): Promise<boolean>;
|
|
69
|
+
/** Parsed shape of a jsonIO hook's stdout JSON response. */
|
|
70
|
+
export type ParsedJsonIoResponse = {
|
|
71
|
+
decision?: "allow" | "deny";
|
|
72
|
+
reason?: string;
|
|
73
|
+
additionalContext?: string;
|
|
74
|
+
permissionDecision?: "allow" | "deny" | "ask";
|
|
75
|
+
};
|
|
76
|
+
/** Parse a hook's stdout as a jsonIO envelope. Returns an empty object on malformed input. */
|
|
77
|
+
export declare function parseJsonIoResponse(raw: string): ParsedJsonIoResponse;
|
|
78
|
+
export type HookOutcome = {
|
|
79
|
+
allowed: boolean;
|
|
80
|
+
additionalContext?: string;
|
|
81
|
+
permissionDecision?: "allow" | "deny" | "ask";
|
|
82
|
+
reason?: string;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Emit a hook event and return a structured HookOutcome parsed from jsonIO responses.
|
|
86
|
+
*
|
|
87
|
+
* Merge semantics:
|
|
88
|
+
* - First `deny` (or `permissionDecision: "deny"`) short-circuits: {allowed: false, ...}.
|
|
89
|
+
* - `permissionDecision: "allow"` short-circuits: {allowed: true, permissionDecision: "allow"}.
|
|
90
|
+
* - `additionalContext` from multiple hooks is concatenated in order, "\n\n" separated.
|
|
91
|
+
* - For NOTIFY_ONLY_OUTCOME_EVENTS (postToolUseFailure), decision/permissionDecision
|
|
92
|
+
* from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
|
|
93
|
+
*/
|
|
94
|
+
export declare function emitHookWithOutcome(event: HookEvent, ctx?: HookContext): Promise<HookOutcome>;
|
|
61
95
|
//# sourceMappingURL=hooks.d.ts.map
|
package/dist/harness/hooks.js
CHANGED
|
@@ -56,6 +56,17 @@ function buildEnv(event, ctx) {
|
|
|
56
56
|
env.OH_AGENT_ID = ctx.agentId;
|
|
57
57
|
if (ctx.message)
|
|
58
58
|
env.OH_MESSAGE = ctx.message;
|
|
59
|
+
if (ctx.prompt !== undefined) {
|
|
60
|
+
// Cap at 8KB to avoid Windows env-var length limits.
|
|
61
|
+
const PROMPT_MAX = 8 * 1024;
|
|
62
|
+
env.OH_PROMPT = ctx.prompt.length > PROMPT_MAX ? ctx.prompt.slice(0, PROMPT_MAX) : ctx.prompt;
|
|
63
|
+
}
|
|
64
|
+
if (ctx.toolError !== undefined)
|
|
65
|
+
env.OH_TOOL_ERROR = ctx.toolError;
|
|
66
|
+
if (ctx.errorMessage !== undefined)
|
|
67
|
+
env.OH_ERROR_MESSAGE = ctx.errorMessage;
|
|
68
|
+
if (ctx.permissionAction !== undefined)
|
|
69
|
+
env.OH_PERMISSION_ACTION = ctx.permissionAction;
|
|
59
70
|
return env;
|
|
60
71
|
}
|
|
61
72
|
/**
|
|
@@ -142,20 +153,15 @@ function runCommandHookAsync(command, env, timeoutMs = 10_000) {
|
|
|
142
153
|
});
|
|
143
154
|
}
|
|
144
155
|
/**
|
|
145
|
-
* Run a JSON-mode command hook
|
|
146
|
-
*
|
|
147
|
-
* Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
|
|
148
|
-
* `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
|
|
156
|
+
* Run a JSON-mode command hook and return the raw stdout string.
|
|
149
157
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
|
|
155
|
-
* - Timeout or spawn error → block.
|
|
158
|
+
* Rejects (throws) on timeout or spawn error so callers can decide how to
|
|
159
|
+
* interpret the failure. Returns an empty string when stdout is empty.
|
|
160
|
+
* Rejects when the process exits with a non-zero code (callers treat this as
|
|
161
|
+
* a block).
|
|
156
162
|
*/
|
|
157
|
-
function
|
|
158
|
-
return new Promise((resolve) => {
|
|
163
|
+
function runJsonIoHookCaptureStdout(command, env, event, ctx, timeoutMs = 10_000) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
159
165
|
const proc = spawn(command, {
|
|
160
166
|
shell: true,
|
|
161
167
|
timeout: timeoutMs,
|
|
@@ -168,7 +174,7 @@ function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
|
|
|
168
174
|
if (!settled) {
|
|
169
175
|
settled = true;
|
|
170
176
|
proc.kill();
|
|
171
|
-
|
|
177
|
+
reject(new Error("hook timed out"));
|
|
172
178
|
}
|
|
173
179
|
}, timeoutMs);
|
|
174
180
|
proc.stdout?.on("data", (chunk) => {
|
|
@@ -188,39 +194,56 @@ function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
|
|
|
188
194
|
return;
|
|
189
195
|
settled = true;
|
|
190
196
|
clearTimeout(timer);
|
|
191
|
-
// Non-zero exit is always a block, regardless of stdout.
|
|
192
197
|
if ((code ?? 1) !== 0) {
|
|
193
|
-
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
// Empty stdout → treat exit code as the signal (allow for exit 0).
|
|
197
|
-
if (!stdoutBuf.trim()) {
|
|
198
|
-
resolve(true);
|
|
198
|
+
reject(new Error(`hook exited with code ${code ?? 1}`));
|
|
199
199
|
return;
|
|
200
200
|
}
|
|
201
|
-
|
|
202
|
-
const parsed = JSON.parse(stdoutBuf);
|
|
203
|
-
if (parsed.decision === "deny") {
|
|
204
|
-
resolve(false);
|
|
205
|
-
}
|
|
206
|
-
else {
|
|
207
|
-
resolve(true); // "allow" or omitted → allow
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
// Malformed JSON with a zero exit — fail closed conservatively.
|
|
212
|
-
resolve(false);
|
|
213
|
-
}
|
|
201
|
+
resolve(stdoutBuf);
|
|
214
202
|
});
|
|
215
|
-
proc.on("error", () => {
|
|
203
|
+
proc.on("error", (err) => {
|
|
216
204
|
if (!settled) {
|
|
217
205
|
settled = true;
|
|
218
206
|
clearTimeout(timer);
|
|
219
|
-
|
|
207
|
+
reject(err);
|
|
220
208
|
}
|
|
221
209
|
});
|
|
222
210
|
});
|
|
223
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Run a JSON-mode command hook (Claude Code convention).
|
|
214
|
+
*
|
|
215
|
+
* Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
|
|
216
|
+
* `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
|
|
217
|
+
*
|
|
218
|
+
* Gating logic:
|
|
219
|
+
* - `decision: "deny"` → blocks (returns false).
|
|
220
|
+
* - `decision: "allow"` or omitted decision → allow (returns true).
|
|
221
|
+
* - Non-zero exit code → block.
|
|
222
|
+
* - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
|
|
223
|
+
* - Timeout or spawn error → block.
|
|
224
|
+
*/
|
|
225
|
+
async function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
|
|
226
|
+
let stdout;
|
|
227
|
+
try {
|
|
228
|
+
stdout = await runJsonIoHookCaptureStdout(command, env, event, ctx, timeoutMs);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// timeout, spawn error, or non-zero exit — block
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
// Empty stdout → treat exit code as the signal (allow for exit 0).
|
|
235
|
+
if (!stdout.trim()) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(stdout);
|
|
240
|
+
return parsed.decision !== "deny";
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Malformed JSON with a zero exit — fail closed conservatively.
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
224
247
|
/** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
|
|
225
248
|
async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
|
|
226
249
|
try {
|
|
@@ -398,4 +421,150 @@ export async function emitHookAsync(event, ctx = {}) {
|
|
|
398
421
|
}
|
|
399
422
|
return true;
|
|
400
423
|
}
|
|
424
|
+
/** Parse a hook's stdout as a jsonIO envelope. Returns an empty object on malformed input. */
|
|
425
|
+
export function parseJsonIoResponse(raw) {
|
|
426
|
+
let obj;
|
|
427
|
+
try {
|
|
428
|
+
obj = JSON.parse(raw);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return {};
|
|
432
|
+
}
|
|
433
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj))
|
|
434
|
+
return {};
|
|
435
|
+
const rec = obj;
|
|
436
|
+
const out = {};
|
|
437
|
+
if (rec.decision === "allow" || rec.decision === "deny")
|
|
438
|
+
out.decision = rec.decision;
|
|
439
|
+
if (typeof rec.reason === "string")
|
|
440
|
+
out.reason = rec.reason;
|
|
441
|
+
const hso = rec.hookSpecificOutput;
|
|
442
|
+
if (hso && typeof hso === "object" && !Array.isArray(hso)) {
|
|
443
|
+
const hsoRec = hso;
|
|
444
|
+
if (typeof hsoRec.additionalContext === "string")
|
|
445
|
+
out.additionalContext = hsoRec.additionalContext;
|
|
446
|
+
if (hsoRec.decision === "allow" || hsoRec.decision === "deny" || hsoRec.decision === "ask") {
|
|
447
|
+
out.permissionDecision = hsoRec.decision;
|
|
448
|
+
}
|
|
449
|
+
if (typeof hsoRec.reason === "string" && !out.reason)
|
|
450
|
+
out.reason = hsoRec.reason;
|
|
451
|
+
}
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
454
|
+
/** Events for which "notify-only" semantics apply — outcome.allowed is always true. */
|
|
455
|
+
const NOTIFY_ONLY_OUTCOME_EVENTS = new Set(["postToolUseFailure"]);
|
|
456
|
+
/**
|
|
457
|
+
* Map a command-hook's boolean (exit 0 / nonzero) result to a ParsedJsonIoResponse
|
|
458
|
+
* for the given event, applying per-event semantics:
|
|
459
|
+
*
|
|
460
|
+
* - userPromptSubmit: exit 0 → allow ({}); nonzero → deny.
|
|
461
|
+
* - permissionRequest: exit 0 → "ask" (fall through to user); nonzero → deny.
|
|
462
|
+
* - postToolUseFailure: notify-only — exit code is irrelevant, always return {}.
|
|
463
|
+
* - All other events: same as userPromptSubmit (exit 0 allow, nonzero deny).
|
|
464
|
+
*/
|
|
465
|
+
function mapEnvExitToOutcome(event, allowed) {
|
|
466
|
+
switch (event) {
|
|
467
|
+
case "permissionRequest":
|
|
468
|
+
return allowed
|
|
469
|
+
? { permissionDecision: "ask" }
|
|
470
|
+
: { permissionDecision: "deny", decision: "deny", reason: "hook denied (exit code)" };
|
|
471
|
+
case "postToolUseFailure":
|
|
472
|
+
// notify-only; exit code is irrelevant
|
|
473
|
+
return {};
|
|
474
|
+
default:
|
|
475
|
+
return allowed ? {} : { decision: "deny", reason: "hook denied (exit code)" };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Execute a single hook definition and return a ParsedJsonIoResponse for outcome merging.
|
|
480
|
+
* Private to this module — not exported.
|
|
481
|
+
*/
|
|
482
|
+
async function runHookForOutcome(def, event, ctx) {
|
|
483
|
+
if (def.jsonIO && def.command) {
|
|
484
|
+
const env = buildEnv(event, ctx);
|
|
485
|
+
let raw;
|
|
486
|
+
try {
|
|
487
|
+
raw = await runJsonIoHookCaptureStdout(def.command, env, event, ctx, def.timeout ?? 10_000);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
// timeout, spawn error, non-zero exit — treat as deny for gating events
|
|
491
|
+
return { decision: "deny", reason: "hook failed (timeout or non-zero exit)" };
|
|
492
|
+
}
|
|
493
|
+
if (!raw.trim()) {
|
|
494
|
+
// empty stdout with exit 0 — treat as allow (no decision)
|
|
495
|
+
return {};
|
|
496
|
+
}
|
|
497
|
+
return parseJsonIoResponse(raw);
|
|
498
|
+
}
|
|
499
|
+
if (def.command) {
|
|
500
|
+
// env-var mode — apply per-event exit-code semantics
|
|
501
|
+
const env = buildEnv(event, ctx);
|
|
502
|
+
const code = await runCommandHookAsync(def.command, env, def.timeout ?? 10_000);
|
|
503
|
+
return mapEnvExitToOutcome(event, code === 0);
|
|
504
|
+
}
|
|
505
|
+
if (def.http) {
|
|
506
|
+
const allowed = await runHttpHook(def.http, event, ctx, def.timeout ?? 10_000);
|
|
507
|
+
return mapEnvExitToOutcome(event, allowed);
|
|
508
|
+
}
|
|
509
|
+
if (def.prompt) {
|
|
510
|
+
const allowed = await runPromptHook(def.prompt, ctx, def.timeout ?? 10_000);
|
|
511
|
+
return allowed ? {} : { decision: "deny", reason: "prompt hook denied" };
|
|
512
|
+
}
|
|
513
|
+
return {};
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Emit a hook event and return a structured HookOutcome parsed from jsonIO responses.
|
|
517
|
+
*
|
|
518
|
+
* Merge semantics:
|
|
519
|
+
* - First `deny` (or `permissionDecision: "deny"`) short-circuits: {allowed: false, ...}.
|
|
520
|
+
* - `permissionDecision: "allow"` short-circuits: {allowed: true, permissionDecision: "allow"}.
|
|
521
|
+
* - `additionalContext` from multiple hooks is concatenated in order, "\n\n" separated.
|
|
522
|
+
* - For NOTIFY_ONLY_OUTCOME_EVENTS (postToolUseFailure), decision/permissionDecision
|
|
523
|
+
* from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
|
|
524
|
+
*/
|
|
525
|
+
export async function emitHookWithOutcome(event, ctx = {}) {
|
|
526
|
+
const hooks = getHooks();
|
|
527
|
+
const list = hooks?.[event];
|
|
528
|
+
if (!list || list.length === 0)
|
|
529
|
+
return { allowed: true };
|
|
530
|
+
const notifyOnly = NOTIFY_ONLY_OUTCOME_EVENTS.has(event);
|
|
531
|
+
const additionalContexts = [];
|
|
532
|
+
let reason;
|
|
533
|
+
let askSeen = false;
|
|
534
|
+
for (const def of list) {
|
|
535
|
+
if (def.match && !matchesHook(def, ctx))
|
|
536
|
+
continue;
|
|
537
|
+
const parsed = await runHookForOutcome(def, event, ctx);
|
|
538
|
+
if (!notifyOnly) {
|
|
539
|
+
if (parsed.decision === "deny" || parsed.permissionDecision === "deny") {
|
|
540
|
+
return {
|
|
541
|
+
allowed: false,
|
|
542
|
+
reason: parsed.reason ?? reason,
|
|
543
|
+
permissionDecision: parsed.permissionDecision,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
if (parsed.permissionDecision === "allow") {
|
|
547
|
+
if (parsed.additionalContext)
|
|
548
|
+
additionalContexts.push(parsed.additionalContext);
|
|
549
|
+
return {
|
|
550
|
+
allowed: true,
|
|
551
|
+
permissionDecision: "allow",
|
|
552
|
+
additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (parsed.permissionDecision === "ask")
|
|
556
|
+
askSeen = true;
|
|
557
|
+
}
|
|
558
|
+
if (parsed.additionalContext)
|
|
559
|
+
additionalContexts.push(parsed.additionalContext);
|
|
560
|
+
if (!reason && parsed.reason)
|
|
561
|
+
reason = parsed.reason;
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
allowed: true,
|
|
565
|
+
additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
|
|
566
|
+
permissionDecision: askSeen ? "ask" : undefined,
|
|
567
|
+
reason,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
401
570
|
//# sourceMappingURL=hooks.js.map
|