@lqc123qwe/car-runtime 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +12 -0
- package/.github/workflows/release.yml +21 -0
- package/README.md +122 -0
- package/adr/ADR-001.md +4 -0
- package/adr/ADR-002.md +5 -0
- package/adr/ADR-003.md +35 -0
- package/adr/host-onboarding.md +68 -0
- package/adr/mlps-audit-checklist.md +33 -0
- package/adr/win32-spike.md +45 -0
- package/package.json +25 -0
- package/scripts/ptc-baseline/run-baseline.ts +118 -0
- package/scripts/release-pipeline.ts +189 -0
- package/scripts/win32-koffi-spike.ts +34 -0
- package/src/authz/authz.ts +96 -0
- package/src/cli.ts +122 -0
- package/src/governance/dump.ts +41 -0
- package/src/host/facade.ts +99 -0
- package/src/host/hostGateway.ts +98 -0
- package/src/host/mappings.ts +74 -0
- package/src/host/stdio.ts +74 -0
- package/src/kernel/context.ts +208 -0
- package/src/kernel/events.ts +99 -0
- package/src/load/loader.ts +165 -0
- package/src/load/registry.ts +88 -0
- package/src/load/verifier.ts +67 -0
- package/src/loop/goal.ts +53 -0
- package/src/loop/recover.ts +47 -0
- package/src/loop/stop.ts +200 -0
- package/src/mcp/gateway.ts +118 -0
- package/src/ptc/budget.ts +37 -0
- package/src/ptc/erasable.ts +28 -0
- package/src/ptc/runCode.ts +127 -0
- package/src/ptc/sdk.ts +28 -0
- package/src/ptc/worker-entry.ts +67 -0
- package/src/sandbox/sandbox.ts +199 -0
- package/src/security/secrets.ts +111 -0
- package/src/session/export.ts +110 -0
- package/src/session/log.ts +120 -0
- package/src/telemetry/metrics.ts +38 -0
- package/test/kernel.spec.ts +136 -0
- package/test/s11.spec.ts +129 -0
- package/test/s12.spec.ts +135 -0
- package/test/s13.spec.ts +117 -0
- package/test/s14.spec.ts +117 -0
- package/test/s15.spec.ts +80 -0
- package/test/s17.spec.ts +102 -0
- package/test/s18.spec.ts +72 -0
- package/test/s2.spec.ts +245 -0
- package/test/s3.spec.ts +179 -0
- package/test/s4.spec.ts +169 -0
- package/test/s5.spec.ts +95 -0
- package/test/s6.spec.ts +169 -0
- package/test/s7.spec.ts +135 -0
- package/test/s9.spec.ts +89 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F6 · 插件装配层:manifest 校验 + 免编译加载(缓存击穿热重载)+ Stub 占位 + invalidate
|
|
3
|
+
*
|
|
4
|
+
* 口径:
|
|
5
|
+
* - 载体:jiti 实装受阻(沙箱 npm),以 Node --experimental-transform-types + 动态 import
|
|
6
|
+
* 查询串缓存击空承载(POC-2 同口径);被测不变量与载体无关
|
|
7
|
+
* - Stub 占位(Pi 范式,CAR 自研层):**注册类动作始终可用(收集待注册项)**,
|
|
8
|
+
* 「依赖宿主绑定的能力查询」在 bindCore 前一调用即抛错——用启动期显式失败换运行期时序正确
|
|
9
|
+
* - invalidate:unload/reload 后旧句柄访问显式报错(CAR-INVALIDATED)
|
|
10
|
+
* - manifest:name 必填、version 精确 semver(禁区间);peer 校验挂点预留(O7,M2 启用)
|
|
11
|
+
*/
|
|
12
|
+
import { pathToFileURL } from 'node:url'
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { checkErasableOnly } from '../ptc/erasable.ts'
|
|
15
|
+
|
|
16
|
+
/** 简易 semver 区间求解(M2 S5:仅支持 ^x.y.z / x.y.z 两种形态;完整区间求解 M3 扩展) */
|
|
17
|
+
function satisfiesRange(version: string, range: string): boolean {
|
|
18
|
+
if (range === version) return true
|
|
19
|
+
const m = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range)
|
|
20
|
+
if (!m) return false
|
|
21
|
+
const [M, mnr, P] = version.split('.').map(Number)
|
|
22
|
+
// npm caret 语义:左起第一个非零位锁定(^1.2.3→>=1.2.3 <2;^0.2.3→>=0.2.3 <0.3;^0.0.3→==0.0.3)
|
|
23
|
+
if (M !== Number(m[1])) return false
|
|
24
|
+
if (M > 0) {
|
|
25
|
+
if (mnr !== Number(m[2])) return mnr > Number(m[2])
|
|
26
|
+
return P >= Number(m[3])
|
|
27
|
+
}
|
|
28
|
+
if (mnr !== Number(m[2])) return false // ^0.x:minor 锁定
|
|
29
|
+
return P >= Number(m[3])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* F10 · peer 版本约束校验(严格模式默认 + 显式豁免,经中间确认⑥)
|
|
34
|
+
* 冲突 = 加载期硬报错并输出冲突链;豁免需 manifest.peerPolicyOverride(reason 必填→审计留痕)
|
|
35
|
+
*/
|
|
36
|
+
export interface PeerViolation { peer: string; range: string; installed: string | null; optional: boolean }
|
|
37
|
+
|
|
38
|
+
export function validatePeers(manifest: Manifest, installed: Map<string, string>): { exempted: boolean; violations: PeerViolation[] } {
|
|
39
|
+
const violations: PeerViolation[] = []
|
|
40
|
+
for (const p of manifest.peers ?? []) {
|
|
41
|
+
const v = installed.get(p.peer)
|
|
42
|
+
if (v === undefined) {
|
|
43
|
+
if (!p.optional) violations.push({ peer: p.peer, range: p.range, installed: null, optional: false })
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (!satisfiesRange(v, p.range)) violations.push({ peer: p.peer, range: p.range, installed: v, optional: !!p.optional })
|
|
47
|
+
}
|
|
48
|
+
const hard = violations.filter(v => !v.optional)
|
|
49
|
+
if (!hard.length) return { exempted: false, violations }
|
|
50
|
+
if (manifest.peerPolicyOverride?.relaxed) {
|
|
51
|
+
// 显式豁免:放宽生效,调用方负责将 manifest.peerPolicy='exempt' 写入审计(留痕)
|
|
52
|
+
return { exempted: true, violations }
|
|
53
|
+
}
|
|
54
|
+
// 严格模式默认:冲突链硬报错(文件/依赖路径/区间/实际版本)
|
|
55
|
+
const chainStr = hard.map(v => `${v.peer}@${v.range} required but ${v.installed ?? '<missing>'} installed`).join('; ')
|
|
56
|
+
throw new Error(`CAR-E-PEER: peer dependency conflict for "${manifest.name}" — ${chainStr}(放宽需 manifest.peerPolicyOverride 显式声明 + reason,审计留痕)`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Manifest {
|
|
60
|
+
name: string
|
|
61
|
+
version: string
|
|
62
|
+
peers?: Array<{ peer: string; range: string; optional?: boolean }>
|
|
63
|
+
peerPolicyOverride?: { relaxed: true; reason: string }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SEMVER_EXACT = /^\d+\.\d+\.\d+$/
|
|
67
|
+
const chain = (file: string, detail: string) => `CAR-E-MANIFEST: ${detail} (conflict chain: ${file})`
|
|
68
|
+
|
|
69
|
+
export function parseManifest(raw: unknown, file = '<manifest>'): Manifest {
|
|
70
|
+
const m = raw as Manifest
|
|
71
|
+
if (!m || typeof m !== 'object') throw new Error(chain(file, 'manifest must be an object'))
|
|
72
|
+
if (!m.name || typeof m.name !== 'string') throw new Error(chain(file, 'field "name" is required and must be a string'))
|
|
73
|
+
if (!m.version || !SEMVER_EXACT.test(m.version)) {
|
|
74
|
+
throw new Error(chain(file, `field "version" must be exact semver (x.y.z), got "${m.version}"(版本区间仅用于 peer 校验)`))
|
|
75
|
+
}
|
|
76
|
+
if (m.peerPolicyOverride && !m.peerPolicyOverride.reason) {
|
|
77
|
+
throw new Error(chain(file, 'field "peerPolicyOverride.reason" is required when豁免声明(审计留痕)'))
|
|
78
|
+
}
|
|
79
|
+
return m
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ToolReg { name: string; run: (args: any) => Promise<unknown> }
|
|
83
|
+
export interface CommandReg { name: string; run: (args: string[]) => Promise<void> }
|
|
84
|
+
export interface PluginApi {
|
|
85
|
+
registerTool(tool: ToolReg): void
|
|
86
|
+
registerCommand(cmd: CommandReg): void
|
|
87
|
+
getRegisteredTools(): ToolReg[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface HostBinding {
|
|
91
|
+
registerTool?(tool: ToolReg): void
|
|
92
|
+
registerCommand?(cmd: CommandReg): void
|
|
93
|
+
getRegisteredTools(): ToolReg[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class ApiImpl {
|
|
97
|
+
#host: HostBinding | undefined
|
|
98
|
+
#pendingTools: ToolReg[] = []
|
|
99
|
+
#pendingCommands: CommandReg[] = []
|
|
100
|
+
registerTool(tool: ToolReg): void {
|
|
101
|
+
if (this.#host?.registerTool) { this.#host.registerTool(tool); return }
|
|
102
|
+
this.#pendingTools.push(tool) // 注册类动作始终可用:Stub 期收集,bind 后冲刷
|
|
103
|
+
}
|
|
104
|
+
registerCommand(cmd: CommandReg): void {
|
|
105
|
+
if (this.#host?.registerCommand) { this.#host.registerCommand(cmd); return }
|
|
106
|
+
this.#pendingCommands.push(cmd)
|
|
107
|
+
}
|
|
108
|
+
getRegisteredTools(): ToolReg[] {
|
|
109
|
+
// 依赖宿主绑定的能力查询:bindCore 前一调用即抛错(Stub 显式失败)
|
|
110
|
+
if (!this.#host) throw new Error('CAR-STUB: "getRegisteredTools" called before bindCore(启动期显式失败换运行期时序正确)')
|
|
111
|
+
return this.#host.getRegisteredTools()
|
|
112
|
+
}
|
|
113
|
+
bindHost(host: HostBinding): void {
|
|
114
|
+
this.#host = host
|
|
115
|
+
for (const t of this.#pendingTools) host.registerTool?.(t)
|
|
116
|
+
for (const c of this.#pendingCommands) host.registerCommand?.(c)
|
|
117
|
+
this.#pendingTools = []
|
|
118
|
+
this.#pendingCommands = []
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface LoadedPlugin {
|
|
123
|
+
manifest: Manifest
|
|
124
|
+
api: PluginApi
|
|
125
|
+
bindCore(host: HostBinding): void
|
|
126
|
+
invalidate(): void
|
|
127
|
+
isInvalidated(): boolean
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 装配插件模块(免编译加载 + Stub 占位 + invalidate 语义) */
|
|
131
|
+
export async function mountPlugin(spec: {
|
|
132
|
+
file: string
|
|
133
|
+
manifest: unknown
|
|
134
|
+
/** reload 版本号:每次 +1 变更 import 查询串击穿 ESM 缓存 */
|
|
135
|
+
reloadEpoch?: number
|
|
136
|
+
}): Promise<LoadedPlugin> {
|
|
137
|
+
const manifest = parseManifest(spec.manifest, spec.file)
|
|
138
|
+
const epoch = spec.reloadEpoch ?? 0
|
|
139
|
+
// erasable-only 双挂点之二:loader 提交链(挂点一在 run_code 入口——单一事实源 checkErasableOnly)
|
|
140
|
+
const era = checkErasableOnly(readFileSync(spec.file, 'utf-8'))
|
|
141
|
+
if (!era.ok) throw new Error(`CAR-E-PTC: ${era.violation} (file: ${spec.file})`)
|
|
142
|
+
const mod = (await import(pathToFileURL(spec.file).href + (epoch ? `?epoch=${epoch}` : ''))) as {
|
|
143
|
+
default: (api: PluginApi) => void
|
|
144
|
+
}
|
|
145
|
+
if (typeof mod.default !== 'function') {
|
|
146
|
+
throw new Error(chain(spec.file, 'plugin module must default-export a factory (api) => void'))
|
|
147
|
+
}
|
|
148
|
+
let alive = true
|
|
149
|
+
const apiImpl = new ApiImpl()
|
|
150
|
+
const api: PluginApi = new Proxy(apiImpl as unknown as PluginApi, {
|
|
151
|
+
get(target, prop: string) {
|
|
152
|
+
if (!alive) throw new Error(`CAR-INVALIDATED: "${prop}" on stale plugin context(旧上下文已失效,请重新加载)`)
|
|
153
|
+
const v = (target as any)[prop]
|
|
154
|
+
return typeof v === 'function' ? v.bind(target) : v
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
mod.default(api)
|
|
158
|
+
return {
|
|
159
|
+
manifest,
|
|
160
|
+
api,
|
|
161
|
+
bindCore(host: HostBinding) { apiImpl.bindHost(host) },
|
|
162
|
+
invalidate() { alive = false },
|
|
163
|
+
isInvalidated() { return !alive },
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T-3/T-4 · registry 对接(M3-S13 落位 + M4-S17 T-5 终局对齐增补)
|
|
3
|
+
*
|
|
4
|
+
* 口径(M3部署设计增补 §3 + M3安全设计增补 T-4 + 决议⑦ + M4安全增补registry对齐 定稿):
|
|
5
|
+
* - **registries 清单即白名单本身**:白名单外默认拒绝 + 审计落盘(不做静默 fallback);
|
|
6
|
+
* - resolution 四级顺序:①显式源绑定(校验级失败**硬失败不换源**——换源重试已拒内容 = 降级攻击面)
|
|
7
|
+
* → ②白名单 priority 升序(网络级失败可按 priority 继续——exclude 通道,类型上仅允许 reason='network')
|
|
8
|
+
* → ③公共 npm 兜底(缺省 false——**候选耗尽**或白名单为空时生效,企业内网安全默认)
|
|
9
|
+
* → ④全不可用 = 离线拒绝(显式错误,非静默);
|
|
10
|
+
* - **signed 字段语义(T-5 定稿①)**:签名物承载方式(true=源直供 / false=镜像透传),**永不产生免验/降档/直信**——验签义务恒在 verifier 层(ADR-003);
|
|
11
|
+
* - 本模块纯判定(零网络 I/O),CI 可全量测试。
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
|
|
15
|
+
export interface RegistryEntry { url: string; priority: number; /** 签名物承载方式(T-5 定稿①:不参与信任分级,验签义务恒在 verifier) */ signed: boolean }
|
|
16
|
+
|
|
17
|
+
export interface RegistryConfig {
|
|
18
|
+
registries: RegistryEntry[]
|
|
19
|
+
/** 公共 npm 兜底(缺省必须显式声明——企业内网 false 为试点准入判据,T-5 定稿③) */
|
|
20
|
+
allowNpmFallback: boolean
|
|
21
|
+
/** 离线模式:全部拒绝(显式错误) */
|
|
22
|
+
offline: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PackageSpec { name: string; version: string; /** 显式源绑定(manifest registry 字段)——校验级失败硬失败不换源 */ pinnedRegistry?: string }
|
|
26
|
+
|
|
27
|
+
export type ResolutionSource = 'registry' | 'npm-fallback' | 'rejected'
|
|
28
|
+
|
|
29
|
+
export interface Resolution {
|
|
30
|
+
source: ResolutionSource
|
|
31
|
+
url?: string
|
|
32
|
+
/** 判定摘要(审计落盘 payload;含拒绝原因/解析路径) */
|
|
33
|
+
detail: string
|
|
34
|
+
/** 解析决策指纹(可落审计哈希链) */
|
|
35
|
+
decisionId: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 单源失败排除记录(T-5 定稿②):reason 类型层仅允许 'network'——
|
|
40
|
+
* 「校验级失败换源」在本签名上不可表达(校验失败由调用方硬拒,不经本层换源)。
|
|
41
|
+
*/
|
|
42
|
+
export type ExcludedSource = { url: string; reason: 'network' }
|
|
43
|
+
|
|
44
|
+
export function resolvePackage(spec: PackageSpec, config: RegistryConfig, audit?: (detail: Record<string, unknown>) => void, opts: { exclude?: ExcludedSource[] } = {}): Resolution {
|
|
45
|
+
const finish = (r: Omit<Resolution, 'decisionId'>): Resolution => {
|
|
46
|
+
const decisionId = 'RD-' + createHash('sha256').update(JSON.stringify({ spec, r })).digest('hex').slice(0, 16)
|
|
47
|
+
audit?.({ kind: 'registry-resolution', spec: spec.name, ...r, decisionId })
|
|
48
|
+
return { ...r, decisionId }
|
|
49
|
+
}
|
|
50
|
+
// ④ 离线:全拒(最高优先级——离线声明不可被任何源覆盖)
|
|
51
|
+
if (config.offline) return finish({ source: 'rejected', detail: 'offline mode: all sources rejected(显式拒绝,非静默)' })
|
|
52
|
+
const excluded = new Set((opts.exclude ?? []).map(e => e.url))
|
|
53
|
+
// ① 显式源绑定:校验级失败硬失败不换源(pinned 不在白名单);
|
|
54
|
+
// pinned 命中但被 exclude(网络失败)→ 语义同「按 priority 继续」(T-5 定稿②网络路径)
|
|
55
|
+
if (spec.pinnedRegistry) {
|
|
56
|
+
const pinned = config.registries.find(r => r.url === spec.pinnedRegistry)
|
|
57
|
+
if (!pinned) return finish({ source: 'rejected', detail: `pinned registry "${spec.pinnedRegistry}" not in whitelist——显式绑定不在白名单,硬失败不换源(fail-closed)` })
|
|
58
|
+
if (!excluded.has(pinned.url)) {
|
|
59
|
+
return finish({ source: 'registry', url: pinned.url, detail: `pinned to ${pinned.url}(priority=${pinned.priority})` })
|
|
60
|
+
}
|
|
61
|
+
// pinned 网络失败:落审计后按 priority 继续剩余白名单(不含 pinned)
|
|
62
|
+
}
|
|
63
|
+
// ② 白名单 priority 升序(排除网络失败源)
|
|
64
|
+
const sorted = [...config.registries].sort((a, b) => a.priority - b.priority).filter(r => !excluded.has(r.url))
|
|
65
|
+
if (sorted.length) {
|
|
66
|
+
const first = sorted[0]
|
|
67
|
+
return finish({ source: 'registry', url: first.url, detail: `whitelist priority order → ${first.url}(candidates=${sorted.length}${excluded.size ? `, excluded=${excluded.size}` : ''})` })
|
|
68
|
+
}
|
|
69
|
+
// ③ 公共 npm 兜底(**候选耗尽**或白名单为空时生效——T-5 定稿②③修正语义)
|
|
70
|
+
if (config.allowNpmFallback) {
|
|
71
|
+
const why = config.registries.length ? 'candidates exhausted(网络级失败耗尽)' : 'whitelist empty'
|
|
72
|
+
return finish({ source: 'npm-fallback', url: 'https://registry.npmjs.org', detail: `${why} → npm fallback(显式开启;兜底命中包强制过 verifier 不降级)` })
|
|
73
|
+
}
|
|
74
|
+
return finish({ source: 'rejected', detail: config.registries.length
|
|
75
|
+
? 'candidates exhausted and npm fallback disabled——候选耗尽且兜底关闭(fail-closed)'
|
|
76
|
+
: 'no whitelist entries and npm fallback disabled——白名单为空且兜底关闭(企业内网安全默认)' })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 只读预览(T-5 定稿②新增):返回按优先级的候选链(不含 pinned 语义,供装载面展示与演练) */
|
|
80
|
+
export function resolveCandidateChain(config: RegistryConfig): { chain: string[]; fallback: boolean } {
|
|
81
|
+
const chain = [...config.registries].sort((a, b) => a.priority - b.priority).map(r => r.url)
|
|
82
|
+
return { chain, fallback: config.allowNpmFallback }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 白名单校验(插件安装面调用:来源不在清单 = 拒绝并审计) */
|
|
86
|
+
export function isWhitelisted(url: string, config: RegistryConfig): boolean {
|
|
87
|
+
return !config.offline && config.registries.some(r => r.url === url)
|
|
88
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S6 验签器(ADR-003 双轨实装):minisign 离线轨(node:crypto ed25519)+ Sigstore 接口预留
|
|
3
|
+
*
|
|
4
|
+
* fail-closed 分级(D-2 裁决):
|
|
5
|
+
* - 校验失败(有签名但不对)= 硬拒绝,不可配置放行
|
|
6
|
+
* - 签名缺失 = warn 模式告警放行(manifestHash 兜底)→ enforce 模式硬拒绝
|
|
7
|
+
* - Sigstore 轨不可达 = 回退静态轨;双轨皆败按缺失处理
|
|
8
|
+
*/
|
|
9
|
+
import { verify, createPublicKey } from 'node:crypto'
|
|
10
|
+
|
|
11
|
+
export interface SignatureBundle {
|
|
12
|
+
/** minisign 式离线轨:对 manifestHash 的 ed25519 签名(base64) */
|
|
13
|
+
minisig?: string
|
|
14
|
+
/** Sigstore keyless 主轨:离线 bundle(S6 接口预留——bundle 校验需生态依赖,M3/沙箱外实装) */
|
|
15
|
+
sigstoreBundle?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface VerifyResult { ok: boolean; track: 'minisign' | 'sigstore' | 'none'; error?: string }
|
|
19
|
+
|
|
20
|
+
export interface VerifierDeps {
|
|
21
|
+
/** 信任锚公钥(ed25519,base64;内置根 pinning 或 trustRoot 替换点) */
|
|
22
|
+
trustRootPublicKey: string
|
|
23
|
+
/** D-2:S6-S9 = warn,S10 起 = enforce */
|
|
24
|
+
mode: 'warn' | 'enforce'
|
|
25
|
+
/** Sigstore 主轨探针(骨架期返回 false → 回退静态轨,复现 ADR-003 回退语义) */
|
|
26
|
+
sigstoreAvailable?: () => boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 静态轨:ed25519 验证 manifestHash 签名 */
|
|
30
|
+
export function verifyMinisig(manifestHash: string, minisig: string, trustRootPublicKey: string): { ok: boolean; error?: string } {
|
|
31
|
+
try {
|
|
32
|
+
const key = createPublicKey({ key: Buffer.from(trustRootPublicKey, 'base64'), format: 'der', type: 'spki' })
|
|
33
|
+
const ok = verify(null, Buffer.from(manifestHash), key, Buffer.from(minisig, 'base64'))
|
|
34
|
+
return ok ? { ok: true } : { ok: false, error: 'signature mismatch' }
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { ok: false, error: `verify error: ${String(e)}` }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 验签入口(装载前调用,A010001 之前的供应链第一道门):
|
|
42
|
+
* 返回 { allowed, warning? }——warning 非空时调用方必须告警放行并留痕(enforce 前过渡期)
|
|
43
|
+
*/
|
|
44
|
+
export function enforceSignature(manifestHash: string, sig: SignatureBundle | undefined, deps: VerifierDeps, onCount?: (name: 'car_unsigned_confirmed', labels: { confirmed: 'yes' | 'no' }) => void): { allowed: boolean; warning?: string; error?: string } {
|
|
45
|
+
// 主轨探针:可用则先走 Sigstore(S6 骨架期默认不可用 → 回退静态轨)
|
|
46
|
+
if (deps.sigstoreAvailable?.()) {
|
|
47
|
+
if (sig?.sigstoreBundle) {
|
|
48
|
+
// bundle 校验需生态依赖(@sigstore/verify),骨架期视为不可达 → 回退静态轨(ADR-003 回退语义)
|
|
49
|
+
} else if (deps.mode === 'enforce') {
|
|
50
|
+
return { allowed: false, error: 'CAR-E-SIG: sigstore bundle missing (enforce mode)' }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// 静态轨
|
|
54
|
+
if (sig?.minisig) {
|
|
55
|
+
const r = verifyMinisig(manifestHash, sig.minisig, deps.trustRootPublicKey)
|
|
56
|
+
if (r.ok) return { allowed: true }
|
|
57
|
+
// 有签名但校验失败 = 硬拒绝(不可配置放行——ADR-003 fail-closed 第一级)
|
|
58
|
+
return { allowed: false, error: `CAR-E-SIG: signature verification FAILED: ${r.error}(不可配置放行)` }
|
|
59
|
+
}
|
|
60
|
+
// 签名缺失
|
|
61
|
+
if (deps.mode === 'enforce') {
|
|
62
|
+
return { allowed: false, error: 'CAR-E-SIG: signature missing (enforce mode)——manifestHash 指纹兜底不替代签名' }
|
|
63
|
+
}
|
|
64
|
+
// S17 采集面:unsigned 路径计数(零内容 labels;confirmed=no=未确认 warn / yes=显式确认豁免动作)
|
|
65
|
+
onCount?.('car_unsigned_confirmed', { confirmed: 'no' })
|
|
66
|
+
return { allowed: true, warning: 'CAR-W-SIG: unsigned plugin(warn 过渡期放行,manifestHash 指纹兜底;enforce 切换见 D-2 终局条件)' }
|
|
67
|
+
}
|
package/src/loop/goal.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F13 · Goal 层(五层停止第五层):phase 持久化 + activation 进程内
|
|
3
|
+
*
|
|
4
|
+
* 口径(dsh goal/goal/src/types.ts:45,75,87-89 源码级已核实 + M2 系统设计增补 T-1):
|
|
5
|
+
* - GoalPhase = active | paused | blocked | complete —— 持久化:经会话日志 goalUpdate 事件
|
|
6
|
+
* - GoalActivation = armed | disarmed —— 进程内、never persisted:
|
|
7
|
+
* 新实例默认 disarmed = 「session-start 重置 disarmed」零分支天然满足(AC-4.5)
|
|
8
|
+
* - update_goal(complete) 不立刻掐断 turn:先写持久状态,turn 由既有收口逻辑完成(D5 §9)
|
|
9
|
+
* - blocked 产出点:授权门 blockOnDeny / 策略门(stop.ts 集成)
|
|
10
|
+
*/
|
|
11
|
+
import type { SessionLog, SessionEvent } from '../session/log.ts'
|
|
12
|
+
|
|
13
|
+
export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete'
|
|
14
|
+
export type GoalActivation = 'armed' | 'disarmed'
|
|
15
|
+
|
|
16
|
+
export class GoalDriver {
|
|
17
|
+
#log: SessionLog
|
|
18
|
+
#phase: GoalPhase = 'active'
|
|
19
|
+
#activation: GoalActivation = 'disarmed' // 重启/新实例默认 disarmed(不会把历史任务叫醒)
|
|
20
|
+
#disarmReasons: string[] = []
|
|
21
|
+
|
|
22
|
+
constructor(log: SessionLog) { this.#log = log }
|
|
23
|
+
|
|
24
|
+
get phase(): GoalPhase { return this.#phase }
|
|
25
|
+
get activation(): GoalActivation { return this.#activation }
|
|
26
|
+
|
|
27
|
+
/** 持久状态变更:写 goalUpdate 事件(重启后由恢复层读取重放) */
|
|
28
|
+
setPhase(phase: GoalPhase, reason: string): void {
|
|
29
|
+
this.#phase = phase
|
|
30
|
+
this.#log.append('runtime', 'goalUpdate', 'GOAL', { phase, reason })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 进程内激活(不持久化——重启后默认 disarmed) */
|
|
34
|
+
arm(): void { this.#activation = 'armed' }
|
|
35
|
+
disarm(reason: string): void {
|
|
36
|
+
this.#activation = 'disarmed'
|
|
37
|
+
this.#disarmReasons.push(reason)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 超限/撞限/错误自动 disarm(D5 §9:Round 超限、max-tokens、Agent error 都会 disarm) */
|
|
41
|
+
autoDisarmOnTurnEnd(reason: string): void {
|
|
42
|
+
if (reason !== 'completed') this.disarm(`turn ended with ${reason}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 恢复层:从已落盘日志重放 goalUpdate 重建 phase(activation 不恢复——保持 disarmed) */
|
|
46
|
+
static replay(events: readonly SessionEvent[]): { phase: GoalPhase; activation: GoalActivation } {
|
|
47
|
+
let phase: GoalPhase = 'active'
|
|
48
|
+
for (const e of events) {
|
|
49
|
+
if (e.kind === 'goalUpdate' && (e.payload as any)?.phase) phase = (e.payload as any).phase
|
|
50
|
+
}
|
|
51
|
+
return { phase, activation: 'disarmed' } // activation 永不持久化
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F13 · 恢复层:interrupted 补记(进程消失后恢复层给未闭合 turn 补状态)
|
|
3
|
+
*
|
|
4
|
+
* 口径(D4 §18 / D5 §19):
|
|
5
|
+
* - interrupted = 进程消失后恢复层补记,与 aborted(运行期取消可清理)不可混用
|
|
6
|
+
* - 未配对 toolCall 补合成错误 toolResult(日志无缺口红线)
|
|
7
|
+
* - 副作用审计:补记事件带 recovered=true 标记(副作用是否落地由恢复层显式声明为未知)
|
|
8
|
+
*/
|
|
9
|
+
import type { SessionLog, SessionEvent } from '../session/log.ts'
|
|
10
|
+
|
|
11
|
+
export interface RecoveryReport {
|
|
12
|
+
/** 补记 interrupted 的 turn 数 */
|
|
13
|
+
turnsClosed: number
|
|
14
|
+
/** 补记合成 toolResult 的未配对调用数 */
|
|
15
|
+
pairedCalls: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 扫描日志中未闭合的 turn(有 turnStart 语义的用户事件但没有 turnEnd)与未配对 toolCall */
|
|
19
|
+
export function recoverInterrupted(log: SessionLog): RecoveryReport {
|
|
20
|
+
const report: RecoveryReport = { turnsClosed: 0, pairedCalls: 0 }
|
|
21
|
+
const callIds = new Set<string>()
|
|
22
|
+
const resultIds = new Set<string>()
|
|
23
|
+
const openTurns = new Set<string>()
|
|
24
|
+
const closedTurns = new Set<string>()
|
|
25
|
+
for (const e of log.events) {
|
|
26
|
+
if (e.kind === 'toolCall') callIds.add((e.payload as any).id)
|
|
27
|
+
if (e.kind === 'toolResult') resultIds.add((e.payload as any).id)
|
|
28
|
+
if (e.kind === 'user') openTurns.add(e.turnId)
|
|
29
|
+
if (e.kind === 'turnEnd') { closedTurns.add(e.turnId); openTurns.delete(e.turnId) }
|
|
30
|
+
}
|
|
31
|
+
// 未配对 toolCall → 合成错误 toolResult(副作用状态未知:recovered=true 显式声明)
|
|
32
|
+
for (const e of log.events) {
|
|
33
|
+
if (e.kind !== 'toolCall') continue
|
|
34
|
+
const id = (e.payload as any).id
|
|
35
|
+
if (resultIds.has(id)) continue
|
|
36
|
+
if (closedTurns.has(e.turnId)) continue // 已闭合 turn 内成对由原逻辑保证
|
|
37
|
+
log.append('runtime', 'toolResult', e.turnId, { id, error: 'recovered: process interrupted before dispatch result', recovered: true })
|
|
38
|
+
resultIds.add(id)
|
|
39
|
+
report.pairedCalls++
|
|
40
|
+
}
|
|
41
|
+
// 未闭合 turn → 补记 turnEnd=interrupted(不可与 aborted 混用)
|
|
42
|
+
for (const turnId of openTurns) {
|
|
43
|
+
log.append('runtime', 'turnEnd', turnId, null, { reason: 'interrupted', recovered: true })
|
|
44
|
+
report.turnsClosed++
|
|
45
|
+
}
|
|
46
|
+
return report
|
|
47
|
+
}
|
package/src/loop/stop.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F5 · 最小 turn 级停止语义(ADR-001 实装)
|
|
3
|
+
*
|
|
4
|
+
* 口径(《系统设计》F5 / D4-D5 机制对照 / POC-3 I4):
|
|
5
|
+
* - step 三结果:completed / max-tokens / null(null = 工具结果交回模型,非异常)
|
|
6
|
+
* - TurnEndReason 六值:completed / max-tokens / aborted / error / blocked / interrupted
|
|
7
|
+
* (aborted=运行期取消可清理;interrupted=进程消失后恢复层补记——不可混用,S1/S2 仅实装前者)
|
|
8
|
+
* - ADR-001:max-tokens 解析前收口(截断响应不解析工具调用,禁补全半截 JSON);
|
|
9
|
+
* 只读白名单可选补错重试(preset.maxTokens.retryReadOnly);endReasonTrail 保守判定:
|
|
10
|
+
* turn 内任一 step 撞限 → turn/end 保留 max-tokens,不被后续成功 step 冲销
|
|
11
|
+
* - 工具收口:concludesTurn=OR(任一 true 即收口);terminate=AND(整批 finalized 全 true 才中断)
|
|
12
|
+
* - 取消:未派发调用补记成对事件(toolCall + 合成错误 toolResult),日志无缺口
|
|
13
|
+
*/
|
|
14
|
+
import type { SessionLog } from '../session/log.ts'
|
|
15
|
+
|
|
16
|
+
export type StepOutcome = 'completed' | 'max-tokens' | 'null'
|
|
17
|
+
export type TurnEndReason = 'completed' | 'max-tokens' | 'aborted' | 'error' | 'blocked' | 'interrupted'
|
|
18
|
+
export type SideEffect = 'readonly' | 'write'
|
|
19
|
+
|
|
20
|
+
export interface ToolCall { id: string; tool: string; args: Record<string, unknown> }
|
|
21
|
+
export interface ToolDef {
|
|
22
|
+
declaredSideEffect: SideEffect
|
|
23
|
+
concludesTurn?: boolean
|
|
24
|
+
terminate?: boolean
|
|
25
|
+
run(args: Record<string, unknown>): Promise<unknown>
|
|
26
|
+
}
|
|
27
|
+
/** 模型单步响应(provider 形态;stopReason 为不可变透传——ADR-001) */
|
|
28
|
+
export interface ModelStep {
|
|
29
|
+
text?: string
|
|
30
|
+
stopReason: 'stop' | 'length' | 'toolUse' | 'error'
|
|
31
|
+
/** 仅 stopReason=toolUse 时有意义;length 时 provider 可能给半截信息(CAR 不解析其 args) */
|
|
32
|
+
toolCalls?: ToolCall[]
|
|
33
|
+
/** length 时 provider 给出的「截断工具调用」:只有名字,args 不可信 */
|
|
34
|
+
truncatedTools?: Array<{ id: string; tool: string }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TurnPreset {
|
|
38
|
+
/** ADR-001:只读白名单补错重试开关(默认关) */
|
|
39
|
+
maxTokensRetryReadOnly?: boolean
|
|
40
|
+
/** 权限模式(F4 集成点):confirm 下写操作需审批回调放行 */
|
|
41
|
+
mode?: 'readonly' | 'confirm' | 'full'
|
|
42
|
+
/** 确认模式审批回调:返回 true 放行 */
|
|
43
|
+
authorize?: (call: ToolCall) => Promise<boolean>
|
|
44
|
+
maxSteps?: number
|
|
45
|
+
/** F13:OR/AND 收口可配置(默认 any/all = M1 行为等价) */
|
|
46
|
+
aggregate?: { concludesTurn?: 'any' | 'all'; terminate?: 'any' | 'all' }
|
|
47
|
+
/** F13:授权拒绝即收口 blocked(默认 false = 模型继续调整) */
|
|
48
|
+
blockOnDeny?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TurnResult { reason: TurnEndReason; steps: number; endReasonTrail: Array<{ seq: number; reason: TurnEndReason }> }
|
|
52
|
+
|
|
53
|
+
export class TurnAborted extends Error {
|
|
54
|
+
constructor() { super('CAR-ABORTED: turn cancelled by user') }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function runTurn(opts: {
|
|
58
|
+
log: SessionLog
|
|
59
|
+
turnId: string
|
|
60
|
+
model: () => Promise<ModelStep>
|
|
61
|
+
tools: Map<string, ToolDef>
|
|
62
|
+
preset?: TurnPreset
|
|
63
|
+
signal?: { aborted: boolean }
|
|
64
|
+
}): Promise<TurnResult> {
|
|
65
|
+
const { log, turnId, model, tools, preset = {}, signal } = opts
|
|
66
|
+
const maxSteps = preset.maxSteps ?? 32
|
|
67
|
+
const trail: Array<{ seq: number; reason: TurnEndReason }> = []
|
|
68
|
+
let sawMaxTokens = false
|
|
69
|
+
let steps = 0
|
|
70
|
+
|
|
71
|
+
const executeBatch = async (calls: ToolCall[]): Promise<{ concludesTurn: boolean; concludesAll: boolean; terminateVotes: number; terminateAll: boolean; finalized: number; denies: number }> => {
|
|
72
|
+
let concludesTurn = false
|
|
73
|
+
let concludesVotes = 0
|
|
74
|
+
let terminateVotes = 0
|
|
75
|
+
let finalized = 0
|
|
76
|
+
let denies = 0
|
|
77
|
+
for (const call of calls) {
|
|
78
|
+
if (signal?.aborted) {
|
|
79
|
+
// 取消:未派发调用补记成对事件(日志无缺口)
|
|
80
|
+
log.append('runtime', 'toolCall', turnId, call)
|
|
81
|
+
log.append('runtime', 'toolResult', turnId, { id: call.id, error: 'aborted-before-dispatch' })
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const def = tools.get(call.tool)
|
|
85
|
+
log.append('model', 'toolCall', turnId, call)
|
|
86
|
+
if (!def) {
|
|
87
|
+
log.append('plugin', 'toolResult', turnId, { id: call.id, error: `unknown tool "${call.tool}"` })
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
// 权限门(F4 集成点):write 类在 readonly/confirm 模式需放行
|
|
91
|
+
if (def.declaredSideEffect === 'write' && preset.mode !== 'full') {
|
|
92
|
+
const granted = preset.mode === 'confirm' ? await (preset.authorize?.(call) ?? Promise.resolve(false)) : false
|
|
93
|
+
log.append('runtime', 'toolResult', turnId, { id: call.id, error: 'authorization-denied', granted, mode: preset.mode })
|
|
94
|
+
if (!granted) { denies++; continue }
|
|
95
|
+
}
|
|
96
|
+
// 工具错误 = 成对 toolResult 交回模型(US-5/D5 语义:工具错误是结果而非 turn 中断——
|
|
97
|
+
// 与取消补记成对事件同纪律;turn 级 error 仅保留给模型请求/协议层失败)
|
|
98
|
+
let result: unknown
|
|
99
|
+
let toolError: string | undefined
|
|
100
|
+
try {
|
|
101
|
+
result = await def.run(call.args)
|
|
102
|
+
} catch (e) {
|
|
103
|
+
result = undefined
|
|
104
|
+
toolError = String(e)
|
|
105
|
+
}
|
|
106
|
+
log.append('plugin', 'toolResult', turnId, { id: call.id, result, ...(toolError ? { error: toolError } : {}) })
|
|
107
|
+
finalized++
|
|
108
|
+
if (def.concludesTurn) { concludesTurn = true; concludesVotes++ }
|
|
109
|
+
if (def.terminate) terminateVotes++
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
concludesTurn, concludesAll: finalized > 0 && concludesVotes === finalized,
|
|
113
|
+
terminateVotes, terminateAll: finalized > 0 && terminateVotes === finalized,
|
|
114
|
+
finalized, denies,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
while (steps < maxSteps) {
|
|
120
|
+
if (signal?.aborted) {
|
|
121
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'aborted' }).seq
|
|
122
|
+
trail.push({ seq, reason: 'aborted' })
|
|
123
|
+
return { reason: 'aborted', steps, endReasonTrail: trail }
|
|
124
|
+
}
|
|
125
|
+
steps++
|
|
126
|
+
log.snapshotModelRequest() // agent-loop 集成点:请求时快照(N1)
|
|
127
|
+
const step = await model()
|
|
128
|
+
|
|
129
|
+
if (step.stopReason === 'error') {
|
|
130
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'error' }).seq
|
|
131
|
+
trail.push({ seq, reason: 'error' })
|
|
132
|
+
return { reason: 'error', steps, endReasonTrail: trail }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ===== ADR-001:解析前收口 =====
|
|
136
|
+
if (step.stopReason === 'length') {
|
|
137
|
+
sawMaxTokens = true
|
|
138
|
+
const readonlyRetry = preset.maxTokensRetryReadOnly === true
|
|
139
|
+
const trunc = step.truncatedTools ?? []
|
|
140
|
+
const retryable = readonlyRetry && trunc.every(t => tools.get(t.tool)?.declaredSideEffect === 'readonly')
|
|
141
|
+
if (retryable && trunc.length) {
|
|
142
|
+
// Pi 式补错重试:只读白名单工具收到「参数可能被截断」错误结果,交回模型下轮
|
|
143
|
+
for (const t of trunc) {
|
|
144
|
+
log.append('runtime', 'toolCall', turnId, { id: t.id, tool: t.tool, args: {}, truncated: true })
|
|
145
|
+
log.append('runtime', 'toolResult', turnId, { id: t.id, error: 'arguments may be truncated (max-tokens)' })
|
|
146
|
+
}
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
// 默认收口:截断响应不解析、不执行任何工具调用
|
|
150
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'max-tokens' }).seq
|
|
151
|
+
trail.push({ seq, reason: 'max-tokens' })
|
|
152
|
+
return { reason: 'max-tokens', steps, endReasonTrail: trail }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (step.stopReason === 'toolUse' && step.toolCalls?.length) {
|
|
156
|
+
const batch = await executeBatch(step.toolCalls)
|
|
157
|
+
// F13:授权拒绝即收口(blockOnDeny 产出点——blocked 仅新增产出点,枚举不增)
|
|
158
|
+
if (preset.blockOnDeny && batch.denies > 0) {
|
|
159
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'blocked', denies: batch.denies }).seq
|
|
160
|
+
trail.push({ seq, reason: 'blocked' })
|
|
161
|
+
return { reason: 'blocked', steps, endReasonTrail: trail }
|
|
162
|
+
}
|
|
163
|
+
const agg = preset.aggregate ?? {}
|
|
164
|
+
const abortedNow = (agg.terminate ?? 'all') === 'any' ? batch.terminateVotes > 0 : batch.terminateAll
|
|
165
|
+
if (abortedNow) { // terminate 收口:默认 AND(整批),可配置 any
|
|
166
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'aborted' }).seq
|
|
167
|
+
trail.push({ seq, reason: 'aborted' })
|
|
168
|
+
return { reason: 'aborted', steps, endReasonTrail: trail }
|
|
169
|
+
}
|
|
170
|
+
const concludedNow = (agg.concludesTurn ?? 'any') === 'any' ? batch.concludesTurn : batch.concludesAll && batch.finalized > 0
|
|
171
|
+
if (concludedNow) { // concludesTurn 收口:默认 OR,可配置 all
|
|
172
|
+
const reason: TurnEndReason = sawMaxTokens ? 'max-tokens' : 'completed'
|
|
173
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason }).seq
|
|
174
|
+
trail.push({ seq, reason })
|
|
175
|
+
return { reason, steps, endReasonTrail: trail }
|
|
176
|
+
}
|
|
177
|
+
// null:工具结果交回模型,继续下一 step(非异常)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// stopReason === 'stop':模型自然完成
|
|
182
|
+
const reason: TurnEndReason = sawMaxTokens ? 'max-tokens' : 'completed'
|
|
183
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason }).seq
|
|
184
|
+
trail.push({ seq, reason })
|
|
185
|
+
return { reason, steps, endReasonTrail: trail }
|
|
186
|
+
}
|
|
187
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'error', detail: 'max-steps' }).seq
|
|
188
|
+
trail.push({ seq, reason: 'error' })
|
|
189
|
+
return { reason: 'error', steps, endReasonTrail: trail }
|
|
190
|
+
} catch (e) {
|
|
191
|
+
if (e instanceof TurnAborted) {
|
|
192
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'aborted' }).seq
|
|
193
|
+
trail.push({ seq, reason: 'aborted' })
|
|
194
|
+
return { reason: 'aborted', steps, endReasonTrail: trail }
|
|
195
|
+
}
|
|
196
|
+
const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'error', detail: String(e) }).seq
|
|
197
|
+
trail.push({ seq, reason: 'error' })
|
|
198
|
+
return { reason: 'error', steps, endReasonTrail: trail }
|
|
199
|
+
}
|
|
200
|
+
}
|