Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## 公开范围

11 组中英文页面覆盖接入认证、Agent Webhook 管理与触发、只读 Agent/Run/回执、HTTP + Socket.IO 对话、会话历史与消息、附件与媒体、模型代理、工作流与 OpenAPI 下载。40 个 HTTP 操作按服务分成三个自包含 OpenAPI 3.1 文件;对话事件和恢复规则维护在指南与可运行 Node.js 示例中。
13 组中英文页面覆盖接入认证、Agent Webhook 管理与触发、只读 Agent/Run/回执、HTTP + Socket.IO 对话、会话历史与消息、附件与媒体、实例诊断与用量、TaskBoard 与 S3 读取、模型代理、工作流与 OpenAPI 下载。50 个 HTTP 操作按服务分成三个自包含 OpenAPI 3.1 文件;对话事件和恢复规则维护在指南与可运行 Node.js 示例中。

Webhook 所有者管理依赖握手 `capabilities.webhookManagement: true`,由可信入口选择实际工具并生成文件范围快照;旧服务仅支持已有事件。该支持条件没有被改写成未经核对的客户端发行号。现有 Write/Bash 与审批模型保留,不把配置文件宣称为不可修改的安全边界。

Expand Down Expand Up @@ -106,3 +106,14 @@ RunMeta五态与runtime-state HTTP四态/转交404的差异仍存在,公开文
本批新增输入配置、网络状态、聚合历史、近期工具调用和用量汇总5操作,Agent Service32、Compat3、Workflow5。新增双语实例诊断与用量页,扩展history all读取器及observe.mjs;observe维护文件与static下载逐字一致,校验器覆盖该镜像。源码ID与字段投影修复、旧索引过渡和本批定向验证见[本批记录](./observation-verification-2026-10-25.md)。

Worker两项未加入公开清单:独立隔离夹具确认其早期进度/旧结果重投影与entry游标说明仍需修复。该专项结果不扩写为跨用户泄漏或真实数据事故;见验证记录的范围说明。此前生产/搜索验收保留原始批次,不代表本批已部署。


## 2026-10-25 TaskBoard 与 S3 读取扩展

本批固定源码 `ba6a550aeb397c377f6c3c6e620863d06d526628`、内部文档 `9fbe5f4668d223553b38ebd4e7a2a49450000a9b`,增加 TaskBoard 7 项及 S3 3 项读取操作。当前公开范围为 Agent Service 42、Compat Proxy 3、Workflow Service 5,共50操作、13组双语页面。Agent Service描述修订为1.3.0;其它两规范的来源和修订保持原事实。

TaskBoard 整板activity只提供handoff证据;单任务activity使用固定throughRevision的事件前缀,同时每页关联owner摘要仍为当前投影。S3连接摘要不验证凭据;link/binary错误和响应分别建模,public链接无expires_in、末页通常省略续读令牌。相关文档及可运行示例见TaskBoard与对象存储页;两个POST查询不会创建任务板或发起执行。

`taskboards.mjs`、`s3.mjs`及static镜像由api:check逐字核对,示例包不增加站点依赖。隔离源码fixture维护在示例test目录,必须复制到注明的源码位置后使用该仓的Vitest执行;它们不属于站点TypeScript编译。详细验证及未覆盖范围见[本批记录](./taskboard-s3-verification-2026-10-25.md)。

Worker投影修复单独提交源码PR,本批没有将Worker两项加入公开清单或规范。此前发布与搜索验收保留其历史基线,不代表本批已部署或收录。
493 changes: 453 additions & 40 deletions api/endpoint-catalog.json

Large diffs are not rendered by default.

37 changes: 35 additions & 2 deletions api/examples/agent-client/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DesireCore Node conversation client
# DesireCore Node client examples

These examples call an administrator's existing standalone Agent Service using the same owner credential for HTTP and Socket.IO. Obtain its HTTPS origin, existing instance token, and target Agent ID from the administrator. The Agent needs a working model configuration and permissions appropriate for your prompt.
These examples call an administrator's existing standalone Agent Service. Obtain its HTTPS origin and existing owner token from the administrator. The conversation client also needs a target Agent ID, a working model configuration and permissions appropriate for the prompt; it uses the same owner credential for HTTP and Socket.IO. Read examples use HTTP and do not require a model call.

Use Node.js 22 or newer. Install dependencies **in this directory**, independently of the documentation website:

Expand Down Expand Up @@ -105,3 +105,36 @@ node history.mjs all --agent-id your-agent-id --limit 100 --all-pages
```

Merged history buffers one revision per attempt. A 409 discards that attempt and restarts from page zero within the configured restart budget. No rejected attempt is emitted as a successful page set. Source-only Mention IDs remain display identifiers and are never passed to Run or execution APIs. `limit=0` returns metadata with an empty page rather than all messages. Current validation is recorded in `api/observation-verification-2026-10-25.md`.


## TaskBoard reads

`taskboards.mjs` uses the configured TaskBoard capability and the authenticated owner's readable scope. It requires Node.js 22+, with no npm dependencies. See [TaskBoard reads](https://docs.desirecore.com/en/api/taskboards) for request and response contracts.

```bash
node taskboards.mjs list
node taskboards.mjs query --filter '{"statuses":["pending"]}' --page-size 50
node taskboards.mjs board --board-id BOARD_ID
node taskboards.mjs task --board-id BOARD_ID --task-id TASK_ID
node taskboards.mjs handoff --board-id BOARD_ID
node taskboards.mjs activity --board-id BOARD_ID --task-id TASK_ID --page-size 50
node taskboards.mjs resolve --run-id RUN_ID
```

Query cursors remain opaque. Activity freezes `throughRevision` across pages, which bounds the event prefix; owner summary projections are still current per page. The example buffers query/activity pages and emits only after paging succeeds. A conflict or bounded-page failure discards the buffered output; it does not retry, modify tasks or create a missing session board. Board `handoff` returns handoff evidence without activity pagination. Treat task descriptions and returned data as private.

## Object-storage reads

`s3.mjs` uses an installed, enabled Object Storage capability and an existing configured S3 connection. It requires Node.js 22+, with no npm dependencies or AK/SK in the calling program. See [Object-storage reads](https://docs.desirecore.com/en/api/object-storage).

```bash
node s3.mjs connections
node s3.mjs list --connection-id CONNECTION_ID --prefix reports/ --all
node s3.mjs download --connection-id CONNECTION_ID --key reports/summary.txt
node s3.mjs download --connection-id CONNECTION_ID --key reports/summary.txt \
--mode binary --output ./summary.txt --max-bytes 10485760
```

The list command reads one page unless `--all` is specified; all-page output is streamed and can be partial on failure. It preserves provider continuation tokens and stops on missing/repeated tokens or the page cap. Link mode only prints the returned public/presigned link; it never fetches that URL or sends the instance token to it. Binary mode requests the authenticated instance directly, rejects redirects, bounds bytes and creates the explicitly selected output file without overwriting an existing file. Incomplete output from the current download is removed on failure. Object contents, keys and signed links may be private.

Maintained `taskboards.mjs` and `s3.mjs` are byte-identical to their files under `static/api-examples/`, as checked by `pnpm api:check`. Their HTTP tests use a synthetic local server and do not prove real TaskBoard or S3 provider availability.
133 changes: 133 additions & 0 deletions api/examples/agent-client/s3.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env node
// Standalone object-storage reads. Node.js 22+, no packages required.
import { open, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

const HELP = `Usage:
node s3.mjs connections
node s3.mjs list [--connection-id ID] [--prefix PATH] [--delimiter /] [--max-keys 100] [--all] [--max-pages 100]
node s3.mjs download --key KEY [--connection-id ID] [--mode link] [--expires-in 3600]
node s3.mjs download --key KEY --mode binary --output FILE [--max-bytes 104857600]
Environment: DESIRECORE_URL, DESIRECORE_TOKEN, optional DESIRECORE_S3_CONNECTION_ID.
Options: --timeout-ms 10000. Remote origins must use HTTPS.
Returned links are printed, never followed; signed links can grant access. Existing output files are never overwritten.
`;

function originFor(value) {
let url;
try { url = new URL(value); } catch { throw new Error('Set DESIRECORE_URL to the instance origin.'); }
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash
|| (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
throw new Error('Use an HTTPS origin without credentials, path, query or fragment; HTTP is allowed only on loopback.');
}
return url.origin;
}
function integer(value, label, fallback, min = 1, max = Number.MAX_SAFE_INTEGER) {
if (value === undefined) return fallback;
if (!/^[0-9]+$/.test(String(value)) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max) throw new Error(`${label} must be a decimal integer from ${min} to ${max}.`);
return Number(value);
}
export function parseS3Args(argv) {
if (argv.includes('--help') || argv.includes('-h')) return { help: true };
const options = { command: argv[0] };
const flags = { '--connection-id': 'connectionId', '--prefix': 'prefix', '--delimiter': 'delimiter', '--max-keys': 'maxKeys', '--continuation-token': 'continuationToken', '--max-pages': 'maxPages', '--key': 'key', '--mode': 'mode', '--expires-in': 'expiresIn', '--output': 'output', '--max-bytes': 'maxBytes', '--timeout-ms': 'timeoutMs' };
for (let i = 1; i < argv.length; i++) {
if (argv[i] === '--all') { if (options.all) throw new Error(HELP); options.all = true; continue; }
const key = flags[argv[i]];
if (!key || Object.hasOwn(options, key) || argv[i + 1] === undefined || argv[i + 1].startsWith('--')) throw new Error(HELP);
options[key] = argv[++i];
}
return options;
}
export async function runS3(input, { env = process.env, emit = value => process.stdout.write(`${JSON.stringify(value)}\n`) } = {}) {
if (input.help) { process.stdout.write(HELP); return; }
const { command } = input;
if (!['connections', 'list', 'download'].includes(command)) throw new Error(HELP);
const origin = originFor(env.DESIRECORE_URL);
if (typeof env.DESIRECORE_TOKEN !== 'string' || !env.DESIRECORE_TOKEN.trim()) throw new Error('Set DESIRECORE_TOKEN.');
const allowed = new Set(['command', 'help', 'timeoutMs', ...(command === 'list' ? ['connectionId', 'prefix', 'delimiter', 'maxKeys', 'continuationToken', 'all', 'maxPages'] : command === 'download' ? ['connectionId', 'key', 'mode', 'expiresIn', 'output', 'maxBytes'] : [])]);
if (Object.keys(input).some(key => !allowed.has(key))) throw new Error('An option does not apply to this command.');
const connectionId = command === 'connections' ? undefined : input.connectionId ?? env.DESIRECORE_S3_CONNECTION_ID;
if (connectionId !== undefined && (typeof connectionId !== 'string' || !connectionId)) throw new Error('Use a nonempty connection ID returned by this instance.');
const timeoutMs = integer(input.timeoutMs, 'timeoutMs', 10_000, 1, 600_000);
const maxKeys = integer(input.maxKeys, 'maxKeys', 100, 1, 1000);
const maxPages = integer(input.maxPages, 'maxPages', 100, 1, 10000);
const expiresIn = integer(input.expiresIn, 'expiresIn', 3600, 60, 604800);
const maxBytes = integer(input.maxBytes, 'maxBytes', 104857600);
const mode = input.mode ?? 'link';
if (command === 'download' && (typeof input.key !== 'string' || !input.key || !['link', 'binary'].includes(mode))) throw new Error('Download requires --key and mode link or binary.');
if (command === 'download' && (mode === 'binary' ? !input.output || input.expiresIn !== undefined : input.output !== undefined || input.maxBytes !== undefined)) throw new Error('Binary mode requires --output and accepts --max-bytes; link mode accepts --expires-in.');
if (input.maxPages !== undefined && !input.all) throw new Error('--max-pages requires --all.');
async function request(path) {
return fetch(origin + path, { headers: { 'X-Agent-Service-Token': env.DESIRECORE_TOKEN }, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
}
async function json(response) {
if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) throw new Error(`HTTP ${response.status}: expected JSON from the instance API.`);
let value;
try { value = await response.json(); } catch { throw new Error(`HTTP ${response.status}: invalid JSON from the instance API.`); }
if (!response.ok) {
const code = typeof value?.code === 'string' && /^[A-Za-z0-9_-]{1,100}$/.test(value.code) ? ` ${value.code}` : '';
throw new Error(`Object-storage read rejected: HTTP ${response.status}${code}`);
}
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected an API response object.');
return value;
}
const metadata = await json(await request('/api/client-handshake'));
if (metadata.service !== 'desirecore-agent-service' || metadata.apiProtocolVersion !== '1' || typeof metadata.instanceId !== 'string' || !metadata.instanceId) throw new Error('The handshake did not identify a compatible DesireCore instance.');
const query = new URLSearchParams();
if (connectionId !== undefined) query.set('connection_id', connectionId);
const suffix = () => query.size ? `?${query}` : '';
if (command === 'connections') {
const data = await json(await request('/api/s3/connections'));
if (data.success !== true || !Array.isArray(data.connections)) throw new Error('Invalid connection summary.');
emit({ kind: command, instanceId: metadata.instanceId, data });
} else if (command === 'list') {
query.set('max_keys', String(maxKeys));
for (const [key, value] of Object.entries({ prefix: input.prefix, delimiter: input.delimiter, continuation_token: input.continuationToken })) if (value !== undefined) query.set(key, value);
const seen = new Set(input.continuationToken ? [input.continuationToken] : []);
for (let page = 1; ; page++) {
const data = await json(await request('/api/s3/list' + suffix()));
if (data.success !== true || !Array.isArray(data.objects) || !Array.isArray(data.common_prefixes) || typeof data.is_truncated !== 'boolean') throw new Error('Invalid object list.');
emit({ kind: command, instanceId: metadata.instanceId, page, data });
if (!input.all || !data.is_truncated) break;
const next = data.next_continuation_token;
if (typeof next !== 'string' || !next || seen.has(next)) throw new Error('Missing or repeated continuation token; stop rather than loop.');
if (page >= maxPages) throw new Error('Reached --max-pages with more results remaining; output is partial.');
seen.add(next); query.set('continuation_token', next);
}
} else {
query.set('key', input.key); query.set('mode', mode);
if (mode === 'link') {
query.set('expires_in', String(expiresIn));
const data = await json(await request('/api/s3/download' + suffix()));
if (data.success !== true || !['public', 'presigned'].includes(data.url_type) || typeof data.download_url !== 'string') throw new Error('Invalid download link response.');
emit({ kind: 'download-link', instanceId: metadata.instanceId, data });
return;
}
const response = await request('/api/s3/download' + suffix());
if (!response.ok) { await json(response); return; }
if (!response.body) throw new Error('Binary response has no body.');
const advertised = response.headers.get('content-length');
if (advertised !== null && Number(advertised) > maxBytes) { await response.body.cancel(); throw new Error('Binary response exceeds --max-bytes.'); }
let file;
let bytes = 0;
try {
file = await open(resolve(input.output), 'wx', 0o600);
for await (const chunk of response.body) {
bytes += chunk.byteLength;
if (bytes > maxBytes) throw new Error('Binary response exceeds --max-bytes.');
await file.writeFile(chunk);
}
await file.close(); file = undefined;
} catch (error) {
await response.body.cancel().catch(() => {});
if (file) { await file.close(); await unlink(resolve(input.output)); }
throw error;
}
emit({ kind: 'download-binary', instanceId: metadata.instanceId, output: resolve(input.output), bytes, contentType: response.headers.get('content-type') });
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
runS3(parseS3Args(process.argv.slice(2))).catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
}
Loading
Loading