@shendeguize/dsh-agent-sidecar 0.1.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/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InjectGateway — the single entry point of the injection write path
|
|
3
|
+
* (design §4.d dual-path injection, §4.f.5 / §5.3 two-phase confirm,
|
|
4
|
+
* §8 threat model: server-issued one-time confirmToken).
|
|
5
|
+
*
|
|
6
|
+
* Unifies the three planes both injection paths must share (ADR-4):
|
|
7
|
+
*
|
|
8
|
+
* - **Confirmation**: two-phase `prepare` → `execute`. `prepare` re-verifies
|
|
9
|
+
* the target live and issues a crypto-random one-time confirmToken
|
|
10
|
+
* (≥128 bit, 60s TTL) bound to requestId + target + mode + message sha256.
|
|
11
|
+
* `execute` refuses missing / expired / reused tokens, and refuses a
|
|
12
|
+
* message whose hash differs from the prepare-time binding (anti-swap).
|
|
13
|
+
* Any execute attempt against a live token voids it, success or not
|
|
14
|
+
* (consume-on-attempt).
|
|
15
|
+
* - **Idempotency**: the first execute result per requestId is cached
|
|
16
|
+
* (5 min TTL) and replayed on repeats. `outcome: 'unknown'` is terminal:
|
|
17
|
+
* a repeated execute returns the cached unknown and NEVER re-fires the
|
|
18
|
+
* executor (S6 — no retry through the gateway).
|
|
19
|
+
* - **Logging**: exactly one entry per prepare/execute carrying ts /
|
|
20
|
+
* requestId / target / mode / phase / result / errorCode / message byte
|
|
21
|
+
* size and sha256 prefix — never the message body or head plaintext
|
|
22
|
+
* (the head preview only travels in the prepare response for the UI).
|
|
23
|
+
*
|
|
24
|
+
* The token gate defends against browser-mediated attackers only; it does
|
|
25
|
+
* not claim to stop a local process that can drive both phases itself
|
|
26
|
+
* (ADR-8 trust posture — same as guard.ts).
|
|
27
|
+
*
|
|
28
|
+
* Pure DI: no cordis/dsh imports. Path executors (dsh in-process,
|
|
29
|
+
* sidecar send CLI) are injected; this module owns the contract, not the
|
|
30
|
+
* transport.
|
|
31
|
+
*
|
|
32
|
+
* @module
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Public types (kept local to this module by design).
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** Injection target: one observed session of one agent. */
|
|
42
|
+
export interface InjectTarget {
|
|
43
|
+
agent: string
|
|
44
|
+
sessionId: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Injection mode, aligned with dsh `session.prompt` semantics. */
|
|
48
|
+
export type InjectMode = 'queue' | 'steer'
|
|
49
|
+
|
|
50
|
+
/** Terminal outcome vocabulary shared by both executor paths. */
|
|
51
|
+
export type InjectOutcome = 'delivered' | 'failed' | 'unknown'
|
|
52
|
+
|
|
53
|
+
/** Gateway-issued error vocabulary (executors may add their own codes). */
|
|
54
|
+
export type InjectErrorCode =
|
|
55
|
+
| 'inject_disabled'
|
|
56
|
+
| 'invalid_message'
|
|
57
|
+
| 'target_not_found'
|
|
58
|
+
| 'target_dead'
|
|
59
|
+
| 'too_many_pending'
|
|
60
|
+
| 'token_missing'
|
|
61
|
+
| 'token_expired'
|
|
62
|
+
| 'token_reused'
|
|
63
|
+
| 'token_mismatch'
|
|
64
|
+
| 'unsupported_agent'
|
|
65
|
+
| 'executor_error'
|
|
66
|
+
|
|
67
|
+
/** Result envelope returned by executors and by the gateway itself. */
|
|
68
|
+
export interface InjectResult {
|
|
69
|
+
outcome: InjectOutcome
|
|
70
|
+
/** Gateway code ({@link InjectErrorCode}) or an executor-native code. */
|
|
71
|
+
errorCode?: string
|
|
72
|
+
detail?: string
|
|
73
|
+
/** True when this is a cached first result replayed for a repeat execute. */
|
|
74
|
+
replayed?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Live target snapshot produced by the injected `verifyTarget` re-check
|
|
79
|
+
* (backed by store/bridge outside this module). Status is an observed,
|
|
80
|
+
* possibly lagging value (G11).
|
|
81
|
+
*/
|
|
82
|
+
export interface TargetStatus {
|
|
83
|
+
agent: string
|
|
84
|
+
sessionId: string
|
|
85
|
+
status: string
|
|
86
|
+
title?: string
|
|
87
|
+
project?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Confirmation plan shown to the user; the message body is never retained. */
|
|
91
|
+
export interface InjectPlan {
|
|
92
|
+
target: InjectTarget
|
|
93
|
+
mode: InjectMode
|
|
94
|
+
/** Live status snapshot captured by `prepare`'s re-verification. */
|
|
95
|
+
targetStatus: TargetStatus
|
|
96
|
+
/** Byte size + head preview for the confirm dialog (head is UI-only). */
|
|
97
|
+
messagePreview: { bytes: number; head: string }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What the gateway hands an executor once every gate has passed. */
|
|
101
|
+
export interface InjectExecutionRequest {
|
|
102
|
+
target: InjectTarget
|
|
103
|
+
mode: InjectMode
|
|
104
|
+
message: string
|
|
105
|
+
requestId: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One injection path (dsh in-process API, or the sidecar send CLI). */
|
|
109
|
+
export interface InjectExecutor {
|
|
110
|
+
kind: 'dsh' | 'send-cli'
|
|
111
|
+
execute(req: InjectExecutionRequest): Promise<InjectResult>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One audit entry. Deliberately body-free: only byte size and a sha256
|
|
116
|
+
* prefix identify the message (S8; the head preview never lands here).
|
|
117
|
+
*/
|
|
118
|
+
export interface InjectLogEntry {
|
|
119
|
+
/** Epoch ms from the injected clock. */
|
|
120
|
+
ts: number
|
|
121
|
+
phase: 'prepare' | 'execute'
|
|
122
|
+
/** Null when a prepare was rejected before a requestId was issued. */
|
|
123
|
+
requestId: string | null
|
|
124
|
+
/** Null when an execute referenced a requestId the gateway never issued. */
|
|
125
|
+
target: InjectTarget | null
|
|
126
|
+
mode: InjectMode | null
|
|
127
|
+
/** prepare: token issued; execute: outcome === 'delivered'. */
|
|
128
|
+
ok: boolean
|
|
129
|
+
outcome?: InjectOutcome
|
|
130
|
+
errorCode?: string
|
|
131
|
+
replayed?: boolean
|
|
132
|
+
/** UTF-8 byte size of the submitted message. */
|
|
133
|
+
messageBytes: number
|
|
134
|
+
/** First 12 hex chars of the message sha256 — never the body itself. */
|
|
135
|
+
messageSha12: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `prepare` input. */
|
|
139
|
+
export interface PrepareRequest {
|
|
140
|
+
target: InjectTarget
|
|
141
|
+
mode: InjectMode
|
|
142
|
+
message: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `prepare` output: an issued confirmation, or a vocabulary rejection. */
|
|
146
|
+
export type PrepareResult =
|
|
147
|
+
| {
|
|
148
|
+
ok: true
|
|
149
|
+
requestId: string
|
|
150
|
+
confirmToken: string
|
|
151
|
+
plan: InjectPlan
|
|
152
|
+
/** Epoch ms after which the confirmToken is dead. */
|
|
153
|
+
expiresAt: number
|
|
154
|
+
}
|
|
155
|
+
| { ok: false; errorCode: InjectErrorCode; detail?: string }
|
|
156
|
+
|
|
157
|
+
/** `execute` input: the confirmation issued by `prepare` plus the message. */
|
|
158
|
+
export interface ExecuteRequest {
|
|
159
|
+
requestId: string
|
|
160
|
+
confirmToken: string
|
|
161
|
+
message: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Dependency seam; everything effectful is injected. */
|
|
165
|
+
export interface InjectGatewayDeps {
|
|
166
|
+
executors: { dsh: InjectExecutor; sendCli: InjectExecutor }
|
|
167
|
+
/** Live target re-check (store/bridge outside); null = target unknown. */
|
|
168
|
+
verifyTarget(target: InjectTarget): Promise<TargetStatus | null>
|
|
169
|
+
/** Reads the `inject.enabled` gate at call time (live setting). */
|
|
170
|
+
allowWrite(): boolean
|
|
171
|
+
/** Sink for every audit entry (also kept in the internal ring). */
|
|
172
|
+
log(entry: InjectLogEntry): void
|
|
173
|
+
/** Clock override for tests; defaults to `Date.now`. */
|
|
174
|
+
now?(): number
|
|
175
|
+
/** requestId source override for tests; defaults to `randomUUID`. */
|
|
176
|
+
randomId?(): string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Tunables (exported where tests / routes need the exact boundary).
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** Message byte cap, aligned with the sidecar send 16 KiB limit (§4.d). */
|
|
184
|
+
export const MAX_MESSAGE_BYTES = 16 * 1024
|
|
185
|
+
/** confirmToken lifetime (§4.f.5). */
|
|
186
|
+
export const TOKEN_TTL_MS = 60_000
|
|
187
|
+
/** In-flight (issued, unconsumed, unexpired) token cap; beyond it new prepares are refused. */
|
|
188
|
+
export const MAX_PENDING_TOKENS = 32
|
|
189
|
+
/** Idempotency window: how long a first execute result is replayable. */
|
|
190
|
+
export const RESULT_CACHE_TTL_MS = 5 * 60_000
|
|
191
|
+
|
|
192
|
+
/** 16 random bytes = 128 bits, the spec floor for the confirmToken. */
|
|
193
|
+
const TOKEN_BYTES = 16
|
|
194
|
+
/** Head preview cap (chars) for the confirm dialog; UI-only, never logged. */
|
|
195
|
+
const HEAD_PREVIEW_CHARS = 120
|
|
196
|
+
/** Bound of the internal audit ring served by {@link InjectGateway.getRecentLog}. */
|
|
197
|
+
const LOG_RING_LIMIT = 256
|
|
198
|
+
const DEFAULT_LOG_QUERY_LIMIT = 50
|
|
199
|
+
/** Sha256 hex prefix length recorded in logs. */
|
|
200
|
+
const SHA_LOG_CHARS = 12
|
|
201
|
+
|
|
202
|
+
/** External agents reachable through the sidecar `send` CLI path (§4.d). */
|
|
203
|
+
const SEND_CLI_AGENTS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor-cli'])
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Internals.
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
interface MessageDigest {
|
|
210
|
+
bytes: number
|
|
211
|
+
sha256: string
|
|
212
|
+
sha12: string
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function digestMessage(message: string): MessageDigest {
|
|
216
|
+
const sha256 = createHash('sha256').update(message, 'utf8').digest('hex')
|
|
217
|
+
return {
|
|
218
|
+
bytes: Buffer.byteLength(message, 'utf8'),
|
|
219
|
+
sha256,
|
|
220
|
+
sha12: sha256.slice(0, SHA_LOG_CHARS),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Returns a rejection detail, or null when the message is acceptable. */
|
|
225
|
+
function validateMessage(message: string, bytes: number): string | null {
|
|
226
|
+
if (bytes === 0) return 'message is empty'
|
|
227
|
+
if (message.includes('\u0000')) return 'message contains a NUL byte'
|
|
228
|
+
if (bytes > MAX_MESSAGE_BYTES) {
|
|
229
|
+
return `message is ${bytes} bytes; limit is ${MAX_MESSAGE_BYTES}`
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Constant-time token comparison (length leak is inherent and harmless). */
|
|
235
|
+
function tokenEquals(expected: string, provided: string): boolean {
|
|
236
|
+
const a = Buffer.from(expected, 'utf8')
|
|
237
|
+
const b = Buffer.from(provided, 'utf8')
|
|
238
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** One issued confirmation, alive from prepare until pruned. */
|
|
242
|
+
interface PendingToken {
|
|
243
|
+
token: string
|
|
244
|
+
target: InjectTarget
|
|
245
|
+
mode: InjectMode
|
|
246
|
+
messageSha256: string
|
|
247
|
+
expiresAt: number
|
|
248
|
+
/** Set on the first execute attempt and never cleared (one shot). */
|
|
249
|
+
consumed: boolean
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface CachedResult {
|
|
253
|
+
result: InjectResult
|
|
254
|
+
expiresAt: number
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Gateway.
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/** The confirmation / idempotency / logging hub for both injection paths. */
|
|
262
|
+
export class InjectGateway {
|
|
263
|
+
private readonly deps: InjectGatewayDeps
|
|
264
|
+
private readonly now: () => number
|
|
265
|
+
private readonly randomId: () => string
|
|
266
|
+
private readonly pending = new Map<string, PendingToken>()
|
|
267
|
+
private readonly results = new Map<string, CachedResult>()
|
|
268
|
+
private readonly logRing: InjectLogEntry[] = []
|
|
269
|
+
|
|
270
|
+
constructor(deps: InjectGatewayDeps) {
|
|
271
|
+
this.deps = deps
|
|
272
|
+
this.now = deps.now ?? Date.now
|
|
273
|
+
this.randomId = deps.randomId ?? randomUUID
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Phase one: gate, validate, re-verify, then issue a one-time
|
|
278
|
+
* confirmation bound to this exact target + mode + message.
|
|
279
|
+
*
|
|
280
|
+
* Pipeline (order per spec): allowWrite gate → message pre-validation
|
|
281
|
+
* (≤16 KiB by bytes, non-empty, no NUL) → live target re-check →
|
|
282
|
+
* injectable-agent whitelist → capacity check → token issuance.
|
|
283
|
+
*/
|
|
284
|
+
async prepare(req: PrepareRequest): Promise<PrepareResult> {
|
|
285
|
+
this.prune(this.now())
|
|
286
|
+
const digest = digestMessage(req.message)
|
|
287
|
+
|
|
288
|
+
if (!this.deps.allowWrite()) {
|
|
289
|
+
return this.rejectPrepare(req, digest, 'inject_disabled')
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const invalid = validateMessage(req.message, digest.bytes)
|
|
293
|
+
if (invalid !== null) {
|
|
294
|
+
return this.rejectPrepare(req, digest, 'invalid_message', invalid)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const target: InjectTarget = { agent: req.target.agent, sessionId: req.target.sessionId }
|
|
298
|
+
const status = await this.deps.verifyTarget(target)
|
|
299
|
+
if (status === null) {
|
|
300
|
+
return this.rejectPrepare(req, digest, 'target_not_found')
|
|
301
|
+
}
|
|
302
|
+
if (status.status === 'dead') {
|
|
303
|
+
return this.rejectPrepare(req, digest, 'target_dead')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Agents with no injection path (copilot / kimi / cursor-ide / …) are
|
|
307
|
+
// refused HERE, before a token is issued: a prepare that can only ever
|
|
308
|
+
// fail at execute would waste a confirm dialog and in-flight capacity
|
|
309
|
+
// on a caller that gets its unsupported_agent verdict one phase late
|
|
310
|
+
// (M2 review F-6). The execute-side check stays as defense in depth.
|
|
311
|
+
if (this.executorFor(target.agent) === null) {
|
|
312
|
+
return this.rejectPrepare(req, digest, 'unsupported_agent')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Re-read the clock after the async re-check so capacity and TTL are
|
|
316
|
+
// decided at actual issuance time.
|
|
317
|
+
const issuedAt = this.now()
|
|
318
|
+
if (this.inFlightCount(issuedAt) >= MAX_PENDING_TOKENS) {
|
|
319
|
+
return this.rejectPrepare(req, digest, 'too_many_pending')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const requestId = this.randomId()
|
|
323
|
+
const confirmToken = randomBytes(TOKEN_BYTES).toString('hex')
|
|
324
|
+
const expiresAt = issuedAt + TOKEN_TTL_MS
|
|
325
|
+
this.pending.set(requestId, {
|
|
326
|
+
token: confirmToken,
|
|
327
|
+
target,
|
|
328
|
+
mode: req.mode,
|
|
329
|
+
messageSha256: digest.sha256,
|
|
330
|
+
expiresAt,
|
|
331
|
+
consumed: false,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
this.record({
|
|
335
|
+
ts: issuedAt,
|
|
336
|
+
phase: 'prepare',
|
|
337
|
+
requestId,
|
|
338
|
+
target: { ...target },
|
|
339
|
+
mode: req.mode,
|
|
340
|
+
ok: true,
|
|
341
|
+
messageBytes: digest.bytes,
|
|
342
|
+
messageSha12: digest.sha12,
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
requestId,
|
|
348
|
+
confirmToken,
|
|
349
|
+
plan: {
|
|
350
|
+
target: { ...target },
|
|
351
|
+
mode: req.mode,
|
|
352
|
+
targetStatus: { ...status },
|
|
353
|
+
messagePreview: {
|
|
354
|
+
bytes: digest.bytes,
|
|
355
|
+
head: req.message.slice(0, HEAD_PREVIEW_CHARS),
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
expiresAt,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Phase two: validate the confirmation, dispatch to the path executor,
|
|
364
|
+
* and cache the first result per requestId.
|
|
365
|
+
*
|
|
366
|
+
* Rejection order: cached replay (idempotency wins) → token missing →
|
|
367
|
+
* token reused → token expired → token/message binding mismatch →
|
|
368
|
+
* unsupported agent. Every attempt against a live token consumes it,
|
|
369
|
+
* whatever happens afterwards.
|
|
370
|
+
*/
|
|
371
|
+
async execute(req: ExecuteRequest): Promise<InjectResult> {
|
|
372
|
+
const now = this.now()
|
|
373
|
+
this.prune(now)
|
|
374
|
+
const digest = digestMessage(req.message)
|
|
375
|
+
const record = this.pending.get(req.requestId) ?? null
|
|
376
|
+
|
|
377
|
+
const cached = this.results.get(req.requestId)
|
|
378
|
+
if (cached !== undefined) {
|
|
379
|
+
// A replay must still present the original binding (anti-swap holds
|
|
380
|
+
// on the idempotent path too). The binding facts live on the pending
|
|
381
|
+
// record — and the prune() constants do NOT make "cache alive but
|
|
382
|
+
// pending pruned" impossible: the cache expiry stamp is taken AFTER
|
|
383
|
+
// the executor awaited, so an executor that runs past the token's
|
|
384
|
+
// nominal expiry (send-cli worst case ~35s) stretches the cache past
|
|
385
|
+
// the pending prune time (issuedAt + TOKEN_TTL_MS +
|
|
386
|
+
// RESULT_CACHE_TTL_MS). In that window an unverifiable replay must
|
|
387
|
+
// fail closed rather than hand a cached result to a caller that only
|
|
388
|
+
// knows the requestId (M2 review F-5).
|
|
389
|
+
if (record === null) {
|
|
390
|
+
return this.rejectExecute(req.requestId, null, digest, 'token_missing')
|
|
391
|
+
}
|
|
392
|
+
if (
|
|
393
|
+
!tokenEquals(record.token, req.confirmToken) ||
|
|
394
|
+
record.messageSha256 !== digest.sha256
|
|
395
|
+
) {
|
|
396
|
+
return this.rejectExecute(req.requestId, record, digest, 'token_mismatch')
|
|
397
|
+
}
|
|
398
|
+
const replay: InjectResult = { ...cached.result, replayed: true }
|
|
399
|
+
this.logExecuteResult(req.requestId, record, digest, replay)
|
|
400
|
+
return replay
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!req.confirmToken) {
|
|
404
|
+
// A malformed request (no token at all) is not an "attempt": the
|
|
405
|
+
// issued token, if any, stays live.
|
|
406
|
+
return this.rejectExecute(req.requestId, record, digest, 'token_missing')
|
|
407
|
+
}
|
|
408
|
+
if (record === null) {
|
|
409
|
+
return this.rejectExecute(req.requestId, null, digest, 'token_missing')
|
|
410
|
+
}
|
|
411
|
+
if (record.consumed) {
|
|
412
|
+
return this.rejectExecute(req.requestId, record, digest, 'token_reused')
|
|
413
|
+
}
|
|
414
|
+
if (record.expiresAt <= now) {
|
|
415
|
+
this.pending.delete(req.requestId)
|
|
416
|
+
return this.rejectExecute(req.requestId, record, digest, 'token_expired')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Consume-on-attempt: one execute attempt per issued token, success or
|
|
420
|
+
// not. A later retry needs a fresh prepare (fresh user confirmation).
|
|
421
|
+
record.consumed = true
|
|
422
|
+
|
|
423
|
+
if (!tokenEquals(record.token, req.confirmToken)) {
|
|
424
|
+
return this.rejectExecute(req.requestId, record, digest, 'token_mismatch')
|
|
425
|
+
}
|
|
426
|
+
if (record.messageSha256 !== digest.sha256) {
|
|
427
|
+
return this.rejectExecute(req.requestId, record, digest, 'token_mismatch')
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const executor = this.executorFor(record.target.agent)
|
|
431
|
+
if (executor === null) {
|
|
432
|
+
return this.rejectExecute(req.requestId, record, digest, 'unsupported_agent')
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let result: InjectResult
|
|
436
|
+
try {
|
|
437
|
+
result = await executor.execute({
|
|
438
|
+
target: { ...record.target },
|
|
439
|
+
mode: record.mode,
|
|
440
|
+
message: req.message,
|
|
441
|
+
requestId: req.requestId,
|
|
442
|
+
})
|
|
443
|
+
} catch (err) {
|
|
444
|
+
result = {
|
|
445
|
+
outcome: 'failed',
|
|
446
|
+
errorCode: 'executor_error',
|
|
447
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Cache every terminal result — delivered, failed, and unknown alike.
|
|
452
|
+
// 'unknown' is thereby terminal: replays serve this cache and the
|
|
453
|
+
// consumed token blocks any second dispatch forever (S6).
|
|
454
|
+
this.results.set(req.requestId, {
|
|
455
|
+
result: { ...result },
|
|
456
|
+
expiresAt: this.now() + RESULT_CACHE_TTL_MS,
|
|
457
|
+
})
|
|
458
|
+
this.logExecuteResult(req.requestId, record, digest, result)
|
|
459
|
+
return result
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Read-only audit view (newest first), for the M3 detail page. */
|
|
463
|
+
getRecentLog(limit: number = DEFAULT_LOG_QUERY_LIMIT): InjectLogEntry[] {
|
|
464
|
+
const bounded = Math.min(Math.max(Math.floor(limit), 0), this.logRing.length)
|
|
465
|
+
return this.logRing.slice(this.logRing.length - bounded).reverse()
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// -------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
private executorFor(agent: string): InjectExecutor | null {
|
|
471
|
+
if (agent === 'dsh') return this.deps.executors.dsh
|
|
472
|
+
if (SEND_CLI_AGENTS.has(agent)) return this.deps.executors.sendCli
|
|
473
|
+
return null
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Issued-but-unconsumed-and-unexpired tokens count toward the cap. */
|
|
477
|
+
private inFlightCount(now: number): number {
|
|
478
|
+
let count = 0
|
|
479
|
+
for (const record of this.pending.values()) {
|
|
480
|
+
if (!record.consumed && record.expiresAt > now) count += 1
|
|
481
|
+
}
|
|
482
|
+
return count
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Housekeeping. Pending records outlive their token TTL by the result
|
|
487
|
+
* cache window so that late replays keep their binding check and late
|
|
488
|
+
* reuse attempts still answer `token_reused` (not `token_missing`).
|
|
489
|
+
*/
|
|
490
|
+
private prune(now: number): void {
|
|
491
|
+
for (const [id, record] of this.pending) {
|
|
492
|
+
if (record.expiresAt + RESULT_CACHE_TTL_MS <= now) this.pending.delete(id)
|
|
493
|
+
}
|
|
494
|
+
for (const [id, cached] of this.results) {
|
|
495
|
+
if (cached.expiresAt <= now) this.results.delete(id)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private rejectPrepare(
|
|
500
|
+
req: PrepareRequest,
|
|
501
|
+
digest: MessageDigest,
|
|
502
|
+
errorCode: InjectErrorCode,
|
|
503
|
+
detail?: string,
|
|
504
|
+
): PrepareResult {
|
|
505
|
+
this.record({
|
|
506
|
+
ts: this.now(),
|
|
507
|
+
phase: 'prepare',
|
|
508
|
+
requestId: null,
|
|
509
|
+
target: { agent: req.target.agent, sessionId: req.target.sessionId },
|
|
510
|
+
mode: req.mode,
|
|
511
|
+
ok: false,
|
|
512
|
+
errorCode,
|
|
513
|
+
messageBytes: digest.bytes,
|
|
514
|
+
messageSha12: digest.sha12,
|
|
515
|
+
})
|
|
516
|
+
return detail === undefined
|
|
517
|
+
? { ok: false, errorCode }
|
|
518
|
+
: { ok: false, errorCode, detail }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private rejectExecute(
|
|
522
|
+
requestId: string,
|
|
523
|
+
record: PendingToken | null,
|
|
524
|
+
digest: MessageDigest,
|
|
525
|
+
errorCode: InjectErrorCode,
|
|
526
|
+
): InjectResult {
|
|
527
|
+
this.record({
|
|
528
|
+
ts: this.now(),
|
|
529
|
+
phase: 'execute',
|
|
530
|
+
requestId,
|
|
531
|
+
target: record === null ? null : { ...record.target },
|
|
532
|
+
mode: record === null ? null : record.mode,
|
|
533
|
+
ok: false,
|
|
534
|
+
outcome: 'failed',
|
|
535
|
+
errorCode,
|
|
536
|
+
messageBytes: digest.bytes,
|
|
537
|
+
messageSha12: digest.sha12,
|
|
538
|
+
})
|
|
539
|
+
return { outcome: 'failed', errorCode }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private logExecuteResult(
|
|
543
|
+
requestId: string,
|
|
544
|
+
record: PendingToken | null,
|
|
545
|
+
digest: MessageDigest,
|
|
546
|
+
result: InjectResult,
|
|
547
|
+
): void {
|
|
548
|
+
this.record({
|
|
549
|
+
ts: this.now(),
|
|
550
|
+
phase: 'execute',
|
|
551
|
+
requestId,
|
|
552
|
+
target: record === null ? null : { ...record.target },
|
|
553
|
+
mode: record === null ? null : record.mode,
|
|
554
|
+
ok: result.outcome === 'delivered',
|
|
555
|
+
outcome: result.outcome,
|
|
556
|
+
...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
|
|
557
|
+
...(result.replayed !== undefined ? { replayed: result.replayed } : {}),
|
|
558
|
+
messageBytes: digest.bytes,
|
|
559
|
+
messageSha12: digest.sha12,
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private record(entry: InjectLogEntry): void {
|
|
564
|
+
const frozen: InjectLogEntry = Object.freeze({
|
|
565
|
+
...entry,
|
|
566
|
+
target: entry.target === null ? null : Object.freeze({ ...entry.target }),
|
|
567
|
+
})
|
|
568
|
+
this.logRing.push(frozen)
|
|
569
|
+
if (this.logRing.length > LOG_RING_LIMIT) {
|
|
570
|
+
this.logRing.splice(0, this.logRing.length - LOG_RING_LIMIT)
|
|
571
|
+
}
|
|
572
|
+
this.deps.log(frozen)
|
|
573
|
+
}
|
|
574
|
+
}
|