openclaw-openagent 1.0.0

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 (113) hide show
  1. package/index.ts +114 -0
  2. package/openclaw.plugin.json +159 -0
  3. package/package.json +79 -0
  4. package/skills/clawlink/SKILL.md +145 -0
  5. package/skills/clawlink/SKILL.md.bak +165 -0
  6. package/src/app/channel-tools.ts +249 -0
  7. package/src/app/discovery-tools.ts +273 -0
  8. package/src/app/hooks.ts +60 -0
  9. package/src/app/index.ts +78 -0
  10. package/src/app/messaging-tools.ts +79 -0
  11. package/src/app/ops-tools.ts +155 -0
  12. package/src/app/remote-agent-tool.ts +476 -0
  13. package/src/app/types.ts +67 -0
  14. package/src/app/verbose-preflight.ts +190 -0
  15. package/src/auth/config.ts +197 -0
  16. package/src/auth/credential-manager.ts +146 -0
  17. package/src/auth/index.ts +24 -0
  18. package/src/auth/verify.ts +99 -0
  19. package/src/channel.ts +565 -0
  20. package/src/compat.ts +82 -0
  21. package/src/config/config-schema.ts +39 -0
  22. package/src/messaging/aggregator.ts +120 -0
  23. package/src/messaging/collector.ts +89 -0
  24. package/src/messaging/executor.ts +72 -0
  25. package/src/messaging/inbound.ts +150 -0
  26. package/src/messaging/index.ts +11 -0
  27. package/src/messaging/mention-protocol.ts +94 -0
  28. package/src/messaging/process-c2c-request.ts +564 -0
  29. package/src/messaging/process-message.ts +373 -0
  30. package/src/messaging/scheduler.ts +55 -0
  31. package/src/messaging/types.ts +38 -0
  32. package/src/plugin-ui/assets/agentbook-icon.svg +5 -0
  33. package/src/plugin-ui/assets/magic.svg +5 -0
  34. package/src/plugin-ui/assets/openagent-override.js +9329 -0
  35. package/src/plugin-ui/build.cjs +175 -0
  36. package/src/plugin-ui/index.ts +18 -0
  37. package/src/plugin-ui/modules/agent-book/panel/agent-book.js +458 -0
  38. package/src/plugin-ui/modules/agent-book/panel/agent-card.js +154 -0
  39. package/src/plugin-ui/modules/agent-book/panel/agent-data.js +644 -0
  40. package/src/plugin-ui/modules/agent-book/panel/inject-ui.js +456 -0
  41. package/src/plugin-ui/modules/agent-book/panel/mention-state.js +206 -0
  42. package/src/plugin-ui/modules/agent-book/panel/styles.js +670 -0
  43. package/src/plugin-ui/modules/agent-book/remote-agent-tool/components-core.js +293 -0
  44. package/src/plugin-ui/modules/agent-book/remote-agent-tool/thought-chain-card.js +208 -0
  45. package/src/plugin-ui/modules/agent-book/scanner.js +119 -0
  46. package/src/plugin-ui/modules/agent-book/travelcard/travel-cards.js +500 -0
  47. package/src/plugin-ui/modules/agent-book/travelcard/travel-engine.js +652 -0
  48. package/src/plugin-ui/modules/agent-book/travelcard/travel-styles.js +251 -0
  49. package/src/plugin-ui/modules/loader/bootstrap.js +38 -0
  50. package/src/plugin-ui/modules/loader/shared-state.js +560 -0
  51. package/src/plugin-ui/modules/loader/ws-intercept.js +199 -0
  52. package/src/plugin-ui/modules/remote-agent/chunk-parser.js +161 -0
  53. package/src/plugin-ui/modules/remote-agent/execution-card.js +269 -0
  54. package/src/plugin-ui/modules/remote-agent/markdown-renderer.js +256 -0
  55. package/src/plugin-ui/modules/remote-agent/native-style-adapter.js +146 -0
  56. package/src/plugin-ui/modules/remote-agent/output-card.js +259 -0
  57. package/src/plugin-ui/modules/remote-agent/progress-store.js +363 -0
  58. package/src/plugin-ui/modules/remote-agent/render-hooks.js +1609 -0
  59. package/src/plugin-ui/modules/remote-agent/styles.js +668 -0
  60. package/src/plugin-ui/modules/remote-agent/tool-card-model.js +56 -0
  61. package/src/plugin-ui/ui-extension-loader/backup.ts +92 -0
  62. package/src/plugin-ui/ui-extension-loader/index.ts +276 -0
  63. package/src/plugin-ui/ui-extension-loader/locator.ts +152 -0
  64. package/src/plugin-ui/ui-extension-loader/manifest.ts +57 -0
  65. package/src/plugin-ui/ui-extension-loader/registry-regex.ts +729 -0
  66. package/src/plugin-ui/ui-extension-loader/removed-extensions.ts +70 -0
  67. package/src/plugin-ui/ui-extension-loader/types.ts +68 -0
  68. package/src/proxy/auth-proxy.ts +356 -0
  69. package/src/runtime/account.ts +572 -0
  70. package/src/runtime/index.ts +7 -0
  71. package/src/runtime/plugin-runtime.ts +94 -0
  72. package/src/runtime/registry.ts +71 -0
  73. package/src/sdk/CLASS_MAP.md +143 -0
  74. package/src/sdk/index.d.ts +126 -0
  75. package/src/sdk/index.js +23990 -0
  76. package/src/sdk/modules/cloud-search-module.js +1117 -0
  77. package/src/sdk/modules/follow-module.js +1069 -0
  78. package/src/sdk/modules/group-module.js +7397 -0
  79. package/src/sdk/modules/relationship-module.js +2269 -0
  80. package/src/sdk/modules/signaling-module.js +1468 -0
  81. package/src/sdk/modules/tim-upload-plugin.js +730 -0
  82. package/src/sdk/node-env/http-request.js +90 -0
  83. package/src/sdk/node-env/index.js +57 -0
  84. package/src/sdk/node-env/storage.js +114 -0
  85. package/src/sdk/package.json +10 -0
  86. package/src/sdk/tsconfig.json +16 -0
  87. package/src/state/pending-invocation-store.ts +43 -0
  88. package/src/state/store.ts +676 -0
  89. package/src/tim/c2c.ts +451 -0
  90. package/src/tim/channels.ts +364 -0
  91. package/src/tim/client.ts +330 -0
  92. package/src/tim/index.ts +18 -0
  93. package/src/tim/messages.ts +166 -0
  94. package/src/tim/sdk-logger-init.ts +50 -0
  95. package/src/tools.ts +10 -0
  96. package/src/transport/factory.ts +95 -0
  97. package/src/transport/oasn/index.ts +17 -0
  98. package/src/transport/oasn/oasn-agent-card.ts +111 -0
  99. package/src/transport/oasn/oasn-discovery.ts +108 -0
  100. package/src/transport/oasn/oasn-files.ts +210 -0
  101. package/src/transport/oasn/oasn-http.ts +483 -0
  102. package/src/transport/oasn/oasn-invocation.ts +527 -0
  103. package/src/transport/oasn/oasn-normalize.ts +159 -0
  104. package/src/transport/oasn/oasn-register.ts +106 -0
  105. package/src/transport/oasn/oasn-transport.ts +341 -0
  106. package/src/transport/oasn/oasn-types.ts +353 -0
  107. package/src/transport/tim/index.ts +8 -0
  108. package/src/transport/tim/tim-transport.ts +515 -0
  109. package/src/transport/types.ts +541 -0
  110. package/src/types/openclaw.d.ts +97 -0
  111. package/src/types/tencentcloud-chat.d.ts +15 -0
  112. package/src/util/http.ts +113 -0
  113. package/src/util/logger.ts +131 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * OASN 文件上传 + Artifact 下载
3
+ *
4
+ * 设计依据:
5
+ * - docs/specs/2026-06-16-oasn-api-module-mapping.md §2.16 / §2.17
6
+ * - docs/specs/2026-06-16-oasn-transport-abstraction-design.md §11.4(file_ref LRU)
7
+ *
8
+ * 端点:
9
+ * - POST /api/files multipart 上传 → file_ref
10
+ * - GET /api/artifacts/{id}/content 二进制下载(带 Content-Disposition)
11
+ *
12
+ * §11.4 file_ref 过期刷新:
13
+ * - 维护内存 LRU:sha256(content) → { fileRef, expiresAt, mimeType, displayName }
14
+ * - 上传前调用 ensureFileRef(file):命中且未过期 → 复用;否则重传
15
+ * - 条目数上限 100,淘汰用 LRU
16
+ * - expires_at 临近 60s 视为已过期(避免边缘竞态)
17
+ *
18
+ * 注意:
19
+ * - LRU 仅在单进程内有效;进程重启后丢失,会触发一次重传(可接受)
20
+ * - sha256 是 Node 内置,无外部依赖
21
+ */
22
+
23
+ import { createHash } from 'node:crypto';
24
+
25
+ import { logger } from '../../util/logger.js';
26
+ import type {
27
+ DownloadArtifactResult,
28
+ UploadFileParams,
29
+ UploadFileResult,
30
+ } from '../types.js';
31
+ import type { OasnHttpClient } from './oasn-http.js';
32
+ import { OASN_ENDPOINTS, type UploadFileResponse } from './oasn-types.js';
33
+
34
+ /** LRU 上限 */
35
+ const FILE_REF_LRU_MAX = 100;
36
+ /** expires_at 临近多少 ms 视为已过期 */
37
+ const EXPIRY_MARGIN_MS = 60_000;
38
+
39
+ interface FileRefCacheEntry {
40
+ fileRef: string;
41
+ /** epoch ms */
42
+ expiresAt: number;
43
+ mimeType: string;
44
+ displayName: string;
45
+ sizeBytes: number;
46
+ }
47
+
48
+ export class OasnFiles {
49
+ /**
50
+ * §11.4 LRU 缓存。
51
+ * 利用 Map 的插入顺序天然 = 访问顺序:每次访问后 delete + set 重新插入到尾部。
52
+ */
53
+ private _refCache = new Map<string, FileRefCacheEntry>();
54
+
55
+ constructor(private readonly _http: OasnHttpClient) {}
56
+
57
+ // ═══════════════════════════════════════════════════════════════
58
+ // 上传
59
+ // ═══════════════════════════════════════════════════════════════
60
+
61
+ /**
62
+ * 上传文件,返回 file_ref。
63
+ *
64
+ * 调用方应优先用 ensureFileRef() 复用;本方法是"无脑重传"路径。
65
+ */
66
+ async uploadFile(file: UploadFileParams): Promise<UploadFileResult> {
67
+ const buffer = await this._toBuffer(file.content);
68
+ return this._doUpload(buffer, file);
69
+ }
70
+
71
+ /**
72
+ * §11.4 入口:根据内容 hash 命中缓存就复用,否则重传。
73
+ * sendTask 前应调用此方法,避免 file_ref 过期导致 invocation 400。
74
+ */
75
+ async ensureFileRef(file: UploadFileParams): Promise<UploadFileResult> {
76
+ const buffer = await this._toBuffer(file.content);
77
+ const hash = this._hashBuffer(buffer);
78
+ const cached = this._refCache.get(hash);
79
+
80
+ if (cached && cached.expiresAt - EXPIRY_MARGIN_MS > Date.now()) {
81
+ // LRU touch:移到尾部
82
+ this._refCache.delete(hash);
83
+ this._refCache.set(hash, cached);
84
+ logger.info(`[transport/oasn/files] file_ref cache HIT hash=${hash.slice(0, 8)} ref=${cached.fileRef}`);
85
+ return {
86
+ fileRef: cached.fileRef,
87
+ displayName: cached.displayName,
88
+ mimeType: cached.mimeType,
89
+ sizeBytes: cached.sizeBytes,
90
+ expiresAt: cached.expiresAt,
91
+ };
92
+ }
93
+
94
+ // 未命中或已过期 → 重传
95
+ if (cached) {
96
+ logger.info(`[transport/oasn/files] file_ref cache STALE hash=${hash.slice(0, 8)}, will re-upload`);
97
+ this._refCache.delete(hash);
98
+ }
99
+ const result = await this._doUpload(buffer, file);
100
+ this._cachePut(hash, result);
101
+ return result;
102
+ }
103
+
104
+ // ═══════════════════════════════════════════════════════════════
105
+ // 下载(Artifact 二进制内容)
106
+ // ═══════════════════════════════════════════════════════════════
107
+
108
+ /**
109
+ * 下载 Artifact 内容。
110
+ *
111
+ * 注意:不缓存(artifact 一般是大文件 / 一次性消费),需要缓存的请上层包装。
112
+ */
113
+ async downloadArtifact(artifactIdOrContentUrl: string): Promise<DownloadArtifactResult> {
114
+ logger.info(`[transport/oasn/files] downloadArtifact ref=${artifactIdOrContentUrl}`);
115
+ const path = this._artifactContentPath(artifactIdOrContentUrl);
116
+ const resp = await this._http.getBinary(path);
117
+ return {
118
+ content: resp.content,
119
+ mimeType: resp.mimeType,
120
+ displayName: resp.displayName,
121
+ };
122
+ }
123
+
124
+ // ═══════════════════════════════════════════════════════════════
125
+ // 内部
126
+ // ═══════════════════════════════════════════════════════════════
127
+
128
+ /**
129
+ * 实际上传逻辑。multipart/form-data:
130
+ * - file 文件二进制
131
+ * - display_name / purpose / expires_in_seconds 字段(按需)
132
+ */
133
+ private async _doUpload(buffer: Buffer, file: UploadFileParams): Promise<UploadFileResult> {
134
+ const fields: Record<string, string> = {
135
+ display_name: file.displayName,
136
+ };
137
+ if (file.purpose) fields.purpose = file.purpose;
138
+ if (file.expiresInSeconds !== undefined) fields.expires_in_seconds = String(file.expiresInSeconds);
139
+
140
+ logger.info(
141
+ `[transport/oasn/files] upload name=${file.displayName} size=${buffer.byteLength} `
142
+ + `mime=${file.mimeType ?? 'auto'} purpose=${file.purpose ?? 'none'}`,
143
+ );
144
+
145
+ const resp = await this._http.postMultipart<UploadFileResponse>(
146
+ OASN_ENDPOINTS.FILES,
147
+ {
148
+ file: {
149
+ content: buffer,
150
+ filename: file.displayName,
151
+ mimeType: file.mimeType,
152
+ },
153
+ fields,
154
+ },
155
+ );
156
+
157
+ return {
158
+ fileRef: resp.file_ref,
159
+ displayName: resp.display_name,
160
+ mimeType: resp.mime_type,
161
+ sizeBytes: resp.size_bytes,
162
+ expiresAt: this._parseExpiresAt(resp.expires_at),
163
+ };
164
+ }
165
+
166
+ /** Map LRU put:满了就淘汰最旧 */
167
+ private _cachePut(hash: string, result: UploadFileResult): void {
168
+ if (this._refCache.size >= FILE_REF_LRU_MAX) {
169
+ // 淘汰第一个 key(Map 迭代顺序 = 插入/touch 顺序)
170
+ const firstKey = this._refCache.keys().next().value;
171
+ if (firstKey !== undefined) {
172
+ this._refCache.delete(firstKey);
173
+ logger.info(`[transport/oasn/files] LRU evict hash=${(firstKey as string).slice(0, 8)}`);
174
+ }
175
+ }
176
+ this._refCache.set(hash, {
177
+ fileRef: result.fileRef,
178
+ expiresAt: result.expiresAt,
179
+ mimeType: result.mimeType,
180
+ displayName: result.displayName,
181
+ sizeBytes: result.sizeBytes,
182
+ });
183
+ }
184
+
185
+ private _artifactContentPath(artifactIdOrContentUrl: string): string {
186
+ if (/^https?:\/\//i.test(artifactIdOrContentUrl) || artifactIdOrContentUrl.startsWith('/')) {
187
+ return artifactIdOrContentUrl;
188
+ }
189
+ return OASN_ENDPOINTS.ARTIFACT_CONTENT(artifactIdOrContentUrl);
190
+ }
191
+
192
+ /** Blob | Buffer → Buffer */
193
+ private async _toBuffer(content: Blob | Buffer): Promise<Buffer> {
194
+ if (Buffer.isBuffer(content)) return content;
195
+ // Browser Blob path(Node 18+ 内置 Blob)
196
+ const ab = await content.arrayBuffer();
197
+ return Buffer.from(ab);
198
+ }
199
+
200
+ /** sha256 hex —— 标识文件唯一性 */
201
+ private _hashBuffer(buf: Buffer): string {
202
+ return createHash('sha256').update(buf).digest('hex');
203
+ }
204
+
205
+ /** ISO 8601 → epoch ms(解析失败时给 0,让缓存下次 miss) */
206
+ private _parseExpiresAt(iso: string): number {
207
+ const ms = Date.parse(iso);
208
+ return Number.isNaN(ms) ? 0 : ms;
209
+ }
210
+ }
@@ -0,0 +1,483 @@
1
+ /**
2
+ * OASN HTTP 客户端
3
+ *
4
+ * 设计依据:
5
+ * - docs/specs/2026-06-16-oasn-transport-abstraction-design.md §11.5(401 撤销)
6
+ * - docs/specs/2026-06-16-oasn-api-module-mapping.md §五(统一错误格式)
7
+ *
8
+ * 职责:
9
+ * 1. 提供 GET/POST/PATCH/multipart 上传/二进制下载 等基础方法
10
+ * 2. 注入 `Authorization: Bearer <api_key>` 头
11
+ * 3. 解析 OASN 统一错误响应(`{ error: { code, message, retryable, request_id } }`)
12
+ * → 抛出 TransportError(保留 retryable / requestId 字段,便于上层判断)
13
+ * 4. 401/UNAUTHENTICATED 时触发 `onCredentialRevoked` 回调(§11.5 撤销路径)
14
+ * 5. 支持自动重试(仅当 `retryable: true` 时,最多 3 次指数退避)
15
+ *
16
+ * 不在此层处理:
17
+ * - SSE 升级(详见 oasn-invocation.ts 的 subscribeToInvocation)
18
+ * - 业务幂等持久化(详见 oasn-invocation.ts + state/store.ts)
19
+ * - file_ref LRU(详见 oasn-files.ts)
20
+ */
21
+
22
+ import { request as httpRequest, type RequestOptions } from 'node:http';
23
+ import { request as httpsRequest } from 'node:https';
24
+ import { URL } from 'node:url';
25
+
26
+ import { logger } from '../../util/logger.js';
27
+ import { makeTransportError, type TransportError } from '../types.js';
28
+ import type { OasnErrorBody } from './oasn-types.js';
29
+
30
+ /** OASN HTTP 客户端配置 */
31
+ export interface OasnHttpConfig {
32
+ /** API base URL,如 `https://api.oasn.ai` */
33
+ apiBase: string;
34
+ /** Bearer token(api_key 或 access_token) */
35
+ token?: string;
36
+ /** 单次请求默认超时(ms) */
37
+ defaultTimeoutMs?: number;
38
+ /** 自动重试最大次数(仅当响应 retryable=true 时) */
39
+ maxRetries?: number;
40
+ /**
41
+ * 401/UNAUTHENTICATED 触发回调。
42
+ * 由 OasnTransport 接入,进一步触发 `credential_expired` 事件 + 清本地凭证(§11.5)。
43
+ */
44
+ onCredentialRevoked?: () => void;
45
+ }
46
+
47
+ /** 请求选项 */
48
+ export interface OasnRequestOptions {
49
+ /** 自定义超时(覆盖 defaultTimeoutMs) */
50
+ timeoutMs?: number;
51
+ /** 自定义额外头(Authorization 由 client 自动注入,无需传入) */
52
+ headers?: Record<string, string>;
53
+ /** 跳过自动重试(如轮询接口自己控制节奏) */
54
+ skipRetry?: boolean;
55
+ /** Abort 信号 */
56
+ signal?: AbortSignal;
57
+ }
58
+
59
+ const DEFAULT_TIMEOUT_MS = 30_000;
60
+ const DEFAULT_MAX_RETRIES = 3;
61
+
62
+ // ───────────────────────────────────────────────────────────────
63
+ // 错误转换
64
+ // ───────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * 把 HTTP 响应(含可能的 OASN 统一错误体)转换为 TransportError。
68
+ * 如果响应非 OASN 统一格式,使用 HTTP 状态码做兜底。
69
+ */
70
+ function toTransportError(
71
+ statusCode: number,
72
+ rawBody: string,
73
+ ): TransportError {
74
+ let code = `HTTP_${statusCode}`;
75
+ let message = rawBody.slice(0, 500);
76
+ let retryable = statusCode >= 500 || statusCode === 429 || statusCode === 408;
77
+ let requestId: string | undefined;
78
+
79
+ try {
80
+ const parsed = JSON.parse(rawBody) as Partial<OasnErrorBody>;
81
+ if (parsed?.error?.code) {
82
+ code = parsed.error.code;
83
+ message = parsed.error.message ?? message;
84
+ retryable = parsed.error.retryable ?? retryable;
85
+ requestId = parsed.error.request_id;
86
+ }
87
+ } catch {
88
+ // 非 JSON 响应,保留兜底值
89
+ }
90
+
91
+ return makeTransportError(code, `[${statusCode}] ${message}`, { retryable, requestId });
92
+ }
93
+
94
+ // ───────────────────────────────────────────────────────────────
95
+ // OasnHttpClient
96
+ // ───────────────────────────────────────────────────────────────
97
+
98
+ export class OasnHttpClient {
99
+ private _config: OasnHttpConfig;
100
+
101
+ constructor(config: OasnHttpConfig) {
102
+ if (!config.apiBase) {
103
+ throw new Error('OasnHttpClient: apiBase is required');
104
+ }
105
+ this._config = {
106
+ apiBase: config.apiBase,
107
+ token: config.token,
108
+ defaultTimeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
109
+ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
110
+ onCredentialRevoked: config.onCredentialRevoked,
111
+ };
112
+ }
113
+
114
+ /** 动态更新 token —— 注册成功后由 OasnTransport 调用 */
115
+ setToken(token: string | undefined): void {
116
+ this._config.token = token;
117
+ }
118
+
119
+ // ─────────────────────────────────────────────────────────────
120
+ // 公共方法
121
+ // ─────────────────────────────────────────────────────────────
122
+
123
+ async get<T>(path: string, options?: OasnRequestOptions): Promise<T> {
124
+ return this._jsonRequest<T>('GET', path, undefined, options);
125
+ }
126
+
127
+ async post<T>(path: string, body: unknown, options?: OasnRequestOptions): Promise<T> {
128
+ return this._jsonRequest<T>('POST', path, body, options);
129
+ }
130
+
131
+ async patch<T>(path: string, body: unknown, options?: OasnRequestOptions): Promise<T> {
132
+ return this._jsonRequest<T>('PATCH', path, body, options);
133
+ }
134
+
135
+ /**
136
+ * multipart/form-data 上传。
137
+ * 仅支持 file + metadata 字段,不做通用 multipart 支持(OASN 上传场景固定)。
138
+ */
139
+ async postMultipart<T>(
140
+ path: string,
141
+ parts: {
142
+ file: { content: Buffer; filename: string; mimeType?: string };
143
+ fields?: Record<string, string>;
144
+ },
145
+ options?: OasnRequestOptions,
146
+ ): Promise<T> {
147
+ const boundary = `------oasn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
148
+ const chunks: Buffer[] = [];
149
+
150
+ // 普通字段
151
+ if (parts.fields) {
152
+ for (const [key, value] of Object.entries(parts.fields)) {
153
+ chunks.push(Buffer.from(
154
+ `--${boundary}\r\n`
155
+ + `Content-Disposition: form-data; name="${key}"\r\n\r\n`
156
+ + `${value}\r\n`,
157
+ ));
158
+ }
159
+ }
160
+
161
+ // 文件字段
162
+ const mime = parts.file.mimeType ?? 'application/octet-stream';
163
+ chunks.push(Buffer.from(
164
+ `--${boundary}\r\n`
165
+ + `Content-Disposition: form-data; name="file"; filename="${parts.file.filename}"\r\n`
166
+ + `Content-Type: ${mime}\r\n\r\n`,
167
+ ));
168
+ chunks.push(parts.file.content);
169
+ chunks.push(Buffer.from(`\r\n--${boundary}--\r\n`));
170
+
171
+ const body = Buffer.concat(chunks);
172
+
173
+ return this._rawRequest<T>('POST', path, body, {
174
+ ...options,
175
+ headers: {
176
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
177
+ ...options?.headers,
178
+ },
179
+ isJsonBody: false,
180
+ expectJsonResponse: true,
181
+ });
182
+ }
183
+
184
+ /**
185
+ * 下载二进制内容(用于 Artifact 下载)。
186
+ * 不解析响应,直接返回 ArrayBuffer + 头信息。
187
+ */
188
+ async getBinary(
189
+ path: string,
190
+ options?: OasnRequestOptions,
191
+ ): Promise<{ content: ArrayBuffer; mimeType: string; displayName: string }> {
192
+ const url = this._resolveUrl(path);
193
+ const headers = this._buildHeaders({});
194
+ const timeoutMs = options?.timeoutMs ?? this._config.defaultTimeoutMs!;
195
+ return this._doBinaryRequest(url, headers, timeoutMs, options?.signal);
196
+ }
197
+
198
+ /**
199
+ * 暴露底层 raw 请求,用于 SSE 等流式场景(如 oasn-invocation 的 SSE 升级)。
200
+ * 调用方自行处理流式响应。
201
+ */
202
+ rawHttpRequest(
203
+ method: string,
204
+ path: string,
205
+ headers: Record<string, string>,
206
+ body?: Buffer | string,
207
+ options?: OasnRequestOptions,
208
+ ): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
209
+ const url = this._resolveUrl(path);
210
+ const fullHeaders = this._buildHeaders(headers);
211
+ const timeoutMs = options?.timeoutMs ?? this._config.defaultTimeoutMs!;
212
+ return this._doRawRequest(url, method, fullHeaders, body, timeoutMs, options?.signal);
213
+ }
214
+
215
+ // ─────────────────────────────────────────────────────────────
216
+ // 内部实现
217
+ // ─────────────────────────────────────────────────────────────
218
+
219
+ private _resolveUrl(path: string): URL {
220
+ if (/^https?:\/\//i.test(path)) {
221
+ return new URL(path);
222
+ }
223
+ // path 必须以 / 开头
224
+ const base = this._config.apiBase.replace(/\/$/, '');
225
+ return new URL(`${base}${path}`);
226
+ }
227
+
228
+ private _buildHeaders(extra: Record<string, string>): Record<string, string> {
229
+ const headers: Record<string, string> = {
230
+ Accept: 'application/json',
231
+ ...extra,
232
+ };
233
+ if (this._config.token) {
234
+ headers['Authorization'] = `Bearer ${this._config.token}`;
235
+ }
236
+ return headers;
237
+ }
238
+
239
+ /**
240
+ * JSON 请求 —— body 自动 JSON.stringify,响应 JSON.parse。
241
+ * 含重试逻辑(仅当 retryable=true 时)。
242
+ */
243
+ private async _jsonRequest<T>(
244
+ method: string,
245
+ path: string,
246
+ body: unknown,
247
+ options?: OasnRequestOptions,
248
+ ): Promise<T> {
249
+ return this._rawRequest<T>(method, path, body, {
250
+ ...options,
251
+ isJsonBody: true,
252
+ expectJsonResponse: true,
253
+ });
254
+ }
255
+
256
+ private async _rawRequest<T>(
257
+ method: string,
258
+ path: string,
259
+ body: unknown,
260
+ options: OasnRequestOptions & { isJsonBody: boolean; expectJsonResponse: boolean },
261
+ ): Promise<T> {
262
+ const url = this._resolveUrl(path);
263
+ const headers = this._buildHeaders(options?.headers ?? {});
264
+
265
+ let bodyBuffer: Buffer | undefined;
266
+ if (body !== undefined) {
267
+ if (options.isJsonBody) {
268
+ const json = typeof body === 'string' ? body : JSON.stringify(body);
269
+ bodyBuffer = Buffer.from(json);
270
+ headers['Content-Type'] = 'application/json';
271
+ headers['Content-Length'] = String(bodyBuffer.length);
272
+ } else {
273
+ bodyBuffer = body as Buffer;
274
+ if (!headers['Content-Length']) {
275
+ headers['Content-Length'] = String(bodyBuffer.length);
276
+ }
277
+ }
278
+ }
279
+
280
+ const maxAttempts = options.skipRetry ? 1 : (this._config.maxRetries! + 1);
281
+ let lastErr: unknown;
282
+
283
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
284
+ try {
285
+ if (attempt > 0) {
286
+ // 指数退避:200ms / 400ms / 800ms(含少量随机抖动)
287
+ const delay = 200 * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 50);
288
+ await new Promise((r) => setTimeout(r, delay));
289
+ logger.info(`[transport/oasn/http] retry ${attempt}/${maxAttempts - 1} ${method} ${path}`);
290
+ }
291
+
292
+ const { statusCode, body: respBody } = await this._doRawRequest(
293
+ url,
294
+ method,
295
+ headers,
296
+ bodyBuffer,
297
+ options?.timeoutMs ?? this._config.defaultTimeoutMs!,
298
+ options?.signal,
299
+ );
300
+
301
+ if (statusCode >= 200 && statusCode < 300) {
302
+ if (!options.expectJsonResponse) return undefined as T;
303
+ if (statusCode === 204 || !respBody) return undefined as T;
304
+ try {
305
+ return JSON.parse(respBody) as T;
306
+ } catch (parseErr) {
307
+ throw makeTransportError(
308
+ 'INVALID_JSON_RESPONSE',
309
+ `Failed to parse OASN response: ${(parseErr as Error).message}`,
310
+ );
311
+ }
312
+ }
313
+
314
+ // 错误响应
315
+ const err = toTransportError(statusCode, respBody);
316
+
317
+ // §11.5 401/UNAUTHENTICATED 撤销路径
318
+ if (statusCode === 401 || err.code === 'UNAUTHENTICATED') {
319
+ logger.warn(`[transport/oasn/http] 401 UNAUTHENTICATED on ${method} ${path}`);
320
+ this._config.onCredentialRevoked?.();
321
+ throw err; // 401 不重试
322
+ }
323
+
324
+ if (!err.retryable || attempt === maxAttempts - 1) throw err;
325
+ lastErr = err;
326
+ logger.warn(`[transport/oasn/http] retryable error code=${err.code} on ${method} ${path}, will retry`);
327
+ } catch (err) {
328
+ // 网络层错误(DNS / 连接 / 超时) —— 视为可重试
329
+ const isTransport = (err as TransportError).code !== undefined;
330
+ if (isTransport) throw err;
331
+ lastErr = err;
332
+ if (attempt === maxAttempts - 1) {
333
+ throw makeTransportError(
334
+ 'NETWORK_ERROR',
335
+ `${method} ${path} failed: ${(err as Error).message}`,
336
+ { retryable: true },
337
+ );
338
+ }
339
+ }
340
+ }
341
+
342
+ // 不可达,但 TS 需要
343
+ throw lastErr ?? new Error('Unknown HTTP error');
344
+ }
345
+
346
+ /** 执行单次 HTTP 请求(不解析响应体格式) */
347
+ private _doRawRequest(
348
+ url: URL,
349
+ method: string,
350
+ headers: Record<string, string>,
351
+ body: Buffer | string | undefined,
352
+ timeoutMs: number,
353
+ signal?: AbortSignal,
354
+ ): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
355
+ const isHttps = url.protocol === 'https:';
356
+ const requestFn = isHttps ? httpsRequest : httpRequest;
357
+
358
+ return new Promise((resolve, reject) => {
359
+ const reqOptions: RequestOptions = {
360
+ method,
361
+ headers,
362
+ hostname: url.hostname,
363
+ port: url.port || (isHttps ? 443 : 80),
364
+ path: `${url.pathname}${url.search}`,
365
+ };
366
+
367
+ let settled = false;
368
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
369
+ const finish = (fn: () => void) => {
370
+ if (settled) return;
371
+ settled = true;
372
+ if (timeoutHandle) clearTimeout(timeoutHandle);
373
+ fn();
374
+ };
375
+
376
+ const req = requestFn(reqOptions, (res) => {
377
+ const chunks: Buffer[] = [];
378
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
379
+ res.on('end', () => {
380
+ const respBody = Buffer.concat(chunks).toString('utf-8');
381
+ const respHeaders: Record<string, string> = {};
382
+ for (const [k, v] of Object.entries(res.headers)) {
383
+ if (typeof v === 'string') respHeaders[k] = v;
384
+ else if (Array.isArray(v)) respHeaders[k] = v.join(', ');
385
+ }
386
+ finish(() => resolve({
387
+ statusCode: res.statusCode ?? 0,
388
+ headers: respHeaders,
389
+ body: respBody,
390
+ }));
391
+ });
392
+ res.on('error', (err) => finish(() => reject(err)));
393
+ });
394
+
395
+ req.on('error', (err) => finish(() => reject(err)));
396
+
397
+ timeoutHandle = setTimeout(() => {
398
+ req.destroy(new Error(`OASN HTTP timeout after ${timeoutMs}ms`));
399
+ }, timeoutMs);
400
+
401
+ // Abort 支持
402
+ if (signal) {
403
+ const onAbort = () => {
404
+ req.destroy(new Error('Request aborted'));
405
+ };
406
+ if (signal.aborted) onAbort();
407
+ else signal.addEventListener('abort', onAbort, { once: true });
408
+ }
409
+
410
+ if (body) req.write(body);
411
+ req.end();
412
+ });
413
+ }
414
+
415
+ /** 二进制下载 */
416
+ private _doBinaryRequest(
417
+ url: URL,
418
+ headers: Record<string, string>,
419
+ timeoutMs: number,
420
+ signal?: AbortSignal,
421
+ ): Promise<{ content: ArrayBuffer; mimeType: string; displayName: string }> {
422
+ const isHttps = url.protocol === 'https:';
423
+ const requestFn = isHttps ? httpsRequest : httpRequest;
424
+
425
+ return new Promise((resolve, reject) => {
426
+ const reqOptions: RequestOptions = {
427
+ method: 'GET',
428
+ headers,
429
+ hostname: url.hostname,
430
+ port: url.port || (isHttps ? 443 : 80),
431
+ path: `${url.pathname}${url.search}`,
432
+ };
433
+
434
+ let settled = false;
435
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
436
+ const finish = (fn: () => void) => {
437
+ if (settled) return;
438
+ settled = true;
439
+ if (timeoutHandle) clearTimeout(timeoutHandle);
440
+ fn();
441
+ };
442
+
443
+ const req = requestFn(reqOptions, (res) => {
444
+ const status = res.statusCode ?? 0;
445
+ const chunks: Buffer[] = [];
446
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
447
+ res.on('end', () => {
448
+ const buf = Buffer.concat(chunks);
449
+ if (status >= 200 && status < 300) {
450
+ const mime = String(res.headers['content-type'] ?? 'application/octet-stream');
451
+ const disposition = String(res.headers['content-disposition'] ?? '');
452
+ const nameMatch = disposition.match(/filename\*?=([^;]+)/i);
453
+ const displayName = nameMatch
454
+ ? decodeURIComponent(nameMatch[1].replace(/^UTF-8''/i, '').replace(/^"|"$/g, ''))
455
+ : 'artifact';
456
+ finish(() => resolve({
457
+ content: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer,
458
+ mimeType: mime,
459
+ displayName,
460
+ }));
461
+ } else {
462
+ finish(() => reject(toTransportError(status, buf.toString('utf-8'))));
463
+ }
464
+ });
465
+ res.on('error', (err) => finish(() => reject(err)));
466
+ });
467
+
468
+ req.on('error', (err) => finish(() => reject(err)));
469
+
470
+ timeoutHandle = setTimeout(() => {
471
+ req.destroy(new Error(`OASN binary download timeout after ${timeoutMs}ms`));
472
+ }, timeoutMs);
473
+
474
+ if (signal) {
475
+ const onAbort = () => req.destroy(new Error('Request aborted'));
476
+ if (signal.aborted) onAbort();
477
+ else signal.addEventListener('abort', onAbort, { once: true });
478
+ }
479
+
480
+ req.end();
481
+ });
482
+ }
483
+ }