@capagents/forgeqe 1.3.1 → 2.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 (119) 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/cli.py +10 -0
  37. package/py/forgeqe_engine/llm.py +54 -4
  38. package/py/forgeqe_engine/model_params.py +169 -0
  39. package/py/forgeqe_engine/summary.py +6 -0
  40. package/py/forgeqe_mobile/__init__.py +1 -0
  41. package/py/forgeqe_mobile/__main__.py +3 -0
  42. package/py/forgeqe_mobile/appium_driver.py +342 -0
  43. package/py/forgeqe_mobile/apps.py +84 -0
  44. package/py/forgeqe_mobile/cli.py +70 -0
  45. package/py/forgeqe_mobile/explore.py +203 -0
  46. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  47. package/py/forgeqe_mobile/maestro.py +321 -0
  48. package/py/forgeqe_mobile/service.py +332 -0
  49. package/skills/testcontainers/SKILL.md +42 -0
  50. package/src/agents.ts +11 -2
  51. package/src/api/plan.ts +2 -0
  52. package/src/api/write.ts +2 -0
  53. package/src/auth/copilot.ts +130 -0
  54. package/src/auth/signin.ts +54 -1
  55. package/src/brain/index.ts +26 -4
  56. package/src/capabilities/guide.ts +2 -0
  57. package/src/capabilities/registry.ts +110 -2
  58. package/src/capabilities/status.ts +15 -0
  59. package/src/cases/defects.ts +2 -1
  60. package/src/cases/rca.ts +2 -0
  61. package/src/childEnv.ts +4 -1
  62. package/src/cli/args.ts +3 -2
  63. package/src/cli/help.ts +24 -3
  64. package/src/cli/index.ts +47 -3
  65. package/src/code/find.ts +26 -0
  66. package/src/code/loop.ts +4 -1
  67. package/src/code/prompt.ts +2 -1
  68. package/src/code/prreview.ts +2 -0
  69. package/src/code/review.ts +6 -2
  70. package/src/code/runtime.ts +34 -3
  71. package/src/commands.ts +6 -1
  72. package/src/config.ts +159 -3
  73. package/src/containers/command.ts +68 -0
  74. package/src/containers/compose.ts +173 -0
  75. package/src/containers/config.ts +283 -0
  76. package/src/containers/mock.ts +156 -0
  77. package/src/containers/run.ts +362 -0
  78. package/src/containers/tests.ts +80 -0
  79. package/src/context.ts +33 -6
  80. package/src/copilot.ts +155 -0
  81. package/src/desktop/bridge.ts +229 -0
  82. package/src/desktop/command.ts +350 -0
  83. package/src/desktop/edit.ts +217 -0
  84. package/src/desktop/junit.ts +35 -0
  85. package/src/desktop/report.ts +155 -0
  86. package/src/desktop/run.ts +252 -0
  87. package/src/desktop/setup.ts +134 -0
  88. package/src/engine/bridge.ts +12 -5
  89. package/src/engine/setup.ts +6 -3
  90. package/src/environments.ts +566 -0
  91. package/src/evals/domain.ts +1 -0
  92. package/src/explore/charters.ts +2 -0
  93. package/src/guardrails.ts +145 -0
  94. package/src/heal/loop.ts +4 -1
  95. package/src/heal/prompt.ts +2 -1
  96. package/src/init.ts +5 -2
  97. package/src/llm.ts +23 -2
  98. package/src/mcp/attachtools.ts +16 -4
  99. package/src/mcp/captools.ts +27 -0
  100. package/src/mcp/containertools.ts +212 -0
  101. package/src/mcp/desktoptools.ts +334 -0
  102. package/src/mcp/mobiletools.ts +132 -0
  103. package/src/mcp/reporttools.ts +17 -2
  104. package/src/mcp/repotests.ts +22 -3
  105. package/src/mcp/routing.ts +11 -5
  106. package/src/mcp/scope.ts +210 -0
  107. package/src/mcp/server.ts +80 -5
  108. package/src/mcp/tools.ts +38 -1
  109. package/src/mobile/command.ts +245 -0
  110. package/src/mobile/run.ts +226 -0
  111. package/src/pack/pipeline.ts +44 -2
  112. package/src/refine.ts +3 -0
  113. package/src/report/html.ts +3 -0
  114. package/src/report/template.html +48 -48
  115. package/src/run.ts +7 -3
  116. package/src/tokenomics/ledger.ts +3 -1
  117. package/src/tui/App.tsx +23 -0
  118. package/src/update.ts +118 -8
  119. package/src/usage.ts +1 -1
package/src/code/find.ts CHANGED
@@ -13,11 +13,32 @@ import { CAUSE_TEXT, flakyReport, quarantined, recordResults, RELEASE_AFTER, set
13
13
  import { loadSkills } from "../skills/registry.ts"
14
14
  import { prompt } from "../prompts.ts"
15
15
  import { domainNotes } from "../domain.ts"
16
+ import { guardrailsNotes } from "../guardrails.ts"
16
17
 
17
18
  const LIST_ROUNDS = 3
18
19
  const LIST_TIMEOUT_SECONDS = 180
19
20
  const OUTPUT_CHARS = 6_000
20
21
 
22
+ /**
23
+ * ForgeQE runs Scribe's list command without asking, so it takes only a test framework's own listing: Scribe reads the
24
+ * repository, and a file in it could talk Scribe into any command. The run command is shown to the user first instead.
25
+ */
26
+ const LIST_RUNNER =
27
+ /^(?:(?:(?:npx(?:\s+--no-install)?|pnpm(?:\s+exec)?|yarn|bunx|bun\s+x)\s+)?(?:playwright\s+test|jest|vitest|mocha)|(?:(?:python3?|py)\s+-m\s+|(?:uv|poetry)\s+run\s+)?pytest|dotnet\s+test)(?=\s|$)/i
28
+ const LIST_FLAG = /(?:^|\s)(?:--list|--listTests|--list-tests|--collect-only|--co|--dry-run)(?=[\s=]|$)/
29
+ /** Characters that chain, substitute, or redirect in sh or cmd.exe. */
30
+ const SHELL_SYNTAX = /[;&|`$<>()\r\n%]/
31
+
32
+ /** Whether ForgeQE may run `command` unasked as a list command: one test runner, a listing flag, no shell syntax. */
33
+ export function listCommandAllowed(command: string): boolean {
34
+ const c = command.trim()
35
+ if (!c || SHELL_SYNTAX.test(c)) return false
36
+ const runner = LIST_RUNNER.exec(c)
37
+ if (!runner) return false
38
+ const rest = c.slice(runner[0].length)
39
+ return LIST_FLAG.test(rest) || (/vitest$/i.test(runner[0]) && /^\s+list(?=\s|$)/.test(rest))
40
+ }
41
+
21
42
  export const findAgentPrompt = () => prompt("find-tests")
22
43
 
23
44
  export const FIND_AGENT: AgentSpec = {
@@ -176,6 +197,7 @@ export async function findTests(opts: {
176
197
  repo,
177
198
  profile: opts.config.llm,
178
199
  domain: domainNotes(opts.config),
200
+ guardrails: guardrailsNotes(opts.config),
179
201
  model: opts.config.code.model,
180
202
  bin: opts.config.code.opencode,
181
203
  readDirs: [repo],
@@ -195,6 +217,10 @@ export async function findTests(opts: {
195
217
  let report = reply.report
196
218
  let listed = false
197
219
  for (let round = 1; round <= LIST_ROUNDS && clean(report.tests).length && report.list_command?.trim() && !opts.signal?.aborted; round++) {
220
+ if (!listCommandAllowed(report.list_command!)) {
221
+ emit({ type: "code", kind: "info", text: `ForgeQE does not run this list command, which is not a test framework's own listing: ${report.list_command}` })
222
+ break
223
+ }
198
224
  emit({ type: "code", kind: "info", text: `ForgeQE lists the selected tests: ${report.list_command}` })
199
225
  const run = await runTest({ command: report.list_command!, cwd: repo, timeoutSeconds: LIST_TIMEOUT_SECONDS, signal: opts.signal, hide: privateEnvNames(opts.config) })
200
226
  if (opts.signal?.aborted) break
package/src/code/loop.ts CHANGED
@@ -15,6 +15,7 @@ import { CODE_GRAPH_SERVER, codeGraphTools } from "./graph/tools.ts"
15
15
  import { localToolServer } from "../mcp/server.ts"
16
16
  import type { ToolContext } from "../mcp/tools.ts"
17
17
  import { addWrittenTest, notesSection, readNotes, saveNotes } from "./notes.ts"
18
+ import { environmentBrief } from "../environments.ts"
18
19
  import { canReplay, replayRun, savedReplay, type Replay } from "./replay.ts"
19
20
  import { openSession, READ_AGENT, TEST_AGENT, type OpencodeSession, type PromptOutcome } from "./runtime.ts"
20
21
  import { failureContext } from "./slices.ts"
@@ -48,6 +49,7 @@ import { runTestCommand } from "./testrun.ts"
48
49
  import { changedFiles, jobChanges, ownPaths, preexisting, protectPatterns, restoreRemoved, restoreSnapshot, snapshot, snapshotFiles } from "./worktree.ts"
49
50
  import { loadSkills } from "../skills/registry.ts"
50
51
  import { domainNotes } from "../domain.ts"
52
+ import { guardrailsNotes } from "../guardrails.ts"
51
53
 
52
54
  function realpathOr(dir: string): string {
53
55
  try {
@@ -281,6 +283,7 @@ export async function runCodeAgent(opts: {
281
283
  repo,
282
284
  profile: config.llm,
283
285
  domain: domainNotes(config),
286
+ guardrails: guardrailsNotes(config),
284
287
  model: config.code.model,
285
288
  bin: config.code.opencode,
286
289
  readDirs,
@@ -379,7 +382,7 @@ export async function runCodeAgent(opts: {
379
382
  let prompt =
380
383
  resumed && earlier?.stage === "writing"
381
384
  ? carryOnPrompt(liveOn)
382
- : `${resumed ? `${RESUMED_NOTE}\n\n` : ""}${firstPrompt(handoff, { ...(live ? { liveBrowserDir: live.dir, liveOn } : {}), ...(known ? { notes: notesSection(known) } : {}), ...(profileText ? { profile: profileText } : {}), ...(opts.existingTest ? { existing: opts.existingTest } : {}), ...(opts.target ? { target: opts.target } : {}) })}`
385
+ : `${resumed ? `${RESUMED_NOTE}\n\n` : ""}${firstPrompt(handoff, { ...(live ? { liveBrowserDir: live.dir, liveOn } : {}), ...(known ? { notes: notesSection(known) } : {}), ...(profileText ? { profile: profileText } : {}), ...(opts.existingTest ? { existing: opts.existingTest } : {}), ...(opts.target ? { target: opts.target } : {}), ...(environmentBrief(config, "code") ? { environment: environmentBrief(config, "code") } : {}) })}`
383
386
  if (prepared) {
384
387
  stage = "writing"
385
388
  saveSession()
@@ -154,7 +154,7 @@ function existingSection(e: ExistingTest): string[] {
154
154
  ]
155
155
  }
156
156
 
157
- export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; liveOn?: boolean; notes?: string; profile?: string; existing?: ExistingTest; target?: TestTarget } = {}): string {
157
+ export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; liveOn?: boolean; notes?: string; profile?: string; existing?: ExistingTest; target?: TestTarget; environment?: string } = {}): string {
158
158
  const live = opts.liveBrowserDir
159
159
  ? opts.liveOn
160
160
  ? `Your live_browser_* tools are on because the replay failed at a step; use them on that step's page. They save snapshots and screenshots in ${opts.liveBrowserDir}.`
@@ -176,6 +176,7 @@ export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; l
176
176
  ...(opts.existing ? existingSection(opts.existing) : opts.target ? targetSection(opts.target) : []),
177
177
  ...(opts.profile ? [opts.profile, ""] : []),
178
178
  ...(opts.notes ? [opts.notes, ""] : []),
179
+ ...(opts.environment ? [opts.environment, ""] : []),
179
180
  handoff.markdown,
180
181
  "",
181
182
  `(The same handoff is saved at ${handoff.file}; you do not need to read it again.)`,
@@ -15,6 +15,7 @@ import type { Price } from "../usage.ts"
15
15
  import { openSession, type AgentSpec, type OpencodeSession } from "./runtime.ts"
16
16
  import { prompt } from "../prompts.ts"
17
17
  import { domainNotes } from "../domain.ts"
18
+ import { guardrailsNotes } from "../guardrails.ts"
18
19
 
19
20
  const API = "api-version=7.1"
20
21
  /** The diff is shown in the prompt up to this size; the reviewer runs git diff for the rest. */
@@ -469,6 +470,7 @@ export async function reviewPullRequest(opts: {
469
470
  repo: co.tree,
470
471
  profile,
471
472
  domain: domainNotes(opts.config),
473
+ guardrails: guardrailsNotes(opts.config),
472
474
  model: profile === opts.config.llm ? opts.config.code.model : undefined,
473
475
  bin: opts.config.code.opencode,
474
476
  readDirs: [co.tree],
@@ -3,6 +3,7 @@ import path from "node:path"
3
3
 
4
4
  import type { CodeEvent, CodeReview, Usage } from "../types.ts"
5
5
  import { privateEnvNames } from "../childEnv.ts"
6
+ import { environmentBrief } from "../environments.ts"
6
7
  import type { AppConfig } from "../config.ts"
7
8
  import type { Handoff } from "./handoff.ts"
8
9
  import { openSession, type AgentSpec, type OpencodeSession } from "./runtime.ts"
@@ -12,6 +13,7 @@ import type { Price } from "../usage.ts"
12
13
  import { prompt } from "../prompts.ts"
13
14
  import { domainNotes } from "../domain.ts"
14
15
  import type { TestTarget } from "./prompt.ts"
16
+ import { guardrailsNotes } from "../guardrails.ts"
15
17
 
16
18
  const OUTPUT_CHARS = 4_000
17
19
  /** Changed files are shown in the prompt up to these sizes; the reviewer reads the rest itself. */
@@ -118,7 +120,7 @@ export function changedFiles(repo: string, paths: string[]): string[] {
118
120
  return lines
119
121
  }
120
122
 
121
- export function reviewPrompt(input: ReviewInput, repo?: string): string {
123
+ export function reviewPrompt(input: ReviewInput, repo?: string, environment?: string): string {
122
124
  const { handoff, report } = input
123
125
  const out = input.output.length > OUTPUT_CHARS ? `…${input.output.slice(-OUTPUT_CHARS)}` : input.output
124
126
  const changed = input.changes || report.changed || []
@@ -127,6 +129,7 @@ export function reviewPrompt(input: ReviewInput, repo?: string): string {
127
129
  `Review the test the coding agent wrote for this goal: ${handoff.goal}`,
128
130
  `The handoff it worked from: ${handoff.file}`,
129
131
  ...(checks ? ["", "The browser agent's checks, from the handoff:", checks] : []),
132
+ ...(environment ? ["", environment, "An environment's URL, host or path written into the test, where these settings or the repository's own should be read, is a correctness issue."] : []),
130
133
  "",
131
134
  ...(input.target
132
135
  ? [
@@ -207,6 +210,7 @@ export function openReviewer(opts: {
207
210
  repo: opts.repo,
208
211
  profile: reviewProfile,
209
212
  domain: domainNotes(opts.config),
213
+ guardrails: guardrailsNotes(opts.config),
210
214
  model: reviewProfile === opts.config.llm ? opts.config.code.model : undefined,
211
215
  bin: opts.config.code.opencode,
212
216
  readDirs: opts.readDirs,
@@ -224,7 +228,7 @@ export function openReviewer(opts: {
224
228
  ...(opts.mcp ? { mcp: opts.mcp } : {}),
225
229
  })
226
230
  }
227
- let reply = await session.prompt(reviewPrompt(input, opts.repo))
231
+ let reply = await session.prompt(reviewPrompt(input, opts.repo, environmentBrief(opts.config, "code") || undefined))
228
232
  if (!reply.report && !reply.error) reply = await session.prompt("Give the structured result: approved, summary, and issues.")
229
233
  Object.assign(usage, session.usage)
230
234
  const r = reply.report
@@ -5,6 +5,7 @@ import path from "node:path"
5
5
 
6
6
  import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
7
7
 
8
+ import { COPILOT_PROVIDER_ID, copilotProfileError, RUNTIME_AUTH_ENV, runtimeAuthContent } from "../copilot.ts"
8
9
  import { entraGateway } from "../entra.ts"
9
10
  import { missingKey, type LlmProfile } from "../llm.ts"
10
11
  import type { CodeEvent, FileAction, PlanItem, Usage } from "../types.ts"
@@ -122,11 +123,27 @@ export function providerFor(profile: LlmProfile): { provider: Record<string, unk
122
123
  },
123
124
  model: { providerID: "forgeqe", modelID: profile.model },
124
125
  }
126
+ case "github_copilot": {
127
+ const bad = copilotProfileError(profile)
128
+ if (bad) throw new Error(bad)
129
+ // The agent runtime's own GitHub Copilot provider; its sign-in comes in RUNTIME_AUTH_ENV (copilotSecrets).
130
+ return { provider: {}, model: { providerID: COPILOT_PROVIDER_ID, modelID: profile.model } }
131
+ }
125
132
  default:
126
133
  throw new Error(`The coding agent needs a live LLM profile; "${profile.provider}" has no model. Pick one with --profile or set code.model.`)
127
134
  }
128
135
  }
129
136
 
137
+ /**
138
+ * For a Copilot model, the saved Copilot sign-in as the agent runtime reads it from its environment, so it never sits
139
+ * in the runtime's auth.json where the agent's shell could read it. Without a saved one the runtime uses its own sign-in.
140
+ */
141
+ export function copilotSecrets(model: ModelRef, saved?: string): Record<string, string> {
142
+ if (model.providerID !== COPILOT_PROVIDER_ID) return {}
143
+ const content = runtimeAuthContent(saved)
144
+ return content ? { [RUNTIME_AUTH_ENV]: content } : {}
145
+ }
146
+
130
147
  function parseModel(ref: string): ModelRef {
131
148
  const i = ref.indexOf("/")
132
149
  if (i <= 0) throw new Error(`code.model must be provider/model, got "${ref}".`)
@@ -266,6 +283,9 @@ export const BASH_PERMISSION = {
266
283
  "*git -c * push*": "deny",
267
284
  "gh repo delete*": "deny",
268
285
  "*gh repo delete*": "deny",
286
+ // Containers start through ForgeQE's services and mock_service tools, which run only what forgeqe.yaml declares: a
287
+ // container command from the agent could mount the whole disk or run privileged (docs/CONTAINERS.md).
288
+ ...Object.fromEntries(["docker", "podman", "nerdctl", "docker-compose", "podman-compose"].flatMap((c) => [[`${c} *`, "deny"], [`${c}`, "deny"], [`* ${c} *`, "deny"], [`*/${c} *`, "deny"], [`*${c}.exe*`, "deny"]])),
269
289
  }
270
290
 
271
291
  /**
@@ -524,6 +544,11 @@ export function withDomain(agent: AgentSpec, domain: string | null): AgentSpec {
524
544
  return domain && !prompt.includes(domain) ? { ...agent, prompt: `${prompt}\n\n${domain}` } : agent
525
545
  }
526
546
 
547
+ /** The agent with the team's guardrails added to its instructions (once), last, so nothing after them overrides them. */
548
+ export function withGuardrails(agent: AgentSpec, guardrails: string | null): AgentSpec {
549
+ return guardrails && !agent.prompt.includes(guardrails) ? { ...agent, prompt: `${agent.prompt}\n\n${guardrails}` } : agent
550
+ }
551
+
527
552
  export async function openSession<R = AgentReport>(opts: {
528
553
  repo: string
529
554
  profile: LlmProfile
@@ -561,12 +586,17 @@ export async function openSession<R = AgentReport>(opts: {
561
586
  * not apply (a judge that is given the domain as data).
562
587
  */
563
588
  domain: string | null
589
+ /**
590
+ * The team's guardrails (guardrailsNotes: the rules in .forgeqe/guardrails), added to every agent's instructions.
591
+ * Required for the same reason as domain; null when the team has set none, or for a judge scoring other agents.
592
+ */
593
+ guardrails: string | null
564
594
  /** Values for {env:} references in `mcp` that are not in this terminal (e.g. base64 of email:token). */
565
595
  secrets?: Record<string, string>
566
596
  /** Who this session is in the usage meter. Default Scribe. */
567
597
  role?: Role
568
598
  }): Promise<OpencodeSession<R>> {
569
- const missing = opts.model ? null : missingKey(opts.profile)
599
+ const missing = opts.model ? null : missingKey(opts.profile, "agent")
570
600
  if (missing) throw new Error(missing)
571
601
  const bin = findOpencode(opts.bin)
572
602
  if (!bin) throw new Error(`The agent runtime was not found. ${INSTALL_HINT}`)
@@ -594,8 +624,8 @@ export async function openSession<R = AgentReport>(opts: {
594
624
  profile: gateway?.profile || opts.profile,
595
625
  model: opts.model,
596
626
  readDirs,
597
- agent: withDomain(agent, opts.domain),
598
- agents: opts.agents?.map((a) => withDomain(a, opts.domain)),
627
+ agent: withGuardrails(withDomain(agent, opts.domain), opts.guardrails),
628
+ agents: opts.agents?.map((a) => withGuardrails(withDomain(a, opts.domain), opts.guardrails)),
599
629
  mcp: opts.mcp,
600
630
  protect,
601
631
  readOnly: skillPaths,
@@ -607,6 +637,7 @@ export async function openSession<R = AgentReport>(opts: {
607
637
  throw err
608
638
  }
609
639
  const { config, model } = built
640
+ Object.assign(secrets, copilotSecrets(model))
610
641
  const pluginDir = mkdtempSync(path.join(os.tmpdir(), "forgeqe-runtime-"))
611
642
  const shellHidden = [...(opts.hide || []), ...Object.keys(secrets), ...envRefs(config), "OPENCODE_CONFIG_CONTENT"]
612
643
  config.plugin = [...((config.plugin as string[] | undefined) || []), hideEnvPlugin(pluginDir, shellHidden)]
package/src/commands.ts CHANGED
@@ -7,6 +7,8 @@ export type ShellCommand =
7
7
  /** value unset: toggle. */
8
8
  | { type: "details"; value?: boolean }
9
9
  | { type: "profile"; name: string }
10
+ /** name unset: list the test environments; else switch to that one (env.yaml). */
11
+ | { type: "env"; name?: string }
10
12
  | { type: "logs"; shots: boolean }
11
13
  | { type: "skills"; name?: string }
12
14
  | { type: "features" }
@@ -36,6 +38,7 @@ export const HELP = [
36
38
  " /headed on|off show the browser (live profiles)",
37
39
  " /details [on|off] show everything the agents do (reasoning, every file read, engine logs); off: just progress",
38
40
  " /profile <name> switch LLM profile (or press tab)",
41
+ " /env [name] the test environments in env.yaml and their apps, or switch to one",
39
42
  " /logs [shots] save this session's log and runs in one archive to share (shots: with screenshots)",
40
43
  " /features what ForgeQE can do, and what each capability still needs here",
41
44
  " /init set ForgeQE up in this folder: forgeqe.yaml, llms.json, editable prompts, skills, knowledge",
@@ -49,7 +52,7 @@ export const HELP = [
49
52
  "Select text with the mouse to copy it.",
50
53
  ].join("\n")
51
54
 
52
- const COMMANDS = new Set(["help", "?", "exit", "quit", "q", "clear", "stop", "headed", "details", "verbose", "profile", "p", "logs", "skills", "features", "auth", "signin", "init", "rerun"])
55
+ const COMMANDS = new Set(["env", "help", "?", "exit", "quit", "q", "clear", "stop", "headed", "details", "verbose", "profile", "p", "logs", "skills", "features", "auth", "signin", "init", "rerun"])
53
56
 
54
57
  /** Session controls; every other line, including one that starts with a path, goes to the agent as text. */
55
58
  export function parseCommand(raw: string): ShellCommand {
@@ -81,6 +84,8 @@ export function parseCommand(raw: string): ShellCommand {
81
84
  return arg ? { type: "skills", name: arg } : { type: "skills" }
82
85
  case "features":
83
86
  return { type: "features" }
87
+ case "env":
88
+ return arg ? { type: "env", name: arg } : { type: "env" }
84
89
  case "init":
85
90
  return { type: "init" }
86
91
  case "rerun":
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { setPromptDirs } from "./prompts.ts"
2
2
  import { qualityConfig, type QualityConfig } from "./quality/budgets.ts"
3
+ import { containersConfig, type ContainersConfig } from "./containers/config.ts"
3
4
  import { existsSync, readFileSync } from "node:fs"
4
5
  import os from "node:os"
5
6
  import path from "node:path"
@@ -11,6 +12,8 @@ import YAML from "yaml"
11
12
  import type { LlmProfile } from "./llm.ts"
12
13
  import type { ReplayMode } from "./types.ts"
13
14
  import { loadDotEnv } from "./env.ts"
15
+ import { CAPABILITIES } from "./capabilities/registry.ts"
16
+ import { exportEnvironment, loadEnvironments, withEnvironment, type EnvironmentsConfig } from "./environments.ts"
14
17
  import { systemLocale } from "./locale.ts"
15
18
  import { currencyCode, currencyForLocale } from "./money.ts"
16
19
  import { listPrice, pricesDate, type DeploymentType, type ListPrice } from "./prices.ts"
@@ -366,13 +369,28 @@ export type AppConfig = {
366
369
  profiles: Record<string, LlmProfile>
367
370
  llm: LlmProfile
368
371
  browser: BrowserConfig
372
+ /** `desktop:` in forgeqe.yaml: the Windows apps desktop workflows may start, and the secrets they may type (docs/DESKTOP.md). */
373
+ desktop: DesktopConfig
369
374
  /**
370
- * stepInstruction and guiderails are added to the browser agent's prompt. dirs: where edited prompts are looked for,
375
+ * `agent.capabilities` in forgeqe.yaml: capabilities whose tools ForgeQE's agent always has (always), and ones it never
376
+ * gets (off). The rest it gets per request (src/mcp/scope.ts).
377
+ */
378
+ agentScope: { always: string[]; off: string[] }
379
+ /** `mobile:` in forgeqe.yaml: Android and iOS apps, Appium or Maestro, and the secrets workflows may type (docs/MOBILE.md). */
380
+ mobile: MobileConfig
381
+ /** `containers:` in forgeqe.yaml: services ForgeQE may start in Docker or Podman, and the mocks' image (docs/CONTAINERS.md). */
382
+ containers?: ContainersConfig
383
+ /** Test environments from env.yaml (`environments:` in forgeqe.yaml); the active one's desktop and mobile apps are in `desktop` and `mobile`. */
384
+ environments?: EnvironmentsConfig
385
+ /**
386
+ * stepInstruction is added to the browser agent's prompt; guiderails' lines are guardrails for every agent (src/guardrails.ts). dirs: where edited prompts are looked for,
371
387
  * highest precedence first (prompts.dir, the project's .forgeqe/prompts, ~/.forgeqe/prompts); see src/prompts.ts.
372
388
  */
373
389
  prompts: { stepInstruction: string; guiderails: string; dirs?: { dir: string; source: string }[] }
374
390
  /** Product knowledge (.forgeqe/knowledge): see src/knowledge.ts. */
375
391
  knowledge?: { dir: string; browserMaxChars: number }
392
+ /** The team's guardrails (.forgeqe/guardrails): see src/guardrails.ts. */
393
+ guardrails?: { dir: string }
376
394
  /** Defect intelligence (defects: in forgeqe.yaml): see src/cases/defects.ts, repro.ts, rca.ts. */
377
395
  defects: DefectsConfig
378
396
  export: { outDir: string; format: string }
@@ -412,6 +430,122 @@ export type AppConfig = {
412
430
  budget?: BudgetConfig
413
431
  }
414
432
 
433
+ export type DesktopConfig = {
434
+ /** App name to { path, args, window }; the desktop engine checks each entry (an absolute .exe, an argument list). */
435
+ apps: Record<string, unknown>
436
+ /** Environment variables a workflow's {{secret:NAME}} may read; nothing else in the environment reaches the engine. */
437
+ secrets: string[]
438
+ /** Replay asks the model for a new locator when a step's control is not found (desktop.heal, default on). */
439
+ heal: boolean
440
+ screenshots: boolean
441
+ }
442
+
443
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
444
+
445
+ function desktopConfig(raw: Record<string, unknown>): DesktopConfig {
446
+ const secrets = raw.secrets === undefined || raw.secrets === null ? [] : raw.secrets
447
+ if (!Array.isArray(secrets) || !secrets.every((n) => typeof n === "string" && ENV_NAME.test(n))) {
448
+ throw new Error("desktop.secrets in forgeqe.yaml must be a list of environment variable names (letters, digits and _).")
449
+ }
450
+ return { apps: asRecord(raw.apps), secrets: [...new Set(secrets as string[])], heal: raw.heal !== false, screenshots: raw.screenshots !== false }
451
+ }
452
+
453
+ export type MobileConfig = {
454
+ /** App name to { platform, app_id, activity, app }; the mobile engine checks each entry. */
455
+ apps: Record<string, unknown>
456
+ /** Environment variables a workflow's {{secret:NAME}} may read. */
457
+ secrets: string[]
458
+ /** How workflows replay unless a run says otherwise: appium (step by step, with healing) or maestro. */
459
+ driver: "appium" | "maestro"
460
+ appium: {
461
+ /** The Appium server: the team's own (default http://127.0.0.1:4723) or a device cloud's hub. */
462
+ url: string
463
+ /** Extra W3C capabilities (appium:deviceName, appium:udid, appium:platformVersion, a cloud's options). */
464
+ capabilities: Record<string, unknown>
465
+ /** A device cloud's user and key, as the names of environment variables. */
466
+ usernameEnv?: string
467
+ accessKeyEnv?: string
468
+ }
469
+ maestro: {
470
+ command: string[]
471
+ device?: string
472
+ /** Maestro on TestMu AI's devices through HyperExecute, instead of a local maestro. */
473
+ testmu?: { cli: string; devices: string[]; realDevice: boolean; region?: string; usernameEnv: string; accessKeyEnv: string }
474
+ }
475
+ heal: boolean
476
+ screenshots: boolean
477
+ }
478
+
479
+ function agentScopeConfig(raw: Record<string, unknown>): AppConfig["agentScope"] {
480
+ const caps = asRecord(raw.capabilities)
481
+ const known = new Set(CAPABILITIES.map((c) => c.id))
482
+ const list = (key: "always" | "off") => {
483
+ const v = caps[key]
484
+ if (v === undefined || v === null) return []
485
+ if (!Array.isArray(v) || !v.every((x) => typeof x === "string" && known.has(x))) {
486
+ throw new Error(`agent.capabilities.${key} in forgeqe.yaml must list capability ids (${[...known].join(", ")}).`)
487
+ }
488
+ return [...new Set(v as string[])]
489
+ }
490
+ const always = list("always")
491
+ const off = list("off")
492
+ const both = always.filter((c) => off.includes(c))
493
+ if (both.length) throw new Error(`agent.capabilities: ${both.join(", ")} cannot be both always and off.`)
494
+ return { always, off }
495
+ }
496
+
497
+ export function mobileConfig(raw: Record<string, unknown>, base: string = process.cwd()): MobileConfig {
498
+ const secrets = raw.secrets === undefined || raw.secrets === null ? [] : raw.secrets
499
+ if (!Array.isArray(secrets) || !secrets.every((n) => typeof n === "string" && ENV_NAME.test(n))) {
500
+ throw new Error("mobile.secrets in forgeqe.yaml must be a list of environment variable names (letters, digits and _).")
501
+ }
502
+ const driver = raw.driver === undefined ? "appium" : raw.driver
503
+ if (driver !== "appium" && driver !== "maestro") throw new Error("mobile.driver in forgeqe.yaml must be appium or maestro.")
504
+ const a = asRecord(raw.appium)
505
+ const url = a.url === undefined ? "http://127.0.0.1:4723" : String(a.url)
506
+ if (!/^https?:\/\/[^\s]+$/i.test(url)) throw new Error("mobile.appium.url in forgeqe.yaml must be an http(s) URL.")
507
+ if (/^https?:\/\/[^/]*@/i.test(url)) throw new Error("mobile.appium.url must not hold a user or key: use mobile.appium.username_env and access_key_env.")
508
+ const envName = (v: unknown, key: string): string | undefined => {
509
+ if (v === undefined || v === null || v === "") return undefined
510
+ if (typeof v !== "string" || !ENV_NAME.test(v)) throw new Error(`mobile.appium.${key} must be the name of an environment variable.`)
511
+ return v
512
+ }
513
+ const m = asRecord(raw.maestro)
514
+ const command = m.command === undefined ? ["maestro"] : Array.isArray(m.command) ? m.command.map(String) : [String(m.command)]
515
+ if (!command.length || command.some((c) => !c || /[\r\n]/.test(c))) throw new Error("mobile.maestro.command must be the maestro program (and its arguments) as a list.")
516
+ const device = m.device === undefined ? undefined : String(m.device)
517
+ if (device !== undefined && !/^[A-Za-z0-9._:-]{1,100}$/.test(device)) throw new Error("mobile.maestro.device must be a device id (adb devices or xcrun simctl list).")
518
+ const t = m.testmu === undefined || m.testmu === null || m.testmu === false ? undefined : asRecord(m.testmu)
519
+ let testmu: MobileConfig["maestro"]["testmu"]
520
+ if (t) {
521
+ const devices = Array.isArray(t.devices) ? t.devices.map(String) : []
522
+ if (!devices.length || devices.some((d) => !/^[A-Za-z0-9 ._*()+-]{1,60}$/.test(d))) throw new Error('mobile.maestro.testmu.devices must be a list of TestMu device names, e.g. ["Pixel 6-14"].')
523
+ const region = t.region === undefined ? undefined : String(t.region)
524
+ if (region !== undefined && !/^[a-z]{2,4}$/.test(region)) throw new Error("mobile.maestro.testmu.region must be a TestMu region code, e.g. us or ap.")
525
+ const named = t.cli === undefined ? "hyperexecute" : String(t.cli)
526
+ if (!named || /[\x00-\x1f]/.test(named)) throw new Error("mobile.maestro.testmu.cli must be the path of the hyperexecute program.")
527
+ // A path (./hyperexecute) is relative to forgeqe.yaml; a bare name is looked up on PATH.
528
+ const cli = /[\\/]/.test(named) && !path.isAbsolute(named) ? path.resolve(base, named) : named
529
+ const tEnv = (v: unknown, key: string, fallback: string) => {
530
+ const name = v === undefined ? fallback : v
531
+ if (typeof name !== "string" || !ENV_NAME.test(name)) throw new Error(`mobile.maestro.testmu.${key} must be the name of an environment variable.`)
532
+ return name
533
+ }
534
+ testmu = { cli, devices, realDevice: t.real_device !== false, ...(region ? { region } : {}), usernameEnv: tEnv(t.username_env, "username_env", "LT_USERNAME"), accessKeyEnv: tEnv(t.access_key_env, "access_key_env", "LT_ACCESS_KEY") }
535
+ }
536
+ const usernameEnv = envName(a.username_env, "username_env")
537
+ const accessKeyEnv = envName(a.access_key_env, "access_key_env")
538
+ return {
539
+ apps: asRecord(raw.apps),
540
+ secrets: [...new Set(secrets as string[])],
541
+ driver,
542
+ appium: { url, capabilities: asRecord(a.capabilities), ...(usernameEnv ? { usernameEnv } : {}), ...(accessKeyEnv ? { accessKeyEnv } : {}) },
543
+ maestro: { command, ...(device ? { device } : {}), ...(testmu ? { testmu } : {}) },
544
+ heal: raw.heal !== false,
545
+ screenshots: raw.screenshots !== false,
546
+ }
547
+ }
548
+
415
549
  export type DomainConfig = {
416
550
  /** In the team's words, e.g. "retail banking", "property and casualty insurance". */
417
551
  industry: string
@@ -681,8 +815,12 @@ function profileFromRaw(name: string, raw: Record<string, unknown>): LlmProfile
681
815
  ...(String(raw.auth || "").toLowerCase() === "entra" ? { auth: "entra" as const } : {}),
682
816
  managedIdentityClientId: raw.managed_identity_client_id ? String(raw.managed_identity_client_id) : undefined,
683
817
  tokenScope: raw.token_scope ? String(raw.token_scope) : undefined,
818
+ ...(raw.engine_model ? { engineModel: String(raw.engine_model) } : {}),
819
+ ...(raw.github_org ? { githubOrg: String(raw.github_org) } : {}),
684
820
  apiVersion: raw.api_version ? String(raw.api_version) : undefined,
685
821
  temperature: typeof raw.temperature === "number" ? raw.temperature : undefined,
822
+ ...(typeof raw.vision === "boolean" ? { vision: raw.vision } : {}),
823
+ ...(typeof raw.reasoning === "boolean" ? { reasoning: raw.reasoning } : {}),
686
824
  ...priceFor(name, raw, provider, String(raw.model || "gpt-4o")),
687
825
  }
688
826
  }
@@ -797,7 +935,7 @@ function codeBudget(raw: unknown): number | undefined {
797
935
  return n > 0 ? n : undefined
798
936
  }
799
937
 
800
- export function loadConfig(opts?: { configPath?: string; profile?: string }): AppConfig {
938
+ export function loadConfig(opts?: { configPath?: string; profile?: string; env?: string }): AppConfig {
801
939
  // FORGEQE_CWD_ENV=off: tests never read the .env of the folder they run in (the developer's own keys).
802
940
  if (process.env.FORGEQE_CWD_ENV !== "off") loadDotEnv(process.cwd())
803
941
  const configPath = findConfigFile(opts?.configPath)
@@ -848,6 +986,10 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
848
986
  activeProfile,
849
987
  profiles,
850
988
  llm: profiles[activeProfile] || { ...NO_PROFILE },
989
+ desktop: desktopConfig(asRecord(yaml.desktop)),
990
+ mobile: mobileConfig(asRecord(yaml.mobile), base),
991
+ containers: containersConfig(asRecord(yaml.containers), base),
992
+ agentScope: agentScopeConfig(asRecord(yaml.agent)),
851
993
  browser: {
852
994
  provider,
853
995
  headless: browser.headless !== false,
@@ -871,6 +1013,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
871
1013
  dirs: promptDirsFor(prompts, base, configPath),
872
1014
  },
873
1015
  knowledge: knowledgeConfig(asRecord(yaml.knowledge), base, configPath),
1016
+ guardrails: guardrailsConfig(asRecord(yaml.guardrails), base, configPath),
874
1017
  defects: defectsConfig(asRecord(yaml.defects), base),
875
1018
  export: {
876
1019
  outDir,
@@ -929,7 +1072,16 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
929
1072
  }
930
1073
  // Prompts resolve from these folders from now on (src/prompts.ts).
931
1074
  setPromptDirs(config.prompts.dirs || [])
932
- return config
1075
+ const environments = loadEnvironments(asRecord(yaml.environments), base, config.desktop, opts?.env || process.env.FORGEQE_ENV || undefined, config.browser.allowedHosts, {
1076
+ startUrl: config.browser.startUrl,
1077
+ apiUrl: config.api?.baseUrl,
1078
+ mobile: config.mobile,
1079
+ })
1080
+ if (!environments) return config
1081
+ const withEnv = withEnvironment({ ...config, environments })
1082
+ // Tests and agents ForgeQE starts read the environment from FORGEQE_ACTIVE_ENV and FORGEQE_APP_* (src/environments.ts).
1083
+ exportEnvironment(withEnv)
1084
+ return withEnv
933
1085
  }
934
1086
 
935
1087
  function skillsConfig(raw: unknown, base: string): SkillsConfig {
@@ -1187,6 +1339,10 @@ export function defectsConfig(d: Record<string, unknown>, base: string): Defects
1187
1339
  }
1188
1340
  }
1189
1341
 
1342
+ function guardrailsConfig(g: Record<string, unknown>, base: string, configPath: string | undefined): NonNullable<AppConfig["guardrails"]> {
1343
+ return { dir: g.dir ? path.resolve(base, String(g.dir)) : configPath ? path.join(projectDir(base), "guardrails") : path.join(forgeqeHome(), "guardrails") }
1344
+ }
1345
+
1190
1346
  function knowledgeConfig(k: Record<string, unknown>, base: string, configPath: string | undefined): NonNullable<AppConfig["knowledge"]> {
1191
1347
  const dir = k.dir ? path.resolve(base, String(k.dir)) : configPath ? path.join(projectDir(base), "knowledge") : path.join(forgeqeHome(), "knowledge")
1192
1348
  const max = Number(k.browser_max_chars)
@@ -0,0 +1,68 @@
1
+ import { configOpts, strFlag, type Flags } from "../cli/args.ts"
2
+ import { loadConfig } from "../config.ts"
3
+ import { containersOf, startOrder } from "./config.ts"
4
+ import { findRuntime, listContainers, runtimeVersion, serviceLogs, spawnExec, startService, stopServices, type Ctx } from "./run.ts"
5
+
6
+ /** `forgeqe services`: the project's services in Docker or Podman (docs/CONTAINERS.md). */
7
+
8
+ export const SERVICES_USAGE = `Usage:
9
+ forgeqe services [status] the declared services, what runs, and whether Docker or Podman answers
10
+ forgeqe services up [name ...] start services (default: all) and wait until each is ready; they keep
11
+ running after this command, until forgeqe services down
12
+ forgeqe services down [name ...] stop and remove this project's containers (default: all, mocks included)
13
+ forgeqe services logs <name> [--lines N] a service's last lines, secrets removed`
14
+
15
+ export async function cmdServices(args: string[], flags: Flags): Promise<number> {
16
+ const cfg = loadConfig(configOpts(flags))
17
+ const [op = "status", ...names] = args
18
+ const declared = containersOf(cfg).services
19
+ const runtime = findRuntime(cfg)
20
+ if (!runtime) {
21
+ console.error(`${containersOf(cfg).runtime || "Docker or Podman"} is not on PATH. Install Docker Desktop, Docker Engine or Podman.`)
22
+ return 1
23
+ }
24
+ const ctx: Ctx = { cfg, runtime, exec: spawnExec, log: (t) => console.log(t) }
25
+ if (op === "status") {
26
+ const v = await runtimeVersion(ctx)
27
+ console.log(v.ok ? `${runtime.name} ${v.version}` : `${runtime.name} does not answer: ${v.error}`)
28
+ const rows = v.ok ? await listContainers(ctx) : []
29
+ const names = Object.keys(declared)
30
+ console.log(names.length ? `Declared: ${names.map((n) => `${n} (${declared[n]!.image})`).join(", ")}` : "No services declared (containers.services in forgeqe.yaml).")
31
+ for (const r of rows) console.log(` ${r.service.padEnd(24)} ${r.state.padEnd(10)} ${r.ports || ""}`)
32
+ if (v.ok && !rows.length) console.log("Nothing of this project is running.")
33
+ return v.ok ? 0 : 1
34
+ }
35
+ const unknown = names.filter((n) => !declared[n] && !(op !== "up" && n.startsWith("mock-")))
36
+ if (unknown.length) {
37
+ console.error(`Not declared in forgeqe.yaml containers.services: ${unknown.join(", ")} (declared: ${Object.keys(declared).join(", ") || "none"}).`)
38
+ return 1
39
+ }
40
+ if (op === "up") {
41
+ const specs = startOrder(declared, names)
42
+ if (!specs.length) {
43
+ console.error("No services declared: add them under containers.services in forgeqe.yaml (docs/CONTAINERS.md).")
44
+ return 1
45
+ }
46
+ for (const s of specs) {
47
+ const r = await startService(ctx, s, { keep: true })
48
+ if (!r.ready) {
49
+ console.error(`✗ ${s.name}: ${r.error}${r.logs ? `\n${r.logs}` : ""}`)
50
+ return 1
51
+ }
52
+ console.log(`✓ ${s.name} ready${r.ports.length ? ` on ${r.ports.map((p) => p.address).join(", ")}` : ""} (${(r.ms / 1000).toFixed(1)} s)`)
53
+ }
54
+ return 0
55
+ }
56
+ if (op === "down") {
57
+ const stopped = await stopServices(ctx, names.length ? names : undefined)
58
+ console.log(stopped.length ? `Stopped ${stopped.join(", ")}.` : "Nothing of this project was running.")
59
+ return 0
60
+ }
61
+ if (op === "logs") {
62
+ if (names.length !== 1) throw new Error(SERVICES_USAGE)
63
+ const lines = strFlag(flags, "lines")
64
+ console.log(await serviceLogs(ctx, names[0]!, lines ? Number(lines) : 100))
65
+ return 0
66
+ }
67
+ throw new Error(SERVICES_USAGE)
68
+ }