rtrt-agent 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.ko.md +40 -0
- package/README.md +41 -0
- package/index.js +1 -0
- package/package.json +43 -0
- package/rtrt-provenance.js +1270 -0
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
// BEGIN rtrt-managed provenance plugin
|
|
2
|
+
import { constants } from "node:fs"
|
|
3
|
+
import { chmod, lstat, mkdir, open, readFile, realpath } from "node:fs/promises"
|
|
4
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
|
|
5
|
+
import { createServer } from "node:http"
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
|
|
9
|
+
const BROKER_PATH = "/rtrt/permission/v1"
|
|
10
|
+
const BODY_LIMIT = 32_768
|
|
11
|
+
const MAX_PENDING = 8
|
|
12
|
+
const MAX_DEPTH = 12
|
|
13
|
+
const MAX_FIELD = 4_096
|
|
14
|
+
const MAX_REPLAYS = 128
|
|
15
|
+
const MAX_PERMISSION_EVENT_REPLIES = 128
|
|
16
|
+
const MAX_TRACKED_CHILDREN = 128
|
|
17
|
+
const MAX_APPROVAL_SESSIONS = 128
|
|
18
|
+
const MAX_APPROVALS_PER_SESSION = 128
|
|
19
|
+
const MAX_COMMAND = 8_192
|
|
20
|
+
const MANAGED_AGENT_STATE_LIMIT = 65_536
|
|
21
|
+
const MANAGED_AGENT_STATE_DEPTH = 8
|
|
22
|
+
const MANAGED_AGENT_STATE_NODES = 512
|
|
23
|
+
const MAX_MANAGED_AGENTS = 128
|
|
24
|
+
const MANAGED_AGENT_STATE_OWNER = "rtrt-opencode-task-agents"
|
|
25
|
+
const MANAGED_AGENT_STATE_VERSION = 1
|
|
26
|
+
const MANAGER_AGENT = "rtrt-manager"
|
|
27
|
+
const configuredPath = (value) => typeof value === "string" && value && !value.includes("\0")
|
|
28
|
+
const resolveManagedAgentStatePath = ({ OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME, HOME }) => {
|
|
29
|
+
const configDirectory = configuredPath(OPENCODE_CONFIG_DIR)
|
|
30
|
+
? path.resolve(OPENCODE_CONFIG_DIR)
|
|
31
|
+
: configuredPath(XDG_CONFIG_HOME)
|
|
32
|
+
? path.resolve(XDG_CONFIG_HOME, "opencode")
|
|
33
|
+
: path.resolve(HOME, ".config", "opencode")
|
|
34
|
+
return path.join(configDirectory, "agents", ".rtrt-managed-state.json")
|
|
35
|
+
}
|
|
36
|
+
const defaultManagedAgentStatePath = () => resolveManagedAgentStatePath({
|
|
37
|
+
...process.env,
|
|
38
|
+
HOME: configuredPath(process.env.HOME) ? process.env.HOME : homedir(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const tokenizeCommand = (command) => {
|
|
42
|
+
if (typeof command !== "string" || !command || Buffer.byteLength(command) > MAX_COMMAND) return
|
|
43
|
+
if (/[\0\r\n`$~;|&<>*?\[]/.test(command)) return
|
|
44
|
+
const tokens = []
|
|
45
|
+
let token = ""
|
|
46
|
+
let quote = ""
|
|
47
|
+
let active = false
|
|
48
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
49
|
+
const character = command[index]
|
|
50
|
+
if (!quote && /\s/.test(character)) {
|
|
51
|
+
if (active) tokens.push(token)
|
|
52
|
+
token = ""
|
|
53
|
+
active = false
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (character === "\\") {
|
|
57
|
+
index += 1
|
|
58
|
+
if (index >= command.length) return
|
|
59
|
+
token += command[index]
|
|
60
|
+
active = true
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (character === "'" || character === '"') {
|
|
64
|
+
if (!quote) quote = character
|
|
65
|
+
else if (quote === character) quote = ""
|
|
66
|
+
else token += character
|
|
67
|
+
active = true
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
token += character
|
|
71
|
+
active = true
|
|
72
|
+
}
|
|
73
|
+
if (quote) return
|
|
74
|
+
if (active) tokens.push(token)
|
|
75
|
+
return tokens.length ? tokens : undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const URL_ARGUMENT = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//
|
|
79
|
+
const BARE_COMMAND = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
|
|
80
|
+
const SAFE_SESSION_FIELD = /^[A-Za-z0-9_-]{1,256}$/
|
|
81
|
+
const SAFE_AGENT_ID = /^[A-Za-z0-9_-]{1,256}$/
|
|
82
|
+
|
|
83
|
+
const boundedJson = (value, remaining = MANAGED_AGENT_STATE_DEPTH, count = { value: 0 }) => {
|
|
84
|
+
count.value += 1
|
|
85
|
+
if (count.value > MANAGED_AGENT_STATE_NODES || remaining < 0) return false
|
|
86
|
+
if (!value || typeof value !== "object") return true
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
if (value.length > MAX_MANAGED_AGENTS) return false
|
|
89
|
+
return value.every((item) => boundedJson(item, remaining - 1, count))
|
|
90
|
+
}
|
|
91
|
+
const entries = Object.entries(value)
|
|
92
|
+
if (entries.length > MAX_MANAGED_AGENTS) return false
|
|
93
|
+
return entries.every(([, item]) => boundedJson(item, remaining - 1, count))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const loadManagedAgents = async (statePath) => {
|
|
97
|
+
if (typeof statePath !== "string" || !statePath || statePath.includes("\0")) return undefined
|
|
98
|
+
let handle
|
|
99
|
+
try {
|
|
100
|
+
const before = await lstat(statePath)
|
|
101
|
+
if (before.isSymbolicLink() || !before.isFile() || before.size > MANAGED_AGENT_STATE_LIMIT) return undefined
|
|
102
|
+
handle = await open(statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
|
103
|
+
const opened = await handle.stat()
|
|
104
|
+
if (
|
|
105
|
+
!opened.isFile() ||
|
|
106
|
+
opened.size > MANAGED_AGENT_STATE_LIMIT ||
|
|
107
|
+
opened.dev !== before.dev ||
|
|
108
|
+
opened.ino !== before.ino
|
|
109
|
+
) return undefined
|
|
110
|
+
const raw = await handle.readFile("utf8")
|
|
111
|
+
if (Buffer.byteLength(raw) > MANAGED_AGENT_STATE_LIMIT) return undefined
|
|
112
|
+
const after = await handle.stat()
|
|
113
|
+
if (after.size !== opened.size || after.dev !== opened.dev || after.ino !== opened.ino) return undefined
|
|
114
|
+
const state = JSON.parse(raw)
|
|
115
|
+
if (!state || typeof state !== "object" || Array.isArray(state) || !boundedJson(state)) return undefined
|
|
116
|
+
if (state.owner !== MANAGED_AGENT_STATE_OWNER || state.version !== MANAGED_AGENT_STATE_VERSION) return undefined
|
|
117
|
+
if (!Array.isArray(state.agents) || state.agents.length === 0 || state.agents.length > MAX_MANAGED_AGENTS) return undefined
|
|
118
|
+
const agents = new Set()
|
|
119
|
+
for (const agent of state.agents) {
|
|
120
|
+
if (typeof agent !== "string" || !SAFE_AGENT_ID.test(agent) || agents.has(agent)) return undefined
|
|
121
|
+
agents.add(agent)
|
|
122
|
+
}
|
|
123
|
+
return agents.has(MANAGER_AGENT) ? agents : undefined
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined
|
|
126
|
+
} finally {
|
|
127
|
+
await handle?.close().catch(() => {})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const realisticInside = async (root, operand) => {
|
|
132
|
+
if (!operand || operand.includes("\0") || URL_ARGUMENT.test(operand)) return false
|
|
133
|
+
if (!path.isAbsolute(operand) && operand.split(/[\\/]/).includes("..")) return false
|
|
134
|
+
const context = typeof root === "string" ? { cwd: root, bounds: [root] } : root
|
|
135
|
+
const target = path.resolve(context.cwd, operand)
|
|
136
|
+
if (!context.bounds.some((bound) => isWithin(bound, target))) return false
|
|
137
|
+
let probe = target
|
|
138
|
+
while (true) {
|
|
139
|
+
try {
|
|
140
|
+
const resolved = await realpath(probe)
|
|
141
|
+
return context.bounds.some((bound) => isWithin(bound, resolved))
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") return false
|
|
144
|
+
const parent = path.dirname(probe)
|
|
145
|
+
if (parent === probe || !context.bounds.some((bound) => isWithin(bound, parent))) return false
|
|
146
|
+
probe = parent
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const splitOptions = (args, valueOptions = new Set()) => {
|
|
152
|
+
const operands = []
|
|
153
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
154
|
+
const argument = args[index]
|
|
155
|
+
if (argument === "--") {
|
|
156
|
+
operands.push(...args.slice(index + 1))
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
if (!argument.startsWith("-") || argument === "-") {
|
|
160
|
+
operands.push(argument)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const [option, attached] = argument.split(/=(.*)/s, 2)
|
|
164
|
+
if (valueOptions.has(option)) {
|
|
165
|
+
const value = attached ?? args[++index]
|
|
166
|
+
if (!value) return
|
|
167
|
+
operands.push(value)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return operands
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const allPathsInside = async (root, operands) => {
|
|
174
|
+
if (!operands) return false
|
|
175
|
+
for (const operand of operands) if (!(await realisticInside(root, operand))) return false
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const READ_COMMANDS = new Set(["cat", "wc", "stat"])
|
|
180
|
+
|
|
181
|
+
const safeCommand = async (command, root) => {
|
|
182
|
+
const tokens = tokenizeCommand(command)
|
|
183
|
+
if (!tokens || !root || !BARE_COMMAND.test(tokens[0]) || tokens.some((item) => URL_ARGUMENT.test(item))) return false
|
|
184
|
+
const [executable, ...args] = tokens
|
|
185
|
+
for (const argument of args) {
|
|
186
|
+
const value = argument.includes("=") ? argument.slice(argument.indexOf("=") + 1) : argument
|
|
187
|
+
if ((path.isAbsolute(value) || value.includes("/") || value.includes("\\") || value.startsWith(".")) && !(await realisticInside(root, value))) return false
|
|
188
|
+
}
|
|
189
|
+
if (executable === "pwd") return args.every((item) => item === "-L" || item === "-P")
|
|
190
|
+
if (executable === "ls") {
|
|
191
|
+
if (args.some((item) => item.startsWith("-") && !/^-[AacdFfghikLlmnopqRrSstuUx1]+$/.test(item))) return false
|
|
192
|
+
return allPathsInside(root, args.filter((item) => !item.startsWith("-")))
|
|
193
|
+
}
|
|
194
|
+
if (READ_COMMANDS.has(executable)) {
|
|
195
|
+
if (args.some((item) => item.startsWith("-") && item !== "--")) return false
|
|
196
|
+
const operands = splitOptions(args)
|
|
197
|
+
return Boolean(operands?.length) && allPathsInside(root, operands)
|
|
198
|
+
}
|
|
199
|
+
if (["head", "tail"].includes(executable)) {
|
|
200
|
+
const operands = []
|
|
201
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
202
|
+
const argument = args[index]
|
|
203
|
+
if (argument === "-q" || argument === "--quiet" || argument === "--silent" || argument === "-v" || argument === "--verbose") continue
|
|
204
|
+
const attached = argument.match(/^(?:-n|--lines|-c|--bytes)=(.*)$/)
|
|
205
|
+
if (attached) {
|
|
206
|
+
if (!/^[-+]?\d+$/.test(attached[1])) return false
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (["-n", "--lines", "-c", "--bytes"].includes(argument)) {
|
|
210
|
+
if (!/^[-+]?\d+$/.test(args[++index] ?? "")) return false
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (argument.startsWith("-")) return false
|
|
214
|
+
operands.push(argument)
|
|
215
|
+
}
|
|
216
|
+
return operands.length > 0 && allPathsInside(root, operands)
|
|
217
|
+
}
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const defaultClientFactory = async (options) => {
|
|
222
|
+
const { createOpencodeClient } = await import("@opencode-ai/sdk/v2")
|
|
223
|
+
return createOpencodeClient(options)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const RTRT_AGENT_TOOLS = new Set([
|
|
227
|
+
"rtrt_agent_call",
|
|
228
|
+
"rtrt_agent_route",
|
|
229
|
+
"rtrt_team_dispatch",
|
|
230
|
+
])
|
|
231
|
+
|
|
232
|
+
const sanitizeSessionID = (sessionID) => {
|
|
233
|
+
const sanitized = String(sessionID ?? "")
|
|
234
|
+
.replace(/[^A-Za-z0-9_-]/g, "_")
|
|
235
|
+
.slice(0, 128)
|
|
236
|
+
return sanitized || "session"
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const isWithin = (parent, child) => {
|
|
240
|
+
const relative = path.relative(parent, child)
|
|
241
|
+
return (
|
|
242
|
+
relative === "" ||
|
|
243
|
+
(!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const hasTraversal = (candidate) =>
|
|
248
|
+
!path.isAbsolute(candidate) && candidate.split(path.sep).includes("..")
|
|
249
|
+
|
|
250
|
+
const canonicalProjectPath = async (rootDirectory, directory, candidate, allowMissing) => {
|
|
251
|
+
if (
|
|
252
|
+
typeof rootDirectory !== "string" ||
|
|
253
|
+
!rootDirectory ||
|
|
254
|
+
rootDirectory.includes("\0") ||
|
|
255
|
+
typeof directory !== "string" ||
|
|
256
|
+
!directory ||
|
|
257
|
+
directory.includes("\0") ||
|
|
258
|
+
typeof candidate !== "string" ||
|
|
259
|
+
!candidate ||
|
|
260
|
+
candidate.includes("\0")
|
|
261
|
+
) return undefined
|
|
262
|
+
if (hasTraversal(candidate)) return undefined
|
|
263
|
+
try {
|
|
264
|
+
const root = await realpath(path.resolve(rootDirectory))
|
|
265
|
+
const cwd = await realpath(path.resolve(directory))
|
|
266
|
+
if (!isWithin(root, cwd)) return undefined
|
|
267
|
+
const target = path.resolve(cwd, candidate)
|
|
268
|
+
if (!isWithin(root, target)) return undefined
|
|
269
|
+
try {
|
|
270
|
+
const canonical = await realpath(target)
|
|
271
|
+
return isWithin(root, canonical) ? canonical : undefined
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (!allowMissing || (error?.code !== "ENOENT" && error?.code !== "ENOTDIR")) return undefined
|
|
274
|
+
let probe = path.dirname(target)
|
|
275
|
+
while (isWithin(root, probe)) {
|
|
276
|
+
try {
|
|
277
|
+
const canonicalParent = await realpath(probe)
|
|
278
|
+
return isWithin(root, canonicalParent) ? target : undefined
|
|
279
|
+
} catch (parentError) {
|
|
280
|
+
if (parentError?.code !== "ENOENT" && parentError?.code !== "ENOTDIR") return undefined
|
|
281
|
+
}
|
|
282
|
+
const parent = path.dirname(probe)
|
|
283
|
+
if (parent === probe) return undefined
|
|
284
|
+
probe = parent
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
return undefined
|
|
289
|
+
}
|
|
290
|
+
return undefined
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const readPathFile = async (file, prefix = "") => {
|
|
294
|
+
const lines = (await readFile(file, "utf8")).split(/\r?\n/)
|
|
295
|
+
if (lines.at(-1) === "") lines.pop()
|
|
296
|
+
if (lines.length !== 1 || !lines[0].startsWith(prefix)) return undefined
|
|
297
|
+
|
|
298
|
+
const value = lines[0].slice(prefix.length).trim()
|
|
299
|
+
return value && !value.includes("\0") ? value : undefined
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const resolveLinkedWorktree = async (worktree, gitFile) => {
|
|
303
|
+
const rawGitDir = await readPathFile(gitFile, "gitdir:")
|
|
304
|
+
if (!rawGitDir) return undefined
|
|
305
|
+
|
|
306
|
+
const gitDirPath = path.resolve(worktree, rawGitDir)
|
|
307
|
+
const gitDirStat = await lstat(gitDirPath)
|
|
308
|
+
if (gitDirStat.isSymbolicLink() || !gitDirStat.isDirectory()) return undefined
|
|
309
|
+
const gitDir = await realpath(gitDirPath)
|
|
310
|
+
|
|
311
|
+
const rawCommonDir = await readPathFile(path.join(gitDir, "commondir"))
|
|
312
|
+
const rawBacklink = await readPathFile(path.join(gitDir, "gitdir"))
|
|
313
|
+
if (!rawCommonDir || !rawBacklink) return undefined
|
|
314
|
+
|
|
315
|
+
const commonDirPath = path.resolve(gitDir, rawCommonDir)
|
|
316
|
+
const commonDirStat = await lstat(commonDirPath)
|
|
317
|
+
if (commonDirStat.isSymbolicLink() || !commonDirStat.isDirectory()) return undefined
|
|
318
|
+
const commonDir = await realpath(commonDirPath)
|
|
319
|
+
if (path.basename(commonDir) !== ".git") return undefined
|
|
320
|
+
|
|
321
|
+
const mainWorktree = path.dirname(commonDir)
|
|
322
|
+
if (mainWorktree === path.parse(mainWorktree).root) return undefined
|
|
323
|
+
|
|
324
|
+
const mainGitStat = await lstat(path.join(mainWorktree, ".git"))
|
|
325
|
+
if (mainGitStat.isSymbolicLink() || !mainGitStat.isDirectory()) return undefined
|
|
326
|
+
if ((await realpath(path.join(mainWorktree, ".git"))) !== commonDir) return undefined
|
|
327
|
+
|
|
328
|
+
const worktreesDir = await realpath(path.join(commonDir, "worktrees"))
|
|
329
|
+
const adminRelative = path.relative(worktreesDir, gitDir)
|
|
330
|
+
if (
|
|
331
|
+
!adminRelative ||
|
|
332
|
+
path.isAbsolute(adminRelative) ||
|
|
333
|
+
adminRelative === ".." ||
|
|
334
|
+
adminRelative.startsWith(`..${path.sep}`) ||
|
|
335
|
+
adminRelative.includes(path.sep)
|
|
336
|
+
) {
|
|
337
|
+
return undefined
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const backlink = path.resolve(gitDir, rawBacklink)
|
|
341
|
+
if ((await realpath(backlink)) !== (await realpath(gitFile))) return undefined
|
|
342
|
+
return worktree
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const resolveProjectWorktree = async (projectWorktree) => {
|
|
346
|
+
if (!projectWorktree) return undefined
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
const worktree = await realpath(path.resolve(projectWorktree))
|
|
350
|
+
if (worktree === path.parse(worktree).root) return undefined
|
|
351
|
+
|
|
352
|
+
const gitEntry = path.join(worktree, ".git")
|
|
353
|
+
const gitStat = await lstat(gitEntry)
|
|
354
|
+
if (gitStat.isSymbolicLink()) return undefined
|
|
355
|
+
if (gitStat.isDirectory()) return worktree
|
|
356
|
+
if (gitStat.isFile()) return await resolveLinkedWorktree(worktree, gitEntry)
|
|
357
|
+
} catch {
|
|
358
|
+
return undefined
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return undefined
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const ensureSessionTempDir = async (parentWorktree, sessionID) => {
|
|
365
|
+
const root = await realpath(parentWorktree)
|
|
366
|
+
const tempRoot = path.join(root, ".rtrt", "tmp", "opencode")
|
|
367
|
+
const tempDir = path.join(tempRoot, sanitizeSessionID(sessionID))
|
|
368
|
+
|
|
369
|
+
if (!isWithin(root, tempDir)) {
|
|
370
|
+
throw new Error("OpenCode session temp directory escapes parent worktree")
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Create one level at a time so symlinks cannot redirect writes outside the repository.
|
|
374
|
+
for (const directory of [
|
|
375
|
+
path.join(root, ".rtrt"),
|
|
376
|
+
path.join(root, ".rtrt", "tmp"),
|
|
377
|
+
tempRoot,
|
|
378
|
+
tempDir,
|
|
379
|
+
]) {
|
|
380
|
+
try {
|
|
381
|
+
await mkdir(directory, { mode: 0o700 })
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error?.code !== "EEXIST") throw error
|
|
384
|
+
}
|
|
385
|
+
const stat = await lstat(directory)
|
|
386
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
387
|
+
throw new Error(`OpenCode session temp path is not a secure directory: ${directory}`)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const realTempDir = await realpath(tempDir)
|
|
392
|
+
if (!isWithin(root, realTempDir)) {
|
|
393
|
+
throw new Error("OpenCode session temp directory escapes parent worktree")
|
|
394
|
+
}
|
|
395
|
+
await chmod(tempDir, 0o700)
|
|
396
|
+
return tempDir
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const boundedString = (value, maximum = MAX_FIELD) =>
|
|
400
|
+
typeof value === "string" && value.length > 0 && value.length <= maximum ? value : undefined
|
|
401
|
+
|
|
402
|
+
const WEEKLY_USAGE_CODES = new Set([
|
|
403
|
+
"weekly_usage_limit",
|
|
404
|
+
"weekly-usage-limit",
|
|
405
|
+
"weekly_limit",
|
|
406
|
+
"usage_limit_weekly",
|
|
407
|
+
])
|
|
408
|
+
|
|
409
|
+
const weeklyUsageCode = (value) => {
|
|
410
|
+
const normalized = boundedString(value, 128)?.trim().toLowerCase()
|
|
411
|
+
return normalized && WEEKLY_USAGE_CODES.has(normalized)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const usageLimitPhrase = (value) => {
|
|
415
|
+
const message = boundedString(value)
|
|
416
|
+
if (!message) return undefined
|
|
417
|
+
if (/\bweekly usage (?:limit|cap)\b/i.test(message)) return "weekly_usage_limit"
|
|
418
|
+
if (/\b5[- ]hour usage (?:limit|cap)\b/i.test(message)) return "usage_limit_5h"
|
|
419
|
+
return undefined
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const providerLimit = (error) => {
|
|
423
|
+
if (!error || typeof error !== "object" || Array.isArray(error)) return undefined
|
|
424
|
+
if (error.statusCode === 429) return { kind: "rate_limit", status: 429 }
|
|
425
|
+
if (error.statusCode === 529) return { kind: "capacity", status: 529 }
|
|
426
|
+
return undefined
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const sessionErrorProviderLimit = (error) => {
|
|
430
|
+
if (!error || typeof error !== "object" || Array.isArray(error)) return undefined
|
|
431
|
+
if (error.name !== "APIError" && error.name !== "UnknownError") return undefined
|
|
432
|
+
|
|
433
|
+
const data = error.data
|
|
434
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined
|
|
435
|
+
if (error.name === "APIError") {
|
|
436
|
+
if (data.statusCode === 429) return { kind: "rate_limit", status: 429 }
|
|
437
|
+
if (data.statusCode === 529) return { kind: "capacity", status: 529 }
|
|
438
|
+
if (data.statusCode !== undefined) return undefined
|
|
439
|
+
|
|
440
|
+
const metadata = data.metadata
|
|
441
|
+
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
|
|
442
|
+
for (const field of ["code", "type", "reason"]) {
|
|
443
|
+
if (weeklyUsageCode(metadata[field])) {
|
|
444
|
+
return { kind: "weekly_usage_limit", status: null }
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const phrase = usageLimitPhrase(data.message)
|
|
451
|
+
if (phrase) return { kind: phrase, status: null }
|
|
452
|
+
return undefined
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const statusProviderLimit = (status) => {
|
|
456
|
+
if (!status || typeof status !== "object" || Array.isArray(status) || status.type !== "retry") {
|
|
457
|
+
return undefined
|
|
458
|
+
}
|
|
459
|
+
const action = status.action
|
|
460
|
+
if (action && typeof action === "object" && !Array.isArray(action)) {
|
|
461
|
+
if (action.reason === "account_rate_limit") return {
|
|
462
|
+
kind: usageLimitPhrase(status.message) ?? "account_rate_limit",
|
|
463
|
+
status: null,
|
|
464
|
+
}
|
|
465
|
+
if (action.reason === "free_tier_limit") return { kind: "free_tier_limit", status: null }
|
|
466
|
+
if (action.reason !== undefined) return undefined
|
|
467
|
+
}
|
|
468
|
+
// Compatibility fallback for providers predating OpenCode Go structured actions.
|
|
469
|
+
const phrase = usageLimitPhrase(status.message)
|
|
470
|
+
if (phrase) return { kind: phrase, status: null }
|
|
471
|
+
return undefined
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const sdkSession = (response) => {
|
|
475
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) return undefined
|
|
476
|
+
let data = response.data
|
|
477
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined
|
|
478
|
+
if (Object.hasOwn(data, "data")) data = data.data
|
|
479
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : undefined
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const sdkPermissions = (response) => {
|
|
483
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) return undefined
|
|
484
|
+
let data = response.data
|
|
485
|
+
if (data && typeof data === "object" && !Array.isArray(data) && Object.hasOwn(data, "data")) data = data.data
|
|
486
|
+
return Array.isArray(data) ? data : undefined
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const sessionProjectIdentity = (session) => {
|
|
490
|
+
for (const value of [
|
|
491
|
+
session?.projectID,
|
|
492
|
+
session?.projectId,
|
|
493
|
+
session?.project_id,
|
|
494
|
+
session?.project?.id,
|
|
495
|
+
]) {
|
|
496
|
+
const identity = boundedString(value, 256)
|
|
497
|
+
if (identity) return identity
|
|
498
|
+
}
|
|
499
|
+
return undefined
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const sessionPathIdentity = (session) => {
|
|
503
|
+
for (const value of [session?.worktree, session?.directory]) {
|
|
504
|
+
const identity = boundedString(value)
|
|
505
|
+
if (identity && !identity.includes("\0")) return path.resolve(identity)
|
|
506
|
+
}
|
|
507
|
+
return undefined
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const sameAvailableIdentity = (child, parent, currentProject, currentPaths) => {
|
|
511
|
+
const childProject = sessionProjectIdentity(child)
|
|
512
|
+
const parentProject = sessionProjectIdentity(parent)
|
|
513
|
+
if ((childProject || parentProject) && (!childProject || !parentProject || childProject !== parentProject)) return false
|
|
514
|
+
if (childProject && currentProject && childProject !== currentProject) return false
|
|
515
|
+
|
|
516
|
+
const childPath = sessionPathIdentity(child)
|
|
517
|
+
const parentPath = sessionPathIdentity(parent)
|
|
518
|
+
if ((childPath || parentPath) && (!childPath || !parentPath || childPath !== parentPath)) return false
|
|
519
|
+
if (childPath && currentPaths.size > 0 && !currentPaths.has(childPath)) return false
|
|
520
|
+
return true
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const depthWithin = (value, remaining = MAX_DEPTH) => {
|
|
524
|
+
if (remaining < 0) return false
|
|
525
|
+
if (!value || typeof value !== "object") return true
|
|
526
|
+
if (Array.isArray(value)) return value.every((item) => depthWithin(item, remaining - 1))
|
|
527
|
+
return Object.values(value).every((item) => depthWithin(item, remaining - 1))
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const safeEqual = (left, right) => {
|
|
531
|
+
const leftDigest = createHash("sha256").update(String(left ?? "")).digest()
|
|
532
|
+
const rightDigest = createHash("sha256").update(String(right ?? "")).digest()
|
|
533
|
+
return timingSafeEqual(leftDigest, rightDigest) && typeof left === "string" && left === right
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const directPermissionClaude = (command) =>
|
|
537
|
+
typeof command === "string" &&
|
|
538
|
+
/^claude -p(?:\s|$)/.test(command) &&
|
|
539
|
+
/(?:^|\s)--permission-prompt-tool(?:=|\s+)mcp__rtrt__permission_prompt(?:\s|$)/.test(command)
|
|
540
|
+
|
|
541
|
+
const mappedPermission = async (body, projectRoot, pluginDirectory) => {
|
|
542
|
+
const tool = boundedString(body.tool_name, 128)
|
|
543
|
+
if (!tool) return undefined
|
|
544
|
+
const input = body.input
|
|
545
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || !depthWithin(input)) return undefined
|
|
546
|
+
|
|
547
|
+
const pick = (name) => boundedString(input[name])
|
|
548
|
+
const mappedPath = async (resource, action, allowMissing = false) => {
|
|
549
|
+
const classified = await canonicalProjectPath(
|
|
550
|
+
projectRoot,
|
|
551
|
+
pluginDirectory,
|
|
552
|
+
resource,
|
|
553
|
+
allowMissing,
|
|
554
|
+
)
|
|
555
|
+
return classified ? { action, resources: [classified] } : undefined
|
|
556
|
+
}
|
|
557
|
+
switch (tool) {
|
|
558
|
+
case "Bash":
|
|
559
|
+
return pick("command") && { action: "bash", resources: [pick("command")] }
|
|
560
|
+
case "Read":
|
|
561
|
+
return mappedPath(pick("file_path"), "read")
|
|
562
|
+
case "Glob": {
|
|
563
|
+
const pattern = pick("pattern")
|
|
564
|
+
if (!pattern) return undefined
|
|
565
|
+
return mappedPath(Object.hasOwn(input, "path") ? pick("path") : ".", "glob")
|
|
566
|
+
}
|
|
567
|
+
case "Grep": {
|
|
568
|
+
const pattern = pick("pattern")
|
|
569
|
+
return pattern && mappedPath(Object.hasOwn(input, "path") ? pick("path") : ".", "grep")
|
|
570
|
+
}
|
|
571
|
+
case "Edit":
|
|
572
|
+
case "Write":
|
|
573
|
+
case "MultiEdit":
|
|
574
|
+
return mappedPath(pick("file_path"), "edit", true)
|
|
575
|
+
case "NotebookEdit":
|
|
576
|
+
return mappedPath(pick("notebook_path"), "edit", true)
|
|
577
|
+
case "WebFetch":
|
|
578
|
+
return pick("url") && { action: "webfetch", resources: [pick("url")] }
|
|
579
|
+
case "WebSearch":
|
|
580
|
+
return pick("query") && { action: "websearch", resources: [pick("query")] }
|
|
581
|
+
default: {
|
|
582
|
+
const action = tool.toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 64)
|
|
583
|
+
return action && { action, resources: [`claude-tool:${action}`] }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const approvalDigest = ({ action, resources }) => createHash("sha256")
|
|
589
|
+
.update(JSON.stringify([
|
|
590
|
+
action.normalize("NFC"),
|
|
591
|
+
resources.map((resource) => resource.normalize("NFC")),
|
|
592
|
+
]))
|
|
593
|
+
.digest("base64url")
|
|
594
|
+
|
|
595
|
+
const listenLoopback = (server) =>
|
|
596
|
+
new Promise((resolve, reject) => {
|
|
597
|
+
server.once("error", reject)
|
|
598
|
+
server.listen(0, "127.0.0.1", () => {
|
|
599
|
+
server.off("error", reject)
|
|
600
|
+
resolve()
|
|
601
|
+
})
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
const closeServer = (server) =>
|
|
605
|
+
new Promise((resolve) => server.close(() => resolve()))
|
|
606
|
+
|
|
607
|
+
const createPlugin = async (
|
|
608
|
+
{ project, directory, serverUrl, client: legacyClient },
|
|
609
|
+
{
|
|
610
|
+
clientFactory = defaultClientFactory,
|
|
611
|
+
bodyLimit = BODY_LIMIT,
|
|
612
|
+
maxPending = MAX_PENDING,
|
|
613
|
+
maxApprovalSessions = MAX_APPROVAL_SESSIONS,
|
|
614
|
+
maxApprovalsPerSession = MAX_APPROVALS_PER_SESSION,
|
|
615
|
+
maxTrackedChildren = MAX_TRACKED_CHILDREN,
|
|
616
|
+
managedAgentStatePath = defaultManagedAgentStatePath(),
|
|
617
|
+
} = {},
|
|
618
|
+
) => {
|
|
619
|
+
const agents = new Map()
|
|
620
|
+
const invocations = new Map()
|
|
621
|
+
const brokerInvocations = new Map()
|
|
622
|
+
const permissionWaiters = new Map()
|
|
623
|
+
const approvals = new Map()
|
|
624
|
+
const trackedChildren = new Map()
|
|
625
|
+
const cleanedChildren = new Set()
|
|
626
|
+
const lifecycleOperations = new Map()
|
|
627
|
+
const permissionEventReplies = new Set()
|
|
628
|
+
const managedAgents = await loadManagedAgents(managedAgentStatePath)
|
|
629
|
+
const parentWorktree = await resolveProjectWorktree(project?.worktree)
|
|
630
|
+
const parentProject = parentWorktree ? path.basename(parentWorktree) : undefined
|
|
631
|
+
const pluginDirectory = (
|
|
632
|
+
typeof directory === "string" && directory && !directory.includes("\0")
|
|
633
|
+
? path.resolve(directory)
|
|
634
|
+
: undefined
|
|
635
|
+
)
|
|
636
|
+
const projectRoot = parentWorktree ?? pluginDirectory
|
|
637
|
+
const currentProjectIdentity = sessionProjectIdentity({
|
|
638
|
+
projectID: project?.id ?? project?.projectID ?? project?.projectId ?? project?.project_id,
|
|
639
|
+
})
|
|
640
|
+
const currentPathIdentities = new Set([
|
|
641
|
+
parentWorktree,
|
|
642
|
+
pluginDirectory,
|
|
643
|
+
boundedString(project?.worktree) && !project.worktree.includes("\0")
|
|
644
|
+
? path.resolve(project.worktree)
|
|
645
|
+
: undefined,
|
|
646
|
+
].filter(Boolean))
|
|
647
|
+
let approvalRoot
|
|
648
|
+
try {
|
|
649
|
+
const cwd = await realpath(path.resolve(directory ?? parentWorktree))
|
|
650
|
+
const bounds = [...new Set([parentWorktree, cwd].filter(Boolean))]
|
|
651
|
+
if (cwd !== path.parse(cwd).root && bounds.every((bound) => bound !== path.parse(bound).root)) {
|
|
652
|
+
approvalRoot = { cwd, bounds }
|
|
653
|
+
}
|
|
654
|
+
} catch {}
|
|
655
|
+
const v2Client = await clientFactory({
|
|
656
|
+
baseUrl: serverUrl ? serverUrl.toString() : "http://127.0.0.1",
|
|
657
|
+
directory,
|
|
658
|
+
})
|
|
659
|
+
const diagnostic = async (message, extra = {}) => {
|
|
660
|
+
if (typeof v2Client.app?.log !== "function") return
|
|
661
|
+
try {
|
|
662
|
+
await v2Client.app.log({
|
|
663
|
+
service: "rtrt-provenance",
|
|
664
|
+
level: "debug",
|
|
665
|
+
message,
|
|
666
|
+
extra,
|
|
667
|
+
})
|
|
668
|
+
} catch {}
|
|
669
|
+
}
|
|
670
|
+
let pendingCount = 0
|
|
671
|
+
let disposed = false
|
|
672
|
+
|
|
673
|
+
const invocationFor = (callID) => {
|
|
674
|
+
if (!callID) return randomUUID()
|
|
675
|
+
let invocationID = invocations.get(callID)
|
|
676
|
+
if (!invocationID) {
|
|
677
|
+
invocationID = randomUUID()
|
|
678
|
+
invocations.set(callID, invocationID)
|
|
679
|
+
}
|
|
680
|
+
return invocationID
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const rememberAgent = (sessionID, agent) => {
|
|
684
|
+
if (sessionID && agent) agents.set(sessionID, agent)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const confirmManagedSession = async (sessionID) => {
|
|
688
|
+
if (
|
|
689
|
+
!SAFE_SESSION_FIELD.test(sessionID ?? "") ||
|
|
690
|
+
cleanedChildren.has(sessionID) ||
|
|
691
|
+
typeof legacyClient?.session?.get !== "function"
|
|
692
|
+
) return
|
|
693
|
+
try {
|
|
694
|
+
const session = sdkSession(await legacyClient.session.get({ path: { id: sessionID } }))
|
|
695
|
+
if (boundedString(session?.id, 256) !== sessionID) return
|
|
696
|
+
if (session.agent === MANAGER_AGENT) return { manager: true, session }
|
|
697
|
+
const parentID = boundedString(session.parentID, 256)
|
|
698
|
+
if (!parentID || !SAFE_SESSION_FIELD.test(parentID)) return
|
|
699
|
+
const parent = sdkSession(await legacyClient.session.get({ path: { id: parentID } }))
|
|
700
|
+
if (boundedString(parent?.id, 256) !== parentID) return
|
|
701
|
+
if (managedAgents) {
|
|
702
|
+
if (!managedAgents.has(session.agent) || !sameAvailableIdentity(
|
|
703
|
+
session,
|
|
704
|
+
parent,
|
|
705
|
+
currentProjectIdentity,
|
|
706
|
+
currentPathIdentities,
|
|
707
|
+
)) return
|
|
708
|
+
} else if (parent?.agent !== MANAGER_AGENT) return
|
|
709
|
+
return { manager: false, session, parentID }
|
|
710
|
+
} catch {
|
|
711
|
+
// Ownership is security-sensitive: SDK errors and malformed responses fail closed.
|
|
712
|
+
return undefined
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const confirmPermissionRequest = async (requestID, sessionID, patterns) => {
|
|
717
|
+
if (typeof v2Client.permission?.list !== "function") return false
|
|
718
|
+
try {
|
|
719
|
+
const permissions = sdkPermissions(await v2Client.permission.list())
|
|
720
|
+
if (!permissions) return false
|
|
721
|
+
return permissions.some((permission) =>
|
|
722
|
+
permission &&
|
|
723
|
+
typeof permission === "object" &&
|
|
724
|
+
!Array.isArray(permission) &&
|
|
725
|
+
permission.id === requestID &&
|
|
726
|
+
permission.sessionID === sessionID &&
|
|
727
|
+
permission.permission === "bash" &&
|
|
728
|
+
Array.isArray(permission.patterns) &&
|
|
729
|
+
permission.patterns.length === patterns.length &&
|
|
730
|
+
permission.patterns.every((pattern, index) => pattern === patterns[index]))
|
|
731
|
+
} catch {
|
|
732
|
+
return false
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const rejectWaiters = (predicate) => {
|
|
737
|
+
for (const waiter of permissionWaiters.values()) {
|
|
738
|
+
if (!predicate(waiter)) continue
|
|
739
|
+
waiter.resolve("reject")
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const hasApproval = (sessionID, digest) => approvals.get(sessionID)?.has(digest) === true
|
|
744
|
+
|
|
745
|
+
const cacheApproval = (sessionID, digest) => {
|
|
746
|
+
let sessionApprovals = approvals.get(sessionID)
|
|
747
|
+
if (sessionApprovals?.has(digest)) return true
|
|
748
|
+
if (!sessionApprovals) {
|
|
749
|
+
if (approvals.size >= maxApprovalSessions) return false
|
|
750
|
+
sessionApprovals = new Set()
|
|
751
|
+
}
|
|
752
|
+
if (sessionApprovals.size >= maxApprovalsPerSession) return false
|
|
753
|
+
sessionApprovals.add(digest)
|
|
754
|
+
approvals.set(sessionID, sessionApprovals)
|
|
755
|
+
return true
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const invalidate = (sessionID, callID) => {
|
|
759
|
+
const key = `${sessionID ?? ""}\0${callID ?? ""}`
|
|
760
|
+
const record = brokerInvocations.get(key)
|
|
761
|
+
if (!record) return
|
|
762
|
+
brokerInvocations.delete(key)
|
|
763
|
+
record.valid = false
|
|
764
|
+
rejectWaiters((waiter) => waiter.record === record)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const respond = (response, requestID, decision = "reject", status = 200) => {
|
|
768
|
+
if (response.writableEnded || response.destroyed) return
|
|
769
|
+
response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" })
|
|
770
|
+
response.end(JSON.stringify({ version: 1, request_id: requestID ?? "", decision }))
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const readBody = (request) =>
|
|
774
|
+
new Promise((resolve, reject) => {
|
|
775
|
+
let size = 0
|
|
776
|
+
let oversized = false
|
|
777
|
+
const chunks = []
|
|
778
|
+
const cleanup = () => {
|
|
779
|
+
request.off("data", onData)
|
|
780
|
+
request.off("end", onEnd)
|
|
781
|
+
request.off("error", onError)
|
|
782
|
+
}
|
|
783
|
+
const onData = (chunk) => {
|
|
784
|
+
size += chunk.length
|
|
785
|
+
if (size > bodyLimit) {
|
|
786
|
+
oversized = true
|
|
787
|
+
return
|
|
788
|
+
}
|
|
789
|
+
chunks.push(chunk)
|
|
790
|
+
}
|
|
791
|
+
const onEnd = () => {
|
|
792
|
+
cleanup()
|
|
793
|
+
if (oversized) reject(Object.assign(new Error("oversize"), { status: 413 }))
|
|
794
|
+
else resolve(Buffer.concat(chunks).toString("utf8"))
|
|
795
|
+
}
|
|
796
|
+
const onError = (error) => {
|
|
797
|
+
cleanup()
|
|
798
|
+
reject(error)
|
|
799
|
+
}
|
|
800
|
+
request.on("data", onData)
|
|
801
|
+
request.once("end", onEnd)
|
|
802
|
+
request.once("error", onError)
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
const awaitReply = (record, sessionID, requestID, digest, request, response) =>
|
|
806
|
+
new Promise((resolve) => {
|
|
807
|
+
const key = `${sessionID}\0${requestID}`
|
|
808
|
+
const finish = (decision) => {
|
|
809
|
+
if (!permissionWaiters.has(key)) return
|
|
810
|
+
permissionWaiters.delete(key)
|
|
811
|
+
request.off("aborted", disconnect)
|
|
812
|
+
response.off("close", disconnect)
|
|
813
|
+
resolve(decision)
|
|
814
|
+
}
|
|
815
|
+
const disconnect = () => {
|
|
816
|
+
if (response.writableFinished) return
|
|
817
|
+
invalidate(record.sessionID, record.callID)
|
|
818
|
+
finish("reject")
|
|
819
|
+
}
|
|
820
|
+
permissionWaiters.set(key, { record, resolve: finish, sessionID, requestID, digest })
|
|
821
|
+
request.once("aborted", disconnect)
|
|
822
|
+
response.once("close", disconnect)
|
|
823
|
+
if (request.aborted || response.destroyed) disconnect()
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
const handlePermission = async (request, response) => {
|
|
827
|
+
let requestID = ""
|
|
828
|
+
let counted = false
|
|
829
|
+
try {
|
|
830
|
+
if (disposed || request.method !== "POST" || request.url !== BROKER_PATH) {
|
|
831
|
+
respond(response, requestID, "reject", 404)
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
if (pendingCount >= maxPending) {
|
|
835
|
+
respond(response, requestID, "reject", 429)
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
const contentLength = request.headers["content-length"]
|
|
839
|
+
if (typeof contentLength !== "string" || !/^\d+$/.test(contentLength)) {
|
|
840
|
+
respond(response, requestID, "reject", 411)
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
const declared = Number(contentLength)
|
|
844
|
+
if (!Number.isSafeInteger(declared) || declared > bodyLimit) {
|
|
845
|
+
respond(response, requestID, "reject", 413)
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
pendingCount += 1
|
|
849
|
+
counted = true
|
|
850
|
+
let body
|
|
851
|
+
try {
|
|
852
|
+
body = JSON.parse(await readBody(request))
|
|
853
|
+
} catch (error) {
|
|
854
|
+
if (!response.destroyed) respond(response, requestID, "reject", error?.status ?? 400)
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
requestID = boundedString(body?.request_id, 256) ?? ""
|
|
858
|
+
const allowedFields = new Set([
|
|
859
|
+
"version",
|
|
860
|
+
"request_id",
|
|
861
|
+
"broker_nonce",
|
|
862
|
+
"invocation_id",
|
|
863
|
+
"parent_session_id",
|
|
864
|
+
"parent_call_id",
|
|
865
|
+
"child_session_id",
|
|
866
|
+
"tool_use_id",
|
|
867
|
+
"tool_name",
|
|
868
|
+
"input",
|
|
869
|
+
])
|
|
870
|
+
const required = [
|
|
871
|
+
requestID,
|
|
872
|
+
boundedString(body?.broker_nonce, 256),
|
|
873
|
+
boundedString(body?.invocation_id, 256),
|
|
874
|
+
boundedString(body?.parent_session_id, 256),
|
|
875
|
+
boundedString(body?.parent_call_id, 256),
|
|
876
|
+
boundedString(body?.tool_name, 128),
|
|
877
|
+
]
|
|
878
|
+
if (
|
|
879
|
+
body?.version !== 1 ||
|
|
880
|
+
!body ||
|
|
881
|
+
typeof body !== "object" ||
|
|
882
|
+
Array.isArray(body) ||
|
|
883
|
+
Object.keys(body).some((field) => !allowedFields.has(field)) ||
|
|
884
|
+
required.some((value) => !value) ||
|
|
885
|
+
!body.input ||
|
|
886
|
+
typeof body.input !== "object" ||
|
|
887
|
+
Array.isArray(body.input) ||
|
|
888
|
+
!depthWithin(body) ||
|
|
889
|
+
(body.child_session_id !== undefined && !boundedString(body.child_session_id, 256)) ||
|
|
890
|
+
(body.tool_use_id !== undefined && !boundedString(body.tool_use_id, 256))
|
|
891
|
+
) {
|
|
892
|
+
respond(response, requestID, "reject", 400)
|
|
893
|
+
return
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const key = `${body.parent_session_id}\0${body.parent_call_id}`
|
|
897
|
+
const record = brokerInvocations.get(key)
|
|
898
|
+
const authorization = request.headers.authorization ?? ""
|
|
899
|
+
const suppliedToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""
|
|
900
|
+
const suppliedNonce = request.headers["x-rtrt-broker-nonce"]
|
|
901
|
+
if (
|
|
902
|
+
!record ||
|
|
903
|
+
!record.valid ||
|
|
904
|
+
!safeEqual(suppliedToken, record.token) ||
|
|
905
|
+
!safeEqual(suppliedNonce, record.nonce) ||
|
|
906
|
+
!safeEqual(body.broker_nonce, record.nonce) ||
|
|
907
|
+
!safeEqual(body.invocation_id, record.invocationID) ||
|
|
908
|
+
!safeEqual(body.parent_session_id, record.sessionID) ||
|
|
909
|
+
!safeEqual(body.parent_call_id, record.callID)
|
|
910
|
+
) {
|
|
911
|
+
respond(response, requestID, "reject", 401)
|
|
912
|
+
return
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const digest = createHash("sha256").update(JSON.stringify(body)).digest("base64url")
|
|
916
|
+
const previous = record.replays.get(requestID)
|
|
917
|
+
if (previous) {
|
|
918
|
+
const identical = previous.digest === digest && previous.done
|
|
919
|
+
respond(response, requestID, identical ? previous.decision : "reject", identical ? 200 : 409)
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
if (record.replays.size >= MAX_REPLAYS) {
|
|
923
|
+
respond(response, requestID, "reject", 429)
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
record.replays.set(requestID, { digest, done: false, decision: "reject" })
|
|
927
|
+
|
|
928
|
+
const mapped = await mappedPermission(body, projectRoot, pluginDirectory)
|
|
929
|
+
if (!mapped) {
|
|
930
|
+
record.replays.set(requestID, { digest, done: true, decision: "reject" })
|
|
931
|
+
respond(response, requestID)
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
const mappedDigest = approvalDigest(mapped)
|
|
935
|
+
if (hasApproval(record.sessionID, mappedDigest)) {
|
|
936
|
+
record.replays.set(requestID, { digest, done: true, decision: "always" })
|
|
937
|
+
respond(response, requestID, "always")
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
const permissionID = randomUUID()
|
|
941
|
+
let result
|
|
942
|
+
try {
|
|
943
|
+
result = await v2Client.session.permission.create({
|
|
944
|
+
sessionID: record.sessionID,
|
|
945
|
+
id: permissionID,
|
|
946
|
+
action: mapped.action,
|
|
947
|
+
resources: mapped.resources,
|
|
948
|
+
save: [],
|
|
949
|
+
metadata: { source: "claude-cli", tool: body.tool_name },
|
|
950
|
+
...(record.agent ? { agent: record.agent } : {}),
|
|
951
|
+
})
|
|
952
|
+
} catch {
|
|
953
|
+
result = { effect: "deny" }
|
|
954
|
+
}
|
|
955
|
+
const effect = result?.data?.data?.effect
|
|
956
|
+
let decision = "reject"
|
|
957
|
+
if (effect === "allow") decision = "once"
|
|
958
|
+
else if (effect === "ask") {
|
|
959
|
+
decision = await awaitReply(
|
|
960
|
+
record,
|
|
961
|
+
record.sessionID,
|
|
962
|
+
permissionID,
|
|
963
|
+
mappedDigest,
|
|
964
|
+
request,
|
|
965
|
+
response,
|
|
966
|
+
)
|
|
967
|
+
}
|
|
968
|
+
record.replays.set(requestID, { digest, done: true, decision })
|
|
969
|
+
respond(response, requestID, decision)
|
|
970
|
+
} catch {
|
|
971
|
+
respond(response, requestID, "reject", 500)
|
|
972
|
+
} finally {
|
|
973
|
+
if (counted) pendingCount -= 1
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const server = createServer((request, response) => void handlePermission(request, response))
|
|
978
|
+
await listenLoopback(server)
|
|
979
|
+
server.unref()
|
|
980
|
+
const address = server.address()
|
|
981
|
+
const brokerUrl = `http://127.0.0.1:${address.port}${BROKER_PATH}`
|
|
982
|
+
|
|
983
|
+
const abortTracked = async (sessionID, tracked, limit) => {
|
|
984
|
+
if (
|
|
985
|
+
disposed ||
|
|
986
|
+
tracked.aborted ||
|
|
987
|
+
tracked.abortAttempted ||
|
|
988
|
+
cleanedChildren.has(sessionID)
|
|
989
|
+
) return
|
|
990
|
+
tracked.abortAttempted = true
|
|
991
|
+
tracked.kind = limit.kind
|
|
992
|
+
tracked.status = limit.status
|
|
993
|
+
try {
|
|
994
|
+
if (typeof legacyClient?.session?.abort !== "function") {
|
|
995
|
+
await diagnostic("provider limit abort failed", { kind: limit.kind, reason: "unavailable" })
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
const result = await legacyClient.session.abort({ path: { id: sessionID } })
|
|
999
|
+
if (result?.error || result?.data !== true) {
|
|
1000
|
+
await diagnostic("provider limit abort rejected", { kind: limit.kind })
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
tracked.aborted = true
|
|
1004
|
+
await diagnostic("provider limit abort accepted", { kind: limit.kind })
|
|
1005
|
+
} catch {
|
|
1006
|
+
await diagnostic("provider limit abort failed", { kind: limit.kind })
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const reserveChildCandidate = (sessionID, details = {}) => {
|
|
1011
|
+
if (
|
|
1012
|
+
disposed ||
|
|
1013
|
+
!SAFE_SESSION_FIELD.test(sessionID ?? "") ||
|
|
1014
|
+
cleanedChildren.has(sessionID)
|
|
1015
|
+
) return undefined
|
|
1016
|
+
let tracked = trackedChildren.get(sessionID)
|
|
1017
|
+
if (!tracked) {
|
|
1018
|
+
if (trackedChildren.size >= Math.max(1, maxTrackedChildren)) return undefined
|
|
1019
|
+
tracked = { candidate: true, managed: undefined, aborted: false, ...details }
|
|
1020
|
+
trackedChildren.set(sessionID, tracked)
|
|
1021
|
+
}
|
|
1022
|
+
return tracked
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const recoverAndAbort = async (sessionID, limit) => {
|
|
1026
|
+
if (disposed || cleanedChildren.has(sessionID)) return
|
|
1027
|
+
const tracked = reserveChildCandidate(sessionID)
|
|
1028
|
+
if (!tracked) return
|
|
1029
|
+
if (tracked.managed === undefined) {
|
|
1030
|
+
const ownership = await confirmManagedSession(sessionID)
|
|
1031
|
+
if (disposed || cleanedChildren.has(sessionID)) return
|
|
1032
|
+
if (!ownership || ownership.manager) {
|
|
1033
|
+
tracked.managed = false
|
|
1034
|
+
return
|
|
1035
|
+
}
|
|
1036
|
+
tracked.managed = true
|
|
1037
|
+
tracked.parentID = ownership.parentID
|
|
1038
|
+
tracked.agent = ownership.session.agent
|
|
1039
|
+
}
|
|
1040
|
+
if (tracked.managed) await abortTracked(sessionID, tracked, limit)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const serializeLifecycle = (sessionID, operation) => {
|
|
1044
|
+
const previous = lifecycleOperations.get(sessionID) ?? Promise.resolve()
|
|
1045
|
+
const current = previous.catch(() => {}).then(operation)
|
|
1046
|
+
lifecycleOperations.set(sessionID, current)
|
|
1047
|
+
return current.finally(() => {
|
|
1048
|
+
if (lifecycleOperations.get(sessionID) === current) lifecycleOperations.delete(sessionID)
|
|
1049
|
+
})
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const tombstoneChild = (sessionID, confirmedChild = false) => {
|
|
1053
|
+
if (!sessionID || (!confirmedChild && !trackedChildren.has(sessionID))) return false
|
|
1054
|
+
trackedChildren.delete(sessionID)
|
|
1055
|
+
if (cleanedChildren.size >= Math.max(1, maxTrackedChildren)) {
|
|
1056
|
+
cleanedChildren.delete(cleanedChildren.values().next().value)
|
|
1057
|
+
}
|
|
1058
|
+
cleanedChildren.add(sessionID)
|
|
1059
|
+
return true
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const dispose = async () => {
|
|
1063
|
+
if (disposed) return
|
|
1064
|
+
disposed = true
|
|
1065
|
+
for (const record of brokerInvocations.values()) record.valid = false
|
|
1066
|
+
brokerInvocations.clear()
|
|
1067
|
+
approvals.clear()
|
|
1068
|
+
trackedChildren.clear()
|
|
1069
|
+
lifecycleOperations.clear()
|
|
1070
|
+
cleanedChildren.clear()
|
|
1071
|
+
permissionEventReplies.clear()
|
|
1072
|
+
rejectWaiters(() => true)
|
|
1073
|
+
await closeServer(server)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return {
|
|
1077
|
+
"chat.message": async (input) => {
|
|
1078
|
+
rememberAgent(input.sessionID, input.agent)
|
|
1079
|
+
},
|
|
1080
|
+
"chat.params": async (input) => {
|
|
1081
|
+
rememberAgent(input.sessionID, input.agent)
|
|
1082
|
+
},
|
|
1083
|
+
"permission.ask": async (input, output) => {
|
|
1084
|
+
const permission = input?.type ?? input?.permission
|
|
1085
|
+
if (permission !== "bash" && permission !== "Bash") return
|
|
1086
|
+
if (output?.status !== "ask") return
|
|
1087
|
+
const sessionID = boundedString(input?.sessionID, 256)
|
|
1088
|
+
if (!sessionID || !SAFE_SESSION_FIELD.test(sessionID) || !(await confirmManagedSession(sessionID))) return
|
|
1089
|
+
const patterns = Array.isArray(input?.pattern) ? input.pattern : [input?.pattern]
|
|
1090
|
+
if (!patterns.length || patterns.some((pattern) => typeof pattern !== "string")) return
|
|
1091
|
+
for (const pattern of patterns) if (!(await safeCommand(pattern, approvalRoot))) return
|
|
1092
|
+
output.status = "allow"
|
|
1093
|
+
},
|
|
1094
|
+
"tool.execute.before": async (input, output) => {
|
|
1095
|
+
const invocationID = invocationFor(input.callID)
|
|
1096
|
+
const command = output?.args?.command
|
|
1097
|
+
if ((input.tool === "Bash" || input.tool === "bash") && directPermissionClaude(command)) {
|
|
1098
|
+
const key = `${input.sessionID ?? ""}\0${input.callID ?? ""}`
|
|
1099
|
+
invalidate(input.sessionID, input.callID)
|
|
1100
|
+
brokerInvocations.set(key, {
|
|
1101
|
+
token: randomBytes(32).toString("base64url"),
|
|
1102
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
1103
|
+
invocationID,
|
|
1104
|
+
sessionID: String(input.sessionID ?? ""),
|
|
1105
|
+
callID: String(input.callID ?? ""),
|
|
1106
|
+
agent: agents.get(input.sessionID),
|
|
1107
|
+
replays: new Map(),
|
|
1108
|
+
valid: true,
|
|
1109
|
+
})
|
|
1110
|
+
}
|
|
1111
|
+
if (!RTRT_AGENT_TOOLS.has(input.tool)) return
|
|
1112
|
+
|
|
1113
|
+
output.args.invocation_id = invocationID
|
|
1114
|
+
output.args.parent_project = parentProject
|
|
1115
|
+
output.args.parent_session_id = input.sessionID
|
|
1116
|
+
output.args.parent_call_id = input.callID
|
|
1117
|
+
output.args.caller_agent = agents.get(input.sessionID)
|
|
1118
|
+
output.args.parent_cwd = directory
|
|
1119
|
+
output.args.parent_worktree = parentWorktree
|
|
1120
|
+
},
|
|
1121
|
+
"shell.env": async (input, output) => {
|
|
1122
|
+
if (parentWorktree) {
|
|
1123
|
+
const sessionTempDir = await ensureSessionTempDir(parentWorktree, input.sessionID)
|
|
1124
|
+
output.env.TMPDIR = sessionTempDir
|
|
1125
|
+
output.env.TEMP = sessionTempDir
|
|
1126
|
+
output.env.TMP = sessionTempDir
|
|
1127
|
+
}
|
|
1128
|
+
output.env.RTRT_INVOCATION_ID = invocationFor(input.callID)
|
|
1129
|
+
output.env.RTRT_OPENCODE_PLUGIN_ACTIVE = "1"
|
|
1130
|
+
output.env.RTRT_PARENT_PROJECT = parentProject
|
|
1131
|
+
output.env.RTRT_PARENT_CWD = input.cwd
|
|
1132
|
+
output.env.RTRT_PARENT_WORKTREE = parentWorktree
|
|
1133
|
+
if (input.sessionID) {
|
|
1134
|
+
output.env.RTRT_PARENT_SESSION_ID = input.sessionID
|
|
1135
|
+
const agent = agents.get(input.sessionID)
|
|
1136
|
+
if (agent) output.env.RTRT_PARENT_AGENT = agent
|
|
1137
|
+
}
|
|
1138
|
+
if (input.callID) output.env.RTRT_PARENT_CALL_ID = input.callID
|
|
1139
|
+
const record = brokerInvocations.get(`${input.sessionID ?? ""}\0${input.callID ?? ""}`)
|
|
1140
|
+
if (record?.valid) {
|
|
1141
|
+
output.env.RTRT_PERMISSION_BROKER_URL = brokerUrl
|
|
1142
|
+
output.env.RTRT_PERMISSION_BROKER_TOKEN = record.token
|
|
1143
|
+
output.env.RTRT_PERMISSION_BROKER_NONCE = record.nonce
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
"tool.execute.after": async (input) => {
|
|
1147
|
+
invalidate(input.sessionID, input.callID)
|
|
1148
|
+
invocations.delete(input.callID)
|
|
1149
|
+
},
|
|
1150
|
+
event: async (input) => {
|
|
1151
|
+
const event = input?.event ?? input
|
|
1152
|
+
const properties = event?.properties ?? {}
|
|
1153
|
+
if (event?.type === "session.created") {
|
|
1154
|
+
const sessionID = boundedString(properties.info?.id, 256)
|
|
1155
|
+
const parentID = boundedString(properties.info?.parentID, 256)
|
|
1156
|
+
const agent = boundedString(properties.info?.agent, 256)
|
|
1157
|
+
if (
|
|
1158
|
+
sessionID &&
|
|
1159
|
+
parentID &&
|
|
1160
|
+
SAFE_SESSION_FIELD.test(sessionID) &&
|
|
1161
|
+
SAFE_SESSION_FIELD.test(parentID) &&
|
|
1162
|
+
!cleanedChildren.has(sessionID) &&
|
|
1163
|
+
(!trackedChildren.has(sessionID) || trackedChildren.get(sessionID).managed === undefined) &&
|
|
1164
|
+
(trackedChildren.has(sessionID) || trackedChildren.size < maxTrackedChildren)
|
|
1165
|
+
) {
|
|
1166
|
+
// Reserve synchronously: OpenCode does not await plugin event callbacks.
|
|
1167
|
+
const tracked = reserveChildCandidate(sessionID, { parentID, agent })
|
|
1168
|
+
if (!tracked) return
|
|
1169
|
+
await serializeLifecycle(sessionID, async () => {
|
|
1170
|
+
const ownership = await confirmManagedSession(sessionID)
|
|
1171
|
+
if (disposed || cleanedChildren.has(sessionID)) return
|
|
1172
|
+
tracked.managed = Boolean(
|
|
1173
|
+
ownership &&
|
|
1174
|
+
!ownership.manager &&
|
|
1175
|
+
ownership.parentID === parentID &&
|
|
1176
|
+
(!agent || ownership.session.agent === agent)
|
|
1177
|
+
)
|
|
1178
|
+
if (tracked.managed) tracked.agent = ownership.session.agent
|
|
1179
|
+
})
|
|
1180
|
+
}
|
|
1181
|
+
} else if (event?.type === "permission.asked") {
|
|
1182
|
+
const requestID = boundedString(properties.id, 256)
|
|
1183
|
+
const sessionID = boundedString(properties.sessionID, 256)
|
|
1184
|
+
const patterns = properties.patterns
|
|
1185
|
+
if (
|
|
1186
|
+
properties.permission !== "bash" ||
|
|
1187
|
+
!requestID ||
|
|
1188
|
+
!sessionID ||
|
|
1189
|
+
!SAFE_SESSION_FIELD.test(requestID) ||
|
|
1190
|
+
!SAFE_SESSION_FIELD.test(sessionID) ||
|
|
1191
|
+
!Array.isArray(patterns) ||
|
|
1192
|
+
patterns.length === 0 ||
|
|
1193
|
+
patterns.length > MAX_PERMISSION_EVENT_REPLIES ||
|
|
1194
|
+
patterns.some((pattern) => typeof pattern !== "string") ||
|
|
1195
|
+
!approvalRoot ||
|
|
1196
|
+
permissionEventReplies.size >= MAX_PERMISSION_EVENT_REPLIES
|
|
1197
|
+
) return
|
|
1198
|
+
const key = `${sessionID}\0${requestID}`
|
|
1199
|
+
if (permissionEventReplies.has(key)) return
|
|
1200
|
+
// Reserve before asynchronous classification so duplicate events cannot race a reply.
|
|
1201
|
+
permissionEventReplies.add(key)
|
|
1202
|
+
const ownership = await confirmManagedSession(sessionID)
|
|
1203
|
+
if (!ownership || !(await confirmPermissionRequest(requestID, sessionID, patterns))) return
|
|
1204
|
+
for (const pattern of patterns) {
|
|
1205
|
+
if (!(await safeCommand(pattern, approvalRoot))) return
|
|
1206
|
+
}
|
|
1207
|
+
if (disposed) return
|
|
1208
|
+
try {
|
|
1209
|
+
await v2Client.permission.reply({ requestID, reply: "once" })
|
|
1210
|
+
} catch {
|
|
1211
|
+
// SDK/network failures fail closed; retained reservation prevents uncertain retries.
|
|
1212
|
+
}
|
|
1213
|
+
} else if (event?.type === "session.error") {
|
|
1214
|
+
const sessionID = boundedString(properties.sessionID, 256)
|
|
1215
|
+
const limit = sessionErrorProviderLimit(properties.error)
|
|
1216
|
+
if (sessionID && limit && reserveChildCandidate(sessionID)) {
|
|
1217
|
+
await serializeLifecycle(sessionID, () => recoverAndAbort(sessionID, limit))
|
|
1218
|
+
}
|
|
1219
|
+
} else if (event?.type === "session.next.retried" || event?.type === "session.status") {
|
|
1220
|
+
const sessionID = boundedString(properties.sessionID ?? properties.session_id, 256)
|
|
1221
|
+
const limit = event.type === "session.next.retried"
|
|
1222
|
+
? providerLimit(properties.error)
|
|
1223
|
+
: statusProviderLimit(properties.status)
|
|
1224
|
+
if (sessionID && (!limit || reserveChildCandidate(sessionID))) {
|
|
1225
|
+
await serializeLifecycle(
|
|
1226
|
+
sessionID,
|
|
1227
|
+
() => limit ? recoverAndAbort(sessionID, limit) : undefined,
|
|
1228
|
+
)
|
|
1229
|
+
}
|
|
1230
|
+
} else if (event?.type === "permission.v2.replied") {
|
|
1231
|
+
const sessionID = properties.sessionID ?? properties.session_id
|
|
1232
|
+
const requestID = properties.requestID ?? properties.request_id ?? properties.id
|
|
1233
|
+
const key = `${sessionID}\0${requestID}`
|
|
1234
|
+
const waiter = permissionWaiters.get(key)
|
|
1235
|
+
if (waiter) {
|
|
1236
|
+
const reply = properties.reply ?? properties.response ?? properties.decision
|
|
1237
|
+
if (reply === "always" || reply === "allow_always") {
|
|
1238
|
+
waiter.resolve(
|
|
1239
|
+
waiter.record.valid && cacheApproval(waiter.sessionID, waiter.digest)
|
|
1240
|
+
? "always"
|
|
1241
|
+
: "reject",
|
|
1242
|
+
)
|
|
1243
|
+
} else waiter.resolve(reply === "once" || reply === "allow_once" ? "once" : "reject")
|
|
1244
|
+
}
|
|
1245
|
+
} else if (event?.type === "session.idle" || event?.type === "session.deleted") {
|
|
1246
|
+
const sessionID = properties.sessionID ?? properties.session_id ?? properties.info?.id
|
|
1247
|
+
// Only known child candidates become terminal tombstones. Parent idle is ordinary.
|
|
1248
|
+
const deletedChild = event.type === "session.deleted" &&
|
|
1249
|
+
SAFE_SESSION_FIELD.test(properties.info?.parentID ?? "")
|
|
1250
|
+
tombstoneChild(sessionID, deletedChild)
|
|
1251
|
+
if (sessionID) void serializeLifecycle(sessionID, async () => {})
|
|
1252
|
+
if (event.type !== "session.deleted") return
|
|
1253
|
+
for (const key of permissionEventReplies) {
|
|
1254
|
+
if (key.startsWith(`${sessionID}\0`)) permissionEventReplies.delete(key)
|
|
1255
|
+
}
|
|
1256
|
+
approvals.delete(sessionID)
|
|
1257
|
+
for (const record of [...brokerInvocations.values()]) {
|
|
1258
|
+
if (record.sessionID === sessionID) invalidate(record.sessionID, record.callID)
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
},
|
|
1262
|
+
dispose,
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
export const RtrtProvenance = (input) => createPlugin(input)
|
|
1267
|
+
|
|
1268
|
+
export const __createRtrtProvenanceForTest = (input, options) => createPlugin(input, options)
|
|
1269
|
+
export const __resolveManagedAgentStatePathForTest = resolveManagedAgentStatePath
|
|
1270
|
+
// END rtrt-managed provenance plugin
|