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
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
use std::path::PathBuf;
|
|
3
|
+
use std::process::Stdio;
|
|
4
|
+
use std::sync::Arc;
|
|
5
|
+
use std::time::Instant;
|
|
6
|
+
|
|
7
|
+
use async_trait::async_trait;
|
|
8
|
+
use chrono::Utc;
|
|
9
|
+
use hajimi_claw_policy::{PolicyDecision, PolicyEngine};
|
|
10
|
+
use hajimi_claw_types::{
|
|
11
|
+
ClawError, ClawResult, ExecRequest, ExecResult, Executor, SessionHandle, SessionId,
|
|
12
|
+
SessionOpenRequest,
|
|
13
|
+
};
|
|
14
|
+
use tokio::process::Command;
|
|
15
|
+
use tokio::sync::Mutex;
|
|
16
|
+
use tokio::time::{Duration, timeout};
|
|
17
|
+
|
|
18
|
+
#[derive(Debug, Clone, Copy)]
|
|
19
|
+
pub enum PlatformMode {
|
|
20
|
+
Unix,
|
|
21
|
+
WindowsSafe,
|
|
22
|
+
WindowsElevated,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[derive(Debug, Clone)]
|
|
26
|
+
struct SessionState {
|
|
27
|
+
handle: SessionHandle,
|
|
28
|
+
env_allowlist: Vec<String>,
|
|
29
|
+
history: Vec<String>,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub struct LocalExecutor {
|
|
33
|
+
policy: Arc<PolicyEngine>,
|
|
34
|
+
mode: PlatformMode,
|
|
35
|
+
sessions: Mutex<HashMap<SessionId, SessionState>>,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
impl LocalExecutor {
|
|
39
|
+
pub fn new(policy: Arc<PolicyEngine>, mode: PlatformMode) -> Self {
|
|
40
|
+
Self {
|
|
41
|
+
policy,
|
|
42
|
+
mode,
|
|
43
|
+
sessions: Mutex::new(HashMap::new()),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
pub fn policy(&self) -> &Arc<PolicyEngine> {
|
|
48
|
+
&self.policy
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async fn run_checked(&self, req: ExecRequest) -> ClawResult<ExecResult> {
|
|
52
|
+
match self.policy.evaluate_exec(&req) {
|
|
53
|
+
PolicyDecision::Allow { .. } => self.spawn(req).await,
|
|
54
|
+
PolicyDecision::RequiresApproval(approval) => Err(ClawError::ApprovalRequired(
|
|
55
|
+
format!("{} [{}]", approval.reason, approval.request_id),
|
|
56
|
+
)),
|
|
57
|
+
PolicyDecision::Deny(reason) => Err(ClawError::AccessDenied(reason)),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async fn spawn(&self, req: ExecRequest) -> ClawResult<ExecResult> {
|
|
62
|
+
if matches!(self.mode, PlatformMode::WindowsSafe) && !self.policy.is_elevated() {
|
|
63
|
+
self.validate_windows_safe_request(&req)?;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let start = Instant::now();
|
|
67
|
+
let mut command = build_command(&req, self.mode)?;
|
|
68
|
+
let child = command
|
|
69
|
+
.spawn()
|
|
70
|
+
.map_err(|err| ClawError::Backend(err.to_string()))?;
|
|
71
|
+
|
|
72
|
+
#[cfg(windows)]
|
|
73
|
+
let _job = windows_job::attach_kill_on_close(&child).ok();
|
|
74
|
+
|
|
75
|
+
let output = timeout(
|
|
76
|
+
Duration::from_secs(req.timeout_secs),
|
|
77
|
+
child.wait_with_output(),
|
|
78
|
+
)
|
|
79
|
+
.await
|
|
80
|
+
.map_err(|_| ClawError::Backend("command timed out".into()))?
|
|
81
|
+
.map_err(|err| ClawError::Backend(err.to_string()))?;
|
|
82
|
+
|
|
83
|
+
let (stdout, stderr, truncated) =
|
|
84
|
+
truncate_output(output.stdout, output.stderr, req.max_output_bytes);
|
|
85
|
+
|
|
86
|
+
Ok(ExecResult {
|
|
87
|
+
exit_code: output.status.code(),
|
|
88
|
+
stdout,
|
|
89
|
+
stderr,
|
|
90
|
+
duration_ms: start.elapsed().as_millis(),
|
|
91
|
+
truncated,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fn validate_windows_safe_request(&self, req: &ExecRequest) -> ClawResult<()> {
|
|
96
|
+
if !self.policy.windows_command_allowed(&req.command) {
|
|
97
|
+
return Err(ClawError::AccessDenied(format!(
|
|
98
|
+
"command is not in the Windows safe allowlist: {}",
|
|
99
|
+
req.command
|
|
100
|
+
)));
|
|
101
|
+
}
|
|
102
|
+
if let Some(cwd) = &req.cwd {
|
|
103
|
+
if !self.policy.is_allowed_workdir(cwd) {
|
|
104
|
+
return Err(ClawError::AccessDenied(format!(
|
|
105
|
+
"working directory is not allowed: {}",
|
|
106
|
+
cwd.display()
|
|
107
|
+
)));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
Ok(())
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#[async_trait]
|
|
115
|
+
impl Executor for LocalExecutor {
|
|
116
|
+
async fn run_once(&self, req: ExecRequest) -> ClawResult<ExecResult> {
|
|
117
|
+
self.run_checked(req).await
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async fn open_session(&self, req: SessionOpenRequest) -> ClawResult<SessionHandle> {
|
|
121
|
+
let cwd = req
|
|
122
|
+
.cwd
|
|
123
|
+
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
|
124
|
+
if !self.policy.is_allowed_workdir(&cwd) {
|
|
125
|
+
return Err(ClawError::AccessDenied(format!(
|
|
126
|
+
"working directory is not allowed: {}",
|
|
127
|
+
cwd.display()
|
|
128
|
+
)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let handle = SessionHandle {
|
|
132
|
+
id: SessionId::new(),
|
|
133
|
+
name: req
|
|
134
|
+
.name
|
|
135
|
+
.unwrap_or_else(|| format!("shell-{}", Utc::now().timestamp())),
|
|
136
|
+
cwd,
|
|
137
|
+
created_at: Utc::now(),
|
|
138
|
+
last_used_at: Utc::now(),
|
|
139
|
+
};
|
|
140
|
+
let state = SessionState {
|
|
141
|
+
handle: handle.clone(),
|
|
142
|
+
env_allowlist: req.env_allowlist,
|
|
143
|
+
history: Vec::new(),
|
|
144
|
+
};
|
|
145
|
+
self.sessions.lock().await.insert(handle.id, state);
|
|
146
|
+
Ok(handle)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async fn run_in_session(&self, id: SessionId, mut req: ExecRequest) -> ClawResult<ExecResult> {
|
|
150
|
+
let mut sessions = self.sessions.lock().await;
|
|
151
|
+
let state = sessions
|
|
152
|
+
.get_mut(&id)
|
|
153
|
+
.ok_or_else(|| ClawError::NotFound(format!("session not found: {id}")))?;
|
|
154
|
+
|
|
155
|
+
if req.command.eq_ignore_ascii_case("cd") {
|
|
156
|
+
let target = req
|
|
157
|
+
.args
|
|
158
|
+
.first()
|
|
159
|
+
.map(PathBuf::from)
|
|
160
|
+
.ok_or_else(|| ClawError::InvalidRequest("cd requires a target path".into()))?;
|
|
161
|
+
let next = if target.is_absolute() {
|
|
162
|
+
target
|
|
163
|
+
} else {
|
|
164
|
+
state.handle.cwd.join(target)
|
|
165
|
+
};
|
|
166
|
+
if !self.policy.is_allowed_workdir(&next) {
|
|
167
|
+
return Err(ClawError::AccessDenied(format!(
|
|
168
|
+
"working directory is not allowed: {}",
|
|
169
|
+
next.display()
|
|
170
|
+
)));
|
|
171
|
+
}
|
|
172
|
+
state.handle.cwd = next.clone();
|
|
173
|
+
state.handle.last_used_at = Utc::now();
|
|
174
|
+
state.history.push(format!("cd {}", next.display()));
|
|
175
|
+
return Ok(ExecResult {
|
|
176
|
+
exit_code: Some(0),
|
|
177
|
+
stdout: format!("cwd -> {}", next.display()),
|
|
178
|
+
stderr: String::new(),
|
|
179
|
+
duration_ms: 0,
|
|
180
|
+
truncated: false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
req.cwd = Some(state.handle.cwd.clone());
|
|
185
|
+
if req.env_allowlist.is_empty() {
|
|
186
|
+
req.env_allowlist = state.env_allowlist.clone();
|
|
187
|
+
}
|
|
188
|
+
state.history.push(req.summary());
|
|
189
|
+
state.handle.last_used_at = Utc::now();
|
|
190
|
+
drop(sessions);
|
|
191
|
+
|
|
192
|
+
self.run_checked(req).await
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async fn close_session(&self, id: SessionId) -> ClawResult<()> {
|
|
196
|
+
self.sessions
|
|
197
|
+
.lock()
|
|
198
|
+
.await
|
|
199
|
+
.remove(&id)
|
|
200
|
+
.ok_or_else(|| ClawError::NotFound(format!("session not found: {id}")))?;
|
|
201
|
+
Ok(())
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fn build_command(req: &ExecRequest, mode: PlatformMode) -> ClawResult<Command> {
|
|
206
|
+
let mut command = if matches!(mode, PlatformMode::Unix) {
|
|
207
|
+
let mut cmd = Command::new(&req.command);
|
|
208
|
+
cmd.args(&req.args);
|
|
209
|
+
cmd
|
|
210
|
+
} else {
|
|
211
|
+
let program = req.command.to_ascii_lowercase();
|
|
212
|
+
if matches!(
|
|
213
|
+
program.as_str(),
|
|
214
|
+
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
|
|
215
|
+
) {
|
|
216
|
+
let mut cmd = Command::new(&req.command);
|
|
217
|
+
cmd.arg("-NoProfile").args(&req.args);
|
|
218
|
+
cmd
|
|
219
|
+
} else {
|
|
220
|
+
let mut cmd = Command::new(&req.command);
|
|
221
|
+
cmd.args(&req.args);
|
|
222
|
+
cmd
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
command.kill_on_drop(true);
|
|
227
|
+
command.stdin(Stdio::null());
|
|
228
|
+
command.stdout(Stdio::piped());
|
|
229
|
+
command.stderr(Stdio::piped());
|
|
230
|
+
command.env_clear();
|
|
231
|
+
|
|
232
|
+
for key in &req.env_allowlist {
|
|
233
|
+
if let Ok(value) = std::env::var(key) {
|
|
234
|
+
command.env(key, value);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if let Some(cwd) = &req.cwd {
|
|
239
|
+
command.current_dir(cwd);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[cfg(unix)]
|
|
243
|
+
{
|
|
244
|
+
command.env("PATH", std::env::var("PATH").unwrap_or_default());
|
|
245
|
+
}
|
|
246
|
+
#[cfg(windows)]
|
|
247
|
+
{
|
|
248
|
+
command.env("PATH", std::env::var("PATH").unwrap_or_default());
|
|
249
|
+
command.creation_flags(0x08000000);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
Ok(command)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
fn truncate_output(
|
|
256
|
+
stdout: Vec<u8>,
|
|
257
|
+
stderr: Vec<u8>,
|
|
258
|
+
max_output_bytes: usize,
|
|
259
|
+
) -> (String, String, bool) {
|
|
260
|
+
let mut truncated = false;
|
|
261
|
+
let max_each = max_output_bytes / 2;
|
|
262
|
+
|
|
263
|
+
let stdout = if stdout.len() > max_each {
|
|
264
|
+
truncated = true;
|
|
265
|
+
lossy_tail(stdout, max_each)
|
|
266
|
+
} else {
|
|
267
|
+
String::from_utf8_lossy(&stdout).into_owned()
|
|
268
|
+
};
|
|
269
|
+
let stderr = if stderr.len() > max_each {
|
|
270
|
+
truncated = true;
|
|
271
|
+
lossy_tail(stderr, max_each)
|
|
272
|
+
} else {
|
|
273
|
+
String::from_utf8_lossy(&stderr).into_owned()
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
(stdout, stderr, truncated)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
fn lossy_tail(bytes: Vec<u8>, limit: usize) -> String {
|
|
280
|
+
let slice = if bytes.len() > limit {
|
|
281
|
+
&bytes[bytes.len() - limit..]
|
|
282
|
+
} else {
|
|
283
|
+
&bytes[..]
|
|
284
|
+
};
|
|
285
|
+
format!("...[truncated]\n{}", String::from_utf8_lossy(slice))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[cfg(windows)]
|
|
289
|
+
mod windows_job {
|
|
290
|
+
use tokio::process::Child;
|
|
291
|
+
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
|
292
|
+
use windows::Win32::System::JobObjects::{
|
|
293
|
+
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
294
|
+
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
|
295
|
+
SetInformationJobObject,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
pub struct KillOnCloseJob(*mut core::ffi::c_void);
|
|
299
|
+
|
|
300
|
+
unsafe impl Send for KillOnCloseJob {}
|
|
301
|
+
unsafe impl Sync for KillOnCloseJob {}
|
|
302
|
+
|
|
303
|
+
impl Drop for KillOnCloseJob {
|
|
304
|
+
fn drop(&mut self) {
|
|
305
|
+
unsafe {
|
|
306
|
+
let _ = CloseHandle(HANDLE(self.0));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
pub fn attach_kill_on_close(child: &Child) -> anyhow::Result<KillOnCloseJob> {
|
|
312
|
+
unsafe {
|
|
313
|
+
let job = CreateJobObjectW(None, None)?;
|
|
314
|
+
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
|
315
|
+
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
316
|
+
SetInformationJobObject(
|
|
317
|
+
job,
|
|
318
|
+
JobObjectExtendedLimitInformation,
|
|
319
|
+
&info as *const _ as *const _,
|
|
320
|
+
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
321
|
+
)?;
|
|
322
|
+
|
|
323
|
+
let raw = child
|
|
324
|
+
.raw_handle()
|
|
325
|
+
.ok_or_else(|| anyhow::anyhow!("child does not expose a raw handle"))?;
|
|
326
|
+
let process = HANDLE(raw);
|
|
327
|
+
AssignProcessToJobObject(job, process)?;
|
|
328
|
+
Ok(KillOnCloseJob(job.0))
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#[cfg(test)]
|
|
334
|
+
mod tests {
|
|
335
|
+
use std::path::PathBuf;
|
|
336
|
+
use std::sync::Arc;
|
|
337
|
+
|
|
338
|
+
use hajimi_claw_policy::{PolicyConfig, PolicyEngine};
|
|
339
|
+
use hajimi_claw_types::{ExecRequest, Executor, SessionOpenRequest};
|
|
340
|
+
|
|
341
|
+
use super::{LocalExecutor, PlatformMode};
|
|
342
|
+
|
|
343
|
+
fn executor() -> LocalExecutor {
|
|
344
|
+
let mut config = PolicyConfig::default();
|
|
345
|
+
config.allowed_workdirs = vec![std::env::current_dir().unwrap(), std::env::temp_dir()];
|
|
346
|
+
LocalExecutor::new(
|
|
347
|
+
Arc::new(PolicyEngine::new(config)),
|
|
348
|
+
PlatformMode::WindowsSafe,
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[tokio::test]
|
|
353
|
+
async fn truncates_output() {
|
|
354
|
+
let executor = executor();
|
|
355
|
+
let req = ExecRequest {
|
|
356
|
+
command: "cmd".into(),
|
|
357
|
+
args: vec!["/C".into(), "for /L %i in (1,1,300) do @echo line".into()],
|
|
358
|
+
cwd: Some(std::env::temp_dir()),
|
|
359
|
+
env_allowlist: vec![],
|
|
360
|
+
timeout_secs: 10,
|
|
361
|
+
max_output_bytes: 128,
|
|
362
|
+
requires_tty: false,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
let result = executor.run_once(req).await.expect("command succeeds");
|
|
366
|
+
assert!(result.truncated);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#[tokio::test]
|
|
370
|
+
async fn session_preserves_cwd() {
|
|
371
|
+
let executor = executor();
|
|
372
|
+
let dir = tempfile::tempdir().unwrap();
|
|
373
|
+
let session = executor
|
|
374
|
+
.open_session(SessionOpenRequest {
|
|
375
|
+
name: None,
|
|
376
|
+
cwd: Some(PathBuf::from(dir.path())),
|
|
377
|
+
env_allowlist: vec![],
|
|
378
|
+
})
|
|
379
|
+
.await
|
|
380
|
+
.expect("session opens");
|
|
381
|
+
executor
|
|
382
|
+
.run_in_session(
|
|
383
|
+
session.id,
|
|
384
|
+
ExecRequest {
|
|
385
|
+
command: "cd".into(),
|
|
386
|
+
args: vec![".".into()],
|
|
387
|
+
cwd: None,
|
|
388
|
+
env_allowlist: vec![],
|
|
389
|
+
timeout_secs: 10,
|
|
390
|
+
max_output_bytes: 128,
|
|
391
|
+
requires_tty: false,
|
|
392
|
+
},
|
|
393
|
+
)
|
|
394
|
+
.await
|
|
395
|
+
.expect("cd succeeds");
|
|
396
|
+
|
|
397
|
+
let output = executor
|
|
398
|
+
.run_in_session(
|
|
399
|
+
session.id,
|
|
400
|
+
ExecRequest {
|
|
401
|
+
command: "cmd".into(),
|
|
402
|
+
args: vec!["/C".into(), "cd".into()],
|
|
403
|
+
cwd: None,
|
|
404
|
+
env_allowlist: vec![],
|
|
405
|
+
timeout_secs: 10,
|
|
406
|
+
max_output_bytes: 256,
|
|
407
|
+
requires_tty: false,
|
|
408
|
+
},
|
|
409
|
+
)
|
|
410
|
+
.await
|
|
411
|
+
.expect("pwd succeeds");
|
|
412
|
+
assert!(
|
|
413
|
+
output
|
|
414
|
+
.stdout
|
|
415
|
+
.to_ascii_lowercase()
|
|
416
|
+
.contains(&dir.path().display().to_string().to_ascii_lowercase())
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "hajimi-claw-gateway"
|
|
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
|
+
async-trait.workspace = true
|
|
12
|
+
hajimi-claw-agent = { path = "../hajimi-claw-agent" }
|
|
13
|
+
hajimi-claw-llm = { path = "../hajimi-claw-llm" }
|
|
14
|
+
hajimi-claw-policy = { path = "../hajimi-claw-policy" }
|
|
15
|
+
hajimi-claw-store = { path = "../hajimi-claw-store" }
|
|
16
|
+
hajimi-claw-types = { path = "../hajimi-claw-types" }
|
|
17
|
+
reqwest.workspace = true
|
|
18
|
+
serde.workspace = true
|
|
19
|
+
thiserror.workspace = true
|
|
20
|
+
|
|
21
|
+
[dev-dependencies]
|
|
22
|
+
anyhow.workspace = true
|
|
23
|
+
hajimi-claw-exec = { path = "../hajimi-claw-exec" }
|
|
24
|
+
hajimi-claw-store = { path = "../hajimi-claw-store" }
|
|
25
|
+
hajimi-claw-tools = { path = "../hajimi-claw-tools" }
|
|
26
|
+
tempfile.workspace = true
|
|
27
|
+
tokio.workspace = true
|