@agent-guardrails/opencode-shell-warden-hook 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -52
- package/package.json +1 -1
- package/src/apply.ts +23 -48
- package/src/checker.ts +181 -0
- package/src/config.ts +121 -133
- package/src/index.ts +76 -57
- package/src/types.ts +36 -54
- package/src/protocol.ts +0 -87
- package/src/supervisor.ts +0 -412
package/src/protocol.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
// NDJSON JSON-RPC framing — docs/policyd-plugin-design/02-protocol.md §2.1–2.2
|
|
2
|
-
// One UTF-8 JSON value per \n-terminated line; lines over the byte cap are a
|
|
3
|
-
// protocol violation; unparseable lines are a protocol violation.
|
|
4
|
-
|
|
5
|
-
import type { WireMessage } from "./types.ts"
|
|
6
|
-
|
|
7
|
-
export class FrameError extends Error {
|
|
8
|
-
readonly kind: "frame-limit" | "parse"
|
|
9
|
-
constructor(kind: "frame-limit" | "parse") {
|
|
10
|
-
super(`protocol violation: ${kind}`)
|
|
11
|
-
this.kind = kind
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function encodeMessage(msg: WireMessage, maxFrameBytes: number): string {
|
|
16
|
-
const line = JSON.stringify(msg)
|
|
17
|
-
if (Buffer.byteLength(line, "utf8") + 1 > maxFrameBytes) {
|
|
18
|
-
throw new FrameError("frame-limit")
|
|
19
|
-
}
|
|
20
|
-
return line + "\n"
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Incremental line parser for a byte stream. After `fatal` is set the parser
|
|
25
|
-
* stops consuming input; the caller must treat the connection as violated.
|
|
26
|
-
*/
|
|
27
|
-
export class FrameParser {
|
|
28
|
-
private buf = ""
|
|
29
|
-
private queue: WireMessage[] = []
|
|
30
|
-
fatal: string | null = null
|
|
31
|
-
|
|
32
|
-
private readonly maxFrameBytes: number
|
|
33
|
-
constructor(maxFrameBytes: number) {
|
|
34
|
-
this.maxFrameBytes = maxFrameBytes
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
push(chunk: Buffer | string): void {
|
|
38
|
-
if (this.fatal !== null) return
|
|
39
|
-
this.buf += typeof chunk === "string" ? chunk : chunk.toString("utf8")
|
|
40
|
-
for (;;) {
|
|
41
|
-
const nl = this.buf.indexOf("\n")
|
|
42
|
-
if (nl === -1) {
|
|
43
|
-
if (Buffer.byteLength(this.buf, "utf8") > this.maxFrameBytes) this.fatal = "frame-limit"
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
const line = this.buf.slice(0, nl)
|
|
47
|
-
this.buf = this.buf.slice(nl + 1)
|
|
48
|
-
if (Buffer.byteLength(line, "utf8") > this.maxFrameBytes) {
|
|
49
|
-
this.fatal = "frame-limit"
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
if (line.trim().length === 0) {
|
|
53
|
-
this.fatal = "parse" // blank lines are not valid NDJSON
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
let msg: unknown
|
|
57
|
-
try {
|
|
58
|
-
msg = JSON.parse(line)
|
|
59
|
-
} catch {
|
|
60
|
-
this.fatal = "parse"
|
|
61
|
-
return
|
|
62
|
-
}
|
|
63
|
-
if (msg === null || typeof msg !== "object") {
|
|
64
|
-
this.fatal = "parse"
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
this.queue.push(msg as WireMessage)
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
drain(): WireMessage[] {
|
|
72
|
-
const q = this.queue
|
|
73
|
-
this.queue = []
|
|
74
|
-
return q
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Monotonic JSON-RPC id source; reset per engine connection. */
|
|
79
|
-
export class IdPool {
|
|
80
|
-
private next = 1
|
|
81
|
-
take(): number {
|
|
82
|
-
return this.next++
|
|
83
|
-
}
|
|
84
|
-
reset(): void {
|
|
85
|
-
this.next = 1
|
|
86
|
-
}
|
|
87
|
-
}
|
package/src/supervisor.ts
DELETED
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
// Engine supervisor — docs/policyd-plugin-design/03-shim.md §3.3, 02-protocol.md §2.7
|
|
2
|
-
// Lazy spawn (empty env, absolute argv), exact-version handshake, per-check
|
|
3
|
-
// deadlines, violation/restart ladder, unhealthy cooldown, fail-closed dispose.
|
|
4
|
-
|
|
5
|
-
import { spawn } from "node:child_process"
|
|
6
|
-
import type { ChildProcess } from "node:child_process"
|
|
7
|
-
import { createHash } from "node:crypto"
|
|
8
|
-
import { createReadStream } from "node:fs"
|
|
9
|
-
import { FrameParser, IdPool, encodeMessage } from "./protocol.ts"
|
|
10
|
-
import type { CheckParams, CheckResult, WireMessage } from "./types.ts"
|
|
11
|
-
import type { CheckOutcome } from "./apply.ts"
|
|
12
|
-
|
|
13
|
-
export interface SupervisorOptions {
|
|
14
|
-
command: string[]
|
|
15
|
-
protocolVersion: number
|
|
16
|
-
sha256: string | null
|
|
17
|
-
cwd: string | null
|
|
18
|
-
clientVersion: string
|
|
19
|
-
budgets: {
|
|
20
|
-
handshakeTimeoutMs: number
|
|
21
|
-
checkTimeoutMs: number
|
|
22
|
-
killGraceMs: number
|
|
23
|
-
maxFrameBytes: number
|
|
24
|
-
maxInFlight: number
|
|
25
|
-
maxViolations: number
|
|
26
|
-
maxViolationsWindowMs: number
|
|
27
|
-
maxBackoffMs: number
|
|
28
|
-
windowMs: number
|
|
29
|
-
resetAfterMs: number
|
|
30
|
-
}
|
|
31
|
-
log?: (level: "debug" | "info" | "warn" | "error", msg: string) => void
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
type Waiter = {
|
|
35
|
-
resolve: (o: CheckOutcome) => void
|
|
36
|
-
timer: ReturnType<typeof setTimeout>
|
|
37
|
-
id: number | null
|
|
38
|
-
params: CheckParams
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const MAX_QUEUED = 64
|
|
42
|
-
const FATAL_COOLDOWN_MS = 15 * 60_000
|
|
43
|
-
const KILL_KINDS = new Set(["frame-limit", "parse", "version-mismatch", "handshake-timeout", "timeout", "exit", "spawn"])
|
|
44
|
-
|
|
45
|
-
export class EngineSupervisor {
|
|
46
|
-
private state: "cold" | "spawning" | "handshaking" | "ready" | "restarting" | "unhealthy" | "disposed" = "cold"
|
|
47
|
-
private child: ChildProcess | null = null
|
|
48
|
-
private parser: FrameParser
|
|
49
|
-
private ids = new IdPool()
|
|
50
|
-
private pending = new Map<number, Waiter>()
|
|
51
|
-
private queue: Waiter[] = []
|
|
52
|
-
private handshakeId: number | null = null
|
|
53
|
-
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
|
54
|
-
private violations = { count: 0, windowStart: 0 }
|
|
55
|
-
private restartsAt: number[] = []
|
|
56
|
-
private backoffLevel = 0
|
|
57
|
-
private unhealthyUntil = 0
|
|
58
|
-
private lastReadyAt = 0
|
|
59
|
-
private spawnTimer: ReturnType<typeof setTimeout> | null = null
|
|
60
|
-
private graceTimer: ReturnType<typeof setTimeout> | null = null
|
|
61
|
-
private stderrRing: string[] = []
|
|
62
|
-
private stderrBytes = 0
|
|
63
|
-
private disposed = false
|
|
64
|
-
|
|
65
|
-
private readonly opts: SupervisorOptions
|
|
66
|
-
private readonly now: () => number
|
|
67
|
-
|
|
68
|
-
constructor(opts: SupervisorOptions, now: () => number = Date.now) {
|
|
69
|
-
this.opts = opts
|
|
70
|
-
this.now = now
|
|
71
|
-
this.parser = new FrameParser(opts.budgets.maxFrameBytes)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
get currentState(): string {
|
|
75
|
-
return this.state
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
lastStderr(): string {
|
|
79
|
-
return this.stderrRing.join("")
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async check(params: CheckParams): Promise<CheckOutcome> {
|
|
83
|
-
if (this.disposed || this.state === "disposed") return { ok: false, reason: "disposed" }
|
|
84
|
-
if (this.now() < this.unhealthyUntil) return { ok: false, reason: "unhealthy" }
|
|
85
|
-
void this.ensureEngine()
|
|
86
|
-
return new Promise<CheckOutcome>((resolve) => {
|
|
87
|
-
if (this.state === "ready" && this.child !== null && this.pending.size < this.opts.budgets.maxInFlight) {
|
|
88
|
-
this.sendCheck(resolve, params)
|
|
89
|
-
return
|
|
90
|
-
}
|
|
91
|
-
if (this.queue.length >= MAX_QUEUED) {
|
|
92
|
-
resolve({ ok: false, reason: "backpressure" })
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
const waiter: Waiter = {
|
|
96
|
-
resolve,
|
|
97
|
-
id: null,
|
|
98
|
-
params,
|
|
99
|
-
timer: setTimeout(() => this.failWaiter(waiter, "timeout"), this.opts.budgets.checkTimeoutMs),
|
|
100
|
-
}
|
|
101
|
-
this.queue.push(waiter)
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Fire-and-forget audit notification (policy/observe); dropped unless ready. */
|
|
106
|
-
observe(notification: Record<string, unknown>): void {
|
|
107
|
-
if (this.state !== "ready" || this.child === null) return
|
|
108
|
-
try {
|
|
109
|
-
this.child.stdin?.write(encodeMessage({ jsonrpc: "2.0", method: "policy/observe", params: notification }, this.opts.budgets.maxFrameBytes))
|
|
110
|
-
} catch {
|
|
111
|
-
/* audit must never throw */
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
dispose(): void {
|
|
116
|
-
if (this.disposed) return
|
|
117
|
-
this.disposed = true
|
|
118
|
-
this.state = "disposed"
|
|
119
|
-
this.clearTimers()
|
|
120
|
-
this.rejectPending("disposed")
|
|
121
|
-
this.rejectQueued("disposed")
|
|
122
|
-
if (this.child !== null) {
|
|
123
|
-
try {
|
|
124
|
-
this.child.kill("SIGKILL")
|
|
125
|
-
} catch {
|
|
126
|
-
/* already gone */
|
|
127
|
-
}
|
|
128
|
-
this.child = null
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ---------- internal ----------
|
|
133
|
-
|
|
134
|
-
private ensureEngine(): void {
|
|
135
|
-
if (this.disposed || this.state !== "cold") return
|
|
136
|
-
if (this.now() < this.unhealthyUntil) {
|
|
137
|
-
this.rejectQueued("unhealthy")
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
if (this.lastReadyAt !== 0 && this.now() - this.lastReadyAt >= this.opts.budgets.resetAfterMs) {
|
|
141
|
-
this.restartsAt = []
|
|
142
|
-
this.backoffLevel = 0
|
|
143
|
-
}
|
|
144
|
-
this.state = "spawning"
|
|
145
|
-
void this.spawnNow()
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
private async spawnNow(): Promise<void> {
|
|
149
|
-
if (this.disposed || this.state !== "spawning") return
|
|
150
|
-
if (this.opts.sha256 !== null) {
|
|
151
|
-
const ok = await this.verifySha256(this.opts.sha256)
|
|
152
|
-
if (!ok) {
|
|
153
|
-
this.markFatal("engine binary sha256 mismatch")
|
|
154
|
-
return
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
const [cmd, ...args] = this.opts.command
|
|
158
|
-
const child = spawn(cmd as string, args, {
|
|
159
|
-
env: {},
|
|
160
|
-
cwd: this.opts.cwd ?? undefined,
|
|
161
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
162
|
-
})
|
|
163
|
-
this.child = child
|
|
164
|
-
this.parser = new FrameParser(this.opts.budgets.maxFrameBytes)
|
|
165
|
-
this.ids.reset()
|
|
166
|
-
this.handshakeId = null
|
|
167
|
-
this.state = "handshaking"
|
|
168
|
-
child.on("error", (err: Error & { code?: string }) => {
|
|
169
|
-
this.protocolViolation(`spawn:${err.code ?? "error"}`)
|
|
170
|
-
})
|
|
171
|
-
child.stdout?.setEncoding("utf8")
|
|
172
|
-
child.stdout?.on("data", (chunk: string) => {
|
|
173
|
-
this.parser.push(chunk)
|
|
174
|
-
if (this.parser.fatal !== null) {
|
|
175
|
-
this.protocolViolation(`frame:${this.parser.fatal}`)
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
for (const msg of this.parser.drain()) this.route(msg)
|
|
179
|
-
})
|
|
180
|
-
child.stderr?.setEncoding("utf8")
|
|
181
|
-
child.stderr?.on("data", (chunk: string) => {
|
|
182
|
-
this.stderrRing.push(chunk)
|
|
183
|
-
this.stderrBytes += chunk.length
|
|
184
|
-
while (this.stderrBytes > 8 * 1024 && this.stderrRing.length > 0) {
|
|
185
|
-
const head = this.stderrRing.shift() as string
|
|
186
|
-
this.stderrBytes -= head.length
|
|
187
|
-
}
|
|
188
|
-
})
|
|
189
|
-
child.on("exit", (code, signal) => this.onExit(code, signal))
|
|
190
|
-
this.sendHandshake()
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
private sendHandshake(): void {
|
|
194
|
-
const id = this.ids.take()
|
|
195
|
-
this.handshakeId = id
|
|
196
|
-
this.handshakeTimer = setTimeout(() => {
|
|
197
|
-
this.handshakeId = null
|
|
198
|
-
this.protocolViolation("handshake-timeout")
|
|
199
|
-
}, this.opts.budgets.handshakeTimeoutMs)
|
|
200
|
-
this.write({
|
|
201
|
-
jsonrpc: "2.0",
|
|
202
|
-
id,
|
|
203
|
-
method: "initialize",
|
|
204
|
-
params: { protocolVersion: this.opts.protocolVersion, client: { name: "opencode-shell-warden-hook", version: this.opts.clientVersion } },
|
|
205
|
-
})
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
private completeHandshake(result: unknown): void {
|
|
209
|
-
if (this.handshakeTimer !== null) clearTimeout(this.handshakeTimer)
|
|
210
|
-
this.handshakeTimer = null
|
|
211
|
-
this.handshakeId = null
|
|
212
|
-
if (this.state !== "handshaking") return
|
|
213
|
-
const r = result as { protocolVersion?: number } | null
|
|
214
|
-
if (r === null || typeof r !== "object" || r.protocolVersion !== this.opts.protocolVersion) {
|
|
215
|
-
this.protocolViolation("version-mismatch")
|
|
216
|
-
return
|
|
217
|
-
}
|
|
218
|
-
this.state = "ready"
|
|
219
|
-
this.log("info", `engine ready: ${JSON.stringify((result as InitializeLike).engine ?? {})}`)
|
|
220
|
-
this.flushQueue()
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
private flushQueue(): void {
|
|
224
|
-
while (this.state === "ready" && this.queue.length > 0 && this.pending.size < this.opts.budgets.maxInFlight) {
|
|
225
|
-
const waiter = this.queue.shift()
|
|
226
|
-
if (waiter === undefined) break
|
|
227
|
-
this.sendCheck(waiter.resolve, waiter.params, waiter)
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
private sendCheck(resolve: (o: CheckOutcome) => void, params: CheckParams, existing?: Waiter): void {
|
|
232
|
-
let waiter: Waiter
|
|
233
|
-
if (existing !== undefined) {
|
|
234
|
-
waiter = existing
|
|
235
|
-
} else {
|
|
236
|
-
const w: Waiter = { resolve, params, id: null, timer: null! }
|
|
237
|
-
w.timer = setTimeout(() => this.failWaiter(w, "timeout"), this.opts.budgets.checkTimeoutMs)
|
|
238
|
-
waiter = w
|
|
239
|
-
}
|
|
240
|
-
const id = this.ids.take()
|
|
241
|
-
waiter.id = id
|
|
242
|
-
this.pending.set(id, waiter)
|
|
243
|
-
this.write({ jsonrpc: "2.0", id, method: "policy/check", params })
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
private route(msg: WireMessage): void {
|
|
247
|
-
if ("method" in msg && !("id" in msg)) {
|
|
248
|
-
if (msg.method === "audit/event") this.log("info", `audit: ${JSON.stringify(msg.params ?? {})}`)
|
|
249
|
-
return // unknown notifications ignored (forward compatibility)
|
|
250
|
-
}
|
|
251
|
-
if ("error" in msg) {
|
|
252
|
-
if (msg.id === null) return
|
|
253
|
-
const waiter = this.pending.get(msg.id)
|
|
254
|
-
if (waiter !== undefined) {
|
|
255
|
-
this.pending.delete(msg.id)
|
|
256
|
-
clearTimeout(waiter.timer)
|
|
257
|
-
waiter.resolve({ ok: false, reason: "engine-error" })
|
|
258
|
-
}
|
|
259
|
-
if (msg.error.data?.fatal === true) this.markFatal(`engine fatal: ${msg.error.message}`)
|
|
260
|
-
return
|
|
261
|
-
}
|
|
262
|
-
if (!("result" in msg)) return
|
|
263
|
-
const id = msg.id
|
|
264
|
-
if (id === this.handshakeId) {
|
|
265
|
-
this.completeHandshake(msg.result)
|
|
266
|
-
return
|
|
267
|
-
}
|
|
268
|
-
const waiter = this.pending.get(id)
|
|
269
|
-
if (waiter === undefined) return // unknown/late id — ignore
|
|
270
|
-
this.pending.delete(id)
|
|
271
|
-
clearTimeout(waiter.timer)
|
|
272
|
-
const result = msg.result as CheckResult | null
|
|
273
|
-
if (result === null || typeof result !== "object" || typeof (result as CheckResult).verdict !== "string") {
|
|
274
|
-
this.protocolViolation("bad-result-shape")
|
|
275
|
-
waiter.resolve({ ok: false, reason: "malformed-response" })
|
|
276
|
-
return
|
|
277
|
-
}
|
|
278
|
-
waiter.resolve({ ok: true, result })
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
private failWaiter(waiter: Waiter, reason: string): void {
|
|
282
|
-
if (waiter.id !== null) this.pending.delete(waiter.id)
|
|
283
|
-
else this.queue = this.queue.filter((w) => w !== waiter)
|
|
284
|
-
waiter.resolve({ ok: false, reason })
|
|
285
|
-
if (reason === "timeout") this.protocolViolation("timeout")
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
private onExit(_code: number | null, _signal: string | null): void {
|
|
289
|
-
if (this.graceTimer !== null) clearTimeout(this.graceTimer)
|
|
290
|
-
this.graceTimer = null
|
|
291
|
-
if (this.disposed || this.state === "restarting" || this.state === "unhealthy" || this.state === "disposed") return
|
|
292
|
-
this.rejectPending("engine-exited")
|
|
293
|
-
this.protocolViolation("exit")
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
private protocolViolation(kind: string): void {
|
|
297
|
-
if (this.disposed) return
|
|
298
|
-
this.log("warn", `violation: ${kind}`)
|
|
299
|
-
const now = this.now()
|
|
300
|
-
if (now - this.violations.windowStart > this.opts.budgets.maxViolationsWindowMs) {
|
|
301
|
-
this.violations = { count: 1, windowStart: now }
|
|
302
|
-
} else {
|
|
303
|
-
this.violations.count += 1
|
|
304
|
-
}
|
|
305
|
-
if (this.violations.count >= this.opts.budgets.maxViolations) {
|
|
306
|
-
this.violations = { count: 0, windowStart: 0 }
|
|
307
|
-
this.killAndRestart(`violations:${kind}`)
|
|
308
|
-
return
|
|
309
|
-
}
|
|
310
|
-
if (KILL_KINDS.has(kind) || kind.startsWith("spawn:")) this.killAndRestart(kind)
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
private killAndRestart(reason: string): void {
|
|
314
|
-
if (this.disposed) return
|
|
315
|
-
const now = this.now()
|
|
316
|
-
this.restartsAt.push(now)
|
|
317
|
-
this.restartsAt = this.restartsAt.filter((t) => now - t <= this.opts.budgets.windowMs)
|
|
318
|
-
this.state = "restarting"
|
|
319
|
-
this.rejectPending("engine-restarting")
|
|
320
|
-
this.killChild()
|
|
321
|
-
if (this.restartsAt.length > this.opts.budgets.maxViolations) {
|
|
322
|
-
this.unhealthyUntil = now + this.opts.budgets.windowMs
|
|
323
|
-
this.state = "unhealthy"
|
|
324
|
-
this.rejectQueued("unhealthy")
|
|
325
|
-
this.log("error", `engine unhealthy until ${new Date(this.unhealthyUntil).toISOString()} (${reason})`)
|
|
326
|
-
return
|
|
327
|
-
}
|
|
328
|
-
const delay = Math.min(250 * 2 ** this.backoffLevel, this.opts.budgets.maxBackoffMs)
|
|
329
|
-
this.backoffLevel += 1
|
|
330
|
-
this.log("warn", `restart #${this.restartsAt.length} in ${delay}ms (${reason})`)
|
|
331
|
-
this.spawnTimer = setTimeout(() => {
|
|
332
|
-
this.spawnTimer = null
|
|
333
|
-
this.state = "cold"
|
|
334
|
-
void this.ensureEngine()
|
|
335
|
-
}, delay)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
private killChild(): void {
|
|
339
|
-
const child = this.child
|
|
340
|
-
this.child = null
|
|
341
|
-
if (child === null) return
|
|
342
|
-
try {
|
|
343
|
-
child.kill("SIGTERM")
|
|
344
|
-
} catch {
|
|
345
|
-
/* already gone */
|
|
346
|
-
}
|
|
347
|
-
this.graceTimer = setTimeout(() => {
|
|
348
|
-
try {
|
|
349
|
-
child.kill("SIGKILL")
|
|
350
|
-
} catch {
|
|
351
|
-
/* already gone */
|
|
352
|
-
}
|
|
353
|
-
}, this.opts.budgets.killGraceMs)
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
private markFatal(reason: string): void {
|
|
357
|
-
this.unhealthyUntil = this.now() + FATAL_COOLDOWN_MS
|
|
358
|
-
this.state = "unhealthy"
|
|
359
|
-
this.rejectPending("unhealthy")
|
|
360
|
-
this.rejectQueued("unhealthy")
|
|
361
|
-
this.killChild()
|
|
362
|
-
this.log("error", `fatal: ${reason}`)
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
private rejectPending(reason: string): void {
|
|
366
|
-
for (const [, waiter] of this.pending) {
|
|
367
|
-
clearTimeout(waiter.timer)
|
|
368
|
-
waiter.resolve({ ok: false, reason })
|
|
369
|
-
}
|
|
370
|
-
this.pending.clear()
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
private rejectQueued(reason: string): void {
|
|
374
|
-
for (const waiter of this.queue) {
|
|
375
|
-
clearTimeout(waiter.timer)
|
|
376
|
-
waiter.resolve({ ok: false, reason })
|
|
377
|
-
}
|
|
378
|
-
this.queue = []
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
private clearTimers(): void {
|
|
382
|
-
if (this.handshakeTimer !== null) clearTimeout(this.handshakeTimer)
|
|
383
|
-
if (this.spawnTimer !== null) clearTimeout(this.spawnTimer)
|
|
384
|
-
if (this.graceTimer !== null) clearTimeout(this.graceTimer)
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
private write(msg: WireMessage): void {
|
|
388
|
-
if (this.child === null) return
|
|
389
|
-
try {
|
|
390
|
-
this.child.stdin?.write(encodeMessage(msg, this.opts.budgets.maxFrameBytes))
|
|
391
|
-
} catch {
|
|
392
|
-
this.protocolViolation("frame-limit")
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
private log(level: "debug" | "info" | "warn" | "error", msg: string): void {
|
|
397
|
-
this.opts.log?.(level, `[opencode-shell-warden-hook] ${msg}`)
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
private async verifySha256(expected: string): Promise<boolean> {
|
|
401
|
-
const path = this.opts.command[0] as string
|
|
402
|
-
return new Promise((resolve) => {
|
|
403
|
-
const hash = createHash("sha256")
|
|
404
|
-
const stream = createReadStream(path)
|
|
405
|
-
stream.on("data", (chunk) => hash.update(chunk))
|
|
406
|
-
stream.on("end", () => resolve(hash.digest("hex") === expected))
|
|
407
|
-
stream.on("error", () => resolve(false))
|
|
408
|
-
})
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
type InitializeLike = { engine?: { name?: string; version?: string } }
|