@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
package/src/run.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { assertGoalUrlsAllowed, hostAllowed, normalizeHttpUrl } from "./guard.ts"
|
|
6
|
+
import { missingKey } from "./llm.ts"
|
|
7
|
+
import type { AgentEvent, ReplayMode, RunResult } from "./types.ts"
|
|
8
|
+
import type { AppConfig } from "./config.ts"
|
|
9
|
+
import { runEngine } from "./engine/bridge.ts"
|
|
10
|
+
import { MOCK_ENGINE } from "./engine/mock.ts"
|
|
11
|
+
import { ensureEngine } from "./engine/setup.ts"
|
|
12
|
+
import { reportEnv, writeRunReport } from "./report/html.ts"
|
|
13
|
+
import { logEvent } from "./sessionLog.ts"
|
|
14
|
+
import { costOf } from "./usage.ts"
|
|
15
|
+
import { allLogins, redactSecrets } from "./sessionLogins.ts"
|
|
16
|
+
import { browserSkills, rememberChoice } from "./skills/select.ts"
|
|
17
|
+
import { axeTags, DEFAULT_QUALITY, emptyReport, evaluateQuality, readA11y } from "./quality/budgets.ts"
|
|
18
|
+
import { historyFile, recordQuality } from "./quality/history.ts"
|
|
19
|
+
import { compareVisual, engineVisual } from "./quality/visual.ts"
|
|
20
|
+
import { enginePrompts, prompt } from "./prompts.ts"
|
|
21
|
+
import { knowledgeBlock, readKnowledge, relevantKnowledge } from "./knowledge.ts"
|
|
22
|
+
|
|
23
|
+
export type ExecuteOptions = {
|
|
24
|
+
config: AppConfig
|
|
25
|
+
goal: string
|
|
26
|
+
url?: string
|
|
27
|
+
headed?: boolean
|
|
28
|
+
maxSteps?: number
|
|
29
|
+
outDir?: string
|
|
30
|
+
/** Overrides memory.replay from forgeqe.yaml. */
|
|
31
|
+
replay?: ReplayMode
|
|
32
|
+
/** false runs without page memory. */
|
|
33
|
+
memory?: boolean
|
|
34
|
+
/** Steps in the test case this goal came from; the agent then reports a verdict for each. */
|
|
35
|
+
caseSteps?: number
|
|
36
|
+
/** A failed or cut-off run's folder (or its name under the runs folder): replay the steps that worked there first. */
|
|
37
|
+
resumeFrom?: string
|
|
38
|
+
/** Browser skills the caller chose (empty: none); unset: the engine picks the ones the goal needs. */
|
|
39
|
+
skills?: string[]
|
|
40
|
+
/** The repository the run is for: its .forgeqe/skills count as project skills. */
|
|
41
|
+
repo?: string
|
|
42
|
+
signal?: AbortSignal
|
|
43
|
+
onEvent?: (event: AgentEvent) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The resume.json a failed run, or a run cut off before it finished, left in its folder. */
|
|
47
|
+
export function loadResume(outRoot: string, from: string): Record<string, unknown> {
|
|
48
|
+
const dir = [path.resolve(from), path.resolve(outRoot, from)].find((d) => existsSync(path.join(d, "resume.json")))
|
|
49
|
+
if (!dir) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`No steps to resume in ${from}. Only a run that failed or was cut off part of the way leaves resume.json; run the goal without resume_from.`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
const data = JSON.parse(readFileSync(path.join(dir, "resume.json"), "utf8")) as Record<string, unknown>
|
|
55
|
+
if (!Array.isArray(data.steps) || !data.steps.length) throw new Error(`${path.join(dir, "resume.json")} has no steps.`)
|
|
56
|
+
return data
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function runDirectory(outRoot: string, explicit?: string): string {
|
|
60
|
+
if (explicit) return path.resolve(explicit)
|
|
61
|
+
const d = new Date()
|
|
62
|
+
const p = (n: number) => String(n).padStart(2, "0")
|
|
63
|
+
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
64
|
+
// Created here so runs started in the same second (parallel tool calls, back-to-back monitor rounds) get their own folder.
|
|
65
|
+
mkdirSync(outRoot, { recursive: true })
|
|
66
|
+
for (let n = 1; ; n++) {
|
|
67
|
+
const dir = path.join(outRoot, n === 1 ? stamp : `${stamp}-${n}`)
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dir)
|
|
70
|
+
return dir
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The bridge's hard ceiling for one engine run: the engine's own timeout covers only the agent, so browser start-up,
|
|
79
|
+
* skill choice, and replaying remembered steps get as long again plus five minutes before the run is stopped.
|
|
80
|
+
*/
|
|
81
|
+
export function engineDeadlineMs(timeoutSeconds: number): number {
|
|
82
|
+
return (2 * timeoutSeconds + 300) * 1000
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** axe-core's script, installed with ForgeQE; null when it is missing. */
|
|
86
|
+
export function axeCoreScript(): string | null {
|
|
87
|
+
try {
|
|
88
|
+
const file = createRequire(import.meta.url).resolve("axe-core/axe.min.js")
|
|
89
|
+
return existsSync(file) ? file : null
|
|
90
|
+
} catch {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Judge the run's accessibility and timings against the budgets, keep them in the quality history, and mark a run that
|
|
97
|
+
* passed its steps but exceeded a budget. Without budgets nothing fails: the report and the history still have them.
|
|
98
|
+
*/
|
|
99
|
+
export function applyQuality(config: AppConfig, dir: string, result: RunResult, onEvent?: (e: AgentEvent) => void): void {
|
|
100
|
+
try {
|
|
101
|
+
const q = config.quality || DEFAULT_QUALITY
|
|
102
|
+
const visual = q.visual.enabled ? compareVisual(dir, q.visual, q.visual.baselineDir || path.join(path.dirname(config.memory?.dir || config.export.outDir), "visual-baselines"), q.budgets.visual) : null
|
|
103
|
+
const report = evaluateQuality(dir, q) || (visual ? emptyReport(q) : null)
|
|
104
|
+
if (!report) return
|
|
105
|
+
if (visual) {
|
|
106
|
+
report.visual = visual
|
|
107
|
+
report.budgets.visual = q.budgets.visual !== undefined || Object.values(q.visual.pages).some((p) => p.maxDiffPercent !== undefined)
|
|
108
|
+
for (const p of visual.pages.filter((x) => x.over)) report.breaches.push({ kind: "visual", page: p.url, text: `${p.diffPercent}% of the page differs from its baseline (budget ${p.limit}%)${p.resized ? `; size ${p.resized}` : ""}` })
|
|
109
|
+
}
|
|
110
|
+
result.quality = report
|
|
111
|
+
writeFileSync(path.join(dir, "quality.json"), `${JSON.stringify(report, null, 2)}\n`)
|
|
112
|
+
recordQuality(historyFile(config.export.outDir), report, readA11y(dir), path.basename(dir))
|
|
113
|
+
if (report.breaches.length) {
|
|
114
|
+
result.budgetExceeded = report.breaches.map((b) => `${shortPage(b.page)}: ${b.text}`)
|
|
115
|
+
onEvent?.({ type: "log", level: "warn", text: `Budget exceeded: ${result.budgetExceeded.join("; ")}` })
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
onEvent?.({ type: "log", level: "warn", text: `Accessibility and performance not judged: ${(err as Error).message}` })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function shortPage(url: string): string {
|
|
123
|
+
try {
|
|
124
|
+
const u = new URL(url)
|
|
125
|
+
return `${u.host}${u.pathname}`
|
|
126
|
+
} catch {
|
|
127
|
+
return url
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunResult; files: string[] }> {
|
|
132
|
+
const goal = redactSecrets(opts.goal.trim())
|
|
133
|
+
if (!goal) throw new Error("A goal is required. Pass --goal or type one in the shell.")
|
|
134
|
+
|
|
135
|
+
const allowed = opts.config.browser.allowedHosts
|
|
136
|
+
const blockedGoal = assertGoalUrlsAllowed(goal, allowed)
|
|
137
|
+
if (blockedGoal) throw new Error(blockedGoal)
|
|
138
|
+
|
|
139
|
+
let startUrl: string | undefined
|
|
140
|
+
const requested = opts.url || opts.config.browser.startUrl
|
|
141
|
+
if (requested) {
|
|
142
|
+
const normalized = normalizeHttpUrl(requested)
|
|
143
|
+
if (!normalized) throw new Error(`Invalid URL: ${requested}`)
|
|
144
|
+
const blocked = hostAllowed(normalized, allowed)
|
|
145
|
+
if (blocked) throw new Error(blocked)
|
|
146
|
+
startUrl = normalized
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const dir = opts.outDir || runDirectory(opts.config.export.outDir)
|
|
150
|
+
const maxSteps = opts.maxSteps || opts.config.browser.maxSteps
|
|
151
|
+
const t0 = Date.now()
|
|
152
|
+
const withReport = (out: { result: RunResult; files: string[] }) => {
|
|
153
|
+
out.result.cost = costOf(out.result.usage, opts.config.llm.price)
|
|
154
|
+
applyQuality(opts.config, dir, out.result, opts.onEvent)
|
|
155
|
+
logEvent("run.done", { out_dir: dir, status: out.result.status, summary: out.result.summary, usage: out.result.usage, memory: out.result.memory, ...(out.result.skills ? { skills: out.result.skills } : {}) })
|
|
156
|
+
try {
|
|
157
|
+
out.files.push(writeRunReport(dir, out.result, reportEnv(opts.config, { url: startUrl, headed: opts.headed }), t0))
|
|
158
|
+
} catch (err) {
|
|
159
|
+
opts.onEvent?.({ type: "log", level: "warn", text: `report.html not written: ${(err as Error).message}` })
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (opts.config.llm.provider === "mock") {
|
|
165
|
+
opts.onEvent?.({
|
|
166
|
+
type: "log",
|
|
167
|
+
level: "info",
|
|
168
|
+
text: "Mock profile: synthetic page, no browser or model. Press Tab for a live profile.",
|
|
169
|
+
})
|
|
170
|
+
const out = await runEngine({
|
|
171
|
+
python: process.execPath,
|
|
172
|
+
command: [process.execPath, MOCK_ENGINE],
|
|
173
|
+
request: { goal, outDir: dir, llm: opts.config.llm, startUrl, headed: false, maxSteps, allowedHosts: allowed },
|
|
174
|
+
signal: opts.signal,
|
|
175
|
+
onEvent: opts.onEvent,
|
|
176
|
+
})
|
|
177
|
+
return withReport(out)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const missing = missingKey(opts.config.llm)
|
|
181
|
+
if (missing) throw new Error(missing)
|
|
182
|
+
const log = (text: string) => opts.onEvent?.({ type: "log", level: "info", text })
|
|
183
|
+
const python = await ensureEngine(log)
|
|
184
|
+
// The browser agent's added instructions: forgeqe.yaml's, prompts/browser-extra.md, and the product knowledge that matches the goal.
|
|
185
|
+
const knowledge = opts.config.knowledge && opts.config.knowledge.browserMaxChars > 0 ? knowledgeBlock(relevantKnowledge(readKnowledge(opts.config.knowledge.dir), goal, opts.config.knowledge.browserMaxChars, 2)) : ""
|
|
186
|
+
const extend = [opts.config.prompts.guiderails, opts.config.prompts.stepInstruction, prompt("browser-extra"), knowledge]
|
|
187
|
+
.map((s) => s.trim())
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.join("\n\n")
|
|
190
|
+
const env: Record<string, string> = {}
|
|
191
|
+
const tuning: Record<string, string> = {
|
|
192
|
+
BROWSER_USE_TIMEOUT_SECONDS: String(opts.config.browser.timeoutSeconds),
|
|
193
|
+
BROWSER_USE_LOW_VISION_DETAIL: opts.config.browser.visionDetail === "high" ? "false" : "true",
|
|
194
|
+
BROWSER_USE_USE_JUDGE: opts.config.browser.judge ? "true" : "false",
|
|
195
|
+
}
|
|
196
|
+
for (const [k, v] of Object.entries(tuning)) if (!process.env[k]) env[k] = v
|
|
197
|
+
const mem = opts.config.memory
|
|
198
|
+
const memory = mem.enabled && opts.memory !== false ? { dir: mem.dir, replay: opts.replay || mem.replay } : undefined
|
|
199
|
+
const resume = opts.resumeFrom ? loadResume(opts.config.export.outDir, opts.resumeFrom) : undefined
|
|
200
|
+
if (resume && !memory) throw new Error("resume_from replays remembered steps, so it needs page memory; it is off for this run.")
|
|
201
|
+
const q = opts.config.quality || DEFAULT_QUALITY
|
|
202
|
+
const axeScript = opts.config.report.accessibility && q.engine === "axe" ? axeCoreScript() : null
|
|
203
|
+
if (opts.config.report.accessibility && q.engine === "axe" && !axeScript) opts.onEvent?.({ type: "log", level: "warn", text: "axe-core is not installed with ForgeQE: the built-in accessibility checks run instead." })
|
|
204
|
+
const axe = axeScript ? { script: axeScript, tags: axeTags(q) } : undefined
|
|
205
|
+
const skills = browserSkills(opts.config.skills, goal, opts.skills, opts.repo)
|
|
206
|
+
if (skills.unknown.length) {
|
|
207
|
+
opts.onEvent?.({ type: "log", level: "warn", text: `No browser skill named ${skills.unknown.join(", ")}; forgeqe skills list shows them.` })
|
|
208
|
+
}
|
|
209
|
+
const out = await runEngine({
|
|
210
|
+
python,
|
|
211
|
+
request: {
|
|
212
|
+
goal,
|
|
213
|
+
outDir: dir,
|
|
214
|
+
llm: opts.config.llm,
|
|
215
|
+
startUrl,
|
|
216
|
+
headed: Boolean(opts.headed) || !opts.config.browser.headless,
|
|
217
|
+
maxSteps,
|
|
218
|
+
allowedHosts: allowed,
|
|
219
|
+
extendSystemMessage: extend || undefined,
|
|
220
|
+
memory,
|
|
221
|
+
env,
|
|
222
|
+
logins: allLogins(opts.config),
|
|
223
|
+
caseSteps: opts.caseSteps,
|
|
224
|
+
screenshots: opts.config.report.screenshots,
|
|
225
|
+
accessibility: opts.config.report.accessibility,
|
|
226
|
+
network: opts.config.api?.capture !== false,
|
|
227
|
+
...(axe ? { axe } : {}),
|
|
228
|
+
vitals: true,
|
|
229
|
+
prompts: enginePrompts(),
|
|
230
|
+
...(engineVisual(q.visual) ? { visual: engineVisual(q.visual) } : {}),
|
|
231
|
+
overlay: opts.config.browser.overlay,
|
|
232
|
+
resume,
|
|
233
|
+
skills: skills.skills,
|
|
234
|
+
skillsPick: skills.skillsPick,
|
|
235
|
+
skillsMaxChars: skills.skillsMaxChars,
|
|
236
|
+
},
|
|
237
|
+
signal: opts.signal,
|
|
238
|
+
onEvent: opts.onEvent,
|
|
239
|
+
deadlineMs: engineDeadlineMs(opts.config.browser.timeoutSeconds),
|
|
240
|
+
})
|
|
241
|
+
rememberChoice(goal, skills.skillsPick, out.result.skills)
|
|
242
|
+
return withReport(out)
|
|
243
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import { forgeqeHome } from "./tools.ts"
|
|
7
|
+
|
|
8
|
+
import type { AppConfig } from "./config.ts"
|
|
9
|
+
import { redactSecrets } from "./sessionLogins.ts"
|
|
10
|
+
import { packageVersion } from "./tui/theme.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One log file per ForgeQE session: what the user asked, ForgeQE's agent's answers and tool calls, every
|
|
14
|
+
* browser engine event and engine log line, and errors. Secrets are replaced before anything is written.
|
|
15
|
+
* `--debug` (or FORGEQE_DEBUG=1) also turns on the engine library's debug logging and keeps long values whole.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const KEEP_LOGS = 30
|
|
19
|
+
const CLIP = 8_000
|
|
20
|
+
const CLIP_DEBUG = 200_000
|
|
21
|
+
const SECRET_ENV = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|PAT|CREDENTIALS?)(_|$)/i
|
|
22
|
+
const SECRET_FIELD = /^(api_?key|password|secret|token|authorization|values|client_secret|totp_secret|totp)$/i
|
|
23
|
+
const RUN_FILES = ["run.json", "steps.json", "trace.md", "resume.json", "workflow.yaml", "handoff.md", "accessibility.json", "network.json", "performance.json", "quality.json", "failure.png", "junit.xml", "heal.md"]
|
|
24
|
+
|
|
25
|
+
let file: string | null = null
|
|
26
|
+
let debug = false
|
|
27
|
+
const extraSecrets = new Map<string, string>()
|
|
28
|
+
|
|
29
|
+
export function sessionLogFile(): string | null {
|
|
30
|
+
return file
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function debugLogging(): boolean {
|
|
34
|
+
return debug
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function logsDirectory(cfg: AppConfig): string {
|
|
38
|
+
return cfg.logsDir || path.join(forgeqeHome(), "logs")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stamp(d = new Date()): string {
|
|
42
|
+
const p = (n: number) => String(n).padStart(2, "0")
|
|
43
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Secret values from the environment and llms.json, longest first, with the name that replaces each. */
|
|
47
|
+
function secretValues(): [string, string][] {
|
|
48
|
+
const out: [string, string][] = [...extraSecrets.entries()]
|
|
49
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
50
|
+
if (value && value.length >= 8 && SECRET_ENV.test(name)) out.push([value, `$${name}`])
|
|
51
|
+
}
|
|
52
|
+
return out.sort((a, b) => b[0].length - a[0].length)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Text with every known secret replaced: remembered login values, secret environment variables, inline API keys, tokens. */
|
|
56
|
+
export function redactForLog(text: string): string {
|
|
57
|
+
let out = redactSecrets(text)
|
|
58
|
+
for (const [value, label] of secretValues()) if (out.includes(value)) out = out.split(value).join(label)
|
|
59
|
+
// Bearer tokens (Microsoft Entra ID and other JWTs) are never in the environment, so match their shape instead.
|
|
60
|
+
return out.replace(JWT, "[token]")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
|
|
64
|
+
|
|
65
|
+
function serialize(data: unknown): string {
|
|
66
|
+
const limit = debug ? CLIP_DEBUG : CLIP
|
|
67
|
+
const json = JSON.stringify(data, (key, value) => {
|
|
68
|
+
if (key && SECRET_FIELD.test(key) && value !== null && value !== undefined && value !== "") return "[hidden]"
|
|
69
|
+
if (typeof value === "string" && value.length > limit) return `${value.slice(0, limit)}…(${value.length - limit} more characters)`
|
|
70
|
+
return value
|
|
71
|
+
})
|
|
72
|
+
return redactForLog(json ?? String(data))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function prune(dir: string): void {
|
|
76
|
+
const logs = readdirSync(dir)
|
|
77
|
+
.filter((f) => f.endsWith(".log"))
|
|
78
|
+
.map((f) => ({ f, t: statSync(path.join(dir, f)).mtimeMs }))
|
|
79
|
+
.sort((a, b) => b.t - a.t)
|
|
80
|
+
for (const { f } of logs.slice(KEEP_LOGS)) rmSync(path.join(dir, f), { force: true })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Opens this session's log (once per process) and writes what ForgeQE is running with. */
|
|
84
|
+
export function startSessionLog(cfg: AppConfig, opts: { command: string; debug?: boolean; argv?: string[] }): string | null {
|
|
85
|
+
if (file) return file
|
|
86
|
+
debug = Boolean(opts.debug)
|
|
87
|
+
for (const p of Object.values(cfg.profiles || {})) if (p.apiKey && p.apiKey.length >= 6) extraSecrets.set(p.apiKey, "[api key from llms.json]")
|
|
88
|
+
if (cfg.llm.apiKey && cfg.llm.apiKey.length >= 6) extraSecrets.set(cfg.llm.apiKey, "[api key from llms.json]")
|
|
89
|
+
try {
|
|
90
|
+
const dir = logsDirectory(cfg)
|
|
91
|
+
mkdirSync(dir, { recursive: true })
|
|
92
|
+
const ignore = path.join(dir, ".gitignore")
|
|
93
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "# ForgeQE session logs\n*\n")
|
|
94
|
+
file = path.join(dir, `${stamp()}-${opts.command}.log`)
|
|
95
|
+
writeFileSync(file, "")
|
|
96
|
+
prune(dir)
|
|
97
|
+
} catch {
|
|
98
|
+
file = null
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
logEvent("session", {
|
|
102
|
+
forgeqe: packageVersion(),
|
|
103
|
+
command: opts.command,
|
|
104
|
+
argv: opts.argv,
|
|
105
|
+
debug,
|
|
106
|
+
os: `${os.type()} ${os.release()} ${os.arch()}`,
|
|
107
|
+
bun: process.versions.bun,
|
|
108
|
+
node: process.versions.node,
|
|
109
|
+
cwd: process.cwd(),
|
|
110
|
+
config: cfg.configPath || null,
|
|
111
|
+
profile: { name: cfg.activeProfile, provider: cfg.llm.provider, model: cfg.llm.model, code_model: cfg.code?.model },
|
|
112
|
+
browser: cfg.browser,
|
|
113
|
+
memory: { enabled: cfg.memory.enabled, replay: cfg.memory.replay, dir: cfg.memory.dir },
|
|
114
|
+
reports: cfg.export.outDir,
|
|
115
|
+
logins: cfg.logins.map((l) => ({ name: l.name, domains: l.domains })),
|
|
116
|
+
})
|
|
117
|
+
return file
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One line in the session log; nothing happens when no log is open. */
|
|
121
|
+
export function logEvent(kind: string, data?: unknown): void {
|
|
122
|
+
if (!file) return
|
|
123
|
+
try {
|
|
124
|
+
appendFileSync(file, `${new Date().toISOString()} ${kind}${data === undefined ? "" : ` ${serialize(data)}`}\n`)
|
|
125
|
+
} catch {
|
|
126
|
+
// logging never breaks a run
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Closes the log; the next startSessionLog opens a new one. For tests. */
|
|
131
|
+
export function endSessionLog(): void {
|
|
132
|
+
file = null
|
|
133
|
+
debug = false
|
|
134
|
+
extraSecrets.clear()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function recentLogs(cfg: AppConfig, limit = 10): { file: string; size: number; modified: Date }[] {
|
|
138
|
+
const dir = logsDirectory(cfg)
|
|
139
|
+
if (!existsSync(dir)) return []
|
|
140
|
+
return readdirSync(dir)
|
|
141
|
+
.filter((f) => f.endsWith(".log"))
|
|
142
|
+
.map((f) => {
|
|
143
|
+
const st = statSync(path.join(dir, f))
|
|
144
|
+
return { file: path.join(dir, f), size: st.size, modified: st.mtime }
|
|
145
|
+
})
|
|
146
|
+
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
|
|
147
|
+
.slice(0, limit)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Run folders a session log mentions (browser runs and suite cases). */
|
|
151
|
+
export function runDirsInLog(text: string): string[] {
|
|
152
|
+
const dirs = new Set<string>()
|
|
153
|
+
for (const m of text.matchAll(/"(?:out_dir|runDir|run_dir)":"((?:[^"\\]|\\.)+)"/g)) {
|
|
154
|
+
try {
|
|
155
|
+
dirs.add(JSON.parse(`"${m[1]}"`))
|
|
156
|
+
} catch {
|
|
157
|
+
// not a path
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...dirs].filter((d) => existsSync(path.join(d, "run.json")))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function copyRedacted(from: string, to: string): void {
|
|
164
|
+
mkdirSync(path.dirname(to), { recursive: true })
|
|
165
|
+
if (/\.(png|jpe?g|gif|webp|zip|gz)$/i.test(from)) copyFileSync(from, to)
|
|
166
|
+
else writeFileSync(to, redactForLog(readFileSync(from, "utf8")))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Everything needed to debug a session in one archive: the session log, the run folders it mentions (without
|
|
171
|
+
* step screenshots unless `shots`), the settings with secrets removed, and the versions in use.
|
|
172
|
+
*/
|
|
173
|
+
export function bundleLogs(cfg: AppConfig, opts: { log?: string; shots?: boolean } = {}): { archive: string; log: string; runs: number } {
|
|
174
|
+
const log = opts.log || file || recentLogs(cfg, 1)[0]?.file
|
|
175
|
+
if (!log || !existsSync(log)) throw new Error(`No session log yet in ${logsDirectory(cfg)}. Run forgeqe once, then try again.`)
|
|
176
|
+
const dir = logsDirectory(cfg)
|
|
177
|
+
const name = `forgeqe-debug-${stamp()}`
|
|
178
|
+
const staging = path.join(dir, name)
|
|
179
|
+
mkdirSync(staging, { recursive: true })
|
|
180
|
+
copyRedacted(log, path.join(staging, path.basename(log)))
|
|
181
|
+
|
|
182
|
+
const text = readFileSync(log, "utf8")
|
|
183
|
+
const runs = runDirsInLog(text)
|
|
184
|
+
for (const run of runs) {
|
|
185
|
+
const target = path.join(staging, "runs", path.basename(path.dirname(run)) + "-" + path.basename(run))
|
|
186
|
+
const files = opts.shots ? walk(run) : RUN_FILES.map((f) => path.join(run, f)).filter((f) => existsSync(f))
|
|
187
|
+
for (const f of files) copyRedacted(f, path.join(target, path.relative(run, f)))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (cfg.configPath && existsSync(cfg.configPath)) copyRedacted(cfg.configPath, path.join(staging, "forgeqe.yaml"))
|
|
191
|
+
const profiles = Object.fromEntries(Object.entries(cfg.profiles || {}).map(([k, p]) => [k, { ...p, apiKey: p.apiKey ? "[hidden]" : undefined }]))
|
|
192
|
+
writeFileSync(path.join(staging, "profiles.json"), redactForLog(JSON.stringify(profiles, null, 2)))
|
|
193
|
+
writeFileSync(
|
|
194
|
+
path.join(staging, "environment.txt"),
|
|
195
|
+
[
|
|
196
|
+
`forgeqe ${packageVersion()}`,
|
|
197
|
+
`os ${os.type()} ${os.release()} ${os.arch()}`,
|
|
198
|
+
`bun ${process.versions.bun || "-"} node ${process.versions.node}`,
|
|
199
|
+
`config ${cfg.configPath || "(none)"}`,
|
|
200
|
+
`profile ${cfg.activeProfile} ${cfg.llm.provider}/${cfg.llm.model}`,
|
|
201
|
+
`runs ${runs.length}`,
|
|
202
|
+
].join("\n") + "\n",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const archive = `${staging}.tar.gz`
|
|
206
|
+
const tar = spawnSync("tar", ["-czf", archive, "-C", dir, name], { stdio: "ignore" })
|
|
207
|
+
if (tar.status === 0 && existsSync(archive)) {
|
|
208
|
+
rmSync(staging, { recursive: true, force: true })
|
|
209
|
+
return { archive, log, runs: runs.length }
|
|
210
|
+
}
|
|
211
|
+
return { archive: staging, log, runs: runs.length }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function walk(dir: string): string[] {
|
|
215
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
|
|
216
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { rmSync } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { forgeqeHome } from "./tools.ts"
|
|
6
|
+
|
|
7
|
+
import { loginDomain, type AppConfig, type LoginConfig } from "./config.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Logins remembered for this ForgeQE process from credentials the user gave in a message or a test case.
|
|
11
|
+
* The values stay in memory: they reach the engine over its stdin and become placeholders the browser model never
|
|
12
|
+
* sees. The signed-in browser session is saved like any saved login's, so later runs start signed in.
|
|
13
|
+
*/
|
|
14
|
+
export type SessionLogin = LoginConfig & { values: { username?: string; password?: string; totp?: string } }
|
|
15
|
+
|
|
16
|
+
const store = new Map<string, SessionLogin>()
|
|
17
|
+
|
|
18
|
+
const envName = (name: string, field: string) => `FORGEQE_${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_${field}`
|
|
19
|
+
|
|
20
|
+
/** An engine domain pattern (scheme and host) from a URL, a host, or a wildcard host such as *.okta.com. */
|
|
21
|
+
function domainOf(raw: string): string {
|
|
22
|
+
const s = raw.trim()
|
|
23
|
+
const withScheme = /^[a-z]+:\/\//i.test(s) ? s : `https://${s}`
|
|
24
|
+
if (!withScheme.includes("*")) return loginDomain(withScheme)
|
|
25
|
+
const m = /^([a-z]+:\/\/)([^/:?#]+)/i.exec(withScheme)
|
|
26
|
+
if (!m) throw new Error(`Not a site: ${raw}`)
|
|
27
|
+
return `${m[1]}${m[2]}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function rememberLogin(
|
|
31
|
+
cfg: AppConfig,
|
|
32
|
+
input: { name: string; url?: string; domains?: string[]; username?: string; password?: string; totp?: string },
|
|
33
|
+
): SessionLogin {
|
|
34
|
+
const name = input.name.trim().replace(/[^A-Za-z0-9_-]/g, "_")
|
|
35
|
+
if (!name) throw new Error("A login needs a name.")
|
|
36
|
+
if (!input.username && !input.password && !input.totp) throw new Error("Give the username, the password, or both.")
|
|
37
|
+
const domains = [...new Set([...(input.url ? [input.url] : []), ...(input.domains || [])].filter(Boolean).map(domainOf))]
|
|
38
|
+
if (!domains.length) throw new Error("Give the site's url, and the sign-in domains (such as login.microsoftonline.com) when sign-in happens elsewhere.")
|
|
39
|
+
const dir = cfg.sessionsDir || path.join(forgeqeHome(), "sessions")
|
|
40
|
+
const values = { ...(input.username ? { username: input.username } : {}), ...(input.password ? { password: input.password } : {}), ...(input.totp ? { totp: input.totp } : {}) }
|
|
41
|
+
const login: SessionLogin = {
|
|
42
|
+
name,
|
|
43
|
+
domains,
|
|
44
|
+
...(input.url ? { url: input.url } : {}),
|
|
45
|
+
...(values.username ? { usernameEnv: envName(name, "USERNAME") } : {}),
|
|
46
|
+
...(values.password ? { passwordEnv: envName(name, "PASSWORD") } : {}),
|
|
47
|
+
...(values.totp ? { totpEnv: envName(name, "TOTP") } : {}),
|
|
48
|
+
sessionFile: path.join(dir, `${name}.json`),
|
|
49
|
+
values,
|
|
50
|
+
}
|
|
51
|
+
store.set(name, login)
|
|
52
|
+
return login
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sessionLogins(): SessionLogin[] {
|
|
56
|
+
return [...store.values()]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Forget remembered credentials and their saved sessions: one login by name, or all of them when no name is given. */
|
|
60
|
+
export function forgetSessionLogins(name?: string): string[] {
|
|
61
|
+
const names = name === undefined ? [...store.keys()] : store.has(name) ? [name] : []
|
|
62
|
+
for (const n of names) {
|
|
63
|
+
const file = store.get(n)?.sessionFile
|
|
64
|
+
if (file) rmSync(file, { force: true })
|
|
65
|
+
store.delete(n)
|
|
66
|
+
}
|
|
67
|
+
return names
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** forgeqe.yaml's logins plus this session's; a remembered login replaces a configured one with the same name. */
|
|
71
|
+
export function allLogins(cfg: AppConfig): LoginConfig[] {
|
|
72
|
+
return [...cfg.logins.filter((l) => !store.has(l.name)), ...store.values()]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The remembered values under their env names, for ForgeQE's own runs of tests that read them from the environment. */
|
|
76
|
+
export function sessionLoginEnv(): Record<string, string> {
|
|
77
|
+
const out: Record<string, string> = {}
|
|
78
|
+
for (const l of store.values()) {
|
|
79
|
+
if (l.usernameEnv && l.values.username) out[l.usernameEnv] = l.values.username
|
|
80
|
+
if (l.passwordEnv && l.values.password) out[l.passwordEnv] = l.values.password
|
|
81
|
+
if (l.totpEnv && l.values.totp) out[l.totpEnv] = l.values.totp
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every remembered value in `text` replaced by a mention of its login, so goals and reports never carry them. */
|
|
87
|
+
export function redactSecrets(text: string): string {
|
|
88
|
+
let out = text
|
|
89
|
+
const pairs = [...store.values()].flatMap((l) =>
|
|
90
|
+
(Object.entries(l.values) as [string, string][]).filter(([field, v]) => field !== "username" && v.length >= 4).map(([field, v]) => [v, `(the ${field} of saved login ${l.name})`] as const),
|
|
91
|
+
)
|
|
92
|
+
for (const [value, label] of pairs.sort((a, b) => b[0].length - a[0].length)) out = out.split(value).join(label)
|
|
93
|
+
return out
|
|
94
|
+
}
|