@capagents/forgeqe 0.0.0-stage → 1.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/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import { allowListEnv } from "../childEnv.ts"
|
|
7
|
+
import { EXE, fetchBytes, installTool, isMusl, toolPath, toolsDir, forgeqeHome, WINDOWS, type Log } from "../tools.ts"
|
|
8
|
+
|
|
9
|
+
export const BROWSER_USE_VERSION = "0.13.10"
|
|
10
|
+
// azure-identity: keyless sign-in to the model with Microsoft Entra ID (auth: entra in llms.json).
|
|
11
|
+
const IDENTITY_PACKAGE = "azure-identity>=1.19"
|
|
12
|
+
const PACKAGES = [`browser-use==${BROWSER_USE_VERSION}`, "pyyaml>=6", IDENTITY_PACKAGE]
|
|
13
|
+
|
|
14
|
+
export const PACKAGE_ROOT = path.resolve(import.meta.dir, "..", "..")
|
|
15
|
+
export const ENGINE_SOURCE = path.join(PACKAGE_ROOT, "py")
|
|
16
|
+
|
|
17
|
+
export type { Log }
|
|
18
|
+
|
|
19
|
+
export function engineHome(): string {
|
|
20
|
+
return forgeqeHome()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function venvDir(): string {
|
|
24
|
+
return path.join(engineHome(), "engine", ".venv")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function venvPython(): string {
|
|
28
|
+
return process.platform === "win32"
|
|
29
|
+
? path.join(venvDir(), "Scripts", "python.exe")
|
|
30
|
+
: path.join(venvDir(), "bin", "python")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function markerFile(): string {
|
|
34
|
+
return path.join(engineHome(), "engine", "installed.json")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function installedVersion(): string {
|
|
38
|
+
try {
|
|
39
|
+
return String(JSON.parse(readFileSync(markerFile(), "utf8")).browser_use || "")
|
|
40
|
+
} catch {
|
|
41
|
+
return ""
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** FORGEQE_PYTHON without the quotes Windows users often keep around paths such as C:\\Program Files\\... */
|
|
46
|
+
function pythonOverride(): string | null {
|
|
47
|
+
const raw = (process.env.FORGEQE_PYTHON || "").trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1")
|
|
48
|
+
return raw || null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const skippedOverride = new Set<string>()
|
|
52
|
+
let identityTried = false
|
|
53
|
+
|
|
54
|
+
function managedPython(): string | null {
|
|
55
|
+
return existsSync(venvPython()) && installedVersion() === BROWSER_USE_VERSION ? venvPython() : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Python that runs the engine: FORGEQE_PYTHON, else the managed venv once the engine library is installed there. */
|
|
59
|
+
export function enginePython(): string | null {
|
|
60
|
+
return pythonOverride() || managedPython()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The engine's environment: system settings, engine tuning, and the variables in `allow` (the model key and saved-login
|
|
65
|
+
* variables a run reads). Other secrets in this terminal, such as test case system tokens, never reach the browser agent.
|
|
66
|
+
*/
|
|
67
|
+
export function engineEnv(extra?: Record<string, string>, allow: string[] = []): Record<string, string> {
|
|
68
|
+
const env = allowListEnv(process.env, allow)
|
|
69
|
+
env.PYTHONPATH = [ENGINE_SOURCE, env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
|
70
|
+
env.PYTHONUNBUFFERED = "1"
|
|
71
|
+
return { ...env, ...extra }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const UV_VERSION = "0.11.16"
|
|
75
|
+
|
|
76
|
+
function managedUv(): string {
|
|
77
|
+
return toolPath("uv", UV_VERSION, `uv${EXE}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A uv ForgeQE downloaded keeps its Pythons under ~/.forgeqe too, so deleting that folder removes everything. */
|
|
81
|
+
function uvEnv(uv: string): Record<string, string> {
|
|
82
|
+
return uv === managedUv() ? { UV_PYTHON_INSTALL_DIR: path.join(toolsDir(), "python") } : {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function findUv(): string | null {
|
|
86
|
+
const home = os.homedir()
|
|
87
|
+
for (const c of ["uv", path.join(home, ".local", "bin", `uv${EXE}`), path.join(home, ".cargo", "bin", `uv${EXE}`), managedUv()]) {
|
|
88
|
+
if (c !== "uv" && !existsSync(c)) continue
|
|
89
|
+
const r = spawnSync(c, ["--version"], { encoding: "utf8" })
|
|
90
|
+
if (!r.error && r.status === 0) return c
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** uv release asset for this machine, or null when uv has no build for it. */
|
|
96
|
+
export function uvAsset(platform: string = process.platform, arch: string = process.arch, musl = platform === "linux" && isMusl()): string | null {
|
|
97
|
+
const cpu = ({ x64: "x86_64", arm64: "aarch64" } as Record<string, string>)[arch]
|
|
98
|
+
if (!cpu) return null
|
|
99
|
+
if (platform === "darwin") return `uv-${cpu}-apple-darwin.tar.gz`
|
|
100
|
+
if (platform === "linux") return `uv-${cpu}-unknown-linux-${musl ? "musl" : "gnu"}.tar.gz`
|
|
101
|
+
if (platform === "win32") return `uv-${cpu}-pc-windows-msvc.zip`
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** System uv, else a uv ForgeQE downloaded earlier, else download it now. Null only when the download fails. */
|
|
106
|
+
async function ensureUv(log: Log): Promise<string | null> {
|
|
107
|
+
const found = findUv()
|
|
108
|
+
if (found) return found
|
|
109
|
+
try {
|
|
110
|
+
const asset = uvAsset()
|
|
111
|
+
if (!asset) throw new Error(`no uv build for ${process.platform}/${process.arch}`)
|
|
112
|
+
const url = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${asset}`
|
|
113
|
+
const sha256 = (await fetchBytes(`${url}.sha256`)).toString("utf8").trim().split(/\s+/)[0]?.toLowerCase() || null
|
|
114
|
+
return await installTool({ name: "uv", version: UV_VERSION, asset, url, binary: `uv${EXE}`, sha256, log })
|
|
115
|
+
} catch (err) {
|
|
116
|
+
log(`Could not download uv (${err instanceof Error ? err.message : String(err)}); falling back to a system Python 3.11+.`)
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function pythonVersion(cmd: string): [number, number] | null {
|
|
122
|
+
const r = spawnSync(cmd, ["-c", "import sys; print(sys.version_info[0], sys.version_info[1])"], { encoding: "utf8" })
|
|
123
|
+
if (r.error || r.status !== 0) return null
|
|
124
|
+
const [major, minor] = r.stdout.trim().split(/\s+/).map(Number)
|
|
125
|
+
return Number.isFinite(major) && Number.isFinite(minor) ? [major, minor] : null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The Windows `py` launcher picks the newest installed Python; resolve it to that python.exe. */
|
|
129
|
+
function pyLauncherPython(): string | null {
|
|
130
|
+
const r = spawnSync("py", ["-3", "-c", "import sys; print(sys.executable)"], { encoding: "utf8" })
|
|
131
|
+
if (r.error || r.status !== 0) return null
|
|
132
|
+
const exe = r.stdout.trim()
|
|
133
|
+
return exe && existsSync(exe) ? exe : null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function findSystemPython(): string | null {
|
|
137
|
+
const launcher = WINDOWS ? pyLauncherPython() : null
|
|
138
|
+
for (const c of [...(launcher ? [launcher] : []), "python3.13", "python3.12", "python3.11", "python3", "python"]) {
|
|
139
|
+
const v = pythonVersion(c)
|
|
140
|
+
if (v && (v[0] > 3 || (v[0] === 3 && v[1] >= 11))) return c
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function run(cmd: string, args: string[], log: Log, env: Record<string, string> = {}, shown?: string): Promise<void> {
|
|
146
|
+
log(`$ ${shown || [path.basename(cmd), ...args].join(" ")}`)
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const child = spawn(cmd, args, {
|
|
149
|
+
env: engineEnv({ NO_COLOR: "1", UV_NO_PROGRESS: "1", ...env }),
|
|
150
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
151
|
+
})
|
|
152
|
+
const tail: string[] = []
|
|
153
|
+
const onData = (buf: Buffer) => {
|
|
154
|
+
tail.push(buf.toString("utf8"))
|
|
155
|
+
if (tail.length > 40) tail.shift()
|
|
156
|
+
}
|
|
157
|
+
child.stdout.on("data", onData)
|
|
158
|
+
child.stderr.on("data", onData)
|
|
159
|
+
child.on("error", reject)
|
|
160
|
+
child.on("close", (code) => {
|
|
161
|
+
if (code === 0) resolve()
|
|
162
|
+
else {
|
|
163
|
+
const lines = tail.join("").split(/\r?\n/).filter((l) => l.trim())
|
|
164
|
+
reject(new Error(`${path.basename(cmd)} exited with ${code}\n${lines.slice(-15).join("\n")}`))
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type Doctor = {
|
|
171
|
+
python?: string
|
|
172
|
+
browser_use?: string
|
|
173
|
+
browser_use_error?: string
|
|
174
|
+
/** Installed azure-identity version; missing in environments set up before auth: entra existed. */
|
|
175
|
+
azure_identity?: string
|
|
176
|
+
channel?: string
|
|
177
|
+
executable_path?: string
|
|
178
|
+
/** Why the engine cannot run with this Python; set whenever browser_use is missing. */
|
|
179
|
+
error?: string
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function doctorProblem(stderr: string): string {
|
|
183
|
+
const lines = stderr.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
|
|
184
|
+
const missing = lines.find((l) => /No module named '?(browser_use|yaml|forgeqe_engine)/.test(l))
|
|
185
|
+
if (missing?.includes("browser_use")) return `the engine's browser library is not installed in this Python (${missing})`
|
|
186
|
+
if (missing?.includes("yaml")) return `pyyaml is not installed in this Python (${missing})`
|
|
187
|
+
return lines.slice(-3).join(" | ") || "the engine check printed nothing"
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function engineDoctor(python: string): Doctor {
|
|
191
|
+
const r = spawnSync(python, ["-m", "forgeqe_engine", "doctor"], { encoding: "utf8", env: engineEnv() })
|
|
192
|
+
if (r.error) {
|
|
193
|
+
const code = (r.error as NodeJS.ErrnoException).code
|
|
194
|
+
return { error: code === "ENOENT" ? `${python} does not exist or is not a program` : r.error.message }
|
|
195
|
+
}
|
|
196
|
+
const line = (r.stdout || "").trim().split(/\r?\n/).pop() || ""
|
|
197
|
+
let doc: Doctor & { engine_error?: string } = {}
|
|
198
|
+
try {
|
|
199
|
+
doc = JSON.parse(line) as Doctor
|
|
200
|
+
} catch {}
|
|
201
|
+
const [major, minor] = String(doc.python || "").split(".").map(Number)
|
|
202
|
+
if (major === 3 && minor < 11) return { ...doc, browser_use: undefined, error: `Python ${doc.python} is too old; the engine needs 3.11+` }
|
|
203
|
+
if (doc.browser_use && !doc.engine_error) return doc
|
|
204
|
+
const error = doc.engine_error
|
|
205
|
+
? `the engine failed to load (${doc.engine_error})`
|
|
206
|
+
: doc.browser_use_error
|
|
207
|
+
? `the engine's browser library is not installed in this Python (${doc.browser_use_error})`
|
|
208
|
+
: doctorProblem(r.stderr || "")
|
|
209
|
+
return { ...doc, browser_use: undefined, error }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function ensureBrowser(python: string, uv: string | null, log: Log): Promise<void> {
|
|
213
|
+
const doc = engineDoctor(python)
|
|
214
|
+
if (doc?.executable_path) {
|
|
215
|
+
log(`Browser: ${doc.executable_path}`)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
log("Google Chrome not found; installing Chromium for the engine.")
|
|
219
|
+
if (uv) {
|
|
220
|
+
await run(uv, ["tool", "run", "playwright", "install", "chromium", "--no-shell"], log, uvEnv(uv))
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
await run(python, ["-m", "pip", "install", "-q", "playwright"], log)
|
|
224
|
+
await run(python, ["-m", "playwright", "install", "chromium", "--no-shell"], log)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create ~/.forgeqe/engine/.venv with the engine library. Uses uv (system, or downloaded into ~/.forgeqe/tools),
|
|
229
|
+
* which fetches Python 3.11+ itself; a system Python 3.11+ is the fallback when uv cannot be downloaded.
|
|
230
|
+
* A FORGEQE_PYTHON that cannot run the engine is skipped, and a broken environment is rebuilt, without asking.
|
|
231
|
+
* Safe to call repeatedly; it only installs when the pinned version is missing.
|
|
232
|
+
*/
|
|
233
|
+
export async function ensureEngine(log: Log, opts?: { force?: boolean }): Promise<string> {
|
|
234
|
+
const override = pythonOverride()
|
|
235
|
+
if (override) {
|
|
236
|
+
const doc = engineDoctor(override)
|
|
237
|
+
if (doc.browser_use) {
|
|
238
|
+
if (doc.browser_use !== BROWSER_USE_VERSION) log(`Note: ${override} has engine library ${doc.browser_use}; ForgeQE is tested with ${BROWSER_USE_VERSION}.`)
|
|
239
|
+
return override
|
|
240
|
+
}
|
|
241
|
+
if (!skippedOverride.has(override)) {
|
|
242
|
+
skippedOverride.add(override)
|
|
243
|
+
log(`FORGEQE_PYTHON (${override}) cannot run the engine: ${doc.error}. Using ForgeQE's own environment instead.`)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let rebuild = Boolean(opts?.force)
|
|
247
|
+
const existing = rebuild ? null : managedPython()
|
|
248
|
+
if (existing) {
|
|
249
|
+
const doc = engineDoctor(existing)
|
|
250
|
+
if (doc.browser_use && !doc.azure_identity && !identityTried) {
|
|
251
|
+
// Set up before auth: entra existed: add the one package rather than rebuild the environment (once per process).
|
|
252
|
+
identityTried = true
|
|
253
|
+
log("Adding azure-identity to the engine environment (for auth: entra).")
|
|
254
|
+
const uv = findUv()
|
|
255
|
+
try {
|
|
256
|
+
if (uv) await run(uv, ["pip", "install", "--python", existing, IDENTITY_PACKAGE], log, uvEnv(uv))
|
|
257
|
+
else await run(existing, ["-m", "pip", "install", "-q", IDENTITY_PACKAGE], log)
|
|
258
|
+
} catch (err) {
|
|
259
|
+
log(`Could not add azure-identity (${err instanceof Error ? err.message.split("\n")[0] : String(err)}); auth: entra profiles will not work until forgeqe setup succeeds.`)
|
|
260
|
+
}
|
|
261
|
+
return existing
|
|
262
|
+
}
|
|
263
|
+
if (doc.browser_use) return existing
|
|
264
|
+
log(`The engine environment is broken (${doc.error}); rebuilding it.`)
|
|
265
|
+
rebuild = true
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const venv = venvDir()
|
|
269
|
+
mkdirSync(path.dirname(venv), { recursive: true })
|
|
270
|
+
if (rebuild && existsSync(venv)) rmSync(venv, { recursive: true, force: true })
|
|
271
|
+
rmSync(markerFile(), { force: true })
|
|
272
|
+
log(`Setting up the ForgeQE engine in ${venv} (one time).`)
|
|
273
|
+
const uv = await ensureUv(log)
|
|
274
|
+
|
|
275
|
+
if (uv) {
|
|
276
|
+
if (!existsSync(venvPython())) await run(uv, ["venv", "--python", ">=3.11", venv], log, uvEnv(uv))
|
|
277
|
+
await run(uv, ["pip", "install", "--python", venvPython(), ...PACKAGES], log, uvEnv(uv), "uv pip install <engine packages>")
|
|
278
|
+
} else {
|
|
279
|
+
const py = findSystemPython()
|
|
280
|
+
if (!py) {
|
|
281
|
+
const uvInstall = WINDOWS
|
|
282
|
+
? `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
|
|
283
|
+
: "curl -LsSf https://astral.sh/uv/install.sh | sh"
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Could not download uv and found no Python 3.11+. Check the network or proxy, or install uv (${uvInstall}) ` +
|
|
286
|
+
"or Python 3.11+ yourself, then run: forgeqe setup.",
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
if (!existsSync(venvPython())) await run(py, ["-m", "venv", venv], log)
|
|
290
|
+
await run(venvPython(), ["-m", "pip", "install", "-q", "--upgrade", "pip"], log)
|
|
291
|
+
await run(venvPython(), ["-m", "pip", "install", "-q", ...PACKAGES], log, {}, "pip install <engine packages>")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await ensureBrowser(venvPython(), uv, log)
|
|
295
|
+
writeFileSync(markerFile(), JSON.stringify({ browser_use: BROWSER_USE_VERSION }, null, 2) + "\n")
|
|
296
|
+
log("Engine ready.")
|
|
297
|
+
return venvPython()
|
|
298
|
+
}
|
package/src/entra.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
3
|
+
|
|
4
|
+
import { ENTRA_PROVIDERS, type LlmProfile } from "./llm.ts"
|
|
5
|
+
import { enginePython, engineEnv } from "./engine/setup.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keyless model access with Microsoft Entra ID (`auth: entra` in llms.json). The engine signs in itself with
|
|
9
|
+
* azure-identity; the coding agent's runtime only takes a fixed key, so it talks to a local gateway that adds a current
|
|
10
|
+
* Entra ID token to every request. Neither ever holds a long-lived secret.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The variables azure-identity reads to find a credential: managed identity (App Service, Functions, Container Apps,
|
|
15
|
+
* VMs, Arc), workload identity (AKS, federated CI), a service principal, and the Azure CLI's folder. The engine sees
|
|
16
|
+
* them only for an `auth: entra` profile.
|
|
17
|
+
*/
|
|
18
|
+
export const AZURE_IDENTITY_ENV = [
|
|
19
|
+
"AZURE_CLIENT_ID",
|
|
20
|
+
"AZURE_TENANT_ID",
|
|
21
|
+
"AZURE_CLIENT_SECRET",
|
|
22
|
+
"AZURE_CLIENT_CERTIFICATE_PATH",
|
|
23
|
+
"AZURE_CLIENT_CERTIFICATE_PASSWORD",
|
|
24
|
+
"AZURE_CLIENT_SEND_CERTIFICATE_CHAIN",
|
|
25
|
+
"AZURE_FEDERATED_TOKEN_FILE",
|
|
26
|
+
"AZURE_AUTHORITY_HOST",
|
|
27
|
+
"AZURE_REGIONAL_AUTHORITY_NAME",
|
|
28
|
+
"AZURE_ADDITIONALLY_ALLOWED_TENANTS",
|
|
29
|
+
"AZURE_TOKEN_CREDENTIALS",
|
|
30
|
+
"AZURE_POD_IDENTITY_AUTHORITY_HOST",
|
|
31
|
+
"AZURE_CONFIG_DIR",
|
|
32
|
+
"IDENTITY_ENDPOINT",
|
|
33
|
+
"IDENTITY_HEADER",
|
|
34
|
+
"IDENTITY_SERVER_THUMBPRINT",
|
|
35
|
+
"IMDS_ENDPOINT",
|
|
36
|
+
"MSI_ENDPOINT",
|
|
37
|
+
"MSI_SECRET",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** The profile fields the engine's credential reads, in llms.json's spelling. */
|
|
41
|
+
export function entraPayload(p: LlmProfile): Record<string, string> {
|
|
42
|
+
return {
|
|
43
|
+
auth: "entra",
|
|
44
|
+
...(p.managedIdentityClientId ? { managed_identity_client_id: p.managedIdentityClientId } : {}),
|
|
45
|
+
...(p.tokenScope ? { token_scope: p.tokenScope } : {}),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type Token = { token: string; expiresOn: number }
|
|
50
|
+
|
|
51
|
+
/** One token from the engine's credential chain (`python -m forgeqe_engine token`). */
|
|
52
|
+
export function engineToken(p: LlmProfile, python = enginePython()): Promise<Token> {
|
|
53
|
+
if (!python) return Promise.reject(new Error("auth: entra signs in through the ForgeQE engine, which is not set up yet. Run forgeqe setup."))
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const child = spawn(python, ["-m", "forgeqe_engine", "token"], { env: engineEnv(undefined, AZURE_IDENTITY_ENV), stdio: ["pipe", "pipe", "pipe"] })
|
|
56
|
+
let out = ""
|
|
57
|
+
child.stdout.setEncoding("utf8")
|
|
58
|
+
child.stdout.on("data", (c: string) => (out += c))
|
|
59
|
+
child.stderr.resume()
|
|
60
|
+
child.on("error", reject)
|
|
61
|
+
child.on("close", () => {
|
|
62
|
+
let r: { token?: string; expires_on?: number; error?: string } = {}
|
|
63
|
+
try {
|
|
64
|
+
r = JSON.parse(out.trim().split(/\r?\n/).pop() || "{}")
|
|
65
|
+
} catch {}
|
|
66
|
+
if (r.token) resolve({ token: r.token, expiresOn: Number(r.expires_on || 0) * 1000 || Date.now() + 30 * 60_000 })
|
|
67
|
+
else reject(new Error(`Could not get a Microsoft Entra ID token for the model: ${r.error || "the engine printed none"}`))
|
|
68
|
+
})
|
|
69
|
+
child.stdin.end(JSON.stringify(entraPayload(p)) + "\n")
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A token source that reuses a token until five minutes before it expires, and asks once however many callers wait. */
|
|
74
|
+
export function cachedToken(fetchToken: () => Promise<Token>, now = Date.now): () => Promise<string> {
|
|
75
|
+
let current: Token | null = null
|
|
76
|
+
let pending: Promise<Token> | null = null
|
|
77
|
+
return async () => {
|
|
78
|
+
if (current && now() < current.expiresOn - 5 * 60_000) return current.token
|
|
79
|
+
pending ||= fetchToken().finally(() => (pending = null))
|
|
80
|
+
current = await pending
|
|
81
|
+
return current.token
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The OpenAI-style base URL behind a profile: Azure's v1 API (which takes Entra ID tokens), or the gateway's URL. */
|
|
86
|
+
export function upstreamURL(p: LlmProfile): string {
|
|
87
|
+
if (!p.endpoint) throw new Error(`The ${p.provider} profile needs an endpoint.`)
|
|
88
|
+
const base = p.endpoint.replace(/\/+$/, "")
|
|
89
|
+
return p.provider === "azure" ? `${base.replace(/\/openai(\/v1)?$/, "")}/openai/v1` : base
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The variable the coding agent's runtime reads the gateway's key from. */
|
|
93
|
+
export const GATEWAY_KEY_ENV = "FORGEQE_ENTRA_GATEWAY_KEY"
|
|
94
|
+
|
|
95
|
+
export type EntraGateway = {
|
|
96
|
+
/** The profile the runtime uses instead: an OpenAI-compatible endpoint on this machine. */
|
|
97
|
+
profile: LlmProfile
|
|
98
|
+
/** Values for the runtime's {env:} references. */
|
|
99
|
+
secrets: Record<string, string>
|
|
100
|
+
close(): void
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A gateway on 127.0.0.1 for the coding agent's runtime. It accepts only a one-time random key (never valid anywhere
|
|
105
|
+
* else), and forwards each request to the model with a current Entra ID token in place of that key.
|
|
106
|
+
*/
|
|
107
|
+
export function entraGateway(p: LlmProfile, token: () => Promise<string> = cachedToken(() => engineToken(p))): EntraGateway {
|
|
108
|
+
if (!ENTRA_PROVIDERS.includes(p.provider)) throw new Error(`auth: entra works with the azure and openai_compatible providers, not ${p.provider}.`)
|
|
109
|
+
const upstream = upstreamURL(p)
|
|
110
|
+
const key = randomBytes(24).toString("hex")
|
|
111
|
+
const expected = Buffer.from(`Bearer ${key}`)
|
|
112
|
+
const server = Bun.serve({
|
|
113
|
+
hostname: "127.0.0.1",
|
|
114
|
+
port: 0,
|
|
115
|
+
// Model responses stream for minutes; Bun's default idle timeout would cut them off.
|
|
116
|
+
idleTimeout: 0,
|
|
117
|
+
async fetch(req) {
|
|
118
|
+
const got = Buffer.from(req.headers.get("authorization") || "")
|
|
119
|
+
if (got.length !== expected.length || !timingSafeEqual(got, expected)) return Response.json({ error: { message: "Unauthorized" } }, { status: 401 })
|
|
120
|
+
let bearer: string
|
|
121
|
+
try {
|
|
122
|
+
bearer = await token()
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return Response.json({ error: { message: err instanceof Error ? err.message : String(err) } }, { status: 401 })
|
|
125
|
+
}
|
|
126
|
+
const url = new URL(req.url)
|
|
127
|
+
const headers = new Headers(req.headers)
|
|
128
|
+
for (const h of ["host", "authorization", "api-key", "content-length", "connection"]) headers.delete(h)
|
|
129
|
+
headers.set("authorization", `Bearer ${bearer}`)
|
|
130
|
+
// Bun decompresses fetched bodies, so ask for none rather than pass on a content-encoding that no longer holds.
|
|
131
|
+
headers.set("accept-encoding", "identity")
|
|
132
|
+
const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer()
|
|
133
|
+
const res = await fetch(`${upstream}${url.pathname}${url.search}`, { method: req.method, headers, body, redirect: "manual", signal: req.signal })
|
|
134
|
+
const out = new Headers(res.headers)
|
|
135
|
+
out.delete("content-encoding")
|
|
136
|
+
out.delete("content-length")
|
|
137
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: out })
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
return {
|
|
141
|
+
profile: {
|
|
142
|
+
...p,
|
|
143
|
+
provider: "openai_compatible",
|
|
144
|
+
// Azure's v1 API names the deployment as the model.
|
|
145
|
+
model: p.provider === "azure" ? p.deployment || p.model : p.model,
|
|
146
|
+
endpoint: `http://127.0.0.1:${server.port}`,
|
|
147
|
+
auth: "key",
|
|
148
|
+
apiKey: undefined,
|
|
149
|
+
apiKeyEnv: GATEWAY_KEY_ENV,
|
|
150
|
+
},
|
|
151
|
+
secrets: { [GATEWAY_KEY_ENV]: key },
|
|
152
|
+
close: () => server.stop(true),
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
/** Load KEY=VALUE lines. Existing process env wins. */
|
|
5
|
+
export function loadDotEnv(dir: string): void {
|
|
6
|
+
const file = path.join(dir, ".env")
|
|
7
|
+
if (!existsSync(file)) return
|
|
8
|
+
const text = readFileSync(file, "utf8")
|
|
9
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
10
|
+
const line = raw.trim()
|
|
11
|
+
if (!line || line.startsWith("#")) continue
|
|
12
|
+
const eq = line.indexOf("=")
|
|
13
|
+
if (eq <= 0) continue
|
|
14
|
+
const key = line.slice(0, eq).trim()
|
|
15
|
+
let value = line.slice(eq + 1).trim()
|
|
16
|
+
if (
|
|
17
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
18
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
19
|
+
) {
|
|
20
|
+
value = value.slice(1, -1)
|
|
21
|
+
}
|
|
22
|
+
if (process.env[key] === undefined) process.env[key] = value
|
|
23
|
+
}
|
|
24
|
+
}
|