clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat: add native tinyagent connector and Hermes lab

Dami committed 3e68ec9
Add a Tiny signing helper and a standard Hermes platform plugin with native questions, approvals, mentions and media. Add a disposable Compose lab against pinned, unchanged mainline Hermes, and render validated choices in chat activity.
commit
3e68ec9ae2369ca84045b8689fe90c479d93ed0a
parent
ca0878fb21756636b0f54ac31ca938c40e901b51
author
Dami <dami.etoile@gmail.com>
41 files changed, +3830 -30
MMakefile7+-
MREADME.md7+-
Mdocs/extensions/README.md1+
Adocs/extensions/tinyagent.md20+
Minternal/daemon/approvals.go166+++++++-
Minternal/daemon/approvals_test.go84++++
Alabs/hermes/Dockerfile70+++
Alabs/hermes/Dockerfile.dockerignore10+
Alabs/hermes/README.md44++
Alabs/hermes/bootstrap.py91++++
Alabs/hermes/compose.yaml126++++++
Alabs/hermes/health_hermes.py10+
Alabs/hermes/labctl.py37++
Alabs/hermes/model.py179+++++++++
Alabs/hermes/rpc.py95+++++
Alabs/hermes/smoke.py122++++++
Alabs/hermes/source_check.py32++
Alabs/hermes/start_hermes.py18+
Alabs/hermes/start_tiny.py12+
Alabs/hermes/test_model.py81++++
Mscripts/chat-activity.test.mjs44++
Atinyagent/README.md44++
Atinyagent/client/client.go627++++++++++++++++++++++++++++++
Atinyagent/client/client_test.go176++++++++
Atinyagent/hermes/__init__.py5+
Atinyagent/hermes/adapter.py343++++++++++++++++
Atinyagent/hermes/client.py140+++++++
Atinyagent/hermes/ingress.py158++++++++
Atinyagent/hermes/interactions.py156+++++++
Atinyagent/hermes/media.py87++++
Atinyagent/hermes/plugin.yaml24+
Atinyagent/hermes/tests/test_adapter.py245++++++++++++
Atinyagent/hermes/tests/test_ingress.py62+++
Atinyagent/hermes/tests/test_media.py66+++
Atinyagent/main.go350+++++++++++++++++
Atinyagent/main_test.go46++
Mtinyclient/chat-activity.go31+-
Mtinyclient/chat-activity.html4+-
Mtinyclient/chat-activity.js20+-
Mtinyclient/chat_activity_http_test.go6+-
Mtinyclient/chat_activity_test.go14+
M Makefile +5 -2
M README.md +5 -2
@@ -21,7 +21,7 @@ flowchart LR
2121 2222 ## Components2323 24-The repository contains four executables in one Go module:24+The repository contains five executables in one Go module:2525 2626 | Component | Build target | Role |2727 | --- | --- | --- |
@@ -29,11 +29,14 @@ The repository contains four executables in one Go module:
2929 | `tiny` | `make build-tiny` | Combined host with the relay, Git hosting and browser interface. |3030 | [`tinygit`](tinygit/README.md) | `make build-tinygit` | Standalone public Git host using signed repository metadata and refs. |3131 | [`tinyclient`](tinyclient/README.md) | `make build-tinyclient` | Standalone frontend connected to the combined `tiny` backend. |32+| [`tinyagent`](tinyagent/README.md) | `make build-tinyagent` | Native Tiny connection for Hermes, with a signing helper and platform plugin. |3233 33-`make build` writes all four binaries to `bin/`. Go builds embed the checked-in browser assets and do not require Node.js. The integrated `tiny` command, wire formats and existing tenant data remain compatible. Standalone components have narrower capabilities: tinygit does not start a relay or private-repository service, tinyclient needs a backend and tinyrelay serves the Nostr protocol without the web UI or Git services.34+`make build` writes all five binaries to `bin/`. Go builds embed the checked-in browser assets and do not require Node.js. The integrated `tiny` command, wire formats and existing tenant data remain compatible. Standalone components have narrower capabilities: tinygit does not start a relay or private-repository service, tinyclient needs a backend and tinyrelay serves the Nostr protocol without the web UI or Git services.3435 3536 Reusable `protocol/nostr` and `protocol/auth` packages supply wire values and proof verification without importing host services. Feature semantics and access policy remain separate. See [Architecture](docs/architecture.md), [Module boundaries and protocol contracts](docs/module-contracts.md) and the [extension catalog](docs/extensions/README.md) for ownership, supported profiles and remaining seams.3637 38+Try the [disposable Hermes lab](labs/hermes/README.md) to exercise the `tiny` connection against unchanged mainline Hermes.39+3740 ## Run locally3841 3942 Build and start the daemon:
M docs/extensions/README.md +1 -0
A docs/extensions/tinyagent.md +20 -0
@@ -0,0 +1,20 @@
1+# Tiny agent interactions2+3+Tiny agent interactions preserve a small set of structured choices in an ordinary room request. They are intended for an agent platform adapter that needs an inline answer while keeping the relay compatible with standard Nostr clients.4+5+A native request remains a kind 9 room message and carries the usual `h` and `p` tags, plus:6+7+```json8+["request", "question"],9+["tinyagent", "1"],10+["interaction", "question"],11+["selection", "single"],12+["option", "continue", "Continue playing"],13+["option", "stop", "Stop here"]14+```15+16+Use `selection=multiple` for checkboxes. Use `selection=text` for an open-ended question with no options. Add `freeform=true` to an option question to offer a text answer alongside its choices. Include `subject` and `expiration` when useful. The relay accepts at most 12 options. An option id is 1 to 64 characters and its label is 1 to 200 characters.17+18+The `p` tags identify the people who may answer. Native notifications are sent only to asked people also named by a `mention` tag. This keeps an agent's pending card visible without waking a device unless the agent explicitly mentions that person.19+20+Answers are kind 1111 NIP-22 replies naming the request. For a single selection, the content is the selected option id. For multiple selections, the content is a JSON array of option ids. A text answer uses the plain text content for `selection=text`, or `{"text":"..."}` when `freeform=true` accompanies options. The relay settles only answers from asked keys, before expiration, whose ids match the request. Older requests without the native tags retain the existing approve, deny and freeform reply behavior.
M internal/daemon/approvals.go +149 -17
@@ -8,6 +8,7 @@ package daemon
88 99 import (1010 "context"11+ "encoding/json"1112 "sort"1213 "strconv"1314 "strings"
@@ -23,9 +24,55 @@ const (
2324 kindChatMessage = 92425 kindThreadRoot = 112526 // approvalScan bounds how many stored events one listing walks.26- approvalScan = 100027+ approvalScan = 100028+ nativeMaxOptions = 122729 )2830 31+type nativeOption struct {32+ ID string `json:"id"`33+ Label string `json:"label"`34+}35+36+func nativeInteraction(e event.Event) ([]nativeOption, string, bool) {37+ interaction := event.Tag(e, "interaction")38+ if event.Tag(e, "tinyagent") != "1" || (interaction != "question" && interaction != "approval" && interaction != "confirmation") {39+ return nil, "", false40+ }41+ selection := event.Tag(e, "selection")42+ if selection != "single" && selection != "multiple" && selection != "text" {43+ return nil, "", false44+ }45+ freeform := event.Tag(e, "freeform") == "true"46+ if (interaction == "approval" || interaction == "confirmation") && (selection == "text" || freeform) {47+ return nil, "", false48+ }49+ if (interaction == "approval" || interaction == "confirmation") && selection != "single" {50+ return nil, "", false51+ }52+ options := make([]nativeOption, 0, nativeMaxOptions)53+ for _, tag := range e.Tags {54+ if len(tag) != 3 || tag[0] != "option" || len(tag[1]) == 0 || len(tag[1]) > 64 || len(tag[2]) == 0 || len(tag[2]) > 200 {55+ if len(tag) > 0 && tag[0] == "option" {56+ return nil, "", false57+ }58+ continue59+ }60+ for _, option := range options {61+ if option.ID == tag[1] {62+ return nil, "", false63+ }64+ }65+ options = append(options, nativeOption{ID: tag[1], Label: tag[2]})66+ if len(options) > nativeMaxOptions {67+ return nil, "", false68+ }69+ }70+ if selection == "text" {71+ return nil, selection, len(options) == 072+ }73+ return options, selection, len(options) > 074+}75+2976 // approvalTypes is the bounded vocabulary of the request tag.3077 var approvalTypes = []string{"approve", "decide", "question", "grant"}3178 var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot, event.KIND_THREAD_REPLY}
@@ -37,22 +84,26 @@ const (
3784 3885 // approvalItem is one request as the asked person sees it.3986 type approvalItem struct {40- ID string `json:"id"`41- Kind int `json:"kind"`42- Type string `json:"type"`43- Asker string `json:"asker"`44- Asked []string `json:"asked"`45- Subject string `json:"subject,omitempty"`46- Content string `json:"content"`47- CreatedAt int64 `json:"created_at"`48- Expires int64 `json:"expires,omitempty"`49- State string `json:"state"`50- Room string `json:"room,omitempty"`51- About *approvalAbout `json:"about,omitempty"`52- Answer *approvalAnswer `json:"answer,omitempty"`53- Event event.Event `json:"event"`54- Grant *community.AgentGrantReview `json:"grant,omitempty"`55- GrantError string `json:"grant_error,omitempty"`87+ ID string `json:"id"`88+ Kind int `json:"kind"`89+ Type string `json:"type"`90+ Asker string `json:"asker"`91+ Asked []string `json:"asked"`92+ Subject string `json:"subject,omitempty"`93+ Content string `json:"content"`94+ CreatedAt int64 `json:"created_at"`95+ Expires int64 `json:"expires,omitempty"`96+ State string `json:"state"`97+ Room string `json:"room,omitempty"`98+ About *approvalAbout `json:"about,omitempty"`99+ Answer *approvalAnswer `json:"answer,omitempty"`100+ Event event.Event `json:"event"`101+ Grant *community.AgentGrantReview `json:"grant,omitempty"`102+ GrantError string `json:"grant_error,omitempty"`103+ Options []nativeOption `json:"options,omitempty"`104+ Selection string `json:"selection,omitempty"`105+ Freeform bool `json:"freeform,omitempty"`106+ Interaction string `json:"interaction,omitempty"`56107 }57108 58109 // approvalAbout is what the request refers to: a repository coordinate, an
@@ -81,6 +132,11 @@ func approvalRequest(e event.Event) (string, bool) {
81132 if !containsInt(approvalKinds, e.Kind) {82133 return "", false83134 }135+ if event.Tag(e, "tinyagent") != "" {136+ if _, _, ok := nativeInteraction(e); !ok {137+ return "", false138+ }139+ }84140 kind := strings.ToLower(strings.TrimSpace(event.Tag(e, "request")))85141 if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 {86142 return "", false
@@ -98,6 +154,9 @@ func approvalAsked(e event.Event) []string {
98154 99155 func approvalItemFrom(e event.Event, kind string, base string) approvalItem {100156 item := approvalItem{ID: e.ID, Kind: e.Kind, Type: kind, Asker: e.PubKey, Asked: approvalAsked(e), Subject: strings.TrimSpace(event.Tag(e, "subject")), Content: e.Content, CreatedAt: e.CreatedAt, Expires: event.Expiration(e), State: "open", Room: event.Tag(e, "h"), Event: e}157+ item.Options, item.Selection, _ = nativeInteraction(e)158+ item.Freeform = event.Tag(e, "freeform") == "true"159+ item.Interaction = event.Tag(e, "interaction")101160 item.About = approvalAboutFrom(e, base)102161 return item103162 }
@@ -184,6 +243,9 @@ func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now
184243 }185244 for _, id := range event.TagValues(row, "e") {186245 if asked[id] != nil && asked[id][row.PubKey] {246+ if item := approvalItemByID(items, id); item != nil && !nativeAnswerValid(*item, row) {247+ continue248+ }187249 answers[id] = append(answers[id], row)188250 }189251 }
@@ -191,6 +253,72 @@ func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now
191253 return answers, nil192254 }193255 256+func approvalItemByID(items []approvalItem, id string) *approvalItem {257+ for i := range items {258+ if items[i].ID == id {259+ return &items[i]260+ }261+ }262+ return nil263+}264+265+func nativeAnswerValid(item approvalItem, answer event.Event) bool {266+ options, selection, ok := nativeInteraction(item.Event)267+ if !ok {268+ return true269+ }270+ if answer.Kind != kindComment || answer.CreatedAt < item.CreatedAt || item.Expires > 0 && answer.CreatedAt >= item.Expires || event.Tag(answer, "h") != event.Tag(item.Event, "h") || event.Tag(answer, "e") != item.ID || event.Tag(answer, "p") != item.Asker {271+ return false272+ }273+ if event.Tag(answer, "E") != item.ID || event.Tag(answer, "P") != item.Asker || event.Tag(answer, "K") != strconv.Itoa(item.Kind) || event.Tag(answer, "k") != strconv.Itoa(item.Kind) {274+ return false275+ }276+ if selection != "text" && item.Freeform && strings.HasPrefix(strings.TrimSpace(answer.Content), "{") {277+ var custom map[string]any278+ if json.Unmarshal([]byte(answer.Content), &custom) != nil || len(custom) != 1 {279+ return false280+ }281+ text, ok := custom["text"].(string)282+ return ok && len([]rune(strings.TrimSpace(text))) > 0 && len([]rune(text)) <= 8000283+ }284+ if selection == "text" {285+ return len([]rune(strings.TrimSpace(answer.Content))) > 0 && len([]rune(answer.Content)) <= 8000286+ }287+ if selection == "single" {288+ for _, option := range options {289+ if strings.TrimSpace(answer.Content) == option.ID {290+ return true291+ }292+ }293+ return false294+ }295+ var ids []string296+ if json.Unmarshal([]byte(answer.Content), &ids) != nil {297+ return false298+ }299+ if len(ids) == 0 || len(ids) > len(options) {300+ return false301+ }302+ seen := map[string]bool{}303+ for _, id := range ids {304+ if seen[id] {305+ return false306+ }307+ seen[id] = true308+ found := false309+ for _, option := range options {310+ if option.ID == id {311+ found = true312+ break313+ }314+ }315+ if !found {316+ return false317+ }318+ }319+ return true320+}321+194322 // approvalSettle fills the answer and state of one request from its answers,195323 // which arrive newest first.196324 func approvalSettle(item *approvalItem, rows []event.Event, now int64) {
@@ -309,7 +437,11 @@ func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice {
309437 actions = []pushAction{{Action: "review", Title: "Review access"}}310438 }311439 var notices []pushNotice440+ _, _, native := nativeInteraction(e)312441 for _, recipient := range approvalAsked(e) {442+ if native && !containsString(event.TagValues(e, "mention"), recipient) {443+ continue444+ }313445 notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions})314446 }315447 return notices
M internal/daemon/approvals_test.go +84 -0
@@ -16,6 +16,49 @@ const (
1616 approvalOtherSecret = "6666666666666666666666666666666666666666666666666666666666666666"1717 )1818 19+func TestNativeInteractionValidatesChoicesAndSelection(t *testing.T) {20+ e := event.Event{Kind: kindChatMessage, ID: strings.Repeat("e", 64), PubKey: strings.Repeat("a", 64), Tags: [][]string{{"tinyagent", "1"}, {"interaction", "question"}, {"selection", "single"}, {"option", "continue", "Continue playing"}, {"option", "stop", "Stop here"}}}21+ options, selection, ok := nativeInteraction(e)22+ if !ok || selection != "single" || len(options) != 2 || options[0].ID != "continue" {23+ t.Fatalf("native interaction = %#v, %q, %v", options, selection, ok)24+ }25+ answerTags := [][]string{{"h", ""}, {"E", e.ID}, {"K", "9"}, {"P", e.PubKey}, {"e", e.ID}, {"k", "9"}, {"p", e.PubKey}}26+ if nativeAnswerValid(approvalItem{ID: e.ID, Kind: e.Kind, Asker: e.PubKey, Event: e}, event.Event{Kind: kindComment, Content: "other", Tags: answerTags}) {27+ t.Fatal("unknown native choice accepted")28+ }29+ multiple := e30+ multiple.Tags = append([][]string(nil), e.Tags[:2]...)31+ multiple.Tags = append(multiple.Tags, []string{"selection", "multiple"}, []string{"option", "a", "A"}, []string{"option", "b", "B"})32+ if !nativeAnswerValid(approvalItem{ID: e.ID, Kind: e.Kind, Asker: e.PubKey, Event: multiple}, event.Event{Kind: kindComment, Content: `["a","b"]`, Tags: answerTags}) {33+ t.Fatal("valid multiple choice rejected")34+ }35+ if nativeAnswerValid(approvalItem{ID: e.ID, Kind: e.Kind, Asker: e.PubKey, Event: multiple}, event.Event{Kind: kindComment, Content: `["a","a"]`, Tags: answerTags}) {36+ t.Fatal("duplicate multiple choice accepted")37+ }38+}39+40+func TestNativeApprovalNotificationsRequireExplicitMention(t *testing.T) {41+ _, tenant := testTenant(t)42+ owner := tenant.Policy().Owner43+ asker := wikiKey(t, approvalAskerSecret)44+ base := [][]string{{"request", "question"}, {"tinyagent", "1"}, {"interaction", "question"}, {"selection", "single"}, {"option", "yes", "Yes"}, {"p", owner}, {"h", tenant.meta.Name}}45+ request := event.Event{Kind: kindChatMessage, PubKey: asker, Tags: base, Content: "Choose"}46+ if notices := tenant.pushNotices(context.Background(), request); len(notices) != 0 {47+ t.Fatalf("unmentioned native request notified: %+v", notices)48+ }49+ request.Tags = append(request.Tags, []string{"mention", owner})50+ if notices := tenant.pushNotices(context.Background(), request); len(notices) != 1 || notices[0].category != pushApprovals {51+ t.Fatalf("mentioned native request notices: %+v", notices)52+ }53+}54+55+func TestMalformedNativeMarkerIsNotLegacyApproval(t *testing.T) {56+ e := event.Event{Kind: kindChatMessage, Tags: [][]string{{"tinyagent", "1"}, {"request", "approve"}, {"p", strings.Repeat("a", 64)}}}57+ if _, ok := approvalRequest(e); ok {58+ t.Fatal("malformed native request fell back to legacy approval")59+ }60+}61+1962 func approvalCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) (map[string]any, error) {2063 t.Helper()2164 raw, err := json.Marshal(params)
@@ -296,3 +339,44 @@ func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) {
296339 t.Fatalf("unread after answering = %d", got)297340 }298341 }342+343+func TestNativeFreeformAllowsBothOptionsAndCustomText(t *testing.T) {344+ for _, selection := range []string{"single", "multiple", "text"} {345+ t.Run(selection, func(t *testing.T) {346+ e := event.Event{Kind: 9, ID: strings.Repeat("a", 64), PubKey: strings.Repeat("b", 64), CreatedAt: 100,347+ Tags: [][]string{{"h", "lab"}, {"tinyagent", "1"}, {"interaction", "question"}, {"selection", selection}, {"freeform", "true"}, {"expiration", "200"}}}348+ if selection != "text" {349+ e.Tags = append(e.Tags, []string{"option", "c0", "Continue"})350+ }351+ item := approvalItemFrom(e, "question", "")352+ answer := event.Event{Kind: 1111, CreatedAt: 110, Tags: [][]string{{"h", "lab"}, {"e", e.ID}, {"E", e.ID}, {"p", e.PubKey}, {"P", e.PubKey}, {"k", "9"}, {"K", "9"}}}353+ valid := []string{`{"text":"custom"}`, "c0"}354+ invalid := []string{`{"text":"custom","extra":true}`, "unknown", `[]`}355+ if selection == "multiple" {356+ valid[1] = `["c0"]`357+ invalid = append(invalid, `["c0","c0"]`)358+ }359+ if selection == "text" {360+ valid = []string{"free text"}361+ invalid = []string{"", " "}362+ }363+ for _, content := range valid {364+ answer.Content = content365+ if !nativeAnswerValid(item, answer) {366+ t.Errorf("valid answer rejected: %s", content)367+ }368+ }369+ for _, content := range invalid {370+ answer.Content = content371+ if nativeAnswerValid(item, answer) {372+ t.Errorf("invalid answer accepted: %s", content)373+ }374+ }375+ answer.Content = valid[0]376+ answer.CreatedAt = 200377+ if nativeAnswerValid(item, answer) {378+ t.Error("answer at expiration accepted")379+ }380+ })381+ }382+}
A labs/hermes/Dockerfile +70 -0
@@ -0,0 +1,70 @@
1+# syntax=docker/dockerfile:12+FROM golang:1.27.1-bookworm AS tiny-build3+WORKDIR /src4+COPY go.mod go.sum ./5+RUN go mod download6+COPY cmd ./cmd7+COPY internal ./internal8+COPY protocol ./protocol9+COPY tinyrelay ./tinyrelay10+COPY tinygit ./tinygit11+COPY tinyclient ./tinyclient12+COPY tinyagent/*.go ./tinyagent/13+COPY tinyagent/client ./tinyagent/client14+RUN CGO_ENABLED=0 go build -mod=readonly -trimpath -o /out/tiny ./cmd/tiny && \15+ CGO_ENABLED=0 go build -mod=readonly -trimpath -o /out/tinyagent ./tinyagent16+17+FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie AS hermes-base18+ARG HERMES_REVISION=1ad89ac018f26a4f21817ebf37bb09f508656d6319+ARG HERMES_ARCHIVE_SHA256=388837b0bacb37381aafeb018543accc398f9c3e88931859f8f7a35c999d4a7c20+RUN apt-get update && apt-get install --no-install-recommends -y ca-certificates curl git && \21+ rm -rf /var/lib/apt/lists/*22+WORKDIR /opt/hermes23+RUN curl --fail --location --retry 3 \24+ "https://codeload.github.com/NousResearch/hermes-agent/tar.gz/${HERMES_REVISION}" -o /tmp/hermes.tar.gz && \25+ printf '%s /tmp/hermes.tar.gz\n' "$HERMES_ARCHIVE_SHA256" | sha256sum -c - && \26+ tar -xzf /tmp/hermes.tar.gz --strip-components=1 -C /opt/hermes && \27+ rm /tmp/hermes.tar.gz && \28+ printf '%s\n' "$HERMES_REVISION" > /opt/hermes-revision29+COPY labs/hermes/source_check.py /lab/source_check.py30+RUN python /lab/source_check.py record && \31+ uv sync --frozen --no-dev --no-install-project --python /usr/local/bin/python3.13 && \32+ python /lab/source_check.py verify33+ENV PATH="/opt/hermes/.venv/bin:$PATH" \34+ PYTHONPATH=/opt/hermes \35+ PYTHONUNBUFFERED=1 \36+ PYTHONDONTWRITEBYTECODE=1 \37+ HERMES_HOME=/home/hermes/.hermes38+RUN useradd --create-home --uid 10001 hermes39+40+FROM hermes-base AS hermes41+COPY --from=tiny-build /out/tinyagent /usr/local/bin/tinyagent42+COPY tinyagent/hermes /opt/tinyagent43+COPY labs/hermes /lab44+USER hermes45+ENTRYPOINT ["python", "/lab/start_hermes.py"]46+47+FROM hermes AS adapter-test48+USER root49+RUN uv pip install --python /opt/hermes/.venv/bin/python pytest==9.1.1 pytest-asyncio==1.3.050+USER hermes51+ENV HERMES_HOME=/tmp/hermes-test52+ENTRYPOINT ["python", "-m", "pytest", "-q", "-p", "no:cacheprovider", "--tb=short", "/opt/tinyagent/tests"]53+54+FROM python:3.13-slim-bookworm AS tools55+COPY --from=tiny-build /out/tinyagent /usr/local/bin/tinyagent56+COPY labs/hermes /lab57+ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=158+ENTRYPOINT ["python"]59+60+FROM python:3.13-slim-bookworm AS tiny61+COPY --from=tiny-build /out/tiny /usr/local/bin/tiny62+COPY labs/hermes /lab63+RUN useradd --create-home --uid 10001 relay && mkdir /data && chown relay /data64+USER relay65+ENTRYPOINT ["python", "/lab/start_tiny.py"]66+67+FROM python:3.13-slim-bookworm AS model68+COPY labs/hermes/model.py /lab/model.py69+USER 10001:1000170+ENTRYPOINT ["python", "/lab/model.py"]
A labs/hermes/Dockerfile.dockerignore +10 -0
A labs/hermes/README.md +44 -0
@@ -0,0 +1,44 @@
1+# Hermes integration lab2+3+This disposable Compose stack tests the native `tinyagent` connector against an unchanged mainline Hermes Agent checkout. The adapter registers the Hermes platform as `tiny`. The lab pins Hermes revision `1ad89ac018f26a4f21817ebf37bb09f508656d63` and verifies that the checkout is unchanged during the image build.4+5+The stack uses a local deterministic OpenAI compatible model fixture, so it needs no model credentials or external provider. It exercises ordinary replies, native clarify and approval cards, multiple selection, custom and open-ended answers, attachment round trips, and explicit operator mentions. This verifies the real gateway and tool callbacks; the fixture does not perform open-ended reasoning. The default relay is available only on loopback at `http://127.0.0.1:18479`.6+7+From the repository root, start the lab with:8+9+```sh10+docker compose -f labs/hermes/compose.yaml up -d --build --wait11+docker compose -f labs/hermes/compose.yaml --profile test run --rm --no-deps check12+```13+14+Run the adapter tests against the pinned Hermes checkout with:15+16+```sh17+docker compose -f labs/hermes/compose.yaml --profile test run --rm --build adapter-test18+```19+20+The lab uses Hermes' standard queue mode so a new message waits for the current turn to finish. The operator helper can inspect the seeded room or send a test message:21+22+```sh23+docker compose -f labs/hermes/compose.yaml --profile tools run --rm operator help24+docker compose -f labs/hermes/compose.yaml --profile tools run --rm operator send LAB_HELLO_manual25+docker compose -f labs/hermes/compose.yaml --profile tools run --rm operator events26+```27+28+The check command prints a pass line for each scenario. To try a question manually, send `LAB_QUESTION_manual`, list requests, then answer with its event ID and an offered option ID:29+30+```sh31+docker compose -f labs/hermes/compose.yaml --profile tools run --rm operator send LAB_QUESTION_manual32+docker compose -f labs/hermes/compose.yaml --profile tools run --rm operator requests33+docker compose -f labs/hermes/compose.yaml --profile tools run --rm operator answer EVENT_ID c034+```35+36+To explore the browser interface, load the disposable operator key into your test Nostr signer. `docker compose -f labs/hermes/compose.yaml run --rm operator export-key` prints that key. The agent and operator have separate identities stored in lab volumes; no production credentials are used. Agent grants last one day. Rerun the `seed` service to renew them.37+38+Finish or cancel pending interactions before rebuilding Hermes. Callback waiters live in its process. An interrupted turn can also leave mainline Hermes waiting for a session lease on restart. To start fresh, stop the lab and remove its disposable volumes with:39+40+```sh41+docker compose -f labs/hermes/compose.yaml down -v42+```43+44+The stack models a member of one room. It does not claim direct-message support, moderator deletion, or durable interaction callbacks across a Hermes restart. Interaction waiters are process-local. Ordinary agent messages remain quiet; only an explicit `@` followed by a 64-character public key creates a notification recipient.
A labs/hermes/bootstrap.py +91 -0
@@ -0,0 +1,91 @@
1+"""Create identities and seed a disposable Tiny and Hermes workspace."""2+3+import json4+import os5+from pathlib import Path6+import subprocess7+import sys8+import time9+10+from rpc import Client, public11+12+13+def save(path, text, private=False):14+ path.write_text(text)15+ path.chmod(0o600 if private else 0o644)16+17+18+def identity():19+ return json.loads(subprocess.check_output(["tinyagent", "keygen"], text=True))20+21+22+def initialize():23+ os.umask(0o077)24+ for directory in ("/public", "/operator", "/hermes-home", "/tiny-data"):25+ Path(directory).mkdir(parents=True, exist_ok=True)26+ for name in ("owner", "observer"):27+ path = Path(f"/operator/{name}.json")28+ if not path.exists():29+ save(path, json.dumps(identity()), private=True)30+ actor = json.loads(path.read_text())31+ save(Path(f"/public/{name}.pub"), actor["pubkey"] + "\n")32+ home = Path("/hermes-home")33+ key_path = home / "tiny-identity.json"34+ if not key_path.exists():35+ save(key_path, json.dumps(identity()), private=True)36+ agent = json.loads(key_path.read_text())37+ save(Path("/public/agent.pub"), agent["pubkey"] + "\n")38+ env = {39+ "TINY_PRIVATE_KEY": agent["secret"], "TINY_RELAY_URL": "http://tiny:7447",40+ "TINY_CHANNELS": "lab", "TINY_HOME_CHANNEL": "lab", "TINY_ALLOWED_USERS": public("owner"),41+ "TINY_CLI_PATH": "/usr/local/bin/tinyagent",42+ }43+ save(home / ".env", "".join(f"{name}={value}\n" for name, value in env.items()), private=True)44+ config = {45+ "model": {"provider": "custom", "default": "tiny-lab", "base_url": "http://model:8080/v1",46+ "api_key": "lab-only", "api_mode": "chat_completions", "context_length": 131072},47+ "agent": {"max_turns": 12},48+ "display": {"busy_input_mode": "queue"},49+ "model_catalog": {"enabled": False},50+ "tools": {"tool_search": {"enabled": "off"}},51+ "platform_toolsets": {"tiny": ["clarify", "code_execution", "terminal", "file"]},52+ "platforms": {"tiny": {"enabled": True, "extra": {"rooms": "lab", "allowed_users": public("owner")}}},53+ "plugins": {"enabled": ["tinyagent"]},54+ "streaming": {"enabled": True, "edit_interval": 0.2, "buffer_threshold": 10},55+ "auxiliary": {"title_generation": {"enabled": False}},56+ "memory": {"memory_enabled": False, "user_profile_enabled": False},57+ "compression": {"enabled": False},58+ "terminal": {"backend": "local", "cwd": "/home/hermes/.hermes/workspace"},59+ }60+ # JSON is a YAML subset; no bootstrap dependency is needed.61+ save(home / "config.yaml", json.dumps(config, indent=2) + "\n", private=True)62+ (home / "workspace").mkdir(exist_ok=True)63+ for path in [home, *home.rglob("*")]:64+ if not path.is_symlink():65+ os.chown(path, 10001, 10001)66+ Path("/public").chmod(0o755)67+ os.chown("/tiny-data", 10001, 10001)68+ Path("/tiny-data").chmod(0o700)69+ print("Disposable identities and Hermes configuration are ready.")70+71+72+def seed():73+ with Client() as client:74+ client.browse("setpolicy", {"writes": "allowlist", "reads": "members",75+ "directoryPublic": False, "features": {"files": True}})76+ client.publish(0, json.dumps({"name": "Lab operator"}), [])77+ if not client.query({"kinds": [9007], "#h": ["lab"], "limit": 1}):78+ client.publish(9007, "A disposable workspace for the tinyagent Hermes connector.",79+ [["h", "lab"], ["name", "Hermes lab"], ["visibility", "open"]])80+ for actor in ("agent", "observer"):81+ key = public(actor)82+ tags = [["d", key], ["p", key], ["name", f"Lab {actor}"], ["room", "lab"],83+ ["jobs", "both"], ["expiration", str(int(time.time()) + 86400)], ["rate", "600"]]84+ tags += [["k", str(kind)] for kind in (0, 9, 11, 12, 7, 1111, 40003, 20001, 20002)]85+ client.publish(30392, "", tags)86+ client.publish(9000, "", [["h", "lab"], ["p", key, "member"]])87+ print("Lab room and one-day grants are ready.")88+89+90+if __name__ == "__main__":91+ {"init": initialize, "seed": seed}[sys.argv[1]]()
A labs/hermes/compose.yaml +126 -0
@@ -0,0 +1,126 @@
1+name: tinyhermeslab2+3+x-tools: &tools4+ image: tinyagent-lab-tools5+ build:6+ context: ../..7+ dockerfile: labs/hermes/Dockerfile8+ target: tools9+10+services:11+ init:12+ <<: *tools13+ command: ["/lab/bootstrap.py", "init"]14+ volumes:15+ - public:/public16+ - operator:/operator17+ - hermes-home:/hermes-home18+ - tiny-data:/tiny-data19+ network_mode: none20+21+ tiny:22+ image: tinyagent-lab-tiny23+ build:24+ context: ../..25+ dockerfile: labs/hermes/Dockerfile26+ target: tiny27+ depends_on:28+ init:29+ condition: service_completed_successfully30+ ports:31+ - "127.0.0.1:${TINY_LAB_PORT:-18479}:7447"32+ networks: [default, browser]33+ volumes:34+ - public:/public:ro35+ - tiny-data:/data36+ healthcheck:37+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7447/readyz', timeout=3).read()"]38+ interval: 3s39+ timeout: 5s40+ retries: 3041+42+ seed:43+ <<: *tools44+ command: ["/lab/bootstrap.py", "seed"]45+ depends_on:46+ tiny:47+ condition: service_healthy48+ volumes:49+ - public:/public:ro50+ - operator:/operator:ro51+52+ model:53+ image: tinyagent-lab-model54+ build:55+ context: ../..56+ dockerfile: labs/hermes/Dockerfile57+ target: model58+ healthcheck:59+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).read()"]60+ interval: 3s61+ timeout: 5s62+ retries: 2063+64+ hermes:65+ image: tinyagent-lab-hermes66+ build:67+ context: ../..68+ dockerfile: labs/hermes/Dockerfile69+ target: hermes70+ init: true71+ depends_on:72+ seed:73+ condition: service_completed_successfully74+ model:75+ condition: service_healthy76+ environment:77+ HERMES_HOME: /home/hermes/.hermes78+ TINY_CLI_PATH: /usr/local/bin/tinyagent79+ HERMES_DISABLE_LAZY_INSTALLS: "1"80+ volumes:81+ - hermes-home:/home/hermes/.hermes82+ stop_grace_period: 20s83+ healthcheck:84+ test: ["CMD", "python", "/lab/health_hermes.py"]85+ interval: 3s86+ timeout: 5s87+ retries: 3088+89+ check:90+ <<: *tools91+ profiles: [test]92+ command: ["/lab/smoke.py"]93+ depends_on:94+ hermes:95+ condition: service_healthy96+ volumes:97+ - public:/public:ro98+ - operator:/operator:ro99+100+ adapter-test:101+ profiles: [test]102+ build:103+ context: ../..104+ dockerfile: labs/hermes/Dockerfile105+ target: adapter-test106+ network_mode: none107+108+ operator:109+ <<: *tools110+ profiles: [tools]111+ entrypoint: ["python", "/lab/labctl.py"]112+ command: ["help"]113+ volumes:114+ - public:/public:ro115+ - operator:/operator:ro116+117+networks:118+ default:119+ internal: true120+ browser: {}121+122+volumes:123+ public:124+ operator:125+ hermes-home:126+ tiny-data:
A labs/hermes/health_hermes.py +10 -0
A labs/hermes/labctl.py +37 -0
A labs/hermes/model.py +179 -0
@@ -0,0 +1,179 @@
1+#!/usr/bin/env python32+"""Small deterministic OpenAI-compatible model used by the integration lab.3+4+It deliberately implements only the HTTP surface Hermes needs. Scenario5+markers are read from the latest user turn, so setup prompts and older turns6+cannot accidentally trigger a lab interaction.7+"""8+9+from __future__ import annotations10+11+import json12+import re13+import time14+import uuid15+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer16+from typing import Any17+18+PORT = 808019+MARKER = re.compile(r"\b(LAB_(?:HELLO|QUESTION|CUSTOM|OPEN|MULTI|APPROVAL|MENTION|MEDIA)_[A-Za-z0-9_-]+)\b")20+MENTION = re.compile(r"@([0-9a-fA-F]{64})")21+ATTACHMENT = re.compile(r"\b(tinyagent-attachment-[A-Za-z0-9._-]+)\b")22+23+24+def _text(content: Any) -> str:25+ if isinstance(content, str):26+ return content27+ if isinstance(content, list):28+ return " ".join(_text(part.get("text", "")) for part in content if isinstance(part, dict))29+ return ""30+31+32+def _scenario(messages: list[dict[str, Any]]) -> tuple[str | None, int | None]:33+ """Return the marker and the index of its user turn, if any."""34+ for index in range(len(messages) - 1, -1, -1):35+ message = messages[index]36+ if message.get("role") != "user":37+ continue38+ found = MARKER.search(_text(message.get("content")))39+ if found:40+ return found.group(1), index41+ return None, None42+ return None, None43+44+45+def _latest_user_text(messages: list[dict[str, Any]], start: int) -> str:46+ return _text(messages[start].get("content"))47+48+49+def _tool_names(tools: list[dict[str, Any]]) -> set[str]:50+ names: set[str] = set()51+ for tool in tools:52+ function = tool.get("function") if isinstance(tool, dict) else None53+ if isinstance(function, dict) and isinstance(function.get("name"), str):54+ names.add(function["name"])55+ return names56+57+58+def _tool_result(messages: list[dict[str, Any]], start: int) -> str | None:59+ for message in messages[start + 1 :]:60+ if message.get("role") == "tool":61+ return _text(message.get("content"))62+ return None63+64+65+def _response(message: dict[str, Any], *, model: str, stream: bool) -> dict[str, Any]:66+ response_id = "chatcmpl-" + uuid.uuid4().hex67+ choice = {"index": 0, "message": message, "finish_reason": "tool_calls" if message.get("tool_calls") else "stop"}68+ return {"id": response_id, "object": "chat.completion", "created": int(time.time()), "model": model,69+ "choices": [choice], "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}}70+71+72+def complete(body: dict[str, Any]) -> dict[str, Any]:73+ messages = body.get("messages") or []74+ tools = _tool_names(body.get("tools") or [])75+ marker, start = _scenario(messages)76+ model = str(body.get("model") or "tiny-lab")77+ if not marker or start is None:78+ return _response({"role": "assistant", "content": "LAB_IDLE"}, model=model, stream=False)79+ result = _tool_result(messages, start)80+ if result is not None:81+ media = re.search(r"It is saved at: (\S+)\. Its content", _latest_user_text(messages, start)) if marker.startswith("LAB_MEDIA_") else None82+ suffix = f"\nMEDIA:{media.group(1)}" if media else ""83+ return _response({"role": "assistant", "content": f"LAB_DONE {marker} {result}{suffix}"}, model=model, stream=False)84+ if marker.startswith("LAB_HELLO_"):85+ return _response({"role": "assistant", "content": f"LAB_DONE {marker}"}, model=model, stream=False)86+ latest_text = _latest_user_text(messages, start)87+ if marker.startswith("LAB_MENTION_"):88+ mentions = MENTION.findall(latest_text)89+ suffix = " @" + mentions[0] if mentions else ""90+ return _response({"role": "assistant", "content": f"LAB_DONE {marker}{suffix}"}, model=model, stream=False)91+ if marker.startswith("LAB_MEDIA_"):92+ attachment = ATTACHMENT.search(latest_text)93+ cached = re.search(r"It is saved at: (\S+)\. Its content", latest_text)94+ if cached and "read_file" in tools:95+ call = {"id": "call_" + marker.lower(), "type": "function", "function": {96+ "name": "read_file", "arguments": json.dumps({"path": cached.group(1)})}}97+ return _response({"role": "assistant", "content": None, "tool_calls": [call]}, model=model, stream=False)98+ suffix = f" MEDIA_RECEIVED {attachment.group(1)}" if attachment else " MEDIA_NOT_FOUND"99+ return _response({"role": "assistant", "content": f"LAB_DONE {marker}{suffix}"}, model=model, stream=False)100+ if marker.startswith(("LAB_QUESTION_", "LAB_MULTI_", "LAB_CUSTOM_", "LAB_OPEN_")):101+ if "clarify" not in tools:102+ return _response({"role": "assistant", "content": "LAB_ERROR clarify tool is unavailable"}, model=model, stream=False)103+ multi = marker.startswith("LAB_MULTI_")104+ choices = [] if marker.startswith("LAB_OPEN_") else ["Continue", "Stop"]105+ arguments = {"questions": [{"question": f"Choose for {marker}", "choices": choices, "multi_select": multi}]}106+ call = {"id": "call_" + marker.lower(), "type": "function", "function": {"name": "clarify", "arguments": json.dumps(arguments)}}107+ return _response({"role": "assistant", "content": None, "tool_calls": [call]}, model=model, stream=False)108+ if "execute_code" not in tools:109+ return _response({"role": "assistant", "content": "LAB_ERROR execute_code tool is unavailable"}, model=model, stream=False)110+ call = {"id": "call_" + marker.lower(), "type": "function", "function": {"name": "execute_code", "arguments": json.dumps({"code": "print('tinyagent-lab')"})}}111+ return _response({"role": "assistant", "content": None, "tool_calls": [call]}, model=model, stream=False)112+113+114+def _sse_payload(result: dict[str, Any]) -> list[str]:115+ choice = result["choices"][0]116+ message = choice["message"]117+ chunks: list[dict[str, Any]] = []118+ base = {"id": result["id"], "object": "chat.completion.chunk", "created": result["created"], "model": result["model"]}119+ delta: dict[str, Any] = {"role": "assistant"}120+ if message.get("content") is not None:121+ delta["content"] = message["content"]122+ if message.get("tool_calls"):123+ delta["tool_calls"] = [{"index": 0, **message["tool_calls"][0]}]124+ chunks.append({**base, "choices": [{"index": 0, "delta": delta, "finish_reason": None}]})125+ chunks.append({**base, "choices": [{"index": 0, "delta": {}, "finish_reason": choice["finish_reason"]}]})126+ return [f"data: {json.dumps(chunk, separators=(',', ':'))}\n\n" for chunk in chunks] + ["data: [DONE]\n\n"]127+128+129+class Handler(BaseHTTPRequestHandler):130+ server_version = "tinyagent-lab/1"131+132+ def log_message(self, _format: str, *_args: Any) -> None:133+ return134+135+ def do_GET(self) -> None: # noqa: N802136+ if self.path == "/healthz":137+ self._send(200, {"status": "ok"})138+ elif self.path == "/v1/models":139+ self._send(200, {"object": "list", "data": [{"id": "tiny-lab", "object": "model", "owned_by": "tinyagent-lab"}]})140+ else:141+ self._send(404, {"error": {"message": "not found", "type": "invalid_request_error"}})142+143+ def do_POST(self) -> None: # noqa: N802144+ if self.path != "/v1/chat/completions":145+ self._send(404, {"error": {"message": "not found", "type": "invalid_request_error"}})146+ return147+ length = int(self.headers.get("Content-Length", "0"))148+ try:149+ body = json.loads(self.rfile.read(length))150+ result = complete(body)151+ except (ValueError, TypeError, json.JSONDecodeError) as exc:152+ self._send(400, {"error": {"message": f"invalid JSON: {exc}", "type": "invalid_request_error"}})153+ return154+ if body.get("stream"):155+ payload = "".join(_sse_payload(result)).encode()156+ self.send_response(200)157+ self.send_header("Content-Type", "text/event-stream")158+ self.send_header("Cache-Control", "no-cache")159+ self.send_header("Content-Length", str(len(payload)))160+ self.end_headers()161+ self.wfile.write(payload)162+ else:163+ self._send(200, result)164+165+ def _send(self, status: int, value: dict[str, Any]) -> None:166+ payload = json.dumps(value, separators=(",", ":")).encode()167+ self.send_response(status)168+ self.send_header("Content-Type", "application/json")169+ self.send_header("Content-Length", str(len(payload)))170+ self.end_headers()171+ self.wfile.write(payload)172+173+174+def serve(port: int = PORT) -> None:175+ ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever()176+177+178+if __name__ == "__main__":179+ serve()
A labs/hermes/rpc.py +95 -0
@@ -0,0 +1,95 @@
1+"""Lab operator using the connector's public JSON-lines interface."""2+3+import json4+import os5+from pathlib import Path6+import selectors7+import subprocess8+import time9+10+11+def public(name):12+ return Path(f"/public/{name}.pub").read_text().strip()13+14+15+def tag(event, name):16+ return next((row[1] for row in event.get("tags", []) if len(row) > 1 and row[0] == name), None)17+18+19+class Client:20+ def __init__(self, actor="owner"):21+ key = json.loads(Path(f"/operator/{actor}.json").read_text())["secret"]22+ env = {**os.environ, "TINY_PRIVATE_KEY": key}23+ self.process = subprocess.Popen(24+ ["tinyagent", "rpc", "--relay", "http://tiny:7447"],25+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, env=env,26+ )27+ self.sequence = 028+ self.selector = selectors.DefaultSelector()29+ self.selector.register(self.process.stdout, selectors.EVENT_READ)30+31+ def call(self, method, params=None, timeout=30):32+ self.sequence += 133+ self.process.stdin.write(json.dumps({"id": self.sequence, "method": method, "params": params or {}}) + "\n")34+ self.process.stdin.flush()35+ deadline = time.monotonic() + timeout36+ while time.monotonic() < deadline:37+ if not self.selector.select(max(0, deadline - time.monotonic())):38+ break39+ line = self.process.stdout.readline()40+ if not line:41+ raise RuntimeError("tinyagent exited before replying")42+ message = json.loads(line)43+ if message.get("id") != self.sequence:44+ continue45+ if "error" in message:46+ raise RuntimeError(message["error"]["message"])47+ return message.get("result")48+ raise TimeoutError(f"tinyagent {method} timed out")49+50+ def publish(self, kind, content, tags):51+ return self.call("publish", {"event": {"kind": kind, "content": content, "tags": tags}})52+53+ def query(self, filter):54+ return self.call("query", {"filter": filter}) or []55+56+ def browse(self, method, params):57+ response = self.call("request", {"path": "/manage/rpc", "method": "POST", "body": {58+ "jsonrpc": "2.0", "id": 1, "method": method, "params": [params],59+ }})60+ if response.get("error"):61+ raise RuntimeError(str(response["error"]))62+ return response.get("result")63+64+ def close(self):65+ self.selector.close()66+ self.process.stdin.close()67+ try:68+ self.process.wait(timeout=5)69+ except subprocess.TimeoutExpired:70+ self.process.kill()71+ self.process.wait()72+73+ def __enter__(self):74+ return self75+76+ def __exit__(self, *_args):77+ self.close()78+79+80+def answer(client, request, content):81+ return client.publish(1111, content, [82+ ["h", tag(request, "h")], ["E", request["id"]], ["K", "9"], ["P", request["pubkey"]],83+ ["e", request["id"]], ["k", "9"], ["p", request["pubkey"]],84+ ])85+86+87+def wait_event(client, filter, predicate=lambda _event: True, timeout=90):88+ deadline = time.monotonic() + timeout89+ while time.monotonic() < deadline:90+ events = client.query(filter)91+ match = next((event for event in events if predicate(event)), None)92+ if match:93+ return match94+ time.sleep(0.3)95+ raise TimeoutError(f"No matching event for {filter}")
A labs/hermes/smoke.py +122 -0
@@ -0,0 +1,122 @@
1+"""Exercise unchanged Hermes over the native Tiny connection."""2+3+import base644+import hashlib5+import json6+import time7+import uuid8+from urllib.parse import urlsplit9+10+from rpc import Client, answer, public, tag, wait_event11+12+13+def check(condition, message):14+ if not condition:15+ raise AssertionError(message)16+17+18+def send(client, text, extra=None):19+ return client.publish(9, text, [["h", "lab"], ["p", public("agent")], *(extra or [])])20+21+22+def done(client, marker, since):23+ event = wait_event(client, {"kinds": [9, 40003], "authors": [public("agent")],24+ "#h": ["lab"], "since": since},25+ lambda row: f"LAB_DONE {marker}" in row["content"])26+ check(not any(row[0] in ("p", "mention") for row in event["tags"]),27+ "Ordinary agent output unexpectedly mentions someone")28+ return event29+30+31+def interaction(client, scenario, run):32+ marker = f"LAB_{scenario}_{run}"33+ prior = {row["id"] for row in client.query({"kinds": [9], "#h": ["lab"], "#request": ["question"]})}34+ sent = send(client, marker)35+ request = wait_event(client, {"kinds": [9], "authors": [public("agent")], "#h": ["lab"],36+ "#request": ["question"], "since": sent["created_at"]},37+ lambda row: row["id"] not in prior)38+ check(tag(request, "tinyagent") == "1", "Request lost its native interaction marker")39+ check(tag(request, "p") == public("owner"), "Request does not name its human assignee")40+ check(tag(request, "mention") is None, "Assignee was turned into an unsolicited mention")41+ check(int(tag(request, "expiration") or 0) > time.time(), "Request has no future expiry")42+ options = {row[1]: row[2] for row in request["tags"] if len(row) >= 3 and row[0] == "option"}43+ check(bool(options) or scenario == "OPEN", "Options were flattened into text")44+ if scenario == "OPEN":45+ check(tag(request, "selection") == "text", "Open question lost its text input")46+ choice = "Custom lab answer"47+ elif scenario == "APPROVAL":48+ check(tag(request, "interaction") == "approval", "Approval lost its type")49+ choice = next(key for key in options if key == "once")50+ else:51+ check(tag(request, "interaction") == "question", "Question lost its type")52+ choice = next(key for key, label in options.items() if label.startswith("Continue"))53+ if scenario == "MULTI":54+ check(tag(request, "selection") == "multiple", "Multiple choice became single choice")55+ choice = json.dumps([key for key, label in options.items() if label.startswith(("Continue", "Stop"))])56+ if scenario == "CUSTOM":57+ check(tag(request, "freeform") == "true", "Choice question lost its custom input")58+ choice = json.dumps({"text": "Custom lab answer"})59+ # A signed response from another room member must not resolve the operator's request.60+ with Client("observer") as observer:61+ answer(observer, request, choice)62+ answer(client, request, "" if scenario == "OPEN" else "option-that-was-not-offered")63+ time.sleep(0.5)64+ state = client.browse("browseapproval", {"id": request["id"]})65+ check(state["item"]["state"] == "open", "An unauthorized or invalid answer settled the card")66+ answer(client, request, choice)67+ final = done(client, marker, sent["created_at"])68+ expected = "tinyagent-lab" if scenario == "APPROVAL" else "Custom lab answer" if scenario in ("OPEN", "CUSTOM") else "Continue"69+ check(expected in final["content"], "Hermes did not receive the selected response")70+ if scenario == "MULTI":71+ check("Stop" in final["content"], "Hermes lost a selected choice")72+ state = client.browse("browseapproval", {"id": request["id"]})73+ check(state["item"]["state"] == "answered", "The valid answer did not settle the card")74+ print(f"PASS {scenario.lower()}: native card, silent assignment, authorized answer, Hermes resumed")75+76+77+def media(client, run):78+ marker = f"LAB_MEDIA_{run}"79+ content = f"tinyagent-attachment-{run}\n".encode()80+ descriptor = client.call("upload", {"room": "lab", "filename": "lab-note.txt", "type": "text/plain",81+ "data": base64.b64encode(content).decode()})82+ digest = hashlib.sha256(content).hexdigest()83+ check(descriptor["sha256"] == digest, "Uploaded attachment hash differs")84+ path = urlsplit(descriptor["url"]).path85+ downloaded = client.call("download", {"path": path})86+ check(base64.b64decode(downloaded["data"]) == content, "Authenticated download differs")87+ sent = send(client, marker, [["imeta", f"url {descriptor['url']}", "m text/plain", f"x {digest}",88+ f"size {len(content)}", "filename lab-note.txt"]])89+ final = done(client, marker, sent["created_at"])90+ check(f"tinyagent-attachment-{run}" in final["content"], "Attachment text did not reach Hermes")91+ returned = wait_event(client, {"kinds": [9], "authors": [public("agent")], "#h": ["lab"],92+ "since": sent["created_at"]},93+ lambda row: any(t[0] == "imeta" and f"x {digest}" in t for t in row["tags"]))94+ attachment = next(t for t in returned["tags"] if t[0] == "imeta")95+ url = next(t[4:] for t in attachment if t.startswith("url "))96+ check(base64.b64decode(client.call("download", {"path": urlsplit(url).path})["data"]) == content,97+ "Hermes outbound attachment differs")98+ print("PASS media: authenticated upload, Hermes read_file, native outbound attachment, matching bytes")99+100+101+def main():102+ run = uuid.uuid4().hex[:12]103+ with Client() as client:104+ marker = f"LAB_HELLO_{run}"105+ sent = send(client, marker)106+ done(client, marker, sent["created_at"])107+ print("PASS connection: real Hermes replied through Tiny without a mention")108+ for scenario in ("QUESTION", "MULTI", "CUSTOM", "OPEN", "APPROVAL"):109+ interaction(client, scenario, run)110+ media(client, run)111+ marker = f"LAB_MENTION_{run}"112+ sent = send(client, f"{marker} @{public('owner')}")113+ mentioned = wait_event(client, {"kinds": [9, 40003], "authors": [public("agent")],114+ "#h": ["lab"], "#p": [public("owner")], "since": sent["created_at"]},115+ lambda row: f"LAB_DONE {marker}" in row["content"])116+ check(tag(mentioned, "mention") == public("owner"), "Explicit agent mention was not preserved")117+ print("PASS mention: explicit model mention becomes a native recipient")118+ print(json.dumps({"ok": True, "run": run, "connection": "tiny", "connector": "tinyagent"}))119+120+121+if __name__ == "__main__":122+ main()
A labs/hermes/source_check.py +32 -0
A labs/hermes/start_hermes.py +18 -0
A labs/hermes/start_tiny.py +12 -0
A labs/hermes/test_model.py +81 -0
@@ -0,0 +1,81 @@
1+import json2+import threading3+import unittest4+from http.client import HTTPConnection5+from pathlib import Path6+import importlib.util7+8+9+MODULE_PATH = Path(__file__).with_name("model.py")10+SPEC = importlib.util.spec_from_file_location("tiny_agent_lab_model", MODULE_PATH)11+model = importlib.util.module_from_spec(SPEC)12+assert SPEC.loader is not None13+SPEC.loader.exec_module(model)14+15+16+class ModelFixtureTests(unittest.TestCase):17+ def test_hello_uses_latest_user_marker(self):18+ result = model.complete({"model": "tiny-lab", "messages": [19+ {"role": "user", "content": "old LAB_HELLO_old"},20+ {"role": "assistant", "content": "done"},21+ {"role": "user", "content": "LAB_HELLO_new"},22+ ]})23+ self.assertEqual(result["choices"][0]["message"]["content"], "LAB_DONE LAB_HELLO_new")24+25+ def test_question_requires_advertised_tool_and_returns_canonical_batch(self):26+ body = {"messages": [{"role": "user", "content": "LAB_QUESTION_123"}], "tools": [{"type": "function", "function": {"name": "clarify"}}]}27+ call = model.complete(body)["choices"][0]["message"]["tool_calls"][0]28+ self.assertEqual(call["function"]["name"], "clarify")29+ args = json.loads(call["function"]["arguments"])30+ self.assertEqual(args["questions"][0]["choices"], ["Continue", "Stop"])31+ self.assertFalse(args["questions"][0]["multi_select"])32+ error = model.complete({"messages": body["messages"]})["choices"][0]["message"]["content"]33+ self.assertIn("clarify tool is unavailable", error)34+35+ def test_multi_and_approval_tool_results_complete_turn(self):36+ multi = model.complete({"messages": [{"role": "user", "content": "LAB_MULTI_123"}], "tools": [{"type": "function", "function": {"name": "clarify"}}]})37+ args = json.loads(multi["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"])38+ self.assertTrue(args["questions"][0]["multi_select"])39+ approval = model.complete({"messages": [{"role": "user", "content": "LAB_APPROVAL_123"}], "tools": [{"type": "function", "function": {"name": "execute_code"}}]})40+ approval_call = approval["choices"][0]["message"]["tool_calls"][0]41+ self.assertEqual(json.loads(approval_call["function"]["arguments"])["code"], "print('tinyagent-lab')")42+ done = model.complete({"messages": [43+ {"role": "user", "content": "LAB_APPROVAL_123"},44+ {"role": "assistant", "content": None, "tool_calls": [approval_call]},45+ {"role": "tool", "tool_call_id": approval_call["id"], "content": '{"stdout":"tinyagent-lab\\n"}'},46+ ]})47+ self.assertIn("tinyagent-lab", done["choices"][0]["message"]["content"])48+49+ def test_mention_requires_explicit_operator_hex_and_media_is_visible(self):50+ operator = "a" * 6451+ mentioned = model.complete({"messages": [{"role": "user", "content": f"LAB_MENTION_123 @{operator}"}]})52+ self.assertEqual(mentioned["choices"][0]["message"]["content"], f"LAB_DONE LAB_MENTION_123 @{operator}")53+ absent = model.complete({"messages": [{"role": "user", "content": "LAB_MENTION_124 @operator"}]})54+ self.assertEqual(absent["choices"][0]["message"]["content"], "LAB_DONE LAB_MENTION_124")55+ media = model.complete({"messages": [{"role": "user", "content": "LAB_MEDIA_123 [file: tinyagent-attachment-notes.txt]"}]})56+ self.assertEqual(media["choices"][0]["message"]["content"], "LAB_DONE LAB_MEDIA_123 MEDIA_RECEIVED tinyagent-attachment-notes.txt")57+58+ def test_http_health_models_and_sse(self):59+ server = model.ThreadingHTTPServer(("127.0.0.1", 0), model.Handler)60+ threading.Thread(target=server.serve_forever, daemon=True).start()61+ try:62+ conn = HTTPConnection("127.0.0.1", server.server_port)63+ conn.request("GET", "/healthz")64+ self.assertEqual(conn.getresponse().status, 200)65+ conn.close()66+ conn = HTTPConnection("127.0.0.1", server.server_port)67+ body = json.dumps({"model": "tiny-lab", "stream": True, "messages": [{"role": "user", "content": "LAB_HELLO_sse"}]}).encode()68+ conn.request("POST", "/v1/chat/completions", body, {"Content-Type": "application/json"})69+ response = conn.getresponse()70+ payload = response.read().decode()71+ self.assertEqual(response.status, 200)72+ self.assertIn('LAB_DONE LAB_HELLO_sse', payload)73+ self.assertIn("data: [DONE]", payload)74+ conn.close()75+ finally:76+ server.shutdown()77+ server.server_close()78+79+80+if __name__ == "__main__":81+ unittest.main()
M scripts/chat-activity.test.mjs +44 -0
@@ -90,3 +90,47 @@ test("decision refuses a card that disappeared while it was being answered", asy
9090 await assert.rejects(decision.submit({elements: {}}), /no longer waiting/);9191 assert.equal(host.scheduled, true);9292 });93+94+function nativeForm(selection="single", interaction="question", freeform=true) {95+ const {definitions, document, template, published} = setup();96+ const host = new (definitions["chat-activity"])(); host.localName="chat-activity"; host.parent=document;97+ host.read=async()=>"fresh"; host.schedule=()=>{};98+ const allowed=new Node("chat-decision"), decision=new (definitions["chat-decision"])();99+ decision.localName="chat-decision"; decision.parent=host;100+ for(const el of [allowed,decision]) {101+ for(const [name,value] of [["event",request],["actor",actor],["pubkey",author],["room","room"],["kind","9"],["native",""],["selection",selection],["interaction",interaction]]) el.setAttribute(name,value);102+ if(interaction==="question")el.setAttribute("question","");103+ if(freeform)el.setAttribute("freeform","");104+ if(selection==="multiple")el.setAttribute("multiple","");105+ }106+ allowed.querySelectorAll=()=>selection==="text"?[]:[{value:"c0"},{value:"c1"}];107+ template.content={querySelectorAll:()=>[allowed]};108+ return {decision,allowed,published};109+}110+111+for(const [selection,form,expected] of [112+ ["single",{option:{value:"c0",checked:true}},"c0"],113+ ["multiple",{option:[{value:"c0",checked:true},{value:"c1",checked:true}]},'["c0","c1"]'],114+ ["single",{"custom-answer":{value:"custom reply"}},'{"text":"custom reply"}'],115+ ["text",{"custom-answer":{value:"plain reply"}},"plain reply"],116+]) test(`native ${selection} question submits the selected or custom answer`,async()=>{117+ const {decision,published}=nativeForm(selection);118+ await decision.submit({elements:form});119+ assert.equal(published.length,1); assert.equal(published[0].content,expected); assert.equal(published[0].kind,1111);120+});121+122+test("native approval accepts only an offered option",async()=>{123+ const {decision,published}=nativeForm("single","approval",false);124+ await assert.rejects(decision.submit({elements:{"custom-answer":{value:"yes"}}}),/options only/);125+ await assert.rejects(decision.submit({elements:{option:{value:"always",checked:true}}}),/options changed/);126+ await decision.submit({elements:{option:{value:"c0",checked:true}}});127+ assert.equal(published[0].content,"c0");128+});129+130+test("native decisions validate freeform and selection against the fresh card",async()=>{131+ const {decision,allowed,published}=nativeForm();132+ delete allowed.attributes.freeform;133+ await assert.rejects(decision.submit({elements:{"custom-answer":{value:"custom"}}}),/options only/);134+ await assert.rejects(decision.submit({elements:{option:[{value:"c0",checked:true},{value:"c1",checked:true}]}}),/one option/);135+ assert.equal(published.length,0);136+});
A tinyagent/README.md +44 -0
@@ -0,0 +1,44 @@
1+# tinyagent2+3+`tinyagent` is the native Tinyrelay connector helper for Hermes Agent. It is the connector name used by Hermes plugin discovery. The Hermes platform registered by the plugin is named `tiny`.4+5+Build the helper from the repository root:6+7+```sh8+mkdir -p bin9+go build -o bin/tinyagent ./tinyagent10+```11+12+Install the plugin in a standard Hermes home and put the helper on `PATH`:13+14+```sh15+export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"16+mkdir -p "$HERMES_HOME/plugins"17+cp -R tinyagent/hermes "$HERMES_HOME/plugins/tinyagent"18+export PATH="$PWD/bin:$PATH"19+```20+21+Configure the connector with the relay URL, the private key used by this Hermes instance, the room to monitor, and the public keys allowed to control it:22+23+```sh24+export TINY_RELAY_URL=https://relay.example.test25+export TINY_PRIVATE_KEY=YOUR_HEX_PRIVATE_KEY26+export TINY_CHANNELS=room-id27+export TINY_HOME_CHANNEL=room-id28+export TINY_ALLOWED_USERS=OPERATOR_PUBLIC_KEY29+export TINY_CLI_PATH="$PWD/bin/tinyagent"30+hermes plugins enable tinyagent31+hermes gateway run32+```33+34+`TINY_PRIVATE_KEY` is read from the environment by the helper and is never a command-line argument. Give that key membership and an agent grant for the selected rooms on Tiny, including attachment access. Use a relay with the [native interaction extension](../docs/extensions/tinyagent.md) for interactive cards. Hermes loads the adapter through its normal plugin system. Attachments use authenticated Tinyrelay endpoints, with size and hash checks.35+36+The helper exposes a JSON-lines RPC mode for the adapter. Its public commands are:37+38+```sh39+tinyagent keygen40+TINY_PRIVATE_KEY=YOUR_HEX_PRIVATE_KEY tinyagent rpc --relay https://relay.example.test41+tinyagent call --relay https://relay.example.test identity42+```43+44+The connector currently targets member access to rooms. Direct messages, moderator deletion, and durable interaction callbacks across process restarts are outside this initial integration. Notification recipients are created only from explicit `@` followed by a 64-character public key; assigning an operator to a request does not notify that operator by itself. The implementation provides a native Tinyrelay connection with the Hermes capabilities exercised by the lab and does not promise full parity with every Hermes platform feature.
A tinyagent/client/client.go +627 -0
@@ -0,0 +1,627 @@
1+// Package client provides the native Tinyrelay transport used by tinyagent.2+package client3+4+import (5+ "bytes"6+ "context"7+ "crypto/rand"8+ "crypto/sha256"9+ "encoding/base64"10+ "encoding/hex"11+ "encoding/json"12+ "errors"13+ "fmt"14+ "io"15+ "net/http"16+ "net/url"17+ "path"18+ "strings"19+ "time"20+21+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"22+ "github.com/coder/websocket"23+)24+25+const maxDownload = 32 << 2026+const maxQueryEvents = 1000027+28+type Client struct {29+ BaseURL, Secret, PubKey string30+ HTTP *http.Client31+}32+type Filter map[string]any33+type UploadResult map[string]any34+35+func New(base, secret string) (*Client, error) {36+ u, err := normalizeBase(base)37+ if err != nil {38+ return nil, err39+ }40+ pub, err := nostr.PublicKey(secret)41+ if err != nil {42+ return nil, err43+ }44+ return &Client{BaseURL: u, Secret: secret, PubKey: pub, HTTP: &http.Client{CheckRedirect: rejectExternalRedirect}}, nil45+}46+47+func (c *Client) Identity() map[string]string { return map[string]string{"pubkey": c.PubKey} }48+49+func (c *Client) Publish(ctx context.Context, e nostr.Event) (nostr.Event, error) {50+ if e.CreatedAt == 0 {51+ e.CreatedAt = time.Now().Unix()52+ }53+ if err := nostr.Sign(&e, c.Secret); err != nil {54+ return nostr.Event{}, err55+ }56+ body, err := nostr.Canonical(e)57+ if err != nil {58+ return nostr.Event{}, err59+ }60+ result, err := c.doRaw(ctx, http.MethodPost, "/events", body)61+ if err != nil {62+ return nostr.Event{}, err63+ }64+ var receipt struct {65+ Accepted *bool `json:"accepted"`66+ Message string `json:"message"`67+ }68+ if err := json.Unmarshal(result, &receipt); err != nil || receipt.Accepted == nil {69+ return nostr.Event{}, errors.New("relay returned an invalid publish receipt")70+ }71+ if !*receipt.Accepted {72+ if receipt.Message == "" {73+ receipt.Message = "relay rejected event"74+ }75+ return nostr.Event{}, errors.New(receipt.Message)76+ }77+ return e, nil78+}79+80+func (c *Client) Request(ctx context.Context, requestPath, method string, body json.RawMessage) (any, error) {81+ if method == "" {82+ method = http.MethodGet83+ }84+ if !validMethod(method) {85+ return nil, errors.New("invalid HTTP method")86+ }87+ b, err := c.doRaw(ctx, method, requestPath, body)88+ if err != nil {89+ return nil, err90+ }91+ var out any92+ if err := json.Unmarshal(b, &out); err != nil {93+ return nil, fmt.Errorf("decode response: %w", err)94+ }95+ return out, nil96+}97+98+func (c *Client) Upload(ctx context.Context, room, filename, typ string, data []byte) (UploadResult, error) {99+ if room == "" || len(data) == 0 || len(data) > maxDownload {100+ return nil, errors.New("invalid upload")101+ }102+ p := "/rooms/" + url.PathEscape(room) + "/attachments?filename=" + url.QueryEscape(path.Base(filename))103+ b, err := c.doRawWithHeaders(ctx, http.MethodPut, p, data, map[string]string{"Content-Type": typ, "X-Filename": filename})104+ if err != nil {105+ return nil, err106+ }107+ var out UploadResult108+ if err := json.Unmarshal(b, &out); err != nil {109+ return nil, fmt.Errorf("decode upload: %w", err)110+ }111+ return out, nil112+}113+114+func (c *Client) Download(ctx context.Context, requestPath string) (map[string]any, error) {115+ b, typ, err := c.getBounded(ctx, requestPath)116+ if err != nil {117+ return nil, err118+ }119+ return map[string]any{"data": base64.StdEncoding.EncodeToString(b), "type": typ}, nil120+}121+122+func (c *Client) Query(ctx context.Context, filters []Filter) ([]nostr.Event, error) {123+ return c.queryWebsocket(ctx, filters)124+}125+126+type EventSink func(string, nostr.Event)127+128+func (c *Client) Subscribe(ctx context.Context, name string, filter Filter, sink EventSink) error {129+ delay := time.Second130+ for ctx.Err() == nil {131+ err := c.subscribeOnce(ctx, name, filter, sink)132+ if ctx.Err() != nil {133+ return ctx.Err()134+ }135+ if err == nil {136+ delay = time.Second137+ } else {138+ delay = min(delay*2, 30*time.Second)139+ }140+ timer := time.NewTimer(delay)141+ select {142+ case <-ctx.Done():143+ timer.Stop()144+ return ctx.Err()145+ case <-timer.C:146+ }147+ }148+ return ctx.Err()149+}150+151+func (c *Client) subscribeOnce(ctx context.Context, name string, filter Filter, sink EventSink) error {152+ wsURL, err := websocketURL(c.BaseURL)153+ if err != nil {154+ return err155+ }156+ conn, _, err := websocket.Dial(ctx, wsURL, nil)157+ if err != nil {158+ return err159+ }160+ defer conn.CloseNow()161+ conn.SetReadLimit(1 << 20)162+ if err := writeFrame(ctx, conn, []any{"REQ", name, filter}); err != nil {163+ return err164+ }165+ connected := false166+ authRequired := false167+ authenticated := false168+ emitConnected := func() {169+ if !connected && sink != nil {170+ sink("connected", nostr.Event{})171+ connected = true172+ }173+ }174+ authID := ""175+ for {176+ _, raw, err := conn.Read(ctx)177+ if err != nil {178+ return err179+ }180+ var frame []json.RawMessage181+ if json.Unmarshal(raw, &frame) != nil || len(frame) < 2 {182+ continue183+ }184+ var kind string185+ _ = json.Unmarshal(frame[0], &kind)186+ switch kind {187+ case "AUTH":188+ authRequired = true189+ var challenge string190+ if json.Unmarshal(frame[1], &challenge) != nil {191+ return errors.New("invalid AUTH challenge")192+ }193+ authRelay, relayErr := websocketURL(c.BaseURL)194+ if relayErr != nil {195+ return relayErr196+ }197+ auth := nostr.Event{Kind: 22242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"relay", authRelay}, {"challenge", challenge}}}198+ if err := nostr.Sign(&auth, c.Secret); err != nil {199+ return err200+ }201+ if err := writeFrame(ctx, conn, []any{"AUTH", auth}); err != nil {202+ return err203+ }204+ authID = auth.ID205+ // Tiny may have already evaluated the unauthenticated REQ. Reissue it206+ // after AUTH so private filters are evaluated in the authenticated session.207+ if err := writeFrame(ctx, conn, []any{"REQ", name, filter}); err != nil {208+ return err209+ }210+ case "OK":211+ if len(frame) >= 3 {212+ var id string213+ var accepted bool214+ _ = json.Unmarshal(frame[1], &id)215+ _ = json.Unmarshal(frame[2], &accepted)216+ if id == authID {217+ if !accepted {218+ return errors.New("relay rejected AUTH")219+ }220+ authenticated = true221+ }222+ }223+ case "EVENT":224+ if len(frame) != 3 {225+ continue226+ }227+ var e nostr.Event228+ if json.Unmarshal(frame[2], &e) != nil || nostr.Validate(e) != nil {229+ continue230+ }231+ var sub string232+ _ = json.Unmarshal(frame[1], &sub)233+ if sink != nil && sub == name && eventMatches(e, filter) && (!authRequired || authenticated) {234+ emitConnected()235+ sink(sub, e)236+ }237+ case "EOSE":238+ var sub string239+ _ = json.Unmarshal(frame[1], &sub)240+ if sub == name && (!authRequired || authenticated) {241+ emitConnected()242+ }243+ case "CLOSED":244+ var reason string245+ if len(frame) > 2 {246+ _ = json.Unmarshal(frame[2], &reason)247+ }248+ if strings.HasPrefix(reason, "auth-required:") && !authenticated {249+ continue250+ }251+ return errors.New("subscription closed")252+ }253+ }254+}255+256+func (c *Client) queryWebsocket(ctx context.Context, filters []Filter) ([]nostr.Event, error) {257+ wsURL, err := websocketURL(c.BaseURL)258+ if err != nil {259+ return nil, err260+ }261+ conn, _, err := websocket.Dial(ctx, wsURL, nil)262+ if err != nil {263+ return nil, err264+ }265+ defer conn.CloseNow()266+ conn.SetReadLimit(1 << 20)267+ name := "tinyagent-query"268+ if err := writeQuery(ctx, conn, name, filters); err != nil {269+ return nil, err270+ }271+ var out []nostr.Event272+ awaitingAuth := false273+ authID := ""274+ for {275+ _, raw, readErr := conn.Read(ctx)276+ if readErr != nil {277+ return nil, readErr278+ }279+ var frame []json.RawMessage280+ if json.Unmarshal(raw, &frame) != nil || len(frame) < 2 {281+ continue282+ }283+ var kind string284+ _ = json.Unmarshal(frame[0], &kind)285+ if kind == "AUTH" {286+ var challenge string287+ if json.Unmarshal(frame[1], &challenge) != nil {288+ return nil, errors.New("invalid AUTH challenge")289+ }290+ authRelay, relayErr := websocketURL(c.BaseURL)291+ if relayErr != nil {292+ return nil, relayErr293+ }294+ auth := nostr.Event{Kind: 22242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"relay", authRelay}, {"challenge", challenge}}}295+ if err := nostr.Sign(&auth, c.Secret); err != nil {296+ return nil, err297+ }298+ if err := writeFrame(ctx, conn, []any{"AUTH", auth}); err != nil {299+ return nil, err300+ }301+ authID = auth.ID302+ awaitingAuth = true303+ if err := writeQuery(ctx, conn, name, filters); err != nil {304+ return nil, err305+ }306+ continue307+ }308+ if kind == "OK" && len(frame) >= 3 {309+ var id string310+ var accepted bool311+ _ = json.Unmarshal(frame[1], &id)312+ _ = json.Unmarshal(frame[2], &accepted)313+ if id == authID {314+ if !accepted {315+ return nil, errors.New("relay rejected AUTH")316+ }317+ awaitingAuth = false318+ }319+ }320+ if kind == "EVENT" && len(frame) == 3 {321+ var sub string322+ if json.Unmarshal(frame[1], &sub) != nil || sub != name {323+ continue324+ }325+ var e nostr.Event326+ if json.Unmarshal(frame[2], &e) == nil && nostr.Validate(e) == nil && queryMatches(e, filters) {327+ if len(out) >= maxQueryEvents {328+ return nil, errors.New("query exceeds 10000 events")329+ }330+ out = append(out, e)331+ }332+ }333+ if kind == "EOSE" {334+ if awaitingAuth {335+ continue336+ }337+ return out, nil338+ }339+ if kind == "CLOSED" {340+ var reason string341+ if len(frame) > 2 {342+ _ = json.Unmarshal(frame[2], &reason)343+ }344+ if strings.HasPrefix(reason, "auth-required:") && awaitingAuth {345+ continue346+ }347+ return nil, errors.New("query closed")348+ }349+ }350+}351+352+func (c *Client) doJSON(ctx context.Context, method, p string, body []byte, out any) error {353+ raw, err := c.doRaw(ctx, method, p, body)354+ if err != nil {355+ return err356+ }357+ return json.Unmarshal(raw, out)358+}359+func (c *Client) doRaw(ctx context.Context, method, p string, body []byte) ([]byte, error) {360+ return c.doRawWithHeaders(ctx, method, p, body, map[string]string{"Content-Type": "application/json"})361+}362+func (c *Client) doRawWithHeaders(ctx context.Context, method, p string, body []byte, headers map[string]string) ([]byte, error) {363+ u, err := c.requestURL(p)364+ if err != nil {365+ return nil, err366+ }367+ proof, err := c.proof(method, u.String(), body)368+ if err != nil {369+ return nil, err370+ }371+ req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))372+ if err != nil {373+ return nil, err374+ }375+ req.Header.Set("Authorization", proof)376+ for k, v := range headers {377+ if v != "" {378+ req.Header.Set(k, v)379+ }380+ }381+ if method == http.MethodPost && u.Path == "/manage/rpc" {382+ req.Header.Set("Content-Type", "application/nostr+json+rpc")383+ }384+ resp, err := c.httpClient().Do(req)385+ if err != nil {386+ return nil, err387+ }388+ defer resp.Body.Close()389+ data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1))390+ if err != nil {391+ return nil, err392+ }393+ if len(data) > maxDownload {394+ return nil, errors.New("response exceeds 32 MiB")395+ }396+ if resp.StatusCode/100 != 2 {397+ return nil, fmt.Errorf("relay HTTP %s: %s", resp.Status, strings.TrimSpace(string(data)))398+ }399+ return data, nil400+}401+402+func (c *Client) getBounded(ctx context.Context, p string) ([]byte, string, error) {403+ u, err := c.requestURL(p)404+ if err != nil {405+ return nil, "", err406+ }407+ proof, err := c.proof(http.MethodGet, u.String(), nil)408+ if err != nil {409+ return nil, "", err410+ }411+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)412+ if err != nil {413+ return nil, "", err414+ }415+ req.Header.Set("Authorization", proof)416+ resp, err := c.httpClient().Do(req)417+ if err != nil {418+ return nil, "", err419+ }420+ defer resp.Body.Close()421+ b, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1))422+ if err != nil {423+ return nil, "", err424+ }425+ if len(b) > maxDownload {426+ return nil, "", errors.New("response exceeds 32 MiB")427+ }428+ if resp.StatusCode/100 != 2 {429+ return nil, "", fmt.Errorf("relay HTTP %s: %s", resp.Status, strings.TrimSpace(string(b)))430+ }431+ return b, resp.Header.Get("Content-Type"), nil432+}433+func (c *Client) proof(method, raw string, body []byte) (string, error) {434+ nonce := make([]byte, 12)435+ if _, err := rand.Read(nonce); err != nil {436+ return "", fmt.Errorf("generate request nonce: %w", err)437+ }438+ tags := [][]string{{"u", raw}, {"method", strings.ToUpper(method)}, {"nonce", hex.EncodeToString(nonce)}}439+ if len(body) > 0 {440+ sum := sha256.Sum256(body)441+ tags = append(tags, []string{"payload", hex.EncodeToString(sum[:])})442+ }443+ e := nostr.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: tags}444+ if err := nostr.Sign(&e, c.Secret); err != nil {445+ return "", err446+ }447+ b, err := nostr.Canonical(e)448+ if err != nil {449+ return "", err450+ }451+ return "Nostr " + base64.StdEncoding.EncodeToString(b), nil452+}453+func (c *Client) requestURL(p string) (*url.URL, error) {454+ if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {455+ return nil, errors.New("path must be same-origin and absolute")456+ }457+ u, err := url.Parse(p)458+ if err != nil || strings.Contains(p, "..") || strings.Contains(u.Path, "..") {459+ return nil, errors.New("unsafe path")460+ }461+ base, _ := url.Parse(c.BaseURL)462+ u.Scheme, u.Host = base.Scheme, base.Host463+ if base.Path != "" && base.Path != "/" {464+ u.Path = strings.TrimRight(base.Path, "/") + u.Path465+ }466+ return u, nil467+}468+func (c *Client) httpClient() *http.Client {469+ if c.HTTP != nil {470+ return c.HTTP471+ }472+ return http.DefaultClient473+}474+475+func rejectExternalRedirect(req *http.Request, via []*http.Request) error {476+ if len(via) == 0 || req.URL.Scheme == via[0].URL.Scheme && req.URL.Host == via[0].URL.Host {477+ return nil478+ }479+ return errors.New("external redirects are forbidden")480+}481+func normalizeBase(raw string) (string, error) {482+ u, err := url.Parse(raw)483+ if err != nil || u.Host == "" || u.User != nil || u.Fragment != "" || (u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "ws" && u.Scheme != "wss") {484+ return "", errors.New("relay must be an http(s) or ws(s) URL")485+ }486+ if u.Scheme == "ws" {487+ u.Scheme = "http"488+ }489+ if u.Scheme == "wss" {490+ u.Scheme = "https"491+ }492+ return strings.TrimRight(u.String(), "/"), nil493+}494+func websocketURL(raw string) (string, error) {495+ u, err := url.Parse(raw)496+ if err != nil || u.Host == "" {497+ return "", errors.New("invalid relay URL")498+ }499+ if u.Scheme == "http" {500+ u.Scheme = "ws"501+ } else if u.Scheme == "https" {502+ u.Scheme = "wss"503+ } else if u.Scheme != "ws" && u.Scheme != "wss" {504+ return "", errors.New("relay must be HTTP or WebSocket")505+ }506+ return u.String(), nil507+}508+func writeFrame(ctx context.Context, conn *websocket.Conn, v any) error {509+ b, err := json.Marshal(v)510+ if err != nil {511+ return err512+ }513+ return conn.Write(ctx, websocket.MessageText, b)514+}515+516+func writeQuery(ctx context.Context, conn *websocket.Conn, name string, filters []Filter) error {517+ args := make([]any, 2, len(filters)+2)518+ args[0], args[1] = "REQ", name519+ for _, filter := range filters {520+ args = append(args, filter)521+ }522+ return writeFrame(ctx, conn, args)523+}524+func validateEvents(events []nostr.Event) ([]nostr.Event, error) {525+ for _, e := range events {526+ if err := nostr.Validate(e); err != nil {527+ return nil, err528+ }529+ }530+ return events, nil531+}532+533+func eventMatches(e nostr.Event, filter Filter) bool { return queryMatches(e, []Filter{filter}) }534+535+func queryMatches(e nostr.Event, filters []Filter) bool {536+ if len(filters) == 0 {537+ return true538+ }539+ for _, filter := range filters {540+ if filterMatches(e, filter) {541+ return true542+ }543+ }544+ return false545+}546+547+func filterMatches(e nostr.Event, filter Filter) bool {548+ if !matchesStrings(filter["ids"], e.ID) || !matchesStrings(filter["authors"], e.PubKey) || !matchesInts(filter["kinds"], e.Kind) {549+ return false550+ }551+ if n, ok := filterInt64(filter["since"]); ok && e.CreatedAt < n {552+ return false553+ }554+ if n, ok := filterInt64(filter["until"]); ok && e.CreatedAt > n {555+ return false556+ }557+ for key, value := range filter {558+ if strings.HasPrefix(key, "#") && !matchesTag(e, key[1:], value) {559+ return false560+ }561+ }562+ return true563+}564+565+func matchesStrings(raw any, want string) bool {566+ if raw == nil {567+ return true568+ }569+ values, ok := raw.([]any)570+ if !ok {571+ return false572+ }573+ for _, value := range values {574+ if value == want {575+ return true576+ }577+ }578+ return false579+}580+581+func matchesInts(raw any, want int) bool {582+ if raw == nil {583+ return true584+ }585+ values, ok := raw.([]any)586+ if !ok {587+ return false588+ }589+ for _, value := range values {590+ if number, ok := value.(float64); ok && int(number) == want {591+ return true592+ }593+ }594+ return false595+}596+597+func filterInt64(raw any) (int64, bool) {598+ number, ok := raw.(float64)599+ return int64(number), ok600+}601+602+func matchesTag(e nostr.Event, name string, raw any) bool {603+ values, ok := raw.([]any)604+ if !ok {605+ return false606+ }607+ for _, tag := range e.Tags {608+ if len(tag) < 2 || tag[0] != name {609+ continue610+ }611+ for _, value := range values {612+ if value == tag[1] {613+ return true614+ }615+ }616+ }617+ return false618+}619+func validMethod(method string) bool {620+ return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete || method == http.MethodHead621+}622+func min(a, b time.Duration) time.Duration {623+ if a < b {624+ return a625+ }626+ return b627+}
A tinyagent/client/client_test.go +176 -0
@@ -0,0 +1,176 @@
1+package client2+3+import (4+ "context"5+ "encoding/json"6+ "io"7+ "net/http"8+ "net/http/httptest"9+ "net/url"10+ "strings"11+ "testing"12+13+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"14+ "github.com/coder/websocket"15+)16+17+func TestRequestURLRejectsExternalAndEscapedPaths(t *testing.T) {18+ c := Client{BaseURL: "https://relay.example"}19+ for _, path := range []string{"https://evil.example/x", "//evil.example/x", "/../secret", "/rooms/%2e%2e/secret", "rooms/x"} {20+ if _, err := c.requestURL(path); err == nil {21+ t.Errorf("requestURL(%q) accepted unsafe path", path)22+ }23+ }24+ got, err := c.requestURL("/healthz?ok=1")25+ if err != nil || got.String() != "https://relay.example/healthz?ok=1" {26+ t.Fatalf("safe path: %v %v", got, err)27+ }28+}29+30+func TestWebsocketURL(t *testing.T) {31+ for raw, want := range map[string]string{"https://relay.example/x": "wss://relay.example/x", "http://relay.example": "ws://relay.example"} {32+ u, err := websocketURL(raw)33+ if err != nil || u != want {34+ t.Errorf("websocketURL(%q) = %q, %v; want %q", raw, u, err, want)35+ }36+ }37+ if _, err := websocketURL("file:///tmp/x"); err == nil {38+ t.Error("accepted invalid websocket URL")39+ }40+ _ = url.URL{}41+}42+43+func TestPublishChecksAcceptedReceiptAndUsesFreshNIP98Nonce(t *testing.T) {44+ secret, err := nostr.GenerateKey()45+ if err != nil {46+ t.Fatal(err)47+ }48+ c, err := New("http://relay.example/t/lab", secret)49+ if err != nil {50+ t.Fatal(err)51+ }52+ var proofs []string53+ c.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {54+ proofs = append(proofs, req.Header.Get("Authorization"))55+ if req.URL.Path != "/t/lab/events" {56+ t.Fatalf("path = %s", req.URL.Path)57+ }58+ return response(`{"accepted":false,"message":"blocked: test"}`), nil59+ })}60+ e := nostr.Event{Kind: 9, Content: "test"}61+ if _, err := c.Publish(context.Background(), e); err == nil || !strings.Contains(err.Error(), "blocked: test") {62+ t.Fatalf("publish error = %v", err)63+ }64+ if len(proofs) != 1 || !strings.Contains(proofs[0], "Nostr ") {65+ t.Fatalf("proof = %#v", proofs)66+ }67+ first, err := c.proof(http.MethodGet, "http://relay.example/t/lab/query", nil)68+ if err != nil {69+ t.Fatal(err)70+ }71+ second, err := c.proof(http.MethodGet, "http://relay.example/t/lab/query", nil)72+ if err != nil {73+ t.Fatal(err)74+ }75+ if first == second {76+ t.Fatal("identical NIP98 requests reused a proof")77+ }78+}79+80+func TestPublishRejectsMalformedReceipt(t *testing.T) {81+ secret, err := nostr.GenerateKey()82+ if err != nil {83+ t.Fatal(err)84+ }85+ c, err := New("http://relay.example", secret)86+ if err != nil {87+ t.Fatal(err)88+ }89+ c.HTTP = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {90+ return response("not-json"), nil91+ })}92+ if _, err := c.Publish(context.Background(), nostr.Event{Kind: 9}); err == nil || !strings.Contains(err.Error(), "invalid publish receipt") {93+ t.Fatalf("publish error = %v", err)94+ }95+}96+97+type roundTripFunc func(*http.Request) (*http.Response, error)98+99+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }100+101+func response(body string) *http.Response {102+ return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}103+}104+105+func TestQueryAuthenticatesAndFiltersSocketFrames(t *testing.T) {106+ clientSecret, err := nostr.GenerateKey()107+ if err != nil {108+ t.Fatal(err)109+ }110+ eventSecret, err := nostr.GenerateKey()111+ if err != nil {112+ t.Fatal(err)113+ }114+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {115+ conn, err := websocket.Accept(w, r, nil)116+ if err != nil {117+ return118+ }119+ defer conn.CloseNow()120+ ctx := r.Context()121+ _, raw, err := conn.Read(ctx)122+ if err != nil {123+ return124+ }125+ var req []json.RawMessage126+ if json.Unmarshal(raw, &req) != nil {127+ return128+ }129+ _ = writeFrame(ctx, conn, []any{"AUTH", "challenge"})130+ _, raw, err = conn.Read(ctx)131+ if err != nil {132+ return133+ }134+ var auth []json.RawMessage135+ if json.Unmarshal(raw, &auth) != nil || len(auth) < 2 {136+ return137+ }138+ var proof nostr.Event139+ if json.Unmarshal(auth[1], &proof) != nil {140+ return141+ }142+ _ = writeFrame(ctx, conn, []any{"OK", proof.ID, true, ""})143+ _, _, err = conn.Read(ctx)144+ if err != nil {145+ return146+ }147+ matching := nostr.Event{Kind: 9, CreatedAt: 2, Tags: [][]string{{"h", "room"}}, Content: "match"}148+ if err := nostr.Sign(&matching, eventSecret); err != nil {149+ return150+ }151+ wrongRoom := matching152+ wrongRoom.Tags = [][]string{{"h", "other"}}153+ if err := nostr.Sign(&wrongRoom, eventSecret); err != nil {154+ return155+ }156+ invalid := matching157+ invalid.Content = "tampered"158+ _ = writeFrame(ctx, conn, []any{"EVENT", "wrong-sub", matching})159+ _ = writeFrame(ctx, conn, []any{"EVENT", "tinyagent-query", invalid})160+ _ = writeFrame(ctx, conn, []any{"EVENT", "tinyagent-query", wrongRoom})161+ _ = writeFrame(ctx, conn, []any{"EVENT", "tinyagent-query", matching})162+ _ = writeFrame(ctx, conn, []any{"EOSE", "tinyagent-query"})163+ }))164+ defer server.Close()165+ c, err := New(server.URL, clientSecret)166+ if err != nil {167+ t.Fatal(err)168+ }169+ got, err := c.Query(context.Background(), []Filter{{"#h": []any{"room"}}})170+ if err != nil {171+ t.Fatal(err)172+ }173+ if len(got) != 1 || got[0].Content != "match" {174+ t.Fatalf("query returned %#v", got)175+ }176+}
A tinyagent/hermes/__init__.py +5 -0
A tinyagent/hermes/adapter.py +343 -0
@@ -0,0 +1,343 @@
1+"""Native Tinyrelay adapter for Hermes Agent.2+3+Tiny is a Nostr relay connection. The adapter intentionally uses structured events for4+interactive prompts; no Buzz compatibility or prose parsing is involved.5+"""6+7+from __future__ import annotations8+9+import asyncio10+import json11+import mimetypes12+import hashlib13+import os14+import re15+import time16+from datetime import datetime17+from pathlib import Path18+from typing import Any19+20+from gateway.config import Platform, PlatformConfig21+from gateway.platforms.base import BasePlatformAdapter, ExecApprovalPrompt, SendResult22+from gateway.platforms.event import MessageEvent, MessageType23+24+try:25+ from .client import TinyRPC26+ from .interactions import InteractionMixin27+ from .media import incoming_media28+ from .ingress import EventTracker, event_thread, reply_parent29+except ImportError: # Hermes plugin loader may import adapter.py as a top-level module.30+ from client import TinyRPC31+ from interactions import InteractionMixin32+ from media import incoming_media33+ from ingress import EventTracker, event_thread, reply_parent34+35+_HEX = re.compile(r"\b[0-9a-f]{64}\b", re.I)36+_MENTION = re.compile(r"(?<![0-9A-Za-z])@([0-9a-f]{64})(?![0-9A-Za-z])", re.I)37+38+39+def _tags(event: dict, name: str) -> list[list[str]]:40+ return [tag for tag in event.get("tags", []) if len(tag) >= 2 and tag[0] == name]41+42+43+def _tag(event: dict, name: str) -> str | None:44+ found = _tags(event, name)45+ return found[0][1] if found else None46+47+48+def _explicit_mentions(text: str) -> list[str]:49+ """Return only public keys explicitly written as @mentions in text."""50+ return list(dict.fromkeys(match.group(1).lower() for match in _MENTION.finditer(text or "")))51+52+53+def _mention_tags(text: str) -> list[list[str]]:54+ return [["p", pubkey] for pubkey in _explicit_mentions(text)]55+56+57+class TinyAdapter(InteractionMixin, BasePlatformAdapter):58+ """Native room messages and interactions for Hermes."""59+60+ supports_code_blocks = True61+ supports_async_delivery = True62+ MAX_MESSAGE_LENGTH = 1200063+ MAX_INTERACTION_OPTIONS = 1264+65+ def __init__(self, config: PlatformConfig):66+ super().__init__(config, Platform("tiny"))67+ extra = config.extra or {}68+ self.relay_url = str(extra.get("relay_url") or os.environ.get("TINY_RELAY_URL", "")).strip()69+ self.cli_path = str(extra.get("cli_path") or os.environ.get("TINY_CLI_PATH", "tinyagent"))70+ self.rooms = [x.strip() for x in str(extra.get("rooms") or extra.get("channels") or os.environ.get("TINY_CHANNELS", "")).split(",") if x.strip()]71+ home = extra.get("home_channel") or os.environ.get("TINY_HOME_CHANNEL", "")72+ self.home_room = str(home).strip() or (self.rooms[0] if self.rooms else "")73+ self.allowed = {x.strip().lower() for x in str(extra.get("allowed_users") or os.environ.get("TINY_ALLOWED_USERS", "")).split(",") if x.strip()}74+ self.rpc: TinyRPC | None = None75+ self.pubkey = ""76+ self._subscription = "tinyagent"77+ self._loop_task: asyncio.Task | None = None78+ # request event id -> (room, Hermes key, interaction kind, expiry, option ids)79+ self._pending = {}80+ self._tracker = None81+ self._sent = {}82+ self._session_assignees: dict[str, str] = {}83+ self._processing: set[str] = set()84+85+ @property86+ def name(self) -> str:87+ return "Tiny"88+89+ async def connect(self, *, is_reconnect: bool = False) -> bool:90+ self.rpc = TinyRPC(self.relay_url, cli_path=self.cli_path)91+ identity = await self.rpc.call("identity")92+ self.pubkey = str(identity.get("pubkey", ""))93+ if not self.pubkey:94+ raise RuntimeError("Tiny identity did not return a public key")95+ self._tracker = EventTracker(self.relay_url, self.pubkey, self.rooms)96+ filter: dict[str, Any] = {"kinds": [9, 11, 12, 1111], **self._tracker.filter_since()}97+ if self.rooms:98+ filter["#h"] = self.rooms99+ await self.rpc.subscribe(self._subscription, filter, self._on_event)100+ self._mark_connected()101+ return True102+103+ async def disconnect(self) -> None:104+ if self.rpc:105+ with_context = self.rpc.unsubscribe(self._subscription)106+ await asyncio.gather(with_context, return_exceptions=True)107+ await self.rpc.close()108+ self.rpc = None109+ self._mark_disconnected()110+111+ async def _on_event(self, event: dict) -> None:112+ event_id = event.get("id") if isinstance(event, dict) else None113+ if event_id in self._processing:114+ return115+ self._processing.add(event_id)116+ try:117+ await self._receive_event(event)118+ finally:119+ self._processing.discard(event_id)120+121+ async def _receive_event(self, event: dict) -> None:122+ if not isinstance(event, dict) or event.get("pubkey", "").lower() == self.pubkey.lower():123+ return124+ if event.get("kind") in (1111, 7):125+ await self._on_answer(event)126+ return127+ room = _tag(event, "h")128+ author = str(event.get("pubkey", ""))129+ if not room or (self.rooms and room not in self.rooms) or not self._authorized(author):130+ return131+ if self._tracker and not self._tracker.should_accept(event, now=int(time.time())):132+ return133+ root = event_thread(event)134+ parent_id = reply_parent(event)135+ parent = None136+ mentioned = self.pubkey in [row[1] for row in _tags(event, "p")]137+ if parent_id:138+ rows = await self.rpc.query({"ids": [parent_id], "#h": [room], "limit": 1})139+ parent = rows[0] if rows else None140+ own_reply = bool(parent and parent.get("pubkey") == self.pubkey)141+ if not mentioned and not own_reply:142+ return143+ event_id = str(event.get("id", ""))144+ # Only thread kinds create a separate thread session. Kind 9 replies stay in the room.145+ thread_id = event_id if event.get("kind") == 11 else root if event.get("kind") == 12 else None146+ source = self.build_source(chat_id=room, chat_name=room, chat_type="group", user_id=author,147+ thread_id=thread_id, message_id=event_id)148+ media_urls, media_types = await incoming_media(self.rpc, event, self.relay_url)149+ message_type = MessageType.TEXT150+ if media_types:151+ major = media_types[0].split("/", 1)[0]152+ message_type = {"image": MessageType.PHOTO, "audio": MessageType.AUDIO,153+ "video": MessageType.VIDEO}.get(major, MessageType.DOCUMENT)154+ message = MessageEvent(text=str(event.get("content", "")), message_type=message_type, source=source,155+ raw_message=event, message_id=event_id,156+ media_urls=media_urls, media_types=media_types,157+ media_text_inlined=[False] * len(media_urls),158+ timestamp=datetime.fromtimestamp(int(event.get("created_at", time.time()))),159+ reply_to_message_id=parent_id, reply_to_is_own_message=own_reply,160+ reply_to_text=parent.get("content") if parent else None)161+ self._session_assignees[self._event_session_key(message)] = author162+ if self._tracker:163+ self._tracker.mark(event)164+ await self.handle_message(message)165+166+ def _authorized(self, author: str) -> bool:167+ return author.lower() in self.allowed168+169+ @staticmethod170+ def _mentions(content: str) -> list[str]:171+ return _explicit_mentions(content)172+173+ async def _publish(self, room: str, content: str, tags: list[list[str]], *, kind: int = 9) -> SendResult:174+ if not self.rpc:175+ return SendResult(success=False, error="Tiny is disconnected", retryable=True)176+ try:177+ result = await self.rpc.publish({"kind": kind, "content": content, "tags": [["h", room], *tags]})178+ event = result if isinstance(result, dict) else {}179+ return SendResult(success=True, message_id=event.get("id"), raw_response=event)180+ except Exception as exc:181+ return SendResult(success=False, error=str(exc), retryable=True)182+183+ async def send(self, chat_id: str, content: str, reply_to: str | None = None, metadata: dict | None = None) -> SendResult:184+ tags: list[list[str]] = []185+ if reply_to:186+ tags.append(["e", str(reply_to)])187+ mentions = _explicit_mentions(content)188+ for pubkey in (metadata or {}).get("mentions", []):189+ if _HEX.fullmatch(str(pubkey)):190+ mentions.append(str(pubkey).lower())191+ for pubkey in dict.fromkeys(mentions):192+ tags.append(["p", pubkey])193+ tags.append(["mention", pubkey])194+ thread_id = (metadata or {}).get("thread_id")195+ if thread_id:196+ tags.insert(0, ["e", str(thread_id), "", "root"])197+ result = await self._publish(chat_id, content, tags, kind=12 if thread_id else 9)198+ if result.success:199+ self._sent[result.message_id] = tags200+ if len(self._sent) > 1000:201+ self._sent.pop(next(iter(self._sent)))202+ return result203+204+ async def edit_message(self, chat_id: str, message_id: str, content: str, *, finalize: bool = False) -> SendResult:205+ tags = [["e", message_id]]206+ for key in _explicit_mentions(content):207+ tags.extend([["p", key], ["mention", key]])208+ result = await self._publish(chat_id, content, tags, kind=40003)209+ if result.success:210+ result.message_id = message_id211+ return result212+213+ async def delete_message(self, chat_id: str, message_id: str) -> bool:214+ # Tiny's room deletion is a moderator action; this member connector cannot claim deletion.215+ return False216+217+ async def _send_file(self, chat_id: str, file_path: str, caption: str | None = None,218+ media_type: str | None = None, metadata: dict | None = None,219+ reply_to: str | None = None, file_name: str | None = None) -> SendResult:220+ if not self.rpc:221+ return SendResult(success=False, error="Tiny is disconnected", retryable=True)222+ try:223+ path = Path(file_path)224+ if not 0 < path.stat().st_size <= 32 << 20:225+ return SendResult(success=False, error="Tiny attachments must be between 1 byte and 32 MiB")226+ data = await asyncio.to_thread(path.read_bytes)227+ mime = media_type or mimetypes.guess_type(path.name)[0] or "application/octet-stream"228+ filename = Path(file_name).name if file_name else path.name229+ descriptor = await self.rpc.upload(chat_id, filename, mime, data)230+ target = descriptor.get("url") or descriptor.get("path") or descriptor.get("sha256")231+ if not target:232+ return SendResult(success=False, error="Tiny upload returned no descriptor")233+ digest = descriptor.get("sha256") or descriptor.get("hash")234+ if digest != hashlib.sha256(data).hexdigest():235+ return SendResult(success=False, error="Tiny returned a different attachment hash")236+ fields = ["url " + str(target), "m " + mime, "filename " + filename]237+ if digest:238+ fields.append("x " + str(digest))239+ fields.append("size " + str(len(data)))240+ tags = [["imeta", *fields]]241+ thread_id = (metadata or {}).get("thread_id")242+ if thread_id:243+ tags.insert(0, ["e", str(thread_id), "", "root"])244+ if reply_to:245+ tags.append(["e", str(reply_to), "", "reply"])246+ for key in _explicit_mentions(caption or ""):247+ tags.extend([["p", key], ["mention", key]])248+ return await self._publish(chat_id, caption or "", tags, kind=12 if thread_id else 9)249+ except Exception as exc:250+ return SendResult(success=False, error=str(exc), retryable=True)251+252+ async def send_image_file(self, chat_id: str, image_path: str, caption: str | None = None,253+ reply_to=None, metadata=None, **kwargs) -> SendResult:254+ return await self._send_file(chat_id, image_path, caption,255+ mimetypes.guess_type(image_path)[0] or "image/png", metadata, reply_to)256+257+ async def send_document(self, chat_id: str, file_path: str, caption: str | None = None,258+ file_name=None, reply_to=None, metadata=None, **kwargs) -> SendResult:259+ return await self._send_file(chat_id, file_path, caption, metadata=metadata, reply_to=reply_to, file_name=file_name)260+261+ async def send_video(self, chat_id: str, video_path: str, caption: str | None = None,262+ reply_to=None, metadata=None, **kwargs) -> SendResult:263+ return await self._send_file(chat_id, video_path, caption, metadata=metadata, reply_to=reply_to)264+265+ async def send_voice(self, chat_id: str, audio_path: str, caption: str | None = None,266+ reply_to=None, metadata=None, **kwargs) -> SendResult:267+ return await self._send_file(chat_id, audio_path, caption,268+ mimetypes.guess_type(audio_path)[0] or "audio/ogg", metadata, reply_to)269+270+ async def send_typing(self, chat_id: str, metadata=None) -> None:271+ await self._publish(chat_id, "", [["expiration", str(int(time.time()) + 30)], ["status", "typing"]], kind=20002)272+273+ async def get_chat_info(self, chat_id: str) -> dict[str, Any]:274+ return {"name": chat_id, "type": "group"}275+276+277+def check_requirements() -> bool:278+ return bool(os.environ.get("TINY_RELAY_URL") and os.environ.get("TINY_PRIVATE_KEY"))279+280+281+def validate_config(config: PlatformConfig) -> bool:282+ extra = config.extra or {}283+ return bool(extra.get("relay_url") or os.environ.get("TINY_RELAY_URL"))284+285+286+def _env_enablement() -> dict | None:287+ relay = os.environ.get("TINY_RELAY_URL", "").strip()288+ key = os.environ.get("TINY_PRIVATE_KEY", "").strip()289+ if not relay or not key:290+ return None291+ result = {"relay_url": relay}292+ for env, key_name in (("TINY_HOME_CHANNEL", "home_channel"), ("TINY_ALLOWED_USERS", "allowed_users"),293+ ("TINY_CLI_PATH", "cli_path")):294+ if os.environ.get(env):295+ result[key_name] = os.environ[env]296+ return result297+298+299+async def _standalone_send(pconfig, chat_id: str, message: str, *, thread_id=None, media_files=None,300+ force_document=False):301+ extra = getattr(pconfig, "extra", {}) or {}302+ rpc = TinyRPC(str(extra.get("relay_url") or os.environ.get("TINY_RELAY_URL", "")),303+ cli_path=str(extra.get("cli_path") or os.environ.get("TINY_CLI_PATH", "tinyagent")))304+ try:305+ await rpc.connect()306+ adapter = TinyAdapter(pconfig)307+ adapter.rpc = rpc308+ media = list(media_files or [])309+ last_id = None310+ if message and len(media) != 1:311+ result = await adapter.send(chat_id, message, metadata={"thread_id": thread_id})312+ if not result.success:313+ return {"error": result.error or "Tiny send failed"}314+ last_id = result.message_id315+ for item in media:316+ path = str(item[0] if isinstance(item, (list, tuple)) else item)317+ is_voice = bool(item[1]) if isinstance(item, (list, tuple)) and len(item) > 1 else False318+ mime = mimetypes.guess_type(path)[0] or ("audio/ogg" if is_voice else "application/octet-stream")319+ if force_document:320+ mime = "application/octet-stream"321+ result = await adapter._send_file(chat_id, path, message if len(media) == 1 else None,322+ mime, {"thread_id": thread_id})323+ if not result.success:324+ return {"error": result.error or "Tiny media send failed"}325+ last_id = result.message_id or last_id326+ return {"success": True, "message_id": last_id, "media_delivered": bool(media)}327+ except Exception as exc:328+ return {"error": str(exc)}329+ finally:330+ await rpc.close()331+332+333+def register(ctx):334+ ctx.register_platform(335+ name="tiny", label="Tiny", adapter_factory=TinyAdapter,336+ check_fn=check_requirements, validate_config=validate_config,337+ required_env=["TINY_RELAY_URL", "TINY_PRIVATE_KEY"],338+ install_hint="Requires the tinyagent helper binary on PATH or at TINY_CLI_PATH",339+ env_enablement_fn=_env_enablement, cron_deliver_env_var="TINY_HOME_CHANNEL",340+ standalone_sender_fn=_standalone_send, allowed_users_env="TINY_ALLOWED_USERS",341+ max_message_length=12000, platform_hint="You are collaborating through Tinyrelay. Only explicit @ mentions notify a person. Use @ followed by their 64-character public key when you need their attention; ordinary updates stay quiet.",342+ emoji="◈", pii_safe=False, allow_update_command=True,343+ )
A tinyagent/hermes/client.py +140 -0
@@ -0,0 +1,140 @@
1+"""Small JSON-lines client for the tinyagent helper."""2+3+from __future__ import annotations4+5+import asyncio6+import base647+import json8+import logging9+import os10+from typing import Any, Awaitable, Callable11+12+13+class TinyRPC:14+ def __init__(self, relay_url: str, key_env: str = "TINY_PRIVATE_KEY", cli_path: str = "tinyagent"):15+ self.relay_url = relay_url16+ self.key_env = key_env17+ self.cli_path = cli_path18+ self._proc: asyncio.subprocess.Process | None = None19+ self._reader_task: asyncio.Task | None = None20+ self._next_id = 021+ self._pending: dict[int, asyncio.Future] = {}22+ self._subscriptions: dict[str, Callable[[dict], Awaitable[None]]] = {}23+ self._callback_tasks: set[asyncio.Task] = set()24+ self._ready: dict[str, asyncio.Event] = {}25+ self._write_lock = asyncio.Lock()26+27+ async def connect(self) -> None:28+ if self._proc and self._proc.returncode is None:29+ return30+ env = dict(os.environ)31+ # The key is inherited by the helper, never put in arguments or logs.32+ self._proc = await asyncio.create_subprocess_exec(33+ self.cli_path, "rpc", "--relay", self.relay_url, "--key-env", self.key_env,34+ stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,35+ stderr=asyncio.subprocess.DEVNULL, env=env, limit=64 * 1024 * 1024,36+ )37+ self._reader_task = asyncio.create_task(self._read_loop())38+39+ async def close(self) -> None:40+ if self._reader_task:41+ self._reader_task.cancel()42+ await asyncio.gather(self._reader_task, return_exceptions=True)43+ for task in self._callback_tasks:44+ task.cancel()45+ await asyncio.gather(*self._callback_tasks, return_exceptions=True)46+ self._callback_tasks.clear()47+ if self._proc and self._proc.returncode is None:48+ self._proc.terminate()49+ await self._proc.wait()50+ error = RuntimeError("tiny rpc closed")51+ for future in self._pending.values():52+ if not future.done():53+ future.set_exception(error)54+ self._pending.clear()55+ self._proc = None56+57+ async def _read_loop(self) -> None:58+ assert self._proc and self._proc.stdout59+ try:60+ async for line in self._proc.stdout:61+ try:62+ msg = json.loads(line)63+ except (ValueError, UnicodeDecodeError):64+ continue65+ if msg.get("event") == "event":66+ callback = self._subscriptions.get(str(msg.get("subscription", "")))67+ if callback:68+ task = asyncio.create_task(callback(msg.get("data") or {}))69+ self._callback_tasks.add(task)70+ task.add_done_callback(self._callback_done)71+ continue72+ if msg.get("event") == "connected":73+ ready = self._ready.get(str(msg.get("subscription", "")))74+ if ready:75+ ready.set()76+ continue77+ request_id = msg.get("id")78+ if isinstance(request_id, int) and request_id in self._pending:79+ future = self._pending.pop(request_id)80+ if future.done():81+ continue82+ if "error" in msg:83+ future.set_exception(RuntimeError(str((msg["error"] or {}).get("message", "rpc error"))))84+ else:85+ future.set_result(msg.get("result"))86+ finally:87+ error = RuntimeError("tiny rpc exited")88+ for future in self._pending.values():89+ if not future.done():90+ future.set_exception(error)91+ self._pending.clear()92+93+ def _callback_done(self, task):94+ self._callback_tasks.discard(task)95+ if not task.cancelled() and task.exception():96+ logging.getLogger(__name__).error("Tiny event handling failed", exc_info=task.exception())97+98+ async def call(self, method: str, params: dict[str, Any] | None = None, timeout: float = 30) -> Any:99+ await self.connect()100+ if not self._proc or not self._proc.stdin:101+ raise RuntimeError("tiny rpc is not connected")102+ self._next_id += 1103+ request_id = self._next_id104+ loop = asyncio.get_running_loop()105+ future = loop.create_future()106+ self._pending[request_id] = future107+ payload = json.dumps({"id": request_id, "method": method, "params": params or {}}, separators=(",", ":"))108+ try:109+ async with self._write_lock:110+ self._proc.stdin.write((payload + "\n").encode())111+ await self._proc.stdin.drain()112+ return await asyncio.wait_for(future, timeout)113+ finally:114+ self._pending.pop(request_id, None)115+116+ async def publish(self, event: dict) -> dict:117+ return await self.call("publish", {"event": event})118+119+ async def query(self, filter: dict) -> list[dict]:120+ return await self.call("query", {"filter": filter})121+122+ async def subscribe(self, subscription: str, filter: dict, callback: Callable[[dict], Awaitable[None]]) -> Any:123+ self._subscriptions[subscription] = callback124+ self._ready[subscription] = asyncio.Event()125+ result = await self.call("subscribe", {"subscription": subscription, "filter": filter})126+ await asyncio.wait_for(self._ready[subscription].wait(), 30)127+ return result128+129+ async def unsubscribe(self, subscription: str) -> Any:130+ self._subscriptions.pop(subscription, None)131+ self._ready.pop(subscription, None)132+ return await self.call("unsubscribe", {"subscription": subscription})133+134+ async def upload(self, room: str, filename: str, media_type: str, data: bytes) -> dict:135+ return await self.call("upload", {"room": room, "filename": filename, "type": media_type,136+ "data": base64.b64encode(data).decode()})137+138+ async def download(self, path: str) -> tuple[bytes, str]:139+ result = await self.call("download", {"path": path})140+ return base64.b64decode(result["data"]), result.get("type", "application/octet-stream")
A tinyagent/hermes/ingress.py +158 -0
@@ -0,0 +1,158 @@
1+"""Replay-safe ingress bookkeeping for Tiny's Nostr event stream.2+3+This module has no Hermes or network dependencies. State is scoped to the relay,4+agent identity and subscribed rooms, so reconnects cannot replay old commands.5+"""6+7+from __future__ import annotations8+9+import hashlib10+import json11+import os12+import re13+import tempfile14+import time15+from pathlib import Path16+from typing import Iterable17+18+_EVENT_ID = re.compile(r"^[0-9a-f]{64}$", re.I)19+_REPLAY_WINDOW = 30020+_FUTURE_SKEW = 30021+22+23+def _tag_values(event: dict, name: str) -> list[list[str]]:24+ return [tag for tag in event.get("tags", []) if isinstance(tag, list) and len(tag) >= 2 and tag[0] == name]25+26+27+def _expiration(event: dict) -> int | None:28+ values = _tag_values(event, "expiration")29+ if not values:30+ return None31+ try:32+ return int(values[0][1])33+ except (TypeError, ValueError):34+ return None35+36+37+class EventTracker:38+ """Bounded persistent event cursor and ID set for one Tiny subscription."""39+40+ def __init__(self, relay: str, identity: str, rooms: Iterable[str], *, state_dir: str | Path | None = None,41+ now: int | None = None, max_seen: int = 10000):42+ self.relay, self.identity = str(relay), str(identity).lower()43+ self.rooms = tuple(sorted(str(room) for room in rooms))44+ root = Path(state_dir or os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()45+ key = hashlib.sha256((self.relay + "\0" + self.identity + "\0" + "\0".join(self.rooms)).encode()).hexdigest()[:24]46+ self.path = root / "state" / "tinyagent" / f"{key}.json"47+ self.max_seen = max(1, int(max_seen))48+ self.now = int(time.time() if now is None else now)49+ self.floor = self.now50+ self.cursor = self.now51+ self.seen: list[str] = []52+ self._seen: set[str] = set()53+ self._load()54+ # Establish the first-start floor immediately, including a clean restart before55+ # any event arrives. This prevents a later process from treating its own startup56+ # time as an advancing cursor.57+ self._save()58+59+ def _load(self) -> None:60+ try:61+ data = json.loads(self.path.read_text())62+ self.floor = int(data.get("floor", data.get("cursor", self.floor)))63+ self.cursor = int(data.get("cursor", self.cursor))64+ self.seen = [str(value) for value in data.get("seen", [])][-self.max_seen:]65+ self._seen = set(self.seen)66+ except (OSError, ValueError, TypeError):67+ return68+69+ def _save(self) -> None:70+ self.path.parent.mkdir(parents=True, exist_ok=True)71+ os.chmod(self.path.parent, 0o700)72+ payload = json.dumps({"floor": self.floor, "cursor": self.cursor, "seen": self.seen[-self.max_seen:]}, separators=(",", ":"))73+ fd, temporary = tempfile.mkstemp(prefix="tinyagent-", dir=self.path.parent)74+ try:75+ with os.fdopen(fd, "w") as stream:76+ stream.write(payload)77+ stream.flush()78+ os.fsync(stream.fileno())79+ os.replace(temporary, self.path)80+ finally:81+ try:82+ os.unlink(temporary)83+ except FileNotFoundError:84+ pass85+86+ def should_accept(self, event: dict, *, now: int | None = None) -> bool:87+ """Return true only for unseen, non-expired events at/after the cursor."""88+ event_id = str(event.get("id", ""))89+ if not _EVENT_ID.fullmatch(event_id) or event_id in self._seen:90+ return False91+ try:92+ created = int(event.get("created_at"))93+ except (TypeError, ValueError):94+ return False95+ if created < max(self.floor, self.cursor - _REPLAY_WINDOW):96+ return False97+ if created > int(time.time() if now is None else now) + _FUTURE_SKEW:98+ return False99+ expires = _expiration(event)100+ if expires is None and any(len(tag) >= 2 and tag[0] == "expiration" for tag in event.get("tags", [])):101+ return False102+ if expires is not None and expires <= int(time.time() if now is None else now):103+ return False104+ return True105+106+ def mark(self, event: dict) -> None:107+ """Record a delivered event and atomically persist the cursor."""108+ event_id = str(event.get("id", ""))109+ if not _EVENT_ID.fullmatch(event_id):110+ return111+ try:112+ self.cursor = max(self.cursor, int(event.get("created_at", self.cursor)))113+ except (TypeError, ValueError):114+ pass115+ if event_id not in self._seen:116+ self._seen.add(event_id)117+ self.seen.append(event_id)118+ if len(self.seen) > self.max_seen:119+ # Retire the overlap instead of forgetting IDs still eligible for replay.120+ self.floor = self.cursor + 1121+ self._seen = set(self.seen[-self.max_seen:])122+ self.seen = self.seen[-self.max_seen:]123+ self._save()124+125+ def filter_since(self) -> dict:126+ """Return an overlapping subscription filter; same-timestamp IDs are retained."""127+ # Match should_accept's bounded overlap. A one-second overlap is insufficient128+ # when the helper returns events out of order or the gateway was offline.129+ result = {"since": max(self.floor, self.cursor - _REPLAY_WINDOW)}130+ if self.rooms:131+ result["#h"] = list(self.rooms)132+ return result133+134+135+def event_thread(event: dict) -> str | None:136+ """Return canonical NIP-10 root thread ID, including marked e tags."""137+ marked_root = [tag[1] for tag in _tag_values(event, "e") if len(tag) >= 4 and tag[3] == "root"]138+ if marked_root:139+ return marked_root[0]140+ marked_reply = [tag[1] for tag in _tag_values(event, "e") if len(tag) >= 4 and tag[3] == "reply"]141+ if marked_reply:142+ return marked_reply[0]143+ e_tags = _tag_values(event, "e")144+ return e_tags[0][1] if e_tags else None145+146+147+def reply_parent(event: dict) -> str | None:148+ """Return the direct NIP-10 parent, preferring the marked reply e tag."""149+ marked = [tag[1] for tag in _tag_values(event, "e") if len(tag) >= 4 and tag[3] == "reply"]150+ return marked[0] if marked else event_thread(event)151+152+153+def is_reply_to_own(event: dict, own_ids: set[str]) -> bool:154+ """True when the event's canonical parent or root is one of our event IDs."""155+ return any(tag[1] in own_ids for tag in _tag_values(event, "e"))156+157+158+__all__ = ["EventTracker", "event_thread", "reply_parent", "is_reply_to_own"]
A tinyagent/hermes/interactions.py +156 -0
@@ -0,0 +1,156 @@
1+"""Native request cards and exact Hermes callback correlation."""2+3+from __future__ import annotations4+5+from dataclasses import dataclass6+import json7+import time8+9+from gateway.platforms.base import SendResult10+11+12+def tag(event, name):13+ return next((row[1] for row in event.get("tags", []) if len(row) > 1 and row[0] == name), None)14+15+16+@dataclass17+class Pending:18+ room: str19+ assignee: str20+ session: str21+ callback_id: str22+ interaction: str23+ selection: str24+ options: dict[str, str]25+ created_at: int26+ expires: int27+28+29+class InteractionMixin:30+ def _assignee(self, session_key, metadata):31+ return self._session_assignees.get(session_key) or (metadata or {}).get("user_id", "")32+33+ async def _request(self, pending, content, subject=""):34+ if not self._authorized(pending.assignee):35+ return SendResult(success=False, error="Tiny request has no authorized human assignee")36+ if len(pending.options) > 12:37+ return SendResult(success=False, error="Tiny supports at most 12 choices per question")38+ tags = [["tinyagent", "1"], ["request", "question"], ["interaction", pending.interaction],39+ ["p", pending.assignee], ["selection", pending.selection],40+ ["expiration", str(pending.expires)], ["subject", subject or content[:100]]]41+ for key, label in pending.options.items():42+ visible = label.encode("utf-8")[:200].decode("utf-8", errors="ignore")43+ tags.append(["option", key, visible])44+ if pending.interaction == "question" and pending.options:45+ tags.append(["freeform", "true"])46+ # Assignment and notification are separate even when the same key appears in both.47+ if pending.assignee in self._mentions(content):48+ tags.append(["mention", pending.assignee])49+ result = await self._publish(pending.room, content, tags)50+ if result.success and result.message_id:51+ self._pending[result.message_id] = pending52+ return result53+54+ def _pending_request(self, room, session, callback, interaction, options, selection, metadata, ttl=300):55+ now = int(time.time())56+ return Pending(room, self._assignee(session, metadata), session, callback, interaction,57+ selection, options, now, now + ttl)58+59+ async def send_clarify(self, chat_id, question, choices, clarify_id, session_key, metadata=None):60+ from tools import clarify_gateway61+ with clarify_gateway._lock:62+ entry = clarify_gateway._entries.get(clarify_id)63+ multiple = bool(entry and entry.multi_select)64+ options = {f"c{index}": str(label) for index, label in enumerate(choices or [])}65+ selection = "multiple" if multiple else "single" if choices else "text"66+ pending = self._pending_request(chat_id, session_key, clarify_id, "question", options,67+ selection, metadata, ttl=clarify_gateway.get_clarify_timeout() or 3600)68+ return await self._request(pending, question)69+70+ async def _send_exec_approval_prompt(self, prompt):71+ from tools.approval import list_gateway_approvals72+ used = {entry.callback_id for entry in self._pending.values() if entry.interaction == "approval"}73+ candidates = [entry for entry in list_gateway_approvals(prompt.session_key)74+ if entry.get("request_id") and entry["request_id"] not in used]75+ matching = [entry for entry in candidates if entry.get("command") == prompt.command]76+ # Hermes redacts command previews. A single remaining waiter is still unambiguous;77+ # multiple unmatched waiters require the gateway's standard fallback.78+ entry = matching[0] if matching else candidates[0] if len(candidates) == 1 else None79+ if entry is None:80+ return SendResult(success=False, error="Tiny cannot identify the pending Hermes approval")81+ options = {choice: label for label, choice, _style in prompt.actions}82+ from tools.approval_context import _get_approval_timeout83+ pending = self._pending_request(prompt.chat_id, prompt.session_key, entry["request_id"],84+ "approval", options, "single", prompt.metadata,85+ ttl=int(_get_approval_timeout()))86+ return await self._request(pending, prompt.text, prompt.command[:200])87+88+ async def send_slash_confirm(self, chat_id, title, message, session_key, confirm_id, metadata=None):89+ options = {"once": "Approve once", "always": "Always approve", "cancel": "Cancel"}90+ pending = self._pending_request(chat_id, session_key, confirm_id, "confirmation", options,91+ "single", metadata)92+ return await self._request(pending, f"{title}\n\n{message}", title)93+94+ async def _on_answer(self, event):95+ event_id = tag(event, "e")96+ pending = self._pending.get(event_id)97+ if pending is None or not self._answer_matches(event, event_id, pending):98+ return99+ response = self._answer_content(event.get("content", ""), pending)100+ if response is None:101+ return102+ if pending.interaction == "question":103+ from tools.clarify_gateway import resolve_gateway_clarify104+ resolved = resolve_gateway_clarify(pending.callback_id, response)105+ elif pending.interaction == "approval":106+ from tools.approval import resolve_gateway_approval107+ resolved = resolve_gateway_approval(pending.session, response, request_id=pending.callback_id) > 0108+ else:109+ from tools import slash_confirm110+ current = slash_confirm.get_pending(pending.session)111+ resolved = bool(current and current.get("confirm_id") == pending.callback_id)112+ if resolved:113+ result = await slash_confirm.resolve(pending.session, pending.callback_id, response)114+ if result:115+ await self.send(pending.room, result)116+ # Waiters are process-local in Hermes. Never apply a late answer to another request.117+ self._pending.pop(event_id, None)118+119+ def _answer_matches(self, event, event_id, pending):120+ now = int(time.time())121+ if now >= pending.expires:122+ self._pending.pop(event_id, None)123+ return False124+ if event.get("kind") != 1111 or event.get("pubkey") != pending.assignee:125+ return False126+ if not self._authorized(pending.assignee):127+ return False128+ created = event.get("created_at", 0)129+ if not isinstance(created, int) or not pending.created_at <= created < pending.expires or created > now + 60:130+ return False131+ expected = {"h": pending.room, "e": event_id, "E": event_id, "k": "9", "K": "9",132+ "p": self.pubkey, "P": self.pubkey}133+ return all(tag(event, key) == value for key, value in expected.items())134+135+ @staticmethod136+ def _answer_content(content, pending):137+ if not isinstance(content, str) or not content.strip() or len(content) > 8000:138+ return None139+ if pending.selection == "text":140+ return content141+ try:142+ decoded = json.loads(content)143+ except ValueError:144+ decoded = None145+ if pending.interaction == "question" and isinstance(decoded, dict):146+ text = decoded.get("text")147+ return text if set(decoded) == {"text"} and isinstance(text, str) and text.strip() else None148+ if pending.selection == "multiple":149+ if not isinstance(decoded, list) or not decoded or any(not isinstance(key, str) for key in decoded):150+ return None151+ if len(decoded) != len(set(decoded)) or any(key not in pending.options for key in decoded):152+ return None153+ return json.dumps([pending.options[key] for key in decoded])154+ if content not in pending.options:155+ return None156+ return pending.options[content] if pending.interaction == "question" else content
A tinyagent/hermes/media.py +87 -0
@@ -0,0 +1,87 @@
1+"""Validated Tinyrelay room media handling for the Hermes adapter."""2+3+from __future__ import annotations4+5+import hashlib6+import re7+from typing import Any8+from urllib.parse import urlsplit9+10+MAX_MEDIA_BYTES = 32 << 2011+_MEDIA_PATH = re.compile(r"^/media/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$", re.I)12+13+14+def imeta_fields(tag: list[str]) -> dict[str, str]:15+ """Parse NIP-92 ``key value`` fields from an imeta tag."""16+ fields: dict[str, str] = {}17+ for item in tag[1:]:18+ key, separator, value = str(item).partition(" ")19+ if separator and key and value:20+ fields.setdefault(key.lower(), value)21+ return fields22+23+24+def _media_path(raw_url: str, relay_url: str) -> tuple[str, str] | None:25+ target = urlsplit(raw_url)26+ origin = urlsplit(relay_url)27+ if target.query or target.fragment or origin.query or origin.fragment:28+ return None29+ if target.scheme or target.netloc:30+ if (target.scheme, target.netloc) != (origin.scheme, origin.netloc):31+ return None32+ path = target.path33+ elif raw_url.startswith("/"):34+ path = target.path35+ else:36+ return None37+ prefix = origin.path.rstrip("/")38+ if prefix:39+ if not path.startswith(prefix + "/"):40+ return None41+ path = path[len(prefix):]42+ match = _MEDIA_PATH.fullmatch(path)43+ return (path, match.group(1).lower()) if match else None44+45+46+async def incoming_media(rpc: Any, event: dict, relay_url: str) -> tuple[list[str], list[str]]:47+ """Download and cache validated same-relay imeta attachments.48+49+ Tiny authorization is enforced by the signed helper download. The URL and50+ digest checks ensure a message cannot make Hermes fetch arbitrary internet51+ content or cache bytes under a false identity.52+ """53+ from gateway.platforms.base import cache_media_bytes_async54+55+ urls: list[str] = []56+ types: list[str] = []57+ for tag in event.get("tags", []):58+ if not isinstance(tag, list) or len(tag) < 2 or tag[0] != "imeta":59+ continue60+ fields = imeta_fields(tag)61+ location = _media_path(fields.get("url", ""), relay_url)62+ if location is None:63+ continue64+ path, digest = location65+ declared = fields.get("x", "").lower()66+ if declared and declared != digest:67+ continue68+ try:69+ data, mime = await rpc.download(path)70+ except Exception:71+ continue72+ if len(data) > MAX_MEDIA_BYTES or hashlib.sha256(data).hexdigest() != digest:73+ continue74+ try:75+ if fields.get("size") and int(fields["size"]) != len(data):76+ continue77+ except ValueError:78+ continue79+ cached = await cache_media_bytes_async(80+ data,81+ filename=fields.get("filename", "attachment"),82+ mime_type=fields.get("m", mime),83+ )84+ if cached:85+ urls.append(cached.path)86+ types.append(cached.media_type)87+ return urls, types
A tinyagent/hermes/plugin.yaml +24 -0
A tinyagent/hermes/tests/test_adapter.py +245 -0
@@ -0,0 +1,245 @@
1+"""Exercise real Hermes waiters through the Tiny adapter's public hooks."""2+3+import asyncio4+import json5+import sys6+import time7+from pathlib import Path8+9+import pytest10+11+sys.path.insert(0, str(Path(__file__).parents[1]))12+from adapter import TinyAdapter13+from gateway.config import PlatformConfig14+from gateway.platform_registry import PlatformEntry, platform_registry15+from gateway.platforms.base import ExecApprovalPrompt16+from tools import approval, clarify_gateway, slash_confirm17+from tools.approval_gateway_wait import _ApprovalEntry18+19+OWNER = "b" * 6420+BOT = "a" * 6421+OTHER = "c" * 6422+23+24+class FakeRPC:25+ def __init__(self):26+ self.events = []27+28+ async def publish(self, event):29+ result = {"id": f"{len(self.events) + 1:064x}", "created_at": int(time.time()), "pubkey": BOT, **event}30+ self.events.append(result)31+ return result32+33+34+@pytest.fixture35+def adapter(monkeypatch, tmp_path):36+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))37+ platform_registry.register(PlatformEntry(name="tiny", label="Tiny", adapter_factory=TinyAdapter,38+ check_fn=lambda: True))39+ instance = TinyAdapter(PlatformConfig(enabled=True, extra={"rooms": "room", "allowed_users": f"{OWNER},{OTHER}"}))40+ instance.rpc = FakeRPC()41+ instance.pubkey = BOT42+ instance._session_assignees["session"] = OWNER43+ yield instance44+ clarify_gateway._entries.clear()45+ clarify_gateway._session_index.clear()46+ approval._gateway_queues.clear()47+ slash_confirm._pending.clear()48+49+50+def response(request, content, author=OWNER):51+ event_id = request["id"]52+ return {"kind": 1111, "pubkey": author, "created_at": int(time.time()), "content": content,53+ "tags": [["h", "room"], ["e", event_id], ["E", event_id], ["p", BOT], ["P", BOT], ["k", "9"], ["K", "9"]]}54+55+56+def test_real_inbound_source_tracks_human_session(adapter):57+ async def run():58+ received = []59+ async def handle(message):60+ received.append(message)61+ adapter.handle_message = handle62+ await adapter._on_event({"kind": 9, "id": "d" * 64, "pubkey": OWNER, "created_at": int(time.time()),63+ "content": "hello", "tags": [["h", "room"], ["p", BOT]]})64+ assert len(received) == 165+ key = adapter._event_session_key(received[0])66+ assert adapter._session_assignees[key] == OWNER67+ assert received[0].source.platform.value == "tiny"68+ asyncio.run(run())69+70+71+@pytest.mark.parametrize("multiple,content,expected", [(False, "c1", "beta"),72+ (True, '["c0"]', '["alpha"]'), (True, '["c0","c1"]', '["alpha", "beta"]'),73+ (False, '{"text":"my own answer"}', "my own answer")])74+def test_clarify_resolves_exact_real_waiter(adapter, multiple, content, expected):75+ async def run():76+ entry = clarify_gateway.register("question-id", "session", "Pick", ["alpha", "beta"], multiple)77+ untouched = clarify_gateway.register("other-question", "session", "Other", ["yes"])78+ result = await adapter.send_clarify("room", "Pick", ["alpha", "beta"], "question-id", "session")79+ assert result.success80+ request = adapter.rpc.events[-1]81+ assert ["p", OWNER] in request["tags"]82+ assert not any(tag[0] == "mention" for tag in request["tags"])83+ assert ["freeform", "true"] in request["tags"]84+ assert all("session" not in str(tag) for tag in request["tags"])85+ await adapter._on_answer(response(request, content))86+ assert entry.event.is_set() and entry.response == expected87+ assert not untouched.event.is_set()88+ asyncio.run(run())89+90+91+@pytest.mark.parametrize("mutation", ["actor", "room", "root", "kind", "invalid", "duplicate", "expired"])92+def test_invalid_answers_do_not_resolve(adapter, mutation):93+ async def run():94+ entry = clarify_gateway.register("question-id", "session", "Pick", ["alpha", "beta"], True)95+ await adapter.send_clarify("room", "Pick", ["alpha", "beta"], "question-id", "session")96+ request = adapter.rpc.events[-1]97+ answer = response(request, '["c0"]')98+ if mutation == "actor": answer["pubkey"] = OTHER99+ if mutation == "room": answer["tags"][0][1] = "elsewhere"100+ if mutation == "root": answer["tags"][2][1] = "f" * 64101+ if mutation == "kind": answer["kind"] = 7102+ if mutation == "invalid": answer["content"] = '["unknown"]'103+ if mutation == "duplicate": answer["content"] = '["c0","c0"]'104+ if mutation == "expired": adapter._pending[request["id"]].expires = int(time.time()) - 1105+ await adapter._on_answer(answer)106+ assert not entry.event.is_set()107+ asyncio.run(run())108+109+110+def test_open_ended_question_is_structured(adapter):111+ async def run():112+ entry = clarify_gateway.register("question-id", "session", "Explain", None)113+ await adapter.send_clarify("room", "Explain", None, "question-id", "session")114+ request = adapter.rpc.events[-1]115+ assert ["selection", "text"] in request["tags"]116+ await adapter._on_answer(response(request, "Here is my answer"))117+ assert entry.response == "Here is my answer"118+ asyncio.run(run())119+120+121+def test_approval_uses_exact_request_and_offered_scope(adapter):122+ async def run():123+ first = _ApprovalEntry({"command": "first", "request_id": "first-id"})124+ second = _ApprovalEntry({"command": "second", "request_id": "second-id"})125+ approval._gateway_queues["session"] = [first, second]126+ prompt = ExecApprovalPrompt("room", "session", "Allow second?", [("Once", "once", "primary"),127+ ("Deny", "deny", "danger")], "second", "description", False)128+ assert (await adapter._send_exec_approval_prompt(prompt)).success129+ request = adapter.rpc.events[-1]130+ await adapter._on_answer(response(request, "always"))131+ assert not first.event.is_set() and not second.event.is_set()132+ await adapter._on_answer(response(request, "once"))133+ assert not first.event.is_set() and second.event.is_set()134+ assert second.result == "once"135+ await adapter._on_answer(response(request, "once"))136+ assert not first.event.is_set()137+ asyncio.run(run())138+139+140+def test_confirmation_calls_real_handler_once(adapter):141+ async def run():142+ choices = []143+ async def handler(choice):144+ choices.append(choice)145+ return "Reloaded"146+ slash_confirm.register("session", "confirm-id", "reload", handler)147+ await adapter.send_slash_confirm("room", "Reload?", "Confirm", "session", "confirm-id")148+ request = adapter.rpc.events[-1]149+ await adapter._on_answer(response(request, "once"))150+ await adapter._on_answer(response(request, "once"))151+ assert choices == ["once"]152+ assert adapter.rpc.events[-1]["content"] == "Reloaded"153+ asyncio.run(run())154+155+156+def test_explicit_mentions_and_edits(adapter):157+ async def run():158+ result = await adapter.send("room", "ordinary", reply_to="d" * 64)159+ assert not any(tag[0] in ("p", "mention") for tag in adapter.rpc.events[-1]["tags"])160+ edited = await adapter.edit_message("room", result.message_id, f"Attention @{OWNER}")161+ assert edited.message_id == result.message_id162+ event = adapter.rpc.events[-1]163+ assert event["kind"] == 40003164+ assert ["p", OWNER] in event["tags"] and ["mention", OWNER] in event["tags"]165+ assert not await adapter.delete_message("room", result.message_id)166+ asyncio.run(run())167+168+169+def test_native_prompt_mentions_assignee_only_when_explicit(adapter):170+ async def run():171+ clarify_gateway.register("q", "session", "Pick", ["yes"])172+ await adapter.send_clarify("room", f"@{OWNER} Pick", ["yes"], "q", "session")173+ assert ["mention", OWNER] in adapter.rpc.events[-1]["tags"]174+ asyncio.run(run())175+176+177+def test_outbound_document_preserves_thread_caption_and_bytes(adapter, tmp_path):178+ import hashlib179+ data = b'attachment bytes'180+ path = tmp_path / 'note.txt'181+ path.write_bytes(data)182+ digest = hashlib.sha256(data).hexdigest()183+ uploads = []184+ async def upload(room, name, mime, body):185+ uploads.append((room, name, mime, body))186+ return {'url': f'http://tiny/media/{digest}', 'sha256': digest}187+ adapter.rpc.upload = upload188+ result = asyncio.run(adapter.send_document(chat_id='room', file_path=str(path),189+ file_name='renamed.txt', caption=f'For @{OWNER}', metadata={'thread_id': 'd' * 64}, reply_to='e' * 64))190+ assert result.success191+ event = adapter.rpc.events[-1]192+ assert uploads == [('room', 'renamed.txt', 'text/plain', data)]193+ assert event['kind'] == 12194+ assert ['e', 'd' * 64, '', 'root'] in event['tags']195+ assert ['e', 'e' * 64, '', 'reply'] in event['tags']196+ assert ['p', OWNER] in event['tags'] and ['mention', OWNER] in event['tags']197+ assert any(t[0] == 'imeta' and f'x {digest}' in t and 'size 16' in t for t in event['tags'])198+199+200+def test_standalone_delivery_uses_native_media(adapter, tmp_path, monkeypatch):201+ import adapter as module202+ import hashlib203+ path = tmp_path / 'voice.mp3'204+ path.write_bytes(b'audio bytes')205+ rpc = adapter.rpc206+ async def connect(): pass207+ async def close(): pass208+ async def upload(room, name, mime, body):209+ digest = hashlib.sha256(body).hexdigest()210+ return {'url': f'http://tiny/media/{digest}', 'sha256': digest}211+ rpc.connect, rpc.close, rpc.upload = connect, close, upload212+ monkeypatch.setattr(module, 'TinyRPC', lambda *a, **kw: rpc)213+ result = asyncio.run(module._standalone_send(adapter.config, 'room', f'For @{OWNER}',214+ thread_id='d' * 64, media_files=[(str(path), True)]))215+ assert result['success'] and result['media_delivered']216+ assert len(rpc.events) == 1217+ event = rpc.events[0]218+ assert event['kind'] == 12219+ assert ['mention', OWNER] in event['tags']220+ assert any(t[0] == 'imeta' and 'm audio/mpeg' in t for t in event['tags'])221+222+223+def test_outbound_media_rejects_wrong_relay_hash(adapter, tmp_path):224+ path = tmp_path / 'note.txt'225+ path.write_text('local content')226+ async def upload(*args): return {'url': 'http://tiny/media/' + '0' * 64, 'sha256': '0' * 64}227+ adapter.rpc.upload = upload228+ result = asyncio.run(adapter.send_document('room', str(path)))229+ assert not result.success and adapter.rpc.events == []230+231+232+def test_duplicate_callbacks_cannot_race_before_media_intake(adapter, monkeypatch):233+ import adapter as module234+ received = []235+ async def media(*args):236+ await asyncio.sleep(0.01)237+ return [], []238+ async def handle(message): received.append(message)239+ monkeypatch.setattr(module, 'incoming_media', media)240+ adapter.handle_message = handle241+ event = {'kind': 9, 'id': 'e' * 64, 'pubkey': OWNER, 'created_at': int(time.time()),242+ 'content': 'hello', 'tags': [['h', 'room'], ['p', BOT]]}243+ async def run(): await asyncio.gather(adapter._on_event(event), adapter._on_event(event))244+ asyncio.run(run())245+ assert len(received) == 1
A tinyagent/hermes/tests/test_ingress.py +62 -0
@@ -0,0 +1,62 @@
1+import json2+import hashlib3+import sys4+from pathlib import Path5+6+sys.path.insert(0, str(Path(__file__).parents[1]))7+8+from ingress import EventTracker, event_thread, is_reply_to_own, reply_parent9+10+11+def event(event_id, created=100, tags=None):12+ return {"id": hashlib.sha256(event_id.encode()).hexdigest(), "created_at": created, "tags": tags or []}13+14+15+def test_first_tracker_does_not_replay_history(tmp_path):16+ tracker = EventTracker("http://relay", "a" * 64, ["lab"], state_dir=tmp_path, now=100)17+ assert not tracker.should_accept(event("old", 99))18+ assert tracker.should_accept(event("same", 100))19+20+21+def test_persisted_cursor_overlaps_timestamp_and_deduplicates(tmp_path):22+ first = EventTracker("r", "a", ["lab"], state_dir=tmp_path, now=100)23+ first.mark(event("one", 101))24+ second = EventTracker("r", "a", ["lab"], state_dir=tmp_path, now=100)25+ assert second.filter_since()["since"] == 10026+ assert second.should_accept(event("two", 101))27+ assert not second.should_accept(event("one", 101))28+29+30+def test_expiration_and_bounded_seen(tmp_path):31+ tracker = EventTracker("r", "a", ["lab"], state_dir=tmp_path, now=100, max_seen=2)32+ assert not tracker.should_accept(event("expired", 100, [["expiration", "100"]]))33+ for index in range(3):34+ tracker.mark(event(str(index), 101 + index))35+ assert len(json.loads(tracker.path.read_text())["seen"]) == 236+37+38+def test_nip10_root_reply_and_own_parent():39+ root = "r" * 6440+ parent = "p" * 6441+ child = event("c", tags=[["e", root, "", "root"], ["e", parent, "", "reply"]])42+ assert event_thread(child) == root43+ assert reply_parent(child) == parent44+ assert is_reply_to_own(child, {parent})45+46+47+def test_overlap_accepts_out_of_order_events_and_survives_restart(tmp_path):48+ tracker = EventTracker('r', 'a', ['lab'], state_dir=tmp_path, now=100)49+ tracker.mark(event('newer', 400))50+ assert tracker.should_accept(event('earlier', 200))51+ tracker.mark(event('earlier', 200))52+ restored = EventTracker('r', 'a', ['lab'], state_dir=tmp_path, now=500)53+ assert restored.filter_since()['since'] == 10054+ assert not restored.should_accept(event('earlier', 200))55+ assert restored.should_accept(event('same-time', 400))56+57+58+def test_evicted_ids_cannot_become_commands_again(tmp_path):59+ tracker = EventTracker('r', 'a', ['lab'], state_dir=tmp_path, now=100, max_seen=2)60+ for i in range(3): tracker.mark(event(str(i), 101 + i))61+ assert not tracker.should_accept(event('0', 101))62+ assert tracker.should_accept(event('next', 104))
A tinyagent/hermes/tests/test_media.py +66 -0
@@ -0,0 +1,66 @@
1+import hashlib2+import sys3+from pathlib import Path4+5+import pytest6+7+sys.path.insert(0, str(Path(__file__).parents[1]))8+from media import incoming_media # noqa: E4029+10+11+class FakeRPC:12+ def __init__(self, data: bytes):13+ self.data = data14+ self.paths = []15+16+ async def download(self, path):17+ self.paths.append(path)18+ return self.data, "text/plain"19+20+21+@pytest.mark.asyncio22+async def test_incoming_media_caches_same_relay_content(tmp_path, monkeypatch):23+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))24+ data = b"tinyagent attachment\n"25+ digest = hashlib.sha256(data).hexdigest()26+ rpc = FakeRPC(data)27+ urls, types = await incoming_media(28+ rpc,29+ {"tags": [["imeta", f"url http://tiny:7447/media/{digest}", "m text/plain", "filename note.txt", f"x {digest}", f"size {len(data)}"]]},30+ "http://tiny:7447",31+ )32+ assert len(urls) == 1 and Path(urls[0]).is_file()33+ assert types == ["text/plain"]34+ assert rpc.paths == [f"/media/{digest}"]35+36+37+@pytest.mark.asyncio38+@pytest.mark.parametrize("url", ["https://example.com/media/" + "a" * 64, "http://tiny:7447/files/a"])39+async def test_incoming_media_rejects_external_or_non_media_url(url, tmp_path, monkeypatch):40+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))41+ rpc = FakeRPC(b"bad")42+ urls, _ = await incoming_media(rpc, {"tags": [["imeta", "url " + url]]}, "http://tiny:7447")43+ assert urls == [] and rpc.paths == []44+45+46+@pytest.mark.asyncio47+async def test_incoming_media_rejects_false_hash_and_size(tmp_path, monkeypatch):48+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))49+ data = b"actual"50+ digest = hashlib.sha256(data).hexdigest()51+ rpc = FakeRPC(data)52+ urls, _ = await incoming_media(53+ rpc,54+ {"tags": [["imeta", f"url /media/{digest}", f"x {'0' * 64}", "size 999"]]},55+ "http://tiny:7447",56+ )57+ assert urls == []58+59+60+def test_tenant_media_paths_remain_relative_to_helper_base():61+ from media import _media_path62+ digest = 'a' * 6463+ assert _media_path(f'https://tiny/r/team/media/{digest}', 'https://tiny/r/team') == (f'/media/{digest}', digest)64+ assert _media_path(f'https://tiny/r/other/media/{digest}', 'https://tiny/r/team') is None65+ assert _media_path(f'https://tiny/media/{digest}?token=no', 'https://tiny') is None66+ assert _media_path(f'/media/{digest}#fragment', 'https://tiny') is None
A tinyagent/main.go +350 -0
@@ -0,0 +1,350 @@
1+package main2+3+import (4+ "bufio"5+ "context"6+ "encoding/base64"7+ "encoding/json"8+ "errors"9+ "flag"10+ "fmt"11+ "io"12+ "os"13+ "sync"14+ "time"15+16+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"17+ "github.com/FelineStateMachine/tinyrelay/tinyagent/client"18+)19+20+type request struct {21+ ID json.RawMessage `json:"id"`22+ Method string `json:"method"`23+ Params json.RawMessage `json:"params"`24+}25+type response struct {26+ ID json.RawMessage `json:"id,omitempty"`27+ Result any `json:"result,omitempty"`28+ Error *rpcError `json:"error,omitempty"`29+ Event string `json:"event,omitempty"`30+ Subscription string `json:"subscription,omitempty"`31+ Data any `json:"data,omitempty"`32+}33+type rpcError struct {34+ Message string `json:"message"`35+}36+type rpcServer struct {37+ client *client.Client38+ outMu sync.Mutex39+ subMu sync.Mutex40+ subs map[string]context.CancelFunc41+ sem chan struct{}42+ wg sync.WaitGroup43+ subWG sync.WaitGroup44+}45+46+func main() {47+ if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {48+ fmt.Fprintln(os.Stderr, err)49+ os.Exit(1)50+ }51+}52+func run(args []string, in io.Reader, out, stderr io.Writer) error {53+ if len(args) == 0 {54+ return errors.New("usage: tinyagent keygen | rpc | call")55+ }56+ switch args[0] {57+ case "keygen":58+ return keygen(out)59+ case "rpc":60+ return rpc(args[1:], in, out)61+ case "call":62+ return call(args[1:], out)63+ default:64+ return fmt.Errorf("unknown command %q", args[0])65+ }66+}67+func keygen(out io.Writer) error {68+ secret, err := nostr.GenerateKey()69+ if err != nil {70+ return err71+ }72+ pub, err := nostr.PublicKey(secret)73+ if err != nil {74+ return err75+ }76+ return json.NewEncoder(out).Encode(map[string]string{"secret": secret, "pubkey": pub})77+}78+func rpc(args []string, in io.Reader, out io.Writer) error {79+ fs := flag.NewFlagSet("rpc", flag.ContinueOnError)80+ fs.SetOutput(io.Discard)81+ relay := fs.String("relay", "", "relay URL")82+ keyEnv := fs.String("key-env", "TINY_PRIVATE_KEY", "private key environment variable")83+ if err := fs.Parse(args); err != nil {84+ return err85+ }86+ if *relay == "" {87+ return errors.New("--relay is required")88+ }89+ secret := os.Getenv(*keyEnv)90+ if secret == "" {91+ return fmt.Errorf("%s is not set", *keyEnv)92+ }93+ c, err := client.New(*relay, secret)94+ if err != nil {95+ return err96+ }97+ s := &rpcServer{client: c, subs: make(map[string]context.CancelFunc), sem: make(chan struct{}, 32)}98+ return s.serve(in, out)99+}100+func (s *rpcServer) serve(in io.Reader, out io.Writer) error {101+ scan := bufio.NewScanner(in)102+ // A 32 MiB binary upload expands to roughly 43 MiB in base64, plus JSON103+ // framing and the request envelope.104+ scan.Buffer(make([]byte, 4096), 48<<20)105+ ctx, cancel := context.WithCancel(context.Background())106+ defer cancel()107+ for scan.Scan() {108+ var req request109+ if err := json.Unmarshal(scan.Bytes(), &req); err != nil {110+ s.write(out, response{Error: &rpcError{Message: "invalid request: " + err.Error()}})111+ continue112+ }113+ if req.Method != "subscribe" {114+ s.sem <- struct{}{}115+ }116+ s.wg.Add(1)117+ go func() {118+ defer s.wg.Done()119+ if req.Method != "subscribe" {120+ defer func() { <-s.sem }()121+ }122+ s.handle(ctx, req, out)123+ }()124+ }125+ s.wg.Wait()126+ s.stopAll()127+ s.subWG.Wait()128+ return scan.Err()129+}130+func (s *rpcServer) handle(parent context.Context, req request, out io.Writer) {131+ if req.Method == "subscribe" {132+ if err := s.launchSubscribe(parent, req.Params, out); err != nil {133+ s.write(out, response{ID: req.ID, Error: &rpcError{Message: err.Error()}})134+ return135+ }136+ s.write(out, response{ID: req.ID, Result: map[string]bool{"subscribed": true}})137+ return138+ }139+ ctx := parent140+ cancel := func() {}141+ if req.Method != "subscribe" {142+ ctx, cancel = context.WithTimeout(parent, 2*time.Minute)143+ }144+ defer cancel()145+ result, err := s.dispatch(ctx, req.Method, req.Params, out)146+ if err != nil {147+ s.write(out, response{ID: req.ID, Error: &rpcError{Message: err.Error()}})148+ return149+ }150+ s.write(out, response{ID: req.ID, Result: result})151+}152+func (s *rpcServer) dispatch(ctx context.Context, method string, raw json.RawMessage, out io.Writer) (any, error) {153+ var p map[string]json.RawMessage154+ if len(raw) > 0 {155+ if err := json.Unmarshal(raw, &p); err != nil {156+ return nil, err157+ }158+ }159+ switch method {160+ case "identity":161+ return s.client.Identity(), nil162+ case "publish":163+ var e nostr.Event164+ if err := json.Unmarshal(p["event"], &e); err != nil {165+ return nil, err166+ }167+ return s.client.Publish(ctx, e)168+ case "query":169+ var f []client.Filter170+ if err := json.Unmarshal(p["filter"], &f); err != nil {171+ var one client.Filter172+ if objectErr := json.Unmarshal(p["filter"], &one); objectErr != nil {173+ return nil, err174+ }175+ f = []client.Filter{one}176+ }177+ return s.client.Query(ctx, f)178+ case "request":179+ var path, method string180+ if err := json.Unmarshal(p["path"], &path); err != nil || path == "" {181+ return nil, errors.New("path is required")182+ }183+ if len(p["method"]) > 0 {184+ if err := json.Unmarshal(p["method"], &method); err != nil {185+ return nil, err186+ }187+ }188+ return s.client.Request(ctx, path, method, p["body"])189+ case "upload":190+ var room, filename, typ string191+ if err := json.Unmarshal(p["room"], &room); err != nil || room == "" {192+ return nil, errors.New("room is required")193+ }194+ if len(p["filename"]) > 0 {195+ if err := json.Unmarshal(p["filename"], &filename); err != nil {196+ return nil, err197+ }198+ }199+ if len(p["type"]) > 0 {200+ if err := json.Unmarshal(p["type"], &typ); err != nil {201+ return nil, err202+ }203+ }204+ var encoded string205+ if err := json.Unmarshal(p["data"], &encoded); err != nil {206+ return nil, err207+ }208+ data, err := decode(encoded)209+ if err != nil {210+ return nil, err211+ }212+ return s.client.Upload(ctx, room, filename, typ, data)213+ case "download":214+ var path string215+ if err := json.Unmarshal(p["path"], &path); err != nil {216+ return nil, err217+ }218+ return s.client.Download(ctx, path)219+ case "unsubscribe":220+ var name string221+ if err := json.Unmarshal(p["subscription"], &name); err != nil {222+ return nil, err223+ }224+ s.subMu.Lock()225+ stop := s.subs[name]226+ delete(s.subs, name)227+ s.subMu.Unlock()228+ if stop != nil {229+ stop()230+ }231+ return map[string]bool{"stopped": stop != nil}, nil232+ default:233+ return nil, fmt.Errorf("unknown method %q", method)234+ }235+}236+func (s *rpcServer) launchSubscribe(parent context.Context, raw json.RawMessage, out io.Writer) error {237+ var p map[string]json.RawMessage238+ if err := json.Unmarshal(raw, &p); err != nil {239+ return err240+ }241+ var name string242+ if err := json.Unmarshal(p["subscription"], &name); err != nil || name == "" {243+ return errors.New("subscription is required")244+ }245+ var f client.Filter246+ if err := json.Unmarshal(p["filter"], &f); err != nil {247+ return err248+ }249+ ctx, cancel := context.WithCancel(parent)250+ s.subMu.Lock()251+ previous := s.subs[name]252+ s.subs[name] = cancel253+ s.subMu.Unlock()254+ if previous != nil {255+ previous()256+ }257+ s.subWG.Add(1)258+ go func() {259+ defer s.subWG.Done()260+ err := s.client.Subscribe(ctx, name, f, func(kind string, e nostr.Event) {261+ if kind == "connected" {262+ s.write(out, response{Event: kind, Subscription: name})263+ return264+ }265+ s.write(out, response{Event: "event", Subscription: kind, Data: e})266+ })267+ if err != nil && parent.Err() == nil {268+ s.write(out, response{Event: "error", Subscription: name, Data: map[string]string{"message": err.Error()}})269+ }270+ // Keep the cancellation handle until an explicit unsubscribe. A newer271+ // subscription may already own this name when the old stream exits.272+ }()273+ return nil274+}275+func (s *rpcServer) subscribe(parent context.Context, p map[string]json.RawMessage, out io.Writer) error {276+ var name string277+ if err := json.Unmarshal(p["subscription"], &name); err != nil || name == "" {278+ return errors.New("subscription is required")279+ }280+ var f client.Filter281+ if err := json.Unmarshal(p["filter"], &f); err != nil {282+ return err283+ }284+ ctx, cancel := context.WithCancel(parent)285+ s.subMu.Lock()286+ s.subs[name] = cancel287+ s.subMu.Unlock()288+ return s.client.Subscribe(ctx, name, f, func(kind string, e nostr.Event) {289+ if kind == "connected" {290+ s.write(out, response{Event: kind, Subscription: name})291+ return292+ }293+ s.write(out, response{Event: "event", Subscription: kind, Data: e})294+ })295+}296+func (s *rpcServer) stopAll() {297+ s.subMu.Lock()298+ defer s.subMu.Unlock()299+ for name, stop := range s.subs {300+ stop()301+ delete(s.subs, name)302+ }303+}304+func (s *rpcServer) write(out io.Writer, v response) {305+ s.outMu.Lock()306+ defer s.outMu.Unlock()307+ _ = json.NewEncoder(out).Encode(v)308+}309+func decode(raw string) ([]byte, error) {310+ const max = 32 << 20311+ if len(raw) > max*2 {312+ return nil, errors.New("invalid attachment data")313+ }314+ b, err := base64.StdEncoding.DecodeString(raw)315+ if err != nil || len(b) > max {316+ return nil, errors.New("invalid attachment data")317+ }318+ return b, nil319+}320+func call(args []string, out io.Writer) error {321+ fs := flag.NewFlagSet("call", flag.ContinueOnError)322+ fs.SetOutput(io.Discard)323+ relay := fs.String("relay", "", "relay URL")324+ keyEnv := fs.String("key-env", "TINY_PRIVATE_KEY", "private key environment variable")325+ if err := fs.Parse(args); err != nil {326+ return err327+ }328+ if *relay == "" || fs.NArg() < 1 {329+ return errors.New("usage: tinyagent call --relay URL METHOD [JSON_PARAMS]")330+ }331+ secret := os.Getenv(*keyEnv)332+ if secret == "" {333+ return fmt.Errorf("%s is not set", *keyEnv)334+ }335+ c, err := client.New(*relay, secret)336+ if err != nil {337+ return err338+ }339+ method := fs.Arg(0)340+ raw := json.RawMessage(`{}`)341+ if fs.NArg() > 1 {342+ raw = json.RawMessage(fs.Arg(1))343+ }344+ server := &rpcServer{client: c, subs: make(map[string]context.CancelFunc), sem: make(chan struct{}, 1)}345+ result, err := server.dispatch(context.Background(), method, raw, out)346+ if err != nil {347+ return err348+ }349+ return json.NewEncoder(out).Encode(result)350+}
A tinyagent/main_test.go +46 -0
M tinyclient/chat-activity.go +29 -2
@@ -20,8 +20,13 @@ type chatActivityItem struct {
2020 CreatedAt, Expires int642121 Answer string2222 CanDecide, Question, Encrypted bool23+ Native, Multiple bool24+ Freeform bool25+ Interaction string26+ Options []chatActivityOption2327 Result *chatActivityResult2428 }29+type chatActivityOption struct{ ID, Label string }2530 type chatActivityResult struct{ ID, Content string }2631 type chatActivityPage struct {2732 Items []chatActivityItem
@@ -98,7 +103,20 @@ func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatA
98103 }99104 answer := valueMap(item["answer"])100105 decision := plainString(answer["decision"])101- items = append(items, chatActivityItem{Type: "approval", ID: id, Author: asker, Kind: plainString(item["kind"]), State: state, Title: truncateActivity(title, 150), Content: plainString(item["content"]), Answer: plainString(answer["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: expires, CanDecide: can && state == "open", Question: plainString(item["type"]) == "question", Decision: decision})106+ row := chatActivityItem{Type: "approval", ID: id, Author: asker, Kind: plainString(item["kind"]), State: state, Title: truncateActivity(title, 150), Content: plainString(item["content"]), Answer: plainString(answer["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: expires, CanDecide: can && state == "open", Question: plainString(item["type"]) == "question", Decision: decision}107+ rawOptions := collaborationSlice(item, "options")108+ if plainString(item["selection"]) != "" && (len(rawOptions) > 0 || eventHasTag(item, "tinyagent")) {109+ row.Native = true110+ row.Interaction = plainString(item["interaction"])111+ row.Question = row.Interaction == "question"112+ row.Multiple = plainString(item["selection"]) == "multiple"113+ row.Freeform = plainString(item["freeform"]) == "true" || plainString(item["selection"]) == "text"114+ for _, rawOption := range rawOptions {115+ option := valueMap(rawOption)116+ row.Options = append(row.Options, chatActivityOption{ID: plainString(option["id"]), Label: plainString(option["label"])})117+ }118+ }119+ items = append(items, row)102120 }103121 sort.SliceStable(items, func(i, j int) bool {104122 if items[i].CreatedAt == items[j].CreatedAt {
@@ -108,6 +126,15 @@ func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatA
108126 })109127 return items110128 }129+130+func eventHasTag(item map[string]any, name string) bool {131+ for _, tag := range tagValues(item["event"], name) {132+ if tag == "1" {133+ return true134+ }135+ }136+ return false137+}111138 func truncateActivity(value string, max int) string {112139 runes := []rune(value)113140 if len(runes) > max {
@@ -177,7 +204,7 @@ func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {
177204 w.Header().Set("Content-Type", "text/html; charset=utf-8")178205 var body bytes.Buffer179206 if err := a.tmpl.ExecuteTemplate(&body, "chatActivityResponse", page); err != nil {180- http.Error(w, "Activity rendering failed.", 500)207+ http.Error(w, "Activity rendering failed: "+err.Error(), 500)181208 return182209 }183210 _, _ = w.Write([]byte(injectBase(body.String(), requestPrefix(r))))
M tinyclient/chat-activity.html +2 -2
@@ -1,8 +1,8 @@
11 {{define "chatActivity"}}<chat-activity id="chat-activity" endpoint="{{.Endpoint}}" room="{{.Room}}" actor="{{.Actor}}" fx-action="{{.Endpoint}}" fx-trigger="tiny:activity-refresh" fx-target="#chat-activity-cards" fx-swap="morph"><div id="chat-activity-cards">{{template "chatActivityCards" .}}</div></chat-activity>{{end}}22 {{define "chatActivityResponse"}}<div id="chat-activity-cards">{{template "chatActivityCards" .}}</div>{{end}}33 {{define "chatActivityCards"}}{{if .Error}}<p role="status">{{.Error}}</p>{{else}}{{range .Items}}<article id="activity-{{.ID}}" data-activity="{{.Type}}" data-state="{{.State}}" data-event="{{.ID}}">4-{{if eq .Type "approval"}}{{if ne .State "open"}}<details data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></summary>{{end}}<header><strong>{{if .Question}}Question{{else}}Approval request{{end}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></header><p>{{.Title}}</p>{{if ne .Title .Content}}<details><summary>Details</summary><p>{{.Content}}</p></details>{{end}}4+{{if eq .Type "approval"}}{{if ne .State "open"}}<details data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></summary>{{end}}<header><strong>{{if eq .Interaction "approval"}}Approval request{{else if eq .Interaction "confirmation"}}Confirmation{{else if .Question}}Question{{else}}Approval request{{end}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></header><p>{{.Title}}</p>{{if ne .Title .Content}}<details><summary>Details</summary><p>{{.Content}}</p></details>{{end}}55 <p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals?id={{.ID}}">Open request</a></p>6-{{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}"{{if .Question}} question{{end}}>{{if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit">Reply</button>{{else}}<button type="submit" name="reaction" value="+">Approve</button><button type="submit" name="reaction" value="-">Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}6+{{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}" interaction="{{.Interaction}}" selection="{{if .Multiple}}multiple{{else if .Native}}{{if .Options}}single{{else}}text{{end}}{{end}}"{{if .Freeform}} freeform{{end}}{{if .Question}} question{{end}}{{if .Native}} native{{end}}{{if .Multiple}} multiple{{end}}>{{if .Native}}{{$multiple := .Multiple}}{{$freeform := .Freeform}}{{if .Options}}<fieldset><legend>Choose an option</legend>{{range .Options}}<label><input type="{{if $multiple}}checkbox{{else}}radio{{end}}" name="option" value="{{.ID}}"> {{.Label}}</label>{{end}}</fieldset>{{end}}{{if $freeform}}<label>Your own answer <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label>{{end}}<button type="submit">Submit</button>{{else if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit">Reply</button>{{else}}<button type="submit" name="reaction" value="+">Approve</button><button type="submit" name="reaction" value="-">Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}77 {{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>{{template "nostrName" .Author}}</small></p><div>{{chatMarkdown .Content}}</div>{{with .Result}}{{if .Content}}<h4>Result</h4><div>{{chatMarkdown .Content}}</div>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p></details>{{end}}88 </article>{{end}}{{end}}{{end}}
M tinyclient/chat-activity.js +18 -2
@@ -66,7 +66,23 @@
6666 const allowed=[...fresh.content.querySelectorAll("chat-decision")].find(el=>el.getAttribute("event")===id && el.getAttribute("actor")===actor && el.getAttribute("pubkey")===author);6767 if(!allowed) { host.schedule(); throw Error("This request is no longer waiting for your answer."); }6868 let unsigned;69- if(this.hasAttribute("question")) {69+ if(this.hasAttribute("native")) {70+ const selection=allowed.getAttribute("selection"),interaction=allowed.getAttribute("interaction");71+ if(!allowed.hasAttribute("native") || !["single","multiple","text"].includes(selection)) throw Error("This request changed. Refresh and try again.");72+ const field=form.elements.option;73+ const fields=field ? (field.length === undefined ? [field] : [...field]) : [];74+ const selected=fields.filter(input=>input.checked).map(input=>input.value);75+ const custom=(form.elements["custom-answer"]?.value || "").trim();76+ if(custom && (interaction !== "question" || !allowed.hasAttribute("freeform"))) throw Error("This request accepts options only.");77+ const freshOptions=[...(allowed.querySelectorAll?.('input[name="option"]') || [])].map(input=>input.value);78+ if(selected.some(value=>!freshOptions.includes(value))) { host.schedule(); throw Error("This request's options changed."); }79+ if(!selected.length && !custom) throw Error("Choose an option or write an answer.");80+ if(custom && selected.length) throw Error("Choose options or write your own answer, not both.");81+ if(!custom && (selection === "text" || (selection === "single" && selected.length !== 1))) throw Error("Choose one option.");82+ if(custom && custom.length > 8000) throw Error("Your answer is too long.");83+ const content=custom ? (selection === "text" ? custom : JSON.stringify({text:custom})) : (selection === "multiple" ? JSON.stringify(selected) : selected[0]);84+ unsigned={kind:1111,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["E",id,"",author],["K",this.getAttribute("kind")],["P",author],["e",id,"",author],["k",this.getAttribute("kind")],["p",author]],content};85+ } else if(this.hasAttribute("question")) {7086 const content=(form.elements.content?.value || "").trim(); if(!content) throw Error("Write an answer first.");7187 unsigned={kind:1111,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["E",id,"",author],["K",this.getAttribute("kind")],["P",author],["e",id,"",author],["k",this.getAttribute("kind")],["p",author]],content};7288 } else {
@@ -75,7 +91,7 @@
7591 unsigned={kind:7,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["e",id,"",author],["p",author],["k",this.getAttribute("kind")]],content:choice};7692 }7793 this.report("Signing…"); await tiny.signing.publish(unsigned);78- this.report("Answer sent."); this.querySelectorAll("button,textarea").forEach(el=>el.disabled=true);94+ this.report("Answer sent."); this.querySelectorAll("button,input,textarea").forEach(el=>el.disabled=true);7995 await host.refresh(true);8096 }8197 }
M tinyclient/chat_activity_http_test.go +3 -3
@@ -22,7 +22,7 @@ func (b *activityHTTPBackend) ReadChatActivity(_ context.Context, actor, room, r
2222 if b.denied {2323 return nil, errors.New("private details that must stay hidden")2424 }25- return map[string]any{"approvals": []any{map[string]any{"id": roomReply, "asker": roomAgent, "asked": []string{roomOwner}, "kind": 9, "state": "open", "subject": "Approve <script>bad()</script>", "content": "Review the preview"}}}, nil25+ return map[string]any{"approvals": []any{map[string]any{"id": roomReply, "asker": roomAgent, "asked": []string{roomOwner}, "kind": 9, "state": "open", "subject": "Approve <script>bad()</script>", "content": "Review the preview", "type": "question", "interaction": "approval", "selection": "single", "options": []any{map[string]any{"id": "yes", "label": "Yes"}, map[string]any{"id": "no", "label": "No"}}}}}, nil2626 }2727 2828 func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {
@@ -38,9 +38,9 @@ func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {
3838 app.ServeHTTP(res, req)3939 body := res.Body.String()4040 if res.Code != 200 || res.Header().Get("Cache-Control") != "private, no-store" || b.seenActor != roomOwner || b.seenRoom != "general" || b.seenRoot != roomThread {41- t.Fatalf("request context: %d %v %#v", res.Code, res.Header(), b)41+ t.Fatalf("request context: %d %v %#v body=%s", res.Code, res.Header(), b, res.Body.String())4242 }43- for _, want := range []string{`<chat-decision`, `href="/r/team/approvals?id=` + roomReply, `Approve &lt;script&gt;bad()&lt;/script&gt;`, `<noscript>`} {43+ for _, want := range []string{`<chat-decision`, `type="radio"`, `value="yes"`, `interaction="approval"`, `selection="single"`, `<strong>Approval request</strong>`, `href="/r/team/approvals?id=` + roomReply, `Approve &lt;script&gt;bad()&lt;/script&gt;`, `<noscript>`} {4444 if !strings.Contains(body, want) {4545 t.Errorf("missing %q in %s", want, body)4646 }
M tinyclient/chat_activity_test.go +14 -0