hajimi-claw 0.1.0

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/Cargo.toml ADDED
@@ -0,0 +1,57 @@
1
+ [workspace]
2
+ members = [
3
+ ".",
4
+ "crates/hajimi-claw-agent",
5
+ "crates/hajimi-claw-bot",
6
+ "crates/hajimi-claw-daemon",
7
+ "crates/hajimi-claw-exec",
8
+ "crates/hajimi-claw-gateway",
9
+ "crates/hajimi-claw-llm",
10
+ "crates/hajimi-claw-policy",
11
+ "crates/hajimi-claw-store",
12
+ "crates/hajimi-claw-tools",
13
+ "crates/hajimi-claw-types",
14
+ ]
15
+ resolver = "2"
16
+
17
+ [workspace.package]
18
+ version = "0.1.0"
19
+ edition = "2024"
20
+ license = "MIT"
21
+ authors = ["OpenAI Codex"]
22
+
23
+ [workspace.dependencies]
24
+ aes-gcm = "0.10"
25
+ anyhow = "1.0"
26
+ async-stream = "0.3"
27
+ async-trait = "0.1"
28
+ base64 = "0.22"
29
+ chrono = { version = "0.4", features = ["serde"] }
30
+ futures = "0.3"
31
+ regex = "1.11"
32
+ reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
33
+ rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
34
+ serde = { version = "1.0", features = ["derive"] }
35
+ serde_json = "1.0"
36
+ sha2 = "0.10"
37
+ tempfile = "3.13"
38
+ thiserror = "2.0"
39
+ tokio = { version = "1.39", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
40
+ tokio-stream = "0.1"
41
+ toml = "0.8"
42
+ tracing = "0.1"
43
+ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
44
+ uuid = { version = "1.10", features = ["serde", "v4"] }
45
+ windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] }
46
+
47
+ [package]
48
+ name = "hajimi-claw"
49
+ version.workspace = true
50
+ edition.workspace = true
51
+ license.workspace = true
52
+ authors.workspace = true
53
+
54
+ [dependencies]
55
+ anyhow.workspace = true
56
+ hajimi-claw-daemon = { path = "crates/hajimi-claw-daemon" }
57
+ tokio.workspace = true
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # hajimi-claw
2
+
3
+ Single-user Telegram-first ops agent in Rust.
4
+
5
+ ## Current scope
6
+
7
+ - Telegram channel -> gateway -> runtime command flow
8
+ - Telegram long polling command surface
9
+ - Single active task gate
10
+ - Structured tools for file access, Docker, and systemd
11
+ - Guarded local command execution with approvals and short-lived elevated lease
12
+ - SQLite audit/task/session persistence
13
+ - Windows-safe execution mode with allowlist checks and Job Object cleanup
14
+ - Telegram onboarding flow for custom providers and chat-level provider binding
15
+
16
+ ## Running
17
+
18
+ 1. Copy `config.example.toml` to `config.toml`.
19
+ 2. Fill in the Telegram bot token and admin ids.
20
+ 3. Set the provider secret key env var before starting:
21
+ `set HAJIMI_CLAW_MASTER_KEY=replace-me-with-a-long-random-string`
22
+ 4. Optional: fill in the `llm` section to bootstrap a default provider on first start.
23
+ 5. Run `cargo run`.
24
+ 6. In Telegram, use `/onboard` to add or switch providers interactively.
25
+
26
+ Set `HAJIMI_CLAW_CONFIG` if you want to load a different config path.
27
+
28
+ ## Install
29
+
30
+ ### Windows
31
+
32
+ - Build and install to a user directory in `PATH`:
33
+ `powershell -ExecutionPolicy Bypass -File .\scripts\install.ps1`
34
+ - System-wide install:
35
+ `powershell -ExecutionPolicy Bypass -File .\scripts\install.ps1 -System`
36
+
37
+ The installer copies `hajimi-claw.exe` and `config.example.toml`, and updates `PATH`.
38
+
39
+ ### Linux
40
+
41
+ - User install:
42
+ `sh ./scripts/install.sh`
43
+ - System-wide install:
44
+ `sudo sh ./scripts/install.sh --system`
45
+ - System-wide install with systemd unit:
46
+ `sudo sh ./scripts/install.sh --system --install-service`
47
+
48
+ The Linux installer copies the binary to `PREFIX/bin` and `config.example.toml` to `PREFIX/share/hajimi-claw`.
49
+
50
+ ### npm
51
+
52
+ - Global install from the package:
53
+ `npm install -g hajimi-claw`
54
+ - Global install from the repo:
55
+ `npm install -g .`
56
+
57
+ The npm package builds the Rust binary locally during `postinstall`, so `cargo` must already be installed on the target machine.
58
+
59
+ ## Telegram commands
60
+
61
+ - `/onboard`
62
+ - `/onboard cancel`
63
+ - `/provider list`
64
+ - `/provider current`
65
+ - `/provider use <id>`
66
+ - `/provider bind <id>`
67
+ - `/provider test [id]`
68
+ - `/provider models [id]`
69
+ - `/ask <text>`
70
+ - `/shell open [name]`
71
+ - `/shell exec <cmd>`
72
+ - `/shell close`
73
+ - `/status`
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { spawn } = require("child_process");
6
+
7
+ const root = path.resolve(__dirname, "..");
8
+ const binaryName = process.platform === "win32" ? "hajimi-claw.exe" : "hajimi-claw";
9
+ const binaryPath = path.join(root, "npm-dist", binaryName);
10
+
11
+ if (!fs.existsSync(binaryPath)) {
12
+ console.error(
13
+ "hajimi-claw binary is not installed. Reinstall the npm package or run `npm rebuild hajimi-claw`."
14
+ );
15
+ process.exit(1);
16
+ }
17
+
18
+ const child = spawn(binaryPath, process.argv.slice(2), {
19
+ stdio: "inherit",
20
+ });
21
+
22
+ child.on("exit", (code, signal) => {
23
+ if (signal) {
24
+ process.kill(process.pid, signal);
25
+ return;
26
+ }
27
+ process.exit(code ?? 0);
28
+ });
@@ -0,0 +1,32 @@
1
+ [telegram]
2
+ bot_token = "123456:replace-me"
3
+ poll_timeout_secs = 30
4
+
5
+ [llm]
6
+ # Optional bootstrap provider written into SQLite on first start.
7
+ base_url = "https://api.openai.com/v1"
8
+ api_key = "replace-me"
9
+ model = "gpt-4.1-mini"
10
+ static_fallback_response = "LLM backend not configured."
11
+
12
+ [storage]
13
+ sqlite_path = "./data/hajimi-claw.sqlite3"
14
+
15
+ [security]
16
+ # Required at runtime. Set the environment variable before starting hajimi-claw.
17
+ master_key_env = "HAJIMI_CLAW_MASTER_KEY"
18
+
19
+ [execution]
20
+ mode = "auto"
21
+
22
+ [policy]
23
+ admin_user_id = 123456789
24
+ admin_chat_id = 123456789
25
+ allowed_workdirs = ["./", "./data", "C:/temp"]
26
+ writable_workdirs = ["./data", "C:/temp"]
27
+ windows_safe_allowlist = ["cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "docker", "docker.exe", "systemctl"]
28
+ guarded_patterns = ["\\b(systemctl|docker)\\s+(restart|stop|rm)\\b", "\\b(chmod|chown|mv|cp)\\b"]
29
+ dangerous_patterns = ["\\b(rm|del)\\s+(-rf|/s|/q|/f)\\b", "\\b(sudo|su|passwd|shutdown|reboot)\\b", ">\\s*/", "format\\s+"]
30
+ max_timeout_secs = 120
31
+ max_output_bytes = 32768
32
+ session_idle_timeout_secs = 1800
@@ -0,0 +1,25 @@
1
+ [package]
2
+ name = "hajimi-claw-agent"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ authors.workspace = true
7
+
8
+ [dependencies]
9
+ anyhow.workspace = true
10
+ chrono.workspace = true
11
+ futures.workspace = true
12
+ serde_json.workspace = true
13
+ tokio.workspace = true
14
+ tracing.workspace = true
15
+ hajimi-claw-llm = { path = "../hajimi-claw-llm" }
16
+ hajimi-claw-policy = { path = "../hajimi-claw-policy" }
17
+ hajimi-claw-store = { path = "../hajimi-claw-store" }
18
+ hajimi-claw-tools = { path = "../hajimi-claw-tools" }
19
+ hajimi-claw-types = { path = "../hajimi-claw-types" }
20
+ uuid.workspace = true
21
+
22
+ [dev-dependencies]
23
+ anyhow.workspace = true
24
+ hajimi-claw-exec = { path = "../hajimi-claw-exec" }
25
+ tempfile.workspace = true
@@ -0,0 +1,351 @@
1
+ use std::path::PathBuf;
2
+ use std::sync::Arc;
3
+
4
+ use chrono::Utc;
5
+ use futures::TryStreamExt;
6
+ use hajimi_claw_llm::StaticBackend;
7
+ use hajimi_claw_policy::PolicyEngine;
8
+ use hajimi_claw_store::Store;
9
+ use hajimi_claw_tools::ToolRegistry;
10
+ use hajimi_claw_types::{
11
+ AgentRequest, ApprovalId, ClawError, ClawResult, ConversationId, ConversationMessage,
12
+ LlmBackend, MessageRole, PolicyMode, TaskId, TaskKind, TaskStatus, ToolContext,
13
+ };
14
+ use serde_json::json;
15
+ use tokio::sync::Semaphore;
16
+
17
+ pub struct AgentRuntime {
18
+ llm: Arc<dyn LlmBackend>,
19
+ tools: Arc<ToolRegistry>,
20
+ store: Arc<Store>,
21
+ policy: Arc<PolicyEngine>,
22
+ task_gate: Arc<Semaphore>,
23
+ }
24
+
25
+ #[derive(Debug, Clone)]
26
+ pub struct ShellOpenReply {
27
+ pub session_id: String,
28
+ pub message: String,
29
+ }
30
+
31
+ impl AgentRuntime {
32
+ pub fn new(
33
+ llm: Arc<dyn LlmBackend>,
34
+ tools: Arc<ToolRegistry>,
35
+ store: Arc<Store>,
36
+ policy: Arc<PolicyEngine>,
37
+ ) -> Self {
38
+ Self {
39
+ llm,
40
+ tools,
41
+ store,
42
+ policy,
43
+ task_gate: Arc::new(Semaphore::new(1)),
44
+ }
45
+ }
46
+
47
+ pub fn for_tests(
48
+ tools: Arc<ToolRegistry>,
49
+ store: Arc<Store>,
50
+ policy: Arc<PolicyEngine>,
51
+ ) -> Self {
52
+ Self::new(
53
+ Arc::new(StaticBackend::new("fallback")),
54
+ tools,
55
+ store,
56
+ policy,
57
+ )
58
+ }
59
+
60
+ pub async fn ask(&self, prompt: &str, cwd: Option<PathBuf>) -> ClawResult<String> {
61
+ self.ask_with_provider(prompt, cwd, None).await
62
+ }
63
+
64
+ pub async fn ask_with_provider(
65
+ &self,
66
+ prompt: &str,
67
+ cwd: Option<PathBuf>,
68
+ provider_id: Option<String>,
69
+ ) -> ClawResult<String> {
70
+ let _permit = self
71
+ .task_gate
72
+ .acquire()
73
+ .await
74
+ .map_err(|_| ClawError::Backend("task gate closed".into()))?;
75
+
76
+ let task_id = TaskId::new();
77
+ let conversation_id = ConversationId::new();
78
+ let mut status = TaskStatus {
79
+ id: task_id,
80
+ kind: TaskKind::EphemeralAgentTask,
81
+ description: prompt.into(),
82
+ queued_at: Utc::now(),
83
+ started_at: Some(Utc::now()),
84
+ finished_at: None,
85
+ running: true,
86
+ };
87
+ self.store.upsert_task(&status).map_err(store_error)?;
88
+
89
+ self.store
90
+ .save_message(
91
+ conversation_id,
92
+ &ConversationMessage {
93
+ role: MessageRole::User,
94
+ content: prompt.into(),
95
+ created_at: Utc::now(),
96
+ },
97
+ )
98
+ .map_err(store_error)?;
99
+
100
+ let result = if let Some((tool, input)) = select_tool(prompt) {
101
+ self.tools
102
+ .call(
103
+ tool,
104
+ ToolContext {
105
+ conversation_id,
106
+ working_directory: cwd,
107
+ elevated: self.policy.is_elevated(),
108
+ },
109
+ input,
110
+ )
111
+ .await
112
+ .map(|output| output.content)
113
+ } else {
114
+ self.run_llm(conversation_id, prompt, provider_id).await
115
+ };
116
+
117
+ status.running = false;
118
+ status.finished_at = Some(Utc::now());
119
+ self.store.upsert_task(&status).map_err(store_error)?;
120
+
121
+ if let Ok(content) = &result {
122
+ self.store
123
+ .save_message(
124
+ conversation_id,
125
+ &ConversationMessage {
126
+ role: MessageRole::Assistant,
127
+ content: content.clone(),
128
+ created_at: Utc::now(),
129
+ },
130
+ )
131
+ .map_err(store_error)?;
132
+ }
133
+
134
+ result
135
+ }
136
+
137
+ pub async fn shell_open(
138
+ &self,
139
+ name: Option<String>,
140
+ cwd: Option<PathBuf>,
141
+ ) -> ClawResult<ShellOpenReply> {
142
+ let output = self
143
+ .tools
144
+ .call(
145
+ "session_open",
146
+ ToolContext {
147
+ conversation_id: ConversationId::new(),
148
+ working_directory: cwd,
149
+ elevated: self.policy.is_elevated(),
150
+ },
151
+ json!({ "name": name }),
152
+ )
153
+ .await?;
154
+ let session_id = output
155
+ .structured
156
+ .as_ref()
157
+ .and_then(|value| value.get("session_id"))
158
+ .and_then(|value| value.as_str())
159
+ .ok_or_else(|| ClawError::Backend("session_open did not return session_id".into()))?;
160
+ Ok(ShellOpenReply {
161
+ session_id: session_id.to_string(),
162
+ message: output.content,
163
+ })
164
+ }
165
+
166
+ pub async fn shell_exec(&self, session_id: &str, command: &str) -> ClawResult<String> {
167
+ self.tools
168
+ .call(
169
+ "session_exec",
170
+ ToolContext {
171
+ conversation_id: ConversationId::new(),
172
+ working_directory: None,
173
+ elevated: self.policy.is_elevated(),
174
+ },
175
+ json!({ "session_id": session_id, "command": command }),
176
+ )
177
+ .await
178
+ .map(|output| output.content)
179
+ }
180
+
181
+ pub async fn shell_close(&self, session_id: &str) -> ClawResult<String> {
182
+ self.tools
183
+ .call(
184
+ "session_close",
185
+ ToolContext {
186
+ conversation_id: ConversationId::new(),
187
+ working_directory: None,
188
+ elevated: self.policy.is_elevated(),
189
+ },
190
+ json!({ "session_id": session_id }),
191
+ )
192
+ .await
193
+ .map(|output| output.content)
194
+ }
195
+
196
+ pub fn request_elevated(&self, minutes: i64, reason: String) -> String {
197
+ let approval = self.policy.request_elevation(minutes, reason.clone());
198
+ let _ = self.store.save_approval(&approval, None);
199
+ format!(
200
+ "approval required: {} [{}], expires at {}",
201
+ reason, approval.request_id, approval.expires_at
202
+ )
203
+ }
204
+
205
+ pub fn approve(&self, request_id: &str) -> ClawResult<String> {
206
+ let approval_id = ApprovalId(
207
+ uuid::Uuid::parse_str(request_id)
208
+ .map_err(|err| ClawError::InvalidRequest(err.to_string()))?,
209
+ );
210
+ let approval = self
211
+ .policy
212
+ .approve(approval_id)
213
+ .ok_or_else(|| ClawError::NotFound(format!("approval not found: {request_id}")))?;
214
+ let _ = self.store.save_approval(&approval, Some(true));
215
+ Ok(format!(
216
+ "approved {} ({})",
217
+ approval.command_preview, approval.request_id
218
+ ))
219
+ }
220
+
221
+ pub fn stop_elevated(&self) -> String {
222
+ self.policy.stop_elevation();
223
+ "elevated lease stopped".into()
224
+ }
225
+
226
+ pub fn status(&self) -> ClawResult<String> {
227
+ let tasks = self.store.list_tasks().map_err(store_error)?;
228
+ let task_lines = if tasks.is_empty() {
229
+ "no tasks".into()
230
+ } else {
231
+ tasks
232
+ .into_iter()
233
+ .take(5)
234
+ .map(|task| {
235
+ format!(
236
+ "{} [{}] running={} queued_at={}",
237
+ task.id, task.description, task.running, task.queued_at
238
+ )
239
+ })
240
+ .collect::<Vec<_>>()
241
+ .join("\n")
242
+ };
243
+ let mode = match self.policy.current_mode() {
244
+ PolicyMode::Normal => "normal",
245
+ PolicyMode::ApprovalPending => "approval_pending",
246
+ PolicyMode::ElevatedLease => "elevated",
247
+ };
248
+ Ok(format!("policy_mode={mode}\n{task_lines}"))
249
+ }
250
+
251
+ async fn run_llm(
252
+ &self,
253
+ conversation_id: ConversationId,
254
+ prompt: &str,
255
+ provider_id: Option<String>,
256
+ ) -> ClawResult<String> {
257
+ let stream = self
258
+ .llm
259
+ .respond(AgentRequest {
260
+ conversation_id,
261
+ provider_id,
262
+ system_prompt: "You are hajimi-claw, a narrow single-user operations agent. Prefer concise, actionable answers. Use tools when possible.".into(),
263
+ messages: vec![ConversationMessage {
264
+ role: MessageRole::User,
265
+ content: prompt.into(),
266
+ created_at: Utc::now(),
267
+ }],
268
+ tool_specs: self.tools.specs(),
269
+ })
270
+ .await?;
271
+
272
+ let events = stream.try_collect::<Vec<_>>().await?;
273
+ let content = events
274
+ .into_iter()
275
+ .filter_map(|event| match event {
276
+ hajimi_claw_types::AgentEvent::TextDelta(delta) => Some(delta),
277
+ _ => None,
278
+ })
279
+ .collect::<String>();
280
+ Ok(content)
281
+ }
282
+ }
283
+
284
+ fn store_error(err: anyhow::Error) -> ClawError {
285
+ ClawError::Backend(err.to_string())
286
+ }
287
+
288
+ fn select_tool(prompt: &str) -> Option<(&'static str, serde_json::Value)> {
289
+ let trimmed = prompt.trim();
290
+
291
+ if trimmed.eq_ignore_ascii_case("docker ps") {
292
+ return Some(("docker_ps", json!({})));
293
+ }
294
+ if let Some(service) = trimmed.strip_prefix("systemctl status ") {
295
+ return Some(("systemd_status", json!({ "service": service.trim() })));
296
+ }
297
+ if let Some(service) = trimmed.strip_prefix("systemctl restart ") {
298
+ return Some(("systemd_restart", json!({ "service": service.trim() })));
299
+ }
300
+ if let Some(container) = trimmed.strip_prefix("docker logs ") {
301
+ return Some(("docker_logs", json!({ "container": container.trim() })));
302
+ }
303
+ if let Some(container) = trimmed.strip_prefix("docker restart ") {
304
+ return Some(("docker_restart", json!({ "container": container.trim() })));
305
+ }
306
+ if let Some(path) = trimmed.strip_prefix("read ") {
307
+ return Some(("read_file", json!({ "path": path.trim() })));
308
+ }
309
+ None
310
+ }
311
+
312
+ #[cfg(test)]
313
+ mod tests {
314
+ use std::sync::Arc;
315
+
316
+ use anyhow::Result;
317
+ use hajimi_claw_exec::{LocalExecutor, PlatformMode};
318
+ use hajimi_claw_policy::{PolicyConfig, PolicyEngine};
319
+ use hajimi_claw_store::Store;
320
+ use tempfile::tempdir;
321
+
322
+ use super::AgentRuntime;
323
+
324
+ #[tokio::test]
325
+ async fn routes_file_read_without_llm() -> Result<()> {
326
+ let dir = tempdir()?;
327
+ let path = dir.path().join("notes.txt");
328
+ tokio::fs::write(&path, "hello from tool").await?;
329
+
330
+ let mut config = PolicyConfig::default();
331
+ config.allowed_workdirs = vec![dir.path().to_path_buf()];
332
+ let policy = Arc::new(PolicyEngine::new(config));
333
+ let executor = Arc::new(LocalExecutor::new(
334
+ policy.clone(),
335
+ PlatformMode::WindowsSafe,
336
+ ));
337
+ let tools = Arc::new(hajimi_claw_tools::ToolRegistry::default(
338
+ executor,
339
+ policy.clone(),
340
+ ));
341
+ let store = Arc::new(Store::open_in_memory()?);
342
+ let agent = AgentRuntime::for_tests(tools, store, policy);
343
+
344
+ let response = agent
345
+ .ask(&format!("read {}", path.display()), None)
346
+ .await
347
+ .expect("agent response");
348
+ assert_eq!(response, "hello from tool");
349
+ Ok(())
350
+ }
351
+ }
@@ -0,0 +1,18 @@
1
+ [package]
2
+ name = "hajimi-claw-bot"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ authors.workspace = true
7
+
8
+ [dependencies]
9
+ anyhow.workspace = true
10
+ chrono.workspace = true
11
+ hajimi-claw-gateway = { path = "../hajimi-claw-gateway" }
12
+ reqwest.workspace = true
13
+ serde.workspace = true
14
+ serde_json.workspace = true
15
+ tokio.workspace = true
16
+ tracing.workspace = true
17
+ hajimi-claw-store = { path = "../hajimi-claw-store" }
18
+ hajimi-claw-types = { path = "../hajimi-claw-types" }