Skip to content

Commit 4b1fef6

Browse files
feat(sparsekernel): add artifact api endpoints
1 parent fa6a1ad commit 4b1fef6

7 files changed

Lines changed: 423 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sparsekernel-cli/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ repository.workspace = true
77
rust-version.workspace = true
88

99
[dependencies]
10+
base64 = "0.22"
1011
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
1112
clap = { version = "4", features = ["derive"] }
1213
serde = { version = "1", features = ["derive"] }
1314
serde_json = "1"
1415
sparsekernel-core = { path = "../sparsekernel-core" }
1516
tiny_http = "0.12"
1617

18+
[dev-dependencies]
19+
tempfile = "3"
20+
1721
[[bin]]
1822
name = "sparsekernel"
1923
path = "src/bin/sparsekernel.rs"

crates/sparsekernel-cli/src/lib.rs

Lines changed: 262 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
12
use chrono::Utc;
23
use clap::{Args, Parser, Subcommand};
34
use serde::Deserialize;
45
use serde_json::{json, Value};
56
use sparsekernel_core::{
6-
CapabilityCheck, EnqueueTaskInput, GrantCapabilityInput, SparseKernelDb, SparseKernelPaths,
7+
ArtifactStore, CapabilityCheck, EnqueueTaskInput, GrantCapabilityInput, SparseKernelDb,
8+
SparseKernelPaths,
79
};
810
use std::error::Error;
911
use std::net::ToSocketAddrs;
12+
use std::path::{Path, PathBuf};
1013
use tiny_http::{Header, Response, Server};
1114

1215
#[derive(Debug, Parser)]
@@ -261,6 +264,78 @@ pub fn handle_api_request(
261264
method: &str,
262265
url: &str,
263266
body: &[u8],
267+
) -> Result<ApiReply, Box<dyn Error>> {
268+
handle_api_request_with_artifact_root(db, method, url, body, None)
269+
}
270+
271+
#[derive(Debug, Deserialize)]
272+
struct ArtifactSubject {
273+
subject_type: String,
274+
subject_id: String,
275+
permission: Option<String>,
276+
}
277+
278+
#[derive(Debug, Deserialize)]
279+
struct CreateArtifactRequest {
280+
content_base64: Option<String>,
281+
content_text: Option<String>,
282+
mime_type: Option<String>,
283+
retention_policy: Option<String>,
284+
subject: Option<ArtifactSubject>,
285+
}
286+
287+
#[derive(Debug, Deserialize)]
288+
struct ArtifactAccessRequest {
289+
id: String,
290+
subject: Option<ArtifactSubject>,
291+
}
292+
293+
fn artifact_root_path(override_root: Option<&Path>) -> PathBuf {
294+
override_root
295+
.map(Path::to_path_buf)
296+
.unwrap_or_else(|| SparseKernelPaths::from_env().artifact_root)
297+
}
298+
299+
fn decode_artifact_content(input: &CreateArtifactRequest) -> Result<Vec<u8>, Box<dyn Error>> {
300+
match (&input.content_base64, &input.content_text) {
301+
(Some(_), Some(_)) => Err("pass only one of content_base64 or content_text".into()),
302+
(Some(raw), None) => Ok(BASE64_STANDARD.decode(raw)?),
303+
(None, Some(text)) => Ok(text.as_bytes().to_vec()),
304+
(None, None) => Err("content_base64 or content_text is required".into()),
305+
}
306+
}
307+
308+
fn require_artifact_capability(
309+
db: &SparseKernelDb,
310+
subject: &ArtifactSubject,
311+
artifact_id: Option<&str>,
312+
action: &str,
313+
) -> Result<(), Box<dyn Error>> {
314+
let allowed = db.check_capability(CapabilityCheck {
315+
subject_type: subject.subject_type.clone(),
316+
subject_id: subject.subject_id.clone(),
317+
resource_type: "artifact".to_string(),
318+
resource_id: artifact_id.map(str::to_string),
319+
action: action.to_string(),
320+
context: None,
321+
audit_denied: true,
322+
})?;
323+
if allowed {
324+
return Ok(());
325+
}
326+
Err(format!(
327+
"{} {} lacks artifact {action} capability",
328+
subject.subject_type, subject.subject_id
329+
)
330+
.into())
331+
}
332+
333+
pub fn handle_api_request_with_artifact_root(
334+
db: &mut SparseKernelDb,
335+
method: &str,
336+
url: &str,
337+
body: &[u8],
338+
artifact_root: Option<&Path>,
264339
) -> Result<ApiReply, Box<dyn Error>> {
265340
let reply = match (method, url) {
266341
("GET", "/health") => ApiReply {
@@ -375,6 +450,78 @@ pub fn handle_api_request(
375450
)?,
376451
}
377452
}
453+
("POST", "/artifacts/create") => {
454+
let input: CreateArtifactRequest = parse_body(body)?;
455+
let bytes = decode_artifact_content(&input)?;
456+
if let Some(subject) = &input.subject {
457+
require_artifact_capability(db, subject, None, "write")?;
458+
}
459+
let store = ArtifactStore::new(db, artifact_root_path(artifact_root));
460+
let subject = input.subject.as_ref().map(|subject| {
461+
(
462+
subject.subject_type.as_str(),
463+
subject.subject_id.as_str(),
464+
subject.permission.as_deref().unwrap_or("read"),
465+
)
466+
});
467+
ApiReply {
468+
status_code: 200,
469+
body: serde_json::to_value(store.write(
470+
&bytes,
471+
input.mime_type.as_deref(),
472+
input.retention_policy.as_deref(),
473+
subject,
474+
)?)?,
475+
}
476+
}
477+
("POST", "/artifacts/read") => {
478+
let input: ArtifactAccessRequest = parse_body(body)?;
479+
if let Some(subject) = &input.subject {
480+
require_artifact_capability(db, subject, Some(&input.id), "read")?;
481+
}
482+
let store = ArtifactStore::new(db, artifact_root_path(artifact_root));
483+
let subject = input.subject.as_ref().map(|subject| {
484+
(
485+
subject.subject_type.as_str(),
486+
subject.subject_id.as_str(),
487+
subject.permission.as_deref().unwrap_or("read"),
488+
)
489+
});
490+
let bytes = store.read(&input.id, subject)?;
491+
ApiReply {
492+
status_code: 200,
493+
body: json!({
494+
"artifact": db.get_artifact(&input.id)?,
495+
"content_base64": BASE64_STANDARD.encode(bytes),
496+
}),
497+
}
498+
}
499+
("POST", "/artifacts/metadata") => {
500+
let input: ArtifactAccessRequest = parse_body(body)?;
501+
if let Some(subject) = &input.subject {
502+
require_artifact_capability(db, subject, Some(&input.id), "read")?;
503+
if !db.has_artifact_access(
504+
&input.id,
505+
&subject.subject_type,
506+
&subject.subject_id,
507+
subject.permission.as_deref().unwrap_or("read"),
508+
)? {
509+
db.record_audit(sparsekernel_core::AuditInput {
510+
actor_type: Some(subject.subject_type.clone()),
511+
actor_id: Some(subject.subject_id.clone()),
512+
action: "artifact_access.denied".to_string(),
513+
object_type: Some("artifact".to_string()),
514+
object_id: Some(input.id.clone()),
515+
payload: Some(json!({ "permission": subject.permission.as_deref().unwrap_or("read") })),
516+
})?;
517+
return Err(format!("artifact access denied: {}", input.id).into());
518+
}
519+
}
520+
ApiReply {
521+
status_code: 200,
522+
body: serde_json::to_value(db.get_artifact(&input.id)?)?,
523+
}
524+
}
378525
("GET", "/audit") => ApiReply {
379526
status_code: 200,
380527
body: serde_json::to_value(db.list_audit(100)?)?,
@@ -431,6 +578,24 @@ mod tests {
431578
.body
432579
}
433580

581+
fn json_call_with_artifact_root(
582+
db: &mut SparseKernelDb,
583+
artifact_root: &Path,
584+
method: &str,
585+
url: &str,
586+
body: Value,
587+
) -> Value {
588+
handle_api_request_with_artifact_root(
589+
db,
590+
method,
591+
url,
592+
serde_json::to_string(&body).unwrap().as_bytes(),
593+
Some(artifact_root),
594+
)
595+
.unwrap()
596+
.body
597+
}
598+
434599
#[test]
435600
fn task_api_enqueues_claims_and_completes() {
436601
let mut db = SparseKernelDb::open(":memory:").unwrap();
@@ -516,4 +681,100 @@ mod tests {
516681
);
517682
assert_eq!(revoked["revoked"], true);
518683
}
684+
685+
#[test]
686+
fn artifact_api_creates_reads_and_checks_access() {
687+
let mut db = SparseKernelDb::open(":memory:").unwrap();
688+
let root = tempfile::tempdir().unwrap();
689+
db.grant_capability(GrantCapabilityInput {
690+
subject_type: "agent".to_string(),
691+
subject_id: "main".to_string(),
692+
resource_type: "artifact".to_string(),
693+
resource_id: None,
694+
action: "write".to_string(),
695+
constraints: None,
696+
expires_at: None,
697+
})
698+
.unwrap();
699+
700+
let created = json_call_with_artifact_root(
701+
&mut db,
702+
root.path(),
703+
"POST",
704+
"/artifacts/create",
705+
json!({
706+
"content_text": "hello",
707+
"mime_type": "text/plain",
708+
"retention_policy": "session",
709+
"subject": {
710+
"subject_type": "agent",
711+
"subject_id": "main",
712+
"permission": "read",
713+
},
714+
}),
715+
);
716+
let artifact_id = created["id"].as_str().unwrap().to_string();
717+
let read_without_capability = handle_api_request_with_artifact_root(
718+
&mut db,
719+
"POST",
720+
"/artifacts/read",
721+
serde_json::to_string(&json!({
722+
"id": artifact_id,
723+
"subject": {
724+
"subject_type": "agent",
725+
"subject_id": "main",
726+
"permission": "read",
727+
},
728+
}))
729+
.unwrap()
730+
.as_bytes(),
731+
Some(root.path()),
732+
);
733+
assert!(read_without_capability.is_err());
734+
735+
db.grant_capability(GrantCapabilityInput {
736+
subject_type: "agent".to_string(),
737+
subject_id: "main".to_string(),
738+
resource_type: "artifact".to_string(),
739+
resource_id: Some(artifact_id.clone()),
740+
action: "read".to_string(),
741+
constraints: None,
742+
expires_at: None,
743+
})
744+
.unwrap();
745+
let metadata = json_call_with_artifact_root(
746+
&mut db,
747+
root.path(),
748+
"POST",
749+
"/artifacts/metadata",
750+
json!({
751+
"id": artifact_id,
752+
"subject": {
753+
"subject_type": "agent",
754+
"subject_id": "main",
755+
"permission": "read",
756+
},
757+
}),
758+
);
759+
assert_eq!(metadata["mime_type"], "text/plain");
760+
761+
let read = json_call_with_artifact_root(
762+
&mut db,
763+
root.path(),
764+
"POST",
765+
"/artifacts/read",
766+
json!({
767+
"id": metadata["id"],
768+
"subject": {
769+
"subject_type": "agent",
770+
"subject_id": "main",
771+
"permission": "read",
772+
},
773+
}),
774+
);
775+
let decoded = BASE64_STANDARD
776+
.decode(read["content_base64"].as_str().unwrap())
777+
.unwrap();
778+
assert_eq!(decoded, b"hello");
779+
}
519780
}

docs/architecture/artifact-store.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ Retention policies:
2525
- `debug`
2626

2727
Artifact reads and writes are capability-mediated where the caller is not the trusted runtime. Access grants are recorded in `artifact_access`.
28+
29+
The v0 `sparsekerneld` API exposes artifact create/read/metadata endpoints over local JSON. Binary content is transported as base64 in the API; the daemon writes the bytes to the content-addressed store and records only metadata, permissions, and audit events in SQLite.

docs/architecture/sparsekernel.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ Small machines can keep many logical agents parked in durable state, but they ca
4242

4343
## Local API
4444

45-
The v0 daemon exposes localhost JSON endpoints for health/status, task enqueue/claim/heartbeat/complete/fail, expired lease release, capability grant/check/list/revoke, task listing, and audit listing. The TypeScript client uses those endpoints instead of opening the SQLite file directly.
45+
The v0 daemon exposes localhost JSON endpoints for health/status, task enqueue/claim/heartbeat/complete/fail, expired lease release, artifact create/read/metadata, capability grant/check/list/revoke, task listing, and audit listing. The TypeScript client uses those endpoints instead of opening the SQLite file directly.
4646

4747
The API is intentionally narrow. Agents and adapters should call the daemon or typed core APIs; they should not read or mutate the ledger with raw SQL.
4848

4949
## Current limitations
5050

51-
V0 proves the foundation. It does not implement real Playwright browser process pooling, production sandbox backends, egress proxy enforcement, plugin subprocess isolation, artifact binary upload/download endpoints, or an OpenClaw-wide rewrite.
51+
V0 proves the foundation. It does not implement real Playwright browser process pooling, production sandbox backends, egress proxy enforcement, plugin subprocess isolation, streaming artifact transfer, or an OpenClaw-wide rewrite.

0 commit comments

Comments
 (0)