Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/plugin-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ pub fn valid_id(id: &str) -> Result<()> {
}
pub fn bundled_manifests() -> Vec<Manifest> {
vec![
serde_json::from_str(include_str!(
"../../../src/bundled/agent-activity/manifest.json"
))
.expect("agent activity manifest"),
serde_json::from_str(include_str!("../../../src/bundled/terminal/manifest.json"))
.expect("terminal manifest"),
serde_json::from_str(include_str!("../../../src/bundled/profiles/manifest.json"))
Expand Down
39 changes: 39 additions & 0 deletions dev/agent-observer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { getPublicKey, nip44 } from "nostr-tools";
import { eventDto } from "../src/features/relay/events.ts";
import {
OBSERVER_KIND,
observerFrame,
} from "../src/features/agents/observer.ts";

/** Purpose-bound, synchronous decode in the existing key-owning host only.
* Relay admission establishes the agent/owner relationship; tags alone do not. */
export function decodeAgentObserver(input, secret, viewer) {
if (getPublicKey(secret) !== viewer)
throw new Error("Observer viewer changed");
const event = eventDto(input);
const exact = (name, value) => {
const tags = event.tags.filter((tag) => tag[0] === name);
return tags.length === 1 && tags[0].length === 2 && tags[0][1] === value;
};
if (
event.kind !== OBSERVER_KIND ||
!exact("p", viewer) ||
!exact("agent", event.pubkey) ||
!exact("frame", "telemetry") ||
event.content.length < 132 ||
event.content.length > 87472 ||
Math.abs(event.created_at - Math.floor(Date.now() / 1000)) > 300
)
throw new Error("Invalid observer envelope");
const key = nip44.v2.utils.getConversationKey(secret, event.pubkey);
try {
return observerFrame({
id: event.id,
agent: event.pubkey,
createdAt: event.created_at,
plaintext: nip44.v2.decrypt(event.content, key),
});
} finally {
key.fill(0);
}
}
75 changes: 75 additions & 0 deletions dev/agent-observer.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, expect } from "vitest";
import {
finalizeEvent,
generateSecretKey,
getPublicKey,
nip44,
} from "nostr-tools";
import { decodeAgentObserver } from "./agent-observer.mjs";

const owner = generateSecretKey(),
agent = generateSecretKey(),
stranger = generateSecretKey();
const viewer = getPublicKey(owner),
sender = getPublicKey(agent);
const raw = JSON.stringify({
kind: "acp_read",
channelId: null,
sessionId: null,
turnId: null,
seq: 1,
timestamp: new Date().toISOString(),
payload: { text: "<script>not markup</script>" },
});
function frame(patch = {}, plaintext = raw) {
return finalizeEvent(
{
kind: 24200,
created_at: Math.floor(Date.now() / 1000),
tags: [
["p", viewer],
["agent", sender],
["frame", "telemetry"],
],
content: nip44.v2.encrypt(
plaintext,
nip44.v2.utils.getConversationKey(agent, viewer),
),
...patch,
},
agent,
);
}
test("purpose-bound host decoder preserves raw JSON and never returns keys", () => {
const event = frame();
expect(decodeAgentObserver(event, owner, viewer)).toEqual({
id: event.id,
agent: sender,
createdAt: event.created_at,
plaintext: raw,
});
});
test("rejects signature, recipient, sender, direction, cardinality, freshness, content and captured-viewer violations", () => {
const tags = frame().tags;
const invalid = [
{ ...frame(), sig: "0".repeat(128) },
frame({ kind: 9 }),
frame({ tags: [["p", getPublicKey(stranger)], ...tags.slice(1)] }),
frame({ tags: [tags[0], ["agent", viewer], tags[2]] }),
frame({ tags: [tags[0], tags[1], ["frame", "control"]] }),
...tags.map((tag) => frame({ tags: [...tags, tag] })),
frame({ tags: [["p", viewer, "extra"], ...tags.slice(1)] }),
frame({ content: "x" }),
frame({ content: "x".repeat(87473) }),
frame({ created_at: Math.floor(Date.now() / 1000) - 301 }),
frame({ created_at: Math.floor(Date.now() / 1000) + 301 }),
frame({}, "not JSON"),
];
for (const event of invalid)
expect(() => decodeAgentObserver(event, owner, viewer)).toThrow();
expect(() => decodeAgentObserver(frame(), stranger, viewer)).toThrow();
// Cached verification symbols from finalizeEvent must not bypass verification.
const cached = frame();
cached.content = frame({}, "{}").content;
expect(() => decodeAgentObserver(cached, owner, viewer)).toThrow();
});
Loading
Loading