@capagents/forgeqe 1.3.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/llm.py +54 -4
  37. package/py/forgeqe_engine/model_params.py +169 -0
  38. package/py/forgeqe_mobile/__init__.py +1 -0
  39. package/py/forgeqe_mobile/__main__.py +3 -0
  40. package/py/forgeqe_mobile/appium_driver.py +342 -0
  41. package/py/forgeqe_mobile/apps.py +84 -0
  42. package/py/forgeqe_mobile/cli.py +70 -0
  43. package/py/forgeqe_mobile/explore.py +203 -0
  44. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  45. package/py/forgeqe_mobile/maestro.py +321 -0
  46. package/py/forgeqe_mobile/service.py +332 -0
  47. package/skills/testcontainers/SKILL.md +42 -0
  48. package/src/agents.ts +11 -2
  49. package/src/api/plan.ts +2 -0
  50. package/src/api/write.ts +2 -0
  51. package/src/auth/copilot.ts +130 -0
  52. package/src/auth/signin.ts +54 -1
  53. package/src/brain/index.ts +26 -4
  54. package/src/capabilities/guide.ts +2 -0
  55. package/src/capabilities/registry.ts +110 -2
  56. package/src/capabilities/status.ts +15 -0
  57. package/src/cases/rca.ts +2 -0
  58. package/src/childEnv.ts +4 -1
  59. package/src/cli/args.ts +3 -2
  60. package/src/cli/help.ts +24 -3
  61. package/src/cli/index.ts +47 -3
  62. package/src/code/find.ts +26 -0
  63. package/src/code/loop.ts +4 -1
  64. package/src/code/prompt.ts +2 -1
  65. package/src/code/prreview.ts +2 -0
  66. package/src/code/review.ts +6 -2
  67. package/src/code/runtime.ts +34 -3
  68. package/src/commands.ts +6 -1
  69. package/src/config.ts +159 -3
  70. package/src/containers/command.ts +68 -0
  71. package/src/containers/compose.ts +173 -0
  72. package/src/containers/config.ts +283 -0
  73. package/src/containers/mock.ts +156 -0
  74. package/src/containers/run.ts +362 -0
  75. package/src/containers/tests.ts +80 -0
  76. package/src/context.ts +33 -6
  77. package/src/copilot.ts +155 -0
  78. package/src/desktop/bridge.ts +229 -0
  79. package/src/desktop/command.ts +350 -0
  80. package/src/desktop/edit.ts +217 -0
  81. package/src/desktop/junit.ts +35 -0
  82. package/src/desktop/report.ts +155 -0
  83. package/src/desktop/run.ts +252 -0
  84. package/src/desktop/setup.ts +134 -0
  85. package/src/engine/bridge.ts +12 -5
  86. package/src/engine/setup.ts +3 -3
  87. package/src/environments.ts +566 -0
  88. package/src/evals/domain.ts +1 -0
  89. package/src/explore/charters.ts +2 -0
  90. package/src/guardrails.ts +145 -0
  91. package/src/heal/loop.ts +4 -1
  92. package/src/heal/prompt.ts +2 -1
  93. package/src/init.ts +5 -2
  94. package/src/llm.ts +23 -2
  95. package/src/mcp/attachtools.ts +16 -4
  96. package/src/mcp/captools.ts +27 -0
  97. package/src/mcp/containertools.ts +212 -0
  98. package/src/mcp/desktoptools.ts +334 -0
  99. package/src/mcp/mobiletools.ts +132 -0
  100. package/src/mcp/reporttools.ts +17 -2
  101. package/src/mcp/repotests.ts +22 -3
  102. package/src/mcp/routing.ts +11 -5
  103. package/src/mcp/scope.ts +210 -0
  104. package/src/mcp/server.ts +80 -5
  105. package/src/mcp/tools.ts +38 -1
  106. package/src/mobile/command.ts +245 -0
  107. package/src/mobile/run.ts +226 -0
  108. package/src/pack/pipeline.ts +44 -2
  109. package/src/refine.ts +3 -0
  110. package/src/report/html.ts +3 -0
  111. package/src/report/template.html +48 -48
  112. package/src/run.ts +7 -3
  113. package/src/tokenomics/ledger.ts +3 -1
  114. package/src/tui/App.tsx +23 -0
  115. package/src/usage.ts +1 -1
@@ -0,0 +1,245 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { boolFlag, configOpts, strFlag, type Flags } from "../cli/args.ts"
6
+ import { terminalAsk } from "../cli/terminal.ts"
7
+ import { loadConfig, type AppConfig } from "../config.ts"
8
+ import type { DesktopEvent } from "../desktop/bridge.ts"
9
+ import { abortOnSigint, color, offerHeals, printer, splitReplayArgs, workflowFiles } from "../desktop/command.ts"
10
+ import { junitXml, type JunitCase } from "../desktop/junit.ts"
11
+ import { parseCsv, visionProblem } from "../desktop/run.ts"
12
+ import { engineEnv, ensureEngine } from "../engine/setup.ts"
13
+ import { withEnvironment } from "../environments.ts"
14
+ import { checkMobileApp, checkMobileConfig, exploreMobile, hasModel, maestroFlowFiles, mobileDoctor, mobileOutDir, replayMobile, runMaestroFlows, uploadToTestmu } from "./run.ts"
15
+
16
+ /** `forgeqe mobile`: Android and iOS apps through Appium or Maestro (docs/MOBILE.md). */
17
+
18
+ export const MOBILE_USAGE = `Usage:
19
+ forgeqe mobile apps [--env NAME] the mobile apps of the environment (env.yaml) and mobile:
20
+ forgeqe mobile doctor whether the Appium server answers and maestro is installed
21
+ forgeqe mobile explore "<goal>" --app NAME [--name TEXT] [--out DIR] [--yes]
22
+ the model does the goal in the app through Appium; what it did is
23
+ saved as workflow.yaml, with checks of the outcome
24
+ forgeqe mobile replay <workflow.yaml|folder> ... [name=value ...] [--driver appium|maestro] [--data rows.csv]
25
+ [--no-heal] [--accept-heals] [--update-baselines] [--junit results.xml] [--out DIR]
26
+ replay mobile workflows with no model (Appium heals a moved control)
27
+ forgeqe mobile maestro <flow.yaml|folder> ... [--junit results.xml] [--out DIR]
28
+ run your team's Maestro flows, after checking them against the allowlist
29
+ forgeqe mobile upload <app.apk|.ipa> [--name TEXT] [--virtual]
30
+ upload a build to TestMu AI; prints the lt:// id for the app's \`app\`
31
+ forgeqe mobile flow <workflow.yaml> print a mobile workflow as a Maestro flow
32
+ forgeqe mobile validate <workflow.yaml> check a workflow without running it`
33
+
34
+ function config(flags: Flags): AppConfig {
35
+ const cfg = loadConfig(configOpts(flags))
36
+ checkMobileConfig(cfg)
37
+ return cfg
38
+ }
39
+
40
+ function driverFlag(flags: Flags): "appium" | "maestro" | undefined {
41
+ const d = strFlag(flags, "driver")
42
+ if (d === undefined) return undefined
43
+ if (d !== "appium" && d !== "maestro") throw new Error("--driver must be appium or maestro")
44
+ return d
45
+ }
46
+
47
+ async function cmdReplay(args: string[], flags: Flags): Promise<number> {
48
+ const { paths, variables } = splitReplayArgs(args)
49
+ if (!paths.length) throw new Error(MOBILE_USAGE)
50
+ const workflows = workflowFiles(paths, process.cwd(), "mobile")
51
+ const dataFile = strFlag(flags, "data")
52
+ const rows = dataFile ? parseCsv(readFileSync(path.resolve(dataFile), "utf8")) : [{}]
53
+ if (!rows.length) throw new Error(`${dataFile} has a header but no rows`)
54
+ const cfg = config(flags)
55
+ const driver = driverFlag(flags) || cfg.mobile.driver
56
+ const heal = cfg.mobile.heal && !boolFlag(flags, "no-heal")
57
+ if (driver === "appium" && heal && !hasModel(cfg)) console.error("Note: no model in llms.json, so moved controls will not be healed.")
58
+ const outDir = mobileOutDir(cfg, "replay", strFlag(flags, "out"))
59
+ const { signal, done } = abortOnSigint()
60
+ const cases: JunitCase[] = []
61
+ let failed = 0
62
+ try {
63
+ for (const workflow of workflows) {
64
+ const label = path.relative(process.cwd(), workflow) || workflow
65
+ if (workflows.length > 1) console.log(`\n${label}`)
66
+ let offered = false
67
+ for (let i = 0; i < rows.length; i++) {
68
+ if (signal.aborted) break
69
+ const dir = workflows.length * rows.length > 1 ? mobileOutDir(cfg, "replay", path.join(outDir, `${path.basename(workflow).replace(/\.ya?ml$/i, "")}${rows.length > 1 ? `-row-${String(i + 1).padStart(3, "0")}` : ""}`)) : outDir
70
+ const started = Date.now()
71
+ const run = await replayMobile({ cfg, workflow, variables: { ...variables, ...rows[i] }, heal, driver, updateBaselines: boolFlag(flags, "update-baselines"), outDir: dir, signal, onEvent: printer(color()), log: (t) => console.error(t) })
72
+ const bad = (run.result.steps || []).find((s) => !s.ok)
73
+ const error = run.result.error || (bad ? `step ${bad.index} (${bad.op}): ${bad.error || "failed"}` : run.result.ok ? "" : "failed")
74
+ if (run.result.error) console.error(run.result.error)
75
+ console.log(`report ${run.report}`)
76
+ if (!run.result.ok) failed++
77
+ cases.push({ suite: label, name: `${run.result.name || path.basename(workflow)}${rows.length > 1 ? ` (row ${i + 1})` : ""}`, ok: run.result.ok, seconds: (Date.now() - started) / 1000, ...(error ? { error } : {}), report: run.report })
78
+ if (run.result.heals?.length && !offered) {
79
+ offered = true
80
+ await offerHeals(workflow, dir, run.result.heals, flags, signal)
81
+ }
82
+ }
83
+ }
84
+ writeJunit(flags, cases)
85
+ const planned = workflows.length * rows.length
86
+ if (planned > 1) console.log(`\n${cases.length - failed} of ${planned} runs passed`)
87
+ return cases.length === planned && failed === 0 ? 0 : 1
88
+ } finally {
89
+ done()
90
+ }
91
+ }
92
+
93
+ function writeJunit(flags: Flags, cases: JunitCase[]): void {
94
+ const junit = strFlag(flags, "junit")
95
+ if (!junit) return
96
+ const file = path.resolve(junit)
97
+ mkdirSync(path.dirname(file), { recursive: true })
98
+ writeFileSync(file, junitXml(cases, "forgeqe mobile"))
99
+ console.log(`junit ${file}`)
100
+ }
101
+
102
+ async function cmdMaestro(args: string[], flags: Flags): Promise<number> {
103
+ if (!args.length) throw new Error(MOBILE_USAGE)
104
+ const cfg = config(flags)
105
+ const root = process.cwd()
106
+ const flows = maestroFlowFiles(args, root)
107
+ const outDir = mobileOutDir(cfg, "maestro", strFlag(flags, "out"))
108
+ const { signal, done } = abortOnSigint()
109
+ try {
110
+ const started = Date.now()
111
+ const { result, report } = await runMaestroFlows({ cfg, flows, root, outDir, signal, onEvent: printer(color()), log: (t) => console.error(t) })
112
+ for (const f of result.flows || []) console.log(`${f.ok ? "✓" : "✗"} ${f.name}${f.error ? `: ${f.error}` : ""}`)
113
+ if (result.error) console.error(result.error)
114
+ console.log(`report ${report}`)
115
+ const seconds = (Date.now() - started) / 1000
116
+ writeJunit(flags, (result.flows || []).map((f) => ({ suite: "maestro", name: f.name, ok: f.ok, seconds: seconds / Math.max(1, (result.flows || []).length), ...(f.error ? { error: f.error } : {}), report })))
117
+ return result.ok ? 0 : 1
118
+ } finally {
119
+ done()
120
+ }
121
+ }
122
+
123
+ async function cmdExplore(args: string[], flags: Flags): Promise<number> {
124
+ const goal = args.join(" ").trim()
125
+ const app = strFlag(flags, "app")
126
+ if (!goal || !app) throw new Error(MOBILE_USAGE)
127
+ const cfg = config(flags)
128
+ checkMobileApp(cfg, app)
129
+ if (!hasModel(cfg)) throw new Error("No model is set up in llms.json. Run: forgeqe init, then forgeqe models.")
130
+ const problem = visionProblem(cfg.activeProfile, cfg.llm)
131
+ if (problem) throw new Error(problem.replace("the desktop agent", "the mobile agent"))
132
+ const { signal, done } = abortOnSigint()
133
+ try {
134
+ if (!boolFlag(flags, "yes")) {
135
+ const ask = terminalAsk(signal)
136
+ if (!ask) throw new Error("explore acts in the app on a real device or emulator; pass --yes to run it without a terminal to confirm in.")
137
+ const answer = await ask({
138
+ title: `The mobile agent will operate ${app} to: ${goal}`,
139
+ detail: `It acts in the app through Appium (${cfg.mobile.appium.url}), and its model is ${cfg.activeProfile}. Use a test device and a test account.`,
140
+ options: [
141
+ { id: "no", label: "Cancel" },
142
+ { id: "yes", label: "Start" },
143
+ ],
144
+ })
145
+ if (answer !== "yes") return 1
146
+ }
147
+ const outDir = mobileOutDir(cfg, "explore", strFlag(flags, "out"))
148
+ const onEvent = (ev: DesktopEvent) => {
149
+ if (ev.type === "step") console.log(`● ${String(ev.index).padStart(3)} ${ev.op}${ev.target ? ` ${String(ev.target.name || ev.target.automation_id || "")}` : ""}`)
150
+ else printer(color())(ev)
151
+ }
152
+ const { result, report } = await exploreMobile({ cfg, goal, app, name: strFlag(flags, "name"), outDir, signal, onEvent, log: (t) => console.error(t) })
153
+ if (result.error) console.error(result.error)
154
+ console.log(`\nreport ${report}`)
155
+ if (result.workflow) {
156
+ console.log(`workflow ${result.workflow} (${result.recorded} steps, ${result.checks ?? 0} checks)`)
157
+ console.log(`replay forgeqe mobile replay "${result.workflow}" (or --driver maestro)`)
158
+ }
159
+ return result.ok ? 0 : 1
160
+ } finally {
161
+ done()
162
+ }
163
+ }
164
+
165
+ async function cmdFlow(args: string[], flags: Flags): Promise<number> {
166
+ const file = args[0]
167
+ if (!file) throw new Error(MOBILE_USAGE)
168
+ const cfg = config(flags)
169
+ const app = String((readFileSync(path.resolve(file), "utf8").match(/^app:\s*["']?([A-Za-z0-9_.-]+)/m) || [])[1] || "")
170
+ checkMobileApp(cfg, app)
171
+ const raw = cfg.mobile.apps[app] as { app_id?: string }
172
+ const python = await ensureEngine((t) => console.error(t))
173
+ const r = spawnSync(python, ["-m", "forgeqe_mobile", "flow", path.resolve(file), "--app-id", String(raw.app_id || "")], { env: engineEnv(), encoding: "utf8" })
174
+ if (r.error) throw r.error
175
+ process.stdout.write(r.stdout || "")
176
+ process.stderr.write(r.stderr || "")
177
+ return r.status ?? 1
178
+ }
179
+
180
+ async function cmdValidate(args: string[]): Promise<number> {
181
+ const file = args[0]
182
+ if (!file) throw new Error(MOBILE_USAGE)
183
+ const python = await ensureEngine((t) => console.error(t))
184
+ const r = spawnSync(python, ["-m", "forgeqe_desktop", "validate", path.resolve(file)], { env: engineEnv(), encoding: "utf8" })
185
+ if (r.error) throw r.error
186
+ process.stdout.write(r.stdout || "")
187
+ process.stderr.write(r.stderr || "")
188
+ return r.status ?? 1
189
+ }
190
+
191
+ async function cmdDoctor(flags: Flags): Promise<number> {
192
+ const cfg = config(flags)
193
+ const d = await mobileDoctor(cfg, (t) => console.error(t))
194
+ if (d.error) console.log(`engine not ready (${d.error})`)
195
+ console.log(`appium ${d.appium ? `answers at ${d.appiumUrl}${d.appiumVersion ? ` (Appium ${d.appiumVersion})` : ""}` : `no answer at ${d.appiumUrl}${d.appiumError ? ` (${d.appiumError})` : ""}; start it with: appium`}`)
196
+ console.log(`maestro ${d.maestro ? `installed (${d.maestroPath})` : "not installed (https://maestro.mobile.dev)"}`)
197
+ console.log(`driver ${cfg.mobile.driver} (mobile.driver; --driver on replay)`)
198
+ return (cfg.mobile.driver === "appium" ? d.appium : d.maestro) ? 0 : 1
199
+ }
200
+
201
+ function cmdApps(flags: Flags): number {
202
+ const cfg = withEnvironment(config(flags), strFlag(flags, "env"))
203
+ const apps = Object.entries(cfg.mobile.apps)
204
+ if (!apps.length) {
205
+ console.log("No mobile apps. Add one to env.yaml (kind: mobile) or forgeqe.yaml:\n\nmobile:\n apps:\n courier:\n platform: android\n app_id: com.contoso.courier\n activity: .MainActivity\n secrets: [COURIER_PASSWORD]")
206
+ return 0
207
+ }
208
+ for (const [name, raw] of apps) {
209
+ const r = (raw && typeof raw === "object" ? raw : {}) as { platform?: string; app_id?: string; activity?: string; app?: string }
210
+ console.log(`${name.padEnd(16)} ${String(r.platform || "?")} ${String(r.app_id || "")}${r.activity ? ` ${r.activity}` : ""}${r.app ? ` (installs ${r.app})` : ""}`)
211
+ }
212
+ console.log(`\nsecrets ${cfg.mobile.secrets.join(", ") || "none"}`)
213
+ console.log(`driver ${cfg.mobile.driver}; appium ${cfg.mobile.appium.url}`)
214
+ return 0
215
+ }
216
+
217
+ export async function cmdMobile(args: string[], flags: Flags): Promise<number> {
218
+ const [sub, ...rest] = args
219
+ switch (sub) {
220
+ case "apps":
221
+ return cmdApps(flags)
222
+ case "doctor":
223
+ return cmdDoctor(flags)
224
+ case "explore":
225
+ return cmdExplore(rest, flags)
226
+ case "replay":
227
+ return cmdReplay(rest, flags)
228
+ case "maestro":
229
+ return cmdMaestro(rest, flags)
230
+ case "flow":
231
+ return cmdFlow(rest, flags)
232
+ case "upload": {
233
+ if (!rest[0]) throw new Error(MOBILE_USAGE)
234
+ const id = await uploadToTestmu(config(flags), rest[0], { name: strFlag(flags, "name"), virtual: boolFlag(flags, "virtual") })
235
+ console.log(id)
236
+ console.error(`Put it in the mobile app's app: (env.yaml or forgeqe.yaml); ForgeQE gives it to Appium (LT:Options) and to Maestro on TestMu itself.`)
237
+ return 0
238
+ }
239
+ case "validate":
240
+ return cmdValidate(rest)
241
+ default:
242
+ console.log(MOBILE_USAGE)
243
+ return sub ? 2 : 0
244
+ }
245
+ }
@@ -0,0 +1,226 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import type { AppConfig, MobileConfig } from "../config.ts"
6
+ import { runEngine, type DesktopEvent, type DesktopResult } from "../desktop/bridge.ts"
7
+ import { writeDesktopReport } from "../desktop/report.ts"
8
+ import { AZURE_IDENTITY_ENV } from "../entra.ts"
9
+ import { llmPayload } from "../engine/bridge.ts"
10
+ import { engineEnv, ensureEngine } from "../engine/setup.ts"
11
+ import { privateEnvNames } from "../childEnv.ts"
12
+
13
+ /**
14
+ * Mobile apps (Android, iOS) as the CLI (`forgeqe mobile`) and ForgeQE's agent (mobile_* tools) run them: through the
15
+ * mobile engine (`python -m forgeqe_mobile serve`, in the browser engine's Python), on Appium or Maestro. docs/MOBILE.md.
16
+ */
17
+
18
+ export type MobileRun = { result: DesktopResult; report: string; outDir: string }
19
+ type Common = { cfg: AppConfig; outDir: string; signal?: AbortSignal; onEvent?: (e: DesktopEvent) => void; log?: (t: string) => void }
20
+
21
+ /** mobile.secrets that are ForgeQE's own (a model key, a tracker token): a workflow may never type those. */
22
+ export function privateMobileSecrets(cfg: AppConfig): string[] {
23
+ const own = new Set(privateEnvNames(cfg))
24
+ return cfg.mobile.secrets.filter((n) => own.has(n))
25
+ }
26
+
27
+ export function checkMobileConfig(cfg: AppConfig): void {
28
+ const own = privateMobileSecrets(cfg)
29
+ if (own.length) throw new Error(`mobile.secrets lists ${own.join(", ")}, which ForgeQE itself uses (a model key or a service token); a workflow may not type it.`)
30
+ }
31
+
32
+ export function checkMobileApp(cfg: AppConfig, app: string): void {
33
+ if (!Object.prototype.hasOwnProperty.call(cfg.mobile.apps, app)) {
34
+ throw new Error(`App "${app}" is not a mobile app of this environment or of forgeqe.yaml mobile.apps (configured: ${Object.keys(cfg.mobile.apps).join(", ") || "none"}).`)
35
+ }
36
+ }
37
+
38
+ /** The engine's view of mobile: config, with the cloud credentials as names only. */
39
+ export function mobilePayload(m: MobileConfig): Record<string, unknown> {
40
+ return {
41
+ apps: m.apps,
42
+ secrets: m.secrets,
43
+ driver: m.driver,
44
+ screenshots: m.screenshots,
45
+ appium: { url: m.appium.url, capabilities: m.appium.capabilities, ...(m.appium.usernameEnv ? { username_env: m.appium.usernameEnv } : {}), ...(m.appium.accessKeyEnv ? { access_key_env: m.appium.accessKeyEnv } : {}) },
46
+ maestro: {
47
+ command: m.maestro.command,
48
+ ...(m.maestro.device ? { device: m.maestro.device } : {}),
49
+ ...(m.maestro.testmu
50
+ ? { testmu: { cli: m.maestro.testmu.cli, devices: m.maestro.testmu.devices, real_device: m.maestro.testmu.realDevice, ...(m.maestro.testmu.region ? { region: m.maestro.testmu.region } : {}), username_env: m.maestro.testmu.usernameEnv, access_key_env: m.maestro.testmu.accessKeyEnv } }
51
+ : {}),
52
+ },
53
+ }
54
+ }
55
+
56
+ /** What the engine process may see besides system settings: the model's key, mobile.secrets, the cloud's user and key,
57
+ * and the Android and Java settings Maestro and Appium need. */
58
+ export function mobileEnvNames(cfg: AppConfig): string[] {
59
+ const llm = cfg.llm
60
+ return [...new Set([
61
+ ...(llm.auth === "entra" ? AZURE_IDENTITY_ENV : llm.apiKeyEnv ? [llm.apiKeyEnv] : []),
62
+ ...cfg.mobile.secrets,
63
+ ...[cfg.mobile.appium.usernameEnv, cfg.mobile.appium.accessKeyEnv, cfg.mobile.maestro.testmu?.usernameEnv, cfg.mobile.maestro.testmu?.accessKeyEnv].filter((n): n is string => Boolean(n)),
64
+ "JAVA_HOME",
65
+ "ANDROID_HOME",
66
+ "ANDROID_SDK_ROOT",
67
+ "MAESTRO_CLI_NO_ANALYTICS",
68
+ ])]
69
+ }
70
+
71
+ function stamp(): string {
72
+ return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19)
73
+ }
74
+
75
+ export function mobileOutDir(cfg: AppConfig, kind: "explore" | "replay" | "maestro", out?: string): string {
76
+ const dir = out ? path.resolve(out) : path.join(cfg.export.outDir, `mobile-${kind}-${stamp()}`)
77
+ mkdirSync(dir, { recursive: true })
78
+ return dir
79
+ }
80
+
81
+ function modelLabel(cfg: AppConfig): string {
82
+ return `${cfg.activeProfile} (${cfg.llm.provider}/${cfg.llm.deployment || cfg.llm.model})`
83
+ }
84
+
85
+ async function run(opts: Common, payload: Record<string, unknown>): Promise<MobileRun> {
86
+ const python = await ensureEngine(opts.log || (() => {}))
87
+ const full = { ...payload, out_dir: opts.outDir, llm: llmPayload(opts.cfg.llm), profile_name: opts.cfg.activeProfile, mobile: mobilePayload(opts.cfg.mobile) }
88
+ const result = await runEngine({
89
+ command: [python, "-m", "forgeqe_mobile", "serve"],
90
+ payload: full,
91
+ logged: { ...payload, mobile: { apps: Object.keys(opts.cfg.mobile.apps), secrets: opts.cfg.mobile.secrets, driver: opts.cfg.mobile.driver, appium: opts.cfg.mobile.appium.url } },
92
+ envNames: mobileEnvNames(opts.cfg),
93
+ outDir: opts.outDir,
94
+ llm: opts.cfg.llm,
95
+ agent: "mobile agent",
96
+ log: "mobile",
97
+ signal: opts.signal,
98
+ onEvent: opts.onEvent,
99
+ })
100
+ const report = writeDesktopReport(opts.outDir, result, { model: modelLabel(opts.cfg), kind: "Mobile", ...(opts.cfg.environments?.active ? { environment: opts.cfg.environments.active } : {}) })
101
+ if (result.heals?.length) writeFileSync(path.join(opts.outDir, "heals.json"), JSON.stringify(result.heals, null, 2))
102
+ return { result, report, outDir: opts.outDir }
103
+ }
104
+
105
+ export function hasModel(cfg: AppConfig): boolean {
106
+ return Boolean(cfg.llm.model) && cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
107
+ }
108
+
109
+ /** Replay a mobile workflow on Appium (step by step, healing a moved control) or Maestro. */
110
+ export function replayMobile(opts: Common & { workflow: string; variables?: Record<string, string>; heal: boolean; driver?: "appium" | "maestro"; updateBaselines?: boolean }): Promise<MobileRun> {
111
+ const driver = opts.driver || opts.cfg.mobile.driver
112
+ return run(opts, {
113
+ mode: "replay",
114
+ workflow: opts.workflow,
115
+ variables: opts.variables || {},
116
+ driver,
117
+ heal: driver === "appium" && opts.heal && hasModel(opts.cfg),
118
+ update_baselines: Boolean(opts.updateBaselines),
119
+ })
120
+ }
121
+
122
+ /** The model carries out a goal in the app through Appium; what it did is saved as workflow.yaml, with checks. */
123
+ export function exploreMobile(opts: Common & { goal: string; app: string; name?: string }): Promise<MobileRun> {
124
+ return run(opts, { mode: "explore", goal: opts.goal, app: opts.app, ...(opts.name ? { name: opts.name } : {}) })
125
+ }
126
+
127
+ /** The team's own Maestro flows, checked against the allowlist, then run. */
128
+ export function runMaestroFlows(opts: Common & { flows: string[]; root: string }): Promise<MobileRun> {
129
+ return run(opts, { mode: "maestro", flows: opts.flows, root: opts.root })
130
+ }
131
+
132
+ /** Maestro flow files a caller names, as real paths inside `root`: .yaml files, and the flows directly in a folder. */
133
+ export function maestroFlowFiles(paths: string[], root: string): string[] {
134
+ const realRoot = realpathSync(root)
135
+ const inside = (p: string) => {
136
+ const rel = path.relative(realRoot, p)
137
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
138
+ }
139
+ const out: string[] = []
140
+ for (const p of paths) {
141
+ const full = path.resolve(root, p)
142
+ if (!existsSync(full)) throw new Error(`No flow at ${full}`)
143
+ const real = realpathSync(full)
144
+ if (!inside(real) && real !== realRoot) throw new Error(`${p} is outside the working folder; Maestro flows are read from it only.`)
145
+ if (statSync(real).isDirectory()) {
146
+ const found = readdirSync(real)
147
+ .filter((f) => /\.ya?ml$/i.test(f) && !f.startsWith("."))
148
+ .sort()
149
+ .map((f) => path.join(real, f))
150
+ .filter((f) => statSync(f).isFile() && /^appId:/m.test(readFileSync(f, "utf8")))
151
+ if (!found.length) throw new Error(`${full} has no Maestro flows (.yaml files with appId:)`)
152
+ out.push(...found)
153
+ } else if (/\.ya?ml$/i.test(real)) out.push(real)
154
+ else throw new Error(`${p} is not a Maestro flow (.yaml)`)
155
+ }
156
+ return [...new Set(out)]
157
+ }
158
+
159
+ export type MobileDoctor = { python?: string; appium: boolean; appiumUrl: string; appiumVersion?: string; appiumError?: string; maestro: boolean; maestroPath?: string; error?: string }
160
+
161
+ /** Whether the Appium server answers and maestro is installed. */
162
+ export async function mobileDoctor(cfg: AppConfig, log: (t: string) => void = () => {}): Promise<MobileDoctor> {
163
+ let python: string
164
+ try {
165
+ python = await ensureEngine(log)
166
+ } catch (e) {
167
+ return { appium: false, appiumUrl: cfg.mobile.appium.url, maestro: false, error: (e as Error).message }
168
+ }
169
+ const r = spawnSync(python, ["-m", "forgeqe_mobile", "doctor", "--appium-url", cfg.mobile.appium.url, "--maestro", cfg.mobile.maestro.command[0]!], { env: engineEnv({}, mobileEnvNames(cfg)), encoding: "utf8" })
170
+ try {
171
+ const d = JSON.parse((r.stdout || "").trim().split(/\r?\n/).pop() || "{}") as Record<string, unknown>
172
+ return {
173
+ python,
174
+ appium: d.appium === true,
175
+ appiumUrl: cfg.mobile.appium.url,
176
+ ...(d.appium_version ? { appiumVersion: String(d.appium_version) } : {}),
177
+ ...(d.appium_error ? { appiumError: String(d.appium_error) } : {}),
178
+ maestro: d.maestro === true,
179
+ ...(d.maestro_path ? { maestroPath: String(d.maestro_path) } : {}),
180
+ }
181
+ } catch {
182
+ return { python, appium: false, appiumUrl: cfg.mobile.appium.url, maestro: false, error: (r.stderr || "the check did not run").trim().split(/\r?\n/).pop() }
183
+ }
184
+ }
185
+
186
+ /** TestMu AI's app upload: real devices, or its emulators and simulators. */
187
+ export const TESTMU_UPLOAD = {
188
+ real: "https://manual-api.lambdatest.com/app/upload/realDevice",
189
+ virtual: "https://manual-api.lambdatest.com/app/upload/virtualDevice",
190
+ } as const
191
+
192
+ /**
193
+ * Upload an app build to TestMu AI and return its lt:// id, for the mobile app's `app`. The user and key come from the
194
+ * variables mobile.appium (or mobile.maestro.testmu) names, LT_USERNAME and LT_ACCESS_KEY by default.
195
+ */
196
+ export async function uploadToTestmu(cfg: AppConfig, file: string, opts: { name?: string; virtual?: boolean; fetch?: typeof fetch } = {}): Promise<string> {
197
+ const full = path.resolve(file)
198
+ if (!/\.(apk|aab|ipa|zip)$/i.test(full)) throw new Error(`${file} is not an app build (.apk, .aab, .ipa or .zip).`)
199
+ if (!existsSync(full) || !statSync(full).isFile()) throw new Error(`No app build at ${full}`)
200
+ const userEnv = cfg.mobile.maestro.testmu?.usernameEnv || cfg.mobile.appium.usernameEnv || "LT_USERNAME"
201
+ const keyEnv = cfg.mobile.maestro.testmu?.accessKeyEnv || cfg.mobile.appium.accessKeyEnv || "LT_ACCESS_KEY"
202
+ const user = process.env[userEnv]
203
+ const key = process.env[keyEnv]
204
+ if (!user || !key) throw new Error(`Set ${userEnv} and ${keyEnv} (your TestMu user name and access key), for example in .env in this folder.`)
205
+ const form = new FormData()
206
+ form.append("appFile", new Blob([readFileSync(full)]), path.basename(full))
207
+ form.append("name", (opts.name || path.basename(full)).replace(/[^A-Za-z0-9 ._-]/g, "_").slice(0, 100))
208
+ const res = await (opts.fetch || fetch)(opts.virtual ? TESTMU_UPLOAD.virtual : TESTMU_UPLOAD.real, {
209
+ method: "POST",
210
+ headers: { authorization: `Basic ${Buffer.from(`${user}:${key}`).toString("base64")}` },
211
+ body: form,
212
+ })
213
+ const text = await res.text()
214
+ let doc: { app_url?: unknown; message?: unknown; error?: unknown } = {}
215
+ try {
216
+ doc = JSON.parse(text)
217
+ } catch {
218
+ // reported below
219
+ }
220
+ const appUrl = typeof doc.app_url === "string" ? doc.app_url : ""
221
+ if (!res.ok || !/^lt:\/\/[A-Za-z0-9]+$/.test(appUrl)) {
222
+ const why = String(doc.message || doc.error || text || res.statusText).split(key).join("***").slice(0, 300)
223
+ throw new Error(`TestMu did not take the upload (${res.status}): ${why}`)
224
+ }
225
+ return appUrl
226
+ }
@@ -8,6 +8,7 @@ import { http } from "../cases/http.ts"
8
8
  import { parseResults, type TestOutcome, type TestsRun } from "../code/find.ts"
9
9
  import { walk } from "../code/profile.ts"
10
10
  import { runDirectory } from "../run.ts"
11
+ import { environmentVariables } from "../environments.ts"
11
12
  import { loadPack, savePack, type Pack, type PackRun } from "./pack.ts"
12
13
  import { WS_ENDPOINT_ENV } from "./run.ts"
13
14
 
@@ -72,6 +73,28 @@ function testCommands(framework: Framework, dotnet?: { project: string; settings
72
73
  }
73
74
 
74
75
  /** A GitHub Actions workflow that runs a pack: dispatched by ForgeQE with the pack's selector and target. */
76
+ /**
77
+ * The test environment's FORGEQE_* values (src/environments.ts) into the job's environment, from the env_vars input:
78
+ * only FORGEQE_ names, and no value with a line break, so an input can never set another variable.
79
+ */
80
+ const ENV_VARS_SCRIPT = [
81
+ "python3 - <<'PY' >> {OUT}",
82
+ "import json, os, re",
83
+ "for k, v in json.loads(os.environ.get('ENV_VARS') or '{}').items():",
84
+ " if re.fullmatch(r'FORGEQE_[A-Z0-9_]+', k) and isinstance(v, str) and not re.search(r'[\\r\\n]', v):",
85
+ " print({LINE})",
86
+ "PY",
87
+ ].join("\n")
88
+
89
+ /** Whether a pipeline file ForgeQE wrote takes the env_vars input (files written before it do not, and reject it). */
90
+ export function takesEnvVars(file: string): boolean {
91
+ try {
92
+ return /\benv_vars\b/.test(readFileSync(file, "utf8"))
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
75
98
  export function githubWorkflow(framework: Framework, remote: BrowserRemoteConfig | undefined, dotnet?: { project: string; settings?: string }): string {
76
99
  const t = testCommands(framework, dotnet)
77
100
  const setupStep = framework === "dotnet" ? ` - uses: actions/setup-dotnet@v4\n with:\n dotnet-version: "9.0.x"` : framework === "playwright" ? ` - uses: actions/setup-node@v4\n with:\n node-version: 22` : ` - uses: actions/setup-python@v5\n with:\n python-version: "3.12"`
@@ -96,6 +119,10 @@ on:
96
119
  type: choice
97
120
  options: [local, testmu]
98
121
  default: local
122
+ env_vars:
123
+ description: The test environment's FORGEQE_* values (env.yaml), as JSON; ForgeQE fills it
124
+ required: false
125
+ default: "{}"
99
126
  jobs:
100
127
  pack:
101
128
  runs-on: ubuntu-latest
@@ -115,6 +142,11 @@ ${indent(t.setup.join("\n"), 10)}
115
142
  RUN_ID: \${{ inputs.run_id }}
116
143
  run: |
117
144
  ${indent(endpointScript(remote, '"$GITHUB_ENV"'), 10)}
145
+ - name: Test environment
146
+ env:
147
+ ENV_VARS: \${{ inputs.env_vars }}
148
+ run: |
149
+ ${indent(ENV_VARS_SCRIPT.replace("{OUT}", '"$GITHUB_ENV"').replace("{LINE}", "f\"{k}={v}\""), 10)}
118
150
  - name: Run the pack
119
151
  env:
120
152
  TESTS: \${{ inputs.tests }}
@@ -150,6 +182,9 @@ parameters:
150
182
  type: string
151
183
  default: local
152
184
  values: [local, testmu]
185
+ - name: env_vars
186
+ type: string
187
+ default: "{}"
153
188
  pool:
154
189
  vmImage: ubuntu-latest
155
190
  steps:
@@ -168,6 +203,11 @@ ${indent(endpointScript(remote, "endpoint.env"), 6)}
168
203
  LT_ACCESS_KEY: $(LT_ACCESS_KEY)
169
204
  PACK: \${{ parameters.pack }}
170
205
  RUN_ID: \${{ parameters.run_id }}
206
+ - script: |
207
+ ${indent(ENV_VARS_SCRIPT.replace("{OUT}", "/dev/stdout").replace("{LINE}", "f\"##vso[task.setvariable variable={k}]{v}\""), 6)}
208
+ displayName: Test environment
209
+ env:
210
+ ENV_VARS: \${{ parameters.env_vars }}
171
211
  - script: ${t.run}
172
212
  displayName: Run the pack
173
213
  env:
@@ -269,7 +309,9 @@ export async function runPackOnPipeline(opts: PipelineOpts): Promise<{ pack: Pac
269
309
  if (kind === "github") {
270
310
  const g = pipe!.github!
271
311
  const exec = opts.exec || ghExec
272
- const inputs = { pack: pack.name, run_id: runId, tests: selector, target: opts.target }
312
+ const envVars = environmentVariables(cfg)
313
+ const withEnv = Object.keys(envVars).length && takesEnvVars(path.join(opts.repo, ".github", "workflows", g.workflow))
314
+ const inputs = { pack: pack.name, run_id: runId, tests: selector, target: opts.target, ...(withEnv ? { env_vars: JSON.stringify(envVars) } : {}) }
273
315
  command = `gh workflow run ${g.workflow} -R ${g.repo} --ref ${g.ref} ${Object.keys(inputs).map((k) => `-f ${k}=…`).join(" ")}`
274
316
  const fired = await exec(["workflow", "run", g.workflow, "-R", g.repo, "--ref", g.ref, ...Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`])], opts.repo)
275
317
  if (fired.code !== 0) throw new Error(`GitHub did not start ${g.workflow} in ${g.repo}: ${(fired.err || fired.out).trim().slice(0, 300)}`)
@@ -318,7 +360,7 @@ export async function runPackOnPipeline(opts: PipelineOpts): Promise<{ pack: Pac
318
360
  command = `Azure DevOps pipeline ${a.pipelineId} in ${a.project} (${a.ref})`
319
361
  const { data: queued } = await http<{ id: number; _links?: { web?: { href?: string } } }>(`${base}/_apis/pipelines/${a.pipelineId}/runs?api-version=7.1`, {
320
362
  auth,
321
- body: { resources: { repositories: { self: { refName: a.ref } } }, templateParameters: { pack: pack.name, run_id: runId, tests: selector, target: opts.target } },
363
+ body: { resources: { repositories: { self: { refName: a.ref } } }, templateParameters: { pack: pack.name, run_id: runId, tests: selector, target: opts.target, ...(Object.keys(environmentVariables(cfg)).length && takesEnvVars(path.join(opts.repo, ADO_PIPELINE_FILE)) ? { env_vars: JSON.stringify(environmentVariables(cfg)) } : {}) } },
322
364
  })
323
365
  url = queued?._links?.web?.href || ""
324
366
  say(`Queued pipeline run ${queued.id}${url ? ` (${url})` : ""}`)
package/src/refine.ts CHANGED
@@ -11,6 +11,7 @@ import { allStatuses } from "./capabilities/status.ts"
11
11
  import { domainNotes } from "./domain.ts"
12
12
  import type { CodeEvent } from "./types.ts"
13
13
  import { steScore } from "./ste.ts"
14
+ import { guardrailsNotes } from "./guardrails.ts"
14
15
 
15
16
  /**
16
17
  * The shell's Refine (ctrl+r): what the user typed becomes a precise request for ForgeQE's agent, checked against what
@@ -151,6 +152,8 @@ export async function refine(opts: {
151
152
  profile: opts.config.llm,
152
153
  // The refiner works on the request, not the domain: ForgeQE's agent applies that when it handles it.
153
154
  domain: null,
155
+ // The refined request is what the agents work to, so it must not ask for anything a guardrail forbids.
156
+ guardrails: guardrailsNotes(opts.config),
154
157
  model: opts.config.code.model,
155
158
  bin: opts.config.code.opencode,
156
159
  readDirs: [cwd],
@@ -14,6 +14,8 @@ export { readTimeline, type TimelineStep } from "./data.ts"
14
14
 
15
15
  /** Where and how the run happened, shown at the top of the report. */
16
16
  export type ReportEnv = {
17
+ /** The test environment (env.yaml) the run used. */
18
+ testEnvironment?: string
17
19
  model: string
18
20
  browser: string
19
21
  startUrl?: string
@@ -54,6 +56,7 @@ export function reportEnv(
54
56
  ? `${remote.browser} ${remote.browserVersion} on ${remote.platform || [remote.os, remote.osVersion].filter(Boolean).join(" ")} (${opts.browser!.provider}${opts.browser!.device ? `, device ${opts.browser!.device}` : ""})`
55
57
  : undefined
56
58
  return {
59
+ ...(config.environments?.active ? { testEnvironment: config.environments.active } : {}),
57
60
  model: `${config.activeProfile} (${llm.provider}/${llm.deployment || llm.model})`,
58
61
  browser:
59
62
  llm.provider === "mock"