From ec716d71f750c33004c42602b82b1cf0846a744e Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Fri, 21 Aug 2026 15:43:37 +0800 Subject: [PATCH 1/3] feat: add DSH cloud bootstrap flow --- README.md | 21 ++- docs/cloud-connect.md | 11 +- docs/cloud-native-api.md | 29 ++-- docs/dsh.md | 11 +- scripts/cloud-install.sh | 57 +++++++ src/cli.ts | 76 ++++++--- src/config.ts | 4 +- src/feed/cron.ts | 4 +- src/installers.ts | 24 ++- src/postinstall.ts | 2 +- src/tests/cli-connect.test.ts | 68 +++++++- src/tests/cli-init.test.ts | 206 ++++++++++++++++++++++++- src/tests/cloud-install-script.test.ts | 102 ++++++++++++ src/tests/feed-cron.test.ts | 72 ++++----- src/tests/postinstall.test.ts | 6 +- 15 files changed, 604 insertions(+), 89 deletions(-) create mode 100755 scripts/cloud-install.sh create mode 100644 src/tests/cloud-install-script.test.ts diff --git a/README.md b/README.md index be53e51..c311f2d 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,21 @@ 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. +For a hosted Cloud bootstrap, publish the repository-source template [`scripts/cloud-install.sh`](scripts/cloud-install.sh) over HTTPS and set its Cloud URL default. The script installs the npm package, runs auto-discovery, connects Cloud, and prints `AGENTGUARD_ACTIVATION_URL=...` for account binding. A backend may render `AGENTGUARD_AGENT=dsh` into the response to force DSH when environment auto-detection is unavailable. Although a backend can offer `curl -fsSL https://your-cloud.example/install.sh | bash`, a security-conscious installation should download, inspect, and then execute the script: + +```bash +curl -fsSLo agentguard-install.sh https://your-cloud.example/install.sh +less agentguard-install.sh +bash agentguard-install.sh +``` + ## 3 minutes: protect your agent ```bash @@ -65,7 +73,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 +90,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 +128,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..a82897c 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 @@ -84,31 +84,40 @@ GET /install.sh?agent=claude-code 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 repository (not the published npm artifact) includes [`scripts/cloud-install.sh`](../scripts/cloud-install.sh) as an executable template for Agent JWT activation-link flows. The 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, render a validated +`AGENTGUARD_AGENT` default into the response; the template then adds +`--agent "$AGENTGUARD_AGENT"`. 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: +The CLI registers a local Agent JWT and prints an activation link. The template +reprints it as `AGENTGUARD_ACTIVATION_URL=` so a hosting backend can return it +to the user. For other agent hosts, use the CLI's separate API-key flow rather +than this activation-link template: ```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/scripts/cloud-install.sh b/scripts/cloud-install.sh new file mode 100755 index 0000000..146da57 --- /dev/null +++ b/scripts/cloud-install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# AgentGuard Cloud bootstrap template. +# +# The hosting endpoint may render the defaults below from validated query +# parameters, or callers may provide the same values as environment variables. +# AGENTGUARD_AGENT is optional: when absent, `agentguard init` discovers every +# supported local agent. Keep credentials out of this script and its URL. + +PACKAGE_SPEC="${AGENTGUARD_PACKAGE_SPEC:-@goplus/agentguard}" +CLOUD_URL="${AGENTGUARD_CLOUD_URL:-https://agentguard.gopluslabs.io}" +AGENT="${AGENTGUARD_AGENT:-}" + +case "$AGENT" in + ''|auto|openclaw|hermes|dsh) ;; + *) + echo 'ERROR: activation-link bootstrap supports auto, openclaw, hermes, or dsh.' >&2 + echo 'Use the AgentGuard CLI with an API key for other agent hosts.' >&2 + exit 2 + ;; +esac + +if ! command -v node >/dev/null 2>&1; then + echo 'ERROR: Node.js is required to install AgentGuard.' >&2 + exit 1 +fi +if ! command -v npm >/dev/null 2>&1; then + echo 'ERROR: npm is required to install AgentGuard.' >&2 + exit 1 +fi + +echo "Installing ${PACKAGE_SPEC}..." +npm install -g "$PACKAGE_SPEC" + +if ! command -v agentguard >/dev/null 2>&1; then + echo 'ERROR: npm completed, but the agentguard command is not on PATH.' >&2 + exit 1 +fi + +if [ -n "$AGENT" ] && [ "$AGENT" != 'auto' ]; then + agentguard init --agent "$AGENT" --cloud "$CLOUD_URL" +else + agentguard init --cloud "$CLOUD_URL" +fi + +CONNECT_OUTPUT="$(agentguard connect --cloud "$CLOUD_URL")" +printf '%s\n' "$CONNECT_OUTPUT" + +if [ "${DSH_SHELL:-}" = '1' ] || [ "$AGENT" = 'dsh' ]; then + echo 'Restart DSH after account binding to activate AgentGuard in the web profile.' +fi + +ACTIVATION_URL="$(printf '%s\n' "$CONNECT_OUTPUT" | awk '/^https:\/\/[^[:space:]]+$/ { url=$0 } END { print url }')" +if [ -n "$ACTIVATION_URL" ]; then + printf 'AGENTGUARD_ACTIVATION_URL=%s\n' "$ACTIVATION_URL" +fi diff --git a/src/cli.ts b/src/cli.ts index f9557d9..ea5470b 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,13 @@ 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 dshAgents: AgentInstaller[] = detectDshRuntime() ? ['dsh'] : []; + const detectedAgents: AgentInstaller[] = process.env.DSH_SHELL === '1' + ? [...dshAgents, ...directoryAgents] + : [...directoryAgents, ...dshAgents]; for (const agent of detectedAgents) { try { @@ -1029,6 +1033,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 +1597,41 @@ 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 || detectDshRuntime(); +} + 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 (detectDshRuntime()) 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 detectDshRuntime(): boolean { + if (process.env.DSH_SHELL === '1') return true; + 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..79901f2 100644 --- a/src/tests/cli-connect.test.ts +++ b/src/tests/cli-connect.test.ts @@ -336,7 +336,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 +405,71 @@ 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('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/cloud-install-script.test.ts b/src/tests/cloud-install-script.test.ts new file mode 100644 index 0000000..6f25729 --- /dev/null +++ b/src/tests/cloud-install-script.test.ts @@ -0,0 +1,102 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const projectRoot = resolve(__dirname, '..', '..'); +const installerPath = join(projectRoot, 'scripts', 'cloud-install.sh'); + +function installFakeCommands(root: string): { bin: string; npmLog: string; agentguardLog: string } { + const bin = join(root, 'bin'); + const npmLog = join(root, 'npm-call.json'); + const agentguardLog = join(root, 'agentguard-calls.jsonl'); + mkdirSync(bin, { recursive: true }); + + const npm = join(bin, 'npm'); + writeFileSync(npm, [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'fs.writeFileSync(process.env.NPM_CALL_LOG, JSON.stringify(process.argv.slice(2)));', + '', + ].join('\n')); + chmodSync(npm, 0o755); + + const agentguard = join(bin, 'agentguard'); + writeFileSync(agentguard, [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'fs.appendFileSync(process.env.AGENTGUARD_CALL_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");', + 'if (process.argv[2] === "connect") {', + ' process.stdout.write("Registered local AgentGuard agent (agt_cloud_test).\\n");', + ' process.stdout.write("Open this link to bind this agent to your account:\\n");', + ' process.stdout.write("https://agentguard.example/activate?token=cloud-test\\n");', + '}', + '', + ].join('\n')); + chmodSync(agentguard, 0o755); + + return { bin, npmLog, agentguardLog }; +} + +async function runInstaller(extraEnv: Record = {}) { + const root = mkdtempSync(join(tmpdir(), 'agentguard-cloud-install-')); + const commands = installFakeCommands(root); + assert.ok(existsSync(installerPath), 'cloud installer script must exist'); + const result = await execFileAsync('bash', [installerPath], { + cwd: root, + env: { + ...process.env, + PATH: `${commands.bin}:${process.env.PATH || ''}`, + NPM_CALL_LOG: commands.npmLog, + AGENTGUARD_CALL_LOG: commands.agentguardLog, + AGENTGUARD_PACKAGE_SPEC: '@goplus/agentguard@1.2.3', + AGENTGUARD_CLOUD_URL: 'https://agentguard.example', + ...extraEnv, + }, + }); + return { + ...result, + npmArgs: JSON.parse(readFileSync(commands.npmLog, 'utf8')) as string[], + agentguardCalls: readFileSync(commands.agentguardLog, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as string[]), + }; +} + +describe('Cloud install script', () => { + it('uses init auto-discovery and returns the activation URL as its final line', async () => { + const result = await runInstaller(); + + assert.deepEqual(result.npmArgs, ['install', '-g', '@goplus/agentguard@1.2.3']); + assert.deepEqual(result.agentguardCalls, [ + ['init', '--cloud', 'https://agentguard.example'], + ['connect', '--cloud', 'https://agentguard.example'], + ]); + assert.equal(result.stdout.trimEnd().split('\n').at(-1), + 'AGENTGUARD_ACTIVATION_URL=https://agentguard.example/activate?token=cloud-test'); + }); + + it('passes an explicitly configured agent host to init', async () => { + const result = await runInstaller({ AGENTGUARD_AGENT: 'dsh' }); + + assert.deepEqual(result.agentguardCalls[0], [ + 'init', '--agent', 'dsh', '--cloud', 'https://agentguard.example', + ]); + }); + + it('rejects explicit hosts that cannot return an Agent JWT activation link', async () => { + await assert.rejects( + runInstaller({ AGENTGUARD_AGENT: 'codex' }), + (error: NodeJS.ErrnoException & { stderr?: string }) => { + assert.equal(error.code, 2); + assert.match(error.stderr || '', /supports auto, openclaw, hermes, or dsh/); + return true; + } + ); + }); +}); 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/); }); From 9d47da6b73ca58fffb29135c34239bdcee731dae Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 21 Aug 2026 16:09:12 +0800 Subject: [PATCH 2/3] remove install.sh --- README.md | 8 -- docs/cloud-native-api.md | 15 ++-- scripts/cloud-install.sh | 57 -------------- src/tests/cloud-install-script.test.ts | 102 ------------------------- 4 files changed, 6 insertions(+), 176 deletions(-) delete mode 100755 scripts/cloud-install.sh delete mode 100644 src/tests/cloud-install-script.test.ts diff --git a/README.md b/README.md index c311f2d..4fdce74 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,6 @@ agentguard status 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. -For a hosted Cloud bootstrap, publish the repository-source template [`scripts/cloud-install.sh`](scripts/cloud-install.sh) over HTTPS and set its Cloud URL default. The script installs the npm package, runs auto-discovery, connects Cloud, and prints `AGENTGUARD_ACTIVATION_URL=...` for account binding. A backend may render `AGENTGUARD_AGENT=dsh` into the response to force DSH when environment auto-detection is unavailable. Although a backend can offer `curl -fsSL https://your-cloud.example/install.sh | bash`, a security-conscious installation should download, inspect, and then execute the script: - -```bash -curl -fsSLo agentguard-install.sh https://your-cloud.example/install.sh -less agentguard-install.sh -bash agentguard-install.sh -``` - ## 3 minutes: protect your agent ```bash diff --git a/docs/cloud-native-api.md b/docs/cloud-native-api.md index a82897c..09b5c48 100644 --- a/docs/cloud-native-api.md +++ b/docs/cloud-native-api.md @@ -78,7 +78,7 @@ safe | low | medium | high | critical ### Commercial install script ```http -GET /install.sh?agent=claude-code +GET /install.sh?agent=dsh ``` Allowed `agent` values: @@ -87,15 +87,14 @@ Allowed `agent` values: auto | openclaw | hermes | dsh ``` -The repository (not the published npm artifact) includes [`scripts/cloud-install.sh`](../scripts/cloud-install.sh) as an executable template for Agent JWT activation-link flows. The script installs `@goplus/agentguard`, initializes integrations, and connects Cloud. With no explicit host it calls: +The hosted script installs `@goplus/agentguard`, initializes integrations, and connects Cloud. With no explicit host it calls: ```bash agentguard init --cloud "$AGENTGUARD_CLOUD_URL" ``` -When the hosting service already knows the requested host, render a validated -`AGENTGUARD_AGENT` default into the response; the template then adds -`--agent "$AGENTGUARD_AGENT"`. Explicit activation-link hosts are restricted to +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 @@ -108,10 +107,8 @@ without an API key: agentguard connect --cloud "$AGENTGUARD_CLOUD_URL" ``` -The CLI registers a local Agent JWT and prints an activation link. The template -reprints it as `AGENTGUARD_ACTIVATION_URL=` so a hosting backend can return it -to the user. For other agent hosts, use the CLI's separate API-key flow rather -than this activation-link template: +The CLI registers a local Agent JWT and prints an activation link. For other +agent hosts, use the CLI's separate API-key flow: ```bash agentguard connect --cloud "$AGENTGUARD_CLOUD_URL" --api-key "$AGENTGUARD_API_KEY" diff --git a/scripts/cloud-install.sh b/scripts/cloud-install.sh deleted file mode 100755 index 146da57..0000000 --- a/scripts/cloud-install.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# AgentGuard Cloud bootstrap template. -# -# The hosting endpoint may render the defaults below from validated query -# parameters, or callers may provide the same values as environment variables. -# AGENTGUARD_AGENT is optional: when absent, `agentguard init` discovers every -# supported local agent. Keep credentials out of this script and its URL. - -PACKAGE_SPEC="${AGENTGUARD_PACKAGE_SPEC:-@goplus/agentguard}" -CLOUD_URL="${AGENTGUARD_CLOUD_URL:-https://agentguard.gopluslabs.io}" -AGENT="${AGENTGUARD_AGENT:-}" - -case "$AGENT" in - ''|auto|openclaw|hermes|dsh) ;; - *) - echo 'ERROR: activation-link bootstrap supports auto, openclaw, hermes, or dsh.' >&2 - echo 'Use the AgentGuard CLI with an API key for other agent hosts.' >&2 - exit 2 - ;; -esac - -if ! command -v node >/dev/null 2>&1; then - echo 'ERROR: Node.js is required to install AgentGuard.' >&2 - exit 1 -fi -if ! command -v npm >/dev/null 2>&1; then - echo 'ERROR: npm is required to install AgentGuard.' >&2 - exit 1 -fi - -echo "Installing ${PACKAGE_SPEC}..." -npm install -g "$PACKAGE_SPEC" - -if ! command -v agentguard >/dev/null 2>&1; then - echo 'ERROR: npm completed, but the agentguard command is not on PATH.' >&2 - exit 1 -fi - -if [ -n "$AGENT" ] && [ "$AGENT" != 'auto' ]; then - agentguard init --agent "$AGENT" --cloud "$CLOUD_URL" -else - agentguard init --cloud "$CLOUD_URL" -fi - -CONNECT_OUTPUT="$(agentguard connect --cloud "$CLOUD_URL")" -printf '%s\n' "$CONNECT_OUTPUT" - -if [ "${DSH_SHELL:-}" = '1' ] || [ "$AGENT" = 'dsh' ]; then - echo 'Restart DSH after account binding to activate AgentGuard in the web profile.' -fi - -ACTIVATION_URL="$(printf '%s\n' "$CONNECT_OUTPUT" | awk '/^https:\/\/[^[:space:]]+$/ { url=$0 } END { print url }')" -if [ -n "$ACTIVATION_URL" ]; then - printf 'AGENTGUARD_ACTIVATION_URL=%s\n' "$ACTIVATION_URL" -fi diff --git a/src/tests/cloud-install-script.test.ts b/src/tests/cloud-install-script.test.ts deleted file mode 100644 index 6f25729..0000000 --- a/src/tests/cloud-install-script.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); -const projectRoot = resolve(__dirname, '..', '..'); -const installerPath = join(projectRoot, 'scripts', 'cloud-install.sh'); - -function installFakeCommands(root: string): { bin: string; npmLog: string; agentguardLog: string } { - const bin = join(root, 'bin'); - const npmLog = join(root, 'npm-call.json'); - const agentguardLog = join(root, 'agentguard-calls.jsonl'); - mkdirSync(bin, { recursive: true }); - - const npm = join(bin, 'npm'); - writeFileSync(npm, [ - '#!/usr/bin/env node', - 'const fs = require("node:fs");', - 'fs.writeFileSync(process.env.NPM_CALL_LOG, JSON.stringify(process.argv.slice(2)));', - '', - ].join('\n')); - chmodSync(npm, 0o755); - - const agentguard = join(bin, 'agentguard'); - writeFileSync(agentguard, [ - '#!/usr/bin/env node', - 'const fs = require("node:fs");', - 'fs.appendFileSync(process.env.AGENTGUARD_CALL_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");', - 'if (process.argv[2] === "connect") {', - ' process.stdout.write("Registered local AgentGuard agent (agt_cloud_test).\\n");', - ' process.stdout.write("Open this link to bind this agent to your account:\\n");', - ' process.stdout.write("https://agentguard.example/activate?token=cloud-test\\n");', - '}', - '', - ].join('\n')); - chmodSync(agentguard, 0o755); - - return { bin, npmLog, agentguardLog }; -} - -async function runInstaller(extraEnv: Record = {}) { - const root = mkdtempSync(join(tmpdir(), 'agentguard-cloud-install-')); - const commands = installFakeCommands(root); - assert.ok(existsSync(installerPath), 'cloud installer script must exist'); - const result = await execFileAsync('bash', [installerPath], { - cwd: root, - env: { - ...process.env, - PATH: `${commands.bin}:${process.env.PATH || ''}`, - NPM_CALL_LOG: commands.npmLog, - AGENTGUARD_CALL_LOG: commands.agentguardLog, - AGENTGUARD_PACKAGE_SPEC: '@goplus/agentguard@1.2.3', - AGENTGUARD_CLOUD_URL: 'https://agentguard.example', - ...extraEnv, - }, - }); - return { - ...result, - npmArgs: JSON.parse(readFileSync(commands.npmLog, 'utf8')) as string[], - agentguardCalls: readFileSync(commands.agentguardLog, 'utf8') - .trim() - .split('\n') - .map((line) => JSON.parse(line) as string[]), - }; -} - -describe('Cloud install script', () => { - it('uses init auto-discovery and returns the activation URL as its final line', async () => { - const result = await runInstaller(); - - assert.deepEqual(result.npmArgs, ['install', '-g', '@goplus/agentguard@1.2.3']); - assert.deepEqual(result.agentguardCalls, [ - ['init', '--cloud', 'https://agentguard.example'], - ['connect', '--cloud', 'https://agentguard.example'], - ]); - assert.equal(result.stdout.trimEnd().split('\n').at(-1), - 'AGENTGUARD_ACTIVATION_URL=https://agentguard.example/activate?token=cloud-test'); - }); - - it('passes an explicitly configured agent host to init', async () => { - const result = await runInstaller({ AGENTGUARD_AGENT: 'dsh' }); - - assert.deepEqual(result.agentguardCalls[0], [ - 'init', '--agent', 'dsh', '--cloud', 'https://agentguard.example', - ]); - }); - - it('rejects explicit hosts that cannot return an Agent JWT activation link', async () => { - await assert.rejects( - runInstaller({ AGENTGUARD_AGENT: 'codex' }), - (error: NodeJS.ErrnoException & { stderr?: string }) => { - assert.equal(error.code, 2); - assert.match(error.stderr || '', /supports auto, openclaw, hermes, or dsh/); - return true; - } - ); - }); -}); From 865e96f04cca5b543d196474f93e3780722e135d Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 21 Aug 2026 17:08:53 +0800 Subject: [PATCH 3/3] fix code-review findings --- src/cli.ts | 16 ++++++++----- src/tests/cli-connect.test.ts | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index ea5470b..7e266ca 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1008,8 +1008,9 @@ function initAutoAgents(config: AgentGuardConfig, force: boolean): { const directoryAgents = AUTO_AGENT_DETECTION .filter(({ dir }) => existsSync(join(process.cwd(), dir))) .map(({ agent }) => agent); - const dshAgents: AgentInstaller[] = detectDshRuntime() ? ['dsh'] : []; - const detectedAgents: AgentInstaller[] = process.env.DSH_SHELL === '1' + const isDshManagedShell = detectDshManagedShell(); + const dshAgents: AgentInstaller[] = isDshManagedShell || detectInstalledDshWebProfile() ? ['dsh'] : []; + const detectedAgents: AgentInstaller[] = isDshManagedShell ? [...dshAgents, ...directoryAgents] : [...directoryAgents, ...dshAgents]; @@ -1598,7 +1599,7 @@ function isHermesAgentConfigured(config: AgentGuardConfig): boolean { } function isDshAgentConfigured(config: AgentGuardConfig): boolean { - return config.agentHost === 'dsh' || config.agentHosts?.includes('dsh') === true || detectDshRuntime(); + return config.agentHost === 'dsh' || config.agentHosts?.includes('dsh') === true || detectDshManagedShell(); } function isAgentJwtHostConfigured(config: AgentGuardConfig): boolean { @@ -1609,7 +1610,7 @@ function withDetectedAgentJwtHost(config: AgentGuardConfig): AgentGuardConfig { if (isAgentJwtAgentHost(config.agentHost)) return config; const savedAgentJwtHost = config.agentHosts?.find(isAgentJwtAgentHost); if (savedAgentJwtHost) return withDetectedAgentHost(config, savedAgentJwtHost); - if (detectDshRuntime()) return withDetectedAgentHost(config, 'dsh'); + if (detectDshManagedShell()) return withDetectedAgentHost(config, 'dsh'); if (detectOpenClawRuntime()) return withDetectedAgentHost(config, 'openclaw'); if (detectHermesRuntime()) return withDetectedAgentHost(config, 'hermes'); return config; @@ -1619,8 +1620,11 @@ function isAgentJwtAgentHost(value: AgentGuardAgentHost | undefined): value is ' return value === 'openclaw' || value === 'hermes' || value === 'dsh'; } -function detectDshRuntime(): boolean { - if (process.env.DSH_SHELL === '1') return true; +function detectDshManagedShell(): boolean { + return process.env.DSH_SHELL === '1'; +} + +function detectInstalledDshWebProfile(): boolean { const configuredHome = process.env.DSH_HOME?.trim(); const dshHome = configuredHome ? configuredHome === '~' diff --git a/src/tests/cli-connect.test.ts b/src/tests/cli-connect.test.ts index 79901f2..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( @@ -470,6 +472,46 @@ describe('CLI connect Agent JWT mode', () => { } }); + 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');