process 服务
1. 定位
把所有 node:child_process / node:os / node:fs 直读用法收口到一个能力插件后面,让业务插件无需直接 import 这些 Node 内置模块,即可执行子进程、创建临时目录、读取 OS 外部文件。
- 服务注册名:
'process'(ctx.getService<ProcessService>('process'))。 - 契约包:
@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 daemon 推来的附件路径——不传上限就意味着把对方指定的任意大小文件整份读进堆,事后再判超限已经晚了(峰值内存已经吃掉)。readExternalFile在给了maxBytes时会先stat再决定读不读。
2. 契约
接口与类型全部由 @aalis/api-process 导出。真实签名(贴 file:line):
2.1 ProcessService(api-process/src/index.ts)
interface ProcessService {
// 同 child_process.spawn,但只接 (cmd, args, opts),不接 shell 字符串
spawn(cmd: string, args: readonly string[], opts?: SpawnOptions): SpawnHandle; // :80
// 同 execFile,返回 ExecResult;非零退出会 reject(err.result 挂 ExecResult)
execFile(cmd: string, args: readonly string[], opts?: SpawnOptions): Promise<ExecResult>; // :85
// 在 storage 的 tmp:/ 根下创建本地临时目录,拿到本地绝对路径,用完调 cleanup()
makeTempDir(prefix: string): Promise<TempDirHandle>; // :90
// 读 OS 任意本地路径(绕过 storage root 沙箱)——仅限「外部推来的路径」场景
// maxBytes 可选:给了就读前先 stat,超限直接抛,不把整份文件读进堆
readExternalFile(path: string, maxBytes?: number): Promise<Uint8Array>;
}容器类型映射在同文件 declare module '@aalis/core' { interface ServiceTypeMap { process: ProcessService } }(:103-107),因此 ctx.getService('process') 已带类型推断。
2.2 SpawnOptions(:14-36)
interface SpawnOptions {
cwd?: string; // 工作目录(本地绝对路径)
env?: Record<string, string | undefined>; // 注入/覆盖环境变量
timeout?: number; // 毫秒;到时 SIGKILL(本地实现打整个进程组,第 7 节)(:17)
input?: string | Uint8Array; // 写入 stdin 的内容(:19)
detached?: boolean; // 与父进程解耦;须配 stdio:'ignore' 且手动 unref()(:25)
// 本地实现在 POSIX 下一律 detached(见「detached fire-and-forget」)
stdio?: 'pipe' | 'ignore' | 'inherit'; // 默认 'pipe'(:30)
maxBuffer?: number; // wait() 累计缓冲(stdout+stderr 合计)字节上限(:35)
}maxBuffer 缺省由实现给安全默认(本地实现 = 10MB,见第 7 节)。
2.3 ExecResult(:38-49)
interface ExecResult {
code: number | null; // 退出码;被信号杀死时为 null
signal: NodeJS.Signals | null; // 终止信号(如超时的 SIGKILL)
stdout: string;
stderr: string;
truncated?: boolean; // 输出超 maxBuffer 被截断(区别于 timeout 的 SIGKILL)(:48)
}2.4 SpawnHandle(:51-64)
interface SpawnHandle {
pid: number | undefined;
stdin: Writable | null;
stdout: Readable | null;
stderr: Readable | null;
wait(): Promise<ExecResult>; // 等子进程结束(:57)
kill(signal?: NodeJS.Signals): boolean; // 杀子进程;本地实现在 POSIX 下打整个进程组(:59)
unref(): void; // 仅 detached 模式有效,否则 no-op(:63)
}2.5 TempDirHandle(:67-74)
interface TempDirHandle {
path: string; // 本地绝对路径,可直接传给子进程
uri: string; // 对应 storage URI(tmp:/...),可被 storage 读写
cleanup(): Promise<void>; // 递归删除该目录
}2.6 导出的工具函数
createProcessGateway(ctx): ProcessService(:112-126)——消费方标准入口。返回一个网关:无实例时抛错、有实例时每次方法调用都重新ctx.getService('process')后转发(懒取,见第 5 节)。makeTempDirViaStorage(storage, prefix): Promise<TempDirHandle>(:129-148)——供 provider 使用的辅助。基于一个支持resolveLocalPath的StorageService实现makeTempDir的默认骨架;prefix 会被脱敏([^A-Za-z0-9_-]→_,截 32 字符),目录落在tmp:/<prefix>-<ts>-<rand>。storage 不支持resolveLocalPath时抛错。
3. 谁提供 / 谁消费
提供方(参考实现)
@aalis/plugin-process-local(packages/plugin-process-local/src/index.ts)——唯一的内置实现 LocalProcessService(:19-127),用 node:child_process / node:fs/promises 落地,apply() 里 ctx.provide('process', service)(:133)。
典型消费点(均经 createProcessGateway)
| 包 | 用途 | file:line |
|---|---|---|
plugin-tool-system | exec / exec_background shell 工具组 | src/tools/shell.ts(index.ts 取网关) |
plugin-tool-code-runner | 代码执行(fail-closed 到 code-sandbox) | src/index.ts |
plugin-code-sandbox-os | 在 process.spawn 外再裹 bwrap/seatbelt 沙箱 | src/index.ts |
plugin-media | ffmpeg 抽帧/转音轨 + makeTempDir | src/ffmpeg.ts(index.ts 注入 runtime) |
plugin-office | PDF 工具(可选依赖,见第 5 节) | src/index.ts |
plugin-adapter-onebot | readExternalFile 读 daemon 推来的附件路径 | src/attachment-cache.ts |
plugin-asr-whisper-cpp / plugin-asr-openai | 调本地 whisper / 转码 | src/index.ts / :96 |
plugin-llm-ollama / plugin-package-manager / plugin-tool-browser / plugin-webui-server | 拉起本地进程 / 装包 / 起浏览器 | 各 src/index.ts |
4. 写一个 provider
必须实现 vs 可选
接口四个方法都必须实现,没有可选方法。但常见做法是复用本地实现的骨架:
makeTempDir可直接转发makeTempDirViaStorage(storage, prefix)(process-local即如此,index.ts)——只需注入一个支持resolveLocalPath的 storage。execFile通常用spawn(...).wait()包一层(本地实现index.ts:非零退出 reject 并把ExecResult挂在err.result)。
替换默认实现(如远程执行 / 容器内执行)时用 priority 抬高;不替换、只想并存请用 entryId。
注册(priority / entryId / label)
ctx.provide(name, instance, options?),options 见 服务模型 §2.1:
import type { Context, PluginModule } from '@aalis/core';
import type { ProcessService, ExecResult, SpawnHandle, SpawnOptions, TempDirHandle } from '@aalis/api-process';
import { makeTempDirViaStorage } from '@aalis/api-process';
import { createStorageGateway, type StorageService } from '@aalis/api-storage';
export const name = '@aalis/plugin-process-remote';
export const provides = ['process']; // 双源之一:导出常量
export const inject = ['storage']; // makeTempDir 依赖 storage
class RemoteProcessService implements ProcessService {
constructor(private readonly storage: StorageService) {}
spawn(cmd: string, args: readonly string[], opts?: SpawnOptions): SpawnHandle { /* ... */ }
async execFile(cmd: string, args: readonly string[], opts?: SpawnOptions): Promise<ExecResult> { /* ... */ }
async makeTempDir(prefix: string): Promise<TempDirHandle> {
return makeTempDirViaStorage(this.storage, prefix);
}
async readExternalFile(path: string): Promise<Uint8Array> { /* ... */ }
}
export async function apply(ctx: Context): Promise<void> {
const storage = createStorageGateway(ctx);
ctx.provide('process', new RemoteProcessService(storage), {
priority: 50, // 想覆盖 process-local(默认 0)时抬高;并存则省略
label: 'Process / remote', // WebUI/CLI Services 视图展示
// entryId: `${ctx.id}/remote`, // 只在「一个插件拆多条 entry」时用,前缀必须是 ctx.id
});
}
const plugin: PluginModule = { name, apply };
export default plugin;provides / inject 双源必须与 package.json 同步
清单元数据是双源的(见 清单元数据)。除了源码里导出 provides / inject,package.json 的 aalis.service 也要写——参考 plugin-process-local/package.json:
{
"keywords": ["aalis", "aalis-plugin"],
"aalis": {
"service": {
"provides": ["process"]
// "inject": ["storage"] // 若 makeTempDir 走 storage,应一并声明
}
}
}契约包
api-process自身不提供任何运行时服务,它的package.json用的是"aalis": { "types": true }+keywords: ["aalis-api"],标记为「纯类型/契约包」。不要把aalis.service写到契约包上。
5. 标准消费方式
懒取(必须)
始终使用 createProcessGateway(ctx),不要缓存它转发到的实例——网关内部每次方法调用都重新 ctx.getService('process')(api-process/src/index.ts),这样 provider 被替换/下线(provider bounce)后下一次调用自动命中新的胜出实例。详见 懒服务访问。
import { createProcessGateway } from '@aalis/api-process';
export async function apply(ctx: Context): Promise<void> {
const proc = createProcessGateway(ctx); // 持有网关可以;不要将 proc.spawn 解构出来长期持有
const result = await proc.execFile('git', ['rev-parse', 'HEAD'], { timeout: 5000 });
ctx.logger.info(result.stdout.trim());
}临时目录:try/finally cleanup
makeTempDir 拿到的目录必须在 finally 里 cleanup,否则 tmp:/ 下泄漏。参考 plugin-media/src/ffmpeg.ts:
const tmp = await proc.makeTempDir('media-frames');
try {
await proc.execFile('ffmpeg', ['-i', filePath, /* ... */ `${tmp.path}/frame_%04d.png`], { timeout: 60000 });
const buf = await storage.readFile(`${tmp.uri}/frame_0001.png`); // 也可经 storage URI 读回
} finally {
await tmp.cleanup();
}服务缺失 / 可选依赖
- 硬依赖:声明
inject = ['process'],框架在 process 就绪前不会apply你的插件;网关在缺失时也会抛未找到 process 服务...(api-process/src/index.ts)。 - 可选依赖:先
ctx.getService('process') !== undefined探测再决定。参考plugin-office/src/index.ts:const proc = ctx.getService('process') !== undefined ? createProcessGateway(ctx) : undefined;——没有 process 时 PDF 工具优雅降级。
错误边界
execFile非零退出会 reject,错误对象上挂result: ExecResult(plugin-process-local/src/index.ts)——需要部分输出时从err.result.stderr取。spawn().wait()不会因非零退出 reject,只在子进程'error'(如可执行文件不存在)时 reject;超时是正常 resolve(signal为SIGKILL/SIGTERM),不抛错。plugin-tool-system的exec据此判断timedOut(shell.ts)。
6. 能力 / 风险 → 影响
process 是框架里权限最高的能力之一(任意子进程 = 完整宿主权限)。约束分两层:
Provider 侧
- maxBuffer 边读边计数:本地实现在
wait()里边读边累计、超限即停止累积并标truncated,不杀进程(后台 dev server/--watch本应长跑,强行终止会误终止这类进程)——见plugin-process-local/src/index.ts。自写 provider 也应有上限,禁止无限stdout += chunk再在Buffer.concat前累积(OOM 向量,见第 7 节)。 - stdin error 必须挂监听:
child.stdin.on('error', () => {})——否则 EPIPE 这类异步错误无监听器会uncaughtException崩整个宿主进程(index.ts)。
Consumer 侧(鉴权 / 确认)
process 本身没有内核级鉴权门——风险控制落在调用它的工具上。把 process 暴露给 LLM 的工具,要在工具层按 鉴权模型 / authority 设门:
- 任意 shell 命令是最强的 confused-deputy 向量。
plugin-tool-system的exec/exec_background/process_kill都设visibility: 'restricted'+confirm: 'session'——连 owner 也要本会话确认一次(shell.ts、:376-377)。你的工具若直通spawn,应比照此设级别 + 确认。 - shell 工具(exec/exec_background)继承宿主完整环境(含代理与密钥类变量)——owner 工具的既定取舍(2026-08 拍板;曾有的 env 白名单因本地实现无条件合并从未生效,已删除)。本地实现对
SpawnOptions.env的语义是叠加({ ...process.env, ...opts.env })而非替换——传白名单不构成隔离。需要环境隔离的执行走 code-sandbox-os(env -i真清)。
不是沙箱
readExternalFile 显式绕过 storage root 沙箱读任意 OS 路径——契约注释(api-process/src/index.ts)限定它只用于「外部推来的本地路径」场景(如 OneBot daemon/NapCat 容器挂载的 /tmp 附件,adapter-onebot/src/attachment-cache.ts、:116-118)。它不是 storage 的替代品:受沙箱约束的「在声明 root 内读写」请走 storage 服务。
需要在隔离环境跑不可信代码:用 code-sandbox 服务。plugin-code-sandbox-os 正是把 process.spawn 再裹一层 bwrap/seatbelt 启动器(src/index.ts),且后端不可用时 fail-closed。
detached fire-and-forget
启动「打开浏览器」这类不需要等待的进程:detached: true + stdio: 'ignore' + .unref() 三者缺一不可(webui-server/src/auth.ts),否则父进程会被阻塞或无法独立退出。
本地实现在 POSIX 下对所有子进程都设 detached(plugin-process-local/src/index.ts),目的是让子进程自成进程组组长,超时与 kill() 才能打到 sh -c fork 出的孙进程。代价是子进程脱离宿主的进程组,终端 Ctrl+C 不再直达它们——插件因此按进程组登记(cmd & 留下的孙进程在直接子进程退出后仍占着该组),并在 app:stopping 时对仍有成员的组补 SIGKILL,维持「Aalis 退出,工具子进程一起退出」;调用方显式传 detached: true 的进程不登记,它本就该独立于宿主生存。detached 选项本身仍只表示调用方要 fire-and-forget(配 stdio: 'ignore' + unref())。
7. 边界与注意事项(审计标注)
maxOutputSize是事后截断,不是流式限额(消费侧注意事项)。 工具层常见的maxOutputSize只在拿到完整字符串后truncateOutput(plugin-tool-system/src/tools/shell.ts),真正的内存上限是 provider 的maxBuffer(本地默认 10MB,plugin-process-local/src/index.ts)。历史上exec自起无上限stdout += chunk累加器,在「超限只停累积不杀进程」改动之后会无界增长 → OOM;现已改为直接用wait()内部带maxBuffer上限的result.stdout/stderr(shell.ts)。自写工具切勿在 process 之上再叠一个无上限累加器;exec_background这类需要持续读流的,要像shell.ts那样自己滚动裁剪缓冲区。readExternalFile= confused-deputy + 无大小上限。 它fs.readFile任意路径(plugin-process-local/src/index.ts),daemon 给什么路径就读什么、一次性全量进内存、且不校验路径来源。这是「daemon-trusted」的信任面——只在「确实是外部可信组件推来的路径」时用,不要把用户/LLM 可控字符串直接喂进去(路径遍历读取宿主任意文件)。下载的体积上限要由调用方自己加(参考 onebot 的readBodyCapped,但那只覆盖 http,readExternalFile路径无此保护)。timeout走 SIGKILL,无优雅期,打的是整个进程组。 超时时本地实现在 POSIX 下process.kill(-pid, 'SIGKILL')、Windows 下taskkill /PID <pid> /T /F(index.ts),连sh -c 'cmd &'fork 出的孙进程一并杀掉;子进程没有清理机会。handle.kill(signal)同样按进程组发信号。需要 graceful 关停的长进程,自己拿handle.kill('SIGTERM')管理(shell.ts的process_kill即如此)。wait()在'exit'后只等一个短宽限(200ms)就返回。 孙进程继承同一对 pipe,只要它还活着'close'就不会来——sh -c 'sleep 20 & echo hi'曾要等满 20 秒,npm run dev &则永不返回。本地实现改为'exit'后给'close'200ms 宽限,到点即用已收集的输出 resolve(index.ts);管道保持打开、之后的输出丢弃(不销毁读端,后台进程不会因 EPIPE 被误杀),该进程组留待停机收尸。因此子进程退出后孙进程的输出会被丢弃;需要持续读后台进程输出的,应让直接子进程自己长跑(exec_background即如此),不要靠孙进程。spawn不接 shell 字符串。 只接(cmd, args[]),要 shell 特性须显式spawn('/bin/sh', ['-c', cmd])(shell.ts、:149)——这是有意为之,避免隐式 shell 注入面,但也意味着 provider/consumer 都要自己负责 shell 语义。
8. 交叉链接
- 服务模型 —— DI 按名寻址、priority/preference/注册顺序、entryId 约定。
- 懒服务访问 —— 为什么
createProcessGateway每次重取、provider bounce。 - 清单元数据 ——
provides/inject与package.json aalis.service双源。 - 存储 URI 文法 ——
tmp:/根、resolveLocalPath(makeTempDir 的底座)。 - 安全模型 / authority —— 给暴露 process 的工具设级别 + 确认。
- storage 服务 —— 受沙箱约束的「在 root 内读写」(对照
readExternalFile的直通)。 - code-sandbox 服务 —— 隔离执行不可信代码(process 不是沙箱)。
- tools / context —— 工具注册的 visibility/confirm、
ctx.provide/getService。