@lqc123qwe/car-runtime 1.9.0 → 1.11.0
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/README.md +17 -1
- package/dist/SHA256SUMS +1 -0
- package/dist/authz/authz.d.ts +46 -0
- package/dist/authz/authz.js +76 -0
- package/dist/car-runtime-1.11.0.tgz +0 -0
- package/dist/car-runtime-1.11.0.tgz.sig.bundle +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +825 -0
- package/dist/dx/doctor.d.ts +62 -0
- package/dist/dx/doctor.js +127 -0
- package/dist/governance/dump.d.ts +47 -0
- package/dist/governance/dump.js +17 -0
- package/dist/host/facade.d.ts +13 -0
- package/dist/host/facade.js +154 -0
- package/dist/host/hostGateway.d.ts +117 -0
- package/dist/host/hostGateway.js +65 -0
- package/dist/host/mappings.d.ts +27 -0
- package/dist/host/mappings.js +52 -0
- package/dist/host/stdio.d.ts +14 -0
- package/dist/host/stdio.js +83 -0
- package/dist/kernel/context.d.ts +76 -0
- package/dist/kernel/context.js +203 -0
- package/dist/kernel/events.d.ts +37 -0
- package/dist/kernel/events.js +82 -0
- package/dist/load/config.d.ts +79 -0
- package/dist/load/config.js +285 -0
- package/dist/load/loader.d.ts +72 -0
- package/dist/load/loader.js +162 -0
- package/dist/load/registry.d.ts +44 -0
- package/dist/load/registry.js +58 -0
- package/dist/load/report.d.ts +98 -0
- package/dist/load/report.js +301 -0
- package/dist/load/sigGate.d.ts +22 -0
- package/dist/load/sigGate.js +68 -0
- package/dist/load/sign.d.ts +25 -0
- package/dist/load/sign.js +47 -0
- package/dist/load/verifier.d.ts +35 -0
- package/dist/load/verifier.js +56 -0
- package/dist/loop/goal.d.ts +31 -0
- package/dist/loop/goal.js +34 -0
- package/dist/loop/recover.d.ts +17 -0
- package/dist/loop/recover.js +39 -0
- package/dist/loop/stop.d.ts +82 -0
- package/dist/loop/stop.js +184 -0
- package/dist/mcp/gateway.d.ts +96 -0
- package/dist/mcp/gateway.js +69 -0
- package/dist/mcp/httpClient.d.ts +27 -0
- package/dist/mcp/httpClient.js +196 -0
- package/dist/mcp/stdioClient.d.ts +8 -0
- package/dist/mcp/stdioClient.js +73 -0
- package/dist/ptc/budget.d.ts +21 -0
- package/dist/ptc/budget.js +36 -0
- package/dist/ptc/erasable.d.ts +14 -0
- package/dist/ptc/erasable.js +27 -0
- package/dist/ptc/runCode.d.ts +65 -0
- package/dist/ptc/runCode.js +140 -0
- package/dist/ptc/schemaRender.d.ts +15 -0
- package/dist/ptc/schemaRender.js +90 -0
- package/dist/ptc/sdk.d.ts +27 -0
- package/dist/ptc/sdk.js +23 -0
- package/dist/ptc/worker-entry.d.ts +1 -0
- package/dist/ptc/worker-entry.js +71 -0
- package/dist/release-audit.jsonl +4 -0
- package/dist/runtime-core/chatStep.d.ts +43 -0
- package/dist/runtime-core/chatStep.js +121 -0
- package/dist/runtime-core/credentials.d.ts +49 -0
- package/dist/runtime-core/credentials.js +118 -0
- package/dist/runtime-core/errors.d.ts +30 -0
- package/dist/runtime-core/errors.js +36 -0
- package/dist/runtime-core/llm.d.ts +76 -0
- package/dist/runtime-core/llm.js +638 -0
- package/dist/runtime-core/redaction.d.ts +39 -0
- package/dist/runtime-core/redaction.js +79 -0
- package/dist/runtime-core/telemetry.d.ts +88 -0
- package/dist/runtime-core/telemetry.js +332 -0
- package/dist/runtime-core/tools.d.ts +15 -0
- package/dist/runtime-core/tools.js +12 -0
- package/dist/runtime-core/trace.d.ts +55 -0
- package/dist/runtime-core/trace.js +137 -0
- package/dist/runtime-core/types.d.ts +104 -0
- package/dist/runtime-core/types.js +13 -0
- package/dist/sandbox/sandbox.d.ts +68 -0
- package/dist/sandbox/sandbox.js +153 -0
- package/dist/sbom.json +263 -0
- package/dist/seam.d.ts +24 -0
- package/dist/seam.js +23 -0
- package/dist/security/secrets-baseline.d.ts +43 -0
- package/dist/security/secrets-baseline.js +176 -0
- package/dist/security/secrets.d.ts +45 -0
- package/dist/security/secrets.js +138 -0
- package/dist/session/export.d.ts +55 -0
- package/dist/session/export.js +81 -0
- package/dist/session/fork.d.ts +51 -0
- package/dist/session/fork.js +63 -0
- package/dist/session/format.d.ts +21 -0
- package/dist/session/format.js +49 -0
- package/dist/session/indexStore.d.ts +44 -0
- package/dist/session/indexStore.js +265 -0
- package/dist/session/log.d.ts +79 -0
- package/dist/session/log.js +157 -0
- package/dist/session/store.d.ts +9 -0
- package/dist/session/store.js +37 -0
- package/dist/telemetry/metrics.d.ts +25 -0
- package/dist/telemetry/metrics.js +26 -0
- package/package.json +17 -1
- package/src/authz/authz.ts +12 -0
- package/src/loop/stop.ts +43 -10
- package/src/runtime-core/chatStep.ts +22 -2
- package/src/runtime-core/llm.ts +96 -13
- package/src/runtime-core/types.ts +22 -1
- package/src/seam.ts +24 -0
- package/src/session/log.ts +1 -1
- package/.gitattributes +0 -11
- package/.github/workflows/ci.yml +0 -293
- package/.github/workflows/release.yml +0 -54
- package/adr/ADR-001.md +0 -4
- package/adr/ADR-002.md +0 -5
- package/adr/ADR-003.md +0 -35
- package/adr/host-onboarding.md +0 -68
- package/adr/mlps-audit-checklist.md +0 -41
- package/adr/win32-spike.md +0 -45
- package/docs/plugin-authoring.md +0 -82
- package/native/landlock-run/Makefile +0 -19
- package/native/landlock-run/landlock-run.c +0 -162
- package/scripts/contract-check.ts +0 -56
- package/scripts/invariant-n1.ts +0 -96
- package/scripts/package-check.ts +0 -29
- package/scripts/ptc-baseline/run-baseline.ts +0 -118
- package/scripts/release-artifacts.ts +0 -111
- package/scripts/release-pipeline.ts +0 -245
- package/scripts/secrets-baseline.ts +0 -33
- package/scripts/secrets-scan.ts +0 -56
- package/scripts/win32-ffi-debug.ts +0 -143
- package/scripts/win32-koffi-spike.ts +0 -537
- package/test/contract/eventmap.golden.json +0 -7
- package/test/fixtures/hello-plugin.ts +0 -5
- package/test/kernel.spec.ts +0 -136
- package/test/registry-e2e/run-e2e.ts +0 -213
- package/test/s11.spec.ts +0 -129
- package/test/s12.spec.ts +0 -135
- package/test/s13.spec.ts +0 -117
- package/test/s14.spec.ts +0 -117
- package/test/s15.spec.ts +0 -80
- package/test/s16.spec.ts +0 -133
- package/test/s17.spec.ts +0 -102
- package/test/s18.spec.ts +0 -81
- package/test/s19.spec.ts +0 -138
- package/test/s2.spec.ts +0 -245
- package/test/s20.spec.ts +0 -241
- package/test/s21.spec.ts +0 -231
- package/test/s22-m7-store-format.spec.ts +0 -135
- package/test/s23-m7-export-index.spec.ts +0 -189
- package/test/s24-signature-gate.spec.ts +0 -185
- package/test/s25-m8-runtime-core.spec.ts +0 -600
- package/test/s26-signature-closeout.spec.ts +0 -349
- package/test/s27-host-load-wiring.spec.ts +0 -134
- package/test/s28-session-index-followup.spec.ts +0 -236
- package/test/s29-otel-producer.spec.ts +0 -252
- package/test/s3.spec.ts +0 -179
- package/test/s30-release-artifacts.spec.ts +0 -127
- package/test/s31-real-model-wiring.spec.ts +0 -329
- package/test/s32-telemetry-mcp-ptc.spec.ts +0 -218
- package/test/s33-anthropic-adapter.spec.ts +0 -354
- package/test/s34-mcp-remote-transport.spec.ts +0 -250
- package/test/s35-config-ts-zod.spec.ts +0 -145
- package/test/s36-host-tool-surface.spec.ts +0 -250
- package/test/s37-projection-dedupe.spec.ts +0 -161
- package/test/s38-config-jiti.spec.ts +0 -116
- package/test/s39-otlp-span-semantics.spec.ts +0 -100
- package/test/s4.spec.ts +0 -169
- package/test/s40-metrics-cumulative.spec.ts +0 -67
- package/test/s41-mcp-serve-turn-span.spec.ts +0 -148
- package/test/s42-traceparent-propagation.spec.ts +0 -415
- package/test/s43-remote-http-traceparent.spec.ts +0 -262
- package/test/s5.spec.ts +0 -95
- package/test/s6.spec.ts +0 -169
- package/test/s7.spec.ts +0 -136
- package/test/s7b-baseline.spec.ts +0 -20
- package/test/s9.spec.ts +0 -89
- package/test/sandbox-escape/j17-orchestrate.sh +0 -30
- package/test/sandbox-escape/run-matrix.sh +0 -98
- package/tsconfig.json +0 -18
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 1.2 · 会话索引后续项(S28):--session id 经索引定位(W3-1)+ 运行时异步索引更新(W3-2)
|
|
3
|
-
*
|
|
4
|
-
* 覆盖(1.2-迭代规划 W3-1/W3-2;M7 §4/§5 登记后续出清):
|
|
5
|
-
* - lookupSession:命中全字段还原 / 未收录 null / 索引不存在 / 索引文件无效显式报错
|
|
6
|
-
* - CLI 三入口 --session 全链:verify / replay / export 经索引定位;互斥、缺 db、未收录显式报错
|
|
7
|
-
* - IndexUpdater:append → flush → 索引可见(自举建库建表);快照覆盖;close flush;失败非致命
|
|
8
|
-
* - 生产调用点:car run 真进程 append → sessions-index.db 自动可见(「更新半边」闭环)
|
|
9
|
-
*/
|
|
10
|
-
import { test } from 'node:test'
|
|
11
|
-
import assert from 'node:assert/strict'
|
|
12
|
-
import { spawn } from 'node:child_process'
|
|
13
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
-
import { tmpdir } from 'node:os'
|
|
15
|
-
import { join, dirname } from 'node:path'
|
|
16
|
-
import { fileURLToPath } from 'node:url'
|
|
17
|
-
import { SessionLog } from '../src/session/log.ts'
|
|
18
|
-
import { SessionFileStore } from '../src/session/store.ts'
|
|
19
|
-
import { rebuildIndex, lookupSession, IndexUpdater } from '../src/session/indexStore.ts'
|
|
20
|
-
|
|
21
|
-
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
|
|
22
|
-
|
|
23
|
-
function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
|
|
24
|
-
const dir = mkdtempSync(join(tmpdir(), `car-s28-${name}-`))
|
|
25
|
-
try {
|
|
26
|
-
const r = fn(dir)
|
|
27
|
-
const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
|
|
28
|
-
if (r instanceof Promise) return r.finally(cleanup)
|
|
29
|
-
cleanup()
|
|
30
|
-
return Promise.resolve()
|
|
31
|
-
} catch (e) {
|
|
32
|
-
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
|
|
33
|
-
throw e
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** 写一个链完整会话文件(car run 同构:log + attachSink(fs store)),返回 sessionId */
|
|
38
|
-
function makeSession(dir: string, file: string, sessionId: string, turns = 2): string {
|
|
39
|
-
const log = new SessionLog(sessionId)
|
|
40
|
-
const store = new SessionFileStore(join(dir, file))
|
|
41
|
-
log.attachSink(line => store.append(line))
|
|
42
|
-
for (let i = 0; i < turns; i++) {
|
|
43
|
-
log.append('user', 'user', `T${i}`, `question ${i}`)
|
|
44
|
-
log.append('model', 'assistant', `T${i}`, `answer ${i}`)
|
|
45
|
-
}
|
|
46
|
-
store.close()
|
|
47
|
-
return sessionId
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function car(args: string[], opts: { cwd?: string } = {}): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
51
|
-
return new Promise((resolve, reject) => {
|
|
52
|
-
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, ...args], { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
53
|
-
let out = ''
|
|
54
|
-
let err = ''
|
|
55
|
-
child.stdout.on('data', d => { out += d })
|
|
56
|
-
child.stderr.on('data', d => { err += d })
|
|
57
|
-
child.on('error', reject)
|
|
58
|
-
child.on('exit', code => resolve({ code: code ?? -1, stdout: out, stderr: err }))
|
|
59
|
-
})
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ==================== lookupSession(W3-1) ====================
|
|
63
|
-
|
|
64
|
-
test('S28: lookupSession——命中全字段还原 / 未收录 null / 索引不存在 / 索引文件无效显式报错', async () => {
|
|
65
|
-
await withTempDir('lookup', async dir => {
|
|
66
|
-
makeSession(dir, 'session-S-lookup-a.jsonl', 'S-lookup-a')
|
|
67
|
-
makeSession(dir, 'session-S-lookup-b.jsonl', 'S-lookup-b')
|
|
68
|
-
const dbPath = join(dir, 'sessions-index.db')
|
|
69
|
-
const r = await rebuildIndex(dir, dbPath)
|
|
70
|
-
assert.equal(r.errors.length, 0)
|
|
71
|
-
|
|
72
|
-
const hit = await lookupSession(dbPath, 'S-lookup-a')
|
|
73
|
-
assert.ok(hit)
|
|
74
|
-
assert.equal(hit.sessionId, 'S-lookup-a')
|
|
75
|
-
assert.equal(hit.file, join(dir, 'session-S-lookup-a.jsonl'))
|
|
76
|
-
assert.equal(hit.eventCount, 4)
|
|
77
|
-
assert.equal(hit.tornTail, false)
|
|
78
|
-
assert.equal(hit.encoding, 'plain')
|
|
79
|
-
|
|
80
|
-
assert.equal(await lookupSession(dbPath, 'S-missing'), null, '未收录 = null(可索引 = 可验证)')
|
|
81
|
-
|
|
82
|
-
await assert.rejects(
|
|
83
|
-
() => lookupSession(join(dir, 'nope.db'), 'S-lookup-a'),
|
|
84
|
-
/CAR-E-INDEX: 会话索引不存在/,
|
|
85
|
-
'db 缺文件显式报错(lookup 不产生建文件副作用)',
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
const bad = join(dir, 'bad.db')
|
|
89
|
-
writeFileSync(bad, 'this is not a sqlite database')
|
|
90
|
-
await assert.rejects(() => lookupSession(bad, 'S-lookup-a'), /CAR-E-INDEX: 索引文件无效/)
|
|
91
|
-
})
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
test('S28: CLI --session 三入口——verify/replay/export 经索引定位全链(真进程)', async () => {
|
|
95
|
-
await withTempDir('cli-session-flag', async dir => {
|
|
96
|
-
const sid = makeSession(dir, 'session-S-cli-s1.jsonl', 'S-cli-s1')
|
|
97
|
-
await rebuildIndex(dir, join(dir, 'sessions-index.db'))
|
|
98
|
-
|
|
99
|
-
const v = await car(['session', 'verify', '--session', sid, '--db', 'sessions-index.db'], { cwd: dir })
|
|
100
|
-
assert.equal(v.code, 0, v.stderr)
|
|
101
|
-
assert.match(v.stdout, /哈希链完整:4 事件/)
|
|
102
|
-
|
|
103
|
-
const rp = await car(['session', 'replay', '--session', sid, '--db', 'sessions-index.db'], { cwd: dir })
|
|
104
|
-
assert.equal(rp.code, 0, rp.stderr)
|
|
105
|
-
assert.match(rp.stdout, /"role":"user"/)
|
|
106
|
-
assert.match(rp.stdout, /answer 1/)
|
|
107
|
-
|
|
108
|
-
const ex = await car(['session', 'export', '--session', sid, '--out', 'out-bundle', '--db', 'sessions-index.db'], { cwd: dir })
|
|
109
|
-
assert.equal(ex.code, 0, ex.stderr)
|
|
110
|
-
assert.match(ex.stdout, /取证包导出:4 事件/)
|
|
111
|
-
assert.ok(existsSync(join(dir, 'out-bundle', 'manifest.json')))
|
|
112
|
-
})
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
test('S28: CLI --session 显式拒绝面——与位置路径互斥 / 缺 db / 未收录 / 坏格式会话不入索引(真进程)', async () => {
|
|
116
|
-
await withTempDir('cli-session-flag-reject', async dir => {
|
|
117
|
-
const sid = makeSession(dir, 'session-S-cli-r1.jsonl', 'S-cli-r1')
|
|
118
|
-
// 与位置路径互斥
|
|
119
|
-
const both = await car(['session', 'verify', 's1.jsonl', '--session', sid, '--db', 'sessions-index.db'], { cwd: dir })
|
|
120
|
-
assert.equal(both.code, 2)
|
|
121
|
-
assert.match(both.stderr, /互斥/)
|
|
122
|
-
// 未建索引 + cwd 无 sessions-index.db → 显式报错提示 rebuild
|
|
123
|
-
const nodb = await car(['session', 'verify', '--session', sid], { cwd: dir })
|
|
124
|
-
assert.equal(nodb.code, 2)
|
|
125
|
-
assert.match(nodb.stderr, /CAR-E-INDEX: 会话索引不存在/)
|
|
126
|
-
// 建索引后查不存在的 id → 未收录显式报错
|
|
127
|
-
await rebuildIndex(dir, join(dir, 'sessions-index.db'))
|
|
128
|
-
const miss = await car(['session', 'verify', '--session', 'S-absent', '--db', 'sessions-index.db'], { cwd: dir })
|
|
129
|
-
assert.equal(miss.code, 2)
|
|
130
|
-
assert.match(miss.stderr, /未收录索引/)
|
|
131
|
-
// 坏格式会话(完整非法行)不入索引(「可索引 = 可验证」)→ --session 定位失败
|
|
132
|
-
const bf = join(dir, 'session-S-broken.jsonl')
|
|
133
|
-
makeSession(dir, 'session-S-broken.jsonl', 'S-broken')
|
|
134
|
-
writeFileSync(bf, readFileSync(bf, 'utf-8') + 'not-a-json-line\n')
|
|
135
|
-
const rb = await rebuildIndex(dir, join(dir, 'broken.db'))
|
|
136
|
-
assert.equal(rb.rows.some(r => r.sessionId === 'S-broken'), false, '坏格式文件不入索引(errors 通道)')
|
|
137
|
-
assert.equal(rb.errors.length, 1)
|
|
138
|
-
const broken = await car(['session', 'verify', '--session', 'S-broken', '--db', 'broken.db'], { cwd: dir })
|
|
139
|
-
assert.equal(broken.code, 2)
|
|
140
|
-
assert.match(broken.stderr, /未收录索引/)
|
|
141
|
-
})
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
// ==================== IndexUpdater(W3-2) ====================
|
|
145
|
-
|
|
146
|
-
test('S28: IndexUpdater——append 后 flush 索引可见(自举建库建表)/ 快照覆盖 / close flush(真 sqlite)', async () => {
|
|
147
|
-
await withTempDir('updater', async dir => {
|
|
148
|
-
const dbPath = join(dir, 'sessions-index.db')
|
|
149
|
-
const log = new SessionLog('S-updater-1')
|
|
150
|
-
const store = new SessionFileStore(join(dir, 'session-S-updater-1.jsonl'))
|
|
151
|
-
const file = join(dir, 'session-S-updater-1.jsonl')
|
|
152
|
-
const updater = new IndexUpdater({ dbPath, debounceMs: 30 })
|
|
153
|
-
// 单 sink 语义(SessionLog.attachSink 覆盖式)——更新器经链式包装接入,不改日志层;
|
|
154
|
-
// sink 先于事件入内存(fail-fast),故 record 按行增量(seq/hash 自行数据)
|
|
155
|
-
log.attachSink(line => { store.append(line); updater.record('S-updater-1', line, file) })
|
|
156
|
-
|
|
157
|
-
log.append('user', 'user', 'T0', 'q0')
|
|
158
|
-
await updater.flush()
|
|
159
|
-
assert.equal(updater.stats.updates, 1)
|
|
160
|
-
let row = await lookupSession(dbPath, 'S-updater-1')
|
|
161
|
-
assert.ok(row, 'flush 后索引可见(无 rebuild 自举)')
|
|
162
|
-
assert.equal(row!.eventCount, 1)
|
|
163
|
-
|
|
164
|
-
// debounce 窗口内多次 record 合并为一次 upsert,行快照 = 最新 append 时点全量
|
|
165
|
-
log.append('model', 'assistant', 'T0', 'a0')
|
|
166
|
-
log.append('user', 'user', 'T1', 'q1')
|
|
167
|
-
await updater.flush()
|
|
168
|
-
row = await lookupSession(dbPath, 'S-updater-1')
|
|
169
|
-
assert.equal(row!.eventCount, 3)
|
|
170
|
-
|
|
171
|
-
// close flush(car run 关句柄前收口)
|
|
172
|
-
log.append('user', 'user', 'T2', 'q2')
|
|
173
|
-
await updater.close()
|
|
174
|
-
assert.equal(updater.stats.updates, 3)
|
|
175
|
-
row = await lookupSession(dbPath, 'S-updater-1')
|
|
176
|
-
assert.equal(row!.eventCount, 4)
|
|
177
|
-
assert.equal(row!.tornTail, false, '活会话按构造完整(tornTail=false 口径)')
|
|
178
|
-
assert.equal(row!.encoding, 'plain')
|
|
179
|
-
store.close()
|
|
180
|
-
})
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
test('S28: IndexUpdater 失败非致命——db 路径不可用持续留痕不抛出(append 路径零影响)', async () => {
|
|
184
|
-
await withTempDir('updater-fail', async dir => {
|
|
185
|
-
// dbPath 指向目录 → open 必败
|
|
186
|
-
const badPath = join(dir, 'a-directory')
|
|
187
|
-
mkdirSync(badPath)
|
|
188
|
-
const errors: string[] = []
|
|
189
|
-
const updater = new IndexUpdater({ dbPath: badPath, onError: e => errors.push(e.message) })
|
|
190
|
-
const log = new SessionLog('S-fail-1')
|
|
191
|
-
const store = new SessionFileStore(join(dir, 'x.jsonl'))
|
|
192
|
-
log.attachSink(line => { store.append(line); updater.record('S-fail-1', line, join(dir, 'x.jsonl')) })
|
|
193
|
-
|
|
194
|
-
// 事件照常落盘(updater 错误绝不传播到 append 路径——R-2 钉死)
|
|
195
|
-
log.append('user', 'user', 'T0', 'q')
|
|
196
|
-
await updater.flush()
|
|
197
|
-
assert.equal(updater.stats.failures, 1)
|
|
198
|
-
assert.ok(errors[0], '错误经 onError 留痕(不向调用方抛出)')
|
|
199
|
-
assert.match(errors[0]!, /CAR-E-INDEX|CAR-E-SQLITE/)
|
|
200
|
-
|
|
201
|
-
// 继续使用不抛出
|
|
202
|
-
log.append('user', 'user', 'T1', 'q')
|
|
203
|
-
await updater.close()
|
|
204
|
-
assert.equal(updater.stats.failures, 2)
|
|
205
|
-
// 落盘本体未受影响:文件行数 = 事件数
|
|
206
|
-
assert.equal(readFileSync(join(dir, 'x.jsonl'), 'utf-8').trim().split('\n').length, 2)
|
|
207
|
-
store.close()
|
|
208
|
-
})
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
test('S28: 生产调用点——car run 真进程 append → cwd sessions-index.db 自动可见(「更新半边」闭环)', async () => {
|
|
212
|
-
await withTempDir('car-run-index', async dir => {
|
|
213
|
-
// 工具名避开 demo_tool:CLI 演示脚手架硬编码注册 demo_tool,同名插件工具触发预存在的 provide
|
|
214
|
-
// 撞名边缘(登记不修——W1 重做 car run 装配面时出清)
|
|
215
|
-
writeFileSync(join(dir, 'p.ts'), `export const manifest = { name: 'p', version: '1.0.0' }\nexport default function apply(api) {\n api.registerTool({ name: 't', run: async () => 'ok' })\n}\n`)
|
|
216
|
-
const r = await car(['run', 'p.ts', '--demo'], { cwd: dir }) // 1.4 D-13:stub 流挂 --demo
|
|
217
|
-
assert.equal(r.code, 0, r.stderr)
|
|
218
|
-
const dbPath = join(dir, 'sessions-index.db')
|
|
219
|
-
assert.ok(existsSync(dbPath), 'car run 运行后 cwd 索引自举在位')
|
|
220
|
-
// 经 sqlite 直查(run 会话 id 为 S-<time36> 运行期生成,全表核对)
|
|
221
|
-
const mod = await import('node:sqlite')
|
|
222
|
-
const DatabaseSync = (mod as unknown as { DatabaseSync: new (path: string) => { prepare(s: string): { all(...v: unknown[]): Record<string, unknown>[] }; close(): void } }).DatabaseSync
|
|
223
|
-
const db = new DatabaseSync(dbPath)
|
|
224
|
-
try {
|
|
225
|
-
const all = db.prepare('SELECT sessionId, eventCount, file FROM sessions').all()
|
|
226
|
-
assert.ok(all.length >= 1, 'run 会话入索引')
|
|
227
|
-
const hit = all.find(x => String(x.sessionId).startsWith('S-'))
|
|
228
|
-
assert.ok(hit, 'run 会话 id 在索引')
|
|
229
|
-
// realpath 比较(1.2-BUG-4):macOS 子进程 cwd 为物理路径 /private/var…,父进程持逻辑路径 /var…
|
|
230
|
-
assert.equal(realpathSync(String(hit!.file)), realpathSync(join(dir, `session-${hit!.sessionId}.jsonl`)))
|
|
231
|
-
assert.ok(Number(hit!.eventCount) >= 3, '事件计数 = run 全程 append 量(装配+对话+收口)')
|
|
232
|
-
} finally {
|
|
233
|
-
db.close()
|
|
234
|
-
}
|
|
235
|
-
})
|
|
236
|
-
})
|
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 1.2 · OTLP exporter 生产级策略(S29):有界重试 / 采样 / 批上限 / env 通道 / mcp-serve meter 桥
|
|
3
|
-
*
|
|
4
|
-
* 覆盖(1.2-迭代规划 W3-3;M8 §4.4 登记后续出清;D-12b 口径修订 0 重试 → 有界 ≤2):
|
|
5
|
-
* - 重试:429/5xx/网络错误退避重试(序列 1s/2s 可注入)→ 成功计数;耗尽 droppedExports 静默丢弃;
|
|
6
|
-
* 4xx 业务错不重试;res.ok 语义(旧实现 5xx 也计成功——本代修正为显式状态判定)
|
|
7
|
-
* - 采样:always_on 缺省行为不变 / always_off 全采出 / ratio 概率边界;spansSampledOut 计数
|
|
8
|
-
* - 批上限:maxBatchSize 分批(tracesExported 按请求计)/ maxQueueSize 溢出丢最旧 queueOverflows
|
|
9
|
-
* - env 通道:telemetryConfigFromEnv(endpoint 唯一开关 / sampling 三形态 / 非法值保持缺省)
|
|
10
|
-
* - 生产调用点:mcp-serve 真进程 CAR_OTEL_ENDPOINT → 本地 http 捕获端点断言 OTLP metrics 出站
|
|
11
|
-
* (S17 counters 双写桥;快照面不变);零依赖静态断言保持(telemetry 零 session import)
|
|
12
|
-
*/
|
|
13
|
-
import { test } from 'node:test'
|
|
14
|
-
import assert from 'node:assert/strict'
|
|
15
|
-
import { spawn } from 'node:child_process'
|
|
16
|
-
import { createServer, type Server } from 'node:http'
|
|
17
|
-
import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
|
|
18
|
-
import { tmpdir } from 'node:os'
|
|
19
|
-
import { join, dirname } from 'node:path'
|
|
20
|
-
import { fileURLToPath } from 'node:url'
|
|
21
|
-
import { createTelemetryFacade, telemetryConfigFromEnv, type TelemetryConfig } from '../src/runtime-core/telemetry.ts'
|
|
22
|
-
|
|
23
|
-
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
|
|
24
|
-
|
|
25
|
-
const sleepsOf = (log: number[]) => async (ms: number) => { log.push(ms) }
|
|
26
|
-
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
27
|
-
const statusRes = (status: number) => ({ ok: false, status }) as unknown as Response
|
|
28
|
-
|
|
29
|
-
function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
|
|
30
|
-
const dir = mkdtempSync(join(tmpdir(), `car-s29-${name}-`))
|
|
31
|
-
try {
|
|
32
|
-
const r = fn(dir)
|
|
33
|
-
const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
|
|
34
|
-
if (r instanceof Promise) return r.finally(cleanup)
|
|
35
|
-
cleanup()
|
|
36
|
-
return Promise.resolve()
|
|
37
|
-
} catch (e) {
|
|
38
|
-
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
|
|
39
|
-
throw e
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ==================== env 通道 ====================
|
|
44
|
-
|
|
45
|
-
test('S29: telemetryConfigFromEnv——endpoint 唯一开关 / sampling 三形态 / 非法值保持缺省 / interval 解析', () => {
|
|
46
|
-
assert.equal(telemetryConfigFromEnv({}), null, '无 endpoint = null(默认关)')
|
|
47
|
-
const c1 = telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://otel.internal' })!
|
|
48
|
-
assert.equal(c1.endpoint, 'https://otel.internal')
|
|
49
|
-
assert.equal(c1.sampling, undefined, '缺省采样不写入(= always_on 行为)')
|
|
50
|
-
assert.equal(telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_SAMPLING: 'always_off' })!.sampling, 'always_off')
|
|
51
|
-
assert.deepEqual(telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_SAMPLING: '0.25' })!.sampling, { ratio: 0.25 })
|
|
52
|
-
assert.equal(telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_SAMPLING: 'bogus' })!.sampling, undefined, '非法值保持缺省(禁 fail-hard)')
|
|
53
|
-
assert.equal(telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_SAMPLING: '1.5' })!.sampling, undefined, '超界 ratio = 非法')
|
|
54
|
-
const c2 = telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_INTERVAL_MS: '5000', CAR_OTEL_SERVICE_NAME: 'svc' })!
|
|
55
|
-
assert.equal(c2.intervalMs, 5000)
|
|
56
|
-
assert.equal(c2.serviceName, 'svc')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
// ==================== 有界重试(D-12b 口径) ====================
|
|
60
|
-
|
|
61
|
-
test('S29: 重试——5xx 退避后成功计数;耗尽 droppedExports 静默丢弃;4xx 不重试直接丢', async () => {
|
|
62
|
-
await withTempDir('retry', async () => {
|
|
63
|
-
const sleeps: number[] = []
|
|
64
|
-
// 500 → 500 → 200:两次退避后成功
|
|
65
|
-
let calls = 0
|
|
66
|
-
const f1 = createTelemetryFacade({ endpoint: 'https://otel', retryBackoffMs: [10, 20], sleep: sleepsOf(sleeps) }, {
|
|
67
|
-
fetchImpl: (async () => (calls++ === 2 ? okRes() : statusRes(500))) as typeof fetch,
|
|
68
|
-
})
|
|
69
|
-
f1.getTracer().startSpan('s').end()
|
|
70
|
-
await f1.flush()
|
|
71
|
-
assert.deepEqual(sleeps, [10, 20], '退避序列可注入且按序生效')
|
|
72
|
-
assert.deepEqual(f1.stats(), { spansEnded: 1, spansSampledOut: 0, tracesExported: 1, metricsExported: 0, droppedExports: 0, queueOverflows: 0 })
|
|
73
|
-
|
|
74
|
-
// 恒 429:耗尽 2 次重试(3 次尝试)→ 静默丢弃,flush 不抛错
|
|
75
|
-
let tries = 0
|
|
76
|
-
const f2 = createTelemetryFacade({ endpoint: 'https://otel', retryLimit: 2, retryBackoffMs: [1, 1], sleep: sleepsOf([]) }, {
|
|
77
|
-
fetchImpl: (async () => (tries++, statusRes(429))) as typeof fetch,
|
|
78
|
-
})
|
|
79
|
-
f2.getTracer().startSpan('s').end()
|
|
80
|
-
await f2.flush()
|
|
81
|
-
assert.equal(tries, 3, '1 原始 + 2 重试')
|
|
82
|
-
assert.equal(f2.stats().droppedExports, 1)
|
|
83
|
-
assert.equal(f2.stats().tracesExported, 0)
|
|
84
|
-
|
|
85
|
-
// 400 业务错:不重试,立即丢弃
|
|
86
|
-
let tries4xx = 0
|
|
87
|
-
const f3 = createTelemetryFacade({ endpoint: 'https://otel', sleep: sleepsOf([]) }, {
|
|
88
|
-
fetchImpl: (async () => (tries4xx++, statusRes(400))) as typeof fetch,
|
|
89
|
-
})
|
|
90
|
-
f3.getTracer().startSpan('s').end()
|
|
91
|
-
await f3.flush()
|
|
92
|
-
assert.equal(tries4xx, 1, '4xx 不重试')
|
|
93
|
-
assert.equal(f3.stats().droppedExports, 1)
|
|
94
|
-
|
|
95
|
-
// 网络错误(fetch throw):可重试,耗尽后静默
|
|
96
|
-
let netTries = 0
|
|
97
|
-
const f4 = createTelemetryFacade({ endpoint: 'https://otel', retryLimit: 1, retryBackoffMs: [1], sleep: sleepsOf([]) }, {
|
|
98
|
-
fetchImpl: (async () => (netTries++, Promise.reject(new Error('ECONNREFUSED')))) as unknown as typeof fetch,
|
|
99
|
-
})
|
|
100
|
-
f4.getTracer().startSpan('s').end()
|
|
101
|
-
await f4.flush()
|
|
102
|
-
assert.equal(netTries, 2)
|
|
103
|
-
assert.equal(f4.stats().droppedExports, 1)
|
|
104
|
-
})
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('S29: res.ok 显式语义——5xx 不再被计为成功(旧实现口径修正的回归钉)', async () => {
|
|
108
|
-
await withTempDir('ok-semantics', async () => {
|
|
109
|
-
const f = createTelemetryFacade({ endpoint: 'https://otel', retryLimit: 0, sleep: sleepsOf([]) }, {
|
|
110
|
-
fetchImpl: (async () => statusRes(503)) as typeof fetch,
|
|
111
|
-
})
|
|
112
|
-
f.getTracer().startSpan('s').end()
|
|
113
|
-
await f.flush()
|
|
114
|
-
assert.equal(f.stats().tracesExported, 0, '非 2xx 不计成功')
|
|
115
|
-
assert.equal(f.stats().droppedExports, 1)
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
// ==================== 采样 ====================
|
|
120
|
-
|
|
121
|
-
test('S29: 采样——always_on 缺省全量(1.1 行为不变)/ always_off 全采出 / ratio=0 与 1 边界', async () => {
|
|
122
|
-
await withTempDir('sampling', async () => {
|
|
123
|
-
const posts: unknown[] = []
|
|
124
|
-
const mk = (cfg: TelemetryConfig) => createTelemetryFacade({ endpoint: 'https://otel', ...cfg }, {
|
|
125
|
-
fetchImpl: (async (_url: string | URL, init?: RequestInit) => { posts.push(init?.body); return okRes() }) as typeof fetch,
|
|
126
|
-
})
|
|
127
|
-
// always_on(缺省不写 sampling):全量出站
|
|
128
|
-
const f1 = mk({})
|
|
129
|
-
for (let i = 0; i < 3; i++) f1.getTracer().startSpan(`s${i}`).end()
|
|
130
|
-
await f1.flush()
|
|
131
|
-
assert.equal(f1.stats().spansSampledOut, 0)
|
|
132
|
-
assert.equal(f1.stats().tracesExported, 1)
|
|
133
|
-
// always_off:全采出,零出站
|
|
134
|
-
const f2 = mk({ sampling: 'always_off' })
|
|
135
|
-
for (let i = 0; i < 3; i++) f2.getTracer().startSpan(`s${i}`).end()
|
|
136
|
-
await f2.flush()
|
|
137
|
-
assert.equal(f2.stats().spansSampledOut, 3)
|
|
138
|
-
assert.equal(f2.stats().tracesExported, 0)
|
|
139
|
-
// ratio 0 / 1 边界
|
|
140
|
-
const f3 = mk({ sampling: { ratio: 0 } })
|
|
141
|
-
f3.getTracer().startSpan('s').end()
|
|
142
|
-
assert.equal(f3.stats().spansSampledOut, 1)
|
|
143
|
-
const f4 = mk({ sampling: { ratio: 1 } })
|
|
144
|
-
f4.getTracer().startSpan('s').end()
|
|
145
|
-
assert.equal(f4.stats().spansSampledOut, 0)
|
|
146
|
-
})
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
// ==================== 批上限 ====================
|
|
150
|
-
|
|
151
|
-
test('S29: 批上限——maxBatchSize 分批(tracesExported 按请求计)/ maxQueueSize 溢出丢最旧', async () => {
|
|
152
|
-
await withTempDir('batch', async () => {
|
|
153
|
-
const bodies: any[] = []
|
|
154
|
-
const f = createTelemetryFacade({ endpoint: 'https://otel', maxBatchSize: 2, maxQueueSize: 5 }, {
|
|
155
|
-
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
156
|
-
})
|
|
157
|
-
for (let i = 0; i < 5; i++) f.getTracer().startSpan(`s${i}`).end()
|
|
158
|
-
await f.flush()
|
|
159
|
-
assert.equal(bodies.length, 3, '5 spans / batch=2 → 3 请求')
|
|
160
|
-
assert.deepEqual(bodies.map(b => b.scopeSpans[0].spans.length), [2, 2, 1])
|
|
161
|
-
assert.equal(f.stats().tracesExported, 3, 'tracesExported 按成功出站请求计数')
|
|
162
|
-
|
|
163
|
-
// 溢出丢最旧:queue=3,end 5 个 → 丢最旧 2 个,flush 导出最新 3 个
|
|
164
|
-
const f2 = createTelemetryFacade({ endpoint: 'https://otel', maxQueueSize: 3 }, {
|
|
165
|
-
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
166
|
-
})
|
|
167
|
-
for (let i = 0; i < 5; i++) f2.getTracer().startSpan(`s${i}`).end()
|
|
168
|
-
assert.equal(f2.stats().queueOverflows, 2)
|
|
169
|
-
await f2.flush()
|
|
170
|
-
const names = bodies[bodies.length - 1]!.scopeSpans[0].spans.map((s: { name: string }) => s.name)
|
|
171
|
-
assert.deepEqual(names, ['s2', 's3', 's4'], '溢出丢最旧(保留最新)')
|
|
172
|
-
})
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
// ==================== 默认关(noop)不变 ====================
|
|
176
|
-
|
|
177
|
-
test('S29: 默认关三原则——无 endpoint = noop 零出站(fetch 不被调用)+ stats 全零', async () => {
|
|
178
|
-
await withTempDir('default-off', async () => {
|
|
179
|
-
let called = 0
|
|
180
|
-
const f = createTelemetryFacade(null, { fetchImpl: (async () => (called++, okRes())) as typeof fetch })
|
|
181
|
-
assert.equal(f.enabled, false)
|
|
182
|
-
f.getTracer().startSpan('s').end()
|
|
183
|
-
f.getMeter().createCounter('c').add(1)
|
|
184
|
-
await f.flush()
|
|
185
|
-
await f.shutdown()
|
|
186
|
-
assert.equal(called, 0, '零出站')
|
|
187
|
-
assert.deepEqual(f.stats(), { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 })
|
|
188
|
-
})
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
// ==================== 零依赖解耦红线(静态断言保持) ====================
|
|
192
|
-
|
|
193
|
-
test('S29: 严格解耦——telemetry.ts 零 import 自 session/*(静态断言)', () => {
|
|
194
|
-
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'runtime-core', 'telemetry.ts'), 'utf-8')
|
|
195
|
-
assert.equal(/from\s+'\.\.\/session\//.test(src), false)
|
|
196
|
-
assert.equal(/from\s+'\.\/\.\.\/session\//.test(src), false)
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
// ==================== 生产调用点:mcp-serve meter 桥(真进程 + 本地捕获端点) ====================
|
|
200
|
-
|
|
201
|
-
test('S29: mcp-serve 生产接线——CAR_OTEL_ENDPOINT 显式开 → S17 counters 双写出站 OTLP metrics(快照面不变)', async () => {
|
|
202
|
-
await withTempDir('mcp-otel', async dir => {
|
|
203
|
-
const bodies: any[] = []
|
|
204
|
-
let done: (v: void) => void = () => {}
|
|
205
|
-
const closed = new Promise<void>(r => { done = r })
|
|
206
|
-
const server: Server = createServer((req, res) => {
|
|
207
|
-
let body = ''
|
|
208
|
-
req.on('data', c => { body += c })
|
|
209
|
-
req.on('end', () => {
|
|
210
|
-
if (req.url === '/v1/metrics') bodies.push(JSON.parse(body))
|
|
211
|
-
res.writeHead(200).end()
|
|
212
|
-
})
|
|
213
|
-
})
|
|
214
|
-
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
215
|
-
const port = (server.address() as { port: number }).port
|
|
216
|
-
try {
|
|
217
|
-
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve'], {
|
|
218
|
-
cwd: dir,
|
|
219
|
-
env: { ...process.env, CAR_OTEL_ENDPOINT: `http://127.0.0.1:${port}`, CAR_OTEL_INTERVAL_MS: '0' },
|
|
220
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
221
|
-
})
|
|
222
|
-
let err = ''
|
|
223
|
-
child.stderr.on('data', d => { err += d })
|
|
224
|
-
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's29' } } }) + '\n')
|
|
225
|
-
child.stdin.end()
|
|
226
|
-
child.on('exit', () => done())
|
|
227
|
-
// macOS runner 慢启动容差(60s);落选的 race timer 必须 clear——未清时它把事件循环
|
|
228
|
-
// 挂满整个超时窗(本文件曾因此空转 20s,慢 runner 上叠加成 j03 假失败形态)
|
|
229
|
-
let raceTimer: ReturnType<typeof setTimeout> | undefined
|
|
230
|
-
try {
|
|
231
|
-
await Promise.race([
|
|
232
|
-
closed,
|
|
233
|
-
new Promise<never>((_, rej) => { raceTimer = setTimeout(() => rej(new Error(`mcp-serve 超时\n${err}`)), 60_000) }),
|
|
234
|
-
])
|
|
235
|
-
} finally {
|
|
236
|
-
if (raceTimer) clearTimeout(raceTimer)
|
|
237
|
-
}
|
|
238
|
-
// S17 快照面不变(双写非迁移)
|
|
239
|
-
assert.match(err, /snapshot=/)
|
|
240
|
-
assert.match(err, /zeroContent=true/)
|
|
241
|
-
assert.match(err, /otel=enabled/)
|
|
242
|
-
// OTLP metrics 出站:counter 名 + 零内容 labels 到达用户端点
|
|
243
|
-
assert.ok(bodies.length >= 1, 'metrics POST 到达捕获端点')
|
|
244
|
-
const names = bodies.flatMap(b => b.scopeMetrics[0].metrics.map((m: { name: string }) => m.name))
|
|
245
|
-
assert.ok(names.includes('car_registry_decision'), 'S17 counter 名出站')
|
|
246
|
-
assert.equal(/sk-|ghp_/.test(JSON.stringify(bodies)), false, '出站体无凭据形态(零内容口径)')
|
|
247
|
-
} finally {
|
|
248
|
-
server.closeAllConnections()
|
|
249
|
-
server.close()
|
|
250
|
-
}
|
|
251
|
-
})
|
|
252
|
-
})
|
package/test/s3.spec.ts
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import { test } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { probeCapabilities, SandboxExecutor } from '../src/sandbox/sandbox.ts'
|
|
4
|
-
import { AuthzService, checkCapability } from '../src/authz/authz.ts'
|
|
5
|
-
import { McpGateway, containsPlaintextCredential, type McpTransport, type JsonRpcRequest, type JsonRpcResponse } from '../src/mcp/gateway.ts'
|
|
6
|
-
import { SessionLog } from '../src/session/log.ts'
|
|
7
|
-
|
|
8
|
-
// ==================== F3 沙箱:探测/降级/Q-04 约束 ====================
|
|
9
|
-
|
|
10
|
-
test('F3: Windows 平台探测 → 显式降级(BD-01,经中间确认② Linux 先行)', async () => {
|
|
11
|
-
const probe = await probeCapabilities({ platform: 'win32' })
|
|
12
|
-
assert.equal(probe.degraded, true)
|
|
13
|
-
assert.ok(probe.reason)
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
test('F3: Linux 无 landlock-run 二进制 → 探测失败降级(容器环境同路径)', async () => {
|
|
17
|
-
const probe = await probeCapabilities({ platform: 'linux' })
|
|
18
|
-
assert.equal(probe.degraded, true)
|
|
19
|
-
assert.match(probe.reason!, /landlock-run/)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test('F3 Q-04: 降级态 env-read 一律拒绝(DENIED,不进审批)', async () => {
|
|
23
|
-
const events: any[] = []
|
|
24
|
-
const exec = new SandboxExecutor({
|
|
25
|
-
probe: { platform: 'win32', landlock: false, seccomp: false, degraded: true, reason: 'test' },
|
|
26
|
-
audit: (e) => events.push(e),
|
|
27
|
-
workspace: process.cwd(),
|
|
28
|
-
})
|
|
29
|
-
const r = await exec.exec({ argv: ['node', '-e', '1'], capabilities: ['env-read'], mode: 'workspace-write', permissionMode: 'full' })
|
|
30
|
-
assert.equal(r.ok, false)
|
|
31
|
-
assert.match(r.denied!, /env-read denied in degraded/)
|
|
32
|
-
assert.ok(events.some(e => e.kind === 'sandbox-denied'))
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('F3 Q-04: 降级态写操作强制确认模式(full 亦受限;拒绝即留痕)', async () => {
|
|
36
|
-
const events: any[] = []
|
|
37
|
-
const exec = new SandboxExecutor({
|
|
38
|
-
probe: { platform: 'win32', landlock: false, seccomp: false, degraded: true, reason: 'test' },
|
|
39
|
-
audit: (e) => events.push(e),
|
|
40
|
-
workspace: process.cwd(),
|
|
41
|
-
})
|
|
42
|
-
const r = await exec.exec({
|
|
43
|
-
argv: ['node', '-e', 'console.log(1)'], capabilities: ['exec'], mode: 'workspace-write',
|
|
44
|
-
permissionMode: 'full', // full 在降级态受限为 confirm,且无审批回调 → 默认拒绝
|
|
45
|
-
})
|
|
46
|
-
assert.equal(r.ok, false)
|
|
47
|
-
assert.match(r.denied!, /authorization-denied/)
|
|
48
|
-
assert.ok(events.some(e => e.kind === 'sandbox-denied' && e.detail.degraded === true))
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('F3 Q-04: 降级态 confirm + 审批放行 → 可执行且留痕 degraded=true', async () => {
|
|
52
|
-
const events: any[] = []
|
|
53
|
-
const exec = new SandboxExecutor({
|
|
54
|
-
probe: { platform: 'win32', landlock: false, seccomp: false, degraded: true, reason: 'test' },
|
|
55
|
-
audit: (e) => events.push(e),
|
|
56
|
-
workspace: process.cwd(),
|
|
57
|
-
})
|
|
58
|
-
const r = await exec.exec({
|
|
59
|
-
argv: ['node', '-e', 'console.log("hi")'], capabilities: ['exec'], mode: 'workspace-write',
|
|
60
|
-
permissionMode: 'full', authorize: async () => true,
|
|
61
|
-
})
|
|
62
|
-
assert.equal(r.ok, true)
|
|
63
|
-
assert.equal(r.degraded, true)
|
|
64
|
-
assert.ok(events.some(e => e.kind === 'sandbox-exec' && e.detail.degraded === true))
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
// ==================== F4 授权服务 ====================
|
|
68
|
-
|
|
69
|
-
test('F4: 能力标签 deny-by-default(未声明 = 无能力,T-22)', () => {
|
|
70
|
-
assert.equal(checkCapability({}, 'fs-write'), false)
|
|
71
|
-
assert.equal(checkCapability({ capabilities: ['net'] }, 'fs-write'), false)
|
|
72
|
-
assert.equal(checkCapability({ capabilities: ['net'] }, 'net'), true)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
test('F4: 幂等——同 authorizationId 返回同一决策(T-10 防重放不一致)', async () => {
|
|
76
|
-
const log = new SessionLog()
|
|
77
|
-
const authz = new AuthzService({ log, timeoutMs: 200 })
|
|
78
|
-
const req = { authorizationId: 'A-1', actor: 'plugin-x', capability: 'exec' as const, resource: '/ws' }
|
|
79
|
-
const d1 = await authz.decideByUser(req, async () => true)
|
|
80
|
-
const d2 = await authz.decideByUser(req, async () => false) // 重放:返回缓存决策
|
|
81
|
-
assert.equal(d1.decision, 'APPROVED')
|
|
82
|
-
assert.equal(d2.decision, 'APPROVED')
|
|
83
|
-
assert.equal(d2, d1)
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
test('F4: 超时默认拒绝(timeout-default-deny,EXPIRED)', async () => {
|
|
87
|
-
const log = new SessionLog()
|
|
88
|
-
const authz = new AuthzService({ log, timeoutMs: 50 })
|
|
89
|
-
const d = await authz.decideByUser(
|
|
90
|
-
{ authorizationId: 'A-2', actor: 'p', capability: 'fs-write', resource: '/ws' },
|
|
91
|
-
() => new Promise<boolean>(() => {}), // 永不回应
|
|
92
|
-
)
|
|
93
|
-
assert.equal(d.decision, 'EXPIRED')
|
|
94
|
-
assert.equal(d.decidedBy, 'timeout')
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
test('F4: 授权决策 100% 落审计日志(US6 凭据链)', async () => {
|
|
98
|
-
const log = new SessionLog()
|
|
99
|
-
const authz = new AuthzService({ log, timeoutMs: 100 })
|
|
100
|
-
await authz.decideByPolicy({ authorizationId: 'A-3', actor: 'p', capability: 'net', resource: 'https://x' }, 'readonly')
|
|
101
|
-
const audit = log.events.find(e => e.turnId === 'authz:A-3')
|
|
102
|
-
assert.ok(audit)
|
|
103
|
-
assert.equal((audit!.meta as any)?.reason.includes('denied in readonly mode'), true)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
// ==================== F8 MCP 桥接网关 ====================
|
|
107
|
-
|
|
108
|
-
function mockTransport(script: Map<string, unknown>, failAfter?: number): McpTransport & { calls: number } {
|
|
109
|
-
let calls = 0
|
|
110
|
-
return {
|
|
111
|
-
calls: 0,
|
|
112
|
-
async send(req: JsonRpcRequest): Promise<JsonRpcResponse> {
|
|
113
|
-
calls++
|
|
114
|
-
if (failAfter !== undefined && calls > failAfter) throw new Error('EPIPE: process crashed')
|
|
115
|
-
const method = req.method
|
|
116
|
-
if (method === 'tools/list') {
|
|
117
|
-
return { jsonrpc: '2.0', id: req.id, result: { tools: [
|
|
118
|
-
{ name: 'py_search', description: 'python tool', sideEffect: 'readonly' },
|
|
119
|
-
{ name: 'py_write', description: 'python writer' }, // 未声明 → write 最高约束
|
|
120
|
-
] } }
|
|
121
|
-
}
|
|
122
|
-
if (method === 'tools/call') return { jsonrpc: '2.0', id: req.id, result: { content: 'py-result' } }
|
|
123
|
-
throw new Error('unknown method ' + method)
|
|
124
|
-
},
|
|
125
|
-
alive() { return true },
|
|
126
|
-
close() {},
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
test('F8: 登记后工具进入能力矩阵;未声明 sideEffect 按 write 最高约束(T-22)', async () => {
|
|
131
|
-
const gw = new McpGateway()
|
|
132
|
-
const tools = await gw.register({ serverId: 'py1', transport: mockTransport(new Map()) })
|
|
133
|
-
assert.deepEqual(tools.map(t => t.name).sort(), ['py_search', 'py_write'])
|
|
134
|
-
const matrix = gw.listTools()
|
|
135
|
-
assert.equal(matrix.find(t => t.name === 'py_search')!.declaredSideEffect, 'readonly')
|
|
136
|
-
assert.equal(matrix.find(t => t.name === 'py_write')!.declaredSideEffect, 'write')
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
test('F8: 未登记连接拒绝(A050001)+ 重复登记显式报错', async () => {
|
|
140
|
-
const gw = new McpGateway()
|
|
141
|
-
// 未登记 serverId 调用 = 显式拒绝(登记制)
|
|
142
|
-
await assert.rejects(() => gw.callTool('ghost', 'any_tool', {}), /A050001/)
|
|
143
|
-
const t = mockTransport(new Map())
|
|
144
|
-
await gw.register({ serverId: 'py1', transport: t })
|
|
145
|
-
await assert.rejects(() => gw.register({ serverId: 'py1', transport: t }), /already registered/)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
test('F8: 调用走网关 + 崩溃 → BD-02 标记不可用(错误结果不抛异常,主链路不崩)', async () => {
|
|
149
|
-
const gw = new McpGateway()
|
|
150
|
-
const t = mockTransport(new Map(), 2) // tools/list 占 1 次,tools/call 起崩溃 // 第 2 次调用起崩溃
|
|
151
|
-
await gw.register({ serverId: 'py1', transport: t })
|
|
152
|
-
const ok1 = await gw.callTool('py1', 'py_search', {})
|
|
153
|
-
assert.equal(ok1.ok, true)
|
|
154
|
-
const fail = await gw.callTool('py1', 'py_search', {})
|
|
155
|
-
assert.equal(fail.ok, false)
|
|
156
|
-
assert.match(fail.error!, /BD-02/)
|
|
157
|
-
// 后续调用:直接不可用(不再触碰崩溃通道),返回显式错误结果
|
|
158
|
-
const fail2 = await gw.callTool('py1', 'py_search', {})
|
|
159
|
-
assert.match(fail2.error!, /unavailable/)
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
test('F8: 明文凭据 env = 拒绝登记(§3.2.5 传参禁忌)', () => {
|
|
163
|
-
assert.match(containsPlaintextCredential({ OPENAI_KEY: 'sk-abc123' })!, /OPENAI_KEY/)
|
|
164
|
-
assert.equal(containsPlaintextCredential({ LOG_LEVEL: 'info' }), null)
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
test('F8: mcp-call 权限一致性——写类 MCP 工具在 confirm 模式触发审批(无旁路,US-6 AC3)', async () => {
|
|
168
|
-
const gw = new McpGateway()
|
|
169
|
-
const log = new SessionLog()
|
|
170
|
-
const authz = new AuthzService({ log, timeoutMs: 100 })
|
|
171
|
-
await gw.register({ serverId: 'py1', transport: mockTransport(new Map()) })
|
|
172
|
-
const def = gw.listTools().find(t => t.name === 'py_write')!
|
|
173
|
-
// 未声明 → write → readonly 模式下 policy 直接 DENIED
|
|
174
|
-
const d = authz.decideByPolicy(
|
|
175
|
-
{ authorizationId: 'A-M1', actor: 'model', capability: 'mcp', resource: `py1:${def.name}` },
|
|
176
|
-
'readonly',
|
|
177
|
-
)
|
|
178
|
-
assert.equal(d.decision, 'DENIED')
|
|
179
|
-
})
|