@john-ezra/openralph 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +25 -0
- package/AGENTS.md +148 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/PROMPT_build.md +48 -0
- package/PROMPT_plan.md +29 -0
- package/README.md +247 -0
- package/bin/openralph +5 -0
- package/bun.lock +85 -0
- package/container/Dockerfile +58 -0
- package/container/bin/chrome-devtools-mcp-wrapper +57 -0
- package/package.json +50 -0
- package/src/args.ts +236 -0
- package/src/artifacts.ts +183 -0
- package/src/cli.ts +125 -0
- package/src/design.ts +24 -0
- package/src/docker.ts +403 -0
- package/src/exec.ts +103 -0
- package/src/git.ts +62 -0
- package/src/launcher.ts +235 -0
- package/src/loop.ts +339 -0
- package/src/plugin.ts +56 -0
- package/src/release-check.ts +143 -0
- package/src/sentinels.ts +23 -0
- package/src/tags.ts +22 -0
- package/src/trust.ts +116 -0
- package/src/tui.ts +436 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join, normalize } from "node:path"
|
|
4
|
+
import { pathToFileURL } from "node:url"
|
|
5
|
+
import { runCommand } from "./exec.ts"
|
|
6
|
+
|
|
7
|
+
export interface ReleaseCheckFinding {
|
|
8
|
+
file: string
|
|
9
|
+
line: number
|
|
10
|
+
reason: string
|
|
11
|
+
marker: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ForbiddenPathMarker {
|
|
15
|
+
reason: string
|
|
16
|
+
value: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReleaseCheckResult {
|
|
20
|
+
files: string[]
|
|
21
|
+
findings: ReleaseCheckFinding[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface NpmPackFile {
|
|
25
|
+
path?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface NpmPackEntry {
|
|
29
|
+
files?: NpmPackFile[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runReleaseCheck(cwd = process.cwd()): Promise<ReleaseCheckResult> {
|
|
33
|
+
const [trackedFiles, packedFiles] = await Promise.all([listGitTrackedFiles(cwd), listNpmPackFiles(cwd)])
|
|
34
|
+
const files = uniqueSorted([...trackedFiles, ...packedFiles])
|
|
35
|
+
const findings = await scanFilesForForbiddenPaths(cwd, files, buildForbiddenPathMarkers(cwd))
|
|
36
|
+
return { files, findings }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildForbiddenPathMarkers(projectRoot: string, home = homedir()): ForbiddenPathMarker[] {
|
|
40
|
+
const markers: ForbiddenPathMarker[] = []
|
|
41
|
+
const seen = new Set<string>()
|
|
42
|
+
|
|
43
|
+
addMarker(markers, seen, pathToFileURL(normalizePath(home)).href, "file URL pointing into the current user's home directory")
|
|
44
|
+
addMarker(markers, seen, normalizePath(home), "absolute path under the current user's home directory")
|
|
45
|
+
addMarker(markers, seen, normalizePath(projectRoot), "absolute path into this local OpenRalph checkout")
|
|
46
|
+
|
|
47
|
+
return markers
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function scanFilesForForbiddenPaths(
|
|
51
|
+
cwd: string,
|
|
52
|
+
files: string[],
|
|
53
|
+
markers = buildForbiddenPathMarkers(cwd),
|
|
54
|
+
): Promise<ReleaseCheckFinding[]> {
|
|
55
|
+
const findings: ReleaseCheckFinding[] = []
|
|
56
|
+
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
let content: Buffer
|
|
59
|
+
try {
|
|
60
|
+
content = await readFile(join(cwd, file))
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (isMissingFileError(error)) continue
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (content.includes(0)) continue
|
|
67
|
+
|
|
68
|
+
const lines = content.toString("utf8").split(/\r?\n/)
|
|
69
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
70
|
+
for (const marker of markers) {
|
|
71
|
+
if (lines[index].includes(marker.value)) {
|
|
72
|
+
findings.push({ file, line: index + 1, reason: marker.reason, marker: marker.value })
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return findings
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addMarker(markers: ForbiddenPathMarker[], seen: Set<string>, value: string, reason: string): void {
|
|
83
|
+
if (!value || value === "/" || seen.has(value)) return
|
|
84
|
+
seen.add(value)
|
|
85
|
+
markers.push({ value, reason })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizePath(path: string): string {
|
|
89
|
+
return normalize(path).replace(/\/$/, "")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isMissingFileError(error: unknown): boolean {
|
|
93
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function listGitTrackedFiles(cwd: string): Promise<string[]> {
|
|
97
|
+
const result = await runCommand("git", ["ls-files", "-z"], cwd)
|
|
98
|
+
if (result.exitCode !== 0) throw new Error(`git ls-files failed: ${result.stderr.trim() || "unknown error"}`)
|
|
99
|
+
return result.stdout.split("\0").filter(Boolean)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function listNpmPackFiles(cwd: string): Promise<string[]> {
|
|
103
|
+
const result = await runCommand("npm", ["pack", "--dry-run", "--json"], cwd)
|
|
104
|
+
if (result.exitCode !== 0) throw new Error(`npm pack --dry-run failed: ${result.stderr.trim() || "unknown error"}`)
|
|
105
|
+
|
|
106
|
+
let entries: NpmPackEntry[]
|
|
107
|
+
try {
|
|
108
|
+
entries = JSON.parse(result.stdout.trim()) as NpmPackEntry[]
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new Error(`npm pack --dry-run returned invalid JSON: ${formatError(error)}`)
|
|
111
|
+
}
|
|
112
|
+
return entries.flatMap((entry) => entry.files?.map((file) => file.path).filter((path): path is string => Boolean(path)) ?? [])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function uniqueSorted(values: string[]): string[] {
|
|
116
|
+
return [...new Set(values)].sort()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function formatFindings(findings: ReleaseCheckFinding[]): string {
|
|
120
|
+
return [
|
|
121
|
+
"Release path check failed: found local machine paths in public files.",
|
|
122
|
+
...findings.map((finding) => `${finding.file}:${finding.line}: ${finding.reason}: ${finding.marker}`),
|
|
123
|
+
].join("\n")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatError(error: unknown): string {
|
|
127
|
+
return error instanceof Error ? error.message : String(error)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (import.meta.main) {
|
|
131
|
+
try {
|
|
132
|
+
const result = await runReleaseCheck()
|
|
133
|
+
if (result.findings.length > 0) {
|
|
134
|
+
process.stderr.write(`${formatFindings(result.findings)}\n`)
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
process.stdout.write(`Release path check passed: scanned ${result.files.length} public files.\n`)
|
|
139
|
+
} catch (error) {
|
|
140
|
+
process.stderr.write(`Release path check failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/sentinels.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const PLAN_COMPLETE = "RALPH_PLAN_COMPLETE"
|
|
2
|
+
export const ITERATION_COMPLETE = "RALPH_ITERATION_COMPLETE"
|
|
3
|
+
export const BUILD_COMPLETE = "RALPH_COMPLETE"
|
|
4
|
+
export const BUILD_BLOCKED = "RALPH_BLOCKED"
|
|
5
|
+
|
|
6
|
+
export type BuildSentinel = "complete" | "iteration-complete" | "blocked" | "none"
|
|
7
|
+
|
|
8
|
+
export function isPlanComplete(output: string): boolean {
|
|
9
|
+
return output.includes(PLAN_COMPLETE)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function detectBuildSentinel(output: string): BuildSentinel {
|
|
13
|
+
let detected: BuildSentinel = "none"
|
|
14
|
+
|
|
15
|
+
for (const line of output.split(/\r?\n/)) {
|
|
16
|
+
const trimmed = line.trim()
|
|
17
|
+
if (trimmed === BUILD_COMPLETE) detected = "complete"
|
|
18
|
+
else if (trimmed === ITERATION_COMPLETE) detected = "iteration-complete"
|
|
19
|
+
else if (trimmed === BUILD_BLOCKED) detected = "blocked"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return detected
|
|
23
|
+
}
|
package/src/tags.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function createBuildRunId(date = new Date()): string {
|
|
2
|
+
return createTimestampId(date)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function createTimestampId(date = new Date()): string {
|
|
6
|
+
const year = date.getFullYear()
|
|
7
|
+
const month = pad(date.getMonth() + 1)
|
|
8
|
+
const day = pad(date.getDate())
|
|
9
|
+
const hour = pad(date.getHours())
|
|
10
|
+
const minute = pad(date.getMinutes())
|
|
11
|
+
const second = pad(date.getSeconds())
|
|
12
|
+
return `${year}${month}${day}-${hour}${minute}${second}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createBuildTagName(runId: string, index: number): string {
|
|
16
|
+
if (!Number.isInteger(index) || index < 1) throw new Error("tag index must be a positive integer")
|
|
17
|
+
return `openralph/build-${runId}/${String(index).padStart(3, "0")}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pad(value: number): string {
|
|
21
|
+
return String(value).padStart(2, "0")
|
|
22
|
+
}
|
package/src/trust.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto"
|
|
2
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { isAbsolute, join, relative } from "node:path"
|
|
5
|
+
|
|
6
|
+
export const DOCKER_TOKEN_PATH = "/run/openralph/docker-token"
|
|
7
|
+
|
|
8
|
+
export interface TrustDeps {
|
|
9
|
+
containerEvidence?: () => Promise<boolean>
|
|
10
|
+
dockerTokenPath?: string
|
|
11
|
+
pathExists?: (path: string) => Promise<boolean>
|
|
12
|
+
readTextFile?: (path: string) => Promise<string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface HostLoopToken {
|
|
16
|
+
env: Record<string, string>
|
|
17
|
+
cleanup: () => Promise<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function hasDockerMarker(env: NodeJS.ProcessEnv = process.env, deps: TrustDeps = {}): Promise<boolean> {
|
|
21
|
+
if (env.OPENRALPH_IN_DOCKER === "1") return true
|
|
22
|
+
if (env.OPENRALPH_DOCKER_TOKEN) return true
|
|
23
|
+
return hasContainerEvidence(deps)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function attestDockerEnvironment(env: NodeJS.ProcessEnv = process.env, deps: TrustDeps = {}): Promise<boolean> {
|
|
27
|
+
const token = env.OPENRALPH_DOCKER_TOKEN
|
|
28
|
+
if (!token || token.trim() === "") return false
|
|
29
|
+
if (!(await hasContainerEvidence(deps))) return false
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const fileToken = await readText(deps.dockerTokenPath ?? DOCKER_TOKEN_PATH, deps)
|
|
33
|
+
return fileToken.length > 0 && fileToken === token
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function authorizeLoopChild(
|
|
40
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
41
|
+
projectRoot?: string,
|
|
42
|
+
deps: TrustDeps = {},
|
|
43
|
+
): Promise<boolean> {
|
|
44
|
+
if (env.OPENRALPH_LOOP_CHILD !== "1") return false
|
|
45
|
+
if (await attestDockerEnvironment(env, deps)) return true
|
|
46
|
+
return authorizeHostLoopChild(env, projectRoot, deps)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function authorizeHostLoopChild(
|
|
50
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
51
|
+
projectRoot?: string,
|
|
52
|
+
deps: TrustDeps = {},
|
|
53
|
+
): Promise<boolean> {
|
|
54
|
+
const token = env.OPENRALPH_HOST_LOOP_TOKEN
|
|
55
|
+
const tokenFile = env.OPENRALPH_HOST_LOOP_TOKEN_FILE
|
|
56
|
+
if (!token || token.trim() === "" || !tokenFile || !isAbsolute(tokenFile)) return false
|
|
57
|
+
if (projectRoot && isPathInside(projectRoot, tokenFile)) return false
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const fileToken = await readText(tokenFile, deps)
|
|
61
|
+
return fileToken.length > 0 && fileToken === token
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function createHostLoopToken(): Promise<HostLoopToken> {
|
|
68
|
+
const tempDir = await mkdtemp(join(tmpdir(), "openralph-host-child-"))
|
|
69
|
+
const token = randomBytes(32).toString("hex")
|
|
70
|
+
const tokenFile = join(tempDir, "token")
|
|
71
|
+
await writeFile(tokenFile, token)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
env: {
|
|
75
|
+
OPENRALPH_HOST_LOOP_TOKEN: token,
|
|
76
|
+
OPENRALPH_HOST_LOOP_TOKEN_FILE: tokenFile,
|
|
77
|
+
},
|
|
78
|
+
cleanup: () => rm(tempDir, { recursive: true, force: true }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function hasContainerEvidence(deps: TrustDeps): Promise<boolean> {
|
|
83
|
+
if (deps.containerEvidence) return deps.containerEvidence()
|
|
84
|
+
|
|
85
|
+
const hasExpectedPaths = (await pathExists("/workspace", deps)) && (await pathExists("/opt/openralph", deps))
|
|
86
|
+
if (!hasExpectedPaths) return false
|
|
87
|
+
|
|
88
|
+
if (await pathExists("/.dockerenv", deps)) return true
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const cgroup = await readText("/proc/1/cgroup", deps)
|
|
92
|
+
return /docker|containerd|kubepods|podman/i.test(cgroup)
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function pathExists(path: string, deps: TrustDeps): Promise<boolean> {
|
|
99
|
+
if (deps.pathExists) return deps.pathExists(path)
|
|
100
|
+
try {
|
|
101
|
+
await access(path)
|
|
102
|
+
return true
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readText(path: string, deps: TrustDeps): Promise<string> {
|
|
109
|
+
if (deps.readTextFile) return deps.readTextFile(path)
|
|
110
|
+
return readFile(path, "utf8")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isPathInside(root: string, path: string): boolean {
|
|
114
|
+
const rel = relative(root, path)
|
|
115
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)
|
|
116
|
+
}
|