openclaw-openagent 1.0.8 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/src/app/remote-agent-tool.js +108 -10
  2. package/dist/src/auth/config.js +65 -10
  3. package/dist/src/channel.js +2 -1
  4. package/dist/src/plugin-ui/assets/bg.png +0 -0
  5. package/dist/src/plugin-ui/assets/icon.png +0 -0
  6. package/dist/src/plugin-ui/assets/openagent-override.js +1753 -774
  7. package/dist/src/plugin-ui/ui-extension-loader/registry-regex.js +53 -5
  8. package/dist/src/proxy/auth-proxy.d.ts +1 -0
  9. package/dist/src/proxy/auth-proxy.js +28 -9
  10. package/dist/src/transport/oasn/oasn-http.d.ts +12 -0
  11. package/dist/src/transport/oasn/oasn-http.js +45 -14
  12. package/dist/src/util/url-resolver.d.ts +6 -0
  13. package/dist/src/util/url-resolver.js +24 -8
  14. package/openclaw.plugin.json +16 -1
  15. package/package.json +3 -3
  16. package/src/app/remote-agent-tool.ts +126 -11
  17. package/src/auth/config.ts +79 -10
  18. package/src/channel.ts +2 -1
  19. package/src/plugin-ui/assets/bg.png +0 -0
  20. package/src/plugin-ui/assets/icon.png +0 -0
  21. package/src/plugin-ui/assets/openagent-override.js +1753 -774
  22. package/src/plugin-ui/build.cjs +80 -4
  23. package/src/plugin-ui/modules/agent-book/panel/agent-book.js +92 -9
  24. package/src/plugin-ui/modules/agent-book/panel/agent-card.js +26 -10
  25. package/src/plugin-ui/modules/agent-book/panel/agent-data.js +1 -1
  26. package/src/plugin-ui/modules/agent-book/panel/inject-ui.js +64 -0
  27. package/src/plugin-ui/modules/agent-book/panel/styles.js +313 -178
  28. package/src/plugin-ui/modules/loader/bootstrap.js +1 -1
  29. package/src/plugin-ui/modules/loader/shared-state.js +54 -0
  30. package/src/plugin-ui/modules/remote-agent/execution-card.js +347 -124
  31. package/src/plugin-ui/modules/remote-agent/media-links.js +151 -0
  32. package/src/plugin-ui/modules/remote-agent/output-card.js +110 -33
  33. package/src/plugin-ui/modules/remote-agent/render-hooks.js +97 -18
  34. package/src/plugin-ui/modules/remote-agent/styles.js +488 -402
  35. package/src/plugin-ui/modules/remote-agent/tool-card-model.js +6 -0
  36. package/src/plugin-ui/ui-extension-loader/registry-regex.ts +50 -5
  37. package/src/proxy/auth-proxy.ts +30 -10
  38. package/src/transport/oasn/oasn-http.ts +60 -14
  39. package/src/util/url-resolver.ts +24 -8
@@ -14,6 +14,8 @@ import { backupFile, restoreFile } from './backup.js';
14
14
  import { makeMarker, hasMarker } from './types.js';
15
15
  // ── 常量 ──────────────────────────────────────────────────────────────────
16
16
  const OVERRIDE_ASSET_URL = new URL('../assets/openagent-override.js', import.meta.url);
17
+ const ICON_ASSET_URL = new URL('../assets/icon.png', import.meta.url);
18
+ const BG_ASSET_URL = new URL('../assets/bg.png', import.meta.url);
17
19
  function getOverrideAssetHash() {
18
20
  return createHash('sha256')
19
21
  .update(readFileSync(OVERRIDE_ASSET_URL))
@@ -113,6 +115,27 @@ const overrideScript = {
113
115
  }
114
116
  copyFileSync(OVERRIDE_ASSET_URL, dest);
115
117
  logger.info('[ui-ext:regex] override-script: deployed openagent-override.js');
118
+ // Deploy icon.png alongside
119
+ if (existsSync(ICON_ASSET_URL)) {
120
+ const iconDest = join(controlUiDir, 'icon.png');
121
+ try {
122
+ copyFileSync(ICON_ASSET_URL, iconDest);
123
+ logger.info('[ui-ext:regex] override-script: deployed icon.png');
124
+ }
125
+ catch (err) {
126
+ logger.warn('[ui-ext:regex] override-script: failed to deploy icon.png');
127
+ }
128
+ }
129
+ if (existsSync(BG_ASSET_URL)) {
130
+ const bgDest = join(controlUiDir, 'bg.png');
131
+ try {
132
+ copyFileSync(BG_ASSET_URL, bgDest);
133
+ logger.info('[ui-ext:regex] override-script: deployed bg.png');
134
+ }
135
+ catch (err) {
136
+ logger.warn('[ui-ext:regex] override-script: failed to deploy bg.png');
137
+ }
138
+ }
116
139
  return true;
117
140
  },
118
141
  revert(controlUiDir) {
@@ -121,12 +144,28 @@ const overrideScript = {
121
144
  unlinkSync(dest);
122
145
  logger.info('[ui-ext:regex] override-script: removed');
123
146
  }
147
+ const iconDest = join(controlUiDir, 'icon.png');
148
+ if (existsSync(iconDest)) {
149
+ try {
150
+ unlinkSync(iconDest);
151
+ logger.info('[ui-ext:regex] override-script: removed icon.png');
152
+ }
153
+ catch { }
154
+ }
155
+ const bgDest = join(controlUiDir, 'bg.png');
156
+ if (existsSync(bgDest)) {
157
+ try {
158
+ unlinkSync(bgDest);
159
+ logger.info('[ui-ext:regex] override-script: removed bg.png');
160
+ }
161
+ catch { }
162
+ }
124
163
  },
125
164
  };
126
165
  // ── Extension 2: html-script-tag ─────────────────────────────────────────
127
166
  const htmlScriptTag = {
128
167
  id: 'html-script-tag',
129
- version: 4,
168
+ version: 5,
130
169
  alwaysApply: true,
131
170
  apply(controlUiDir) {
132
171
  const indexPath = join(controlUiDir, 'index.html');
@@ -136,11 +175,21 @@ const htmlScriptTag = {
136
175
  }
137
176
  let html = readFileSync(indexPath, 'utf-8');
138
177
  const htmlMarker = `<!--openagent:${this.id}-->`;
178
+ const markerPattern = /(?:\/\*openagent:html-script-tag\*\/|<!--openagent:html-script-tag-->)/;
139
179
  const overrideHash = getOverrideAssetHash();
140
180
  const scriptTag = `${htmlMarker}<script src="./openagent-override.js?v=${overrideHash}"></script>`;
141
181
  const markedScriptPattern = /(?:\/\*openagent:html-script-tag\*\/|<!--openagent:html-script-tag-->)<script[^>]*openagent-override[^>]*><\/script>\n?\s*/g;
142
- if (html.includes(htmlMarker) || hasMarker(html, this.id)) {
143
- const nextHtml = html.replace(markedScriptPattern, `${scriptTag}\n `);
182
+ const oldScriptPattern = /<script[^>]*openagent-override[^>]*><\/script>\n?\s*/g;
183
+ if (markerPattern.test(html)) {
184
+ let nextHtml = html.replace(markedScriptPattern, `${scriptTag}\n `);
185
+ if (nextHtml === html && !nextHtml.includes('openagent-override.js')) {
186
+ nextHtml = nextHtml.replace(markerPattern, `${scriptTag}\n `);
187
+ }
188
+ else if (nextHtml === html && nextHtml.includes('openagent-override.js')) {
189
+ nextHtml = nextHtml
190
+ .replace(oldScriptPattern, '')
191
+ .replace(markerPattern, `${scriptTag}\n `);
192
+ }
144
193
  if (nextHtml === html) {
145
194
  logger.debug('[ui-ext:regex] html-script-tag: already applied');
146
195
  }
@@ -150,7 +199,6 @@ const htmlScriptTag = {
150
199
  }
151
200
  return true;
152
201
  }
153
- const oldScriptPattern = /<script[^>]*openagent-override[^>]*><\/script>\n?\s*/g;
154
202
  if (oldScriptPattern.test(html)) {
155
203
  html = html.replace(oldScriptPattern, '');
156
204
  }
@@ -333,7 +381,7 @@ const toolcardRender = {
333
381
  toolMessageOk = false;
334
382
  }
335
383
  if (toolMessageOk) {
336
- const toolMessageHook = `${marker}let __openagentToolMsgCard=null;console.log('[openagent] toolMsg hook: cards='+${cardsVar}.length+' mediaVar='+${mediaVar});if(!${mediaVar}&&window.__openagentRenderToolCard){for(let __openagentI=${cardsVar}.length-1;__openagentI>=0&&!__openagentToolMsgCard;__openagentI--){let __openagentCandidate=${cardsVar}[__openagentI];if(__openagentCandidate&&(__openagentCandidate.outputText||__openagentCandidate.text))__openagentToolMsgCard=window.__openagentRenderToolCard(__openagentCandidate,${toolMessageSidebarArg})}for(let __openagentI=0;__openagentI<${cardsVar}.length&&!__openagentToolMsgCard;__openagentI++){__openagentToolMsgCard=window.__openagentRenderToolCard(${cardsVar}[__openagentI],${toolMessageSidebarArg})}}if(__openagentToolMsgCard)return ${toolMessageHtmlVar}\`<div class="\${${chatBubbleClassVar}}">\${__openagentToolMsgCard}</div>\`;`;
384
+ const toolMessageHook = `${marker}/* openagent-media-aware-tool-message */let __openagentToolMsgCard=null;if(window.__openagentRenderToolCard){for(let __openagentI=${cardsVar}.length-1;__openagentI>=0&&!__openagentToolMsgCard;__openagentI--){let __openagentCandidate=${cardsVar}[__openagentI];if(__openagentCandidate&&(__openagentCandidate.outputText||__openagentCandidate.text))__openagentToolMsgCard=window.__openagentRenderToolCard(__openagentCandidate,${toolMessageSidebarArg})}for(let __openagentI=0;__openagentI<${cardsVar}.length&&!__openagentToolMsgCard;__openagentI++){__openagentToolMsgCard=window.__openagentRenderToolCard(${cardsVar}[__openagentI],${toolMessageSidebarArg})}}if(__openagentToolMsgCard)return ${toolMessageHtmlVar}\`<div class="\${${chatBubbleClassVar}}">\${__openagentToolMsgCard}</div>\`;`;
337
385
  const toolMessageInsertAt = toolMessageFn.bodyStart + toolMessageReturnIdx;
338
386
  content = content.substring(0, toolMessageInsertAt) + toolMessageHook + content.substring(toolMessageInsertAt);
339
387
  patchedToolMessageWrapper = true;
@@ -17,4 +17,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
17
17
  /** Returns the gateway base URL as seen by the browser (e.g. http://localhost:18794). */
18
18
  export declare function getGatewayBaseUrl(): string;
19
19
  export declare function createAuthProxyHandler(): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
20
+ export declare function resolveOasnProxyTargetUrl(upstreamBase: string, targetPath: string, search?: string): string;
20
21
  export declare function isAllowedOasnProxyRequest(method: string, pathname: string): boolean;
@@ -18,6 +18,7 @@ import nodePath from 'node:path';
18
18
  import { logger } from '../util/logger.js';
19
19
  import { registry } from '../runtime/registry.js';
20
20
  import { isUnconfiguredOasnApiBase } from '../auth/config.js';
21
+ import { resolveOasnRelativeUrl } from '../util/url-resolver.js';
21
22
  /** TIM 路径默认上游(兼容旧版) */
22
23
  const AUTH_UPSTREAM_TIM = 'https://auth.ai-talk.live';
23
24
  const PROXY_TIMEOUT_MS = 15_000;
@@ -56,7 +57,7 @@ function pickUpstream(method, pathname) {
56
57
  let _gatewayBaseUrl;
57
58
  /** Returns the gateway base URL as seen by the browser (e.g. http://localhost:18794). */
58
59
  export function getGatewayBaseUrl() {
59
- return _gatewayBaseUrl ?? `http://localhost:18794`;
60
+ return _gatewayBaseUrl ?? 'http://localhost:18794';
60
61
  }
61
62
  // ── Handler factory ─────────────────────────────────────────────────────
62
63
  export function createAuthProxyHandler() {
@@ -76,19 +77,19 @@ export function createAuthProxyHandler() {
76
77
  _gatewayBaseUrl = `${proto}://${host}`;
77
78
  logger.info(`[proxy] Gateway base URL detected: ${_gatewayBaseUrl}`);
78
79
  }
79
- // ── T4: Serve local media files from /tmp/openclaw/openagent-media/ ──
80
- // Files downloaded from remote agents via C2C are stored here.
81
- // Tool result references them as MEDIA:http://localhost:PORT/plugins/openagent/media/{filename}
82
- // to pass through the framework's filterToolResultMediaUrls (http URLs bypass whitelist check).
83
- if (url.pathname.startsWith('/media/')) {
84
- return serveLocalMedia(url.pathname, res);
85
- }
86
80
  // req.url may or may not have the /plugins/openagent prefix stripped by Gateway
87
81
  // (behavior varies across Gateway versions), so strip it defensively.
88
82
  let targetPath = url.pathname;
89
83
  if (targetPath.startsWith('/plugins/openagent')) {
90
84
  targetPath = targetPath.slice('/plugins/openagent'.length);
91
85
  }
86
+ // ── T4: Serve local media files from /tmp/openclaw/openagent-media/ ──
87
+ // Files downloaded from remote agents are stored here. The media route must
88
+ // be detected after prefix normalization because Gateway versions disagree
89
+ // on whether /plugins/openagent has already been stripped.
90
+ if (targetPath.startsWith('/media/')) {
91
+ return serveLocalMedia(targetPath, res);
92
+ }
92
93
  // v3.9+ 双轨抽象:上游按当前 transport 切换(OASN → api.oasn.ai;TIM → auth.ai-talk.live)
93
94
  const method = req.method ?? 'GET';
94
95
  const rt = registry.getDefault();
@@ -116,7 +117,7 @@ export function createAuthProxyHandler() {
116
117
  writeJson(res, 503, { error: 'OASN api_base is not configured' });
117
118
  return true;
118
119
  }
119
- const targetUrl = new URL(targetPath + url.search, upstreamBase).href;
120
+ const targetUrl = resolveOasnProxyTargetUrl(upstreamBase, targetPath, url.search);
120
121
  logger.info(`[proxy] ${method} ${url.pathname} → ${targetUrl}`);
121
122
  try {
122
123
  // Read request body for non-GET methods
@@ -173,6 +174,9 @@ export function createAuthProxyHandler() {
173
174
  return true;
174
175
  };
175
176
  }
177
+ export function resolveOasnProxyTargetUrl(upstreamBase, targetPath, search = '') {
178
+ return resolveOasnRelativeUrl(upstreamBase, `${targetPath}${search}`);
179
+ }
176
180
  // ── T4: Local media serve ────────────────────────────────────────────────
177
181
  const MEDIA_DIR = '/tmp/openclaw/openagent-media';
178
182
  /** MIME type lookup for common media extensions. */
@@ -190,6 +194,12 @@ const MIME_MAP = {
190
194
  '.webp': 'image/webp',
191
195
  '.pdf': 'application/pdf',
192
196
  '.txt': 'text/plain',
197
+ '.md': 'text/markdown; charset=utf-8',
198
+ '.json': 'application/json; charset=utf-8',
199
+ '.zip': 'application/zip',
200
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
201
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
202
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
193
203
  };
194
204
  /**
195
205
  * Serve a file from the local openagent-media directory.
@@ -230,6 +240,9 @@ function serveLocalMedia(pathname, res) {
230
240
  const stat = fs.statSync(filePath);
231
241
  res.setHeader('Content-Type', contentType);
232
242
  res.setHeader('Content-Length', stat.size);
243
+ if (shouldDownloadAsAttachment(contentType)) {
244
+ res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`);
245
+ }
233
246
  // Allow browser to cache media for 1 hour
234
247
  res.setHeader('Cache-Control', 'private, max-age=3600');
235
248
  const stream = fs.createReadStream(filePath);
@@ -252,6 +265,12 @@ function serveLocalMedia(pathname, res) {
252
265
  }
253
266
  return true;
254
267
  }
268
+ function shouldDownloadAsAttachment(contentType) {
269
+ return !contentType.startsWith('image/')
270
+ && !contentType.startsWith('audio/')
271
+ && !contentType.startsWith('video/')
272
+ && !contentType.startsWith('text/plain');
273
+ }
255
274
  // ── Helpers ──────────────────────────────────────────────────────────────
256
275
  /**
257
276
  * Build headers for the upstream request.
@@ -18,6 +18,8 @@
18
18
  * - 业务幂等持久化(详见 oasn-invocation.ts + state/store.ts)
19
19
  * - file_ref LRU(详见 oasn-files.ts)
20
20
  */
21
+ import { type RequestOptions } from 'node:http';
22
+ import { URL } from 'node:url';
21
23
  /** OASN HTTP 客户端配置 */
22
24
  export interface OasnHttpConfig {
23
25
  /** API base URL,如 `https://api.oasn.ai` */
@@ -45,6 +47,16 @@ export interface OasnRequestOptions {
45
47
  /** Abort 信号 */
46
48
  signal?: AbortSignal;
47
49
  }
50
+ /**
51
+ * OpenClaw gateway may install global-agent for model/provider traffic. OASN
52
+ * REL endpoints should keep their original TLS hostname; otherwise a local
53
+ * proxy can turn `https://oasn-test...` into a certificate check against
54
+ * `localhost`.
55
+ */
56
+ export declare function ensureOasnHostBypassesGlobalAgentProxy(hostname: string): void;
57
+ export declare function buildNodeRequestOptions(url: URL, method: string, headers: Record<string, string>): RequestOptions & {
58
+ servername?: string;
59
+ };
48
60
  export declare class OasnHttpClient {
49
61
  private _config;
50
62
  constructor(config: OasnHttpConfig);
@@ -25,6 +25,47 @@ import { logger } from '../../util/logger.js';
25
25
  import { makeTransportError } from '../types.js';
26
26
  const DEFAULT_TIMEOUT_MS = 30_000;
27
27
  const DEFAULT_MAX_RETRIES = 3;
28
+ /**
29
+ * OpenClaw gateway may install global-agent for model/provider traffic. OASN
30
+ * REL endpoints should keep their original TLS hostname; otherwise a local
31
+ * proxy can turn `https://oasn-test...` into a certificate check against
32
+ * `localhost`.
33
+ */
34
+ export function ensureOasnHostBypassesGlobalAgentProxy(hostname) {
35
+ const normalized = hostname.trim().toLowerCase();
36
+ if (!normalized || normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1')
37
+ return;
38
+ const globalAgent = globalThis.GLOBAL_AGENT;
39
+ if (!globalAgent || typeof globalAgent !== 'object')
40
+ return;
41
+ const existing = String(globalAgent.NO_PROXY || '');
42
+ const entries = existing.split(/[\s,]+/).filter(Boolean);
43
+ if (entries.some((entry) => entry.toLowerCase() === normalized))
44
+ return;
45
+ globalAgent.NO_PROXY = entries.length > 0 ? `${entries.join(',')},${normalized}` : normalized;
46
+ }
47
+ function hasHeader(headers, name) {
48
+ const lower = name.toLowerCase();
49
+ return Object.keys(headers).some((key) => key.toLowerCase() === lower);
50
+ }
51
+ export function buildNodeRequestOptions(url, method, headers) {
52
+ const isHttps = url.protocol === 'https:';
53
+ const requestHeaders = { ...headers };
54
+ if (!hasHeader(requestHeaders, 'Host')) {
55
+ requestHeaders.Host = url.host;
56
+ }
57
+ const options = {
58
+ method,
59
+ headers: requestHeaders,
60
+ hostname: url.hostname,
61
+ port: url.port || (isHttps ? 443 : 80),
62
+ path: `${url.pathname}${url.search}`,
63
+ };
64
+ if (isHttps) {
65
+ options.servername = url.hostname;
66
+ }
67
+ return options;
68
+ }
28
69
  // ───────────────────────────────────────────────────────────────
29
70
  // 错误转换
30
71
  // ───────────────────────────────────────────────────────────────
@@ -241,14 +282,9 @@ export class OasnHttpClient {
241
282
  _doRawRequest(url, method, headers, body, timeoutMs, signal) {
242
283
  const isHttps = url.protocol === 'https:';
243
284
  const requestFn = isHttps ? httpsRequest : httpRequest;
285
+ ensureOasnHostBypassesGlobalAgentProxy(url.hostname);
244
286
  return new Promise((resolve, reject) => {
245
- const reqOptions = {
246
- method,
247
- headers,
248
- hostname: url.hostname,
249
- port: url.port || (isHttps ? 443 : 80),
250
- path: `${url.pathname}${url.search}`,
251
- };
287
+ const reqOptions = buildNodeRequestOptions(url, method, headers);
252
288
  let settled = false;
253
289
  let timeoutHandle;
254
290
  const finish = (fn) => {
@@ -302,14 +338,9 @@ export class OasnHttpClient {
302
338
  _doBinaryRequest(url, headers, timeoutMs, signal) {
303
339
  const isHttps = url.protocol === 'https:';
304
340
  const requestFn = isHttps ? httpsRequest : httpRequest;
341
+ ensureOasnHostBypassesGlobalAgentProxy(url.hostname);
305
342
  return new Promise((resolve, reject) => {
306
- const reqOptions = {
307
- method: 'GET',
308
- headers,
309
- hostname: url.hostname,
310
- port: url.port || (isHttps ? 443 : 80),
311
- path: `${url.pathname}${url.search}`,
312
- };
343
+ const reqOptions = buildNodeRequestOptions(url, 'GET', headers);
313
344
  let settled = false;
314
345
  let timeoutHandle;
315
346
  const finish = (fn) => {
@@ -5,6 +5,12 @@
5
5
  * Used by remote-agent-tool (WebUI continuation) and download-file-tool (artifact download).
6
6
  */
7
7
  export declare function resolveOasnAccessUrl(accessApiBase: string | undefined, url: string): string;
8
+ /**
9
+ * Resolve an OASN API/WebUI-relative path without losing env prefixes such as
10
+ * `/e/limj1`. `new URL('/api/..', 'https://host/e/limj1')` resets to the
11
+ * origin root, so use explicit path joining for env-scoped public bases.
12
+ */
13
+ export declare function resolveOasnRelativeUrl(accessApiBase: string, path: string): string;
8
14
  /**
9
15
  * Resolve a WebUI continuation URL for user-facing browser clicks.
10
16
  *
@@ -12,12 +12,22 @@ export function resolveOasnAccessUrl(accessApiBase, url) {
12
12
  if (!accessApiBase)
13
13
  return url;
14
14
  try {
15
- return new URL(url, accessApiBase).toString();
15
+ return resolveOasnRelativeUrl(accessApiBase, url);
16
16
  }
17
17
  catch {
18
18
  return url;
19
19
  }
20
20
  }
21
+ /**
22
+ * Resolve an OASN API/WebUI-relative path without losing env prefixes such as
23
+ * `/e/limj1`. `new URL('/api/..', 'https://host/e/limj1')` resets to the
24
+ * origin root, so use explicit path joining for env-scoped public bases.
25
+ */
26
+ export function resolveOasnRelativeUrl(accessApiBase, path) {
27
+ const base = accessApiBase.replace(/\/+$/, '');
28
+ const suffix = path.startsWith('/') ? path : `/${path}`;
29
+ return new URL(`${base}${suffix}`).toString();
30
+ }
21
31
  /**
22
32
  * Resolve a WebUI continuation URL for user-facing browser clicks.
23
33
  *
@@ -28,23 +38,29 @@ export function resolveOasnAccessUrl(accessApiBase, url) {
28
38
  export function resolveOasnWebuiUrl(accessApiBase, claimUrl, url) {
29
39
  if (!url)
30
40
  return url;
31
- const claimOrigin = originOf(claimUrl);
32
- if (!claimOrigin || !isWebuiUrl(url, accessApiBase || claimOrigin)) {
41
+ const claimPublicBase = publicBaseOfClaimUrl(claimUrl);
42
+ if (!claimPublicBase || !isWebuiUrl(url, accessApiBase || claimPublicBase)) {
33
43
  return resolveOasnAccessUrl(accessApiBase, url);
34
44
  }
35
45
  try {
36
- const absolute = new URL(url, accessApiBase || claimOrigin);
37
- return new URL(`${absolute.pathname}${absolute.search}${absolute.hash}`, claimOrigin).toString();
46
+ const absolute = new URL(resolveOasnAccessUrl(accessApiBase || claimPublicBase, url));
47
+ const webuiIndex = absolute.pathname.indexOf('/webui/');
48
+ const path = webuiIndex >= 0 ? absolute.pathname.slice(webuiIndex) : absolute.pathname;
49
+ return resolveOasnRelativeUrl(claimPublicBase, `${path}${absolute.search}${absolute.hash}`);
38
50
  }
39
51
  catch {
40
52
  return resolveOasnAccessUrl(accessApiBase, url);
41
53
  }
42
54
  }
43
- function originOf(value) {
55
+ function publicBaseOfClaimUrl(value) {
44
56
  if (!value)
45
57
  return undefined;
46
58
  try {
47
- return new URL(value).origin;
59
+ const parsed = new URL(value);
60
+ const claimPathIndex = parsed.pathname.indexOf('/agents/claim');
61
+ if (claimPathIndex <= 0)
62
+ return parsed.origin;
63
+ return `${parsed.origin}${parsed.pathname.slice(0, claimPathIndex)}`;
48
64
  }
49
65
  catch {
50
66
  return undefined;
@@ -52,7 +68,7 @@ function originOf(value) {
52
68
  }
53
69
  function isWebuiUrl(url, base) {
54
70
  try {
55
- return new URL(url, base).pathname.startsWith('/webui/');
71
+ return /(?:^|\/)webui\//.test(new URL(resolveOasnAccessUrl(base, url)).pathname);
56
72
  }
57
73
  catch {
58
74
  return false;
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-openagent",
3
3
  "name": "OpenAgent",
4
4
  "description": "OpenAgent channel plugin — real-time messaging between AI agents via OASN",
5
- "version": "1.0.0",
5
+ "version": "1.0.10",
6
6
  "contracts": {
7
7
  "tools": [
8
8
  "openagent_call_remote_agent",
@@ -37,6 +37,11 @@
37
37
  "description": "OASN API base URL. Required for OASN transport.",
38
38
  "default": "https://oasn.invalid/CONFIGURE_ME"
39
39
  },
40
+ "oasn_env_id": {
41
+ "type": "string",
42
+ "title": "OASN Env ID",
43
+ "description": "REL environment id, e.g. limj1. When api_base is not set, the plugin derives https://oasn-test.haimawan.com/e/<env_id>."
44
+ },
40
45
  "access_api_base": {
41
46
  "type": "string",
42
47
  "title": "OASN Access API Base URL",
@@ -70,6 +75,11 @@
70
75
  "description": "OASN API base URL",
71
76
  "default": "https://oasn.invalid/CONFIGURE_ME"
72
77
  },
78
+ "oasn_env_id": {
79
+ "type": "string",
80
+ "title": "OASN Env ID",
81
+ "description": "REL environment id, e.g. limj1. Used to derive api_base/access_api_base when explicit URLs are omitted."
82
+ },
73
83
  "access_api_base": {
74
84
  "type": "string",
75
85
  "title": "Access API Base URL",
@@ -130,6 +140,11 @@
130
140
  "description": "OASN API base URL",
131
141
  "default": "https://oasn.invalid/CONFIGURE_ME"
132
142
  },
143
+ "oasn_env_id": {
144
+ "type": "string",
145
+ "title": "OASN Env ID",
146
+ "description": "REL environment id, e.g. limj1. Used to derive api_base/access_api_base when explicit URLs are omitted."
147
+ },
133
148
  "access_api_base": {
134
149
  "type": "string",
135
150
  "title": "Access API Base URL",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-openagent",
3
- "version": "1.0.8",
3
+ "version": "1.0.11",
4
4
  "type": "module",
5
5
  "description": "OpenAgent channel plugin for OpenClaw",
6
6
  "license": "MIT",
@@ -47,13 +47,13 @@
47
47
  "node": ">=22"
48
48
  },
49
49
  "scripts": {
50
- "build": "tsc -p tsconfig.build.json && cp -r src/sdk dist/src/sdk && mkdir -p dist/src/plugin-ui/assets && cp src/plugin-ui/assets/openagent-override.js dist/src/plugin-ui/assets/",
50
+ "build": "tsc -p tsconfig.build.json && cp -r src/sdk dist/src/sdk && mkdir -p dist/src/plugin-ui/assets && cp src/plugin-ui/assets/openagent-override.js src/plugin-ui/assets/icon.png src/plugin-ui/assets/bg.png dist/src/plugin-ui/assets/",
51
51
  "build:override": "node ./src/plugin-ui/build.cjs",
52
52
  "watch:override": "node ./src/plugin-ui/build.cjs --watch",
53
53
  "prepack": "npm run build:override && npm run build && npm run typecheck",
54
54
  "typecheck": "tsc --noEmit",
55
55
  "prepublishOnly": "npm run typecheck",
56
- "test:contract": "node tests/contract.test.mjs && node tests/agent-book-batch-category.test.mjs",
56
+ "test:contract": "node tests/contract.test.mjs && node tests/agent-book-batch-category.test.mjs && tsx tests/ui-extension-loader.test.mts",
57
57
  "test:smoke": "node tests/smoke.test.mjs",
58
58
  "test": "npm run test:contract && npm run test:smoke"
59
59
  },
@@ -26,7 +26,7 @@ import {
26
26
  type ToolUpdateCallback,
27
27
  } from './types.js';
28
28
  import { sendC2CCustomMessage, waitForC2CReply } from '../tim/c2c.js';
29
- import type { OasnTransport, TaskHandle, TaskRequest } from '../transport/types.js';
29
+ import type { ArtifactRef, OasnTransport, TaskHandle, TaskRequest } from '../transport/types.js';
30
30
  import fs from 'node:fs';
31
31
  import nodePath from 'node:path';
32
32
  import { resolveOasnAccessUrl, resolveOasnWebuiUrl } from '../util/url-resolver.js';
@@ -91,6 +91,118 @@ function artifactContentToText(content: ArrayBuffer): string {
91
91
  return Buffer.from(content).toString('utf8').trim();
92
92
  }
93
93
 
94
+ const OPENAGENT_MEDIA_DIR = '/tmp/openclaw/openagent-media';
95
+
96
+ const EXT_BY_MIME: Record<string, string> = {
97
+ 'application/json': '.json',
98
+ 'text/markdown': '.md',
99
+ 'text/plain': '.txt',
100
+ 'application/pdf': '.pdf',
101
+ 'image/png': '.png',
102
+ 'image/jpeg': '.jpg',
103
+ 'image/gif': '.gif',
104
+ 'image/webp': '.webp',
105
+ 'application/zip': '.zip',
106
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
107
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
108
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
109
+ };
110
+
111
+ function sanitizeMediaFileName(name: string): string {
112
+ const base = nodePath.basename(name || 'artifact');
113
+ return base
114
+ .replace(/[\0\r\n]/g, '')
115
+ .replace(/[/:\\]/g, '_')
116
+ .replace(/^\.+$/, 'artifact')
117
+ .slice(0, 180) || 'artifact';
118
+ }
119
+
120
+ function inferExtension(buffer: Buffer, mimeType: string): string {
121
+ const mime = mimeType.split(';')[0]?.trim().toLowerCase() || '';
122
+ const byMime = EXT_BY_MIME[mime];
123
+ if (byMime) return byMime;
124
+
125
+ if (buffer.subarray(0, 2).toString('latin1') === 'PK') {
126
+ const zipNames = buffer.toString('latin1');
127
+ if (zipNames.includes('[Content_Types].xml') && zipNames.includes('ppt/')) return '.pptx';
128
+ if (zipNames.includes('[Content_Types].xml') && zipNames.includes('word/')) return '.docx';
129
+ if (zipNames.includes('[Content_Types].xml') && zipNames.includes('xl/')) return '.xlsx';
130
+ return '.zip';
131
+ }
132
+
133
+ if (buffer.subarray(0, 4).toString('latin1') === '%PDF') return '.pdf';
134
+ if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png';
135
+ if (buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return '.jpg';
136
+ return '.bin';
137
+ }
138
+
139
+ function buildOasnMediaFileName(artifact: ArtifactRef, buffer: Buffer, downloadedName: string, downloadedMime: string): string {
140
+ const preferredName = downloadedName && downloadedName !== 'artifact'
141
+ ? downloadedName
142
+ : artifact.displayName && artifact.displayName !== 'artifact'
143
+ ? artifact.displayName
144
+ : artifact.id || 'artifact';
145
+
146
+ let safeName = sanitizeMediaFileName(preferredName);
147
+ if (!nodePath.extname(safeName)) {
148
+ safeName += inferExtension(buffer, downloadedMime || artifact.mimeType);
149
+ }
150
+ return safeName;
151
+ }
152
+
153
+ async function cacheOasnArtifactsForMedia(
154
+ oasnTransport: OasnTransport,
155
+ artifacts: ArtifactRef[],
156
+ ): Promise<string[]> {
157
+ const localPaths: string[] = [];
158
+
159
+ for (const artifact of artifacts) {
160
+ const ref = artifact.contentUrl || artifact.id;
161
+ if (!ref) continue;
162
+
163
+ try {
164
+ const downloaded = await oasnTransport.downloadArtifact(ref);
165
+ const buffer = Buffer.from(downloaded.content);
166
+ const fileName = buildOasnMediaFileName(
167
+ artifact,
168
+ buffer,
169
+ downloaded.displayName,
170
+ downloaded.mimeType,
171
+ );
172
+ const filePath = nodePath.join(OPENAGENT_MEDIA_DIR, fileName);
173
+ fs.mkdirSync(nodePath.dirname(filePath), { recursive: true });
174
+ fs.writeFileSync(filePath, buffer);
175
+ localPaths.push(filePath);
176
+ logger.info(
177
+ `[remote-tool] Cached OASN artifact for media: id=${artifact.id} file=${filePath} size=${buffer.byteLength}`,
178
+ );
179
+ } catch (err) {
180
+ logger.warn(
181
+ `[remote-tool] Failed to cache OASN artifact ${artifact.id || ref}: ${(err as Error).message}`,
182
+ );
183
+ }
184
+ }
185
+
186
+ return localPaths;
187
+ }
188
+
189
+ function mediaDownloadLabel(url: string): string {
190
+ let fileName = 'artifact';
191
+ try {
192
+ fileName = decodeURIComponent(new URL(url).pathname.split('/').pop() || fileName);
193
+ } catch {
194
+ fileName = decodeURIComponent(url.split('?')[0]?.split('/').pop() || fileName);
195
+ }
196
+ return sanitizeMediaFileName(fileName).replace(/[[\]\r\n]/g, ' ') || 'artifact';
197
+ }
198
+
199
+ function formatMediaDownloadLines(urls: string[]): string {
200
+ const lines = urls
201
+ .filter((url) => typeof url === 'string' && url.trim())
202
+ .map((url) => `- [${mediaDownloadLabel(url)}](${url})`);
203
+ return lines.length ? `下载链接:\n${lines.join('\n')}` : '';
204
+ }
205
+
94
206
  function normalizeRemoteToolExecuteArgs(
95
207
  signalOrUpdate?: AbortSignal | ToolUpdateCallback,
96
208
  onUpdateOrContext?: ToolUpdateCallback | unknown,
@@ -322,7 +434,7 @@ export function createCallRemoteAgentTool(): ToolDescriptor {
322
434
 
323
435
  const artifacts = result.artifacts ?? [];
324
436
  let content = result.content;
325
- let mediaUrls = artifacts.map((a) => a.contentUrl).filter(Boolean);
437
+ let mediaUrls: string[] = [];
326
438
 
327
439
  if (!content.trim()) {
328
440
  for (const textArtifact of artifacts) {
@@ -331,10 +443,6 @@ export function createCallRemoteAgentTool(): ToolDescriptor {
331
443
  const downloaded = await oasnTransport.downloadArtifact(textArtifact.contentUrl || textArtifact.id);
332
444
  if (isTextArtifact(downloaded)) {
333
445
  content = artifactContentToText(downloaded.content);
334
- mediaUrls = artifacts
335
- .filter((a) => a.id !== textArtifact.id)
336
- .map((a) => a.contentUrl)
337
- .filter(Boolean);
338
446
  break;
339
447
  }
340
448
  } catch (err) {
@@ -379,6 +487,10 @@ export function createCallRemoteAgentTool(): ToolDescriptor {
379
487
  : `Files from remote agent:\n${artifactLines.join('\n')}`;
380
488
  }
381
489
 
490
+ if (artifacts.length > 0) {
491
+ mediaUrls = await cacheOasnArtifactsForMedia(oasnTransport, artifacts);
492
+ }
493
+
382
494
  // Resolve OASN-relative URLs in content (remote agent may have embedded
383
495
  // /webui/continuations/... or /api/artifacts/... in its output text).
384
496
  if (content && accessBase) {
@@ -388,7 +500,7 @@ export function createCallRemoteAgentTool(): ToolDescriptor {
388
500
  );
389
501
  }
390
502
 
391
- // OASN 的非文本 artifacts 暂继续复用 ArtifactRef.contentUrl 作为 mediaUrls。
503
+ // OASN artifacts are cached locally before being handed to Gateway media rendering.
392
504
  reply = {
393
505
  status: 'complete',
394
506
  content,
@@ -468,13 +580,16 @@ export function createCallRemoteAgentTool(): ToolDescriptor {
468
580
  });
469
581
  }
470
582
 
471
- // Path 1: MEDIA: lines in text for Gateway MEDIA: parser + LLM echo
583
+ // Path 1: explicit markdown links + MEDIA: lines for Gateway parser.
584
+ // The markdown block makes the link visible even when the host does not
585
+ // render MEDIA: attachments in assistant/tool-result bubbles.
586
+ const downloadLines = formatMediaDownloadLines(httpMediaUrls);
472
587
  const mediaLines = httpMediaUrls
473
588
  .map((url: string) => `MEDIA:${url}`)
474
589
  .join('\n');
475
- const textWithMedia = reply.content
476
- ? `${reply.content}\n${mediaLines}`
477
- : mediaLines;
590
+ const textWithMedia = [reply.content.trim(), downloadLines, mediaLines]
591
+ .filter(Boolean)
592
+ .join('\n\n');
478
593
 
479
594
  return {
480
595
  content: [{ type: 'text' as const, text: textWithMedia }],