forge-openclaw-plugin 0.2.61 → 0.2.66
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 +28 -5
- package/dist/assets/{board-DThHV1D8.js → board-DFNV9VAZ.js} +1 -1
- package/dist/assets/index-CFZxwOFB.js +90 -0
- package/dist/assets/{index-7gvVCqnV.css → index-lFN9z5op.css} +1 -1
- package/dist/assets/{motion-BtTJtHCw.js → motion-CXdn34ih.js} +1 -1
- package/dist/assets/{table-Bnw6pcwN.js → table-CEq3bTDv.js} +1 -1
- package/dist/assets/{ui-CnVxFkj0.js → ui-g7FaEglG.js} +1 -1
- package/dist/assets/{vendor-BgZ3YrRd.js → vendor-BcOHGipZ.js} +236 -216
- package/dist/companion-iroh/darwin-arm64/forge-companion-iroh +0 -0
- package/dist/companion-iroh/darwin-x64/forge-companion-iroh +0 -0
- package/dist/companion-iroh/linux-x64/forge-companion-iroh +0 -0
- package/dist/companion-iroh-src/Cargo.lock +4559 -0
- package/dist/companion-iroh-src/Cargo.toml +37 -0
- package/dist/companion-iroh-src/src/lib.rs +279 -0
- package/dist/companion-iroh-src/src/main.rs +478 -0
- package/dist/companion-iroh-src/src/protocol.rs +129 -0
- package/dist/index.html +7 -7
- package/dist/server/server/src/app.js +163 -18
- package/dist/server/server/src/discovery-advertiser.js +13 -0
- package/dist/server/server/src/health.js +18 -3
- package/dist/server/server/src/movement.js +16 -1
- package/dist/server/server/src/openapi.js +12 -2
- package/dist/server/server/src/services/companion-iroh.js +425 -0
- package/dist/server/server/src/services/life-force.js +166 -25
- package/dist/server/server/src/web.js +88 -12
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -3
- package/skills/forge-openclaw/SKILL.md +44 -2
- package/skills/forge-openclaw/entity_conversation_playbooks.md +217 -17
- package/skills/forge-openclaw/psyche_entity_playbooks.md +59 -0
- package/dist/assets/index-_Cn6Prym.js +0 -90
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "forge-companion-iroh"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
license = "Apache-2.0"
|
|
6
|
+
publish = false
|
|
7
|
+
|
|
8
|
+
[lib]
|
|
9
|
+
name = "forge_companion_iroh"
|
|
10
|
+
path = "src/lib.rs"
|
|
11
|
+
crate-type = ["rlib", "staticlib"]
|
|
12
|
+
|
|
13
|
+
[[bin]]
|
|
14
|
+
name = "forge-companion-iroh"
|
|
15
|
+
path = "src/main.rs"
|
|
16
|
+
|
|
17
|
+
[dependencies]
|
|
18
|
+
anyhow = "1.0"
|
|
19
|
+
base64 = "0.22"
|
|
20
|
+
clap = { version = "4.5", features = ["derive"] }
|
|
21
|
+
hex = "0.4"
|
|
22
|
+
hostname = "0.4"
|
|
23
|
+
iroh = "=0.98.1"
|
|
24
|
+
ed25519 = "=3.0.0-rc.4"
|
|
25
|
+
der = "=0.8.0-rc.10"
|
|
26
|
+
pkcs8 = "=0.11.0-rc.10"
|
|
27
|
+
signature = "=3.0.0-rc.10"
|
|
28
|
+
spki = "=0.8.0-rc.4"
|
|
29
|
+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
|
30
|
+
serde = { version = "1.0", features = ["derive"] }
|
|
31
|
+
serde_json = "1.0"
|
|
32
|
+
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "fs", "io-util", "signal", "time"] }
|
|
33
|
+
tracing = "0.1"
|
|
34
|
+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
35
|
+
|
|
36
|
+
[dev-dependencies]
|
|
37
|
+
tempfile = "3.23"
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
use std::ffi::{CStr, CString, c_char};
|
|
2
|
+
use std::time::Duration;
|
|
3
|
+
|
|
4
|
+
use iroh::{Endpoint, SecretKey};
|
|
5
|
+
use protocol::{
|
|
6
|
+
BridgeRequest, BridgeResponse, COMPANION_ALPN, FORGE_AGENT_NAME, ForgeHttpRequest,
|
|
7
|
+
ForgeHttpResponse, HeaderPair, PROTOCOL_VERSION, PairPayload,
|
|
8
|
+
};
|
|
9
|
+
use serde::{Deserialize, Serialize};
|
|
10
|
+
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
11
|
+
|
|
12
|
+
pub mod protocol;
|
|
13
|
+
|
|
14
|
+
const MAX_FRAME_BYTES: usize = 50 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
#[derive(Debug, Deserialize)]
|
|
17
|
+
#[serde(rename_all = "camelCase")]
|
|
18
|
+
struct FfiHttpRequest {
|
|
19
|
+
pair_payload: PairPayload,
|
|
20
|
+
method: String,
|
|
21
|
+
path: String,
|
|
22
|
+
#[serde(default)]
|
|
23
|
+
headers: Vec<HeaderPair>,
|
|
24
|
+
#[serde(default)]
|
|
25
|
+
body_base64: Option<String>,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#[derive(Debug, Serialize)]
|
|
29
|
+
#[serde(rename_all = "camelCase")]
|
|
30
|
+
struct FfiHttpResponse {
|
|
31
|
+
ok: bool,
|
|
32
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
33
|
+
status: Option<u16>,
|
|
34
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
35
|
+
headers: Vec<HeaderPair>,
|
|
36
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
37
|
+
body_base64: Option<String>,
|
|
38
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
39
|
+
error: Option<String>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[unsafe(no_mangle)]
|
|
43
|
+
pub extern "C" fn forge_iroh_http_request_json(input_json: *const c_char) -> *mut c_char {
|
|
44
|
+
let response = match read_c_string(input_json)
|
|
45
|
+
.and_then(|input| serde_json::from_str::<FfiHttpRequest>(&input).map_err(|e| e.to_string()))
|
|
46
|
+
.and_then(run_ffi_http_request)
|
|
47
|
+
{
|
|
48
|
+
Ok(response) => response,
|
|
49
|
+
Err(error) => FfiHttpResponse {
|
|
50
|
+
ok: false,
|
|
51
|
+
status: None,
|
|
52
|
+
headers: vec![],
|
|
53
|
+
body_base64: None,
|
|
54
|
+
error: Some(error),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
into_c_string(serde_json::to_string(&response).unwrap_or_else(|error| {
|
|
58
|
+
format!(r#"{{"ok":false,"error":"failed to encode Forge Iroh response: {error}"}}"#)
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[unsafe(no_mangle)]
|
|
63
|
+
pub extern "C" fn forge_iroh_free_string(value: *mut c_char) {
|
|
64
|
+
if value.is_null() {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
unsafe {
|
|
68
|
+
let _ = CString::from_raw(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn read_c_string(input_json: *const c_char) -> Result<String, String> {
|
|
73
|
+
if input_json.is_null() {
|
|
74
|
+
return Err("input JSON pointer was null".to_string());
|
|
75
|
+
}
|
|
76
|
+
unsafe { CStr::from_ptr(input_json) }
|
|
77
|
+
.to_str()
|
|
78
|
+
.map(|value| value.to_string())
|
|
79
|
+
.map_err(|error| format!("input JSON was not valid UTF-8: {error}"))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn into_c_string(value: String) -> *mut c_char {
|
|
83
|
+
let sanitized = value.replace('\0', "\\u0000");
|
|
84
|
+
CString::new(sanitized)
|
|
85
|
+
.expect("sanitized Forge Iroh response should not contain NUL bytes")
|
|
86
|
+
.into_raw()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn run_ffi_http_request(request: FfiHttpRequest) -> Result<FfiHttpResponse, String> {
|
|
90
|
+
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
91
|
+
.enable_all()
|
|
92
|
+
.build()
|
|
93
|
+
.map_err(|error| format!("building Forge Iroh runtime: {error}"))?;
|
|
94
|
+
runtime.block_on(async move {
|
|
95
|
+
let response = send_http_request_over_iroh(request).await?;
|
|
96
|
+
Ok(FfiHttpResponse {
|
|
97
|
+
ok: true,
|
|
98
|
+
status: Some(response.status),
|
|
99
|
+
headers: response.headers,
|
|
100
|
+
body_base64: response.body_base64,
|
|
101
|
+
error: None,
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async fn send_http_request_over_iroh(request: FfiHttpRequest) -> Result<ForgeHttpResponse, String> {
|
|
107
|
+
validate_pair_payload(&request.pair_payload)?;
|
|
108
|
+
validate_proxy_path(&request.path)?;
|
|
109
|
+
|
|
110
|
+
let endpoint = Endpoint::builder(iroh::endpoint::presets::N0)
|
|
111
|
+
.secret_key(SecretKey::generate())
|
|
112
|
+
.bind()
|
|
113
|
+
.await
|
|
114
|
+
.map_err(|error| format!("binding Iroh endpoint: {error}"))?;
|
|
115
|
+
let result = async {
|
|
116
|
+
let node_id = request
|
|
117
|
+
.pair_payload
|
|
118
|
+
.node_id
|
|
119
|
+
.parse()
|
|
120
|
+
.map_err(|error| format!("parsing Iroh node id: {error}"))?;
|
|
121
|
+
let mut addr = iroh::EndpointAddr::new(node_id);
|
|
122
|
+
if let Some(relay) = request.pair_payload.relay.as_deref() {
|
|
123
|
+
addr = addr.with_relay_url(
|
|
124
|
+
relay
|
|
125
|
+
.parse()
|
|
126
|
+
.map_err(|error| format!("parsing Iroh relay URL: {error}"))?,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
let conn = endpoint
|
|
130
|
+
.connect(addr, COMPANION_ALPN)
|
|
131
|
+
.await
|
|
132
|
+
.map_err(|error| format!("connecting over Forge Iroh bridge: {error}"))?;
|
|
133
|
+
let (mut send, mut recv) = conn
|
|
134
|
+
.open_bi()
|
|
135
|
+
.await
|
|
136
|
+
.map_err(|error| format!("opening Iroh stream: {error}"))?;
|
|
137
|
+
write_json_frame(
|
|
138
|
+
&mut send,
|
|
139
|
+
&BridgeRequest::Connect {
|
|
140
|
+
v: PROTOCOL_VERSION,
|
|
141
|
+
token: request.pair_payload.token.clone(),
|
|
142
|
+
agent: FORGE_AGENT_NAME.to_string(),
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
.await?;
|
|
146
|
+
let ack: BridgeResponse = read_json_frame(&mut recv).await?;
|
|
147
|
+
validate_bridge_response(&ack)?;
|
|
148
|
+
write_json_frame(
|
|
149
|
+
&mut send,
|
|
150
|
+
&ForgeHttpRequest {
|
|
151
|
+
v: PROTOCOL_VERSION,
|
|
152
|
+
method: request.method,
|
|
153
|
+
path: request.path,
|
|
154
|
+
headers: request.headers,
|
|
155
|
+
body_base64: request.body_base64,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
.await?;
|
|
159
|
+
let response = tokio::time::timeout(
|
|
160
|
+
Duration::from_secs(60),
|
|
161
|
+
read_json_frame::<ForgeHttpResponse, _>(&mut recv),
|
|
162
|
+
)
|
|
163
|
+
.await
|
|
164
|
+
.map_err(|_| "timed out waiting for Forge Iroh response".to_string())??;
|
|
165
|
+
conn.close(
|
|
166
|
+
iroh::endpoint::VarInt::from_u32(0),
|
|
167
|
+
b"forge request complete",
|
|
168
|
+
);
|
|
169
|
+
Ok(response)
|
|
170
|
+
}
|
|
171
|
+
.await;
|
|
172
|
+
endpoint.close().await;
|
|
173
|
+
result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fn validate_pair_payload(payload: &PairPayload) -> Result<(), String> {
|
|
177
|
+
if payload.v != PROTOCOL_VERSION {
|
|
178
|
+
return Err(format!(
|
|
179
|
+
"Iroh bridge protocol mismatch: payload={} client={}",
|
|
180
|
+
payload.v, PROTOCOL_VERSION
|
|
181
|
+
));
|
|
182
|
+
}
|
|
183
|
+
if payload.node_id.trim().is_empty() {
|
|
184
|
+
return Err("Iroh node id was empty".to_string());
|
|
185
|
+
}
|
|
186
|
+
if payload.token.trim().is_empty() {
|
|
187
|
+
return Err("Iroh pairing token was empty".to_string());
|
|
188
|
+
}
|
|
189
|
+
Ok(())
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fn validate_proxy_path(path: &str) -> Result<(), String> {
|
|
193
|
+
if path.starts_with("http://") || path.starts_with("https://") {
|
|
194
|
+
return Err("absolute proxy paths are not allowed".to_string());
|
|
195
|
+
}
|
|
196
|
+
if !path.starts_with('/') {
|
|
197
|
+
return Err("proxy path must start with /".to_string());
|
|
198
|
+
}
|
|
199
|
+
Ok(())
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
fn validate_bridge_response(response: &BridgeResponse) -> Result<(), String> {
|
|
203
|
+
if response.v != PROTOCOL_VERSION {
|
|
204
|
+
return Err(format!(
|
|
205
|
+
"Iroh bridge response protocol mismatch: host={} client={}",
|
|
206
|
+
response.v, PROTOCOL_VERSION
|
|
207
|
+
));
|
|
208
|
+
}
|
|
209
|
+
if !response.ok {
|
|
210
|
+
return Err(response
|
|
211
|
+
.error
|
|
212
|
+
.clone()
|
|
213
|
+
.unwrap_or_else(|| "Forge Iroh host rejected the request".to_string()));
|
|
214
|
+
}
|
|
215
|
+
Ok(())
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async fn read_json_frame<T, R>(reader: &mut R) -> Result<T, String>
|
|
219
|
+
where
|
|
220
|
+
T: serde::de::DeserializeOwned,
|
|
221
|
+
R: AsyncRead + Unpin,
|
|
222
|
+
{
|
|
223
|
+
let len = reader
|
|
224
|
+
.read_u32()
|
|
225
|
+
.await
|
|
226
|
+
.map_err(|error| format!("reading frame length: {error}"))? as usize;
|
|
227
|
+
if len > MAX_FRAME_BYTES {
|
|
228
|
+
return Err(format!("frame too large: {len} bytes"));
|
|
229
|
+
}
|
|
230
|
+
let mut buf = vec![0u8; len];
|
|
231
|
+
reader
|
|
232
|
+
.read_exact(&mut buf)
|
|
233
|
+
.await
|
|
234
|
+
.map_err(|error| format!("reading frame body: {error}"))?;
|
|
235
|
+
serde_json::from_slice(&buf).map_err(|error| format!("decoding JSON frame: {error}"))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async fn write_json_frame<T, W>(writer: &mut W, value: &T) -> Result<(), String>
|
|
239
|
+
where
|
|
240
|
+
T: serde::Serialize,
|
|
241
|
+
W: AsyncWrite + Unpin,
|
|
242
|
+
{
|
|
243
|
+
let buf = serde_json::to_vec(value).map_err(|error| format!("encoding JSON frame: {error}"))?;
|
|
244
|
+
if buf.len() > MAX_FRAME_BYTES {
|
|
245
|
+
return Err(format!("frame too large: {} bytes", buf.len()));
|
|
246
|
+
}
|
|
247
|
+
writer
|
|
248
|
+
.write_u32(buf.len() as u32)
|
|
249
|
+
.await
|
|
250
|
+
.map_err(|error| format!("writing frame length: {error}"))?;
|
|
251
|
+
writer
|
|
252
|
+
.write_all(&buf)
|
|
253
|
+
.await
|
|
254
|
+
.map_err(|error| format!("writing frame body: {error}"))?;
|
|
255
|
+
writer
|
|
256
|
+
.flush()
|
|
257
|
+
.await
|
|
258
|
+
.map_err(|error| format!("flushing frame: {error}"))?;
|
|
259
|
+
Ok(())
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#[cfg(test)]
|
|
263
|
+
mod tests {
|
|
264
|
+
use super::*;
|
|
265
|
+
|
|
266
|
+
#[test]
|
|
267
|
+
fn ffi_rejects_null_input() {
|
|
268
|
+
let ptr = forge_iroh_http_request_json(std::ptr::null());
|
|
269
|
+
assert!(!ptr.is_null());
|
|
270
|
+
let response = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().to_string();
|
|
271
|
+
forge_iroh_free_string(ptr);
|
|
272
|
+
assert!(response.contains("input JSON pointer was null"));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[test]
|
|
276
|
+
fn proxy_path_rejects_absolute_urls() {
|
|
277
|
+
assert!(validate_proxy_path("https://example.com/api/v1/health").is_err());
|
|
278
|
+
}
|
|
279
|
+
}
|