headlesscode 1.2.0 → 1.2.2
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 +245 -459
- package/package.json +2 -1
- package/shared/openshell/headlesscode-openrouter.yaml +21 -0
- package/shared/openshell/headlesscode-policy.yaml +21 -0
- package/src/cli.ts +30 -0
- package/src/cloud/openshell-preflight.ts +16 -0
- package/src/cloud/openshell-provider.ts +582 -0
- package/src/cloud/openshell-session.ts +119 -0
- package/src/cloud/openshell-subsession.ts +63 -0
- package/src/cloud/openshell-worker.ts +124 -0
- package/src/engine/events.ts +3 -0
- package/src/engine/loop.ts +154 -4
- package/src/engine/types.ts +42 -0
- package/src/monitoring/controller.ts +73 -0
- package/src/monitoring/features.ts +103 -0
- package/src/monitoring/index.ts +4 -0
- package/src/monitoring/predictor.ts +246 -0
- package/src/monitoring/types.ts +192 -0
- package/src/orchestrator/cli.ts +24 -0
- package/src/orchestrator/reviewer.ts +11 -0
- package/src/project-store.ts +11 -0
- package/src/qa/qa.ts +12 -0
- package/src/watcher/cli.ts +18 -0
- package/src/watcher/watch.ts +3 -1
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenShellSessionProvider — experimental CloudProvider backed by NVIDIA
|
|
3
|
+
* OpenShell. Each session uses a disposable, independent Git clone mounted at
|
|
4
|
+
* /workspace. The host worktree and its process-control files are never mounted.
|
|
5
|
+
* After sandbox deletion, a verified Git bundle imports changes to the assigned
|
|
6
|
+
* host branch.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync, spawnSync } from "node:child_process"
|
|
9
|
+
import { createHash } from "node:crypto"
|
|
10
|
+
import * as fs from "node:fs"
|
|
11
|
+
import * as os from "node:os"
|
|
12
|
+
import * as path from "node:path"
|
|
13
|
+
import { parse, stringify } from "yaml"
|
|
14
|
+
|
|
15
|
+
import { writeTaskFiles } from "../orchestrator/cli.js"
|
|
16
|
+
import { projectStoreRoot, resolveProjectDataDir } from "../project-store.js"
|
|
17
|
+
import type { CloudProvider, CloudSessionRequest, CommandResult, SessionHandle } from "./provider.js"
|
|
18
|
+
|
|
19
|
+
/** The harness is baked into the sandbox image; never mount the host checkout. */
|
|
20
|
+
export const OPENSHELL_HARNESS_ROOT = "/opt/headlesscode"
|
|
21
|
+
|
|
22
|
+
export const OPENSHELL_WORKSPACE_TARGET = "/workspace"
|
|
23
|
+
|
|
24
|
+
interface OpenShellWorkspaceState {
|
|
25
|
+
hostWorkspace: string
|
|
26
|
+
sandboxWorkspace: string
|
|
27
|
+
exportDir: string
|
|
28
|
+
tempRoot: string
|
|
29
|
+
lockPath: string
|
|
30
|
+
memorySnapshot?: string
|
|
31
|
+
memoryHost?: string
|
|
32
|
+
branchRef: string
|
|
33
|
+
baseCommit: string
|
|
34
|
+
importResults: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeOpenShellSandboxMarker(workspace: string, name: string): string {
|
|
38
|
+
const marker = path.join(workspace, ".harness.openshell-sandbox")
|
|
39
|
+
try {
|
|
40
|
+
const stat = fs.lstatSync(marker)
|
|
41
|
+
if (!stat.isFile() || stat.isSymbolicLink() || fs.readFileSync(marker, "utf8").trim() !== name) {
|
|
42
|
+
throw new Error(`refusing to overwrite unexpected sandbox marker at ${marker}`)
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
|
|
46
|
+
}
|
|
47
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK
|
|
48
|
+
const fd = fs.openSync(marker, flags, 0o600)
|
|
49
|
+
try {
|
|
50
|
+
fs.fchmodSync(fd, 0o600)
|
|
51
|
+
fs.writeFileSync(fd, `${name}\n`)
|
|
52
|
+
} finally {
|
|
53
|
+
fs.closeSync(fd)
|
|
54
|
+
}
|
|
55
|
+
return marker
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface OpenShellSessionProviderOptions {
|
|
59
|
+
image?: string
|
|
60
|
+
policy?: string
|
|
61
|
+
providers?: string[]
|
|
62
|
+
cpu?: string
|
|
63
|
+
memory?: string
|
|
64
|
+
namePrefix?: string
|
|
65
|
+
readOnlyMounts?: Array<{ source: string; target: string }>
|
|
66
|
+
importResults?: boolean
|
|
67
|
+
scratchRoot?: string
|
|
68
|
+
run?: (args: string[], timeoutMs?: number) => CommandResult
|
|
69
|
+
listSandboxes?: () => string[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class OpenShellSessionProvider implements CloudProvider {
|
|
73
|
+
readonly name = "openshell"
|
|
74
|
+
private readonly image: string
|
|
75
|
+
private readonly policy?: string
|
|
76
|
+
private readonly providers: string[]
|
|
77
|
+
private readonly cpu: string
|
|
78
|
+
private readonly memory: string
|
|
79
|
+
private readonly namePrefix: string
|
|
80
|
+
private readonly readOnlyMounts: Array<{ source: string; target: string }>
|
|
81
|
+
private readonly run: (args: string[], timeoutMs?: number) => CommandResult
|
|
82
|
+
private readonly listSandboxes: () => string[]
|
|
83
|
+
private readonly importResults: boolean
|
|
84
|
+
private readonly scratchRoot: string
|
|
85
|
+
private readonly workspaces = new Map<string, OpenShellWorkspaceState>()
|
|
86
|
+
|
|
87
|
+
constructor(options: OpenShellSessionProviderOptions = {}) {
|
|
88
|
+
this.image = options.image ?? process.env.HEADLESSCODE_OPENSHELL_IMAGE ?? "headlesscode-openshell:local"
|
|
89
|
+
this.policy = options.policy ?? process.env.HEADLESSCODE_OPENSHELL_POLICY
|
|
90
|
+
this.providers = options.providers ?? (process.env.HEADLESSCODE_OPENSHELL_PROVIDERS ?? "headlesscode-openrouter").split(",").map((name) => name.trim()).filter(Boolean)
|
|
91
|
+
this.cpu = options.cpu ?? process.env.HEADLESSCODE_OPENSHELL_CPU ?? "2"
|
|
92
|
+
this.memory = options.memory ?? process.env.HEADLESSCODE_OPENSHELL_MEMORY ?? "4Gi"
|
|
93
|
+
this.namePrefix = options.namePrefix ?? process.env.HEADLESSCODE_OPENSHELL_NAME_PREFIX ?? "hcls"
|
|
94
|
+
this.readOnlyMounts = options.readOnlyMounts ?? []
|
|
95
|
+
this.importResults = options.importResults ?? true
|
|
96
|
+
this.scratchRoot = path.resolve(options.scratchRoot ?? process.env.HEADLESSCODE_OPENSHELL_SCRATCH_ROOT ?? path.join(os.homedir(), ".local", "share", "headlesscode", "openshell-sessions"))
|
|
97
|
+
this.run = options.run ?? ((args, timeoutMs) => {
|
|
98
|
+
const result = spawnSync("openshell", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs })
|
|
99
|
+
return { exitCode: result.status ?? 1, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }
|
|
100
|
+
})
|
|
101
|
+
this.listSandboxes = options.listSandboxes ?? (() => {
|
|
102
|
+
const result = this.run(["sandbox", "list", "--output", "json"])
|
|
103
|
+
if (result.exitCode !== 0) throw new Error(`OpenShell sandbox list failed: ${result.output}`)
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(result.output) as unknown
|
|
106
|
+
const value = Array.isArray(parsed)
|
|
107
|
+
? parsed
|
|
108
|
+
: typeof parsed === "object" && parsed !== null && "sandboxes" in parsed && Array.isArray((parsed as { sandboxes: unknown }).sandboxes)
|
|
109
|
+
? (parsed as { sandboxes: unknown[] }).sandboxes
|
|
110
|
+
: undefined
|
|
111
|
+
if (!value || value.some((item) => typeof item !== "object" || item === null || !("name" in item) || typeof (item as { name: unknown }).name !== "string")) {
|
|
112
|
+
throw new Error("expected a sandbox array with string names")
|
|
113
|
+
}
|
|
114
|
+
return value.map((item) => (item as { name: string }).name)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw new Error(`OpenShell sandbox list returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private sandboxName(handle: SessionHandle): string { return handle.containerId ?? this.sandboxNameForWorktree(handle.id) }
|
|
122
|
+
|
|
123
|
+
/** Stable OpenShell name shared by worker launch and stop/recovery paths. */
|
|
124
|
+
sandboxNameForWorktree(worktreeName: string, repoPath = ""): string {
|
|
125
|
+
const prefix = this.namePrefix.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "") || "hcls"
|
|
126
|
+
const prefixPart = prefix.slice(0, 3).replace(/-+$/g, "") || "h"
|
|
127
|
+
const identity = `${path.resolve(repoPath || process.cwd())}\0${worktreeName}`
|
|
128
|
+
const hash = createHash("sha256").update(identity).digest("hex").slice(0, 19 - prefixPart.length - 1)
|
|
129
|
+
const name = `${prefixPart}-${hash}`
|
|
130
|
+
if (name.length > 19 || !/^[a-z0-9](?:[a-z0-9-]{0,17}[a-z0-9])?$/.test(name)) {
|
|
131
|
+
throw new Error(`OpenShell sandbox name is not a DNS label: ${name}`)
|
|
132
|
+
}
|
|
133
|
+
return name
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async spawnWorktreeSession(request: CloudSessionRequest): Promise<SessionHandle> {
|
|
137
|
+
const repo = path.resolve(request.repo)
|
|
138
|
+
if (!/^[A-Za-z0-9._-]+$/.test(request.worktreeSpec.name)) {
|
|
139
|
+
throw new Error(`Invalid OpenShell worktree name: ${request.worktreeSpec.name}`)
|
|
140
|
+
}
|
|
141
|
+
const workspace = path.join(repo, ".worktrees", request.worktreeSpec.name)
|
|
142
|
+
const name = this.sandboxNameForWorktree(request.worktreeSpec.name, repo)
|
|
143
|
+
try {
|
|
144
|
+
if (!fs.statSync(path.join(repo, ".git")).isDirectory()) throw new Error("not a repository root")
|
|
145
|
+
} catch {
|
|
146
|
+
throw new Error(`OpenShell provider requires a Git repository root with a .git directory: ${repo}`)
|
|
147
|
+
}
|
|
148
|
+
writeTaskFiles(repo, [request.worktreeSpec], [request.issue])
|
|
149
|
+
const branch = `headlesscode/${request.worktreeSpec.name}-${Date.now()}`
|
|
150
|
+
const wt = spawnSync("git", ["worktree", "add", "-b", branch, workspace, "HEAD"], { cwd: repo, encoding: "utf8" })
|
|
151
|
+
if (wt.status !== 0) throw new Error(`OpenShell worktree creation failed: ${wt.stderr || wt.stdout}`)
|
|
152
|
+
const taskFile = path.join(repo, "plans", "parallel-tasks", request.worktreeSpec.taskFile)
|
|
153
|
+
try {
|
|
154
|
+
fs.copyFileSync(taskFile, path.join(workspace, "ORCHESTRATOR_TASK.md"))
|
|
155
|
+
} catch (error) {
|
|
156
|
+
spawnSync("git", ["worktree", "remove", "--force", workspace], { cwd: repo, encoding: "utf8" })
|
|
157
|
+
spawnSync("git", ["branch", "-D", branch], { cwd: repo, encoding: "utf8" })
|
|
158
|
+
throw error
|
|
159
|
+
}
|
|
160
|
+
return this.spawnExistingWorktreeSession(request, workspace)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Start a sandbox around a worktree already created by the normal spawner. */
|
|
164
|
+
async spawnExistingWorktreeSession(request: CloudSessionRequest, worktreePath: string): Promise<SessionHandle> {
|
|
165
|
+
const repo = path.resolve(request.repo)
|
|
166
|
+
const workspace = path.resolve(worktreePath)
|
|
167
|
+
const expected = path.join(repo, ".worktrees", request.worktreeSpec.name)
|
|
168
|
+
if (workspace !== expected) throw new Error(`OpenShell worker path must be ${expected}`)
|
|
169
|
+
if (!fs.statSync(workspace).isDirectory()) throw new Error(`OpenShell worker worktree does not exist: ${workspace}`)
|
|
170
|
+
const name = this.sandboxNameForWorktree(request.worktreeSpec.name, repo)
|
|
171
|
+
const isolated = this.prepareIsolatedWorkspace(workspace, name)
|
|
172
|
+
try {
|
|
173
|
+
return await this.createSandbox(request, repo, workspace, isolated, name)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (!fs.existsSync(path.join(workspace, ".harness.openshell-sandbox"))) {
|
|
176
|
+
this.workspaces.delete(name)
|
|
177
|
+
fs.rmSync(isolated.tempRoot, { recursive: true, force: true })
|
|
178
|
+
fs.rmSync(isolated.lockPath, { recursive: true, force: true })
|
|
179
|
+
}
|
|
180
|
+
throw error
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private prepareIsolatedWorkspace(hostWorkspace: string, sandboxName: string): OpenShellWorkspaceState {
|
|
185
|
+
const tempRoot = path.join(this.scratchRoot, `headlesscode-openshell-session-${sandboxName}`)
|
|
186
|
+
const lockPath = `${tempRoot}.lock`
|
|
187
|
+
const sandboxWorkspace = path.join(tempRoot, "workspace")
|
|
188
|
+
const exportDir = path.join(tempRoot, "result")
|
|
189
|
+
let ownsLock = false
|
|
190
|
+
let ownsTempRoot = false
|
|
191
|
+
try {
|
|
192
|
+
fs.mkdirSync(this.scratchRoot, { recursive: true, mode: 0o700 })
|
|
193
|
+
const rootStat = fs.lstatSync(this.scratchRoot)
|
|
194
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error(`refusing unexpected OpenShell scratch root ${this.scratchRoot}`)
|
|
195
|
+
fs.mkdirSync(lockPath, { mode: 0o700 })
|
|
196
|
+
ownsLock = true
|
|
197
|
+
if (this.listSandboxes().includes(sandboxName)) throw new Error(`OpenShell sandbox ${sandboxName} is already active`)
|
|
198
|
+
try {
|
|
199
|
+
const existing = fs.lstatSync(tempRoot)
|
|
200
|
+
if (!existing.isDirectory() || existing.isSymbolicLink()) throw new Error(`refusing unexpected OpenShell scratch path ${tempRoot}`)
|
|
201
|
+
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
|
|
204
|
+
}
|
|
205
|
+
fs.mkdirSync(tempRoot, { mode: 0o700 })
|
|
206
|
+
ownsTempRoot = true
|
|
207
|
+
fs.mkdirSync(exportDir, { mode: 0o700 })
|
|
208
|
+
const branchRef = execFileSync("git", ["symbolic-ref", "HEAD"], { cwd: hostWorkspace, encoding: "utf8" }).trim()
|
|
209
|
+
if (!branchRef.startsWith("refs/heads/")) throw new Error(`OpenShell requires a local worktree branch, got ${branchRef}`)
|
|
210
|
+
const branchName = branchRef.slice("refs/heads/".length)
|
|
211
|
+
const baseCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: hostWorkspace, encoding: "utf8" }).trim()
|
|
212
|
+
const cloned = spawnSync("git", ["clone", "--no-local", "--single-branch", "--branch", branchName, hostWorkspace, sandboxWorkspace], { encoding: "utf8", timeout: 120_000 })
|
|
213
|
+
if (cloned.status !== 0) throw new Error(`OpenShell isolated Git clone failed: ${cloned.stderr || cloned.stdout}`)
|
|
214
|
+
const ident = execFileSync("git", ["var", "GIT_AUTHOR_IDENT"], { cwd: hostWorkspace, encoding: "utf8" }).trim()
|
|
215
|
+
const match = /^(.*) <([^<>]+)> \d+ [+-]\d{4}$/.exec(ident)
|
|
216
|
+
if (match) {
|
|
217
|
+
execFileSync("git", ["-C", sandboxWorkspace, "config", "user.name", match[1]])
|
|
218
|
+
execFileSync("git", ["-C", sandboxWorkspace, "config", "user.email", match[2]])
|
|
219
|
+
}
|
|
220
|
+
fs.appendFileSync(path.join(sandboxWorkspace, ".git", "info", "exclude"), "\n/ORCHESTRATOR_TASK.md\n/ORCHESTRATOR_PLAN.md\n/.qa-task.md\n/.env\n/.env.*\n/.headlesscode/\n/.headlesscode-session/\n/.harness.*\n/harness.log\n/qa.log\n/review.log\n")
|
|
221
|
+
// Stage only known orchestration inputs that wrappers can reference by
|
|
222
|
+
// path. The disposable clone does not include arbitrary host files.
|
|
223
|
+
for (const inputName of ["ORCHESTRATOR_TASK.md", "ORCHESTRATOR_PLAN.md", ".qa-task.md"]) {
|
|
224
|
+
const inputPath = path.join(hostWorkspace, inputName)
|
|
225
|
+
try {
|
|
226
|
+
const inputStat = fs.lstatSync(inputPath)
|
|
227
|
+
if (!inputStat.isFile() || inputStat.isSymbolicLink()) throw new Error(`OpenShell task input is not a regular file: ${inputPath}`)
|
|
228
|
+
fs.copyFileSync(inputPath, path.join(sandboxWorkspace, inputName))
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// The orchestrator creates this file from port overrides only for
|
|
234
|
+
// OpenShell worktrees. Copy just those known-safe settings.
|
|
235
|
+
const envSource = path.join(hostWorkspace, ".env")
|
|
236
|
+
try {
|
|
237
|
+
if (fs.lstatSync(envSource).isFile()) {
|
|
238
|
+
const safeEnv = fs.readFileSync(envSource, "utf8").split(/\r?\n/).filter((line) => /^(COMPOSE_PROJECT_NAME|POSTGRES_PORT|AIRUNNER_HTTP_PORT|VITE_PORT)=/.test(line))
|
|
239
|
+
fs.writeFileSync(path.join(sandboxWorkspace, ".env"), `${safeEnv.join("\n")}\n`, { mode: 0o600 })
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
|
|
243
|
+
}
|
|
244
|
+
return { hostWorkspace, sandboxWorkspace, exportDir, tempRoot, lockPath, branchRef, baseCommit, importResults: this.importResults }
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (ownsTempRoot) fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
247
|
+
if (ownsLock) fs.rmSync(lockPath, { recursive: true, force: true })
|
|
248
|
+
throw error
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async createSandbox(
|
|
253
|
+
request: CloudSessionRequest,
|
|
254
|
+
repo: string,
|
|
255
|
+
hostWorkspace: string,
|
|
256
|
+
isolated: OpenShellWorkspaceState,
|
|
257
|
+
name: string,
|
|
258
|
+
): Promise<SessionHandle> {
|
|
259
|
+
const { sandboxWorkspace } = isolated
|
|
260
|
+
if (!this.policy || !fs.existsSync(this.policy)) throw new Error("OpenShell requires a base policy file")
|
|
261
|
+
const args = ["sandbox", "create", "--detach", "--auto-providers", "--cpu", this.cpu, "--memory", this.memory, "--name", name, "--from", this.image]
|
|
262
|
+
if (this.policy) args.push("--policy", path.resolve(this.policy))
|
|
263
|
+
for (const provider of this.providers) args.push("--provider", provider)
|
|
264
|
+
// Only the disposable clone is writable inside the sandbox. Host Git
|
|
265
|
+
// metadata and process-control files live in the separate orchestration
|
|
266
|
+
// worktree, which is never mounted into the container.
|
|
267
|
+
const mounts: Array<{ type: string; source: string; target: string; read_only: boolean }> = [
|
|
268
|
+
{ type: "bind", source: sandboxWorkspace, target: OPENSHELL_WORKSPACE_TARGET, read_only: false },
|
|
269
|
+
{ type: "bind", source: isolated.exportDir, target: "/result", read_only: false },
|
|
270
|
+
]
|
|
271
|
+
const projectData = resolveProjectDataDir(repo)
|
|
272
|
+
const projectDataTarget = "/opt/headlesscode-data/projects/" + path.basename(projectData)
|
|
273
|
+
const dataSnapshotRoot = path.join(isolated.tempRoot, "data")
|
|
274
|
+
const projectDataSnapshot = path.join(dataSnapshotRoot, "projects", path.basename(projectData))
|
|
275
|
+
fs.mkdirSync(path.dirname(projectDataSnapshot), { recursive: true })
|
|
276
|
+
fs.cpSync(projectData, projectDataSnapshot, { recursive: true, dereference: true })
|
|
277
|
+
mounts.push({ type: "bind", source: projectDataSnapshot, target: projectDataTarget, read_only: true })
|
|
278
|
+
const policy = this.policy ? parse(fs.readFileSync(this.policy, "utf8")) as Record<string, any> : undefined
|
|
279
|
+
const filesystemPolicy = policy?.filesystem_policy
|
|
280
|
+
if (!filesystemPolicy || !Array.isArray(filesystemPolicy.read_only) || !Array.isArray(filesystemPolicy.read_write)) {
|
|
281
|
+
throw new Error("policy must define filesystem_policy.read_only and read_write arrays")
|
|
282
|
+
}
|
|
283
|
+
filesystemPolicy.read_write.push(OPENSHELL_WORKSPACE_TARGET)
|
|
284
|
+
filesystemPolicy.read_write.push("/result")
|
|
285
|
+
filesystemPolicy.read_only.push(projectDataTarget, OPENSHELL_HARNESS_ROOT)
|
|
286
|
+
const sharedData = path.join(projectStoreRoot(), "shared")
|
|
287
|
+
if (fs.existsSync(sharedData)) {
|
|
288
|
+
const sharedDataSnapshot = path.join(dataSnapshotRoot, "shared")
|
|
289
|
+
fs.cpSync(sharedData, sharedDataSnapshot, { recursive: true, dereference: true })
|
|
290
|
+
mounts.push({ type: "bind", source: sharedDataSnapshot, target: "/opt/headlesscode-data/shared", read_only: true })
|
|
291
|
+
filesystemPolicy.read_only.push("/opt/headlesscode-data/shared")
|
|
292
|
+
}
|
|
293
|
+
const checkpointsSnapshot = path.join(dataSnapshotRoot, "checkpoints")
|
|
294
|
+
fs.mkdirSync(checkpointsSnapshot, { recursive: true })
|
|
295
|
+
mounts.push({ type: "bind", source: checkpointsSnapshot, target: "/opt/headlesscode-data/checkpoints", read_only: false })
|
|
296
|
+
filesystemPolicy.read_only.push("/opt/headlesscode-data")
|
|
297
|
+
filesystemPolicy.read_write.push("/opt/headlesscode-data/checkpoints")
|
|
298
|
+
const memoryDir = request.env?.HEADLESSCODE_MEMORY_DIR
|
|
299
|
+
if (memoryDir) {
|
|
300
|
+
const requestedMemoryPath = path.resolve(memoryDir)
|
|
301
|
+
assertNoSymlinkAncestors(requestedMemoryPath)
|
|
302
|
+
const memoryHost = fs.realpathSync(requestedMemoryPath)
|
|
303
|
+
const commonGitDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: repo, encoding: "utf8" }).trim()
|
|
304
|
+
for (const protectedPath of [hostWorkspace, commonGitDir, isolated.tempRoot, this.scratchRoot]) {
|
|
305
|
+
if (pathsOverlap(memoryHost, path.resolve(protectedPath))) throw new Error(`OpenShell memory path overlaps protected session data: ${memoryHost}`)
|
|
306
|
+
}
|
|
307
|
+
const memorySnapshot = path.join(isolated.tempRoot, "memory")
|
|
308
|
+
fs.mkdirSync(memorySnapshot, { recursive: true })
|
|
309
|
+
copyMemorySnapshot(memoryHost, memorySnapshot)
|
|
310
|
+
isolated.memoryHost = memoryHost
|
|
311
|
+
isolated.memorySnapshot = memorySnapshot
|
|
312
|
+
mounts.push({ type: "bind", source: memorySnapshot, target: "/opt/headlesscode-memory", read_only: false })
|
|
313
|
+
filesystemPolicy.read_write.push("/opt/headlesscode-memory")
|
|
314
|
+
}
|
|
315
|
+
for (const mount of this.readOnlyMounts) {
|
|
316
|
+
const relativeTarget = path.relative(OPENSHELL_WORKSPACE_TARGET, path.resolve(mount.target))
|
|
317
|
+
if (relativeTarget.startsWith("..") || path.isAbsolute(relativeTarget)) throw new Error(`OpenShell read-only mount target must be under ${OPENSHELL_WORKSPACE_TARGET}: ${mount.target}`)
|
|
318
|
+
const target = path.join(sandboxWorkspace, relativeTarget)
|
|
319
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
320
|
+
fs.cpSync(path.resolve(mount.source), target, { recursive: true, dereference: true })
|
|
321
|
+
}
|
|
322
|
+
const generatedPolicy = path.join(os.tmpdir(), `headlesscode-openshell-policy-${process.pid}-${Date.now()}.yaml`)
|
|
323
|
+
fs.writeFileSync(generatedPolicy, stringify(policy), { mode: 0o600 })
|
|
324
|
+
args[args.indexOf("--policy") + 1] = generatedPolicy
|
|
325
|
+
args.push("--driver-config-json", JSON.stringify({ docker: { mounts } }))
|
|
326
|
+
for (const [key, value] of Object.entries(request.env ?? {})) {
|
|
327
|
+
if (key === "HEADLESSCODE_MEMORY_DIR" && isolated.memorySnapshot) args.push("--env", "HEADLESSCODE_MEMORY_DIR=/opt/headlesscode-memory")
|
|
328
|
+
else if (key !== "TARGET_REPO") args.push("--env", `${key}=${value}`)
|
|
329
|
+
}
|
|
330
|
+
args.push("--env", `TARGET_REPO=${OPENSHELL_WORKSPACE_TARGET}`, "--env", `HEADLESSCODE_ROOT=${OPENSHELL_HARNESS_ROOT}`, "--env", "HEADLESSCODE_DATA_DIR=/opt/headlesscode-data", "--env", `HEADLESSCODE_PROJECT_IDENTITY_ROOT=${repo}`, "--env", "GIT_CONFIG_NOSYSTEM=1", "--env", "GIT_CONFIG_GLOBAL=/dev/null")
|
|
331
|
+
args.push("--", "sleep", "infinity")
|
|
332
|
+
this.workspaces.set(name, isolated)
|
|
333
|
+
writeOpenShellSandboxMarker(hostWorkspace, name)
|
|
334
|
+
let result: CommandResult
|
|
335
|
+
try {
|
|
336
|
+
result = this.run(args)
|
|
337
|
+
} finally {
|
|
338
|
+
fs.rmSync(generatedPolicy, { force: true })
|
|
339
|
+
}
|
|
340
|
+
if (result.exitCode !== 0) {
|
|
341
|
+
// A non-zero create often means the sandbox was accepted but failed
|
|
342
|
+
// during provisioning. Clean up this request's deterministic name; if
|
|
343
|
+
// deletion is not confirmed, keep the host marker and scratch tree for
|
|
344
|
+
// the stop-worker recovery path.
|
|
345
|
+
try {
|
|
346
|
+
this.run(["sandbox", "delete", name])
|
|
347
|
+
const deadline = Date.now() + 30_000
|
|
348
|
+
while (this.listSandboxes().includes(name)) {
|
|
349
|
+
if (Date.now() >= deadline) break
|
|
350
|
+
await sleep(250)
|
|
351
|
+
}
|
|
352
|
+
if (!this.listSandboxes().includes(name)) {
|
|
353
|
+
this.workspaces.delete(name)
|
|
354
|
+
fs.rmSync(isolated.tempRoot, { recursive: true, force: true })
|
|
355
|
+
fs.rmSync(isolated.lockPath, { recursive: true, force: true })
|
|
356
|
+
fs.rmSync(path.join(hostWorkspace, ".harness.openshell-sandbox"), { force: true })
|
|
357
|
+
}
|
|
358
|
+
} catch {
|
|
359
|
+
// Preserve the marker and scratch data so operators can recover it.
|
|
360
|
+
}
|
|
361
|
+
throw new Error(`OpenShell sandbox create failed (exit ${result.exitCode}): ${result.output}`)
|
|
362
|
+
}
|
|
363
|
+
return { id: request.worktreeSpec.name, provider: this.name, address: hostWorkspace, containerId: name }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async waitReady(handle: SessionHandle): Promise<void> {
|
|
367
|
+
const result = this.run(["sandbox", "exec", "--name", this.sandboxName(handle), "--no-login-shell", "--", "true"])
|
|
368
|
+
if (result.exitCode !== 0) throw new Error(`OpenShell sandbox not ready: ${result.output}`)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async runHarness(handle: SessionHandle, cmd: string): Promise<CommandResult> {
|
|
372
|
+
const name = this.sandboxName(handle)
|
|
373
|
+
const state = this.workspaces.get(name)
|
|
374
|
+
if (!state) throw new Error(`OpenShell isolated workspace state is unavailable for ${name}`)
|
|
375
|
+
let mappedCommand = cmd.split(state.hostWorkspace).join(OPENSHELL_WORKSPACE_TARGET)
|
|
376
|
+
if (state.memoryHost) mappedCommand = mappedCommand.split(state.memoryHost).join("/opt/headlesscode-memory")
|
|
377
|
+
return this.run(["sandbox", "exec", "--name", name, "--workdir", OPENSHELL_WORKSPACE_TARGET, "--no-login-shell", "--", "/bin/bash", "-c", mappedCommand])
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async collectResults(handle: SessionHandle): Promise<Record<string, unknown>> {
|
|
381
|
+
const root = handle.address
|
|
382
|
+
if (!root) throw new Error(`OpenShellSessionProvider: no workspace path on ${handle.id}`)
|
|
383
|
+
return {
|
|
384
|
+
exitCode: readInt(path.join(root, ".harness.exit")),
|
|
385
|
+
done: fs.existsSync(path.join(root, ".harness.done")),
|
|
386
|
+
summary: tail(path.join(root, "harness.log"), 40),
|
|
387
|
+
worktree: root,
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async teardown(handle: SessionHandle): Promise<void> {
|
|
392
|
+
const name = this.sandboxName(handle)
|
|
393
|
+
if (!handle.address) throw new Error(`OpenShellSessionProvider: no host workspace path on ${handle.id}`)
|
|
394
|
+
const state = this.workspaces.get(name)
|
|
395
|
+
let exportResult: CommandResult | undefined
|
|
396
|
+
let exportError: Error | undefined
|
|
397
|
+
try {
|
|
398
|
+
if (state?.importResults) {
|
|
399
|
+
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
|
|
400
|
+
const branchRef = quote(state.branchRef)
|
|
401
|
+
const script = [
|
|
402
|
+
"for p in .headlesscode-session .headlesscode .harness.done ORCHESTRATOR_TASK.md ORCHESTRATOR_PLAN.md .qa-task.md harness.log qa.log review.log; do if [ -z \"$(git ls-files -- \"$p\" \"$p/**\")\" ]; then rm -rf -- \"/workspace/$p\"; fi; done",
|
|
403
|
+
"for f in /workspace/.harness.* /workspace/.qa.* /workspace/.review.*; do [ -e \"$f\" ] || continue; rel=\"${f#/workspace/}\"; [ -z \"$(git ls-files -- \"$rel\")\" ] && rm -f -- \"$f\"; done",
|
|
404
|
+
"git ls-files -z -- .env '.env.*' | while IFS= read -r -d '' rel; do git show \"HEAD:$rel\" > \"/workspace/$rel\"; done",
|
|
405
|
+
"for f in /workspace/.env /workspace/.env.*; do [ -e \"$f\" ] || continue; rel=\"${f#/workspace/}\"; git ls-files --error-unmatch -- \"$rel\" >/dev/null 2>&1 || rm -f -- \"$f\"; done",
|
|
406
|
+
"git add -A",
|
|
407
|
+
"if ! git diff --cached --quiet HEAD; then git -c core.hooksPath=/dev/null commit -m 'HeadlessCode sandbox result'; fi",
|
|
408
|
+
`git bundle create /result/result.bundle ${branchRef}`,
|
|
409
|
+
].join(" && ")
|
|
410
|
+
exportResult = this.run(["sandbox", "exec", "--name", name, "--workdir", OPENSHELL_WORKSPACE_TARGET, "--no-login-shell", "--", "/bin/bash", "-c", script], 120_000)
|
|
411
|
+
if (exportResult.exitCode !== 0) exportError = new Error(`OpenShell Git export failed for ${name}: ${exportResult.output}`)
|
|
412
|
+
}
|
|
413
|
+
} catch (error) { exportError = error instanceof Error ? error : new Error(String(error)) }
|
|
414
|
+
let deleteError: Error | undefined
|
|
415
|
+
try {
|
|
416
|
+
const result = this.run(["sandbox", "delete", name], 30_000)
|
|
417
|
+
if (result.exitCode !== 0 && !/not found|does not exist/i.test(result.output)) deleteError = new Error(`OpenShell sandbox delete failed for ${name}: ${result.output}`)
|
|
418
|
+
} catch (error) { deleteError = error instanceof Error ? error : new Error(String(error)) }
|
|
419
|
+
const deadline = Date.now() + 30_000
|
|
420
|
+
try {
|
|
421
|
+
while (this.listSandboxes().includes(name)) {
|
|
422
|
+
if (Date.now() >= deadline) throw deleteError ?? new Error(`OpenShell sandbox ${name} remains after delete`)
|
|
423
|
+
await sleep(250)
|
|
424
|
+
}
|
|
425
|
+
} catch (error) {
|
|
426
|
+
// Keep the scratch tree and lock if teardown cannot confirm that the
|
|
427
|
+
// gateway released its bind mounts. stop-worker can retry deletion.
|
|
428
|
+
throw error
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
if (deleteError) throw deleteError
|
|
432
|
+
if (state?.importResults) {
|
|
433
|
+
if (exportError) throw exportError
|
|
434
|
+
if (!exportResult || exportResult.exitCode !== 0) throw new Error(`OpenShell Git export failed for ${name}`)
|
|
435
|
+
this.importIsolatedGitResults(state)
|
|
436
|
+
}
|
|
437
|
+
if (state?.memorySnapshot && state.memoryHost) importMemorySnapshot(state.memorySnapshot, state.memoryHost)
|
|
438
|
+
} finally {
|
|
439
|
+
this.workspaces.delete(name)
|
|
440
|
+
if (state) fs.rmSync(state.tempRoot, { recursive: true, force: true })
|
|
441
|
+
if (state) fs.rmSync(state.lockPath, { recursive: true, force: true })
|
|
442
|
+
fs.rmSync(path.join(handle.address, ".harness.openshell-sandbox"), { force: true })
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private importIsolatedGitResults(state: OpenShellWorkspaceState): void {
|
|
447
|
+
const bundlePath = path.join(state.exportDir, "result.bundle")
|
|
448
|
+
const bundleStat = fs.lstatSync(bundlePath)
|
|
449
|
+
if (!bundleStat.isFile() || bundleStat.isSymbolicLink() || bundleStat.size > 2 * 1024 * 1024 * 1024) {
|
|
450
|
+
throw new Error(`OpenShell Git export is not a regular bundle within the 2 GiB limit: ${bundlePath}`)
|
|
451
|
+
}
|
|
452
|
+
const hostHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: state.hostWorkspace, encoding: "utf8" }).trim()
|
|
453
|
+
if (hostHead !== state.baseCommit) throw new Error("OpenShell result import refused because the host worktree moved during the session")
|
|
454
|
+
const hostBranch = execFileSync("git", ["symbolic-ref", "HEAD"], { cwd: state.hostWorkspace, encoding: "utf8" }).trim()
|
|
455
|
+
if (hostBranch !== state.branchRef) throw new Error("OpenShell result import refused because the host worktree switched branches during the session")
|
|
456
|
+
const status = execFileSync("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: state.hostWorkspace, encoding: "utf8" }).trim()
|
|
457
|
+
if (status) throw new Error("OpenShell result import refused because the host worktree has tracked changes")
|
|
458
|
+
const heads = execFileSync("git", ["bundle", "list-heads", bundlePath], { cwd: state.hostWorkspace, encoding: "utf8" }).trim().split(/\r?\n/).filter(Boolean)
|
|
459
|
+
if (heads.length !== 1 || !heads[0].endsWith(` ${state.branchRef}`)) throw new Error("OpenShell Git bundle did not contain exactly the expected branch")
|
|
460
|
+
execFileSync("git", ["bundle", "verify", bundlePath], { cwd: state.hostWorkspace, stdio: "pipe", timeout: 30_000 })
|
|
461
|
+
const importRef = `refs/headlesscode/openshell-import/${createHash("sha256").update(bundlePath).digest("hex").slice(0, 24)}`
|
|
462
|
+
const env = { ...process.env }
|
|
463
|
+
for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_INDEX_FILE"]) delete env[key]
|
|
464
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "fetch", "--no-write-fetch-head", "--force", bundlePath, `${state.branchRef}:${importRef}`], { cwd: state.hostWorkspace, env, stdio: "pipe", timeout: 120_000 })
|
|
465
|
+
try {
|
|
466
|
+
execFileSync("git", ["merge-base", "--is-ancestor", state.baseCommit, importRef], { cwd: state.hostWorkspace, env, stdio: "pipe" })
|
|
467
|
+
validateImportedTree(state.hostWorkspace, state.baseCommit, importRef, env)
|
|
468
|
+
execFileSync("git", ["update-ref", state.branchRef, importRef, state.baseCommit], { cwd: state.hostWorkspace, env, stdio: "pipe" })
|
|
469
|
+
const filterConfigResult = spawnSync("git", ["config", "--get-regexp", "^filter\\..*\\.(smudge|process|required)$"], { cwd: state.hostWorkspace, env, encoding: "utf8" })
|
|
470
|
+
if (filterConfigResult.status !== 0 && filterConfigResult.status !== 1) throw new Error(`Could not inspect host Git filters: ${filterConfigResult.stderr}`)
|
|
471
|
+
const filterNames = [...new Set((filterConfigResult.stdout ?? "").split(/\r?\n/).map((line) => /^filter\.(.+)\.(?:smudge|process|required)\s/.exec(line)?.[1]).filter((value): value is string => !!value))]
|
|
472
|
+
const safeCheckoutArgs = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ...filterNames.flatMap((name) => ["-c", `filter.${name}.smudge=`, "-c", `filter.${name}.process=`, "-c", `filter.${name}.required=false`]), "reset", "--hard", state.branchRef]
|
|
473
|
+
execFileSync("git", safeCheckoutArgs, { cwd: state.hostWorkspace, env: { ...env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, stdio: "pipe", timeout: 120_000 })
|
|
474
|
+
} finally {
|
|
475
|
+
execFileSync("git", ["update-ref", "-d", importRef], { cwd: state.hostWorkspace, env, stdio: "pipe" })
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function validateImportedTree(cwd: string, base: string, ref: string, env: NodeJS.ProcessEnv): void {
|
|
482
|
+
const paths = execFileSync("git", ["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", base, ref], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).split("\0").filter(Boolean)
|
|
483
|
+
const protectedPath = (entry: string): boolean => {
|
|
484
|
+
const root = entry.split("/", 1)[0]
|
|
485
|
+
return root === "ORCHESTRATOR_TASK.md"
|
|
486
|
+
|| root === "harness.log"
|
|
487
|
+
|| root === "qa.log"
|
|
488
|
+
|| root === "review.log"
|
|
489
|
+
|| root === ".headlesscode"
|
|
490
|
+
|| root === ".headlesscode-session"
|
|
491
|
+
|| root === ".env"
|
|
492
|
+
|| root.startsWith(".env.")
|
|
493
|
+
|| root.startsWith(".harness.")
|
|
494
|
+
|| root.startsWith(".qa.")
|
|
495
|
+
|| root.startsWith(".review.")
|
|
496
|
+
}
|
|
497
|
+
const violation = paths.find(protectedPath)
|
|
498
|
+
if (violation) throw new Error(`OpenShell result import refused protected orchestration or secret path: ${violation}`)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function readInt(file: string): number | undefined {
|
|
502
|
+
try { const value = Number(fs.readFileSync(file, "utf8").trim()); return Number.isFinite(value) ? value : undefined } catch { return undefined }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function tail(file: string, count: number): string {
|
|
506
|
+
try { return fs.readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean).slice(-count).join("\n") } catch { return "" }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function copyMemorySnapshot(source: string, target: string): void {
|
|
510
|
+
assertNoSymlinkAncestors(source)
|
|
511
|
+
for (const category of ["facts", "sessions"]) {
|
|
512
|
+
const sourceDir = path.join(source, category)
|
|
513
|
+
if (!fs.existsSync(sourceDir)) continue
|
|
514
|
+
const stat = fs.lstatSync(sourceDir)
|
|
515
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`OpenShell memory snapshot refused unexpected path ${sourceDir}`)
|
|
516
|
+
fs.mkdirSync(path.join(target, category), { recursive: true })
|
|
517
|
+
for (const name of fs.readdirSync(sourceDir)) {
|
|
518
|
+
if (!/^[A-Za-z0-9._-]+\.jsonl$/.test(name)) continue
|
|
519
|
+
const file = path.join(sourceDir, name)
|
|
520
|
+
const fileStat = fs.lstatSync(file)
|
|
521
|
+
if (!fileStat.isFile() || fileStat.isSymbolicLink() || fileStat.size > 16 * 1024 * 1024) throw new Error(`OpenShell memory snapshot refused unexpected file ${file}`)
|
|
522
|
+
fs.copyFileSync(file, path.join(target, category, name))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function importMemorySnapshot(snapshot: string, destination: string): void {
|
|
528
|
+
assertNoSymlinkAncestors(destination)
|
|
529
|
+
for (const category of ["facts", "sessions"]) {
|
|
530
|
+
const sourceDir = path.join(snapshot, category)
|
|
531
|
+
if (!fs.existsSync(sourceDir)) continue
|
|
532
|
+
const directoryStat = fs.lstatSync(sourceDir)
|
|
533
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`OpenShell memory import refused unexpected path ${sourceDir}`)
|
|
534
|
+
for (const name of fs.readdirSync(sourceDir)) {
|
|
535
|
+
if (!/^[A-Za-z0-9._-]+\.jsonl$/.test(name)) continue
|
|
536
|
+
const source = path.join(sourceDir, name)
|
|
537
|
+
const stat = fs.lstatSync(source)
|
|
538
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024) throw new Error(`OpenShell memory import refused unexpected file ${source}`)
|
|
539
|
+
const target = path.join(destination, category, name)
|
|
540
|
+
assertNoSymlinkAncestors(path.dirname(target))
|
|
541
|
+
if (fs.existsSync(target)) {
|
|
542
|
+
const targetStat = fs.lstatSync(target)
|
|
543
|
+
if (!targetStat.isFile() || targetStat.isSymbolicLink()) throw new Error(`OpenShell memory import refused unexpected destination ${target}`)
|
|
544
|
+
}
|
|
545
|
+
const existing = new Set(fs.existsSync(target) ? fs.readFileSync(target, "utf8").split(/\r?\n/).filter(Boolean) : [])
|
|
546
|
+
const additions = fs.readFileSync(source, "utf8").split(/\r?\n/).filter((line) => {
|
|
547
|
+
if (!line) return false
|
|
548
|
+
try { const value = JSON.parse(line); return !!value && typeof value === "object" && !Array.isArray(value) && !existing.has(line) } catch { return false }
|
|
549
|
+
})
|
|
550
|
+
if (additions.length) {
|
|
551
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
552
|
+
fs.appendFileSync(target, `${additions.join("\n")}\n`, { mode: 0o600 })
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function pathsOverlap(left: string, right: string): boolean {
|
|
559
|
+
return isSameOrInside(left, right) || isSameOrInside(right, left)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function isSameOrInside(parent: string, child: string): boolean {
|
|
563
|
+
const relative = path.relative(path.resolve(parent), path.resolve(child))
|
|
564
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function assertNoSymlinkAncestors(target: string): void {
|
|
568
|
+
const absolute = path.resolve(target)
|
|
569
|
+
let current = path.parse(absolute).root
|
|
570
|
+
for (const component of absolute.slice(current.length).split(path.sep).filter(Boolean)) {
|
|
571
|
+
current = path.join(current, component)
|
|
572
|
+
try {
|
|
573
|
+
const stat = fs.lstatSync(current)
|
|
574
|
+
if (stat.isSymbolicLink()) throw new Error(`OpenShell path refused symbolic link ${current}`)
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") break
|
|
577
|
+
throw error
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)) }
|