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; }); }