process 服务
1. 定位
把所有 node:child_process / node:os / node:fs 直读用法收口到一个能力插件后面,让业务插件无需直接 import 这些 Node 内置模块,即可执行子进程、创建临时目录、读取 OS 外部文件。
- 服务注册名:描述符
processService(name: 'process')。契约不给 bind,业务层就是ProcessService形状的活句柄(caps.process.use)。 - 契约包:
@aalis/api-process(packages/api-process/src/index.ts)。 - 默认实现:
@aalis/plugin-process-local(packages/plugin-process-local/src/index.ts)。
注意:process 不是沙箱。
spawn产生的子进程拥有宿主进程的完整 OS 权限(默认继承宿主全量process.env),readExternalFile可读任意 OS 路径。需要隔离的不可信代码执行请看 code-sandbox 服务 与第 6 节。
路径来自外部时请传
maxBytes。典型场景是 OneBot 实现端动作应答里交回的附件路径——不传上限就意味着把对方指定的任意大小文件整份读进堆。readExternalFile在给了maxBytes时会先stat再决定读不读。
2. 契约
2.1 ProcessService
interface ProcessService {
spawn(cmd: string, args: readonly string[], opts?: SpawnOptions): SpawnHandle;
execFile(cmd: string, args: readonly string[], opts?: SpawnOptions): Promise<ExecResult>;
makeTempDir(prefix: string): Promise<TempDirHandle>;
readExternalFile(path: string, maxBytes?: number): Promise<Uint8Array>;
}2.2 SpawnOptions
interface SpawnOptions {
cwd?: string;
env?: Record<string, string | undefined>;
timeout?: number;
input?: string | Uint8Array;
detached?: boolean;
stdio?: 'pipe' | 'ignore' | 'inherit';
maxBuffer?: number;
signal?: AbortSignal;
}env 的语义是替换:给了就是子进程的完整环境(传白名单即只给白名单),不给时继承宿主环境;要在宿主环境上加几项,调用方自己展开 { ...process.env, ...extra }。第三方实现须遵守替换语义。maxBuffer 缺省由实现给安全默认(本地实现 = 10MB)。
signal 是中止信号,语义由契约规定,提供方必须遵守(忽略它的实现不合约,调用方的中止会静默失效):
- 调用
spawn时已中止:不创建子进程,同步抛出signal.reason;execFile随之 reject。 - POSIX:中止时对整个进程组先发
SIGTERM,宽限后组里还有成员就整组SIGKILL。宽限由实现定,须明显短于 core 的停机宽限(disposeTimeoutMs,默认 5000ms),本地实现为 2000ms(导出常量ABORT_KILL_GRACE_MS)。升级定时器不因直接子进程退出而取消,孙进程可能还在。wait()照常以ExecResult落定,signal为终止信号;execFile按非零退出 reject,错误对象上挂result。 - Windows:没有进程组,也没有优雅终止。中止时趁根进程还活着立即结束整棵进程树(本地实现为
taskkill /PID <pid> /T /F),不起升级定时器:根进程一死,孙进程就不在它的树上,之后再按树结束找不到它们,pid 被复用时还会误杀无关的进程树。wait()照常落定,但signal不保证有值(强制结束通常是退出码 1、signal为null)。 - 子进程创建失败(没有 pid)时不登记监听;子进程退出或出错后必须摘掉监听,之后再中止不对任何进程发信号,以免打到被复用的进程组。
- POSIX 下中止与超时一样只打原进程组:主动脱离进程组的后代(
setsid、以detached: true另起的进程等守护化写法)不受影响。
2.3 句柄
interface ExecResult {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
truncated?: boolean;
}
interface SpawnHandle {
pid: number | undefined;
stdin: Writable | null;
stdout: Readable | null;
stderr: Readable | null;
wait(): Promise<ExecResult>;
kill(signal?: NodeJS.Signals): boolean;
unref(): void;
}
interface TempDirHandle {
path: string;
uri: string;
cleanup(): Promise<void>;
}2.4 导出的工具函数
makeTempDirViaStorage(storage, prefix): Promise<TempDirHandle>——供 provider 使用的辅助。基于一个支持resolveLocalPath的StorageService(传caps.storage.use即可)。
消费方没有单独的入口函数,直接经业务句柄 caps.process.use 调用(见 §5)。
3. 谁提供 / 谁消费
提供方
@aalis/plugin-process-local——provide.use(processService, service)。
典型消费点(均经业务句柄 use 调用)
plugin-tool-system、plugin-tool-code-runner、plugin-code-sandbox-os、plugin-media、plugin-office、plugin-adapter-onebot、plugin-asr-*、plugin-llm-ollama、plugin-package-manager、plugin-tool-browser、plugin-webui-server。
传 signal 的消费点:plugin-tool-system 的 exec、plugin-tool-code-runner 的无沙箱路径、plugin-office 的 pdf_convert 与 plugin-draw 的动图编码传工具执行器给这次调用的中止信号(这些工具都声明可转后台:在前台时随回合中止,停止键即停掉命令;转到后台之后随停止、工具撤回登记与执行器停用中止,见 plugin-tools),不另设超时;plugin-code-sandbox-os 的沙箱内运行转交调用方的信号、启动探测传本次激活的 lifecycle.use.signal。
4. 写一个 provider
接口四个方法都必须实现,SpawnOptions.signal 的中止语义(§2.2)同样要实现,否则停止键停不掉 exec 与代码执行。makeTempDir 可转发 makeTempDirViaStorage。替换默认实现时用 priority 抬高。
import { makeTempDirViaStorage, processService, type ExecResult, type ProcessService, type SpawnHandle, type SpawnOptions, type TempDirHandle } from '@aalis/api-process';
import { type StorageService, storage } from '@aalis/api-storage';
import { definePlugin, provide } from '@aalis/core';
class RemoteProcessService implements ProcessService {
files!: StorageService;
spawn(_cmd: string, _args: readonly string[], _opts?: SpawnOptions): SpawnHandle {
throw new Error('未实现');
}
execFile(_cmd: string, _args: readonly string[], _opts?: SpawnOptions): Promise<ExecResult> {
throw new Error('未实现');
}
makeTempDir(prefix: string): Promise<TempDirHandle> {
return makeTempDirViaStorage(this.files, prefix);
}
readExternalFile(_path: string, _maxBytes?: number): Promise<Uint8Array> {
throw new Error('未实现');
}
}
export default definePlugin({
name: '@aalis/plugin-process-remote',
provides: [processService],
uses: { provide, storage },
apply({ provide, storage }) {
const svc = new RemoteProcessService();
svc.files = storage.use;
provide.use(processService, svc, {
priority: 50,
label: 'Process / remote',
});
},
});5. 标准消费方式
直接经业务句柄 process.use 调用。它是活的,选择、缺席与固定同一家的规则见 选择层与业务层。
import { processService } from '@aalis/api-process';
import { definePlugin, logger } from '@aalis/core';
export default definePlugin({
name: '@acme/plugin-example-process',
uses: { process: processService, logger },
apply({ process, logger }) {
const proc = process.use;
void proc.execFile('git', ['rev-parse', 'HEAD'], { timeout: 5000 }).then(result => {
logger.use.info(result.stdout.trim());
});
},
});临时目录:try/finally cleanup
makeTempDir 拿到的目录必须在 finally 里 cleanup。
服务缺失 / 可选依赖
- 硬依赖:
uses: { process: processService }。 - 可选依赖:
optional(processService),先看process.present再调用。
错误边界
execFile非零退出会 reject,错误对象上挂result: ExecResult。spawn().wait()不会因非零退出 reject,只在子进程'error'时 reject;超时与中止都是正常 resolve。signal已中止时spawn同步抛出signal.reason,调用方要把spawn放进 try。
6. 能力 / 风险 → 影响
process 是框架里权限最高的能力之一。约束分两层:
Provider 侧
- maxBuffer 边读边计数:超限即停止累积并标
truncated,不杀进程。 - stdin error 必须挂监听:否则 EPIPE 会
uncaughtException崩整个宿主。
Consumer 侧
process 本身没有内核级鉴权门——风险控制落在调用它的工具上。plugin-tool-system 的 exec 设 visibility: 'restricted' + confirm: 'session';转到后台的 exec 由执行器的 background_stop 按中止契约停掉,只停得了同一会话里的任务,background_stop 本身 2 级加确认。shell 工具继承宿主完整环境。需要环境隔离的执行走 code-sandbox-os。
不是沙箱
readExternalFile 显式绕过 storage root 读任意 OS 路径——只用于「外部推来的本地路径」。路径必须是绝对路径或 file:// 加绝对路径,相对路径直接拒绝:宿主进程的 cwd 不带语义,按它解析会因启动目录不同读到不同的文件。受沙箱约束的读写请走 storage 服务。
detached fire-and-forget
启动不需要等待的进程:detached: true + stdio: 'ignore' + .unref() 三者缺一不可。
本地实现在 POSIX 下对所有子进程都设 detached,目的是让子进程自成进程组组长,超时、中止与 kill() 才能打到孙进程。
App.stop() 先冻结激活,再发 app:stopping(知会,不驱动 drain/close),再执行关停计划。本地实现把补杀挂在 events.use.on('app:stopping', () => service.killAll()) 上——不是 lifecycle.use.onDispose:bounce 本插件时,别的插件正在跑的子进程(ffmpeg 等)不该陪葬。未显式 detached: true 的进程组在登记表里,停机知会时对仍有成员的组补 SIGKILL,维持「Aalis 退出,工具子进程一起退出」;显式 detached: true 的 fire-and-forget 不登记。停机开始后新发起的管理动作一律返回 false。单独卸载本提供者时,正在用它的 required 下游先收尾再关;子进程仍只挂在被拆掉的那份实例上。
7. 边界与注意事项(审计标注)
- 工具层
maxOutputSize是事后截断。真正的内存上限是 provider 的maxBuffer。自写工具切勿在 process 之上再叠一个无上限累加器。 readExternalFile= confused-deputy。不要把用户/LLM 可控字符串直接喂进去。给了maxBytes才先 stat。timeout走 SIGKILL,无优雅期,打的是整个进程组。 中止(signal)在 POSIX 下先 SIGTERM、宽限后才 SIGKILL(§2.2)。wait()在'exit'后只等一个短宽限(200ms)就返回。 子进程退出后孙进程的输出会被丢弃。spawn不接 shell 字符串。 要 shell 特性须显式spawn('/bin/sh', ['-c', cmd])。