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
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:
5
extensions/tlon/README.md
Normal file
5
extensions/tlon/README.md
Normal 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
17
extensions/tlon/api.ts
Normal 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";
|
||||
2
extensions/tlon/channel-plugin-api.ts
Normal file
2
extensions/tlon/channel-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Tlon API module exposes the plugin public contract.
|
||||
export { tlonPlugin } from "./src/channel.js";
|
||||
2
extensions/tlon/doctor-contract-api.ts
Normal file
2
extensions/tlon/doctor-contract-api.ts
Normal 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
17
extensions/tlon/index.ts
Normal 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
544
extensions/tlon/npm-shrinkwrap.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
extensions/tlon/openclaw.plugin.json
Normal file
15
extensions/tlon/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
87
extensions/tlon/package.json
Normal file
87
extensions/tlon/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
17
extensions/tlon/runtime-api.ts
Normal file
17
extensions/tlon/runtime-api.ts
Normal 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";
|
||||
3
extensions/tlon/setup-api.ts
Normal file
3
extensions/tlon/setup-api.ts
Normal 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";
|
||||
10
extensions/tlon/setup-entry.ts
Normal file
10
extensions/tlon/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
32
extensions/tlon/src/account-fields.ts
Normal file
32
extensions/tlon/src/account-fields.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
147
extensions/tlon/src/channel.message-adapter.test.ts
Normal file
147
extensions/tlon/src/channel.message-adapter.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
261
extensions/tlon/src/channel.runtime.ts
Normal file
261
extensions/tlon/src/channel.runtime.ts
Normal 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 };
|
||||
193
extensions/tlon/src/channel.ts
Normal file
193
extensions/tlon/src/channel.ts
Normal 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,
|
||||
});
|
||||
55
extensions/tlon/src/config-schema.ts
Normal file
55
extensions/tlon/src/config-schema.ts
Normal 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);
|
||||
299
extensions/tlon/src/core.test.ts
Normal file
299
extensions/tlon/src/core.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
10
extensions/tlon/src/doctor-contract.ts
Normal file
10
extensions/tlon/src/doctor-contract.ts
Normal 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;
|
||||
47
extensions/tlon/src/doctor.test.ts
Normal file
47
extensions/tlon/src/doctor.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
11
extensions/tlon/src/doctor.ts
Normal file
11
extensions/tlon/src/doctor.ts
Normal 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,
|
||||
};
|
||||
2
extensions/tlon/src/logger-runtime.ts
Normal file
2
extensions/tlon/src/logger-runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Tlon plugin module implements logger runtime behavior.
|
||||
export { createLoggerBackedRuntime } from "openclaw/plugin-sdk/runtime";
|
||||
364
extensions/tlon/src/monitor/approval-runtime.ts
Normal file
364
extensions/tlon/src/monitor/approval-runtime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
95
extensions/tlon/src/monitor/approval.test.ts
Normal file
95
extensions/tlon/src/monitor/approval.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
260
extensions/tlon/src/monitor/approval.ts
Normal file
260
extensions/tlon/src/monitor/approval.ts
Normal 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")}`;
|
||||
}
|
||||
31
extensions/tlon/src/monitor/authorization.ts
Normal file
31
extensions/tlon/src/monitor/authorization.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
55
extensions/tlon/src/monitor/cites.ts
Normal file
55
extensions/tlon/src/monitor/cites.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
69
extensions/tlon/src/monitor/discovery.ts
Normal file
69
extensions/tlon/src/monitor/discovery.ts
Normal 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;
|
||||
}
|
||||
227
extensions/tlon/src/monitor/history.ts
Normal file
227
extensions/tlon/src/monitor/history.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
1532
extensions/tlon/src/monitor/index.ts
Normal file
1532
extensions/tlon/src/monitor/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
81
extensions/tlon/src/monitor/media.test.ts
Normal file
81
extensions/tlon/src/monitor/media.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
156
extensions/tlon/src/monitor/media.ts
Normal file
156
extensions/tlon/src/monitor/media.ts
Normal 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;
|
||||
}
|
||||
59
extensions/tlon/src/monitor/processed-messages.test.ts
Normal file
59
extensions/tlon/src/monitor/processed-messages.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
90
extensions/tlon/src/monitor/processed-messages.ts
Normal file
90
extensions/tlon/src/monitor/processed-messages.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
114
extensions/tlon/src/monitor/settings-helpers.test.ts
Normal file
114
extensions/tlon/src/monitor/settings-helpers.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
151
extensions/tlon/src/monitor/settings-helpers.ts
Normal file
151
extensions/tlon/src/monitor/settings-helpers.ts
Normal 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 ?? [])]);
|
||||
}
|
||||
403
extensions/tlon/src/monitor/utils.ts
Normal file
403
extensions/tlon/src/monitor/utils.ts
Normal 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));
|
||||
}
|
||||
10
extensions/tlon/src/runtime.ts
Normal file
10
extensions/tlon/src/runtime.ts
Normal 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 };
|
||||
654
extensions/tlon/src/security.test.ts
Normal file
654
extensions/tlon/src/security.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
41
extensions/tlon/src/session-route.ts
Normal file
41
extensions/tlon/src/session-route.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
391
extensions/tlon/src/settings.ts
Normal file
391
extensions/tlon/src/settings.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
232
extensions/tlon/src/setup-core.ts
Normal file
232
extensions/tlon/src/setup-core.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
98
extensions/tlon/src/setup-surface.ts
Normal file
98
extensions/tlon/src/setup-surface.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
103
extensions/tlon/src/targets.ts
Normal file
103
extensions/tlon/src/targets.ts
Normal 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";
|
||||
}
|
||||
573
extensions/tlon/src/tlon-api.test.ts
Normal file
573
extensions/tlon/src/tlon-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
390
extensions/tlon/src/tlon-api.ts
Normal file
390
extensions/tlon/src/tlon-api.ts
Normal 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") };
|
||||
}
|
||||
161
extensions/tlon/src/types.ts
Normal file
161
extensions/tlon/src/types.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
46
extensions/tlon/src/urbit/auth.ssrf.test.ts
Normal file
46
extensions/tlon/src/urbit/auth.ssrf.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
49
extensions/tlon/src/urbit/auth.ts
Normal file
49
extensions/tlon/src/urbit/auth.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
49
extensions/tlon/src/urbit/base-url.test.ts
Normal file
49
extensions/tlon/src/urbit/base-url.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
62
extensions/tlon/src/urbit/base-url.ts
Normal file
62
extensions/tlon/src/urbit/base-url.ts
Normal 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);
|
||||
}
|
||||
37
extensions/tlon/src/urbit/channel-ops.test.ts
Normal file
37
extensions/tlon/src/urbit/channel-ops.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
extensions/tlon/src/urbit/channel-ops.ts
Normal file
153
extensions/tlon/src/urbit/channel-ops.ts
Normal 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);
|
||||
}
|
||||
42
extensions/tlon/src/urbit/context.ts
Normal file
42
extensions/tlon/src/urbit/context.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
101
extensions/tlon/src/urbit/error-body-boundary.test.ts
Normal file
101
extensions/tlon/src/urbit/error-body-boundary.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
52
extensions/tlon/src/urbit/errors.ts
Normal file
52
extensions/tlon/src/urbit/errors.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
43
extensions/tlon/src/urbit/fetch.ts
Normal file
43
extensions/tlon/src/urbit/fetch.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
49
extensions/tlon/src/urbit/foreigns.ts
Normal file
49
extensions/tlon/src/urbit/foreigns.ts
Normal 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
|
||||
}
|
||||
84
extensions/tlon/src/urbit/send.test.ts
Normal file
84
extensions/tlon/src/urbit/send.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
229
extensions/tlon/src/urbit/send.ts
Normal file
229
extensions/tlon/src/urbit/send.ts
Normal 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: [""] }];
|
||||
}
|
||||
262
extensions/tlon/src/urbit/sse-client.test.ts
Normal file
262
extensions/tlon/src/urbit/sse-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
508
extensions/tlon/src/urbit/sse-client.ts
Normal file
508
extensions/tlon/src/urbit/sse-client.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
327
extensions/tlon/src/urbit/story.ts
Normal file
327
extensions/tlon/src/urbit/story.ts
Normal 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: 
|
||||
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;
|
||||
}
|
||||
156
extensions/tlon/src/urbit/upload.test.ts
Normal file
156
extensions/tlon/src/urbit/upload.test.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
59
extensions/tlon/src/urbit/upload.ts
Normal file
59
extensions/tlon/src/urbit/upload.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2
extensions/tlon/test-api.ts
Normal file
2
extensions/tlon/test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Tlon API module exposes the plugin public contract.
|
||||
export { tlonPlugin } from "./src/channel.js";
|
||||
16
extensions/tlon/tsconfig.json
Normal file
16
extensions/tlon/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user