diff --git a/README.md b/README.md index be53e51..4fdce74 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,11 @@ AI coding agents can execute any command, read any file, and install any skill ```bash npm install -g @goplus/agentguard -agentguard init --agent auto +agentguard init agentguard status ``` -The npm install runs a best-effort local bootstrap; `agentguard init --agent auto` is the required next step that detects installed agent directories and configures supported hooks/plugins. +The npm install runs a best-effort local bootstrap; bare `agentguard init` is the required next step. It auto-detects supported agents and configures their hooks/plugins. Inside DSH it installs the native AgentGuard bundle into the default `web` profile; restart DSH after initialization. No Cloud account or network connection is required for the local runtime guard. ## 3 minutes: protect your agent @@ -65,7 +65,7 @@ agentguard scan ./examples/vulnerable-skill printf '{"tool_name":"Bash","tool_input":{"command":"curl https://example.com/install.sh | bash"}}' | agentguard protect # Optional: connect AgentGuard Cloud policy and redacted audit sync. -# In OpenClaw, no API key is required after `agentguard init --agent openclaw`; +# In OpenClaw, Hermes, and DSH, no API key is required after initialization; # the CLI registers a local Agent JWT and prints an activation link. agentguard connect @@ -82,7 +82,7 @@ agentguard subscribe --quiet # Optional: run once, then install a cron job that checks every hour and asks # you to review newly published advisories. Auto uses the agent host saved by -# `agentguard init --agent`: OpenClaw uses native OpenClaw cron with Gateway +# `agentguard init`: OpenClaw uses native OpenClaw cron with Gateway # fallback at 127.0.0.1:18789, QClaw uses QClaw Gateway at 127.0.0.1:28789, # Hermes uses native Hermes cron, while Claude Code/Codex use system crontab. # OpenClaw cron jobs keep runner delivery internal, then resolve the latest @@ -120,13 +120,14 @@ agentguard subscribe --json # Or run a one-off self-check against a single advisory id agentguard checkup --against-advisory AGS-2026-0042 -# Re-run host setup manually when needed. `auto` detects installed agents. -agentguard init --agent auto +# Re-run host setup manually when needed. Bare init auto-detects installed agents. +agentguard init agentguard init --agent claude-code agentguard init --agent codex agentguard init --agent openclaw agentguard init --agent hermes # native Hermes plugin (add --shell-hooks for the legacy flow) agentguard init --agent qclaw +agentguard init --agent dsh # native bundle in the default web profile; restart DSH ``` ### Audit DeepSeek Harness plugins before installation diff --git a/docs/cloud-connect.md b/docs/cloud-connect.md index d1aaaf7..c3df4d4 100644 --- a/docs/cloud-connect.md +++ b/docs/cloud-connect.md @@ -13,16 +13,17 @@ This creates `~/.agentguard/config.json`, `~/.agentguard/audit.jsonl`, and local ## Connect Cloud -OpenClaw users can connect without an API key after initialization: +OpenClaw, Hermes, and DSH users can connect without an API key after initialization: ```bash -agentguard init --agent openclaw +agentguard init agentguard connect ``` -In this mode, `connect` registers a local Agent JWT, prints an activation link, -and may send that link to the latest OpenClaw channel. Open the link to bind the -local agent to your AgentGuard account. +In this mode, `connect` registers a local Agent JWT and prints an activation link. +OpenClaw may also send that link to its latest channel. Open the link to bind the +local agent to your AgentGuard account. If auto-detection is unavailable, pass an +explicit host such as `agentguard init --agent dsh`. API-key auth is also supported: diff --git a/docs/cloud-native-api.md b/docs/cloud-native-api.md index 830a0e7..09b5c48 100644 --- a/docs/cloud-native-api.md +++ b/docs/cloud-native-api.md @@ -45,7 +45,7 @@ Native UI may present `require_approval` as `confirm`, but API payloads should k ### Agent hosts ```text -claude-code | codex | openclaw | cursor | gemini | copilot | other +claude-code | codex | openclaw | hermes | qclaw | dsh | cursor | gemini | copilot | other ``` ### Action types @@ -78,37 +78,43 @@ safe | low | medium | high | critical ### Commercial install script ```http -GET /install.sh?agent=claude-code +GET /install.sh?agent=dsh ``` Allowed `agent` values: ```text -auto | claude-code | openclaw | codex +auto | openclaw | hermes | dsh ``` -The script installs `@goplus/agentguard`, writes a safe fallback local config, then calls: +The hosted script installs `@goplus/agentguard`, initializes integrations, and connects Cloud. With no explicit host it calls: ```bash -agentguard init --agent "$AGENTGUARD_AGENT" --cloud "$AGENTGUARD_CLOUD_URL" +agentguard init --cloud "$AGENTGUARD_CLOUD_URL" ``` -When the effective agent host is OpenClaw, the script should connect without an -API key: +When the hosting service already knows the requested host, it may add a validated +`--agent` value. Explicit activation-link hosts are restricted to +OpenClaw, Hermes, and DSH. DSH uses its default `web` profile and must be +restarted after installation. In `auto` mode, at least one detected host must +support Agent JWT registration or `connect` exits with guidance instead of +claiming a binding link was created. + +When the effective agent host is OpenClaw, Hermes, or DSH, the script connects +without an API key: ```bash agentguard connect --cloud "$AGENTGUARD_CLOUD_URL" ``` The CLI registers a local Agent JWT and prints an activation link. For other -agent hosts, or when the user explicitly chooses API-key auth, the script should -call: +agent hosts, use the CLI's separate API-key flow: ```bash agentguard connect --cloud "$AGENTGUARD_CLOUD_URL" --api-key "$AGENTGUARD_API_KEY" ``` -Native CLI implementations should support `--cloud` as an alias for the Cloud URL and `--api-key` as an alias for the API key. Installers that accept `agent=auto` should use the agent host persisted by `agentguard init --agent auto` when choosing between Agent JWT and API-key auth. +Native CLI implementations should support `--cloud` as an alias for the Cloud URL and `--api-key` as an alias for the API key. Bare `agentguard init` is equivalent to `--agent auto`; it persists detected hosts so `connect` can choose Agent JWT or API-key auth. ### Health check diff --git a/docs/dsh.md b/docs/dsh.md index 99b0863..d28416a 100644 --- a/docs/dsh.md +++ b/docs/dsh.md @@ -8,6 +8,15 @@ The Phase 1 scanner is intentionally read-only: it scans source, classifies capa AgentGuard can be loaded into a DSH profile as a native tool plugin. From an npm release: +```bash +npm install -g @goplus/agentguard +agentguard init --agent dsh +``` + +When invoked inside DSH, bare `agentguard init` auto-detects DSH and performs the same installation. Both commands install the packaged native bundle into DSH's default `web` profile. Restart DSH after initialization. + +The equivalent low-level DSH command is: + ```bash dsh plugin --profile web add @goplus/agentguard ``` @@ -18,7 +27,7 @@ For local development, link the checkout instead: dsh plugin --profile web add link:/absolute/path/to/agentguard ``` -Restart DSH after installation. The profile then exposes `agentguard_dsh_scan`, which accepts a local directory or HTTPS GitHub repository URL, an optional GitHub `ref`, and a Markdown or JSON format. It also exposes `agentguard_dsh_scan_batch` for sequentially scanning up to 10 targets, `agentguard_dsh_compare` for comparing an approved version with a candidate, and `agentguard_dsh_runtime_summary` for input-redacted runtime audit aggregates. For example, ask DSH: “Use AgentGuard to compare tags `v1.2.3` and `v1.3.0` of `https://github.com/owner/plugin` before I update.” +The profile then exposes `agentguard_dsh_scan`, which accepts a local directory or HTTPS GitHub repository URL, an optional GitHub `ref`, and a Markdown or JSON format. It also exposes `agentguard_dsh_scan_batch` for sequentially scanning up to 10 targets, `agentguard_dsh_compare` for comparing an approved version with a candidate, and `agentguard_dsh_runtime_summary` for input-redacted runtime audit aggregates. For example, ask DSH: “Use AgentGuard to compare tags `v1.2.3` and `v1.3.0` of `https://github.com/owner/plugin` before I update.” The three static AgentGuard DSH tools preserve the Phase 1 boundary: they do not install or execute the target plugin. The fourth tool only summarizes local runtime audit events and never returns raw tool input. The installed bundle enables `observe` by default; [DSH runtime guard](dsh-runtime.md) documents explicit `protect` configuration. diff --git a/src/cli.ts b/src/cli.ts index f9557d9..7e266ca 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,7 +48,7 @@ import { type OpenClawGatewayOptions, } from './feed/cron.js'; -const SUPPORTED_AGENT_INSTALLERS: AgentInstaller[] = ['claude-code', 'codex', 'openclaw', 'hermes', 'qclaw']; +const SUPPORTED_AGENT_INSTALLERS: AgentInstaller[] = ['claude-code', 'codex', 'openclaw', 'hermes', 'qclaw', 'dsh']; const AUTO_AGENT_DETECTION: Array<{ agent: AgentInstaller; dir: string }> = [ { agent: 'claude-code', dir: '.claude' }, { agent: 'openclaw', dir: '.openclaw' }, @@ -56,7 +56,7 @@ const AUTO_AGENT_DETECTION: Array<{ agent: AgentInstaller; dir: string }> = [ { agent: 'qclaw', dir: '.qclaw' }, { agent: 'codex', dir: '.codex' }, ]; -const REQUIRED_INIT_COMMAND = 'agentguard init --agent auto'; +const REQUIRED_INIT_COMMAND = 'agentguard init'; async function main() { const program = new Command(); @@ -70,7 +70,7 @@ async function main() { .command('init') .description('Create ~/.agentguard/config.json and local runtime paths') .option('--level ', 'Protection level: strict | balanced | permissive') - .option('--agent ', 'Install hook/template for claude-code, codex, openclaw, hermes, or qclaw') + .option('--agent ', 'Install integration for auto, claude-code, codex, openclaw, hermes, qclaw, or dsh (default: auto)') .option('--cloud ', 'AgentGuard Cloud URL to store in local config') .option('--shell-hooks', 'For Hermes: install legacy shell hooks instead of the native plugin') .option('--force', 'Overwrite existing hook/template files') @@ -92,18 +92,19 @@ async function main() { const paths = getAgentGuardPaths(); console.log(`AgentGuard initialized at ${paths.home}`); console.log(`Config: ${paths.configPath}`); - if (options.agent) { - const normalizedAgent = String(options.agent).trim().toLowerCase(); + { + const normalizedAgent = options.agent === undefined + ? 'auto' + : String(options.agent).trim().toLowerCase(); if (normalizedAgent === 'auto') { const results = initAutoAgents(config, forceTemplates); if (results.detected.length === 0) { - console.log('No supported agent directories found. Looked for .claude, .openclaw, .hermes, .qclaw, and .codex.'); + console.log('No supported agent installation found. Looked for DSH and .claude, .openclaw, .hermes, .qclaw, and .codex.'); } else if (results.installed.length === 0) { - console.log('No agent templates were installed; all detected agent initializers failed.'); + console.log('No agent integrations were installed; all detected agent initializers failed.'); } for (const result of results.installed) { - console.log(`Installed ${result.agent} template:`); - for (const file of result.files) console.log(`- ${file}`); + printInstallResult(result); if (result.agent === 'hermes') printHermesNativePluginEnabled(); } for (const failure of results.failed) { @@ -112,16 +113,15 @@ async function main() { return; } if (!SUPPORTED_AGENT_INSTALLERS.includes(normalizedAgent as AgentInstaller)) { - throw new Error('Invalid agent. Use auto, claude-code, codex, openclaw, hermes, or qclaw.'); + throw new Error('Invalid agent. Use auto, claude-code, codex, openclaw, hermes, qclaw, or dsh.'); } const agent = normalizedAgent as AgentInstaller; + const shellHooks = Boolean(options.shellHooks); + const result = installAgentTemplates(agent, { force: forceTemplates, shellHooks }); config.agentHost = agent; config.agentHosts = appendAgentHost(config.agentHosts, agent); saveConfig(config); - const shellHooks = Boolean(options.shellHooks); - const result = installAgentTemplates(agent, { force: forceTemplates, shellHooks }); - console.log(`Installed ${result.agent} template:`); - for (const file of result.files) console.log(`- ${file}`); + printInstallResult(result); if (agent === 'hermes' && !shellHooks) { printHermesNativePluginEnabled(); } @@ -140,7 +140,7 @@ async function main() { if (!apiKey) { let config = ensureConfig(); 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.'); + throw new Error('AgentGuard Cloud connect supports API-key auth or Agent JWT registration for OpenClaw, Hermes, and DSH. No API key was provided, and no supported Agent JWT host has been initialized. Run `agentguard init` to auto-detect the host, then rerun `agentguard connect`; or pass --key, --api-key, or AGENTGUARD_API_KEY for API-key auth.'); } config = withDetectedAgentJwtHost(config); const cloudUrl = normalizeCloudUrl(options.cloud || options.url || config.cloudUrl || 'https://agentguard.gopluslabs.io'); @@ -568,7 +568,7 @@ async function main() { let registration: AgentCredentialRegistration | null = null; if (!client.connected) { 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.'; + const message = 'AgentGuard Cloud is not connected. Run `agentguard connect --key ` first, or run `agentguard init` to auto-detect an OpenClaw, Hermes, or DSH host for Agent JWT registration.'; if (cronNotifyRun) { console.log('NO_REPLY'); } else if (options.json) { @@ -1005,9 +1005,14 @@ function initAutoAgents(config: AgentGuardConfig, force: boolean): { } { const installed: InstallResult[] = []; const failed: Array<{ agent: AgentInstaller; error: string }> = []; - const detectedAgents = AUTO_AGENT_DETECTION + const directoryAgents = AUTO_AGENT_DETECTION .filter(({ dir }) => existsSync(join(process.cwd(), dir))) .map(({ agent }) => agent); + const isDshManagedShell = detectDshManagedShell(); + const dshAgents: AgentInstaller[] = isDshManagedShell || detectInstalledDshWebProfile() ? ['dsh'] : []; + const detectedAgents: AgentInstaller[] = isDshManagedShell + ? [...dshAgents, ...directoryAgents] + : [...directoryAgents, ...dshAgents]; for (const agent of detectedAgents) { try { @@ -1029,6 +1034,16 @@ function initAutoAgents(config: AgentGuardConfig, force: boolean): { return { installed, failed, detected: detectedAgents }; } +function printInstallResult(result: InstallResult): void { + if (result.agent === 'dsh') { + console.log('Installed dsh integration in profile web.'); + console.log('Restart DSH to activate AgentGuard in that profile.'); + return; + } + console.log(`Installed ${result.agent} template:`); + for (const file of result.files) console.log(`- ${file}`); +} + function appendAgentHost( agentHosts: AgentGuardConfig['agentHosts'] | undefined, agent: AgentGuardAgentHost @@ -1583,17 +1598,44 @@ function isHermesAgentConfigured(config: AgentGuardConfig): boolean { return config.agentHost === 'hermes' || config.agentHosts?.includes('hermes') === true || detectHermesRuntime(); } +function isDshAgentConfigured(config: AgentGuardConfig): boolean { + return config.agentHost === 'dsh' || config.agentHosts?.includes('dsh') === true || detectDshManagedShell(); +} + function isAgentJwtHostConfigured(config: AgentGuardConfig): boolean { - return isOpenClawAgentConfigured(config) || isHermesAgentConfigured(config); + return isOpenClawAgentConfigured(config) || isHermesAgentConfigured(config) || isDshAgentConfigured(config); } function withDetectedAgentJwtHost(config: AgentGuardConfig): AgentGuardConfig { - if (hasSavedAgentHost(config)) return config; + if (isAgentJwtAgentHost(config.agentHost)) return config; + const savedAgentJwtHost = config.agentHosts?.find(isAgentJwtAgentHost); + if (savedAgentJwtHost) return withDetectedAgentHost(config, savedAgentJwtHost); + if (detectDshManagedShell()) return withDetectedAgentHost(config, 'dsh'); if (detectOpenClawRuntime()) return withDetectedAgentHost(config, 'openclaw'); if (detectHermesRuntime()) return withDetectedAgentHost(config, 'hermes'); return config; } +function isAgentJwtAgentHost(value: AgentGuardAgentHost | undefined): value is 'openclaw' | 'hermes' | 'dsh' { + return value === 'openclaw' || value === 'hermes' || value === 'dsh'; +} + +function detectDshManagedShell(): boolean { + return process.env.DSH_SHELL === '1'; +} + +function detectInstalledDshWebProfile(): boolean { + const configuredHome = process.env.DSH_HOME?.trim(); + const dshHome = configuredHome + ? configuredHome === '~' + ? homedir() + : /^~[\\/]/.test(configuredHome) + ? join(homedir(), configuredHome.slice(2)) + : resolve(configuredHome) + : join(homedir(), '.dsh'); + return existsSync(join(dshHome, 'profiles', 'web', 'package.json')); +} + function withDetectedAgentHost(config: AgentGuardConfig, agentHost: AgentGuardAgentHost): AgentGuardConfig { const next: AgentGuardConfig = { ...config, diff --git a/src/config.ts b/src/config.ts index 2a6adfa..2ebd497 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; -export type AgentGuardAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; +export type AgentGuardAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw' | 'dsh'; export interface AgentGuardConfig { version: 1; @@ -226,7 +226,7 @@ function normalizeLevel(value: unknown): AgentGuardConfig['level'] | null { } function normalizeAgentHost(value: unknown): AgentGuardAgentHost | undefined { - return value === 'claude-code' || value === 'codex' || value === 'openclaw' || value === 'hermes' || value === 'qclaw' + return value === 'claude-code' || value === 'codex' || value === 'openclaw' || value === 'hermes' || value === 'qclaw' || value === 'dsh' ? value : undefined; } diff --git a/src/feed/cron.ts b/src/feed/cron.ts index ed5dc32..ce8d9cf 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -9,7 +9,7 @@ import { isAbsolute, join } from 'node:path'; export type CronBackend = 'auto' | 'openclaw' | 'qclaw' | 'hermes' | 'system'; export type ResolvedCronBackend = 'openclaw' | 'openclaw-gateway' | 'qclaw-gateway' | 'hermes' | 'system'; -export type CronAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; +export type CronAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw' | 'dsh'; export interface OpenClawCronInstallResult { name: string; @@ -103,7 +103,7 @@ export async function installThreatFeedCron( const backend = options.backend ?? 'auto'; if (backend === 'auto' && !options.agentHost) { throw new Error( - 'Cron target auto requires a saved agent host. Run `agentguard init --agent ` first, or pass `--cron-target openclaw`, `--cron-target qclaw`, `--cron-target hermes`, or `--cron-target system`.' + 'Cron target auto requires a saved agent host. Run `agentguard init --agent ` first, or pass `--cron-target openclaw`, `--cron-target qclaw`, `--cron-target hermes`, or `--cron-target system`.' ); } if (backend === 'openclaw' && options.agentHost && options.agentHost !== 'openclaw') { diff --git a/src/installers.ts b/src/installers.ts index 24f729b..35fed32 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -1,8 +1,9 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; -export type AgentInstaller = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; +export type AgentInstaller = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw' | 'dsh'; export interface InstallResult { agent: AgentInstaller; @@ -21,9 +22,30 @@ export function installAgentTemplates(agent: AgentInstaller, options: { cwd?: st if (agent === 'openclaw') return installOpenClaw(options.cwd, Boolean(options.force)); if (agent === 'hermes') return installHermes(options.cwd, Boolean(options.force), { shellHooks: Boolean(options.shellHooks) }); if (agent === 'qclaw') return installQClaw(root, Boolean(options.force)); + if (agent === 'dsh') return installDsh(root); throw new Error(`Unsupported agent installer: ${agent}`); } +function installDsh(root: string): InstallResult { + const args = ['plugin', '--profile', 'web', 'add', '@goplus/agentguard']; + const result = spawnSync('dsh', args, { + cwd: root, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.error) { + const code = (result.error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + throw new Error('DSH CLI was not found on PATH. Install or run DSH before initializing AgentGuard for DSH.'); + } + throw result.error; + } + if (result.status !== 0) { + throw new Error(`DSH plugin installation failed with exit code ${result.status ?? 1}.`); + } + return { agent: 'dsh', files: [] }; +} + function installClaudeCode(root: string, force: boolean): InstallResult { const hookDir = join(root, '.claude', 'hooks'); const hookPath = join(hookDir, 'agentguard-protect.sh'); diff --git a/src/postinstall.ts b/src/postinstall.ts index 21ff575..89a5ca7 100644 --- a/src/postinstall.ts +++ b/src/postinstall.ts @@ -6,7 +6,7 @@ import { ensureConfig, getAgentGuardPaths } from './config.js'; const NEXT_STEPS = [ 'Next step:', - ' agentguard init --agent auto', + ' agentguard init', '', ].join('\n'); diff --git a/src/tests/cli-connect.test.ts b/src/tests/cli-connect.test.ts index 5559d88..314a2bf 100644 --- a/src/tests/cli-connect.test.ts +++ b/src/tests/cli-connect.test.ts @@ -19,6 +19,8 @@ const ISOLATED_OPENCLAW_ENV = { OPENCLAW_CONFIG_PATH: '', OPENCLAW_STATE_DIR: '', HERMES_HOME: '', + DSH_HOME: '', + DSH_SHELL: '', }; function runCli( @@ -336,7 +338,8 @@ describe('CLI connect Agent JWT mode', () => { assert.equal(result.exitCode, 1); assert.equal(result.stdout, ''); - assert.match(result.stderr, /init --agent openclaw/); + assert.match(result.stderr, /Run `agentguard init` to auto-detect the host/); + assert.doesNotMatch(result.stderr, /init --agent openclaw/); }); it('uses Hermes Agent JWT registration when Hermes has been initialized', async () => { @@ -404,6 +407,111 @@ describe('CLI connect Agent JWT mode', () => { } }); + it('selects DSH for Agent JWT registration when the primary host is unsupported', 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_dsh_cli_test', + jwt: 'agent.jwt.dsh-cli-test', + registerUrl: 'https://agentguard.example/activate?token=dsh-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-dsh-')); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl, + agentHost: 'codex', + agentHosts: ['codex', 'dsh'], + 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_dsh_cli_test\)/); + assert.match(result.stdout, /https:\/\/agentguard\.example\/activate\?token=dsh-cli-test/); + assert.equal(requests[0].body.metadata.agentHost, 'dsh'); + assert.deepEqual(requests[0].body.metadata.agentHosts, ['codex', 'dsh']); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentId?: string; + agentJwt?: string; + agentRegisterUrl?: string; + agentHost?: string; + }; + assert.equal(config.agentHost, 'dsh'); + assert.equal(config.agentId, 'agt_dsh_cli_test'); + assert.equal(config.agentJwt, 'agent.jwt.dsh-cli-test'); + assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=dsh-cli-test'); + } finally { + await new Promise((resolvePromise) => server.close(() => resolvePromise())); + } + }); + + it('does not treat an installed DSH web profile as the active connect host', async () => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-connect-dsh-profile-only-')); + const dshHome = join(home, '.dsh'); + mkdirSync(join(dshHome, 'profiles', 'web'), { recursive: true }); + writeFileSync(join(dshHome, 'profiles', 'web', 'package.json'), '{}'); + + const result = await runCli(['connect', '--url', 'https://agentguard.example'], home, { + DSH_HOME: dshHome, + }); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + + assert.equal(result.exitCode, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Run `agentguard init` to auto-detect the host/); + assert.equal(config.agentHost, undefined); + assert.equal(config.agentHosts, undefined); + }); + + it('uses the DSH managed shell contract for no-key connect', async () => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-connect-dsh-shell-')); + + const result = await runCli(['connect', '--url', 'http://127.0.0.1:9'], home, { + DSH_HOME: join(home, '.dsh'), + DSH_SHELL: '1', + }); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + + assert.equal(result.exitCode, 1); + assert.doesNotMatch(result.stderr, /No API key was provided/); + assert.match(result.stderr, /Could not register AgentGuard agent/); + assert.equal(config.agentHost, 'dsh'); + assert.deepEqual(config.agentHosts, ['dsh']); + }); + 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'); diff --git a/src/tests/cli-init.test.ts b/src/tests/cli-init.test.ts index 70f7f59..c00c64f 100644 --- a/src/tests/cli-init.test.ts +++ b/src/tests/cli-init.test.ts @@ -9,6 +9,20 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); describe('init CLI', () => { + function installFakeDsh(bin: string, callLog: string, exitCode = 0): void { + mkdirSync(bin, { recursive: true }); + const dsh = join(bin, 'dsh'); + writeFileSync(dsh, [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'fs.writeFileSync(process.env.DSH_CALL_LOG, JSON.stringify(process.argv.slice(2)));', + 'process.stdout.write("profile bundle installed\\n");', + `process.exit(${exitCode});`, + '', + ].join('\n')); + chmodSync(dsh, 0o755); + } + it('prints required init guidance when run without a command', async () => { const home = mkdtempSync(join(tmpdir(), 'agentguard-init-guidance-home-')); const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-guidance-cwd-')); @@ -20,7 +34,8 @@ describe('init CLI', () => { }); assert.match(stdout, /Required next step:/); - assert.match(stdout, /agentguard init --agent auto/); + assert.match(stdout, / agentguard init\n/); + assert.doesNotMatch(stdout, /agentguard init --agent auto/); assert.doesNotMatch(stdout, /agentguard connect/); assert.doesNotMatch(stdout, /agentguard checkup/); }); @@ -36,7 +51,8 @@ describe('init CLI', () => { }); assert.match(stdout, /Agent host: not configured/); - assert.match(stdout, /agentguard init --agent auto/); + assert.match(stdout, / agentguard init\n/); + assert.doesNotMatch(stdout, /agentguard init --agent auto/); assert.doesNotMatch(stdout, /agentguard connect/); assert.doesNotMatch(stdout, /agentguard checkup/); }); @@ -181,6 +197,66 @@ describe('init CLI', () => { assert.equal(config.agentHost, 'codex'); }); + it('installs the native AgentGuard bundle when DSH is selected explicitly', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-dsh-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-dsh-cwd-')); + const bin = join(cwd, 'bin'); + const callLog = join(cwd, 'dsh-call.json'); + const cliPath = resolve('dist', 'cli.js'); + installFakeDsh(bin, callLog); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'dsh'], { + cwd, + env: { + ...process.env, + AGENTGUARD_HOME: home, + DSH_CALL_LOG: callLog, + PATH: `${bin}:${process.env.PATH || ''}`, + }, + }); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + assert.equal(config.agentHost, 'dsh'); + assert.deepEqual(config.agentHosts, ['dsh']); + assert.deepEqual(JSON.parse(readFileSync(callLog, 'utf8')), [ + 'plugin', '--profile', 'web', 'add', '@goplus/agentguard', + ]); + assert.match(stdout, /Installed dsh integration in profile web/); + assert.match(stdout, /Restart DSH/); + }); + + it('does not persist DSH as initialized when native bundle installation fails', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-dsh-failure-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-dsh-failure-cwd-')); + const bin = join(cwd, 'bin'); + const callLog = join(cwd, 'dsh-call.json'); + const cliPath = resolve('dist', 'cli.js'); + installFakeDsh(bin, callLog, 17); + + await assert.rejects( + execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'dsh'], { + cwd, + env: { + ...process.env, + AGENTGUARD_HOME: home, + DSH_CALL_LOG: callLog, + PATH: `${bin}:${process.env.PATH || ''}`, + }, + }), + /DSH plugin installation failed with exit code 17/ + ); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + assert.equal(config.agentHost, undefined); + assert.equal(config.agentHosts, undefined); + }); + it('overwrites existing agent templates by default', async () => { const home = mkdtempSync(join(tmpdir(), 'agentguard-init-force-default-home-')); const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-force-default-cwd-')); @@ -300,6 +376,128 @@ describe('init CLI', () => { assert.match(stdout, /Installed codex template:/); }); + it('auto-detects DSH from its managed shell environment', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-dsh-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-dsh-cwd-')); + const bin = join(cwd, 'bin'); + const callLog = join(cwd, 'dsh-call.json'); + const cliPath = resolve('dist', 'cli.js'); + installFakeDsh(bin, callLog); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'auto'], { + cwd, + env: { + ...process.env, + AGENTGUARD_HOME: home, + DSH_CALL_LOG: callLog, + DSH_SHELL: '1', + PATH: `${bin}:${process.env.PATH || ''}`, + }, + }); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + assert.equal(config.agentHost, 'dsh'); + assert.deepEqual(config.agentHosts, ['dsh']); + assert.deepEqual(JSON.parse(readFileSync(callLog, 'utf8')), [ + 'plugin', '--profile', 'web', 'add', '@goplus/agentguard', + ]); + assert.match(stdout, /Installed dsh integration in profile web/); + }); + + it('auto-detects and installs DSH when init omits --agent', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-default-dsh-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-default-dsh-cwd-')); + const bin = join(cwd, 'bin'); + const callLog = join(cwd, 'dsh-call.json'); + const cliPath = resolve('dist', 'cli.js'); + installFakeDsh(bin, callLog); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'init'], { + cwd, + env: { + ...process.env, + AGENTGUARD_HOME: home, + DSH_CALL_LOG: callLog, + DSH_SHELL: '1', + PATH: `${bin}:${process.env.PATH || ''}`, + }, + }); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + assert.equal(config.agentHost, 'dsh'); + assert.deepEqual(config.agentHosts, ['dsh']); + assert.deepEqual(JSON.parse(readFileSync(callLog, 'utf8')), [ + 'plugin', '--profile', 'web', 'add', '@goplus/agentguard', + ]); + assert.match(stdout, /Installed dsh integration in profile web/); + }); + + it('auto-discovers an installed DSH web profile outside the managed shell', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-installed-dsh-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-installed-dsh-cwd-')); + const dshHome = join(home, 'dsh'); + const bin = join(cwd, 'bin'); + const callLog = join(cwd, 'dsh-call.json'); + const cliPath = resolve('dist', 'cli.js'); + mkdirSync(join(dshHome, 'profiles', 'web'), { recursive: true }); + writeFileSync(join(dshHome, 'profiles', 'web', 'package.json'), '{}'); + installFakeDsh(bin, callLog); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'init'], { + cwd, + env: { + ...process.env, + AGENTGUARD_HOME: home, + HOME: home, + DSH_HOME: '~/dsh', + DSH_CALL_LOG: callLog, + PATH: `${bin}:${process.env.PATH || ''}`, + }, + }); + + assert.deepEqual(JSON.parse(readFileSync(callLog, 'utf8')), [ + 'plugin', '--profile', 'web', 'add', '@goplus/agentguard', + ]); + assert.match(stdout, /Installed dsh integration in profile web/); + }); + + it('does not let an installed DSH profile override the current directory agent', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-dsh-secondary-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-dsh-secondary-cwd-')); + const dshHome = join(home, 'dsh'); + const bin = join(cwd, 'bin'); + const callLog = join(cwd, 'dsh-call.json'); + const cliPath = resolve('dist', 'cli.js'); + mkdirSync(join(dshHome, 'profiles', 'web'), { recursive: true }); + mkdirSync(join(cwd, '.openclaw'), { recursive: true }); + writeFileSync(join(dshHome, 'profiles', 'web', 'package.json'), '{}'); + installFakeDsh(bin, callLog); + + await execFileAsync(process.execPath, [cliPath, 'init'], { + cwd, + env: { + ...process.env, + AGENTGUARD_HOME: home, + DSH_HOME: dshHome, + DSH_CALL_LOG: callLog, + PATH: `${bin}:${process.env.PATH || ''}`, + }, + }); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + assert.equal(config.agentHost, 'openclaw'); + assert.deepEqual(config.agentHosts, ['openclaw', 'dsh']); + }); + it('does not fail auto init when no supported agent directory exists', async () => { const home = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-empty-home-')); const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-auto-empty-cwd-')); @@ -307,7 +505,7 @@ describe('init CLI', () => { const { stdout } = await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'auto'], { cwd, - env: { ...process.env, AGENTGUARD_HOME: home }, + env: { ...process.env, AGENTGUARD_HOME: home, DSH_HOME: join(home, 'missing-dsh-home') }, }); const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { @@ -316,7 +514,7 @@ describe('init CLI', () => { }; assert.equal(config.agentHost, undefined); assert.equal(config.agentHosts, undefined); - assert.match(stdout, /No supported agent directories found/); + assert.match(stdout, /No supported agent installation found/); }); it('continues auto init after one detected agent fails', async () => { diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index 37f220d..06dece3 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -162,43 +162,45 @@ describe('feed/cron', () => { assert.match(job.payload.message, /NO_REPLY/); }); - it('auto-installs system crontab jobs for Codex and Claude Code agents', async () => { - const calls: Array<{ command: string; args: string[]; input?: string }> = []; - const home = mkdtempSync(join(tmpdir(), 'agentguard-system-')); - const runner: CommandRunner = async (command, args, input) => { - calls.push({ command, args, input }); - if (command === 'crontab' && args[0] === '-l') { - return { stdout: '# existing\n', stderr: '' }; - } - return { stdout: '', stderr: '' }; - }; + it('auto-installs system crontab jobs for non-native cron agent hosts', async () => { + for (const agentHost of ['codex', 'dsh'] as const) { + const calls: Array<{ command: string; args: string[]; input?: string }> = []; + const home = mkdtempSync(join(tmpdir(), `agentguard-system-${agentHost}-`)); + const runner: CommandRunner = async (command, args, input) => { + calls.push({ command, args, input }); + if (command === 'crontab' && args[0] === '-l') { + return { stdout: '# existing\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }; - const result = await installThreatFeedCron( - { - name: 'agentguard-threat-feed', - cronExpression: '0 * * * *', - quiet: true, - force: false, - backend: 'auto', - agentHost: 'codex', - agentGuardHome: home, - timezone: 'UTC', - }, - { runCommand: runner } - ); + const result = await installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: true, + force: false, + backend: 'auto', + agentHost, + agentGuardHome: home, + timezone: 'UTC', + }, + { runCommand: runner } + ); - assert.equal(result.backend, 'system'); - assert.equal(result.created, true); - assert.equal(calls[0].command, 'crontab'); - assert.deepEqual(calls[0].args, ['-l']); - assert.equal(calls[1].command, 'crontab'); - assert.deepEqual(calls[1].args, ['-']); - assert.match(calls[1].input ?? '', /# AgentGuard begin agentguard-threat-feed/); - assert.match(calls[1].input ?? '', /agentguard-system-.*\/scripts\/agentguard-threat-feed\.sh/); - assert.doesNotMatch(calls[1].input ?? '', /AGENTGUARD_HOME=/); - const script = readFileSync(join(home, 'scripts', 'agentguard-threat-feed.sh'), 'utf8'); - assert.match(script, new RegExp(`export AGENTGUARD_HOME='${home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`)); - assert.match(script, /exec agentguard subscribe --quiet --json --cron-run/); + assert.equal(result.backend, 'system'); + assert.equal(result.created, true); + assert.equal(calls[0].command, 'crontab'); + assert.deepEqual(calls[0].args, ['-l']); + assert.equal(calls[1].command, 'crontab'); + assert.deepEqual(calls[1].args, ['-']); + assert.match(calls[1].input ?? '', /# AgentGuard begin agentguard-threat-feed/); + assert.match(calls[1].input ?? '', /agentguard-system-.*\/scripts\/agentguard-threat-feed\.sh/); + assert.doesNotMatch(calls[1].input ?? '', /AGENTGUARD_HOME=/); + const script = readFileSync(join(home, 'scripts', 'agentguard-threat-feed.sh'), 'utf8'); + assert.match(script, new RegExp(`export AGENTGUARD_HOME='${home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`)); + assert.match(script, /exec agentguard subscribe --quiet --json --cron-run/); + } }); it('removes the managed system crontab block without touching other entries', async () => { diff --git a/src/tests/postinstall.test.ts b/src/tests/postinstall.test.ts index e6ef82c..73b413a 100644 --- a/src/tests/postinstall.test.ts +++ b/src/tests/postinstall.test.ts @@ -18,12 +18,14 @@ describe('postinstall', () => { }); assert.match(stdout, /AgentGuard local config ready:/); - assert.match(stdout, /agentguard init --agent auto/); + assert.match(stdout, / agentguard init\n/); + assert.doesNotMatch(stdout, /agentguard init --agent auto/); assert.doesNotMatch(stdout, /agentguard connect/); assert.doesNotMatch(stdout, /agentguard checkup/); const nextSteps = readFileSync(join(home, 'next-steps.txt'), 'utf8'); - assert.match(nextSteps, /agentguard init --agent auto/); + assert.match(nextSteps, / agentguard init\n/); + assert.doesNotMatch(nextSteps, /agentguard init --agent auto/); assert.doesNotMatch(nextSteps, /agentguard connect/); assert.doesNotMatch(nextSteps, /agentguard checkup/); });