pipeline-moe 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.

Potentially problematic release.


This version of pipeline-moe might be problematic. Click here for more details.

Files changed (48) hide show
  1. package/.env.example +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +408 -0
  4. package/bin/pipeline-moe.mjs +40 -0
  5. package/package.json +68 -0
  6. package/presets/2106BUILD.json +163 -0
  7. package/presets/CHEAPBUILD.json +97 -0
  8. package/presets/FREEROOM.json +96 -0
  9. package/presets/Versa.json +145 -0
  10. package/presets/cloud-main.json +76 -0
  11. package/presets/cloud-sprint.json +70 -0
  12. package/presets/local-default.json +152 -0
  13. package/presets/main.json +147 -0
  14. package/presets/mainmix.json +145 -0
  15. package/src/circuit-breaker.ts +141 -0
  16. package/src/config.ts +46 -0
  17. package/src/custom-tools/arxiv-search.ts +186 -0
  18. package/src/custom-tools/check-room.ts +45 -0
  19. package/src/custom-tools/destroy-room.ts +45 -0
  20. package/src/custom-tools/index.ts +75 -0
  21. package/src/custom-tools/spawn-room.ts +99 -0
  22. package/src/custom-tools/stop-room.ts +50 -0
  23. package/src/custom-tools/web-read.ts +104 -0
  24. package/src/custom-tools/web-search.ts +144 -0
  25. package/src/custom-tools/youcom-search.ts +230 -0
  26. package/src/custom-tools/youtube-transcript.ts +124 -0
  27. package/src/local-model-lock.ts +52 -0
  28. package/src/model.ts +131 -0
  29. package/src/orchestrator.ts +59 -0
  30. package/src/participant.ts +423 -0
  31. package/src/path-guard.ts +13 -0
  32. package/src/personas.ts +480 -0
  33. package/src/receipts.ts +120 -0
  34. package/src/registry.ts +317 -0
  35. package/src/room-manager.ts +505 -0
  36. package/src/room.ts +1657 -0
  37. package/src/sandbox-tools.ts +141 -0
  38. package/src/server.ts +1846 -0
  39. package/src/sse.ts +86 -0
  40. package/src/sshfs.ts +139 -0
  41. package/src/store.ts +79 -0
  42. package/src/types.ts +131 -0
  43. package/src/validation.ts +36 -0
  44. package/tsconfig.json +15 -0
  45. package/web/dist/assets/index-CmMGhMKG.css +1 -0
  46. package/web/dist/assets/index-LAGqbZII.js +41 -0
  47. package/web/dist/index.html +13 -0
  48. package/web/tsconfig.json +21 -0
@@ -0,0 +1,59 @@
1
+ // RoomOrchestrator — the capability surface that lets an agent (the planner)
2
+ // spawn, inspect, and tear down sub-rooms from inside a turn.
3
+ //
4
+ // The interface lives here, decoupled from its implementation (server.ts) and
5
+ // from the tools that consume it (custom-tools/spawn-room.ts etc.). This avoids
6
+ // a circular import between the tool registry and the server/room-manager, and
7
+ // keeps the tools testable against a mock orchestrator.
8
+
9
+ export interface SpawnRoomOptions {
10
+ /** Display name for the sub-room. */
11
+ name: string
12
+ /** The goal the sub-room's agents work on autonomously. */
13
+ goal: string
14
+ /** Preset roster name (from presets/). Omit to use the default roster. */
15
+ preset?: string
16
+ /** Working directory scope — local path or user@host:/path (sshfs).
17
+ * Omit for the pipeline workspace. */
18
+ workspaceDir?: string
19
+ /** Goal completion mode. "auto" (default): the goal completes when the
20
+ * pipeline drains naturally. "eval": after each drain the evaluator agent
21
+ * verifies the goal independently and either dispatches more work or declares
22
+ * GOAL_MET. */
23
+ goalMode?: "auto" | "eval"
24
+ /** Agent id that evaluates the goal in "eval" mode. Defaults to "planner". */
25
+ goalEvaluator?: string
26
+ /** Max eval iterations before the goal auto-fails (eval mode only). Default 10. */
27
+ maxGoalIterations?: number
28
+ }
29
+
30
+ export interface SpawnRoomResult {
31
+ roomId: string
32
+ name: string
33
+ goalStatus: string
34
+ }
35
+
36
+ export interface CheckRoomResult {
37
+ found: boolean
38
+ roomId: string
39
+ name?: string
40
+ goalStatus?: string
41
+ goalText?: string | null
42
+ /** The last few transcript lines, "Author: text" formatted. */
43
+ lastMessages?: string[]
44
+ }
45
+
46
+ export interface RoomOrchestrator {
47
+ /** Create a room, load its roster, submit its goal. Fire-and-forget — the
48
+ * room runs in the background and this resolves once it has started. */
49
+ spawnRoom(opts: SpawnRoomOptions): Promise<SpawnRoomResult>
50
+ /** Read a sub-room's current goal status and recent transcript. */
51
+ checkRoom(roomId: string): CheckRoomResult
52
+ /** Stop a sub-room's in-flight pipeline WITHOUT destroying it: aborts running
53
+ * agents and cancels any goal (status → "cancelled"), leaving the room and its
54
+ * transcript intact so the caller can inspect why it ran away. Returns false
55
+ * when the room is absent or protected (the default room cannot be stopped). */
56
+ stopRoom(roomId: string): Promise<boolean>
57
+ /** Destroy a sub-room (aborts it, then unmounts any sshfs target). Returns false if absent. */
58
+ destroyRoom(roomId: string): Promise<boolean>
59
+ }
@@ -0,0 +1,423 @@
1
+ // A Participant wraps one pi AgentSession (one persona) and exposes a simple
2
+ // run()/dispose() lifecycle. Its session keeps its own conversation memory
3
+ // across turns (stateful). The shared room transcript is threaded in by Room.
4
+
5
+ import {
6
+ createAgentSession,
7
+ DefaultResourceLoader,
8
+ getAgentDir,
9
+ SessionManager,
10
+ SettingsManager,
11
+ type AgentSession,
12
+ type AgentSessionEvent,
13
+ } from "@earendil-works/pi-coding-agent"
14
+ import { readFileSync } from "node:fs"
15
+ import { access, readFile } from "node:fs/promises"
16
+ import { constants } from "node:fs"
17
+ import { join } from "node:path"
18
+ import { config } from "./config.js"
19
+ import { buildConfinedTools } from "./sandbox-tools.js"
20
+ import { buildCustomTools } from "./custom-tools/index.js"
21
+ import { resolveModelRef, type ResolvedModel } from "./model.js"
22
+ import type { RoomOrchestrator } from "./orchestrator.js"
23
+ import type { Persona, ParticipantStatus, ToolActivity } from "./types.js"
24
+
25
+ /** Cap a tool result/arg to keep SSE frames and persisted transcripts small. */
26
+ function clip(value: unknown, max = 2000): string {
27
+ let s: string
28
+ if (typeof value === "string") s = value
29
+ else {
30
+ try {
31
+ s = JSON.stringify(value)
32
+ } catch {
33
+ s = String(value)
34
+ }
35
+ }
36
+ return s.length > max ? `${s.slice(0, max)}… (+${s.length - max} chars)` : s
37
+ }
38
+
39
+ /** The workspace-scope note, parameterised by the room's actual root directory.
40
+ * A room scoped to the pipeline workspace gets the shared-workspace wording;
41
+ * a room scoped elsewhere (e.g. another project, or the machine root) is told
42
+ * exactly which directory its file tools are confined to. */
43
+ function workspaceNote(root: string): string {
44
+ return (
45
+ `Your working directory is ${root}. Use paths relative to it ` +
46
+ "(e.g. `notes.md`, `src/app.ts`). Never read or write outside it — absolute paths " +
47
+ "pointing outside this root are denied."
48
+ )
49
+ }
50
+
51
+ const ROOM_NOTE =
52
+ "You are one agent in a shared multi-agent chat room. Other agents are addressed by " +
53
+ "@<id> in lowercase (e.g. @scout, @builder, @auditor, @scribe, @tester). To hand work " +
54
+ "to another agent, write @<id> EXPLICITLY in your reply — a plain sentence like " +
55
+ "'over to the builder' does NOT trigger anything. Only @-mention an agent when you " +
56
+ "genuinely need it. Do not @-mention yourself, and do not use @all (that is for the human).\n" +
57
+ "You can refer to other agents by name in discussion (e.g. 'the builder said...') without " +
58
+ "triggering a handoff — only the @prefix routes work.\n" +
59
+ "When handing off work to another agent, @-mention them in your reply — " +
60
+ "any @mention triggers routing. Example: 'I've finished the analysis. @builder please implement " +
61
+ "the fix above.'\n" +
62
+ "If you need information only the user can provide (preferences, credentials, context), " +
63
+ "use the ask_user tool — it will pause the pipeline and wait for their response. Do NOT " +
64
+ "use it for rhetorical questions or self-clarification.\n" +
65
+ "Your personal memory lives at agent_memory/<your_id>.md (e.g. agent_memory/builder.md). " +
66
+ "Read it at the start of a task to recall prior context. The scribe updates these files. " +
67
+ "After a compaction, your memory is refreshed automatically."
68
+
69
+ export type Emit = (event: "token" | "status" | "activity" | "reasoning", data: unknown) => void
70
+
71
+ /** What a turn produced: the final text plus the tool calls made to get there. */
72
+ export interface TurnResult {
73
+ text: string
74
+ activity: ToolActivity[]
75
+ /** Reasoning trace accumulated during the turn. */
76
+ reasoning?: string
77
+ /** If the agent called ask_user, the question text. */
78
+ question?: string
79
+ }
80
+
81
+ export class Participant {
82
+ readonly persona: Persona
83
+ active = true
84
+ /** When true, may run concurrently with adjacent parallel-flagged agents. */
85
+ parallel = false
86
+ status: ParticipantStatus = "idle"
87
+ /** Index of the next room transcript entry this participant has NOT yet seen. */
88
+ cursor = 0
89
+
90
+ private session!: AgentSession
91
+ private unsubscribe: (() => void) | null = null
92
+ private buffer = ""
93
+ /** Tool calls made during the current turn, keyed for start/end matching. */
94
+ private activity = new Map<string, ToolActivity>()
95
+ /** Reasoning accumulated during the current turn. */
96
+ private reasoningBuffer = ""
97
+ private readonly emit: Emit
98
+ /** The directory this participant's file tools are confined to. */
99
+ private readonly workspaceDir: string
100
+
101
+ private constructor(persona: Persona, emit: Emit, workspaceDir: string) {
102
+ this.persona = persona
103
+ this.emit = emit
104
+ this.workspaceDir = workspaceDir
105
+ }
106
+
107
+ static async create(
108
+ persona: Persona,
109
+ resolved: ResolvedModel,
110
+ emit: Emit,
111
+ workspaceDir: string = config.workspaceDir,
112
+ orchestrator?: RoomOrchestrator,
113
+ defaultThinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" = config.thinkingLevel,
114
+ allowCloud: boolean = config.allowCloud,
115
+ compactionReserveTokens: number = 38000,
116
+ ): Promise<Participant> {
117
+ const p = new Participant(persona, emit, workspaceDir)
118
+
119
+ // Read agent memory (if it exists) — injected after the persona prompt.
120
+ // Capped at 4KB to avoid consuming excessive context tokens.
121
+ const memoryPath = join(workspaceDir, "agent_memory", `${persona.id}.md`)
122
+ let memoryNote = ""
123
+ try {
124
+ await access(memoryPath, constants.R_OK)
125
+ const raw = await readFile(memoryPath, "utf-8")
126
+ const content = raw.length > 4096 ? raw.slice(0, 4096) + "… (truncated)" : raw
127
+ memoryNote = `\nYOUR MEMORY (agent_memory/${persona.id}.md):\n${content}\n` +
128
+ "---\n(End of memory — updated by the scribe. After compaction, this is refreshed.)\n"
129
+ } catch {
130
+ // No memory file — fine, first run or not yet populated.
131
+ }
132
+
133
+ const loader = new DefaultResourceLoader({
134
+ cwd: workspaceDir,
135
+ agentDir: getAgentDir(),
136
+ // Append the persona to pi's default prompt so we keep tool-usage guidance.
137
+ appendSystemPromptOverride: (base: string[]) => [
138
+ ...base,
139
+ persona.systemPrompt,
140
+ workspaceNote(workspaceDir),
141
+ ROOM_NOTE,
142
+ ...(memoryNote ? [memoryNote] : []),
143
+ ],
144
+ })
145
+ await loader.reload()
146
+
147
+ // Each persona may pin its own model ("provider/id"); undefined → default.
148
+ const model = resolveModelRef(resolved, allowCloud, persona.model)
149
+
150
+ // Auto-compaction: trigger when context exceeds 90K tokens.
151
+ // reserveTokens = contextWindow - threshold. For 128K ctx: 128000 - 90000 = 38000.
152
+ const settings = SettingsManager.inMemory({
153
+ compaction: { enabled: true, reserveTokens: compactionReserveTokens },
154
+ })
155
+
156
+ const { session } = await createAgentSession({
157
+ cwd: workspaceDir,
158
+ // Disable built-in file tools and supply workspace-confined replacements,
159
+ // gated to this persona's allowlist. Keeps all file work inside the workspace.
160
+ noTools: "builtin",
161
+ customTools: (() => {
162
+ const confined = buildConfinedTools(workspaceDir, persona.tools)
163
+ const custom = buildCustomTools(persona.tools, { orchestrator })
164
+ console.log(`[Participant.create] persona=${persona.id} orchestrator=${!!orchestrator} confined=${confined.length} custom=${custom.length} customNames=${custom.map(t => t.name).join(",")}`)
165
+ return [...confined, ...custom]
166
+ })(),
167
+ thinkingLevel: persona.thinkingLevel ?? defaultThinkingLevel,
168
+ resourceLoader: loader,
169
+ sessionManager: SessionManager.inMemory(workspaceDir),
170
+ settingsManager: settings,
171
+ authStorage: resolved.authStorage,
172
+ modelRegistry: resolved.modelRegistry,
173
+ ...(model ? { model } : {}),
174
+ })
175
+ // Enable auto-compaction on the session (triggers compact() automatically
176
+ // when context tokens exceed the threshold after each turn).
177
+ session.setAutoCompactionEnabled(true)
178
+ // Name the session after the persona for debug visibility.
179
+ session.setSessionName(persona.id)
180
+ p.session = session
181
+ p.unsubscribe = session.subscribe((ev) => p.onEvent(ev))
182
+ return p
183
+ }
184
+
185
+ private setStatus(status: ParticipantStatus): void {
186
+ this.status = status
187
+ this.emit("status", { id: this.persona.id, status })
188
+ }
189
+
190
+ private onEvent(ev: AgentSessionEvent): void {
191
+ if (ev.type === "message_update") {
192
+ const me = ev.assistantMessageEvent
193
+ if (me.type === "text_delta") {
194
+ this.buffer += me.delta
195
+ this.emit("token", { id: this.persona.id, delta: me.delta })
196
+ } else if (me.type === "thinking_delta") {
197
+ this.reasoningBuffer += me.delta
198
+ this.emit("reasoning", { id: this.persona.id, delta: me.delta })
199
+ }
200
+ } else if (ev.type === "tool_execution_start") {
201
+ const item: ToolActivity = {
202
+ toolCallId: ev.toolCallId,
203
+ toolName: ev.toolName,
204
+ args: ev.args,
205
+ status: "running",
206
+ ts: Date.now(),
207
+ }
208
+ this.activity.set(ev.toolCallId, item)
209
+ this.setStatus("working")
210
+ this.emit("activity", { id: this.persona.id, item })
211
+ } else if (ev.type === "compaction_start") {
212
+ this.emit("status", { id: this.persona.id, status: "compacting", reason: ev.reason })
213
+ } else if (ev.type === "compaction_end") {
214
+ this.emit("status", { id: this.persona.id, status: "idle", compactionResult: ev.result ? { summary: ev.result.summary.slice(0, 200), tokensBefore: ev.result.tokensBefore } : undefined })
215
+ } else if (ev.type === "tool_execution_end") {
216
+ const item = this.activity.get(ev.toolCallId) ?? {
217
+ toolCallId: ev.toolCallId,
218
+ toolName: ev.toolName,
219
+ status: "running" as const,
220
+ ts: Date.now(),
221
+ }
222
+ item.status = ev.isError ? "error" : "ok"
223
+ item.result = clip(ev.result)
224
+ this.activity.set(ev.toolCallId, item)
225
+ this.setStatus("active")
226
+ this.emit("activity", { id: this.persona.id, item })
227
+ } else if (ev.type === "auto_retry_start") {
228
+ this.emit("status", {
229
+ id: this.persona.id,
230
+ status: "retrying",
231
+ retry: {
232
+ attempt: ev.attempt,
233
+ maxAttempts: ev.maxAttempts,
234
+ delayMs: ev.delayMs,
235
+ errorMessage: ev.errorMessage,
236
+ },
237
+ })
238
+ } else if (ev.type === "auto_retry_end") {
239
+ this.emit("status", {
240
+ id: this.persona.id,
241
+ status: ev.success ? "active" : "idle",
242
+ retryResult: {
243
+ success: ev.success,
244
+ attempt: ev.attempt,
245
+ finalError: ev.finalError,
246
+ },
247
+ })
248
+ }
249
+ }
250
+
251
+ /** Run one turn with the given prompt text. Optionally pass image paths
252
+ * (workspace-relative, e.g. "media/abc.png") for vision support. */
253
+ async run(promptText: string, imagePaths?: string[]): Promise<TurnResult> {
254
+ this.buffer = ""
255
+ this.reasoningBuffer = ""
256
+ this.activity.clear()
257
+ this.setStatus("active")
258
+ try {
259
+ const images = await this.resolveImages(imagePaths)
260
+ await this.session.prompt(promptText, images.length > 0 ? { images } : undefined)
261
+ const result: TurnResult = {
262
+ text: this.buffer.trim(),
263
+ activity: [...this.activity.values()],
264
+ }
265
+ if (this.reasoningBuffer.trim()) {
266
+ result.reasoning = this.reasoningBuffer.trim()
267
+ }
268
+ // Check if the agent called ask_user — extract the question from the tool args.
269
+ for (const act of result.activity) {
270
+ if (act.toolName === "ask_user" && act.status === "ok") {
271
+ const args = act.args as Record<string, unknown> | undefined
272
+ const q = typeof args?.question === "string" ? args.question : undefined
273
+ if (q) {
274
+ result.question = q
275
+ break
276
+ }
277
+ }
278
+ }
279
+ return result
280
+ } finally {
281
+ this.setStatus("idle")
282
+ }
283
+ }
284
+
285
+ /** Resolve workspace-relative image paths to ImageContent objects for the LLM. */
286
+ private async resolveImages(paths?: string[]): Promise<
287
+ Array<{ type: "image"; data: string; mimeType: string }>
288
+ > {
289
+ if (!paths) return []
290
+ const images: Array<{ type: "image"; data: string; mimeType: string }> = []
291
+ for (const relPath of paths) {
292
+ try {
293
+ const fullPath = join(this.workspaceDir, relPath)
294
+ const buf = readFileSync(fullPath)
295
+ const ext = relPath.split(".").pop()?.toLowerCase()
296
+ const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/jpeg"
297
+ images.push({ type: "image", data: buf.toString("base64"), mimeType })
298
+ } catch (err) {
299
+ // Skip images that fail to read.
300
+ console.warn(`[participant] image read failed: ${relPath} — ${err instanceof Error ? err.message : String(err)}`)
301
+ }
302
+ }
303
+ return images
304
+ }
305
+
306
+ async abort(): Promise<void> {
307
+ await this.session.abort()
308
+ }
309
+
310
+ /** Compact the agent's session context (summarize old turns to free tokens). */
311
+ async compact(): Promise<{ summary: string; tokensBefore: number }> {
312
+ const result = await this.session.compact(this.persona.compactionInstructions)
313
+ return { summary: result.summary, tokensBefore: result.tokensBefore }
314
+ }
315
+
316
+ /** Whether the agent is currently compacting. */
317
+ get isCompacting(): boolean {
318
+ return this.session.isCompacting
319
+ }
320
+
321
+ /** Get the agent's current context usage. Undefined if the session has no usage info yet. */
322
+ getContextUsage(): ReturnType<AgentSession["getContextUsage"]> {
323
+ return this.session.getContextUsage()
324
+ }
325
+
326
+ /** Set thinking level in-place — no session recreation needed. */
327
+ async setThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): Promise<void> {
328
+ await this.session.setThinkingLevel(level)
329
+ this.persona.thinkingLevel = level
330
+ }
331
+
332
+ /** Get the thinking levels supported by the current model. */
333
+ getAvailableThinkingLevels(): string[] {
334
+ return this.session.getAvailableThinkingLevels() ?? []
335
+ }
336
+
337
+ /** Get session stats — token counts, cache split, turn counts. */
338
+ getSessionStats(): ReturnType<AgentSession["getSessionStats"]> | undefined {
339
+ return this.session.getSessionStats()
340
+ }
341
+
342
+ /** Get the last assistant response text (convenience method). */
343
+ getLastAssistantText(): string | undefined {
344
+ return this.session.getLastAssistantText()
345
+ }
346
+
347
+ /** Export the agent's session to a self-contained HTML file. Returns the file path. */
348
+ async exportToHtml(): Promise<string> {
349
+ return await this.session.exportToHtml()
350
+ }
351
+
352
+ /** Export the agent's session as JSONL (one JSON object per line). Returns the file path. */
353
+ exportToJsonl(): string {
354
+ return this.session.exportToJsonl()
355
+ }
356
+
357
+ /** Queue a steering message while the agent is running.
358
+ * Throws if the session is not currently streaming — can't steer an idle agent. */
359
+ async steer(text: string): Promise<void> {
360
+ if (!this.session.isStreaming) {
361
+ throw new Error(`participant "${this.persona.id}" is not running — cannot steer`)
362
+ }
363
+ await this.session.steer(text)
364
+ }
365
+
366
+ /** Queue a follow-up message — guaranteed to be the next thing the agent
367
+ * processes. Used for self-chaining (e.g., delivering an ask_user answer
368
+ * directly to the agent that asked it). */
369
+ async followUp(text: string, imagePaths?: string[]): Promise<TurnResult> {
370
+ this.buffer = ""
371
+ this.reasoningBuffer = ""
372
+ this.activity.clear()
373
+ this.setStatus("active")
374
+ try {
375
+ const images = await this.resolveImages(imagePaths)
376
+ // session.followUp() only delivers when the agent is currently streaming.
377
+ // After ask_user (terminate=true) the session is idle — the followUp message
378
+ // would be queued but never processed. Use prompt() for idle sessions instead.
379
+ if (!this.session.isStreaming) {
380
+ await this.session.prompt(text, images.length > 0 ? { images } : undefined)
381
+ } else {
382
+ await this.session.followUp(text, images.length > 0 ? images : undefined)
383
+ }
384
+ const result: TurnResult = {
385
+ text: this.buffer.trim(),
386
+ activity: [...this.activity.values()],
387
+ }
388
+ if (this.reasoningBuffer.trim()) {
389
+ result.reasoning = this.reasoningBuffer.trim()
390
+ }
391
+ // Check for ask_user in the follow-up result too.
392
+ for (const act of result.activity) {
393
+ if (act.toolName === "ask_user" && act.status === "ok") {
394
+ const args = act.args as Record<string, unknown> | undefined
395
+ const q = typeof args?.question === "string" ? args.question : undefined
396
+ if (q) {
397
+ result.question = q
398
+ break
399
+ }
400
+ }
401
+ }
402
+ return result
403
+ } finally {
404
+ this.setStatus("idle")
405
+ }
406
+ }
407
+
408
+ /** Inject a custom message into the agent's context (invisible in the transcript).
409
+ * Used for structured signals like work receipts between agents. */
410
+ async sendCustomMessage(message: {
411
+ customType: string
412
+ content: string
413
+ display: boolean
414
+ }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void> {
415
+ await this.session.sendCustomMessage(message, options)
416
+ }
417
+
418
+ dispose(): void {
419
+ this.unsubscribe?.()
420
+ this.unsubscribe = null
421
+ this.session.dispose()
422
+ }
423
+ }
@@ -0,0 +1,13 @@
1
+ // Shared path confinement guard used by both ConversationStore and sandbox-tools.
2
+ // Prevents path traversal by verifying that the resolved target path lives
3
+ // inside the allowed root directory.
4
+
5
+ import { isAbsolute, relative, resolve } from "node:path"
6
+
7
+ /** Throw if `target` resolves outside `root`. */
8
+ export function assertInside(root: string, target: string): void {
9
+ const rel = relative(root, resolve(target))
10
+ if (rel !== "" && (rel === ".." || rel.startsWith(`..${"/"}`) || rel.startsWith("..\\") || isAbsolute(rel))) {
11
+ throw new Error(`Permission denied: "${target}" is outside the allowed directory.`)
12
+ }
13
+ }