agentapprove 0.1.20 → 0.1.22
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/cli.js +977 -73
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -2313,9 +2313,9 @@ var source_default = chalk;
|
|
|
2313
2313
|
|
|
2314
2314
|
// src/cli.ts
|
|
2315
2315
|
var import_qrcode_terminal = __toESM(require_main(), 1);
|
|
2316
|
-
import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync, readdirSync as readdirSync2, statSync, lstatSync, realpathSync as realpathSync2, rmSync as rmSync2 } from "fs";
|
|
2317
|
-
import { homedir, hostname, platform } from "os";
|
|
2318
|
-
import { join, dirname as dirname2 } from "path";
|
|
2316
|
+
import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, mkdtempSync, copyFileSync, renameSync, readdirSync as readdirSync2, statSync, lstatSync, realpathSync as realpathSync2, rmSync as rmSync2 } from "fs";
|
|
2317
|
+
import { homedir, hostname, platform, tmpdir } from "os";
|
|
2318
|
+
import { join, dirname as dirname2, sep as sep2 } from "path";
|
|
2319
2319
|
import { execSync, spawnSync } from "child_process";
|
|
2320
2320
|
import { randomBytes, createHash } from "crypto";
|
|
2321
2321
|
|
|
@@ -2630,8 +2630,204 @@ function shouldCreateFreshPairing(connectionMethod) {
|
|
|
2630
2630
|
return connectionMethod === "qr" || connectionMethod === "copy";
|
|
2631
2631
|
}
|
|
2632
2632
|
|
|
2633
|
+
// src/install-validation.ts
|
|
2634
|
+
async function safeReadText(response) {
|
|
2635
|
+
try {
|
|
2636
|
+
return await response.text();
|
|
2637
|
+
} catch {
|
|
2638
|
+
return "";
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
function parseErrorBody(body) {
|
|
2642
|
+
if (!body)
|
|
2643
|
+
return null;
|
|
2644
|
+
try {
|
|
2645
|
+
const parsed = JSON.parse(body);
|
|
2646
|
+
if (parsed && typeof parsed === "object") {
|
|
2647
|
+
return parsed;
|
|
2648
|
+
}
|
|
2649
|
+
} catch {}
|
|
2650
|
+
return null;
|
|
2651
|
+
}
|
|
2652
|
+
async function validateExistingToken(token, apiUrl, apiVersion, options = {}) {
|
|
2653
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
2654
|
+
const filename = options.preflightFilename || "common.sh";
|
|
2655
|
+
const url = `${apiUrl}/${apiVersion}/hooks/${filename}?format=raw`;
|
|
2656
|
+
let response;
|
|
2657
|
+
try {
|
|
2658
|
+
response = await fetchImpl(url, {
|
|
2659
|
+
headers: {
|
|
2660
|
+
Authorization: `Bearer ${token}`
|
|
2661
|
+
}
|
|
2662
|
+
});
|
|
2663
|
+
} catch (err) {
|
|
2664
|
+
const message = err instanceof Error ? err.message : "network error";
|
|
2665
|
+
return {
|
|
2666
|
+
kind: "network_error",
|
|
2667
|
+
summary: "cannot reach Agent Approve from this network",
|
|
2668
|
+
serverMessage: message
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
const status = response.status;
|
|
2672
|
+
if (response.ok) {
|
|
2673
|
+
const body2 = await safeReadText(response);
|
|
2674
|
+
return {
|
|
2675
|
+
kind: "valid",
|
|
2676
|
+
summary: "verified - token is valid",
|
|
2677
|
+
cachedContent: body2,
|
|
2678
|
+
status
|
|
2679
|
+
};
|
|
2680
|
+
}
|
|
2681
|
+
const body = await safeReadText(response);
|
|
2682
|
+
const parsed = parseErrorBody(body);
|
|
2683
|
+
const code = parsed?.code;
|
|
2684
|
+
const serverMessage = parsed?.error;
|
|
2685
|
+
if (status === 401 && code === "TOKEN_EXPIRED") {
|
|
2686
|
+
return {
|
|
2687
|
+
kind: "expired",
|
|
2688
|
+
summary: "expired (refresh required)",
|
|
2689
|
+
serverMessage,
|
|
2690
|
+
status
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
if (status === 401 && code === "INVALID_TOKEN") {
|
|
2694
|
+
return {
|
|
2695
|
+
kind: "invalid",
|
|
2696
|
+
summary: "invalid (re-pair required)",
|
|
2697
|
+
serverMessage,
|
|
2698
|
+
status
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
if (status === 403 && code === "AUTH_SCOPE_DENIED") {
|
|
2702
|
+
return {
|
|
2703
|
+
kind: "scope_denied",
|
|
2704
|
+
summary: "scope denied (re-pair required)",
|
|
2705
|
+
serverMessage,
|
|
2706
|
+
status
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
if (status === 503) {
|
|
2710
|
+
return {
|
|
2711
|
+
kind: "unavailable",
|
|
2712
|
+
summary: "validation temporarily unavailable",
|
|
2713
|
+
serverMessage,
|
|
2714
|
+
status
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
if (status === 403) {
|
|
2718
|
+
return {
|
|
2719
|
+
kind: "network_blocked",
|
|
2720
|
+
summary: "cannot reach service from this network",
|
|
2721
|
+
serverMessage,
|
|
2722
|
+
status
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
if (status === 401) {
|
|
2726
|
+
return {
|
|
2727
|
+
kind: "invalid",
|
|
2728
|
+
summary: "invalid (re-pair required)",
|
|
2729
|
+
serverMessage,
|
|
2730
|
+
status
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
return {
|
|
2734
|
+
kind: "unavailable",
|
|
2735
|
+
summary: "validation temporarily unavailable",
|
|
2736
|
+
serverMessage,
|
|
2737
|
+
status
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
function classifyHookDownloadFailure(status, body) {
|
|
2741
|
+
const parsed = parseErrorBody(body);
|
|
2742
|
+
const code = parsed?.code;
|
|
2743
|
+
if (status === 401 && code === "TOKEN_EXPIRED")
|
|
2744
|
+
return "token_expired";
|
|
2745
|
+
if (status === 401 && code === "INVALID_TOKEN")
|
|
2746
|
+
return "token_invalid";
|
|
2747
|
+
if (status === 403 && code === "AUTH_SCOPE_DENIED")
|
|
2748
|
+
return "scope_denied";
|
|
2749
|
+
if (status === 503)
|
|
2750
|
+
return "validation_unavailable";
|
|
2751
|
+
if (status === 403)
|
|
2752
|
+
return "network_blocked";
|
|
2753
|
+
if (status === 401)
|
|
2754
|
+
return "token_invalid";
|
|
2755
|
+
return "recoverable";
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// src/copy-hook-scripts.ts
|
|
2759
|
+
import { writeFileSync as fsWriteFileSync } from "fs";
|
|
2760
|
+
import { join as pathJoin } from "path";
|
|
2761
|
+
async function copyHookScripts(hooksDir, token, files, options) {
|
|
2762
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
2763
|
+
const writeFile = options.writeFileSync || fsWriteFileSync;
|
|
2764
|
+
const join = options.joinPath || pathJoin;
|
|
2765
|
+
let downloaded = 0;
|
|
2766
|
+
const failed = [];
|
|
2767
|
+
for (const file of files) {
|
|
2768
|
+
try {
|
|
2769
|
+
const response = await fetchImpl(`${options.apiUrl}/${options.apiVersion}/hooks/${file}?format=raw`, {
|
|
2770
|
+
headers: {
|
|
2771
|
+
Authorization: `Bearer ${token}`
|
|
2772
|
+
}
|
|
2773
|
+
});
|
|
2774
|
+
if (response.ok) {
|
|
2775
|
+
const content = await response.text();
|
|
2776
|
+
const isShellScript = content.startsWith("#!/") || content.startsWith("# ");
|
|
2777
|
+
const isJsBundle = file.endsWith(".js") && (content.startsWith("//") || content.startsWith("import ") || content.startsWith("var ") || content.startsWith("const ") || content.startsWith("export "));
|
|
2778
|
+
if (isShellScript || isJsBundle) {
|
|
2779
|
+
const filePath = join(hooksDir, file);
|
|
2780
|
+
writeFile(filePath, content, { mode: isShellScript ? 493 : 420 });
|
|
2781
|
+
downloaded++;
|
|
2782
|
+
} else {
|
|
2783
|
+
failed.push(`${file} (invalid content)`);
|
|
2784
|
+
}
|
|
2785
|
+
} else {
|
|
2786
|
+
const body = await response.text().catch(() => "");
|
|
2787
|
+
const failureKind = classifyHookDownloadFailure(response.status, body);
|
|
2788
|
+
if (failureKind !== "recoverable") {
|
|
2789
|
+
return {
|
|
2790
|
+
downloaded,
|
|
2791
|
+
failed,
|
|
2792
|
+
terminalFailure: {
|
|
2793
|
+
kind: failureKind,
|
|
2794
|
+
file,
|
|
2795
|
+
status: response.status
|
|
2796
|
+
}
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
failed.push(`${file} (${response.status})`);
|
|
2800
|
+
}
|
|
2801
|
+
} catch (err) {
|
|
2802
|
+
failed.push(`${file} (${err instanceof Error ? err.message : "network error"})`);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
return { downloaded, failed };
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
// src/pairing-api-url.ts
|
|
2809
|
+
function looksLikeHostedAgentApproveApi(baseUrl) {
|
|
2810
|
+
try {
|
|
2811
|
+
const host = new URL(baseUrl).hostname.toLowerCase();
|
|
2812
|
+
return host === "agentapprove.com" || host.endsWith(".agentapprove.com");
|
|
2813
|
+
} catch {
|
|
2814
|
+
return false;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
function resolvePairingApiBaseUrl(options) {
|
|
2818
|
+
const fromEnv = options.agentapproveApiEnv?.trim();
|
|
2819
|
+
if (fromEnv) {
|
|
2820
|
+
return fromEnv;
|
|
2821
|
+
}
|
|
2822
|
+
const fromFile = options.savedApiUrl?.trim();
|
|
2823
|
+
if (fromFile) {
|
|
2824
|
+
return fromFile;
|
|
2825
|
+
}
|
|
2826
|
+
return options.processDefaultApiUrl;
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2633
2829
|
// src/cli.ts
|
|
2634
|
-
var VERSION = "0.1.
|
|
2830
|
+
var VERSION = "0.1.22";
|
|
2635
2831
|
function getApiUrl() {
|
|
2636
2832
|
return process.env.AGENTAPPROVE_API || "https://api.agentapprove.com";
|
|
2637
2833
|
}
|
|
@@ -2703,13 +2899,15 @@ function getCommand() {
|
|
|
2703
2899
|
}
|
|
2704
2900
|
return filtered[0] || "install";
|
|
2705
2901
|
}
|
|
2706
|
-
var OPENCODE_PLUGIN_VERSION = "0.1.
|
|
2902
|
+
var OPENCODE_PLUGIN_VERSION = "0.1.18";
|
|
2707
2903
|
var OPENCODE_PLUGIN_SPEC = `@agentapprove/opencode@${OPENCODE_PLUGIN_VERSION}`;
|
|
2708
|
-
var OPENCLAW_PLUGIN_VERSION = "0.2.
|
|
2904
|
+
var OPENCLAW_PLUGIN_VERSION = "0.2.11";
|
|
2709
2905
|
var OPENCLAW_PLUGIN_SPEC = `@agentapprove/openclaw@${OPENCLAW_PLUGIN_VERSION}`;
|
|
2710
|
-
var PI_PLUGIN_VERSION = "0.1.
|
|
2906
|
+
var PI_PLUGIN_VERSION = "0.1.7";
|
|
2711
2907
|
var PI_PLUGIN_SPEC = `npm:@agentapprove/pi@${PI_PLUGIN_VERSION}`;
|
|
2712
2908
|
var PI_STATUS_TIMEOUT_MS = 5000;
|
|
2909
|
+
var HERMES_PLUGIN_VERSION = "0.1.0";
|
|
2910
|
+
var HERMES_PIP_DEPS = ["cryptography>=44.0,<46", "httpx>=0.27,<0.29"];
|
|
2713
2911
|
var AGENTS = {
|
|
2714
2912
|
"claude-code": {
|
|
2715
2913
|
name: "Claude Code",
|
|
@@ -2748,6 +2946,23 @@ var AGENTS = {
|
|
|
2748
2946
|
{ name: "afterAgentResponse", file: "cursor-response.sh", description: "Agent response" }
|
|
2749
2947
|
]
|
|
2750
2948
|
},
|
|
2949
|
+
windsurf: {
|
|
2950
|
+
name: "Windsurf",
|
|
2951
|
+
configPath: join(homedir(), ".codeium", "windsurf", "hooks.json"),
|
|
2952
|
+
hooksKey: "hooks",
|
|
2953
|
+
hooks: [
|
|
2954
|
+
{ name: "pre_user_prompt", file: "windsurf-hook.sh", description: "User prompt (logging only)" },
|
|
2955
|
+
{ name: "pre_read_code", file: "windsurf-hook.sh", description: "File read approval", isApprovalHook: true },
|
|
2956
|
+
{ name: "post_read_code", file: "windsurf-hook.sh", description: "File read completion logging" },
|
|
2957
|
+
{ name: "pre_write_code", file: "windsurf-hook.sh", description: "File write approval", isApprovalHook: true },
|
|
2958
|
+
{ name: "post_write_code", file: "windsurf-hook.sh", description: "File write completion logging" },
|
|
2959
|
+
{ name: "pre_run_command", file: "windsurf-hook.sh", description: "Shell command approval", isApprovalHook: true },
|
|
2960
|
+
{ name: "post_run_command", file: "windsurf-hook.sh", description: "Shell command completion logging" },
|
|
2961
|
+
{ name: "pre_mcp_tool_use", file: "windsurf-hook.sh", description: "MCP tool approval", isApprovalHook: true },
|
|
2962
|
+
{ name: "post_mcp_tool_use", file: "windsurf-hook.sh", description: "MCP tool completion logging" },
|
|
2963
|
+
{ name: "post_cascade_response", file: "windsurf-hook.sh", description: "Cascade response (logging)" }
|
|
2964
|
+
]
|
|
2965
|
+
},
|
|
2751
2966
|
"gemini-cli": {
|
|
2752
2967
|
name: "Gemini CLI",
|
|
2753
2968
|
configPath: join(homedir(), ".gemini", "settings.json"),
|
|
@@ -2827,6 +3042,27 @@ var AGENTS = {
|
|
|
2827
3042
|
hooks: [
|
|
2828
3043
|
{ name: "agentapprove", file: "@agentapprove/pi", description: "Tool approval + event monitoring", isApprovalHook: true, isPlugin: true }
|
|
2829
3044
|
]
|
|
3045
|
+
},
|
|
3046
|
+
hermes: {
|
|
3047
|
+
name: "Hermes",
|
|
3048
|
+
configPath: join(homedir(), ".hermes", "config.yaml"),
|
|
3049
|
+
hooksKey: "plugins",
|
|
3050
|
+
hooks: [
|
|
3051
|
+
{ name: "agentapprove", file: "agentapprove", description: "Tool approval + event monitoring + follow-up input", isApprovalHook: true, isPlugin: true }
|
|
3052
|
+
]
|
|
3053
|
+
},
|
|
3054
|
+
openhands: {
|
|
3055
|
+
name: "OpenHands",
|
|
3056
|
+
configPath: join(homedir(), ".openhands", "hooks.json"),
|
|
3057
|
+
hooksKey: "",
|
|
3058
|
+
hooks: [
|
|
3059
|
+
{ name: "session_start", file: "openhands-hook.sh", description: "Session started" },
|
|
3060
|
+
{ name: "user_prompt_submit", file: "openhands-hook.sh", description: "User prompt (logging only)" },
|
|
3061
|
+
{ name: "pre_tool_use", file: "openhands-hook.sh", description: "Tool approval", isApprovalHook: true, timeout: 300 },
|
|
3062
|
+
{ name: "post_tool_use", file: "openhands-hook.sh", description: "Tool completion logging" },
|
|
3063
|
+
{ name: "stop", file: "openhands-hook.sh", description: "Agent stopped (iOS input)", isApprovalHook: true, timeout: 600 },
|
|
3064
|
+
{ name: "session_end", file: "openhands-hook.sh", description: "Session ended" }
|
|
3065
|
+
]
|
|
2830
3066
|
}
|
|
2831
3067
|
};
|
|
2832
3068
|
var SHARED_HOOK_FILES = ["common.sh"];
|
|
@@ -2874,7 +3110,24 @@ var HOOK_STATUS_LABELS = {
|
|
|
2874
3110
|
userPromptSubmitted: "Prompt submitted",
|
|
2875
3111
|
errorOccurred: "Error",
|
|
2876
3112
|
SubagentStart: "Subagent start",
|
|
2877
|
-
SubagentStop: "Subagent stop"
|
|
3113
|
+
SubagentStop: "Subagent stop",
|
|
3114
|
+
pre_user_prompt: "User prompt",
|
|
3115
|
+
pre_read_code: "File read approval",
|
|
3116
|
+
post_read_code: "File read completed",
|
|
3117
|
+
pre_write_code: "File write approval",
|
|
3118
|
+
post_write_code: "File write completed",
|
|
3119
|
+
pre_run_command: "Shell approval",
|
|
3120
|
+
post_run_command: "Shell completed",
|
|
3121
|
+
pre_mcp_tool_use: "MCP approval",
|
|
3122
|
+
post_mcp_tool_use: "MCP completed",
|
|
3123
|
+
post_cascade_response: "Cascade response",
|
|
3124
|
+
pre_tool_call: "Tool approval",
|
|
3125
|
+
post_tool_call: "Tool completed",
|
|
3126
|
+
pre_llm_call: "Prompt submitted",
|
|
3127
|
+
post_llm_call: "Stop",
|
|
3128
|
+
on_session_start: "Session start",
|
|
3129
|
+
on_session_end: "Session end",
|
|
3130
|
+
subagent_stop: "Subagent stop"
|
|
2878
3131
|
};
|
|
2879
3132
|
function findGitBash() {
|
|
2880
3133
|
if (!isWindows())
|
|
@@ -3073,6 +3326,15 @@ function detectInstalledAgents() {
|
|
|
3073
3326
|
installed.push(id);
|
|
3074
3327
|
} catch {}
|
|
3075
3328
|
}
|
|
3329
|
+
} else if (id === "hermes") {
|
|
3330
|
+
if (existsSync2(join(homedir(), ".hermes"))) {
|
|
3331
|
+
installed.push(id);
|
|
3332
|
+
} else {
|
|
3333
|
+
try {
|
|
3334
|
+
execSync("hermes --version", { stdio: "ignore" });
|
|
3335
|
+
installed.push(id);
|
|
3336
|
+
} catch {}
|
|
3337
|
+
}
|
|
3076
3338
|
} else {
|
|
3077
3339
|
const configDir = dirname2(agent.configPath);
|
|
3078
3340
|
if (existsSync2(configDir)) {
|
|
@@ -3590,10 +3852,10 @@ function disableCodexFeatureFlag(configPath = join(homedir(), ".codex", "config.
|
|
|
3590
3852
|
writeFileSync(configPath, updated, { mode: 384 });
|
|
3591
3853
|
return { updated: true, backupPath };
|
|
3592
3854
|
}
|
|
3593
|
-
async function createPairingSession(configuredAgents, e2eKeyId) {
|
|
3855
|
+
async function createPairingSession(configuredAgents, e2eKeyId, apiBaseUrl = API_URL) {
|
|
3594
3856
|
try {
|
|
3595
3857
|
const machineHostname = hostname();
|
|
3596
|
-
const response = await fetch(`${
|
|
3858
|
+
const response = await fetch(`${apiBaseUrl}/${API_VERSION}/pair`, {
|
|
3597
3859
|
method: "POST",
|
|
3598
3860
|
headers: { "Content-Type": "application/json" },
|
|
3599
3861
|
body: JSON.stringify({
|
|
@@ -3612,9 +3874,9 @@ async function createPairingSession(configuredAgents, e2eKeyId) {
|
|
|
3612
3874
|
return { sessionCode: "", qrUrl: "", expiresIn: 0, error: String(err) };
|
|
3613
3875
|
}
|
|
3614
3876
|
}
|
|
3615
|
-
async function pollPairingSession(sessionCode) {
|
|
3877
|
+
async function pollPairingSession(sessionCode, apiBaseUrl = API_URL) {
|
|
3616
3878
|
try {
|
|
3617
|
-
const response = await fetch(`${
|
|
3879
|
+
const response = await fetch(`${apiBaseUrl}/${API_VERSION}/pair/${sessionCode}`);
|
|
3618
3880
|
return await response.json();
|
|
3619
3881
|
} catch {
|
|
3620
3882
|
return { status: "error" };
|
|
@@ -3623,7 +3885,7 @@ async function pollPairingSession(sessionCode) {
|
|
|
3623
3885
|
function sleep(ms) {
|
|
3624
3886
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3625
3887
|
}
|
|
3626
|
-
async function waitForPairing(sessionCode, onProgress, onCancel) {
|
|
3888
|
+
async function waitForPairing(sessionCode, onProgress, onCancel, apiBaseUrl = API_URL) {
|
|
3627
3889
|
let cancelled = false;
|
|
3628
3890
|
const handleKeypress = (key) => {
|
|
3629
3891
|
const char = key.toString();
|
|
@@ -3650,14 +3912,14 @@ async function waitForPairing(sessionCode, onProgress, onCancel) {
|
|
|
3650
3912
|
cleanup();
|
|
3651
3913
|
return "cancelled";
|
|
3652
3914
|
}
|
|
3653
|
-
const result = await pollPairingSession(sessionCode);
|
|
3915
|
+
const result = await pollPairingSession(sessionCode, apiBaseUrl);
|
|
3654
3916
|
if (result.status === "completed" && result.token) {
|
|
3655
3917
|
cleanup();
|
|
3656
3918
|
return {
|
|
3657
3919
|
token: result.token,
|
|
3658
3920
|
privacy: result.privacy || "full",
|
|
3659
3921
|
email: result.email || "",
|
|
3660
|
-
apiUrl: result.apiUrl ||
|
|
3922
|
+
apiUrl: result.apiUrl || apiBaseUrl,
|
|
3661
3923
|
e2eServerKey: result.e2eServerKey
|
|
3662
3924
|
};
|
|
3663
3925
|
}
|
|
@@ -3911,12 +4173,27 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
|
|
|
3911
4173
|
}
|
|
3912
4174
|
return { success: true, backupPath: null, hooks: [installResult.label] };
|
|
3913
4175
|
}
|
|
4176
|
+
if (agentId === "hermes") {
|
|
4177
|
+
if (mode === "observe") {
|
|
4178
|
+
const disableResult = disableHermesPluginViaCli();
|
|
4179
|
+
if (!disableResult.success) {
|
|
4180
|
+
return { success: false, backupPath: null, hooks: [], error: disableResult.error };
|
|
4181
|
+
}
|
|
4182
|
+
return { success: true, backupPath: null, hooks: [] };
|
|
4183
|
+
}
|
|
4184
|
+
const installResult = await installHermesPluginViaCli();
|
|
4185
|
+
if (!installResult.success) {
|
|
4186
|
+
return { success: false, backupPath: null, hooks: [], error: installResult.error };
|
|
4187
|
+
}
|
|
4188
|
+
return { success: true, backupPath: null, hooks: [installResult.label] };
|
|
4189
|
+
}
|
|
3914
4190
|
const backupPath = backupConfig(agent.configPath);
|
|
3915
4191
|
const config = readJsonConfig(agent.configPath);
|
|
3916
|
-
|
|
4192
|
+
const useTopLevelHooks = agent.hooksKey === "";
|
|
4193
|
+
if (!useTopLevelHooks && !config[agent.hooksKey]) {
|
|
3917
4194
|
config[agent.hooksKey] = {};
|
|
3918
4195
|
}
|
|
3919
|
-
const hooksConfig = config[agent.hooksKey];
|
|
4196
|
+
const hooksConfig = useTopLevelHooks ? config : config[agent.hooksKey];
|
|
3920
4197
|
const installedHooks = [];
|
|
3921
4198
|
if (agentId === "openclaw") {
|
|
3922
4199
|
const installResult = installOpenClawPluginViaCli();
|
|
@@ -4096,6 +4373,19 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
|
|
|
4096
4373
|
}
|
|
4097
4374
|
}
|
|
4098
4375
|
installedHooks.push(hook.name);
|
|
4376
|
+
} else if (agentId === "windsurf") {
|
|
4377
|
+
const existing = hooksConfig[hook.name];
|
|
4378
|
+
const existingArray = Array.isArray(existing) ? existing : existing && typeof existing === "object" ? [existing] : typeof existing === "string" ? [{ command: existing }] : [];
|
|
4379
|
+
const cleanedArray = existingArray.filter((h2) => {
|
|
4380
|
+
const command = h2.command || h2.powershell || "";
|
|
4381
|
+
return !command.includes("agentapprove");
|
|
4382
|
+
});
|
|
4383
|
+
cleanedArray.push({
|
|
4384
|
+
command: hookCommand,
|
|
4385
|
+
show_output: false
|
|
4386
|
+
});
|
|
4387
|
+
hooksConfig[hook.name] = cleanedArray;
|
|
4388
|
+
installedHooks.push(hook.name);
|
|
4099
4389
|
} else if (agentId === "gemini-cli") {
|
|
4100
4390
|
if (!hooksConfig[hook.name]) {
|
|
4101
4391
|
hooksConfig[hook.name] = [];
|
|
@@ -4144,6 +4434,41 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
|
|
|
4144
4434
|
cleanedArray.push(hookEntry);
|
|
4145
4435
|
hooksConfig[hook.name] = cleanedArray;
|
|
4146
4436
|
installedHooks.push(hook.name);
|
|
4437
|
+
} else if (agentId === "openhands") {
|
|
4438
|
+
if (!hooksConfig[hook.name]) {
|
|
4439
|
+
hooksConfig[hook.name] = [];
|
|
4440
|
+
}
|
|
4441
|
+
const hookArray = hooksConfig[hook.name];
|
|
4442
|
+
const hookTimeout = hook.timeout;
|
|
4443
|
+
const isApproval = hook.isApprovalHook;
|
|
4444
|
+
const existingIdx = hookArray.findIndex((h2) => h2.hooks?.some((hookScript) => {
|
|
4445
|
+
if (typeof hookScript === "string")
|
|
4446
|
+
return hookScript.includes("agentapprove");
|
|
4447
|
+
if (typeof hookScript === "object" && hookScript.command)
|
|
4448
|
+
return hookScript.command.includes("agentapprove");
|
|
4449
|
+
return false;
|
|
4450
|
+
}));
|
|
4451
|
+
const hookObject = {
|
|
4452
|
+
type: "command",
|
|
4453
|
+
command: hookCommand
|
|
4454
|
+
};
|
|
4455
|
+
if (isApproval === true || hookTimeout !== undefined) {
|
|
4456
|
+
hookObject.timeout = hookTimeout ?? 300;
|
|
4457
|
+
hookObject.async = false;
|
|
4458
|
+
} else {
|
|
4459
|
+
hookObject.timeout = 30;
|
|
4460
|
+
hookObject.async = true;
|
|
4461
|
+
}
|
|
4462
|
+
const hookEntry = {
|
|
4463
|
+
matcher: "*",
|
|
4464
|
+
hooks: [hookObject]
|
|
4465
|
+
};
|
|
4466
|
+
if (existingIdx >= 0) {
|
|
4467
|
+
hookArray[existingIdx] = hookEntry;
|
|
4468
|
+
} else {
|
|
4469
|
+
hookArray.push(hookEntry);
|
|
4470
|
+
}
|
|
4471
|
+
installedHooks.push(hook.name);
|
|
4147
4472
|
} else if (agentId === "copilot-cli") {
|
|
4148
4473
|
if (typeof config["version"] !== "number") {
|
|
4149
4474
|
config["version"] = 1;
|
|
@@ -4176,7 +4501,7 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
|
|
|
4176
4501
|
const approvalHooks = agent.hooks.filter((h2) => h2.isApprovalHook);
|
|
4177
4502
|
for (const hook of approvalHooks) {
|
|
4178
4503
|
if (hooksConfig[hook.name]) {
|
|
4179
|
-
if (agentId === "claude-code" || agentId === "gemini-cli" || agentId === "codex") {
|
|
4504
|
+
if (agentId === "claude-code" || agentId === "gemini-cli" || agentId === "codex" || agentId === "openhands") {
|
|
4180
4505
|
const hookArray = hooksConfig[hook.name];
|
|
4181
4506
|
if (Array.isArray(hookArray)) {
|
|
4182
4507
|
const cleaned = hookArray.filter((h2) => {
|
|
@@ -4237,6 +4562,31 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
|
|
|
4237
4562
|
hooksConfig[hook.name] = cleaned;
|
|
4238
4563
|
}
|
|
4239
4564
|
}
|
|
4565
|
+
} else if (agentId === "windsurf") {
|
|
4566
|
+
const existing = hooksConfig[hook.name];
|
|
4567
|
+
if (Array.isArray(existing)) {
|
|
4568
|
+
const cleaned = existing.filter((h2) => {
|
|
4569
|
+
if (typeof h2 === "object" && h2 !== null) {
|
|
4570
|
+
const cmd = h2.command || h2.powershell || "";
|
|
4571
|
+
return !cmd.includes("agentapprove");
|
|
4572
|
+
}
|
|
4573
|
+
if (typeof h2 === "string")
|
|
4574
|
+
return !h2.includes("agentapprove");
|
|
4575
|
+
return true;
|
|
4576
|
+
});
|
|
4577
|
+
if (cleaned.length === 0) {
|
|
4578
|
+
delete hooksConfig[hook.name];
|
|
4579
|
+
} else {
|
|
4580
|
+
hooksConfig[hook.name] = cleaned;
|
|
4581
|
+
}
|
|
4582
|
+
} else if (typeof existing === "object" && existing !== null) {
|
|
4583
|
+
const cmd = existing.command || existing.powershell || "";
|
|
4584
|
+
if (cmd.includes("agentapprove")) {
|
|
4585
|
+
delete hooksConfig[hook.name];
|
|
4586
|
+
}
|
|
4587
|
+
} else if (typeof existing === "string" && existing.includes("agentapprove")) {
|
|
4588
|
+
delete hooksConfig[hook.name];
|
|
4589
|
+
}
|
|
4240
4590
|
} else if (agentId === "openclaw") {
|
|
4241
4591
|
const pluginsObj = hooksConfig;
|
|
4242
4592
|
const entries = pluginsObj?.entries;
|
|
@@ -4314,6 +4664,14 @@ codex_hooks = true`;
|
|
|
4314
4664
|
hooksObj[hook.name] = [entry];
|
|
4315
4665
|
}
|
|
4316
4666
|
return JSON.stringify({ version: 1, hooks: hooksObj }, null, 2);
|
|
4667
|
+
} else if (agentId === "windsurf") {
|
|
4668
|
+
const hooksObj = {};
|
|
4669
|
+
for (const hook of agent.hooks) {
|
|
4670
|
+
const hookPath = join(hooksDir, hook.file);
|
|
4671
|
+
const hookCmd = buildHookCommand(hookPath, agentId);
|
|
4672
|
+
hooksObj[hook.name] = [{ command: hookCmd, show_output: false }];
|
|
4673
|
+
}
|
|
4674
|
+
return JSON.stringify({ hooks: hooksObj }, null, 2);
|
|
4317
4675
|
} else if (agentId === "gemini-cli") {
|
|
4318
4676
|
const hooksObj = {};
|
|
4319
4677
|
for (const hook of agent.hooks) {
|
|
@@ -4361,6 +4719,23 @@ codex_hooks = true`;
|
|
|
4361
4719
|
hooksObj[hook.name] = [entry];
|
|
4362
4720
|
}
|
|
4363
4721
|
return JSON.stringify({ version: 1, hooks: hooksObj }, null, 2);
|
|
4722
|
+
} else if (agentId === "openhands") {
|
|
4723
|
+
const hooksObj = {};
|
|
4724
|
+
for (const hook of agent.hooks) {
|
|
4725
|
+
const hookPath = join(hooksDir, hook.file);
|
|
4726
|
+
const hookCmd = buildHookCommand(hookPath, agentId);
|
|
4727
|
+
const hookTimeout = hook.timeout;
|
|
4728
|
+
const isApproval = hook.isApprovalHook;
|
|
4729
|
+
const blocking = isApproval === true || hookTimeout !== undefined;
|
|
4730
|
+
const hookObject = {
|
|
4731
|
+
type: "command",
|
|
4732
|
+
command: hookCmd,
|
|
4733
|
+
timeout: blocking ? hookTimeout ?? 300 : 30,
|
|
4734
|
+
async: !blocking
|
|
4735
|
+
};
|
|
4736
|
+
hooksObj[hook.name] = [{ matcher: "*", hooks: [hookObject] }];
|
|
4737
|
+
}
|
|
4738
|
+
return JSON.stringify(hooksObj, null, 2);
|
|
4364
4739
|
} else if (agentId === "openclaw") {
|
|
4365
4740
|
const configJson = JSON.stringify({
|
|
4366
4741
|
plugins: {
|
|
@@ -4424,39 +4799,50 @@ codex_hooks = true`;
|
|
|
4424
4799
|
"",
|
|
4425
4800
|
"Restart Pi to activate the extension."
|
|
4426
4801
|
].join(`
|
|
4802
|
+
`);
|
|
4803
|
+
} else if (agentId === "hermes") {
|
|
4804
|
+
return [
|
|
4805
|
+
"Install the Agent Approve plugin for Hermes:",
|
|
4806
|
+
"",
|
|
4807
|
+
" npx agentapprove # automatic: download + verify + extract + enable",
|
|
4808
|
+
"",
|
|
4809
|
+
`Manual: download ${HERMES_BUNDLE_FILENAME} from your Agent Approve API,`,
|
|
4810
|
+
`verify SHA-256 matches ${HERMES_BUNDLE_SHA256.slice(0, 16)}…, extract into`,
|
|
4811
|
+
`~/.hermes/plugins/agentapprove/, write the SHA-256 to a .bundle-hash sidecar`,
|
|
4812
|
+
"in that directory, then:",
|
|
4813
|
+
"",
|
|
4814
|
+
" pip install --user cryptography httpx",
|
|
4815
|
+
" hermes plugins enable agentapprove",
|
|
4816
|
+
"",
|
|
4817
|
+
"The plugin reads your existing Agent Approve config from ~/.agentapprove/env.",
|
|
4818
|
+
"",
|
|
4819
|
+
"Hermes loads the plugin on the next session — no restart needed."
|
|
4820
|
+
].join(`
|
|
4427
4821
|
`);
|
|
4428
4822
|
}
|
|
4429
4823
|
return "";
|
|
4430
4824
|
}
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
} else {
|
|
4453
|
-
failed.push(`${file} (${response.status})`);
|
|
4454
|
-
}
|
|
4455
|
-
} catch (err) {
|
|
4456
|
-
failed.push(`${file} (${err instanceof Error ? err.message : "network error"})`);
|
|
4457
|
-
}
|
|
4458
|
-
}
|
|
4459
|
-
return { downloaded, failed };
|
|
4825
|
+
function describeDownloadTerminalFailure(kind) {
|
|
4826
|
+
switch (kind) {
|
|
4827
|
+
case "token_expired":
|
|
4828
|
+
return "Hook download stopped: your saved token has expired. Run `npx agentapprove refresh` to re-pair, or run the installer again to choose Refresh / Re-pair.";
|
|
4829
|
+
case "token_invalid":
|
|
4830
|
+
return "Hook download stopped: your saved token is no longer valid. Run `npx agentapprove pair` to pair this computer again.";
|
|
4831
|
+
case "scope_denied":
|
|
4832
|
+
return "Hook download stopped: your saved token cannot download hooks. Run `npx agentapprove pair` to pair this computer again.";
|
|
4833
|
+
case "validation_unavailable":
|
|
4834
|
+
return "Hook download stopped: Agent Approve could not validate your token right now. Please try again in a few minutes.";
|
|
4835
|
+
case "network_blocked":
|
|
4836
|
+
return "Hook download stopped: Service temporarily unavailable. Try again later.";
|
|
4837
|
+
case "recoverable":
|
|
4838
|
+
return "Hook download stopped: encountered an unexpected response.";
|
|
4839
|
+
}
|
|
4840
|
+
}
|
|
4841
|
+
async function copyHookScripts2(hooksDir, token, files, options = {}) {
|
|
4842
|
+
return copyHookScripts(hooksDir, token, files, {
|
|
4843
|
+
apiUrl: options.apiUrl || API_URL,
|
|
4844
|
+
apiVersion: API_VERSION
|
|
4845
|
+
});
|
|
4460
4846
|
}
|
|
4461
4847
|
function readOpenClawInstalledVersion() {
|
|
4462
4848
|
const packagePath = join(homedir(), ".openclaw", "extensions", "openclaw", "package.json");
|
|
@@ -4603,6 +4989,346 @@ function removePiPluginViaCli() {
|
|
|
4603
4989
|
};
|
|
4604
4990
|
}
|
|
4605
4991
|
}
|
|
4992
|
+
var HERMES_BUNDLE_SHA256 = "2572cec0a2d8467e9ffc2000e5c52cf652265d20638244a8a70e2e3a6c9d2734";
|
|
4993
|
+
var HERMES_BUNDLE_FILENAME = `agentapprove-hermes-${HERMES_PLUGIN_VERSION}.tar.gz`;
|
|
4994
|
+
var HERMES_PLUGIN_DIR = join(homedir(), ".hermes", "plugins", "agentapprove");
|
|
4995
|
+
function findPython() {
|
|
4996
|
+
for (const candidate of ["python3.12", "python3.11", "python3.10", "python3"]) {
|
|
4997
|
+
try {
|
|
4998
|
+
const out = execSync(`${candidate} -c "import sys; print('%d.%d' % sys.version_info[:2])"`, {
|
|
4999
|
+
encoding: "utf-8",
|
|
5000
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
5001
|
+
}).trim();
|
|
5002
|
+
const [major, minor] = out.split(".").map(Number);
|
|
5003
|
+
if (major === 3 && minor >= 10) {
|
|
5004
|
+
return { command: candidate, version: out };
|
|
5005
|
+
}
|
|
5006
|
+
} catch {
|
|
5007
|
+
continue;
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
5010
|
+
return null;
|
|
5011
|
+
}
|
|
5012
|
+
function isSafeApiUrl(candidate) {
|
|
5013
|
+
try {
|
|
5014
|
+
const parsed = new URL(candidate);
|
|
5015
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
5016
|
+
return false;
|
|
5017
|
+
if (!parsed.hostname)
|
|
5018
|
+
return false;
|
|
5019
|
+
return true;
|
|
5020
|
+
} catch {
|
|
5021
|
+
return false;
|
|
5022
|
+
}
|
|
5023
|
+
}
|
|
5024
|
+
function isSafeApiVersion(candidate) {
|
|
5025
|
+
return /^[a-z0-9-]+$/i.test(candidate);
|
|
5026
|
+
}
|
|
5027
|
+
function readInstallerConfig() {
|
|
5028
|
+
const envPath = join(getAgentApproveDir(), "env");
|
|
5029
|
+
let apiUrl = API_URL;
|
|
5030
|
+
let apiVersion = "v001";
|
|
5031
|
+
let token = "";
|
|
5032
|
+
if (existsSync2(envPath)) {
|
|
5033
|
+
try {
|
|
5034
|
+
const content = readFileSync(envPath, "utf-8");
|
|
5035
|
+
for (const line of content.split(`
|
|
5036
|
+
`)) {
|
|
5037
|
+
if (line.startsWith("#"))
|
|
5038
|
+
continue;
|
|
5039
|
+
const assignment = parseEnvAssignment(line);
|
|
5040
|
+
if (!assignment)
|
|
5041
|
+
continue;
|
|
5042
|
+
if (assignment.key === "AGENTAPPROVE_TOKEN") {
|
|
5043
|
+
token = assignment.value;
|
|
5044
|
+
} else if (assignment.key === "AGENTAPPROVE_API") {
|
|
5045
|
+
const normalized = assignment.value.replace(/\/+$/, "");
|
|
5046
|
+
if (isSafeApiUrl(normalized)) {
|
|
5047
|
+
apiUrl = normalized;
|
|
5048
|
+
}
|
|
5049
|
+
} else if (assignment.key === "AGENTAPPROVE_API_VERSION") {
|
|
5050
|
+
const normalized = assignment.value.replace(/^\/+|\/+$/g, "");
|
|
5051
|
+
if (isSafeApiVersion(normalized)) {
|
|
5052
|
+
apiVersion = normalized;
|
|
5053
|
+
}
|
|
5054
|
+
}
|
|
5055
|
+
}
|
|
5056
|
+
} catch {}
|
|
5057
|
+
}
|
|
5058
|
+
return { apiUrl: apiUrl.replace(/\/+$/, ""), apiVersion, token };
|
|
5059
|
+
}
|
|
5060
|
+
var HERMES_BUNDLE_MAX_BYTES = 10 * 1024 * 1024;
|
|
5061
|
+
async function downloadHermesBundle(apiUrl, apiVersion, token) {
|
|
5062
|
+
if (!isSafeApiUrl(apiUrl) || !isSafeApiVersion(apiVersion)) {
|
|
5063
|
+
return { ok: false, error: `Refusing to download bundle: invalid API URL or version in ~/.agentapprove/env` };
|
|
5064
|
+
}
|
|
5065
|
+
let url;
|
|
5066
|
+
try {
|
|
5067
|
+
url = new URL(`${apiVersion}/hooks/${encodeURIComponent(HERMES_BUNDLE_FILENAME)}`, apiUrl.endsWith("/") ? apiUrl : apiUrl + "/");
|
|
5068
|
+
} catch (err) {
|
|
5069
|
+
return { ok: false, error: `Could not build download URL: ${err instanceof Error ? err.message : String(err)}` };
|
|
5070
|
+
}
|
|
5071
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
5072
|
+
return { ok: false, error: `Refusing to download bundle: non-http(s) URL ${url.protocol}` };
|
|
5073
|
+
}
|
|
5074
|
+
let response;
|
|
5075
|
+
try {
|
|
5076
|
+
response = await fetch(url.toString(), {
|
|
5077
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
5078
|
+
});
|
|
5079
|
+
} catch (err) {
|
|
5080
|
+
return { ok: false, error: `Network error downloading ${HERMES_BUNDLE_FILENAME}: ${err instanceof Error ? err.message : String(err)}` };
|
|
5081
|
+
}
|
|
5082
|
+
if (!response.ok) {
|
|
5083
|
+
return { ok: false, error: `Bundle download returned ${response.status} from ${url}` };
|
|
5084
|
+
}
|
|
5085
|
+
const declared = parseInt(response.headers.get("content-length") || "", 10);
|
|
5086
|
+
if (Number.isFinite(declared) && declared > HERMES_BUNDLE_MAX_BYTES) {
|
|
5087
|
+
return { ok: false, error: `Bundle exceeds ${HERMES_BUNDLE_MAX_BYTES} byte limit (server reported ${declared}). Refusing to download.` };
|
|
5088
|
+
}
|
|
5089
|
+
try {
|
|
5090
|
+
const buffer = await response.arrayBuffer();
|
|
5091
|
+
if (buffer.byteLength > HERMES_BUNDLE_MAX_BYTES) {
|
|
5092
|
+
return { ok: false, error: `Bundle body exceeded ${HERMES_BUNDLE_MAX_BYTES} byte limit (got ${buffer.byteLength}).` };
|
|
5093
|
+
}
|
|
5094
|
+
return { ok: true, bytes: new Uint8Array(buffer) };
|
|
5095
|
+
} catch (err) {
|
|
5096
|
+
return { ok: false, error: `Failed to read bundle body: ${err instanceof Error ? err.message : String(err)}` };
|
|
5097
|
+
}
|
|
5098
|
+
}
|
|
5099
|
+
function verifyBundleHash(bytes) {
|
|
5100
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
5101
|
+
}
|
|
5102
|
+
function validateExtractedTree(root) {
|
|
5103
|
+
const realRoot = realpathSync2(root);
|
|
5104
|
+
const stack = [root];
|
|
5105
|
+
while (stack.length > 0) {
|
|
5106
|
+
const dir = stack.pop();
|
|
5107
|
+
let entries;
|
|
5108
|
+
try {
|
|
5109
|
+
entries = readdirSync2(dir, { withFileTypes: true });
|
|
5110
|
+
} catch (err) {
|
|
5111
|
+
return { ok: false, error: `Cannot read staged dir ${dir}: ${err instanceof Error ? err.message : String(err)}` };
|
|
5112
|
+
}
|
|
5113
|
+
for (const entry of entries) {
|
|
5114
|
+
const fullPath = join(dir, String(entry.name));
|
|
5115
|
+
let resolved;
|
|
5116
|
+
try {
|
|
5117
|
+
resolved = realpathSync2(fullPath);
|
|
5118
|
+
} catch (err) {
|
|
5119
|
+
return { ok: false, error: `Cannot resolve ${fullPath}: ${err instanceof Error ? err.message : String(err)}` };
|
|
5120
|
+
}
|
|
5121
|
+
if (resolved !== realRoot && !resolved.startsWith(realRoot + sep2)) {
|
|
5122
|
+
return { ok: false, error: `Bundle entry escapes staging dir: ${String(entry.name)} → ${resolved}` };
|
|
5123
|
+
}
|
|
5124
|
+
if (!entry.isFile() && !entry.isDirectory()) {
|
|
5125
|
+
return { ok: false, error: `Bundle contains non-regular entry: ${String(entry.name)} (${entry.isSymbolicLink() ? "symlink" : "other"})` };
|
|
5126
|
+
}
|
|
5127
|
+
if (entry.isDirectory()) {
|
|
5128
|
+
stack.push(fullPath);
|
|
5129
|
+
}
|
|
5130
|
+
}
|
|
5131
|
+
}
|
|
5132
|
+
return { ok: true };
|
|
5133
|
+
}
|
|
5134
|
+
function hermesDepsAlreadyInstalled(pythonCommand) {
|
|
5135
|
+
try {
|
|
5136
|
+
execSync(`${pythonCommand} -c "import cryptography, httpx"`, {
|
|
5137
|
+
stdio: "pipe",
|
|
5138
|
+
timeout: 5000
|
|
5139
|
+
});
|
|
5140
|
+
return true;
|
|
5141
|
+
} catch {
|
|
5142
|
+
return false;
|
|
5143
|
+
}
|
|
5144
|
+
}
|
|
5145
|
+
async function installHermesPluginViaCli() {
|
|
5146
|
+
try {
|
|
5147
|
+
execSync("hermes --version", { stdio: "pipe" });
|
|
5148
|
+
} catch {
|
|
5149
|
+
return {
|
|
5150
|
+
success: false,
|
|
5151
|
+
label: "Agent Approve plugin",
|
|
5152
|
+
error: "Hermes CLI not found on PATH. Install Hermes from https://hermes-agent.nousresearch.com first, then re-run npx agentapprove."
|
|
5153
|
+
};
|
|
5154
|
+
}
|
|
5155
|
+
const python = findPython();
|
|
5156
|
+
if (!python) {
|
|
5157
|
+
return {
|
|
5158
|
+
success: false,
|
|
5159
|
+
label: "Agent Approve plugin",
|
|
5160
|
+
error: "Python 3.10+ not found on PATH. Install Python 3.10 or newer (e.g. `brew install python@3.12`) and re-run npx agentapprove."
|
|
5161
|
+
};
|
|
5162
|
+
}
|
|
5163
|
+
const cfg = readInstallerConfig();
|
|
5164
|
+
if (!cfg.token) {
|
|
5165
|
+
return {
|
|
5166
|
+
success: false,
|
|
5167
|
+
label: "Agent Approve plugin",
|
|
5168
|
+
error: "Missing API token. Pair your phone first by running `npx agentapprove` and completing the QR scan."
|
|
5169
|
+
};
|
|
5170
|
+
}
|
|
5171
|
+
const download = await downloadHermesBundle(cfg.apiUrl, cfg.apiVersion, cfg.token);
|
|
5172
|
+
if (!download.ok) {
|
|
5173
|
+
return { success: false, label: "Agent Approve plugin", error: download.error };
|
|
5174
|
+
}
|
|
5175
|
+
const actualHash = verifyBundleHash(download.bytes);
|
|
5176
|
+
if (actualHash !== HERMES_BUNDLE_SHA256) {
|
|
5177
|
+
return {
|
|
5178
|
+
success: false,
|
|
5179
|
+
label: "Agent Approve plugin",
|
|
5180
|
+
error: `Bundle integrity check failed: expected ${HERMES_BUNDLE_SHA256.slice(0, 16)}…, got ${actualHash.slice(0, 16)}…. Refusing to install.`
|
|
5181
|
+
};
|
|
5182
|
+
}
|
|
5183
|
+
let tmpDir;
|
|
5184
|
+
try {
|
|
5185
|
+
tmpDir = mkdtempSync(join(tmpdir(), "aa-hermes-"));
|
|
5186
|
+
} catch (err) {
|
|
5187
|
+
return {
|
|
5188
|
+
success: false,
|
|
5189
|
+
label: "Agent Approve plugin",
|
|
5190
|
+
error: `Could not create temp directory for bundle staging: ${err instanceof Error ? err.message : String(err)}`
|
|
5191
|
+
};
|
|
5192
|
+
}
|
|
5193
|
+
const tarballPath = join(tmpDir, HERMES_BUNDLE_FILENAME);
|
|
5194
|
+
let backupPath = null;
|
|
5195
|
+
try {
|
|
5196
|
+
writeFileSync(tarballPath, download.bytes);
|
|
5197
|
+
try {
|
|
5198
|
+
execSync(`tar -xzf "${tarballPath}" -C "${tmpDir}" --no-same-owner --no-same-permissions`, { stdio: "pipe" });
|
|
5199
|
+
} catch (err) {
|
|
5200
|
+
return {
|
|
5201
|
+
success: false,
|
|
5202
|
+
label: "Agent Approve plugin",
|
|
5203
|
+
error: `tar extract failed: ${err instanceof Error ? err.message : String(err)}`
|
|
5204
|
+
};
|
|
5205
|
+
}
|
|
5206
|
+
const stagedDir = join(tmpDir, "agentapprove");
|
|
5207
|
+
if (!existsSync2(stagedDir)) {
|
|
5208
|
+
return {
|
|
5209
|
+
success: false,
|
|
5210
|
+
label: "Agent Approve plugin",
|
|
5211
|
+
error: "Bundle did not contain expected `agentapprove/` directory."
|
|
5212
|
+
};
|
|
5213
|
+
}
|
|
5214
|
+
const validation = validateExtractedTree(stagedDir);
|
|
5215
|
+
if (!validation.ok) {
|
|
5216
|
+
return {
|
|
5217
|
+
success: false,
|
|
5218
|
+
label: "Agent Approve plugin",
|
|
5219
|
+
error: `Bundle integrity violation: ${validation.error}`
|
|
5220
|
+
};
|
|
5221
|
+
}
|
|
5222
|
+
mkdirSync(dirname2(HERMES_PLUGIN_DIR), { recursive: true });
|
|
5223
|
+
if (existsSync2(HERMES_PLUGIN_DIR)) {
|
|
5224
|
+
backupPath = `${HERMES_PLUGIN_DIR}.bak-${Date.now()}`;
|
|
5225
|
+
renameSync(HERMES_PLUGIN_DIR, backupPath);
|
|
5226
|
+
}
|
|
5227
|
+
try {
|
|
5228
|
+
renameSync(stagedDir, HERMES_PLUGIN_DIR);
|
|
5229
|
+
} catch (err) {
|
|
5230
|
+
if (backupPath) {
|
|
5231
|
+
try {
|
|
5232
|
+
renameSync(backupPath, HERMES_PLUGIN_DIR);
|
|
5233
|
+
backupPath = null;
|
|
5234
|
+
} catch {}
|
|
5235
|
+
}
|
|
5236
|
+
const restoreNote = backupPath ? ` Your previous install was preserved at ${backupPath} — recover with: mv "${backupPath}" "${HERMES_PLUGIN_DIR}"` : "";
|
|
5237
|
+
return {
|
|
5238
|
+
success: false,
|
|
5239
|
+
label: "Agent Approve plugin",
|
|
5240
|
+
error: `Could not install bundle to ${HERMES_PLUGIN_DIR}: ${err instanceof Error ? err.message : String(err)}.${restoreNote}`
|
|
5241
|
+
};
|
|
5242
|
+
}
|
|
5243
|
+
writeFileSync(join(HERMES_PLUGIN_DIR, ".bundle-hash"), HERMES_BUNDLE_SHA256, { mode: 420 });
|
|
5244
|
+
if (backupPath) {
|
|
5245
|
+
rmSync2(backupPath, { recursive: true, force: true });
|
|
5246
|
+
backupPath = null;
|
|
5247
|
+
}
|
|
5248
|
+
} catch (err) {
|
|
5249
|
+
if (backupPath) {
|
|
5250
|
+
try {
|
|
5251
|
+
rmSync2(HERMES_PLUGIN_DIR, { recursive: true, force: true });
|
|
5252
|
+
renameSync(backupPath, HERMES_PLUGIN_DIR);
|
|
5253
|
+
backupPath = null;
|
|
5254
|
+
} catch {}
|
|
5255
|
+
}
|
|
5256
|
+
const restoreNote = backupPath ? ` Your previous install was preserved at ${backupPath} — recover with: mv "${backupPath}" "${HERMES_PLUGIN_DIR}"` : "";
|
|
5257
|
+
return {
|
|
5258
|
+
success: false,
|
|
5259
|
+
label: "Agent Approve plugin",
|
|
5260
|
+
error: `Bundle install failed: ${err instanceof Error ? err.message : String(err)}.${restoreNote}`
|
|
5261
|
+
};
|
|
5262
|
+
} finally {
|
|
5263
|
+
try {
|
|
5264
|
+
rmSync2(tmpDir, { recursive: true, force: true });
|
|
5265
|
+
} catch {}
|
|
5266
|
+
if (backupPath && existsSync2(HERMES_PLUGIN_DIR)) {
|
|
5267
|
+
try {
|
|
5268
|
+
rmSync2(backupPath, { recursive: true, force: true });
|
|
5269
|
+
} catch {}
|
|
5270
|
+
}
|
|
5271
|
+
}
|
|
5272
|
+
if (!hermesDepsAlreadyInstalled(python.command)) {
|
|
5273
|
+
const depsArg = HERMES_PIP_DEPS.map((d2) => `"${d2}"`).join(" ");
|
|
5274
|
+
try {
|
|
5275
|
+
execSync(`${python.command} -m pip install --user --quiet ${depsArg}`, { stdio: "pipe" });
|
|
5276
|
+
} catch (err) {
|
|
5277
|
+
return {
|
|
5278
|
+
success: false,
|
|
5279
|
+
label: "Agent Approve plugin",
|
|
5280
|
+
error: `Installed bundle but could not install Python runtime deps: ${err instanceof Error ? err.message : String(err)}. ` + `Try manually: ${python.command} -m pip install --user ${HERMES_PIP_DEPS.join(" ")}`
|
|
5281
|
+
};
|
|
5282
|
+
}
|
|
5283
|
+
}
|
|
5284
|
+
try {
|
|
5285
|
+
execSync("hermes plugins enable agentapprove", { stdio: "pipe" });
|
|
5286
|
+
} catch (err) {
|
|
5287
|
+
return {
|
|
5288
|
+
success: false,
|
|
5289
|
+
label: "Agent Approve plugin",
|
|
5290
|
+
error: `Installed bundle but could not enable plugin in Hermes: ${err instanceof Error ? err.message : String(err)}. ` + `Try manually: hermes plugins enable agentapprove`
|
|
5291
|
+
};
|
|
5292
|
+
}
|
|
5293
|
+
return { success: true, label: `Agent Approve plugin ${HERMES_PLUGIN_VERSION}` };
|
|
5294
|
+
}
|
|
5295
|
+
function isHermesDisableNoOp(err) {
|
|
5296
|
+
if (!err || typeof err !== "object")
|
|
5297
|
+
return false;
|
|
5298
|
+
const e2 = err;
|
|
5299
|
+
const stderr = Buffer.isBuffer(e2.stderr) ? e2.stderr.toString() : String(e2.stderr || "");
|
|
5300
|
+
const stdout = Buffer.isBuffer(e2.stdout) ? e2.stdout.toString() : String(e2.stdout || "");
|
|
5301
|
+
const haystack = `${e2.message || ""}
|
|
5302
|
+
${stderr}
|
|
5303
|
+
${stdout}`.toLowerCase();
|
|
5304
|
+
return haystack.includes("not enabled") || haystack.includes("not installed") || haystack.includes("is not enabled") || haystack.includes("plugin agentapprove not found") || haystack.includes("plugin 'agentapprove' not found");
|
|
5305
|
+
}
|
|
5306
|
+
function disableHermesPluginViaCli() {
|
|
5307
|
+
try {
|
|
5308
|
+
execSync("hermes plugins disable agentapprove", { stdio: "pipe" });
|
|
5309
|
+
return { success: true };
|
|
5310
|
+
} catch (err) {
|
|
5311
|
+
if (isHermesDisableNoOp(err)) {
|
|
5312
|
+
return { success: true, alreadyDisabled: true };
|
|
5313
|
+
}
|
|
5314
|
+
return {
|
|
5315
|
+
success: false,
|
|
5316
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5317
|
+
};
|
|
5318
|
+
}
|
|
5319
|
+
}
|
|
5320
|
+
function removeHermesPlugin() {
|
|
5321
|
+
const disabled = disableHermesPluginViaCli();
|
|
5322
|
+
try {
|
|
5323
|
+
rmSync2(HERMES_PLUGIN_DIR, { recursive: true, force: true });
|
|
5324
|
+
return disabled.success ? { success: true } : { success: false, error: `disable failed: ${disabled.error || "unknown"}` };
|
|
5325
|
+
} catch (err) {
|
|
5326
|
+
return {
|
|
5327
|
+
success: false,
|
|
5328
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5329
|
+
};
|
|
5330
|
+
}
|
|
5331
|
+
}
|
|
4606
5332
|
var SYSTEM_DEPS = [
|
|
4607
5333
|
{
|
|
4608
5334
|
name: "curl",
|
|
@@ -4810,16 +5536,107 @@ async function checkSystemDependencies() {
|
|
|
4810
5536
|
}
|
|
4811
5537
|
return true;
|
|
4812
5538
|
}
|
|
5539
|
+
async function runExistingTokenPreflight(token, apiUrlForPreflight) {
|
|
5540
|
+
const preflightSpinner = _2();
|
|
5541
|
+
preflightSpinner.start("Checking saved token");
|
|
5542
|
+
let result = await validateExistingToken(token, apiUrlForPreflight, API_VERSION);
|
|
5543
|
+
if (result.kind === "unavailable" || result.kind === "network_error") {
|
|
5544
|
+
const message = result.kind === "unavailable" ? "Token validation is temporarily unavailable." : "Could not reach Agent Approve from this network.";
|
|
5545
|
+
preflightSpinner.stop(message);
|
|
5546
|
+
const retryChoice = await le({
|
|
5547
|
+
message: "How would you like to continue?",
|
|
5548
|
+
options: [
|
|
5549
|
+
{ value: "retry", label: "Retry" },
|
|
5550
|
+
{ value: "cancel", label: "Cancel" }
|
|
5551
|
+
]
|
|
5552
|
+
});
|
|
5553
|
+
if (lD(retryChoice) || retryChoice === "cancel") {
|
|
5554
|
+
he("Installation cancelled");
|
|
5555
|
+
process.exit(0);
|
|
5556
|
+
}
|
|
5557
|
+
preflightSpinner.start("Checking saved token");
|
|
5558
|
+
result = await validateExistingToken(token, apiUrlForPreflight, API_VERSION);
|
|
5559
|
+
}
|
|
5560
|
+
switch (result.kind) {
|
|
5561
|
+
case "valid":
|
|
5562
|
+
preflightSpinner.stop("Saved token verified");
|
|
5563
|
+
break;
|
|
5564
|
+
case "expired":
|
|
5565
|
+
preflightSpinner.stop("Saved token has expired");
|
|
5566
|
+
break;
|
|
5567
|
+
case "invalid":
|
|
5568
|
+
case "scope_denied":
|
|
5569
|
+
preflightSpinner.stop("Saved token is no longer valid");
|
|
5570
|
+
break;
|
|
5571
|
+
case "network_blocked":
|
|
5572
|
+
preflightSpinner.stop("Service temporarily unavailable");
|
|
5573
|
+
break;
|
|
5574
|
+
default:
|
|
5575
|
+
preflightSpinner.stop("Saved token check finished");
|
|
5576
|
+
}
|
|
5577
|
+
return result;
|
|
5578
|
+
}
|
|
5579
|
+
function describeExistingTokenStatus(preflight) {
|
|
5580
|
+
if (!preflight) {
|
|
5581
|
+
return "already linked to your Agent Approve account";
|
|
5582
|
+
}
|
|
5583
|
+
switch (preflight.kind) {
|
|
5584
|
+
case "valid":
|
|
5585
|
+
return "verified - linked to your Agent Approve account";
|
|
5586
|
+
case "expired":
|
|
5587
|
+
return "expired (refresh required)";
|
|
5588
|
+
case "invalid":
|
|
5589
|
+
return "invalid (re-pair required)";
|
|
5590
|
+
case "scope_denied":
|
|
5591
|
+
return "scope denied (re-pair required)";
|
|
5592
|
+
case "unavailable":
|
|
5593
|
+
return "validation temporarily unavailable";
|
|
5594
|
+
case "network_blocked":
|
|
5595
|
+
return "cannot reach service from this network";
|
|
5596
|
+
case "network_error":
|
|
5597
|
+
return "cannot reach Agent Approve from this network";
|
|
5598
|
+
}
|
|
5599
|
+
}
|
|
5600
|
+
async function promptTokenRecoveryAction(preflight) {
|
|
5601
|
+
if (preflight.kind === "expired") {
|
|
5602
|
+
const choice2 = await le({
|
|
5603
|
+
message: "Your saved token has expired. How would you like to continue?",
|
|
5604
|
+
options: [
|
|
5605
|
+
{ value: "refresh", label: "Refresh token", hint: "Keeps the existing encryption key and current settings" },
|
|
5606
|
+
{ value: "repair", label: "Pair this computer again", hint: "You can rotate the encryption key" },
|
|
5607
|
+
{ value: "cancel", label: "Cancel", hint: "Exit the installer" }
|
|
5608
|
+
]
|
|
5609
|
+
});
|
|
5610
|
+
if (lD(choice2))
|
|
5611
|
+
return "cancel";
|
|
5612
|
+
return choice2;
|
|
5613
|
+
}
|
|
5614
|
+
const choice = await le({
|
|
5615
|
+
message: "Your saved token is no longer valid. How would you like to continue?",
|
|
5616
|
+
options: [
|
|
5617
|
+
{ value: "repair", label: "Pair this computer again" },
|
|
5618
|
+
{ value: "cancel", label: "Cancel", hint: "Exit the installer" }
|
|
5619
|
+
]
|
|
5620
|
+
});
|
|
5621
|
+
if (lD(choice))
|
|
5622
|
+
return "cancel";
|
|
5623
|
+
return choice;
|
|
5624
|
+
}
|
|
4813
5625
|
async function installCommand() {
|
|
4814
5626
|
console.clear();
|
|
4815
5627
|
pe(source_default.bgCyan.black(" Agent Approve ") + source_default.gray(` Hooks Installer v${VERSION}`));
|
|
4816
5628
|
migrateE2ERootKey();
|
|
4817
|
-
const isCustomApi = !API_URL.includes("agentapprove.com");
|
|
4818
|
-
if (isCustomApi) {
|
|
4819
|
-
v2.warn(`Using custom API: ${API_URL}`);
|
|
4820
|
-
}
|
|
4821
5629
|
await checkSystemDependencies();
|
|
4822
5630
|
const existingConfig = readExistingConfig();
|
|
5631
|
+
const pairingApiBase = resolvePairingApiBaseUrl({
|
|
5632
|
+
agentapproveApiEnv: process.env.AGENTAPPROVE_API,
|
|
5633
|
+
savedApiUrl: existingConfig?.apiUrl,
|
|
5634
|
+
processDefaultApiUrl: API_URL
|
|
5635
|
+
});
|
|
5636
|
+
const isCustomApi = !looksLikeHostedAgentApproveApi(pairingApiBase);
|
|
5637
|
+
if (isCustomApi) {
|
|
5638
|
+
v2.warn(`Using custom API: ${pairingApiBase}`);
|
|
5639
|
+
}
|
|
4823
5640
|
const hasExistingToken = !!(existingConfig?.token && existingConfig.token.length > 10);
|
|
4824
5641
|
let existingTokenPreview = "Not set";
|
|
4825
5642
|
let existingKeyId = null;
|
|
@@ -4831,8 +5648,14 @@ async function installCommand() {
|
|
|
4831
5648
|
existingKeyId = createHash("sha256").update(Buffer.from(keyHex, "hex")).digest("hex").slice(0, 8);
|
|
4832
5649
|
}
|
|
4833
5650
|
}
|
|
5651
|
+
const existingTokenPreflight = hasExistingToken ? await runExistingTokenPreflight(existingConfig.token, pairingApiBase) : null;
|
|
5652
|
+
if (existingTokenPreflight?.kind === "network_blocked") {
|
|
5653
|
+
v2.error("Service temporarily unavailable. Try again later.");
|
|
5654
|
+
he("Installation cancelled");
|
|
5655
|
+
process.exit(1);
|
|
5656
|
+
}
|
|
4834
5657
|
me(`Approve AI agent actions from your iPhone or Apple Watch.
|
|
4835
|
-
Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw, and more.`, "About");
|
|
5658
|
+
Installs hooks and extensions for Cursor, Windsurf, Claude, Gemini, Pi, OpenCode, OpenClaw, and more.`, "About");
|
|
4836
5659
|
const installedAgents = detectInstalledAgents();
|
|
4837
5660
|
const agentOptions = Object.entries(AGENTS).map(([id, agent]) => ({
|
|
4838
5661
|
value: id,
|
|
@@ -4850,7 +5673,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
4850
5673
|
process.exit(0);
|
|
4851
5674
|
}
|
|
4852
5675
|
const setupProfileSummary = existingConfig ? formatSetupProfileBlock("Existing config", getInitialInstallConfig("existing-config", existingConfig), [
|
|
4853
|
-
`Connection token: ${existingTokenPreview} -
|
|
5676
|
+
`Connection token: ${existingTokenPreview} - ${describeExistingTokenStatus(existingTokenPreflight)}.`,
|
|
4854
5677
|
...existingKeyId ? [`Encryption key ID: ${existingKeyId} - already installed for this computer.`] : []
|
|
4855
5678
|
]) : formatSetupProfileBlock("Recommended setup", getInitialInstallConfig("recommended", existingConfig));
|
|
4856
5679
|
me(setupProfileSummary, "Setup profiles");
|
|
@@ -4862,13 +5685,23 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
4862
5685
|
he("Installation cancelled");
|
|
4863
5686
|
process.exit(0);
|
|
4864
5687
|
}
|
|
5688
|
+
let pendingRecoveryAction = null;
|
|
5689
|
+
if (hasExistingToken && existingTokenPreflight && (existingTokenPreflight.kind === "expired" || existingTokenPreflight.kind === "invalid" || existingTokenPreflight.kind === "scope_denied")) {
|
|
5690
|
+
pendingRecoveryAction = await promptTokenRecoveryAction(existingTokenPreflight);
|
|
5691
|
+
if (pendingRecoveryAction === "cancel") {
|
|
5692
|
+
he("Installation cancelled");
|
|
5693
|
+
process.exit(0);
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
const requiresFreshPair = pendingRecoveryAction !== null;
|
|
5697
|
+
const forceKeyReuseForRecovery = pendingRecoveryAction === "refresh";
|
|
4865
5698
|
ensureAgentApproveDir();
|
|
4866
5699
|
const hooksDir = join(getAgentApproveDir(), "hooks");
|
|
4867
5700
|
const selectedInstallConfig = getInitialInstallConfig(setupProfile, existingConfig);
|
|
4868
5701
|
let token = null;
|
|
4869
5702
|
let finalPrivacy = selectedInstallConfig.privacy;
|
|
4870
5703
|
let email = "";
|
|
4871
|
-
let apiUrl =
|
|
5704
|
+
let apiUrl = pairingApiBase;
|
|
4872
5705
|
let debugLog = selectedInstallConfig.debugLog;
|
|
4873
5706
|
let retentionDays = selectedInstallConfig.retentionDays;
|
|
4874
5707
|
let failBehavior = selectedInstallConfig.failBehavior;
|
|
@@ -4956,7 +5789,8 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
4956
5789
|
if (existsSync2(existingKeyPath)) {
|
|
4957
5790
|
const oldKeyHex = readFileSync(existingKeyPath, "utf-8").trim();
|
|
4958
5791
|
const oldKeyId = createHash("sha256").update(Buffer.from(oldKeyHex, "hex")).digest("hex").slice(0, 8);
|
|
4959
|
-
|
|
5792
|
+
const autoReuseKey = forceKeyReuseForRecovery || setupProfile === "existing-config" && !requiresFreshPair;
|
|
5793
|
+
if (autoReuseKey) {
|
|
4960
5794
|
e2eUserKey = oldKeyHex;
|
|
4961
5795
|
} else {
|
|
4962
5796
|
v2.info(`Existing E2E key found (Key ID: ${oldKeyId})`);
|
|
@@ -5014,9 +5848,10 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
5014
5848
|
}
|
|
5015
5849
|
debugLog = debugLogChoice;
|
|
5016
5850
|
}
|
|
5017
|
-
const
|
|
5851
|
+
const canReuseExistingToken = hasExistingToken && !requiresFreshPair;
|
|
5852
|
+
const silentlyReuseExistingToken = setupProfile === "existing-config" && canReuseExistingToken;
|
|
5018
5853
|
const connectionOptions = [];
|
|
5019
|
-
if (
|
|
5854
|
+
if (canReuseExistingToken) {
|
|
5020
5855
|
const tokenPreview = existingConfig.token.slice(0, 15) + "...";
|
|
5021
5856
|
connectionOptions.push({
|
|
5022
5857
|
value: "existing",
|
|
@@ -5024,10 +5859,20 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
5024
5859
|
hint: tokenPreview
|
|
5025
5860
|
});
|
|
5026
5861
|
}
|
|
5027
|
-
connectionOptions.push({ value: "qr", label: "Scan QR code", hint:
|
|
5862
|
+
connectionOptions.push({ value: "qr", label: "Scan QR code", hint: canReuseExistingToken ? undefined : "Recommended" }, { value: "copy", label: "Copy and paste code", hint: "No camera" });
|
|
5028
5863
|
let connectionMethod;
|
|
5029
5864
|
if (silentlyReuseExistingToken) {
|
|
5030
5865
|
connectionMethod = "existing";
|
|
5866
|
+
} else if (requiresFreshPair) {
|
|
5867
|
+
const connectionChoice = await le({
|
|
5868
|
+
message: pendingRecoveryAction === "refresh" ? "How would you like to refresh the token?" : "How would you like to pair this computer?",
|
|
5869
|
+
options: connectionOptions
|
|
5870
|
+
});
|
|
5871
|
+
if (lD(connectionChoice)) {
|
|
5872
|
+
he("Installation cancelled");
|
|
5873
|
+
process.exit(0);
|
|
5874
|
+
}
|
|
5875
|
+
connectionMethod = connectionChoice;
|
|
5031
5876
|
} else {
|
|
5032
5877
|
const connectionChoice = await le({
|
|
5033
5878
|
message: "Connect to iOS app (required for setup and any hook downloads)",
|
|
@@ -5041,7 +5886,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
5041
5886
|
}
|
|
5042
5887
|
if (connectionMethod === "existing") {
|
|
5043
5888
|
token = existingConfig.token;
|
|
5044
|
-
apiUrl =
|
|
5889
|
+
apiUrl = pairingApiBase;
|
|
5045
5890
|
if (setupProfile === "existing-config" && existingConfig?.configSetAt) {
|
|
5046
5891
|
configSetAt = existingConfig.configSetAt;
|
|
5047
5892
|
}
|
|
@@ -5067,7 +5912,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
5067
5912
|
configSetAt,
|
|
5068
5913
|
e2eEnabled: useE2E
|
|
5069
5914
|
};
|
|
5070
|
-
const session = await createPairingSession(selectedAgents, e2eKeyId);
|
|
5915
|
+
const session = await createPairingSession(selectedAgents, e2eKeyId, pairingApiBase);
|
|
5071
5916
|
if (!session || session.error) {
|
|
5072
5917
|
v2.error(`Failed to create pairing session: ${session?.error || "Unknown error"}`);
|
|
5073
5918
|
he("Cannot continue without token");
|
|
@@ -5093,7 +5938,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
5093
5938
|
const minutes = Math.floor(expiresIn / 60);
|
|
5094
5939
|
const seconds = expiresIn % 60;
|
|
5095
5940
|
pairingSpinner.message(`Waiting for iOS app... ${minutes}:${seconds.toString().padStart(2, "0")}`);
|
|
5096
|
-
}, () => {});
|
|
5941
|
+
}, () => {}, pairingApiBase);
|
|
5097
5942
|
if (result === "cancelled") {
|
|
5098
5943
|
pairingSpinner.stop("Cancelled");
|
|
5099
5944
|
he("Installation cancelled");
|
|
@@ -5102,6 +5947,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
5102
5947
|
token = result.token;
|
|
5103
5948
|
finalPrivacy = result.privacy;
|
|
5104
5949
|
email = result.email;
|
|
5950
|
+
apiUrl = result.apiUrl || pairingApiBase;
|
|
5105
5951
|
if (e2eUserKey && e2eKeyId) {
|
|
5106
5952
|
const rootKeyPath = join(agentApproveDir, "e2e-root-key");
|
|
5107
5953
|
writeFileSync(rootKeyPath, e2eUserKey, { mode: 384 });
|
|
@@ -5152,9 +5998,15 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
|
|
|
5152
5998
|
if (hookDownloadPlan.files.length > 0) {
|
|
5153
5999
|
const downloadSpinner = _2();
|
|
5154
6000
|
downloadSpinner.start("Downloading hook scripts");
|
|
5155
|
-
const downloadResult = await
|
|
6001
|
+
const downloadResult = await copyHookScripts2(hooksDir, token, hookDownloadPlan.files, { apiUrl });
|
|
5156
6002
|
const summary = formatHookDownloadSummary(hookDownloadPlan);
|
|
5157
|
-
if (downloadResult.
|
|
6003
|
+
if (downloadResult.terminalFailure) {
|
|
6004
|
+
downloadSpinner.stop(`Downloaded ${downloadResult.downloaded} of ${hookDownloadPlan.files.length} hook files (${summary})`);
|
|
6005
|
+
const message = describeDownloadTerminalFailure(downloadResult.terminalFailure.kind);
|
|
6006
|
+
v2.error(message);
|
|
6007
|
+
he("Hook download stopped before completion.");
|
|
6008
|
+
process.exit(1);
|
|
6009
|
+
} else if (downloadResult.failed.length > 0) {
|
|
5158
6010
|
downloadSpinner.stop(`Downloaded ${downloadResult.downloaded} of ${hookDownloadPlan.files.length} hook files (${summary})`);
|
|
5159
6011
|
v2.warn(`Failed to download: ${downloadResult.failed.join(", ")}`);
|
|
5160
6012
|
} else {
|
|
@@ -5217,6 +6069,9 @@ AGENTAPPROVE_E2E_MODE=${installMode}
|
|
|
5217
6069
|
if (agentId === "pi") {
|
|
5218
6070
|
filesToModify.push("Pi package registry (via `pi install`)");
|
|
5219
6071
|
}
|
|
6072
|
+
if (agentId === "hermes") {
|
|
6073
|
+
filesToModify.push(`Hermes plugin directory (download + verify ${HERMES_BUNDLE_FILENAME} → extract to ~/.hermes/plugins/agentapprove/ → \`hermes plugins enable agentapprove\`)`);
|
|
6074
|
+
}
|
|
5220
6075
|
if (agentId === "vscode-agent") {
|
|
5221
6076
|
const vsCodeVariants = findInstalledVSCodeVariants();
|
|
5222
6077
|
for (const { path: settingsPath, variant } of vsCodeVariants) {
|
|
@@ -5238,7 +6093,7 @@ Backups will be created with timestamp`, "Files to be modified");
|
|
|
5238
6093
|
for (const agentId of selectedAgents) {
|
|
5239
6094
|
const agent = AGENTS[agentId];
|
|
5240
6095
|
const spinner = _2();
|
|
5241
|
-
const spinnerMsg = agentId === "pi" ? `Installing ${agent.name} extension` : agentId === "openclaw" ? `Installing ${agent.name} plugin` : `Configuring ${agent.name}`;
|
|
6096
|
+
const spinnerMsg = agentId === "pi" ? `Installing ${agent.name} extension` : agentId === "openclaw" || agentId === "hermes" ? `Installing ${agent.name} plugin` : `Configuring ${agent.name}`;
|
|
5242
6097
|
spinner.start(spinnerMsg);
|
|
5243
6098
|
const result = await installHooksForAgent(agentId, hooksDir, installMode);
|
|
5244
6099
|
if (result.success) {
|
|
@@ -5279,6 +6134,17 @@ Backups will be created with timestamp`, "Files to be modified");
|
|
|
5279
6134
|
v2.warn(`Could not install ${PI_PLUGIN_SPEC} via Pi.
|
|
5280
6135
|
` + ` Error: ${result.error || "unknown"}
|
|
5281
6136
|
` + ` Install manually: pi install ${PI_PLUGIN_SPEC}
|
|
6137
|
+
` + ` Then re-run: npx agentapprove`);
|
|
6138
|
+
} else if (agentId === "hermes") {
|
|
6139
|
+
v2.warn(`Could not install Agent Approve plugin for Hermes.
|
|
6140
|
+
` + ` Error: ${result.error || "unknown"}
|
|
6141
|
+
` + ` Install manually:
|
|
6142
|
+
` + ` 1. Download ${HERMES_BUNDLE_FILENAME} from your Agent Approve API
|
|
6143
|
+
` + ` 2. Verify SHA-256 matches ${HERMES_BUNDLE_SHA256.slice(0, 16)}…
|
|
6144
|
+
` + ` 3. Extract into ~/.hermes/plugins/agentapprove/
|
|
6145
|
+
` + ` 4. Write the SHA-256 to ~/.hermes/plugins/agentapprove/.bundle-hash
|
|
6146
|
+
` + ` 5. pip install --user cryptography httpx
|
|
6147
|
+
` + ` 6. hermes plugins enable agentapprove
|
|
5282
6148
|
` + ` Then re-run: npx agentapprove`);
|
|
5283
6149
|
}
|
|
5284
6150
|
}
|
|
@@ -5354,6 +6220,22 @@ async function statusCommand() {
|
|
|
5354
6220
|
} catch {}
|
|
5355
6221
|
continue;
|
|
5356
6222
|
}
|
|
6223
|
+
if (agentId === "hermes") {
|
|
6224
|
+
try {
|
|
6225
|
+
const listOutput = execSync("hermes plugins list", {
|
|
6226
|
+
encoding: "utf-8",
|
|
6227
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
6228
|
+
timeout: PI_STATUS_TIMEOUT_MS
|
|
6229
|
+
});
|
|
6230
|
+
const enabled = /^.*agentapprove.*enabled/im.test(listOutput);
|
|
6231
|
+
if (enabled) {
|
|
6232
|
+
console.log(` ${source_default.green("✓")} ${agent.name}: Agent Approve plugin`);
|
|
6233
|
+
} else if (listOutput.includes("agentapprove")) {
|
|
6234
|
+
console.log(` ${source_default.yellow("⚠")} ${agent.name}: Agent Approve plugin installed but disabled (run: hermes plugins enable agentapprove)`);
|
|
6235
|
+
}
|
|
6236
|
+
} catch {}
|
|
6237
|
+
continue;
|
|
6238
|
+
}
|
|
5357
6239
|
if (existsSync2(agent.configPath)) {
|
|
5358
6240
|
const config = readJsonConfig(agent.configPath);
|
|
5359
6241
|
if (agentId === "opencode") {
|
|
@@ -5370,7 +6252,7 @@ async function statusCommand() {
|
|
|
5370
6252
|
}
|
|
5371
6253
|
continue;
|
|
5372
6254
|
}
|
|
5373
|
-
const hooksConfig = config[agent.hooksKey];
|
|
6255
|
+
const hooksConfig = agent.hooksKey === "" ? config : config[agent.hooksKey];
|
|
5374
6256
|
if (hooksConfig) {
|
|
5375
6257
|
const installedHooks = agent.hooks.filter((h2) => {
|
|
5376
6258
|
const hookEntry = hooksConfig[h2.name];
|
|
@@ -5450,10 +6332,19 @@ async function performUninstall(mode) {
|
|
|
5450
6332
|
}
|
|
5451
6333
|
continue;
|
|
5452
6334
|
}
|
|
6335
|
+
if (agentId === "hermes") {
|
|
6336
|
+
const result = removeHermesPlugin();
|
|
6337
|
+
if (result.success) {
|
|
6338
|
+
console.log(` ${source_default.green("✓")} Hermes plugin disabled and removed`);
|
|
6339
|
+
} else {
|
|
6340
|
+
console.log(` ${source_default.yellow("⚠")} Hermes plugin removal partial (${result.error || "unknown error"})`);
|
|
6341
|
+
}
|
|
6342
|
+
continue;
|
|
6343
|
+
}
|
|
5453
6344
|
if (!existsSync2(agent.configPath))
|
|
5454
6345
|
continue;
|
|
5455
6346
|
const config = readJsonConfig(agent.configPath);
|
|
5456
|
-
const hooksConfig = config[agent.hooksKey] ?? {};
|
|
6347
|
+
const hooksConfig = agent.hooksKey === "" ? config : config[agent.hooksKey] ?? {};
|
|
5457
6348
|
let modified = false;
|
|
5458
6349
|
if (agentId === "opencode") {
|
|
5459
6350
|
const pluginArray = config.plugin;
|
|
@@ -5697,10 +6588,15 @@ async function refreshCommand() {
|
|
|
5697
6588
|
v2.error('No existing configuration found. Run "npx agentapprove install" first.');
|
|
5698
6589
|
process.exit(1);
|
|
5699
6590
|
}
|
|
6591
|
+
const pairingApiBase = resolvePairingApiBaseUrl({
|
|
6592
|
+
agentapproveApiEnv: process.env.AGENTAPPROVE_API,
|
|
6593
|
+
savedApiUrl: existingConfig?.apiUrl,
|
|
6594
|
+
processDefaultApiUrl: API_URL
|
|
6595
|
+
});
|
|
5700
6596
|
me(`Your API token has expired (30 days). Tokens extend automatically on use,
|
|
5701
6597
|
but if unused for 30 days they expire. Get a new one below.`, "Token Expired");
|
|
5702
6598
|
let token = null;
|
|
5703
|
-
let apiUrl =
|
|
6599
|
+
let apiUrl = pairingApiBase;
|
|
5704
6600
|
const installedAgents = detectInstalledAgents();
|
|
5705
6601
|
const e2eEnabled = existingConfig.e2eEnabled !== false;
|
|
5706
6602
|
const e2eKeyPath = join(getAgentApproveDir(), "e2e-key");
|
|
@@ -5727,7 +6623,7 @@ but if unused for 30 days they expire. Get a new one below.`, "Token Expired");
|
|
|
5727
6623
|
configSetAt,
|
|
5728
6624
|
e2eEnabled
|
|
5729
6625
|
};
|
|
5730
|
-
const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId);
|
|
6626
|
+
const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId, pairingApiBase);
|
|
5731
6627
|
if (!session || session.error) {
|
|
5732
6628
|
v2.error(`Failed to create pairing session: ${session?.error || "Unknown error"}`);
|
|
5733
6629
|
process.exit(1);
|
|
@@ -5753,13 +6649,14 @@ but if unused for 30 days they expire. Get a new one below.`, "Token Expired");
|
|
|
5753
6649
|
const minutes = Math.floor(expiresIn / 60);
|
|
5754
6650
|
const seconds = expiresIn % 60;
|
|
5755
6651
|
pairingSpinner.message(`Waiting for iOS app... ${minutes}:${seconds.toString().padStart(2, "0")}`);
|
|
5756
|
-
}, () => {});
|
|
6652
|
+
}, () => {}, pairingApiBase);
|
|
5757
6653
|
if (result === "cancelled") {
|
|
5758
6654
|
pairingSpinner.stop("Cancelled");
|
|
5759
6655
|
he("Refresh cancelled");
|
|
5760
6656
|
process.exit(0);
|
|
5761
6657
|
} else if (result) {
|
|
5762
6658
|
token = result.token;
|
|
6659
|
+
apiUrl = result.apiUrl || pairingApiBase;
|
|
5763
6660
|
pairingSpinner.stop(`Connected! ${result.email ? `Account: ${result.email}` : ""}`);
|
|
5764
6661
|
} else {
|
|
5765
6662
|
pairingSpinner.stop("Session expired");
|
|
@@ -5797,6 +6694,11 @@ async function pairCommand() {
|
|
|
5797
6694
|
console.clear();
|
|
5798
6695
|
pe(source_default.bgCyan.black(" Agent Approve ") + source_default.gray(" Device Pairing"));
|
|
5799
6696
|
const existingConfig = readExistingConfig();
|
|
6697
|
+
const pairingApiBase = resolvePairingApiBaseUrl({
|
|
6698
|
+
agentapproveApiEnv: process.env.AGENTAPPROVE_API,
|
|
6699
|
+
savedApiUrl: existingConfig?.apiUrl,
|
|
6700
|
+
processDefaultApiUrl: API_URL
|
|
6701
|
+
});
|
|
5800
6702
|
const keys = discoverE2EKeys();
|
|
5801
6703
|
if (keys.length === 0 && !existingConfig) {
|
|
5802
6704
|
v2.error("No E2E keys or configuration found.");
|
|
@@ -5887,7 +6789,7 @@ async function pairCommand() {
|
|
|
5887
6789
|
configSetAt: pairingConfigSetAt,
|
|
5888
6790
|
e2eEnabled: !!e2eUserKey
|
|
5889
6791
|
};
|
|
5890
|
-
const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId);
|
|
6792
|
+
const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId, pairingApiBase);
|
|
5891
6793
|
if (!session || session.error) {
|
|
5892
6794
|
v2.error(`Failed to create pairing session: ${session?.error || "Unknown error"}`);
|
|
5893
6795
|
process.exit(1);
|
|
@@ -5913,7 +6815,7 @@ async function pairCommand() {
|
|
|
5913
6815
|
const minutes = Math.floor(expiresIn / 60);
|
|
5914
6816
|
const seconds = expiresIn % 60;
|
|
5915
6817
|
pairingSpinner.message(`Waiting for iOS app... ${minutes}:${seconds.toString().padStart(2, "0")}`);
|
|
5916
|
-
}, () => {});
|
|
6818
|
+
}, () => {}, pairingApiBase);
|
|
5917
6819
|
if (result === "cancelled") {
|
|
5918
6820
|
pairingSpinner.stop("Cancelled");
|
|
5919
6821
|
he("Pairing cancelled");
|
|
@@ -5943,7 +6845,7 @@ async function pairCommand() {
|
|
|
5943
6845
|
writeFileSync(serverKeyPath, result.e2eServerKey, { mode: 384 });
|
|
5944
6846
|
}
|
|
5945
6847
|
}
|
|
5946
|
-
const apiUrl =
|
|
6848
|
+
const apiUrl = result.apiUrl || pairingApiBase;
|
|
5947
6849
|
const configSetAt = Math.floor(Date.now() / 1000);
|
|
5948
6850
|
saveEnvConfig({
|
|
5949
6851
|
apiUrl,
|
|
@@ -5992,7 +6894,9 @@ async function updateHookScriptsWithToken(token, apiUrl) {
|
|
|
5992
6894
|
"cursor-thought.sh",
|
|
5993
6895
|
"cursor-response.sh",
|
|
5994
6896
|
"gemini-approval.sh",
|
|
5995
|
-
"gemini-complete.sh"
|
|
6897
|
+
"gemini-complete.sh",
|
|
6898
|
+
"windsurf-hook.sh",
|
|
6899
|
+
"openhands-hook.sh"
|
|
5996
6900
|
];
|
|
5997
6901
|
for (const file of hookFiles) {
|
|
5998
6902
|
const filePath = join(hooksDir, file);
|