Skip to content

process 服务

1. 定位

把所有 node:child_process / node:os / node:fs 直读用法收口到一个能力插件后面,让业务插件无需直接 import 这些 Node 内置模块,即可执行子进程、创建临时目录、读取 OS 外部文件

  • 服务注册名:'process'ctx.getService<ProcessService>('process'))。
  • 契约包:@aalis/api-processpackages/api-process/src/index.ts)。
  • 默认实现:@aalis/plugin-process-localpackages/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 ProcessServiceapi-process/src/index.ts

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

ts
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

ts
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

ts
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

ts
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 使用的辅助。基于一个支持 resolveLocalPathStorageService 实现 makeTempDir 的默认骨架;prefix 会被脱敏([^A-Za-z0-9_-]_,截 32 字符),目录落在 tmp:/<prefix>-<ts>-<rand>。storage 不支持 resolveLocalPath 时抛错。

3. 谁提供 / 谁消费

提供方(参考实现)

@aalis/plugin-process-localpackages/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-systemexec / exec_background shell 工具组src/tools/shell.tsindex.ts 取网关)
plugin-tool-code-runner代码执行(fail-closed 到 code-sandbox)src/index.ts
plugin-code-sandbox-os在 process.spawn 外再裹 bwrap/seatbelt 沙箱src/index.ts
plugin-mediaffmpeg 抽帧/转音轨 + makeTempDirsrc/ffmpeg.tsindex.ts 注入 runtime)
plugin-officePDF 工具(可选依赖,见第 5 节)src/index.ts
plugin-adapter-onebotreadExternalFile 读 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

ts
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 / injectpackage.jsonaalis.service 也要写——参考 plugin-process-local/package.json

jsonc
{
  "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)后下一次调用自动命中新的胜出实例。详见 懒服务访问

ts
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

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.tsconst proc = ctx.getService('process') !== undefined ? createProcessGateway(ctx) : undefined;——没有 process 时 PDF 工具优雅降级。

错误边界

  • execFile 非零退出会 reject,错误对象上挂 result: ExecResultplugin-process-local/src/index.ts)——需要部分输出时从 err.result.stderr 取。
  • spawn().wait() 不会因非零退出 reject,只在子进程 'error'(如可执行文件不存在)时 reject;超时是正常 resolve(signalSIGKILL/SIGTERM),不抛错。plugin-tool-systemexec 据此判断 timedOutshell.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-systemexec / 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 下对所有子进程都设 detachedplugin-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. 边界与注意事项(审计标注)

  1. maxOutputSize 是事后截断,不是流式限额(消费侧注意事项)。 工具层常见的 maxOutputSize 只在拿到完整字符串后 truncateOutputplugin-tool-system/src/tools/shell.ts),真正的内存上限是 provider 的 maxBuffer(本地默认 10MB,plugin-process-local/src/index.ts)。历史上 exec 自起无上限 stdout += chunk 累加器,在「超限只停累积不杀进程」改动之后会无界增长 → OOM;现已改为直接用 wait() 内部带 maxBuffer 上限的 result.stdout/stderrshell.ts)。自写工具切勿在 process 之上再叠一个无上限累加器exec_background 这类需要持续读流的,要像 shell.ts 那样自己滚动裁剪缓冲区。

  2. readExternalFile = confused-deputy + 无大小上限。fs.readFile 任意路径(plugin-process-local/src/index.ts),daemon 给什么路径就读什么、一次性全量进内存、且不校验路径来源。这是「daemon-trusted」的信任面——只在「确实是外部可信组件推来的路径」时用,不要把用户/LLM 可控字符串直接喂进去(路径遍历读取宿主任意文件)。下载的体积上限要由调用方自己加(参考 onebot 的 readBodyCapped,但那只覆盖 http,readExternalFile 路径无此保护)。

  3. timeout 走 SIGKILL,无优雅期,打的是整个进程组。 超时时本地实现在 POSIX 下 process.kill(-pid, 'SIGKILL')、Windows 下 taskkill /PID <pid> /T /Findex.ts),连 sh -c 'cmd &' fork 出的孙进程一并杀掉;子进程没有清理机会。handle.kill(signal) 同样按进程组发信号。需要 graceful 关停的长进程,自己拿 handle.kill('SIGTERM') 管理(shell.tsprocess_kill 即如此)。

  4. wait()'exit' 后只等一个短宽限(200ms)就返回。 孙进程继承同一对 pipe,只要它还活着 'close' 就不会来——sh -c 'sleep 20 & echo hi' 曾要等满 20 秒,npm run dev & 则永不返回。本地实现改为 'exit' 后给 'close' 200ms 宽限,到点即用已收集的输出 resolve(index.ts);管道保持打开、之后的输出丢弃(不销毁读端,后台进程不会因 EPIPE 被误杀),该进程组留待停机收尸。因此子进程退出后孙进程的输出会被丢弃;需要持续读后台进程输出的,应让直接子进程自己长跑(exec_background 即如此),不要靠孙进程。

  5. 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/injectpackage.json aalis.service 双源。
  • 存储 URI 文法 —— tmp:/ 根、resolveLocalPath(makeTempDir 的底座)。
  • 安全模型 / authority —— 给暴露 process 的工具设级别 + 确认。
  • storage 服务 —— 受沙箱约束的「在 root 内读写」(对照 readExternalFile 的直通)。
  • code-sandbox 服务 —— 隔离执行不可信代码(process 不是沙箱)。
  • tools / context —— 工具注册的 visibility/confirm、ctx.provide/getService