207 lines
11 KiB
JavaScript
207 lines
11 KiB
JavaScript
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;
|
||
});
|
||
}
|