rol-websocket-channel 1.5.7 → 1.5.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.
@@ -1908,3 +1908,103 @@ MQTT 请求示例:
1908
1908
  }
1909
1909
  }
1910
1910
  ```
1911
+
1912
+ ---
1913
+
1914
+ ## apiCoreBot 配置与 Artifact 发布(新增 configSetApiCoreBot;上传链接可返回会话)
1915
+
1916
+ ### configSetApiCoreBot
1917
+
1918
+ 作用:前端在需要开启文件上传/下载链接能力时主动调用,写入当前插件的 `apiCoreBot` 配置。这个配置不是配对时写入,只有收到该 MQTT 消息后才会更新 `openclaw.json`。
1919
+
1920
+ 写入位置:
1921
+
1922
+ ```json
1923
+ {
1924
+ "plugins": {
1925
+ "entries": {
1926
+ "rol-websocket-channel": {
1927
+ "config": {
1928
+ "apiCoreBot": {
1929
+ "baseUrl": "http://192.168.1.23:9092",
1930
+ "authToken": "123"
1931
+ }
1932
+ }
1933
+ }
1934
+ }
1935
+ }
1936
+ }
1937
+ ```
1938
+
1939
+ 默认值:
1940
+
1941
+ - `baseUrl`: `http://192.168.1.23:9092`
1942
+ - `authToken`: `123`
1943
+
1944
+ MQTT 请求示例:
1945
+
1946
+ ```json
1947
+ {
1948
+ "type": "configSetApiCoreBot",
1949
+ "trace_id": "config-api-core-bot-001",
1950
+ "data": {}
1951
+ }
1952
+ ```
1953
+
1954
+ 如果前端需要覆盖默认值,也可以显式传入:
1955
+
1956
+ ```json
1957
+ {
1958
+ "type": "configSetApiCoreBot",
1959
+ "trace_id": "config-api-core-bot-002",
1960
+ "data": {
1961
+ "baseUrl": "http://192.168.1.23:9092",
1962
+ "authToken": "123"
1963
+ }
1964
+ }
1965
+ ```
1966
+
1967
+ 成功返回示例:
1968
+
1969
+ ```json
1970
+ {
1971
+ "type": "receiver",
1972
+ "trace_id": "config-api-core-bot-001",
1973
+ "source": "system",
1974
+ "success": true,
1975
+ "data": {
1976
+ "ok": true,
1977
+ "configFile": "/home/woowonjae/.openclaw/openclaw.json",
1978
+ "updated": [
1979
+ "plugins.entries.rol-websocket-channel.config.apiCoreBot.baseUrl",
1980
+ "plugins.entries.rol-websocket-channel.config.apiCoreBot.authToken"
1981
+ ],
1982
+ "apiCoreBot": {
1983
+ "baseUrl": "http://192.168.1.23:9092",
1984
+ "authToken": "********"
1985
+ }
1986
+ }
1987
+ }
1988
+ ```
1989
+
1990
+ ### Artifact 发布链路
1991
+
1992
+ `configSetApiCoreBot` 写入成功后,Artifact 发布方法会从 `openclaw.json` 读取 `apiCoreBot.baseUrl` 和 `apiCoreBot.authToken`,再调用上传接口。小龙虾会话如果调用插件工具 `rol_artifacts_publish`,成功后会拿到可返回给用户的下载链接。
1993
+
1994
+ 成功返回中的核心字段:
1995
+
1996
+ ```json
1997
+ {
1998
+ "ok": true,
1999
+ "uploaded": true,
2000
+ "artifactId": "art_91f6d03e144c",
2001
+ "objectKey": "uploads/91a78677-5e79-4259-a28d-fa01f24d4848.svg",
2002
+ "downloadUrl": "https://draft-user.s3.us-east-2.amazonaws.com/uploads/91a78677-5e79-4259-a28d-fa01f24d4848.svg"
2003
+ }
2004
+ ```
2005
+
2006
+ 注意:
2007
+
2008
+ - 如果没有先调用 `configSetApiCoreBot`,发布时可能返回 `apiCoreBot.baseUrl is not configured`。
2009
+ - `authToken` 在返回值中会脱敏显示,实际写入 `openclaw.json` 的值是 `123` 或前端传入的值。
2010
+ - 生成文件本身仍然在 `.openclaw/workspace` 下,上传只在发布/下载链路触发时执行。
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  // extensions/rol-websocket-channel/index.ts
2
2
  // WebSocket Channel 插件实现
3
3
  // 提供基于 WebSocket 的双向通信能力,支持 AI 回复和主动消息
4
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
4
5
  import { messageHandler } from "./message-handler.js";
5
6
  import { GlobalMqttClient } from "./src/mqtt/mqtt-client.js";
6
7
  import * as ConnectionManager from "./src/mqtt/connection-manager.js";
8
+ import { registerArtifactsTools } from "./src/admin/tools/artifacts-tools.js";
7
9
  // ============================================
8
10
  // 3. 全局状态
9
11
  // ============================================
@@ -558,14 +560,30 @@ async function handleCustomMessageType(msgType, innerData, traceId, accountId, m
558
560
  // ============================================
559
561
  // 6. 插件注册入口
560
562
  // ============================================
561
- let isPluginRegistered = false;
562
- export default function register(api) {
563
+ export default defineChannelPluginEntry({
564
+ id: "rol-websocket-channel",
565
+ name: "WebSocket Channel",
566
+ description: "Unified plugin providing MQTT Channel and Admin Bridge capabilities for OpenClaw management",
567
+ plugin: WebSocketChannel,
568
+ setRuntime(runtime) {
569
+ pluginRuntime = runtime;
570
+ initializeContext();
571
+ },
572
+ registerCliMetadata(api) {
573
+ registerAdminBridgeCli(api);
574
+ },
575
+ registerFull(api) {
576
+ registerArtifactsTools(api);
577
+ },
578
+ });
579
+ let legacyIsPluginRegistered = false;
580
+ function legacyRegister(api) {
563
581
  console.log("[mqtt] Register rol-websocket-channel");
564
- if (isPluginRegistered) {
582
+ if (legacyIsPluginRegistered) {
565
583
  return;
566
584
  }
567
585
  console.log("[mqtt] Register rol-websocket-channel real");
568
- isPluginRegistered = true;
586
+ legacyIsPluginRegistered = true;
569
587
  pluginRuntime = api.runtime;
570
588
  // 初始化共享 context
571
589
  initializeContext();
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { getContext } from './src/shared/context.js';
6
6
  import { wrapAdminCall } from './src/shared/wrapper.js';
7
- import { getAgents, getConfig } from './src/admin/methods/admin.js';
7
+ import { getAgents, getConfig, setApiCoreBotConfig } from './src/admin/methods/admin.js';
8
8
  import { createAgent, deleteAgent, listAgents, updateAgent } from './src/admin/methods/agents-extended.js';
9
9
  import { createArtifactRecord, ensureArtifactUploaded, getArtifactContent, getArtifactPresignedPost, listArtifacts, markArtifactUploaded, refreshArtifacts } from './src/admin/methods/artifacts.js';
10
10
  import { addCron, disableCron, enableCron, getCronStatus, listCron, listCronRuns, renameCron, removeCron, rescheduleCron, runCron, setCronContent, updateCronMessage, updateCronSystemEvent } from './src/admin/methods/cron.js';
@@ -77,6 +77,12 @@ export class MessageHandler {
77
77
  return await getConfig(data, context);
78
78
  });
79
79
  }
80
+ async configSetApiCoreBot(data) {
81
+ return wrapAdminCall(async () => {
82
+ const context = getContext();
83
+ return await setApiCoreBotConfig(data, context);
84
+ });
85
+ }
80
86
  async cronList(data) {
81
87
  return wrapAdminCall(async () => {
82
88
  const context = getContext();
@@ -1,19 +1,37 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import test from 'node:test';
4
- import register, { formatCliErrorPayload } from '../../index.js';
4
+ import entry, { formatCliErrorPayload } from '../../index.js';
5
5
  const manifest = JSON.parse(readFileSync(new URL('../../openclaw.plugin.json', import.meta.url), 'utf8'));
6
+ const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
6
7
  test('manifest declares OpenClaw 2026 command ownership for admin bridge CLI', () => {
7
8
  assert.deepEqual(manifest.activation?.onCommands, ['admin-bridge', 'rol-websocket-channel']);
8
9
  assert.deepEqual(manifest.commandAliases, [
9
10
  { name: 'admin-bridge' },
10
11
  { name: 'rol-websocket-channel' }
11
12
  ]);
13
+ assert.deepEqual(manifest.contracts?.tools, [
14
+ 'rol_artifacts_find_latest',
15
+ 'rol_artifacts_publish'
16
+ ]);
17
+ });
18
+ test('package.json declares official OpenClaw runtime metadata', () => {
19
+ assert.deepEqual(packageJson.openclaw?.extensions, ['./index.ts']);
20
+ assert.deepEqual(packageJson.openclaw?.runtimeExtensions, ['./dist/index.js']);
21
+ assert.deepEqual(packageJson.openclaw?.compat, {
22
+ pluginApi: '>=2026.5.7',
23
+ minGatewayVersion: '2026.5.7'
24
+ });
25
+ assert.deepEqual(packageJson.openclaw?.build, {
26
+ openclawVersion: '2026.5.7',
27
+ pluginSdkVersion: '2026.5.7'
28
+ });
12
29
  });
13
- test('runtime CLI registrar exposes admin-bridge and rol-websocket-channel command roots', () => {
30
+ test('runtime entry exposes admin-bridge CLI roots and registers artifacts tools in full mode', () => {
14
31
  const descriptors = [];
15
32
  const commands = [];
16
33
  const aliases = [];
34
+ const registeredTools = [];
17
35
  const commandNode = {
18
36
  alias(name) {
19
37
  aliases.push(name);
@@ -37,14 +55,18 @@ test('runtime CLI registrar exposes admin-bridge and rol-websocket-channel comma
37
55
  }
38
56
  };
39
57
  const api = {
58
+ registrationMode: 'full',
40
59
  runtime: {},
41
60
  registerChannel() { },
61
+ registerTool(tool) {
62
+ registeredTools.push(tool);
63
+ },
42
64
  registerCli(callback, options) {
43
65
  descriptors.push(...options.descriptors);
44
66
  callback({ program: commandNode });
45
67
  }
46
68
  };
47
- register(api);
69
+ entry.register(api);
48
70
  assert.deepEqual(descriptors, [
49
71
  {
50
72
  name: 'admin-bridge',
@@ -59,6 +81,21 @@ test('runtime CLI registrar exposes admin-bridge and rol-websocket-channel comma
59
81
  ]);
60
82
  assert.equal(commands[0], 'admin-bridge');
61
83
  assert.equal(aliases[0], 'rol-websocket-channel');
84
+ assert.equal(registeredTools.length, 1);
85
+ });
86
+ test('CLI metadata mode skips full-only tool registration', () => {
87
+ let registerToolCalled = false;
88
+ const api = {
89
+ registrationMode: 'cli-metadata',
90
+ runtime: {},
91
+ registerChannel() { },
92
+ registerTool() {
93
+ registerToolCalled = true;
94
+ },
95
+ registerCli() { }
96
+ };
97
+ entry.register(api);
98
+ assert.equal(registerToolCalled, false);
62
99
  });
63
100
  test('CLI error formatter preserves diagnostic data for pairing failures', () => {
64
101
  const error = new Error('mqttUrl is missing from pairing payload');
@@ -1,5 +1,9 @@
1
1
  import path from 'node:path';
2
- import { readJsonFile } from '../lib/fs.js';
2
+ import { readJsonFile, writeJsonFile } from '../lib/fs.js';
3
+ import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
4
+ const DEFAULT_PLUGIN_ID = 'rol-websocket-channel';
5
+ const DEFAULT_API_CORE_BOT_BASE_URL = 'http://192.168.1.23:9092';
6
+ const DEFAULT_API_CORE_BOT_AUTH_TOKEN = '123';
3
7
  export const getAgents = async (_params, context) => {
4
8
  const configPath = path.join(context.openclawRoot, 'openclaw.json');
5
9
  const config = await readJsonFile(configPath);
@@ -28,6 +32,57 @@ export const getConfig = async (params, context) => {
28
32
  sectionValue: value === undefined ? null : redactSecrets(value)
29
33
  };
30
34
  };
35
+ export const setApiCoreBotConfig = async (params, context) => {
36
+ const objectParams = isObject(params) ? params : {};
37
+ const baseUrl = typeof objectParams.baseUrl === 'string'
38
+ ? objectParams.baseUrl.trim().replace(/\/+$/, '')
39
+ : DEFAULT_API_CORE_BOT_BASE_URL;
40
+ const authToken = typeof objectParams.authToken === 'string' && objectParams.authToken.trim()
41
+ ? objectParams.authToken.trim()
42
+ : DEFAULT_API_CORE_BOT_AUTH_TOKEN;
43
+ if (!baseUrl) {
44
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'apiCoreBot.baseUrl is required');
45
+ }
46
+ const configPath = path.join(context.openclawRoot, 'openclaw.json');
47
+ const config = await readJsonFile(configPath);
48
+ if (!config.plugins)
49
+ config.plugins = {};
50
+ if (!config.plugins.entries || typeof config.plugins.entries !== 'object') {
51
+ config.plugins.entries = {};
52
+ }
53
+ const existingEntry = isObject(config.plugins.entries[DEFAULT_PLUGIN_ID])
54
+ ? config.plugins.entries[DEFAULT_PLUGIN_ID]
55
+ : {};
56
+ const existingPluginConfig = isObject(existingEntry.config) ? existingEntry.config : {};
57
+ const existingApiCoreBot = isObject(existingPluginConfig.apiCoreBot)
58
+ ? existingPluginConfig.apiCoreBot
59
+ : {};
60
+ const nextApiCoreBot = {
61
+ ...existingApiCoreBot,
62
+ baseUrl
63
+ };
64
+ if (authToken) {
65
+ nextApiCoreBot.authToken = authToken;
66
+ }
67
+ config.plugins.entries[DEFAULT_PLUGIN_ID] = {
68
+ ...existingEntry,
69
+ enabled: existingEntry.enabled === false ? false : true,
70
+ config: {
71
+ ...existingPluginConfig,
72
+ apiCoreBot: nextApiCoreBot
73
+ }
74
+ };
75
+ await writeJsonFile(configPath, config);
76
+ return {
77
+ ok: true,
78
+ configFile: configPath,
79
+ updated: [
80
+ `plugins.entries.${DEFAULT_PLUGIN_ID}.config.apiCoreBot.baseUrl`,
81
+ ...(authToken ? [`plugins.entries.${DEFAULT_PLUGIN_ID}.config.apiCoreBot.authToken`] : [])
82
+ ],
83
+ apiCoreBot: redactSecrets(nextApiCoreBot)
84
+ };
85
+ };
31
86
  function isObject(value) {
32
87
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
33
88
  }
@@ -0,0 +1,64 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ import { setApiCoreBotConfig } from './admin.js';
7
+ test('setApiCoreBotConfig writes apiCoreBot endpoint without changing pairing or channel config', async () => {
8
+ const context = await createMethodContext();
9
+ const configPath = path.join(context.openclawRoot, 'openclaw.json');
10
+ await fs.writeFile(configPath, JSON.stringify({
11
+ plugins: {
12
+ entries: {
13
+ 'rol-websocket-channel': {
14
+ enabled: true,
15
+ config: {
16
+ pairing: {
17
+ paired: true,
18
+ pairingKeyLast4: '7a46'
19
+ }
20
+ }
21
+ }
22
+ }
23
+ },
24
+ channels: {
25
+ 'rol-websocket-channel': {
26
+ enabled: true,
27
+ config: {
28
+ mqttUrl: 'ws://mqtt.example.test:8083/mqtt'
29
+ }
30
+ }
31
+ }
32
+ }, null, 2));
33
+ const result = await setApiCoreBotConfig({
34
+ baseUrl: 'https://api.example.test/',
35
+ authToken: 'secret-token'
36
+ }, context);
37
+ const savedConfig = JSON.parse(await fs.readFile(configPath, 'utf8'));
38
+ assert.equal(result.ok, true);
39
+ assert.equal(result.apiCoreBot.baseUrl, 'https://api.example.test');
40
+ assert.equal(result.apiCoreBot.authToken, 'secr***oken');
41
+ assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.baseUrl, 'https://api.example.test');
42
+ assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.authToken, 'secret-token');
43
+ assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.pairing.pairingKeyLast4, '7a46');
44
+ assert.equal(savedConfig.channels['rol-websocket-channel'].config.mqttUrl, 'ws://mqtt.example.test:8083/mqtt');
45
+ });
46
+ test('setApiCoreBotConfig uses default apiCoreBot values when omitted', async () => {
47
+ const context = await createMethodContext();
48
+ await fs.writeFile(path.join(context.openclawRoot, 'openclaw.json'), '{}');
49
+ const result = await setApiCoreBotConfig({}, context);
50
+ const savedConfig = JSON.parse(await fs.readFile(path.join(context.openclawRoot, 'openclaw.json'), 'utf8'));
51
+ assert.equal(result.apiCoreBot.baseUrl, 'http://192.168.1.23:9092');
52
+ assert.equal(result.apiCoreBot.authToken, '********');
53
+ assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.baseUrl, 'http://192.168.1.23:9092');
54
+ assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.authToken, '123');
55
+ });
56
+ async function createMethodContext() {
57
+ const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'api-core-bot-config-test-'));
58
+ const openclawRoot = path.join(projectRoot, '.openclaw');
59
+ await fs.mkdir(openclawRoot, { recursive: true });
60
+ return {
61
+ projectRoot,
62
+ openclawRoot
63
+ };
64
+ }
@@ -169,6 +169,45 @@ export const ensureArtifactUploaded = async (params, context) => {
169
169
  item: artifactToJsonValue(updated)
170
170
  };
171
171
  };
172
+ export const publishArtifact = async (params, context) => {
173
+ const objectParams = expectObject(params);
174
+ const refresh = objectParams.refresh !== false;
175
+ const artifact = await resolveArtifact(objectParams, context, {
176
+ refreshManifest: refresh
177
+ });
178
+ const uploadParams = {
179
+ artifactId: artifact.id
180
+ };
181
+ if (objectParams.presignedPostBody !== undefined) {
182
+ uploadParams.presignedPostBody = objectParams.presignedPostBody;
183
+ }
184
+ if (objectParams.baseUrl !== undefined) {
185
+ uploadParams.baseUrl = objectParams.baseUrl;
186
+ }
187
+ if (objectParams.authToken !== undefined) {
188
+ uploadParams.authToken = objectParams.authToken;
189
+ }
190
+ return await ensureArtifactUploaded(uploadParams, context);
191
+ };
192
+ export const findLatestArtifacts = async (params, context) => {
193
+ const objectParams = expectOptionalObject(params);
194
+ const refresh = objectParams.refresh !== false;
195
+ const manifest = refresh
196
+ ? await refreshArtifactManifest(context)
197
+ : await readOrRefreshArtifactManifest(context);
198
+ const filters = normalizeArtifactFindFilters(objectParams);
199
+ const items = manifest.items
200
+ .filter((item) => matchesArtifactFindFilters(item, filters))
201
+ .sort(compareArtifactsByRecency)
202
+ .slice(0, filters.limit)
203
+ .map(artifactToJsonValue);
204
+ return {
205
+ ok: true,
206
+ scope: 'workspace',
207
+ count: items.length,
208
+ items
209
+ };
210
+ };
172
211
  export const getArtifactPresignedPost = async (params, context) => {
173
212
  const objectParams = expectObject(params);
174
213
  const body = isObject(objectParams.body) ? objectParams.body : null;
@@ -403,8 +442,10 @@ async function collectArtifactFiles(rootDir) {
403
442
  }
404
443
  return files;
405
444
  }
406
- async function resolveArtifact(params, context) {
407
- const manifest = await readOrRefreshArtifactManifest(context);
445
+ async function resolveArtifact(params, context, options) {
446
+ const manifest = options?.refreshManifest
447
+ ? await refreshArtifactManifest(context)
448
+ : await readOrRefreshArtifactManifest(context);
408
449
  const artifact = findArtifact(manifest.items, params);
409
450
  if (!artifact) {
410
451
  throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Artifact not found', {
@@ -590,6 +631,84 @@ function expectString(value, fieldName) {
590
631
  function optionalTrimmedString(value) {
591
632
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
592
633
  }
634
+ function normalizeArtifactFindFilters(params) {
635
+ return {
636
+ category: normalizeArtifactCategory(optionalTrimmedString(params.category)),
637
+ mimeType: normalizeOptionalLowercaseString(params.mimeType),
638
+ fileName: normalizeOptionalLowercaseString(params.fileName),
639
+ relativePathPrefix: normalizeRelativePath(optionalTrimmedString(params.relativePathPrefix)),
640
+ createdAfterMs: normalizeOptionalTimestamp(params.createdAfter),
641
+ limit: normalizeArtifactLimit(params.limit)
642
+ };
643
+ }
644
+ function normalizeArtifactCategory(value) {
645
+ if (!value) {
646
+ return null;
647
+ }
648
+ if (value === 'image' ||
649
+ value === 'video' ||
650
+ value === 'document' ||
651
+ value === 'archive' ||
652
+ value === 'other') {
653
+ return value;
654
+ }
655
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, `Unsupported artifact category: ${value}`);
656
+ }
657
+ function normalizeOptionalLowercaseString(value) {
658
+ const normalized = optionalTrimmedString(value);
659
+ return normalized ? normalized.toLowerCase() : null;
660
+ }
661
+ function normalizeOptionalTimestamp(value) {
662
+ if (value === undefined) {
663
+ return null;
664
+ }
665
+ if (typeof value !== 'string' || value.trim().length === 0) {
666
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'createdAfter must be a non-empty ISO timestamp');
667
+ }
668
+ const timestamp = Date.parse(value);
669
+ if (Number.isNaN(timestamp)) {
670
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'createdAfter must be a valid ISO timestamp');
671
+ }
672
+ return timestamp;
673
+ }
674
+ function normalizeArtifactLimit(value) {
675
+ if (value === undefined) {
676
+ return 5;
677
+ }
678
+ if (!Number.isFinite(value) || value <= 0) {
679
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'limit must be a positive number');
680
+ }
681
+ return Math.min(50, Math.floor(value));
682
+ }
683
+ function matchesArtifactFindFilters(item, filters) {
684
+ if (filters.category && item.category !== filters.category) {
685
+ return false;
686
+ }
687
+ if (filters.mimeType && item.mimeType.toLowerCase() !== filters.mimeType) {
688
+ return false;
689
+ }
690
+ if (filters.fileName && item.fileName.toLowerCase() !== filters.fileName) {
691
+ return false;
692
+ }
693
+ if (filters.relativePathPrefix && !item.relativePath.startsWith(filters.relativePathPrefix)) {
694
+ return false;
695
+ }
696
+ if (filters.createdAfterMs !== null && Date.parse(item.createdAt) <= filters.createdAfterMs) {
697
+ return false;
698
+ }
699
+ return true;
700
+ }
701
+ function compareArtifactsByRecency(left, right) {
702
+ const createdDelta = Date.parse(right.createdAt) - Date.parse(left.createdAt);
703
+ if (createdDelta !== 0) {
704
+ return createdDelta;
705
+ }
706
+ const updatedDelta = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
707
+ if (updatedDelta !== 0) {
708
+ return updatedDelta;
709
+ }
710
+ return left.fileName.localeCompare(right.fileName);
711
+ }
593
712
  function isObject(value) {
594
713
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
595
714
  }
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { ensureArtifactUploaded, listArtifacts, markArtifactUploaded, refreshArtifacts } from './artifacts.js';
6
+ import { ensureArtifactUploaded, findLatestArtifacts, listArtifacts, markArtifactUploaded, publishArtifact, refreshArtifacts } from './artifacts.js';
7
7
  const tempDirs = [];
8
8
  const originalFetch = globalThis.fetch;
9
9
  afterEach(async () => {
@@ -181,6 +181,90 @@ describe('artifacts workspace scope', () => {
181
181
  assert.equal(ensured.item.storageStatus, 'uploaded');
182
182
  assert.equal(ensured.item.fileUrl, 'https://cdn.example.com/artifacts/me.pdf');
183
183
  });
184
+ test('findLatest returns newest matching artifacts with filters', async () => {
185
+ const context = await createMethodContext();
186
+ const workspaceRoot = path.join(context.openclawRoot, 'workspace');
187
+ await fs.mkdir(path.join(workspaceRoot, 'exports'), { recursive: true });
188
+ await fs.writeFile(path.join(workspaceRoot, 'exports', 'first.pdf'), 'pdf-data', 'utf8');
189
+ await new Promise((resolve) => setTimeout(resolve, 20));
190
+ await fs.writeFile(path.join(workspaceRoot, 'exports', 'second.png'), 'png-data', 'utf8');
191
+ await new Promise((resolve) => setTimeout(resolve, 20));
192
+ await fs.writeFile(path.join(workspaceRoot, 'exports', 'third.docx'), 'docx-data', 'utf8');
193
+ const latest = await findLatestArtifacts({
194
+ category: 'document',
195
+ relativePathPrefix: 'exports/',
196
+ limit: 2
197
+ }, context);
198
+ assert.equal(latest.ok, true);
199
+ assert.equal(latest.count, 2);
200
+ assert.deepEqual(latest.items.map((item) => item.fileName), ['third.docx', 'first.pdf']);
201
+ assert.deepEqual(latest.items.map((item) => item.category), ['document', 'document']);
202
+ assert.deepEqual(latest.items.map((item) => item.relativePath), ['exports/third.docx', 'exports/first.pdf']);
203
+ });
204
+ test('publish refreshes manifest for a new file and uploads it by relativePath', async () => {
205
+ const context = await createMethodContext();
206
+ const workspaceRoot = path.join(context.openclawRoot, 'workspace');
207
+ const artifactPath = path.join(workspaceRoot, 'generated', 'result.pdf');
208
+ await writeApiCoreBotConfig(context.openclawRoot, 'https://api.example.com');
209
+ await fs.mkdir(path.dirname(artifactPath), { recursive: true });
210
+ await fs.writeFile(artifactPath, 'pdf-data', 'utf8');
211
+ const calls = [];
212
+ globalThis.fetch = (async (input, init) => {
213
+ const url = typeof input === 'string'
214
+ ? input
215
+ : input instanceof URL
216
+ ? input.toString()
217
+ : input.url;
218
+ const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
219
+ calls.push({ url, method });
220
+ if (url === 'https://api.example.com/api-core-bot/front/s3/get-presigned-post') {
221
+ const parsedBody = JSON.parse(String(init?.body));
222
+ assert.equal(parsedBody.filename, 'result.pdf');
223
+ assert.equal(parsedBody.dir, 'generated/');
224
+ return new Response(JSON.stringify({
225
+ success: true,
226
+ data: {
227
+ url: 'https://upload.example.com',
228
+ fields: {
229
+ key: 'generated/result.pdf',
230
+ policy: 'policy-token'
231
+ },
232
+ file_url: 'https://cdn.example.com/generated/result.pdf'
233
+ }
234
+ }), {
235
+ status: 200,
236
+ headers: { 'Content-Type': 'application/json' }
237
+ });
238
+ }
239
+ if (url === 'https://upload.example.com') {
240
+ assert.ok(init?.body instanceof FormData);
241
+ return new Response(null, { status: 204 });
242
+ }
243
+ throw new Error(`Unexpected fetch call: ${url}`);
244
+ });
245
+ const published = await publishArtifact({
246
+ relativePath: 'generated/result.pdf',
247
+ presignedPostBody: {
248
+ dir: 'generated/'
249
+ }
250
+ }, context);
251
+ assert.equal(published.ok, true);
252
+ assert.equal(published.uploaded, true);
253
+ assert.equal(published.downloadUrl, 'https://cdn.example.com/generated/result.pdf');
254
+ assert.equal(published.item.relativePath, 'generated/result.pdf');
255
+ assert.equal(published.item.storageStatus, 'uploaded');
256
+ assert.match(published.artifactId, /^art_/);
257
+ assert.deepEqual(calls, [
258
+ {
259
+ url: 'https://api.example.com/api-core-bot/front/s3/get-presigned-post',
260
+ method: 'POST'
261
+ },
262
+ {
263
+ url: 'https://upload.example.com',
264
+ method: 'POST'
265
+ }
266
+ ]);
267
+ });
184
268
  });
185
269
  async function createMethodContext() {
186
270
  const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'artifacts-task-scope-'));
@@ -1,7 +1,7 @@
1
1
  import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
2
- import { getAgents, getConfig } from './admin.js';
2
+ import { getAgents, getConfig, setApiCoreBotConfig } from './admin.js';
3
3
  import { createAgent, deleteAgent, listAgents, updateAgent } from './agents-extended.js';
4
- import { createArtifactRecord, ensureArtifactUploaded, getArtifactContent, getArtifactPresignedPost, listArtifacts, markArtifactUploaded, refreshArtifacts } from './artifacts.js';
4
+ import { createArtifactRecord, ensureArtifactUploaded, findLatestArtifacts, getArtifactContent, getArtifactPresignedPost, listArtifacts, markArtifactUploaded, publishArtifact, refreshArtifacts } from './artifacts.js';
5
5
  import { addCron, disableCron, enableCron, getCronStatus, listCron, listCronRuns, renameCron, removeCron, rescheduleCron, runCron, setCronContent, updateCronMessage, updateCronSystemEvent } from './cron.js';
6
6
  import { backupMemory, createMemoryBackupRecord, exportMemoryZip, getMemoryPresignedPost, getMemoryFile, importMemoryZip, listMemoryFiles } from './memory.js';
7
7
  import { getMem9Config, installMem9, reconnectMem9 } from './mem9.js';
@@ -30,6 +30,7 @@ const methods = new Map([
30
30
  ['agents.update', updateAgent],
31
31
  // Config
32
32
  ['config.get', getConfig],
33
+ ['config.setApiCoreBot', setApiCoreBotConfig],
33
34
  // Cron
34
35
  ['cron.list', listCron],
35
36
  ['cron.status', getCronStatus],
@@ -82,6 +83,8 @@ const methods = new Map([
82
83
  ['artifacts.refresh', refreshArtifacts],
83
84
  ['artifacts.getContent', getArtifactContent],
84
85
  ['artifacts.ensureUploaded', ensureArtifactUploaded],
86
+ ['artifacts.findLatest', findLatestArtifacts],
87
+ ['artifacts.publish', publishArtifact],
85
88
  ['artifacts.getPresignedPost', getArtifactPresignedPost],
86
89
  ['artifacts.createRecord', createArtifactRecord],
87
90
  ['artifacts.markUploaded', markArtifactUploaded],
@@ -0,0 +1,15 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { findLatestArtifacts, publishArtifact } from './artifacts.js';
4
+ import { setApiCoreBotConfig } from './admin.js';
5
+ import { getMethod, listMethods } from './index.js';
6
+ test('methods registry exposes local artifact discovery and publish methods', () => {
7
+ assert.equal(getMethod('artifacts.findLatest'), findLatestArtifacts);
8
+ assert.equal(getMethod('artifacts.publish'), publishArtifact);
9
+ assert.ok(listMethods().includes('artifacts.findLatest'));
10
+ assert.ok(listMethods().includes('artifacts.publish'));
11
+ });
12
+ test('methods registry exposes apiCoreBot config writer', () => {
13
+ assert.equal(getMethod('config.setApiCoreBot'), setApiCoreBotConfig);
14
+ assert.ok(listMethods().includes('config.setApiCoreBot'));
15
+ });