Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# Tlon (OpenClaw plugin)
Tlon/Urbit channel plugin for OpenClaw. Supports DMs, group mentions, and thread replies.
Docs: https://docs.openclaw.ai/channels/tlon

17
extensions/tlon/api.ts Normal file
View File

@@ -0,0 +1,17 @@
// Tlon API module exposes the plugin public contract.
export {
createDedupeCache,
createLoggerBackedRuntime,
fetchWithSsrFGuard,
isBlockedHostnameOrIp,
type LookupFn,
type OpenClawConfig,
type ReplyPayload,
type RuntimeEnv,
SsrFBlockedError,
type SsrFPolicy,
ssrfPolicyFromAllowPrivateNetwork,
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
} from "./runtime-api.js";
export { tlonPlugin } from "./src/channel.js";
export { setTlonRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,2 @@
// Tlon API module exposes the plugin public contract.
export { tlonPlugin } from "./src/channel.js";

View File

@@ -0,0 +1,2 @@
// Tlon API module exposes the plugin public contract.
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

17
extensions/tlon/index.ts Normal file
View File

@@ -0,0 +1,17 @@
// Tlon plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "tlon",
name: "Tlon",
description: "Tlon/Urbit channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "tlonPlugin",
},
runtime: {
specifier: "./api.js",
exportName: "setTlonRuntime",
},
});

544
extensions/tlon/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,544 @@
{
"name": "@openclaw/tlon",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/tlon",
"version": "2026.6.11",
"dependencies": {
"@aws-sdk/client-s3": "3.1078.0",
"@aws-sdk/s3-request-presigner": "3.1078.0",
"@tloncorp/tlon-skill": "0.4.3",
"@urbit/aura": "3.0.0",
"zod": "4.4.3"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.11",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.11.tgz",
"integrity": "sha512-nW5kNBJBwVxkvBqygBYksS8WUFDO8Ad8OSfsVa+f5KFRRTrHL1rnWZNsWBwc5fC9YvZbET/57+X4hym/jKfV+g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1078.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1078.0.tgz",
"integrity": "sha512-uQMPma3CzTXPKrRfTkhdKLfXocGoEJwBePcI/ca9iWF+re2gbFSDVV9EIcOrK8ke1OV3Jw4ejNmfmgRVlI5b9A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.11",
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/credential-provider-node": "^3.972.61",
"@aws-sdk/middleware-sdk-s3": "^3.972.57",
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.6.2",
"@smithy/node-http-handler": "^4.9.2",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.974.27",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.27.tgz",
"integrity": "sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.15",
"@aws-sdk/xml-builder": "^3.972.33",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.29.0",
"@smithy/signature-v4": "^5.6.1",
"@smithy/types": "^4.15.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.52",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.52.tgz",
"integrity": "sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.54",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.54.tgz",
"integrity": "sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.6.2",
"@smithy/node-http-handler": "^4.9.2",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.972.59",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.59.tgz",
"integrity": "sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/credential-provider-env": "^3.972.52",
"@aws-sdk/credential-provider-http": "^3.972.54",
"@aws-sdk/credential-provider-login": "^3.972.58",
"@aws-sdk/credential-provider-process": "^3.972.52",
"@aws-sdk/credential-provider-sso": "^3.972.58",
"@aws-sdk/credential-provider-web-identity": "^3.972.58",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/credential-provider-imds": "^4.4.5",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.58.tgz",
"integrity": "sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.61",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.61.tgz",
"integrity": "sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.52",
"@aws-sdk/credential-provider-http": "^3.972.54",
"@aws-sdk/credential-provider-ini": "^3.972.59",
"@aws-sdk/credential-provider-process": "^3.972.52",
"@aws-sdk/credential-provider-sso": "^3.972.58",
"@aws-sdk/credential-provider-web-identity": "^3.972.58",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/credential-provider-imds": "^4.4.5",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.52",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.52.tgz",
"integrity": "sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.58.tgz",
"integrity": "sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/token-providers": "3.1078.0",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.58.tgz",
"integrity": "sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.57",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.57.tgz",
"integrity": "sha512-E7tRmmUb64LlsoI6pZZRTov21K74Fr/MVgYBeNFfBkFzpF8e2tuJkKd4YGmcNwz0UjDs8fMoS0v/MGgHASIx+Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.26",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.26.tgz",
"integrity": "sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.6.2",
"@smithy/node-http-handler": "^4.9.2",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.1078.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1078.0.tgz",
"integrity": "sha512-paQ3KW+VVHptNvNA+qRRlSO6pU9BaiF83M9Ax8NeTEsZHG3Gd83/Z/Ym1/5kDt8xgCNUTeyrrJWx/ony2YrLYg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.38",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz",
"integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.15",
"@smithy/signature-v4": "^5.6.1",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1078.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz",
"integrity": "sha512-/uyXLBGu3Lw1GbBA2X66hcOMnKtMcqAIF+3/eHfxBQmUeXF2sdqozDPrTfEr/TnSd0D6deZar+eVyhEqqWu29w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.973.15",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz",
"integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.33",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz",
"integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/core": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.0.tgz",
"integrity": "sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.5.tgz",
"integrity": "sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.2.tgz",
"integrity": "sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.2.tgz",
"integrity": "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.1.tgz",
"integrity": "sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.15.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz",
"integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@tloncorp/tlon-skill": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@tloncorp/tlon-skill/-/tlon-skill-0.4.3.tgz",
"integrity": "sha512-CJwIpZItw0ubabY5VxPRs9PlyFYZlTqcYHBxm+xlgCAMa6aR8SNr73D6Qr1Yot8eB+m7y+K/BJBu1ZJPJPrtnw==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"tlon": "bin/tlon.js"
},
"optionalDependencies": {
"@tloncorp/tlon-skill-darwin-arm64": "0.4.3",
"@tloncorp/tlon-skill-darwin-x64": "0.4.3",
"@tloncorp/tlon-skill-linux-arm64": "0.4.3",
"@tloncorp/tlon-skill-linux-x64": "0.4.3"
}
},
"node_modules/@tloncorp/tlon-skill-darwin-arm64": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@tloncorp/tlon-skill-darwin-arm64/-/tlon-skill-darwin-arm64-0.4.3.tgz",
"integrity": "sha512-1duHwRDnU8Y87LlHXqGpQ4SPvJcv7jKUYrf6Ublo+mImsx1Vjq2O9NwdKVA5wu+dUPwuiDy8BPbWJXaF95cpdw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"bin": {
"tlon": "tlon"
}
},
"node_modules/@tloncorp/tlon-skill-darwin-x64": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@tloncorp/tlon-skill-darwin-x64/-/tlon-skill-darwin-x64-0.4.3.tgz",
"integrity": "sha512-8PzGWuQsq5NKDysNaP6RPv8YsxErrmuYs8vXHIJhlD3N96GWdfBht6WuD/VsOuzUO1w2FXThXa51FQkIi6WC5A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"bin": {
"tlon": "tlon"
}
},
"node_modules/@tloncorp/tlon-skill-linux-arm64": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@tloncorp/tlon-skill-linux-arm64/-/tlon-skill-linux-arm64-0.4.3.tgz",
"integrity": "sha512-w1BVxjWZbO/xPrrGO+CGn/6vCh8D1EAF8dGF8ocmXzXF3or7c82VTq0ihMv2PCmyd1pOay2LFj5xOeLJU6o9cQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"bin": {
"tlon": "tlon"
}
},
"node_modules/@tloncorp/tlon-skill-linux-x64": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@tloncorp/tlon-skill-linux-x64/-/tlon-skill-linux-x64-0.4.3.tgz",
"integrity": "sha512-RYgQ/mvyZmoNbmcmgtlA5JTGqr35qDh0YHBZCkUon026MQMDL4bAgkxP9bp3c2s5Iw4vWQODAmJeiIUiFfT8Hw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"bin": {
"tlon": "tlon"
}
},
"node_modules/@urbit/aura": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@urbit/aura/-/aura-3.0.0.tgz",
"integrity": "sha512-N8/FHc/lmlMDCumMuTXyRHCxlov5KZY6unmJ9QR2GOw+OpROZMBsXYGwE+ZMtvN21ql9+Xb8KhGNBj08IrG3Wg==",
"license": "MIT",
"engines": {
"node": ">=16",
"npm": ">=8"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"id": "tlon",
"name": "Tlon/Urbit",
"description": "OpenClaw Tlon/Urbit channel plugin for chat workflows.",
"activation": {
"onStartup": false
},
"channels": ["tlon"],
"skills": ["node_modules/@tloncorp/tlon-skill"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,87 @@
{
"name": "@openclaw/tlon",
"version": "2026.6.11",
"description": "OpenClaw Tlon/Urbit channel plugin for chat workflows.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "3.1078.0",
"@aws-sdk/s3-request-presigner": "3.1078.0",
"@tloncorp/tlon-skill": "0.4.3",
"@urbit/aura": "3.0.0",
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "tlon",
"label": "Tlon",
"selectionLabel": "Tlon (Urbit)",
"docsPath": "/channels/tlon",
"docsLabel": "tlon",
"blurb": "decentralized messaging on Urbit; install the plugin to enable.",
"order": 90,
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--ship <ship>",
"description": "Tlon ship name (~sampel-palnet)"
},
{
"flags": "--code <code>",
"description": "Tlon login code"
},
{
"flags": "--group-channels <list>",
"description": "Tlon group channels (comma-separated)"
},
{
"flags": "--dm-allowlist <list>",
"description": "Tlon DM allowlist (comma-separated ships)"
},
{
"flags": "--auto-discover-channels",
"description": "Tlon auto-discover group channels"
},
{
"flags": "--no-auto-discover-channels",
"description": "Disable Tlon auto-discovery"
}
]
},
"install": {
"npmSpec": "@openclaw/tlon",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"release": {
"bundleRuntimeDependencies": false,
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,17 @@
// Private runtime barrel for the bundled Tlon extension.
// Keep this barrel thin and aligned with the local extension surface.
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export { createDedupeCache } from "openclaw/plugin-sdk/core";
export { createLoggerBackedRuntime } from "./src/logger-runtime.js";
export {
fetchWithSsrFGuard,
isBlockedHostnameOrIp,
ssrfPolicyFromAllowPrivateNetwork,
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
type LookupFn,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
export { SsrFBlockedError } from "openclaw/plugin-sdk/ssrf-runtime";

View File

@@ -0,0 +1,3 @@
// Tlon API module exposes the plugin public contract.
export { tlonSetupAdapter } from "./src/setup-core.js";
export { tlonSetupWizard } from "./src/setup-surface.js";

View File

@@ -0,0 +1,10 @@
// Tlon plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "tlonPlugin",
},
});

View File

@@ -0,0 +1,32 @@
// Tlon plugin module implements account fields behavior.
export type TlonAccountFieldsInput = {
ship?: string;
url?: string;
code?: string;
dangerouslyAllowPrivateNetwork?: boolean;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
ownerShip?: string;
};
export function buildTlonAccountFields(input: TlonAccountFieldsInput) {
return {
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(typeof input.dangerouslyAllowPrivateNetwork === "boolean"
? {
network: {
dangerouslyAllowPrivateNetwork: input.dangerouslyAllowPrivateNetwork,
},
}
: {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
...(input.ownerShip ? { ownerShip: input.ownerShip } : {}),
};
}

View File

@@ -0,0 +1,147 @@
// Tlon tests cover channel.message adapter plugin behavior.
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
const mocks = vi.hoisted(() => ({
sendText: vi.fn(),
sendMedia: vi.fn(),
}));
vi.mock("./channel.runtime.js", () => ({
tlonRuntimeOutbound: {
sendText: mocks.sendText,
sendMedia: mocks.sendMedia,
},
}));
import { tlonPlugin } from "./channel.js";
const cfg = {
channels: {
tlon: {
ship: "~zod",
url: "https://zod.example",
code: "lidlut-tabwed-pillex-ridrup",
},
},
} as OpenClawConfig;
describe("tlon channel message adapter", () => {
beforeEach(() => {
mocks.sendText.mockReset();
mocks.sendMedia.mockReset();
mocks.sendText.mockResolvedValue({
channel: "tlon",
messageId: "~zod/1700000000000",
conversationId: "~nec/general",
});
mocks.sendMedia.mockResolvedValue({
channel: "tlon",
messageId: "~zod/1700000000001",
conversationId: "~nec/general",
});
});
it("backs declared durable-final capabilities with outbound send proofs", async () => {
const adapter = tlonPlugin.message;
if (!adapter?.send?.text || !adapter.send.media) {
throw new Error("expected tlon channel message adapter with text and media senders");
}
const sendText = adapter.send.text;
const sendMedia = adapter.send.media;
const proveText = async () => {
mocks.sendText.mockClear();
const result = await sendText({
cfg,
to: "chat/~nec/general",
text: "hello",
accountId: "default",
});
expect(mocks.sendText).toHaveBeenLastCalledWith({
cfg,
to: "chat/~nec/general",
text: "hello",
accountId: "default",
replyToId: undefined,
threadId: undefined,
});
expect(result.receipt.platformMessageIds).toEqual(["~zod/1700000000000"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
mocks.sendMedia.mockClear();
const result = await sendMedia({
cfg,
to: "chat/~nec/general",
text: "image",
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
expect(mocks.sendMedia).toHaveBeenLastCalledWith({
cfg,
to: "chat/~nec/general",
text: "image",
mediaUrl: "https://example.com/image.png",
accountId: "default",
replyToId: undefined,
threadId: undefined,
});
expect(result.receipt.platformMessageIds).toEqual(["~zod/1700000000001"]);
expect(result.receipt.parts[0]?.kind).toBe("media");
};
const proveReplyThread = async () => {
mocks.sendText.mockClear();
const result = await sendText({
cfg,
to: "chat/~nec/general",
text: "threaded",
accountId: "default",
replyToId: "1700000000000",
threadId: "1700000000000",
});
expect(mocks.sendText).toHaveBeenLastCalledWith({
cfg,
to: "chat/~nec/general",
text: "threaded",
accountId: "default",
replyToId: "1700000000000",
threadId: "1700000000000",
});
expect(result.receipt.replyToId).toBe("1700000000000");
expect(result.receipt.threadId).toBe("1700000000000");
};
const proofs = await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "tlonMessageAdapter",
adapter,
proofs: {
text: proveText,
media: proveMedia,
replyTo: proveReplyThread,
thread: proveReplyThread,
messageSendingHooks: () => {
expect(sendText).toBeTypeOf("function");
},
},
});
expect(proofs).toStrictEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
{ capability: "replyTo", status: "verified" },
{ capability: "thread", status: "verified" },
{ capability: "nativeQuote", status: "not_declared" },
{ capability: "messageSendingHooks", status: "verified" },
{ capability: "batch", status: "not_declared" },
{ capability: "reconcileUnknownSend", status: "not_declared" },
{ capability: "afterSendSuccess", status: "not_declared" },
{ capability: "afterCommit", status: "not_declared" },
]);
});
});

View File

@@ -0,0 +1,261 @@
// Tlon plugin module implements channel behavior.
import crypto from "node:crypto";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonSetupWizard } from "./setup-surface.js";
import {
formatTargetHint,
normalizeShip,
parseTlonTarget,
resolveTlonOutboundTarget,
} from "./targets.js";
import { configureClient } from "./tlon-api.js";
import { resolveTlonAccount } from "./types.js";
import { authenticate } from "./urbit/auth.js";
import { ssrfPolicyFromDangerouslyAllowPrivateNetwork } from "./urbit/context.js";
import { urbitFetch } from "./urbit/fetch.js";
import {
buildMediaStory,
sendDm,
sendDmWithStory,
sendGroupMessage,
sendGroupMessageWithStory,
} from "./urbit/send.js";
import { uploadImageFromUrl } from "./urbit/upload.js";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
type ResolvedTlonAccount = ReturnType<typeof resolveTlonAccount>;
type ConfiguredTlonAccount = ResolvedTlonAccount & {
ship: string;
url: string;
code: string;
};
async function createHttpPokeApi(params: {
url: string;
code: string;
ship: string;
dangerouslyAllowPrivateNetwork?: boolean;
}) {
const ssrfPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(
params.dangerouslyAllowPrivateNetwork,
);
const cookie = await authenticate(params.url, params.code, { ssrfPolicy });
const channelId = `${Math.floor(Date.now() / 1000)}-${crypto.randomUUID()}`;
const channelPath = `/~/channel/${channelId}`;
const shipName = params.ship.replace(/^~/, "");
return {
poke: async (pokeParams: { app: string; mark: string; json: unknown }) => {
const pokeId = Date.now();
const pokeData = {
id: pokeId,
action: "poke",
ship: shipName,
app: pokeParams.app,
mark: pokeParams.mark,
json: pokeParams.json,
};
const { response, release } = await urbitFetch({
baseUrl: params.url,
path: channelPath,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: cookie.split(";")[0],
},
body: JSON.stringify([pokeData]),
},
ssrfPolicy,
auditContext: "tlon-poke",
});
try {
if (!response.ok && response.status !== 204) {
const errorText = await readResponseTextLimited(response, 16 * 1024);
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
}
return pokeId;
} finally {
await release();
}
},
delete: async () => {
// No-op for HTTP-only client
},
};
}
function resolveOutboundContext(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}) {
const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined);
if (!account.configured || !account.ship || !account.url || !account.code) {
throw new Error("Tlon account not configured");
}
const parsed = parseTlonTarget(params.to);
if (!parsed) {
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
}
return { account: account as ConfiguredTlonAccount, parsed };
}
function resolveReplyId(replyToId?: string | null, threadId?: string | number | null) {
return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
}
async function withHttpPokeAccountApi<T>(
account: ConfiguredTlonAccount,
run: (api: Awaited<ReturnType<typeof createHttpPokeApi>>) => Promise<T>,
) {
const api = await createHttpPokeApi({
url: account.url,
ship: account.ship,
code: account.code,
dangerouslyAllowPrivateNetwork: account.dangerouslyAllowPrivateNetwork ?? undefined,
});
try {
return await run(api);
} finally {
try {
await api.delete();
} catch {
// ignore cleanup errors
}
}
}
export const tlonRuntimeOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
textChunkLimit: 10000,
resolveTarget: ({ to }) => resolveTlonOutboundTarget(to),
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
const { account, parsed } = resolveOutboundContext({ cfg, accountId, to });
return withHttpPokeAccountApi(account, async (api) => {
const fromShip = normalizeShip(account.ship);
if (parsed.kind === "dm") {
return await sendDm({
api,
fromShip,
toShip: parsed.ship,
text,
});
}
return await sendGroupMessage({
api,
fromShip,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
text,
replyToId: resolveReplyId(replyToId, threadId),
});
});
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
const { account, parsed } = resolveOutboundContext({ cfg, accountId, to });
configureClient({
shipUrl: account.url,
shipName: account.ship.replace(/^~/, ""),
verbose: false,
getCode: async () => account.code,
dangerouslyAllowPrivateNetwork: account.dangerouslyAllowPrivateNetwork ?? undefined,
});
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined;
return withHttpPokeAccountApi(account, async (api) => {
const fromShip = normalizeShip(account.ship);
const story = buildMediaStory(text, uploadedUrl);
if (parsed.kind === "dm") {
return await sendDmWithStory({
api,
fromShip,
toShip: parsed.ship,
story,
kind: "media",
});
}
return await sendGroupMessageWithStory({
api,
fromShip,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
story,
replyToId: resolveReplyId(replyToId, threadId),
kind: "media",
});
});
},
};
export async function probeTlonAccount(account: ConfiguredTlonAccount) {
try {
const ssrfPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(
account.dangerouslyAllowPrivateNetwork,
);
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
const { response, release } = await urbitFetch({
baseUrl: account.url,
path: "/~/name",
init: {
method: "GET",
headers: { Cookie: cookie },
},
ssrfPolicy,
timeoutMs: 30_000,
auditContext: "tlon-probe-account",
});
try {
if (!response.ok) {
return { ok: false, error: `Name request failed: ${response.status}` };
}
return { ok: true };
} finally {
await release();
}
} catch (error) {
return { ok: false, error: (error as { message?: string })?.message ?? String(error) };
}
}
export async function startTlonGatewayAccount(
ctx: Parameters<
NonNullable<NonNullable<ChannelPlugin<ResolvedTlonAccount>["gateway"]>["startAccount"]>
>[0],
) {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
ship: account.ship,
url: account.url,
} as ChannelAccountSnapshot);
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
return monitorTlonProvider({
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
accountId: account.accountId,
});
}
export { tlonSetupWizard };

View File

@@ -0,0 +1,193 @@
// Tlon plugin module implements channel behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/channel-outbound";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { tlonChannelConfigSchema } from "./config-schema.js";
import { tlonDoctor } from "./doctor.js";
import { resolveTlonOutboundSessionRoute } from "./session-route.js";
import { createTlonSetupWizardBase, tlonSetupAdapter } from "./setup-core.js";
import {
formatTargetHint,
normalizeShip,
parseTlonTarget,
resolveTlonOutboundTarget,
} from "./targets.js";
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
const TLON_CHANNEL_ID = "tlon" as const;
const loadTlonChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const tlonSetupWizardProxy = createTlonSetupWizardBase({
resolveConfigured: async ({ cfg, accountId }) =>
await (
await loadTlonChannelRuntime()
).tlonSetupWizard.status.resolveConfigured({
cfg,
accountId,
}),
resolveStatusLines: async ({ cfg, accountId, configured }) =>
(await (
await loadTlonChannelRuntime()
).tlonSetupWizard.status.resolveStatusLines?.({
cfg,
accountId,
configured,
})) ?? [],
finalize: async (params) =>
await (
await loadTlonChannelRuntime()
).tlonSetupWizard.finalize!(params),
}) satisfies NonNullable<ChannelPlugin["setupWizard"]>;
const tlonConfigAdapter = createHybridChannelConfigAdapter({
sectionKey: TLON_CHANNEL_ID,
listAccountIds: listTlonAccountIds,
resolveAccount: resolveTlonAccount,
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
clearBaseFields: ["ship", "code", "url", "name"],
preserveSectionOnDefaultDelete: true,
resolveAllowFrom: (account) => account.dmAllowlist,
formatAllowFrom: (allowFrom) =>
allowFrom.map((entry) => normalizeShip(String(entry))).filter(Boolean),
});
const tlonChannelOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
textChunkLimit: 10000,
resolveTarget: ({ to }) => resolveTlonOutboundTarget(to),
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
...createRuntimeOutboundDelegates({
getRuntime: loadTlonChannelRuntime,
sendText: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendText },
sendMedia: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendMedia },
}),
};
const tlonMessageAdapter = createChannelMessageAdapterFromOutbound({
id: TLON_CHANNEL_ID,
outbound: tlonChannelOutbound,
});
export const tlonPlugin = createChatChannelPlugin({
base: {
id: TLON_CHANNEL_ID,
meta: {
id: TLON_CHANNEL_ID,
label: "Tlon",
selectionLabel: "Tlon (Urbit)",
docsPath: "/channels/tlon",
docsLabel: "tlon",
blurb: "Decentralized messaging on Urbit",
aliases: ["urbit"],
order: 90,
},
capabilities: {
chatTypes: ["direct", "group", "thread"],
media: true,
reply: true,
threads: true,
},
setup: tlonSetupAdapter,
setupWizard: tlonSetupWizardProxy,
reload: { configPrefixes: ["channels.tlon"] },
configSchema: tlonChannelConfigSchema,
config: {
...tlonConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
ship: account.ship,
url: account.url,
},
}),
},
doctor: tlonDoctor,
messaging: {
targetPrefixes: ["tlon"],
normalizeTarget: (target) => {
const parsed = parseTlonTarget(target);
if (!parsed) {
return target.trim();
}
if (parsed.kind === "dm") {
return parsed.ship;
}
return parsed.nest;
},
targetResolver: {
looksLikeId: (target) => Boolean(parseTlonTarget(target)),
hint: formatTargetHint(),
},
resolveOutboundSessionRoute: (params) => resolveTlonOutboundSessionRoute(params),
},
message: tlonMessageAdapter,
status: createComputedAccountStatusAdapter<ReturnType<typeof resolveTlonAccount>>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: (accounts) => {
return accounts.flatMap((account) => {
if (!account.configured) {
return [
{
channel: TLON_CHANNEL_ID,
accountId: account.accountId,
kind: "config",
message: "Account not configured (missing ship, code, or url)",
},
];
}
return [];
});
},
buildChannelSummary: ({ snapshot }) => {
const s = snapshot as { configured?: boolean; ship?: string; url?: string };
return {
configured: s.configured ?? false,
ship: s.ship ?? null,
url: s.url ?? null,
};
},
probeAccount: async ({ account }) => {
if (!account.configured || !account.ship || !account.url || !account.code) {
return { ok: false, error: "Not configured" };
}
return await (await loadTlonChannelRuntime()).probeTlonAccount(account as never);
},
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name ?? undefined,
enabled: account.enabled,
configured: account.configured,
extra: {
ship: account.ship,
url: account.url,
},
}),
}),
gateway: {
startAccount: async (ctx) =>
await (await loadTlonChannelRuntime()).startTlonGatewayAccount(ctx),
},
},
outbound: tlonChannelOutbound,
});

View File

@@ -0,0 +1,55 @@
// Tlon helper module supports config schema behavior.
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
const ShipSchema = z.string().min(1);
const ChannelNestSchema = z.string().min(1);
const TlonChannelRuleSchema = z.object({
mode: z.enum(["restricted", "open"]).optional(),
allowedShips: z.array(ShipSchema).optional(),
});
export const TlonAuthorizationSchema = z.object({
channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
});
const TlonNetworkSchema = z
.object({
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
})
.strict()
.optional();
const tlonCommonConfigFields = {
name: z.string().optional(),
enabled: z.boolean().optional(),
ship: ShipSchema.optional(),
url: z.string().optional(),
code: z.string().optional(),
network: TlonNetworkSchema,
groupChannels: z.array(ChannelNestSchema).optional(),
dmAllowlist: z.array(ShipSchema).optional(),
groupInviteAllowlist: z.array(ShipSchema).optional(),
autoDiscoverChannels: z.boolean().optional(),
showModelSignature: z.boolean().optional(),
responsePrefix: z.string().optional(),
// Auto-accept settings
autoAcceptDmInvites: z.boolean().optional(), // Auto-accept DMs from ships in dmAllowlist
autoAcceptGroupInvites: z.boolean().optional(), // Auto-accept all group invites
// Owner ship for approval system
ownerShip: ShipSchema.optional(), // Ship that receives approval requests and can approve/deny
} satisfies z.ZodRawShape;
const TlonAccountSchema = z.object({
...tlonCommonConfigFields,
});
export const TlonConfigSchema = z.object({
...tlonCommonConfigFields,
authorization: TlonAuthorizationSchema.optional(),
defaultAuthorizedShips: z.array(ShipSchema).optional(),
accounts: z.record(z.string(), TlonAccountSchema).optional(),
});
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);

View File

@@ -0,0 +1,299 @@
// Tlon tests cover core plugin behavior.
import {
createPluginSetupWizardConfigure,
createPluginSetupWizardStatus,
createTestWizardPrompter,
runSetupWizardConfigure,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../api.js";
import { TlonAuthorizationSchema, TlonConfigSchema } from "./config-schema.js";
import { tlonSetupWizard } from "./setup-surface.js";
import { normalizeShip, resolveTlonOutboundTarget } from "./targets.js";
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
const tlonTestPlugin = {
id: "tlon",
meta: { label: "Tlon" },
setupWizard: tlonSetupWizard,
config: {
listAccountIds: listTlonAccountIds,
defaultAccountId: () => "default",
resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) =>
resolveTlonAccount(cfg, accountId).dmAllowlist,
formatAllowFrom: ({
allowFrom,
}: {
cfg: OpenClawConfig;
allowFrom: Array<string | number> | undefined | null;
}) => {
const entries: string[] = [];
for (const entry of allowFrom ?? []) {
const normalized = normalizeShip(String(entry));
if (normalized) {
entries.push(normalized);
}
}
return entries;
},
},
setup: {
resolveAccountId: ({ accountId }: { cfg: OpenClawConfig; accountId?: string | null }) =>
accountId ?? "default",
},
};
const tlonConfigure = createPluginSetupWizardConfigure(tlonTestPlugin);
const tlonStatus = createPluginSetupWizardStatus(tlonTestPlugin);
describe("tlon core", () => {
it("formats dm allowlist entries through the shared hybrid adapter", () => {
expect(
tlonTestPlugin.config.formatAllowFrom?.({
cfg: {} as OpenClawConfig,
allowFrom: ["zod", " ~nec "],
}),
).toEqual(["~zod", "~nec"]);
});
it("returns an empty dm allowlist when the default account is unconfigured", () => {
expect(
tlonTestPlugin.config.resolveAllowFrom?.({
cfg: {} as OpenClawConfig,
accountId: "default",
}),
).toStrictEqual([]);
});
it("resolves dm allowlist from the default account", () => {
expect(
tlonTestPlugin.config.resolveAllowFrom?.({
cfg: {
channels: {
tlon: {
ship: "~sampel-palnet",
url: "https://urbit.example.com",
code: "lidlut-tabwed-pillex-ridrup",
dmAllowlist: ["~zod"],
},
},
} as OpenClawConfig,
accountId: "default",
}),
).toEqual(["~zod"]);
});
it("accepts channelRules with string keys", () => {
const parsed = TlonAuthorizationSchema.parse({
channelRules: {
"chat/~zod/test": {
mode: "open",
allowedShips: ["~zod"],
},
},
});
expect(parsed.channelRules?.["chat/~zod/test"]?.mode).toBe("open");
});
it("accepts accounts with string keys", () => {
const parsed = TlonConfigSchema.parse({
accounts: {
primary: {
ship: "~zod",
url: "https://example.com",
code: "code-123",
},
},
});
expect(parsed.accounts?.primary?.ship).toBe("~zod");
});
it("exposes group invite allowlists in channel config schema", () => {
expect(TlonConfigSchema.parse({ groupInviteAllowlist: ["~zod"] }).groupInviteAllowlist).toEqual(
["~zod"],
);
expect(
TlonConfigSchema.parse({
accounts: { primary: { groupInviteAllowlist: ["~nec"] } },
}).accounts?.primary?.groupInviteAllowlist,
).toEqual(["~nec"]);
});
it("configures ship, auth, and discovery settings", async () => {
const prompter = createTestWizardPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Ship name") {
return "sampel-palnet";
}
if (message === "Ship URL") {
return "https://urbit.example.com";
}
if (message === "Login code") {
return "lidlut-tabwed-pillex-ridrup";
}
if (message === "Group channels (comma-separated)") {
return "chat/~host-ship/general, chat/~host-ship/support";
}
if (message === "DM allowlist (comma-separated ship names)") {
return "~zod, nec";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Add group channels manually? (optional)") {
return true;
}
if (message === "Restrict DMs with an allowlist?") {
return true;
}
if (message === "Enable auto-discovery of group channels?") {
return true;
}
return false;
}),
});
const result = await runSetupWizardConfigure({
configure: tlonConfigure,
cfg: {} as OpenClawConfig,
prompter,
options: {},
});
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.tlon?.enabled).toBe(true);
expect(result.cfg.channels?.tlon?.ship).toBe("~sampel-palnet");
expect(result.cfg.channels?.tlon?.url).toBe("https://urbit.example.com");
expect(result.cfg.channels?.tlon?.code).toBe("lidlut-tabwed-pillex-ridrup");
expect(result.cfg.channels?.tlon?.groupChannels).toEqual([
"chat/~host-ship/general",
"chat/~host-ship/support",
]);
expect(result.cfg.channels?.tlon?.dmAllowlist).toEqual(["~zod", "~nec"]);
expect(result.cfg.channels?.tlon?.autoDiscoverChannels).toBe(true);
expect(result.cfg.channels?.tlon?.network?.dangerouslyAllowPrivateNetwork).toBe(false);
});
it("resolves dm targets to normalized ships", () => {
expect(resolveTlonOutboundTarget("dm/sampel-palnet")).toEqual({
ok: true,
to: "~sampel-palnet",
});
});
it("resolves group targets to canonical chat nests", () => {
expect(resolveTlonOutboundTarget("group:host-ship/general")).toEqual({
ok: true,
to: "chat/~host-ship/general",
});
});
it("returns a helpful error for invalid targets", () => {
const resolved = resolveTlonOutboundTarget("group:bad-target");
expect(resolved.ok).toBe(false);
if (resolved.ok) {
throw new Error("expected invalid target");
}
expect(resolved.error.message).toMatch(/invalid tlon target/i);
});
it("lists named accounts and the implicit default account", () => {
const cfg = {
channels: {
tlon: {
ship: "~zod",
accounts: {
Work: { ship: "~bus" },
alerts: { ship: "~nec" },
},
},
},
} as OpenClawConfig;
expect(listTlonAccountIds(cfg)).toEqual(["alerts", "default", "work"]);
});
it("merges named account config over channel defaults", () => {
const resolved = resolveTlonAccount(
{
channels: {
tlon: {
name: "Base",
ship: "~zod",
url: "https://urbit.example.com",
code: "base-code",
dmAllowlist: ["~nec"],
groupInviteAllowlist: ["~bus"],
defaultAuthorizedShips: ["~marzod"],
accounts: {
Work: {
name: "Work",
code: "work-code",
dmAllowlist: ["~rovnys"],
},
},
},
},
} as OpenClawConfig,
"work",
);
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.ship).toBe("~zod");
expect(resolved.url).toBe("https://urbit.example.com");
expect(resolved.code).toBe("work-code");
expect(resolved.dmAllowlist).toEqual(["~rovnys"]);
expect(resolved.groupInviteAllowlist).toEqual(["~bus"]);
expect(resolved.defaultAuthorizedShips).toEqual(["~marzod"]);
expect(resolved.configured).toBe(true);
});
it("keeps the default account on channel-level config only", () => {
const resolved = resolveTlonAccount(
{
channels: {
tlon: {
ship: "~zod",
url: "https://urbit.example.com",
code: "base-code",
accounts: {
default: {
ship: "~ignored",
code: "ignored-code",
},
},
},
},
} as OpenClawConfig,
"default",
);
expect(resolved.ship).toBe("~zod");
expect(resolved.code).toBe("base-code");
});
it("setup status labels the selected account", async () => {
const status = await tlonStatus({
cfg: {
channels: {
tlon: {
ship: "~zod",
url: "https://urbit.example.com",
code: "base-code",
accounts: {
work: {},
},
},
},
} as OpenClawConfig,
accountOverrides: { tlon: "work" },
});
expect(status.configured).toBe(true);
expect(status.statusLines).toEqual(["Tlon (work): configured"]);
});
});

View File

@@ -0,0 +1,10 @@
// Tlon plugin module implements doctor contract behavior.
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "tlon",
});
export const legacyConfigRules = contract.legacyConfigRules;
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;

View File

@@ -0,0 +1,47 @@
// Tlon tests cover doctor plugin behavior.
import { describe, expect, it } from "vitest";
import { tlonDoctor } from "./doctor.js";
function getTlonCompatibilityNormalizer(): NonNullable<
typeof tlonDoctor.normalizeCompatibilityConfig
> {
const normalize = tlonDoctor.normalizeCompatibilityConfig;
if (!normalize) {
throw new Error("Expected tlon doctor to expose normalizeCompatibilityConfig");
}
return normalize;
}
describe("tlon doctor", () => {
it("normalizes legacy private-network aliases", () => {
const normalize = getTlonCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
tlon: {
allowPrivateNetwork: true,
accounts: {
alt: {
allowPrivateNetwork: false,
},
},
},
},
} as never,
});
expect(result.config.channels?.tlon?.network).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
expect(
(
result.config.channels?.tlon?.accounts?.alt as
| { network?: Record<string, unknown> }
| undefined
)?.network,
).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
});
});

View File

@@ -0,0 +1,11 @@
// Tlon plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import {
legacyConfigRules as TLON_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeTlonCompatibilityConfig,
} from "./doctor-contract.js";
export const tlonDoctor: ChannelDoctorAdapter = {
legacyConfigRules: TLON_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeTlonCompatibilityConfig,
};

View File

@@ -0,0 +1,2 @@
// Tlon plugin module implements logger runtime behavior.
export { createLoggerBackedRuntime } from "openclaw/plugin-sdk/runtime";

View File

@@ -0,0 +1,364 @@
// Tlon plugin module implements approval runtime behavior.
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import type { PendingApproval, TlonSettingsStore } from "../settings.js";
import { normalizeShip } from "../targets.js";
import { sendDm } from "../urbit/send.js";
import type { UrbitSSEClient } from "../urbit/sse-client.js";
import {
findPendingApproval,
formatApprovalConfirmation,
formatApprovalRequest,
formatBlockedList,
formatPendingList,
parseAdminCommand,
parseApprovalResponse,
removePendingApproval,
} from "./approval.js";
type TlonApprovalApi = Pick<UrbitSSEClient, "poke" | "scry">;
type ApprovedMessageProcessor = (approval: PendingApproval) => Promise<void>;
export function createTlonApprovalRuntime(params: {
api: TlonApprovalApi;
runtime: RuntimeEnv;
botShipName: string;
getPendingApprovals: () => PendingApproval[];
setPendingApprovals: (approvals: PendingApproval[]) => void;
getCurrentSettings: () => TlonSettingsStore;
setCurrentSettings: (settings: TlonSettingsStore) => void;
getEffectiveDmAllowlist: () => string[];
setEffectiveDmAllowlist: (ships: string[]) => void;
getEffectiveOwnerShip: () => string | null;
processApprovedMessage: ApprovedMessageProcessor;
refreshWatchedChannels: () => Promise<number>;
}) {
const {
api,
runtime,
botShipName,
getPendingApprovals,
setPendingApprovals,
getCurrentSettings,
setCurrentSettings,
getEffectiveDmAllowlist,
setEffectiveDmAllowlist,
getEffectiveOwnerShip,
processApprovedMessage,
refreshWatchedChannels,
} = params;
const savePendingApprovals = async (): Promise<void> => {
try {
await api.poke({
app: "settings",
mark: "settings-event",
json: {
"put-entry": {
desk: "moltbot",
"bucket-key": "tlon",
"entry-key": "pendingApprovals",
value: JSON.stringify(getPendingApprovals()),
},
},
});
} catch (err) {
runtime.error?.(`[tlon] Failed to save pending approvals: ${String(err)}`);
}
};
const addToDmAllowlist = async (ship: string): Promise<void> => {
const normalizedShip = normalizeShip(ship);
const nextAllowlist = getEffectiveDmAllowlist().includes(normalizedShip)
? getEffectiveDmAllowlist()
: [...getEffectiveDmAllowlist(), normalizedShip];
setEffectiveDmAllowlist(nextAllowlist);
try {
await api.poke({
app: "settings",
mark: "settings-event",
json: {
"put-entry": {
desk: "moltbot",
"bucket-key": "tlon",
"entry-key": "dmAllowlist",
value: nextAllowlist,
},
},
});
runtime.log?.(`[tlon] Added ${normalizedShip} to dmAllowlist`);
} catch (err) {
runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
}
};
const addToChannelAllowlist = async (ship: string, channelNest: string): Promise<void> => {
const normalizedShip = normalizeShip(ship);
const currentSettings = getCurrentSettings();
const channelRules = currentSettings.channelRules ?? {};
const rule = channelRules[channelNest] ?? { mode: "restricted", allowedShips: [] };
const allowedShips = [...(rule.allowedShips ?? [])];
if (!allowedShips.includes(normalizedShip)) {
allowedShips.push(normalizedShip);
}
const updatedRules = {
...channelRules,
[channelNest]: { ...rule, allowedShips },
};
setCurrentSettings({ ...currentSettings, channelRules: updatedRules });
try {
await api.poke({
app: "settings",
mark: "settings-event",
json: {
"put-entry": {
desk: "moltbot",
"bucket-key": "tlon",
"entry-key": "channelRules",
value: JSON.stringify(updatedRules),
},
},
});
runtime.log?.(`[tlon] Added ${normalizedShip} to ${channelNest} allowlist`);
} catch (err) {
runtime.error?.(`[tlon] Failed to update channelRules: ${String(err)}`);
}
};
const blockShip = async (ship: string): Promise<void> => {
const normalizedShip = normalizeShip(ship);
try {
await api.poke({
app: "chat",
mark: "chat-block-ship",
json: { ship: normalizedShip },
});
runtime.log?.(`[tlon] Blocked ship ${normalizedShip}`);
} catch (err) {
runtime.error?.(`[tlon] Failed to block ship ${normalizedShip}: ${String(err)}`);
}
};
const isShipBlocked = async (ship: string): Promise<boolean> => {
const normalizedShip = normalizeShip(ship);
try {
const blocked = (await api.scry("/chat/blocked.json")) as string[] | undefined;
return (
Array.isArray(blocked) && blocked.some((item) => normalizeShip(item) === normalizedShip)
);
} catch (err) {
runtime.log?.(`[tlon] Failed to check blocked list: ${String(err)}`);
return false;
}
};
const getBlockedShips = async (): Promise<string[]> => {
try {
const blocked = (await api.scry("/chat/blocked.json")) as string[] | undefined;
return Array.isArray(blocked) ? blocked : [];
} catch (err) {
runtime.log?.(`[tlon] Failed to get blocked list: ${String(err)}`);
return [];
}
};
const unblockShip = async (ship: string): Promise<boolean> => {
const normalizedShip = normalizeShip(ship);
try {
await api.poke({
app: "chat",
mark: "chat-unblock-ship",
json: { ship: normalizedShip },
});
runtime.log?.(`[tlon] Unblocked ship ${normalizedShip}`);
return true;
} catch (err) {
runtime.error?.(`[tlon] Failed to unblock ship ${normalizedShip}: ${String(err)}`);
return false;
}
};
const sendOwnerNotification = async (message: string): Promise<void> => {
const ownerShip = getEffectiveOwnerShip();
if (!ownerShip) {
runtime.log?.("[tlon] No ownerShip configured, cannot send notification");
return;
}
try {
await sendDm({
api,
fromShip: botShipName,
toShip: ownerShip,
text: message,
});
runtime.log?.(`[tlon] Sent notification to owner ${ownerShip}`);
} catch (err) {
runtime.error?.(`[tlon] Failed to send notification to owner: ${String(err)}`);
}
};
const queueApprovalRequest = async (approval: PendingApproval): Promise<void> => {
if (await isShipBlocked(approval.requestingShip)) {
runtime.log?.(`[tlon] Ignoring request from blocked ship ${approval.requestingShip}`);
return;
}
const approvals = getPendingApprovals();
const existingIndex = approvals.findIndex(
(item) =>
item.type === approval.type &&
item.requestingShip === approval.requestingShip &&
(approval.type !== "channel" || item.channelNest === approval.channelNest) &&
(approval.type !== "group" || item.groupFlag === approval.groupFlag),
);
if (existingIndex !== -1) {
const existing = approvals[existingIndex];
if (approval.originalMessage) {
existing.originalMessage = approval.originalMessage;
existing.messagePreview = approval.messagePreview;
}
runtime.log?.(
`[tlon] Updated existing approval for ${approval.requestingShip} (${approval.type}) - re-sending notification`,
);
await savePendingApprovals();
await sendOwnerNotification(formatApprovalRequest(existing));
return;
}
setPendingApprovals([...approvals, approval]);
await savePendingApprovals();
await sendOwnerNotification(formatApprovalRequest(approval));
runtime.log?.(
`[tlon] Queued approval request: ${approval.id} (${approval.type} from ${approval.requestingShip})`,
);
};
const handleApprovalResponse = async (text: string): Promise<boolean> => {
const parsed = parseApprovalResponse(text);
if (!parsed) {
return false;
}
const approval = findPendingApproval(getPendingApprovals(), parsed.id);
if (!approval) {
await sendOwnerNotification(
`No pending approval found${parsed.id ? ` for ID: ${parsed.id}` : ""}`,
);
return true;
}
if (parsed.action === "approve") {
switch (approval.type) {
case "dm":
await addToDmAllowlist(approval.requestingShip);
if (approval.originalMessage) {
runtime.log?.(
`[tlon] Processing original message from ${approval.requestingShip} after approval`,
);
await processApprovedMessage(approval);
}
break;
case "channel":
if (approval.channelNest) {
await addToChannelAllowlist(approval.requestingShip, approval.channelNest);
if (approval.originalMessage) {
runtime.log?.(
`[tlon] Processing original message from ${approval.requestingShip} in ${approval.channelNest} after approval`,
);
await processApprovedMessage(approval);
}
}
break;
case "group":
if (approval.groupFlag) {
try {
await api.poke({
app: "groups",
mark: "group-join",
json: {
flag: approval.groupFlag,
"join-all": true,
},
});
runtime.log?.(`[tlon] Joined group ${approval.groupFlag} after approval`);
setTimeout(() => {
void (async () => {
try {
const newCount = await refreshWatchedChannels();
if (newCount > 0) {
runtime.log?.(
`[tlon] Discovered ${newCount} new channel(s) after joining group`,
);
}
} catch (err) {
runtime.log?.(
`[tlon] Channel discovery after group join failed: ${String(err)}`,
);
}
})();
}, 2000);
} catch (err) {
runtime.error?.(`[tlon] Failed to join group ${approval.groupFlag}: ${String(err)}`);
}
}
break;
}
await sendOwnerNotification(formatApprovalConfirmation(approval, "approve"));
} else if (parsed.action === "block") {
await blockShip(approval.requestingShip);
await sendOwnerNotification(formatApprovalConfirmation(approval, "block"));
} else {
await sendOwnerNotification(formatApprovalConfirmation(approval, "deny"));
}
setPendingApprovals(removePendingApproval(getPendingApprovals(), approval.id));
await savePendingApprovals();
return true;
};
const handleAdminCommand = async (text: string): Promise<boolean> => {
const command = parseAdminCommand(text);
if (!command) {
return false;
}
switch (command.type) {
case "blocked": {
const blockedShips = await getBlockedShips();
await sendOwnerNotification(formatBlockedList(blockedShips));
runtime.log?.(`[tlon] Owner requested blocked ships list (${blockedShips.length} ships)`);
return true;
}
case "pending":
await sendOwnerNotification(formatPendingList(getPendingApprovals()));
runtime.log?.(
`[tlon] Owner requested pending approvals list (${getPendingApprovals().length} pending)`,
);
return true;
case "unblock": {
const shipToUnblock = command.ship;
if (!(await isShipBlocked(shipToUnblock))) {
await sendOwnerNotification(`${shipToUnblock} is not blocked.`);
return true;
}
const success = await unblockShip(shipToUnblock);
await sendOwnerNotification(
success ? `Unblocked ${shipToUnblock}.` : `Failed to unblock ${shipToUnblock}.`,
);
return true;
}
}
throw new Error("Unsupported Tlon admin command");
};
return {
queueApprovalRequest,
handleApprovalResponse,
handleAdminCommand,
};
}

View File

@@ -0,0 +1,95 @@
// Tlon tests cover approval plugin behavior.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const cryptoMocks = vi.hoisted(() => ({
randomBytes: vi.fn(),
}));
vi.mock("node:crypto", () => ({
randomBytes: cryptoMocks.randomBytes,
}));
let generateApprovalId: typeof import("./approval.js").generateApprovalId;
let createPendingApproval: typeof import("./approval.js").createPendingApproval;
let formatApprovalRequest: typeof import("./approval.js").formatApprovalRequest;
beforeAll(async () => {
({ generateApprovalId, createPendingApproval, formatApprovalRequest } =
await import("./approval.js"));
});
beforeEach(() => {
cryptoMocks.randomBytes.mockReset();
});
describe("generateApprovalId", () => {
it("uses secure hex entropy while preserving the ID format", () => {
cryptoMocks.randomBytes.mockReturnValueOnce(Buffer.from("a1b2c3", "hex"));
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_717_171_717_171);
try {
expect(generateApprovalId("dm")).toBe("dm-1717171717171-a1b2c3");
expect(cryptoMocks.randomBytes).toHaveBeenCalledWith(3);
} finally {
nowSpy.mockRestore();
}
});
});
describe("approval preview UTF-16 boundary safety", () => {
const LONE_SURROGATE = /[\uD800-\uDFFF]/;
// U+1F600 is two UTF-16 code units.
// Place it so the high surrogate lands exactly at the 100-unit cap.
// "a".repeat(99) = 99 units, then \uD83D at index 99 splits the pair.
const textWithEmojiAtBoundary = "a".repeat(99) + "\uD83D\uDE00tail";
it("DM path: messagePreview stored in PendingApproval is free of lone surrogates", () => {
cryptoMocks.randomBytes.mockReturnValue(Buffer.from("aabbcc", "hex"));
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000_000_000);
try {
const approval = createPendingApproval({
type: "dm",
requestingShip: "~sampel-palnet",
messagePreview: textWithEmojiAtBoundary,
});
expect(LONE_SURROGATE.test(approval.messagePreview ?? "")).toBe(false);
expect(approval.messagePreview).not.toContain("\uD83D");
} finally {
nowSpy.mockRestore();
}
});
it("channel path: messagePreview stored in PendingApproval is free of lone surrogates", () => {
cryptoMocks.randomBytes.mockReturnValue(Buffer.from("aabbcc", "hex"));
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000_000_000);
try {
const approval = createPendingApproval({
type: "channel",
requestingShip: "~sampel-palnet",
channelNest: "chat/~sampel/test",
messagePreview: textWithEmojiAtBoundary,
});
expect(LONE_SURROGATE.test(approval.messagePreview ?? "")).toBe(false);
expect(approval.messagePreview).not.toContain("\uD83D");
} finally {
nowSpy.mockRestore();
}
});
it("formatApprovalRequest renders well-formed UTF-16 in the owner notification", () => {
cryptoMocks.randomBytes.mockReturnValue(Buffer.from("aabbcc", "hex"));
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000_000_000);
try {
const approval = createPendingApproval({
type: "dm",
requestingShip: "~sampel-palnet",
messagePreview: textWithEmojiAtBoundary,
});
const rendered = formatApprovalRequest(approval);
expect(LONE_SURROGATE.test(rendered)).toBe(false);
} finally {
nowSpy.mockRestore();
}
});
});

View File

@@ -0,0 +1,260 @@
/**
* Approval system for managing DM, channel mention, and group invite approvals.
*
* When an unknown ship tries to interact with the bot, the owner receives
* a notification and can approve or deny the request.
*/
// Extensions cannot import core internals directly, so use node:crypto here.
import { randomBytes } from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { PendingApproval } from "../settings.js";
export type { PendingApproval };
export type ApprovalType = "dm" | "channel" | "group";
export type CreateApprovalParams = {
type: ApprovalType;
requestingShip: string;
channelNest?: string;
groupFlag?: string;
messagePreview?: string;
originalMessage?: {
messageId: string;
messageText: string;
messageContent: unknown;
timestamp: number;
parentId?: string;
isThreadReply?: boolean;
};
};
/**
* Generate a unique approval ID in the format: {type}-{timestamp}-{shortHash}
*/
export function generateApprovalId(type: ApprovalType): string {
const timestamp = Date.now();
const randomPart = randomBytes(3).toString("hex");
return `${type}-${timestamp}-${randomPart}`;
}
/**
* Create a pending approval object.
*/
export function createPendingApproval(params: CreateApprovalParams): PendingApproval {
return {
id: generateApprovalId(params.type),
type: params.type,
requestingShip: params.requestingShip,
channelNest: params.channelNest,
groupFlag: params.groupFlag,
messagePreview:
params.messagePreview != null ? sliceUtf16Safe(params.messagePreview, 0, 100) : undefined,
originalMessage: params.originalMessage,
timestamp: Date.now(),
};
}
/**
* Truncate text to a maximum length with ellipsis.
*/
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return sliceUtf16Safe(text, 0, maxLength - 3) + "...";
}
/**
* Format a notification message for the owner about a pending approval.
*/
export function formatApprovalRequest(approval: PendingApproval): string {
const preview = approval.messagePreview ? `\n"${truncate(approval.messagePreview, 100)}"` : "";
switch (approval.type) {
case "dm":
return (
`New DM request from ${approval.requestingShip}:${preview}\n\n` +
`Reply "approve", "deny", or "block" (ID: ${approval.id})`
);
case "channel":
return (
`${approval.requestingShip} mentioned you in ${approval.channelNest}:${preview}\n\n` +
`Reply "approve", "deny", or "block"\n` +
`(ID: ${approval.id})`
);
case "group":
return (
`Group invite from ${approval.requestingShip} to join ${approval.groupFlag}\n\n` +
`Reply "approve", "deny", or "block"\n` +
`(ID: ${approval.id})`
);
}
throw new Error("Unsupported approval type");
}
export type ApprovalResponse = {
action: "approve" | "deny" | "block";
id?: string;
};
/**
* Parse an owner's response to an approval request.
* Supports formats:
* - "approve" / "deny" / "block" (applies to most recent pending)
* - "approve dm-1234567890-abc" / "deny dm-1234567890-abc" (specific ID)
* - "block" permanently blocks the ship via Tlon's native blocking
*/
export function parseApprovalResponse(text: string): ApprovalResponse | null {
const trimmed = normalizeLowercaseStringOrEmpty(text);
// Match "approve", "deny", or "block" optionally followed by an ID
const match = trimmed.match(/^(approve|deny|block)(?:\s+(.+))?$/);
if (!match) {
return null;
}
const action = match[1] as "approve" | "deny" | "block";
const id = match[2]?.trim();
return { action, id };
}
/**
* Check if a message text looks like an approval response.
* Used to determine if we should intercept the message before normal processing.
*/
export function isApprovalResponse(text: string): boolean {
const trimmed = normalizeLowercaseStringOrEmpty(text);
return trimmed.startsWith("approve") || trimmed.startsWith("deny") || trimmed.startsWith("block");
}
/**
* Find a pending approval by ID, or return the most recent if no ID specified.
*/
export function findPendingApproval(
pendingApprovals: PendingApproval[],
id?: string,
): PendingApproval | undefined {
if (id) {
return pendingApprovals.find((a) => a.id === id);
}
// Return most recent
return pendingApprovals[pendingApprovals.length - 1];
}
/**
* Remove a pending approval from the list by ID.
*/
export function removePendingApproval(
pendingApprovals: PendingApproval[],
id: string,
): PendingApproval[] {
return pendingApprovals.filter((a) => a.id !== id);
}
/**
* Format a confirmation message after an approval action.
*/
export function formatApprovalConfirmation(
approval: PendingApproval,
action: "approve" | "deny" | "block",
): string {
if (action === "block") {
return `Blocked ${approval.requestingShip}. They will no longer be able to contact the bot.`;
}
const actionText = action === "approve" ? "Approved" : "Denied";
switch (approval.type) {
case "dm":
if (action === "approve") {
return `${actionText} DM access for ${approval.requestingShip}. They can now message the bot.`;
}
return `${actionText} DM request from ${approval.requestingShip}.`;
case "channel":
if (action === "approve") {
return `${actionText} ${approval.requestingShip} for ${approval.channelNest}. They can now interact in this channel.`;
}
return `${actionText} ${approval.requestingShip} for ${approval.channelNest}.`;
case "group":
if (action === "approve") {
return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}. Joining group...`;
}
return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}.`;
}
throw new Error("Unsupported approval type");
}
// ============================================================================
// Admin Commands
// ============================================================================
export type AdminCommand =
| { type: "unblock"; ship: string }
| { type: "blocked" }
| { type: "pending" };
/**
* Parse an admin command from owner message.
* Supports:
* - "unblock ~ship" - unblock a specific ship
* - "blocked" - list all blocked ships
* - "pending" - list all pending approvals
*/
export function parseAdminCommand(text: string): AdminCommand | null {
const trimmed = normalizeLowercaseStringOrEmpty(text);
// "blocked" - list blocked ships
if (trimmed === "blocked") {
return { type: "blocked" };
}
// "pending" - list pending approvals
if (trimmed === "pending") {
return { type: "pending" };
}
// "unblock ~ship" - unblock a specific ship
const unblockMatch = trimmed.match(/^unblock\s+(~[\w-]+)$/);
if (unblockMatch) {
return { type: "unblock", ship: unblockMatch[1] };
}
return null;
}
/**
* Check if a message text looks like an admin command.
*/
export function isAdminCommand(text: string): boolean {
return parseAdminCommand(text) !== null;
}
/**
* Format the list of blocked ships for display to owner.
*/
export function formatBlockedList(ships: string[]): string {
if (ships.length === 0) {
return "No ships are currently blocked.";
}
return `Blocked ships (${ships.length}):\n${ships.map((s) => `${s}`).join("\n")}`;
}
/**
* Format the list of pending approvals for display to owner.
*/
export function formatPendingList(approvals: PendingApproval[]): string {
if (approvals.length === 0) {
return "No pending approval requests.";
}
return `Pending approvals (${approvals.length}):\n${approvals
.map((a) => `${a.id}: ${a.type} from ${a.requestingShip}`)
.join("\n")}`;
}

View File

@@ -0,0 +1,31 @@
// Tlon plugin module implements authorization behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { TlonSettingsStore } from "../settings.js";
type ChannelAuthorization = {
mode?: "restricted" | "open";
allowedShips?: string[];
};
export function resolveChannelAuthorization(
cfg: OpenClawConfig,
channelNest: string,
settings?: TlonSettingsStore,
): { mode: "restricted" | "open"; allowedShips: string[] } {
const tlonConfig = cfg.channels?.tlon as
| {
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
defaultAuthorizedShips?: string[];
}
| undefined;
const fileRules = tlonConfig?.authorization?.channelRules ?? {};
const settingsRules = settings?.channelRules ?? {};
const rule = settingsRules[channelNest] ?? fileRules[channelNest];
const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
return {
mode: rule?.mode ?? "restricted",
allowedShips: rule?.allowedShips ?? defaultShips,
};
}

View File

@@ -0,0 +1,55 @@
// Tlon plugin module implements cites behavior.
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { asRecord, extractCites, extractMessageText, type ParsedCite } from "./utils.js";
type TlonScryApi = {
scry: (path: string) => Promise<unknown>;
};
export function createTlonCitationResolver(params: { api: TlonScryApi; runtime: RuntimeEnv }) {
const { api, runtime } = params;
const resolveCiteContent = async (cite: ParsedCite): Promise<string | null> => {
if (cite.type !== "chan" || !cite.nest || !cite.postId) {
return null;
}
try {
const scryPath = `/channels/v4/${cite.nest}/posts/post/${cite.postId}.json`;
runtime.log?.(`[tlon] Fetching cited post: ${scryPath}`);
const data = asRecord(await api.scry(scryPath));
const essay = asRecord(data?.essay);
if (essay?.content) {
return extractMessageText(essay.content) || null;
}
return null;
} catch (err) {
runtime.log?.(`[tlon] Failed to fetch cited post: ${String(err)}`);
return null;
}
};
const resolveAllCites = async (content: unknown): Promise<string> => {
const cites = extractCites(content);
if (cites.length === 0) {
return "";
}
const resolved: string[] = [];
for (const cite of cites) {
const text = await resolveCiteContent(cite);
if (text) {
resolved.push(`> ${cite.author || "unknown"} wrote: ${text}`);
}
}
return resolved.length > 0 ? `${resolved.join("\n")}\n\n` : "";
};
return {
resolveCiteContent,
resolveAllCites,
};
}

View File

@@ -0,0 +1,69 @@
// Tlon plugin module implements discovery behavior.
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import type { Foreigns } from "../urbit/foreigns.js";
import { asRecord, formatErrorMessage } from "./utils.js";
interface InitData {
channels: string[];
foreigns: Foreigns | null;
}
/**
* Fetch groups-ui init data, returning channels and foreigns.
* This is a single scry that provides both channel discovery and pending invites.
*/
export async function fetchInitData(
api: { scry: (path: string) => Promise<unknown> },
runtime: RuntimeEnv,
): Promise<InitData> {
try {
runtime.log?.("[tlon] Fetching groups-ui init data...");
const initData = asRecord(await api.scry("/groups-ui/v6/init.json"));
const channels: string[] = [];
const groups = asRecord(initData?.groups);
if (groups) {
for (const groupData of Object.values(groups)) {
const typedGroupData = asRecord(groupData);
const groupChannels = asRecord(typedGroupData?.channels);
if (groupChannels) {
for (const channelNest of Object.keys(groupChannels)) {
if (channelNest.startsWith("chat/")) {
channels.push(channelNest);
}
}
}
}
}
if (channels.length > 0) {
runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`);
} else {
runtime.log?.("[tlon] No chat channels found via auto-discovery");
}
const foreignsValue = asRecord(initData?.foreigns);
const foreigns = foreignsValue ? (foreignsValue as Foreigns) : null;
if (foreigns) {
const pendingCount = Object.values(foreigns).filter((f) =>
f.invites?.some((i) => i.valid),
).length;
if (pendingCount > 0) {
runtime.log?.(`[tlon] Found ${pendingCount} pending group invite(s)`);
}
}
return { channels, foreigns };
} catch (error: unknown) {
runtime.log?.(`[tlon] Init data fetch failed: ${formatErrorMessage(error)}`);
return { channels: [], foreigns: null };
}
}
export async function fetchAllChannels(
api: { scry: (path: string) => Promise<unknown> },
runtime: RuntimeEnv,
): Promise<string[]> {
const { channels } = await fetchInitData(api, runtime);
return channels;
}

View File

@@ -0,0 +1,227 @@
// Tlon plugin module implements history behavior.
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { asRecord, extractMessageText, formatErrorMessage } from "./utils.js";
/**
* Format a number as @ud (with dots every 3 digits from the right)
* e.g., 170141184507799509469114119040828178432 -> 170.141.184.507.799.509.469.114.119.040.828.178.432
*/
function formatUd(id: string | number): string {
const str = String(id).replace(/\./g, ""); // Remove any existing dots
const reversed = str.split("").toReversed();
const chunks: string[] = [];
for (let i = 0; i < reversed.length; i += 3) {
chunks.push(
reversed
.slice(i, i + 3)
.toReversed()
.join(""),
);
}
return chunks.toReversed().join(".");
}
type TlonHistoryEntry = {
author: string;
content: string;
timestamp: number;
id?: string;
};
function createHistoryEntryFromMemo(params: {
memo?: Record<string, unknown> | null;
seal?: Record<string, unknown> | null;
fallbackId?: unknown;
}): TlonHistoryEntry {
const { memo, seal, fallbackId } = params;
return {
author: typeof memo?.author === "string" ? memo.author : "unknown",
content: extractMessageText(memo?.content || []),
timestamp: typeof memo?.sent === "number" ? memo.sent : Date.now(),
id:
typeof seal?.id === "string"
? seal.id
: typeof fallbackId === "string"
? fallbackId
: undefined,
};
}
const messageCache = new Map<string, TlonHistoryEntry[]>();
const MAX_CACHED_MESSAGES = 100;
export function cacheMessage(channelNest: string, message: TlonHistoryEntry) {
if (!messageCache.has(channelNest)) {
messageCache.set(channelNest, []);
}
const cache = messageCache.get(channelNest);
if (!cache) {
return;
}
cache.unshift(message);
if (cache.length > MAX_CACHED_MESSAGES) {
cache.pop();
}
}
async function fetchChannelHistory(
api: { scry: (path: string) => Promise<unknown> },
channelNest: string,
count = 50,
runtime?: RuntimeEnv,
): Promise<TlonHistoryEntry[]> {
try {
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
const data: unknown = await api.scry(scryPath);
if (!data) {
return [];
}
let posts: unknown[] = [];
if (Array.isArray(data)) {
posts = data;
} else {
const dataRecord = asRecord(data);
const postMap = asRecord(dataRecord?.posts);
if (postMap) {
posts = Object.values(postMap);
} else if (dataRecord) {
posts = Object.values(dataRecord);
}
}
const messages = posts
.map((item) => {
const itemRecord = asRecord(item);
const replyPost = asRecord(itemRecord?.["r-post"]);
const replyPostSet = asRecord(replyPost?.set);
const essay = asRecord(itemRecord?.essay) ?? asRecord(replyPostSet?.essay);
const seal = asRecord(itemRecord?.seal) ?? asRecord(replyPostSet?.seal);
return {
author: typeof essay?.author === "string" ? essay.author : "unknown",
content: extractMessageText(essay?.content || []),
timestamp: typeof essay?.sent === "number" ? essay.sent : Date.now(),
id: typeof seal?.id === "string" ? seal.id : undefined,
} as TlonHistoryEntry;
})
.filter((msg) => msg.content);
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
return messages;
} catch (error: unknown) {
runtime?.log?.(`[tlon] Error fetching channel history: ${formatErrorMessage(error)}`);
return [];
}
}
export async function getChannelHistory(
api: { scry: (path: string) => Promise<unknown> },
channelNest: string,
count = 50,
runtime?: RuntimeEnv,
): Promise<TlonHistoryEntry[]> {
const cache = messageCache.get(channelNest) ?? [];
if (cache.length >= count) {
runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`);
return cache.slice(0, count);
}
runtime?.log?.(`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`);
return await fetchChannelHistory(api, channelNest, count, runtime);
}
/**
* Fetch thread/reply history for a specific parent post.
* Used to get context when entering a thread conversation.
*/
export async function fetchThreadHistory(
api: { scry: (path: string) => Promise<unknown> },
channelNest: string,
parentId: string,
count = 50,
runtime?: RuntimeEnv,
): Promise<TlonHistoryEntry[]> {
try {
// Tlon API: fetch replies to a specific post
// Format: /channels/v4/{nest}/posts/post/{parentId}/replies/newest/{count}.json
// parentId needs @ud formatting (dots every 3 digits)
const formattedParentId = formatUd(parentId);
runtime?.log?.(
`[tlon] Thread history - parentId: ${parentId} -> formatted: ${formattedParentId}`,
);
const scryPath = `/channels/v4/${channelNest}/posts/post/id/${formattedParentId}/replies/newest/${count}.json`;
runtime?.log?.(`[tlon] Fetching thread history: ${scryPath}`);
const data: unknown = await api.scry(scryPath);
if (!data) {
runtime?.log?.(`[tlon] No thread history data returned`);
return [];
}
let replies: unknown[] = [];
if (Array.isArray(data)) {
replies = data;
} else {
const dataRecord = asRecord(data);
const replyValue = dataRecord?.replies;
if (Array.isArray(replyValue)) {
replies = replyValue;
} else if (typeof replyValue === "object" && replyValue) {
replies = Object.values(replyValue as Record<string, unknown>);
} else if (dataRecord) {
replies = Object.values(dataRecord);
}
}
const messages = replies
.map((item) => {
// Thread replies use 'memo' structure
const itemRecord = asRecord(item);
const replyRecord = asRecord(itemRecord?.["r-reply"]);
const replySet = asRecord(replyRecord?.set);
const memo = asRecord(itemRecord?.memo) ?? asRecord(replySet?.memo) ?? itemRecord;
const seal = asRecord(itemRecord?.seal) ?? asRecord(replySet?.seal);
return createHistoryEntryFromMemo({ memo, seal, fallbackId: itemRecord?.id });
})
.filter((msg) => msg.content);
runtime?.log?.(`[tlon] Extracted ${messages.length} thread replies from history`);
return messages;
} catch (error: unknown) {
runtime?.log?.(`[tlon] Error fetching thread history: ${formatErrorMessage(error)}`);
// Fall back to trying alternate path structure
try {
const altPath = `/channels/v4/${channelNest}/posts/post/id/${formatUd(parentId)}.json`;
runtime?.log?.(`[tlon] Trying alternate path: ${altPath}`);
const data = asRecord(await api.scry(altPath));
const dataSeal = asRecord(data?.seal);
const dataMeta = asRecord(dataSeal?.meta);
const repliesValue = data?.replies;
if (typeof dataMeta?.replyCount === "number" && dataMeta.replyCount > 0 && repliesValue) {
const replies = Array.isArray(repliesValue)
? repliesValue
: Object.values(repliesValue as Record<string, unknown>);
const messages = replies
.map((reply: unknown) => {
const replyRecord = asRecord(reply);
const memo = asRecord(replyRecord?.memo);
const seal = asRecord(replyRecord?.seal);
return createHistoryEntryFromMemo({ memo, seal });
})
.filter((msg: TlonHistoryEntry) => msg.content);
runtime?.log?.(`[tlon] Extracted ${messages.length} replies from post data`);
return messages;
}
} catch (altError: unknown) {
runtime?.log?.(`[tlon] Alternate path also failed: ${formatErrorMessage(altError)}`);
}
return [];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
// Tlon tests cover media plugin behavior.
import {
readRemoteMediaBuffer,
MAX_IMAGE_BYTES,
saveRemoteMedia,
} from "openclaw/plugin-sdk/media-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { downloadMedia, extractImageBlocks } from "./media.js";
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
MAX_IMAGE_BYTES: 6 * 1024 * 1024,
readRemoteMediaBuffer: vi.fn(),
saveRemoteMedia: vi.fn(),
}));
const readRemoteMediaBufferMock = vi.mocked(readRemoteMediaBuffer);
const saveRemoteMediaMock = vi.mocked(saveRemoteMedia);
describe("tlon monitor media", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("caps extracted images at eight per message", () => {
const content = Array.from({ length: 10 }, (_, index) => ({
block: { image: { src: `https://example.com/${index}.png`, alt: `image-${index}` } },
}));
const images = extractImageBlocks(content);
expect(images).toHaveLength(8);
expect(images.map((image) => image.url)).toEqual(
Array.from({ length: 8 }, (_, index) => `https://example.com/${index}.png`),
);
});
it("stores fetched media through the shared inbound media store with the image cap", async () => {
saveRemoteMediaMock.mockResolvedValue({
id: "photo---uuid.png",
path: "/tmp/openclaw/media/inbound/photo---uuid.png",
size: "image-data".length,
contentType: "image/png",
});
const result = await downloadMedia("https://example.com/photo.png");
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
expect(saveRemoteMediaMock).toHaveBeenCalledWith({
url: "https://example.com/photo.png",
maxBytes: MAX_IMAGE_BYTES,
readIdleTimeoutMs: 30_000,
ssrfPolicy: undefined,
requestInit: { method: "GET" },
});
expect(result).toEqual({
localPath: "/tmp/openclaw/media/inbound/photo---uuid.png",
contentType: "image/png",
originalUrl: "https://example.com/photo.png",
});
});
it("returns null when the fetch exceeds the image cap", async () => {
saveRemoteMediaMock.mockRejectedValue(
new Error(
`Failed to fetch media from https://example.com/photo.png: payload exceeds maxBytes ${MAX_IMAGE_BYTES}`,
),
);
const result = await downloadMedia("https://example.com/photo.png");
expect(result).toBeNull();
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,156 @@
// Tlon plugin module implements media behavior.
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import * as path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import {
readRemoteMediaBuffer,
MAX_IMAGE_BYTES,
saveRemoteMedia,
} from "openclaw/plugin-sdk/media-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
const MAX_IMAGES_PER_MESSAGE = 8;
const TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
interface ExtractedImage {
url: string;
alt?: string;
}
interface DownloadedMedia {
localPath: string;
contentType: string;
originalUrl: string;
}
/**
* Extract image blocks from Tlon message content.
* Returns array of image URLs found in the message.
*/
export function extractImageBlocks(content: unknown): ExtractedImage[] {
if (!content || !Array.isArray(content)) {
return [];
}
const images: ExtractedImage[] = [];
for (const verse of content) {
if (verse?.block?.image?.src) {
images.push({
url: verse.block.image.src,
alt: verse.block.image.alt,
});
if (images.length >= MAX_IMAGES_PER_MESSAGE) {
break;
}
}
}
return images;
}
/**
* Download a media file from URL to local storage.
* Returns the local path where the file was saved.
*/
export async function downloadMedia(
url: string,
mediaDir?: string,
): Promise<DownloadedMedia | null> {
try {
// Validate URL is http/https before fetching
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
console.warn(`[tlon-media] Rejected non-http(s) URL: ${url}`);
return null;
}
const fetchOptions = {
url,
maxBytes: MAX_IMAGE_BYTES,
readIdleTimeoutMs: TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS,
ssrfPolicy: undefined,
requestInit: { method: "GET" },
};
if (!mediaDir) {
const saved = await saveRemoteMedia(fetchOptions);
return {
localPath: saved.path,
contentType: saved.contentType ?? "application/octet-stream",
originalUrl: url,
};
}
const fetched = await readRemoteMediaBuffer(fetchOptions);
await mkdir(mediaDir, { recursive: true });
const ext =
getExtensionFromFileName(fetched.fileName) ||
getExtensionFromContentType(fetched.contentType ?? "") ||
getExtensionFromUrl(url) ||
"bin";
const localPath = path.join(mediaDir, `${randomUUID()}.${ext}`);
await writeFile(localPath, fetched.buffer);
return {
localPath,
contentType: fetched.contentType ?? "application/octet-stream",
originalUrl: url,
};
} catch (error: unknown) {
console.error(`[tlon-media] Error downloading ${url}: ${formatErrorMessage(error)}`);
return null;
}
}
function getExtensionFromFileName(fileName?: string): string | null {
if (!fileName) {
return null;
}
const ext = path.extname(fileName).replace(/^\./, "");
return ext || null;
}
function getExtensionFromContentType(contentType: string): string | null {
return extensionForMime(contentType)?.replace(/^\./u, "") ?? null;
}
function getExtensionFromUrl(url: string): string | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\.([a-z0-9]+)$/i);
return match ? normalizeLowercaseStringOrEmpty(match[1]) : null;
} catch {
return null;
}
}
/**
* Download all images from a message and return attachment metadata.
* Format matches OpenClaw's expected attachment structure.
*/
export async function downloadMessageImages(
content: unknown,
mediaDir?: string,
): Promise<Array<{ path: string; contentType: string }>> {
const images = extractImageBlocks(content);
if (images.length === 0) {
return [];
}
const attachments: Array<{ path: string; contentType: string }> = [];
for (const image of images) {
const downloaded = await downloadMedia(image.url, mediaDir);
if (downloaded) {
attachments.push({
path: downloaded.localPath,
contentType: downloaded.contentType,
});
}
}
return attachments;
}

View File

@@ -0,0 +1,59 @@
// Tlon tests cover processed messages plugin behavior.
import { describe, expect, it } from "vitest";
import {
createProcessedMessageTracker,
runWithProcessedMessageClaim,
} from "./processed-messages.js";
describe("createProcessedMessageTracker", () => {
it("dedupes and evicts oldest entries", () => {
const tracker = createProcessedMessageTracker(3);
expect(tracker.mark("a")).toBe(true);
expect(tracker.mark("a")).toBe(false);
expect(tracker.has("a")).toBe(true);
tracker.mark("b");
tracker.mark("c");
expect(tracker.size()).toBe(3);
tracker.mark("d");
expect(tracker.size()).toBe(3);
expect(tracker.has("a")).toBe(false);
expect(tracker.has("b")).toBe(true);
expect(tracker.has("c")).toBe(true);
expect(tracker.has("d")).toBe(true);
});
it("releases failed claims so retries can run again", async () => {
const tracker = createProcessedMessageTracker();
await expect(
runWithProcessedMessageClaim({
tracker,
id: "evt-1",
task: async () => {
throw new Error("boom");
},
}),
).rejects.toThrow("boom");
expect(tracker.has("evt-1")).toBe(false);
expect(tracker.claim("evt-1")).toEqual({ kind: "claimed" });
});
it("keeps successful claims deduped", async () => {
const tracker = createProcessedMessageTracker();
await expect(
runWithProcessedMessageClaim({
tracker,
id: "evt-2",
task: async () => undefined,
}),
).resolves.toEqual({ kind: "processed", value: undefined });
expect(tracker.has("evt-2")).toBe(true);
expect(tracker.claim("evt-2")).toEqual({ kind: "duplicate" });
});
});

View File

@@ -0,0 +1,90 @@
// Tlon plugin module implements processed messages behavior.
import { createDedupeCache } from "../../runtime-api.js";
type ProcessedMessageTracker = {
claim: (id?: string | null) => { kind: "claimed" } | { kind: "duplicate" };
commit: (id?: string | null) => void;
release: (id?: string | null) => void;
mark: (id?: string | null) => boolean;
has: (id?: string | null) => boolean;
size: () => number;
};
export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTracker {
const dedupe = createDedupeCache({ ttlMs: 0, maxSize: limit });
const inFlight = new Set<string>();
const claim = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) {
return { kind: "claimed" } as const;
}
if (inFlight.has(trimmed) || dedupe.peek(trimmed)) {
return { kind: "duplicate" } as const;
}
inFlight.add(trimmed);
return { kind: "claimed" } as const;
};
const commit = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) {
return;
}
inFlight.delete(trimmed);
dedupe.check(trimmed);
};
const release = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) {
return;
}
inFlight.delete(trimmed);
};
const mark = (id?: string | null) => {
const claimed = claim(id);
if (claimed.kind === "duplicate") {
return false;
}
commit(id);
return true;
};
const has = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) {
return false;
}
return dedupe.peek(trimmed);
};
return {
claim,
commit,
release,
mark,
has,
size: () => dedupe.size(),
};
}
export async function runWithProcessedMessageClaim<T>(params: {
tracker: ProcessedMessageTracker;
id?: string | null;
task: () => Promise<T>;
}): Promise<{ kind: "processed"; value: T } | { kind: "duplicate" }> {
const claim = params.tracker.claim(params.id);
if (claim.kind === "duplicate") {
return claim;
}
try {
const value = await params.task();
params.tracker.commit(params.id);
return { kind: "processed", value };
} catch (error) {
params.tracker.release(params.id);
throw error;
}
}

View File

@@ -0,0 +1,114 @@
// Tlon tests cover settings helpers plugin behavior.
import { describe, expect, it } from "vitest";
import type { TlonResolvedAccount } from "../types.js";
import {
applyTlonSettingsOverrides,
buildTlonSettingsMigrations,
shouldMigrateTlonSetting,
} from "./settings-helpers.js";
const baseAccount: TlonResolvedAccount = {
accountId: "default",
name: "Tlon",
enabled: true,
configured: true,
ship: "~sampel-palnet",
url: "https://example.com",
code: "lidlut-tabwed-pillex-ridrup",
dangerouslyAllowPrivateNetwork: false,
groupChannels: ["chat/~host/general"],
dmAllowlist: ["~zod"],
groupInviteAllowlist: ["~bus"],
autoDiscoverChannels: true,
showModelSignature: false,
autoAcceptDmInvites: true,
autoAcceptGroupInvites: true,
defaultAuthorizedShips: ["~nec"],
ownerShip: "~marzod",
};
function allowlistMigrationDecisions(currentSettings: Record<string, unknown>) {
const allowlistKeys = new Set(["dmAllowlist", "groupInviteAllowlist", "defaultAuthorizedShips"]);
return Object.fromEntries(
buildTlonSettingsMigrations(baseAccount, currentSettings)
.filter((migration) => allowlistKeys.has(migration.key))
.map((migration) => [
migration.key,
shouldMigrateTlonSetting(migration.fileValue, migration.settingsValue),
]),
);
}
describe("shouldMigrateTlonSetting", () => {
it("does not rehydrate explicit empty-array revocations during startup migration", () => {
const decisions = allowlistMigrationDecisions({
dmAllowlist: [],
groupInviteAllowlist: [],
defaultAuthorizedShips: [],
});
expect(decisions).toEqual({
dmAllowlist: false,
groupInviteAllowlist: false,
defaultAuthorizedShips: false,
});
});
it("still seeds file-config allowlists on first run when settings are missing", () => {
const decisions = allowlistMigrationDecisions({});
expect(decisions).toEqual({
dmAllowlist: true,
groupInviteAllowlist: true,
defaultAuthorizedShips: true,
});
});
});
describe("applyTlonSettingsOverrides", () => {
it("treats explicit empty settings allowlists as authoritative deny-all", () => {
const result = applyTlonSettingsOverrides({
account: baseAccount,
currentSettings: {
dmAllowlist: [],
groupInviteAllowlist: [],
},
});
expect(result.effectiveDmAllowlist).toStrictEqual([]);
expect(result.effectiveGroupInviteAllowlist).toStrictEqual([]);
});
it("falls back to file config when settings fields are removed", () => {
const result = applyTlonSettingsOverrides({
account: baseAccount,
currentSettings: {},
});
expect(result.effectiveDmAllowlist).toEqual(baseAccount.dmAllowlist);
expect(result.effectiveGroupInviteAllowlist).toEqual(baseAccount.groupInviteAllowlist);
expect(result.effectiveAutoDiscoverChannels).toBe(baseAccount.autoDiscoverChannels);
expect(result.effectiveOwnerShip).toBe(baseAccount.ownerShip);
});
it("keeps other explicit settings overrides authoritative", () => {
const result = applyTlonSettingsOverrides({
account: baseAccount,
currentSettings: {
autoDiscoverChannels: false,
autoAcceptDmInvites: false,
autoAcceptGroupInvites: false,
showModelSig: true,
ownerShip: "~nec",
pendingApprovals: [],
},
});
expect(result.effectiveAutoDiscoverChannels).toBe(false);
expect(result.effectiveAutoAcceptDmInvites).toBe(false);
expect(result.effectiveAutoAcceptGroupInvites).toBe(false);
expect(result.effectiveShowModelSig).toBe(true);
expect(result.effectiveOwnerShip).toBe("~nec");
expect(result.pendingApprovals).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,151 @@
// Tlon helper module supports settings helpers behavior.
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { PendingApproval, TlonSettingsStore } from "../settings.js";
import { normalizeShip } from "../targets.js";
import type { TlonResolvedAccount } from "../types.js";
type TlonMonitorSettingsState = {
effectiveDmAllowlist: string[];
effectiveShowModelSig: boolean;
effectiveAutoAcceptDmInvites: boolean;
effectiveAutoAcceptGroupInvites: boolean;
effectiveGroupInviteAllowlist: string[];
effectiveAutoDiscoverChannels: boolean;
effectiveOwnerShip: string | null;
pendingApprovals: PendingApproval[];
currentSettings: TlonSettingsStore;
};
export function buildTlonSettingsMigrations(
account: TlonResolvedAccount,
currentSettings: TlonSettingsStore,
): Array<{ key: string; fileValue: unknown; settingsValue: unknown }> {
return [
{
key: "dmAllowlist",
fileValue: account.dmAllowlist,
settingsValue: currentSettings.dmAllowlist,
},
{
key: "groupInviteAllowlist",
fileValue: account.groupInviteAllowlist,
settingsValue: currentSettings.groupInviteAllowlist,
},
{
key: "groupChannels",
fileValue: account.groupChannels,
settingsValue: currentSettings.groupChannels,
},
{
key: "defaultAuthorizedShips",
fileValue: account.defaultAuthorizedShips,
settingsValue: currentSettings.defaultAuthorizedShips,
},
{
key: "autoDiscoverChannels",
fileValue: account.autoDiscoverChannels,
settingsValue: currentSettings.autoDiscoverChannels,
},
{
key: "autoAcceptDmInvites",
fileValue: account.autoAcceptDmInvites,
settingsValue: currentSettings.autoAcceptDmInvites,
},
{
key: "autoAcceptGroupInvites",
fileValue: account.autoAcceptGroupInvites,
settingsValue: currentSettings.autoAcceptGroupInvites,
},
{
key: "showModelSig",
fileValue: account.showModelSignature,
settingsValue: currentSettings.showModelSig,
},
];
}
export function shouldMigrateTlonSetting(fileValue: unknown, settingsValue: unknown): boolean {
const hasFileValue = Array.isArray(fileValue) ? fileValue.length > 0 : fileValue != null;
const hasSettingsValue = settingsValue != null;
return hasFileValue && !hasSettingsValue;
}
export function applyTlonSettingsOverrides(params: {
account: TlonResolvedAccount;
currentSettings: TlonSettingsStore;
log?: (message: string) => void;
}): TlonMonitorSettingsState {
let effectiveDmAllowlist = params.account.dmAllowlist;
let effectiveShowModelSig = params.account.showModelSignature ?? false;
let effectiveAutoAcceptDmInvites = params.account.autoAcceptDmInvites ?? false;
let effectiveAutoAcceptGroupInvites = params.account.autoAcceptGroupInvites ?? false;
let effectiveGroupInviteAllowlist = params.account.groupInviteAllowlist;
let effectiveAutoDiscoverChannels = params.account.autoDiscoverChannels ?? false;
let effectiveOwnerShip = params.account.ownerShip
? normalizeShip(params.account.ownerShip)
: null;
let pendingApprovals: PendingApproval[] = [];
if (params.currentSettings.defaultAuthorizedShips?.length) {
params.log?.(
`[tlon] Using defaultAuthorizedShips from settings store: ${params.currentSettings.defaultAuthorizedShips.join(", ")}`,
);
}
if (params.currentSettings.autoDiscoverChannels !== undefined) {
effectiveAutoDiscoverChannels = params.currentSettings.autoDiscoverChannels;
params.log?.(
`[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`,
);
}
if (params.currentSettings.dmAllowlist !== undefined) {
effectiveDmAllowlist = params.currentSettings.dmAllowlist;
params.log?.(
`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`,
);
}
if (params.currentSettings.showModelSig !== undefined) {
effectiveShowModelSig = params.currentSettings.showModelSig;
}
if (params.currentSettings.autoAcceptDmInvites !== undefined) {
effectiveAutoAcceptDmInvites = params.currentSettings.autoAcceptDmInvites;
params.log?.(
`[tlon] Using autoAcceptDmInvites from settings store: ${effectiveAutoAcceptDmInvites}`,
);
}
if (params.currentSettings.autoAcceptGroupInvites !== undefined) {
effectiveAutoAcceptGroupInvites = params.currentSettings.autoAcceptGroupInvites;
params.log?.(
`[tlon] Using autoAcceptGroupInvites from settings store: ${effectiveAutoAcceptGroupInvites}`,
);
}
if (params.currentSettings.groupInviteAllowlist !== undefined) {
effectiveGroupInviteAllowlist = params.currentSettings.groupInviteAllowlist;
params.log?.(
`[tlon] Using groupInviteAllowlist from settings store: ${effectiveGroupInviteAllowlist.join(", ")}`,
);
}
if (params.currentSettings.ownerShip) {
effectiveOwnerShip = normalizeShip(params.currentSettings.ownerShip);
params.log?.(`[tlon] Using ownerShip from settings store: ${effectiveOwnerShip}`);
}
if (params.currentSettings.pendingApprovals?.length) {
pendingApprovals = params.currentSettings.pendingApprovals;
params.log?.(`[tlon] Loaded ${pendingApprovals.length} pending approval(s) from settings`);
}
return {
effectiveDmAllowlist,
effectiveShowModelSig,
effectiveAutoAcceptDmInvites,
effectiveAutoAcceptGroupInvites,
effectiveGroupInviteAllowlist,
effectiveAutoDiscoverChannels,
effectiveOwnerShip,
pendingApprovals,
currentSettings: params.currentSettings,
};
}
export function mergeUniqueStrings(base: string[], next?: string[]): string[] {
return uniqueStrings([...base, ...(next ?? [])]);
}

View File

@@ -0,0 +1,403 @@
// Tlon helper module supports utils behavior.
import {
resolveStableChannelMessageIngress,
type StableChannelIngressIdentityParams,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import { formatErrorMessage as sharedFormatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeShip } from "../targets.js";
export interface ParsedCite {
type: "chan" | "group" | "desk" | "bait";
nest?: string;
author?: string;
postId?: string;
group?: string;
flag?: string;
where?: string;
}
export function extractCites(content: unknown): ParsedCite[] {
if (!content || !Array.isArray(content)) {
return [];
}
const cites: ParsedCite[] = [];
for (const verse of content) {
if (verse?.block?.cite && typeof verse.block.cite === "object") {
const cite = verse.block.cite;
if (cite.chan && typeof cite.chan === "object") {
const { nest, where } = cite.chan;
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
cites.push({
type: "chan",
nest,
where,
author: whereMatch?.[1],
postId: whereMatch?.[2],
});
} else if (cite.group && typeof cite.group === "string") {
cites.push({ type: "group", group: cite.group });
} else if (cite.desk && typeof cite.desk === "object") {
cites.push({ type: "desk", flag: cite.desk.flag, where: cite.desk.where });
} else if (cite.bait && typeof cite.bait === "object") {
cites.push({
type: "bait",
group: cite.bait.group,
nest: cite.bait.graph,
where: cite.bait.where,
});
}
}
}
return cites;
}
export function formatModelName(modelString?: string | null): string {
if (!modelString) {
return "AI";
}
const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString;
const modelMappings: Record<string, string> = {
"claude-opus-4-5": "Claude Opus 4.5",
"claude-sonnet-4-5": "Claude Sonnet 4.5",
"claude-sonnet-3-5": "Claude Sonnet 3.5",
"gpt-4o": "GPT-4o",
"gpt-4-turbo": "GPT-4 Turbo",
"gpt-4": "GPT-4",
"gemini-2.0-flash": "Gemini 2.0 Flash",
"gemini-pro": "Gemini Pro",
};
if (modelMappings[modelName]) {
return modelMappings[modelName];
}
return modelName
.replace(/-/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
export function isBotMentioned(
messageText: string,
botShipName: string,
nickname?: string,
): boolean {
if (!messageText || !botShipName) {
return false;
}
if (/@all\b/i.test(messageText)) {
return true;
}
const normalizedBotShip = normalizeShip(botShipName);
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
if (mentionPattern.test(messageText)) {
return true;
}
if (nickname) {
const escapedNickname = nickname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const nicknamePattern = new RegExp(`(^|\\s)${escapedNickname}(?=\\s|$|[,!?.])`, "i");
if (nicknamePattern.test(messageText)) {
return true;
}
}
return false;
}
export function stripBotMention(messageText: string, botShipName: string): string {
if (!messageText || !botShipName) {
return messageText;
}
return messageText.replace(normalizeShip(botShipName), "").trim();
}
const tlonIngressIdentity = {
key: "sender-ship",
normalize: normalizeShip,
sensitivity: "pii",
isWildcardEntry: () => false,
entryIdPrefix: "tlon-entry",
} satisfies StableChannelIngressIdentityParams;
export async function isDmAllowedWithIngress(
senderShip: string,
allowlist: string[] | undefined,
): Promise<boolean> {
const access = await resolveStableChannelMessageIngress({
channelId: "tlon",
accountId: "default",
identity: tlonIngressIdentity,
subject: { stableId: senderShip },
conversation: {
kind: "direct",
id: "direct",
},
dmPolicy: "allowlist",
allowFrom: allowlist ?? [],
});
return access.senderAccess.allowed;
}
export async function resolveTlonCommandAuthorizationWithIngress(params: {
senderShip: string;
ownerShip: string | null | undefined;
useAccessGroups: boolean;
}) {
const normalizedOwner = params.ownerShip ? normalizeShip(params.ownerShip) : null;
return await resolveStableChannelMessageIngress({
channelId: "tlon",
accountId: "default",
identity: tlonIngressIdentity,
useAccessGroups: params.useAccessGroups,
subject: { stableId: params.senderShip },
conversation: {
kind: "direct",
id: "command",
},
event: {
authMode: "none",
mayPair: false,
},
dmPolicy: "allowlist",
groupPolicy: "open",
allowFrom: normalizedOwner ? [normalizedOwner] : [],
command: {},
});
}
export function isGroupInviteAllowed(
inviterShip: string,
allowlist: string[] | undefined,
): boolean {
if (!allowlist || allowlist.length === 0) {
return false;
}
const normalizedInviter = normalizeShip(inviterShip);
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedInviter);
}
export async function resolveAuthorizedMessageText(params: {
rawText: string;
content: unknown;
authorizedForCites: boolean;
resolveAllCites: (content: unknown) => Promise<string>;
}): Promise<string> {
const { rawText, content, authorizedForCites, resolveAllCites } = params;
if (!authorizedForCites) {
return rawText;
}
const citedContent = await resolveAllCites(content);
return citedContent + rawText;
}
export const asRecord = asNullableObjectRecord;
export const formatErrorMessage = sharedFormatErrorMessage;
export const readString = readStringField;
function asNullableObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readStringField(
record: Record<string, unknown> | null | undefined,
field: string,
): string | undefined {
const value = record?.[field];
return typeof value === "string" ? value : undefined;
}
// Helper to recursively extract text from inline content
function renderInlineItem(
item: unknown,
options?: {
linkMode?: "content-or-href" | "href";
allowBreak?: boolean;
allowBlockquote?: boolean;
},
): string {
if (typeof item === "string") {
return item;
}
const record = asRecord(item);
if (!record) {
return "";
}
const ship = readString(record, "ship");
if (ship) {
return ship;
}
if ("sect" in record) {
const sect = record.sect;
if (typeof sect === "string") {
return `@${sect || "all"}`;
}
if (sect === null) {
return "@all";
}
}
if (options?.allowBreak && "break" in record) {
return "\n";
}
const inlineCode = readString(record, "inline-code");
if (inlineCode) {
return `\`${inlineCode}\``;
}
const code = readString(record, "code");
if (code) {
return `\`${code}\``;
}
const link = asRecord(record.link);
const linkHref = link ? readString(link, "href") : undefined;
if (link && linkHref) {
const linkContent = readString(link, "content");
return options?.linkMode === "href" ? linkHref : linkContent || linkHref;
}
if (Array.isArray(record.bold)) {
return `**${extractInlineText(record.bold)}**`;
}
if (Array.isArray(record.italics)) {
return `*${extractInlineText(record.italics)}*`;
}
if (Array.isArray(record.strike)) {
return `~~${extractInlineText(record.strike)}~~`;
}
if (options?.allowBlockquote && Array.isArray(record.blockquote)) {
return `> ${extractInlineText(record.blockquote)}`;
}
return "";
}
function extractInlineText(items: readonly unknown[]): string {
return items.map((item) => renderInlineItem(item)).join("");
}
export function extractMessageText(content: unknown): string {
if (!content || !Array.isArray(content)) {
return "";
}
return content
.map((verse) => {
const verseRecord = asRecord(verse);
if (!verseRecord) {
return "";
}
// Handle inline content (text, ships, links, etc.)
if (Array.isArray(verseRecord.inline)) {
return verseRecord.inline
.map((item) =>
renderInlineItem(item, {
linkMode: "href",
allowBreak: true,
allowBlockquote: true,
}),
)
.join("");
}
// Handle block content (images, code blocks, etc.)
const block = asRecord(verseRecord.block);
if (block) {
const image = asRecord(block.image);
// Image blocks
if (image) {
const imageSrc = readString(image, "src");
if (imageSrc) {
const altText = readString(image, "alt");
const alt = altText ? ` (${altText})` : "";
return `\n${imageSrc}${alt}\n`;
}
}
// Code blocks
const codeBlock = asRecord(block.code);
if (codeBlock) {
const lang = readString(codeBlock, "lang") ?? "";
const code = readString(codeBlock, "code") ?? "";
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
}
// Header blocks
const header = asRecord(block.header);
if (header) {
const headerContent = Array.isArray(header.content) ? header.content : [];
const text =
headerContent.map((item) => (typeof item === "string" ? item : "")).join("") || "";
return `\n## ${text}\n`;
}
// Cite/quote blocks - parse the reference structure
const cite = asRecord(block.cite);
if (cite) {
const chanCite = asRecord(cite.chan);
// ChanCite - reference to a channel message
if (chanCite) {
const nest = readString(chanCite, "nest");
const where = readString(chanCite, "where");
// where is typically /msg/~author/timestamp
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
if (whereMatch) {
const [, author, _postId] = whereMatch;
return `\n> [quoted: ${author} in ${nest}]\n`;
}
return `\n> [quoted from ${nest}]\n`;
}
// GroupCite - reference to a group
const group = readString(cite, "group");
if (group) {
return `\n> [ref: group ${group}]\n`;
}
// DeskCite - reference to an app/desk
const desk = asRecord(cite.desk);
if (desk) {
const flag = readString(desk, "flag");
if (flag) {
return `\n> [ref: ${flag}]\n`;
}
}
// BaitCite - reference with group+graph context
const bait = asRecord(cite.bait);
if (bait) {
const graph = readString(bait, "graph");
const groupName = readString(bait, "group");
if (graph && groupName) {
return `\n> [ref: ${graph} in ${groupName}]\n`;
}
}
return `\n> [quoted message]\n`;
}
}
return "";
})
.join("\n")
.trim();
}
export function isSummarizationRequest(messageText: string): boolean {
const patterns = [
/summarize\s+(this\s+)?(channel|chat|conversation)/i,
/what\s+did\s+i\s+miss/i,
/catch\s+me\s+up/i,
/channel\s+summary/i,
/tldr/i,
];
return patterns.some((pattern) => pattern.test(messageText));
}

View File

@@ -0,0 +1,10 @@
// Tlon plugin module implements runtime behavior.
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "tlon",
errorMessage: "Tlon runtime not initialized",
});
export { getTlonRuntime, setTlonRuntime };

View File

@@ -0,0 +1,654 @@
/**
* Security Tests for Tlon Plugin
*
* These tests ensure that security-critical behavior cannot regress:
* - DM allowlist enforcement
* - Channel authorization rules
* - Ship normalization consistency
* - Bot mention detection boundaries
*/
import { describe, expect, it, vi } from "vitest";
import {
extractCites,
resolveTlonCommandAuthorizationWithIngress,
isDmAllowedWithIngress,
isGroupInviteAllowed,
isBotMentioned,
extractMessageText,
resolveAuthorizedMessageText,
} from "./monitor/utils.js";
import { normalizeShip } from "./targets.js";
const allowlistShipMatchingCases = [
{ label: "DM allowlist", isAllowed: isDmAllowedWithIngress },
{ label: "group invite allowlist", isAllowed: isGroupInviteAllowed },
] satisfies Array<{
label: string;
isAllowed: (ship: string, allowlist: string[] | undefined) => boolean | Promise<boolean>;
}>;
async function expectAllowed(
isAllowed: (ship: string, allowlist: string[] | undefined) => boolean | Promise<boolean>,
ship: string,
allowlist: string[] | undefined,
expected: boolean,
) {
await expect(Promise.resolve(isAllowed(ship, allowlist))).resolves.toBe(expected);
}
async function expectDmAllowed(ship: string, allowlist: string[] | undefined, expected: boolean) {
await expect(isDmAllowedWithIngress(ship, allowlist)).resolves.toBe(expected);
}
describe("Security: allowlist ship matching", () => {
it.each(allowlistShipMatchingCases)(
"$label normalizes ship names with and without ~ prefix",
async ({ isAllowed }) => {
const allowlist = ["~zod"];
await expectAllowed(isAllowed, "zod", allowlist, true);
await expectAllowed(isAllowed, "~zod", allowlist, true);
const allowlistWithoutTilde = ["zod"];
await expectAllowed(isAllowed, "~zod", allowlistWithoutTilde, true);
await expectAllowed(isAllowed, "zod", allowlistWithoutTilde, true);
},
);
it.each(allowlistShipMatchingCases)(
"$label rejects partial ship matches",
async ({ isAllowed }) => {
const allowlist = ["~zod"];
await expectAllowed(isAllowed, "~zod-extra", allowlist, false);
await expectAllowed(isAllowed, "~extra-zod", allowlist, false);
},
);
});
describe("Security: DM Allowlist", () => {
describe("DM ingress allowlist", () => {
it("rejects DMs when allowlist is empty", async () => {
await expectDmAllowed("~zod", [], false);
await expectDmAllowed("~sampel-palnet", [], false);
});
it("rejects DMs when allowlist is undefined", async () => {
await expectDmAllowed("~zod", undefined, false);
});
it("allows DMs from ships on the allowlist", async () => {
const allowlist = ["~zod", "~bus"];
await expectDmAllowed("~zod", allowlist, true);
await expectDmAllowed("~bus", allowlist, true);
});
it("rejects DMs from ships NOT on the allowlist", async () => {
const allowlist = ["~zod", "~bus"];
await expectDmAllowed("~nec", allowlist, false);
await expectDmAllowed("~sampel-palnet", allowlist, false);
await expectDmAllowed("~random-ship", allowlist, false);
});
it("handles galaxy, star, planet, and moon names", async () => {
const allowlist = [
"~zod", // galaxy
"~marzod", // star
"~sampel-palnet", // planet
"~dozzod-dozzod-dozzod-dozzod", // moon
];
await expectDmAllowed("~zod", allowlist, true);
await expectDmAllowed("~marzod", allowlist, true);
await expectDmAllowed("~sampel-palnet", allowlist, true);
await expectDmAllowed("~dozzod-dozzod-dozzod-dozzod", allowlist, true);
// Similar but different ships should be rejected
await expectDmAllowed("~nec", allowlist, false);
await expectDmAllowed("~wanzod", allowlist, false);
await expectDmAllowed("~sampel-palned", allowlist, false);
});
// NOTE: Ship names in Urbit are always lowercase by convention.
// This test documents current behavior - strict equality after normalization.
// If case-insensitivity is desired, normalizeShip should lowercase.
it("uses strict equality after normalization (case-sensitive)", async () => {
const allowlist = ["~zod"];
await expectDmAllowed("~zod", allowlist, true);
// Different case would NOT match with current implementation
await expectDmAllowed("~Zod", ["~Zod"], true); // exact match works
});
it("handles whitespace in ship names (normalized)", async () => {
// Ships with leading/trailing whitespace are normalized by normalizeShip
const allowlist = [" ~zod ", "~bus"];
await expectDmAllowed("~zod", allowlist, true);
await expectDmAllowed(" ~zod ", allowlist, true);
});
it("uses the ingress command gate for owner-only command authorization", async () => {
const authorized = await resolveTlonCommandAuthorizationWithIngress({
senderShip: "~zod",
ownerShip: "zod",
useAccessGroups: true,
});
expect(authorized.commandAccess.requested).toBe(true);
expect(authorized.commandAccess.authorized).toBe(true);
expect(authorized.commandAccess.shouldBlockControlCommand).toBe(false);
expect(authorized.commandAccess.reasonCode).toBe("command_authorized");
const unauthorized = await resolveTlonCommandAuthorizationWithIngress({
senderShip: "~nec",
ownerShip: "~zod",
useAccessGroups: true,
});
expect(unauthorized.commandAccess.requested).toBe(true);
expect(unauthorized.commandAccess.authorized).toBe(false);
expect(unauthorized.commandAccess.shouldBlockControlCommand).toBe(false);
});
});
});
describe("Security: Group Invite Allowlist", () => {
describe("isGroupInviteAllowed", () => {
it("rejects invites when allowlist is empty (fail-safe)", () => {
// CRITICAL: Empty allowlist must DENY, not accept-all
expect(isGroupInviteAllowed("~zod", [])).toBe(false);
expect(isGroupInviteAllowed("~sampel-palnet", [])).toBe(false);
expect(isGroupInviteAllowed("~malicious-actor", [])).toBe(false);
});
it("rejects invites when allowlist is undefined (fail-safe)", () => {
// CRITICAL: Undefined allowlist must DENY, not accept-all
expect(isGroupInviteAllowed("~zod", undefined)).toBe(false);
expect(isGroupInviteAllowed("~sampel-palnet", undefined)).toBe(false);
});
it("accepts invites from ships on the allowlist", () => {
const allowlist = ["~nocsyx-lassul", "~malmur-halmex"];
expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true);
expect(isGroupInviteAllowed("~malmur-halmex", allowlist)).toBe(true);
});
it("rejects invites from ships NOT on the allowlist", () => {
const allowlist = ["~nocsyx-lassul", "~malmur-halmex"];
expect(isGroupInviteAllowed("~random-attacker", allowlist)).toBe(false);
expect(isGroupInviteAllowed("~malicious-ship", allowlist)).toBe(false);
expect(isGroupInviteAllowed("~zod", allowlist)).toBe(false);
});
it("handles whitespace in allowlist entries", () => {
const allowlist = [" ~nocsyx-lassul ", "~malmur-halmex"];
expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true);
});
});
});
describe("Security: Bot Mention Detection", () => {
describe("isBotMentioned", () => {
const botShip = "~sampel-palnet";
const nickname = "nimbus";
it("detects direct ship mention", () => {
expect(isBotMentioned("hey ~sampel-palnet", botShip)).toBe(true);
expect(isBotMentioned("~sampel-palnet can you help?", botShip)).toBe(true);
expect(isBotMentioned("hello ~sampel-palnet how are you", botShip)).toBe(true);
});
it("detects @all mention", () => {
expect(isBotMentioned("@all please respond", botShip)).toBe(true);
expect(isBotMentioned("hey @all", botShip)).toBe(true);
expect(isBotMentioned("@ALL uppercase", botShip)).toBe(true);
});
it("detects nickname mention", () => {
expect(isBotMentioned("hey nimbus", botShip, nickname)).toBe(true);
expect(isBotMentioned("nimbus help me", botShip, nickname)).toBe(true);
expect(isBotMentioned("hello NIMBUS", botShip, nickname)).toBe(true);
});
it("does NOT trigger on random messages", () => {
expect(isBotMentioned("hello world", botShip)).toBe(false);
expect(isBotMentioned("this is a normal message", botShip)).toBe(false);
expect(isBotMentioned("hey everyone", botShip)).toBe(false);
});
it("does NOT trigger on partial ship matches", () => {
expect(isBotMentioned("~sampel-palnet-extra", botShip)).toBe(false);
expect(isBotMentioned("my~sampel-palnetfriend", botShip)).toBe(false);
});
it("does NOT trigger on substring nickname matches", () => {
// "nimbus" should not match "nimbusy" or "animbust"
expect(isBotMentioned("nimbusy", botShip, nickname)).toBe(false);
expect(isBotMentioned("prenimbus", botShip, nickname)).toBe(false);
});
it("handles empty/null inputs safely", () => {
expect(isBotMentioned("", botShip)).toBe(false);
expect(isBotMentioned("test", "")).toBe(false);
expect(isBotMentioned(null as unknown as string, botShip)).toBe(false);
});
it("requires word boundary for nickname", () => {
expect(isBotMentioned("nimbus, hello", botShip, nickname)).toBe(true);
expect(isBotMentioned("hello nimbus!", botShip, nickname)).toBe(true);
expect(isBotMentioned("nimbus?", botShip, nickname)).toBe(true);
});
});
});
describe("Security: Ship Normalization", () => {
describe("normalizeShip", () => {
it("adds ~ prefix if missing", () => {
expect(normalizeShip("zod")).toBe("~zod");
expect(normalizeShip("sampel-palnet")).toBe("~sampel-palnet");
});
it("preserves ~ prefix if present", () => {
expect(normalizeShip("~zod")).toBe("~zod");
expect(normalizeShip("~sampel-palnet")).toBe("~sampel-palnet");
});
it("trims whitespace", () => {
expect(normalizeShip(" ~zod ")).toBe("~zod");
expect(normalizeShip(" zod ")).toBe("~zod");
});
it("handles empty string", () => {
expect(normalizeShip("")).toBe("");
expect(normalizeShip(" ")).toBe("");
});
});
});
describe("Security: Message Text Extraction", () => {
describe("extractMessageText", () => {
it("extracts plain text", () => {
const content = [{ inline: ["hello world"] }];
expect(extractMessageText(content)).toBe("hello world");
});
it("extracts @all mentions from sect null", () => {
const content = [{ inline: [{ sect: null }] }];
expect(extractMessageText(content)).toContain("@all");
});
it("extracts ship mentions", () => {
const content = [{ inline: [{ ship: "~zod" }] }];
expect(extractMessageText(content)).toContain("~zod");
});
it("handles malformed input safely", () => {
expect(extractMessageText(null)).toBe("");
expect(extractMessageText(undefined)).toBe("");
expect(extractMessageText([])).toBe("");
expect(extractMessageText([{}])).toBe("");
expect(extractMessageText("not an array")).toBe("");
});
it("does not execute injected code in inline content", () => {
// Ensure malicious content doesn't get executed
const maliciousContent = [{ inline: ["<script>alert('xss')</script>"] }];
const result = extractMessageText(maliciousContent);
expect(result).toBe("<script>alert('xss')</script>");
// Just a string, not executed
});
});
});
describe("Security: Channel Authorization Logic", () => {
/**
* These tests document the expected behavior of channel authorization.
* The actual resolveChannelAuthorization function is internal to monitor/index.ts
* but these tests verify the building blocks and expected invariants.
*/
it("default mode should be restricted (not open)", () => {
// This is a critical security invariant: if no mode is specified,
// channels should default to RESTRICTED, not open.
// If this test fails, someone may have changed the default unsafely.
// The logic in resolveChannelAuthorization is:
// const mode = rule?.mode ?? "restricted";
// We verify this by checking undefined rule gives restricted
type ModeRule = { mode?: "restricted" | "open" };
const rule = undefined as ModeRule | undefined;
const mode = rule?.mode ?? "restricted";
expect(mode).toBe("restricted");
});
it("empty allowedShips with restricted mode should block all", () => {
const allowedShips: string[] = [];
const sender = "~random-ship";
const isAllowed = allowedShips.some((ship) => normalizeShip(ship) === normalizeShip(sender));
expect(isAllowed).toBe(false);
});
it("open mode should not check allowedShips", () => {
// In open mode, any ship can send regardless of allowedShips
const mode: "open" | "restricted" = "open";
// The check in monitor/index.ts is:
// if (mode === "restricted") { /* check ships */ }
// So open mode skips the ship check entirely
expect(mode).not.toBe("restricted");
});
it("settings should override file config for channel rules", () => {
// Documented behavior: settingsRules[nest] ?? fileRules[nest]
// This means settings take precedence
type ChannelRule = { mode: "restricted" | "open" };
const fileRules: Record<string, ChannelRule> = { "chat/~zod/test": { mode: "restricted" } };
const settingsRules: Record<string, ChannelRule> = { "chat/~zod/test": { mode: "open" } };
const nest = "chat/~zod/test";
const effectiveRule = settingsRules[nest] ?? fileRules[nest];
expect(effectiveRule?.mode).toBe("open"); // settings wins
});
});
describe("Security: Authorization Edge Cases", () => {
it("empty strings are not valid ships", async () => {
await expectDmAllowed("", ["~zod"], false);
await expectDmAllowed("~zod", [""], false);
});
it("handles very long ship-like strings", async () => {
const longName = "~" + "a".repeat(1000);
await expectDmAllowed(longName, ["~zod"], false);
});
it("handles special characters that could break regex", async () => {
// These should not cause regex injection
const maliciousShip = "~zod.*";
await expectDmAllowed("~zodabc", [maliciousShip], false);
const allowlist = ["~zod"];
await expectDmAllowed("~zod.*", allowlist, false);
});
it("protects against prototype pollution-style keys", async () => {
const suspiciousShip = "__proto__";
await expectDmAllowed(suspiciousShip, ["~zod"], false);
await expectDmAllowed("~zod", [suspiciousShip], false);
});
});
describe("Security: Cite Resolution Authorization Ordering", () => {
async function resolveAllCitesForPoC(
content: unknown,
api: { scry: (path: string) => Promise<unknown> },
): Promise<string> {
const cites = extractCites(content);
if (cites.length === 0) {
return "";
}
const resolved: string[] = [];
for (const cite of cites) {
if (cite.type !== "chan" || !cite.nest || !cite.postId) {
continue;
}
const data = (await api.scry(`/channels/v4/${cite.nest}/posts/post/${cite.postId}.json`)) as {
essay?: { content?: unknown };
};
const text = data?.essay?.content ? extractMessageText(data.essay.content) : "";
if (text) {
resolved.push(`> ${cite.author || "unknown"} wrote: ${text}`);
}
}
return resolved.length > 0 ? resolved.join("\n") + "\n\n" : "";
}
function buildCitedMessage(
secretNest = "chat/~private-ship/ops",
postId = "1701411845077995094",
) {
return [
{
block: {
cite: {
chan: {
nest: secretNest,
where: `/msg/~victim-ship/${postId}`,
},
},
},
},
{ inline: ["~bot-ship please summarize this"] },
];
}
it("does not resolve channel cites for unauthorized senders", async () => {
const content = buildCitedMessage();
const rawText = extractMessageText(content);
const api = {
scry: vi.fn(async () => ({
essay: { content: [{ inline: ["TOP-SECRET"] }] },
})),
};
const messageText = await resolveAuthorizedMessageText({
rawText,
content,
authorizedForCites: false,
resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api),
});
expect(messageText).toBe(rawText);
expect(api.scry).not.toHaveBeenCalled();
});
it("resolves channel cites after sender authorization passes", async () => {
const secretNest = "chat/~private-ship/ops";
const postId = "170141184507799509469114119040828178432";
const content = buildCitedMessage(secretNest, postId);
const rawText = extractMessageText(content);
const api = {
scry: vi.fn(async (path: string) => {
expect(path).toBe(`/channels/v4/${secretNest}/posts/post/${postId}.json`);
return {
essay: { content: [{ inline: ["TOP-SECRET: migration key is rotate-me"] }] },
};
}),
};
const messageText = await resolveAuthorizedMessageText({
rawText,
content,
authorizedForCites: true,
resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api),
});
expect(api.scry).toHaveBeenCalledTimes(1);
expect(messageText).toContain("TOP-SECRET: migration key is rotate-me");
expect(messageText).toContain("> ~victim-ship wrote: TOP-SECRET: migration key is rotate-me");
});
it("does not resolve DM cites before a deny path", async () => {
const content = buildCitedMessage("chat/~secret-dm/ops", "1701411845077995095");
const rawText = extractMessageText(content);
const senderShip = "~attacker-ship";
const allowlist = ["~trusted-ship"];
const api = {
scry: vi.fn(async () => ({
essay: { content: [{ inline: ["DM-SECRET"] }] },
})),
};
const senderAllowed = allowlist
.map((ship) => normalizeShip(ship))
.includes(normalizeShip(senderShip));
expect(senderAllowed).toBe(false);
const messageText = await resolveAuthorizedMessageText({
rawText,
content,
authorizedForCites: senderAllowed,
resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api),
});
expect(messageText).toBe(rawText);
expect(api.scry).not.toHaveBeenCalled();
});
it("does not resolve DM cites before owner approval command handling", async () => {
const content = [
{
block: {
cite: {
chan: {
nest: "chat/~private-ship/admin",
where: "/msg/~victim-ship/1701411845077995096",
},
},
},
},
{ inline: ["/approve 1"] },
];
const rawText = extractMessageText(content);
const api = {
scry: vi.fn(async () => ({
essay: { content: [{ inline: ["ADMIN-SECRET"] }] },
})),
};
const messageText = await resolveAuthorizedMessageText({
rawText,
content,
authorizedForCites: false,
resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api),
});
expect(rawText).toContain("/approve 1");
expect(messageText).toBe(rawText);
expect(messageText).not.toContain("ADMIN-SECRET");
expect(api.scry).not.toHaveBeenCalled();
});
it("resolves DM cites for allowed senders after authorization passes", async () => {
const secretNest = "chat/~private-ship/dm";
const postId = "1701411845077995097";
const content = buildCitedMessage(secretNest, postId);
const rawText = extractMessageText(content);
const api = {
scry: vi.fn(async (path: string) => {
expect(path).toBe(`/channels/v4/${secretNest}/posts/post/${postId}.json`);
return {
essay: { content: [{ inline: ["ALLOWED-DM-SECRET"] }] },
};
}),
};
const messageText = await resolveAuthorizedMessageText({
rawText,
content,
authorizedForCites: true,
resolveAllCites: (nextContent) => resolveAllCitesForPoC(nextContent, api),
});
expect(api.scry).toHaveBeenCalledTimes(1);
expect(messageText).toContain("ALLOWED-DM-SECRET");
expect(messageText).toContain("> ~victim-ship wrote: ALLOWED-DM-SECRET");
});
});
describe("Security: Sender Role Identification", () => {
/**
* Tests for sender role identification (owner vs user).
* This prevents impersonation attacks where an approved user
* tries to claim owner privileges through prompt injection.
*
* SECURITY.md Section 9: Sender Role Identification
*/
// Helper to compute sender role (mirrors logic in monitor/index.ts)
function getSenderRole(senderShip: string, ownerShip: string | null): "owner" | "user" {
if (!ownerShip) {
return "user";
}
return normalizeShip(senderShip) === normalizeShip(ownerShip) ? "owner" : "user";
}
describe("owner detection", () => {
it("identifies owner when ownerShip matches sender", () => {
expect(getSenderRole("~nocsyx-lassul", "~nocsyx-lassul")).toBe("owner");
expect(getSenderRole("nocsyx-lassul", "~nocsyx-lassul")).toBe("owner");
expect(getSenderRole("~nocsyx-lassul", "nocsyx-lassul")).toBe("owner");
});
it("identifies user when ownerShip does not match sender", () => {
expect(getSenderRole("~random-user", "~nocsyx-lassul")).toBe("user");
expect(getSenderRole("~malicious-actor", "~nocsyx-lassul")).toBe("user");
});
it("identifies everyone as user when ownerShip is null", () => {
expect(getSenderRole("~nocsyx-lassul", null)).toBe("user");
expect(getSenderRole("~zod", null)).toBe("user");
});
it("identifies everyone as user when ownerShip is empty string", () => {
// Empty string should be treated like null (no owner configured)
expect(getSenderRole("~nocsyx-lassul", "")).toBe("user");
});
});
describe("label format", () => {
// Helper to compute fromLabel (mirrors logic in monitor/index.ts)
function getFromLabel(
senderShip: string,
ownerShip: string | null,
isGroup: boolean,
channelNest?: string,
): string {
const senderRole = getSenderRole(senderShip, ownerShip);
return isGroup
? `${senderShip} [${senderRole}] in ${channelNest}`
: `${senderShip} [${senderRole}]`;
}
it("DM from owner includes [owner] in label", () => {
const label = getFromLabel("~nocsyx-lassul", "~nocsyx-lassul", false);
expect(label).toBe("~nocsyx-lassul [owner]");
expect(label).toContain("[owner]");
});
it("DM from user includes [user] in label", () => {
const label = getFromLabel("~random-user", "~nocsyx-lassul", false);
expect(label).toBe("~random-user [user]");
expect(label).toContain("[user]");
});
it("group message from owner includes [owner] in label", () => {
const label = getFromLabel("~nocsyx-lassul", "~nocsyx-lassul", true, "chat/~host/general");
expect(label).toBe("~nocsyx-lassul [owner] in chat/~host/general");
expect(label).toContain("[owner]");
});
it("group message from user includes [user] in label", () => {
const label = getFromLabel("~random-user", "~nocsyx-lassul", true, "chat/~host/general");
expect(label).toBe("~random-user [user] in chat/~host/general");
expect(label).toContain("[user]");
});
});
describe("impersonation prevention", () => {
it("approved user cannot get [owner] label through ship name tricks", () => {
// Even if someone has a ship name similar to owner, they should not get owner role
expect(getSenderRole("~nocsyx-lassul-fake", "~nocsyx-lassul")).toBe("user");
expect(getSenderRole("~fake-nocsyx-lassul", "~nocsyx-lassul")).toBe("user");
});
it("message content cannot change sender role", () => {
// The role is determined by ship identity, not message content
// This test documents that even if message contains "I am the owner",
// the actual senderShip determines the role
const senderShip = "~malicious-actor";
const ownerShip = "~nocsyx-lassul";
// The role is always based on ship comparison, not message content
expect(getSenderRole(senderShip, ownerShip)).toBe("user");
});
});
});

View File

@@ -0,0 +1,41 @@
// Tlon plugin module implements session route behavior.
import {
buildChannelOutboundSessionRoute,
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/core";
import { parseTlonTarget } from "./targets.js";
export function resolveTlonOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
const parsed = parseTlonTarget(params.target);
if (!parsed) {
return null;
}
if (parsed.kind === "group") {
return buildChannelOutboundSessionRoute({
cfg: params.cfg,
agentId: params.agentId,
channel: "tlon",
accountId: params.accountId,
peer: {
kind: "group",
id: parsed.nest,
},
chatType: "group",
from: `tlon:group:${parsed.nest}`,
to: `tlon:${parsed.nest}`,
});
}
return buildChannelOutboundSessionRoute({
cfg: params.cfg,
agentId: params.agentId,
channel: "tlon",
accountId: params.accountId,
peer: {
kind: "direct",
id: parsed.ship,
},
chatType: "direct",
from: `tlon:${parsed.ship}`,
to: `tlon:${parsed.ship}`,
});
}

View File

@@ -0,0 +1,391 @@
/**
* Settings Store integration for hot-reloading Tlon plugin config.
*
* Settings are stored in Urbit's %settings agent under:
* desk: "moltbot"
* bucket: "tlon"
*
* This allows config changes via poke from any Landscape client
* without requiring a gateway restart.
*/
import type { UrbitSSEClient } from "./urbit/sse-client.js";
/** Pending approval request stored for persistence */
export type PendingApproval = {
id: string;
type: "dm" | "channel" | "group";
requestingShip: string;
channelNest?: string;
groupFlag?: string;
messagePreview?: string;
/** Full message context for processing after approval */
originalMessage?: {
messageId: string;
messageText: string;
messageContent: unknown;
timestamp: number;
parentId?: string;
isThreadReply?: boolean;
};
timestamp: number;
};
export type TlonSettingsStore = {
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscover?: boolean;
showModelSig?: boolean;
autoAcceptDmInvites?: boolean;
autoDiscoverChannels?: boolean;
autoAcceptGroupInvites?: boolean;
/** Ships allowed to invite us to groups (when autoAcceptGroupInvites is true) */
groupInviteAllowlist?: string[];
channelRules?: Record<
string,
{
mode?: "restricted" | "open";
allowedShips?: string[];
}
>;
defaultAuthorizedShips?: string[];
/** Ship that receives approval requests for DMs, channel mentions, and group invites */
ownerShip?: string;
/** Pending approval requests awaiting owner response */
pendingApprovals?: PendingApproval[];
};
type TlonSettingsState = {
current: TlonSettingsStore;
loaded: boolean;
};
const SETTINGS_DESK = "moltbot";
const SETTINGS_BUCKET = "tlon";
/**
* Parse channelRules - handles both JSON string and object formats.
* Settings-store doesn't support nested objects, so we store as JSON string.
*/
function parseChannelRules(
value: unknown,
): Record<string, { mode?: "restricted" | "open"; allowedShips?: string[] }> | undefined {
if (!value) {
return undefined;
}
// If it's a string, try to parse as JSON
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
if (isChannelRulesObject(parsed)) {
return parsed;
}
} catch {
return undefined;
}
}
// If it's already an object, use directly
if (isChannelRulesObject(value)) {
return value;
}
return undefined;
}
/**
* Parse settings from the raw Urbit settings-store response.
* The response shape is: { [bucket]: { [key]: value } }
*/
function parseSettingsResponse(raw: unknown): TlonSettingsStore {
if (!raw || typeof raw !== "object") {
return {};
}
const desk = raw as Record<string, unknown>;
const bucket = desk[SETTINGS_BUCKET];
if (!bucket || typeof bucket !== "object") {
return {};
}
const settings = bucket as Record<string, unknown>;
return {
groupChannels: Array.isArray(settings.groupChannels)
? settings.groupChannels.filter((x): x is string => typeof x === "string")
: undefined,
dmAllowlist: Array.isArray(settings.dmAllowlist)
? settings.dmAllowlist.filter((x): x is string => typeof x === "string")
: undefined,
autoDiscover: typeof settings.autoDiscover === "boolean" ? settings.autoDiscover : undefined,
showModelSig: typeof settings.showModelSig === "boolean" ? settings.showModelSig : undefined,
autoAcceptDmInvites:
typeof settings.autoAcceptDmInvites === "boolean" ? settings.autoAcceptDmInvites : undefined,
autoAcceptGroupInvites:
typeof settings.autoAcceptGroupInvites === "boolean"
? settings.autoAcceptGroupInvites
: undefined,
groupInviteAllowlist: Array.isArray(settings.groupInviteAllowlist)
? settings.groupInviteAllowlist.filter((x): x is string => typeof x === "string")
: undefined,
channelRules: parseChannelRules(settings.channelRules),
defaultAuthorizedShips: Array.isArray(settings.defaultAuthorizedShips)
? settings.defaultAuthorizedShips.filter((x): x is string => typeof x === "string")
: undefined,
ownerShip: typeof settings.ownerShip === "string" ? settings.ownerShip : undefined,
pendingApprovals: parsePendingApprovals(settings.pendingApprovals),
};
}
function isChannelRulesObject(
val: unknown,
): val is Record<string, { mode?: "restricted" | "open"; allowedShips?: string[] }> {
if (!val || typeof val !== "object" || Array.isArray(val)) {
return false;
}
for (const [, rule] of Object.entries(val)) {
if (!rule || typeof rule !== "object") {
return false;
}
}
return true;
}
/**
* Parse pendingApprovals - handles both JSON string and array formats.
* Settings-store stores complex objects as JSON strings.
*/
function parsePendingApprovals(value: unknown): PendingApproval[] | undefined {
if (!value) {
return undefined;
}
// If it's a string, try to parse as JSON
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return undefined;
}
}
// Validate it's an array
if (!Array.isArray(parsed)) {
return undefined;
}
// Filter to valid PendingApproval objects
return parsed.filter((item): item is PendingApproval => {
if (!item || typeof item !== "object") {
return false;
}
const obj = item as Record<string, unknown>;
return (
typeof obj.id === "string" &&
(obj.type === "dm" || obj.type === "channel" || obj.type === "group") &&
typeof obj.requestingShip === "string" &&
typeof obj.timestamp === "number"
);
});
}
/**
* Parse a single settings entry update event.
*/
function parseSettingsEvent(event: unknown): { key: string; value: unknown } | null {
if (!event || typeof event !== "object") {
return null;
}
const evt = event as Record<string, unknown>;
// Handle put-entry events
if (evt["put-entry"]) {
const put = evt["put-entry"] as Record<string, unknown>;
if (put.desk !== SETTINGS_DESK || put["bucket-key"] !== SETTINGS_BUCKET) {
return null;
}
return {
key: typeof put["entry-key"] === "string" ? put["entry-key"] : "",
value: put.value,
};
}
// Handle del-entry events
if (evt["del-entry"]) {
const del = evt["del-entry"] as Record<string, unknown>;
if (del.desk !== SETTINGS_DESK || del["bucket-key"] !== SETTINGS_BUCKET) {
return null;
}
return {
key: typeof del["entry-key"] === "string" ? del["entry-key"] : "",
value: undefined,
};
}
return null;
}
/**
* Apply a single settings update to the current state.
*/
function applySettingsUpdate(
current: TlonSettingsStore,
key: string,
value: unknown,
): TlonSettingsStore {
const next = { ...current };
switch (key) {
case "groupChannels":
next.groupChannels = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: undefined;
break;
case "dmAllowlist":
next.dmAllowlist = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: undefined;
break;
case "autoDiscover":
next.autoDiscover = typeof value === "boolean" ? value : undefined;
break;
case "showModelSig":
next.showModelSig = typeof value === "boolean" ? value : undefined;
break;
case "autoAcceptDmInvites":
next.autoAcceptDmInvites = typeof value === "boolean" ? value : undefined;
break;
case "autoAcceptGroupInvites":
next.autoAcceptGroupInvites = typeof value === "boolean" ? value : undefined;
break;
case "groupInviteAllowlist":
next.groupInviteAllowlist = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: undefined;
break;
case "channelRules":
next.channelRules = parseChannelRules(value);
break;
case "defaultAuthorizedShips":
next.defaultAuthorizedShips = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: undefined;
break;
case "ownerShip":
next.ownerShip = typeof value === "string" ? value : undefined;
break;
case "pendingApprovals":
next.pendingApprovals = parsePendingApprovals(value);
break;
}
return next;
}
type SettingsLogger = {
log?: (msg: string) => void;
error?: (msg: string) => void;
};
/**
* Create a settings store subscription manager.
*
* Usage:
* const settings = createSettingsManager(api, logger);
* await settings.load();
* settings.subscribe((newSettings) => { ... });
*/
export function createSettingsManager(api: UrbitSSEClient, logger?: SettingsLogger) {
const state: TlonSettingsState = {
current: {},
loaded: false,
};
const listeners = new Set<(settings: TlonSettingsStore) => void>();
const notify = () => {
for (const listener of listeners) {
try {
listener(state.current);
} catch (err) {
logger?.error?.(`[settings] Listener error: ${String(err)}`);
}
}
};
return {
/**
* Get current settings (may be empty if not loaded yet).
*/
get current(): TlonSettingsStore {
return state.current;
},
/**
* Whether initial settings have been loaded.
*/
get loaded(): boolean {
return state.loaded;
},
/**
* Load initial settings via scry.
*/
async load(): Promise<TlonSettingsStore> {
try {
const raw = await api.scry("/settings/all.json");
// Response shape: { all: { [desk]: { [bucket]: { [key]: value } } } }
const allData = raw as { all?: Record<string, Record<string, unknown>> };
const deskData = allData?.all?.[SETTINGS_DESK];
state.current = parseSettingsResponse(deskData ?? {});
state.loaded = true;
logger?.log?.(`[settings] Loaded: ${JSON.stringify(state.current)}`);
return state.current;
} catch (err) {
// Settings desk may not exist yet - that's fine, use defaults
logger?.log?.(`[settings] No settings found (using defaults): ${String(err)}`);
state.current = {};
state.loaded = true;
return state.current;
}
},
/**
* Subscribe to settings changes.
*/
async startSubscription(): Promise<void> {
await api.subscribe({
app: "settings",
path: "/desk/" + SETTINGS_DESK,
event: (event) => {
const update = parseSettingsEvent(event);
if (!update) {
return;
}
logger?.log?.(`[settings] Update: ${update.key} = ${JSON.stringify(update.value)}`);
state.current = applySettingsUpdate(state.current, update.key, update.value);
notify();
},
err: (error) => {
logger?.error?.(`[settings] Subscription error: ${String(error)}`);
},
quit: () => {
logger?.log?.("[settings] Subscription ended");
},
});
logger?.log?.("[settings] Subscribed to settings updates");
},
/**
* Register a listener for settings changes.
*/
onChange(listener: (settings: TlonSettingsStore) => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}

View File

@@ -0,0 +1,232 @@
// Tlon plugin module implements setup core behavior.
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
normalizeAccountId,
patchScopedAccountConfig,
prepareScopedSetupConfig,
createSetupTranslator,
createSetupInputPresenceValidator,
type ChannelSetupAdapter,
type ChannelSetupInput,
type ChannelSetupWizard,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { buildTlonAccountFields, type TlonAccountFieldsInput } from "./account-fields.js";
import { normalizeShip } from "./targets.js";
import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js";
import { validateUrbitBaseUrl } from "./urbit/base-url.js";
const t = createSetupTranslator();
function tlonChannelId() {
return "tlon" as const;
}
type TlonSetupInput = ChannelSetupInput & TlonAccountFieldsInput;
function isConfigured(account: TlonResolvedAccount): boolean {
return Boolean(account.ship && account.url && account.code);
}
type TlonSetupWizardBaseParams = {
resolveConfigured: (params: {
cfg: OpenClawConfig;
accountId?: string;
}) => boolean | Promise<boolean>;
resolveStatusLines?: (params: {
cfg: OpenClawConfig;
accountId?: string;
configured: boolean;
}) => string[] | Promise<string[]>;
finalize: NonNullable<ChannelSetupWizard["finalize"]>;
};
export function createTlonSetupWizardBase(params: TlonSetupWizardBaseParams): ChannelSetupWizard {
return {
channel: tlonChannelId(),
status: {
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsSetup"),
configuredHint: t("wizard.channels.statusConfigured"),
unconfiguredHint: t("wizard.channels.statusUrbitMessenger"),
configuredScore: 1,
unconfiguredScore: 4,
resolveConfigured: ({ cfg, accountId }) => params.resolveConfigured({ cfg, accountId }),
resolveStatusLines: ({ cfg, accountId, configured }) =>
params.resolveStatusLines?.({ cfg, accountId, configured }) ?? [],
},
introNote: {
title: t("wizard.tlon.setupTitle"),
lines: [
t("wizard.tlon.helpNeedsUrlCode"),
t("wizard.tlon.helpExampleUrl"),
t("wizard.tlon.helpExampleShip"),
t("wizard.tlon.helpPrivateNetwork"),
`Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`,
],
},
credentials: [],
textInputs: [
{
inputKey: "ship",
message: t("wizard.tlon.shipPrompt"),
placeholder: "~sampel-palnet",
currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).ship ?? undefined,
validate: ({ value }) =>
normalizeStringifiedOptionalString(value) ? undefined : "Required",
normalizeValue: ({ value }) =>
normalizeShip(normalizeStringifiedOptionalString(value) ?? ""),
applySet: async ({ cfg, accountId, value }) =>
applyTlonSetupConfig({
cfg,
accountId,
input: { ship: value },
}),
},
{
inputKey: "url",
message: t("wizard.tlon.shipUrlPrompt"),
placeholder: "https://your-ship-host",
currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).url ?? undefined,
validate: ({ value }) => {
const next = validateUrbitBaseUrl(value ?? "");
if (!next.ok) {
return next.error;
}
return undefined;
},
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
applySet: async ({ cfg, accountId, value }) =>
applyTlonSetupConfig({
cfg,
accountId,
input: { url: value },
}),
},
{
inputKey: "code",
message: t("wizard.tlon.loginCodePrompt"),
placeholder: "lidlut-tabwed-pillex-ridrup",
currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).code ?? undefined,
validate: ({ value }) =>
normalizeStringifiedOptionalString(value) ? undefined : "Required",
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
applySet: async ({ cfg, accountId, value }) =>
applyTlonSetupConfig({
cfg,
accountId,
input: { code: value },
}),
},
],
finalize: params.finalize,
};
}
export async function resolveTlonSetupConfigured(
cfg: OpenClawConfig,
accountId?: string,
): Promise<boolean> {
if (accountId) {
return isConfigured(resolveTlonAccount(cfg, accountId));
}
const accountIds = listTlonAccountIds(cfg);
return accountIds.length > 0
? accountIds.some((resolvedAccountId) =>
isConfigured(resolveTlonAccount(cfg, resolvedAccountId)),
)
: isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID));
}
export async function resolveTlonSetupStatusLines(
cfg: OpenClawConfig,
accountId?: string,
): Promise<string[]> {
const configured = await resolveTlonSetupConfigured(cfg, accountId);
const label = accountId && accountId !== DEFAULT_ACCOUNT_ID ? `Tlon (${accountId})` : "Tlon";
return [`${label}: ${configured ? "configured" : "needs setup"}`];
}
export function applyTlonSetupConfig(params: {
cfg: OpenClawConfig;
accountId: string;
input: TlonSetupInput;
}): OpenClawConfig {
const { cfg, accountId, input } = params;
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
const namedConfig = prepareScopedSetupConfig({
cfg,
channelKey: tlonChannelId(),
accountId,
name: input.name,
});
const base = namedConfig.channels?.tlon ?? {};
const payload = buildTlonAccountFields(input);
if (useDefault) {
return {
...namedConfig,
channels: {
...namedConfig.channels,
tlon: {
...base,
enabled: true,
...payload,
},
},
};
}
return patchScopedAccountConfig({
cfg: namedConfig,
channelKey: tlonChannelId(),
accountId,
patch: { enabled: base.enabled ?? true },
accountPatch: {
enabled: true,
...payload,
},
ensureChannelEnabled: false,
ensureAccountEnabled: false,
});
}
export const tlonSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
prepareScopedSetupConfig({
cfg,
channelKey: tlonChannelId(),
accountId,
name,
}),
validateInput: createSetupInputPresenceValidator({
validate: ({ cfg, accountId, input }) => {
const resolved = resolveTlonAccount(cfg, accountId ?? undefined);
const ship = normalizeOptionalString(input.ship) || resolved.ship;
const url = normalizeOptionalString(input.url) || resolved.url;
const code = normalizeOptionalString(input.code) || resolved.code;
if (!ship) {
return "Tlon requires --ship.";
}
if (!url) {
return "Tlon requires --url.";
}
if (!code) {
return "Tlon requires --code.";
}
return null;
},
}),
applyAccountConfig: ({ cfg, accountId, input }) =>
applyTlonSetupConfig({
cfg,
accountId,
input: input as TlonSetupInput,
}),
};

View File

@@ -0,0 +1,98 @@
// Tlon plugin module implements setup surface behavior.
import { createSetupTranslator } from "openclaw/plugin-sdk/setup-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
applyTlonSetupConfig,
createTlonSetupWizardBase,
resolveTlonSetupConfigured,
resolveTlonSetupStatusLines,
} from "./setup-core.js";
import { normalizeShip } from "./targets.js";
import { resolveTlonAccount } from "./types.js";
import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js";
const t = createSetupTranslator();
function parseList(value: string): string[] {
return normalizeStringEntries(value.split(/[\n,;]+/g));
}
export const tlonSetupWizard = createTlonSetupWizardBase({
resolveConfigured: async ({ cfg, accountId }) => await resolveTlonSetupConfigured(cfg, accountId),
resolveStatusLines: async ({ cfg, accountId }) =>
await resolveTlonSetupStatusLines(cfg, accountId),
finalize: async ({ cfg, accountId, prompter }) => {
let next = cfg;
const resolved = resolveTlonAccount(next, accountId);
const validatedUrl = validateUrbitBaseUrl(resolved.url ?? "");
if (!validatedUrl.ok) {
throw new Error(`Invalid URL: ${validatedUrl.error}`);
}
let dangerouslyAllowPrivateNetwork = resolved.dangerouslyAllowPrivateNetwork ?? false;
if (isBlockedUrbitHostname(validatedUrl.hostname)) {
dangerouslyAllowPrivateNetwork = await prompter.confirm({
message: t("wizard.tlon.privateNetworkPrompt"),
initialValue: dangerouslyAllowPrivateNetwork,
});
if (!dangerouslyAllowPrivateNetwork) {
throw new Error("Refusing private/internal ship URL without explicit network opt-in");
}
}
next = applyTlonSetupConfig({
cfg: next,
accountId,
input: { dangerouslyAllowPrivateNetwork },
});
const currentGroups = resolved.groupChannels;
const wantsGroupChannels = await prompter.confirm({
message: t("wizard.tlon.addGroupsPrompt"),
initialValue: currentGroups.length > 0,
});
if (wantsGroupChannels) {
const entry = await prompter.text({
message: t("wizard.tlon.groupChannelsPrompt"),
placeholder: "chat/~host-ship/general, chat/~host-ship/support",
initialValue: currentGroups.join(", ") || undefined,
});
next = applyTlonSetupConfig({
cfg: next,
accountId,
input: { groupChannels: parseList(entry ?? "") },
});
}
const currentAllowlist = resolved.dmAllowlist;
const wantsAllowlist = await prompter.confirm({
message: t("wizard.tlon.restrictDmsPrompt"),
initialValue: currentAllowlist.length > 0,
});
if (wantsAllowlist) {
const entry = await prompter.text({
message: t("wizard.tlon.dmAllowlistPrompt"),
placeholder: "~zod, ~nec",
initialValue: currentAllowlist.join(", ") || undefined,
});
next = applyTlonSetupConfig({
cfg: next,
accountId,
input: {
dmAllowlist: parseList(entry ?? "").map((ship) => normalizeShip(ship)),
},
});
}
const autoDiscoverChannels = await prompter.confirm({
message: t("wizard.tlon.autoDiscoveryPrompt"),
initialValue: resolved.autoDiscoverChannels ?? true,
});
next = applyTlonSetupConfig({
cfg: next,
accountId,
input: { autoDiscoverChannels },
});
return { cfg: next };
},
});

View File

@@ -0,0 +1,103 @@
// Tlon plugin module implements targets behavior.
type TlonTarget =
| { kind: "dm"; ship: string }
| { kind: "group"; nest: string; hostShip: string; channelName: string };
const SHIP_RE = /^~?[a-z-]+$/i;
const NEST_RE = /^chat\/([^/]+)\/([^/]+)$/i;
export function normalizeShip(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return trimmed;
}
return trimmed.startsWith("~") ? trimmed : `~${trimmed}`;
}
export function parseChannelNest(raw: string): { hostShip: string; channelName: string } | null {
const match = NEST_RE.exec(raw.trim());
if (!match) {
return null;
}
const hostShip = normalizeShip(match[1]);
const channelName = match[2];
return { hostShip, channelName };
}
function makeGroupTarget(parsed: { hostShip: string; channelName: string }): TlonTarget {
return {
kind: "group",
nest: `chat/${parsed.hostShip}/${parsed.channelName}`,
hostShip: parsed.hostShip,
channelName: parsed.channelName,
};
}
export function parseTlonTarget(raw?: string | null): TlonTarget | null {
const trimmed = raw?.trim();
if (!trimmed) {
return null;
}
const withoutPrefix = trimmed.replace(/^tlon:/i, "");
const dmPrefix = withoutPrefix.match(/^dm[/:](.+)$/i);
if (dmPrefix) {
return { kind: "dm", ship: normalizeShip(dmPrefix[1]) };
}
const groupPrefix = withoutPrefix.match(/^(group|room)[/:](.+)$/i);
if (groupPrefix) {
const groupTarget = groupPrefix[2].trim();
if (groupTarget.startsWith("chat/")) {
const parsed = parseChannelNest(groupTarget);
if (!parsed) {
return null;
}
return makeGroupTarget(parsed);
}
const parts = groupTarget.split("/");
if (parts.length === 2) {
const hostShip = normalizeShip(parts[0]);
const channelName = parts[1];
return {
kind: "group",
nest: `chat/${hostShip}/${channelName}`,
hostShip,
channelName,
};
}
return null;
}
if (withoutPrefix.startsWith("chat/")) {
const parsed = parseChannelNest(withoutPrefix);
if (!parsed) {
return null;
}
return makeGroupTarget(parsed);
}
if (SHIP_RE.test(withoutPrefix)) {
return { kind: "dm", ship: normalizeShip(withoutPrefix) };
}
return null;
}
export function resolveTlonOutboundTarget(to?: string | null) {
const parsed = parseTlonTarget(to ?? "");
if (!parsed) {
return {
ok: false as const,
error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`),
};
}
if (parsed.kind === "dm") {
return { ok: true as const, to: parsed.ship };
}
return { ok: true as const, to: parsed.nest };
}
export function formatTargetHint(): string {
return "dm/~sampel-palnet | ~sampel-palnet | chat/~host-ship/channel | group:~host-ship/channel";
}

View File

@@ -0,0 +1,573 @@
// Tlon tests cover tlon api plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { authenticate } from "./urbit/auth.js";
import { scryUrbitPath } from "./urbit/channel-ops.js";
const { mockFetchGuard, mockRelease, mockGetSignedUrl } = vi.hoisted(() => ({
mockFetchGuard: vi.fn(),
mockRelease: vi.fn(async () => {}),
mockGetSignedUrl: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const original = (await vi.importActual("openclaw/plugin-sdk/ssrf-runtime")) as Record<
string,
unknown
>;
return {
...original,
fetchWithSsrFGuard: mockFetchGuard,
};
});
vi.mock("@aws-sdk/s3-request-presigner", () => ({
getSignedUrl: mockGetSignedUrl,
}));
vi.mock("./urbit/auth.js", () => ({
authenticate: vi.fn(),
}));
vi.mock("./urbit/channel-ops.js", () => ({
scryUrbitPath: vi.fn(),
}));
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { configureClient, uploadFile } from "./tlon-api.js";
const mockAuthenticate = vi.mocked(authenticate);
const mockScryUrbitPath = vi.mocked(scryUrbitPath);
const mockGuardedFetch = vi.mocked(fetchWithSsrFGuard);
function createMemexResponse(
uploadUrl: string,
filePath = "https://memex.tlon.network/files/uploaded.png",
): Response {
return new Response(
JSON.stringify({
url: uploadUrl,
filePath,
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
}
function createGuardedResult(response: Response, finalUrl: string) {
return {
response,
finalUrl,
release: mockRelease,
};
}
function guardedFetchCall(index: number): Parameters<typeof fetchWithSsrFGuard>[0] {
const call = mockGuardedFetch.mock.calls[index]?.at(0);
if (call === undefined) {
throw new Error(`expected guarded fetch call ${index}`);
}
return call;
}
describe("uploadFile memex upload hardening", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
mockAuthenticate.mockResolvedValue("urbauth-~zod=fake-cookie");
configureClient({
shipUrl: "https://groups.tlon.network",
shipName: "~zod",
verbose: false,
getCode: async () => "123456",
});
mockScryUrbitPath.mockImplementation(async (_deps, params) => {
if (params.path === "/storage/configuration.json") {
return {
currentBucket: "uploads",
buckets: ["uploads"],
publicUrlBase: "https://files.tlon.network/",
presignedUrl: "https://files.tlon.network/presigned",
region: "us-east-1",
service: "presigned-url",
};
}
if (params.path === "/storage/credentials.json") {
return { "storage-update": {} };
}
if (params.path === "/genuine/secret.json") {
return { secret: "genuine-secret" };
}
throw new Error(`Unexpected scry path: ${params.path}`);
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("routes the memex upload URL through the SSRF guard", async () => {
mockGuardedFetch
.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://uploads.tlon.network/put"),
"https://memex.tlon.network/v1/zod/upload",
),
)
.mockResolvedValueOnce(
createGuardedResult(
new Response(null, { status: 200 }),
"https://uploads.tlon.network/put",
),
);
const result = await uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
});
expect(result).toEqual({ url: "https://memex.tlon.network/files/uploaded.png" });
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
const firstCall = guardedFetchCall(0);
expect(firstCall?.url).toBe("https://memex.tlon.network/v1/zod/upload");
expect(firstCall?.init?.method).toBe("PUT");
expect(firstCall?.init?.headers).toEqual({ "Content-Type": "application/json" });
expect(firstCall?.auditContext).toBe("tlon-memex-upload-url");
expect(firstCall?.capture).toBe(false);
expect(firstCall?.maxRedirects).toBe(0);
const firstBodyRaw = firstCall?.init?.body;
expect(typeof firstBodyRaw).toBe("string");
const firstBody = JSON.parse(firstBodyRaw as string) as Record<string, unknown>;
expect(firstBody.token).toBe("genuine-secret");
expect(firstBody.contentLength).toBe(11);
expect(firstBody.contentType).toBe("image/png");
expect(typeof firstBody.fileName).toBe("string");
const secondCall = guardedFetchCall(1);
expect(secondCall?.url).toBe("https://uploads.tlon.network/put");
expect(secondCall?.init?.method).toBe("PUT");
expect(secondCall?.init?.headers).toEqual({
"Cache-Control": "public, max-age=3600",
"Content-Type": "image/png",
});
expect(secondCall?.auditContext).toBe("tlon-memex-upload");
expect(secondCall?.capture).toBe(false);
expect(secondCall?.maxRedirects).toBe(0);
expect(secondCall?.init?.body).toBeInstanceOf(Blob);
expect(mockRelease).toHaveBeenCalledTimes(2);
});
it("surfaces guarded upload failures for hosted Memex targets", async () => {
mockGuardedFetch
.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://uploads.tlon.network/put"),
"https://memex.tlon.network/v1/zod/upload",
),
)
.mockRejectedValueOnce(new Error("Blocked upload target"));
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Blocked upload target");
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
const uploadCall = guardedFetchCall(1);
expect(uploadCall?.url).toBe("https://uploads.tlon.network/put");
expect(uploadCall?.auditContext).toBe("tlon-memex-upload");
expect(uploadCall?.capture).toBe(false);
expect(uploadCall?.maxRedirects).toBe(0);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("rejects Memex upload targets outside the hosted Tlon domain allowlist", async () => {
mockGuardedFetch.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://eviltlon.network/upload"),
"https://memex.tlon.network/v1/zod/upload",
),
);
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Memex upload URL must target a trusted hosted Tlon domain");
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("rejects Memex hosted result URLs outside the hosted Tlon domain allowlist", async () => {
mockGuardedFetch
.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse(
"https://uploads.tlon.network/put",
"https://evil.example/files/uploaded.png",
),
"https://memex.tlon.network/v1/zod/upload",
),
)
.mockResolvedValueOnce(
createGuardedResult(
new Response(null, { status: 200 }),
"https://uploads.tlon.network/put",
),
);
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Memex hosted URL must target a trusted hosted Tlon domain");
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
expect(mockRelease).toHaveBeenCalledTimes(2);
});
it("rejects Memex upload targets with a non-standard port", async () => {
mockGuardedFetch.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://uploads.tlon.network:8443/put"),
"https://memex.tlon.network/v1/zod/upload",
),
);
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Memex upload URL must not specify a non-standard port");
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("disables redirects for Memex upload targets", async () => {
mockGuardedFetch
.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://uploads.tlon.network/put"),
"https://memex.tlon.network/v1/zod/upload",
),
)
.mockRejectedValueOnce(new Error("Too many redirects (limit: 0)"));
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Too many redirects (limit: 0)");
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
const uploadCall = guardedFetchCall(1);
expect(uploadCall?.url).toBe("https://uploads.tlon.network/put");
expect(uploadCall?.auditContext).toBe("tlon-memex-upload");
expect(uploadCall?.capture).toBe(false);
expect(uploadCall?.maxRedirects).toBe(0);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("routes scheme-less hosted ship URLs through the Memex upload path", async () => {
configureClient({
shipUrl: "foo.tlon.network",
shipName: "~zod",
verbose: false,
getCode: async () => "123456",
});
mockGuardedFetch
.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://uploads.tlon.network/put"),
"https://memex.tlon.network/v1/zod/upload",
),
)
.mockResolvedValueOnce(
createGuardedResult(
new Response(null, { status: 200 }),
"https://uploads.tlon.network/put",
),
);
const result = await uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
});
expect(result).toEqual({ url: "https://memex.tlon.network/files/uploaded.png" });
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
expect(mockRelease).toHaveBeenCalledTimes(2);
});
it("rejects truly unparseable ship URLs as not hosted", async () => {
configureClient({
shipUrl: " ",
shipName: "~zod",
verbose: false,
getCode: async () => "123456",
});
mockScryUrbitPath.mockImplementation(async (_deps, params) => {
if (params.path === "/storage/configuration.json") {
return {
currentBucket: "uploads",
buckets: ["uploads"],
publicUrlBase: "https://files.tlon.network/",
presignedUrl: "https://files.tlon.network/presigned",
region: "us-east-1",
service: "presigned-url",
};
}
if (params.path === "/storage/credentials.json") {
return { "storage-update": {} };
}
throw new Error(`Unexpected scry path: ${params.path}`);
});
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("No storage credentials configured");
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
expect(mockGuardedFetch).not.toHaveBeenCalled();
expect(mockRelease).not.toHaveBeenCalled();
});
it("accepts hosted Memex upload URLs with an explicit :443 port", async () => {
mockGuardedFetch
.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://uploads.tlon.network:443/put"),
"https://memex.tlon.network/v1/zod/upload",
),
)
.mockResolvedValueOnce(
createGuardedResult(
new Response(null, { status: 200 }),
"https://uploads.tlon.network:443/put",
),
);
const result = await uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
});
expect(result).toEqual({ url: "https://memex.tlon.network/files/uploaded.png" });
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
expect(mockRelease).toHaveBeenCalledTimes(2);
});
it("disables redirects for the Memex upload URL lookup", async () => {
mockGuardedFetch.mockRejectedValueOnce(new Error("Too many redirects (limit: 0)"));
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Too many redirects (limit: 0)");
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
const lookupCall = guardedFetchCall(0);
expect(lookupCall?.url).toBe("https://memex.tlon.network/v1/zod/upload");
expect(lookupCall?.auditContext).toBe("tlon-memex-upload-url");
expect(lookupCall?.capture).toBe(false);
expect(lookupCall?.maxRedirects).toBe(0);
expect(mockRelease).not.toHaveBeenCalled();
});
});
describe("uploadFile custom S3 upload hardening", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
mockAuthenticate.mockResolvedValue("urbauth-~zod=fake-cookie");
configureClient({
shipUrl: "https://ship.example.com",
shipName: "~zod",
verbose: false,
getCode: async () => "123456",
});
mockScryUrbitPath.mockImplementation(async (_deps, params) => {
if (params.path === "/storage/configuration.json") {
return {
currentBucket: "uploads",
buckets: ["uploads"],
publicUrlBase: "https://files.example.com/",
presignedUrl: "",
region: "us-east-1",
service: "custom",
};
}
if (params.path === "/storage/credentials.json") {
return {
"storage-update": {
credentials: {
endpoint: "https://s3.example.com",
accessKeyId: "AKIAFAKE",
secretAccessKey: "fake-secret",
},
},
};
}
throw new Error(`Unexpected scry path: ${params.path}`);
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("routes the custom S3 signed URL through the SSRF guard", async () => {
mockGetSignedUrl.mockResolvedValueOnce("https://s3.example.com/uploads/file?sig=abc");
mockGuardedFetch.mockResolvedValueOnce(
createGuardedResult(
new Response(null, { status: 200 }),
"https://s3.example.com/uploads/file?sig=abc",
),
);
const result = await uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
});
expect(result.url.startsWith("https://files.example.com/")).toBe(true);
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
const uploadCall = guardedFetchCall(0);
expect(uploadCall?.url).toBe("https://s3.example.com/uploads/file?sig=abc");
expect(uploadCall?.init?.method).toBe("PUT");
expect(uploadCall?.init?.headers).toBeUndefined();
expect(uploadCall?.auditContext).toBe("tlon-custom-s3-upload");
expect(uploadCall?.capture).toBe(false);
expect(uploadCall?.maxRedirects).toBe(0);
expect(uploadCall?.policy).toBeUndefined();
expect(mockRelease).toHaveBeenCalledTimes(1);
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
});
it("surfaces guarded upload failures for custom S3 targets without calling release", async () => {
mockGetSignedUrl.mockResolvedValueOnce("https://169.254.169.254/uploads/file?sig=abc");
mockGuardedFetch.mockRejectedValueOnce(new Error("Blocked private network target"));
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Blocked private network target");
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
expect(mockRelease).not.toHaveBeenCalled();
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
});
it("passes the private-network opt-in to guarded custom S3 uploads", async () => {
configureClient({
shipUrl: "https://ship.example.com",
shipName: "~zod",
verbose: false,
getCode: async () => "123456",
dangerouslyAllowPrivateNetwork: true,
});
mockGetSignedUrl.mockResolvedValueOnce("https://10.0.0.15/uploads/file?sig=abc");
mockGuardedFetch.mockResolvedValueOnce(
createGuardedResult(
new Response(null, { status: 200 }),
"https://10.0.0.15/uploads/file?sig=abc",
),
);
const result = await uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
});
expect(result.url.startsWith("https://files.example.com/")).toBe(true);
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
const uploadCall = guardedFetchCall(0);
expect(uploadCall?.url).toBe("https://10.0.0.15/uploads/file?sig=abc");
expect(uploadCall?.auditContext).toBe("tlon-custom-s3-upload");
expect(uploadCall?.capture).toBe(false);
expect(uploadCall?.maxRedirects).toBe(0);
expect(uploadCall?.policy).toEqual({ allowPrivateNetwork: true });
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("rejects custom S3 result URLs that are not http(s)", async () => {
mockScryUrbitPath.mockImplementation(async (_deps, params) => {
if (params.path === "/storage/configuration.json") {
return {
currentBucket: "uploads",
buckets: ["uploads"],
publicUrlBase: "ftp://files.example.com/",
presignedUrl: "",
region: "us-east-1",
service: "custom",
};
}
if (params.path === "/storage/credentials.json") {
return {
"storage-update": {
credentials: {
endpoint: "https://s3.example.com",
accessKeyId: "AKIAFAKE",
secretAccessKey: "fake-secret",
},
},
};
}
throw new Error(`Unexpected scry path: ${params.path}`);
});
mockGetSignedUrl.mockResolvedValueOnce("https://s3.example.com/uploads/file?sig=abc");
mockGuardedFetch.mockResolvedValueOnce(
createGuardedResult(
new Response(null, { status: 200 }),
"https://s3.example.com/uploads/file?sig=abc",
),
);
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Upload result URL must use http or https");
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,390 @@
// Tlon API module exposes the plugin public contract.
import crypto from "node:crypto";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { authenticate } from "./urbit/auth.js";
import { scryUrbitPath } from "./urbit/channel-ops.js";
import { ssrfPolicyFromDangerouslyAllowPrivateNetwork } from "./urbit/context.js";
type ClientConfig = {
shipUrl: string;
shipName: string;
verbose: boolean;
getCode: () => Promise<string>;
dangerouslyAllowPrivateNetwork?: boolean;
};
type StorageService = "presigned-url" | "credentials";
type StorageConfiguration = {
buckets: string[];
currentBucket: string;
region: string;
publicUrlBase: string;
presignedUrl: string;
service: StorageService;
};
type StorageCredentials = {
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
};
type UploadFileParams = {
blob: Blob;
fileName?: string;
contentType?: string;
};
type UploadResult = {
url: string;
};
const MEMEX_BASE_URL = "https://memex.tlon.network";
let currentClientConfig: ClientConfig | null = null;
export function configureClient(params: ClientConfig): void {
currentClientConfig = {
...params,
shipName: params.shipName.replace(/^~/, ""),
};
}
function requireClientConfig(): ClientConfig {
if (!currentClientConfig) {
throw new Error("Tlon client not configured");
}
return currentClientConfig;
}
function getExtensionFromMimeType(mimeType?: string): string {
return extensionForMime(mimeType) || ".jpg";
}
function hasCustomS3Creds(
credentials: StorageCredentials | null,
): credentials is StorageCredentials {
return Boolean(credentials?.accessKeyId && credentials?.endpoint && credentials?.secretAccessKey);
}
function isStorageCredentials(value: unknown): value is StorageCredentials {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return (
typeof record.endpoint === "string" &&
typeof record.accessKeyId === "string" &&
typeof record.secretAccessKey === "string"
);
}
function hostnameMatchesDomainBoundary(hostname: string, domain: string): boolean {
return hostname === domain || hostname.endsWith(`.${domain}`);
}
function isHostedShipUrl(shipUrl: string): boolean {
const hostname = extractShipHostname(shipUrl);
return hostname !== null && isHostedTlonHostname(hostname);
}
function extractShipHostname(shipUrl: string): string | null {
const trimmed = shipUrl.trim();
if (!trimmed) {
return null;
}
const normalized = /^[a-zA-Z][\w+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
return new URL(normalized).hostname;
} catch {
return null;
}
}
function isHostedTlonHostname(hostname: string): boolean {
return (
hostnameMatchesDomainBoundary(hostname, "tlon.network") ||
hostnameMatchesDomainBoundary(hostname, "test.tlon.systems")
);
}
function assertTrustedMemexUploadUrl(rawUrl: string, label: string): string {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`${label} must be a valid https URL`);
}
if (parsed.protocol !== "https:") {
throw new Error(`${label} must use https`);
}
if (!isHostedTlonHostname(parsed.hostname)) {
throw new Error(`${label} must target a trusted hosted Tlon domain`);
}
if (parsed.port && parsed.port !== "443") {
throw new Error(`${label} must not specify a non-standard port`);
}
return parsed.toString();
}
function assertSafeUploadResultUrl(rawUrl: string, label: string): string {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`${label} must be a valid http(s) URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${label} must use http or https`);
}
return parsed.toString();
}
function prefixEndpoint(endpoint: string): string {
return /https?:\/\//.test(endpoint) ? endpoint : `https://${endpoint}`;
}
function sanitizeFileName(fileName: string): string {
return fileName.split(/[/\\]/).pop() || fileName;
}
async function getAuthCookie(config: ClientConfig): Promise<string> {
return await authenticate(config.shipUrl, await config.getCode(), {
ssrfPolicy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(config.dangerouslyAllowPrivateNetwork),
});
}
async function scryJson<T>(config: ClientConfig, cookie: string, path: string): Promise<T> {
return (await scryUrbitPath(
{
baseUrl: config.shipUrl,
cookie,
ssrfPolicy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(
config.dangerouslyAllowPrivateNetwork,
),
},
{ path, auditContext: "tlon-storage-scry" },
)) as T;
}
async function getStorageConfiguration(
config: ClientConfig,
cookie: string,
): Promise<StorageConfiguration> {
const result = await scryJson<
{ "storage-update"?: { configuration?: StorageConfiguration } } | StorageConfiguration
>(config, cookie, "/storage/configuration.json");
if ("storage-update" in result && result["storage-update"]?.configuration) {
return result["storage-update"].configuration;
}
if ("currentBucket" in result) {
return result;
}
throw new Error("Invalid storage configuration response");
}
async function getStorageCredentials(
config: ClientConfig,
cookie: string,
): Promise<StorageCredentials | null> {
const result = await scryJson<
{ "storage-update"?: { credentials?: StorageCredentials } } | StorageCredentials
>(config, cookie, "/storage/credentials.json");
if ("storage-update" in result) {
return result["storage-update"]?.credentials ?? null;
}
if (isStorageCredentials(result)) {
return result;
}
return null;
}
async function getMemexUploadUrl(params: {
config: ClientConfig;
cookie: string;
contentLength: number;
contentType: string;
fileName: string;
}): Promise<{ hostedUrl: string; uploadUrl: string }> {
const token = await scryJson<string | { secret?: string }>(
params.config,
params.cookie,
"/genuine/secret.json",
);
const resolvedToken = typeof token === "string" ? token : token.secret;
if (!resolvedToken) {
throw new Error("Missing genuine secret");
}
const endpoint = `${MEMEX_BASE_URL}/v1/${params.config.shipName}/upload`;
let release: (() => Promise<void>) | undefined;
try {
const guarded = await fetchWithSsrFGuard({
url: endpoint,
init: {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: resolvedToken,
contentLength: params.contentLength,
contentType: params.contentType,
fileName: params.fileName,
}),
},
auditContext: "tlon-memex-upload-url",
capture: false,
maxRedirects: 0,
});
release = guarded.release;
if (!guarded.response.ok) {
throw new Error(`Memex upload request failed: ${guarded.response.status}`);
}
const data = (await guarded.response.json()) as { url?: string; filePath?: string } | null;
if (!data?.url || !data.filePath) {
throw new Error("Invalid response from Memex");
}
return { hostedUrl: data.filePath, uploadUrl: data.url };
} finally {
await release?.();
}
}
export async function uploadFile(params: UploadFileParams): Promise<UploadResult> {
const config = requireClientConfig();
const cookie = await getAuthCookie(config);
const privateNetworkPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(
config.dangerouslyAllowPrivateNetwork,
);
const [storageConfig, credentials] = await Promise.all([
getStorageConfiguration(config, cookie),
getStorageCredentials(config, cookie),
]);
const contentType = params.contentType || params.blob.type || "application/octet-stream";
const extension = getExtensionFromMimeType(contentType);
const fileName = sanitizeFileName(params.fileName || `upload${extension}`);
const fileKey = `${config.shipName}/${Date.now()}-${crypto.randomUUID()}-${fileName}`;
const useMemex =
isHostedShipUrl(config.shipUrl) &&
(storageConfig.service === "presigned-url" || !hasCustomS3Creds(credentials));
if (useMemex) {
const { hostedUrl, uploadUrl } = await getMemexUploadUrl({
config,
cookie,
contentLength: params.blob.size,
contentType,
fileName: fileKey,
});
const trustedUploadUrl = assertTrustedMemexUploadUrl(uploadUrl, "Memex upload URL");
let release: (() => Promise<void>) | undefined;
try {
const guarded = await fetchWithSsrFGuard({
url: trustedUploadUrl,
init: {
method: "PUT",
body: params.blob,
headers: {
"Cache-Control": "public, max-age=3600",
"Content-Type": contentType,
},
},
auditContext: "tlon-memex-upload",
capture: false,
maxRedirects: 0,
});
release = guarded.release;
assertTrustedMemexUploadUrl(guarded.finalUrl, "Memex final upload URL");
if (!guarded.response.ok) {
throw new Error(`Upload failed: ${guarded.response.status}`);
}
} finally {
await release?.();
}
return { url: assertTrustedMemexUploadUrl(hostedUrl, "Memex hosted URL") };
}
if (!hasCustomS3Creds(credentials)) {
throw new Error("No storage credentials configured");
}
const endpoint = new URL(prefixEndpoint(credentials.endpoint));
const client = new S3Client({
endpoint: {
protocol: endpoint.protocol.slice(0, -1) as "http" | "https",
hostname: endpoint.host,
path: endpoint.pathname || "/",
},
region: storageConfig.region || "us-east-1",
credentials: {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
},
forcePathStyle: true,
});
const headers: Record<string, string> = {
"Cache-Control": "public, max-age=3600",
"Content-Type": contentType,
"x-amz-acl": "public-read",
};
const command = new PutObjectCommand({
Bucket: storageConfig.currentBucket,
Key: fileKey,
ContentType: headers["Content-Type"],
CacheControl: headers["Cache-Control"],
ACL: "public-read",
});
const signedUrl = await getSignedUrl(client, command, {
expiresIn: 3600,
signableHeaders: new Set(Object.keys(headers)),
});
let release: (() => Promise<void>) | undefined;
try {
const guarded = await fetchWithSsrFGuard({
url: signedUrl,
init: {
method: "PUT",
body: params.blob,
headers: signedUrl.includes("digitaloceanspaces.com") ? headers : undefined,
},
auditContext: "tlon-custom-s3-upload",
capture: false,
maxRedirects: 0,
policy: privateNetworkPolicy,
});
release = guarded.release;
if (!guarded.response.ok) {
throw new Error(`Upload failed: ${guarded.response.status}`);
}
} finally {
await release?.();
}
const publicUrl = storageConfig.publicUrlBase
? new URL(fileKey, storageConfig.publicUrlBase).toString()
: signedUrl.split("?")[0];
return { url: assertSafeUploadResultUrl(publicUrl, "Upload result URL") };
}

View File

@@ -0,0 +1,161 @@
// Tlon type declarations define plugin contracts.
import {
DEFAULT_ACCOUNT_ID,
listCombinedAccountIds,
normalizeAccountId,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
isPrivateNetworkOptInEnabled,
} from "openclaw/plugin-sdk/ssrf-runtime";
type TlonAccountConfig = {
name?: string;
enabled?: boolean;
ship?: string;
url?: string;
code?: string;
network?: {
dangerouslyAllowPrivateNetwork?: boolean;
};
groupChannels?: string[];
dmAllowlist?: string[];
groupInviteAllowlist?: string[];
autoDiscoverChannels?: boolean;
showModelSignature?: boolean;
autoAcceptDmInvites?: boolean;
autoAcceptGroupInvites?: boolean;
defaultAuthorizedShips?: string[];
ownerShip?: string;
accounts?: Record<string, TlonAccountConfig>;
};
export type TlonResolvedAccount = {
accountId: string;
name: string | null;
enabled: boolean;
configured: boolean;
ship: string | null;
url: string | null;
code: string | null;
dangerouslyAllowPrivateNetwork: boolean | null;
groupChannels: string[];
dmAllowlist: string[];
/** Ships allowed to invite us to groups (security: prevent malicious group invites) */
groupInviteAllowlist: string[];
autoDiscoverChannels: boolean | null;
showModelSignature: boolean | null;
autoAcceptDmInvites: boolean | null;
autoAcceptGroupInvites: boolean | null;
defaultAuthorizedShips: string[];
/** Ship that receives approval requests for DMs, channel mentions, and group invites */
ownerShip: string | null;
};
function resolveTlonChannelConfig(cfg: OpenClawConfig): TlonAccountConfig | undefined {
return cfg.channels?.tlon as TlonAccountConfig | undefined;
}
function resolveMergedTlonAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> & TlonAccountConfig {
const channel = resolveTlonChannelConfig(cfg);
if (accountId === DEFAULT_ACCOUNT_ID) {
return (channel ?? {}) as Record<string, unknown> & TlonAccountConfig;
}
return resolveMergedAccountConfig<Record<string, unknown> & TlonAccountConfig>({
channelConfig: (channel ?? {}) as Record<string, unknown> & TlonAccountConfig,
accounts: channel?.accounts as
| Record<string, Partial<Record<string, unknown> & TlonAccountConfig>>
| undefined,
accountId,
normalizeAccountId,
});
}
export function resolveTlonAccount(
cfg: OpenClawConfig,
accountId?: string | null,
): TlonResolvedAccount {
const resolvedAccountId = normalizeAccountId(accountId);
const base = resolveTlonChannelConfig(cfg);
if (!base) {
return {
accountId: resolvedAccountId,
name: null,
enabled: false,
configured: false,
ship: null,
url: null,
code: null,
dangerouslyAllowPrivateNetwork: null,
groupChannels: [],
dmAllowlist: [],
groupInviteAllowlist: [],
autoDiscoverChannels: null,
showModelSignature: null,
autoAcceptDmInvites: null,
autoAcceptGroupInvites: null,
defaultAuthorizedShips: [],
ownerShip: null,
};
}
const merged = resolveMergedTlonAccountConfig(cfg, resolvedAccountId);
const ship = merged.ship ?? null;
const url = merged.url ?? null;
const code = merged.code ?? null;
const dangerouslyAllowPrivateNetwork = isPrivateNetworkOptInEnabled(merged)
? true
: typeof merged.network?.dangerouslyAllowPrivateNetwork === "boolean"
? merged.network.dangerouslyAllowPrivateNetwork
: hasLegacyFlatAllowPrivateNetworkAlias(merged) &&
typeof merged.allowPrivateNetwork === "boolean"
? merged.allowPrivateNetwork
: null;
const groupChannels = merged.groupChannels ?? [];
const dmAllowlist = merged.dmAllowlist ?? [];
const groupInviteAllowlist = merged.groupInviteAllowlist ?? [];
const autoDiscoverChannels = merged.autoDiscoverChannels ?? null;
const showModelSignature = merged.showModelSignature ?? null;
const autoAcceptDmInvites = merged.autoAcceptDmInvites ?? null;
const autoAcceptGroupInvites = merged.autoAcceptGroupInvites ?? null;
const ownerShip = merged.ownerShip ?? null;
const defaultAuthorizedShips = merged.defaultAuthorizedShips ?? [];
const configured = Boolean(ship && url && code);
return {
accountId: resolvedAccountId,
name: merged.name ?? null,
enabled: merged.enabled !== false,
configured,
ship,
url,
code,
dangerouslyAllowPrivateNetwork,
groupChannels,
dmAllowlist,
groupInviteAllowlist,
autoDiscoverChannels,
showModelSignature,
autoAcceptDmInvites,
autoAcceptGroupInvites,
defaultAuthorizedShips,
ownerShip,
};
}
export function listTlonAccountIds(cfg: OpenClawConfig): string[] {
const base = resolveTlonChannelConfig(cfg);
if (!base) {
return [];
}
return listCombinedAccountIds({
configuredAccountIds: Object.keys(base.accounts ?? {}).map(normalizeAccountId),
implicitAccountId: base.ship ? DEFAULT_ACCOUNT_ID : undefined,
});
}

View File

@@ -0,0 +1,46 @@
// Tlon tests cover auth.ssrf plugin behavior.
import { SsrFBlockedError } from "openclaw/plugin-sdk/ssrf-runtime";
import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { authenticate } from "./auth.js";
describe("tlon urbit auth ssrf", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("blocks private IPs by default", async () => {
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
await expect(authenticate("http://127.0.0.1:8080", "code")).rejects.toBeInstanceOf(
SsrFBlockedError,
);
expect(mockFetch).not.toHaveBeenCalled();
});
it("allows private IPs when allowPrivateNetwork is enabled", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => "ok",
headers: new Headers({
"set-cookie": "urbauth-~zod=123; Path=/; HttpOnly",
}),
});
vi.stubGlobal("fetch", mockFetch);
const lookupFn = (async () => [{ address: "127.0.0.1", family: 4 }]) as unknown as LookupFn;
const cookie = await authenticate("http://127.0.0.1:8080", "code", {
ssrfPolicy: { allowPrivateNetwork: true },
lookupFn,
fetchImpl: mockFetch as typeof fetch,
});
expect(cookie).toContain("urbauth-~zod=123");
expect(mockFetch).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,49 @@
// Tlon plugin module implements auth behavior.
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { UrbitAuthError } from "./errors.js";
import { urbitFetch } from "./fetch.js";
type UrbitAuthenticateOptions = {
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
timeoutMs?: number;
};
export async function authenticate(
url: string,
code: string,
options: UrbitAuthenticateOptions = {},
): Promise<string> {
const { response, release } = await urbitFetch({
baseUrl: url,
path: "/~/login",
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ password: code }).toString(),
},
ssrfPolicy: options.ssrfPolicy,
lookupFn: options.lookupFn,
fetchImpl: options.fetchImpl,
timeoutMs: options.timeoutMs ?? 15_000,
maxRedirects: 3,
auditContext: "tlon-urbit-login",
});
try {
if (!response.ok) {
throw new UrbitAuthError("auth_failed", `Login failed with status ${response.status}`);
}
// Some Urbit setups require the response body to be read before cookie headers finalize.
await response.text().catch(() => {});
const cookie = response.headers.get("set-cookie");
if (!cookie) {
throw new UrbitAuthError("missing_cookie", "No authentication cookie received");
}
return cookie;
} finally {
await release();
}
}

View File

@@ -0,0 +1,49 @@
// Tlon tests cover base url plugin behavior.
import { describe, expect, it } from "vitest";
import { validateUrbitBaseUrl } from "./base-url.js";
describe("validateUrbitBaseUrl", () => {
function expectValidBaseUrl(raw: string) {
const result = validateUrbitBaseUrl(raw);
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error(result.error);
}
return result;
}
it("adds https:// when scheme is missing and strips path/query fragments", () => {
const result = expectValidBaseUrl("example.com/foo?bar=baz");
expect(result.baseUrl).toBe("https://example.com");
expect(result.hostname).toBe("example.com");
});
it("rejects non-http schemes", () => {
const result = validateUrbitBaseUrl("file:///etc/passwd");
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error).toContain("http:// or https://");
});
it("rejects embedded credentials", () => {
const result = validateUrbitBaseUrl("https://user:pass@example.com");
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error).toContain("credentials");
});
it("normalizes a trailing dot in the hostname for origin construction", () => {
const result = expectValidBaseUrl("https://example.com./foo");
expect(result.baseUrl).toBe("https://example.com");
expect(result.hostname).toBe("example.com");
});
it("preserves port in the normalized origin", () => {
const result = expectValidBaseUrl("http://example.com:8080/~/login");
expect(result.baseUrl).toBe("http://example.com:8080");
});
});

View File

@@ -0,0 +1,62 @@
// Tlon plugin module implements base url behavior.
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
type UrbitBaseUrlValidation =
| { ok: true; baseUrl: string; hostname: string }
| { ok: false; error: string };
function hasScheme(value: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value);
}
export function normalizeUrbitHostname(hostname: string | undefined): string {
return (hostname ?? "").trim().toLowerCase().replace(/\.$/, "");
}
export function validateUrbitBaseUrl(raw: string): UrbitBaseUrlValidation {
const trimmed = raw.trim();
if (!trimmed) {
return { ok: false, error: "Required" };
}
const candidate = hasScheme(trimmed) ? trimmed : `https://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return { ok: false, error: "Invalid URL" };
}
if (!["http:", "https:"].includes(parsed.protocol)) {
return { ok: false, error: "URL must use http:// or https://" };
}
if (parsed.username || parsed.password) {
return { ok: false, error: "URL must not include credentials" };
}
const hostname = normalizeUrbitHostname(parsed.hostname);
if (!hostname) {
return { ok: false, error: "Invalid hostname" };
}
// Normalize to origin so callers can't smuggle paths/query fragments into the base URL,
// and strip a trailing dot from the hostname (DNS root label).
const isIpv6 = hostname.includes(":");
const host = parsed.port
? `${isIpv6 ? `[${hostname}]` : hostname}:${parsed.port}`
: isIpv6
? `[${hostname}]`
: hostname;
return { ok: true, baseUrl: `${parsed.protocol}//${host}`, hostname };
}
export function isBlockedUrbitHostname(hostname: string): boolean {
const normalized = normalizeUrbitHostname(hostname);
if (!normalized) {
return false;
}
return isBlockedHostnameOrIp(normalized);
}

View File

@@ -0,0 +1,37 @@
// Tlon tests cover channel ops plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { scryUrbitPath } from "./channel-ops.js";
import { urbitFetch } from "./fetch.js";
vi.mock("./fetch.js", () => ({
urbitFetch: vi.fn(),
}));
describe("Urbit channel operations", () => {
beforeEach(() => {
vi.mocked(urbitFetch).mockReset();
});
it("wraps malformed scry response JSON", async () => {
const release = vi.fn().mockResolvedValue(undefined);
vi.mocked(urbitFetch).mockResolvedValue({
response: new Response("{not json", {
status: 200,
headers: { "content-type": "application/json" },
}),
finalUrl: "https://example.com/~/scry/chat/inbox.json",
release,
});
await expect(
scryUrbitPath(
{
baseUrl: "https://example.com",
cookie: "urbauth-~zod=123",
},
{ path: "/chat/inbox.json", auditContext: "test" },
),
).rejects.toThrow("Urbit scry response was malformed JSON for path /chat/inbox.json");
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,153 @@
// Tlon plugin module implements channel ops behavior.
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { UrbitHttpError } from "./errors.js";
import { urbitFetch } from "./fetch.js";
type UrbitChannelDeps = {
baseUrl: string;
cookie: string;
ship: string;
channelId: string;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
async function putUrbitChannel(
deps: UrbitChannelDeps,
params: { body: unknown; auditContext: string },
) {
return await urbitFetch({
baseUrl: deps.baseUrl,
path: `/~/channel/${deps.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: deps.cookie,
},
body: JSON.stringify(params.body),
},
ssrfPolicy: deps.ssrfPolicy,
lookupFn: deps.lookupFn,
fetchImpl: deps.fetchImpl,
timeoutMs: 30_000,
auditContext: params.auditContext,
});
}
const TLON_ERROR_BODY_LIMIT_BYTES = 16 * 1024;
export async function pokeUrbitChannel(
deps: UrbitChannelDeps,
params: { app: string; mark: string; json: unknown; auditContext: string },
): Promise<number> {
const pokeId = Date.now();
const pokeData = {
id: pokeId,
action: "poke",
ship: deps.ship,
app: params.app,
mark: params.mark,
json: params.json,
};
const { response, release } = await putUrbitChannel(deps, {
body: [pokeData],
auditContext: params.auditContext,
});
try {
if (!response.ok && response.status !== 204) {
const errorText = await readResponseTextLimited(response, TLON_ERROR_BODY_LIMIT_BYTES).catch(() => "");
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
}
return pokeId;
} finally {
await release();
}
}
export async function scryUrbitPath(
deps: Pick<UrbitChannelDeps, "baseUrl" | "cookie" | "ssrfPolicy" | "lookupFn" | "fetchImpl">,
params: { path: string; auditContext: string },
): Promise<unknown> {
const scryPath = `/~/scry${params.path}`;
const { response, release } = await urbitFetch({
baseUrl: deps.baseUrl,
path: scryPath,
init: {
method: "GET",
headers: { Cookie: deps.cookie },
},
ssrfPolicy: deps.ssrfPolicy,
lookupFn: deps.lookupFn,
fetchImpl: deps.fetchImpl,
timeoutMs: 30_000,
auditContext: params.auditContext,
});
try {
if (!response.ok) {
throw new Error(`Scry failed: ${response.status} for path ${params.path}`);
}
try {
return await response.json();
} catch (cause) {
throw new Error(`Urbit scry response was malformed JSON for path ${params.path}`, { cause });
}
} finally {
await release();
}
}
async function createUrbitChannel(
deps: UrbitChannelDeps,
params: { body: unknown; auditContext: string },
): Promise<void> {
const { response, release } = await putUrbitChannel(deps, params);
try {
if (!response.ok && response.status !== 204) {
throw new UrbitHttpError({ operation: "Channel creation", status: response.status });
}
} finally {
await release();
}
}
async function wakeUrbitChannel(deps: UrbitChannelDeps): Promise<void> {
const { response, release } = await putUrbitChannel(deps, {
body: [
{
id: Date.now(),
action: "poke",
ship: deps.ship,
app: "hood",
mark: "helm-hi",
json: "Opening API channel",
},
],
auditContext: "tlon-urbit-channel-wake",
});
try {
if (!response.ok && response.status !== 204) {
throw new UrbitHttpError({ operation: "Channel activation", status: response.status });
}
} finally {
await release();
}
}
export async function ensureUrbitChannelOpen(
deps: UrbitChannelDeps,
params: { createBody: unknown; createAuditContext: string },
): Promise<void> {
await createUrbitChannel(deps, {
body: params.createBody,
auditContext: params.createAuditContext,
});
await wakeUrbitChannel(deps);
}

View File

@@ -0,0 +1,42 @@
// Tlon plugin module implements context behavior.
export { ssrfPolicyFromDangerouslyAllowPrivateNetwork } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeUrbitHostname, validateUrbitBaseUrl } from "./base-url.js";
import { UrbitUrlError } from "./errors.js";
type UrbitContext = {
baseUrl: string;
hostname: string;
ship: string;
};
function resolveShipFromHostname(hostname: string): string {
const trimmed = normalizeUrbitHostname(hostname);
if (!trimmed) {
return "";
}
if (trimmed.includes(".")) {
return trimmed.split(".")[0] ?? trimmed;
}
return trimmed;
}
function normalizeUrbitShip(ship: string | undefined, hostname: string): string {
const raw = ship?.replace(/^~/, "") ?? resolveShipFromHostname(hostname);
return raw.trim();
}
export function normalizeUrbitCookie(cookie: string): string {
return cookie.split(";")[0] ?? cookie;
}
export function getUrbitContext(url: string, ship?: string): UrbitContext {
const validated = validateUrbitBaseUrl(url);
if (!validated.ok) {
throw new UrbitUrlError(validated.error);
}
return {
baseUrl: validated.baseUrl,
hostname: validated.hostname,
ship: normalizeUrbitShip(ship, validated.hostname),
};
}

View File

@@ -0,0 +1,101 @@
import http from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
return {
...actual,
fetchWithSsrFGuard: async (params: {
url: string;
init?: RequestInit;
signal?: AbortSignal;
}) => ({
response: await fetch(params.url, { ...params.init, signal: params.signal }),
finalUrl: params.url,
release: async () => {},
}),
};
});
const { pokeUrbitChannel } = await import("./channel-ops.js");
const CHUNK = Buffer.alloc(64 * 1024, "X");
describe("tlon error body boundary", () => {
let server: http.Server;
afterEach(async () => {
vi.restoreAllMocks();
await new Promise<void>((resolve) => {
server?.close(() => resolve());
});
});
it("bounds poke error body at 16 KiB", async () => {
server = http.createServer((_req, res) => {
res.writeHead(500, { "Content-Type": "text/plain" });
let written = 0;
function write() {
if (written >= 4 * 1024 * 1024) {
res.end();
return;
}
const ok = res.write(CHUNK);
written += CHUNK.length;
if (ok) {
setImmediate(write);
} else {
res.once("drain", write);
}
}
write();
});
const port = await new Promise<number>((resolve) => {
server.listen(0, "127.0.0.1", () => {
resolve((server.address() as { port: number }).port);
});
});
const err = await pokeUrbitChannel(
{
baseUrl: `http://127.0.0.1:${port}`,
cookie: "urbit=cookie",
ship: "~zod",
channelId: "test",
},
{ app: "test", mark: "test", json: {}, auditContext: "test" },
).catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
const msg = (err as Error).message;
expect(Buffer.byteLength(msg, "utf8")).toBeLessThan(32 * 1024);
expect(msg).toContain("X");
});
it("preserves short error body when under cap", async () => {
server = http.createServer((_req, res) => {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("session expired");
});
const port = await new Promise<number>((resolve) => {
server.listen(0, "127.0.0.1", () => {
resolve((server.address() as { port: number }).port);
});
});
const err = await pokeUrbitChannel(
{
baseUrl: `http://127.0.0.1:${port}`,
cookie: "urbit=cookie",
ship: "~zod",
channelId: "test",
},
{ app: "test", mark: "test", json: {}, auditContext: "test" },
).catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toContain("session expired");
});
});

View File

@@ -0,0 +1,52 @@
// Tlon plugin module implements errors behavior.
type UrbitErrorCode =
| "invalid_url"
| "http_error"
| "auth_failed"
| "missing_cookie"
| "channel_not_open";
class UrbitError extends Error {
readonly code: UrbitErrorCode;
constructor(code: UrbitErrorCode, message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "UrbitError";
this.code = code;
}
}
export class UrbitUrlError extends UrbitError {
constructor(message: string, options?: { cause?: unknown }) {
super("invalid_url", message, options);
this.name = "UrbitUrlError";
}
}
export class UrbitHttpError extends UrbitError {
readonly status: number;
readonly operation: string;
readonly bodyText?: string;
constructor(params: { operation: string; status: number; bodyText?: string; cause?: unknown }) {
const suffix = params.bodyText ? ` - ${params.bodyText}` : "";
super("http_error", `${params.operation} failed: ${params.status}${suffix}`, {
cause: params.cause,
});
this.name = "UrbitHttpError";
this.status = params.status;
this.operation = params.operation;
this.bodyText = params.bodyText;
}
}
export class UrbitAuthError extends UrbitError {
constructor(
code: "auth_failed" | "missing_cookie",
message: string,
options?: { cause?: unknown },
) {
super(code, message, options);
this.name = "UrbitAuthError";
}
}

View File

@@ -0,0 +1,43 @@
// Tlon plugin module implements fetch behavior.
import {
fetchWithSsrFGuard,
type LookupFn,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { validateUrbitBaseUrl } from "./base-url.js";
import { UrbitUrlError } from "./errors.js";
type UrbitFetchOptions = {
baseUrl: string;
path: string;
init?: RequestInit;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
timeoutMs?: number;
maxRedirects?: number;
signal?: AbortSignal;
auditContext?: string;
pinDns?: boolean;
};
export async function urbitFetch(params: UrbitFetchOptions) {
const validated = validateUrbitBaseUrl(params.baseUrl);
if (!validated.ok) {
throw new UrbitUrlError(validated.error);
}
const url = new URL(params.path, validated.baseUrl).toString();
return await fetchWithSsrFGuard({
url,
fetchImpl: params.fetchImpl,
init: params.init,
timeoutMs: params.timeoutMs,
maxRedirects: params.maxRedirects,
signal: params.signal,
policy: params.ssrfPolicy,
lookupFn: params.lookupFn,
auditContext: params.auditContext,
pinDns: params.pinDns,
});
}

View File

@@ -0,0 +1,49 @@
/**
* Types for Urbit groups foreigns (group invites)
* Based on packages/shared/src/urbit/groups.ts from homestead
*/
interface GroupPreviewV7 {
meta: {
title: string;
description: string;
image: string;
cover: string;
};
"channel-count": number;
"member-count": number;
admissions: {
privacy: "public" | "private" | "secret";
};
}
interface ForeignInvite {
flag: string; // group flag e.g. "~host/group-name"
time: number; // timestamp
from: string; // ship that sent invite
token: string | null;
note: string | null;
preview: GroupPreviewV7;
valid: boolean; // tracks if invite has been revoked
}
type Lookup = "preview" | "done" | "error";
type Progress = "ask" | "join" | "watch" | "done" | "error";
interface Foreign {
invites: ForeignInvite[];
lookup: Lookup | null;
preview: GroupPreviewV7 | null;
progress: Progress | null;
token: string | null;
}
export interface Foreigns {
[flag: string]: Foreign;
}
// DM invite structure from chat /v3 firehose
export interface DmInvite {
ship: string;
// Additional fields may be present
}

View File

@@ -0,0 +1,84 @@
// Tlon tests cover send plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@urbit/aura", () => ({
scot: vi.fn(() => "mocked-ud"),
da: {
fromUnix: vi.fn(() => 123n),
},
}));
describe("sendDm", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses aura v3 helpers for the DM id", async () => {
const { sendDm } = await import("./send.js");
const aura = await import("@urbit/aura");
const scot = vi.mocked(aura.scot);
const fromUnix = vi.mocked(aura.da.fromUnix);
const sentAt = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(sentAt);
const poke = vi.fn(async () => ({}));
const result = await sendDm({
api: { poke },
fromShip: "~zod",
toShip: "~nec",
text: "hi",
});
expect(fromUnix).toHaveBeenCalledWith(sentAt);
expect(scot).toHaveBeenCalledWith("ud", 123n);
expect(poke).toHaveBeenCalledTimes(1);
expect(result.messageId).toBe("~zod/mocked-ud");
expect(result.receipt.primaryPlatformMessageId).toBe("~zod/mocked-ud");
});
it("passes numeric group reply ids through aura formatting", async () => {
const { sendGroupMessage } = await import("./send.js");
const aura = await import("@urbit/aura");
const scot = vi.mocked(aura.scot);
scot.mockReturnValueOnce("~2024.1.1");
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const poke = vi.fn(async () => ({}));
const result = await sendGroupMessage({
api: { poke },
fromShip: "~zod",
hostShip: "~nec",
channelName: "general",
text: "threaded",
replyToId: "1700000000000",
});
expect(scot).toHaveBeenCalledWith("ud", 1_700_000_000_000n);
expect(poke).toHaveBeenCalledWith({
app: "channels",
mark: "channel-action-1",
json: {
channel: {
nest: "chat/~nec/general",
action: {
post: {
reply: {
id: "~2024.1.1",
action: {
add: {
content: [{ inline: ["threaded"] }],
author: "~zod",
sent: 1_700_000_000_000,
},
},
},
},
},
},
},
});
expect(result.receipt.threadId).toBe("~nec/general");
});
});

View File

@@ -0,0 +1,229 @@
// Tlon plugin module implements send behavior.
import { scot, da } from "@urbit/aura";
import {
createMessageReceiptFromOutboundResults,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
import { markdownToStory, createImageBlock, isImageUrl, type Story } from "./story.js";
export type TlonPokeApi = {
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
};
type SendTextParams = {
api: TlonPokeApi;
fromShip: string;
toShip: string;
text: string;
};
type SendStoryParams = {
api: TlonPokeApi;
fromShip: string;
toShip: string;
story: Story;
kind?: MessageReceiptPartKind;
};
function createTlonSendReceipt(params: {
messageId: string;
conversationId: string;
kind: MessageReceiptPartKind;
}) {
return createMessageReceiptFromOutboundResults({
results: [
{
channel: "tlon",
messageId: params.messageId,
conversationId: params.conversationId,
},
],
threadId: params.conversationId,
kind: params.kind,
});
}
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
const story: Story = markdownToStory(text);
return sendDmWithStory({ api, fromShip, toShip, story, kind: "text" });
}
export async function sendDmWithStory({
api,
fromShip,
toShip,
story,
kind = "unknown",
}: SendStoryParams) {
const sentAt = Date.now();
const idUd = scot("ud", da.fromUnix(sentAt));
const id = `${fromShip}/${idUd}`;
const delta = {
add: {
memo: {
content: story,
author: fromShip,
sent: sentAt,
},
kind: null,
time: null,
},
};
const action = {
ship: toShip,
diff: { id, delta },
};
await api.poke({
app: "chat",
mark: "chat-dm-action",
json: action,
});
return {
channel: "tlon",
messageId: id,
receipt: createTlonSendReceipt({ messageId: id, conversationId: toShip, kind }),
};
}
type SendGroupParams = {
api: TlonPokeApi;
fromShip: string;
hostShip: string;
channelName: string;
text: string;
replyToId?: string | null;
};
type SendGroupStoryParams = {
api: TlonPokeApi;
fromShip: string;
hostShip: string;
channelName: string;
story: Story;
replyToId?: string | null;
kind?: MessageReceiptPartKind;
};
export async function sendGroupMessage({
api,
fromShip,
hostShip,
channelName,
text,
replyToId,
}: SendGroupParams) {
const story: Story = markdownToStory(text);
return sendGroupMessageWithStory({
api,
fromShip,
hostShip,
channelName,
story,
replyToId,
kind: "text",
});
}
export async function sendGroupMessageWithStory({
api,
fromShip,
hostShip,
channelName,
story,
replyToId,
kind = "unknown",
}: SendGroupStoryParams) {
const sentAt = Date.now();
// Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies
let formattedReplyId = replyToId;
if (replyToId && /^\d+$/.test(replyToId)) {
try {
// scot('ud', n) formats a number as @ud with dots
formattedReplyId = scot("ud", BigInt(replyToId));
} catch {
// Fall back to raw ID if formatting fails
}
}
const action = {
channel: {
nest: `chat/${hostShip}/${channelName}`,
action: formattedReplyId
? {
// Thread reply - needs post wrapper around reply action
// ReplyActionAdd takes Memo: {content, author, sent} - no kind/blob/meta
post: {
reply: {
id: formattedReplyId,
action: {
add: {
content: story,
author: fromShip,
sent: sentAt,
},
},
},
},
}
: {
// Regular post
post: {
add: {
content: story,
author: fromShip,
sent: sentAt,
kind: "/chat",
blob: null,
meta: null,
},
},
},
},
};
await api.poke({
app: "channels",
mark: "channel-action-1",
json: action,
});
const messageId = `${fromShip}/${sentAt}`;
return {
channel: "tlon",
messageId,
receipt: createTlonSendReceipt({
messageId,
conversationId: `${hostShip}/${channelName}`,
kind,
}),
};
}
/**
* Build a story with text and optional media (image)
*/
export function buildMediaStory(text: string | undefined, mediaUrl: string | undefined): Story {
const story: Story = [];
const cleanText = text?.trim() ?? "";
const cleanUrl = mediaUrl?.trim() ?? "";
// Add text content if present
if (cleanText) {
story.push(...markdownToStory(cleanText));
}
// Add image block if URL looks like an image
if (cleanUrl && isImageUrl(cleanUrl)) {
story.push(createImageBlock(cleanUrl, ""));
} else if (cleanUrl) {
// For non-image URLs, add as a link
story.push({ inline: [{ link: { href: cleanUrl, content: cleanUrl } }] });
}
return story.length > 0 ? story : [{ inline: [""] }];
}

View File

@@ -0,0 +1,262 @@
// Tlon tests cover sse client plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { urbitFetch } from "./fetch.js";
import { UrbitSSEClient } from "./sse-client.js";
// Mock urbitFetch to avoid real network calls
vi.mock("./fetch.js", () => ({
urbitFetch: vi.fn(),
}));
// Mock channel-ops to avoid real channel operations
vi.mock("./channel-ops.js", () => ({
ensureUrbitChannelOpen: vi.fn().mockResolvedValue(undefined),
pokeUrbitChannel: vi.fn().mockResolvedValue(undefined),
scryUrbitPath: vi.fn().mockResolvedValue({}),
}));
function requireFirstMockCall(calls: readonly unknown[][], label: string): unknown[] {
const call = calls.at(0);
if (!call) {
throw new Error(`Expected ${label} call`);
}
return call;
}
describe("UrbitSSEClient", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("subscribe", () => {
it("sends subscriptions added after connect", async () => {
const mockUrbitFetch = vi.mocked(urbitFetch);
mockUrbitFetch.mockResolvedValue({
response: { ok: true, status: 200 } as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
});
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
// Simulate connected state
(client as { isConnected: boolean }).isConnected = true;
await client.subscribe({
app: "chat",
path: "/dm/~zod",
event: () => {},
});
expect(mockUrbitFetch).toHaveBeenCalledTimes(1);
const callArgs = requireFirstMockCall(mockUrbitFetch.mock.calls, "urbit fetch")[0] as
| Parameters<typeof urbitFetch>[0]
| undefined;
if (!callArgs) {
throw new Error("Expected urbit fetch arguments");
}
expect(callArgs.path).toContain("/~/channel/");
expect(callArgs.init?.method).toBe("PUT");
const body = JSON.parse(callArgs.init?.body as string);
expect(body).toHaveLength(1);
expect(body[0]).toEqual({
id: 1,
action: "subscribe",
ship: "example",
app: "chat",
path: "/dm/~zod",
});
});
it("queues subscriptions before connect", async () => {
const mockUrbitFetch = vi.mocked(urbitFetch);
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
// Not connected yet
await client.subscribe({
app: "chat",
path: "/dm/~zod",
event: () => {},
});
// Should not call urbitFetch since not connected
expect(mockUrbitFetch).not.toHaveBeenCalled();
// But subscription should be queued
expect(client.subscriptions).toHaveLength(1);
expect(client.subscriptions[0]).toEqual({
id: 1,
action: "subscribe",
ship: "example",
app: "chat",
path: "/dm/~zod",
});
});
});
describe("updateCookie", () => {
it("normalizes cookie when updating", () => {
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
// Cookie with extra parts that should be stripped
client.updateCookie("urbauth-~zod=456; Path=/; HttpOnly");
expect(client.cookie).toBe("urbauth-~zod=456");
});
it("handles simple cookie values", () => {
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
client.updateCookie("urbauth-~zod=newvalue");
expect(client.cookie).toBe("urbauth-~zod=newvalue");
});
});
describe("reconnection", () => {
it("has autoReconnect enabled by default", () => {
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
expect(client.autoReconnect).toBe(true);
});
it("can disable autoReconnect via options", () => {
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
autoReconnect: false,
});
expect(client.autoReconnect).toBe(false);
});
it("stores onReconnect callback", () => {
const onReconnect = vi.fn();
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
onReconnect,
});
expect(client.onReconnect).toBe(onReconnect);
});
it("clamps oversized reconnect delays", () => {
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
reconnectDelay: Number.MAX_SAFE_INTEGER,
maxReconnectDelay: Number.MAX_SAFE_INTEGER,
});
expect(client.reconnectDelay).toBe(MAX_TIMER_TIMEOUT_MS);
expect(client.maxReconnectDelay).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("resets reconnect attempts on successful connect", async () => {
const mockUrbitFetch = vi.mocked(urbitFetch);
// Mock a response that returns a readable stream
const mockStream = new ReadableStream({
start(controller) {
controller.close();
},
});
mockUrbitFetch.mockResolvedValue({
response: {
ok: true,
status: 200,
body: mockStream,
} as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
});
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
autoReconnect: false, // Disable to prevent reconnect loop
});
client.reconnectAttempts = 5;
await client.connect();
expect(client.reconnectAttempts).toBe(0);
});
});
describe("event acking", () => {
it("logs malformed SSE JSON with an owned parser error", () => {
const logger = { error: vi.fn() };
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
logger,
});
client.processEvent("id: 1\ndata: {not json");
expect(logger.error).toHaveBeenCalledWith(
"Error parsing SSE event: Error: Tlon Urbit SSE event was malformed JSON",
);
});
it("ignores malformed event ids when deciding whether to ack", async () => {
const mockUrbitFetch = vi.mocked(urbitFetch);
mockUrbitFetch.mockResolvedValue({
response: { ok: true, status: 200 } as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
});
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
client.processEvent('id: 25abc\ndata: {"json":{"ok":true}}');
await Promise.resolve();
expect(mockUrbitFetch).not.toHaveBeenCalled();
expect((client as unknown as { lastHeardEventId: number }).lastHeardEventId).toBe(-1);
});
it("tracks lastHeardEventId and ackThreshold", () => {
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
// Access private properties for testing
const lastHeardEventId = (client as unknown as { lastHeardEventId: number }).lastHeardEventId;
const ackThreshold = (client as unknown as { ackThreshold: number }).ackThreshold;
expect(lastHeardEventId).toBe(-1);
expect(ackThreshold).toBeGreaterThan(0);
});
});
describe("constructor", () => {
it("generates unique channel ID", () => {
const client1 = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
const client2 = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
expect(client1.channelId).not.toBe(client2.channelId);
});
it("normalizes cookie in constructor", () => {
const client = new UrbitSSEClient(
"https://example.com",
"urbauth-~zod=123; Path=/; HttpOnly",
);
expect(client.cookie).toBe("urbauth-~zod=123");
});
it("sets default reconnection parameters", () => {
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
expect(client.maxReconnectAttempts).toBe(10);
expect(client.reconnectDelay).toBe(1000);
expect(client.maxReconnectDelay).toBe(30000);
});
it("allows overriding reconnection parameters", () => {
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
maxReconnectAttempts: 5,
reconnectDelay: 500,
maxReconnectDelay: 10000,
});
expect(client.maxReconnectAttempts).toBe(5);
expect(client.reconnectDelay).toBe(500);
expect(client.maxReconnectDelay).toBe(10000);
});
});
});

View File

@@ -0,0 +1,508 @@
// Tlon plugin module implements sse client behavior.
import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { urbitFetch } from "./fetch.js";
type UrbitSseLogger = {
log?: (message: string) => void;
error?: (message: string) => void;
};
type UrbitSseOptions = {
ship?: string;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
onReconnect?: (client: UrbitSSEClient) => Promise<void> | void;
autoReconnect?: boolean;
maxReconnectAttempts?: number;
reconnectDelay?: number;
maxReconnectDelay?: number;
logger?: UrbitSseLogger;
};
function parseUrbitSsePayload(data: string): { id?: number; json?: unknown; response?: string } {
try {
return JSON.parse(data) as { id?: number; json?: unknown; response?: string };
} catch (cause) {
throw new Error("Tlon Urbit SSE event was malformed JSON", { cause });
}
}
function parseUrbitSseEventId(value: string): number | null {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) ? parsed : null;
}
export class UrbitSSEClient {
url: string;
cookie: string;
ship: string;
channelId: string;
channelUrl: string;
subscriptions: Array<{
id: number;
action: "subscribe";
ship: string;
app: string;
path: string;
}> = [];
eventHandlers = new Map<
number,
{ event?: (data: unknown) => void; err?: (error: unknown) => void; quit?: () => void }
>();
aborted = false;
streamController: AbortController | null = null;
onReconnect: UrbitSseOptions["onReconnect"] | null;
autoReconnect: boolean;
reconnectAttempts = 0;
maxReconnectAttempts: number;
reconnectDelay: number;
maxReconnectDelay: number;
isConnected = false;
logger: UrbitSseLogger;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
streamRelease: (() => Promise<void>) | null = null;
// Event ack tracking - must ack every ~50 events to keep channel healthy
private lastHeardEventId = -1;
private lastAcknowledgedEventId = -1;
private readonly ackThreshold = 20;
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
const ctx = getUrbitContext(url, options.ship);
this.url = ctx.baseUrl;
this.cookie = normalizeUrbitCookie(cookie);
this.ship = ctx.ship;
this.channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`;
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
this.onReconnect = options.onReconnect ?? null;
this.autoReconnect = options.autoReconnect !== false;
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
this.reconnectDelay = resolveTimerTimeoutMs(options.reconnectDelay, 1000);
this.maxReconnectDelay = resolveTimerTimeoutMs(options.maxReconnectDelay, 30000);
this.logger = options.logger ?? {};
this.ssrfPolicy = options.ssrfPolicy;
this.lookupFn = options.lookupFn;
this.fetchImpl = options.fetchImpl;
}
private channelRequestContext() {
return {
baseUrl: this.url,
cookie: this.cookie,
ship: this.ship,
channelId: this.channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
};
}
async subscribe(params: {
app: string;
path: string;
event?: (data: unknown) => void;
err?: (error: unknown) => void;
quit?: () => void;
}) {
const subId = this.subscriptions.length + 1;
const subscription = {
id: subId,
action: "subscribe",
ship: this.ship,
app: params.app,
path: params.path,
} as const;
this.subscriptions.push(subscription);
this.eventHandlers.set(subId, { event: params.event, err: params.err, quit: params.quit });
if (this.isConnected) {
try {
await this.sendSubscription(subscription);
} catch (error) {
const handler = this.eventHandlers.get(subId);
handler?.err?.(error);
}
}
return subId;
}
private async sendSubscription(subscription: {
id: number;
action: "subscribe";
ship: string;
app: string;
path: string;
}) {
const { response, release } = await this.putChannelPayload([subscription], {
timeoutMs: 30_000,
auditContext: "tlon-urbit-subscribe",
});
try {
if (!response.ok && response.status !== 204) {
const errorText = await readResponseTextLimited(response, 16 * 1024).catch(() => "");
throw new Error(
`Subscribe failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`,
);
}
} finally {
await release();
}
}
async connect() {
await ensureUrbitChannelOpen(this.channelRequestContext(), {
createBody: this.subscriptions,
createAuditContext: "tlon-urbit-channel-create",
});
await this.openStream();
this.isConnected = true;
this.reconnectAttempts = 0;
}
async openStream() {
// Use AbortController with manual timeout so we only abort during initial connection,
// not after the SSE stream is established and actively streaming.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60_000);
this.streamController = controller;
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "GET",
headers: {
Accept: "text/event-stream",
Cookie: this.cookie,
},
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
signal: controller.signal,
auditContext: "tlon-urbit-sse-stream",
});
this.streamRelease = release;
// Clear timeout once connection established (headers received).
clearTimeout(timeoutId);
if (!response.ok) {
await release();
this.streamRelease = null;
throw new Error(`Stream connection failed: ${response.status}`);
}
this.processStream(response.body).catch((error: unknown) => {
if (!this.aborted) {
this.logger.error?.(`Stream error: ${String(error)}`);
for (const { err } of this.eventHandlers.values()) {
if (err) {
err(error);
}
}
}
});
}
async processStream(body: unknown) {
if (!body) {
return;
}
// Bridge DOM fetch stream types to Node's stream/web declaration on newer TS/node combos.
const stream =
body instanceof ReadableStream
? Readable.fromWeb(body as never)
: (body as NodeJS.ReadableStream);
let buffer = "";
try {
for await (const chunk of stream) {
if (this.aborted) {
break;
}
buffer += chunk.toString();
let eventEnd;
while ((eventEnd = buffer.indexOf("\n\n")) !== -1) {
const eventData = buffer.slice(0, eventEnd);
buffer = buffer.slice(eventEnd + 2);
this.processEvent(eventData);
}
}
} finally {
if (this.streamRelease) {
const release = this.streamRelease;
this.streamRelease = null;
await release();
}
this.streamController = null;
if (!this.aborted && this.autoReconnect) {
this.isConnected = false;
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
await this.attemptReconnect();
}
}
}
processEvent(eventData: string) {
const lines = eventData.split("\n");
let data: string | null = null;
let eventId: number | null = null;
for (const line of lines) {
if (line.startsWith("id: ")) {
eventId = parseUrbitSseEventId(line.slice(4));
}
if (line.startsWith("data: ")) {
data = line.slice(6);
}
}
if (!data) {
return;
}
// Track event ID and send ack if needed
if (eventId !== null && !Number.isNaN(eventId)) {
if (eventId > this.lastHeardEventId) {
this.lastHeardEventId = eventId;
if (eventId - this.lastAcknowledgedEventId > this.ackThreshold) {
this.logger.log?.(
`[SSE] Acking event ${eventId} (last acked: ${this.lastAcknowledgedEventId})`,
);
this.ack(eventId).catch((err: unknown) => {
this.logger.error?.(`Failed to ack event ${eventId}: ${String(err)}`);
});
}
}
}
try {
const parsed = parseUrbitSsePayload(data);
if (parsed.response === "quit") {
if (parsed.id) {
const handlers = this.eventHandlers.get(parsed.id);
if (handlers?.quit) {
handlers.quit();
}
}
return;
}
if (parsed.id && this.eventHandlers.has(parsed.id)) {
const { event } = this.eventHandlers.get(parsed.id) ?? {};
if (event && parsed.json) {
event(parsed.json);
}
} else if (parsed.json) {
for (const { event } of this.eventHandlers.values()) {
if (event) {
event(parsed.json);
}
}
}
} catch (error) {
this.logger.error?.(`Error parsing SSE event: ${String(error)}`);
}
}
async poke(params: { app: string; mark: string; json: unknown }) {
return await pokeUrbitChannel(this.channelRequestContext(), {
...params,
auditContext: "tlon-urbit-poke",
});
}
async scry(path: string) {
return await scryUrbitPath(
{
baseUrl: this.url,
cookie: this.cookie,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{ path, auditContext: "tlon-urbit-scry" },
);
}
/**
* Update the cookie used for authentication.
* Call this when re-authenticating after session expiry.
*/
updateCookie(newCookie: string): void {
this.cookie = normalizeUrbitCookie(newCookie);
}
private async ack(eventId: number): Promise<void> {
this.lastAcknowledgedEventId = eventId;
const ackData = {
id: Date.now(),
action: "ack",
"event-id": eventId,
};
const { response, release } = await this.putChannelPayload([ackData], {
timeoutMs: 10_000,
auditContext: "tlon-urbit-ack",
});
try {
if (!response.ok) {
throw new Error(`Ack failed with status ${response.status}`);
}
} finally {
await release();
}
}
async attemptReconnect() {
if (this.aborted || !this.autoReconnect) {
this.logger.log?.("[SSE] Reconnection aborted or disabled");
return;
}
// If we've hit max attempts, wait longer then reset and keep trying
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.logger.log?.(
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Waiting 10s before resetting...`,
);
// Wait 10 seconds before resetting and trying again
const extendedBackoff = 10000; // 10 seconds
await new Promise((resolve) => {
setTimeout(resolve, extendedBackoff);
});
this.reconnectAttempts = 0; // Reset counter to continue trying
this.logger.log?.("[SSE] Reconnection attempts reset, resuming reconnection...");
}
this.reconnectAttempts += 1;
const delay = Math.min(
this.reconnectDelay * 2 ** (this.reconnectAttempts - 1),
this.maxReconnectDelay,
);
this.logger.log?.(
`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`,
);
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
try {
this.channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`;
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
if (this.onReconnect) {
await this.onReconnect(this);
}
await this.connect();
this.logger.log?.("[SSE] Reconnection successful!");
} catch (error) {
this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`);
await this.attemptReconnect();
}
}
async close() {
this.aborted = true;
this.isConnected = false;
this.streamController?.abort();
try {
const unsubscribes = this.subscriptions.map((sub) => ({
id: sub.id,
action: "unsubscribe",
subscription: sub.id,
}));
{
const { response, release } = await this.putChannelPayload(unsubscribes, {
timeoutMs: 30_000,
auditContext: "tlon-urbit-unsubscribe",
});
try {
void response.body?.cancel();
} finally {
await release();
}
}
{
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "DELETE",
headers: {
Cookie: this.cookie,
},
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-close",
});
try {
void response.body?.cancel();
} finally {
await release();
}
}
} catch (error) {
this.logger.error?.(`Error closing channel: ${String(error)}`);
}
if (this.streamRelease) {
const release = this.streamRelease;
this.streamRelease = null;
await release();
}
}
private async putChannelPayload(
payload: unknown,
params: { timeoutMs: number; auditContext: string },
) {
return await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(payload),
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: params.timeoutMs,
auditContext: params.auditContext,
});
}
}

View File

@@ -0,0 +1,327 @@
/**
* Tlon Story Format - Rich text converter
*
* Converts markdown-like text to Tlon's story format.
*/
// Inline content types
type StoryInline =
| string
| { bold: StoryInline[] }
| { italics: StoryInline[] }
| { strike: StoryInline[] }
| { blockquote: StoryInline[] }
| { "inline-code": string }
| { code: string }
| { ship: string }
| { link: { href: string; content: string } }
| { break: null }
| { tag: string };
// Block content types
type StoryBlock =
| { header: { tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; content: StoryInline[] } }
| { code: { code: string; lang: string } }
| { image: { src: string; height: number; width: number; alt: string } }
| { rule: null }
| { listing: StoryListing };
type StoryListing =
| {
list: {
type: "ordered" | "unordered" | "tasklist";
items: StoryListing[];
contents: StoryInline[];
};
}
| { item: StoryInline[] };
// A verse is either a block or inline content
type StoryVerse = { block: StoryBlock } | { inline: StoryInline[] };
// A story is a list of verses
export type Story = StoryVerse[];
/**
* Parse inline markdown formatting (bold, italic, code, links, mentions)
*/
function parseInlineMarkdown(text: string): StoryInline[] {
const result: StoryInline[] = [];
let remaining = text;
while (remaining.length > 0) {
// Ship mentions: ~sampel-palnet
const shipMatch = remaining.match(/^(~[a-z][-a-z0-9]*)/);
if (shipMatch) {
result.push({ ship: shipMatch[1] });
remaining = remaining.slice(shipMatch[0].length);
continue;
}
// Bold: **text** or __text__
const boldMatch = remaining.match(/^\*\*(.+?)\*\*|^__(.+?)__/);
if (boldMatch) {
const content = boldMatch[1] || boldMatch[2];
result.push({ bold: parseInlineMarkdown(content) });
remaining = remaining.slice(boldMatch[0].length);
continue;
}
// Italics: *text* or _text_ (but not inside words for _)
const italicsMatch = remaining.match(/^\*([^*]+?)\*|^_([^_]+?)_(?![a-zA-Z0-9])/);
if (italicsMatch) {
const content = italicsMatch[1] || italicsMatch[2];
result.push({ italics: parseInlineMarkdown(content) });
remaining = remaining.slice(italicsMatch[0].length);
continue;
}
// Strikethrough: ~~text~~
const strikeMatch = remaining.match(/^~~(.+?)~~/);
if (strikeMatch) {
result.push({ strike: parseInlineMarkdown(strikeMatch[1]) });
remaining = remaining.slice(strikeMatch[0].length);
continue;
}
// Inline code: `code`
const codeMatch = remaining.match(/^`([^`]+)`/);
if (codeMatch) {
result.push({ "inline-code": codeMatch[1] });
remaining = remaining.slice(codeMatch[0].length);
continue;
}
// Links: [text](url)
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
result.push({ link: { href: linkMatch[2], content: linkMatch[1] } });
remaining = remaining.slice(linkMatch[0].length);
continue;
}
// Markdown images: ![alt](url)
const imageMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
if (imageMatch) {
// Return a special marker that will be hoisted to a block
result.push({
__image: { src: imageMatch[2], alt: imageMatch[1] },
} as unknown as StoryInline);
remaining = remaining.slice(imageMatch[0].length);
continue;
}
// Plain URL detection
const urlMatch = remaining.match(/^(https?:\/\/[^\s<>"\]]+)/);
if (urlMatch) {
result.push({ link: { href: urlMatch[1], content: urlMatch[1] } });
remaining = remaining.slice(urlMatch[0].length);
continue;
}
// Hashtags: #tag - disabled, chat UI doesn't render them
// const tagMatch = remaining.match(/^#([a-zA-Z][a-zA-Z0-9_-]*)/);
// if (tagMatch) {
// result.push({ tag: tagMatch[1] });
// remaining = remaining.slice(tagMatch[0].length);
// continue;
// }
// Plain text: consume until next special character or URL start
// Exclude : and / to allow URL detection to work (stops before https://)
const plainMatch = remaining.match(/^[^*_`~[#\n:/]+/);
if (plainMatch) {
result.push(plainMatch[0]);
remaining = remaining.slice(plainMatch[0].length);
continue;
}
// Single special char that didn't match a pattern
result.push(remaining[0]);
remaining = remaining.slice(1);
}
// Merge adjacent strings
return mergeAdjacentStrings(result);
}
/**
* Merge adjacent string elements in an inline array
*/
function mergeAdjacentStrings(inlines: StoryInline[]): StoryInline[] {
const result: StoryInline[] = [];
for (const item of inlines) {
if (typeof item === "string" && typeof result[result.length - 1] === "string") {
result[result.length - 1] = (result[result.length - 1] as string) + item;
} else {
result.push(item);
}
}
return result;
}
/**
* Create an image block
*/
export function createImageBlock(src: string, alt = "", height = 0, width = 0): StoryVerse {
return {
block: {
image: { src, height, width, alt },
},
};
}
/**
* Check if URL looks like an image
*/
export function isImageUrl(url: string): boolean {
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i;
return imageExtensions.test(url);
}
/**
* Process inlines and extract any image markers into blocks
*/
function processInlinesForImages(inlines: StoryInline[]): {
inlines: StoryInline[];
imageBlocks: StoryVerse[];
} {
const cleanInlines: StoryInline[] = [];
const imageBlocks: StoryVerse[] = [];
for (const inline of inlines) {
if (typeof inline === "object" && "__image" in inline) {
const img = (inline as unknown as { __image: { src: string; alt: string } })["__image"];
imageBlocks.push(createImageBlock(img.src, img.alt));
} else {
cleanInlines.push(inline);
}
}
return { inlines: cleanInlines, imageBlocks };
}
/**
* Convert markdown text to Tlon story format
*/
export function markdownToStory(markdown: string): Story {
const story: Story = [];
const lines = markdown.split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Code block: ```lang\ncode\n```
if (line.startsWith("```")) {
const lang = line.slice(3).trim() || "plaintext";
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
story.push({
block: {
code: {
code: codeLines.join("\n"),
lang,
},
},
});
i++; // skip closing ```
continue;
}
// Headers: # H1, ## H2, etc.
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6;
const tag = `h${level}` as const;
story.push({
block: {
header: {
tag,
content: parseInlineMarkdown(headerMatch[2]),
},
},
});
i++;
continue;
}
// Horizontal rule: --- or ***
if (/^(-{3,}|\*{3,})$/.test(line.trim())) {
story.push({ block: { rule: null } });
i++;
continue;
}
// Blockquote: > text
if (line.startsWith("> ")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].startsWith("> ")) {
quoteLines.push(lines[i].slice(2));
i++;
}
const quoteText = quoteLines.join("\n");
story.push({
inline: [{ blockquote: parseInlineMarkdown(quoteText) }],
});
continue;
}
// Empty line - skip
if (line.trim() === "") {
i++;
continue;
}
// Regular paragraph - collect consecutive non-empty lines
const paragraphLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].startsWith("#") &&
!lines[i].startsWith("```") &&
!lines[i].startsWith("> ") &&
!/^(-{3,}|\*{3,})$/.test(lines[i].trim())
) {
paragraphLines.push(lines[i]);
i++;
}
if (paragraphLines.length > 0) {
const paragraphText = paragraphLines.join("\n");
// Convert newlines within paragraph to break elements
const inlines = parseInlineMarkdown(paragraphText);
// Replace \n in strings with break elements
const withBreaks: StoryInline[] = [];
for (const inline of inlines) {
if (typeof inline === "string" && inline.includes("\n")) {
const parts = inline.split("\n");
for (let j = 0; j < parts.length; j++) {
if (parts[j]) {
withBreaks.push(parts[j]);
}
if (j < parts.length - 1) {
withBreaks.push({ break: null });
}
}
} else {
withBreaks.push(inline);
}
}
// Extract any images from inlines and add as separate blocks
const { inlines: cleanInlines, imageBlocks } = processInlinesForImages(withBreaks);
if (cleanInlines.length > 0) {
story.push({ inline: cleanInlines });
}
story.push(...imageBlocks);
}
}
return story;
}

View File

@@ -0,0 +1,156 @@
// Tlon tests cover upload plugin behavior.
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { uploadFile } from "../tlon-api.js";
import { uploadImageFromUrl } from "./upload.js";
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: vi.fn(),
}));
vi.mock("../tlon-api.js", () => ({
uploadFile: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithSsrFGuard);
const mockUploadFile = vi.mocked(uploadFile);
type FetchMock = typeof mockFetch;
function mockSuccessfulFetch(params: {
mockFetch: FetchMock;
blob: Blob;
finalUrl: string;
contentType: string;
}) {
params.mockFetch.mockResolvedValue({
response: {
ok: true,
headers: new Headers({ "content-type": params.contentType }),
blob: () => Promise.resolve(params.blob),
} as unknown as Response,
finalUrl: params.finalUrl,
release: vi.fn().mockResolvedValue(undefined),
});
}
async function setupSuccessfulUpload(params?: {
sourceUrl?: string;
contentType?: string;
uploadedUrl?: string;
}) {
const sourceUrl = params?.sourceUrl ?? "https://example.com/image.png";
const contentType = params?.contentType ?? "image/png";
const mockBlob = new Blob(["fake-image"], { type: contentType });
mockSuccessfulFetch({
mockFetch,
blob: mockBlob,
finalUrl: sourceUrl,
contentType,
});
if (params?.uploadedUrl) {
mockUploadFile.mockResolvedValue({ url: params.uploadedUrl });
}
return { mockBlob };
}
function requireUploadParams(): { blob?: Blob; contentType?: string; fileName?: string } {
const [call] = mockUploadFile.mock.calls;
if (!call) {
throw new Error("expected Tlon uploadFile call");
}
const [uploadParams] = call;
if (!uploadParams || typeof uploadParams !== "object" || Array.isArray(uploadParams)) {
throw new Error("expected Tlon uploadFile params");
}
return uploadParams as { blob?: Blob; contentType?: string; fileName?: string };
}
describe("uploadImageFromUrl", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches image and calls uploadFile, returns uploaded URL", async () => {
const { mockBlob } = await setupSuccessfulUpload({
uploadedUrl: "https://memex.tlon.network/uploaded.png",
});
const result = await uploadImageFromUrl("https://example.com/image.png");
expect(result).toBe("https://memex.tlon.network/uploaded.png");
expect(mockUploadFile).toHaveBeenCalledTimes(1);
const uploadParams = requireUploadParams();
expect(uploadParams.blob).toBe(mockBlob);
expect(uploadParams.contentType).toBe("image/png");
});
it("returns original URL if fetch fails", async () => {
mockFetch.mockResolvedValue({
response: {
ok: false,
status: 404,
} as unknown as Response,
finalUrl: "https://example.com/image.png",
release: vi.fn().mockResolvedValue(undefined),
});
const result = await uploadImageFromUrl("https://example.com/image.png");
expect(result).toBe("https://example.com/image.png");
});
it("returns original URL if upload fails", async () => {
await setupSuccessfulUpload();
mockUploadFile.mockRejectedValue(new Error("Upload failed"));
const result = await uploadImageFromUrl("https://example.com/image.png");
expect(result).toBe("https://example.com/image.png");
});
it("rejects non-http(s) URLs", async () => {
const result = await uploadImageFromUrl("file:///etc/passwd");
expect(result).toBe("file:///etc/passwd");
const result2 = await uploadImageFromUrl("ftp://example.com/image.png");
expect(result2).toBe("ftp://example.com/image.png");
});
it("handles invalid URLs gracefully", async () => {
const result = await uploadImageFromUrl("not-a-valid-url");
expect(result).toBe("not-a-valid-url");
});
it("extracts filename from URL path", async () => {
const mockBlob = new Blob(["fake-image"], { type: "image/jpeg" });
mockSuccessfulFetch({
mockFetch,
blob: mockBlob,
finalUrl: "https://example.com/path/to/my-image.jpg",
contentType: "image/jpeg",
});
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.jpg" });
await uploadImageFromUrl("https://example.com/path/to/my-image.jpg");
expect(requireUploadParams().fileName).toBe("my-image.jpg");
});
it("uses default filename when URL has no path", async () => {
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
mockSuccessfulFetch({
mockFetch,
blob: mockBlob,
finalUrl: "https://example.com/",
contentType: "image/png",
});
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
await uploadImageFromUrl("https://example.com/");
expect(requireUploadParams().fileName).toMatch(/^upload-\d+\.png$/);
});
});

View File

@@ -0,0 +1,59 @@
/**
* Upload an image from a URL to Tlon storage.
*/
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { uploadFile } from "../tlon-api.js";
/**
* Fetch an image from a URL and upload it to Tlon storage.
* Returns the uploaded URL, or falls back to the original URL on error.
*
* Note: configureClient must be called before using this function.
*/
export async function uploadImageFromUrl(imageUrl: string): Promise<string> {
try {
// Validate URL is http/https before fetching
const url = new URL(imageUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
console.warn(`[tlon] Rejected non-http(s) URL: ${imageUrl}`);
return imageUrl;
}
// Fetch the image with SSRF protection
// Use fetchWithSsrFGuard directly (not urbitFetch) to preserve the full URL path
const { response, release } = await fetchWithSsrFGuard({
url: imageUrl,
init: { method: "GET" },
policy: undefined,
auditContext: "tlon-upload-image",
});
try {
if (!response.ok) {
console.warn(`[tlon] Failed to fetch image from ${imageUrl}: ${response.status}`);
return imageUrl;
}
const contentType = response.headers.get("content-type") || "image/png";
const blob = await response.blob();
// Extract filename from URL or use a default
const urlPath = new URL(imageUrl).pathname;
const fileName = urlPath.split("/").pop() || `upload-${Date.now()}.png`;
// Upload to Tlon storage
const result = await uploadFile({
blob,
fileName,
contentType,
});
return result.url;
} finally {
await release();
}
} catch (err) {
console.warn(`[tlon] Failed to upload image, using original URL: ${String(err)}`);
return imageUrl;
}
}

View File

@@ -0,0 +1,2 @@
// Tlon API module exposes the plugin public contract.
export { tlonPlugin } from "./src/channel.js";

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}