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.lock +2602 -0
- package/Cargo.toml +57 -0
- package/README.md +73 -0
- package/bin/hajimi-claw.js +28 -0
- package/config.example.toml +32 -0
- package/crates/hajimi-claw-agent/Cargo.toml +25 -0
- package/crates/hajimi-claw-agent/src/lib.rs +351 -0
- package/crates/hajimi-claw-bot/Cargo.toml +18 -0
- package/crates/hajimi-claw-bot/src/lib.rs +305 -0
- package/crates/hajimi-claw-daemon/Cargo.toml +24 -0
- package/crates/hajimi-claw-daemon/src/lib.rs +173 -0
- package/crates/hajimi-claw-exec/Cargo.toml +21 -0
- package/crates/hajimi-claw-exec/src/lib.rs +419 -0
- package/crates/hajimi-claw-gateway/Cargo.toml +27 -0
- package/crates/hajimi-claw-gateway/src/lib.rs +747 -0
- package/crates/hajimi-claw-llm/Cargo.toml +19 -0
- package/crates/hajimi-claw-llm/src/lib.rs +367 -0
- package/crates/hajimi-claw-policy/Cargo.toml +14 -0
- package/crates/hajimi-claw-policy/src/lib.rs +381 -0
- package/crates/hajimi-claw-store/Cargo.toml +17 -0
- package/crates/hajimi-claw-store/src/lib.rs +730 -0
- package/crates/hajimi-claw-tools/Cargo.toml +21 -0
- package/crates/hajimi-claw-tools/src/lib.rs +758 -0
- package/crates/hajimi-claw-types/Cargo.toml +16 -0
- package/crates/hajimi-claw-types/src/lib.rs +300 -0
- package/package.json +26 -0
- package/scripts/npm-install.js +45 -0
- package/src/main.rs +4 -0
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" }
|