From 2eebf7243e0412ae927924356ce37fdbb3960f28 Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Wed, 27 May 2026 17:09:36 +0800 Subject: [PATCH 1/8] Fix OpenClaw init companion plugin install --- src/installers.ts | 89 ++++++++++++++++++++++++++++++++++++- src/tests/installer.test.ts | 67 ++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/installers.ts b/src/installers.ts index 7d0eea5..2f1fa25 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -1,6 +1,6 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; export type AgentInstaller = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; @@ -9,6 +9,11 @@ export interface InstallResult { files: string[]; } +interface ClawInstallTarget { + root: string; + configPath: string; +} + export function installAgentTemplates(agent: AgentInstaller, options: { cwd?: string; force?: boolean } = {}): InstallResult { const root = options.cwd || process.cwd(); if (agent === 'claude-code') return installClaudeCode(root, Boolean(options.force)); @@ -47,7 +52,19 @@ function installOpenClaw(cwd: string | undefined, force: boolean): InstallResult ? join(openClawRoot, 'openclaw.json') : process.env.OPENCLAW_CONFIG_PATH || join(openClawRoot, 'openclaw.json'); - return installClawPlugin('openclaw', openClawRoot, configPath, force); + if (cwd) { + return installClawPlugin('openclaw', openClawRoot, configPath, force); + } + + const targets = uniqueClawInstallTargets([ + { root: openClawRoot, configPath }, + ...inferOpenClawCompanionInstallTargets(openClawRoot, configPath), + ]); + const files = targets.flatMap((target) => + installClawPlugin('openclaw', target.root, target.configPath, force).files + ); + + return { agent: 'openclaw', files: uniqueStrings(files) }; } function installHermes(root: string, force: boolean): InstallResult { @@ -223,6 +240,74 @@ function installClawPlugin(agent: 'openclaw' | 'qclaw', root: string, configPath return { agent, files: [packagePath, pluginPath, manifestPath, configPath] }; } +function inferOpenClawCompanionInstallTargets(root: string, configPath: string): ClawInstallTarget[] { + const targets: ClawInstallTarget[] = []; + const workspaceParent = dirname(root); + + if (basename(root) === '.openclaw' && basename(workspaceParent) === 'workspace') { + const mainRoot = dirname(workspaceParent); + targets.push({ root: mainRoot, configPath: join(mainRoot, 'openclaw.json') }); + return targets; + } + + const workspace = readOpenClawWorkspacePath(configPath, root) || existingOpenClawWorkspacePath(root); + if (workspace) { + const workspaceStateRoot = join(workspace, '.openclaw'); + if (workspaceStateRoot !== root) { + targets.push({ root: workspaceStateRoot, configPath: join(workspaceStateRoot, 'openclaw.json') }); + } + } + + return targets; +} + +function readOpenClawWorkspacePath(configPath: string, root: string): string | undefined { + if (!existsSync(configPath)) return undefined; + try { + const raw = readFileSync(configPath, 'utf8').trim(); + if (!raw) return undefined; + const config = JSON.parse(raw) as Record; + const agents = config.agents; + if (!agents || typeof agents !== 'object' || Array.isArray(agents)) return undefined; + const defaults = (agents as Record).defaults; + if (!defaults || typeof defaults !== 'object' || Array.isArray(defaults)) return undefined; + const workspace = (defaults as Record).workspace; + if (typeof workspace !== 'string' || workspace.trim() === '') return undefined; + return resolveOpenClawPath(workspace.trim(), root); + } catch { + return undefined; + } +} + +function existingOpenClawWorkspacePath(root: string): string | undefined { + const workspace = join(root, 'workspace'); + return existsSync(workspace) ? workspace : undefined; +} + +function resolveOpenClawPath(path: string, baseDir: string): string { + if (path === '~') return homedir(); + if (path.startsWith('~/') || path.startsWith('~\\')) { + return join(homedir(), path.slice(2)); + } + return isAbsolute(path) ? path : resolve(baseDir, path); +} + +function uniqueClawInstallTargets(targets: ClawInstallTarget[]): ClawInstallTarget[] { + const seen = new Set(); + const unique: ClawInstallTarget[] = []; + for (const target of targets) { + const key = `${target.root}\0${target.configPath}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(target); + } + return unique; +} + +function uniqueStrings(values: string[]): string[] { + return values.filter((value, index) => values.indexOf(value) === index); +} + function openClawPluginTemplate(): string { const packageRoot = resolve(__dirname, '..'); return `const agentGuardPackageRoot = ${JSON.stringify(packageRoot)}; diff --git a/src/tests/installer.test.ts b/src/tests/installer.test.ts index fa2b09f..fbba39c 100644 --- a/src/tests/installer.test.ts +++ b/src/tests/installer.test.ts @@ -129,6 +129,73 @@ describe('Agent template installers', () => { assert.ok(!template.includes("level: 'balanced'")); }); + it('also enables the main OpenClaw config when init runs from workspace state', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-workspace-state-')); + const mainRoot = join(dir, '.openclaw'); + const workspaceRoot = join(mainRoot, 'workspace', '.openclaw'); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + + try { + process.env.OPENCLAW_STATE_DIR = workspaceRoot; + delete process.env.OPENCLAW_CONFIG_PATH; + + const result = installAgentTemplates('openclaw'); + const mainPluginDir = join(mainRoot, 'plugins', 'agentguard'); + const workspacePluginDir = join(workspaceRoot, 'plugins', 'agentguard'); + const mainConfig = JSON.parse(readFileSync(join(mainRoot, 'openclaw.json'), 'utf8')); + const workspaceConfig = JSON.parse(readFileSync(join(workspaceRoot, 'openclaw.json'), 'utf8')); + + assert.ok(result.files.includes(join(mainRoot, 'openclaw.json'))); + assert.ok(existsSync(join(mainPluginDir, 'openclaw.plugin.json'))); + assert.ok(existsSync(join(workspacePluginDir, 'openclaw.plugin.json'))); + assert.deepEqual(mainConfig.plugins.load.paths, [mainPluginDir]); + assert.deepEqual(workspaceConfig.plugins.load.paths, [workspacePluginDir]); + } finally { + if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; + else process.env.OPENCLAW_STATE_DIR = previousStateDir; + if (previousConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH; + else process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + } + }); + + it('also enables the workspace OpenClaw config when init runs from main state', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-main-state-')); + const mainRoot = join(dir, '.openclaw'); + const workspace = join(mainRoot, 'workspace'); + const workspaceRoot = join(workspace, '.openclaw'); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + mkdirSync(workspace, { recursive: true }); + mkdirSync(mainRoot, { recursive: true }); + writeFileSync(join(mainRoot, 'openclaw.json'), JSON.stringify({ + agents: { + defaults: { + workspace, + }, + }, + }, null, 2)); + + try { + process.env.OPENCLAW_STATE_DIR = mainRoot; + delete process.env.OPENCLAW_CONFIG_PATH; + + installAgentTemplates('openclaw'); + const mainPluginDir = join(mainRoot, 'plugins', 'agentguard'); + const workspacePluginDir = join(workspaceRoot, 'plugins', 'agentguard'); + const mainConfig = JSON.parse(readFileSync(join(mainRoot, 'openclaw.json'), 'utf8')); + const workspaceConfig = JSON.parse(readFileSync(join(workspaceRoot, 'openclaw.json'), 'utf8')); + + assert.deepEqual(mainConfig.plugins.load.paths, [mainPluginDir]); + assert.deepEqual(workspaceConfig.plugins.load.paths, [workspacePluginDir]); + } finally { + if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; + else process.env.OPENCLAW_STATE_DIR = previousStateDir; + if (previousConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH; + else process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + } + }); + it('adds AgentGuard to an existing OpenClaw plugin allowlist', () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-existing-')); const configPath = join(dir, '.openclaw', 'openclaw.json'); From 5479c2dc5bfef8453a55d891917bab5c64e991da Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Wed, 27 May 2026 17:26:22 +0800 Subject: [PATCH 2/8] Fix OpenClaw gateway token fallback --- src/cli.ts | 2 + src/feed/cron.ts | 75 ++++++++++++++++++--- src/tests/feed-cron.test.ts | 130 ++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 11 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index c871fa5..facc133 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1376,6 +1376,7 @@ function detectOpenClawRuntime(): boolean { function resolveOpenClawGatewayOptionsFromEnv(): OpenClawGatewayOptions { const url = process.env.AGENTGUARD_OPENCLAW_GATEWAY_URL?.trim(); const host = process.env.AGENTGUARD_OPENCLAW_GATEWAY_HOST?.trim(); + const token = process.env.AGENTGUARD_OPENCLAW_GATEWAY_TOKEN?.trim(); const portRaw = process.env.AGENTGUARD_OPENCLAW_GATEWAY_PORT?.trim(); const timeoutRaw = process.env.AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS?.trim(); const port = portRaw ? Number(portRaw) : undefined; @@ -1383,6 +1384,7 @@ function resolveOpenClawGatewayOptionsFromEnv(): OpenClawGatewayOptions { return { ...(url ? { url } : {}), ...(host ? { host } : {}), + ...(token ? { token } : {}), ...(Number.isFinite(port) ? { port } : {}), ...(Number.isFinite(timeoutMs) ? { timeoutMs } : {}), }; diff --git a/src/feed/cron.ts b/src/feed/cron.ts index d70c28b..944b115 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -32,6 +32,7 @@ export interface OpenClawGatewayOptions { host?: string; port?: number; url?: string; + token?: string; label?: string; timeoutMs?: number; runCommand?: CommandRunner; @@ -888,6 +889,7 @@ export function openClawGatewayRequest( const port = options.port ?? 18789; const label = options.label ?? 'OpenClaw Gateway'; const timeoutMs = options.timeoutMs ?? 5000; + const token = options.token ?? resolveOpenClawGatewayToken(); if (shouldUseOpenClawGatewayCli(options)) { return openClawGatewayCliRequest({ method, @@ -895,10 +897,10 @@ export function openClawGatewayRequest( label, timeoutMs, runCommand: options.runCommand ?? execCommand, - }).catch(() => openClawGatewayNetworkRequest({ host, port, method, params, label, timeoutMs, url: options.url })); + }).catch(() => openClawGatewayNetworkRequest({ host, port, method, params, label, timeoutMs, url: options.url, token })); } - return openClawGatewayNetworkRequest({ host, port, method, params, label, timeoutMs, url: options.url }); + return openClawGatewayNetworkRequest({ host, port, method, params, label, timeoutMs, url: options.url, token }); } function openClawGatewayNetworkRequest(options: { @@ -909,6 +911,7 @@ function openClawGatewayNetworkRequest(options: { label: string; timeoutMs: number; url?: string; + token?: string; }): Promise { if (options.url) { return openClawGatewayWebSocketRequest({ @@ -917,6 +920,7 @@ function openClawGatewayNetworkRequest(options: { params: options.params, label: options.label, timeoutMs: options.timeoutMs, + token: options.token, }); } @@ -927,6 +931,7 @@ function openClawGatewayNetworkRequest(options: { params: options.params, label: options.label, timeoutMs: options.timeoutMs, + token: options.token, }).catch((err) => { if (err instanceof GatewayHttpFallbackError) { return openClawGatewayWebSocketRequest({ @@ -935,6 +940,7 @@ function openClawGatewayNetworkRequest(options: { params: options.params, label: options.label, timeoutMs: options.timeoutMs, + token: options.token, }); } throw err; @@ -981,6 +987,7 @@ function openClawGatewayHttpRequest(options: { params: unknown; label: string; timeoutMs: number; + token?: string; }): Promise { const payload = JSON.stringify({ jsonrpc: '2.0', @@ -988,6 +995,13 @@ function openClawGatewayHttpRequest(options: { params: legacyGatewayParams(options.method, options.params), id: 1, }); + const headers: Record = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }; + if (options.token) { + headers.Authorization = `Bearer ${options.token}`; + } return new Promise((resolve, reject) => { let settled = false; @@ -1007,10 +1021,7 @@ function openClawGatewayHttpRequest(options: { port: options.port, path: '/', method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload), - }, + headers, }, (res) => { let data = ''; @@ -1059,12 +1070,44 @@ function legacyGatewayParams(method: string, params: unknown): unknown { return params; } +function resolveOpenClawGatewayToken(): string | undefined { + const agentGuardOverride = process.env.AGENTGUARD_OPENCLAW_GATEWAY_TOKEN?.trim(); + if (agentGuardOverride) return agentGuardOverride; + const openClawOverride = process.env.OPENCLAW_GATEWAY_TOKEN?.trim(); + if (openClawOverride) return openClawOverride; + return readOpenClawGatewayConfigToken(); +} + +function readOpenClawGatewayConfigToken(): string | undefined { + const configPath = resolveOpenClawConfigPath(); + try { + const raw = readFileSync(configPath, 'utf8').trim(); + if (!raw) return undefined; + const config = JSON.parse(raw) as Record; + const gateway = config.gateway; + if (!gateway || typeof gateway !== 'object' || Array.isArray(gateway)) return undefined; + const auth = (gateway as Record).auth; + if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return undefined; + const token = (auth as Record).token; + return typeof token === 'string' && token.trim() ? token.trim() : undefined; + } catch { + return undefined; + } +} + +function resolveOpenClawConfigPath(): string { + const override = process.env.OPENCLAW_CONFIG_PATH?.trim(); + if (override) return resolveOpenClawUserPath(override); + return join(resolveOpenClawStateDir(), 'openclaw.json'); +} + function openClawGatewayWebSocketRequest(options: { url: string; method: string; params: unknown; label: string; timeoutMs: number; + token?: string; }): Promise { const endpoint = parseGatewayWebSocketUrl(options.url, options.label); @@ -1180,7 +1223,7 @@ function openClawGatewayWebSocketRequest(options: { type: 'req', id: connectRequestId, method: 'connect', - params: openClawConnectParams(nonce), + params: openClawConnectParams(nonce, options.token), }))); return; } @@ -1327,7 +1370,7 @@ function encodeWebSocketFrame(text: string, opcode = 0x1): Buffer { return Buffer.concat([header, mask, masked]); } -function openClawConnectParams(connectNonce?: string): unknown { +function openClawConnectParams(connectNonce?: string, token?: string): unknown { return { minProtocol: OPENCLAW_GATEWAY_MIN_PROTOCOL, maxProtocol: OPENCLAW_GATEWAY_MAX_PROTOCOL, @@ -1347,7 +1390,8 @@ function openClawConnectParams(connectNonce?: string): unknown { 'operator.pairing', 'operator.talk.secrets', ], - ...(buildOpenClawGatewayDeviceAuth(connectNonce) ?? {}), + ...(token ? { auth: { token } } : {}), + ...(buildOpenClawGatewayDeviceAuth(connectNonce, token) ?? {}), }; } @@ -1363,7 +1407,7 @@ function extractOpenClawConnectNonce(frame: unknown): string | undefined { return typeof nonce === 'string' && nonce.trim() ? nonce : undefined; } -function buildOpenClawGatewayDeviceAuth(connectNonce?: string): { device: Record } | undefined { +function buildOpenClawGatewayDeviceAuth(connectNonce?: string, token?: string): { device: Record } | undefined { if (!connectNonce?.trim()) return undefined; const identity = loadOpenClawDeviceIdentity(); if (!identity) return undefined; @@ -1385,6 +1429,7 @@ function buildOpenClawGatewayDeviceAuth(connectNonce?: string): { device: Record signedAtMs, nonce: connectNonce, platform: process.platform, + token, }); return { device: { @@ -1446,7 +1491,7 @@ function resolveOpenClawDeviceIdentityPath(): string { function resolveOpenClawStateDir(): string { const override = process.env.OPENCLAW_STATE_DIR?.trim(); - if (override) return override; + if (override) return resolveOpenClawUserPath(override); const current = join(homedir(), OPENCLAW_STATE_DIRNAME); if (existsSync(current)) return current; const legacy = join(homedir(), OPENCLAW_LEGACY_STATE_DIRNAME); @@ -1454,6 +1499,14 @@ function resolveOpenClawStateDir(): string { return current; } +function resolveOpenClawUserPath(path: string): string { + if (path === '~') return homedir(); + if (path.startsWith('~/') || path.startsWith('~\\')) { + return join(homedir(), path.slice(2)); + } + return isAbsolute(path) ? path : join(process.cwd(), path); +} + function normalizeOpenClawDeviceIdentity(value: unknown): OpenClawDeviceIdentity | null { if (!value || typeof value !== 'object') return null; const record = value as Record; diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index 19259dc..3713481 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -677,9 +677,11 @@ describe('feed/cron', () => { it('keeps the default HTTP JSON-RPC Gateway path and legacy cron.add params', async () => { let requestBody: any; + let authorization: string | undefined; const server = http.createServer((req, res) => { assert.equal(req.method, 'POST'); assert.equal(req.url, '/'); + authorization = req.headers.authorization; let raw = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { @@ -696,6 +698,7 @@ describe('feed/cron', () => { const result = await openClawGatewayRequest('cron.add', { name: 'agentguard-threat-feed' }, { host: '127.0.0.1', port: serverPort(server), + token: 'gateway-test-token', timeoutMs: 100, runCommand: async () => { throw new Error('explicit host/port should skip OpenClaw CLI'); @@ -703,6 +706,7 @@ describe('feed/cron', () => { }); assert.deepEqual(result, { ok: true }); + assert.equal(authorization, 'Bearer gateway-test-token'); assert.equal(requestBody.method, 'cron.add'); assert.deepEqual(requestBody.params, [{ name: 'agentguard-threat-feed' }]); } finally { @@ -710,6 +714,61 @@ describe('feed/cron', () => { } }); + it('loads the local OpenClaw Gateway token for direct HTTP fallback requests', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-token-state-')); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + const previousAgentGuardToken = process.env.AGENTGUARD_OPENCLAW_GATEWAY_TOKEN; + const previousOpenClawToken = process.env.OPENCLAW_GATEWAY_TOKEN; + let authorization: string | undefined; + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(stateDir, 'openclaw.json'), JSON.stringify({ + gateway: { + auth: { + token: 'config-gateway-token', + }, + }, + })); + process.env.OPENCLAW_STATE_DIR = stateDir; + delete process.env.OPENCLAW_CONFIG_PATH; + delete process.env.AGENTGUARD_OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + + const server = http.createServer((req, res) => { + authorization = req.headers.authorization; + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + const requestBody = JSON.parse(raw); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: requestBody.id, result: { sessions: [] } })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + await openClawGatewayRequest('sessions.list', {}, { + host: '127.0.0.1', + port: serverPort(server), + timeoutMs: 100, + }); + + assert.equal(authorization, 'Bearer config-gateway-token'); + } finally { + if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; + else process.env.OPENCLAW_STATE_DIR = previousStateDir; + if (previousConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH; + else process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + if (previousAgentGuardToken === undefined) delete process.env.AGENTGUARD_OPENCLAW_GATEWAY_TOKEN; + else process.env.AGENTGUARD_OPENCLAW_GATEWAY_TOKEN = previousAgentGuardToken; + if (previousOpenClawToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN; + else process.env.OPENCLAW_GATEWAY_TOKEN = previousOpenClawToken; + await closeServer(server); + } + }); + it('handles fragmented WebSocket Gateway text responses', async () => { const server = net.createServer((socket) => { let handshakeComplete = false; @@ -879,6 +938,77 @@ describe('feed/cron', () => { } }); + it('sends the Gateway token during the WebSocket connect handshake', async () => { + let connectParams: any; + + const server = net.createServer((socket) => { + let handshakeComplete = false; + let buffer = Buffer.alloc(0); + let clientRequests = 0; + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!handshakeComplete) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = buffer.subarray(0, headerEnd + 4).toString('utf8'); + const key = /^Sec-WebSocket-Key:\s*(.+)$/im.exec(header)?.[1]?.trim(); + assert.ok(key); + const accept = createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + socket.write([ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '', + '', + ].join('\r\n')); + handshakeComplete = true; + buffer = buffer.subarray(headerEnd + 4); + socket.write(encodeServerWebSocketFrame(JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: 'nonce-token' }, + }))); + } + + while (true) { + const parsed = readClientWebSocketFrame(buffer); + if (!parsed) break; + buffer = parsed.rest; + clientRequests += 1; + const frame = JSON.parse(parsed.payload); + if (clientRequests === 1) { + connectParams = frame.params; + socket.write(encodeServerWebSocketFrame(JSON.stringify({ type: 'res', id: frame.id, ok: true, payload: {} }))); + } else { + socket.write(encodeServerWebSocketFrame(JSON.stringify({ + type: 'res', + id: frame.id, + ok: true, + payload: { jobs: [] }, + }))); + } + } + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const result = await openClawGatewayRequest('cron.list', {}, { + url: `ws://127.0.0.1:${serverPort(server)}`, + token: 'gateway-websocket-token', + timeoutMs: 500, + }); + + assert.deepEqual(result, { jobs: [] }); + assert.equal(connectParams.auth.token, 'gateway-websocket-token'); + } finally { + await closeServer(server); + } + }); + it('omits device auth instead of failing when OpenClaw identity keys are invalid', async () => { const stateDir = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-bad-state-')); const identityDir = join(stateDir, 'identity'); From c04a44fdc0eb218697fb8eae69b89f476c6b1209 Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Wed, 27 May 2026 17:40:28 +0800 Subject: [PATCH 3/8] Fix Agent JWT account binding status --- src/cli.ts | 56 +++++++++++----- src/config.ts | 7 ++ src/tests/cli-connect.test.ts | 109 ++++++++++++++++++++++++++++++++ src/tests/cli-init.test.ts | 2 +- src/tests/cli-subscribe.test.ts | 10 +++ 5 files changed, 168 insertions(+), 16 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index facc133..49ad5c1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { connectCloud, connectAgentJwt, clearAgentJwt, + clearAgentRegisterUrl, disconnectCloud, ensureConfig, getAgentGuardPaths, @@ -141,10 +142,11 @@ async function main() { agentRegisterUrl: config.agentRegisterUrl, cloudUrl, }); - saveCachedPolicy(savedConfig.policyCachePath, policy); - console.log(`Connected to AgentGuard Cloud (${savedConfig.cloudUrl}).`); - console.log(`Agent JWT is active for local agent ${savedConfig.agentId}.`); - console.log(`Cached policy ${policy.policyVersion} at ${savedConfig.policyCachePath}.`); + const activeConfig = clearAgentRegisterUrl(savedConfig); + saveCachedPolicy(activeConfig.policyCachePath, policy); + console.log(`Connected to AgentGuard Cloud (${activeConfig.cloudUrl}).`); + console.log(`Agent JWT is active for local agent ${activeConfig.agentId}.`); + console.log(`Cached policy ${policy.policyVersion} at ${activeConfig.policyCachePath}.`); return; } catch (err) { if (!(err instanceof CloudRequestError && err.status === 401)) { @@ -154,14 +156,19 @@ async function main() { } } } - const registration = await registerAgentCredential({ - cloudUrl, - reason: 'connect', - notifyOpenClaw: true, - resetExistingJwt: true, - }); + let registration: AgentCredentialRegistration; + try { + registration = await registerAgentCredential({ + cloudUrl, + reason: 'connect', + notifyOpenClaw: true, + resetExistingJwt: true, + }); + } catch (err) { + throw new Error(`Could not register AgentGuard agent: ${err instanceof Error ? err.message : String(err)}`); + } console.log(`Registered local AgentGuard agent (${registration.config.agentId}).`); - console.log('Open this link to bind AgentGuard Cloud to your email:'); + console.log('Open this link to bind this agent to your account:'); console.log(registration.registerUrl); if (registration.openClawNotification.notified) { console.log('Sent the activation link to the last OpenClaw channel.'); @@ -204,8 +211,8 @@ async function main() { program .command('status') .description('Show local and Cloud connection status') - .action(() => { - const config = ensureConfig(); + .action(async () => { + const config = await refreshAgentAccountBinding(ensureConfig()); const paths = getAgentGuardPaths(); console.log(`Config: ${paths.configPath}`); console.log(`Protection level: ${config.level}`); @@ -894,7 +901,13 @@ function printCloudAuthStatus(config: AgentGuardConfig): void { console.log('API key: not used for this connection'); console.log(`Agent ID: ${config.agentId || 'configured'}`); console.log('Agent JWT: configured'); - console.log(`Agent activation URL: ${config.agentRegisterUrl || 'not configured'}`); + if (config.agentRegisterUrl) { + console.log('Agent account: not bound (activation required)'); + console.log(`Agent activation URL: ${config.agentRegisterUrl}`); + } else { + console.log('Agent account: bound'); + console.log('Agent activation URL: not required'); + } return; } if (config.apiKey) { @@ -909,6 +922,19 @@ function printCloudAuthStatus(config: AgentGuardConfig): void { console.log('Agent JWT: not configured'); } +async function refreshAgentAccountBinding(config: AgentGuardConfig): Promise { + if (!config.agentJwt || !config.agentRegisterUrl) return config; + const client = new AgentGuardCloudClient(config); + try { + const policy = await client.fetchEffectivePolicy(); + const activeConfig = clearAgentRegisterUrl(config); + saveCachedPolicy(activeConfig.policyCachePath, policy); + return activeConfig; + } catch { + return config; + } +} + function printCronRemovalSummary(results: ThreatFeedCronRemovalResult[]): void { const removed = results.filter((result) => result.removed); if (removed.length > 0) { @@ -1343,7 +1369,7 @@ function printAgentActivationRequired( console.error(`! AgentGuard Cloud authorization is not active yet. ${message}`); const registerUrl = registration?.registerUrl || ensureConfig().agentRegisterUrl; if (registerUrl) { - console.error('Open this link to bind this agent to your email, then rerun the command:'); + console.error('Open this link to bind this agent to your account, then rerun the command:'); console.error(registerUrl); } } diff --git a/src/config.ts b/src/config.ts index 693b38c..68c5937 100644 --- a/src/config.ts +++ b/src/config.ts @@ -159,6 +159,13 @@ export function clearAgentJwt(config: AgentGuardConfig = ensureConfig()): AgentG return next; } +export function clearAgentRegisterUrl(config: AgentGuardConfig = ensureConfig()): AgentGuardConfig { + const next: AgentGuardConfig = { ...config }; + delete next.agentRegisterUrl; + saveConfig(next); + return next; +} + export function disconnectCloud(): AgentGuardConfig { const current = ensureConfig(); const next: AgentGuardConfig = { ...current }; diff --git a/src/tests/cli-connect.test.ts b/src/tests/cli-connect.test.ts index 42121ee..2d465b4 100644 --- a/src/tests/cli-connect.test.ts +++ b/src/tests/cli-connect.test.ts @@ -10,6 +10,15 @@ import { getDefaultEffectiveRuntimePolicy } from '../runtime/policy.js'; 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_TOKEN: '', + AGENTGUARD_OPENCLAW_GATEWAY_PORT: '', + AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '', + OPENCLAW_CONFIG_PATH: '', + OPENCLAW_STATE_DIR: '', +}; function runCli( args: string[], @@ -21,6 +30,7 @@ function runCli( stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, + ...ISOLATED_OPENCLAW_ENV, ...extraEnv, AGENTGUARD_HOME: home, HOME: home, @@ -158,6 +168,105 @@ describe('CLI connect Agent JWT mode', () => { } }); + it('status clears the saved activation link after an Agent JWT is active', async () => { + const requests: Array<{ url?: string; method?: string; authorization?: string }> = []; + const server = http.createServer((req, res) => { + requests.push({ url: req.url, method: req.method, authorization: req.headers.authorization }); + if (req.method === 'GET' && req.url === '/api/v1/policies/effective') { + assert.equal(req.headers.authorization, 'Bearer agent.jwt.active'); + const policy = getDefaultEffectiveRuntimePolicy(); + policy.policyVersion = 'status-active-policy'; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ success: true, data: policy })); + 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-status-active-')); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl, + agentHost: 'openclaw', + agentHosts: ['openclaw'], + agentId: 'agt_existing', + agentJwt: 'agent.jwt.active', + agentRegisterUrl: 'https://agentguard.example/activate?token=old', + policyCachePath: join(home, 'policy-cache.json'), + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + })); + + const result = await runCli(['status'], home); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /Agent account: bound/); + assert.match(result.stdout, /Agent activation URL: not required/); + assert.doesNotMatch(result.stdout, /activate\?token=old/); + assert.deepEqual(requests.map((request) => request.url), ['/api/v1/policies/effective']); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentRegisterUrl?: string; + }; + assert.equal(config.agentRegisterUrl, undefined); + } finally { + await new Promise((resolvePromise) => server.close(() => resolvePromise())); + } + }); + + it('status describes an unactivated Agent JWT as account binding instead of email binding', async () => { + const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/api/v1/policies/effective') { + res.statusCode = 401; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ success: false, error: { message: 'agent is not activated' } })); + 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-status-pending-')); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl, + agentHost: 'openclaw', + agentHosts: ['openclaw'], + agentId: 'agt_pending', + agentJwt: 'agent.jwt.pending', + agentRegisterUrl: 'https://agentguard.example/activate?token=pending', + policyCachePath: join(home, 'policy-cache.json'), + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + })); + + const result = await runCli(['status'], home); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /Agent account: not bound \(activation required\)/); + assert.match(result.stdout, /https:\/\/agentguard\.example\/activate\?token=pending/); + assert.doesNotMatch(result.stdout, /email/i); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentRegisterUrl?: string; + }; + assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=pending'); + } finally { + await new Promise((resolvePromise) => server.close(() => resolvePromise())); + } + }); + it('re-registers a saved Agent JWT only after Cloud rejects it with 401', async () => { const requests: Array<{ url?: string; method?: string; authorization?: string }> = []; const server = http.createServer((req, res) => { diff --git a/src/tests/cli-init.test.ts b/src/tests/cli-init.test.ts index a0a96f7..419b87f 100644 --- a/src/tests/cli-init.test.ts +++ b/src/tests/cli-init.test.ts @@ -79,7 +79,6 @@ describe('init CLI', () => { cloudUrl: 'https://agentguard.example', agentId: 'agt_status_test', agentJwt: 'agent.jwt.status', - agentRegisterUrl: 'https://agentguard.example/activate?token=status', agentHost: 'openclaw', policyCachePath: join(home, 'policy-cache.json'), auditPath: join(home, 'audit.jsonl'), @@ -95,6 +94,7 @@ describe('init CLI', () => { assert.match(stdout, /API key: not used for this connection/); assert.match(stdout, /Agent ID: agt_status_test/); assert.match(stdout, /Agent JWT: configured/); + assert.match(stdout, /Agent account: bound/); assert.doesNotMatch(stdout, /API key: not configured/); }); diff --git a/src/tests/cli-subscribe.test.ts b/src/tests/cli-subscribe.test.ts index 33858c3..f07b98d 100644 --- a/src/tests/cli-subscribe.test.ts +++ b/src/tests/cli-subscribe.test.ts @@ -10,6 +10,15 @@ import type { Advisory } from '../feed/types.js'; 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_TOKEN: '', + AGENTGUARD_OPENCLAW_GATEWAY_PORT: '', + AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '', + OPENCLAW_CONFIG_PATH: '', + OPENCLAW_STATE_DIR: '', +}; function runCli( args: string[], @@ -31,6 +40,7 @@ function runCliNoConfigWrite( stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, + ...ISOLATED_OPENCLAW_ENV, ...extraEnv, AGENTGUARD_HOME: home, HOME: home, From 09af98dd306e38aed2d4d6b42a3b1c47e29c7222 Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Wed, 27 May 2026 18:25:00 +0800 Subject: [PATCH 4/8] Fix OpenClaw cron force handling --- src/feed/cron.ts | 38 +++++++++++++++++---- src/tests/feed-cron.test.ts | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 944b115..ed5dc32 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -382,7 +382,8 @@ async function installOpenClawNativeThreatFeedCron( } catch (err) { throw new CronBackendUnavailableError(`Could not list native OpenClaw cron jobs. Is OpenClaw installed and available on PATH? ${(err as Error).message}`); } - if (nativeCronListHasExactName(existing.stdout, options.name) && !options.force) { + const existingJobs = nativeCronListJobsByName(existing.stdout, options.name); + if (existingJobs.length > 0 && !options.force) { return { name: options.name, schedule, @@ -392,6 +393,11 @@ async function installOpenClawNativeThreatFeedCron( command, }; } + if (existingJobs.length > 0) { + for (const job of existingJobs) { + await runCommand('openclaw', ['cron', 'remove', job.id ?? job.name ?? options.name]); + } + } const args = [ 'cron', @@ -414,7 +420,6 @@ async function installOpenClawNativeThreatFeedCron( '--thinking', 'off', ]; - if (options.force) args.push('--force'); await runCommand('openclaw', args); return { name: options.name, @@ -434,14 +439,20 @@ class CronBackendUnavailableError extends Error { } function nativeCronListHasExactName(stdout: string, name: string): boolean { + return nativeCronListJobsByName(stdout, name).length > 0; +} + +function nativeCronListJobsByName(stdout: string, name: string): OpenClawCronJob[] { const jsonJobs = extractOpenClawCronJobs(parseJsonOrNull(stdout)); - if (jsonJobs.some((job) => job.name === name)) return true; + const exactJsonJobs = jsonJobs.filter((job) => job.name === name); + if (exactJsonJobs.length > 0) return exactJsonJobs; return stdout .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) - .some((line) => nativeCronListLineHasExactName(line, name)); + .filter((line) => nativeCronListLineHasExactName(line, name)) + .map((line) => ({ id: nativeCronListLineId(line), name })); } function nativeCronListLineHasExactName(line: string, name: string): boolean { @@ -449,7 +460,17 @@ function nativeCronListLineHasExactName(line: string, name: string): boolean { if (quoted?.[2] === name) return true; const cells = line.split(/\s{2,}|\t+/).map((cell) => cell.trim()).filter(Boolean); - return cells.includes(name); + if (cells.includes(name)) return true; + + return new RegExp(`(^|\\s)${escapeRegExp(name)}(\\s|$)`).test(line); +} + +function nativeCronListLineId(line: string): string | undefined { + return line.match(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i)?.[0]; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function parseJsonOrNull(value: string): unknown { @@ -626,10 +647,13 @@ async function removeOpenClawNativeThreatFeedCron( ): Promise { try { const existing = await runCommand('openclaw', ['cron', 'list']); - if (!nativeCronListHasExactName(existing.stdout, options.name)) { + const jobs = nativeCronListJobsByName(existing.stdout, options.name); + if (jobs.length === 0) { return { name: options.name, backend: 'openclaw', removed: false }; } - await runCommand('openclaw', ['cron', 'remove', options.name]); + for (const job of jobs) { + await runCommand('openclaw', ['cron', 'remove', job.id ?? job.name ?? options.name]); + } return { name: options.name, backend: 'openclaw', removed: true }; } catch (err) { return { name: options.name, backend: 'openclaw', removed: false, error: (err as Error).message }; diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index 3713481..f822527 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -260,6 +260,37 @@ describe('feed/cron', () => { assert.deepEqual(gateway.calls[1].params, { jobId: 'job-1' }); }); + it('removes native OpenClaw cron jobs by id', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const runner: CommandRunner = async (command, args) => { + calls.push({ command, args }); + if (args.join(' ') === 'cron list') { + return { + stdout: JSON.stringify({ + jobs: [ + { id: '7407b173-da3f-4ded-b6e3-722a9c5248b0', name: 'agentguard-threat-feed' }, + ], + }), + stderr: '', + }; + } + return { stdout: '', stderr: '' }; + }; + + const result = await removeThreatFeedCron( + { + name: 'agentguard-threat-feed', + backend: 'openclaw', + }, + { runCommand: runner, gateway: { request: fakeGateway().request } } + ); + + assert.equal(result[0].backend, 'openclaw'); + assert.equal(result[0].removed, true); + assert.deepEqual(calls.map((call) => call.args.slice(0, 2).join(' ')), ['cron list', 'cron remove']); + assert.deepEqual(calls[1].args, ['cron', 'remove', '7407b173-da3f-4ded-b6e3-722a9c5248b0']); + }); + it('rejects unsafe AgentGuard home paths for system crontab jobs', async () => { await assert.rejects( () => @@ -391,6 +422,41 @@ describe('feed/cron', () => { assert.deepEqual(calls.map((call) => call.args.slice(0, 2).join(' ')), ['cron list']); }); + it('replaces native OpenClaw cron jobs by id when force is set', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const runner: CommandRunner = async (command, args) => { + calls.push({ command, args }); + if (args.join(' ') === 'cron list') { + return { + stdout: [ + 'ID Name Schedule', + '7407b173-da3f-4ded-b6e3-722a9c5248b0 agentguard-threat-feed cron */5 * * * * @ UTC', + ].join('\n'), + stderr: '', + }; + } + return { stdout: '', stderr: '' }; + }; + + const result = await installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '*/5 * * * *', + quiet: true, + force: true, + backend: 'auto', + agentHost: 'openclaw', + timezone: 'UTC', + }, + { runCommand: runner } + ); + + assert.equal(result.created, true); + assert.deepEqual(calls.map((call) => call.args.slice(0, 2).join(' ')), ['cron list', 'cron remove', 'cron add']); + assert.deepEqual(calls[1].args, ['cron', 'remove', '7407b173-da3f-4ded-b6e3-722a9c5248b0']); + assert.ok(!calls[2].args.includes('--force')); + }); + it('does not fall back to OpenClaw Gateway when native OpenClaw cron add fails', async () => { const gateway = fakeGateway(); const runner: CommandRunner = async (_command, args) => { From 2fead40fa424eec95d6efc3b2e4b3a3ee6f590eb Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Wed, 27 May 2026 18:58:01 +0800 Subject: [PATCH 5/8] changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc4974..ed866fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.1.20] - 2026-05-27 + +### Changed +- `agentguard init --agent openclaw` now enables the AgentGuard plugin in both the main OpenClaw config and companion workspace state when either layout is detected. +- OpenClaw Gateway fallback requests now reuse configured bearer tokens from `AGENTGUARD_OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_GATEWAY_TOKEN`, or the local OpenClaw config for HTTP and WebSocket paths. +- `agentguard status` now refreshes Agent JWT account binding state and clears stale activation links once the saved Agent JWT is accepted by Cloud. + +### Fixed +- Fixed Agent JWT activation messaging to describe account binding instead of email binding. +- Fixed native OpenClaw cron replacement and removal to delete existing jobs by job ID before reinstalling, avoiding reliance on unsupported `openclaw cron add --force` behavior. + ## [1.1.18] - 2026-05-26 ### Added From 2f54bb05541db8bba1399fc39a0cebab2790a54e Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Wed, 27 May 2026 18:58:04 +0800 Subject: [PATCH 6/8] 1.1.19 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9bfb4e3..ff24859 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@goplus/agentguard", - "version": "1.1.18", + "version": "1.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@goplus/agentguard", - "version": "1.1.18", + "version": "1.1.19", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e10804d..433ade2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@goplus/agentguard", - "version": "1.1.18", + "version": "1.1.19", "description": "GoPlus AgentGuard — Security guard for AI agents. Blocks dangerous commands, prevents data leaks, protects secrets. 20 detection rules, runtime action evaluation, trust registry.", "main": "dist/index.js", "types": "dist/index.d.ts", From 8650bce5dab98263f991d35c6af0acee1b4547f8 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Wed, 27 May 2026 18:58:18 +0800 Subject: [PATCH 7/8] 1.1.20 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ff24859..3f576df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@goplus/agentguard", - "version": "1.1.19", + "version": "1.1.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@goplus/agentguard", - "version": "1.1.19", + "version": "1.1.20", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 433ade2..8f27cff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@goplus/agentguard", - "version": "1.1.19", + "version": "1.1.20", "description": "GoPlus AgentGuard — Security guard for AI agents. Blocks dangerous commands, prevents data leaks, protects secrets. 20 detection rules, runtime action evaluation, trust registry.", "main": "dist/index.js", "types": "dist/index.d.ts", From 65d7a4a72a3d512dcfc2921dc04b0f7a29a0010b Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Wed, 27 May 2026 19:14:02 +0800 Subject: [PATCH 8/8] Fix subscribe cron JWT reauth handling --- CHANGELOG.md | 1 + src/cli.ts | 70 +++++++++++++++++++++++++++------ src/tests/cli-subscribe.test.ts | 67 +++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed866fb..4106748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed Agent JWT activation messaging to describe account binding instead of email binding. +- Fixed subscribe cron runs so Agent JWT 401 responses prompt for a manual `agentguard connect` instead of automatically re-registering the local agent. - Fixed native OpenClaw cron replacement and removal to delete existing jobs by job ID before reinstalling, avoiding reliance on unsupported `openclaw cron add --force` behavior. ## [1.1.18] - 2026-05-26 diff --git a/src/cli.ts b/src/cli.ts index 49ad5c1..1a76177 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -406,6 +406,7 @@ async function main() { const since = options.since as string | undefined; const quiet = Boolean(options.quiet); const cronNotifyRun = Boolean(options.cronNotifyRun); + const cronInternalRun = Boolean(options.cronRun || options.cronNotifyRun); const cronTarget = validateCronTarget(options.cronTarget); const cronRunSendsToOpenClaw = Boolean(options.cronRun) && cronAgentHost === 'openclaw'; const cronExpression = options.cron && !options.cronRun @@ -450,6 +451,11 @@ async function main() { await client.subscribeFeed(); } catch (err) { if (err instanceof CloudRequestError && err.status === 401) { + if (cronInternalRun) { + await printSubscribeConnectRequired(options, cronRunSendsToOpenClaw); + process.exitCode = 1; + return; + } if (!isOpenClawAgentConfigured(config)) { console.error('! AgentGuard Cloud credential was rejected. Run `agentguard connect --key ` again.'); process.exitCode = 1; @@ -496,6 +502,11 @@ async function main() { advisories = await client.pullAdvisories(since); } catch (err) { if (err instanceof CloudRequestError && err.status === 401) { + if (cronInternalRun) { + await printSubscribeConnectRequired(options, cronRunSendsToOpenClaw); + process.exitCode = 1; + return; + } if (!isOpenClawAgentConfigured(config)) { console.error('! AgentGuard Cloud credential was rejected. Run `agentguard connect --key ` again.'); process.exitCode = 1; @@ -576,20 +587,32 @@ async function main() { // match, we must NOT mark the advisory seen, otherwise a // transient network blip silently buries a real hit. try { - const reportResult = await runCloudRequestWithAgentJwtReauth({ - config, - client, - reason: 'reauth', - notifyOpenClaw: resolveCronAgentHost(config) === 'openclaw', - operation: (activeClient) => activeClient.reportSelfCheck(advisory.id, result.matchedArtifacts, { + if (cronInternalRun) { + await client.reportSelfCheck(advisory.id, result.matchedArtifacts, { elapsedMs: result.elapsedMs, warnings: result.warnings, - }), - }); - config = reportResult.config; - client = reportResult.client; - if (reportResult.registration) registration = reportResult.registration; + }); + } else { + const reportResult = await runCloudRequestWithAgentJwtReauth({ + config, + client, + reason: 'reauth', + notifyOpenClaw: resolveCronAgentHost(config) === 'openclaw', + operation: (activeClient) => activeClient.reportSelfCheck(advisory.id, result.matchedArtifacts, { + elapsedMs: result.elapsedMs, + warnings: result.warnings, + }), + }); + config = reportResult.config; + client = reportResult.client; + if (reportResult.registration) registration = reportResult.registration; + } } catch (err) { + if (cronInternalRun && err instanceof CloudRequestError && err.status === 401) { + await printSubscribeConnectRequired(options, cronRunSendsToOpenClaw); + process.exitCode = 1; + return; + } console.error(`! Failed to report self-check for ${advisory.id}: ${(err as Error).message}`); processed = false; hardFailures += 1; @@ -922,6 +945,31 @@ function printCloudAuthStatus(config: AgentGuardConfig): void { console.log('Agent JWT: not configured'); } +async function printSubscribeConnectRequired( + options: { json?: boolean; cronNotifyRun?: boolean }, + notifyOpenClaw: boolean +): Promise { + const message = 'AgentGuard Cloud credential was rejected. Run `agentguard connect` again before the next subscribe cron run.'; + if (notifyOpenClaw) { + const notification = await notifyOpenClawMessage(message, resolveOpenClawGatewayOptionsFromEnv(), { + idempotencyKeyPrefix: 'agentguard-subscribe-auth', + }); + if (notification.notified) { + console.log('NO_REPLY'); + return; + } + console.error(`! Could not send OpenClaw cron auth notification: ${notification.reason ?? 'Unknown error'}`); + return; + } + if (options.cronNotifyRun) { + console.log(message); + } else if (options.json) { + console.log(JSON.stringify({ success: false, error: message }, null, 2)); + } else { + console.error(`! ${message}`); + } +} + async function refreshAgentAccountBinding(config: AgentGuardConfig): Promise { if (!config.agentJwt || !config.agentRegisterUrl) return config; const client = new AgentGuardCloudClient(config); diff --git a/src/tests/cli-subscribe.test.ts b/src/tests/cli-subscribe.test.ts index f07b98d..b900859 100644 --- a/src/tests/cli-subscribe.test.ts +++ b/src/tests/cli-subscribe.test.ts @@ -449,4 +449,71 @@ describe('CLI subscribe command modes', () => { await new Promise((resolvePromise) => server.close(() => resolvePromise())); } }); + + it('does not re-register the local agent during subscribe cron runs when Agent JWT auth returns 401', async () => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-subscribe-cron-reauth-')); + const authHeaders: Array = []; + let registerRequests = 0; + const server = http.createServer((req, res) => { + if (req.method === 'POST' && req.url === '/api/agent/register') { + registerRequests += 1; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + success: true, + data: { + agentId: 'agt_unexpected', + jwt: 'agent.jwt.unexpected', + registerUrl: 'https://agentguard.example/activate?token=unexpected', + }, + })); + return; + } + if (req.method === 'POST' && req.url === '/api/v1/feed/subscribe') { + authHeaders.push(req.headers.authorization); + res.statusCode = 401; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ success: false, error: { message: 'expired agent jwt' } })); + 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}`; + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl, + agentHost: 'codex', + agentHosts: ['codex'], + agentId: 'agt_old_subscribe', + agentJwt: 'agent.jwt.old', + policyCachePath: join(home, 'policy-cache.json'), + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + })); + + const result = await runCliNoConfigWrite(['subscribe', '--json', '--cron-run'], home); + + assert.equal(result.exitCode, 1); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /Run `agentguard connect` again/); + assert.deepEqual(authHeaders, ['Bearer agent.jwt.old']); + assert.equal(registerRequests, 0); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentId?: string; + agentJwt?: string; + agentRegisterUrl?: string; + }; + assert.equal(config.agentId, 'agt_old_subscribe'); + assert.equal(config.agentJwt, 'agent.jwt.old'); + assert.equal(config.agentRegisterUrl, undefined); + } finally { + await new Promise((resolvePromise) => server.close(() => resolvePromise())); + } + }); });