@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
@@ -0,0 +1,362 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"
3
+ import net from "node:net"
4
+ import path from "node:path"
5
+
6
+ import { allowListEnv, privateEnvNames } from "../childEnv.ts"
7
+ import type { AppConfig } from "../config.ts"
8
+ import { containersOf, type ContainerRuntime, type ServiceSpec, type ServiceVolume } from "./config.ts"
9
+
10
+ /**
11
+ * Docker and Podman for test runs (docs/CONTAINERS.md). Every command is an argument array ForgeQE builds from a service
12
+ * forgeqe.yaml declares; every container runs the same restricted way: ports bound to 127.0.0.1, read-only mounts from
13
+ * inside the project, no new privileges, a process limit, and only the environment it lists. Containers carry labels
14
+ * with the project and service, so ForgeQE stops only its own.
15
+ */
16
+
17
+ export type ExecResult = { code: number; stdout: string; stderr: string }
18
+ export type Exec = (bin: string, args: string[], opts: { env: Record<string, string>; timeoutMs?: number; signal?: AbortSignal }) => Promise<ExecResult>
19
+
20
+ export const spawnExec: Exec = async (bin, args, opts) => {
21
+ const proc = Bun.spawn([bin, ...args], { env: opts.env, stdout: "pipe", stderr: "pipe", stdin: "ignore", ...(opts.signal ? { signal: opts.signal } : {}) })
22
+ const timer = opts.timeoutMs ? setTimeout(() => proc.kill(), opts.timeoutMs) : undefined
23
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
24
+ if (timer) clearTimeout(timer)
25
+ return { code, stdout, stderr }
26
+ }
27
+
28
+ export type Runtime = { name: ContainerRuntime; bin: string }
29
+
30
+ /** The container runtime to use: containers.runtime, else docker, else podman, on PATH. */
31
+ export function findRuntime(cfg: Pick<AppConfig, "containers">, which: (cmd: string) => string | null = (c) => Bun.which(c)): Runtime | null {
32
+ const order: ContainerRuntime[] = cfg.containers?.runtime ? [cfg.containers.runtime] : ["docker", "podman"]
33
+ for (const name of order) {
34
+ const bin = which(name)
35
+ if (bin) return { name, bin }
36
+ }
37
+ return null
38
+ }
39
+
40
+ /** Settings the docker and podman programs read to find their daemon; nothing else of ForgeQE's environment. */
41
+ const RUNTIME_ENV = ["DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY", "CONTAINER_HOST", "CONTAINER_CONNECTION", "CONTAINERS_CONF", "CONTAINERS_REGISTRIES_CONF", "CONTAINER_SSHKEY"]
42
+
43
+ /** The project's label value: one project's containers are never another's. */
44
+ export function projectKey(root: string): string {
45
+ const real = existsSync(root) ? realpathSync(root) : path.resolve(root)
46
+ return createHash("sha256").update(real).digest("hex").slice(0, 10)
47
+ }
48
+
49
+ export const containerName = (key: string, service: string) => `forgeqe-${key}-${service}`
50
+ /** The project's own network: its containers reach each other by service name (db:5432), and nothing else joins it. */
51
+ export const projectNetwork = (key: string) => `forgeqe-${key}`
52
+
53
+ /** Files whose names say they hold credentials or keys (as for uploads, src/mcp/attachtools.ts). */
54
+ const SECRET_FILE = /^(?:forgeqe\.ya?ml|credentials\.json|auth\.json|llms\.json|id_(?:rsa|dsa|ecdsa|ed25519)|.*\.(?:pem|key|pfx|p12|kdbx))$/i
55
+ const MAX_WALK = 5000
56
+
57
+ /**
58
+ * A mount's folder or file, resolved: inside the project; not hidden and holding nothing hidden (.env, .git, .npmrc)
59
+ * or named like a key; a container can send what it reads anywhere, so this is checked as for an upload.
60
+ */
61
+ export function checkVolume(v: ServiceVolume, root: string): string {
62
+ const top = realpathSync(root)
63
+ // A path outside the project is refused by its name first, without touching it: host paths such as the Docker
64
+ // socket can fail to stat (EOPNOTSUPP) and must be refused with the reason, not an OS error.
65
+ const lexical = path.resolve(root, v.host)
66
+ const inside = (base: string) => {
67
+ const rel = path.relative(base, lexical)
68
+ return !rel.startsWith("..") && !path.isAbsolute(rel)
69
+ }
70
+ if (!inside(path.resolve(root)) && !inside(top)) throw new Error(`The mount ${v.host} is outside the project folder (${top}); services mount only folders inside it.`)
71
+ if (!existsSync(v.host)) throw new Error(`The mount ${v.host} does not exist.`)
72
+ let real: string
73
+ try {
74
+ real = realpathSync(v.host)
75
+ } catch (e) {
76
+ throw new Error(`The mount ${v.host} cannot be read (${(e as NodeJS.ErrnoException).code || (e as Error).message}), so it is not mounted.`)
77
+ }
78
+ const rel = path.relative(top, real)
79
+ if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`The mount ${v.host} is outside the project folder (${top}); services mount only folders inside it.`)
80
+ if (!rel) throw new Error(`The mount ${v.host} is the whole project folder, which holds its settings and secrets; mount the folder the service needs.`)
81
+ if (rel.split(path.sep).some((p) => p.startsWith("."))) throw new Error(`The mount ${v.host} is a hidden file or in a hidden folder, which can hold secrets, so it is not mounted.`)
82
+ if (SECRET_FILE.test(path.basename(real))) throw new Error(`The mount ${v.host} is named like a file of credentials or keys, so it is not mounted.`)
83
+ if (statSync(real).isDirectory()) {
84
+ let seen = 0
85
+ const walk = (dir: string) => {
86
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
87
+ if (++seen > MAX_WALK) throw new Error(`The mount ${v.host} holds more than ${MAX_WALK} files; mount a smaller folder.`)
88
+ const at = path.join(dir, e.name)
89
+ if (e.name.startsWith(".")) throw new Error(`The mount ${v.host} holds ${path.relative(real, at)}, a hidden file or folder that can hold secrets; mount a folder without it.`)
90
+ if (SECRET_FILE.test(e.name)) throw new Error(`The mount ${v.host} holds ${path.relative(real, at)}, named like a file of credentials or keys; mount a folder without it.`)
91
+ // A link could point outside the project: refused rather than followed.
92
+ if (e.isSymbolicLink()) throw new Error(`The mount ${v.host} holds a link (${path.relative(real, at)}); mount a folder without links.`)
93
+ if (e.isDirectory()) walk(at)
94
+ }
95
+ }
96
+ walk(real)
97
+ }
98
+ return real
99
+ }
100
+
101
+ /** The secrets a service may be given: listed by name, set, and never one of ForgeQE's own keys. */
102
+ export function serviceSecrets(spec: Pick<ServiceSpec, "name" | "secrets">, cfg: AppConfig, env: Record<string, string | undefined> = process.env): Record<string, string> {
103
+ const own = new Set(privateEnvNames(cfg))
104
+ const out: Record<string, string> = {}
105
+ for (const name of spec.secrets) {
106
+ if (own.has(name)) throw new Error(`${spec.name}: ${name} is one of ForgeQE's own keys and is never passed to a container.`)
107
+ const v = env[name]
108
+ if (v === undefined || v === "") throw new Error(`${spec.name} needs ${name}, which is not set: set it in the environment or the project's .env.`)
109
+ out[name] = v
110
+ }
111
+ return out
112
+ }
113
+
114
+ export type Labels = { key: string; service: string; kind?: "service" | "mock" }
115
+
116
+ /** `run` arguments for a service: ForgeQE's own restrictions, then the service's settings, never a flag from elsewhere. */
117
+ export function runArgs(spec: ServiceSpec, labels: Labels, mounts: { host: string; container: string }[]): string[] {
118
+ const name = containerName(labels.key, labels.service)
119
+ return [
120
+ "run",
121
+ "--detach",
122
+ "--name", name,
123
+ "--label", "forgeqe=1",
124
+ "--label", `forgeqe.project=${labels.key}`,
125
+ "--label", `forgeqe.service=${labels.service}`,
126
+ "--label", `forgeqe.kind=${labels.kind || "service"}`,
127
+ "--security-opt", "no-new-privileges",
128
+ "--pids-limit", "1024",
129
+ "--network", projectNetwork(labels.key),
130
+ "--network-alias", labels.service,
131
+ ...spec.ports.flatMap((p) => ["--publish", `127.0.0.1:${p.host}:${p.container}`]),
132
+ ...Object.entries(spec.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
133
+ // By name only: the runtime reads the value from its own environment, so it is never on a command line.
134
+ ...spec.secrets.flatMap((n) => ["--env", n]),
135
+ ...mounts.flatMap((m) => ["--volume", `${m.host}:${m.container}:ro`]),
136
+ spec.image,
137
+ ...spec.command,
138
+ ]
139
+ }
140
+
141
+ /** Replace secret values in output with ***. */
142
+ export function scrub(text: string, values: string[]): string {
143
+ let out = text
144
+ for (const v of values) if (v && v.length >= 4) out = out.split(v).join("***")
145
+ return out
146
+ }
147
+
148
+ const clip = (s: string, n: number) => (s.length <= n ? s : `…${s.slice(-n)}`)
149
+
150
+ export type Ctx = { cfg: AppConfig; runtime: Runtime; exec: Exec; env?: Record<string, string | undefined>; signal?: AbortSignal; log?: (text: string) => void }
151
+
152
+ function runtimeEnv(ctx: Ctx, secrets: Record<string, string> = {}): Record<string, string> {
153
+ return { ...allowListEnv(ctx.env || process.env, RUNTIME_ENV), ...secrets }
154
+ }
155
+
156
+ /** Values to keep out of output: every listed secret and ForgeQE's own keys. */
157
+ function secretValues(ctx: Ctx, extra: Record<string, string> = {}): string[] {
158
+ const env = ctx.env || process.env
159
+ const names = [...privateEnvNames(ctx.cfg), ...Object.values(ctx.cfg.containers?.services || {}).flatMap((s) => s.secrets)]
160
+ return [...Object.values(extra), ...names.map((n) => env[n] || "")]
161
+ }
162
+
163
+ /** Containers this process started, so they are stopped when it ends (unless kept). */
164
+ const started = new Map<string, { runtime: Runtime; exec: Exec; env: Record<string, string> }>()
165
+
166
+ export type StartedService = {
167
+ service: string
168
+ container: string
169
+ image: string
170
+ ready: boolean
171
+ /** this machine's address for each container port */
172
+ ports: { container: number; address: string }[]
173
+ url?: string
174
+ error?: string
175
+ logs?: string
176
+ ms: number
177
+ }
178
+
179
+ /** Whether the runtime answers (its daemon or machine runs). */
180
+ export async function runtimeVersion(ctx: Ctx): Promise<{ ok: boolean; version?: string; error?: string }> {
181
+ const r = await ctx.exec(ctx.runtime.bin, ["version", "--format", ctx.runtime.name === "podman" ? "{{.Client.Version}}" : "{{.Server.Version}}"], { env: runtimeEnv(ctx), timeoutMs: 15000 })
182
+ if (r.code === 0 && r.stdout.trim()) return { ok: true, version: r.stdout.trim() }
183
+ return { ok: false, error: clip((r.stderr || r.stdout).trim() || `${ctx.runtime.name} version exited with ${r.code}`, 400) }
184
+ }
185
+
186
+ /**
187
+ * Whether the service accepts connections on the port. Docker's port proxy accepts every connection on this machine's
188
+ * side and drops it when nothing listens in the container, so a connection counts only when it stays open a moment.
189
+ */
190
+ function tcpOpen(port: number): Promise<boolean> {
191
+ return new Promise((resolve) => {
192
+ const s = net.connect({ host: "127.0.0.1", port })
193
+ let settled = false
194
+ const done = (ok: boolean) => {
195
+ if (settled) return
196
+ settled = true
197
+ s.destroy()
198
+ resolve(ok)
199
+ }
200
+ s.setTimeout(1500, () => done(false))
201
+ s.once("connect", () => setTimeout(() => done(true), 400))
202
+ s.once("end", () => done(false))
203
+ s.once("close", () => done(false))
204
+ s.once("error", () => done(false))
205
+ })
206
+ }
207
+
208
+ async function httpReady(port: number, p: string): Promise<boolean> {
209
+ try {
210
+ const res = await fetch(`http://127.0.0.1:${port}${p}`, { signal: AbortSignal.timeout(2000), redirect: "manual" })
211
+ return res.status < 500
212
+ } catch {
213
+ return false
214
+ }
215
+ }
216
+
217
+ async function running(ctx: Ctx, name: string): Promise<boolean> {
218
+ const r = await ctx.exec(ctx.runtime.bin, ["inspect", "--format", "{{.State.Running}}", name], { env: runtimeEnv(ctx), timeoutMs: 15000 })
219
+ return r.code === 0 && r.stdout.trim() === "true"
220
+ }
221
+
222
+ async function logsOf(ctx: Ctx, name: string, lines: number, secrets: string[]): Promise<string> {
223
+ const r = await ctx.exec(ctx.runtime.bin, ["logs", "--tail", String(lines), name], { env: runtimeEnv(ctx), timeoutMs: 15000 })
224
+ return clip(scrub(`${r.stdout}${r.stderr}`.trim(), secrets), 6000)
225
+ }
226
+
227
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
228
+
229
+ /** Start one service and wait until it is ready; a service that does not get ready is removed, with its last logs. */
230
+ export async function startService(ctx: Ctx, spec: ServiceSpec, opts: { kind?: "service" | "mock"; trustedMounts?: { host: string; container: string }[]; keep?: boolean } = {}): Promise<StartedService> {
231
+ const t0 = Date.now()
232
+ const key = projectKey(containersOf(ctx.cfg).root)
233
+ const name = containerName(key, spec.name)
234
+ const secrets = serviceSecrets(spec, ctx.cfg, ctx.env || process.env)
235
+ const mounts = [...spec.volumes.map((v) => ({ host: checkVolume(v, containersOf(ctx.cfg).root), container: v.container })), ...(opts.trustedMounts || [])]
236
+ const env = runtimeEnv(ctx, secrets)
237
+ const hide = secretValues(ctx, secrets)
238
+ await ensureNetwork(ctx, key)
239
+ // A container of the same name is this service's from an earlier run: replaced, never reused with old settings.
240
+ await ctx.exec(ctx.runtime.bin, ["rm", "--force", "--volumes", name], { env, timeoutMs: 30000 })
241
+ ctx.log?.(`Starting ${spec.name} (${spec.image})`)
242
+ const run = await ctx.exec(ctx.runtime.bin, runArgs(spec, { key, service: spec.name, kind: opts.kind }, mounts), { env, timeoutMs: 600000, ...(ctx.signal ? { signal: ctx.signal } : {}) })
243
+ const ports = spec.ports.map((p) => ({ container: p.container, address: `127.0.0.1:${p.host}` }))
244
+ const base = { service: spec.name, container: name, image: spec.image, ports }
245
+ if (run.code !== 0) return { ...base, ready: false, error: clip(scrub((run.stderr || run.stdout).trim(), hide), 1500) || `${ctx.runtime.name} run exited with ${run.code}`, ms: Date.now() - t0 }
246
+ if (!opts.keep) started.set(name, { runtime: ctx.runtime, exec: ctx.exec, env: runtimeEnv(ctx) })
247
+ const ready = spec.ready
248
+ const hostPort = (c: number) => spec.ports.find((p) => p.container === c)!.host
249
+ const url = ready && "http" in ready ? `http://127.0.0.1:${hostPort(ready.port)}` : undefined
250
+ const deadline = Date.now() + spec.timeout * 1000
251
+ for (;;) {
252
+ if (ctx.signal?.aborted) break
253
+ const ok = !ready ? await running(ctx, name) : "tcp" in ready ? await tcpOpen(hostPort(ready.tcp)) : await httpReady(hostPort(ready.port), ready.http)
254
+ if (ok) return { ...base, ready: true, ...(url ? { url } : {}), ms: Date.now() - t0 }
255
+ if (!(await running(ctx, name))) {
256
+ const logs = await logsOf(ctx, name, 60, hide)
257
+ await removeContainers(ctx, [name])
258
+ return { ...base, ready: false, error: `${spec.name} stopped while starting.`, logs, ms: Date.now() - t0 }
259
+ }
260
+ if (Date.now() > deadline) break
261
+ await sleep(500)
262
+ }
263
+ const logs = await logsOf(ctx, name, 60, hide)
264
+ await removeContainers(ctx, [name])
265
+ return { ...base, ready: false, error: ctx.signal?.aborted ? "Stopped." : `${spec.name} was not ready after ${spec.timeout} s (${ready ? ("tcp" in ready ? `port ${ready.tcp}` : `GET ${ready.http}`) : "running"}).`, logs, ms: Date.now() - t0 }
266
+ }
267
+
268
+ /** Create the project's network when it is not there yet. */
269
+ export async function ensureNetwork(ctx: Ctx, key: string): Promise<void> {
270
+ const net = projectNetwork(key)
271
+ const env = runtimeEnv(ctx)
272
+ const have = await ctx.exec(ctx.runtime.bin, ["network", "inspect", net], { env, timeoutMs: 15000 })
273
+ if (have.code === 0) return
274
+ const made = await ctx.exec(ctx.runtime.bin, ["network", "create", "--label", "forgeqe=1", "--label", `forgeqe.project=${key}`, net], { env, timeoutMs: 30000 })
275
+ if (made.code !== 0 && !/already exists/i.test(made.stderr)) throw new Error(`Could not create the network ${net}: ${clip(made.stderr.trim(), 300)}`)
276
+ }
277
+
278
+ async function removeContainers(ctx: Ctx, names: string[]): Promise<void> {
279
+ if (!names.length) return
280
+ await ctx.exec(ctx.runtime.bin, ["rm", "--force", "--volumes", ...names], { env: runtimeEnv(ctx), timeoutMs: 60000 })
281
+ for (const n of names) started.delete(n)
282
+ }
283
+
284
+ export type ContainerRow = { name: string; service: string; kind: string; state: string; status: string; image: string; ports: string }
285
+
286
+ /** This project's containers (only those ForgeQE labelled). */
287
+ export async function listContainers(ctx: Ctx): Promise<ContainerRow[]> {
288
+ const key = projectKey(containersOf(ctx.cfg).root)
289
+ const r = await ctx.exec(
290
+ ctx.runtime.bin,
291
+ ["ps", "--all", "--filter", `label=forgeqe.project=${key}`, "--format", "{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}"],
292
+ { env: runtimeEnv(ctx), timeoutMs: 30000 },
293
+ )
294
+ if (r.code !== 0) throw new Error(`${ctx.runtime.name} ps failed: ${clip((r.stderr || r.stdout).trim(), 400)}`)
295
+ return r.stdout
296
+ .split("\n")
297
+ .filter((l) => l.trim())
298
+ .map((l) => {
299
+ const [name = "", state = "", status = "", image = "", ports = ""] = l.split("\t")
300
+ // The name is forgeqe-<project>-<service> (both docker and podman print it the same way, unlike labels).
301
+ const service = name.replace(/^\//, "").replace(`forgeqe-${key}-`, "")
302
+ return { name, state, status, image, ports, service, kind: service.startsWith("mock-") ? "mock" : "service" }
303
+ })
304
+ }
305
+
306
+ /** Start the services (and what they depend on) that are not running yet; stops at the first that does not start. */
307
+ export async function ensureServices(ctx: Ctx, specs: ServiceSpec[]): Promise<{ started: StartedService[]; running: string[]; failed?: StartedService }> {
308
+ const up = new Set((await listContainers(ctx)).filter((r) => r.state === "running").map((r) => r.service))
309
+ const started: StartedService[] = []
310
+ const running: string[] = []
311
+ for (const s of specs) {
312
+ if (up.has(s.name)) {
313
+ running.push(s.name)
314
+ continue
315
+ }
316
+ const r = await startService(ctx, s)
317
+ started.push(r)
318
+ if (!r.ready) return { started, running, failed: r }
319
+ }
320
+ return { started, running }
321
+ }
322
+
323
+ /** Stop and remove this project's containers: the named services, or all of them. */
324
+ export async function stopServices(ctx: Ctx, services?: string[]): Promise<string[]> {
325
+ const rows = await listContainers(ctx)
326
+ const pick = services?.length ? rows.filter((r) => services.includes(r.service)) : rows
327
+ await removeContainers(ctx, pick.map((r) => r.name))
328
+ // With everything stopped, the project's network goes too (it is created again on the next start).
329
+ if (!services?.length) await ctx.exec(ctx.runtime.bin, ["network", "rm", projectNetwork(projectKey(containersOf(ctx.cfg).root))], { env: runtimeEnv(ctx), timeoutMs: 30000 })
330
+ return pick.map((r) => r.service)
331
+ }
332
+
333
+ export async function serviceLogs(ctx: Ctx, service: string, lines = 100): Promise<string> {
334
+ const rows = await listContainers(ctx)
335
+ const row = rows.find((r) => r.service === service)
336
+ if (!row) throw new Error(`${service} is not running (forgeqe services status lists what is).`)
337
+ return logsOf(ctx, row.name, Math.min(Math.max(1, lines), 1000), secretValues(ctx))
338
+ }
339
+
340
+ /** Containers this process started and has not stopped. */
341
+ export const startedContainers = () => [...started.keys()]
342
+
343
+ /** Stop what this process started: the session's services end with it. */
344
+ export async function stopStartedContainers(): Promise<string[]> {
345
+ const names = [...started.keys()]
346
+ for (const [name, s] of started) await s.exec(s.runtime.bin, ["rm", "--force", "--volumes", name], { env: s.env, timeoutMs: 60000 }).catch(() => undefined)
347
+ started.clear()
348
+ return names
349
+ }
350
+
351
+ /** A port on 127.0.0.1 that nothing listens on now. */
352
+ export function freePort(): Promise<number> {
353
+ return new Promise((resolve, reject) => {
354
+ const s = net.createServer()
355
+ s.once("error", reject)
356
+ s.listen(0, "127.0.0.1", () => {
357
+ const a = s.address()
358
+ const p = typeof a === "object" && a ? a.port : 0
359
+ s.close(() => resolve(p))
360
+ })
361
+ })
362
+ }
@@ -0,0 +1,80 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import path from "node:path"
3
+
4
+ import type { runTestCommand, TestRun } from "../code/testrun.ts"
5
+ import { promptOutput } from "../code/testrun.ts"
6
+ import { redactSecrets, sessionLoginEnv } from "../sessionLogins.ts"
7
+ import { containersOf, type TestsContainer } from "./config.ts"
8
+ import { ensureNetwork, projectKey, projectNetwork, scrub, serviceSecrets, type Ctx } from "./run.ts"
9
+ import { allowListEnv, privateEnvNames } from "../childEnv.ts"
10
+
11
+ /**
12
+ * The repository's tests in a container (docs/CONTAINERS.md): run_tests runs the confirmed command there instead of on
13
+ * this machine. The repository is mounted at its own path (so result files land where ForgeQE reads them), the
14
+ * container joins the project's network (the services by name: db:5432), runs as this machine's user, and gets only the
15
+ * environment's FORGEQE_* variables, the saved logins' variables, and containers.tests' env and secrets, by name.
16
+ */
17
+
18
+ /** Variables ForgeQE sets for test runs, and the environment's FORGEQE_* (src/environments.ts). */
19
+ const EXPORTED = /^FORGEQE_(ACTIVE_ENV$|APP_|VAR_)/
20
+
21
+ export function testContainerArgs(t: TestsContainer, o: { key: string; name: string; repo: string; cwd: string; mounts: string[]; command: string; byName: string[]; uid?: string }): string[] {
22
+ return [
23
+ "run",
24
+ "--rm",
25
+ "--name", o.name,
26
+ "--label", "forgeqe=1",
27
+ "--label", `forgeqe.project=${o.key}`,
28
+ "--label", "forgeqe.kind=tests",
29
+ "--security-opt", "no-new-privileges",
30
+ "--pids-limit", "4096",
31
+ "--network", projectNetwork(o.key),
32
+ ...(o.uid ? ["--user", o.uid] : []),
33
+ ...[o.repo, ...o.mounts].flatMap((m) => ["--volume", `${m}:${m}`]),
34
+ "--workdir", o.cwd,
35
+ ...Object.entries({ CI: "1", NO_COLOR: "1", FORCE_COLOR: "0", PLAYWRIGHT_HTML_OPEN: "never", HOME: "/tmp", ...t.env }).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
36
+ // By name: the values come from the runtime's own environment, never the command line.
37
+ ...o.byName.flatMap((n) => ["--env", n]),
38
+ t.image,
39
+ // The command the user confirmed, run by the container's shell, inside the container.
40
+ "sh", "-c", o.command,
41
+ ]
42
+ }
43
+
44
+ /** A runTestCommand that runs the command in containers.tests' image. `writable`: folders outside the repo the results go to. */
45
+ export function containerTestRunner(ctx: Ctx, repo: string, writable: string[] = []): typeof runTestCommand {
46
+ const t = containersOf(ctx.cfg).tests
47
+ if (!t) throw new Error("containers.tests is not set in forgeqe.yaml: it names the image the tests run in.")
48
+ if (process.platform === "win32") throw new Error("Tests in a container need a Linux or macOS machine (the repository is mounted at its own path).")
49
+ return async (opts): Promise<TestRun> => {
50
+ const started = Date.now()
51
+ const key = projectKey(containersOf(ctx.cfg).root)
52
+ await ensureNetwork(ctx, key)
53
+ const source = ctx.env || process.env
54
+ const own = new Set([...privateEnvNames(ctx.cfg), ...(opts.hide || [])])
55
+ const secretValues = serviceSecrets({ name: "containers.tests", secrets: t.secrets }, ctx.cfg, source)
56
+ const logins = sessionLoginEnv()
57
+ const extra = opts.env || {}
58
+ const exported = Object.fromEntries(Object.entries(source).filter(([k, v]) => EXPORTED.test(k) && v !== undefined) as [string, string][])
59
+ const pass: Record<string, string> = { ...exported, ...logins, ...extra, ...secretValues }
60
+ for (const k of Object.keys(pass)) if (own.has(k)) delete pass[k]
61
+ const name = `forgeqe-${key}-tests-${randomBytes(3).toString("hex")}`
62
+ const uid = typeof process.getuid === "function" ? `${process.getuid()}:${process.getgid?.() ?? process.getuid()}` : undefined
63
+ const mounts = [...new Set(writable.map((w) => path.resolve(w)))].filter((w) => path.relative(repo, w).startsWith(".."))
64
+ const args = testContainerArgs(t, { key, name, repo, cwd: opts.cwd, mounts, command: opts.command, byName: Object.keys(pass), uid })
65
+ const env = { ...allowListEnv(source, ["DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY", "CONTAINER_HOST", "CONTAINER_CONNECTION"]), ...pass }
66
+ const timeoutMs = opts.timeoutSeconds * 1000
67
+ let r
68
+ try {
69
+ r = await ctx.exec(ctx.runtime.bin, args, { env, timeoutMs, ...(opts.signal ? { signal: opts.signal } : {}) })
70
+ } finally {
71
+ // Stopping the runtime's client leaves the container running: remove it by name.
72
+ await ctx.exec(ctx.runtime.bin, ["rm", "--force", "--volumes", name], { env, timeoutMs: 30000 }).catch(() => undefined)
73
+ }
74
+ const timedOut = Date.now() - started >= timeoutMs
75
+ const hide = [...Object.values(secretValues), ...Object.values(logins), ...[...own].map((n) => source[n] || "")]
76
+ const full = scrub(redactSecrets([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").replace(/\x1b\[[0-9;]*m/g, "")), hide)
77
+ const ok = !timedOut && r.code === 0
78
+ return { ok, exitCode: timedOut ? null : r.code, ...promptOutput(full, ok ? undefined : opts.save), ms: Date.now() - started, browsers: [] }
79
+ }
80
+ }
package/src/context.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { AGENTS, type AgentEntry } from "./agents.ts"
1
+ import { AGENTS, CAPABILITY_TOOL_BUDGET, type AgentEntry } from "./agents.ts"
2
+ import { CAPABILITIES } from "./capabilities/registry.ts"
2
3
  import { createRouter, tokens } from "./mcp/routing.ts"
4
+ import { capabilityIndex, CORE, MAX_PICKED } from "./mcp/scope.ts"
3
5
  import { TOOLS } from "./mcp/tools.ts"
4
6
  import { prompt } from "./prompts.ts"
5
7
 
@@ -14,6 +16,12 @@ export type AgentContext = {
14
16
  over: boolean
15
17
  /** ForgeQE's agent only: what it gets on demand, and what the fixed context would be with everything always on. */
16
18
  onDemand?: { playbooks: number; playbookTokens: number; everything: number }
19
+ /**
20
+ * ForgeQE's agent only: each capability's tool definitions, which a turn carries when it picks the capability
21
+ * (src/mcp/scope.ts), and a turn with the largest ones it picks from words alone.
22
+ */
23
+ capabilities?: { id: string; tokens: number; over: boolean }[]
24
+ largestTurn?: number
17
25
  }
18
26
 
19
27
  const expanded = (name: string) => {
@@ -32,20 +40,32 @@ export function agentContexts(): AgentContext[] {
32
40
  return { agent, prompt: p, total: p, over: p > agent.budget }
33
41
  }
34
42
  const router = createRouter(TOOLS)
35
- const p = tokens(router.parts.core)
36
- const t = tokens(JSON.stringify(TOOLS.map((x) => ({ name: x.name, description: x.description, inputSchema: x.inputSchema }))))
37
- const full = tokens(expanded("brain")) + t
43
+ const toolTokens = (names: string[]) => {
44
+ const list = TOOLS.filter((x) => names.includes(x.name)).map((x) => ({ name: x.name, description: x.description, inputSchema: x.inputSchema }))
45
+ return list.length ? tokens(JSON.stringify(list)) : 0
46
+ }
47
+ // Every turn carries the core prompt, the capability index and the core capabilities' tools.
48
+ const p = tokens(router.parts.core) + tokens(capabilityIndex())
49
+ const t = toolTokens(CAPABILITIES.filter((c) => CORE.includes(c.id)).flatMap((c) => c.tools))
50
+ const capabilities = CAPABILITIES.filter((c) => c.tools.length && !CORE.includes(c.id)).map((c) => {
51
+ const n = toolTokens(c.tools)
52
+ return { id: c.id, tokens: n, over: n > CAPABILITY_TOOL_BUDGET }
53
+ })
54
+ const largest = [...capabilities].sort((a, b) => b.tokens - a.tokens).slice(0, MAX_PICKED)
55
+ const full = tokens(expanded("brain")) + toolTokens(TOOLS.map((x) => x.name))
38
56
  return {
39
57
  agent,
40
58
  prompt: p,
41
59
  tools: t,
42
60
  total: p + t,
43
- over: p + t > agent.budget,
61
+ over: p + t > agent.budget || capabilities.some((c) => c.over),
44
62
  onDemand: {
45
63
  playbooks: router.parts.playbooks.length,
46
64
  playbookTokens: router.parts.playbooks.reduce((s, b) => s + tokens(b.text), 0),
47
65
  everything: full,
48
66
  },
67
+ capabilities,
68
+ largestTurn: p + t + largest.reduce((s, c) => s + c.tokens, 0),
49
69
  }
50
70
  })
51
71
  }
@@ -68,8 +88,15 @@ export function contextReport(rows = agentContexts()): string[] {
68
88
  `ForgeQE's agent loads on demand: ${d.playbooks} playbooks (${k(d.playbookTokens)}), each with the first result of its tools or through the playbook tool.`,
69
89
  `With everything always loaded it would carry ${k(d.everything)} on every call; it carries ${k(f.total)}.`,
70
90
  )
91
+ if (f.capabilities) {
92
+ out.push(
93
+ "",
94
+ `A turn adds the tools of the capabilities its request is about (at most ${k(CAPABILITY_TOOL_BUDGET)} each); with the ${MAX_PICKED} largest it carries ${k(f.largestTurn || 0)}:`,
95
+ ...[...f.capabilities].sort((a, b) => b.tokens - a.tokens).map((c) => ` ${c.over ? "✗" : " "} ${c.id.padEnd(28)} ${k(c.tokens).padStart(6)}`),
96
+ )
97
+ }
71
98
  }
72
99
  const over = rows.filter((r) => r.over)
73
- if (over.length) out.push("", `Over budget: ${over.map((r) => r.agent.name).join(", ")}. Shorten what they load, or raise the budget in src/agents.ts with the reason.`)
100
+ if (over.length) out.push("", `Over budget: ${over.map((r) => r.agent.name).join(", ")}. Shorten what they load (a capability's tool descriptions, or split the capability), or raise the budget in src/agents.ts with the reason.`)
74
101
  return out
75
102
  }