assistant/tools/learning/control.mjs

207 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { pathToFileURL } from 'node:url';
const MAX_INPUT_BYTES = 64 * 1024;
const ID = /^[A-Za-z0-9_-]{1,120}$/;
const API_CODE = /^[A-Z][A-Z0-9_]{1,63}$/;
const PRIVATE_API_CODE = /SECRET|PASSWORD|PASSWD|CREDENTIAL|TOKEN|COOKIE|AUTHORIZATION/;
function safeApiCode(value) {
return typeof value === 'string' && API_CODE.test(value) && !PRIVATE_API_CODE.test(value) ? value : undefined;
}
export class ControlError extends Error {
constructor(message, code = 'CONTROL_ERROR', status, apiCode) {
super(message);
this.name = 'ControlError';
this.code = code;
if (status !== undefined) this.status = status;
const safeCode = safeApiCode(apiCode);
if (safeCode !== undefined) this.apiCode = safeCode;
}
}
function localBase(value) {
let url;
try { url = new URL(value); } catch { throw new ControlError('请使用本机学习后台地址', 'INVALID_BASE_URL'); }
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
|| url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new ControlError('控制入口仅连接本机 HTTP 学习后台,不接受远端地址或地址中的凭据', 'INVALID_BASE_URL');
}
return url.origin;
}
function object(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ControlError(`${label}必须是对象`, 'INVALID_REQUEST');
return value;
}
function id(value, label) {
if (typeof value !== 'string' || !ID.test(value)) throw new ControlError(`${label}格式不正确`, 'INVALID_REQUEST');
return encodeURIComponent(value);
}
function routeFor(request) {
object(request, '请求');
const { operation } = request;
if (typeof operation !== 'string') throw new ControlError('缺少 operation', 'INVALID_REQUEST');
const task = () => id(request.task_id, 'task_id');
const candidate = () => id(request.candidate_id, 'candidate_id');
const validation = () => id(request.validation_id, 'validation_id');
const discovery = () => id(request.run_id, 'run_id');
const reading = () => id(request.reading_id, 'reading_id');
const analysis = () => id(request.analysis_id, 'analysis_id');
const brief = () => id(request.brief_id, 'brief_id');
const review = () => id(request.review_id, 'review_id');
const data = () => object(request.data ?? {}, 'data');
switch (operation) {
case 'health': return ['GET', '/api/health'];
case 'routes.list': return ['GET', '/api/learning/routes'];
case 'routes.resolve': return ['POST', '/api/learning/routes/resolve', data()];
case 'routes.verify': return ['POST', '/api/learning/routes/verify', data()];
case 'tasks.list': return ['GET', '/api/learning/tasks'];
case 'tasks.get': return ['GET', `/api/learning/tasks/${task()}`];
case 'tasks.create': return ['POST', '/api/learning/tasks', data()];
case 'browser.open':
case 'browser.ready':
case 'browser.capture':
case 'browser.action':
case 'browser.close': return ['POST', `/api/learning/tasks/${task()}/browser/${operation.split('.')[1]}`, data()];
case 'candidates.create': return ['POST', `/api/learning/tasks/${task()}/candidates`, data()];
case 'candidates.get': return ['GET', `/api/learning/candidates/${candidate()}`];
case 'candidates.check': return ['POST', `/api/learning/candidates/${candidate()}/check`, data()];
case 'candidates.verify': return ['POST', `/api/learning/candidates/${candidate()}/verify`, data()];
case 'candidates.publish_check': return ['POST', `/api/learning/candidates/${candidate()}/publish-check`, data()];
case 'validations.list': return ['GET', '/api/learning/validations'];
case 'quality.append': return ['POST', `/api/learning/validations/${validation()}/quality-observations`, data()];
case 'discovery.list': return ['GET', '/api/discovery/runs'];
case 'discovery.get': return ['GET', `/api/discovery/runs/${discovery()}`];
case 'discovery.create': return ['POST', '/api/discovery/runs', data()];
case 'readings.list': return ['GET', `/api/discovery/runs/${discovery()}/readings`];
case 'readings.get': return ['GET', `/api/discovery/readings/${reading()}`];
case 'readings.create': return ['POST', `/api/discovery/runs/${discovery()}/readings`, data()];
case 'model.config': return ['GET', '/api/analysis/config'];
case 'briefs.list': return ['GET', '/api/business/briefs'];
case 'briefs.create': return ['POST', '/api/business/briefs', data()];
case 'briefs.get': return ['GET', `/api/business/briefs/${brief()}`];
case 'briefs.confirm': return ['POST', `/api/business/briefs/${brief()}/confirm`, data()];
case 'briefs.discover': return ['POST', `/api/business/briefs/${brief()}/discover`, data()];
case 'analyses.list': return ['GET', `/api/discovery/readings/${reading()}/analyses`];
case 'analyses.create': return ['POST', `/api/discovery/readings/${reading()}/analyses`, data()];
case 'analyses.get': return ['GET', `/api/discovery/analyses/${analysis()}`];
case 'reviews.list': return ['GET', `/api/discovery/analyses/${analysis()}/reviews`];
case 'reviews.create': return ['POST', `/api/discovery/analyses/${analysis()}/reviews`, data()];
case 'reviews.summary': return ['GET', `/api/discovery/analyses/${analysis()}/review-summary`];
case 'reviews.get': return ['GET', `/api/discovery/reviews/${review()}`];
default: throw new ControlError('不支持的控制操作', 'UNKNOWN_OPERATION');
}
}
export class LearningControlClient {
#baseUrl;
#cookies = new Map();
#csrf = '';
#authenticated = false;
#credentials;
constructor({ baseUrl = 'http://127.0.0.1:8810', username, password, timeoutMs = 120000 } = {}) {
this.#baseUrl = localBase(baseUrl);
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 180000) throw new ControlError('控制请求超时须在 100–180000 毫秒内', 'INVALID_TIMEOUT');
this.timeoutMs = timeoutMs;
this.#credentials = { username, password };
}
async #request(method, path, body) {
let response;
try {
response = await fetch(this.#baseUrl + path, {
method, redirect: 'error', signal: AbortSignal.timeout(this.timeoutMs),
headers: {
Origin: this.#baseUrl,
Cookie: [...this.#cookies].map(([key, value]) => `${key}=${value}`).join('; '),
...(body === undefined ? {} : { 'Content-Type': 'application/json', 'X-CSRF-Token': this.#csrf }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
// Do not print request bodies, credentials, cookies or driver error strings.
throw new ControlError('本机学习后台连接失败、超时或拒绝重定向;请先核对状态,不自动重放操作', 'CONNECTION_FAILED');
}
for (const cookie of response.headers.getSetCookie()) {
const pair = cookie.split(';', 1)[0];
const split = pair.indexOf('=');
if (split > 0) this.#cookies.set(pair.slice(0, split), pair.slice(split + 1));
}
let payload;
try { payload = await response.json(); } catch { throw new ControlError('本机学习后台返回格式不正确', 'INVALID_RESPONSE', response.status); }
if (!response.ok) {
// Server details can contain submitted content. Keep only a bounded ASCII
// diagnostic code; never copy server messages or the rest of the payload.
throw new ControlError(`学习后台拒绝操作(HTTP ${response.status}),请在后台查看对应记录`, 'API_REJECTED', response.status, payload?.detail?.code);
}
if (payload && typeof payload.csrf_token === 'string') this.#csrf = payload.csrf_token;
return payload;
}
async #login() {
if (this.#authenticated) return;
const { username, password } = this.#credentials;
if (!username || !password) throw new ControlError('请在本进程设置 ASSISTANT_MAINTAINER_USERNAME 和 ASSISTANT_MAINTAINER_PASSWORD;不要把密码放进请求或命令行参数', 'CREDENTIALS_REQUIRED');
await this.#request('GET', '/api/auth/status');
const result = await this.#request('POST', '/api/auth/login', { username, password });
if (!result.authenticated) throw new ControlError('学习后台未确认维护者身份', 'LOGIN_FAILED');
this.#authenticated = true;
}
async execute(request) {
// Validate the operation and scoped IDs before authentication or side effects.
const [method, path, body] = routeFor(request);
if (request.operation !== 'health') await this.#login();
return this.#request(method, path, body);
}
}
export async function readControlInput(stream) {
const chunks = [];
let bytes = 0;
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buffer.length;
if (bytes > MAX_INPUT_BYTES) throw new ControlError('控制请求不得超过 64 KiB', 'INPUT_TOO_LARGE');
chunks.push(buffer);
}
let request;
try { request = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new ControlError('标准输入必须是 JSON 控制请求或请求数组', 'INVALID_REQUEST'); }
const requests = Array.isArray(request) ? request : [request];
if (!requests.length || requests.length > 30) throw new ControlError('每批须包含 1–30 个控制请求', 'INVALID_REQUEST');
for (const item of requests) routeFor(item);
return { requests, batch: Array.isArray(request) };
}
export async function main(input = process.stdin, output = process.stdout, environment = process.env) {
const { requests, batch } = await readControlInput(input);
const client = new LearningControlClient({
baseUrl: environment.ASSISTANT_CONTROL_URL || 'http://127.0.0.1:8810',
username: environment.ASSISTANT_MAINTAINER_USERNAME,
password: environment.ASSISTANT_MAINTAINER_PASSWORD,
});
const results = [];
for (const request of requests) {
try { results.push({ operation: request.operation, ok: true, data: await client.execute(request) }); }
catch (error) {
const apiCode = safeApiCode(error.apiCode);
results.push({ operation: request.operation, ok: false, error: { code: error.code || 'CONTROL_ERROR', message: error.message, ...(error.status ? { status: error.status } : {}), ...(apiCode ? { api_code: apiCode } : {}) } });
// A completed earlier action is not rolled back and must never be replayed.
output.write(JSON.stringify(batch ? results : results[0]) + '\n');
return 1;
}
}
output.write(JSON.stringify(batch ? results : results[0]) + '\n');
return 0;
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main().then(code => { process.exitCode = code; }).catch(error => {
process.stderr.write(JSON.stringify({ ok: false, error: { code: error.code || 'CONTROL_ERROR', message: error instanceof ControlError ? error.message : '控制入口失败,未自动重试' } }) + '\n');
process.exitCode = 1;
});
}