openclaw-openagent 1.0.8 → 1.0.9
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.
- package/dist/src/app/remote-agent-tool.js +108 -10
- package/dist/src/auth/config.js +65 -10
- package/dist/src/channel.js +2 -1
- package/dist/src/plugin-ui/assets/openagent-override.js +257 -5
- package/dist/src/plugin-ui/ui-extension-loader/registry-regex.js +1 -1
- package/dist/src/proxy/auth-proxy.d.ts +1 -0
- package/dist/src/proxy/auth-proxy.js +28 -9
- package/dist/src/transport/oasn/oasn-http.d.ts +12 -0
- package/dist/src/transport/oasn/oasn-http.js +45 -14
- package/dist/src/util/url-resolver.d.ts +6 -0
- package/dist/src/util/url-resolver.js +24 -8
- package/openclaw.plugin.json +15 -0
- package/package.json +1 -1
- package/src/app/remote-agent-tool.ts +126 -11
- package/src/auth/config.ts +79 -10
- package/src/channel.ts +2 -1
- package/src/plugin-ui/assets/openagent-override.js +257 -5
- package/src/plugin-ui/build.cjs +1 -0
- package/src/plugin-ui/modules/agent-book/panel/inject-ui.js +18 -0
- package/src/plugin-ui/modules/remote-agent/media-links.js +151 -0
- package/src/plugin-ui/modules/remote-agent/output-card.js +21 -4
- package/src/plugin-ui/modules/remote-agent/styles.js +61 -0
- package/src/plugin-ui/ui-extension-loader/registry-regex.ts +1 -1
- package/src/proxy/auth-proxy.ts +30 -10
- package/src/transport/oasn/oasn-http.ts +60 -14
- package/src/util/url-resolver.ts +24 -8
|
@@ -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 ??
|
|
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 =
|
|
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
|
|
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
|
|
32
|
-
if (!
|
|
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(
|
|
37
|
-
|
|
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
|
|
55
|
+
function publicBaseOfClaimUrl(value) {
|
|
44
56
|
if (!value)
|
|
45
57
|
return undefined;
|
|
46
58
|
try {
|
|
47
|
-
|
|
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(
|
|
71
|
+
return /(?:^|\/)webui\//.test(new URL(resolveOasnAccessUrl(base, url)).pathname);
|
|
56
72
|
}
|
|
57
73
|
catch {
|
|
58
74
|
return false;
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
|
@@ -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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
477
|
-
|
|
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 }],
|
package/src/auth/config.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const DEFAULT_API_BASE = 'https://api.openagent.club';
|
|
13
|
+
const DEFAULT_OASN_PUBLIC_ROOT = 'https://oasn-test.haimawan.com';
|
|
14
|
+
const OASN_ENV_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
13
15
|
/**
|
|
14
16
|
* OASN 默认 API base。
|
|
15
17
|
*
|
|
@@ -22,6 +24,55 @@ export function isUnconfiguredOasnApiBase(value: string | undefined): boolean {
|
|
|
22
24
|
return !value || value === DEFAULT_OASN_API_BASE;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
function normalizeOasnApiBase(value: unknown): string {
|
|
28
|
+
const normalized = String(value || '')
|
|
29
|
+
.trim()
|
|
30
|
+
.replace(/\/+$/, '')
|
|
31
|
+
.replace(/\/api$/, '');
|
|
32
|
+
return normalized === DEFAULT_OASN_API_BASE ? '' : normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeOasnEnvId(value: unknown): string {
|
|
36
|
+
const envId = String(value || '').trim();
|
|
37
|
+
if (!envId) return '';
|
|
38
|
+
return OASN_ENV_ID_RE.test(envId) ? envId : '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function oasnPublicBaseFromEnvId(envId: string): string {
|
|
42
|
+
const normalized = normalizeOasnEnvId(envId);
|
|
43
|
+
if (!normalized) return '';
|
|
44
|
+
const root = normalizeOasnApiBase(process.env.OASN_PUBLIC_ROOT || process.env.OPENAGENT_OASN_PUBLIC_ROOT || DEFAULT_OASN_PUBLIC_ROOT);
|
|
45
|
+
return `${root}/e/${normalized}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveOasnBase({
|
|
49
|
+
accountBase,
|
|
50
|
+
channelBase,
|
|
51
|
+
envBase,
|
|
52
|
+
accountEnvId,
|
|
53
|
+
channelEnvId,
|
|
54
|
+
envEnvId,
|
|
55
|
+
fallbackBase,
|
|
56
|
+
}: {
|
|
57
|
+
accountBase?: unknown;
|
|
58
|
+
channelBase?: unknown;
|
|
59
|
+
envBase?: unknown;
|
|
60
|
+
accountEnvId?: unknown;
|
|
61
|
+
channelEnvId?: unknown;
|
|
62
|
+
envEnvId?: unknown;
|
|
63
|
+
fallbackBase: string;
|
|
64
|
+
}): string {
|
|
65
|
+
const configuredBase = normalizeOasnApiBase(accountBase) || normalizeOasnApiBase(channelBase);
|
|
66
|
+
if (configuredBase) return configuredBase;
|
|
67
|
+
const configuredEnvId = normalizeOasnEnvId(accountEnvId) || normalizeOasnEnvId(channelEnvId);
|
|
68
|
+
if (configuredEnvId) return oasnPublicBaseFromEnvId(configuredEnvId);
|
|
69
|
+
const ambientBase = normalizeOasnApiBase(envBase);
|
|
70
|
+
if (ambientBase) return ambientBase;
|
|
71
|
+
const ambientEnvId = normalizeOasnEnvId(envEnvId);
|
|
72
|
+
if (ambientEnvId) return oasnPublicBaseFromEnvId(ambientEnvId);
|
|
73
|
+
return fallbackBase;
|
|
74
|
+
}
|
|
75
|
+
|
|
25
76
|
/**
|
|
26
77
|
* Transport 类型 —— v3.9+ 双轨抽象。
|
|
27
78
|
*
|
|
@@ -132,19 +183,37 @@ export function resolveOpenagentAccount(
|
|
|
132
183
|
const envApiBase = transport === 'oasn'
|
|
133
184
|
? (process.env.OASN_API_BASE || process.env.OPENAGENT_OASN_API_BASE || '')
|
|
134
185
|
: '';
|
|
135
|
-
const apiBase =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
186
|
+
const apiBase = transport === 'oasn'
|
|
187
|
+
? resolveOasnBase({
|
|
188
|
+
accountBase: targetAcc['api_base'] || targetAcc['apiBase'],
|
|
189
|
+
channelBase: clCfg['api_base'] || clCfg['apiBase'],
|
|
190
|
+
envBase: envApiBase,
|
|
191
|
+
accountEnvId: targetAcc['oasn_env_id'] || targetAcc['oasnEnvId'] || targetAcc['env_id'] || targetAcc['envId'],
|
|
192
|
+
channelEnvId: clCfg['oasn_env_id'] || clCfg['oasnEnvId'] || clCfg['env_id'] || clCfg['envId'],
|
|
193
|
+
envEnvId: process.env.OASN_ENV_ID || process.env.OPENAGENT_OASN_ENV_ID,
|
|
194
|
+
fallbackBase: fallbackApiBase,
|
|
195
|
+
})
|
|
196
|
+
: (targetAcc['api_base'] || targetAcc['apiBase']
|
|
197
|
+
|| clCfg['api_base']
|
|
198
|
+
|| clCfg['apiBase']
|
|
199
|
+
|| fallbackApiBase) as string;
|
|
140
200
|
const envAccessApiBase = transport === 'oasn'
|
|
141
201
|
? (process.env.OASN_ACCESS_API_BASE || process.env.OPENAGENT_OASN_ACCESS_API_BASE || '')
|
|
142
202
|
: '';
|
|
143
|
-
const accessApiBase =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
203
|
+
const accessApiBase = transport === 'oasn'
|
|
204
|
+
? resolveOasnBase({
|
|
205
|
+
accountBase: targetAcc['access_api_base'] || targetAcc['accessApiBase'],
|
|
206
|
+
channelBase: clCfg['access_api_base'] || clCfg['accessApiBase'],
|
|
207
|
+
envBase: envAccessApiBase,
|
|
208
|
+
accountEnvId: targetAcc['oasn_env_id'] || targetAcc['oasnEnvId'] || targetAcc['env_id'] || targetAcc['envId'],
|
|
209
|
+
channelEnvId: clCfg['oasn_env_id'] || clCfg['oasnEnvId'] || clCfg['env_id'] || clCfg['envId'],
|
|
210
|
+
envEnvId: process.env.OASN_ENV_ID || process.env.OPENAGENT_OASN_ENV_ID,
|
|
211
|
+
fallbackBase: apiBase,
|
|
212
|
+
})
|
|
213
|
+
: (targetAcc['access_api_base'] || targetAcc['accessApiBase']
|
|
214
|
+
|| clCfg['access_api_base']
|
|
215
|
+
|| clCfg['accessApiBase']
|
|
216
|
+
|| apiBase) as string;
|
|
148
217
|
const claimUrl = (targetAcc['claim_url'] || targetAcc['claimUrl'] || clCfg['claim_url'] || clCfg['claimUrl'] || '') as string;
|
|
149
218
|
const rawStatus = (targetAcc['status'] || targetAcc['claim_status'] || targetAcc['claimStatus'] || clCfg['status'] || '') as string;
|
|
150
219
|
const normalizedStatus = normalizeClaimStatus(rawStatus, agentId, apiKey);
|
package/src/channel.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { registry } from './runtime/registry.js';
|
|
|
13
13
|
import { AccountRuntime } from './runtime/account.js';
|
|
14
14
|
|
|
15
15
|
import { logger } from './util/logger.js';
|
|
16
|
+
import { resolveOasnRelativeUrl } from './util/url-resolver.js';
|
|
16
17
|
import * as timMessages from './tim/messages.js';
|
|
17
18
|
|
|
18
19
|
const API_BASE = 'https://api.clawlink.club';
|
|
@@ -544,7 +545,7 @@ async function claimOasnAgent(
|
|
|
544
545
|
agentId: string,
|
|
545
546
|
ownerEmail: string,
|
|
546
547
|
): Promise<Record<string, unknown>> {
|
|
547
|
-
const res = await fetch(
|
|
548
|
+
const res = await fetch(resolveOasnRelativeUrl(apiBase, '/api/agents/claim'), {
|
|
548
549
|
method: 'POST',
|
|
549
550
|
headers: {
|
|
550
551
|
'Content-Type': 'application/json',
|