diff --git a/.github/workflows/bot-harness-deploy.yml b/.github/workflows/bot-harness-deploy.yml index a7ca0cf643..8f914d09b2 100644 --- a/.github/workflows/bot-harness-deploy.yml +++ b/.github/workflows/bot-harness-deploy.yml @@ -26,20 +26,11 @@ on: - "packages/openclaw/openclaw.plugin.json" - "packages/openclaw/tsconfig.json" - "packages/openclaw/scripts/**" - - "packages/hermes-tlon-adapter/__init__.py" - - "packages/hermes-tlon-adapter/adapter.py" - - "packages/hermes-tlon-adapter/approval.py" - - "packages/hermes-tlon-adapter/attention.py" - - "packages/hermes-tlon-adapter/channel_access.py" - - "packages/hermes-tlon-adapter/history.py" - - "packages/hermes-tlon-adapter/image_search.py" - - "packages/hermes-tlon-adapter/mention.py" - - "packages/hermes-tlon-adapter/owner_listen.py" - - "packages/hermes-tlon-adapter/presence.py" - - "packages/hermes-tlon-adapter/telemetry.py" - - "packages/hermes-tlon-adapter/tlon_api.py" - - "packages/hermes-tlon-adapter/tlon_tool.py" - - "packages/hermes-tlon-adapter/version.py" + # Every top-level runtime module, minus the test suite. A per-file list + # silently stopped deploying cite/lens/media/migration/nudge/sanitize as + # they were added; `*` does not match `/`, so dev/ and prompts/ stay out. + - "packages/hermes-tlon-adapter/*.py" + - "!packages/hermes-tlon-adapter/test_*.py" - "packages/hermes-tlon-adapter/plugin.yaml" - "packages/hermes-tlon-adapter/prompts/**" - "packages/hermes-tlon-adapter/requirements.txt" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f926bc77ac..95320b7e49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,6 +173,15 @@ jobs: - name: Run Hermes adapter unit tests run: ./packages/hermes-tlon-adapter/dev/test.sh + # The drift contract lives in @tloncorp/shared (it owns the static + # command lists) but its whole purpose is catching runtime changes — and + # a bot-only PR skips test-build, where the shared suite runs. Without + # this step, editing a runtime's command registry could never turn it red. + - name: Bot command drift contract + run: + pnpm --filter '@tloncorp/shared' test run + src/domain/runtimeCommandContract.test.ts + # Merge gate: the single required status check for this repo ("CI OK" in # branch protection). It aggregates the conditionally-skipped jobs above # into one context that always reports: a skipped need is fine (the filter diff --git a/.github/workflows/hermes-ci.yml b/.github/workflows/hermes-ci.yml index 8d43f3ec60..712a7a5f69 100644 --- a/.github/workflows/hermes-ci.yml +++ b/.github/workflows/hermes-ci.yml @@ -30,6 +30,25 @@ jobs: - name: Install peru run: pipx install --force peru + # Only the clone cache is restored. `.peru/lastimports` is deliberately + # left out: it records what peru already materialized, and a fresh + # checkout has no desk-deps/, so restoring it could make `peru sync` + # no-op and leave assemble-desk.sh failing on "desk-deps/ not found". + - name: Cache peru clone cache + uses: actions/cache@v4 + with: + path: .peru/cache + key: peru-cache-${{ hashFiles('peru.yaml') }} + + # Vendor desk deps here, not inside the E2E run. branch-desk.ts bounds + # its assemble-desk.sh call to 300s to contain a hung fetch, but that + # cap also covered `peru sync`, which clones ~900MB of upstream history + # (urbit/urbit, landscape) on a cold cache — ~2 minutes at best. Slow + # GitHub blew the cap and killed the job (exit 143) before a single + # scenario ran. Hoisted here it answers to the job timeout instead. + - name: Vendor desk dependencies + run: ./scripts/sync-deps.sh + - name: Setup Node.js environment uses: actions/setup-node@v4 with: @@ -49,7 +68,9 @@ jobs: # Keyed on the compose file so a pier-generation bump rolls the # cache: Actions caches are immutable, and a stale exact-key hit # would leave every job re-downloading ~1GB of archives forever. - key: tlon-bot-e2e-fake-ships-${{ hashFiles('packages/tlon-bot-e2e/docker/docker-compose.base.yml') }} + key: + tlon-bot-e2e-fake-ships-${{ + hashFiles('packages/tlon-bot-e2e/docker/docker-compose.base.yml') }} - name: Prepare fake ship cache run: mkdir -p .cache/tlon-bot-e2e/fake-ships @@ -62,6 +83,9 @@ jobs: - name: Run Hermes shared E2E env: + # desk-deps/ is vendored in the step above; assemble-desk.sh must + # not re-fetch inside branch-desk.ts's 300s cap. + SKIP_SYNC: "true" FAKE_SHIP_CACHE_DIR: ${{ github.workspace }}/.cache/tlon-bot-e2e/fake-ships TLON_BOT_E2E_RUN_ID: @@ -70,6 +94,9 @@ jobs: - name: Run Hermes cron partition E2E env: + # desk-deps/ is vendored in the step above; assemble-desk.sh must + # not re-fetch inside branch-desk.ts's 300s cap. + SKIP_SYNC: "true" FAKE_SHIP_CACHE_DIR: ${{ github.workspace }}/.cache/tlon-bot-e2e/fake-ships TLON_BOT_E2E_RUN_ID: @@ -79,6 +106,9 @@ jobs: - name: Run Hermes package smoke E2E env: + # desk-deps/ is vendored in the step above; assemble-desk.sh must + # not re-fetch inside branch-desk.ts's 300s cap. + SKIP_SYNC: "true" FAKE_SHIP_CACHE_DIR: ${{ github.workspace }}/.cache/tlon-bot-e2e/fake-ships TLON_BOT_E2E_RUN_ID: diff --git a/.github/workflows/openclaw-ci.yml b/.github/workflows/openclaw-ci.yml index f5a3707b48..030e6829da 100644 --- a/.github/workflows/openclaw-ci.yml +++ b/.github/workflows/openclaw-ci.yml @@ -72,6 +72,25 @@ jobs: - name: Install peru run: pipx install --force peru + # Only the clone cache is restored. `.peru/lastimports` is deliberately + # left out: it records what peru already materialized, and a fresh + # checkout has no desk-deps/, so restoring it could make `peru sync` + # no-op and leave assemble-desk.sh failing on "desk-deps/ not found". + - name: Cache peru clone cache + uses: actions/cache@v4 + with: + path: .peru/cache + key: peru-cache-${{ hashFiles('peru.yaml') }} + + # Vendor desk deps here, not inside the E2E run. branch-desk.ts bounds + # its assemble-desk.sh call to 300s to contain a hung fetch, but that + # cap also covered `peru sync`, which clones ~900MB of upstream history + # (urbit/urbit, landscape) on a cold cache — ~2 minutes at best. Slow + # GitHub blew the cap and killed the job (exit 143) before a single + # scenario ran. Hoisted here it answers to the job timeout instead. + - name: Vendor desk dependencies + run: ./scripts/sync-deps.sh + - name: Setup Node.js environment uses: actions/setup-node@v4 with: @@ -107,13 +126,18 @@ jobs: # Keyed on the compose file so a pier-generation bump rolls the # cache: Actions caches are immutable, and a stale exact-key hit # would leave every job re-downloading ~1GB of archives forever. - key: tlon-bot-e2e-fake-ships-${{ hashFiles('packages/tlon-bot-e2e/docker/docker-compose.base.yml') }} + key: + tlon-bot-e2e-fake-ships-${{ + hashFiles('packages/tlon-bot-e2e/docker/docker-compose.base.yml') }} - name: Prepare fake ship cache run: mkdir -p .cache/tlon-bot-e2e/fake-ships - name: Run OpenClaw shared E2E env: + # desk-deps/ is vendored in the step above; assemble-desk.sh must + # not re-fetch inside branch-desk.ts's 300s cap. + SKIP_SYNC: "true" TLONBOT_TOKEN: ${{ secrets.TLONBOT_TOKEN }} BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} # Reuse the S3 test-storage secrets already configured for the diff --git a/docs/bot-info.md b/docs/bot-info.md new file mode 100644 index 0000000000..2fbdb9b4ec --- /dev/null +++ b/docs/bot-info.md @@ -0,0 +1,111 @@ +# Bot info + +Bots publish **who they are** — harness and versions — in their own contact profile. The Tlon client reads that claim off the synced contact record and uses the claimed harness to pick one of its own static slash-command lists for the bot's conversations. + +Bots do **not** publish what they can do. Command lists live in the app (`packages/shared/src/domain/slashCommands.ts`), bound to each runtime's actual command registry by a CI drift contract (see [Command lists](#command-lists)). A command change therefore ships as an app release, and a third-party bot cannot advertise custom commands — an unknown or absent harness gets the default (OpenClaw) list. + +No Hoon/desk changes are involved: a v1 contact is an open key-value map (`+$ contact (map @tas value)`, `desk/sur/contacts.hoon`), unknown keys pass validation and replicate to subscribers, and the client's contacts pipeline carries the key through. + +## Wire format + +- Contact key: `bot-info` (`@tas`-safe). +- Value: a `%text` contact field whose text is JSON. +- The claim is self-published by the bot ship via a `%self` contact action (a merge — nickname/avatar/other keys survive), and propagates to peers through ordinary contact sync (`/v1/news` `%peer`/`%page` facts, `/v1/book`, `/v1/contact/{ship}`). + +```json +{ "type": "text", "value": "{\"v\":1,\"harness\":\"openclaw\",\"version\":\"0.19.0\",\"harnessVersion\":\"2026.5.28\"}" } +``` + +## JSON schema + +```json +{ + "v": 1, + "harness": "openclaw", + "version": "0.19.0", + "harnessVersion": "2026.5.28" +} +``` + +| field | type | notes | +| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `v` | `number` | Format version. Anything other than `1` rejects the whole claim. | +| `harness` | `string` (required) | Non-empty. Matched **case-sensitively** against known ids (`openclaw`, `hermes`); anything else is stored but selects the default list. | +| `version` | `string` (required) | Non-empty. The plugin/adapter's own version — first-party knowledge. | +| `harnessVersion` | `string` (optional) | Non-empty when present. The underlying agent runtime's version; a diagnostic rider, never load-bearing. | + +Unknown fields are ignored (forward compatibility). Wrong types — arrays, numbers, `null` where a string belongs — reject the claim rather than being coerced. + +### Why `harnessVersion` is optional + +It is a diagnostic rider on an identity claim, and a missing rider must never invalidate the claim. The sources below are conventions of the current host versions, not APIs; a future host restructuring them must cost a diagnostic field, never harness selection. Publishers treat a failed sourcing as a loud warning in their own logs — for the in-repo runtimes it always indicates breakage — while the wire stays tolerant. + +- OpenClaw: `api.runtime.version` (the plugin SDK's host runtime). +- Hermes: `from hermes_cli import __version__, __release_date__` → `"0.17.0 (2026.6.19)"`. Both numbers: the SemVer alone matches nothing the pins or the README use, and the CalVer matches the release tag. This is byte-for-byte the source core's own `/version` command reads, guaranteed importable because the gateway process itself is `hermes_cli`. Fallback: `importlib.metadata.version("hermes-agent")` (SemVer only; beware editable-install staleness — dist-info freezes at install time). Then omit. Never `git describe` (production strips `.git`) and never a `hermes --version` subprocess. + +### These are version claims, not code-identity claims + +The constants bump at release-cut, so a build off an unreleased ref reports the last release. Commit-level identity (`get_build_sha()`) exists but returns nothing in current environments; if it is ever wanted, it becomes a separate optional `harnessCommit` — never folded into `harnessVersion`. + +## Caps + +Client-enforced at parse time, in one pure function (`parseBotInfo` in `packages/shared/src/domain/slashCommands.ts`): + +- Raw claim: ≤ **512 UTF-8 bytes** (`new TextEncoder().encode(raw).byteLength` in TS, `len(raw.encode('utf-8'))` in Python). +- Each field: ≤ **64 Unicode code points**, non-empty. + +These are abuse bounds on an identity field, not a data budget. The backend additionally caps the **whole jammed profile** (bio, groups, attestations, claim included) at 10 kB (`desk/lib/contacts.hoon`), so publishers must treat a rejected poke as a real, non-fatal outcome — the bot keeps working and clients fall back to the default list. + +## Publishing + +Compare-then-poke at boot (and on reconnect catch-up): compute the claim JSON, read the current self-contact (`/contacts/v1/self`), and poke only when the value differs. Byte-stable serialization (fixed key order) so the comparison does not false-positive. Example poke, via Eyre or any existing poke path: + +```json +{ + "app": "contacts", + "mark": "contact-action-1", + "json": { + "self": { + "bot-info": { + "type": "text", + "value": "{\"v\":1,\"harness\":\"openclaw\",\"version\":\"0.19.0\"}" + } + } + } +} +``` + +## Clearing the key (rollback / retirement) + +`%self` is a merge: contact keys die only by explicit `null`. Reverting or downgrading a runtime, or retiring a bot, leaves the last-published claim in the profile until it is cleared. Poke: + +```json +{ + "app": "contacts", + "mark": "contact-action-1", + "json": { "self": { "bot-info": null } } +} +``` + +runnable via each runtime's existing poke path or curl against Eyre. Clients then fall back to the default list. A runtime _switch_ needs no clearing — the new runtime's differing claim overwrites on first boot. + +## Client consumption + +- The raw JSON is stored on the contact row (`contacts.bot_info`) and validated only at read. +- `useBotSlashCommandManifest` (`packages/shared/src/store/useBotSlashCommandManifest.ts`) resolves the bot ship for DM channels, parses the claim, and passes `harness` to `getStaticSlashCommandManifest`. Gating (which conversations get a popup at all) is unchanged. +- Cold-start backfill: the legacy v0 `/all` peers scry strips namespaced keys, so the client fetches `/v1/contact/{ship}` on demand for qualifying bot channels that lack a claim. + +## Command lists + +Each harness's static list in `packages/shared/src/domain/slashCommands.ts` is split into two explicitly named parts: + +- **`*_RUNTIME_COMMANDS`** — what the runtime itself handles. Each runtime commits a token-only fixture generated from its own command registry (`packages/openclaw/fixtures/commands.json`, `packages/hermes-tlon-adapter/fixtures/commands.json`), and `packages/shared/src/domain/runtimeCommandContract.test.ts` asserts the fixture's tokens and the static list's tokens are equal once both are sorted. An addition, a removal, or a duplicate in a runtime turns that test red on the PR that makes it — note that sorted-sequence equality is what catches the duplicate; comparing `Set`s instead would silently drop that case. The CI job `bot-checks` runs the contract so a runtime-only PR — which skips the app-wide suite — still executes it. +- **`*_CORE_COMMANDS`** — host-provided commands the runtime neither registers nor dispatches. Neither host exposes its registry to us, so no CI binding is possible: these are deliberate, audit-pinned constants, and changing one means re-auditing the host first. The audit citations live in comments on the lists. + +The split is display-neutral. Presentation order comes from each entry's `priority` — `rankSlashCommands` sorts by it and never by array position — so membership lives in the two arrays and ordering lives in the priorities, asserted through the production ranking function in `packages/app/ui/components/BareChatInput/useSlashCommands.test.ts`. + +**Removals are two-phase.** Hosted bots redeploy from the tracked branch on container restart while the app releases slowly, so removing a runtime command means keeping its handler alive until an app release stops suggesting it. The contract test turning red on the removal PR is the reminder. + +### Icons + +Static-list entries carry an `icon`: the **name** of a glyph in the client's built-in icon set (`packages/ui/src/assets/icons`) — not a URL and not an image. Unknown names degrade to the generic command glyph; `packages/app/ui/components/SlashCommandPopup.test.ts` asserts every name in every static list resolves, so a typo cannot silently degrade. diff --git a/packages/api/src/__tests__/contactsApi.test.ts b/packages/api/src/__tests__/contactsApi.test.ts index 4eaac47c6c..1634344979 100644 --- a/packages/api/src/__tests__/contactsApi.test.ts +++ b/packages/api/src/__tests__/contactsApi.test.ts @@ -1,9 +1,30 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import { + type ContactsUpdate, + contactToClientProfile, + extractBotInfoValue, + getContactProfile, + subscribeToContactUpdates, v0PeerToClientProfile, v0PeersToClientProfiles, + v1PeerToClientProfile, } from '../client/contactsApi'; +import { scry, subscribe } from '../client/urbit'; +import type { ContactBookProfile } from '../urbit/contact'; + +vi.mock('../client/urbit', async () => { + const actual = + await vi.importActual('../client/urbit'); + return { + ...actual, + scry: vi.fn(), + subscribe: vi.fn(), + }; +}); + +const scryMock = scry as unknown as ReturnType; +const subscribeMock = subscribe as unknown as ReturnType; const inputContact: [string, any] = [ 'test', @@ -52,3 +73,164 @@ test('converts an array of contacts from server to client format', () => { v0PeersToClientProfiles({ [inputContact[0]]: inputContact[1] }) ).toStrictEqual([outputContact]); }); + +describe('bot-info contact field', () => { + const claimJson = JSON.stringify({ + v: 1, + harness: 'openclaw', + version: '0.19.0', + }); + + test('v1 peer mapper carries a well-formed text field', () => { + const contact = v1PeerToClientProfile('~bot', { + nickname: { type: 'text', value: 'Bot' }, + 'bot-info': { type: 'text', value: claimJson }, + }); + expect(contact.botInfo).toBe(claimJson); + }); + + test('v1 peer mapper clears (null) when the field is absent', () => { + const contact = v1PeerToClientProfile('~bot', { + nickname: { type: 'text', value: 'Bot' }, + }); + expect(contact.botInfo).toBeNull(); + }); + + test.each([ + ['set field', { type: 'set', value: [] }], + ['numb field', { type: 'numb', value: '0x1' }], + ['look field', { type: 'look', value: 'https://example.com' }], + ['text field with non-string value', { type: 'text', value: 42 }], + ['text field missing value', { type: 'text' }], + ['bare string', claimJson], + ['array', [{ type: 'text', value: claimJson }]], + ['null', null], + ])('v1 peer mapper rejects wrong shape: %s', (_label, field) => { + const contact = v1PeerToClientProfile('~bot', { + 'bot-info': field, + } as unknown as ContactBookProfile); + expect(contact.botInfo).toBeNull(); + }); + + test('book mapper reads the base contact, not the mod overlay', () => { + const contact = contactToClientProfile('~bot', [ + { 'bot-info': { type: 'text', value: claimJson } }, + { + 'bot-info': { + type: 'text', + value: '{"v":1,"harness":"hermes","version":"9"}', + }, + }, + ]); + expect(contact.botInfo).toBe(claimJson); + }); + + test('book mapper carries the base field when there is no overlay', () => { + const contact = contactToClientProfile('~bot', [ + { 'bot-info': { type: 'text', value: claimJson } }, + null, + ]); + expect(contact.botInfo).toBe(claimJson); + }); + + test('book mapper ignores a claim that only exists in the overlay', () => { + const contact = contactToClientProfile('~bot', [ + {}, + { 'bot-info': { type: 'text', value: claimJson } }, + ]); + expect(contact.botInfo).toBeNull(); + }); + + test('extractBotInfoValue accepts only text-shaped fields', () => { + expect(extractBotInfoValue({ type: 'text', value: claimJson })).toBe( + claimJson + ); + expect(extractBotInfoValue(undefined)).toBeNull(); + expect(extractBotInfoValue({ type: 'text', value: null })).toBeNull(); + expect(extractBotInfoValue({ value: claimJson })).toBeNull(); + }); +}); + +// The two carriers that recover a bot's identity claim after the lossy v0 +// `/all` sync: the live `/v1/news` subscription and the targeted v1 scry. +describe('bot-info sync carriers', () => { + const claim = JSON.stringify({ + v: 1, + harness: 'openclaw', + version: '0.19.0', + }); + + function capturedNewsHandler() { + const updates: ContactsUpdate[] = []; + subscribeMock.mockClear(); + subscribeToContactUpdates((update) => updates.push(update)); + const [params, onEvent] = subscribeMock.mock.calls[0]; + expect(params).toEqual({ app: 'contacts', path: '/v1/news' }); + return { updates, onEvent: onEvent as (event: unknown) => void }; + } + + test('a %peer fact carries the claim through the subscription', () => { + const { updates, onEvent } = capturedNewsHandler(); + + onEvent({ + peer: { + who: '~bot', + contact: { 'bot-info': { type: 'text', value: claim } }, + }, + }); + + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ + type: 'upsertContact', + contact: { id: '~bot', botInfo: claim }, + }); + }); + + test('a %page fact carries the claim from the base contact', () => { + const { updates, onEvent } = capturedNewsHandler(); + + onEvent({ + page: { + kip: '~bot', + contact: { 'bot-info': { type: 'text', value: claim } }, + mod: null, + }, + }); + + expect(updates[0]).toMatchObject({ + type: 'upsertContact', + contact: { id: '~bot', botInfo: claim }, + }); + }); + + test('a fact without the key clears the stored claim', () => { + const { updates, onEvent } = capturedNewsHandler(); + + onEvent({ peer: { who: '~bot', contact: { nickname: 'Bot' } } }); + + expect(updates[0]).toMatchObject({ + type: 'upsertContact', + contact: { id: '~bot', botInfo: null }, + }); + }); + + test('getContactProfile scries the un-suffixed v1 contact path', async () => { + scryMock.mockResolvedValueOnce({ + 'bot-info': { type: 'text', value: claim }, + }); + + const contact = await getContactProfile('~bot'); + + // No `.json` — the transport appends it; a suffixed path 404s. + expect(scryMock).toHaveBeenCalledWith({ + app: 'contacts', + path: '/v1/contact/~bot', + }); + expect(contact?.botInfo).toBe(claim); + }); + + test('getContactProfile returns null when the scry fails', async () => { + scryMock.mockRejectedValueOnce(new Error('404')); + expect(await getContactProfile('~bot')).toBeNull(); + }); +}); diff --git a/packages/api/src/client/contactsApi.ts b/packages/api/src/client/contactsApi.ts index 1912735ce4..ffb100df8c 100644 --- a/packages/api/src/client/contactsApi.ts +++ b/packages/api/src/client/contactsApi.ts @@ -11,7 +11,17 @@ import { normalizeUrbitColor } from './utils'; const logger = createDevLogger('contactsApi', false); -export const getContacts = async () => { +export interface ContactsData { + // Peers from the legacy v0 `/all` scry. Lossy: the v0 mark strips + // namespaced keys (e.g. `bot-info`), so these rows carry no signal + // about fields like the bot's identity claim. + v0Peers: db.Contact[]; + // Contact-book entries from the v1 `/book` scry. Authoritative: full + // peer-published profile plus user overrides. + v1Contacts: db.Contact[]; +} + +export const getContactsByProvenance = async (): Promise => { // this is all peers we know about, with merged profile data for // contacts const peersResponse = await scry({ @@ -37,6 +47,11 @@ export const getContacts = async () => { }); }; +export const getContacts = async (): Promise => { + const { v0Peers, v1Contacts } = await getContactsByProvenance(); + return [...v0Peers, ...v1Contacts]; +}; + export const toContactsData = ({ peersResponse, contactsResponse, @@ -45,7 +60,7 @@ export const toContactsData = ({ peersResponse: ub.ContactRolodex; contactsResponse: ub.ContactBookScryResult1; suggestionsResponse: string[]; -}) => { +}): ContactsData => { const skipContacts = new Set(Object.keys(contactsResponse)); const contactSuggestions = new Set(suggestionsResponse); @@ -57,7 +72,7 @@ export const toContactsData = ({ contactSuggestions, }); - return [...peerProfiles, ...contactProfiles]; + return { v0Peers: peerProfiles, v1Contacts: contactProfiles }; }; export const removeContactSuggestion = async (contactId: string) => { @@ -464,6 +479,46 @@ function parseContactAttestations( return finalAttests; } +/** + * The `bot-info` contact field is self-published by bot ships and its TS + * declaration proves nothing at runtime — an arbitrary profile can publish it + * as %set/%numb/%look or any JSON shape. Accept only a %text field carrying a + * string; everything else maps to null so one bad peer profile cannot break a + * contacts sync batch. + */ +export const extractBotInfoValue = (field: unknown): string | null => { + if (!field || typeof field !== 'object' || Array.isArray(field)) { + return null; + } + const candidate = field as { type?: unknown; value?: unknown }; + if (candidate.type !== 'text' || typeof candidate.value !== 'string') { + return null; + } + return candidate.value; +}; + +// Fetch a single peer's full v1 contact profile. Used to backfill the +// identity claim for bots, which the lossy v0 `/all` peers scry +// strips. Returns null when the ship is unknown (404) or the scry fails. +export const getContactProfile = async ( + ship: string +): Promise => { + try { + // No `.json` suffix — the transport appends it. + const contact = await scry({ + app: 'contacts', + path: `/v1/contact/${ship}`, + }); + if (!contact || typeof contact !== 'object') { + return null; + } + return v1PeerToClientProfile(ship, contact); + } catch (e) { + logger.log('getContactProfile failed', e); + return null; + } +}; + export const v1PeersToClientProfiles = ( peers: ub.ContactsAllScryResult1, config?: { @@ -500,6 +555,7 @@ export const v1PeerToClientProfile = ( contactId: id, })) ?? [], attestations: parseContactAttestations(id, contact), + botInfo: extractBotInfoValue(contact['bot-info']), isContact: config?.isContact, isContactSuggestion: config?.isContactSuggestion && !config?.isContact && id !== currentUserId, @@ -548,6 +604,9 @@ export const contactToClientProfile = ( contactId: userId, })) ?? [], attestations: parseContactAttestations(userId, base), + // The claim is the bot's own published property: read it from the + // peer-published base contact only, never the user's `mod` overlay. + botInfo: extractBotInfoValue(base['bot-info']), isContact: !!overrides, isContactSuggestion: false, }; diff --git a/packages/api/src/types/models.ts b/packages/api/src/types/models.ts index 9f67b3a74f..523bc97d6d 100644 --- a/packages/api/src/types/models.ts +++ b/packages/api/src/types/models.ts @@ -93,6 +93,9 @@ export interface Contact extends WithId { systemContactId?: string | null; pinnedGroups?: ContactPinnedGroups | null; attestations?: any[] | null; + // Raw JSON of the bot's self-published identity claim (harness and + // versions), as published in its contact profile. Validated at read. + botInfo?: string | null; } export type ContactPinnedGroups = any[]; diff --git a/packages/api/src/urbit/contact.ts b/packages/api/src/urbit/contact.ts index d17fbf334e..c622af9035 100644 --- a/packages/api/src/urbit/contact.ts +++ b/packages/api/src/urbit/contact.ts @@ -95,6 +95,10 @@ export interface ContactBookProfile { ['lanyard-phone-0-sign']?: AttestationSignature; ['lanyard-twitter-0-url']?: AttestationProviderUrl; ['lanyard-phone-0-url']?: AttestationProviderUrl; + // Self-published bot identity claim (JSON-in-text). Declared as %text but + // treated as untrusted at runtime — any ship can publish any value type + // under an unknown key. + ['bot-info']?: ContactFieldText; } export interface ContactBookProfileEdit { diff --git a/packages/app/ui/components/BareChatInput/useSlashCommands.test.ts b/packages/app/ui/components/BareChatInput/useSlashCommands.test.ts index a9a90477f4..0d20fa941e 100644 --- a/packages/app/ui/components/BareChatInput/useSlashCommands.test.ts +++ b/packages/app/ui/components/BareChatInput/useSlashCommands.test.ts @@ -13,6 +13,22 @@ import { const openclaw = getStaticSlashCommandManifest('openclaw').commands; const hermes = getStaticSlashCommandManifest('hermes').commands; +// A list shaped like neither static one, to keep the ranking assertions about +// the pure function rather than about today's curated lists. +const other: SlashCommandOption[] = [ + { + command: '/compress', + title: 'Compress context', + keywords: ['compact', 'context', 'summarize'], + priority: 1, + }, + { + command: '/model', + title: 'Model', + keywords: ['model', 'provider'], + priority: 2, + }, +]; const commandNames = (options: SlashCommandOption[]) => options.map((o) => o.command); @@ -134,8 +150,58 @@ describe('rankSlashCommands', () => { expect(rankSlashCommands(openclaw, 'STAT')[0].command).toBe('/status'); }); - test('hermes /compress is found via the compact keyword', () => { - expect(rankSlashCommands(hermes, 'compact')[0].command).toBe('/compress'); + test('/compress is found via the compact keyword', () => { + expect(rankSlashCommands(other, 'compact')[0].command).toBe('/compress'); + }); + + // The static lists' presentation order is carried by `priority`, not by + // array position — this is the function that renders it, and the empty-query + // popup shows 4 rows on native / 7 on web (SlashCommandPopup maxResults), so + // the leading rows are the most user-visible part of the ordering decision. + // + // The full sequences are pinned, not just the ends: a mid-list swap (say + // /ban ahead of /unban) never reaches the empty-query first page but does + // reorder a query that matches both, and end-anchored assertions cannot see + // it. Update these lists deliberately when the owner reorders a list. + describe('static list ordering', () => { + test('openclaw ranks in its curated order', () => { + expect(commandNames(rankSlashCommands(openclaw, ''))).toEqual([ + '/owner-listen', + '/status', + '/help', + '/new', + '/pending', + '/allow', + '/reject', + '/ban', + '/banned', + '/unban', + '/tlon-version', + '/tlon', + '/migrate', + ]); + }); + + test('hermes ranks core discovery, the adapter commands, then core tail', () => { + expect(commandNames(rankSlashCommands(hermes, ''))).toEqual([ + '/help', + '/status', + '/new', + '/owner-listen', + '/migrate', + '/tlon', + '/allow', + '/reject', + '/ban', + '/unban', + '/pending', + '/banned', + '/channel-access', + '/stop', + '/usage', + '/model', + ]); + }); }); }); diff --git a/packages/app/ui/components/SlashCommandPopup.test.ts b/packages/app/ui/components/SlashCommandPopup.test.ts new file mode 100644 index 0000000000..0a0fed25b3 --- /dev/null +++ b/packages/app/ui/components/SlashCommandPopup.test.ts @@ -0,0 +1,103 @@ +import { STATIC_MANIFESTS } from '@tloncorp/shared/domain'; +import * as icons from '@tloncorp/ui/assets/icons'; +import { describe, expect, it } from 'vitest'; + +import { makeIconResolver, toIconType } from './slashCommandIcon'; + +// A CJS-interop namespace, which is what the native Babel build produces: an +// ordinary object (so Object.prototype is in its chain) carrying `__esModule`. +// The test runner's own namespace is true ESM — null prototype, no +// `__esModule` — so the hostile keys are simply absent there and a vulnerable +// `in` check would look safe. Injecting the shape is the only way to bind the +// real behavior. +function cjsIconNamespace() { + const ns: Record = { + __esModule: true, + default: {}, + Command: () => null, + Bang: () => null, + }; + return ns; +} + +describe('makeIconResolver', () => { + const resolve = makeIconResolver(cjsIconNamespace()); + + it('accepts a real icon export', () => { + expect(resolve('Command')).toBe('Command'); + expect(resolve('Bang')).toBe('Bang'); + }); + + // Each of these passes `name in ns` on a CJS namespace and would then be + // rendered as a component: `__esModule` is a boolean, the rest are inherited + // functions/accessors. Any one of them crashes the composer for every `/` + // press in that conversation. + it.each([ + '__esModule', + 'default', + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty', + '__proto__', + ])('falls back to Command for hostile key %s', (hostile) => { + expect(resolve(hostile)).toBe('Command'); + }); + + it('falls back for unknown, empty, and absent names', () => { + expect(resolve('NotARealIcon')).toBe('Command'); + expect(resolve('')).toBe('Command'); + expect(resolve(undefined)).toBe('Command'); + }); + + it('only ever returns a key the namespace really owns', () => { + const ns = cjsIconNamespace(); + const real = Object.keys(ns).filter( + (key) => key !== '__esModule' && key !== 'default' + ); + const hostileResolve = makeIconResolver(ns); + + for (const name of ['Command', '__esModule', 'constructor', 'nope']) { + expect(real).toContain(hostileResolve(name)); + } + }); +}); + +// The exported resolver is the one the popup uses; confirm it is bound to the +// real icon module and still refuses the hostile names. +describe('toIconType', () => { + it('resolves a real icon and rejects metadata keys', () => { + expect(Object.keys(icons)).toContain(toIconType('Command')); + expect(toIconType('__esModule')).toBe('Command'); + expect(toIconType('constructor')).toBe('Command'); + expect(toIconType('NotARealIcon')).toBe('Command'); + }); +}); + +// The static command lists name icons from the client's bundled set. The +// shared package asserts every entry *has* an icon, but only this package +// knows whether a name resolves — a typo like "Chekmark" would pass there and +// silently degrade to the generic glyph. This is the only package that depends +// on the icon set, so the resolvability contract lives here. +describe('static command lists', () => { + const harnesses = Object.keys( + STATIC_MANIFESTS + ) as (keyof typeof STATIC_MANIFESTS)[]; + + it('covers every harness', () => { + expect(harnesses).toEqual(['openclaw', 'hermes']); + }); + + it.each(harnesses)('every %s icon resolves to a real glyph', (harness) => { + const { commands } = STATIC_MANIFESTS[harness]; + + expect(commands.length).toBeGreaterThan(0); + for (const entry of commands) { + expect(entry.icon, `${entry.command} must carry an icon`).toBeTruthy(); + expect( + toIconType(entry.icon), + `${entry.command} icon "${entry.icon}" must resolve, not fall back` + ).toBe(entry.icon); + } + }); +}); diff --git a/packages/app/ui/components/SlashCommandPopup.tsx b/packages/app/ui/components/SlashCommandPopup.tsx index 06d6d37106..ea107ec65e 100644 --- a/packages/app/ui/components/SlashCommandPopup.tsx +++ b/packages/app/ui/components/SlashCommandPopup.tsx @@ -1,6 +1,5 @@ import type { SlashCommandOption } from '@tloncorp/shared/domain'; -import { type IconType, Pressable } from '@tloncorp/ui'; -import * as icons from '@tloncorp/ui/assets/icons'; +import { Pressable } from '@tloncorp/ui'; import React, { PropsWithRef, useEffect, @@ -13,6 +12,7 @@ import { Platform } from 'react-native'; import { ContactList } from './ContactList'; import { ListItem } from './ListItem'; import { useBoundHandler } from './listItems/listItemUtils'; +import { toIconType } from './slashCommandIcon'; export interface SlashCommandController { handleSlashCommandKey(key: 'ArrowUp' | 'ArrowDown' | 'Enter'): void; @@ -20,13 +20,6 @@ export interface SlashCommandController { export type SlashCommandPopupRef = React.RefObject; -// Manifest icons are plain name strings (they may come from a fetched, future -// hosting-served manifest). Resolve to a known IconType, falling back to a -// generic command glyph for anything unrecognized. -function toIconType(name?: string): IconType { - return name && name in icons ? (name as IconType) : 'Command'; -} - function SlashCommandOptionItem({ selected, option, diff --git a/packages/app/ui/components/slashCommandIcon.ts b/packages/app/ui/components/slashCommandIcon.ts new file mode 100644 index 0000000000..ba7ba5e4c4 --- /dev/null +++ b/packages/app/ui/components/slashCommandIcon.ts @@ -0,0 +1,40 @@ +import type { IconType } from '@tloncorp/ui'; +import * as icons from '@tloncorp/ui/assets/icons'; + +// Module metadata that shows up as a real own key under CJS interop but is not +// an icon. +const NON_ICON_KEYS = new Set(['__esModule', 'default']); + +/** + * Build an icon-name resolver bound to a module namespace. + * + * Icon names now come from this app's own static command lists, not from the + * wire — but whatever the resolver returns is looked up in that same namespace + * and rendered as a React component, so the lookup still has to be exact. + * + * `name in ns` would not do. Under the native Babel/CJS interop the namespace + * is an ordinary object carrying `__esModule` (a boolean) and inheriting + * `constructor`, `toString` and `__proto__` — all of which pass an `in` check + * and are then handed to the renderer, so a typo'd `icon: "__esModule"` in a + * static list would crash the composer for the whole conversation rather than + * falling back to the default glyph. (A true ESM namespace + * has a null prototype and no `__esModule`, which is why this is only reachable + * in some builds — and why the tests inject a CJS-shaped namespace rather than + * relying on whatever the test runner happens to produce.) + * + * Membership is an own-enumerable-key test (`Object.keys` skips the prototype + * chain) minus that metadata. It deliberately does not inspect the *value*: an + * icon is a component in the app but a string under the test runner's SVG + * transform, so a shape check would reject every real icon in one of those + * worlds. + */ +export function makeIconResolver(namespace: object) { + const iconNames = new Set( + Object.keys(namespace).filter((key) => !NON_ICON_KEYS.has(key)) + ); + return function toIconType(name?: string): IconType { + return name && iconNames.has(name) ? (name as IconType) : 'Command'; + }; +} + +export const toIconType = makeIconResolver(icons); diff --git a/packages/hermes-tlon-adapter/README.md b/packages/hermes-tlon-adapter/README.md index d5e9708b79..396b7070bd 100644 --- a/packages/hermes-tlon-adapter/README.md +++ b/packages/hermes-tlon-adapter/README.md @@ -293,6 +293,7 @@ The seven keys above are the full "dashboard edit works" set. Everything else Tl ``` *Harness*: **Hermes** +*Harness Version*: **0.17.0 (2026.6.19)** *Adapter Version*: **0.1.0** *Tlon Skill*: **0.3.2** *Fingerprint*: **fp1:3f9a2c1b8d02** @@ -300,6 +301,7 @@ The seven keys above are the full "dashboard edit works" set. Everything else Tl ``` - **Harness** — always `Hermes`; identifies which bot framework is running this node at a glance. +- **Harness Version** — the running Hermes Agent's own version, read from its `__version__` and `__release_date__` constants (the same source its `/version` command uses), with the installed distribution's metadata as a fallback. Reads `unknown` when the host reports neither, so the row is never dropped and the reply stays line-for-line comparable with OpenClaw's. - **Adapter Version** — semver from this package's `package.json`, bumped at releases. - **Tlon Skill** — version of the packaged `@tloncorp/tlon-skill` CLI (first line of `tlon --version`). - **Fingerprint** — sha256 over the runtime files (non-test `*.py`, `plugin.yaml`, `prompts/`), so copied or hand-patched installs are still identifiable. To match a fingerprint to a commit, recompute it at a candidate checkout: `python3 -c "import version; print(version.content_fingerprint())"` from this directory. @@ -307,6 +309,12 @@ The seven keys above are the full "dashboard edit works" set. Everything else Tl Nothing is generated or checked in; identity is resolved at runtime. The same summary is logged at gateway startup. +## Bot info + +At connect (and on reconnect catch-up) the adapter publishes the bot's identity — harness, adapter version, Hermes version — in the bot's own contact profile under `bot-info`, compare-then-poke (`bot_info.py`). Tlon clients use the claimed harness to pick which of _their_ static slash-command lists to suggest; this adapter publishes no command list of its own. Wire contract and clear-to-null rollback procedure: [docs/bot-info.md](../../docs/bot-info.md). + +The registry in `commands.py` is the single source of truth for command detection, and holds shared usage constants for `/owner-listen`, `/channel-access`, and `/migrate`; the remaining commands carry their usage text in their handlers. `fixtures/commands.json` is its committed token list — a CI artifact, not a wire payload: the client's drift contract (`packages/shared/src/domain/runtimeCommandContract.test.ts`, run by the `bot-checks` job) asserts it names exactly the commands the client's Hermes list suggests, so adding or removing a command here fails until the client list changes too. `/tlon-version` is handled but deliberately absent from the fixture (legacy alias of `/tlon version`). **Removals are two-phase**: hosted bots redeploy on restart while the app releases slowly, so keep a removed command's handler alive until an app release stops suggesting it. + ## Telemetry Opt-in PostHog telemetry (official `posthog` SDK): set `TLON_TELEMETRY=true` and `TLON_TELEMETRY_API_KEY` (optional `TLON_TELEMETRY_HOST`). Disabled by default and zero-cost when off. diff --git a/packages/hermes-tlon-adapter/adapter.py b/packages/hermes-tlon-adapter/adapter.py index 72d3640606..342287e9ab 100644 --- a/packages/hermes-tlon-adapter/adapter.py +++ b/packages/hermes-tlon-adapter/adapter.py @@ -74,6 +74,14 @@ parse_channel_rules, ) from .cite import resolve_cites +from .bot_info import ( + BOT_INFO_CONTACT_MARK, + build_bot_info_json, + build_bot_info_poke, + extract_bot_info_value, + resolve_harness_version, +) +from .commands import command_detection_regex from .history import ( MessageCache, build_channel_context, @@ -226,6 +234,14 @@ logger = logging.getLogger(__name__) RECONNECT_BACKOFF_SECONDS = (2, 5, 10, 30, 60) +# A transient poke failure would otherwise leave a healthy long-lived bot +# unidentified until an unrelated reconnect or a restart, so the write is +# retried in place. Reads are never retried: a failed read skips entirely. +BOT_INFO_PUBLISH_ATTEMPTS = 3 +BOT_INFO_PUBLISH_BACKOFF_SECONDS = (2, 8) +# Distinguishes "not resolved yet" from a resolved-but-absent host version, so +# a missing version is looked up once rather than on every publish. +_UNSET_HARNESS_VERSION = object() CITE_RESOLUTION_BUDGET_SECONDS = 5.0 RENOTIFY_COOLDOWN_MS = 10 * 60 * 1000 # Window in which a repeated retry request for the same lensId is a no-op @@ -335,7 +351,8 @@ def _is_dm_chat_id(chat_id: str) -> bool: # `/tlon ...` debug namespace. Does not match `/tlon-version` (legacy alias) # because "-" is neither whitespace nor end-of-string after "tlon". -_TLON_COMMAND_RE = re.compile(r"^/tlon(?:\s|$)", re.IGNORECASE) +# Detection lives in the command registry (commands.py). +_TLON_COMMAND_RE = command_detection_regex("tlon") _HOSTED_URL_SUFFIXES = ("tlon.network", ".test.tlon.systems") @@ -962,6 +979,7 @@ def __init__(self, config: PlatformConfig): self._mention_matcher = self._build_mention_matcher() self._bot_nickname: str = "" self._bot_avatar: str = "" + self._harness_version_cache: Any = _UNSET_HARNESS_VERSION self._participated_threads: set[str] = set() self._known_bot_ships: set[str] = set() self._known_bot_consecutive_by_channel: dict[str, int] = {} @@ -1034,6 +1052,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: source=source, fingerprint=fingerprint, cli_version=cli_version, + harness_version=self._harness_version(), markdown=False, ).replace("\n", " | "), ) @@ -1041,7 +1060,11 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: {"adapterVersion": adapter_version, "adapterFingerprint": fingerprint} ) set_active_telemetry(self._telemetry) - await self._load_bot_profile() + self_contact = await self._load_bot_profile() + # Publish the bot's identity claim now that the SSE client is live + # and the current self-contact is in hand (enables + # compare-before-poke idempotence). + await self._publish_bot_info(self_contact) settings_loaded = await self._load_settings_state() self._nudge_settings_ready = settings_loaded if not settings_loaded: @@ -2250,6 +2273,7 @@ async def _version_reply(self) -> str: source=await git_source(), fingerprint=content_fingerprint(), cli_version=await self._cli_version(), + harness_version=self._harness_version(), ) async def _cli_version(self) -> str: @@ -2463,17 +2487,84 @@ async def _send_control_reply( if not result.success: logger.warning("[tlon] control command reply failed: %s", result.error) - async def _load_bot_profile(self) -> None: + async def _load_bot_profile(self) -> Optional[dict[str, Any]]: + """Fetch and apply the self contact; return the raw map so callers + can compare the published bot-info value before poking. + + Returns None when the read did not produce a contact map — callers + must treat that as "current value unknown", never as "key absent".""" if self._sse is None: - return + return None try: profile = await self._sse.scry("/contacts/v1/self.json") except Exception as exc: logger.debug("[tlon] could not fetch self profile: %s", exc) - return + return None if not isinstance(profile, dict): - return + return None self._apply_self_contact(profile) + return profile + + async def _publish_bot_info( + self, self_contact: Optional[Mapping[str, Any]] + ) -> None: + """Publish the bot's identity claim in its own contact profile: + compare the current ``bot-info`` value against the computed claim and + poke only on difference. Non-fatal — the client falls back to treating + the bot as unidentified until the next successful publish.""" + if self._sse is None: + return + if self_contact is None: + # The self-contact read failed: the current value is unknown, so + # there is nothing to compare against. Poking blind here would + # defeat compare-then-poke exactly when the ship is unhealthy. + logger.debug("[tlon] skipping bot info publish: self contact unread") + return + try: + desired = build_bot_info_json( + plugin_version(), self._harness_version() + ) + if extract_bot_info_value(self_contact) == desired: + return + payload = build_bot_info_poke(desired) + for attempt in range(1, BOT_INFO_PUBLISH_ATTEMPTS + 1): + try: + await self._sse.poke("contacts", BOT_INFO_CONTACT_MARK, payload) + logger.info("[tlon] published bot info") + return + except Exception as exc: + if attempt >= BOT_INFO_PUBLISH_ATTEMPTS: + raise + logger.debug( + "[tlon] bot info publish attempt %d failed: %s", + attempt, + exc, + ) + await asyncio.sleep( + BOT_INFO_PUBLISH_BACKOFF_SECONDS[ + min( + attempt - 1, + len(BOT_INFO_PUBLISH_BACKOFF_SECONDS) - 1, + ) + ] + ) + except Exception as exc: + logger.warning("[tlon] could not publish bot info: %s", exc) + + def _harness_version(self) -> Optional[str]: + """Resolved once per process: a function-local import reads Python's + already-cached module, so deferring the read buys no freshness — an + edit to the host's constants needs a restart either way.""" + if self._harness_version_cache is _UNSET_HARNESS_VERSION: + self._harness_version_cache = resolve_harness_version() + return self._harness_version_cache + + async def _clear_bot_info(self) -> None: + """Clear the published claim (rollback/retirement procedure): contact + keys die only by explicit null.""" + if self._sse is None: + return + await self._sse.poke("contacts", BOT_INFO_CONTACT_MARK, build_bot_info_poke(None)) def _apply_self_contact(self, contact: Any) -> None: """Reconcile bot nickname/avatar state from a self contact map. @@ -2632,8 +2723,11 @@ async def _backoff_and_report(exc: BaseException, *, mode: str) -> None: "[tlon] reconnect invite catch-up failed: %s", exc ) # Contacts facts do not replay either; catch up on renames - # (or clears) missed while disconnected. - await self._load_bot_profile() + # (or clears) missed while disconnected, and re-check the + # published identity claim (e.g. a version bump that has + # not been published yet). + self_contact = await self._load_bot_profile() + await self._publish_bot_info(self_contact) except BaseException: await self._close_sse(graceful=False) raise diff --git a/packages/hermes-tlon-adapter/approval.py b/packages/hermes-tlon-adapter/approval.py index 1d026bc0f4..57c7392e41 100644 --- a/packages/hermes-tlon-adapter/approval.py +++ b/packages/hermes-tlon-adapter/approval.py @@ -22,6 +22,7 @@ import uuid from typing import Any, Iterable, Mapping, Optional +from .commands import command_detection_regex from .tlon_api import normalize_ship SETTINGS_KEY_PENDING_APPROVALS = "pendingApprovals" @@ -59,12 +60,14 @@ "dates, and ordering are not preserved." ) -_ALLOW_RE = re.compile(r"^/allow(?:\s+(?P\S+))?\s*$", re.IGNORECASE) -_REJECT_RE = re.compile(r"^/reject(?:\s+(?P\S+))?\s*$", re.IGNORECASE) -_BAN_RE = re.compile(r"^/ban(?:\s+(?P\S+))?\s*$", re.IGNORECASE) -_UNBAN_RE = re.compile(r"^/unban(?:\s+(?P\S+))?\s*$", re.IGNORECASE) -_PENDING_RE = re.compile(r"^/pending\s*$", re.IGNORECASE) -_BANNED_RE = re.compile(r"^/banned\s*$", re.IGNORECASE) +# Detection shapes live in the command registry (commands.py): allow/reject/ +# ban/unban are anchored-optional-arg; pending/banned are strict-no-arg. +_ALLOW_RE = command_detection_regex("allow") +_REJECT_RE = command_detection_regex("reject") +_BAN_RE = command_detection_regex("ban") +_UNBAN_RE = command_detection_regex("unban") +_PENDING_RE = command_detection_regex("pending") +_BANNED_RE = command_detection_regex("banned") def truncate(text: str, max_chars: int = PREVIEW_MAX_CHARS) -> str: diff --git a/packages/hermes-tlon-adapter/bot_info.py b/packages/hermes-tlon-adapter/bot_info.py new file mode 100644 index 0000000000..90f774a040 --- /dev/null +++ b/packages/hermes-tlon-adapter/bot_info.py @@ -0,0 +1,128 @@ +"""The bot's identity claim, published in its own contact profile. + +The adapter tells Tlon clients *who it is* — harness plus versions — and +nothing about what it can do: command lists are app-static, bound to this +package's ``fixtures/commands.json`` by a CI drift contract. Wire contract: +docs/bot-info.md in tlon-apps. + +This module has no package-relative imports so it stays importable from any +context, matching commands.py. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Mapping, Optional + +logger = logging.getLogger(__name__) + +BOT_INFO_CONTACT_KEY = "bot-info" +BOT_INFO_CONTACT_MARK = "contact-action-1" +# Client-side parse ceiling for the raw claim; the backend's 10kB jam cap +# covers the whole profile, so a rejected poke is a real, non-fatal outcome +# regardless. +BOT_INFO_MAX_BYTES = 512 + +HARNESS = "hermes" + + +def resolve_harness_version() -> Optional[str]: + """The Hermes runtime's own version, as a diagnostic rider on the claim. + + Byte-for-byte the source core's own ``/version`` command reads + (``gateway/slash_commands.py`` -> ``hermes_cli/banner.py`` -> these + constants), guaranteed importable because the gateway process *is* + ``hermes_cli``. Both numbers are emitted: the SemVer alone matches nothing + the pins or the README use, and the CalVer matches the release tag. + + These are version claims, not code-identity claims — the constants bump at + release-cut, so a build off an unreleased ref reports the last release. + + Returns None when nothing can be sourced; the caller publishes without the + field rather than withholding the claim. Never shells out to + ``hermes --version`` and never uses ``git describe`` (production strips + ``.git``). + """ + try: + from hermes_cli import __release_date__, __version__ + + version = str(__version__ or "").strip() + release_date = str(__release_date__ or "").strip() + if version and release_date: + return f"{version} ({release_date})" + # Half the pair is a broken host convention, not a usable value: the + # SemVer alone is exactly what the distribution fallback yields, so fall + # through rather than quietly publishing a degraded claim as if it were + # the preferred one. + logger.warning( + "[tlon] hermes_cli version constants incomplete " + "(__version__=%r, __release_date__=%r); falling back", + version, + release_date, + ) + except Exception as exc: # pragma: no cover - depends on the host install + logger.warning("[tlon] could not read hermes_cli version: %s", exc) + + try: + from importlib.metadata import version as distribution_version + + # SemVer only, and stale under an editable install (dist-info freezes + # at install time) — a fallback, not a preference. + fallback = str(distribution_version("hermes-agent") or "").strip() + if fallback: + return fallback + except Exception as exc: # pragma: no cover - depends on the host install + logger.warning("[tlon] could not read hermes-agent metadata: %s", exc) + + logger.warning("[tlon] no Hermes version available for the bot info claim") + return None + + +def build_bot_info_json( + version: str, harness_version: Optional[str] = None +) -> str: + """Serialize the identity claim. Byte-stable (fixed key order, compact + separators) so compare-before-poke does not false-positive. + + ``harnessVersion`` is omitted when the host reports nothing: the field is a + diagnostic rider and a missing rider must never invalidate the claim.""" + claim: dict[str, Any] = {"v": 1, "harness": HARNESS, "version": version} + trimmed = (harness_version or "").strip() + if trimmed: + claim["harnessVersion"] = trimmed + # ensure_ascii=False keeps non-ASCII literal, matching the TS builder's + # JSON.stringify output and making the byte cap count real UTF-8 bytes + # rather than \\uXXXX escapes. + value = json.dumps(claim, separators=(",", ":"), ensure_ascii=False) + size = len(value.encode("utf-8")) + if size > BOT_INFO_MAX_BYTES: + raise ValueError(f"bot info exceeds {BOT_INFO_MAX_BYTES} UTF-8 bytes: {size}") + return value + + +def extract_bot_info_value(self_contact: Any) -> Optional[str]: + """Runtime shape check for the ``bot-info`` field on a self-contact map: + only a %text field carrying a string is a published claim.""" + if not isinstance(self_contact, Mapping): + return None + candidate = self_contact.get(BOT_INFO_CONTACT_KEY) + if not isinstance(candidate, Mapping): + return None + if candidate.get("type") != "text": + return None + value = candidate.get("value") + return value if isinstance(value, str) else None + + +def build_bot_info_poke(value: Optional[str]) -> dict[str, Any]: + """The contact-action-1 self poke publishing (or, with None, clearing) the + claim. Keys die only by explicit null — see docs/bot-info.md for the + rollback procedure.""" + return { + "self": { + BOT_INFO_CONTACT_KEY: None + if value is None + else {"type": "text", "value": value} + } + } diff --git a/packages/hermes-tlon-adapter/channel_access.py b/packages/hermes-tlon-adapter/channel_access.py index befb357df5..f3af6bdf1b 100644 --- a/packages/hermes-tlon-adapter/channel_access.py +++ b/packages/hermes-tlon-adapter/channel_access.py @@ -19,6 +19,7 @@ from dataclasses import dataclass, field from typing import Any, Iterable, Mapping, Optional +from .commands import CHANNEL_ACCESS_USAGE, command_detection_regex from .owner_listen import canonicalize_nest from .tlon_api import normalize_ship @@ -27,11 +28,8 @@ # has a legacy duplicate "autoDiscover" key — deliberately not read here. SETTINGS_KEY_AUTO_DISCOVER_CHANNELS = "autoDiscoverChannels" -CHANNEL_ACCESS_USAGE = ( - "Usage: /channel-access [open|restricted|status|list] []" -) - -_COMMAND_RE = re.compile(r"^/channel-access(?:\s|$)", re.IGNORECASE) +# Detection lives in the command registry (commands.py). +_COMMAND_RE = command_detection_regex("channel-access") def is_channel_access_command(text: str) -> bool: diff --git a/packages/hermes-tlon-adapter/commands.py b/packages/hermes-tlon-adapter/commands.py new file mode 100644 index 0000000000..1316e32430 --- /dev/null +++ b/packages/hermes-tlon-adapter/commands.py @@ -0,0 +1,201 @@ +"""Single source of truth for the adapter's owner control commands. + +Every control command the adapter detects in chat is one row of +``COMMAND_REGISTRY``. The row encodes the command's detection shape (the +three shapes that exist today are reproduced exactly — they are +behavior-relevant: ``/pending 2`` must keep falling through to the model, +``/tlon-version please`` must keep matching), its usage text (the existing +module constants moved here verbatim), and its telemetry token. + +It deliberately carries no popup metadata (titles, subtitles, icons, +keywords): the Tlon client owns the editorial surface, in its own static +per-harness lists. What this side owes the client is the token set, and only +that — which is what ``fixtures/commands.json`` holds and what the client's +drift contract (packages/shared/src/domain/runtimeCommandContract.test.ts) +pins against those lists. + +Feature modules (owner_listen, channel_access, migration, version, +approval, adapter) take their compiled detection regexes from +``command_detection_regex`` so the registry and the dispatch path cannot +drift; the dispatcher in adapter.py consumes the same predicates. + +This module has no package-relative imports so it stays importable from any +context (the version fingerprint module delegates here with a fallback). +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +# Usage strings moved here verbatim from their former homes; existing tests +# pin them literally. +OWNER_LISTEN_USAGE = ( + "Usage: /owner-listen [on|off|status|list] [|<~host/group>] | " + "/owner-listen all [on|off] | /owner-listen default [owned|all]" +) +CHANNEL_ACCESS_USAGE = ( + "Usage: /channel-access [open|restricted|status|list] []" +) +MIGRATE_USAGE = ( + "Usage: /migrate [--allow-write-widening] | " + "/migrate cleanup " +) + + +class CommandShape(str, Enum): + # ``/token`` followed by whitespace or end-of-string; arguments are free + # form (``/tlon-version please`` matches). + PREFIX = "prefix" + # The whole message is the token plus at most one argument + # (``/allow abc`` matches; ``/allow a b`` does not). + ANCHORED_OPTIONAL_ARG = "anchored-optional-arg" + # The whole message is exactly the token (``/pending 2`` falls through to + # the model). + STRICT_NO_ARG = "strict-no-arg" + + +@dataclass(frozen=True) +class CommandRow: + token: str + shape: CommandShape + # Dispatcher/telemetry name (token without the leading slash). + name: str + usage: Optional[str] = None + # False means handled but never named to the client (the client's static + # list must not suggest it); the reason is required alongside. + advertise: bool = True + do_not_advertise_reason: str = "" + # Dispatcher-level telemetry token; None where the dispatcher emits none + # (``/tlon`` reports per-subcommand tokens from its handler instead). + telemetry_token: Optional[str] = None + + +# Dispatcher order (adapter._maybe_handle_control_command). Order is this +# side's own concern: the client sorts its popup by its own priorities. +COMMAND_REGISTRY: tuple[CommandRow, ...] = ( + CommandRow( + token="/owner-listen", + shape=CommandShape.PREFIX, + name="owner-listen", + usage=OWNER_LISTEN_USAGE, + telemetry_token="owner-listen", + ), + CommandRow( + token="/migrate", + shape=CommandShape.PREFIX, + name="migrate", + usage=MIGRATE_USAGE, + telemetry_token="migrate", + ), + CommandRow( + token="/tlon", + shape=CommandShape.PREFIX, + name="tlon", + telemetry_token=None, + ), + CommandRow( + token="/tlon-version", + shape=CommandShape.PREFIX, + name="tlon-version", + advertise=False, + do_not_advertise_reason="legacy alias of /tlon version", + telemetry_token="tlon-version", + ), + CommandRow( + token="/allow", + shape=CommandShape.ANCHORED_OPTIONAL_ARG, + name="allow", + telemetry_token="allow", + ), + CommandRow( + token="/reject", + shape=CommandShape.ANCHORED_OPTIONAL_ARG, + name="reject", + telemetry_token="reject", + ), + CommandRow( + token="/ban", + shape=CommandShape.ANCHORED_OPTIONAL_ARG, + name="ban", + telemetry_token="ban", + ), + CommandRow( + token="/unban", + shape=CommandShape.ANCHORED_OPTIONAL_ARG, + name="unban", + telemetry_token="unban", + ), + CommandRow( + token="/pending", + shape=CommandShape.STRICT_NO_ARG, + name="pending", + telemetry_token="pending", + ), + CommandRow( + token="/banned", + shape=CommandShape.STRICT_NO_ARG, + name="banned", + telemetry_token="banned", + ), + CommandRow( + token="/channel-access", + shape=CommandShape.PREFIX, + name="channel-access", + usage=CHANNEL_ACCESS_USAGE, + telemetry_token="channel-access", + ), +) + +_REGISTRY_BY_NAME: dict[str, CommandRow] = {row.name: row for row in COMMAND_REGISTRY} + + +def get_command_row(name: str) -> CommandRow: + return _REGISTRY_BY_NAME[name] + + +def detection_regex(row: CommandRow) -> re.Pattern[str]: + """Compile the detection regex for a row, reproducing the three shapes + that exist today exactly (case-insensitive, matched against the stripped + message text).""" + token = re.escape(row.token) + if row.shape is CommandShape.PREFIX: + pattern = rf"^{token}(?:\s|$)" + elif row.shape is CommandShape.ANCHORED_OPTIONAL_ARG: + pattern = rf"^{token}(?:\s+(?P\S+))?\s*$" + elif row.shape is CommandShape.STRICT_NO_ARG: + pattern = rf"^{token}\s*$" + else: # pragma: no cover - enum is closed + raise ValueError(f"unknown command shape: {row.shape}") + return re.compile(pattern, re.IGNORECASE) + + +# Compiled once; feature modules must take these exact objects so the +# registry and the dispatch path can never drift. +_DETECTION_REGEXES: dict[str, re.Pattern[str]] = { + row.name: detection_regex(row) for row in COMMAND_REGISTRY +} + + +def command_detection_regex(name: str) -> re.Pattern[str]: + return _DETECTION_REGEXES[name] + + +def advertised_command_rows() -> list[CommandRow]: + return [row for row in COMMAND_REGISTRY if row.advertise] + + +def command_tokens() -> list[str]: + return [row.token for row in advertised_command_rows()] + + +def build_command_tokens_json() -> str: + """The committed fixture's exact bytes (fixtures/commands.json). Nothing + sends this anywhere: it is the CI artifact the client's drift contract + reads, so only its content and its stability matter. Matches the TS + builder's ``JSON.stringify(tokens, null, 2)`` so both runtimes' fixtures + look alike.""" + return json.dumps(command_tokens(), indent=2) + "\n" diff --git a/packages/hermes-tlon-adapter/fixtures/commands.json b/packages/hermes-tlon-adapter/fixtures/commands.json new file mode 100644 index 0000000000..053f14a1e4 --- /dev/null +++ b/packages/hermes-tlon-adapter/fixtures/commands.json @@ -0,0 +1,12 @@ +[ + "/owner-listen", + "/migrate", + "/tlon", + "/allow", + "/reject", + "/ban", + "/unban", + "/pending", + "/banned", + "/channel-access" +] diff --git a/packages/hermes-tlon-adapter/migration.py b/packages/hermes-tlon-adapter/migration.py index 1acfcc6add..29ce2c2b81 100644 --- a/packages/hermes-tlon-adapter/migration.py +++ b/packages/hermes-tlon-adapter/migration.py @@ -12,6 +12,7 @@ from typing import Awaitable, Callable, Mapping, Optional, Sequence from .approval import build_migrate_card +from .commands import MIGRATE_USAGE, command_detection_regex from .owner_listen import canonicalize_nest, canonicalize_notes_nest from .tlon_api import ( TlonDeadlineCallback, @@ -34,16 +35,13 @@ "The source channel stays intact, remains writable, and is renamed with an " "`-ARCHIVE` suffix." ) -MIGRATE_USAGE = ( - "Usage: /migrate [--allow-write-widening] | " - "/migrate cleanup " -) CREATE_FAILURE_MARKER = "Notebook creation may or may not have landed." UNMARKED_NOTES_REFUSAL_MARKER = "without a tlon-migrate provenance footer" # Deliberately only the shared prefix: the CLI emits two variants that diverge # after the nest ("still present" vs "could not be checked"). PARTIAL_CLEANUP_MARKER = "Notebook deleted; group cleanup unconfirmed" -_MIGRATE_COMMAND_RE = re.compile(r"^/migrate(?:\s|$)", re.IGNORECASE) +# Detection lives in the command registry (commands.py). +_MIGRATE_COMMAND_RE = command_detection_regex("migrate") _TARGET_CREATED_RE = re.compile( r"^Target notebook created: " r"(notes/~[a-z-]+/[a-zA-Z0-9-]+)[ \t]*\r?$", diff --git a/packages/hermes-tlon-adapter/owner_listen.py b/packages/hermes-tlon-adapter/owner_listen.py index 7793273e13..cb8ead20aa 100644 --- a/packages/hermes-tlon-adapter/owner_listen.py +++ b/packages/hermes-tlon-adapter/owner_listen.py @@ -21,6 +21,7 @@ from dataclasses import dataclass, field from typing import Any, Iterable, Mapping, Optional +from .commands import OWNER_LISTEN_USAGE, command_detection_regex from .tlon_api import normalize_ship SETTINGS_DESK = "moltbot" @@ -33,14 +34,10 @@ NEST_PREFIXES = frozenset({"chat", "heap", "diary"}) -OWNER_LISTEN_USAGE = ( - "Usage: /owner-listen [on|off|status|list] [|<~host/group>] | " - "/owner-listen all [on|off] | /owner-listen default [owned|all]" -) - _GROUP_FLAG_RE = re.compile(r"^~[a-z][a-z-]*/[^/\s]+$", re.IGNORECASE) -_COMMAND_RE = re.compile(r"^/owner-listen(?:\s|$)", re.IGNORECASE) +# Detection lives in the command registry (commands.py). +_COMMAND_RE = command_detection_regex("owner-listen") def _canonical_ship(ship: str) -> str: diff --git a/packages/hermes-tlon-adapter/prompts/shared/owner-listen.md b/packages/hermes-tlon-adapter/prompts/shared/owner-listen.md index e127ed345f..e77eb9d836 100644 --- a/packages/hermes-tlon-adapter/prompts/shared/owner-listen.md +++ b/packages/hermes-tlon-adapter/prompts/shared/owner-listen.md @@ -5,7 +5,7 @@ The platform adapter handles these owner-only chat commands deterministically, s - `/owner-listen [on|off|status|list] [|<~host/group>]`, `/owner-listen all [on|off]`, `/owner-listen default [owned|all]` — owner-listen lets the owner be heard without a mention. Channels hosted by the bot or owner are on by default; any channel or whole group can be opted in or out, and `default all` extends the default to every monitored channel. - `/channel-access [open|restricted|status|list] []` — open lets anyone in that channel address the bot with a mention; restricted (default) limits it to authorized or approved ships. - `/pending`, `/allow `, `/reject `, `/ban `, `/unban ~ship`, `/banned` — access is deny-by-default. Unknown ships that DM the bot, mention it in restricted channels, or invite it to a group queue for owner approval (approving a group invite joins it). The owner gets a DM with an approval card; the buttons send these same commands. -- `/tlon version` — reports the running adapter version, source commit, content fingerprint, and `tlon` CLI version. (`/tlon-version` is a legacy alias for the same output.) +- `/tlon version` — reports the running Hermes harness version, adapter version, source commit, content fingerprint, and `tlon` CLI version. (`/tlon-version` is a legacy alias for the same output.) - `/tlon status storage` — reports image-upload storage debug info: node URL, whether it looks hosted, the `TLON_HOSTING` override, storage service, S3 credentials, `%genuine` reachability, and the resolved upload path. - `/tlon status binary` — identifies the exact `tlon` CLI the adapter invokes: version, a content hash (so two builds of one version are distinguishable), size, and build time. - `/tlon status telemetry`, `/tlon status telemetry test` — reports telemetry status (whether it is enabled and why, the PostHog identity events are sent under, and any delivery failures); `test` sends a test event and confirms whether PostHog accepted it. diff --git a/packages/hermes-tlon-adapter/test_adapter_owner_listen.py b/packages/hermes-tlon-adapter/test_adapter_owner_listen.py index d81861880e..b9c6c1a1a8 100644 --- a/packages/hermes-tlon-adapter/test_adapter_owner_listen.py +++ b/packages/hermes-tlon-adapter/test_adapter_owner_listen.py @@ -1167,6 +1167,11 @@ def test_migrate_command_from_non_owner_is_not_intercepted(self): def test_version_command_replies_with_field_lines(self): adapter = self.make_adapter({}) adapter._cli = FakeCLI() + # A value nothing else in the tree can produce, so the row can only be + # right if the reply is actually wired to the resolver. A regex on + # "some nonempty string" passes even when the wiring is dropped and + # every bot silently reports `unknown`. + adapter._harness_version_cache = "harness-sentinel-9.9.9" events = self.dispatches(adapter, channel_event("/tlon-version")) @@ -1174,13 +1179,14 @@ def test_version_command_replies_with_field_lines(self): self.assertEqual(len(adapter._cli.messages), 1) self.assertEqual(adapter._cli.messages[0][0], "chat/~pen/general") lines = adapter._cli.messages[0][1].splitlines() - self.assertEqual(len(lines), 5) + self.assertEqual(len(lines), 6) self.assertEqual(lines[0], "*Harness*: **Hermes**") - # exact version is covered in test_version; here we pin field + format - self.assertRegex(lines[1], r"^\*Adapter Version\*: \*\*.+\*\*$") - self.assertEqual(lines[2], "*Tlon Skill*: **0.3.2**") - self.assertRegex(lines[3], r"^\*Fingerprint\*: \*\*fp1:[0-9a-f]{12}\*\*$") - self.assertTrue(lines[4].startswith("*Source*: **")) + self.assertEqual(lines[1], "*Harness Version*: **harness-sentinel-9.9.9**") + # exact versions are covered in test_version; here we pin field + format + self.assertRegex(lines[2], r"^\*Adapter Version\*: \*\*.+\*\*$") + self.assertEqual(lines[3], "*Tlon Skill*: **0.3.2**") + self.assertRegex(lines[4], r"^\*Fingerprint\*: \*\*fp1:[0-9a-f]{12}\*\*$") + self.assertTrue(lines[5].startswith("*Source*: **")) self.assertIn(("--version",), adapter._cli.commands) def test_version_command_works_from_dm_and_with_mention(self): @@ -1223,13 +1229,15 @@ def test_version_command_reports_cli_failure(self): def test_tlon_version_subcommand(self): adapter = self.make_adapter({}) adapter._cli = FakeCLI() + adapter._harness_version_cache = "harness-sentinel-9.9.9" events = self.dispatches(adapter, channel_event("/tlon version")) self.assertEqual(events, []) lines = adapter._cli.messages[0][1].splitlines() self.assertEqual(lines[0], "*Harness*: **Hermes**") - self.assertRegex(lines[1], r"^\*Adapter Version\*: \*\*.+\*\*$") + self.assertEqual(lines[1], "*Harness Version*: **harness-sentinel-9.9.9**") + self.assertRegex(lines[2], r"^\*Adapter Version\*: \*\*.+\*\*$") def test_tlon_status_telemetry_subcommand(self): adapter = self.make_adapter({}) diff --git a/packages/hermes-tlon-adapter/test_adapter_stream.py b/packages/hermes-tlon-adapter/test_adapter_stream.py index 41072fad09..66f0e8791d 100644 --- a/packages/hermes-tlon-adapter/test_adapter_stream.py +++ b/packages/hermes-tlon-adapter/test_adapter_stream.py @@ -159,10 +159,16 @@ async def record_invites(): async def record_profile(): calls.append("profile") + async def record_publish(self_contact): + calls.append("publish") + return [ patch.object(adapter, "_load_settings_state", record_settings), patch.object(adapter, "_process_pending_dm_invites", record_invites), patch.object(adapter, "_load_bot_profile", record_profile), + patch.object( + adapter, "_publish_bot_info", record_publish + ), ] def test_transport_error_resumes_same_client(self): @@ -203,7 +209,7 @@ async def events(self, *, on_open=None): patches = self._patch_catchups(adapter, calls) async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): @@ -248,7 +254,7 @@ async def events(self, *, on_open=None): patches = self._patch_catchups(adapter, calls) async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): @@ -300,7 +306,7 @@ async def events(self, *, on_open=None): with patch.object(adapter_mod, "TlonSSEClient", FailingConnectSSE): async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): asyncio.run(run()) @@ -462,7 +468,7 @@ async def events(self, *, on_open=None): with patch.object(adapter_mod, "TlonSSEClient", RebuildSSE): async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): asyncio.run(run()) @@ -471,7 +477,25 @@ async def run(): self.assertEqual(sse_instances[0].close_calls, [False]) rebuild_events = [e for e in telemetry_events if e.get("mode") == "rebuild"] self.assertEqual(len(rebuild_events), 1) - self.assertEqual(calls, ["settings", "invites", "profile", "settings", "invites", "profile"]) + # The bot-info republish is bound to the reconnect catch-up here: drop + # the call site in _run_stream and this sequence loses its "publish". + self.assertEqual( + calls, + [ + "settings", + "invites", + "profile", + "publish", + "settings", + "invites", + "profile", + "publish", + ], + ) + # Re-read then republish, in that order, on every reconnect. + for index, name in enumerate(calls): + if name == "profile": + self.assertEqual(calls[index + 1], "publish") sub_apps = [s[0] for s in sse_instances[1].subscribe_calls] self.assertIn("steward", sub_apps) @@ -509,7 +533,7 @@ async def events(self, *, on_open=None): patches = self._patch_catchups(adapter, calls) async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): @@ -561,7 +585,7 @@ async def events(self, *, on_open=None): with patch.object(adapter_mod, "TlonSSEClient", AuthSSE): async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): asyncio.run(run()) @@ -599,7 +623,7 @@ async def close_sse(*, graceful=True): patches = self._patch_catchups(adapter, calls) async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): @@ -722,7 +746,7 @@ async def events(self, *, on_open=None): with patch.object(adapter_mod, "TlonSSEClient", ReapSSE): async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): asyncio.run(run()) @@ -788,6 +812,55 @@ async def anoop(*a, **k): self.assertEqual(connect_attempted, [True]) return adapter, result, errors + def test_connect_publishes_the_bot_info_once(self): + """Binds publication to the connect() lifecycle. Every other publisher + test drives _publish_bot_info directly, so deleting the call site would + make publication dead code with no failing test.""" + adapter = self.make_adapter() + published = [] + + async def anoop(*a, **k): + return None + + async def load_profile(): + return {"nickname": {"type": "text", "value": "Bot"}} + + async def record_publish(self_contact): + published.append(self_contact) + + adapter._connect_sse = anoop + adapter._load_bot_profile = load_profile + adapter._publish_bot_info = record_publish + adapter._load_settings_state = anoop + adapter._process_pending_dm_invites = anoop + adapter._process_pending_group_invites = anoop + adapter._start_gateway_status = anoop + adapter._start_lens = anoop + adapter._start_event_worker = lambda *a, **k: None + adapter._start_nudge_settings_retry = lambda *a, **k: None + adapter._run_stream = anoop + adapter._nudge_scheduler = types.SimpleNamespace( + start=lambda *a, **k: None + ) + adapter._telemetry = types.SimpleNamespace( + set_common=lambda *a, **kw: None, + gateway_connected=lambda *a, **kw: None, + error=lambda *a, **kw: None, + ) + + with ( + patch.object(adapter_mod, "AIOHTTP_AVAILABLE", True), + patch.object(adapter_mod, "_cli_available", return_value=True), + patch.object(adapter_mod, "set_active_telemetry", lambda *a: None), + patch.object(adapter_mod, "git_source", anoop), + patch.object(adapter_mod, "content_fingerprint", lambda *a: "fp1:x"), + ): + result = asyncio.run(adapter.connect()) + + self.assertTrue(result) + # Exactly once, against the self contact just read. + self.assertEqual(published, [{"nickname": {"type": "text", "value": "Bot"}}]) + def test_connect_fixed_cookie_terminal_auth_is_fatal(self): # A rejected fixed cookie surfaces at STARTUP via connect()->_connect_sse # (open/subscribe raise TlonTerminalActionError), before _run_stream is @@ -919,7 +992,7 @@ async def record_dispatch(message, **kwargs): with patch.object(adapter_mod, "TlonSSEClient", DedupRebuildSSE), \ patch.object(adapter, "_dispatch_message", record_dispatch): async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): asyncio.run(run()) @@ -960,7 +1033,7 @@ async def fake_route(event): patches = self._patch_catchups(adapter, calls) async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): @@ -1007,7 +1080,7 @@ async def events(self, *, on_open=None): with patch.object(adapter_mod, "TlonSSEClient", FailingSetupSSE): async def run(): - with patches[0], patches[1], patches[2]: + with patches[0], patches[1], patches[2], patches[3]: await adapter._run_stream() with patch("asyncio.sleep", _instant_sleep): with self.assertLogs(adapter_mod.logger.name, level="WARNING") as cm: diff --git a/packages/hermes-tlon-adapter/test_command_registry.py b/packages/hermes-tlon-adapter/test_command_registry.py new file mode 100644 index 0000000000..cb11cbb9d2 --- /dev/null +++ b/packages/hermes-tlon-adapter/test_command_registry.py @@ -0,0 +1,793 @@ +import asyncio +# Imported for the side effect, and it must happen before any +# patch.dict(sys.modules, ...) takes its snapshot: mock restores sys.modules by +# clear+update, so if importlib.metadata first gets imported *inside* a patched +# block (Python 3.10 does not preload it; 3.12 does), the restore evicts it — +# and every later patch("importlib.metadata.version") then patches a different +# module object than the one resolve_harness_version re-imports, silently +# unmocking the fallback path. +import importlib.metadata # noqa: F401 +import importlib.util +import json +import os +import sys +import types +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, patch + + +PACKAGE_DIR = Path(__file__).parent +PACKAGE_NAME = "hermes_tlon_adapter_command_registry_testpkg" + +package = types.ModuleType(PACKAGE_NAME) +package.__path__ = [str(PACKAGE_DIR)] +sys.modules[PACKAGE_NAME] = package + + +class Platform(str): + pass + + +class PlatformConfig: + def __init__(self, extra=None): + self.extra = extra or {} + + +class MessageType: + TEXT = "text" + PHOTO = "photo" + VIDEO = "video" + AUDIO = "audio" + VOICE = "voice" + DOCUMENT = "document" + + +class MessageEvent: + def __init__( + self, + *, + text, + message_type, + source, + raw_message, + message_id, + reply_to_message_id, + timestamp, + media_urls=None, + media_types=None, + ): + self.text = text + self.message_type = message_type + self.source = source + self.raw_message = raw_message + self.message_id = message_id + self.reply_to_message_id = reply_to_message_id + self.timestamp = timestamp + self.media_urls = media_urls or [] + self.media_types = media_types or [] + + +class SendResult: + def __init__( + self, + *, + success, + message_id=None, + error=None, + raw_response=None, + retryable=False, + continuation_message_ids=(), + ): + self.success = success + self.message_id = message_id + self.error = error + self.raw_response = raw_response or {} + self.retryable = retryable + self.continuation_message_ids = tuple(continuation_message_ids) + + +class BasePlatformAdapter: + def __init__(self, *, config, platform): + self.config = config + self.platform = platform + self._running = True + + def _mark_connected(self): + self._running = True + + def _mark_disconnected(self): + self._running = False + + def build_source(self, **kwargs): + return types.SimpleNamespace(**kwargs) + + async def handle_message(self, event): + raise AssertionError("tests should install a recorder") + + +gateway = types.ModuleType("gateway") +gateway_config = types.ModuleType("gateway.config") +gateway_config.Platform = Platform +gateway_config.PlatformConfig = PlatformConfig +gateway_platforms = types.ModuleType("gateway.platforms") +gateway_base = types.ModuleType("gateway.platforms.base") +gateway_base.BasePlatformAdapter = BasePlatformAdapter +gateway_base.MessageEvent = MessageEvent +gateway_base.MessageType = MessageType +gateway_base.SendResult = SendResult +sys.modules["gateway"] = gateway +sys.modules["gateway.config"] = gateway_config +sys.modules["gateway.platforms"] = gateway_platforms +sys.modules["gateway.platforms.base"] = gateway_base + + +def load_module(name): + module_name = f"{PACKAGE_NAME}.{name}" + spec = importlib.util.spec_from_file_location(module_name, PACKAGE_DIR / f"{name}.py") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +commands = load_module("commands") +tlon_api = load_module("tlon_api") +owner_listen = load_module("owner_listen") +channel_access = load_module("channel_access") +approval = load_module("approval") +migration = load_module("migration") +version = load_module("version") +bot_info = load_module("bot_info") +adapter_mod = load_module("adapter") + +FIXTURE_PATH = PACKAGE_DIR / "fixtures" / "commands.json" + +ALL_TOKENS = [ + "/owner-listen", + "/migrate", + "/tlon", + "/tlon-version", + "/allow", + "/reject", + "/ban", + "/unban", + "/pending", + "/banned", + "/channel-access", +] + +# Every advertised row, driven through the real dispatcher below with a +# representative input that its handler accepts. +DISPATCH_CASES = { + "owner-listen": "/owner-listen status", + "migrate": "/migrate", + "tlon": "/tlon version", + "allow": "/allow abc123", + "reject": "/reject abc123", + "ban": "/ban ~ten", + "unban": "/unban ~ten", + "pending": "/pending", + "banned": "/banned", + "channel-access": "/channel-access list", +} + + +class FakeSSE: + def __init__(self, payloads=None): + self.payloads = payloads or {} + self.scries = [] + self.pokes = [] + self.fail_pokes = False + # Transient-failure simulation for the publish retry. + self.fail_first_pokes = 0 + self.poke_attempts = 0 + + async def scry(self, path): + self.scries.append(path) + if path in self.payloads: + return self.payloads[path] + raise ConnectionError(f"no payload for {path}") + + async def poke(self, app, mark, json_payload): + self.poke_attempts += 1 + if self.fail_pokes or self.poke_attempts <= self.fail_first_pokes: + raise ConnectionError("poke rejected") + self.pokes.append((app, mark, json_payload)) + return 1 + + def pokes_for(self, mark): + return [poke for poke in self.pokes if poke[1] == mark] + + +class FakeCLI: + def __init__(self): + self.commands = [] + self.messages = [] + self.replies = [] + + async def run_command(self, args): + self.commands.append(tuple(args)) + return tlon_api.TlonSendResult( + success=True, command=("tlon-test", *args), stdout="ok\n" + ) + + async def send_message(self, chat_id, text, *, blob=None, sent_at=None): + self.messages.append((chat_id, text)) + return tlon_api.TlonSendResult( + success=True, command=("tlon-test", "posts", "send"), message_id="post-id" + ) + + async def send_reply( + self, chat_id, post_id, text, *, parent_author=None, blob=None, sent_at=None + ): + self.replies.append((chat_id, post_id, text, parent_author)) + return tlon_api.TlonSendResult( + success=True, command=("tlon-test", "posts", "reply"), message_id="reply-id" + ) + + +class CommandRegistryTests(unittest.TestCase): + def test_registry_covers_all_eleven_control_commands(self): + self.assertEqual([row.token for row in commands.COMMAND_REGISTRY], ALL_TOKENS) + self.assertEqual(len(commands.COMMAND_REGISTRY), 11) + + def test_only_tlon_version_is_not_advertised_with_reason(self): + hidden = [row for row in commands.COMMAND_REGISTRY if not row.advertise] + self.assertEqual([row.token for row in hidden], ["/tlon-version"]) + self.assertEqual( + hidden[0].do_not_advertise_reason, "legacy alias of /tlon version" + ) + advertised = commands.advertised_command_rows() + self.assertEqual(len(advertised), 10) + self.assertNotIn("/tlon-version", [row.token for row in advertised]) + + def test_detection_shapes_reproduce_todays_behavior(self): + match = lambda name, text: bool( # noqa: E731 + commands.command_detection_regex(name).match(str(text).strip()) + ) + # prefix shape: token plus anything (or nothing) after it + self.assertTrue(match("owner-listen", "/owner-listen")) + self.assertTrue(match("owner-listen", " /Owner-Listen status")) + self.assertFalse(match("owner-listen", "/owner-listening on")) + self.assertTrue(match("tlon-version", "/tlon-version please")) + self.assertFalse(match("tlon-version", "/tlon-versions")) + self.assertTrue(match("tlon", "/tlon version")) + # /tlon must not swallow the legacy alias + self.assertFalse(match("tlon", "/tlon-version")) + self.assertTrue(match("migrate", "/migrate diary/~pen/journal")) + self.assertTrue(match("channel-access", "/Channel-Access")) + self.assertFalse(match("channel-access", "/channel-accessory")) + # anchored-optional-arg shape: token plus at most one argument + self.assertTrue(match("allow", "/allow d1b2c")) + self.assertTrue(match("allow", "/allow")) + self.assertFalse(match("allow", "/allow a b")) + self.assertTrue(match("unban", "/unban ~ten")) + # strict-no-arg shape: anything else falls through to the model + self.assertTrue(match("pending", "/pending")) + self.assertFalse(match("pending", "/pending 2")) + self.assertTrue(match("banned", "/banned")) + self.assertFalse(match("banned", "/banned now")) + + def test_modules_use_the_registry_regex_objects(self): + self.assertIs( + owner_listen._COMMAND_RE, commands.command_detection_regex("owner-listen") + ) + self.assertIs( + channel_access._COMMAND_RE, + commands.command_detection_regex("channel-access"), + ) + self.assertIs( + migration._MIGRATE_COMMAND_RE, commands.command_detection_regex("migrate") + ) + self.assertIs( + version._COMMAND_RE, commands.command_detection_regex("tlon-version") + ) + self.assertIs(approval._ALLOW_RE, commands.command_detection_regex("allow")) + self.assertIs(approval._REJECT_RE, commands.command_detection_regex("reject")) + self.assertIs(approval._BAN_RE, commands.command_detection_regex("ban")) + self.assertIs(approval._UNBAN_RE, commands.command_detection_regex("unban")) + self.assertIs(approval._PENDING_RE, commands.command_detection_regex("pending")) + self.assertIs(approval._BANNED_RE, commands.command_detection_regex("banned")) + self.assertIs(adapter_mod._TLON_COMMAND_RE, commands.command_detection_regex("tlon")) + + def test_usage_strings_moved_verbatim(self): + self.assertEqual( + owner_listen.OWNER_LISTEN_USAGE, + "Usage: /owner-listen [on|off|status|list] [|<~host/group>] | " + "/owner-listen all [on|off] | /owner-listen default [owned|all]", + ) + self.assertEqual( + channel_access.CHANNEL_ACCESS_USAGE, + "Usage: /channel-access [open|restricted|status|list] []", + ) + self.assertEqual( + migration.MIGRATE_USAGE, + "Usage: /migrate [--allow-write-widening] | " + "/migrate cleanup ", + ) + rows = {row.name: row for row in commands.COMMAND_REGISTRY} + self.assertEqual(rows["owner-listen"].usage, owner_listen.OWNER_LISTEN_USAGE) + self.assertEqual(rows["channel-access"].usage, channel_access.CHANNEL_ACCESS_USAGE) + self.assertEqual(rows["migrate"].usage, migration.MIGRATE_USAGE) + + def test_telemetry_tokens_match_the_dispatcher_literals(self): + rows = {row.name: row for row in commands.COMMAND_REGISTRY} + expected = { + "owner-listen": "owner-listen", + "migrate": "migrate", + "tlon": None, # per-subcommand tokens come from the handler + "tlon-version": "tlon-version", + "allow": "allow", + "reject": "reject", + "ban": "ban", + "unban": "unban", + "pending": "pending", + "banned": "banned", + "channel-access": "channel-access", + } + for name, token in expected.items(): + self.assertEqual(rows[name].telemetry_token, token, name) + + # The fixture is what the client's drift contract reads + # (packages/shared/src/domain/runtimeCommandContract.test.ts). Regenerating + # it is the deliberate step that says "the client's static list must change + # too". + def test_build_tokens_matches_fixture(self): + fixture = FIXTURE_PATH.read_text(encoding="utf-8") + self.assertEqual(commands.build_command_tokens_json(), fixture) + # Byte-stable across calls. + self.assertEqual( + commands.build_command_tokens_json(), + commands.build_command_tokens_json(), + ) + + def test_fixture_names_every_advertised_row_and_nothing_else(self): + tokens = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + self.assertEqual( + tokens, [row.token for row in commands.advertised_command_rows()] + ) + self.assertEqual(commands.command_tokens(), tokens) + # The hidden alias is handled but never named to the client. + self.assertNotIn("/tlon-version", tokens) + + +class DispatcherParityTests(unittest.TestCase): + """Every advertised row, driven through _maybe_handle_control_command: + handled (consumed), not merely regex-matched. Pins registry↔dispatcher + wiring.""" + + def make_adapter(self): + base = { + "node_url": "https://pen.tlon.network", + "node_id": "~pen", + "access_code": "code", + "channels": ["chat/~pen/general"], + "owner_ship": "~mug", + "reaction_level": "off", + } + with patch.dict(os.environ, {}, clear=True): + adapter = adapter_mod.TlonAdapter(PlatformConfig(extra=base)) + adapter._sse = FakeSSE() + adapter._cli = FakeCLI() + adapter._settings_loaded = True + adapter._pending_nudge_rehydrated = True + + def _skip_nudge_persistence(*_args, **_kwargs): + return None + + adapter._nudge_activity_persistence.enqueue = _skip_nudge_persistence + adapter._nudge_activity_persistence.enqueue_stage_clear = _skip_nudge_persistence + adapter._pending_nudge_persistence.enqueue = _skip_nudge_persistence + return adapter + + def owner_message(self, text): + return tlon_api.TlonIncomingMessage( + chat_id="~mug", + chat_name="~mug", + chat_type="dm", + user_id="~mug", + user_name="~mug", + text=text, + message_id=f"msg-{text}", + reply_to_message_id=None, + sent_at=datetime.now(tz=timezone.utc), + raw={}, + ) + + def dispatch(self, adapter, text): + return asyncio.run( + adapter._maybe_handle_control_command( + self.owner_message(text), text, ctx_nest=None + ) + ) + + def test_dispatch_cases_cover_exactly_the_advertised_rows(self): + # Closes the parity loop: without this, an advertised-but-undispatched + # row is invisible because the test below only iterates DISPATCH_CASES. + self.assertEqual( + set(DISPATCH_CASES), + {row.name for row in commands.advertised_command_rows()}, + ) + + def test_every_advertised_row_is_handled(self): + for name, sample in DISPATCH_CASES.items(): + with self.subTest(command=name): + adapter = self.make_adapter() + self.assertTrue(self.dispatch(adapter, sample), name) + + def test_hidden_tlon_version_is_still_handled(self): + adapter = self.make_adapter() + self.assertTrue(self.dispatch(adapter, "/tlon-version")) + + def test_non_owner_is_not_handled(self): + adapter = self.make_adapter() + message = tlon_api.TlonIncomingMessage( + chat_id="~ten", + chat_name="~ten", + chat_type="dm", + user_id="~ten", + user_name="~ten", + text="/pending", + message_id="msg-1", + reply_to_message_id=None, + sent_at=datetime.now(tz=timezone.utc), + raw={}, + ) + self.assertFalse( + asyncio.run( + adapter._maybe_handle_control_command( + message, "/pending", ctx_nest=None + ) + ) + ) + + def test_shape_behavior_is_preserved_through_the_dispatcher(self): + # /pending 2 keeps falling through to the model... + adapter = self.make_adapter() + self.assertFalse(self.dispatch(adapter, "/pending 2")) + # ...while /tlon-version please keeps matching. + adapter = self.make_adapter() + self.assertTrue(self.dispatch(adapter, "/tlon-version please")) + + +# Deliberately values nothing in the tree can produce, so a hardcoded literal +# in adapter.py cannot masquerade as correct sourcing. Using the *real* current +# versions here would make `build_bot_info_json("0.15.0", "0.17.0 (2026.6.19)")` +# at the call site indistinguishable from reading them. The real resolver has +# its own tests in BotInfoClaimTests. +FAKE_HARNESS_VERSION = "harness-sentinel-9.9.9 (2099-01-01)" +FAKE_PLUGIN_VERSION = "plugin-sentinel-8.8.8" + + +def expected_claim(_adapter): + # Sourced the way the adapter must source it: its own package version plus + # the host's. Hardcoding either in adapter.py would fail this. + return bot_info.build_bot_info_json( + FAKE_PLUGIN_VERSION, FAKE_HARNESS_VERSION + ) + + +class PublishTests(unittest.TestCase): + def setUp(self): + patcher = patch.object( + adapter_mod, "plugin_version", return_value=FAKE_PLUGIN_VERSION + ) + patcher.start() + self.addCleanup(patcher.stop) + + def make_adapter(self): + base = { + "node_url": "https://pen.tlon.network", + "node_id": "~pen", + "access_code": "code", + "channels": ["chat/~pen/general"], + "owner_ship": "~mug", + "reaction_level": "off", + } + with patch.dict(os.environ, {}, clear=True): + adapter = adapter_mod.TlonAdapter(PlatformConfig(extra=base)) + adapter._sse = FakeSSE() + adapter._harness_version_cache = FAKE_HARNESS_VERSION + return adapter + + def test_publish_on_diff(self): + adapter = self.make_adapter() + asyncio.run(adapter._publish_bot_info({"nickname": {"type": "text", "value": "Bot"}})) + pokes = adapter._sse.pokes_for("contact-action-1") + self.assertEqual(len(pokes), 1) + app, mark, payload = pokes[0] + self.assertEqual(app, "contacts") + self.assertEqual( + payload, + { + "self": { + "bot-info": { + "type": "text", + "value": expected_claim(adapter), + } + } + }, + ) + + def test_skip_on_match(self): + adapter = self.make_adapter() + self_contact = { + "bot-info": {"type": "text", "value": expected_claim(adapter)} + } + asyncio.run(adapter._publish_bot_info(self_contact)) + self.assertEqual(adapter._sse.pokes_for("contact-action-1"), []) + + def test_republish_over_wrong_shape(self): + adapter = self.make_adapter() + self_contact = {"bot-info": {"type": "numb", "value": "0x1"}} + asyncio.run(adapter._publish_bot_info(self_contact)) + self.assertEqual(len(adapter._sse.pokes_for("contact-action-1")), 1) + + def test_publish_failure_is_non_fatal(self): + adapter = self.make_adapter() + adapter._sse.fail_pokes = True + with patch("asyncio.sleep", new_callable=AsyncMock) as sleeps: + # The terminal failure must surface as the caller-visible warning: + # swallowing it (or returning instead of raising internally) would + # hide a permanently unadvertised bot. + with self.assertLogs(adapter_mod.logger, level="WARNING") as logged: + asyncio.run(adapter._publish_bot_info({})) + # Every attempt was spent before giving up, and nothing was published. + self.assertEqual( + adapter._sse.poke_attempts, adapter_mod.BOT_INFO_PUBLISH_ATTEMPTS + ) + self.assertEqual(adapter._sse.pokes_for("contact-action-1"), []) + self.assertTrue( + any("could not publish" in line for line in logged.output), + logged.output, + ) + # Sleeps were *awaited* (await_args_list catches a dropped await, which + # call_args_list would not), only between attempts — never after the + # final failure. + self.assertEqual( + [c.args[0] for c in sleeps.await_args_list], + list(adapter_mod.BOT_INFO_PUBLISH_BACKOFF_SECONDS), + ) + + def test_publish_retries_a_transient_poke_failure(self): + adapter = self.make_adapter() + adapter._sse.fail_first_pokes = 2 + with patch("asyncio.sleep", new_callable=AsyncMock) as sleeps: + asyncio.run(adapter._publish_bot_info({})) + # Published on the third attempt, with backoff only between attempts. + self.assertEqual(len(adapter._sse.pokes_for("contact-action-1")), 1) + self.assertEqual( + adapter._sse.poke_attempts, adapter_mod.BOT_INFO_PUBLISH_ATTEMPTS + ) + self.assertEqual( + [c.args[0] for c in sleeps.await_args_list], + list(adapter_mod.BOT_INFO_PUBLISH_BACKOFF_SECONDS), + ) + + def test_publish_does_not_retry_when_the_read_failed(self): + adapter = self.make_adapter() + adapter._sse.fail_pokes = True + with patch("asyncio.sleep", new_callable=AsyncMock) as sleeps: + asyncio.run(adapter._publish_bot_info(None)) + self.assertEqual(adapter._sse.poke_attempts, 0) + self.assertEqual(sleeps.call_count, 0) + + def test_publish_does_not_retry_an_unchanged_value(self): + adapter = self.make_adapter() + self_contact = { + "bot-info": {"type": "text", "value": expected_claim(adapter)} + } + with patch("asyncio.sleep", new_callable=AsyncMock) as sleeps: + asyncio.run(adapter._publish_bot_info(self_contact)) + self.assertEqual(adapter._sse.poke_attempts, 0) + self.assertEqual(sleeps.call_count, 0) + + def test_publish_skipped_when_self_contact_unread(self): + # A failed read is not evidence the key is absent, so poking blind + # would defeat compare-then-poke exactly when the ship is unhealthy. + adapter = self.make_adapter() + asyncio.run(adapter._publish_bot_info(None)) + self.assertEqual(adapter._sse.pokes_for("contact-action-1"), []) + + def test_publish_on_successful_empty_self_contact(self): + # A successful read of a contact map without the key *is* evidence. + adapter = self.make_adapter() + asyncio.run(adapter._publish_bot_info({})) + self.assertEqual(len(adapter._sse.pokes_for("contact-action-1")), 1) + + def test_rejected_scry_publishes_nothing_end_to_end(self): + adapter = self.make_adapter() + + async def failing_scry(_path): + raise RuntimeError("ship unreachable") + + adapter._sse.scry = failing_scry + self_contact = asyncio.run(adapter._load_bot_profile()) + asyncio.run(adapter._publish_bot_info(self_contact)) + self.assertEqual(adapter._sse.pokes_for("contact-action-1"), []) + + def test_successful_empty_scry_publishes_once_end_to_end(self): + adapter = self.make_adapter() + adapter._sse.payloads["/contacts/v1/self.json"] = {} + self_contact = asyncio.run(adapter._load_bot_profile()) + asyncio.run(adapter._publish_bot_info(self_contact)) + self.assertEqual(len(adapter._sse.pokes_for("contact-action-1")), 1) + + def test_clear_pokes_null(self): + adapter = self.make_adapter() + asyncio.run(adapter._clear_bot_info()) + pokes = adapter._sse.pokes_for("contact-action-1") + self.assertEqual(len(pokes), 1) + self.assertEqual(pokes[0][2], {"self": {"bot-info": None}}) + + def test_load_bot_profile_returns_raw_self_contact(self): + adapter = self.make_adapter() + profile = {"nickname": {"type": "text", "value": "Bot"}} + adapter._sse.payloads["/contacts/v1/self.json"] = profile + self.assertEqual(asyncio.run(adapter._load_bot_profile()), profile) + self.assertEqual(adapter._bot_nickname, "Bot") + + def test_load_bot_profile_none_on_failure(self): + adapter = self.make_adapter() + self.assertIsNone(asyncio.run(adapter._load_bot_profile())) + + +class BotInfoClaimTests(unittest.TestCase): + def test_claim_shape(self): + self.assertEqual( + json.loads(bot_info.build_bot_info_json("0.4.2", "0.17.0 (2026.6.19)")), + { + "v": 1, + "harness": "hermes", + "version": "0.4.2", + "harnessVersion": "0.17.0 (2026.6.19)", + }, + ) + + def test_claim_is_byte_stable(self): + self.assertEqual( + bot_info.build_bot_info_json("0.4.2", "x"), + bot_info.build_bot_info_json("0.4.2", "x"), + ) + + def test_missing_harness_version_is_omitted_not_fatal(self): + for absent in (None, "", " "): + with self.subTest(harness_version=absent): + self.assertEqual( + json.loads(bot_info.build_bot_info_json("0.4.2", absent)), + {"v": 1, "harness": "hermes", "version": "0.4.2"}, + ) + + def test_cap_counts_utf8_bytes_not_characters(self): + """The self-cap is on UTF-8 bytes, not characters — the ship's jam + budget counts bytes, and non-ASCII is 2-4x its character length.""" + wide = "☃" * 40 + rendered = bot_info.build_bot_info_json(wide) + self.assertGreater(len(rendered.encode("utf-8")), len(rendered)) + + ceiling = (len(rendered) + len(rendered.encode("utf-8"))) // 2 + with patch.object(bot_info, "BOT_INFO_MAX_BYTES", ceiling): + with self.assertRaises(ValueError): + bot_info.build_bot_info_json(wide) + + def test_extract_bot_info_value_shape_checks(self): + claim = bot_info.build_bot_info_json("0.4.2") + self.assertEqual( + bot_info.extract_bot_info_value( + {"bot-info": {"type": "text", "value": claim}} + ), + claim, + ) + self.assertIsNone(bot_info.extract_bot_info_value({})) + self.assertIsNone( + bot_info.extract_bot_info_value({"bot-info": {"type": "set", "value": []}}) + ) + self.assertIsNone( + bot_info.extract_bot_info_value({"bot-info": {"type": "text", "value": 42}}) + ) + self.assertIsNone(bot_info.extract_bot_info_value({"bot-info": claim})) + self.assertIsNone(bot_info.extract_bot_info_value(None)) + + def test_build_bot_info_poke(self): + self.assertEqual( + bot_info.build_bot_info_poke("value"), + {"self": {"bot-info": {"type": "text", "value": "value"}}}, + ) + self.assertEqual( + bot_info.build_bot_info_poke(None), + {"self": {"bot-info": None}}, + ) + + def test_harness_version_prefers_the_hosts_own_constants(self): + module = types.ModuleType("hermes_cli") + module.__version__ = "0.17.0" + module.__release_date__ = "2026.6.19" + with patch.dict(sys.modules, {"hermes_cli": module}): + self.assertEqual( + bot_info.resolve_harness_version(), "0.17.0 (2026.6.19)" + ) + + def test_harness_version_falls_back_to_distribution_metadata(self): + with patch.dict(sys.modules, {"hermes_cli": None}): + with patch("importlib.metadata.version", return_value="0.17.0"): + self.assertEqual(bot_info.resolve_harness_version(), "0.17.0") + + def test_half_the_host_constants_is_a_loud_fallback_not_a_value(self): + # A present __version__ with an empty __release_date__ means the host + # convention moved. Publishing the bare SemVer would look like success + # while silently dropping the release identifier — and it is exactly + # what the distribution fallback yields anyway, so the preferred source + # must warn and fall through rather than return a degraded value. + for version_value, release_date in ( + ("0.17.0", ""), + ("", "2026.6.19"), + ): + with self.subTest(version=version_value, release=release_date): + module = types.ModuleType("hermes_cli") + module.__version__ = version_value + module.__release_date__ = release_date + with patch.dict(sys.modules, {"hermes_cli": module}): + with patch( + "importlib.metadata.version", return_value="9.9.9-dist" + ): + with self.assertLogs( + bot_info.logger, level="WARNING" + ) as logged: + self.assertEqual( + bot_info.resolve_harness_version(), "9.9.9-dist" + ) + self.assertTrue( + any("incomplete" in line for line in logged.output), + logged.output, + ) + + def test_harness_version_is_none_and_loud_when_nothing_is_available(self): + def no_metadata(_name): + raise LookupError("not installed") + + with patch.dict(sys.modules, {"hermes_cli": None}): + with patch("importlib.metadata.version", no_metadata): + with self.assertLogs(bot_info.logger, level="WARNING") as logged: + self.assertIsNone(bot_info.resolve_harness_version()) + self.assertTrue( + any("no Hermes version available" in line for line in logged.output), + logged.output, + ) + + +class StandaloneVersionImportTests(unittest.TestCase): + """version.py must stay importable as a top-level module (its documented + fingerprint recipe), while a packaged import uses the registry object.""" + + def test_top_level_import_uses_the_fallback_pattern(self): + saved = { + name: sys.modules.pop(name) + for name in ("version", "commands") + if name in sys.modules + } + sys.path.insert(0, str(PACKAGE_DIR)) + try: + import version as standalone_version + + self.assertIsNone(standalone_version.__package__ or None) + self.assertTrue(standalone_version.is_tlon_version_command("/tlon-version")) + self.assertTrue( + standalone_version.is_tlon_version_command("/tlon-version please") + ) + self.assertFalse(standalone_version.is_tlon_version_command("/tlon-versions")) + self.assertTrue(standalone_version.content_fingerprint().startswith("fp1:")) + finally: + sys.path.remove(str(PACKAGE_DIR)) + sys.modules.pop("version", None) + sys.modules.update(saved) + + def test_packaged_import_shares_the_registry_regex_object(self): + self.assertIs( + version._COMMAND_RE, commands.command_detection_regex("tlon-version") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/hermes-tlon-adapter/test_version.py b/packages/hermes-tlon-adapter/test_version.py index ee91ec814f..3f769d9abf 100644 --- a/packages/hermes-tlon-adapter/test_version.py +++ b/packages/hermes-tlon-adapter/test_version.py @@ -117,11 +117,13 @@ def test_field_per_line_format(self): source="main @ abc1234 (clean)", fingerprint="fp1:0123456789ab", cli_version="0.3.2", + harness_version="0.17.0 (2026.6.19)", ) self.assertEqual( reply.splitlines(), [ "*Harness*: **Hermes**", + "*Harness Version*: **0.17.0 (2026.6.19)**", "*Adapter Version*: **0.1.0**", "*Tlon Skill*: **0.3.2**", "*Fingerprint*: **fp1:0123456789ab**", @@ -129,6 +131,24 @@ def test_field_per_line_format(self): ], ) + def test_absent_harness_version_renders_unknown(self): + # The row is never dropped: OpenClaw always emits it, and the README + # promises the two harnesses answer with the same field-per-line + # summary. A host that reports nothing degrades the value, not the + # shape. + for absent in (None, "", " "): + with self.subTest(harness_version=absent): + reply = version.format_version_reply( + adapter_version="0.1.0", + source=None, + fingerprint="fp1:0123456789ab", + cli_version="0.3.2", + harness_version=absent, + ) + self.assertEqual( + reply.splitlines()[1], "*Harness Version*: **unknown**" + ) + def test_plain_keys_for_logs(self): reply = version.format_version_reply( adapter_version="0.1.0", diff --git a/packages/hermes-tlon-adapter/version.py b/packages/hermes-tlon-adapter/version.py index 22f9c1b727..f7d99e7977 100644 --- a/packages/hermes-tlon-adapter/version.py +++ b/packages/hermes-tlon-adapter/version.py @@ -7,8 +7,9 @@ - a content fingerprint over the runtime files, which identifies copied installs and hand-patched trees that a baked commit constant cannot -This module deliberately has no package-relative imports so the fingerprint -can be recomputed standalone at any checkout: +Its only package-relative import is the command registry, and that one is +guarded on ``__package__`` so the fingerprint can still be recomputed from a +standalone (non-package) import at any checkout: python3 -c "import version; print(version.content_fingerprint())" """ @@ -26,7 +27,17 @@ FINGERPRINT_HEX_CHARS = 12 PACKAGE_DIR = Path(__file__).resolve().parent -_COMMAND_RE = re.compile(r"^/tlon-version(?:\s|$)", re.IGNORECASE) +# Detection lives in the command registry (commands.py). The fallback is +# selected on import *mode*, not on exception: only a standalone (non-package) +# import lacks the registry. Catching ImportError broadly would silently swap +# in an independent regex if `commands.py` itself failed to import, defeating +# the registry-object identity the parity tests rely on. +if __package__: + from .commands import command_detection_regex + + _COMMAND_RE = command_detection_regex("tlon-version") +else: # standalone `import version` — see the module docstring + _COMMAND_RE = re.compile(r"^/tlon-version(?:\s|$)", re.IGNORECASE) def is_tlon_version_command(text: str) -> bool: @@ -114,11 +125,17 @@ def format_version_reply( source: Optional[str], fingerprint: str, cli_version: str, + harness_version: Optional[str] = None, markdown: bool = True, ) -> str: """Field-per-line version summary. For the chat reply keys are italicized and values bolded (Tlon markdown) for scannability; pass ``markdown=False`` - for plain log output.""" + for plain log output. + + ``harness_version`` is the running Hermes Agent's own version, taken by the + caller (the adapter resolves and caches it). It renders as ``unknown`` when + the host reports nothing, matching OpenClaw: the row always appears, so the + two harnesses' replies stay line-for-line comparable.""" def row(label: str, value: str) -> str: return f"*{label}*: **{value}**" if markdown else f"{label}: {value}" @@ -126,6 +143,7 @@ def row(label: str, value: str) -> str: return "\n".join( [ row("Harness", "Hermes"), + row("Harness Version", (harness_version or "").strip() or "unknown"), row("Adapter Version", adapter_version), row("Tlon Skill", cli_version), row("Fingerprint", fingerprint), diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index 276915dcef..499d81b77d 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -144,6 +144,12 @@ Fingerprint: fp1:8aa23ca2bc8d Source: no git checkout ``` +### Bot info + +At monitor boot (and on reconnect catch-up) the plugin publishes the bot's identity — harness, plugin version, host version — in the bot's own contact profile under `bot-info`, compare-then-poke. Tlon clients use the claimed harness to pick which of _their_ static slash-command lists to suggest; this plugin publishes no command list of its own. Wire contract and clear-to-null rollback procedure: [docs/bot-info.md](../../docs/bot-info.md). + +The registry in `src/commands-registry.ts` is the single source of truth for registration, and `fixtures/commands.json` is its committed token list. That fixture is a CI artifact, not a wire payload: the client's drift contract (`packages/shared/src/domain/runtimeCommandContract.test.ts`, run by the `bot-checks` job) asserts it names exactly the commands the client's OpenClaw list suggests, so adding or removing a command here fails until the client list changes too. **Removals are two-phase**: hosted bots redeploy on restart while the app releases slowly, so keep a removed command's handler alive until an app release stops suggesting it. + ## Bundled Skill This plugin bundles [@tloncorp/tlon-skill](https://www.npmjs.com/package/@tloncorp/tlon-skill) which provides CLI commands for: diff --git a/packages/openclaw/fixtures/commands.json b/packages/openclaw/fixtures/commands.json new file mode 100644 index 0000000000..279aa8baad --- /dev/null +++ b/packages/openclaw/fixtures/commands.json @@ -0,0 +1,12 @@ +[ + "/tlon-version", + "/tlon", + "/allow", + "/reject", + "/ban", + "/pending", + "/banned", + "/unban", + "/owner-listen", + "/migrate" +] diff --git a/packages/openclaw/index.ts b/packages/openclaw/index.ts index 340acb74fd..833c598a5c 100644 --- a/packages/openclaw/index.ts +++ b/packages/openclaw/index.ts @@ -9,6 +9,7 @@ import { } from 'openclaw/plugin-sdk/diagnostic-runtime'; import { tlonPlugin } from './src/channel.js'; +import { registerTlonCommands } from './src/commands-registry.js'; import { publishContextLensEvent } from './src/context-lens-events.js'; import { registerContextLensRoutes } from './src/context-lens-routes.js'; import { initContextLensShipSync } from './src/context-lens-ship-sync.js'; @@ -34,13 +35,8 @@ import { } from './src/diagnostic-subscriptions.js'; import { notifyDiaryMigrationDiscovery } from './src/diary-migration-discovery.js'; import { registerGatewayStatusHooks } from './src/gateway-status-registration.js'; -import { - createMigrateCommandHandler, - routeMigrateCommand, -} from './src/migrate-command.js'; -import { resolveBridgeForCommand } from './src/monitor/command-auth.js'; +import { createMigrateCommandHandler } from './src/migrate-command.js'; import { isRouteDebugEnabled } from './src/monitor/session-routing.js'; -import { handleOwnerListenCommand } from './src/owner-listen-command.js'; import { setTlonRuntime } from './src/runtime.js'; import { getSessionRole } from './src/session-roles.js'; import { parseTlonTarget } from './src/targets.js'; @@ -911,33 +907,6 @@ export default defineBundledChannelEntry({ api.logger.info(`[tlon] Tlon skill version: ${version}`); }); - // Register /tlon-version command - api.registerCommand({ - name: 'tlon-version', - description: 'Show Tlon plugin version.', - handler: async () => { - return renderTlonVersion(); - }, - }); - - api.registerCommand({ - name: 'tlon', - description: 'Tlon plugin diagnostics. Usage: /tlon version', - acceptsArgs: true, - handler: async (ctx) => { - const args = (ctx.args ?? '').trim().toLowerCase(); - if (args !== 'version') { - return { text: 'Usage: /tlon version' }; - } - - const result = resolveBridgeForCommand(ctx); - if ('error' in result) { - return { text: result.error }; - } - return renderTlonVersion(); - }, - }); - const contextLensRoutesEnabled = registerContextLensRoutes(api); const contextLensShipSyncEnabled = initContextLensShipSync(api); // Recording and the disk store run when at least one reader path is @@ -1426,139 +1395,13 @@ export default defineBundledChannelEntry({ }); // ── Slash commands for approval & admin ──────────────────────────── - api.registerCommand({ - name: 'allow', - description: 'Allow a pending DM/channel/group request', - acceptsArgs: true, - handler: async (ctx) => { - const result = resolveBridgeForCommand(ctx); - if ('error' in result) { - return { text: result.error }; - } - return { - text: await result.bridge.handleAction( - 'approve', - ctx.args?.trim() || undefined - ), - }; - }, - }); - - api.registerCommand({ - name: 'reject', - description: 'Reject a pending DM/channel/group request', - acceptsArgs: true, - handler: async (ctx) => { - const result = resolveBridgeForCommand(ctx); - if ('error' in result) { - return { text: result.error }; - } - return { - text: await result.bridge.handleAction( - 'deny', - ctx.args?.trim() || undefined - ), - }; - }, - }); - - api.registerCommand({ - name: 'ban', - description: 'Ban a ship and deny its pending request', - acceptsArgs: true, - handler: async (ctx) => { - const result = resolveBridgeForCommand(ctx); - if ('error' in result) { - return { text: result.error }; - } - return { - text: await result.bridge.handleAction( - 'block', - ctx.args?.trim() || undefined - ), - }; - }, - }); - - api.registerCommand({ - name: 'pending', - description: 'List pending approval requests', - handler: async (ctx) => { - const result = resolveBridgeForCommand(ctx); - if ('error' in result) { - return { text: result.error }; - } - return await result.bridge.getPendingApprovalsReply(); - }, - }); - - api.registerCommand({ - name: 'banned', - description: 'List banned ships', - handler: async (ctx) => { - const result = resolveBridgeForCommand(ctx); - if ('error' in result) { - return { text: result.error }; - } - return { text: await result.bridge.getBlockedList() }; - }, - }); - - api.registerCommand({ - name: 'unban', - description: 'Unban a ship (e.g. /unban ~sampel-palnet)', - acceptsArgs: true, - handler: async (ctx) => { - const result = resolveBridgeForCommand(ctx); - if ('error' in result) { - return { text: result.error }; - } - const ship = ctx.args?.trim(); - if (!ship) { - return { text: 'Usage: /unban ~ship-name' }; - } - return { text: await result.bridge.handleUnblock(ship) }; - }, - }); - - api.registerCommand({ - name: 'owner-listen', - description: - 'Control whether the bot listens for the owner without @-mention in owned channels. ' + - 'Usage: /owner-listen [on|off|status|list] []; ' + - '/owner-listen all [on|off] for the global kill switch.', - acceptsArgs: true, - handler: async (ctx) => { - const result = resolveBridgeForCommand(ctx); - if ('error' in result) { - return { text: result.error }; - } - const text = await handleOwnerListenCommand( - result.bridge, - ctx.args, - ctx.from - ); - return { text }; - }, - }); - - api.registerCommand({ - name: 'migrate', - description: - 'Run or clean up a diary-to-notes migration. Usage: ' + - '/migrate [--allow-write-widening] | ' + - '/migrate cleanup ', - acceptsArgs: true, - handler: async (ctx) => { - return { - text: await routeMigrateCommand( - ctx, - ctx.args, - handleMigrateCommand, - api.config - ), - }; - }, + // All plugin commands live in one table (commands-registry.ts) that both + // registers the handlers and serializes as fixtures/commands.json, the + // token list the Tlon client's drift contract pins its static list against. + registerTlonCommands(api, { + renderTlonVersion, + handleMigrateCommand, + config: api.config, }); }, }); diff --git a/packages/openclaw/src/bot-info.test.ts b/packages/openclaw/src/bot-info.test.ts new file mode 100644 index 0000000000..79b5ff16f7 --- /dev/null +++ b/packages/openclaw/src/bot-info.test.ts @@ -0,0 +1,572 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; + +import { + BOT_INFO_CONTACT_KEY, + BOT_INFO_MAX_BYTES, + BOT_INFO_PUBLISH_ATTEMPTS, + BOT_INFO_PUBLISH_BACKOFF_MS, + type BotInfoPokeApi, + SELF_CONTACT_SCRY_PATH, + type SelfContactRead, + buildBotInfoJson, + defaultSleep, + maybePublishBotInfo, + publishBotInfo, + readBotInfoValue, + readSelfContact, + syncBotInfo, +} from './bot-info.js'; + +const infoValue = buildBotInfoJson({ + version: '0.19.0', + harnessVersion: '2026.5.28', +}); + +describe('buildBotInfoJson', () => { + it('names the harness, the plugin version, and the host version', () => { + expect(JSON.parse(infoValue)).toEqual({ + v: 1, + harness: 'openclaw', + version: '0.19.0', + harnessVersion: '2026.5.28', + }); + }); + + it('is byte-stable, so compare-then-poke does not false-positive', () => { + expect( + buildBotInfoJson({ version: '0.19.0', harnessVersion: '2026.5.28' }) + ).toBe(infoValue); + }); + + it('omits an unavailable host version rather than invalidating the claim', () => { + for (const harnessVersion of [undefined, null, '', ' ']) { + const value = buildBotInfoJson({ version: '0.19.0', harnessVersion }); + expect(JSON.parse(value)).toEqual({ + v: 1, + harness: 'openclaw', + version: '0.19.0', + }); + } + }); + + it('throws rather than publishing past the client parse ceiling', () => { + expect(() => + buildBotInfoJson({ version: 'x'.repeat(BOT_INFO_MAX_BYTES) }) + ).toThrow(/exceeds/); + }); +}); + +const selfContactWith = (value: unknown) => ({ + nickname: { type: 'text', value: 'Bot' }, + [BOT_INFO_CONTACT_KEY]: value, +}); + +// A successful read of the given contact map. +const read = (contact: unknown): SelfContactRead => ({ ok: true, contact }); + +describe('readBotInfoValue', () => { + it('reads a well-formed text field', () => { + expect( + readBotInfoValue(selfContactWith({ type: 'text', value: infoValue })) + ).toBe(infoValue); + }); + + it('returns null for absent or wrong-shaped fields', () => { + expect( + readBotInfoValue({ nickname: { type: 'text', value: 'Bot' } }) + ).toBeNull(); + expect( + readBotInfoValue(selfContactWith({ type: 'set', value: [] })) + ).toBeNull(); + expect( + readBotInfoValue(selfContactWith({ type: 'text', value: 42 })) + ).toBeNull(); + expect(readBotInfoValue(selfContactWith(infoValue))).toBeNull(); + expect(readBotInfoValue(null)).toBeNull(); + expect(readBotInfoValue('not-a-contact')).toBeNull(); + }); +}); + +describe('publishBotInfo', () => { + it('pokes the claim as a contact-action-1 self text field', async () => { + const poke = vi.fn(async () => {}); + const api: BotInfoPokeApi = { poke }; + + await expect(publishBotInfo(api, infoValue)).resolves.toBe('published'); + expect(poke).toHaveBeenCalledWith({ + app: 'contacts', + mark: 'contact-action-1', + json: { + self: { + [BOT_INFO_CONTACT_KEY]: { type: 'text', value: infoValue }, + }, + }, + }); + }); + + it('pokes null to clear the key (rollback/retirement)', async () => { + const poke = vi.fn(async () => {}); + const api: BotInfoPokeApi = { poke }; + + await expect(publishBotInfo(api, null)).resolves.toBe('cleared'); + expect(poke).toHaveBeenCalledWith({ + app: 'contacts', + mark: 'contact-action-1', + json: { self: { [BOT_INFO_CONTACT_KEY]: null } }, + }); + }); +}); + +describe('maybePublishBotInfo', () => { + it('publishes when the current value differs', async () => { + const poke = vi.fn(async () => {}); + const api: BotInfoPokeApi = { poke }; + + await expect( + maybePublishBotInfo( + api, + read( + selfContactWith({ + type: 'text', + value: '{"v":1,"harness":"openclaw","version":"0.18.0"}', + }) + ), + infoValue + ) + ).resolves.toBe('published'); + expect(poke).toHaveBeenCalledTimes(1); + }); + + it('publishes when nothing is currently published', async () => { + const poke = vi.fn(async () => {}); + const api: BotInfoPokeApi = { poke }; + + await expect( + maybePublishBotInfo( + api, + read({ nickname: { type: 'text', value: 'Bot' } }), + infoValue + ) + ).resolves.toBe('published'); + expect(poke).toHaveBeenCalledTimes(1); + }); + + it('skips the poke when the value already matches', async () => { + const poke = vi.fn(async () => {}); + const api: BotInfoPokeApi = { poke }; + + await expect( + maybePublishBotInfo( + api, + read(selfContactWith({ type: 'text', value: infoValue })), + infoValue + ) + ).resolves.toBe('unchanged'); + expect(poke).not.toHaveBeenCalled(); + }); + + it('republishes over a wrong-shaped stored value', async () => { + const poke = vi.fn(async () => {}); + const api: BotInfoPokeApi = { poke }; + + await expect( + maybePublishBotInfo( + api, + read(selfContactWith({ type: 'numb', value: '0x1' })), + infoValue + ) + ).resolves.toBe('published'); + expect(poke).toHaveBeenCalledTimes(1); + }); +}); + +// A transient poke failure must not leave a healthy long-lived bot +// unadvertised until an unrelated reconnect or a restart. +describe('publish retry', () => { + // Injected so the backoff never actually delays the suite. + const recordingSleeper = () => { + const slept: number[] = []; + return { + slept, + sleep: async (ms: number) => { + slept.push(ms); + }, + }; + }; + + const flakyPoke = (failures: number) => { + let calls = 0; + return vi.fn(async () => { + calls += 1; + if (calls <= failures) { + throw new Error(`poke nacked ${calls}`); + } + }); + }; + + it('retries a failing poke and succeeds on the third attempt', async () => { + const poke = flakyPoke(2); + const { slept, sleep } = recordingSleeper(); + + await expect( + maybePublishBotInfo({ poke }, read({}), infoValue, sleep) + ).resolves.toBe('published'); + expect(poke).toHaveBeenCalledTimes(BOT_INFO_PUBLISH_ATTEMPTS); + expect(slept).toEqual([...BOT_INFO_PUBLISH_BACKOFF_MS]); + }); + + it('awaits each backoff before the next attempt', async () => { + // A recording sleeper cannot tell an awaited sleep from a dropped one: if + // the publisher forgot `await`, retries would fire immediately and the + // 2s/8s timers would escape the publish call. Deferred sleepers pin the + // ordering — the next poke must not happen until the delay is released. + const poke = flakyPoke(2); + const releases: Array<() => void> = []; + const sleep = vi.fn( + (_ms: number) => + new Promise((resolve) => { + releases.push(resolve); + }) + ); + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + const result = maybePublishBotInfo({ poke }, read({}), infoValue, sleep); + + await flush(); + expect(poke).toHaveBeenCalledTimes(1); + expect(releases).toHaveLength(1); + + releases[0](); + await flush(); + expect(poke).toHaveBeenCalledTimes(2); + expect(releases).toHaveLength(2); + + releases[1](); + await expect(result).resolves.toBe('published'); + expect(poke).toHaveBeenCalledTimes(3); + // No sleep is requested after the attempt that succeeds. + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it('gives up after the attempt cap, non-fatally', async () => { + const poke = flakyPoke(Number.POSITIVE_INFINITY); + const { slept, sleep } = recordingSleeper(); + + await expect( + syncBotInfo( + { poke, scry: vi.fn(async () => ({})) }, + infoValue, + undefined, + sleep + ) + ).resolves.toBe('skipped'); + expect(poke).toHaveBeenCalledTimes(BOT_INFO_PUBLISH_ATTEMPTS); + // Backoff only *between* attempts, never after the last one. + expect(slept).toHaveLength(BOT_INFO_PUBLISH_ATTEMPTS - 1); + }); + + it('stops retrying when aborted during the backoff', async () => { + // Shutdown/config-reload during the 2s/8s window: the retired monitor must + // not keep the retry loop alive against its stale SSE client. The abortable + // default sleeper rejects on abort; the rejection surfaces through + // syncBotInfo's catch as a non-fatal 'skipped'. + const poke = flakyPoke(Number.POSITIVE_INFINITY); + const controller = new AbortController(); + // Honors the signal the way defaultSleep does, without real timers. + const sleep = vi.fn( + (_ms: number, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(new Error('Aborted')), + { once: true } + ); + }) + ); + + const result = syncBotInfo( + { poke, scry: vi.fn(async () => ({})) }, + infoValue, + undefined, + sleep, + controller.signal + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(poke).toHaveBeenCalledTimes(1); + + controller.abort(); + await expect(result).resolves.toBe('skipped'); + // The abort ended the loop: no further pokes were attempted. + expect(poke).toHaveBeenCalledTimes(1); + }); + + it('cancels the pending default-sleeper timer on abort', async () => { + const controller = new AbortController(); + const poke = flakyPoke(Number.POSITIVE_INFINITY); + + // Default sleeper + a 2s backoff: without abort handling this test would + // time out; with it, the abort rejects promptly and the timer is cleared. + const pending = publishBotInfo( + { poke }, + infoValue, + undefined, + controller.signal + ); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await expect(pending).rejects.toThrow('Aborted'); + expect(poke).toHaveBeenCalledTimes(1); + }); + + // Cleanup proofs for the sleeper itself: rejecting promptly is not enough — + // a cleared rejection with a leaked timer keeps the retired monitor's event + // loop alive for the full backoff anyway, and a leaked abort listener + // accumulates across publishes on a long-lived signal. + describe('defaultSleep cleanup', () => { + it('clears the pending timer the moment the signal aborts', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const pending = defaultSleep(2_000, controller.signal); + expect(vi.getTimerCount()).toBe(1); + + controller.abort(); + expect(vi.getTimerCount()).toBe(0); + await expect(pending).rejects.toThrow('Aborted'); + } finally { + vi.useRealTimers(); + } + }); + + it('allocates no timer for an already-aborted signal', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + controller.abort(); + const pending = defaultSleep(2_000, controller.signal); + expect(vi.getTimerCount()).toBe(0); + await expect(pending).rejects.toThrow('Aborted'); + } finally { + vi.useRealTimers(); + } + }); + + it('removes its abort listener when the timer wins', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const removed = vi.spyOn(controller.signal, 'removeEventListener'); + const pending = defaultSleep(2_000, controller.signal); + + vi.advanceTimersByTime(2_000); + await expect(pending).resolves.toBeUndefined(); + expect(removed).toHaveBeenCalledWith('abort', expect.any(Function)); + + // A later abort is a no-op: the promise already settled and no + // listener remains to fire (an unhandled rejection here would fail + // the run). + controller.abort(); + } finally { + vi.useRealTimers(); + } + }); + }); + + it('does not retry when the read failed', async () => { + const poke = flakyPoke(Number.POSITIVE_INFINITY); + const { slept, sleep } = recordingSleeper(); + + await expect( + maybePublishBotInfo( + { poke }, + { ok: false, error: new Error('scry failed') }, + infoValue, + sleep + ) + ).resolves.toBe('skipped'); + expect(poke).not.toHaveBeenCalled(); + expect(slept).toEqual([]); + }); + + it('does not retry when the compare says unchanged', async () => { + const poke = flakyPoke(Number.POSITIVE_INFINITY); + const { slept, sleep } = recordingSleeper(); + + await expect( + maybePublishBotInfo( + { poke }, + read(selfContactWith({ type: 'text', value: infoValue })), + infoValue, + sleep + ) + ).resolves.toBe('unchanged'); + expect(poke).not.toHaveBeenCalled(); + expect(slept).toEqual([]); + }); +}); + +// B-3: a failed self-contact read is not evidence that the key is absent. +describe('failed self-contact reads', () => { + it('skips the poke when the read failed', async () => { + const poke = vi.fn(async () => {}); + const api: BotInfoPokeApi = { poke }; + + await expect( + maybePublishBotInfo( + api, + { ok: false, error: new Error('scry failed') }, + infoValue + ) + ).resolves.toBe('skipped'); + expect(poke).not.toHaveBeenCalled(); + }); + + it('publishes when the read succeeded with an empty contact map', async () => { + const poke = vi.fn(async () => {}); + const api: BotInfoPokeApi = { poke }; + + await expect(maybePublishBotInfo(api, read({}), infoValue)).resolves.toBe( + 'published' + ); + expect(poke).toHaveBeenCalledTimes(1); + }); + + it('readSelfContact reports failure instead of an empty map', async () => { + const failing = { + scry: vi.fn(async () => { + throw new Error('boom'); + }), + }; + await expect(readSelfContact(failing)).resolves.toMatchObject({ + ok: false, + }); + + const ok = { scry: vi.fn(async () => ({})) }; + await expect(readSelfContact(ok)).resolves.toEqual({ + ok: true, + contact: {}, + }); + expect(ok.scry).toHaveBeenCalledWith(SELF_CONTACT_SCRY_PATH); + }); +}); + +// B-4: reconnect catch-up. A boot publish that failed, or a key cleared while +// this process stayed alive, must not wait for a restart. +describe('syncBotInfo', () => { + const makeApi = (scryImpl: () => Promise) => { + const poke = vi.fn(async () => {}); + const scry = vi.fn(scryImpl); + return { api: { poke, scry }, poke, scry }; + }; + + it('re-reads the self-contact and publishes when it differs', async () => { + const { api, poke, scry } = makeApi(async () => ({})); + + await expect(syncBotInfo(api, infoValue)).resolves.toBe('published'); + expect(scry).toHaveBeenCalledWith(SELF_CONTACT_SCRY_PATH); + expect(poke).toHaveBeenCalledTimes(1); + }); + + it('re-reads and skips the poke when the value already matches', async () => { + const { api, poke } = makeApi(async () => + selfContactWith({ type: 'text', value: infoValue }) + ); + + await expect(syncBotInfo(api, infoValue)).resolves.toBe('unchanged'); + expect(poke).not.toHaveBeenCalled(); + }); + + it('skips (never throws) when the re-read fails', async () => { + const { api, poke } = makeApi(async () => { + throw new Error('ship unreachable'); + }); + + await expect(syncBotInfo(api, infoValue)).resolves.toBe('skipped'); + expect(poke).not.toHaveBeenCalled(); + }); + + it('reuses a supplied read instead of scrying again (boot path)', async () => { + const { api, poke, scry } = makeApi(async () => ({})); + + await expect(syncBotInfo(api, infoValue, read({}))).resolves.toBe( + 'published' + ); + expect(scry).not.toHaveBeenCalled(); + expect(poke).toHaveBeenCalledTimes(1); + }); + + it('swallows a failing poke', async () => { + const poke = vi.fn(async () => { + throw new Error('poke nacked'); + }); + const api = { poke, scry: vi.fn(async () => ({})) }; + + // Sleeper injected so the publish retry's backoff does not delay the suite. + await expect( + syncBotInfo(api, infoValue, undefined, async () => {}) + ).resolves.toBe('skipped'); + }); +}); + +// The publisher above is exercised as a helper; nothing else binds it to the +// monitor's lifecycle. monitorTlonProvider is not unit-testable (huge module, +// heavy setup), so this asserts the call sites at the source level — the same +// technique the registration-parity test uses for index.ts. Delete either call +// and publication becomes dead code with no other failing test. +describe('monitor lifecycle call sites', () => { + const monitorSource = fs.readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + './monitor/index.ts' + ), + 'utf8' + ); + + it('publishes on boot and again on reconnect', () => { + expect(monitorSource).toMatch(/publishBotInfoNow\('boot'\)/); + expect(monitorSource).toMatch(/publishBotInfoNow\('reconnect'\)/); + }); + + it('builds the claim inside the failure guard', () => { + const body = monitorSource.slice( + monitorSource.indexOf('async function publishBotInfoNow') + ); + const end = body.indexOf('\n }\n'); + expect(end).toBeGreaterThan(0); + const fn = body.slice(0, end); + + // The builder throws when the claim serializes past the byte cap, and boot + // awaits this call: outside the guard, an oversized value would take the + // bot offline instead of just leaving it unidentified. + expect(fn).toMatch(/catch/); + expect(fn.indexOf('try {')).toBeGreaterThan(-1); + expect(fn.indexOf('try {')).toBeLessThan(fn.indexOf('buildBotInfoJson(')); + }); + + it('sources both versions from the plugin identity and the host', () => { + // buildBotInfoJson is pure, so only the call site can bind the claim to the + // real versions; hardcoding or dropping either would pass every other test. + const body = monitorSource.slice( + monitorSource.indexOf('async function publishBotInfoNow') + ); + const fn = body.slice(0, body.indexOf('\n }\n')); + expect(fn).toMatch(/version: getTlonVersionIdentity\(\)\.pluginVersion/); + expect(fn).toMatch(/harnessVersion: core\.version/); + }); + + it('forwards the monitor abort signal into the publish call', () => { + // The abort-loop unit test injects a signal directly, so it cannot catch + // the monitor forgetting to pass its own: dropping opts.abortSignal from + // this call would revive the retired-monitor backoff zombie with every + // other test green. + const body = monitorSource.slice( + monitorSource.indexOf('async function publishBotInfoNow') + ); + const fn = body.slice(0, body.indexOf('\n }\n')); + expect(fn).toMatch(/syncBotInfo\([\s\S]*?opts\.abortSignal/); + }); +}); diff --git a/packages/openclaw/src/bot-info.ts b/packages/openclaw/src/bot-info.ts new file mode 100644 index 0000000000..e94925cd55 --- /dev/null +++ b/packages/openclaw/src/bot-info.ts @@ -0,0 +1,203 @@ +// Contact-profile key under which the bot publishes its identity claim (wire +// contract: docs/bot-info.md in tlon-apps). +export const BOT_INFO_CONTACT_KEY = 'bot-info'; +// The client rejects raw claims above this size. The backend's 10kB jam cap +// covers the whole profile, so a publish failure is a real, non-fatal outcome +// regardless. +export const BOT_INFO_MAX_BYTES = 512; + +const HARNESS = 'openclaw'; + +export type BotInfoPublishResult = 'published' | 'cleared' | 'unchanged'; + +export interface BotInfoPokeApi { + poke(params: { app: string; mark: string; json: unknown }): Promise; +} + +export interface BotInfoScryApi { + scry(path: string): Promise; +} + +export const SELF_CONTACT_SCRY_PATH = '/contacts/v1/self.json'; + +// A poke that fails transiently would otherwise leave a healthy long-lived bot +// unidentified until an unrelated SSE reconnect or a restart, so the write is +// retried in place. Reads are never retried: a failed read is handled by +// skipping entirely (see SelfContactRead). +export const BOT_INFO_PUBLISH_ATTEMPTS = 3; +export const BOT_INFO_PUBLISH_BACKOFF_MS: readonly number[] = [2_000, 8_000]; + +// The signal is the monitor's opts.abortSignal: a shutdown or config-reload +// restart during the 2s/8s backoff must cancel the pending timer and stop the +// retry loop, or a retired monitor lingers and retries against its stale SSE +// client (same pattern as the authentication backoff in monitor/index.ts). +export type Sleeper = (ms: number, signal?: AbortSignal) => Promise; + +// Exported for the timer/listener-cleanup tests; production always reaches it +// through the Sleeper default. +export const defaultSleep: Sleeper = (ms, signal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Aborted')); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(new Error('Aborted')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); + +// Serialize the identity claim. Byte-stable: JSON key order follows +// construction order, which is fixed here, so compare-then-poke does not +// false-positive. +// +// `harnessVersion` is a diagnostic rider: it is omitted when the host does not +// report a version, never allowed to invalidate the claim. Callers log that +// omission — for this runtime it always means something is broken. +export function buildBotInfoJson(params: { + version: string; + harnessVersion?: string | null; +}): string { + const harnessVersion = params.harnessVersion?.trim(); + const value = JSON.stringify({ + v: 1, + harness: HARNESS, + version: params.version, + ...(harnessVersion ? { harnessVersion } : {}), + }); + const bytes = new TextEncoder().encode(value).byteLength; + if (bytes > BOT_INFO_MAX_BYTES) { + throw new Error( + `bot info exceeds ${BOT_INFO_MAX_BYTES} UTF-8 bytes: ${bytes}` + ); + } + return value; +} + +// A self-contact read that failed is not the same as one that succeeded +// without the key: only the latter proves the key is absent. Publishing on a +// failed read defeats compare-then-poke exactly when the ship is unhealthy. +export type SelfContactRead = + | { ok: true; contact: unknown } + | { ok: false; error: unknown }; + +export async function readSelfContact( + api: BotInfoScryApi +): Promise { + try { + return { ok: true, contact: await api.scry(SELF_CONTACT_SCRY_PATH) }; + } catch (error) { + return { ok: false, error }; + } +} + +// Runtime shape check for the `bot-info` field on a self-contact map: only a +// %text field carrying a string is a published claim. +export function readBotInfoValue(selfContact: unknown): string | null { + if (!selfContact || typeof selfContact !== 'object') { + return null; + } + const field = (selfContact as Record)[BOT_INFO_CONTACT_KEY]; + if (!field || typeof field !== 'object' || Array.isArray(field)) { + return null; + } + const candidate = field as { type?: unknown; value?: unknown }; + if (candidate.type !== 'text' || typeof candidate.value !== 'string') { + return null; + } + return candidate.value; +} + +// Poke the identity claim into the bot's own contact profile. `%self` is a +// merge, so nickname/avatar survive. Passing null clears the key (the +// documented rollback/retirement procedure — see docs/bot-info.md): contact +// keys only die by explicit null. Retried up to BOT_INFO_PUBLISH_ATTEMPTS with +// bounded backoff; the last failure rethrows so callers keep today's non-fatal +// log-and-continue. +export async function publishBotInfo( + api: BotInfoPokeApi, + desiredValue: string | null, + sleep: Sleeper = defaultSleep, + abortSignal?: AbortSignal +): Promise { + for (let attempt = 1; ; attempt++) { + try { + await api.poke({ + app: 'contacts', + mark: 'contact-action-1', + json: { + self: { + [BOT_INFO_CONTACT_KEY]: + desiredValue === null + ? null + : { type: 'text', value: desiredValue }, + }, + }, + }); + return desiredValue === null ? 'cleared' : 'published'; + } catch (error) { + if (attempt >= BOT_INFO_PUBLISH_ATTEMPTS) { + throw error; + } + // An aborted sleep rejects, which lands in syncBotInfo's catch as a + // non-fatal 'skipped' — the retired monitor stops retrying. + await sleep( + BOT_INFO_PUBLISH_BACKOFF_MS[ + Math.min(attempt - 1, BOT_INFO_PUBLISH_BACKOFF_MS.length - 1) + ], + abortSignal + ); + } + } +} + +// Compare-then-poke: only write when the published value actually changed. +// Content comparison is the version/change detection — no fingerprint +// persistence. Non-fatal: callers log and continue (next boot retries). +// A failed self-contact read yields 'skipped': the current value is unknown, +// so there is nothing to compare against. +export async function maybePublishBotInfo( + api: BotInfoPokeApi, + selfContact: SelfContactRead, + desiredValue: string, + sleep: Sleeper = defaultSleep, + abortSignal?: AbortSignal +): Promise { + if (!selfContact.ok) { + return 'skipped'; + } + const currentValue = readBotInfoValue(selfContact.contact); + if (currentValue === desiredValue) { + return 'unchanged'; + } + return publishBotInfo(api, desiredValue, sleep, abortSignal); +} + +// Boot and reconnect both land here: read the self-contact, compare, poke on +// difference. Reconnect matters because a failed boot publish — or a key +// cleared while the monitor stays alive — would otherwise persist until the +// process restarts. Never throws; the result is for logging only. +export async function syncBotInfo( + api: BotInfoPokeApi & BotInfoScryApi, + desiredValue: string, + selfContact?: SelfContactRead, + sleep: Sleeper = defaultSleep, + abortSignal?: AbortSignal +): Promise { + try { + return await maybePublishBotInfo( + api, + selfContact ?? (await readSelfContact(api)), + desiredValue, + sleep, + abortSignal + ); + } catch { + return 'skipped'; + } +} diff --git a/packages/openclaw/src/commands-registry.test.ts b/packages/openclaw/src/commands-registry.test.ts new file mode 100644 index 0000000000..db0591cf87 --- /dev/null +++ b/packages/openclaw/src/commands-registry.test.ts @@ -0,0 +1,125 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { PluginCommandContext } from 'openclaw/plugin-sdk/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { + TLON_COMMAND_REGISTRY, + type TlonCommandDeps, + buildCommandTokensJson, + commandTokens, + registerTlonCommands, +} from './commands-registry.js'; + +const fixturePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../fixtures/commands.json' +); +const fixtureJson = fs.readFileSync(fixturePath, 'utf8'); + +const makeDeps = (): TlonCommandDeps => ({ + renderTlonVersion: async () => ({ text: 'tlon version' }), + handleMigrateCommand: + undefined as unknown as TlonCommandDeps['handleMigrateCommand'], + config: {} as TlonCommandDeps['config'], +}); + +describe('command registry', () => { + it('holds exactly the ten plugin commands', () => { + expect(TLON_COMMAND_REGISTRY.map((entry) => entry.name)).toEqual([ + 'tlon-version', + 'tlon', + 'allow', + 'reject', + 'ban', + 'pending', + 'banned', + 'unban', + 'owner-listen', + 'migrate', + ]); + // OpenClaw core commands (/status, /help, /new) are absent by + // construction: this plugin neither registers nor dispatches them. The + // client carries them on its static list as audit-pinned constants. + expect(TLON_COMMAND_REGISTRY.map((entry) => entry.name)).not.toContain( + 'status' + ); + }); + + it('registers exactly the registry rows (exact-equality parity)', () => { + const registerCommand = vi.fn(); + registerTlonCommands({ registerCommand }, makeDeps()); + + const registered = registerCommand.mock.calls.map( + (call) => call[0] as Record + ); + expect(registered).toHaveLength(TLON_COMMAND_REGISTRY.length); + + // No extras in either direction: same names, same order. + expect(registered.map((command) => command.name)).toEqual( + TLON_COMMAND_REGISTRY.map((entry) => entry.name) + ); + for (let i = 0; i < registered.length; i++) { + const entry = TLON_COMMAND_REGISTRY[i]; + expect(registered[i].description).toBe(entry.description); + // Omitted when the row has no args, matching the core SDK payload. + expect(registered[i].acceptsArgs).toBe(entry.acceptsArgs); + expect(typeof registered[i].handler).toBe('function'); + } + }); + + // Closes the parity loop at the boundary the previous test cannot see: it + // drives registerTlonCommands directly, so an `api.registerCommand` added + // straight to registerFull would register a command the fixture never names + // and stay green. The registry loop is the only registration site. + it('registers commands only through the registry (index.ts boundary)', () => { + const indexSource = fs.readFileSync( + path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../index.ts'), + 'utf8' + ); + + expect(indexSource).toMatch(/registerTlonCommands\s*\(/); + // Every spelling that reaches the SDK method, not just dot-call: bracket + // access and a detached reference register just as well and would + // otherwise slip a command past the registry, the fixture, and the app's + // static list — the exact drift this contract exists to stop. + expect(indexSource).not.toMatch(/\.registerCommand\s*\(/); + expect(indexSource).not.toMatch(/\[\s*['"`]registerCommand['"`]\s*\]/); + expect(indexSource).not.toMatch(/\bregisterCommand\b(?!\s*[,:)}])/); + }); + + it('wires registered handlers through the registry deps', async () => { + const registerCommand = vi.fn(); + const deps = makeDeps(); + const renderTlonVersion = vi.spyOn(deps, 'renderTlonVersion'); + registerTlonCommands({ registerCommand }, deps); + + const tlonVersion = registerCommand.mock.calls[0][0] as { + handler: (ctx: PluginCommandContext) => Promise<{ text: string }>; + }; + await expect( + tlonVersion.handler({} as PluginCommandContext) + ).resolves.toEqual({ text: 'tlon version' }); + expect(renderTlonVersion).toHaveBeenCalledTimes(1); + }); +}); + +// The fixture is what the client's drift contract reads +// (packages/shared/src/domain/runtimeCommandContract.test.ts). Regenerating it +// is the deliberate step that says "the client's static list must change too". +describe('buildCommandTokensJson', () => { + it('matches the committed fixture byte-for-byte', () => { + expect(buildCommandTokensJson()).toBe(fixtureJson); + }); + + it('is byte-stable across calls', () => { + expect(buildCommandTokensJson()).toBe(buildCommandTokensJson()); + }); + + it('names every registered command, and nothing else', () => { + expect(commandTokens()).toEqual( + TLON_COMMAND_REGISTRY.map((entry) => `/${entry.name}`) + ); + }); +}); diff --git a/packages/openclaw/src/commands-registry.ts b/packages/openclaw/src/commands-registry.ts new file mode 100644 index 0000000000..ae290739f8 --- /dev/null +++ b/packages/openclaw/src/commands-registry.ts @@ -0,0 +1,227 @@ +import type { + OpenClawConfig, + OpenClawPluginApi, + PluginCommandContext, + PluginCommandResult, +} from 'openclaw/plugin-sdk/core'; + +import { + type createMigrateCommandHandler, + routeMigrateCommand, +} from './migrate-command.js'; +import { resolveBridgeForCommand } from './monitor/command-auth.js'; +import { handleOwnerListenCommand } from './owner-listen-command.js'; + +// What the inline handlers used to capture from registerFull's scope. +export interface TlonCommandDeps { + renderTlonVersion: () => Promise<{ text: string }>; + handleMigrateCommand: ReturnType; + config: OpenClawConfig; +} + +export interface TlonCommandRegistryEntry { + name: string; + description: string; + acceptsArgs?: boolean; + handler: ( + ctx: PluginCommandContext, + deps: TlonCommandDeps + ) => Promise; +} + +// The single source of truth for the plugin's slash commands: registration +// (index.ts registerFull) and the committed token fixture both derive from +// this table. +// +// The table carries no popup metadata (titles, subtitles, icons, keywords): +// the Tlon client owns the editorial surface, in its own static per-harness +// lists. What this side owes the client is the token set, and only that — +// which is what fixtures/commands.json holds and what the client's drift +// contract (packages/shared/src/domain/runtimeCommandContract.test.ts) pins +// against those lists. +// +// OpenClaw CORE commands (/status, /help, /new) are absent by construction: +// this plugin neither registers nor dispatches them. They are carried on the +// client's static list as audit-pinned constants. +export const TLON_COMMAND_REGISTRY: TlonCommandRegistryEntry[] = [ + { + name: 'tlon-version', + description: 'Show Tlon plugin version.', + handler: async (_ctx, deps) => { + return deps.renderTlonVersion(); + }, + }, + { + name: 'tlon', + description: 'Tlon plugin diagnostics. Usage: /tlon version', + acceptsArgs: true, + handler: async (ctx, deps) => { + const args = (ctx.args ?? '').trim().toLowerCase(); + if (args !== 'version') { + return { text: 'Usage: /tlon version' }; + } + + const result = resolveBridgeForCommand(ctx); + if ('error' in result) { + return { text: result.error }; + } + return deps.renderTlonVersion(); + }, + }, + { + name: 'allow', + description: 'Allow a pending DM/channel/group request', + acceptsArgs: true, + handler: async (ctx) => { + const result = resolveBridgeForCommand(ctx); + if ('error' in result) { + return { text: result.error }; + } + return { + text: await result.bridge.handleAction( + 'approve', + ctx.args?.trim() || undefined + ), + }; + }, + }, + { + name: 'reject', + description: 'Reject a pending DM/channel/group request', + acceptsArgs: true, + handler: async (ctx) => { + const result = resolveBridgeForCommand(ctx); + if ('error' in result) { + return { text: result.error }; + } + return { + text: await result.bridge.handleAction( + 'deny', + ctx.args?.trim() || undefined + ), + }; + }, + }, + { + name: 'ban', + description: 'Ban a ship and deny its pending request', + acceptsArgs: true, + handler: async (ctx) => { + const result = resolveBridgeForCommand(ctx); + if ('error' in result) { + return { text: result.error }; + } + return { + text: await result.bridge.handleAction( + 'block', + ctx.args?.trim() || undefined + ), + }; + }, + }, + { + name: 'pending', + description: 'List pending approval requests', + handler: async (ctx) => { + const result = resolveBridgeForCommand(ctx); + if ('error' in result) { + return { text: result.error }; + } + return await result.bridge.getPendingApprovalsReply(); + }, + }, + { + name: 'banned', + description: 'List banned ships', + handler: async (ctx) => { + const result = resolveBridgeForCommand(ctx); + if ('error' in result) { + return { text: result.error }; + } + return { text: await result.bridge.getBlockedList() }; + }, + }, + { + name: 'unban', + description: 'Unban a ship (e.g. /unban ~sampel-palnet)', + acceptsArgs: true, + handler: async (ctx) => { + const result = resolveBridgeForCommand(ctx); + if ('error' in result) { + return { text: result.error }; + } + const ship = ctx.args?.trim(); + if (!ship) { + return { text: 'Usage: /unban ~ship-name' }; + } + return { text: await result.bridge.handleUnblock(ship) }; + }, + }, + { + name: 'owner-listen', + description: + 'Control whether the bot listens for the owner without @-mention in owned channels. ' + + 'Usage: /owner-listen [on|off|status|list] []; ' + + '/owner-listen all [on|off] for the global kill switch.', + acceptsArgs: true, + handler: async (ctx) => { + const result = resolveBridgeForCommand(ctx); + if ('error' in result) { + return { text: result.error }; + } + const text = await handleOwnerListenCommand( + result.bridge, + ctx.args, + ctx.from + ); + return { text }; + }, + }, + { + name: 'migrate', + description: + 'Run or clean up a diary-to-notes migration. Usage: ' + + '/migrate [--allow-write-widening] | ' + + '/migrate cleanup ', + acceptsArgs: true, + handler: async (ctx, deps) => { + return { + text: await routeMigrateCommand( + ctx, + ctx.args, + deps.handleMigrateCommand, + deps.config + ), + }; + }, + }, +]; + +// Register every command in the table. Called from registerFull; the +// previous inline api.registerCommand calls all live in the table now so +// registration and the published token list cannot drift apart. +export function registerTlonCommands( + api: Pick, + deps: TlonCommandDeps +): void { + for (const entry of TLON_COMMAND_REGISTRY) { + api.registerCommand({ + name: entry.name, + description: entry.description, + ...(entry.acceptsArgs ? { acceptsArgs: entry.acceptsArgs } : {}), + handler: (ctx: PluginCommandContext) => entry.handler(ctx, deps), + }); + } +} + +export function commandTokens(): string[] { + return TLON_COMMAND_REGISTRY.map((entry) => `/${entry.name}`); +} + +// The committed fixture's exact bytes (fixtures/commands.json). Nothing sends +// this anywhere: it is the CI artifact the client's drift contract reads, so +// only its content and its stability matter. Matches Python's +// `json.dumps(tokens, indent=2)` so both runtimes' fixtures look alike. +export function buildCommandTokensJson(): string { + return `${JSON.stringify(commandTokens(), null, 2)}\n`; +} diff --git a/packages/openclaw/src/monitor/index.ts b/packages/openclaw/src/monitor/index.ts index e71e254bf6..7f41114b88 100644 --- a/packages/openclaw/src/monitor/index.ts +++ b/packages/openclaw/src/monitor/index.ts @@ -12,6 +12,11 @@ import { clearAuthRetryState, recordAuthRetryFailure, } from '../auth-retry-state.js'; +import { + type SelfContactRead, + buildBotInfoJson, + syncBotInfo, +} from '../bot-info.js'; import { findRecentContextLensById, publishContextLensEvent, @@ -101,6 +106,7 @@ import { UrbitSSEClient } from '../urbit/sse-client.js'; import { markdownToStory } from '../urbit/story.js'; import { formatTlonVersionIdentity, + getTlonVersionIdentity, resolveTlonSkillVersion, } from '../version.js'; import { @@ -542,6 +548,47 @@ export async function monitorTlonProvider( let api: UrbitSSEClient | null = null; let cookie: string; + // Set by the boot self-contact scry; reconnect publishes re-read instead. + let bootSelfContactRead: SelfContactRead | undefined; + + // Publish the bot's identity claim in its own contact profile: + // compare-then-poke, non-fatal, skipped when the self-contact read failed + // (see syncBotInfo). Declared here — before the SSE client that can call it + // on reconnect — and hoisted so that callback is safe. + async function publishBotInfoNow(reason: 'boot' | 'reconnect') { + if (!api) { + return; + } + try { + // The host always reports a version; an empty one means the SDK contract + // moved under us. The claim still stands without it (the field is a + // diagnostic rider), but say so loudly. + if (!core.version) { + runtime.error?.( + '[tlon] Host reported no version; publishing bot info without harnessVersion' + ); + } + // The builder throws when the claim serializes past the byte cap, and + // boot awaits this call — so it has to be inside the guard too, or an + // oversized value would take the bot offline instead of just leaving it + // unidentified. + const result = await syncBotInfo( + api, + buildBotInfoJson({ + version: getTlonVersionIdentity().pluginVersion, + harnessVersion: core.version, + }), + reason === 'boot' ? bootSelfContactRead : undefined, + undefined, + opts.abortSignal + ); + if (result !== 'unchanged') { + runtime.log?.(`[tlon] Bot info ${result} (${reason})`); + } + } catch (e) { + runtime.error?.(`[tlon] Bot info publish failed (${reason})`, e); + } + } // Stream-watchdog thresholds are normally hardcoded defaults in the client. // The E2E harness overrides them via env so a detached-network fault surfaces // within the scenario's wait window (see TLON_NUDGE_TICK_INTERVAL_MS for the @@ -613,6 +660,10 @@ export async function monitorTlonProvider( // hung socket) is invisible in PostHog — only stdout. onStreamRecovery: (event) => { if (event.phase === 'reconnected') { + // Catch-up publish, mirroring Hermes's reconnect path: a failed boot + // publish, or a key cleared while this process stayed alive, would + // otherwise persist until a restart. Fire-and-forget and non-fatal. + void publishBotInfoNow('reconnect'); if (event.attempt > 0 || (event.downtimeMs ?? 0) > 0) { capturePluginError( 'sse_stream', @@ -987,6 +1038,7 @@ export async function monitorTlonProvider( // Fetch bot's nickname and all contacts try { const selfProfile = await api.scry('/contacts/v1/self.json'); + bootSelfContactRead = { ok: true, contact: selfProfile }; if (selfProfile && typeof selfProfile === 'object') { const profile = selfProfile as { nickname?: { value?: string }; @@ -1000,11 +1052,16 @@ export async function monitorTlonProvider( } } } catch (error: any) { + bootSelfContactRead = { ok: false, error }; runtime.log?.( `[tlon] Could not fetch self profile: ${error?.message ?? String(error)}` ); } + // Compare-then-poke against the self-contact just scried; %self is a + // merge, so nickname/avatar survive. + await publishBotInfoNow('boot'); + // Fetch all contacts to populate nickname cache try { const allContacts = (await api.scry('/contacts/v1/all.json')) as Record< diff --git a/packages/shared/src/db/migrations/0000_unique_texas_twister.sql b/packages/shared/src/db/migrations/0000_pink_forgotten_one.sql similarity index 99% rename from packages/shared/src/db/migrations/0000_unique_texas_twister.sql rename to packages/shared/src/db/migrations/0000_pink_forgotten_one.sql index 5278485a6e..d1a0f8b1df 100644 --- a/packages/shared/src/db/migrations/0000_unique_texas_twister.sql +++ b/packages/shared/src/db/migrations/0000_pink_forgotten_one.sql @@ -172,6 +172,7 @@ CREATE TABLE `contacts` ( `status` text, `color` text, `coverImage` text, + `bot_info` text, `blocked` integer, `isContact` integer, `isContactSuggestion` integer, @@ -528,4 +529,4 @@ CREATE TABLE `volume_settings` ( `level` text NOT NULL ); --> statement-breakpoint -CREATE INDEX `volume_settings_item_id_index` ON `volume_settings` (`item_id`); +CREATE INDEX `volume_settings_item_id_index` ON `volume_settings` (`item_id`); \ No newline at end of file diff --git a/packages/shared/src/db/migrations/meta/0000_snapshot.json b/packages/shared/src/db/migrations/meta/0000_snapshot.json index 09f639dfc1..016ddd17af 100644 --- a/packages/shared/src/db/migrations/meta/0000_snapshot.json +++ b/packages/shared/src/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "e7e28ea4-831e-4c18-aba2-315772c3c581", + "id": "489f9b73-cbcb-443e-a99a-aecba4ab1124", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "activity_event_contact_group_pins": { @@ -1168,6 +1168,13 @@ "notNull": false, "autoincrement": false }, + "bot_info": { + "name": "bot_info", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "blocked": { "name": "blocked", "type": "integer", @@ -3563,4 +3570,4 @@ "internal": { "indexes": {} } -} +} \ No newline at end of file diff --git a/packages/shared/src/db/migrations/meta/_journal.json b/packages/shared/src/db/migrations/meta/_journal.json index d092ac4bbb..25c2f3b63b 100644 --- a/packages/shared/src/db/migrations/meta/_journal.json +++ b/packages/shared/src/db/migrations/meta/_journal.json @@ -5,9 +5,9 @@ { "idx": 0, "version": "6", - "when": 1782835292031, - "tag": "0000_unique_texas_twister", + "when": 1786472821543, + "tag": "0000_pink_forgotten_one", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/shared/src/db/migrations/migrations.js b/packages/shared/src/db/migrations/migrations.js index f3ca13ba70..90a2cff517 100644 --- a/packages/shared/src/db/migrations/migrations.js +++ b/packages/shared/src/db/migrations/migrations.js @@ -1,7 +1,7 @@ // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo import journal from './meta/_journal.json'; -import m0000 from './0000_unique_texas_twister.sql'; +import m0000 from './0000_pink_forgotten_one.sql'; export default { journal, @@ -9,3 +9,4 @@ import m0000 from './0000_unique_texas_twister.sql'; m0000 } } + \ No newline at end of file diff --git a/packages/shared/src/db/queries.test.ts b/packages/shared/src/db/queries.test.ts index 4efdb66389..e9a9d22da9 100644 --- a/packages/shared/src/db/queries.test.ts +++ b/packages/shared/src/db/queries.test.ts @@ -426,11 +426,65 @@ test('inserts contacts without overriding block data', async () => { contacts.find((c) => c.id === '~fonrym-radfur-nocsyx-lassul') ).toBeFalsy(); // insert contacts - await queries.insertContacts(contacts); + await queries.insertContacts({ v0Peers: contacts }); const newBlockedUsers = await queries.getBlockedUsers(); expect(newBlockedUsers.map((b) => b.id)).toEqual(blocks); }); +describe('insertContacts botInfo provenance', () => { + const ship = '~bot-info-provenance'; + const claim = JSON.stringify({ + v: 1, + harness: 'openclaw', + version: '0.19.0', + }); + const updatedClaim = JSON.stringify({ + v: 1, + harness: 'openclaw', + version: '0.20.0', + }); + + test('v0-sourced rows preserve an existing claim', async () => { + await queries.insertContacts({ + v1Contacts: [{ id: ship, botInfo: claim }], + }); + expect((await queries.getContact({ id: ship }))?.botInfo).toBe(claim); + + // The lossy v0 /all scry carries no bot-info signal; re-syncing the + // same peer must not clobber the learned claim. + await queries.insertContacts({ v0Peers: [{ id: ship }] }); + expect((await queries.getContact({ id: ship }))?.botInfo).toBe(claim); + }); + + test('v1-sourced rows replace an existing claim', async () => { + await queries.insertContacts({ + v1Contacts: [{ id: ship, botInfo: claim }], + }); + await queries.insertContacts({ + v1Contacts: [{ id: ship, botInfo: updatedClaim }], + }); + expect((await queries.getContact({ id: ship }))?.botInfo).toBe( + updatedClaim + ); + }); + + test('v1-sourced rows clear the claim when the key is missing', async () => { + await queries.insertContacts({ + v1Contacts: [{ id: ship, botInfo: claim }], + }); + // The bot stopped advertising: the v1 fact arrives without the key. + await queries.insertContacts({ v1Contacts: [{ id: ship }] }); + expect((await queries.getContact({ id: ship }))?.botInfo).toBeNull(); + }); + + test('upsertContact sets and clears the claim (subscription path)', async () => { + await queries.upsertContact({ id: ship, botInfo: claim }); + expect((await queries.getContact({ id: ship }))?.botInfo).toBe(claim); + await queries.upsertContact({ id: ship, botInfo: null }); + expect((await queries.getContact({ id: ship }))?.botInfo).toBeNull(); + }); +}); + const refDate = Date.now(); test('sequenced posts: gets newest posts', async () => { diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 643f649c2d..441571204f 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -3785,7 +3785,9 @@ export const insertChanges = createWriteQuery( ); await perfTime( 'insertChanges.contacts', - () => insertContacts(input.contacts, txCtx), + // Changes contacts come from the v1 changes scry and are + // authoritative for namespaced fields like bot-info. + () => insertContacts({ v1Contacts: input.contacts }, txCtx), { count: input.contacts.length } ); await perfTime( @@ -5323,10 +5325,26 @@ export const insertContact = createWriteQuery( ['contacts'] ); +export interface InsertContactsInput { + // Rows sourced from the lossy v0 `/all` peers scry, which strips + // namespaced contact keys like `bot-info`. They carry no signal about + // the bot's identity claim, so an existing `botInfo` value is + // preserved on conflict instead of being clobbered to null. + v0Peers?: Contact[]; + // Rows sourced from authoritative v1 paths (`/v1/book`, `/v1/news` + // subscription facts, targeted `/v1/contact/{ship}` fetches). These are + // authoritative for `botInfo`: a present value replaces, an absent one + // clears (the bot stopped publishing one). + v1Contacts?: Contact[]; +} + export const insertContacts = createWriteQuery( 'insertContacts', - async (contactsData: Contact[], ctx: QueryCtx) => { + async (input: InsertContactsInput, ctx: QueryCtx) => { const currentUserId = getCurrentUserId(); + const v0Peers = input.v0Peers ?? []; + const v1Contacts = input.v1Contacts ?? []; + const contactsData = [...v0Peers, ...v1Contacts]; if (contactsData.length === 0) { return; } @@ -5357,8 +5375,20 @@ export const insertContacts = createWriteQuery( // Batch size to avoid SQLite variable limits const BATCH_SIZE = 100; - for (let i = 0; i < contactsData.length; i += BATCH_SIZE) { - const batch = contactsData.slice(i, i + BATCH_SIZE); + for (let i = 0; i < v0Peers.length; i += BATCH_SIZE) { + const batch = v0Peers.slice(i, i + BATCH_SIZE); + + await txCtx.db + .insert($contacts) + .values(batch) + .onConflictDoUpdate({ + target: $contacts.id, + set: conflictUpdateSetAll($contacts, ['isBlocked', 'botInfo']), + }); + } + + for (let i = 0; i < v1Contacts.length; i += BATCH_SIZE) { + const batch = v1Contacts.slice(i, i + BATCH_SIZE); await txCtx.db .insert($contacts) @@ -5416,8 +5446,8 @@ export const insertContacts = createWriteQuery( } }); }, - (contacts) => - contacts.length + (input) => + (input.v0Peers?.length ?? 0) + (input.v1Contacts?.length ?? 0) > 0 ? ['contacts', 'groups', 'contactGroups', 'contactAttestations'] : [] ); diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 909adf95ac..8dc3c9e76a 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -163,6 +163,10 @@ export const contacts = sqliteTable( status: text('status'), color: text('color'), coverImage: text('coverImage'), + // Raw JSON of the bot's self-published identity claim (harness and + // versions), read off its contact profile. Validated at read; see + // docs/bot-info.md. + botInfo: text('bot_info'), isBlocked: boolean('blocked'), isContact: boolean('isContact'), isContactSuggestion: boolean('isContactSuggestion'), diff --git a/packages/shared/src/domain/runtimeCommandContract.test.ts b/packages/shared/src/domain/runtimeCommandContract.test.ts new file mode 100644 index 0000000000..eaf662d8ab --- /dev/null +++ b/packages/shared/src/domain/runtimeCommandContract.test.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { type BotAgentType, RUNTIME_COMMANDS } from './slashCommands'; + +// The drift contract. Command lists are app-static now, so nothing on the wire +// keeps them honest — this test does: each runtime commits a token-only fixture +// generated from its own command registry, and the static list for that harness +// must name exactly those tokens. An addition, a removal, or a duplicate in a +// runtime turns this red on the PR that makes it. +// +// It deliberately does NOT compare positions. The two orders legitimately +// differ (a registry is registration-ordered; the static list is curated), and +// ordering is editorial — carried by `priority` and asserted through +// rankSlashCommands in packages/app, not declared by a runtime. +// +// Core commands are outside this relation by construction: the runtimes neither +// register nor dispatch them, so their fixtures cannot mention them. +// +// These assertions live here, in the package that owns the static lists, and +// read the fixtures by relative path. Running them from the runtime packages +// instead would make those packages depend on `@tloncorp/shared`, which is +// workspace-only — the OpenClaw plugin is installed with plain `npm install` in +// its containerized E2E, where an unpublished workspace dep is a hard 404. +const RUNTIMES: { harness: BotAgentType; fixture: string }[] = [ + { harness: 'openclaw', fixture: '../../../openclaw/fixtures/commands.json' }, + { + harness: 'hermes', + fixture: '../../../hermes-tlon-adapter/fixtures/commands.json', + }, +]; + +const readTokens = (rel: string): string[] => + JSON.parse(fs.readFileSync(path.resolve(__dirname, rel), 'utf8')); + +describe.each(RUNTIMES)( + '$harness command drift contract', + ({ harness, fixture }) => { + it('the static runtime list names exactly the runtime tokens', () => { + const runtimeTokens = readTokens(fixture); + const staticTokens = RUNTIME_COMMANDS[harness].map( + (option) => option.command as string + ); + + // Sorted equality: catches additions, removals, and duplicates on either + // side, while leaving order to each side's own concern. + expect([...runtimeTokens].sort()).toEqual([...staticTokens].sort()); + }); + + it('the fixture is a non-empty list of popup-triggerable tokens', () => { + const runtimeTokens = readTokens(fixture); + expect(runtimeTokens.length).toBeGreaterThan(0); + for (const token of runtimeTokens) { + expect(token).toMatch(/^\/[a-zA-Z0-9-]+$/); + } + }); + } +); diff --git a/packages/shared/src/domain/slashCommands.test.ts b/packages/shared/src/domain/slashCommands.test.ts new file mode 100644 index 0000000000..1d8d7e6d73 --- /dev/null +++ b/packages/shared/src/domain/slashCommands.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from 'vitest'; + +import { + BOT_INFO_CONTACT_KEY, + BOT_INFO_MAX_FIELD_CHARS, + BOT_INFO_MAX_RAW_BYTES, + RUNTIME_COMMANDS, + STATIC_MANIFESTS, + getStaticSlashCommandManifest, + isBotAgentType, + parseBotInfo, + utf8ByteLength, +} from './slashCommands'; + +const claim = (overrides: Record = {}) => + JSON.stringify({ + v: 1, + harness: 'openclaw', + version: '0.19.0', + ...overrides, + }); + +describe('parseBotInfo', () => { + test('contact key is the wire contract key', () => { + expect(BOT_INFO_CONTACT_KEY).toBe('bot-info'); + }); + + test('parses a full claim', () => { + expect( + parseBotInfo( + JSON.stringify({ + v: 1, + harness: 'openclaw', + version: '0.19.0', + harnessVersion: '2026.5.28', + }) + ) + ).toEqual({ + harness: 'openclaw', + version: '0.19.0', + harnessVersion: '2026.5.28', + }); + }); + + test('harnessVersion is optional: a claim without it still stands', () => { + expect(parseBotInfo(claim())).toEqual({ + harness: 'openclaw', + version: '0.19.0', + }); + }); + + test('keeps an unknown harness verbatim (selection happens elsewhere)', () => { + expect(parseBotInfo(claim({ harness: 'someone-elses-bot' }))?.harness).toBe( + 'someone-elses-bot' + ); + }); + + test('harness matching is case-sensitive', () => { + // Parsed, but not a harness we know: the claim is what the bot said. + const parsed = parseBotInfo(claim({ harness: 'OpenClaw' })); + expect(parsed?.harness).toBe('OpenClaw'); + expect(isBotAgentType(parsed?.harness)).toBe(false); + }); + + test('ignores unknown fields (forward compat)', () => { + expect( + parseBotInfo(claim({ capabilities: ['commands'], future: 42 })) + ).toEqual({ harness: 'openclaw', version: '0.19.0' }); + }); + + test('rejects non-string input', () => { + expect(parseBotInfo(undefined)).toBeNull(); + expect(parseBotInfo(null)).toBeNull(); + expect(parseBotInfo(42)).toBeNull(); + expect(parseBotInfo({ v: 1, harness: 'openclaw' })).toBeNull(); + }); + + test('rejects malformed JSON', () => { + expect(parseBotInfo('{"v":1,"harness":')).toBeNull(); + }); + + test('rejects JSON that is not an object', () => { + expect(parseBotInfo('null')).toBeNull(); + expect(parseBotInfo('[1,2]')).toBeNull(); + expect(parseBotInfo('"openclaw"')).toBeNull(); + expect(parseBotInfo('1')).toBeNull(); + }); + + test('rejects a wrong or missing v', () => { + expect(parseBotInfo(claim({ v: 2 }))).toBeNull(); + expect(parseBotInfo(claim({ v: '1' }))).toBeNull(); + expect( + parseBotInfo(JSON.stringify({ harness: 'openclaw', version: '1.0.0' })) + ).toBeNull(); + }); + + test.each(['harness', 'version'])('rejects a claim missing %s', (field) => { + expect(parseBotInfo(claim({ [field]: undefined }))).toBeNull(); + }); + + test.each(['harness', 'version', 'harnessVersion'])( + 'rejects an empty %s', + (field) => { + expect(parseBotInfo(claim({ [field]: '' }))).toBeNull(); + } + ); + + test.each(['harness', 'version', 'harnessVersion'])( + 'rejects a non-string %s', + (field) => { + expect(parseBotInfo(claim({ [field]: 42 }))).toBeNull(); + expect(parseBotInfo(claim({ [field]: ['openclaw'] }))).toBeNull(); + expect(parseBotInfo(claim({ [field]: { value: 'x' } }))).toBeNull(); + expect(parseBotInfo(claim({ [field]: true }))).toBeNull(); + expect(parseBotInfo(claim({ [field]: null }))).toBeNull(); + } + ); + + test.each(['harness', 'version', 'harnessVersion'])( + 'accepts %s at exactly the field cap and rejects one over', + (field) => { + expect( + parseBotInfo(claim({ [field]: 'x'.repeat(BOT_INFO_MAX_FIELD_CHARS) })) + ).not.toBeNull(); + expect( + parseBotInfo( + claim({ [field]: 'x'.repeat(BOT_INFO_MAX_FIELD_CHARS + 1) }) + ) + ).toBeNull(); + } + ); + + test('counts the field cap in code points, not UTF-16 units', () => { + // An astral character is one character to a publisher but two `.length` + // units, so a cap-length emoji string must still be accepted. + expect( + parseBotInfo(claim({ version: '🚀'.repeat(BOT_INFO_MAX_FIELD_CHARS) })) + ).not.toBeNull(); + expect( + parseBotInfo( + claim({ version: '🚀'.repeat(BOT_INFO_MAX_FIELD_CHARS + 1) }) + ) + ).toBeNull(); + }); + + test('rejects a raw claim over the UTF-8 byte cap', () => { + // Non-ASCII: each é is two UTF-8 bytes, so this claim is under the cap in + // characters and over it in bytes. Every declared field is individually + // valid — only the total size fails, and the raw size is what the cap + // guards (unknown fields are ignored but still cost bytes). + const raw = claim({ note: 'é'.repeat(400) }); + expect(raw.length).toBeLessThan(BOT_INFO_MAX_RAW_BYTES); + expect(utf8ByteLength(raw)).toBeGreaterThan(BOT_INFO_MAX_RAW_BYTES); + expect(parseBotInfo(raw)).toBeNull(); + }); + + test('accepts a raw claim under the byte cap with non-ASCII content', () => { + const raw = claim({ version: 'é'.repeat(40) }); + expect(utf8ByteLength(raw)).toBeLessThan(BOT_INFO_MAX_RAW_BYTES); + expect(parseBotInfo(raw)?.version).toBe('é'.repeat(40)); + }); +}); + +describe('static command lists', () => { + test('a known harness selects its own list', () => { + expect(getStaticSlashCommandManifest('openclaw')).toBe( + STATIC_MANIFESTS.openclaw + ); + expect(getStaticSlashCommandManifest('hermes')).toBe( + STATIC_MANIFESTS.hermes + ); + }); + + test('an unknown, absent, or mis-cased harness falls back to openclaw', () => { + expect(getStaticSlashCommandManifest('third-party')).toBe( + STATIC_MANIFESTS.openclaw + ); + expect(getStaticSlashCommandManifest('Hermes')).toBe( + STATIC_MANIFESTS.openclaw + ); + expect(getStaticSlashCommandManifest(null)).toBe(STATIC_MANIFESTS.openclaw); + expect(getStaticSlashCommandManifest(undefined)).toBe( + STATIC_MANIFESTS.openclaw + ); + }); + + test.each(['openclaw', 'hermes'] as const)( + 'the %s list is the runtime half plus a non-empty core half', + (harness) => { + const all = STATIC_MANIFESTS[harness].commands; + const runtime = RUNTIME_COMMANDS[harness]; + expect(runtime.length).toBeGreaterThan(0); + expect(all.length).toBeGreaterThan(runtime.length); + // Every CI-bound runtime entry is actually in the rendered list. + for (const option of runtime) { + expect(all).toContain(option); + } + } + ); + + test.each(['openclaw', 'hermes'] as const)( + 'the %s list has unique tokens and unique priorities', + (harness) => { + const commands = STATIC_MANIFESTS[harness].commands; + expect(new Set(commands.map((c) => c.command)).size).toBe( + commands.length + ); + expect(new Set(commands.map((c) => c.priority)).size).toBe( + commands.length + ); + } + ); + + test.each(['openclaw', 'hermes'] as const)( + 'every %s entry carries an icon and a popup-triggerable token', + (harness) => { + // A token that does not match this shape can never trigger the popup + // (computeSlashCommandState in packages/app). + for (const option of STATIC_MANIFESTS[harness].commands) { + expect(option.icon, option.command).toBeTruthy(); + expect(option.command).toMatch(/^\/[a-zA-Z0-9-]+$/); + expect(option.title, option.command).toBeTruthy(); + } + } + ); +}); diff --git a/packages/shared/src/domain/slashCommands.ts b/packages/shared/src/domain/slashCommands.ts index 8125f5586d..2b267860f2 100644 --- a/packages/shared/src/domain/slashCommands.ts +++ b/packages/shared/src/domain/slashCommands.ts @@ -5,22 +5,129 @@ export interface SlashCommandOption { title: string; subtitle?: string; // Icon NAME string, not an IconType: the shared layer must not depend on - // @tloncorp/ui, and future hosting-served manifests carry icons as strings. - // The popup maps the string to an IconType with a 'Command' fallback. + // @tloncorp/ui. The popup maps the string to an IconType with a 'Command' + // fallback; packages/app's icon test asserts every name here resolves. icon?: string; keywords?: string[]; // Static tiebreaker only; ranking is otherwise driven by the query match. + // This — not array position — is what orders the popup (rankSlashCommands). priority: number; // Defaults to `${command} ` when omitted. insertText?: string; } export interface SlashCommandManifest { - agent: BotAgentType; + agent?: BotAgentType; commands: SlashCommandOption[]; } -const OPENCLAW_COMMANDS: SlashCommandOption[] = [ +// ── The bot-info identity claim ───────────────────────────────────────────── +// A bot publishes who it is — harness and versions — in its own contact +// profile, under BOT_INFO_CONTACT_KEY, as a %text value whose text is JSON: +// {"v":1,"harness":"openclaw","version":"0.19.0","harnessVersion":"..."} +// The command lists themselves are app-static (below), selected by `harness`. +// See docs/bot-info.md for the wire contract. + +export const BOT_INFO_CONTACT_KEY = 'bot-info'; + +// The claim is three short strings. These are abuse bounds on an identity +// field, not a data budget: nothing here should ever grow toward them. +export const BOT_INFO_MAX_RAW_BYTES = 512; +export const BOT_INFO_MAX_FIELD_CHARS = 64; + +export interface BotInfo { + // Matched case-sensitively against known harness ids by isBotAgentType. + // Unknown values are kept (they are what the bot claims) but select the + // fallback command list. + harness: string; + // The plugin/adapter's own version — first-party knowledge. + version: string; + // The underlying agent runtime's version. Optional by design: it is a + // diagnostic rider on an identity claim, and a missing rider must never + // invalidate the claim. + harnessVersion?: string; +} + +export function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +/** + * Parse a bot's identity claim off a synced contact record. Takes `unknown` — + * the value comes from the network and the TS declaration of the contact field + * proves nothing at runtime. Returns null when the claim is absent, malformed, + * over-long, or the wrong version; callers then treat the bot as unidentified + * and fall back to the default command list. + */ +export function parseBotInfo(raw: unknown): BotInfo | null { + if (typeof raw !== 'string') { + return null; + } + if (utf8ByteLength(raw) > BOT_INFO_MAX_RAW_BYTES) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + const claim = parsed as Record; + if (claim.v !== 1) { + return null; + } + + const harness = readClaimField(claim.harness); + const version = readClaimField(claim.version); + if (harness === null || version === null) { + return null; + } + + const info: BotInfo = { harness, version }; + // Absent is fine; present-but-unusable is not — a claim carrying a number, + // an array, or an empty string where a version string belongs is malformed. + // Unknown object fields are ignored (forward compatibility). + if (claim.harnessVersion !== undefined) { + const harnessVersion = readClaimField(claim.harnessVersion); + if (harnessVersion === null) { + return null; + } + info.harnessVersion = harnessVersion; + } + return info; +} + +// Caps count code points, not UTF-16 code units: an astral character (emoji, +// most CJK extensions) is one character to a publisher but two `.length` units. +function readClaimField(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0) { + return null; + } + return Array.from(value).length <= BOT_INFO_MAX_FIELD_CHARS ? value : null; +} + +// ── Static per-harness command lists ──────────────────────────────────────── +// Each list is split into two explicitly named parts: +// +// *_RUNTIME_COMMANDS — what the runtime itself handles. CI-bound: the shared +// drift contract (runtimeCommandContract.test.ts) asserts these tokens +// equal the runtime's committed token fixture, so adding, removing, or +// duplicating a command in a runtime turns the contract red. +// *_CORE_COMMANDS — host-provided commands the runtime neither registers nor +// dispatches. No CI binding is possible (neither host exposes its registry +// to us), so these are deliberate, audit-pinned constants: changing one +// means re-auditing the host first. +// +// The split is display-neutral. Presentation order comes from `priority` +// (rankSlashCommands sorts by it and never by array position), so membership +// lives in the two arrays and ordering lives in the priorities. + +const OPENCLAW_RUNTIME_COMMANDS: SlashCommandOption[] = [ { command: '/owner-listen', title: 'Owner listen', @@ -29,30 +136,6 @@ const OPENCLAW_COMMANDS: SlashCommandOption[] = [ keywords: ['owner', 'listen', 'agent'], priority: 1, }, - { - command: '/status', - title: 'Status', - subtitle: 'Show the current OpenClaw session status', - icon: 'Info', - keywords: ['openclaw', 'session', 'model'], - priority: 2, - }, - { - command: '/help', - title: 'Help', - subtitle: 'Show available OpenClaw commands', - icon: 'Info', - keywords: ['openclaw', 'commands'], - priority: 3, - }, - { - command: '/new', - title: 'New session', - subtitle: 'Start a fresh OpenClaw session', - icon: 'Add', - keywords: ['reset', 'session', 'openclaw'], - priority: 4, - }, { command: '/pending', title: 'Pending approvals', @@ -109,99 +192,233 @@ const OPENCLAW_COMMANDS: SlashCommandOption[] = [ keywords: ['version', 'plugin', 'openclaw'], priority: 11, }, + { + command: '/tlon', + title: 'Tlon diagnostics', + subtitle: 'Tlon plugin diagnostics. Usage: /tlon version', + icon: 'Info', + keywords: ['tlon', 'diagnostics', 'version'], + priority: 12, + }, + { + command: '/migrate', + title: 'Migrate diary to notes', + subtitle: 'Run or clean up a diary-to-notes migration', + icon: 'Copy', + keywords: ['migrate', 'diary', 'notes', 'migration'], + priority: 13, + }, ]; -const HERMES_COMMANDS: SlashCommandOption[] = [ +// OpenClaw core. Audit-verified against core at the plugin's dev pin +// (2026.5.28): the keys "help", "status", "new" in core's builtin command +// registry (src/auto-reply/commands-registry.shared.ts), which is exported from +// neither the package entry nor plugin-sdk and offers no runtime enumeration — +// hence a pinned constant rather than a CI-bound list. The plugin supports +// hosts >= 2026.5.7, older than the audited pin. +const OPENCLAW_CORE_COMMANDS: SlashCommandOption[] = [ + { + command: '/status', + title: 'Status', + subtitle: 'Show the current OpenClaw session status', + icon: 'Info', + keywords: ['openclaw', 'session', 'model'], + priority: 2, + }, + { + command: '/help', + title: 'Help', + subtitle: 'Show available OpenClaw commands', + icon: 'Info', + keywords: ['openclaw', 'commands'], + priority: 3, + }, { command: '/new', title: 'New session', - subtitle: 'Start a fresh Hermes session', + subtitle: 'Start a fresh OpenClaw session', icon: 'Add', - keywords: ['reset', 'session', 'hermes'], - priority: 1, + keywords: ['reset', 'session', 'openclaw'], + priority: 4, }, +]; + +const HERMES_RUNTIME_COMMANDS: SlashCommandOption[] = [ { - command: '/reset', - title: 'Reset session', - subtitle: 'Clear the current conversation and start over', - icon: 'Refresh', - keywords: ['clear', 'restart', 'session'], - priority: 2, + command: '/owner-listen', + title: 'Owner listen', + subtitle: 'Let the owner session listen in this channel', + icon: 'Command', + keywords: ['owner', 'listen', 'agent'], + priority: 4, }, { - command: '/stop', - title: 'Stop', - subtitle: 'Interrupt the current Hermes response', - icon: 'Stop', - keywords: ['cancel', 'interrupt', 'halt'], - priority: 3, + command: '/migrate', + title: 'Migrate diary to notes', + subtitle: 'Run or clean up a diary-to-notes migration', + icon: 'Copy', + keywords: ['migrate', 'diary', 'notes', 'migration'], + priority: 5, }, { - command: '/status', - title: 'Status', - subtitle: 'Show the current Hermes session status', + command: '/tlon', + title: 'Tlon diagnostics', + subtitle: 'Tlon adapter diagnostics. Usage: /tlon version', icon: 'Info', - keywords: ['hermes', 'session', 'model'], - priority: 4, + keywords: ['tlon', 'diagnostics', 'version', 'status'], + priority: 6, }, + { + command: '/allow', + title: 'Allow request', + subtitle: 'Approve a pending request by id', + icon: 'Checkmark', + keywords: ['approve', 'approval', 'request'], + priority: 7, + }, + { + command: '/reject', + title: 'Reject request', + subtitle: 'Decline a pending request by id', + icon: 'Close', + keywords: ['deny', 'decline', 'approval', 'request'], + priority: 8, + }, + { + command: '/ban', + title: 'Ban request', + subtitle: 'Block a ship and deny its pending request', + icon: 'EyeClosed', + keywords: ['block', 'deny', 'ship', 'approval'], + priority: 9, + }, + { + command: '/unban', + title: 'Unban ship', + subtitle: 'Remove a ship from the ban list', + icon: 'EyeOpen', + keywords: ['unblock', 'ship', 'allow'], + priority: 10, + }, + { + command: '/pending', + title: 'Pending approvals', + subtitle: 'List pending DM, channel, and group requests', + icon: 'Clock', + keywords: ['approval', 'requests', 'owner'], + priority: 11, + }, + { + command: '/banned', + title: 'Banned ships', + subtitle: 'List currently banned ships', + icon: 'EyeClosed', + keywords: ['blocked', 'ships', 'list'], + priority: 12, + }, + { + command: '/channel-access', + title: 'Channel access', + subtitle: 'Open or restrict a channel, or show its access status', + icon: 'Lock', + keywords: ['channel', 'access', 'open', 'restricted'], + priority: 13, + }, +]; + +// Hermes core. Verified user-invocable by a source audit of the pinned +// hermes-agent runtime (tag v2026.6.19, commit 2bd1977): each is defined in +// core's command registry (hermes_cli/commands.py), dispatched by the gateway +// (gateway/run.py), not cli_only, and carries no per-command +// gateway_config_gate. Hermes' standard slash-access policy +// (gateway/slash_access.py) still applies on top: /help is always allowed, the +// other five can require admin or allowlisting — the same ceiling the adapter's +// own owner-only commands already sit under, so it changes nothing here. The +// ~40 other verified core commands work when typed but are not suggested. +const HERMES_CORE_COMMANDS: SlashCommandOption[] = [ { command: '/help', title: 'Help', subtitle: 'Show available Hermes commands', icon: 'Info', keywords: ['hermes', 'commands'], - priority: 5, + priority: 1, }, { - command: '/compress', - title: 'Compress context', - subtitle: 'Summarize the conversation to free up context', - icon: 'Filter', - keywords: ['compact', 'context', 'summarize'], - priority: 6, + command: '/status', + title: 'Status', + subtitle: 'Show the current Hermes session status', + icon: 'Info', + keywords: ['hermes', 'session', 'model'], + priority: 2, }, { - command: '/model', - title: 'Model', - subtitle: 'Show or change the active model', - icon: 'Settings', - keywords: ['model', 'provider', 'llm'], - priority: 7, + command: '/new', + title: 'New session', + subtitle: 'Start a fresh Hermes session', + icon: 'Add', + keywords: ['reset', 'session', 'hermes'], + priority: 3, + }, + { + command: '/stop', + title: 'Stop', + subtitle: 'Stop the work currently in flight', + icon: 'Stop', + keywords: ['halt', 'cancel', 'interrupt'], + priority: 14, }, { command: '/usage', - title: 'Usage', - subtitle: 'Show token usage for the current session', - icon: 'Clock', - keywords: ['tokens', 'cost', 'consumption'], - priority: 8, + title: 'Token usage', + subtitle: 'Show token usage for this session', + icon: 'Info', + keywords: ['tokens', 'cost', 'usage'], + priority: 15, }, { - command: '/version', - title: 'Version', - subtitle: 'Show the installed Hermes version', - icon: 'Command', - keywords: ['version', 'hermes'], - priority: 9, + command: '/model', + title: 'Switch model', + subtitle: 'Show or change the active model', + icon: 'Settings', + keywords: ['model', 'provider', 'switch'], + priority: 16, }, ]; -const STATIC_MANIFESTS: Record = { - openclaw: { agent: 'openclaw', commands: OPENCLAW_COMMANDS }, - hermes: { agent: 'hermes', commands: HERMES_COMMANDS }, +// The CI-bound half of each list, keyed by harness. The drift contract reads +// this; nothing else should need it. +export const RUNTIME_COMMANDS: Record = { + openclaw: OPENCLAW_RUNTIME_COMMANDS, + hermes: HERMES_RUNTIME_COMMANDS, +}; + +// Concatenation order is arbitrary — `priority` is what users see. +export const STATIC_MANIFESTS: Record = { + openclaw: { + agent: 'openclaw', + commands: [...OPENCLAW_RUNTIME_COMMANDS, ...OPENCLAW_CORE_COMMANDS], + }, + hermes: { + agent: 'hermes', + commands: [...HERMES_RUNTIME_COMMANDS, ...HERMES_CORE_COMMANDS], + }, }; export function isBotAgentType(value: unknown): value is BotAgentType { return value === 'openclaw' || value === 'hermes'; } +/** + * The command list for a harness id, as claimed in a bot's `bot-info`. An + * unknown or absent harness gets the OpenClaw list: a claim we cannot place is + * as untrusted as no claim at all, and third-party bots cannot advertise their + * own commands under this design. + */ export function getStaticSlashCommandManifest( - agent: BotAgentType + harness: string | null | undefined ): SlashCommandManifest { - // Defensive: a stale persisted keyValue is as untrusted as a network - // response if the enum ever evolves, so fall back to openclaw for any - // unrecognized agent value. - return isBotAgentType(agent) - ? STATIC_MANIFESTS[agent] + return isBotAgentType(harness) + ? STATIC_MANIFESTS[harness] : STATIC_MANIFESTS.openclaw; } diff --git a/packages/shared/src/store/__tests__/contactActions.test.ts b/packages/shared/src/store/__tests__/contactActions.test.ts new file mode 100644 index 0000000000..49df31d989 --- /dev/null +++ b/packages/shared/src/store/__tests__/contactActions.test.ts @@ -0,0 +1,284 @@ +import * as api from '@tloncorp/api'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as db from '../../db'; +import { + ensureBotInfoSynced, + resetBotInfoBackfillState, +} from '../contactActions'; +import { handleContactUpdate } from '../sync/sync'; + +// Only the transport-backed calls are faked; `v1PeerToClientProfile` stays +// real so the subscription carrier below exercises the actual mapper. +vi.mock('@tloncorp/api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCurrentUserId: vi.fn(() => '~zod'), + syncUserProfiles: vi.fn(async () => {}), + getContactProfile: vi.fn(async () => null), + }; +}); + +vi.mock('../../db', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getContact: vi.fn(async () => null), + upsertContact: vi.fn(async () => {}), + }; +}); + +const ship = '~bot'; +const claim = JSON.stringify({ + v: 1, + harness: 'openclaw', + version: '0.19.0', +}); + +// The C-3 recovery carrier: a `/v1/news` %peer fact mapped by the real +// contacts mapper and written by the real sync handler. +async function deliverPeerFact(who: string, botInfo: string) { + const contact = api.v1PeerToClientProfile(who, { + 'bot-info': { type: 'text', value: botInfo }, + } as never); + await handleContactUpdate({ type: 'upsertContact', contact }); +} + +// The backfill only acts on a known non-contact row, so every fetching case +// starts from one. +function nonContactRow(overrides: Record = {}) { + return { + id: ship, + isContact: false, + ...overrides, + } as unknown as Awaited>; +} + +describe('ensureBotInfoSynced', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetBotInfoBackfillState(); + vi.mocked(db.getContact).mockResolvedValue(nonContactRow()); + vi.mocked(api.getContactProfile).mockResolvedValue(null); + }); + + it('meets the ship and upserts the fetched profile', async () => { + vi.mocked(api.getContactProfile).mockResolvedValue({ + id: ship, + botInfo: claim, + } as db.Contact); + + await ensureBotInfoSynced(ship); + + expect(api.syncUserProfiles).toHaveBeenCalledWith([ship]); + expect(api.getContactProfile).toHaveBeenCalledWith(ship); + expect(db.upsertContact).toHaveBeenCalledWith( + expect.objectContaining({ id: ship, botInfo: claim }) + ); + }); + + it('dedupes concurrent callers before the first await', async () => { + // The race is at the *DB read*, which precedes the reservation in the + // buggy shape: both callers pass the in-flight check while the first is + // still awaiting getContact, so both reach the network. + let releaseContact: () => void; + const contactPending = new Promise( + (resolve) => (releaseContact = resolve) + ); + vi.mocked(db.getContact).mockImplementation(async () => { + await contactPending; + return nonContactRow(); + }); + + const both = Promise.all([ + ensureBotInfoSynced(ship), + ensureBotInfoSynced(ship), + ]); + releaseContact!(); + await both; + + expect(api.syncUserProfiles).toHaveBeenCalledTimes(1); + expect(api.getContactProfile).toHaveBeenCalledTimes(1); + }); + + it('retries after a scry miss (first scry races the %meet watch)', async () => { + vi.mocked(api.getContactProfile).mockResolvedValueOnce(null); + + await ensureBotInfoSynced(ship); + expect(db.upsertContact).not.toHaveBeenCalled(); + + // The profile arrives on a later attempt (scry or subscription-fed). + vi.mocked(api.getContactProfile).mockResolvedValueOnce({ + id: ship, + botInfo: claim, + } as db.Contact); + await ensureBotInfoSynced(ship); + expect(db.upsertContact).toHaveBeenCalledTimes(1); + }); + + it('scry misses, then the profile lands via the subscription path', async () => { + // First attempt races the %meet watch: the scry 404s. + vi.mocked(api.getContactProfile).mockResolvedValueOnce(null); + await ensureBotInfoSynced(ship); + expect(db.upsertContact).not.toHaveBeenCalled(); + + // The %peer fact then arrives through the real contacts subscription and + // is written by the real sync handler — no hand-rolled upsert here, so + // breaking either carrier fails this test. + await deliverPeerFact(ship, claim); + expect(db.upsertContact).toHaveBeenCalledWith( + expect.objectContaining({ id: ship, botInfo: claim }), + undefined + ); + + // A later hook evaluation sees the stored claim and stops fetching. + vi.mocked(db.getContact).mockResolvedValueOnce( + nonContactRow({ botInfo: claim }) + ); + await ensureBotInfoSynced(ship); + expect(api.getContactProfile).toHaveBeenCalledTimes(1); + }); + + it('retries after a failure; failures are not cached as done', async () => { + vi.mocked(api.getContactProfile) + .mockRejectedValueOnce(new Error('404')) + .mockResolvedValueOnce({ id: ship, botInfo: claim } as db.Contact); + + await ensureBotInfoSynced(ship); + expect(db.upsertContact).not.toHaveBeenCalled(); + + await ensureBotInfoSynced(ship); + expect(db.upsertContact).toHaveBeenCalledTimes(1); + }); + + it('bounds retries per ship per session', async () => { + vi.mocked(api.getContactProfile).mockResolvedValue(null); + + await ensureBotInfoSynced(ship); + await ensureBotInfoSynced(ship); + await ensureBotInfoSynced(ship); + await ensureBotInfoSynced(ship); + await ensureBotInfoSynced(ship); + + expect(api.getContactProfile).toHaveBeenCalledTimes(3); + }); + + it('skips contact-book rows (they arrive lossless via v1 /book)', async () => { + vi.mocked(db.getContact).mockResolvedValue({ + id: ship, + isContact: true, + } as unknown as Awaited>); + + await ensureBotInfoSynced(ship); + + expect(api.syncUserProfiles).not.toHaveBeenCalled(); + expect(api.getContactProfile).not.toHaveBeenCalled(); + }); + + it('skips ships that already published a usable claim', async () => { + vi.mocked(db.getContact).mockResolvedValue( + nonContactRow({ botInfo: claim }) + ); + + await ensureBotInfoSynced(ship); + + expect(api.syncUserProfiles).not.toHaveBeenCalled(); + expect(api.getContactProfile).not.toHaveBeenCalled(); + }); + + it('refreshes a stored value that does not parse as a claim', async () => { + // A v0 `/all` sync preserves whatever was there; if that value is stale + // junk, wrong-version or oversized, every reader treats it as no claim, + // so it must not pin the backfill off. + for (const stored of [ + 'not json', + JSON.stringify({ v: 2, commands: [{ command: '/allow', title: 'A' }] }), + JSON.stringify({ v: 1, commands: [] }), + ]) { + vi.clearAllMocks(); + resetBotInfoBackfillState(); + vi.mocked(db.getContact).mockResolvedValue( + nonContactRow({ botInfo: stored }) + ); + vi.mocked(api.getContactProfile).mockResolvedValue({ + id: ship, + botInfo: claim, + } as db.Contact); + + await ensureBotInfoSynced(ship); + + expect(api.getContactProfile).toHaveBeenCalledWith(ship); + expect(db.upsertContact).toHaveBeenCalledWith( + expect.objectContaining({ id: ship, botInfo: claim }) + ); + } + }); + + it('skips ships with no known contact row yet', async () => { + // An absent row proves nothing: the ship may still turn out to be a + // contact-book entry, whose per-ship scry merges the user's mod overlay. + vi.mocked(db.getContact).mockResolvedValue(null); + + await ensureBotInfoSynced(ship); + + expect(api.syncUserProfiles).not.toHaveBeenCalled(); + expect(api.getContactProfile).not.toHaveBeenCalled(); + }); + + it('skips rows whose isContact is null or undefined (unknown, not proven non-contact)', async () => { + // The column is nullable and partial rows really occur (e.g. + // blocked-contact inserts set no isContact). Unknown must not be treated + // as proven false — the same mod-overlay hazard as the absent-row case. + for (const isContact of [null, undefined]) { + vi.clearAllMocks(); + resetBotInfoBackfillState(); + vi.mocked(db.getContact).mockResolvedValue(nonContactRow({ isContact })); + + await ensureBotInfoSynced(ship); + + expect(api.syncUserProfiles).not.toHaveBeenCalled(); + expect(api.getContactProfile).not.toHaveBeenCalled(); + } + }); + + it('backfills once an unknown isContact resolves to false', async () => { + // The null → false transition is the fresh-start recovery path: a partial + // row settles first, the provenance-carrying write lands later. + vi.mocked(db.getContact).mockResolvedValue( + nonContactRow({ isContact: null }) + ); + await ensureBotInfoSynced(ship); + expect(api.syncUserProfiles).not.toHaveBeenCalled(); + + vi.mocked(db.getContact).mockResolvedValue(nonContactRow()); + await ensureBotInfoSynced(ship); + expect(api.syncUserProfiles).toHaveBeenCalledWith([ship]); + expect(api.getContactProfile).toHaveBeenCalledWith(ship); + }); + + it('keeps skipping contact-book rows even when their stored claim is invalid', async () => { + // The B-1 parse gate must not leak contact-book rows into the fetch path: + // an unusable stored value on an isContact row still means "no backfill". + vi.mocked(db.getContact).mockResolvedValue( + nonContactRow({ isContact: true, botInfo: 'not json' }) + ); + + await ensureBotInfoSynced(ship); + + expect(api.syncUserProfiles).not.toHaveBeenCalled(); + expect(api.getContactProfile).not.toHaveBeenCalled(); + }); + + it('scopes backfill state by current user', async () => { + vi.mocked(api.getContactProfile).mockResolvedValue(null); + await ensureBotInfoSynced(ship); + await ensureBotInfoSynced(ship); + expect(api.getContactProfile).toHaveBeenCalledTimes(2); + + // Switching account resets the per-session bookkeeping. + vi.mocked(api.getCurrentUserId).mockReturnValueOnce('~bus'); + await ensureBotInfoSynced(ship); + expect(api.getContactProfile).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/shared/src/store/cachedData.web.ts b/packages/shared/src/store/cachedData.web.ts index f5570c3f42..4e9291775d 100644 --- a/packages/shared/src/store/cachedData.web.ts +++ b/packages/shared/src/store/cachedData.web.ts @@ -31,7 +31,11 @@ export async function loadCachedContacts(): Promise { return false; } - await db.insertContacts(contacts); + // The cache mixes v0 and v1 sourced rows without provenance, so treat it + // as lossy: preserve any bot identity claims learned since the cache + // was written rather than clobbering them. The authoritative contact + // sync that follows reconciles the column. + await db.insertContacts({ v0Peers: contacts }); return true; } catch (e) { console.error('Failed to load cached contacts', e); diff --git a/packages/shared/src/store/contactActions.ts b/packages/shared/src/store/contactActions.ts index c2d05ffe9a..af2c0a8a8d 100644 --- a/packages/shared/src/store/contactActions.ts +++ b/packages/shared/src/store/contactActions.ts @@ -2,6 +2,7 @@ import * as api from '@tloncorp/api'; import * as db from '../db'; import { createDevLogger } from '../debug'; +import * as domain from '../domain'; import { AnalyticsEvent } from '../domain'; import * as logic from '../logic'; import * as GroupActions from './groupActions'; @@ -10,6 +11,83 @@ import { syncGroup } from './sync/syncGroup'; const logger = createDevLogger('ContactActions', false); +// Cold-start backfill for bot identity claims (fresh install / DB reset): the +// initial v0 `/all` peers scry strips the namespaced `bot-info` key, and +// waiting for the bot's next republish could take weeks. Fetch the ship's full +// v1 profile on demand instead. +const BOT_INFO_BACKFILL_MAX_ATTEMPTS = 3; +// Session-scoped bookkeeping, keyed by `${currentUserId}:${ship}` so a +// switched account starts fresh. +const botInfoBackfillInFlight = new Set(); +const botInfoBackfillAttempts = new Map(); + +export async function ensureBotInfoSynced(ship: string): Promise { + let reservedKey: string | null = null; + try { + const currentUserId = api.getCurrentUserId(); + const key = `${currentUserId}:${ship}`; + // Dedupe in-flight attempts; the %meet poke and scry are otherwise + // repeated on every hook evaluation while the query is settled. + if (botInfoBackfillInFlight.has(key)) { + return; + } + const attempts = botInfoBackfillAttempts.get(key) ?? 0; + if (attempts >= BOT_INFO_BACKFILL_MAX_ATTEMPTS) { + return; + } + // Reserved before the first await: two callers that both got past the + // check above would otherwise both reach the network and share one + // attempt count. + botInfoBackfillInFlight.add(key); + reservedKey = key; + + const contact = await db.getContact({ id: ship }); + // Only a *usable* claim means there is nothing to fetch: a stale, + // malformed or wrong-version value reads as no claim everywhere else (the + // hook falls back to the default list), so it must not pin the backfill + // off either. + if (domain.parseBotInfo(contact?.botInfo)) { + return; + } + // Only a row known to be a non-contact (`isContact === false`, not merely + // absent or null — the column is nullable and partial rows really occur, + // e.g. blocked-contact inserts) is backfillable. Anything less proves + // nothing yet — during a fresh-start sync the bot may still turn out to be + // a contact-book entry, whose per-ship scry merges the user's own `mod` + // overlay and must never become the claim's source. Contact-book bots + // also already arrive lossless via the v1 /book sync. + if (!contact || contact.isContact !== false) { + return; + } + + // Counted before the network work, so a failure mid-flight still burns an + // attempt; success or empty results are never cached as done, so later + // hook evaluations retry up to the cap. + botInfoBackfillAttempts.set(key, attempts + 1); + // Ensure we are subscribed to the ship's profile updates (%meet). The + // first scry can race the remote watch and miss; when it does, the + // subscription delivers the profile later and failures stay retryable. + await api.syncUserProfiles([ship]); + const profile = await api.getContactProfile(ship); + if (profile) { + await db.upsertContact(profile); + } + } catch (e) { + // Silent by design — the popup degrades to the default list. + logger.log('ensureBotInfoSynced failed', e); + } finally { + if (reservedKey !== null) { + botInfoBackfillInFlight.delete(reservedKey); + } + } +} + +/** Test-only: clear the session-scoped backfill bookkeeping. */ +export function resetBotInfoBackfillState() { + botInfoBackfillInFlight.clear(); + botInfoBackfillAttempts.clear(); +} + export async function addContact(contactId: string) { logger.trackEvent(AnalyticsEvent.ActionContactAdded, { count: 1 }); // Optimistic update diff --git a/packages/shared/src/store/dbHooks.ts b/packages/shared/src/store/dbHooks.ts index 4d529a8cc1..d3f0adea5a 100644 --- a/packages/shared/src/store/dbHooks.ts +++ b/packages/shared/src/store/dbHooks.ts @@ -194,11 +194,13 @@ export const useCanUpload = () => { ); }; -export const useContact = (options: { id: string }) => { +export const useContact = (options: { id: string; enabled?: boolean }) => { const deps = useKeyFromQueryDeps(db.getContact, options); + const { enabled = true, ...queryOptions } = options; return useQuery({ + enabled, queryKey: [['contact', options.id], deps], - queryFn: () => db.getContact(options), + queryFn: () => db.getContact(queryOptions), }); }; diff --git a/packages/shared/src/store/sync/syncContacts.ts b/packages/shared/src/store/sync/syncContacts.ts index 2f29770c21..4291ee0b1d 100644 --- a/packages/shared/src/store/sync/syncContacts.ts +++ b/packages/shared/src/store/sync/syncContacts.ts @@ -12,14 +12,18 @@ export const syncContacts = async ( yieldWriter?: boolean ) => { const contacts = await syncQueue.add('contacts', ctx, () => - api.getContacts() + api.getContactsByProvenance() + ); + logger.log( + 'got contacts from api', + contacts.v0Peers.length + contacts.v1Contacts.length, + 'contacts' ); - logger.log('got contacts from api', contacts.length, 'contacts'); const writer = async () => { try { await db.insertContacts(contacts, queryCtx); - LocalCache.cacheContacts(contacts); + LocalCache.cacheContacts([...contacts.v0Peers, ...contacts.v1Contacts]); } catch (e) { logger.error('error inserting contacts', e); } diff --git a/packages/shared/src/store/useBotSlashCommandManifest.test.ts b/packages/shared/src/store/useBotSlashCommandManifest.test.ts new file mode 100644 index 0000000000..06650a2d70 --- /dev/null +++ b/packages/shared/src/store/useBotSlashCommandManifest.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from 'vitest'; + +import * as db from '../db'; +import { getStaticSlashCommandManifest } from '../domain'; +import { + resolveBotManifestShipId, + selectBotSlashCommandManifest, + shouldBackfillBotInfo, +} from './useBotSlashCommandManifest'; + +const staticOpenclaw = getStaticSlashCommandManifest('openclaw'); +const staticHermes = getStaticSlashCommandManifest('hermes'); +const claim = (harness: string) => + JSON.stringify({ v: 1, harness, version: '0.1.0' }); + +const dmChannel = (contactId: string | null) => + ({ type: 'dm', contactId, id: contactId ?? '' }) as db.Channel; +const homeGroupChatChannel = () => + ({ + type: 'chat', + contactId: null, + id: 'chat/~zod/home-group-chat', + }) as db.Channel; + +describe('selectBotSlashCommandManifest', () => { + test("the claimed harness selects that harness's list", () => { + expect( + selectBotSlashCommandManifest({ enabled: true, botInfo: claim('hermes') }) + ).toBe(staticHermes); + expect( + selectBotSlashCommandManifest({ + enabled: true, + botInfo: claim('openclaw'), + }) + ).toBe(staticOpenclaw); + }); + + test('falls back to the openclaw list when no claim is stored', () => { + expect( + selectBotSlashCommandManifest({ enabled: true, botInfo: null }) + ).toBe(staticOpenclaw); + expect( + selectBotSlashCommandManifest({ enabled: true, botInfo: undefined }) + ).toBe(staticOpenclaw); + }); + + test('falls back when the claim is invalid or names an unknown harness', () => { + expect( + selectBotSlashCommandManifest({ enabled: true, botInfo: 'not-json' }) + ).toBe(staticOpenclaw); + expect( + selectBotSlashCommandManifest({ + enabled: true, + botInfo: JSON.stringify({ v: 2, harness: 'hermes', version: '1' }), + }) + ).toBe(staticOpenclaw); + expect( + selectBotSlashCommandManifest({ + enabled: true, + botInfo: claim('third-party-bot'), + }) + ).toBe(staticOpenclaw); + }); + + test('returns null when slash commands are not enabled', () => { + expect( + selectBotSlashCommandManifest({ + enabled: false, + botInfo: claim('hermes'), + }) + ).toBeNull(); + }); +}); + +describe('resolveBotManifestShipId', () => { + test('DM channels resolve to the counterpart ship', () => { + expect(resolveBotManifestShipId(dmChannel('~bot'))).toBe('~bot'); + }); + + test('home-group chat (a group channel) resolves to null: default list', () => { + expect(resolveBotManifestShipId(homeGroupChatChannel())).toBeNull(); + // No ship to look up, so selection stays on the default list. + expect( + selectBotSlashCommandManifest({ enabled: true, botInfo: undefined }) + ).toBe(staticOpenclaw); + }); + + test('null/undefined channels resolve to null', () => { + expect(resolveBotManifestShipId(null)).toBeNull(); + expect(resolveBotManifestShipId(undefined)).toBeNull(); + }); +}); + +describe('shouldBackfillBotInfo', () => { + const base = { + enabled: true, + botShipId: '~bot', + contactQuerySettled: true, + hasBotInfo: false, + }; + + test('fires once the contact query settled without a claim', () => { + expect(shouldBackfillBotInfo(base)).toBe(true); + }); + + test('does not fire while the contact query is still loading', () => { + expect(shouldBackfillBotInfo({ ...base, contactQuerySettled: false })).toBe( + false + ); + }); + + test('does not fire when a claim is already present', () => { + expect(shouldBackfillBotInfo({ ...base, hasBotInfo: true })).toBe(false); + }); + + test('does not fire when the channel is not bot-enabled', () => { + expect(shouldBackfillBotInfo({ ...base, enabled: false })).toBe(false); + }); + + test('does not fire without a bot ship to fetch (home-group chat)', () => { + expect(shouldBackfillBotInfo({ ...base, botShipId: null })).toBe(false); + }); +}); diff --git a/packages/shared/src/store/useBotSlashCommandManifest.ts b/packages/shared/src/store/useBotSlashCommandManifest.ts index 027dd4646e..f3cf73b995 100644 --- a/packages/shared/src/store/useBotSlashCommandManifest.ts +++ b/packages/shared/src/store/useBotSlashCommandManifest.ts @@ -1,21 +1,65 @@ import * as api from '@tloncorp/api'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import * as db from '../db'; import * as domain from '../domain'; import * as logic from '../logic'; -import { useChannelHasBotPost } from './dbHooks'; +import { ensureBotInfoSynced } from './contactActions'; +import { useChannelHasBotPost, useContact } from './dbHooks'; -// Returns the curated slash-command manifest for a bot conversation, or null -// when slash commands should not be offered. A channel qualifies when either: +// The bot's identity claim lives on its own contact record. DMs carry the bot +// ship as contactId; the home-group chat is a group channel with no contactId, +// so it keeps the default list until TLON-6301's membership signal identifies +// the moon member. +export function resolveBotManifestShipId( + channel?: db.Channel | null +): string | null { + return channel?.type === 'dm' ? channel.contactId ?? null : null; +} + +// The claimed harness picks the list; an absent, malformed, or unrecognized +// claim gets the OpenClaw list (getStaticSlashCommandManifest's fallback). +export function selectBotSlashCommandManifest(args: { + enabled: boolean; + botInfo?: string | null; +}): domain.SlashCommandManifest | null { + if (!args.enabled) { + return null; + } + return domain.getStaticSlashCommandManifest( + domain.parseBotInfo(args.botInfo)?.harness + ); +} + +// Cold-start backfill fires only once the contact query has settled without a +// usable claim — never on first-render `undefined` while it is still loading, +// which would cause pointless sync traffic for already-cached claims. +export function shouldBackfillBotInfo(args: { + enabled: boolean; + botShipId: string | null; + contactQuerySettled: boolean; + hasBotInfo: boolean; +}): boolean { + return ( + args.enabled && + !!args.botShipId && + args.contactQuerySettled && + !args.hasBotInfo + ); +} + +// Returns the slash-command manifest for a bot conversation, or null when +// slash commands should not be offered. A channel qualifies when either: // - observed: the DM counterpart has sent bot-authored messages here. Bot // authorship is self-declared by the sending ship (BotProfile author on the // wire) — the same signal that renders the "Bot" tag on messages. // - structural: the DM counterpart is a moon of the user's ship (hosted // `~pinser-botter-*` bots and self-provisioned bots alike), or the channel // is the user's home-group chat. Covers bots that haven't posted yet. -// The manifest is the static OpenClaw list until bots advertise their own -// command manifests. +// Which commands are shown: bots publish an identity claim in their contact +// profile (see domain.parseBotInfo and docs/bot-info.md), and the claimed +// harness selects one of the app's static command lists. An unidentified bot +// gets the OpenClaw list. export const useBotSlashCommandManifest = ( channel?: db.Channel | null ): domain.SlashCommandManifest | null => { @@ -39,9 +83,45 @@ export const useBotSlashCommandManifest = ( const enabled = isStructuralBotChannel || (isDm && hasBotPosts === true); - if (!enabled) { - return null; - } + const botShipId = resolveBotManifestShipId(channel); + const { data: contact, isFetched } = useContact({ + id: botShipId ?? '', + enabled: enabled && !!botShipId, + }); + + const botInfo = useMemo( + () => domain.parseBotInfo(contact?.botInfo), + [contact?.botInfo] + ); + + // The backfill only acts on a row with a *known* isContact value, so the + // raw tri-state (true / false / null-or-absent) is a dependency: a + // fresh-start sync can settle the query before the v0 row exists, and both + // the row's later insertion and a null → false transition have to re-trigger + // the evaluation. Collapsing null and false here would swallow the latter. + const hasContactRow = !!contact; + const contactIsContact = contact?.isContact; + + useEffect(() => { + if ( + !shouldBackfillBotInfo({ + enabled, + botShipId, + contactQuerySettled: isFetched, + hasBotInfo: !!botInfo, + }) + ) { + return; + } + ensureBotInfoSynced(botShipId!); + }, [enabled, botShipId, isFetched, botInfo, hasContactRow, contactIsContact]); - return domain.getStaticSlashCommandManifest('openclaw'); + return useMemo( + () => + selectBotSlashCommandManifest({ + enabled, + botInfo: contact?.botInfo, + }), + [enabled, contact?.botInfo] + ); }; diff --git a/packages/tlon-bot-e2e/README.md b/packages/tlon-bot-e2e/README.md index c3bf1fa9dd..dd863851f6 100644 --- a/packages/tlon-bot-e2e/README.md +++ b/packages/tlon-bot-e2e/README.md @@ -60,6 +60,7 @@ The runner allocates host ports by default, renders a unique compose project nam Current common scenarios are: - no-model connectivity checks across bot, owner, and third-party ships +- `bot-info` identity publication on the bot profile and its replication to the owner's peer contact - owner DM text reply - owner DM `tlon` tool call followed by final assistant text - unauthorized third-party DM produces no fake-model call and no direct reply diff --git a/packages/tlon-bot-e2e/src/scenarios/shared/common.ts b/packages/tlon-bot-e2e/src/scenarios/shared/common.ts index b6b6a7b525..8526cd7cad 100644 --- a/packages/tlon-bot-e2e/src/scenarios/shared/common.ts +++ b/packages/tlon-bot-e2e/src/scenarios/shared/common.ts @@ -1,3 +1,5 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; import { expect } from 'vitest'; import type { DriverName, RuntimeContext } from '../../drivers/types.js'; @@ -92,6 +94,48 @@ export const commonScenarios: readonly SharedScenario[] = [ ); }), + testScenario( + 'bot-info-publishes-and-replicates', + {}, + async ({ ctx, driver, actors }) => { + await actors.bot.state.connect(); + await actors.owner.state.connect(); + + const expected = { + harness: driver.name, + version: await runtimePackageVersion(ctx), + }; + const firstSelf = await waitForBotInfoClaim( + actors.bot, + '/v1/self', + 'bot self-profile', + expected + ); + // %contacts holds no record for a peer the ship has never met, so the + // per-ship scry below 404s until the owner meets the bot. The Tlon client + // has the same constraint and solves it the same way: its bot-info + // backfill pokes %meet before reading the profile (see + // `ensureBotInfoSynced` -> `syncUserProfiles` in packages/shared). Doing + // it here keeps the scenario faithful to the real read path rather than + // asserting a state production never reaches on its own. + await actors.owner.state.poke({ + app: 'contacts', + mark: 'contact-action-1', + json: { meet: [actors.bot.ship] }, + }); + + const ownerContactPath = `/v1/contact/${actors.bot.ship}`; + const firstPeer = await waitForBotInfoClaim( + actors.owner, + ownerContactPath, + `owner contact for ${actors.bot.ship}`, + expected + ); + expect(firstPeer.value).toBe(firstSelf.value); + logBotInfoProof(driver.name, firstSelf, firstPeer); + } + ), + testScenario('owner-dm-text-reply', {}, async ({ ctx, driver, actors }) => { const key = scenarioKey('owner-text'); const reply = `Common text reply ${key}`; @@ -1694,6 +1738,139 @@ export const commonScenarios: readonly SharedScenario[] = [ ), ]; +interface ExpectedBotInfoClaim { + harness: DriverName; + version: string; +} + +type BotInfoTextField = { type: 'text'; value: string }; + +async function runtimePackageVersion(ctx: RuntimeContext): Promise { + const packageJsonPath = path.join(ctx.packageDir, 'package.json'); + let packageJson: unknown; + try { + packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + } catch (error) { + throw new Error( + `Could not read runtime package version from ${packageJsonPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + const version = + packageJson && typeof packageJson === 'object' + ? (packageJson as { version?: unknown }).version + : undefined; + if (typeof version !== 'string' || version.length === 0) { + throw new Error( + `Expected ${packageJsonPath} to contain a non-empty string version.` + ); + } + return version; +} + +async function waitForBotInfoClaim( + actor: ScenarioActor, + scryPath: string, + description: string, + expected: ExpectedBotInfoClaim +): Promise { + return waitFor( + async () => { + const profile = await actor.state.scry('contacts', scryPath); + return parseBotInfoClaim(profile, description, expected); + }, + { + timeoutMs: 60_000, + intervalMs: 1_000, + description: `valid ${expected.harness} bot-info on ${description}`, + } + ); +} + +function parseBotInfoClaim( + profile: unknown, + description: string, + expected: ExpectedBotInfoClaim +): BotInfoTextField { + const field = + profile && typeof profile === 'object' && !Array.isArray(profile) + ? (profile as Record)['bot-info'] + : undefined; + if (!field || typeof field !== 'object' || Array.isArray(field)) { + throw new Error( + `Expected ${description} to contain bot-info as a %text field, got ${JSON.stringify( + field + )}.` + ); + } + const candidate = field as { type?: unknown; value?: unknown }; + if (candidate.type !== 'text' || typeof candidate.value !== 'string') { + throw new Error( + `Expected ${description} bot-info to be a %text field with a string value, got ${JSON.stringify( + field + )}.` + ); + } + const value = candidate.value; + const withinRawCap = new TextEncoder().encode(value).byteLength <= 512; + + let claim: unknown; + try { + claim = JSON.parse(value); + } catch (error) { + throw new Error( + `Expected ${description} bot-info to contain JSON, got ${JSON.stringify( + value + )}: ${error instanceof Error ? error.message : String(error)}` + ); + } + const parsed = (claim ?? {}) as { + v?: unknown; + harness?: unknown; + version?: unknown; + harnessVersion?: unknown; + }; + const matchesSchema = + withinRawCap && + claim !== null && + typeof claim === 'object' && + !Array.isArray(claim) && + parsed.v === 1 && + parsed.harness === expected.harness && + parsed.version === expected.version && + botInfoString(parsed.harness) && + botInfoString(parsed.version) && + (parsed.harnessVersion === undefined || + botInfoString(parsed.harnessVersion)); + if (!matchesSchema) { + throw new Error( + `Expected ${description} bot-info to match v=1, harness=${JSON.stringify( + expected.harness + )}, and runtime package version=${JSON.stringify(expected.version)} ` + + `within the documented size caps, got ${JSON.stringify(claim)}.` + ); + } + return { type: 'text', value }; +} + +function botInfoString(value: unknown): value is string { + return ( + typeof value === 'string' && value.length > 0 && [...value].length <= 64 + ); +} + +function logBotInfoProof( + driverName: DriverName, + selfField: BotInfoTextField, + peerField: BotInfoTextField +): void { + process.stdout.write( + `[tlon-bot-e2e] bot-info proof driver=${driverName} ` + + `self=${JSON.stringify(selfField)} owner=${JSON.stringify(peerField)}\n` + ); +} + function parsePendingNudge(value: unknown): { stage?: unknown } | undefined { if (typeof value === 'string') { try {