hovclaw 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,11 +32,15 @@ HOVClaw is built on a simple principle: **run your own AI agent infrastructure,
32
32
  - **Multi-account Telegram** - Multiple bot accounts with per-account status and logout
33
33
  - **Text mode controls** - Per-channel `plain|markdown` rendering mode (default `plain`)
34
34
  - **Policy layer** - `dmPolicy`, `groupPolicy`, per-group/per-topic overrides, pairing flow
35
+ - **Native Telegram commands** - Auto-registered slash command menu (including skill aliases)
36
+ - **Thinking controls** - `/think <level> <task>` plus persisted default via `commands.defaultThinkingLevel`
35
37
 
36
38
  ### Gateway & Control Plane
37
39
 
38
40
  - **WebSocket protocol v3** - Request/response/event frames with 21 methods
39
41
  - **Built-in web UI** - Connection, health, channels, sessions, and chat in one page
42
+ - **Fail-closed auth defaults** - Gateway auth is required unless explicitly opting into insecure mode
43
+ - **Origin-aware WS policy** - Browser `Origin` headers must be same-origin or allowlisted
40
44
  - **LaunchAgent integration** - `hovclaw gateway install/start/stop` for macOS background service
41
45
  - **Programmatic access** - `hovclaw gateway call <method>` for scripting
42
46
 
@@ -46,11 +50,12 @@ HOVClaw is built on a simple principle: **run your own AI agent infrastructure,
46
50
  - **Multi-provider models** - Anthropic, Google, OpenAI, OpenRouter via `@mariozechner/pi-ai`
47
51
  - **Model routing** - Per-target model slots (interactive, discord, cron) with fallback policy
48
52
  - **Workspace-first tools** - Relative file tool paths resolve from agent workspace
53
+ - **Least-privilege tools** - Bash tool disabled by default (`runtime.tools.bashEnabled=false`)
49
54
  - **Session persistence** - SQLite-backed sessions, messages, agent state, and usage tracking
50
55
 
51
56
  ### Scheduling & Automation
52
57
 
53
- - **Cron jobs** - `agents/*/cron.json` with configurable schedules and timezone support
58
+ - **Cron jobs** - `~/.hovclaw/agents/*/cron.json` with configurable schedules and timezone support
54
59
  - **Channel notifications** - Scheduled job results delivered to Telegram or Discord
55
60
  - **Concurrent execution** - Configurable max concurrent jobs
56
61
 
@@ -105,17 +110,33 @@ hovclaw onboard
105
110
  The wizard handles channel tokens, model provider credentials (via OAuth or API key),
106
111
  and agent configuration. All settings are saved to `~/.hovclaw/config.json`.
107
112
 
113
+ Security defaults in this release are intentionally strict:
114
+ - gateway start fails if `gateway.enabled=true` and neither `gateway.auth.token` nor `gateway.auth.password` is set (unless `gateway.auth.allowUnauthenticated=true`)
115
+ - Telegram webhook mode requires a non-empty webhook secret
116
+ - bash runtime tool is opt-in only via `runtime.tools.bashEnabled=true`
117
+
118
+ Agent and skill definitions are loaded from:
119
+ - `~/.hovclaw/agents/<name>/agent.json` (`CLAUDE.md`, `cron.json`)
120
+ - missing `~/.hovclaw/agents/main/agent.json` is auto-bootstrapped with a minimal scaffold (`name`, `skills`)
121
+ - `~/.agents/skills/<name>/SKILL.md`
122
+ - legacy `~/.hovclaw/skills` content is copied once when shared skills are empty
123
+
124
+ Runtime state is written under:
125
+ - `~/.hovclaw/store` (SQLite `hovclaw.db`, pairing store)
126
+ - `~/.hovclaw/data`
127
+
108
128
  ### Workspace Defaults and Bootstrap
109
129
 
110
130
  - Default workspace: `~/.hovclaw/workspace`
111
131
  - Blank agent workspace values resolve to the same default workspace
112
132
  - On startup and onboarding, HOVClaw auto-creates missing workspace files:
113
133
  - `AGENTS.md`
134
+ - `SOUL.md`
114
135
  - `IDENTITY.md`
115
136
  - `USER.md`
116
137
  - `BOOTSTRAP.md` (only when the workspace is effectively empty)
117
138
  - Workspace files are appended to the system prompt in this order:
118
- - `AGENTS.md` -> `IDENTITY.md` -> `USER.md` -> `BOOTSTRAP.md`
139
+ - `AGENTS.md` -> `SOUL.md` -> `IDENTITY.md` -> `USER.md` -> `BOOTSTRAP.md`
119
140
  - capped at 4,000 chars per file and 12,000 chars total
120
141
 
121
142
  ### Config Structure
@@ -126,6 +147,7 @@ and agent configuration. All settings are saved to `~/.hovclaw/config.json`.
126
147
  | `agents` | Agent definitions and defaults |
127
148
  | `bindings` | Inbound message routing rules |
128
149
  | `models` | Model slots, fallback policy, aliases |
150
+ | `commands` | Native command behavior, slash registration, authorization |
129
151
  | `runtime` | Execution mode, timeouts, allowed paths/commands |
130
152
  | `channels` | Telegram and Discord channel config |
131
153
  | `gateway` | Gateway host, port, auth, web UI settings |
@@ -200,6 +222,7 @@ Environment overrides are supported for most fields. See [docs/config-reference.
200
222
  # Setup
201
223
  hovclaw onboard
202
224
  hovclaw login [provider]
225
+ hovclaw reset [--scope config|config+creds+sessions|full] [--yes] [--non-interactive] [--dry-run] [--json]
203
226
  hovclaw doctor [--fix] [--deep] [--json]
204
227
  hovclaw status [--json]
205
228
 
@@ -209,9 +232,16 @@ hovclaw message send --channel telegram --to <chat_id> --message "hello"
209
232
  # Channel management
210
233
  hovclaw channels list|status|add|remove|login|logout [--account <id>] [--json]
211
234
 
235
+ # Pairing management
236
+ hovclaw pairing approve telegram <code>
237
+ hovclaw pairing approve --channel telegram [--account <id>] <code> [--json]
238
+
212
239
  # Model management
213
240
  hovclaw models list|status|set [--model <ref>] [--target <slot>] [--json]
214
241
 
242
+ # Skill management
243
+ hovclaw skills list|info|check|init [--json]
244
+
215
245
  # Gateway lifecycle
216
246
  hovclaw gateway run
217
247
  hovclaw gateway install|uninstall|start|stop|restart [--json]
@@ -1,8 +1,9 @@
1
- import { A as hasCredentialsFile, C as detectLegacyEnvConfig, E as getCredentialsPath, I as saveCredentials, M as loadCredentials, O as getHovclawHome, T as getConfigPath, j as loadConfig, k as hasConfigFile, w as ensureConfigFromLegacyEnv } from "./hovclaw.js";
1
+ import { A as ensureConfigFromLegacyEnv, F as hasConfigFile, H as saveCredentials, I as hasCredentialsFile, L as loadConfig, M as getCredentialsPath, P as getHovclawHome, R as loadCredentials, V as saveConfigFile, j as getConfigPath, k as detectLegacyEnvConfig, z as loadFileConfig } from "./hovclaw.js";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { intro, log, outro } from "@clack/prompts";
6
+ import { randomBytes } from "node:crypto";
6
7
  import { spawnSync } from "node:child_process";
7
8
 
8
9
  //#region src/cli/doctor.ts
@@ -61,6 +62,11 @@ function checkDockerAvailability() {
61
62
  detail: result.stderr?.trim() || result.stdout?.trim() || "docker command failed. Install/start Docker Desktop and retry."
62
63
  };
63
64
  }
65
+ function mutateFileConfig(env, mutate) {
66
+ const fileConfig = loadFileConfig(env);
67
+ mutate(fileConfig);
68
+ saveConfigFile(fileConfig, env);
69
+ }
64
70
  function parseDoctorArgs(args) {
65
71
  const options = {
66
72
  repair: false,
@@ -125,10 +131,35 @@ function runDoctorChecks(options, env = process.env) {
125
131
  else addFinding(findings, "channels-enabled", "pass", "At least one channel enabled", "OK");
126
132
  if (loadedConfig.channels.discord.enabled) if (!loadedConfig.channels.discord.botToken.trim()) addFinding(findings, "discord-token", "fail", "Discord enabled without token", "Set channels.discord.botToken via onboarding.");
127
133
  else addFinding(findings, "discord-token", "pass", "Discord token configured", "OK");
128
- if (loadedConfig.channels.telegram.enabled) if (!loadedConfig.channels.telegram.botToken.trim()) addFinding(findings, "telegram-token", "fail", "Telegram enabled without token", "Set channels.telegram.botToken via onboarding.");
129
- else addFinding(findings, "telegram-token", "pass", "Telegram token configured", "OK");
134
+ if (loadedConfig.channels.telegram.enabled) {
135
+ if (!loadedConfig.channels.telegram.botToken.trim()) addFinding(findings, "telegram-token", "fail", "Telegram enabled without token", "Set channels.telegram.botToken via onboarding.");
136
+ else addFinding(findings, "telegram-token", "pass", "Telegram token configured", "OK");
137
+ const webhookAccountsMissingSecret = Object.entries(loadedConfig.channels.telegram.accounts).filter(([, account]) => account.webhook.enabled && !account.webhook.secret.trim()).map(([accountId]) => accountId);
138
+ if (webhookAccountsMissingSecret.length > 0) if (options.repair) {
139
+ mutateFileConfig(env, (fileConfig) => {
140
+ for (const accountId of webhookAccountsMissingSecret) {
141
+ const account = fileConfig.channels.telegram.accounts[accountId];
142
+ if (!account || !account.webhook.enabled) continue;
143
+ account.webhook.secret = randomBytes(24).toString("base64url");
144
+ if (accountId === fileConfig.channels.telegram.defaultAccountId) fileConfig.channels.telegram.webhook.secret = account.webhook.secret;
145
+ }
146
+ });
147
+ addFinding(findings, "telegram-webhook-secret", "repair", "Generated missing Telegram webhook secret(s)", webhookAccountsMissingSecret.join(", "));
148
+ } else addFinding(findings, "telegram-webhook-secret", "fail", "Telegram webhook secret missing", `Accounts: ${webhookAccountsMissingSecret.join(", ")}`);
149
+ else addFinding(findings, "telegram-webhook-secret", "pass", "Telegram webhook secret policy", "OK");
150
+ }
130
151
  if (!loadedConfig.gateway.enabled) addFinding(findings, "gateway-enabled", "warn", "Gateway is disabled", "Enable gateway for OpenClaw/ClawHub compatibility.");
131
152
  else addFinding(findings, "gateway-enabled", "pass", "Gateway enabled", "OK");
153
+ if (loadedConfig.gateway.auth.allowUnauthenticated) addFinding(findings, "gateway-auth-mode", "warn", "Gateway unauthenticated mode is enabled", "Set gateway.auth.allowUnauthenticated=false for secure deployments.");
154
+ else if (!loadedConfig.gateway.auth.token.trim() && !loadedConfig.gateway.auth.password.trim()) if (options.repair) {
155
+ const generatedToken = randomBytes(24).toString("base64url");
156
+ mutateFileConfig(env, (fileConfig) => {
157
+ fileConfig.gateway.auth.allowUnauthenticated = false;
158
+ fileConfig.gateway.auth.token = generatedToken;
159
+ });
160
+ addFinding(findings, "gateway-auth-required", "repair", "Generated gateway auth token", "Set in gateway.auth.token to satisfy strict auth mode.");
161
+ } else addFinding(findings, "gateway-auth-required", "fail", "Gateway auth secrets are missing", "Set gateway.auth.token or gateway.auth.password, or explicitly enable allowUnauthenticated.");
162
+ else addFinding(findings, "gateway-auth-required", "pass", "Gateway auth configured", "OK");
132
163
  if (!loadedConfig.gateway.host.trim() || loadedConfig.gateway.port <= 0) addFinding(findings, "gateway-bind", "fail", "Gateway bind is invalid", "Set gateway.host and gateway.port to valid values.");
133
164
  else addFinding(findings, "gateway-bind", "pass", "Gateway bind configured", `${loadedConfig.gateway.host}:${loadedConfig.gateway.port}`);
134
165
  if (loadedConfig.gateway.mode === "remote" && !loadedConfig.gateway.remote.url.trim()) addFinding(findings, "gateway-remote-url", "fail", "Gateway remote mode missing URL", "Set gateway.remote.url or switch gateway.mode to local.");
@@ -136,6 +167,13 @@ function runDoctorChecks(options, env = process.env) {
136
167
  else addFinding(findings, "runtime-read-roots", "pass", "Read roots configured", `${loadedConfig.runtime.allowedReadRoots.length} root(s)`);
137
168
  if (loadedConfig.runtime.allowedWriteRoots.length === 0) addFinding(findings, "runtime-write-roots", "warn", "No write roots configured", "Agent will not be able to write files.");
138
169
  else addFinding(findings, "runtime-write-roots", "pass", "Write roots configured", `${loadedConfig.runtime.allowedWriteRoots.length} root(s)`);
170
+ if (loadedConfig.runtime.tools.bashEnabled) if (options.repair) {
171
+ mutateFileConfig(env, (fileConfig) => {
172
+ fileConfig.runtime.tools.bashEnabled = false;
173
+ });
174
+ addFinding(findings, "runtime-bash-enabled", "repair", "Disabled bash tool by default", "Set runtime.tools.bashEnabled=true only for trusted environments.");
175
+ } else addFinding(findings, "runtime-bash-enabled", "warn", "Bash tool is enabled", "High-risk surface. Prefer runtime.tools.bashEnabled=false.");
176
+ else addFinding(findings, "runtime-bash-enabled", "pass", "Bash tool disabled", "OK");
139
177
  const unreadableWriteRoots = loadedConfig.runtime.allowedWriteRoots.filter((writeRoot) => !pathInsideAnyRoot(writeRoot, loadedConfig.runtime.allowedReadRoots));
140
178
  if (unreadableWriteRoots.length > 0) addFinding(findings, "runtime-write-without-read", "warn", "Some write roots are not in read roots", unreadableWriteRoots.join(", "));
141
179
  for (const writeRoot of loadedConfig.runtime.allowedWriteRoots) if (!fs.existsSync(writeRoot)) if (options.repair) {
@@ -157,7 +195,7 @@ function runDoctorChecks(options, env = process.env) {
157
195
  const missingSkills = parsed.skills.filter((entry) => typeof entry === "string").filter((skill) => !fs.existsSync(path.join(loadedConfig.skillsDir, skill, "SKILL.md")));
158
196
  if (missingSkills.length > 0) addFinding(findings, "main-agent-skills", "fail", "Main agent references missing skills", missingSkills.join(", "));
159
197
  else addFinding(findings, "main-agent-skills", "pass", "Main agent skills are resolvable", "OK");
160
- } else addFinding(findings, "main-agent-skills", "warn", "Main agent skills list is missing or invalid", "Expected skills: string[] in agents/main/agent.json");
198
+ } else addFinding(findings, "main-agent-skills", "warn", "Main agent skills list is missing or invalid", `Expected skills: string[] in ${mainAgentPath}`);
161
199
  } catch (error) {
162
200
  addFinding(findings, "main-agent-parse", "fail", "Main agent config is invalid JSON", error instanceof Error ? error.message : String(error));
163
201
  }
@@ -194,7 +194,7 @@ function appendChatMessage(role, text) {
194
194
  function showChatEmpty(text) {
195
195
  chatOutput.innerHTML = "";
196
196
  const p = document.createElement("p");
197
- p.className = "text-gray-500 text-sm text-center m-auto";
197
+ p.className = "chat-empty";
198
198
  p.textContent = text;
199
199
  chatOutput.appendChild(p);
200
200
  }
@@ -372,7 +372,7 @@ async function connectGateway(event) {
372
372
  });
373
373
 
374
374
  connected = true;
375
- saveStoredConnection({ gatewayUrl, token });
375
+ saveStoredConnection({ gatewayUrl });
376
376
  const connId = hello?.server?.connId || "ok";
377
377
  setConnectionState(true, `Connected (${connId})`);
378
378
  logLine(`Connected to ${gatewayUrl}`);
@@ -501,7 +501,7 @@ function wireEvents() {
501
501
  function bootstrap() {
502
502
  const stored = loadStoredConnection();
503
503
  gatewayUrlInput.value = stored.gatewayUrl || defaultGatewayUrl();
504
- tokenInput.value = stored.token || "";
504
+ tokenInput.value = "";
505
505
  passwordInput.value = "";
506
506
  setConnectionState(false, "Disconnected");
507
507
  setupPanels();
@@ -2,7 +2,6 @@ export const STORAGE_KEY: string;
2
2
 
3
3
  export interface StoredConnection {
4
4
  gatewayUrl: string;
5
- token: string;
6
5
  }
7
6
 
8
7
  export interface StorageLike {
@@ -13,7 +12,7 @@ export interface StorageLike {
13
12
 
14
13
  export function loadStoredConnection(storage?: StorageLike | null): StoredConnection;
15
14
  export function saveStoredConnection(
16
- connection: { gatewayUrl?: string; token?: string; [key: string]: unknown },
15
+ connection: { gatewayUrl?: string; [key: string]: unknown },
17
16
  storage?: StorageLike | null,
18
17
  ): void;
19
18
  export function clearStoredConnection(storage?: StorageLike | null): void;
@@ -17,26 +17,25 @@ function resolveStorage(storage) {
17
17
  export function loadStoredConnection(storage) {
18
18
  const targetStorage = resolveStorage(storage);
19
19
  if (!targetStorage) {
20
- return { gatewayUrl: "", token: "" };
20
+ return { gatewayUrl: "" };
21
21
  }
22
22
 
23
23
  const raw = targetStorage.getItem(STORAGE_KEY);
24
24
  if (!raw) {
25
- return { gatewayUrl: "", token: "" };
25
+ return { gatewayUrl: "" };
26
26
  }
27
27
 
28
28
  try {
29
29
  const parsed = JSON.parse(raw);
30
30
  if (!isRecord(parsed)) {
31
- return { gatewayUrl: "", token: "" };
31
+ return { gatewayUrl: "" };
32
32
  }
33
33
 
34
34
  const gatewayUrl =
35
35
  typeof parsed.gatewayUrl === "string" ? parsed.gatewayUrl : "";
36
- const token = typeof parsed.token === "string" ? parsed.token : "";
37
- return { gatewayUrl, token };
36
+ return { gatewayUrl };
38
37
  } catch {
39
- return { gatewayUrl: "", token: "" };
38
+ return { gatewayUrl: "" };
40
39
  }
41
40
  }
42
41
 
@@ -49,7 +48,6 @@ export function saveStoredConnection(connection, storage) {
49
48
  const payload = {
50
49
  gatewayUrl:
51
50
  typeof connection?.gatewayUrl === "string" ? connection.gatewayUrl : "",
52
- token: typeof connection?.token === "string" ? connection.token : "",
53
51
  };
54
52
 
55
53
  targetStorage.setItem(STORAGE_KEY, JSON.stringify(payload));