From 9a9ae34a391be05545e2ac59779d30ccef38c033 Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Fri, 5 Jun 2026 11:54:10 +0800 Subject: [PATCH] Add Hermes Agent JWT CLI compatibility --- CHANGELOG.md | 1 + src/cli.ts | 46 +++++++++++----- src/tests/cli-connect.test.ts | 93 +++++++++++++++++++++++++++++++-- src/tests/cli-policy.test.ts | 17 ++++-- src/tests/cli-subscribe.test.ts | 90 +++++++++++++++++++++++++++++-- 5 files changed, 225 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a30f1..7951219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Web search actions now use a dedicated `web_search` runtime action across Claude Code, Hermes, OpenClaw, MCP, and the skill CLI, so query-only searches are handled separately from URL fetches and no longer trigger invalid-URL network approval flows. - Direct web fetch and browser navigation GET requests keep the default `network.defaultOutbound: warn` behavior as audit-only, while mutating or high-risk network requests still require confirmation or blocking. +- `agentguard connect` and `agentguard subscribe` now support Hermes Agent JWT registration when Hermes is initialized or detected via `HERMES_HOME`/`~/.hermes`, while preserving the existing OpenClaw notification behavior. ### Fixed - `agentguard init --agent hermes` now targets `HERMES_HOME` or `~/.hermes` for explicit installs instead of creating a nested `.hermes` directory under the current working directory, while only updating the root Hermes config and profile configs. diff --git a/src/cli.ts b/src/cli.ts index 94fa94b..cc1d6de 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -127,10 +127,10 @@ async function main() { const apiKey = options.key || options.apiKey || process.env.AGENTGUARD_API_KEY; if (!apiKey) { let config = ensureConfig(); - if (!isOpenClawAgentConfigured(config)) { - throw new Error('AgentGuard Cloud connect supports API-key auth or OpenClaw Agent JWT registration. No API key was provided, and OpenClaw has not been initialized. Run `agentguard init --agent openclaw`, then rerun `agentguard connect`; or pass --key, --api-key, or AGENTGUARD_API_KEY for API-key auth.'); + if (!isAgentJwtHostConfigured(config)) { + throw new Error('AgentGuard Cloud connect supports API-key auth or Agent JWT registration for OpenClaw and Hermes. No API key was provided, and no supported Agent JWT host has been initialized. Run `agentguard init --agent openclaw` or `agentguard init --agent hermes`, then rerun `agentguard connect`; or pass --key, --api-key, or AGENTGUARD_API_KEY for API-key auth.'); } - config = withDetectedOpenClawAgentHost(config); + config = withDetectedAgentJwtHost(config); const cloudUrl = normalizeCloudUrl(options.cloud || options.url || config.cloudUrl || 'https://agentguard.gopluslabs.io'); if (config.agentId && config.agentJwt) { const existingConfig = { ...config, cloudUrl }; @@ -465,8 +465,8 @@ async function main() { let registration: AgentCredentialRegistration | null = null; if (!client.connected) { - if (!isOpenClawAgentConfigured(config)) { - const message = 'AgentGuard Cloud is not connected. Run `agentguard connect --key ` first, or run `agentguard init --agent openclaw` to use Agent JWT registration.'; + if (!isAgentJwtHostConfigured(config)) { + const message = 'AgentGuard Cloud is not connected. Run `agentguard connect --key ` first, or run `agentguard init --agent openclaw` or `agentguard init --agent hermes` to use Agent JWT registration.'; if (cronNotifyRun) { console.log('NO_REPLY'); } else if (options.json) { @@ -502,7 +502,7 @@ async function main() { await client.subscribeFeed(); } catch (err) { if (err instanceof CloudRequestError && err.status === 401) { - if (!isOpenClawAgentConfigured(config)) { + if (!isAgentJwtHostConfigured(config)) { console.error('! AgentGuard Cloud credential was rejected. Run `agentguard connect --key ` again.'); process.exitCode = 1; return; @@ -544,7 +544,7 @@ async function main() { process.exitCode = 1; return; } - if (!isOpenClawAgentConfigured(config)) { + if (!isAgentJwtHostConfigured(config)) { console.error('! AgentGuard Cloud credential was rejected. Run `agentguard connect --key ` again.'); process.exitCode = 1; return; @@ -1381,7 +1381,7 @@ async function runCloudRequestWithAgentJwtReauth(options: { if ( !(err instanceof CloudRequestError && err.status === 401) || !options.config.agentJwt || - !isOpenClawAgentConfigured(options.config) + !isAgentJwtHostConfigured(options.config) ) { throw err; } @@ -1465,12 +1465,26 @@ function isOpenClawAgentConfigured(config: AgentGuardConfig): boolean { return config.agentHost === 'openclaw' || config.agentHosts?.includes('openclaw') === true || detectOpenClawRuntime(); } -function withDetectedOpenClawAgentHost(config: AgentGuardConfig): AgentGuardConfig { - if (hasSavedAgentHost(config) || !detectOpenClawRuntime()) return config; +function isHermesAgentConfigured(config: AgentGuardConfig): boolean { + return config.agentHost === 'hermes' || config.agentHosts?.includes('hermes') === true || detectHermesRuntime(); +} + +function isAgentJwtHostConfigured(config: AgentGuardConfig): boolean { + return isOpenClawAgentConfigured(config) || isHermesAgentConfigured(config); +} + +function withDetectedAgentJwtHost(config: AgentGuardConfig): AgentGuardConfig { + if (hasSavedAgentHost(config)) return config; + if (detectOpenClawRuntime()) return withDetectedAgentHost(config, 'openclaw'); + if (detectHermesRuntime()) return withDetectedAgentHost(config, 'hermes'); + return config; +} + +function withDetectedAgentHost(config: AgentGuardConfig, agentHost: AgentGuardAgentHost): AgentGuardConfig { const next: AgentGuardConfig = { ...config, - agentHost: 'openclaw', - agentHosts: appendAgentHost(config.agentHosts, 'openclaw'), + agentHost, + agentHosts: appendAgentHost(config.agentHosts, agentHost), }; saveConfig(next); return next; @@ -1486,6 +1500,14 @@ function detectOpenClawRuntime(): boolean { return existsSync(join(homedir(), '.openclaw', 'openclaw.json')); } +function detectHermesRuntime(): boolean { + const hermesHome = process.env.HERMES_HOME?.trim(); + if (hermesHome && (existsSync(hermesHome) || existsSync(join(hermesHome, 'config.yaml')))) return true; + + const defaultHome = join(homedir(), '.hermes'); + return existsSync(join(defaultHome, 'config.yaml')) || existsSync(defaultHome); +} + function resolveOpenClawGatewayOptionsFromEnv(): OpenClawGatewayOptions { const url = process.env.AGENTGUARD_OPENCLAW_GATEWAY_URL?.trim(); const host = process.env.AGENTGUARD_OPENCLAW_GATEWAY_HOST?.trim(); diff --git a/src/tests/cli-connect.test.ts b/src/tests/cli-connect.test.ts index 2d465b4..5559d88 100644 --- a/src/tests/cli-connect.test.ts +++ b/src/tests/cli-connect.test.ts @@ -12,12 +12,13 @@ const projectRoot = resolve(__dirname, '..', '..'); const CLI_PATH = join(projectRoot, 'dist', 'cli.js'); const ISOLATED_OPENCLAW_ENV = { AGENTGUARD_OPENCLAW_GATEWAY_URL: '', - AGENTGUARD_OPENCLAW_GATEWAY_HOST: '', + AGENTGUARD_OPENCLAW_GATEWAY_HOST: '127.0.0.1', AGENTGUARD_OPENCLAW_GATEWAY_TOKEN: '', - AGENTGUARD_OPENCLAW_GATEWAY_PORT: '', - AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '', + AGENTGUARD_OPENCLAW_GATEWAY_PORT: '9', + AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '200', OPENCLAW_CONFIG_PATH: '', OPENCLAW_STATE_DIR: '', + HERMES_HOME: '', }; function runCli( @@ -338,6 +339,71 @@ describe('CLI connect Agent JWT mode', () => { assert.match(result.stderr, /init --agent openclaw/); }); + it('uses Hermes Agent JWT registration when Hermes has been initialized', async () => { + const requests: Array<{ url?: string; method?: string; body?: any }> = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + requests.push({ url: req.url, method: req.method, body: body ? JSON.parse(body) : undefined }); + if (req.method === 'POST' && req.url === '/api/agent/register') { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + success: true, + data: { + agentId: 'agt_hermes_cli_test', + jwt: 'agent.jwt.hermes-cli-test', + registerUrl: 'https://agentguard.example/activate?token=hermes-cli-test', + }, + })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ success: false })); + }); + }); + await new Promise((resolvePromise) => server.listen(0, '127.0.0.1', resolvePromise)); + try { + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const cloudUrl = `http://127.0.0.1:${(address as AddressInfo).port}`; + const home = mkdtempSync(join(tmpdir(), 'ag-cli-connect-hermes-')); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl, + agentHost: 'hermes', + agentHosts: ['hermes'], + policyCachePath: join(home, 'policy-cache.json'), + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + })); + + const result = await runCli(['connect', '--url', cloudUrl], home); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /Registered local AgentGuard agent \(agt_hermes_cli_test\)/); + assert.match(result.stdout, /https:\/\/agentguard\.example\/activate\?token=hermes-cli-test/); + assert.equal(requests[0].body.metadata.agentHost, 'hermes'); + assert.deepEqual(requests[0].body.metadata.agentHosts, ['hermes']); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentId?: string; + agentJwt?: string; + agentRegisterUrl?: string; + agentHost?: string; + }; + assert.equal(config.agentHost, 'hermes'); + assert.equal(config.agentId, 'agt_hermes_cli_test'); + assert.equal(config.agentJwt, 'agent.jwt.hermes-cli-test'); + assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=hermes-cli-test'); + } finally { + await new Promise((resolvePromise) => server.close(() => resolvePromise())); + } + }); + it('uses detected OpenClaw runtime for no-key connect before requiring an API key', async () => { const home = mkdtempSync(join(tmpdir(), 'ag-cli-connect-openclaw-env-')); const openClawState = join(home, '.openclaw'); @@ -358,4 +424,25 @@ describe('CLI connect Agent JWT mode', () => { assert.equal(config.agentHost, 'openclaw'); assert.deepEqual(config.agentHosts, ['openclaw']); }); + + it('uses detected Hermes runtime for no-key connect before requiring an API key', async () => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-connect-hermes-env-')); + const hermesHome = join(home, '.hermes'); + mkdirSync(hermesHome, { recursive: true }); + writeFileSync(join(hermesHome, 'config.yaml'), 'hooks: {}\n'); + + const result = await runCli(['connect', '--url', 'http://127.0.0.1:9'], home, { + HERMES_HOME: hermesHome, + }); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + + assert.equal(result.exitCode, 1); + assert.doesNotMatch(result.stderr, /Missing API key/); + assert.match(result.stderr, /Could not register AgentGuard agent/); + assert.equal(config.agentHost, 'hermes'); + assert.deepEqual(config.agentHosts, ['hermes']); + }); }); diff --git a/src/tests/cli-policy.test.ts b/src/tests/cli-policy.test.ts index 19fe637..a7da7ba 100644 --- a/src/tests/cli-policy.test.ts +++ b/src/tests/cli-policy.test.ts @@ -9,6 +9,15 @@ import { promisify } from 'node:util'; import { getDefaultEffectiveRuntimePolicy } from '../runtime/policy.js'; const execFileAsync = promisify(execFile); +const ISOLATED_OPENCLAW_ENV = { + AGENTGUARD_OPENCLAW_GATEWAY_URL: '', + AGENTGUARD_OPENCLAW_GATEWAY_HOST: '127.0.0.1', + AGENTGUARD_OPENCLAW_GATEWAY_TOKEN: '', + AGENTGUARD_OPENCLAW_GATEWAY_PORT: '9', + AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '200', + OPENCLAW_CONFIG_PATH: '', + OPENCLAW_STATE_DIR: '', +}; describe('policy CLI', () => { it('shows the cached effective policy as JSON', async () => { @@ -30,7 +39,7 @@ describe('policy CLI', () => { const cliPath = resolve('dist/cli.js'); const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'show', '--json'], { - env: { ...process.env, AGENTGUARD_HOME: home }, + env: { ...process.env, ...ISOLATED_OPENCLAW_ENV, AGENTGUARD_HOME: home }, }); const result = JSON.parse(stdout) as { @@ -60,7 +69,7 @@ describe('policy CLI', () => { const cliPath = resolve('dist/cli.js'); const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'show', '--json'], { - env: { ...process.env, AGENTGUARD_HOME: home }, + env: { ...process.env, ...ISOLATED_OPENCLAW_ENV, AGENTGUARD_HOME: home }, }); const result = JSON.parse(stdout) as { @@ -108,7 +117,7 @@ describe('policy CLI', () => { const cliPath = resolve('dist/cli.js'); const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'pull', '--json'], { - env: { ...process.env, AGENTGUARD_HOME: home }, + env: { ...process.env, ...ISOLATED_OPENCLAW_ENV, AGENTGUARD_HOME: home }, }); const result = JSON.parse(stdout) as { success: boolean; policyVersion: string; cachePath: string }; @@ -180,7 +189,7 @@ describe('policy CLI', () => { const cliPath = resolve('dist/cli.js'); const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'pull', '--json'], { - env: { ...process.env, AGENTGUARD_HOME: home }, + env: { ...process.env, ...ISOLATED_OPENCLAW_ENV, AGENTGUARD_HOME: home }, }); const result = JSON.parse(stdout) as { success: boolean; policyVersion: string }; diff --git a/src/tests/cli-subscribe.test.ts b/src/tests/cli-subscribe.test.ts index aec6fb7..d8781bd 100644 --- a/src/tests/cli-subscribe.test.ts +++ b/src/tests/cli-subscribe.test.ts @@ -12,12 +12,13 @@ const projectRoot = resolve(__dirname, '..', '..'); const CLI_PATH = join(projectRoot, 'dist', 'cli.js'); const ISOLATED_OPENCLAW_ENV = { AGENTGUARD_OPENCLAW_GATEWAY_URL: '', - AGENTGUARD_OPENCLAW_GATEWAY_HOST: '', + AGENTGUARD_OPENCLAW_GATEWAY_HOST: '127.0.0.1', AGENTGUARD_OPENCLAW_GATEWAY_TOKEN: '', - AGENTGUARD_OPENCLAW_GATEWAY_PORT: '', - AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '', + AGENTGUARD_OPENCLAW_GATEWAY_PORT: '9', + AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '200', OPENCLAW_CONFIG_PATH: '', OPENCLAW_STATE_DIR: '', + HERMES_HOME: '', }; function runCli( @@ -224,6 +225,89 @@ describe('CLI subscribe command modes', () => { } }); + it('registers a Hermes local agent before subscribing when no Cloud credential exists', async () => { + const requests: Array<{ url?: string; method?: string; authorization?: string; body?: any }> = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + requests.push({ + url: req.url, + method: req.method, + authorization: req.headers.authorization, + body: body ? JSON.parse(body) : undefined, + }); + res.setHeader('content-type', 'application/json'); + if (req.method === 'POST' && req.url === '/api/agent/register') { + res.end(JSON.stringify({ + success: true, + data: { + agentId: 'agt_hermes_subscribe', + jwt: 'agent.jwt.hermes-subscribe', + registerUrl: 'https://agentguard.example/activate?token=hermes-subscribe', + }, + })); + return; + } + if (req.method === 'POST' && req.url === '/api/v1/feed/subscribe') { + res.end(JSON.stringify({ success: true, data: { id: 'sub_hermes', status: 'active' } })); + return; + } + if (req.method === 'GET' && req.url?.startsWith('/api/v1/feed/advisories')) { + res.end(JSON.stringify({ success: true, data: { advisories: [] } })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ success: false })); + }); + }); + await new Promise((resolvePromise) => server.listen(0, '127.0.0.1', resolvePromise)); + try { + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const cloudUrl = `http://127.0.0.1:${(address as AddressInfo).port}`; + const home = mkdtempSync(join(tmpdir(), 'ag-cli-subscribe-hermes-register-')); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl, + agentHost: 'hermes', + agentHosts: ['hermes'], + policyCachePath: join(home, 'policy-cache.json'), + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + })); + + const result = await runCliNoConfigWrite(['subscribe', '--json'], home); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.deepEqual(requests.map((request) => `${request.method} ${request.url}`), [ + 'POST /api/agent/register', + 'POST /api/v1/feed/subscribe', + 'GET /api/v1/feed/advisories', + ]); + assert.equal(requests[0].body.metadata.agentHost, 'hermes'); + assert.equal(requests[1].authorization, 'Bearer agent.jwt.hermes-subscribe'); + assert.equal(requests[2].authorization, 'Bearer agent.jwt.hermes-subscribe'); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentId?: string; + agentJwt?: string; + agentRegisterUrl?: string; + agentHost?: string; + }; + assert.equal(config.agentHost, 'hermes'); + assert.equal(config.agentId, 'agt_hermes_subscribe'); + assert.equal(config.agentJwt, 'agent.jwt.hermes-subscribe'); + assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=hermes-subscribe'); + } finally { + await new Promise((resolvePromise) => server.close(() => resolvePromise())); + } + }); + it('cron internal subscribe runs pull advisories without subscribing first', async () => { for (const args of [ ['subscribe', '--json', '--cron-run'],