@whaletalk/dsh-codex-collab 0.1.9 → 0.1.10
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/CHANGELOG.md +9 -0
- package/README.md +105 -25
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +63 -3
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +443 -39
- package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
- package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
- package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
- package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
- package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
- package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
- package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
- package/harness/audit-tail.mjs +32 -0
- package/harness/codex-approvals.mjs +1078 -0
- package/harness/codex-associations.mjs +39 -7
- package/harness/dsh-bridge.mjs +1950 -191
- package/harness/model-route-host.mjs +530 -0
- package/harness/model-routing.mjs +500 -0
- package/harness/session-observer.mjs +217 -0
- package/harness/session-target.mjs +161 -6
- package/harness/task-table.mjs +6 -4
- package/mcp/dsh-mcp.mjs +474 -12
- package/package.json +2 -2
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-approvals.mjs —— 「Codex 接手 DSH 单次审批」核心:控制凭据、任务 grant、不可变执行快照、
|
|
3
|
+
* 官方请求身份认领、单次状态机、幂等、租约/总期限、撤销与最小持久审计。
|
|
4
|
+
*
|
|
5
|
+
* 认证边界(本轮修正 C):
|
|
6
|
+
* - 传输的是**私有 raw key**(`X-Dsh-Control`),服务端与本机 key 文件做等值比较;
|
|
7
|
+
* `sha256(key)` 之类的持久摘要**不再能认证**(它不是 bearer)。
|
|
8
|
+
* - grant/audit 只存**域分离的单向摘要** `credDigest`(`digest:sha256('dsh-approval-grant-digest:'+key)`),
|
|
9
|
+
* 不能用于登录;raw key 不进工具输入/结果/错误/日志/审计。
|
|
10
|
+
* - key 读入做格式校验(64 hex),不合规一律视为"无 key"(能力关闭),绝不把任意配置当 header 发出去。
|
|
11
|
+
*
|
|
12
|
+
* 绑定边界(本轮修正 A/B/D):
|
|
13
|
+
* - 快照按 `(sessionId, callId)` 存**不可变深拷贝**(含 args/cwd/root/parent/step/turn),
|
|
14
|
+
* 绝不用"当前某 callId 的快照"回看另一个请求;turn/step 必须都是实数,未知不作为可批准默认值。
|
|
15
|
+
* - pending 持有打开时刻的执行上下文副本;决定时要求**实时快照身份仍等于**该副本(参数/上下文变化即拒绝)。
|
|
16
|
+
* - 每个官方 `request()`/callback 各自一个独立桥接 pending 与一次性状态;官方 ask 身份(UUID)必须
|
|
17
|
+
* **唯一认领**(歧义即交人工),一次决定只完成一条。
|
|
18
|
+
* - 指纹绑定 session/callId/toolName/turn/step/root/parent/cwd/真实参数 + task/grant 身份。
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import os from 'node:os'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import crypto from 'node:crypto'
|
|
24
|
+
import { createFileLock } from './fs-lock.mjs'
|
|
25
|
+
|
|
26
|
+
export const APPROVAL_STATES = {
|
|
27
|
+
PENDING: 'pending',
|
|
28
|
+
SUBMITTED: 'decision-submitted',
|
|
29
|
+
CONFIRMED: 'confirmed',
|
|
30
|
+
REJECTED: 'rejected',
|
|
31
|
+
CANCELLED: 'cancelled',
|
|
32
|
+
EXPIRED: 'expired',
|
|
33
|
+
SUPERSEDED: 'superseded',
|
|
34
|
+
AMBIGUOUS: 'unsupported-ambiguous',
|
|
35
|
+
CONTEXT_CHANGED: 'context-changed',
|
|
36
|
+
DECISION_UNKNOWN: 'decision-unknown',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const GRANT_STATES = { ACTIVE: 'active', REVOKED: 'revoked', EXPIRED: 'expired' }
|
|
40
|
+
|
|
41
|
+
export const DECISIONS = { APPROVE_ONCE: 'approve-once', REJECT: 'reject' }
|
|
42
|
+
const OUTCOME_FOR = { 'approve-once': 'allowed-once', reject: 'rejected' }
|
|
43
|
+
|
|
44
|
+
export const APPROVAL_REASONS = {
|
|
45
|
+
NO_KEY: 'approval-key-missing',
|
|
46
|
+
BAD_KEY_FORMAT: 'approval-key-malformed',
|
|
47
|
+
BAD_HEADER: 'approval-control-invalid',
|
|
48
|
+
GRANT_MISSING: 'grant-missing',
|
|
49
|
+
GRANT_REVOKED: 'grant-revoked',
|
|
50
|
+
GRANT_EXPIRED: 'grant-expired',
|
|
51
|
+
GRANT_CRED_MISMATCH: 'grant-cred-mismatch',
|
|
52
|
+
GRANT_SCOPE_MISMATCH: 'grant-scope-mismatch',
|
|
53
|
+
GRANT_VERSION_MISMATCH: 'grant-version-mismatch',
|
|
54
|
+
GRANT_LOG_CORRUPT: 'grant-log-corrupt',
|
|
55
|
+
ORIGIN_REQUIRED: 'origin-required',
|
|
56
|
+
ORIGIN_MISMATCH: 'origin-mismatch',
|
|
57
|
+
TASK_REQUIRED: 'task-required',
|
|
58
|
+
PENDING_MISSING: 'approval-not-found',
|
|
59
|
+
PENDING_ALREADY_DECIDED: 'approval-already-decided',
|
|
60
|
+
PENDING_EXPIRED: 'approval-expired',
|
|
61
|
+
IDEMPOTENCY_CONFLICT: 'idempotency-conflict',
|
|
62
|
+
FINGERPRINT_MISMATCH: 'fingerprint-mismatch',
|
|
63
|
+
CONTEXT_CHANGED: 'approval-context-changed',
|
|
64
|
+
IDENTITY_UNKNOWN: 'execution-identity-unknown',
|
|
65
|
+
OFFICIAL_UNBOUND: 'official-request-unbound',
|
|
66
|
+
OFFICIAL_AMBIGUOUS: 'official-request-ambiguous',
|
|
67
|
+
EXTRA_FIELDS: 'unexpected-fields',
|
|
68
|
+
AUDIT_WRITE_FAILED: 'audit-write-failed',
|
|
69
|
+
UNSUPPORTED_TARGET: 'approval-unsupported-target',
|
|
70
|
+
UNSUPPORTED_CALL: 'approval-unsupported-call',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_LEASE_MS = 60_000
|
|
74
|
+
const DEFAULT_GRANT_TTL_MS = 15 * 60_000
|
|
75
|
+
const MAX_GRANT_TTL_MS = 60 * 60_000
|
|
76
|
+
/** 认证续期能延到的绝对上限(相对签发时间),避免"无限续期"变成永久授权。 */
|
|
77
|
+
export const MAX_AUTHORIZED_TTL_MS = 8 * 60 * 60_000
|
|
78
|
+
const SNAPSHOT_TTL_MS = 10 * 60_000
|
|
79
|
+
const SNAPSHOT_MAX = 256
|
|
80
|
+
const KEY_PATTERN = /^[0-9a-f]{64}$/
|
|
81
|
+
|
|
82
|
+
const uid = (prefix) => prefix + '-' + Date.now().toString(36) + '-' + crypto.randomBytes(6).toString('hex')
|
|
83
|
+
|
|
84
|
+
export const sha256Hex = (value) => crypto.createHash('sha256').update(String(value)).digest('hex')
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------- 控制凭据
|
|
87
|
+
|
|
88
|
+
export const controlKeyPath = (explicit) => {
|
|
89
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') return path.resolve(explicit)
|
|
90
|
+
if (typeof process.env.DSH_APPROVAL_CONTROL_KEY_PATH === 'string' && process.env.DSH_APPROVAL_CONTROL_KEY_PATH.trim() !== '') {
|
|
91
|
+
return path.resolve(process.env.DSH_APPROVAL_CONTROL_KEY_PATH)
|
|
92
|
+
}
|
|
93
|
+
return path.join(os.homedir(), '.dsh-codex-approval', 'control.key')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 非认证用途的指纹(域分离,不可反推、也不可当 header 用)。 */
|
|
97
|
+
export const keyFingerprint8 = (key) => sha256Hex('dsh-approval-key-fp:' + String(key)).slice(0, 8)
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* grant/audit 里保存的单向摘要:**域分离**,与认证 header 不同域 ⇒ 拿到它也无法登录。
|
|
101
|
+
* (修正 C:以前存 `sha256(rawKey)`,而 header 正是同一个 hash,等于把 bearer 落盘。)
|
|
102
|
+
*/
|
|
103
|
+
export const controlCredDigest = (key) => 'digest:' + sha256Hex('dsh-approval-grant-digest:' + String(key))
|
|
104
|
+
|
|
105
|
+
/** 离线原子初始化控制 key(只在不存在时创建;force 才轮换,轮换=撤销全部旧 grant)。 */
|
|
106
|
+
export const initControlKey = (options = {}) => {
|
|
107
|
+
const file = controlKeyPath(options.path)
|
|
108
|
+
const force = options.force === true
|
|
109
|
+
const dir = path.dirname(file)
|
|
110
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
111
|
+
const tmp = path.join(dir, '.key-' + uid('tmp'))
|
|
112
|
+
const raw = crypto.randomBytes(32).toString('hex')
|
|
113
|
+
fs.writeFileSync(tmp, raw + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
114
|
+
let rotated = false
|
|
115
|
+
try {
|
|
116
|
+
if (force) {
|
|
117
|
+
fs.renameSync(tmp, file)
|
|
118
|
+
rotated = true
|
|
119
|
+
} else {
|
|
120
|
+
try {
|
|
121
|
+
fs.linkSync(tmp, file)
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (e !== null && typeof e === 'object' && e.code === 'EEXIST') {
|
|
124
|
+
fs.unlinkSync(tmp)
|
|
125
|
+
throw new Error('control key already exists at ' + file + ' (use explicit rotate to replace it; rotating revokes every existing grant)')
|
|
126
|
+
}
|
|
127
|
+
throw e
|
|
128
|
+
}
|
|
129
|
+
fs.unlinkSync(tmp)
|
|
130
|
+
}
|
|
131
|
+
} finally {
|
|
132
|
+
try { fs.unlinkSync(tmp) } catch (e) { /* temp 已转正或已删 */ }
|
|
133
|
+
}
|
|
134
|
+
return { path: file, fp8: keyFingerprint8(raw), rotated }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 读 key;**格式不合法按"没有 key"处理**(能力关闭)。避免把任意配置/文件内容当 header 发出去。
|
|
139
|
+
* @returns {string|null}
|
|
140
|
+
*/
|
|
141
|
+
export const readControlKey = (explicitPath) => {
|
|
142
|
+
const file = controlKeyPath(explicitPath)
|
|
143
|
+
let value
|
|
144
|
+
try {
|
|
145
|
+
value = fs.readFileSync(file, 'utf8').trim()
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
if (!KEY_PATTERN.test(value)) return null
|
|
150
|
+
return value
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 客户端用:raw key 作为认证 header;无 key/格式不合规 → null(调用方零执行拒绝)。 */
|
|
154
|
+
export const controlHeaderValue = (explicitPath) => {
|
|
155
|
+
const key = readControlKey(explicitPath)
|
|
156
|
+
if (key === null) return null
|
|
157
|
+
return { 'X-Dsh-Control': key }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 服务端用:与 raw key 等值比较(timingSafeEqual,等长前提)。 */
|
|
161
|
+
export const verifyControlHeader = (headerValue, key) => {
|
|
162
|
+
if (typeof headerValue !== 'string' || !KEY_PATTERN.test(headerValue)) return false
|
|
163
|
+
if (typeof key !== 'string' || !KEY_PATTERN.test(key)) return false
|
|
164
|
+
const a = Buffer.from(headerValue, 'utf8')
|
|
165
|
+
const b = Buffer.from(key, 'utf8')
|
|
166
|
+
if (a.length !== b.length) return false
|
|
167
|
+
return crypto.timingSafeEqual(a, b)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------- 脱敏摘要
|
|
171
|
+
|
|
172
|
+
const SECRET_PATTERNS = [
|
|
173
|
+
/\b(authorization|bearer|cookie|password|passwd|token|secret|api[-_]?key|access[-_]?key|client[-_]?secret)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;}]+)/gi,
|
|
174
|
+
/"(authorization|password|passwd|token|secret|api[-_]?key|access[-_]?key|client[-_]?secret)"\s*:\s*"[^"]*"/gi,
|
|
175
|
+
/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{6,}/g,
|
|
176
|
+
]
|
|
177
|
+
/** 只用于审计/展示摘要:遮蔽常见凭据形状(含 JSON 引号键),并截断。不是"能去掉所有秘密"的保证。 */
|
|
178
|
+
export const sanitizePreview = (text, limit = 300) => {
|
|
179
|
+
let out = typeof text === 'string' ? text.replace(/[\r\n]+/g, ' ') : ''
|
|
180
|
+
for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, (match, p1) => (String(match).trimStart().startsWith('"') ? '"' + p1 + '":<redacted>' : p1 + '=<redacted>'))
|
|
181
|
+
out = out.replace(/(https?:\/\/)[^\s@/]+:[^\s@/]+@/g, '$1<redacted>@')
|
|
182
|
+
return out.length > limit ? out.slice(0, limit) + '…' : out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 审计只保留**可核对的最小身份/摘要**(不落任何原文,包括命令文本):
|
|
187
|
+
* 动作内容以 argsSha256 呈现,需要看真实参数走**已鉴权的审批详情**(pendingView)。
|
|
188
|
+
*/
|
|
189
|
+
export const auditActionSummary = (execution) => ({
|
|
190
|
+
toolName: execution.toolName,
|
|
191
|
+
callId: execution.callId,
|
|
192
|
+
turn: execution.turn,
|
|
193
|
+
step: execution.step,
|
|
194
|
+
cwd: execution.cwd,
|
|
195
|
+
argsSha256: sha256Hex(canonicalJson(execution.args)),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
/** 结构化参数的稳定 JSON(键排序)。 */
|
|
199
|
+
export function canonicalJson(value) {
|
|
200
|
+
if (value === undefined) return 'null'
|
|
201
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
|
|
202
|
+
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
|
|
203
|
+
const keys = Object.keys(value).sort()
|
|
204
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const deepFreeze = (value) => {
|
|
208
|
+
if (value === null || typeof value !== 'object') return value
|
|
209
|
+
for (const key of Object.keys(value)) deepFreeze(value[key])
|
|
210
|
+
return Object.freeze(value)
|
|
211
|
+
}
|
|
212
|
+
const deepCopy = (value) => (value === undefined ? undefined : deepFreeze(structuredClone(value)))
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 官方执行身份里"父调用"的真实形态(已核对本机官方源码,不是杜撰):
|
|
216
|
+
* - `tools/pre-execute` 收到的 exec 是 dsh-tools `createExecution()` 构造的 execution 对象:
|
|
217
|
+
* `{token, callId, rootCallId, name, args, signal, agent?, parent?, schema?}`(dsh-tools/lib/index.js:3143-3168)
|
|
218
|
+
* - 嵌套/PTC 派发时 `parent` 是**父执行的 token**(dsh-tools/lib/ptc.js:439 `parent: exec.token`);
|
|
219
|
+
* 顶层执行没有 parent。
|
|
220
|
+
* - `rootCallId` 由 SDK 归一:顶层 `rootCallId = exec.rootCallId ?? callId`(:3135)。
|
|
221
|
+
* - **exec 上没有 cwd**:cwd 的真实来源是 `exec.agent.session.header.cwd`(dsh-tools:1423 同一取值路径;
|
|
222
|
+
* dsh-session:1044-1046 保证它是合法绝对路径字符串)。
|
|
223
|
+
* token 不可序列化,所以父身份只能按"是否存在 + 可序列化的稳定键"参与;**present 但无法稳定表达 → unsupported**。
|
|
224
|
+
*/
|
|
225
|
+
export const canonicalParentKey = (parent) => {
|
|
226
|
+
if (parent === undefined || parent === null) return null
|
|
227
|
+
if (typeof parent === 'string') return parent === '' ? 'unsupported' : 's:' + parent
|
|
228
|
+
if (typeof parent === 'object') {
|
|
229
|
+
for (const field of ['token', 'callId', 'id', 'name']) {
|
|
230
|
+
const value = parent[field]
|
|
231
|
+
if (typeof value === 'string' && value !== '') return field + ':' + value
|
|
232
|
+
}
|
|
233
|
+
return 'unsupported' // 存在父执行但拿不到稳定身份 → 不许假装完整
|
|
234
|
+
}
|
|
235
|
+
return 'unsupported'
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 身份内容(不含 turn/step):任一必需项缺失就返回 null(**未知即 unsupported**,绝不当作完整)。 */
|
|
239
|
+
export const identityContent = (parts) => {
|
|
240
|
+
const callId = typeof parts.callId === 'string' && parts.callId !== '' ? parts.callId : null
|
|
241
|
+
const sessionId = typeof parts.sessionId === 'string' && parts.sessionId !== '' ? parts.sessionId : null
|
|
242
|
+
const toolName = typeof parts.toolName === 'string' && parts.toolName !== '' ? parts.toolName : null
|
|
243
|
+
const cwd = typeof parts.cwd === 'string' && parts.cwd !== '' ? parts.cwd : null
|
|
244
|
+
// 父身份:只给 parent 就派生;只给 parentKey 就用它;**两者都给且不一致 → unsupported**
|
|
245
|
+
// (否则一个陈旧/伪造的 parentKey=null 可以遮蔽真实存在的 parent,让代际变化不可见)
|
|
246
|
+
const derivedParentKey = canonicalParentKey(parts.parent)
|
|
247
|
+
let parentKey
|
|
248
|
+
if (parts.parentKey === undefined) parentKey = derivedParentKey
|
|
249
|
+
else if (parts.parent === undefined) parentKey = parts.parentKey
|
|
250
|
+
else parentKey = parts.parentKey === derivedParentKey ? parts.parentKey : 'unsupported'
|
|
251
|
+
if (callId === null || sessionId === null || toolName === null || cwd === null) return null
|
|
252
|
+
if (parentKey === 'unsupported') return null
|
|
253
|
+
return {
|
|
254
|
+
sessionId,
|
|
255
|
+
callId,
|
|
256
|
+
rootCallId: typeof parts.rootCallId === 'string' && parts.rootCallId !== '' ? parts.rootCallId : callId,
|
|
257
|
+
parentKey: parentKey === undefined ? null : parentKey,
|
|
258
|
+
toolName,
|
|
259
|
+
cwd,
|
|
260
|
+
argsSha256: sha256Hex(canonicalJson(parts.args)),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 身份内容 id(toolName/root/parent/cwd/args 任一变化 → 不同):用于快照"代际"识别。 */
|
|
265
|
+
export const contentIdOf = (content) => (content === null ? null : 'cid-' + sha256Hex(JSON.stringify([
|
|
266
|
+
content.sessionId, content.callId, content.rootCallId, content.parentKey, content.toolName, content.cwd, content.argsSha256,
|
|
267
|
+
])).slice(0, 24))
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* **完整执行身份** = 身份内容 + 真实 turn/step(都必须是整数)。
|
|
271
|
+
* 任一项未知 → null:调用方必须 `next()`/拒绝(unsupported),不得凭不完整身份放行。
|
|
272
|
+
*/
|
|
273
|
+
export const executionIdentityId = (parts) => {
|
|
274
|
+
const content = identityContent(parts)
|
|
275
|
+
if (content === null) return null
|
|
276
|
+
const turn = parts.turn
|
|
277
|
+
const step = parts.step
|
|
278
|
+
if (!Number.isInteger(turn) || !Number.isInteger(step)) return null
|
|
279
|
+
return 'xid-' + sha256Hex(JSON.stringify([
|
|
280
|
+
content.sessionId, content.callId, content.rootCallId, content.parentKey, content.toolName,
|
|
281
|
+
content.cwd, content.argsSha256, turn, step,
|
|
282
|
+
])).slice(0, 32)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 不可变动作指纹:绑定 session/callId/toolName/turn/step/root/parent/cwd/真实参数 + 授权身份(task/grant)。
|
|
287
|
+
* 这些字段任一变化 → 指纹不同 → 决定被拒(修正 A:cwd/root/parent 参与;D:授权身份参与;
|
|
288
|
+
* 第二轮:parent 用官方真实字段 `exec.parent` 参与,不再只在注释里声称)。
|
|
289
|
+
*/
|
|
290
|
+
export const actionFingerprint = (parts) => sha256Hex(JSON.stringify([
|
|
291
|
+
parts.sessionId,
|
|
292
|
+
parts.callId,
|
|
293
|
+
parts.toolName,
|
|
294
|
+
typeof parts.turn === 'number' ? parts.turn : 'no-turn',
|
|
295
|
+
typeof parts.step === 'number' ? parts.step : 'no-step',
|
|
296
|
+
parts.rootCallId === undefined ? null : parts.rootCallId,
|
|
297
|
+
parts.parentKey === undefined ? canonicalParentKey(parts.parent) : parts.parentKey,
|
|
298
|
+
parts.cwd === undefined ? null : parts.cwd,
|
|
299
|
+
canonicalJson(parts.args),
|
|
300
|
+
parts.taskId === undefined ? null : parts.taskId,
|
|
301
|
+
parts.grantId === undefined ? null : parts.grantId,
|
|
302
|
+
]))
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------- 持久存储
|
|
305
|
+
|
|
306
|
+
export const approvalStoreRoot = (explicit) => {
|
|
307
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') return path.resolve(explicit)
|
|
308
|
+
if (typeof process.env.DSH_APPROVAL_DIR === 'string' && process.env.DSH_APPROVAL_DIR.trim() !== '') return path.resolve(process.env.DSH_APPROVAL_DIR)
|
|
309
|
+
return path.join(os.homedir(), '.dsh-codex-approval')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* JSONL 读取。`strict` 用于**权限记录**:任何不可解析行(含被截断的最后一行)都必须安全失败,
|
|
314
|
+
* 而不是跳过坏行后继续把旧 grant 当有效(修正 F)。
|
|
315
|
+
*/
|
|
316
|
+
const readJsonl = (file, options = {}) => {
|
|
317
|
+
let text
|
|
318
|
+
try {
|
|
319
|
+
text = fs.readFileSync(file, 'utf8')
|
|
320
|
+
} catch (e) {
|
|
321
|
+
if (e !== null && typeof e === 'object' && e.code === 'ENOENT') return []
|
|
322
|
+
if (options.strict === true) throw new Error('cannot read ' + file + ': ' + e.message)
|
|
323
|
+
return []
|
|
324
|
+
}
|
|
325
|
+
if (text.trim() === '') return []
|
|
326
|
+
const rows = []
|
|
327
|
+
const lines = text.split('\n')
|
|
328
|
+
for (let i = 0; i < lines.length; i++) {
|
|
329
|
+
const trimmed = lines[i].trim()
|
|
330
|
+
if (trimmed === '') continue
|
|
331
|
+
try {
|
|
332
|
+
rows.push(JSON.parse(trimmed))
|
|
333
|
+
} catch (e) {
|
|
334
|
+
if (options.strict === true) throw new Error(file + ' has an unparseable line ' + (i + 1) + ' — refusing to treat damaged authorization state as valid')
|
|
335
|
+
// 非权限文件(如审计展示)允许跳过坏行
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return rows
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export const openApprovalStore = (options = {}) => {
|
|
342
|
+
const root = approvalStoreRoot(options.root)
|
|
343
|
+
const dir = root
|
|
344
|
+
const grantsFile = path.join(dir, 'grants.jsonl')
|
|
345
|
+
const auditFile = path.join(dir, 'audit.jsonl')
|
|
346
|
+
const lock = createFileLock({ lockFile: path.join(dir, '.write.lock'), timeoutMs: options.lockTimeoutMs })
|
|
347
|
+
|
|
348
|
+
const ensureDir = () => { fs.mkdirSync(dir, { recursive: true }) }
|
|
349
|
+
|
|
350
|
+
const appendLine = (file, row) => {
|
|
351
|
+
ensureDir()
|
|
352
|
+
let size = 0
|
|
353
|
+
try { size = fs.statSync(file).size } catch (e) { size = 0 }
|
|
354
|
+
if (size > 0) {
|
|
355
|
+
let last = 0
|
|
356
|
+
const fd = fs.openSync(file, 'r')
|
|
357
|
+
try {
|
|
358
|
+
const buf = Buffer.alloc(1)
|
|
359
|
+
fs.readSync(fd, buf, 0, 1, size - 1)
|
|
360
|
+
last = buf[0]
|
|
361
|
+
} finally {
|
|
362
|
+
fs.closeSync(fd)
|
|
363
|
+
}
|
|
364
|
+
if (last !== 0x0a) fs.appendFileSync(file, '\n', 'utf8')
|
|
365
|
+
}
|
|
366
|
+
fs.appendFileSync(file, JSON.stringify(row) + '\n', 'utf8')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const withLock = (fn) => lock.withLock(fn)
|
|
370
|
+
|
|
371
|
+
/** 折叠 grant(严格读:坏行 → 抛出让调用方 fail-closed)。 */
|
|
372
|
+
const foldGrants = () => {
|
|
373
|
+
const map = new Map()
|
|
374
|
+
for (const row of readJsonl(grantsFile, { strict: true })) {
|
|
375
|
+
if (row.type === 'grant' && typeof row.grantId === 'string') map.set(row.grantId, row)
|
|
376
|
+
if (row.type === 'grant-update' && typeof row.grantId === 'string') {
|
|
377
|
+
const grant = map.get(row.grantId)
|
|
378
|
+
if (grant === undefined) continue
|
|
379
|
+
if (row.status !== undefined) grant.status = row.status
|
|
380
|
+
if (row.leaseUntil !== undefined) grant.leaseUntil = row.leaseUntil
|
|
381
|
+
if (row.expiresAt !== undefined) grant.expiresAt = row.expiresAt
|
|
382
|
+
if (row.revokedAt !== undefined) grant.revokedAt = row.revokedAt
|
|
383
|
+
if (row.revokeReason !== undefined) grant.revokeReason = row.revokeReason
|
|
384
|
+
if (row.version !== undefined) grant.version = row.version
|
|
385
|
+
if (row.authorization !== undefined) grant.authorization = row.authorization
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return map
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const snapshots = new Map() // `${sessionId}\u0000${callId}` → 不可变执行快照
|
|
392
|
+
const pendings = new Map() // approvalKey → pending(每次官方 ask 一条)
|
|
393
|
+
const executionIndex = new Map() // `${sessionId}\u0000${callId}` → [approvalKey...]
|
|
394
|
+
const officialAsks = new Map() // officialId → {officialId, sessionId, toolName, callId, seenAt, claimedBy?}
|
|
395
|
+
const orphans = [] // 观察到但无对应 pending 的 decided(诊断)
|
|
396
|
+
|
|
397
|
+
const snapKey = (sessionId, callId) => sessionId + '\u0000' + callId
|
|
398
|
+
|
|
399
|
+
const pruneSnapshots = (now) => {
|
|
400
|
+
for (const [key, snapshot] of snapshots) {
|
|
401
|
+
if (now - snapshot.createdAt > SNAPSHOT_TTL_MS) snapshots.delete(key)
|
|
402
|
+
}
|
|
403
|
+
while (snapshots.size > SNAPSHOT_MAX) snapshots.delete(snapshots.keys().next().value)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const store = {
|
|
407
|
+
root,
|
|
408
|
+
dir,
|
|
409
|
+
grantsFile,
|
|
410
|
+
auditFile,
|
|
411
|
+
APPROVAL_STATES,
|
|
412
|
+
|
|
413
|
+
audit(row) {
|
|
414
|
+
try {
|
|
415
|
+
withLock(() => appendLine(auditFile, { ts: Date.now(), actor: 'codex-delegate', ...row }))
|
|
416
|
+
return true
|
|
417
|
+
} catch (e) {
|
|
418
|
+
console.error('[codex-approvals] audit append failed:', String(e && e.message ? e.message : e).slice(0, 200))
|
|
419
|
+
return false
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
// ------------------------------------------------------------ grant
|
|
424
|
+
|
|
425
|
+
issueGrant({ originThreadId, taskId, scope, ttlMs, leaseMs, credDigest, authorization }) {
|
|
426
|
+
const now = Date.now()
|
|
427
|
+
const ttl = Math.min(typeof ttlMs === 'number' && ttlMs > 0 ? ttlMs : DEFAULT_GRANT_TTL_MS, MAX_GRANT_TTL_MS)
|
|
428
|
+
const lease = typeof leaseMs === 'number' && leaseMs > 0 ? leaseMs : DEFAULT_LEASE_MS
|
|
429
|
+
const sessionId = scope !== null && typeof scope === 'object' && typeof scope.sessionId === 'string' ? scope.sessionId : ''
|
|
430
|
+
if (sessionId === '') throw new Error('grant scope requires a sessionId (session-target binding)')
|
|
431
|
+
if (typeof credDigest !== 'string' || !credDigest.startsWith('digest:')) throw new Error('grant requires a domain-separated credential digest (never the raw key or its bare hash)')
|
|
432
|
+
const grant = {
|
|
433
|
+
type: 'grant',
|
|
434
|
+
grantId: uid('grt'),
|
|
435
|
+
version: 1,
|
|
436
|
+
originThreadId,
|
|
437
|
+
taskId,
|
|
438
|
+
scope: { kind: 'session', sessionId },
|
|
439
|
+
// 授权说明/工作范围随 grant 固定记录并回给审批模型;来源必须是可信发起端提供的文本(服务器不解释成策略)。
|
|
440
|
+
authorization: typeof authorization === 'string' ? sanitizePreview(authorization, 500) : undefined,
|
|
441
|
+
credDigest,
|
|
442
|
+
issuedAt: now,
|
|
443
|
+
expiresAt: now + ttl,
|
|
444
|
+
leaseUntil: now + lease,
|
|
445
|
+
leaseMs: lease,
|
|
446
|
+
status: GRANT_STATES.ACTIVE,
|
|
447
|
+
nonce: crypto.randomBytes(8).toString('hex'),
|
|
448
|
+
}
|
|
449
|
+
withLock(() => appendLine(grantsFile, grant))
|
|
450
|
+
return grant
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
getGrant(grantId) {
|
|
454
|
+
try {
|
|
455
|
+
return foldGrants().get(grantId)
|
|
456
|
+
} catch (e) {
|
|
457
|
+
return undefined
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
/** 列表(支持宣称的过滤;严格读,坏行 → 空 + reason)。 */
|
|
462
|
+
listGrants(filter = {}) {
|
|
463
|
+
let all
|
|
464
|
+
try {
|
|
465
|
+
all = Array.from(foldGrants().values())
|
|
466
|
+
} catch (e) {
|
|
467
|
+
return []
|
|
468
|
+
}
|
|
469
|
+
const out = all.filter((g) => {
|
|
470
|
+
if (filter.grantId !== undefined && g.grantId !== filter.grantId) return false
|
|
471
|
+
if (filter.taskId !== undefined && g.taskId !== filter.taskId) return false
|
|
472
|
+
if (filter.originThreadId !== undefined && g.originThreadId !== filter.originThreadId) return false
|
|
473
|
+
if (filter.sessionId !== undefined && g.scope.sessionId !== filter.sessionId) return false
|
|
474
|
+
if (filter.activeOnly === true && g.status !== GRANT_STATES.ACTIVE) return false
|
|
475
|
+
return true
|
|
476
|
+
})
|
|
477
|
+
out.sort((a, b) => b.issuedAt - a.issuedAt)
|
|
478
|
+
return out
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
grantLogHealthy() {
|
|
482
|
+
try {
|
|
483
|
+
foldGrants()
|
|
484
|
+
return true
|
|
485
|
+
} catch (e) {
|
|
486
|
+
return false
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* 校验 grant:身份(grantId/task/origin/session)+ 凭据摘要 + 版本 + 未撤销 + 绝对 TTL + 租约。
|
|
492
|
+
* `options.ignoreLease` 只给续租用(租约正是要被重置的东西)。
|
|
493
|
+
*/
|
|
494
|
+
validateGrant({ grantId, taskId, originThreadId, sessionId, credDigest, grantVersion, now = Date.now() }, opts = {}) {
|
|
495
|
+
let grant
|
|
496
|
+
try {
|
|
497
|
+
grant = foldGrants().get(grantId)
|
|
498
|
+
} catch (e) {
|
|
499
|
+
return { ok: false, reason: APPROVAL_REASONS.GRANT_LOG_CORRUPT }
|
|
500
|
+
}
|
|
501
|
+
if (grant === undefined) return { ok: false, reason: APPROVAL_REASONS.GRANT_MISSING }
|
|
502
|
+
if (grant.status === GRANT_STATES.REVOKED) return { ok: false, reason: APPROVAL_REASONS.GRANT_REVOKED }
|
|
503
|
+
if (now > grant.expiresAt) return { ok: false, reason: APPROVAL_REASONS.GRANT_EXPIRED }
|
|
504
|
+
if (typeof credDigest !== 'string' || credDigest !== grant.credDigest) return { ok: false, reason: APPROVAL_REASONS.GRANT_CRED_MISMATCH }
|
|
505
|
+
if (taskId !== undefined && grant.taskId !== taskId) return { ok: false, reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH }
|
|
506
|
+
if (originThreadId !== undefined && grant.originThreadId !== originThreadId) return { ok: false, reason: APPROVAL_REASONS.ORIGIN_MISMATCH }
|
|
507
|
+
if (sessionId !== undefined && grant.scope.sessionId !== sessionId) return { ok: false, reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH }
|
|
508
|
+
if (grantVersion !== undefined && grantVersion !== grant.version) return { ok: false, reason: APPROVAL_REASONS.GRANT_VERSION_MISMATCH }
|
|
509
|
+
if (opts.ignoreLease !== true && now > grant.leaseUntil) return { ok: false, reason: APPROVAL_REASONS.GRANT_EXPIRED }
|
|
510
|
+
return { ok: true, grant }
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
/** 认证续租:延长**租约**(不复活已过的绝对 TTL)。 */
|
|
514
|
+
renewLease({ grantId, taskId, originThreadId, sessionId, credDigest, leaseMs, now = Date.now() }) {
|
|
515
|
+
const check = store.validateGrant({ grantId, taskId, originThreadId, sessionId, credDigest, now }, { ignoreLease: true })
|
|
516
|
+
if (check.ok !== true) return check
|
|
517
|
+
const lease = typeof leaseMs === 'number' && leaseMs > 0 ? leaseMs : (check.grant.leaseMs || DEFAULT_LEASE_MS)
|
|
518
|
+
const leaseUntil = Math.min(now + lease, check.grant.expiresAt)
|
|
519
|
+
try {
|
|
520
|
+
withLock(() => appendLine(grantsFile, { type: 'grant-update', grantId, leaseUntil, at: now }))
|
|
521
|
+
} catch (e) {
|
|
522
|
+
return { ok: false, reason: 'lease-write-failed' }
|
|
523
|
+
}
|
|
524
|
+
return { ok: true, grant: { ...check.grant, leaseUntil } }
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* 认证续期:在**原授权范围内**显式延长绝对 TTL(封顶 MAX_AUTHORIZED_TTL_MS),并顺带延租约。
|
|
529
|
+
* 这就是"长任务如何显式续期"的官方路径(不靠后台无限续租)。
|
|
530
|
+
*/
|
|
531
|
+
renewGrant({ grantId, taskId, originThreadId, sessionId, credDigest, extendSec, now = Date.now() }) {
|
|
532
|
+
const check = store.validateGrant({ grantId, taskId, originThreadId, sessionId, credDigest, now }, { ignoreLease: true })
|
|
533
|
+
if (check.ok !== true) return check
|
|
534
|
+
const grant = check.grant
|
|
535
|
+
const cap = grant.issuedAt + MAX_AUTHORIZED_TTL_MS
|
|
536
|
+
const extendMs = typeof extendSec === 'number' && extendSec > 0 ? Math.min(extendSec, 3600) * 1000 : grant.leaseMs || DEFAULT_LEASE_MS
|
|
537
|
+
// 从**当前有效期**起延长(不是每次重置成 now+extend),并封顶在授权窗口内。
|
|
538
|
+
const expiresAt = Math.min(grant.expiresAt + extendMs, cap)
|
|
539
|
+
const leaseMs = grant.leaseMs || DEFAULT_LEASE_MS
|
|
540
|
+
const leaseUntil = Math.min(now + leaseMs, expiresAt)
|
|
541
|
+
try {
|
|
542
|
+
withLock(() => appendLine(grantsFile, { type: 'grant-update', grantId, expiresAt, leaseUntil, at: now }))
|
|
543
|
+
} catch (e) {
|
|
544
|
+
return { ok: false, reason: 'renew-write-failed' }
|
|
545
|
+
}
|
|
546
|
+
return { ok: true, grant: { ...grant, expiresAt, leaseUntil }, cap }
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
revokeGrant(grantId, reason) {
|
|
550
|
+
let grant
|
|
551
|
+
try {
|
|
552
|
+
grant = foldGrants().get(grantId)
|
|
553
|
+
} catch (e) {
|
|
554
|
+
return { ok: false, reason: APPROVAL_REASONS.GRANT_LOG_CORRUPT }
|
|
555
|
+
}
|
|
556
|
+
if (grant === undefined) return { ok: false, reason: APPROVAL_REASONS.GRANT_MISSING }
|
|
557
|
+
if (grant.status === GRANT_STATES.REVOKED) return { ok: true, grant }
|
|
558
|
+
withLock(() => appendLine(grantsFile, { type: 'grant-update', grantId, status: GRANT_STATES.REVOKED, revokedAt: Date.now(), revokeReason: String(reason || 'revoked').slice(0, 200) }))
|
|
559
|
+
for (const pending of pendings.values()) {
|
|
560
|
+
if (pending.grantId === grantId && pending.state === APPROVAL_STATES.PENDING) pending.state = APPROVAL_STATES.SUPERSEDED
|
|
561
|
+
}
|
|
562
|
+
return { ok: true, grant: store.getGrant(grantId) }
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
// ------------------------------------------------------------ 不可变执行快照
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* 记录执行快照:按 (sessionId, callId) 存**深拷贝冻结**的 args(修正 A:全局 callId 会串会话)。
|
|
569
|
+
* 第二轮修正:同时冻结**完整执行身份的可见部分**——toolName / rootCallId / parentKey / cwd(cwdSource)——
|
|
570
|
+
* `snapshotId` 覆盖这些内容,因此 tool-only / root-only / parent-only 变化都会换 id;
|
|
571
|
+
* turn/step 只能由 annotateSnapshot 以实数写入(未知不得作为可批准默认值)。
|
|
572
|
+
*/
|
|
573
|
+
snapshotCall({ sessionId, callId, rootCallId, parent, parentKey, name, args, cwd, cwdSource, now = Date.now() }) {
|
|
574
|
+
pruneSnapshots(now)
|
|
575
|
+
if (typeof sessionId !== 'string' || sessionId === '' || typeof callId !== 'string' || callId === '') return null
|
|
576
|
+
const frozenArgs = deepCopy(args)
|
|
577
|
+
const content = identityContent({
|
|
578
|
+
sessionId, callId, rootCallId, toolName: name, cwd, args: frozenArgs,
|
|
579
|
+
parentKey: parentKey === undefined ? canonicalParentKey(parent) : parentKey,
|
|
580
|
+
})
|
|
581
|
+
const snapshot = {
|
|
582
|
+
snapshotId: contentIdOf(content) === null
|
|
583
|
+
? 'cid-unknown:' + sha256Hex(sessionId + '\u0000' + callId).slice(0, 12)
|
|
584
|
+
: contentIdOf(content),
|
|
585
|
+
sessionId,
|
|
586
|
+
callId,
|
|
587
|
+
rootCallId: content === null ? (typeof rootCallId === 'string' ? rootCallId : undefined) : content.rootCallId,
|
|
588
|
+
parentKey: content === null ? 'unsupported' : content.parentKey,
|
|
589
|
+
name: typeof name === 'string' ? name : '',
|
|
590
|
+
cwd: typeof cwd === 'string' && cwd !== '' ? cwd : undefined,
|
|
591
|
+
cwdSource: typeof cwdSource === 'string' ? cwdSource : undefined,
|
|
592
|
+
args: frozenArgs,
|
|
593
|
+
argsSha256: content === null ? null : content.argsSha256,
|
|
594
|
+
identityComplete: content !== null,
|
|
595
|
+
turn: undefined,
|
|
596
|
+
step: undefined,
|
|
597
|
+
turnSource: undefined,
|
|
598
|
+
stepSource: undefined,
|
|
599
|
+
createdAt: now,
|
|
600
|
+
source: 'pre-execute',
|
|
601
|
+
}
|
|
602
|
+
snapshots.set(snapKey(sessionId, callId), snapshot)
|
|
603
|
+
return snapshot
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
getSnapshot(sessionId, callId) {
|
|
607
|
+
return snapshots.get(snapKey(sessionId, callId))
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
/** 写入 turn/step:两者都必须是整数,否则拒绝(返回 null);同时固化**完整执行身份**。 */
|
|
611
|
+
annotateSnapshot(sessionId, callId, patch) {
|
|
612
|
+
const snapshot = snapshots.get(snapKey(sessionId, callId))
|
|
613
|
+
if (snapshot === undefined) return null
|
|
614
|
+
const turn = patch !== null && typeof patch === 'object' ? patch.turn : undefined
|
|
615
|
+
const step = patch !== null && typeof patch === 'object' ? patch.step : undefined
|
|
616
|
+
if (!Number.isInteger(turn) || !Number.isInteger(step)) return null
|
|
617
|
+
snapshot.turn = turn
|
|
618
|
+
snapshot.step = step
|
|
619
|
+
snapshot.turnSource = typeof patch.turnSource === 'string' ? patch.turnSource : 'log'
|
|
620
|
+
snapshot.stepSource = typeof patch.stepSource === 'string' ? patch.stepSource : 'log'
|
|
621
|
+
snapshot.executionId = this.executionIdentity(snapshot, { turn, step })
|
|
622
|
+
return snapshot
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
/** 由快照 + 给定 turn/step 求完整执行身份(null = 身份不完整/未知)。 */
|
|
626
|
+
executionIdentity(snapshot, turnStep) {
|
|
627
|
+
if (snapshot === undefined || snapshot === null) return null
|
|
628
|
+
return executionIdentityId({
|
|
629
|
+
sessionId: snapshot.sessionId,
|
|
630
|
+
callId: snapshot.callId,
|
|
631
|
+
rootCallId: snapshot.rootCallId,
|
|
632
|
+
parentKey: snapshot.parentKey,
|
|
633
|
+
toolName: snapshot.name,
|
|
634
|
+
cwd: snapshot.cwd,
|
|
635
|
+
args: snapshot.args,
|
|
636
|
+
turn: turnStep === undefined || turnStep === null ? snapshot.turn : turnStep.turn,
|
|
637
|
+
step: turnStep === undefined || turnStep === null ? snapshot.step : turnStep.step,
|
|
638
|
+
})
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
// ------------------------------------------------------------ 官方请求身份认领
|
|
642
|
+
|
|
643
|
+
/** 观测官方 approval/asked:建立 officialId → (session, toolName, callId) 台账。 */
|
|
644
|
+
observeOfficialAsked({ officialId, sessionId, toolName, callId, seenAt = Date.now() }) {
|
|
645
|
+
if (typeof officialId !== 'string' || officialId === '') return null
|
|
646
|
+
const existing = officialAsks.get(officialId)
|
|
647
|
+
if (existing !== undefined) return existing
|
|
648
|
+
const row = {
|
|
649
|
+
officialId,
|
|
650
|
+
sessionId,
|
|
651
|
+
toolName: typeof toolName === 'string' ? toolName : '',
|
|
652
|
+
callId: typeof callId === 'string' ? callId : undefined,
|
|
653
|
+
seenAt,
|
|
654
|
+
claimedBy: undefined,
|
|
655
|
+
}
|
|
656
|
+
officialAsks.set(officialId, row)
|
|
657
|
+
return row
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
/** 仍未认领的候选(用于"唯一认领"判定;绝不按最后/第一条近似匹配)。 */
|
|
661
|
+
unclaimedAskedFor(sessionId, callId, toolName) {
|
|
662
|
+
const out = []
|
|
663
|
+
for (const row of officialAsks.values()) {
|
|
664
|
+
if (row.claimedBy !== undefined) continue
|
|
665
|
+
if (row.sessionId !== sessionId) continue
|
|
666
|
+
if (row.callId !== callId) continue
|
|
667
|
+
if (row.toolName !== toolName) continue
|
|
668
|
+
out.push(row)
|
|
669
|
+
}
|
|
670
|
+
out.sort((a, b) => a.seenAt - b.seenAt)
|
|
671
|
+
return out
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
/** 把某个官方 ask 唯一认领给某条桥接 pending;已被认领则失败。 */
|
|
675
|
+
claimOfficialAsk(approvalKey, officialId) {
|
|
676
|
+
const row = officialAsks.get(officialId)
|
|
677
|
+
const pending = pendings.get(approvalKey)
|
|
678
|
+
if (row === undefined || pending === undefined) return { ok: false, reason: APPROVAL_REASONS.OFFICIAL_UNBOUND }
|
|
679
|
+
if (row.claimedBy !== undefined && row.claimedBy !== approvalKey) return { ok: false, reason: APPROVAL_REASONS.OFFICIAL_AMBIGUOUS }
|
|
680
|
+
row.claimedBy = approvalKey
|
|
681
|
+
pending.officialId = officialId
|
|
682
|
+
return { ok: true }
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
// ------------------------------------------------------------ pending
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 每次官方 ask 一条独立 pending(修正 B:不再按 session+callId 合并)。
|
|
689
|
+
* 执行上下文在打开时刻**深拷贝冻结**,展示与决定只看它(修正 A)。
|
|
690
|
+
*/
|
|
691
|
+
openPending({ execution, grant, reason, displayReason, askSeq = 1, now = Date.now() }) {
|
|
692
|
+
const frozen = deepCopy(execution)
|
|
693
|
+
// **身份不完整一律拒绝打开**(未知 cwd/工具/父身份/turn·step → unsupported,不许假装完整)
|
|
694
|
+
const frozenIdentity = executionIdentityId({
|
|
695
|
+
sessionId: frozen.sessionId,
|
|
696
|
+
callId: frozen.callId,
|
|
697
|
+
rootCallId: frozen.rootCallId,
|
|
698
|
+
parentKey: frozen.parentKey,
|
|
699
|
+
toolName: frozen.toolName,
|
|
700
|
+
cwd: frozen.cwd,
|
|
701
|
+
args: frozen.args,
|
|
702
|
+
turn: frozen.turn,
|
|
703
|
+
step: frozen.step,
|
|
704
|
+
})
|
|
705
|
+
if (frozenIdentity === null) return null
|
|
706
|
+
if (contentIdOf(identityContent({
|
|
707
|
+
sessionId: frozen.sessionId, callId: frozen.callId, rootCallId: frozen.rootCallId,
|
|
708
|
+
parentKey: frozen.parentKey, toolName: frozen.toolName, cwd: frozen.cwd, args: frozen.args,
|
|
709
|
+
})) !== frozen.snapshotId) {
|
|
710
|
+
// 快照内容与打开时刻传入的执行身份必须一致(接线错误/陈旧快照 → 拒绝,不猜)
|
|
711
|
+
return null
|
|
712
|
+
}
|
|
713
|
+
const pending = {
|
|
714
|
+
approvalKey: uid('apk'),
|
|
715
|
+
execution: frozen,
|
|
716
|
+
snapshotId: frozen.snapshotId,
|
|
717
|
+
executionId: frozenIdentity,
|
|
718
|
+
fingerprint: actionFingerprint({
|
|
719
|
+
sessionId: frozen.sessionId,
|
|
720
|
+
callId: frozen.callId,
|
|
721
|
+
toolName: frozen.toolName,
|
|
722
|
+
turn: frozen.turn,
|
|
723
|
+
step: frozen.step,
|
|
724
|
+
rootCallId: frozen.rootCallId,
|
|
725
|
+
parentKey: frozen.parentKey,
|
|
726
|
+
cwd: frozen.cwd,
|
|
727
|
+
args: frozen.args,
|
|
728
|
+
taskId: grant.taskId,
|
|
729
|
+
grantId: grant.grantId,
|
|
730
|
+
}),
|
|
731
|
+
grantId: grant.grantId,
|
|
732
|
+
grantVersion: grant.version,
|
|
733
|
+
taskId: grant.taskId,
|
|
734
|
+
originThreadId: grant.originThreadId,
|
|
735
|
+
authorization: grant.authorization,
|
|
736
|
+
sessionId: frozen.sessionId,
|
|
737
|
+
callId: frozen.callId,
|
|
738
|
+
toolName: frozen.toolName,
|
|
739
|
+
reason: typeof reason === 'string' ? sanitizePreview(reason, 300) : undefined,
|
|
740
|
+
displayReason: typeof displayReason === 'object' && displayReason !== null ? displayReason : undefined,
|
|
741
|
+
officialId: undefined,
|
|
742
|
+
askSeq,
|
|
743
|
+
state: APPROVAL_STATES.PENDING,
|
|
744
|
+
createdAt: now,
|
|
745
|
+
expiresAt: Math.min(grant.expiresAt, grant.leaseUntil),
|
|
746
|
+
decision: undefined,
|
|
747
|
+
outcome: undefined,
|
|
748
|
+
decidedAt: undefined,
|
|
749
|
+
confirmedOutcome: undefined,
|
|
750
|
+
idempotencyKey: undefined,
|
|
751
|
+
note: undefined,
|
|
752
|
+
}
|
|
753
|
+
pendings.set(pending.approvalKey, pending)
|
|
754
|
+
const indexKey = snapKey(pending.sessionId, pending.callId)
|
|
755
|
+
const list = executionIndex.get(indexKey) || []
|
|
756
|
+
list.push(pending.approvalKey)
|
|
757
|
+
executionIndex.set(indexKey, list)
|
|
758
|
+
store.audit({
|
|
759
|
+
type: 'approval', phase: 'open', approvalKey: pending.approvalKey, grantId: pending.grantId, taskId: pending.taskId,
|
|
760
|
+
originThreadId: pending.originThreadId, sessionId: pending.sessionId, callId: pending.callId,
|
|
761
|
+
toolName: pending.toolName, turn: frozen.turn, step: frozen.step, cwd: frozen.cwd,
|
|
762
|
+
snapshotId: pending.snapshotId, fingerprint: pending.fingerprint, askSeq,
|
|
763
|
+
officialId: pending.officialId,
|
|
764
|
+
...auditActionSummary(frozen),
|
|
765
|
+
})
|
|
766
|
+
return pending
|
|
767
|
+
},
|
|
768
|
+
|
|
769
|
+
getPending(approvalKey) {
|
|
770
|
+
return pendings.get(approvalKey)
|
|
771
|
+
},
|
|
772
|
+
|
|
773
|
+
/** 同 (session, callId) 的 ask 次数(用于多 ask 场景顺序与歧义判断)。 */
|
|
774
|
+
askCountFor(sessionId, callId) {
|
|
775
|
+
const list = executionIndex.get(snapKey(sessionId, callId))
|
|
776
|
+
return Array.isArray(list) ? list.length : 0
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
/** 展示视图:**只从 pending 的不可变副本读**,绝不回看其它请求的当前 snapshot(修正 A)。 */
|
|
780
|
+
pendingView(p) {
|
|
781
|
+
const g = store.getGrant(p.grantId)
|
|
782
|
+
return {
|
|
783
|
+
approvalKey: p.approvalKey,
|
|
784
|
+
state: p.state,
|
|
785
|
+
sessionId: p.sessionId,
|
|
786
|
+
callId: p.callId,
|
|
787
|
+
toolName: p.toolName,
|
|
788
|
+
reason: p.reason,
|
|
789
|
+
displayReason: p.displayReason,
|
|
790
|
+
args: p.execution.args,
|
|
791
|
+
cwd: p.execution.cwd,
|
|
792
|
+
turn: p.execution.turn,
|
|
793
|
+
step: p.execution.step,
|
|
794
|
+
turnSource: p.execution.turnSource,
|
|
795
|
+
stepSource: p.execution.stepSource,
|
|
796
|
+
rootCallId: p.execution.rootCallId,
|
|
797
|
+
parentKey: p.execution.parentKey === undefined ? null : p.execution.parentKey,
|
|
798
|
+
cwdSource: p.execution.cwdSource,
|
|
799
|
+
snapshotId: p.snapshotId,
|
|
800
|
+
// 完整执行身份(第二轮):toolName/root/parent/cwd/args + turn/step 全部参与;
|
|
801
|
+
// 能打开 pending 就意味着身份完整(openPending 对不完整身份直接拒绝),故以 executionId 为准。
|
|
802
|
+
executionId: p.executionId,
|
|
803
|
+
identityComplete: p.executionId !== undefined,
|
|
804
|
+
identity: {
|
|
805
|
+
toolName: p.execution.toolName,
|
|
806
|
+
callId: p.execution.callId,
|
|
807
|
+
rootCallId: p.execution.rootCallId,
|
|
808
|
+
parentKey: p.execution.parentKey === undefined ? null : p.execution.parentKey,
|
|
809
|
+
cwd: p.execution.cwd,
|
|
810
|
+
cwdSource: p.execution.cwdSource,
|
|
811
|
+
turn: p.execution.turn,
|
|
812
|
+
step: p.execution.step,
|
|
813
|
+
argsSha256: sha256Hex(canonicalJson(p.execution.args)),
|
|
814
|
+
},
|
|
815
|
+
argsSha256: sha256Hex(canonicalJson(p.execution.args)),
|
|
816
|
+
fingerprint: p.fingerprint,
|
|
817
|
+
grantId: p.grantId,
|
|
818
|
+
grantVersion: p.grantVersion,
|
|
819
|
+
taskId: p.taskId,
|
|
820
|
+
originThreadId: p.originThreadId,
|
|
821
|
+
authorization: p.authorization,
|
|
822
|
+
askSeq: p.askSeq,
|
|
823
|
+
officialId: p.officialId,
|
|
824
|
+
live: p.state === APPROVAL_STATES.PENDING,
|
|
825
|
+
createdAt: p.createdAt,
|
|
826
|
+
expiresAt: p.expiresAt,
|
|
827
|
+
...(p.decision === undefined ? {} : { decision: p.decision, outcome: p.outcome, decidedAt: p.decidedAt }),
|
|
828
|
+
...(p.confirmedOutcome === undefined ? {} : { confirmedOutcome: p.confirmedOutcome }),
|
|
829
|
+
...(p.note === undefined ? {} : { note: p.note }),
|
|
830
|
+
...(g === undefined ? {} : { grant: { status: g.status, version: g.version, issuedAt: g.issuedAt, expiresAt: g.expiresAt, leaseUntil: g.leaseUntil } }),
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
|
|
834
|
+
/** 清单:必须显式给 origin(可再叠加 task/session/grant)——不给就空,绝不跨来源泄露。 */
|
|
835
|
+
listPending(filter = {}) {
|
|
836
|
+
const out = []
|
|
837
|
+
for (const p of pendings.values()) {
|
|
838
|
+
if (filter.approvalKey !== undefined && p.approvalKey !== filter.approvalKey) continue
|
|
839
|
+
if (filter.originThreadId !== undefined && p.originThreadId !== filter.originThreadId) continue
|
|
840
|
+
if (filter.taskId !== undefined && p.taskId !== filter.taskId) continue
|
|
841
|
+
if (filter.sessionId !== undefined && p.sessionId !== filter.sessionId) continue
|
|
842
|
+
if (filter.grantId !== undefined && p.grantId !== filter.grantId) continue
|
|
843
|
+
out.push(p)
|
|
844
|
+
}
|
|
845
|
+
out.sort((a, b) => a.createdAt - b.createdAt)
|
|
846
|
+
return out
|
|
847
|
+
},
|
|
848
|
+
|
|
849
|
+
/** 该 task 是否还有未决 pending(用于"并发时不能先把 task 设 running")。 */
|
|
850
|
+
liveCountForTask(taskId) {
|
|
851
|
+
let count = 0
|
|
852
|
+
for (const p of pendings.values()) {
|
|
853
|
+
if (p.taskId === taskId && p.state === APPROVAL_STATES.PENDING) count++
|
|
854
|
+
}
|
|
855
|
+
return count
|
|
856
|
+
},
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* 一次性决定(修正 A/B/C/D/F):
|
|
860
|
+
* 身份(origin/task/grant/version)+ 凭据摘要 + 租约/绝对 TTL + 官方身份已唯一认领
|
|
861
|
+
* + **实时快照身份仍等于** pending 的不可变副本 + 指纹一致 + 审计 intent 先行。
|
|
862
|
+
*/
|
|
863
|
+
decide({ approvalKey, fingerprint, decision, idempotencyKey, credDigest, originThreadId, taskId, grantVersion, currentSnapshotId, currentTurnStep, now = Date.now() }) {
|
|
864
|
+
const pending = pendings.get(approvalKey)
|
|
865
|
+
if (pending === undefined) return { ok: false, reason: APPROVAL_REASONS.PENDING_MISSING }
|
|
866
|
+
const outcome = OUTCOME_FOR[decision]
|
|
867
|
+
if (outcome === undefined) return { ok: false, reason: 'unknown-decision' }
|
|
868
|
+
|
|
869
|
+
if (pending.state !== APPROVAL_STATES.PENDING) {
|
|
870
|
+
if (typeof idempotencyKey === 'string' && idempotencyKey !== '' && pending.idempotencyKey === idempotencyKey) {
|
|
871
|
+
if (pending.decision === decision) return { ok: true, state: pending.state, outcome: pending.outcome, idempotent: true }
|
|
872
|
+
return { ok: false, reason: APPROVAL_REASONS.IDEMPOTENCY_CONFLICT }
|
|
873
|
+
}
|
|
874
|
+
return { ok: false, reason: APPROVAL_REASONS.PENDING_ALREADY_DECIDED, state: pending.state }
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (originThreadId === undefined || taskId === undefined) return { ok: false, reason: originThreadId === undefined ? APPROVAL_REASONS.ORIGIN_REQUIRED : APPROVAL_REASONS.TASK_REQUIRED }
|
|
878
|
+
if (originThreadId !== pending.originThreadId) return { ok: false, reason: APPROVAL_REASONS.ORIGIN_MISMATCH }
|
|
879
|
+
if (taskId !== pending.taskId) return { ok: false, reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH }
|
|
880
|
+
if (grantVersion !== undefined && grantVersion !== pending.grantVersion) return { ok: false, reason: APPROVAL_REASONS.GRANT_VERSION_MISMATCH }
|
|
881
|
+
if (typeof pending.officialId !== 'string' || pending.officialId === '') return { ok: false, reason: APPROVAL_REASONS.OFFICIAL_UNBOUND }
|
|
882
|
+
|
|
883
|
+
const check = store.validateGrant({
|
|
884
|
+
grantId: pending.grantId, taskId: pending.taskId, originThreadId: pending.originThreadId,
|
|
885
|
+
sessionId: pending.sessionId, credDigest, grantVersion: pending.grantVersion, now,
|
|
886
|
+
}, { ignoreLease: true })
|
|
887
|
+
if (check.ok !== true) return { ok: false, reason: check.reason }
|
|
888
|
+
|
|
889
|
+
const windowEnd = Math.min(check.grant.expiresAt, check.grant.leaseUntil)
|
|
890
|
+
if (now > windowEnd) {
|
|
891
|
+
pending.state = APPROVAL_STATES.EXPIRED
|
|
892
|
+
pending.note = 'grant lease/window expired before a decision arrived'
|
|
893
|
+
store.audit({ type: 'decision', phase: 'expired', approvalKey, grantId: pending.grantId, taskId: pending.taskId })
|
|
894
|
+
return { ok: false, reason: APPROVAL_REASONS.PENDING_EXPIRED }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// 实时执行身份必须是**同一个**(第二轮修正):完整身份 = toolName/root/parent/cwd/args + 当前日志 turn/step。
|
|
898
|
+
// 只比 coarse snapshotId 不够——root-only / tool-only / **step-only(仅日志新 tool/call)** 都必须被识别。
|
|
899
|
+
const live = store.getSnapshot(pending.sessionId, pending.callId)
|
|
900
|
+
if (live === undefined) return { ok: false, reason: APPROVAL_REASONS.CONTEXT_CHANGED }
|
|
901
|
+
if (currentSnapshotId !== undefined && currentSnapshotId !== pending.snapshotId) {
|
|
902
|
+
return { ok: false, reason: APPROVAL_REASONS.CONTEXT_CHANGED }
|
|
903
|
+
}
|
|
904
|
+
if (live.snapshotId !== pending.snapshotId) return { ok: false, reason: APPROVAL_REASONS.CONTEXT_CHANGED }
|
|
905
|
+
if (live.identityComplete !== true) return { ok: false, reason: APPROVAL_REASONS.IDENTITY_UNKNOWN }
|
|
906
|
+
const turn = currentTurnStep !== null && typeof currentTurnStep === 'object' ? currentTurnStep.turn : undefined
|
|
907
|
+
const step = currentTurnStep !== null && typeof currentTurnStep === 'object' ? currentTurnStep.step : undefined
|
|
908
|
+
if (!Number.isInteger(turn) || !Number.isInteger(step)) {
|
|
909
|
+
// 拿不到当前 turn/step(日志不可读/无 tool/call)→ 明确 unsupported,不猜、不放行
|
|
910
|
+
return { ok: false, reason: APPROVAL_REASONS.IDENTITY_UNKNOWN }
|
|
911
|
+
}
|
|
912
|
+
const liveIdentity = store.executionIdentity(live, { turn, step })
|
|
913
|
+
if (liveIdentity === null) return { ok: false, reason: APPROVAL_REASONS.IDENTITY_UNKNOWN }
|
|
914
|
+
if (liveIdentity !== pending.executionId) return { ok: false, reason: APPROVAL_REASONS.CONTEXT_CHANGED }
|
|
915
|
+
|
|
916
|
+
if (typeof fingerprint !== 'string' || fingerprint !== pending.fingerprint) {
|
|
917
|
+
return { ok: false, reason: APPROVAL_REASONS.FINGERPRINT_MISMATCH }
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const intentOk = store.audit({
|
|
921
|
+
type: 'decision', phase: 'submitted', approvalKey, grantId: pending.grantId, grantVersion: pending.grantVersion,
|
|
922
|
+
taskId: pending.taskId, originThreadId: pending.originThreadId, sessionId: pending.sessionId,
|
|
923
|
+
callId: pending.callId, toolName: pending.toolName, decision, fingerprint: pending.fingerprint,
|
|
924
|
+
officialId: pending.officialId, snapshotId: pending.snapshotId,
|
|
925
|
+
idempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined,
|
|
926
|
+
credDigest: typeof credDigest === 'string' ? credDigest.slice(0, 24) : undefined,
|
|
927
|
+
...auditActionSummary(pending.execution),
|
|
928
|
+
})
|
|
929
|
+
if (!intentOk) return { ok: false, reason: APPROVAL_REASONS.AUDIT_WRITE_FAILED }
|
|
930
|
+
|
|
931
|
+
pending.state = APPROVAL_STATES.SUBMITTED
|
|
932
|
+
pending.decision = decision
|
|
933
|
+
pending.outcome = outcome
|
|
934
|
+
pending.idempotencyKey = typeof idempotencyKey === 'string' ? idempotencyKey : undefined
|
|
935
|
+
pending.decidedAt = now
|
|
936
|
+
return { ok: true, state: pending.state, outcome, approvalKey }
|
|
937
|
+
},
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* 官方 approval/decided 观测 → 按**具体 officialId** 收敛;outcome 决定状态(修正 F):
|
|
941
|
+
* allowed-once → confirmed;cancelled → cancelled;rejected → rejected;其它 → decision-unknown。
|
|
942
|
+
* 绝不因"我们返回过 allowed-once"就记 confirmed,也绝不把 cancelled/rejected 记成批准。
|
|
943
|
+
*/
|
|
944
|
+
settleFromOfficial(officialId, officialOutcome) {
|
|
945
|
+
let target
|
|
946
|
+
for (const p of pendings.values()) {
|
|
947
|
+
if (p.officialId === officialId) { target = p; break }
|
|
948
|
+
}
|
|
949
|
+
if (target === undefined) {
|
|
950
|
+
orphans.push({ officialId, officialOutcome, at: Date.now() })
|
|
951
|
+
return { ok: false, reason: APPROVAL_REASONS.PENDING_MISSING }
|
|
952
|
+
}
|
|
953
|
+
if (officialOutcome === 'allowed-once') {
|
|
954
|
+
if (target.state === APPROVAL_STATES.CONFIRMED) return { ok: true, state: target.state, idempotent: true }
|
|
955
|
+
if (target.state !== APPROVAL_STATES.SUBMITTED) return { ok: false, reason: APPROVAL_REASONS.PENDING_ALREADY_DECIDED, state: target.state }
|
|
956
|
+
target.state = APPROVAL_STATES.CONFIRMED
|
|
957
|
+
target.confirmedOutcome = officialOutcome
|
|
958
|
+
store.audit({ type: 'decision', phase: 'confirmed', approvalKey: target.approvalKey, grantId: target.grantId, taskId: target.taskId, officialId, outcome: officialOutcome })
|
|
959
|
+
return { ok: true, state: target.state }
|
|
960
|
+
}
|
|
961
|
+
// cancelled 可以赢过晚到的 allowed-once;rejected 亦然——都不算批准
|
|
962
|
+
const mapped = officialOutcome === 'cancelled' ? APPROVAL_STATES.CANCELLED
|
|
963
|
+
: officialOutcome === 'rejected' ? APPROVAL_STATES.REJECTED
|
|
964
|
+
: APPROVAL_STATES.DECISION_UNKNOWN
|
|
965
|
+
if (target.state === mapped) return { ok: true, state: target.state, idempotent: true }
|
|
966
|
+
target.state = mapped
|
|
967
|
+
target.confirmedOutcome = officialOutcome
|
|
968
|
+
target.note = 'official outcome=' + String(officialOutcome)
|
|
969
|
+
store.audit({ type: 'decision', phase: mapped, approvalKey: target.approvalKey, grantId: target.grantId, taskId: target.taskId, officialId, outcome: officialOutcome })
|
|
970
|
+
return { ok: true, state: target.state }
|
|
971
|
+
},
|
|
972
|
+
|
|
973
|
+
markDecisionUnknown(approvalKey, why) {
|
|
974
|
+
const pending = pendings.get(approvalKey)
|
|
975
|
+
if (pending === undefined) return { ok: false, reason: APPROVAL_REASONS.PENDING_MISSING }
|
|
976
|
+
if (pending.state !== APPROVAL_STATES.SUBMITTED) return { ok: true, state: pending.state, idempotent: true }
|
|
977
|
+
pending.state = APPROVAL_STATES.DECISION_UNKNOWN
|
|
978
|
+
pending.note = typeof why === 'string' ? sanitizePreview(why, 200) : 'decision confirmation lost'
|
|
979
|
+
store.audit({ type: 'decision', phase: 'unknown', approvalKey, grantId: pending.grantId, taskId: pending.taskId })
|
|
980
|
+
return { ok: true, state: pending.state }
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
/** 收口(交还人工/上下文变化/取消/卸载)。只会动 PENDING 的项。 */
|
|
984
|
+
closePending(approvalKey, state, note) {
|
|
985
|
+
const pending = pendings.get(approvalKey)
|
|
986
|
+
if (pending === undefined) return { ok: false, reason: APPROVAL_REASONS.PENDING_MISSING }
|
|
987
|
+
if (pending.state !== APPROVAL_STATES.PENDING) return { ok: true, state: pending.state, idempotent: true }
|
|
988
|
+
pending.state = state
|
|
989
|
+
pending.note = typeof note === 'string' ? sanitizePreview(note, 200) : undefined
|
|
990
|
+
store.audit({ type: 'decision', phase: state, approvalKey, grantId: pending.grantId, taskId: pending.taskId, note: pending.note })
|
|
991
|
+
return { ok: true, state }
|
|
992
|
+
},
|
|
993
|
+
|
|
994
|
+
/** 卸载/撤销时把该 task(或全部)在途挂起项安全收口。 */
|
|
995
|
+
closeLiveForTask(taskId, state, note) {
|
|
996
|
+
let closed = 0
|
|
997
|
+
for (const p of pendings.values()) {
|
|
998
|
+
if (taskId !== undefined && p.taskId !== taskId) continue
|
|
999
|
+
if (p.state !== APPROVAL_STATES.PENDING) continue
|
|
1000
|
+
store.closePending(p.approvalKey, state, note)
|
|
1001
|
+
closed++
|
|
1002
|
+
}
|
|
1003
|
+
return closed
|
|
1004
|
+
},
|
|
1005
|
+
|
|
1006
|
+
/** 决定已提交但未确认的(供重启/收口时显式标 unknown)。 */
|
|
1007
|
+
submittedKeys() {
|
|
1008
|
+
const out = []
|
|
1009
|
+
for (const p of pendings.values()) if (p.state === APPROVAL_STATES.SUBMITTED) out.push(p.approvalKey)
|
|
1010
|
+
return out
|
|
1011
|
+
},
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* 重启/恢复视图(修正 F):审计里 open 过但状态未定的项必须**明确**出现,不能静默消失:
|
|
1015
|
+
* - 有 submitted 无 confirmed → decision-unknown / live:false
|
|
1016
|
+
* - 无决定记录 → unanswerable / live:false
|
|
1017
|
+
* - confirmed/终态 → 不再列出
|
|
1018
|
+
*/
|
|
1019
|
+
unansweredFromAudit() {
|
|
1020
|
+
const rows = readJsonl(auditFile)
|
|
1021
|
+
const opened = new Map()
|
|
1022
|
+
const decided = new Map()
|
|
1023
|
+
for (const row of rows) {
|
|
1024
|
+
if (row.type === 'approval' && row.phase === 'open' && typeof row.approvalKey === 'string') opened.set(row.approvalKey, row)
|
|
1025
|
+
if (row.type === 'decision' && typeof row.approvalKey === 'string') {
|
|
1026
|
+
const list = decided.get(row.approvalKey) || []
|
|
1027
|
+
list.push(row)
|
|
1028
|
+
decided.set(row.approvalKey, list)
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const out = []
|
|
1032
|
+
for (const [approvalKey, row] of opened) {
|
|
1033
|
+
// 第三轮修正:**本进程内存里仍有确切状态/有效 callback 的同一条绝不列为"重启残留"**
|
|
1034
|
+
// (真实 MCP 输出曾把同一个 pending 同时列进 approvals(live:true) 和 unanswerable)。
|
|
1035
|
+
// 只有真正丢过 callback 的记录(重启后新 store 里没有该 pending)才进入恢复视图。
|
|
1036
|
+
if (pendings.has(approvalKey)) continue
|
|
1037
|
+
const history = decided.get(approvalKey) || []
|
|
1038
|
+
const phases = history.map((r) => r.phase)
|
|
1039
|
+
if (phases.some((p) => ['confirmed', 'rejected', 'cancelled', 'expired', 'context-changed', 'unsupported-ambiguous'].includes(p))) continue
|
|
1040
|
+
const submitted = phases.includes('submitted')
|
|
1041
|
+
out.push({
|
|
1042
|
+
approvalKey,
|
|
1043
|
+
sessionId: row.sessionId,
|
|
1044
|
+
callId: row.callId,
|
|
1045
|
+
toolName: row.toolName,
|
|
1046
|
+
taskId: row.taskId,
|
|
1047
|
+
originThreadId: row.originThreadId,
|
|
1048
|
+
live: false,
|
|
1049
|
+
state: submitted ? APPROVAL_STATES.DECISION_UNKNOWN : 'unanswerable',
|
|
1050
|
+
note: submitted
|
|
1051
|
+
? 'submitted but no official approval/decided was observed — confirmation unknown; do not re-send an approval'
|
|
1052
|
+
: 'no live callback after restart — cannot approve; wait for a fresh official request or human action',
|
|
1053
|
+
})
|
|
1054
|
+
}
|
|
1055
|
+
return out
|
|
1056
|
+
},
|
|
1057
|
+
|
|
1058
|
+
readAudit(limit = 50) {
|
|
1059
|
+
const rows = readJsonl(auditFile)
|
|
1060
|
+
return rows.slice(-limit).reverse()
|
|
1061
|
+
},
|
|
1062
|
+
|
|
1063
|
+
auditCount() {
|
|
1064
|
+
return readJsonl(auditFile).length
|
|
1065
|
+
},
|
|
1066
|
+
|
|
1067
|
+
/** 测试/诊断:当前在内存里的 pending 数。 */
|
|
1068
|
+
liveCount() {
|
|
1069
|
+
return pendings.size
|
|
1070
|
+
},
|
|
1071
|
+
|
|
1072
|
+
orphanDecided() {
|
|
1073
|
+
return orphans.slice()
|
|
1074
|
+
},
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
return store
|
|
1078
|
+
}
|