@nifrajs/coding-agent 3.3.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/SECURITY.md +2 -1
- package/dist/approvals.d.ts +14 -2
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +51 -43
- package/dist/approvals.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -2
- package/dist/cli.js.map +1 -1
- package/dist/context.js +29 -1
- package/dist/context.js.map +1 -1
- package/dist/diff.js +38 -2
- package/dist/diff.js.map +1 -1
- package/dist/handoffs.d.ts.map +1 -1
- package/dist/handoffs.js +12 -4
- package/dist/handoffs.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +146 -23
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/isolated.js +37 -2
- package/dist/isolated.js.map +1 -1
- package/dist/native.d.ts +12 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +165 -40
- package/dist/native.js.map +1 -1
- package/dist/orchestration/compile.d.ts +7 -0
- package/dist/orchestration/compile.d.ts.map +1 -1
- package/dist/orchestration/compile.js +16 -2
- package/dist/orchestration/compile.js.map +1 -1
- package/dist/orchestration/evidence-store.d.ts.map +1 -1
- package/dist/orchestration/evidence-store.js +9 -4
- package/dist/orchestration/evidence-store.js.map +1 -1
- package/dist/orchestration/host.d.ts +10 -0
- package/dist/orchestration/host.d.ts.map +1 -1
- package/dist/orchestration/host.js +9 -0
- package/dist/orchestration/host.js.map +1 -1
- package/dist/replay.d.ts +4 -0
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +189 -24
- package/dist/replay.js.map +1 -1
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +209 -29
- package/dist/rpc.js.map +1 -1
- package/dist/session-migration.d.ts.map +1 -1
- package/dist/session-migration.js +3 -2
- package/dist/session-migration.js.map +1 -1
- package/dist/sessions.d.ts +20 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +269 -60
- package/dist/sessions.js.map +1 -1
- package/dist/subagents.d.ts +30 -0
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +118 -15
- package/dist/subagents.js.map +1 -1
- package/dist/verification.d.ts +28 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +119 -3
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +55 -23
- package/dist/workflows.js.map +1 -1
- package/package.json +9 -5
- package/src/approvals.ts +62 -45
- package/src/cli.ts +11 -2
- package/src/context.ts +32 -1
- package/src/diff.ts +40 -2
- package/src/handoffs.ts +20 -8
- package/src/host.ts +146 -23
- package/src/index.ts +7 -0
- package/src/isolated.ts +39 -2
- package/src/native.ts +213 -39
- package/src/orchestration/compile.ts +28 -2
- package/src/orchestration/evidence-store.ts +9 -3
- package/src/orchestration/host.ts +17 -0
- package/src/replay.ts +206 -25
- package/src/rpc.ts +262 -33
- package/src/session-migration.ts +3 -2
- package/src/sessions.ts +288 -57
- package/src/subagents.ts +147 -17
- package/src/verification.ts +146 -3
- package/src/workflows.ts +47 -27
package/src/sessions.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants as FS_CONSTANTS } from "node:fs"
|
|
2
|
+
import { type FileHandle, lstat, mkdir, open, rename, unlink, writeFile } from "node:fs/promises"
|
|
2
3
|
import { dirname, join } from "node:path"
|
|
3
4
|
|
|
5
|
+
const DEFAULT_MAX_SESSION_ENTRIES = 4_096
|
|
6
|
+
const DEFAULT_MAX_SESSION_BYTES = 8 * 1024 * 1024
|
|
7
|
+
const MAX_SESSION_ENTRIES = 100_000
|
|
8
|
+
const MAX_SESSION_BYTES = 128 * 1024 * 1024
|
|
9
|
+
// `node:constants` does not expose the POSIX-only named export on Windows in Bun.
|
|
10
|
+
// Read it from the platform-safe `node:fs` constants object and fall back to zero;
|
|
11
|
+
// the Windows open path uses its lstat/open/lstat guard instead of this flag.
|
|
12
|
+
const O_APPEND = FS_CONSTANTS.O_APPEND
|
|
13
|
+
const O_CREAT = FS_CONSTANTS.O_CREAT
|
|
14
|
+
const O_RDONLY = FS_CONSTANTS.O_RDONLY
|
|
15
|
+
const O_WRONLY = FS_CONSTANTS.O_WRONLY
|
|
16
|
+
const O_NOFOLLOW = FS_CONSTANTS.O_NOFOLLOW ?? 0
|
|
17
|
+
|
|
4
18
|
export interface SessionLogEntry {
|
|
5
19
|
readonly version: 1
|
|
6
20
|
readonly sessionId: string
|
|
@@ -18,7 +32,8 @@ export interface SessionStore {
|
|
|
18
32
|
payload?: unknown,
|
|
19
33
|
options?: { readonly pinned?: boolean },
|
|
20
34
|
): Promise<SessionLogEntry>
|
|
21
|
-
|
|
35
|
+
/** Read at most `limit` retained entries from the tail of the session log. */
|
|
36
|
+
read(sessionId: string, limit?: number): Promise<readonly SessionLogEntry[]>
|
|
22
37
|
checkpoint(sessionId: string, payload: unknown): Promise<void>
|
|
23
38
|
fork(sessionId: string, targetSessionId?: string): Promise<string>
|
|
24
39
|
}
|
|
@@ -26,6 +41,10 @@ export interface SessionStore {
|
|
|
26
41
|
export interface FileSessionStoreOptions {
|
|
27
42
|
readonly root: string
|
|
28
43
|
readonly maxEntryBytes?: number
|
|
44
|
+
/** Maximum retained JSONL entries per session. Defaults to 4,096. */
|
|
45
|
+
readonly maxEntries?: number
|
|
46
|
+
/** Maximum retained JSONL bytes per session. Defaults to 8 MiB. */
|
|
47
|
+
readonly maxBytes?: number
|
|
29
48
|
}
|
|
30
49
|
|
|
31
50
|
/**
|
|
@@ -35,13 +54,32 @@ export interface FileSessionStoreOptions {
|
|
|
35
54
|
export class FileSessionStore implements SessionStore {
|
|
36
55
|
private readonly root: string
|
|
37
56
|
private readonly maxEntryBytes: number
|
|
57
|
+
private readonly maxEntries: number
|
|
58
|
+
private readonly maxBytes: number
|
|
38
59
|
private readonly sequences = new Map<string, number>()
|
|
60
|
+
private readonly locks = new Map<string, Promise<void>>()
|
|
39
61
|
|
|
40
62
|
constructor(options: FileSessionStoreOptions) {
|
|
41
63
|
this.root = options.root
|
|
42
64
|
this.maxEntryBytes = options.maxEntryBytes ?? 256 * 1024
|
|
43
65
|
if (!Number.isSafeInteger(this.maxEntryBytes) || this.maxEntryBytes < 1024)
|
|
44
66
|
throw new RangeError("session store: maxEntryBytes must be at least 1024")
|
|
67
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_SESSION_ENTRIES
|
|
68
|
+
if (
|
|
69
|
+
!Number.isSafeInteger(this.maxEntries) ||
|
|
70
|
+
this.maxEntries < 1 ||
|
|
71
|
+
this.maxEntries > MAX_SESSION_ENTRIES
|
|
72
|
+
)
|
|
73
|
+
throw new RangeError(`session store: maxEntries must be between 1 and ${MAX_SESSION_ENTRIES}`)
|
|
74
|
+
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_SESSION_BYTES
|
|
75
|
+
if (
|
|
76
|
+
!Number.isSafeInteger(this.maxBytes) ||
|
|
77
|
+
this.maxBytes < this.maxEntryBytes + 1 ||
|
|
78
|
+
this.maxBytes > MAX_SESSION_BYTES
|
|
79
|
+
)
|
|
80
|
+
throw new RangeError(
|
|
81
|
+
`session store: maxBytes must be between maxEntryBytes+1 and ${MAX_SESSION_BYTES}`,
|
|
82
|
+
)
|
|
45
83
|
}
|
|
46
84
|
|
|
47
85
|
async append(
|
|
@@ -53,59 +91,65 @@ export class FileSessionStore implements SessionStore {
|
|
|
53
91
|
validateToken(sessionId, "sessionId")
|
|
54
92
|
if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(type))
|
|
55
93
|
throw new TypeError("session store: invalid event type")
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
return this.withSessionLock(sessionId, async () => {
|
|
95
|
+
await mkdir(this.root, { recursive: true })
|
|
96
|
+
const nextSeq = await this.nextSequence(sessionId)
|
|
97
|
+
const entry: SessionLogEntry = Object.freeze({
|
|
98
|
+
version: 1,
|
|
99
|
+
sessionId,
|
|
100
|
+
seq: nextSeq,
|
|
101
|
+
at: Date.now(),
|
|
102
|
+
type,
|
|
103
|
+
...(payload === undefined
|
|
104
|
+
? {}
|
|
105
|
+
: { payload: boundValue(redactValue(payload), this.maxEntryBytes) }),
|
|
106
|
+
...(options.pinned === true ? { pinned: true } : {}),
|
|
107
|
+
})
|
|
108
|
+
const line = JSON.stringify(entry)
|
|
109
|
+
if (Buffer.byteLength(line, "utf8") > this.maxEntryBytes)
|
|
110
|
+
throw new RangeError("session store: event exceeds maxEntryBytes")
|
|
111
|
+
const path = this.pathFor(sessionId)
|
|
112
|
+
const file = await openSessionFile(path, O_APPEND | O_CREAT | O_WRONLY, 0o600)
|
|
113
|
+
try {
|
|
114
|
+
await file.writeFile(`${line}\n`, "utf8")
|
|
115
|
+
} finally {
|
|
116
|
+
await file.close()
|
|
117
|
+
}
|
|
118
|
+
this.sequences.set(sessionId, nextSeq)
|
|
119
|
+
await this.compactIfNeeded(sessionId)
|
|
120
|
+
return entry
|
|
68
121
|
})
|
|
69
|
-
const line = JSON.stringify(entry)
|
|
70
|
-
if (Buffer.byteLength(line, "utf8") > this.maxEntryBytes)
|
|
71
|
-
throw new RangeError("session store: event exceeds maxEntryBytes")
|
|
72
|
-
await appendFile(this.pathFor(sessionId), `${line}\n`, "utf8")
|
|
73
|
-
this.sequences.set(sessionId, nextSeq)
|
|
74
|
-
return entry
|
|
75
122
|
}
|
|
76
123
|
|
|
77
|
-
async read(sessionId: string): Promise<readonly SessionLogEntry[]> {
|
|
124
|
+
async read(sessionId: string, limit = this.maxEntries): Promise<readonly SessionLogEntry[]> {
|
|
78
125
|
validateToken(sessionId, "sessionId")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (line.trim().length === 0) continue
|
|
84
|
-
const value: unknown = JSON.parse(line)
|
|
85
|
-
if (isSessionLogEntry(value, sessionId)) entries.push(Object.freeze(value))
|
|
86
|
-
}
|
|
87
|
-
const last = entries.at(-1)?.seq
|
|
88
|
-
if (last !== undefined) this.sequences.set(sessionId, last)
|
|
89
|
-
return Object.freeze(entries)
|
|
90
|
-
} catch (error) {
|
|
91
|
-
if (isNotFound(error)) return Object.freeze([])
|
|
92
|
-
throw error
|
|
93
|
-
}
|
|
126
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
127
|
+
throw new RangeError("session store: read limit must be a positive safe integer")
|
|
128
|
+
const boundedLimit = Math.min(limit, this.maxEntries)
|
|
129
|
+
return this.withSessionLock(sessionId, () => this.readUnlocked(sessionId, boundedLimit))
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
async checkpoint(sessionId: string, payload: unknown): Promise<void> {
|
|
97
133
|
validateToken(sessionId, "sessionId")
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
134
|
+
await this.withSessionLock(sessionId, async () => {
|
|
135
|
+
const target = join(this.root, `${sessionFileStem(sessionId)}.checkpoint.json`)
|
|
136
|
+
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
137
|
+
await mkdir(dirname(target), { recursive: true })
|
|
138
|
+
const content = JSON.stringify({
|
|
139
|
+
version: 1,
|
|
140
|
+
sessionId,
|
|
141
|
+
at: Date.now(),
|
|
142
|
+
payload: boundValue(redactValue(payload), this.maxEntryBytes),
|
|
143
|
+
})
|
|
144
|
+
// Exclusive creation prevents a local peer from planting a symlink at the temporary path.
|
|
145
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 })
|
|
146
|
+
try {
|
|
147
|
+
await rename(temporary, target)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await unlink(temporary).catch(() => {})
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
106
152
|
})
|
|
107
|
-
await writeFile(temporary, content, "utf8")
|
|
108
|
-
await rename(temporary, target)
|
|
109
153
|
}
|
|
110
154
|
|
|
111
155
|
async fork(
|
|
@@ -114,27 +158,134 @@ export class FileSessionStore implements SessionStore {
|
|
|
114
158
|
): Promise<string> {
|
|
115
159
|
validateToken(sessionId, "sessionId")
|
|
116
160
|
validateToken(targetSessionId, "targetSessionId")
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
return this.withSessionLock(sessionId, async () => {
|
|
162
|
+
const entries = await this.readUnlocked(sessionId, this.maxEntries)
|
|
163
|
+
await mkdir(this.root, { recursive: true })
|
|
164
|
+
const target = this.pathFor(targetSessionId)
|
|
165
|
+
const lines = entries.map((entry, index) =>
|
|
166
|
+
JSON.stringify({ ...entry, sessionId: targetSessionId, seq: index }),
|
|
167
|
+
)
|
|
168
|
+
// A caller must never be able to overwrite an existing session by choosing its id. The
|
|
169
|
+
// exclusive create also refuses a pre-planted symlink at the destination.
|
|
170
|
+
await writeFile(target, lines.length === 0 ? "" : `${lines.join("\n")}\n`, {
|
|
171
|
+
encoding: "utf8",
|
|
172
|
+
flag: "wx",
|
|
173
|
+
mode: 0o600,
|
|
174
|
+
})
|
|
175
|
+
this.sequences.set(targetSessionId, Math.max(0, lines.length - 1))
|
|
176
|
+
return targetSessionId
|
|
177
|
+
})
|
|
126
178
|
}
|
|
127
179
|
|
|
128
180
|
private pathFor(sessionId: string): string {
|
|
129
|
-
return join(this.root, `${sessionId}.jsonl`)
|
|
181
|
+
return join(this.root, `${sessionFileStem(sessionId)}.jsonl`)
|
|
130
182
|
}
|
|
131
183
|
|
|
132
184
|
private async nextSequence(sessionId: string): Promise<number> {
|
|
133
185
|
const cached = this.sequences.get(sessionId)
|
|
134
186
|
if (cached !== undefined) return cached + 1
|
|
135
|
-
const entries = await this.
|
|
187
|
+
const entries = await this.readUnlocked(sessionId, 1)
|
|
136
188
|
return (entries.at(-1)?.seq ?? -1) + 1
|
|
137
189
|
}
|
|
190
|
+
|
|
191
|
+
private async compactIfNeeded(sessionId: string): Promise<void> {
|
|
192
|
+
const path = this.pathFor(sessionId)
|
|
193
|
+
const file = await openSessionFile(path, O_RDONLY)
|
|
194
|
+
let size: number
|
|
195
|
+
try {
|
|
196
|
+
size = (await file.stat()).size
|
|
197
|
+
} finally {
|
|
198
|
+
await file.close()
|
|
199
|
+
}
|
|
200
|
+
if (size <= this.maxBytes) {
|
|
201
|
+
const recent = parseSessionEntries(
|
|
202
|
+
await this.readTailText(path, this.maxEntries + 1, this.maxBytes),
|
|
203
|
+
sessionId,
|
|
204
|
+
)
|
|
205
|
+
if (recent.length <= this.maxEntries) return
|
|
206
|
+
}
|
|
207
|
+
// The tail read is bounded by the retention cap plus one maximum-sized line. This keeps
|
|
208
|
+
// compaction memory bounded even if a pre-existing log is already oversized.
|
|
209
|
+
const recent = parseSessionEntries(
|
|
210
|
+
await this.readTailText(path, this.maxEntries + 1, this.maxBytes + this.maxEntryBytes),
|
|
211
|
+
sessionId,
|
|
212
|
+
)
|
|
213
|
+
const retained = retainEntries(recent, this.maxEntries, this.maxBytes)
|
|
214
|
+
if (retained.length === 0) return
|
|
215
|
+
const content = `${retained.map((entry) => JSON.stringify(entry)).join("\n")}\n`
|
|
216
|
+
if (Buffer.byteLength(content, "utf8") >= size && size <= this.maxBytes) return
|
|
217
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
218
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 })
|
|
219
|
+
try {
|
|
220
|
+
await rename(temporary, path)
|
|
221
|
+
} catch (error) {
|
|
222
|
+
await unlink(temporary).catch(() => {})
|
|
223
|
+
throw error
|
|
224
|
+
}
|
|
225
|
+
this.sequences.set(sessionId, retained.at(-1)!.seq)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async readTailText(
|
|
229
|
+
path: string,
|
|
230
|
+
minimumLines: number,
|
|
231
|
+
maxBytes: number,
|
|
232
|
+
): Promise<string> {
|
|
233
|
+
const file = await openSessionFile(path, O_RDONLY)
|
|
234
|
+
try {
|
|
235
|
+
const size = (await file.stat()).size
|
|
236
|
+
let end = size
|
|
237
|
+
let remaining = Math.min(size, maxBytes)
|
|
238
|
+
const chunks: Buffer[] = []
|
|
239
|
+
const chunkBytes = 64 * 1024
|
|
240
|
+
while (remaining > 0) {
|
|
241
|
+
const take = Math.min(remaining, chunkBytes)
|
|
242
|
+
end -= take
|
|
243
|
+
const bytes = Buffer.allocUnsafe(take)
|
|
244
|
+
const result = await file.read(bytes, 0, take, end)
|
|
245
|
+
chunks.unshift(bytes.subarray(0, result.bytesRead))
|
|
246
|
+
remaining -= result.bytesRead
|
|
247
|
+
const text = Buffer.concat(chunks).toString("utf8")
|
|
248
|
+
const lines = text.split("\n")
|
|
249
|
+
const completeLines = end > 0 ? lines.length - 1 : lines.length
|
|
250
|
+
if (completeLines >= minimumLines || end === 0 || result.bytesRead === 0) return text
|
|
251
|
+
}
|
|
252
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
253
|
+
} finally {
|
|
254
|
+
await file.close()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async readUnlocked(
|
|
259
|
+
sessionId: string,
|
|
260
|
+
boundedLimit: number,
|
|
261
|
+
): Promise<readonly SessionLogEntry[]> {
|
|
262
|
+
try {
|
|
263
|
+
const text = await this.readTailText(this.pathFor(sessionId), boundedLimit, this.maxBytes)
|
|
264
|
+
const entries = parseSessionEntries(text, sessionId)
|
|
265
|
+
const last = entries.at(-1)?.seq
|
|
266
|
+
if (last !== undefined) this.sequences.set(sessionId, last)
|
|
267
|
+
return Object.freeze(entries.slice(-boundedLimit))
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (isNotFound(error)) return Object.freeze([])
|
|
270
|
+
throw error
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async withSessionLock<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
|
|
275
|
+
const previous = this.locks.get(sessionId) ?? Promise.resolve()
|
|
276
|
+
let release!: () => void
|
|
277
|
+
const current = new Promise<void>((resolve) => {
|
|
278
|
+
release = resolve
|
|
279
|
+
})
|
|
280
|
+
this.locks.set(sessionId, current)
|
|
281
|
+
await previous
|
|
282
|
+
try {
|
|
283
|
+
return await operation()
|
|
284
|
+
} finally {
|
|
285
|
+
release()
|
|
286
|
+
if (this.locks.get(sessionId) === current) this.locks.delete(sessionId)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
138
289
|
}
|
|
139
290
|
|
|
140
291
|
export interface ContextRecord {
|
|
@@ -235,6 +386,54 @@ export class ContextWindow {
|
|
|
235
386
|
}
|
|
236
387
|
}
|
|
237
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Open a session file without following a final-component symlink. Windows Bun does not support
|
|
391
|
+
* the POSIX `O_NOFOLLOW` flag, so it gets a bounded lstat/open/lstat guard instead; the descriptor is
|
|
392
|
+
* checked before any caller write or read. POSIX keeps the kernel-enforced flag for the race-free path.
|
|
393
|
+
*/
|
|
394
|
+
async function openSessionFile(path: string, flags: number, mode?: number): Promise<FileHandle> {
|
|
395
|
+
if (process.platform !== "win32") {
|
|
396
|
+
return mode === undefined
|
|
397
|
+
? open(path, flags | O_NOFOLLOW)
|
|
398
|
+
: open(path, flags | O_NOFOLLOW, mode)
|
|
399
|
+
}
|
|
400
|
+
await rejectSessionSymlink(path)
|
|
401
|
+
const file = mode === undefined ? await open(path, flags) : await open(path, flags, mode)
|
|
402
|
+
try {
|
|
403
|
+
await rejectSessionSymlink(path)
|
|
404
|
+
return file
|
|
405
|
+
} catch (error) {
|
|
406
|
+
await file.close()
|
|
407
|
+
throw error
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function rejectSessionSymlink(path: string): Promise<void> {
|
|
412
|
+
try {
|
|
413
|
+
if ((await lstat(path)).isSymbolicLink())
|
|
414
|
+
throw new Error("session store: refusing a symbolic-link session file")
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (isNotFound(error)) return
|
|
417
|
+
throw error
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Device names Windows reserves even with an extension after them: `nul.jsonl` is the NUL device.
|
|
422
|
+
const WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The file name stem for a session id. On Windows a `:` in a file name addresses an NTFS alternate
|
|
426
|
+
* data stream, so it is written as `%3A` there, and an id that starts with a reserved device name has
|
|
427
|
+
* its first character percent-encoded. `%` is outside the id grammar, so no two ids collide.
|
|
428
|
+
*/
|
|
429
|
+
export function sessionFileStem(sessionId: string, platform = process.platform): string {
|
|
430
|
+
if (platform !== "win32") return sessionId
|
|
431
|
+
const stem = sessionId.replaceAll(":", "%3A")
|
|
432
|
+
return WINDOWS_DEVICE.test(stem)
|
|
433
|
+
? `%${stem.charCodeAt(0).toString(16).toUpperCase()}${stem.slice(1)}`
|
|
434
|
+
: stem
|
|
435
|
+
}
|
|
436
|
+
|
|
238
437
|
function validateToken(value: string, name: string): void {
|
|
239
438
|
if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(value))
|
|
240
439
|
throw new TypeError(`session store: ${name} must be a bounded token`)
|
|
@@ -257,6 +456,38 @@ function isSessionLogEntry(value: unknown, sessionId: string): value is SessionL
|
|
|
257
456
|
)
|
|
258
457
|
}
|
|
259
458
|
|
|
459
|
+
function parseSessionEntries(text: string, sessionId: string): SessionLogEntry[] {
|
|
460
|
+
const entries: SessionLogEntry[] = []
|
|
461
|
+
for (const line of text.split("\n")) {
|
|
462
|
+
if (line.trim().length === 0) continue
|
|
463
|
+
try {
|
|
464
|
+
const value: unknown = JSON.parse(line)
|
|
465
|
+
if (isSessionLogEntry(value, sessionId)) entries.push(Object.freeze(value))
|
|
466
|
+
} catch {
|
|
467
|
+
// A tail read can begin in the middle of an old line. Invalid records are ignored so a
|
|
468
|
+
// damaged/partially rotated log cannot turn every history request into an unbounded retry.
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return entries
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function retainEntries(
|
|
475
|
+
entries: readonly SessionLogEntry[],
|
|
476
|
+
maxEntries: number,
|
|
477
|
+
maxBytes: number,
|
|
478
|
+
): readonly SessionLogEntry[] {
|
|
479
|
+
const retained: SessionLogEntry[] = []
|
|
480
|
+
let bytes = 0
|
|
481
|
+
for (let index = entries.length - 1; index >= 0 && retained.length < maxEntries; index--) {
|
|
482
|
+
const entry = entries[index]!
|
|
483
|
+
const size = Buffer.byteLength(JSON.stringify(entry), "utf8") + 1
|
|
484
|
+
if (bytes + size > maxBytes) break
|
|
485
|
+
retained.unshift(entry)
|
|
486
|
+
bytes += size
|
|
487
|
+
}
|
|
488
|
+
return retained
|
|
489
|
+
}
|
|
490
|
+
|
|
260
491
|
function redactValue(value: unknown, key = "", depth = 0): unknown {
|
|
261
492
|
if (depth > 5) return "[depth-limited]"
|
|
262
493
|
if (/(?:secret|token|password|passwd|authorization|api[-_]?key|private[-_]?key)/i.test(key))
|