@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.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. 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
+ }