forge-openclaw-plugin 0.2.60 → 0.2.65
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 +121 -51
- package/dist/assets/{board-B1V3M__K.js → board-DUwMfZvN.js} +1 -1
- package/dist/assets/index-B9VOpR7r.css +1 -0
- package/dist/assets/index-DoHjjze2.js +90 -0
- package/dist/assets/{motion-CltSTItx.js → motion-Crg3QyXD.js} +1 -1
- package/dist/assets/{table-B-VrSFx8.js → table-CTlDeYRs.js} +1 -1
- package/dist/assets/{ui-DUqM4jkt.js → ui-CJPaElbj.js} +1 -1
- package/dist/assets/{vendor-C0otBhgu.js → vendor-BdrT2htV.js} +217 -207
- 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/gamification-previews/dark-fantasy-item-trophy-tasks-anvil-marathon.webp +0 -0
- package/dist/gamification-previews/dark-fantasy-item-trophy-xp-levels-the-first-heat.webp +0 -0
- package/dist/gamification-previews/dark-fantasy-item-unlock-streaks-molten-crown-fire.webp +0 -0
- package/dist/gamification-previews/dark-fantasy-mascot.webp +0 -0
- package/dist/gamification-previews/dramatic-smithie-item-trophy-tasks-anvil-marathon.webp +0 -0
- package/dist/gamification-previews/dramatic-smithie-item-trophy-xp-levels-the-first-heat.webp +0 -0
- package/dist/gamification-previews/dramatic-smithie-item-unlock-streaks-molten-crown-fire.webp +0 -0
- package/dist/gamification-previews/dramatic-smithie-mascot.webp +0 -0
- package/dist/gamification-previews/mind-locksmith-item-trophy-tasks-anvil-marathon.webp +0 -0
- package/dist/gamification-previews/mind-locksmith-item-trophy-xp-levels-the-first-heat.webp +0 -0
- package/dist/gamification-previews/mind-locksmith-item-unlock-streaks-molten-crown-fire.webp +0 -0
- package/dist/gamification-previews/mind-locksmith-mascot.webp +0 -0
- package/dist/index.html +7 -7
- package/dist/openclaw/parity.js +27 -0
- package/dist/openclaw/plugin-entry-shared.js +2 -2
- package/dist/openclaw/plugin-sdk-types.d.ts +2 -1
- package/dist/openclaw/routes.d.ts +4 -0
- package/dist/openclaw/routes.js +112 -3
- package/dist/openclaw/tools.js +32 -4
- package/dist/server/server/migrations/059_data_backup_retention.sql +2 -0
- package/dist/server/server/src/app.js +288 -61
- package/dist/server/server/src/data-management-types.js +2 -0
- package/dist/server/server/src/discovery-advertiser.js +13 -0
- package/dist/server/server/src/health.js +58 -3
- package/dist/server/server/src/movement.js +16 -1
- package/dist/server/server/src/openapi.js +410 -9
- package/dist/server/server/src/repositories/rewards.js +60 -0
- package/dist/server/server/src/services/companion-iroh.js +425 -0
- package/dist/server/server/src/services/data-management.js +32 -2
- package/dist/server/server/src/services/doctor.js +762 -0
- package/dist/server/server/src/services/gamification.js +75 -3
- package/dist/server/server/src/services/life-force.js +166 -25
- package/dist/server/server/src/web.js +88 -12
- package/dist/server/src/lib/api.js +9 -0
- package/dist/server/src/lib/gamification-catalog.js +1 -1
- package/openclaw.plugin.json +85 -3
- package/package.json +10 -6
- package/server/migrations/059_data_backup_retention.sql +2 -0
- package/skills/forge-openclaw/SKILL.md +80 -19
- package/skills/forge-openclaw/entity_conversation_playbooks.md +283 -25
- package/skills/forge-openclaw/psyche_entity_playbooks.md +82 -0
- package/dist/assets/index-BwKAPo98.css +0 -1
- package/dist/assets/index-Dy7c-dRY.js +0 -90
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
use std::time::Duration;
|
|
3
|
+
|
|
4
|
+
use anyhow::{Context, anyhow};
|
|
5
|
+
use base64::Engine;
|
|
6
|
+
use clap::{Parser, Subcommand};
|
|
7
|
+
use forge_companion_iroh::protocol::{
|
|
8
|
+
BridgeRequest, BridgeResponse, COMPANION_ALPN, FORGE_AGENT_NAME, ForgeHttpRequest,
|
|
9
|
+
ForgeHttpResponse, HeaderPair, PROTOCOL_VERSION, PairPayload,
|
|
10
|
+
};
|
|
11
|
+
use iroh::endpoint::{IdleTimeout, QuicTransportConfig, RecvStream, SendStream, presets};
|
|
12
|
+
use iroh::{Endpoint, SecretKey};
|
|
13
|
+
use reqwest::header::{HeaderName, HeaderValue};
|
|
14
|
+
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
15
|
+
use tracing::{info, warn};
|
|
16
|
+
use tracing_subscriber::EnvFilter;
|
|
17
|
+
|
|
18
|
+
const MAX_FRAME_BYTES: usize = 50 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
#[derive(Parser, Debug)]
|
|
21
|
+
#[command(version, about = "Forge Companion transport over Iroh QUIC")]
|
|
22
|
+
struct Cli {
|
|
23
|
+
#[command(subcommand)]
|
|
24
|
+
command: Command,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Subcommand, Debug)]
|
|
28
|
+
enum Command {
|
|
29
|
+
Host(HostArgs),
|
|
30
|
+
Probe(ProbeArgs),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[derive(Parser, Debug)]
|
|
34
|
+
struct HostArgs {
|
|
35
|
+
#[arg(long)]
|
|
36
|
+
state_dir: PathBuf,
|
|
37
|
+
#[arg(long)]
|
|
38
|
+
token: Option<String>,
|
|
39
|
+
#[arg(long)]
|
|
40
|
+
local_base_url: String,
|
|
41
|
+
#[arg(long)]
|
|
42
|
+
relay: Option<String>,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[derive(Parser, Debug)]
|
|
46
|
+
struct ProbeArgs {
|
|
47
|
+
#[arg(long)]
|
|
48
|
+
node_id: String,
|
|
49
|
+
#[arg(long)]
|
|
50
|
+
token: String,
|
|
51
|
+
#[arg(long)]
|
|
52
|
+
relay: Option<String>,
|
|
53
|
+
#[arg(long, default_value = "GET")]
|
|
54
|
+
method: String,
|
|
55
|
+
#[arg(long, default_value = "/api/v1/health")]
|
|
56
|
+
path: String,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[tokio::main]
|
|
60
|
+
async fn main() -> anyhow::Result<()> {
|
|
61
|
+
init_logging();
|
|
62
|
+
match Cli::parse().command {
|
|
63
|
+
Command::Host(args) => run_host(args).await,
|
|
64
|
+
Command::Probe(args) => run_probe(args).await,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async fn run_host(args: HostArgs) -> anyhow::Result<()> {
|
|
69
|
+
tokio::fs::create_dir_all(&args.state_dir)
|
|
70
|
+
.await
|
|
71
|
+
.with_context(|| format!("creating {}", args.state_dir.display()))?;
|
|
72
|
+
let secret_key = load_or_create_secret_key(&args.state_dir).await?;
|
|
73
|
+
let token = match args.token {
|
|
74
|
+
Some(token) => token,
|
|
75
|
+
None => load_or_create_token(&args.state_dir).await?,
|
|
76
|
+
};
|
|
77
|
+
let endpoint = bind_endpoint(secret_key.clone()).await?;
|
|
78
|
+
wait_for_endpoint_route(&endpoint).await;
|
|
79
|
+
let relay = endpoint_home_relay(&endpoint).or(args.relay);
|
|
80
|
+
let payload = PairPayload {
|
|
81
|
+
v: PROTOCOL_VERSION,
|
|
82
|
+
node_id: secret_key.public().to_string(),
|
|
83
|
+
token,
|
|
84
|
+
host_name: local_host_name(),
|
|
85
|
+
relay,
|
|
86
|
+
};
|
|
87
|
+
println!(
|
|
88
|
+
"{}",
|
|
89
|
+
serde_json::to_string(&serde_json::json!({
|
|
90
|
+
"event": "ready",
|
|
91
|
+
"pairPayload": payload,
|
|
92
|
+
"alpn": String::from_utf8_lossy(COMPANION_ALPN),
|
|
93
|
+
}))?
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
let client = reqwest::Client::builder()
|
|
97
|
+
.redirect(reqwest::redirect::Policy::none())
|
|
98
|
+
.timeout(Duration::from_secs(60))
|
|
99
|
+
.build()
|
|
100
|
+
.context("building HTTP client")?;
|
|
101
|
+
let shutdown = wait_for_shutdown();
|
|
102
|
+
tokio::pin!(shutdown);
|
|
103
|
+
|
|
104
|
+
loop {
|
|
105
|
+
tokio::select! {
|
|
106
|
+
_ = &mut shutdown => {
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
incoming = endpoint.accept() => {
|
|
110
|
+
let Some(connecting) = incoming else { break };
|
|
111
|
+
let client = client.clone();
|
|
112
|
+
let local_base_url = args.local_base_url.clone();
|
|
113
|
+
let expected_token = payload.token.clone();
|
|
114
|
+
tokio::spawn(async move {
|
|
115
|
+
match connecting.await {
|
|
116
|
+
Ok(conn) => {
|
|
117
|
+
let conn_id = conn.stable_id();
|
|
118
|
+
info!(conn = conn_id, node_id = %conn.remote_id(), "forge iroh connection accepted");
|
|
119
|
+
while let Ok((send, recv)) = conn.accept_bi().await {
|
|
120
|
+
let client = client.clone();
|
|
121
|
+
let local_base_url = local_base_url.clone();
|
|
122
|
+
let expected_token = expected_token.clone();
|
|
123
|
+
tokio::spawn(async move {
|
|
124
|
+
if let Err(error) = handle_stream(send, recv, client, &local_base_url, &expected_token).await {
|
|
125
|
+
warn!("forge iroh stream ended: {error:#}");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
Err(error) => warn!("incoming iroh connection failed: {error:#}"),
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
endpoint.close().await;
|
|
137
|
+
Ok(())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async fn run_probe(args: ProbeArgs) -> anyhow::Result<()> {
|
|
141
|
+
let endpoint = Endpoint::builder(presets::N0)
|
|
142
|
+
.secret_key(SecretKey::generate())
|
|
143
|
+
.alpns(vec![COMPANION_ALPN.to_vec()])
|
|
144
|
+
.bind()
|
|
145
|
+
.await
|
|
146
|
+
.context("binding probe endpoint")?;
|
|
147
|
+
let result = run_probe_with_endpoint(&endpoint, args).await;
|
|
148
|
+
endpoint.close().await;
|
|
149
|
+
result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async fn run_probe_with_endpoint(endpoint: &Endpoint, args: ProbeArgs) -> anyhow::Result<()> {
|
|
153
|
+
let node_id = args
|
|
154
|
+
.node_id
|
|
155
|
+
.parse()
|
|
156
|
+
.with_context(|| format!("parsing node id {}", args.node_id))?;
|
|
157
|
+
let mut addr = iroh::EndpointAddr::new(node_id);
|
|
158
|
+
if let Some(relay) = args.relay {
|
|
159
|
+
addr = addr.with_relay_url(
|
|
160
|
+
relay
|
|
161
|
+
.parse()
|
|
162
|
+
.with_context(|| format!("parsing relay URL {relay}"))?,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
let conn = endpoint
|
|
166
|
+
.connect(addr, COMPANION_ALPN)
|
|
167
|
+
.await
|
|
168
|
+
.context("connecting over Forge Iroh bridge")?;
|
|
169
|
+
let (mut send, mut recv) = conn.open_bi().await.context("opening stream")?;
|
|
170
|
+
write_json_frame(
|
|
171
|
+
&mut send,
|
|
172
|
+
&BridgeRequest::Connect {
|
|
173
|
+
v: PROTOCOL_VERSION,
|
|
174
|
+
token: args.token,
|
|
175
|
+
agent: FORGE_AGENT_NAME.to_string(),
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
.await?;
|
|
179
|
+
let ack: BridgeResponse = read_json_frame(&mut recv).await?;
|
|
180
|
+
if !ack.ok {
|
|
181
|
+
anyhow::bail!("connect rejected: {}", ack.error.unwrap_or_default());
|
|
182
|
+
}
|
|
183
|
+
write_json_frame(
|
|
184
|
+
&mut send,
|
|
185
|
+
&ForgeHttpRequest {
|
|
186
|
+
v: PROTOCOL_VERSION,
|
|
187
|
+
method: args.method,
|
|
188
|
+
path: args.path,
|
|
189
|
+
headers: vec![],
|
|
190
|
+
body_base64: None,
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
.await?;
|
|
194
|
+
let response: ForgeHttpResponse = read_json_frame(&mut recv).await?;
|
|
195
|
+
println!("{}", serde_json::to_string_pretty(&response)?);
|
|
196
|
+
conn.close(iroh::endpoint::VarInt::from_u32(0), b"probe complete");
|
|
197
|
+
Ok(())
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async fn handle_stream(
|
|
201
|
+
mut send: SendStream,
|
|
202
|
+
mut recv: RecvStream,
|
|
203
|
+
client: reqwest::Client,
|
|
204
|
+
local_base_url: &str,
|
|
205
|
+
expected_token: &str,
|
|
206
|
+
) -> anyhow::Result<()> {
|
|
207
|
+
let request: BridgeRequest = read_json_frame(&mut recv).await?;
|
|
208
|
+
validate_bridge_request(&request, expected_token)?;
|
|
209
|
+
match request {
|
|
210
|
+
BridgeRequest::ListAgents { .. } => {
|
|
211
|
+
write_json_frame(&mut send, &BridgeResponse::agents()).await?;
|
|
212
|
+
Ok(())
|
|
213
|
+
}
|
|
214
|
+
BridgeRequest::Connect { agent, .. } if agent == FORGE_AGENT_NAME => {
|
|
215
|
+
write_json_frame(&mut send, &BridgeResponse::ok()).await?;
|
|
216
|
+
let request: ForgeHttpRequest = read_json_frame(&mut recv).await?;
|
|
217
|
+
let response = proxy_http_request(client, local_base_url, request).await?;
|
|
218
|
+
write_json_frame(&mut send, &response).await?;
|
|
219
|
+
let _ = send.finish();
|
|
220
|
+
Ok(())
|
|
221
|
+
}
|
|
222
|
+
BridgeRequest::Connect { agent, .. } => {
|
|
223
|
+
let message = format!("agent `{agent}` is disabled or unknown");
|
|
224
|
+
write_json_frame(&mut send, &BridgeResponse::error(&message)).await?;
|
|
225
|
+
Err(anyhow!(message))
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
fn validate_bridge_request(request: &BridgeRequest, expected_token: &str) -> anyhow::Result<()> {
|
|
231
|
+
if request.version() != PROTOCOL_VERSION {
|
|
232
|
+
anyhow::bail!(
|
|
233
|
+
"protocol mismatch: client={} host={}",
|
|
234
|
+
request.version(),
|
|
235
|
+
PROTOCOL_VERSION
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if request.token() != expected_token {
|
|
239
|
+
anyhow::bail!("invalid token");
|
|
240
|
+
}
|
|
241
|
+
Ok(())
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async fn proxy_http_request(
|
|
245
|
+
client: reqwest::Client,
|
|
246
|
+
local_base_url: &str,
|
|
247
|
+
request: ForgeHttpRequest,
|
|
248
|
+
) -> anyhow::Result<ForgeHttpResponse> {
|
|
249
|
+
if request.v != PROTOCOL_VERSION {
|
|
250
|
+
anyhow::bail!(
|
|
251
|
+
"HTTP protocol mismatch: client={} host={}",
|
|
252
|
+
request.v,
|
|
253
|
+
PROTOCOL_VERSION
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
let url = build_proxy_url(local_base_url, &request.path)?;
|
|
257
|
+
let method = request.method.parse().context("parsing HTTP method")?;
|
|
258
|
+
let mut builder = client.request(method, url);
|
|
259
|
+
for header in request.headers {
|
|
260
|
+
let lower = header.name.to_ascii_lowercase();
|
|
261
|
+
if matches!(
|
|
262
|
+
lower.as_str(),
|
|
263
|
+
"connection" | "host" | "content-length" | "transfer-encoding"
|
|
264
|
+
) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
let name = HeaderName::from_bytes(header.name.as_bytes())
|
|
268
|
+
.with_context(|| format!("invalid header name {}", header.name))?;
|
|
269
|
+
let value = HeaderValue::from_str(&header.value)
|
|
270
|
+
.with_context(|| format!("invalid header value for {}", header.name))?;
|
|
271
|
+
builder = builder.header(name, value);
|
|
272
|
+
}
|
|
273
|
+
if let Some(body) = request.body_base64 {
|
|
274
|
+
let bytes = base64::engine::general_purpose::STANDARD
|
|
275
|
+
.decode(body)
|
|
276
|
+
.context("decoding request body")?;
|
|
277
|
+
builder = builder.body(bytes);
|
|
278
|
+
}
|
|
279
|
+
let response = builder.send().await.context("forwarding HTTP request")?;
|
|
280
|
+
let status = response.status().as_u16();
|
|
281
|
+
let headers = response
|
|
282
|
+
.headers()
|
|
283
|
+
.iter()
|
|
284
|
+
.filter_map(|(name, value)| {
|
|
285
|
+
value.to_str().ok().map(|value| HeaderPair {
|
|
286
|
+
name: name.as_str().to_string(),
|
|
287
|
+
value: value.to_string(),
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
.collect();
|
|
291
|
+
let body = response
|
|
292
|
+
.bytes()
|
|
293
|
+
.await
|
|
294
|
+
.context("reading HTTP response body")?;
|
|
295
|
+
Ok(ForgeHttpResponse {
|
|
296
|
+
v: PROTOCOL_VERSION,
|
|
297
|
+
status,
|
|
298
|
+
headers,
|
|
299
|
+
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(body)),
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fn build_proxy_url(local_base_url: &str, path: &str) -> anyhow::Result<String> {
|
|
304
|
+
if path.starts_with("http://") || path.starts_with("https://") {
|
|
305
|
+
anyhow::bail!("absolute proxy paths are not allowed");
|
|
306
|
+
}
|
|
307
|
+
if !path.starts_with('/') {
|
|
308
|
+
anyhow::bail!("proxy path must start with /");
|
|
309
|
+
}
|
|
310
|
+
Ok(format!("{}{}", local_base_url.trim_end_matches('/'), path))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async fn bind_endpoint(secret_key: SecretKey) -> anyhow::Result<Endpoint> {
|
|
314
|
+
let idle_timeout = IdleTimeout::try_from(Duration::from_secs(600))
|
|
315
|
+
.context("constructing Iroh idle timeout")?;
|
|
316
|
+
let transport = QuicTransportConfig::builder()
|
|
317
|
+
.max_idle_timeout(Some(idle_timeout))
|
|
318
|
+
.build();
|
|
319
|
+
let endpoint = Endpoint::builder(presets::N0)
|
|
320
|
+
.secret_key(secret_key)
|
|
321
|
+
.alpns(vec![COMPANION_ALPN.to_vec()])
|
|
322
|
+
.transport_config(transport)
|
|
323
|
+
.bind()
|
|
324
|
+
.await
|
|
325
|
+
.context("binding Iroh endpoint")?;
|
|
326
|
+
Ok(endpoint)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async fn wait_for_endpoint_route(endpoint: &Endpoint) {
|
|
330
|
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
|
|
331
|
+
while tokio::time::Instant::now() < deadline {
|
|
332
|
+
if endpoint_home_relay(endpoint).is_some() {
|
|
333
|
+
info!(addr = ?endpoint.addr(), "forge iroh endpoint route ready");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
337
|
+
}
|
|
338
|
+
warn!("forge iroh endpoint did not report relay routing within timeout");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async fn load_or_create_secret_key(state_dir: &Path) -> anyhow::Result<SecretKey> {
|
|
342
|
+
let path = state_dir.join("host.key");
|
|
343
|
+
match tokio::fs::read_to_string(&path).await {
|
|
344
|
+
Ok(raw) => parse_secret_key(raw.trim())
|
|
345
|
+
.with_context(|| format!("parsing host key {}", path.display())),
|
|
346
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
347
|
+
let key = SecretKey::generate();
|
|
348
|
+
write_private_file(&path, hex::encode(key.to_bytes())).await?;
|
|
349
|
+
Ok(key)
|
|
350
|
+
}
|
|
351
|
+
Err(error) => Err(error).with_context(|| format!("reading {}", path.display())),
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async fn load_or_create_token(state_dir: &Path) -> anyhow::Result<String> {
|
|
356
|
+
let path = state_dir.join("host.token");
|
|
357
|
+
match tokio::fs::read_to_string(&path).await {
|
|
358
|
+
Ok(raw) => Ok(raw.trim().to_string()),
|
|
359
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
360
|
+
let token = hex::encode(SecretKey::generate().to_bytes());
|
|
361
|
+
write_private_file(&path, token.clone()).await?;
|
|
362
|
+
Ok(token)
|
|
363
|
+
}
|
|
364
|
+
Err(error) => Err(error).with_context(|| format!("reading {}", path.display())),
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
fn parse_secret_key(raw: &str) -> anyhow::Result<SecretKey> {
|
|
369
|
+
let bytes = hex::decode(raw).context("host key must be 32 bytes of hex")?;
|
|
370
|
+
let bytes: [u8; 32] = bytes
|
|
371
|
+
.try_into()
|
|
372
|
+
.map_err(|_| anyhow!("host key must decode to exactly 32 bytes"))?;
|
|
373
|
+
Ok(SecretKey::from_bytes(&bytes))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async fn write_private_file(path: &Path, content: String) -> anyhow::Result<()> {
|
|
377
|
+
let tmp = path.with_extension("tmp");
|
|
378
|
+
tokio::fs::write(&tmp, format!("{content}\n"))
|
|
379
|
+
.await
|
|
380
|
+
.with_context(|| format!("writing {}", tmp.display()))?;
|
|
381
|
+
#[cfg(unix)]
|
|
382
|
+
{
|
|
383
|
+
use std::os::unix::fs::PermissionsExt;
|
|
384
|
+
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))
|
|
385
|
+
.with_context(|| format!("chmod 0600 {}", tmp.display()))?;
|
|
386
|
+
}
|
|
387
|
+
tokio::fs::rename(&tmp, path)
|
|
388
|
+
.await
|
|
389
|
+
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
|
|
390
|
+
Ok(())
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async fn read_json_frame<T, R>(reader: &mut R) -> anyhow::Result<T>
|
|
394
|
+
where
|
|
395
|
+
T: serde::de::DeserializeOwned,
|
|
396
|
+
R: AsyncRead + Unpin,
|
|
397
|
+
{
|
|
398
|
+
let len = reader.read_u32().await.context("reading frame length")? as usize;
|
|
399
|
+
if len > MAX_FRAME_BYTES {
|
|
400
|
+
anyhow::bail!("frame too large: {len} bytes");
|
|
401
|
+
}
|
|
402
|
+
let mut buf = vec![0u8; len];
|
|
403
|
+
reader
|
|
404
|
+
.read_exact(&mut buf)
|
|
405
|
+
.await
|
|
406
|
+
.context("reading frame body")?;
|
|
407
|
+
serde_json::from_slice(&buf).context("decoding JSON frame")
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async fn write_json_frame<T, W>(writer: &mut W, value: &T) -> anyhow::Result<()>
|
|
411
|
+
where
|
|
412
|
+
T: serde::Serialize,
|
|
413
|
+
W: AsyncWrite + Unpin,
|
|
414
|
+
{
|
|
415
|
+
let buf = serde_json::to_vec(value).context("encoding JSON frame")?;
|
|
416
|
+
if buf.len() > MAX_FRAME_BYTES {
|
|
417
|
+
anyhow::bail!("frame too large: {} bytes", buf.len());
|
|
418
|
+
}
|
|
419
|
+
writer
|
|
420
|
+
.write_u32(buf.len() as u32)
|
|
421
|
+
.await
|
|
422
|
+
.context("writing frame length")?;
|
|
423
|
+
writer.write_all(&buf).await.context("writing frame body")?;
|
|
424
|
+
writer.flush().await.context("flushing frame")?;
|
|
425
|
+
Ok(())
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
fn endpoint_home_relay(endpoint: &Endpoint) -> Option<String> {
|
|
429
|
+
endpoint
|
|
430
|
+
.addr()
|
|
431
|
+
.relay_urls()
|
|
432
|
+
.next()
|
|
433
|
+
.map(|url| url.to_string())
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
fn local_host_name() -> Option<String> {
|
|
437
|
+
hostname::get()
|
|
438
|
+
.ok()
|
|
439
|
+
.and_then(|name| name.into_string().ok())
|
|
440
|
+
.map(|name| name.trim().trim_end_matches('.').to_string())
|
|
441
|
+
.filter(|name| !name.is_empty())
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async fn wait_for_shutdown() {
|
|
445
|
+
let _ = tokio::signal::ctrl_c().await;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
fn init_logging() {
|
|
449
|
+
let _ = tracing_subscriber::fmt()
|
|
450
|
+
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
|
451
|
+
EnvFilter::new(
|
|
452
|
+
"warn,forge_companion_iroh=info,iroh=error,noq=error,noq_udp=error,quinn=error",
|
|
453
|
+
)
|
|
454
|
+
}))
|
|
455
|
+
.with_writer(std::io::stderr)
|
|
456
|
+
.try_init();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#[cfg(test)]
|
|
460
|
+
mod tests {
|
|
461
|
+
use super::*;
|
|
462
|
+
|
|
463
|
+
#[test]
|
|
464
|
+
fn proxy_url_rejects_absolute_paths() {
|
|
465
|
+
assert!(build_proxy_url("http://127.0.0.1:4317", "https://example.com").is_err());
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#[test]
|
|
469
|
+
fn proxy_url_requires_absolute_origin_path() {
|
|
470
|
+
assert!(build_proxy_url("http://127.0.0.1:4317", "api/v1/health").is_err());
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#[test]
|
|
474
|
+
fn proxy_url_joins_local_origin() {
|
|
475
|
+
let url = build_proxy_url("http://127.0.0.1:4317/", "/api/v1/health").unwrap();
|
|
476
|
+
assert_eq!(url, "http://127.0.0.1:4317/api/v1/health");
|
|
477
|
+
}
|
|
478
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
|
|
3
|
+
pub const PROTOCOL_VERSION: u32 = 1;
|
|
4
|
+
pub const COMPANION_ALPN: &[u8] = b"forge-companion/1";
|
|
5
|
+
pub const FORGE_AGENT_NAME: &str = "forge";
|
|
6
|
+
|
|
7
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
8
|
+
pub struct PairPayload {
|
|
9
|
+
pub v: u32,
|
|
10
|
+
pub node_id: String,
|
|
11
|
+
pub token: String,
|
|
12
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
13
|
+
pub host_name: Option<String>,
|
|
14
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
15
|
+
pub relay: Option<String>,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
19
|
+
#[serde(rename_all = "snake_case")]
|
|
20
|
+
pub enum AgentWire {
|
|
21
|
+
HttpJson,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
25
|
+
pub struct AgentInfo {
|
|
26
|
+
pub name: String,
|
|
27
|
+
pub display_name: String,
|
|
28
|
+
pub wire: AgentWire,
|
|
29
|
+
pub available: bool,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
33
|
+
#[serde(tag = "op", rename_all = "snake_case")]
|
|
34
|
+
pub enum BridgeRequest {
|
|
35
|
+
ListAgents {
|
|
36
|
+
v: u32,
|
|
37
|
+
token: String,
|
|
38
|
+
},
|
|
39
|
+
Connect {
|
|
40
|
+
v: u32,
|
|
41
|
+
token: String,
|
|
42
|
+
agent: String,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
impl BridgeRequest {
|
|
47
|
+
pub fn version(&self) -> u32 {
|
|
48
|
+
match self {
|
|
49
|
+
Self::ListAgents { v, .. } | Self::Connect { v, .. } => *v,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
pub fn token(&self) -> &str {
|
|
54
|
+
match self {
|
|
55
|
+
Self::ListAgents { token, .. } | Self::Connect { token, .. } => token,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
61
|
+
pub struct BridgeResponse {
|
|
62
|
+
pub v: u32,
|
|
63
|
+
pub ok: bool,
|
|
64
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
65
|
+
pub agents: Option<Vec<AgentInfo>>,
|
|
66
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
67
|
+
pub error: Option<String>,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl BridgeResponse {
|
|
71
|
+
pub fn ok() -> Self {
|
|
72
|
+
Self {
|
|
73
|
+
v: PROTOCOL_VERSION,
|
|
74
|
+
ok: true,
|
|
75
|
+
agents: None,
|
|
76
|
+
error: None,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pub fn agents() -> Self {
|
|
81
|
+
Self {
|
|
82
|
+
v: PROTOCOL_VERSION,
|
|
83
|
+
ok: true,
|
|
84
|
+
agents: Some(vec![AgentInfo {
|
|
85
|
+
name: FORGE_AGENT_NAME.to_string(),
|
|
86
|
+
display_name: "Forge Companion".to_string(),
|
|
87
|
+
wire: AgentWire::HttpJson,
|
|
88
|
+
available: true,
|
|
89
|
+
}]),
|
|
90
|
+
error: None,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
pub fn error(error: impl Into<String>) -> Self {
|
|
95
|
+
Self {
|
|
96
|
+
v: PROTOCOL_VERSION,
|
|
97
|
+
ok: false,
|
|
98
|
+
agents: None,
|
|
99
|
+
error: Some(error.into()),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
105
|
+
pub struct ForgeHttpRequest {
|
|
106
|
+
pub v: u32,
|
|
107
|
+
pub method: String,
|
|
108
|
+
pub path: String,
|
|
109
|
+
#[serde(default)]
|
|
110
|
+
pub headers: Vec<HeaderPair>,
|
|
111
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
112
|
+
pub body_base64: Option<String>,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
116
|
+
pub struct ForgeHttpResponse {
|
|
117
|
+
pub v: u32,
|
|
118
|
+
pub status: u16,
|
|
119
|
+
#[serde(default)]
|
|
120
|
+
pub headers: Vec<HeaderPair>,
|
|
121
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
122
|
+
pub body_base64: Option<String>,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
126
|
+
pub struct HeaderPair {
|
|
127
|
+
pub name: String,
|
|
128
|
+
pub value: String,
|
|
129
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/gamification-previews/dramatic-smithie-item-unlock-streaks-molten-crown-fire.webp
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/index.html
CHANGED
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
/>
|
|
14
14
|
<link rel="icon" type="image/png" href="/forge/assets/favicon-BCHm9dUV.ico" />
|
|
15
15
|
<link rel="alternate icon" href="/forge/assets/favicon-BCHm9dUV.ico" />
|
|
16
|
-
<script type="module" crossorigin src="/forge/assets/index-
|
|
17
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/vendor-
|
|
18
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/board-
|
|
19
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/ui-
|
|
20
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/motion-
|
|
21
|
-
<link rel="modulepreload" crossorigin href="/forge/assets/table-
|
|
16
|
+
<script type="module" crossorigin src="/forge/assets/index-DoHjjze2.js"></script>
|
|
17
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/vendor-BdrT2htV.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/board-DUwMfZvN.js">
|
|
19
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/ui-CJPaElbj.js">
|
|
20
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/motion-Crg3QyXD.js">
|
|
21
|
+
<link rel="modulepreload" crossorigin href="/forge/assets/table-CTlDeYRs.js">
|
|
22
22
|
<link rel="stylesheet" crossorigin href="/forge/assets/vendor-DT3pnAKJ.css">
|
|
23
|
-
<link rel="stylesheet" crossorigin href="/forge/assets/index-
|
|
23
|
+
<link rel="stylesheet" crossorigin href="/forge/assets/index-B9VOpR7r.css">
|
|
24
24
|
</head>
|
|
25
25
|
<body class="bg-canvas text-ink antialiased">
|
|
26
26
|
<div id="root"></div>
|