@capagents/forgeqe 1.3.2 → 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.
- package/HOW_TO_USE.md +373 -9
- package/README.md +55 -4
- package/docs/ARCHITECTURE.md +62 -1
- package/docs/CONTAINERS.md +227 -0
- package/docs/COST_ARCHITECTURE.md +32 -0
- package/docs/DESKTOP-CI.md +87 -0
- package/docs/DESKTOP.md +387 -0
- package/docs/MOBILE.md +228 -0
- package/docs/feature-test-prompts.md +25 -0
- package/docs/security-data-flow.html +440 -3
- package/docs/user-guide.html +797 -415
- package/env.example.yaml +67 -0
- package/forgeqe.example.yaml +56 -1
- package/llms.example.json +7 -0
- package/package.json +4 -1
- package/prompts/brain.md +31 -0
- package/prompts/find-tests.md +1 -0
- package/py/forgeqe_desktop/__init__.py +1 -0
- package/py/forgeqe_desktop/__main__.py +3 -0
- package/py/forgeqe_desktop/apps.py +80 -0
- package/py/forgeqe_desktop/checks.py +168 -0
- package/py/forgeqe_desktop/cli.py +90 -0
- package/py/forgeqe_desktop/handhooks.py +293 -0
- package/py/forgeqe_desktop/handrecord.py +239 -0
- package/py/forgeqe_desktop/heal.py +107 -0
- package/py/forgeqe_desktop/models.py +208 -0
- package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
- package/py/forgeqe_desktop/record.py +150 -0
- package/py/forgeqe_desktop/replay.py +345 -0
- package/py/forgeqe_desktop/service.py +356 -0
- package/py/forgeqe_desktop/session.py +60 -0
- package/py/forgeqe_desktop/ufo_bridge.py +86 -0
- package/py/forgeqe_desktop/ufo_runner.py +305 -0
- package/py/forgeqe_desktop/visual.py +112 -0
- package/py/forgeqe_desktop/workflow.py +338 -0
- package/py/forgeqe_engine/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -0
- package/py/forgeqe_mobile/__init__.py +1 -0
- package/py/forgeqe_mobile/__main__.py +3 -0
- package/py/forgeqe_mobile/appium_driver.py +342 -0
- package/py/forgeqe_mobile/apps.py +84 -0
- package/py/forgeqe_mobile/cli.py +70 -0
- package/py/forgeqe_mobile/explore.py +203 -0
- package/py/forgeqe_mobile/hyperexecute.py +261 -0
- package/py/forgeqe_mobile/maestro.py +321 -0
- package/py/forgeqe_mobile/service.py +332 -0
- package/skills/testcontainers/SKILL.md +42 -0
- package/src/agents.ts +11 -2
- package/src/api/plan.ts +2 -0
- package/src/api/write.ts +2 -0
- package/src/auth/copilot.ts +130 -0
- package/src/auth/signin.ts +54 -1
- package/src/brain/index.ts +26 -4
- package/src/capabilities/guide.ts +2 -0
- package/src/capabilities/registry.ts +110 -2
- package/src/capabilities/status.ts +15 -0
- package/src/cases/rca.ts +2 -0
- package/src/childEnv.ts +4 -1
- package/src/cli/args.ts +3 -2
- package/src/cli/help.ts +24 -3
- package/src/cli/index.ts +47 -3
- package/src/code/find.ts +26 -0
- package/src/code/loop.ts +4 -1
- package/src/code/prompt.ts +2 -1
- package/src/code/prreview.ts +2 -0
- package/src/code/review.ts +6 -2
- package/src/code/runtime.ts +34 -3
- package/src/commands.ts +6 -1
- package/src/config.ts +159 -3
- package/src/containers/command.ts +68 -0
- package/src/containers/compose.ts +173 -0
- package/src/containers/config.ts +283 -0
- package/src/containers/mock.ts +156 -0
- package/src/containers/run.ts +362 -0
- package/src/containers/tests.ts +80 -0
- package/src/context.ts +33 -6
- package/src/copilot.ts +155 -0
- package/src/desktop/bridge.ts +229 -0
- package/src/desktop/command.ts +350 -0
- package/src/desktop/edit.ts +217 -0
- package/src/desktop/junit.ts +35 -0
- package/src/desktop/report.ts +155 -0
- package/src/desktop/run.ts +252 -0
- package/src/desktop/setup.ts +134 -0
- package/src/engine/bridge.ts +12 -5
- package/src/engine/setup.ts +3 -3
- package/src/environments.ts +566 -0
- package/src/evals/domain.ts +1 -0
- package/src/explore/charters.ts +2 -0
- package/src/guardrails.ts +145 -0
- package/src/heal/loop.ts +4 -1
- package/src/heal/prompt.ts +2 -1
- package/src/init.ts +5 -2
- package/src/llm.ts +23 -2
- package/src/mcp/attachtools.ts +16 -4
- package/src/mcp/captools.ts +27 -0
- package/src/mcp/containertools.ts +212 -0
- package/src/mcp/desktoptools.ts +334 -0
- package/src/mcp/mobiletools.ts +132 -0
- package/src/mcp/reporttools.ts +17 -2
- package/src/mcp/repotests.ts +22 -3
- package/src/mcp/routing.ts +11 -5
- package/src/mcp/scope.ts +210 -0
- package/src/mcp/server.ts +80 -5
- package/src/mcp/tools.ts +38 -1
- package/src/mobile/command.ts +245 -0
- package/src/mobile/run.ts +226 -0
- package/src/pack/pipeline.ts +44 -2
- package/src/refine.ts +3 -0
- package/src/report/html.ts +3 -0
- package/src/report/template.html +48 -48
- package/src/run.ts +7 -3
- package/src/tokenomics/ledger.ts +3 -1
- package/src/tui/App.tsx +23 -0
- package/src/usage.ts +1 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: testcontainers
|
|
3
|
+
description: "Use when a test needs a real database, queue, cache or other service, or when the repository already uses Testcontainers (Java org.testcontainers, .NET Testcontainers, Node testcontainers, Python testcontainers, Go testcontainers-go): starting the service from the test itself with a pinned image, waiting until it is ready, wiring its address into the app under test, sharing one container across tests, and cleaning up."
|
|
4
|
+
agents: [scribe]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Testcontainers
|
|
8
|
+
|
|
9
|
+
## Read the setup first
|
|
10
|
+
- **Find what the repository already uses.** Look for the library in the build file:
|
|
11
|
+
- `pom.xml` or `build.gradle`: `org.testcontainers`
|
|
12
|
+
- `.csproj`: `Testcontainers.*`
|
|
13
|
+
- `package.json`: `testcontainers` or `@testcontainers/*`
|
|
14
|
+
- `pyproject.toml` or `requirements*.txt`: `testcontainers`
|
|
15
|
+
- `go.mod`: `github.com/testcontainers/testcontainers-go`
|
|
16
|
+
|
|
17
|
+
Then find its existing containers: a base class, a fixture, `@Testcontainers`/`@Container`, an `IAsyncLifetime` or `[OneTimeSetUp]`, a Jest `globalSetup`, or a pytest fixture with `scope="session"`. Reuse them. Never start a second container for a service a fixture already gives.
|
|
18
|
+
- **Use the repository's own modules** (`PostgreSQLContainer`, `PostgreSqlBuilder`, `PostgreSqlContainer`, `RedisContainer`, `KafkaContainer`) over a generic container. Use a generic one only when no module exists.
|
|
19
|
+
- **Adding the library:** only when the test needs a real service and nothing in the repository gives one. Pin the version the build file uses for its other test dependencies, or the latest release. Then say so in your summary.
|
|
20
|
+
|
|
21
|
+
## Images
|
|
22
|
+
- Pin every image to an exact version: `postgres:16.4`, `redis:7.4.1`, `confluentinc/cp-kafka:7.7.1`. Never `latest` or no tag.
|
|
23
|
+
- Use the version the app runs in production when the repository says it (`docker-compose.yml`, deployment files, a README). Otherwise use the newest version of the major the app's driver supports.
|
|
24
|
+
|
|
25
|
+
## Ready and addresses
|
|
26
|
+
- **Wait for readiness the module's way, or with an explicit wait strategy:** a log line, a health check, or an HTTP path answering 200. Never sleep for a fixed time.
|
|
27
|
+
- **Use the mapped port and host the library gives** (`getMappedPort`, `GetMappedPublicPort`, `getPort()`, `get_exposed_port`, `container.Host`). Never assume a fixed port of this machine: tests run in parallel and on CI.
|
|
28
|
+
- **Hand the address to the app under test** through its own configuration: the connection string or URL setting it already reads. In Spring, use `@DynamicPropertySource` or `@ServiceConnection`. In ASP.NET, use `WebApplicationFactory` with `ConfigureAppConfiguration`. Elsewhere, set an environment variable before the app starts.
|
|
29
|
+
- **Seed data** with the repository's migrations or seed scripts (`withInitScript`, `WithResourceMapping`, running the migration tool against the container). Don't use SQL copied into the test when the repository has migrations.
|
|
30
|
+
|
|
31
|
+
## Lifetime and speed
|
|
32
|
+
- **Start one container per test class or test session**, not per test, unless tests must not share data. Reset data between tests the way the repository does: transactions rolled back, truncating tables, or a fresh schema.
|
|
33
|
+
- **Leave cleanup to the library** (Ryuk, `await using`, `@Container`, a fixture's teardown). Never call `docker` from a test, and never run `docker rm`.
|
|
34
|
+
|
|
35
|
+
## What a test must not do
|
|
36
|
+
- **No privileged containers**, host networking (`withNetworkMode("host")`, `NetworkMode = host`), mounting the Docker socket into a container, or `withPrivilegedMode`.
|
|
37
|
+
- **No real credentials.** Use the module's defaults or fixed test values. A secret the app needs comes from the environment the test reads already.
|
|
38
|
+
- **No containers for things the test doesn't use.** A unit test stays a unit test.
|
|
39
|
+
|
|
40
|
+
## Run one test
|
|
41
|
+
- Docker must be running on the machine, or in CI with the runner's Docker. Run the one test as the repository's own instructions say (`mvn -Dtest=OrderRepositoryIT test`, `dotnet test --filter FullyQualifiedName~OrderRepositoryTests`, `npx jest orders.int.test.ts`, `pytest tests/test_orders.py -k create`, `go test ./... -run TestOrders`).
|
|
42
|
+
- The first run pulls the images, so allow for a slow first run before you judge a timeout.
|
package/src/agents.ts
CHANGED
|
@@ -19,12 +19,21 @@ export type AgentEntry = {
|
|
|
19
19
|
skills?: SkillAgent
|
|
20
20
|
/** Where the code defines it. */
|
|
21
21
|
defined: string
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Most tokens its fixed context may take: the system prompt, and for ForgeQE's agent the capability index and the
|
|
24
|
+
* core capabilities' tools; each other capability is held to CAPABILITY_TOOL_BUDGET.
|
|
25
|
+
*/
|
|
23
26
|
budget: number
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Most tokens one capability's tool definitions may take: a turn of ForgeQE's agent carries the core and the tools of
|
|
31
|
+
* the capabilities it picks (src/mcp/scope.ts), so each capability, not the whole list, is what has to stay small.
|
|
32
|
+
*/
|
|
33
|
+
export const CAPABILITY_TOOL_BUDGET = 2500
|
|
34
|
+
|
|
26
35
|
export const AGENTS: AgentEntry[] = [
|
|
27
|
-
{ name: "forgeqe", title: "ForgeQE's agent (shell, forgeqe run, forgeqe mcp)", kind: "agent-runtime", prompts: ["brain"], skills: "forgeqe", defined: "src/brain/index.ts", budget:
|
|
36
|
+
{ name: "forgeqe", title: "ForgeQE's agent (shell, forgeqe run, forgeqe mcp)", kind: "agent-runtime", prompts: ["brain"], skills: "forgeqe", defined: "src/brain/index.ts", budget: 10000 },
|
|
28
37
|
{ name: "forgeqe-test", title: "Scribe: writes a test from a verified run", kind: "agent-runtime", prompts: ["test-agent"], skills: "scribe", defined: "src/code/runtime.ts", budget: 4900 },
|
|
29
38
|
{ name: "forgeqe-read", title: "Scribe, reading the repository before the run arrives", kind: "agent-runtime", prompts: ["test-agent"], skills: "scribe", defined: "src/code/runtime.ts", budget: 4900 },
|
|
30
39
|
{ name: "forgeqe-review", title: "Reviewer of Scribe's tests", kind: "agent-runtime", prompts: ["test-reviewer"], skills: "scribe", defined: "src/code/review.ts", budget: 4900 },
|
package/src/api/plan.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { knowledgeBlock, readKnowledge, relevantKnowledge } from "../knowledge.t
|
|
|
9
9
|
import type { Contract, ContractSet, KeyField, Scenario, ScenarioStep } from "./types.ts"
|
|
10
10
|
import { prompt } from "../prompts.ts"
|
|
11
11
|
import { domainNotes } from "../domain.ts"
|
|
12
|
+
import { guardrailsNotes } from "../guardrails.ts"
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Scenarios planned by a model from test cases (Azure DevOps, Jira, Xray, TestRail, files) or plain English, against
|
|
@@ -221,6 +222,7 @@ export async function planScenarios(opts: {
|
|
|
221
222
|
repo: opts.cwd,
|
|
222
223
|
profile: opts.config.llm,
|
|
223
224
|
domain: domainNotes(opts.config),
|
|
225
|
+
guardrails: guardrailsNotes(opts.config),
|
|
224
226
|
model: opts.config.code.model,
|
|
225
227
|
bin: opts.config.code.opencode,
|
|
226
228
|
readDirs: [opts.cwd],
|
package/src/api/write.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { briefMarkdown, scenarioMarkdown } from "./store.ts"
|
|
|
17
17
|
import type { ContractSet, Scenario } from "./types.ts"
|
|
18
18
|
import { prompt } from "../prompts.ts"
|
|
19
19
|
import { domainNotes } from "../domain.ts"
|
|
20
|
+
import { guardrailsNotes } from "../guardrails.ts"
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* The coding agent writes API and contract tests for a contract set in the repository's own framework, and ForgeQE
|
|
@@ -157,6 +158,7 @@ export async function writeApiTests(opts: {
|
|
|
157
158
|
repo,
|
|
158
159
|
profile: config.llm,
|
|
159
160
|
domain: domainNotes(config),
|
|
161
|
+
guardrails: guardrailsNotes(config),
|
|
160
162
|
model: config.code.model,
|
|
161
163
|
bin: config.code.opencode,
|
|
162
164
|
readDirs: [repo, opts.setDir],
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
|
6
|
+
|
|
7
|
+
import { allowListEnv } from "../childEnv.ts"
|
|
8
|
+
import { startServer } from "../code/runtime.ts"
|
|
9
|
+
import { COPILOT_PROVIDER_ID, parseCopilotAuth, type CopilotAuth } from "../copilot.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The GitHub Copilot device sign-in, run by the agent runtime's own Copilot provider (the sign-in GitHub supports for
|
|
13
|
+
* it). The runtime runs in a throwaway folder, so its auth.json exists only until the sign-in is read back and moved
|
|
14
|
+
* to ForgeQE's credential store.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type DeviceLoginIO = {
|
|
18
|
+
/** The one-time code and the page to enter it on. */
|
|
19
|
+
showCode(code: string, url: string): void
|
|
20
|
+
signal?: AbortSignal
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How long GitHub's device codes stay valid. */
|
|
24
|
+
const DEVICE_CODE_MS = 15 * 60_000
|
|
25
|
+
|
|
26
|
+
/** The page GitHub asks the user to enter the code on, when it is GitHub's own (or the enterprise host's) https page. */
|
|
27
|
+
export function verificationUrl(raw: string, enterprise?: string): string | null {
|
|
28
|
+
let u: URL
|
|
29
|
+
try {
|
|
30
|
+
u = new URL(raw)
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
if (u.protocol !== "https:" || u.username || u.password || u.port) return null
|
|
35
|
+
return u.hostname === (enterprise || "github.com") ? u.toString() : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "Enter code: ABCD-1234" → "ABCD-1234". */
|
|
39
|
+
export function userCode(instructions: string): string | null {
|
|
40
|
+
return /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/.exec(instructions || "")?.[1] || null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const message = (error: unknown) => {
|
|
44
|
+
const e = error as { data?: { message?: string }; name?: string } | undefined
|
|
45
|
+
return e?.data?.message || e?.name || "no reason given"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sign in to GitHub Copilot with the device flow: GitHub.com, or `enterprise` (a GitHub Enterprise host such as
|
|
50
|
+
* acme.ghe.com). Returns the sign-in and the Copilot models it can use.
|
|
51
|
+
*/
|
|
52
|
+
export async function copilotDeviceLogin(bin: string, io: DeviceLoginIO, enterprise?: string): Promise<{ auth: CopilotAuth; models: string[] }> {
|
|
53
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "forgeqe-copilot-"))
|
|
54
|
+
const sub = (name: string) => {
|
|
55
|
+
const p = path.join(dir, name)
|
|
56
|
+
mkdirSync(p, { recursive: true, mode: 0o700 })
|
|
57
|
+
return p
|
|
58
|
+
}
|
|
59
|
+
const env = {
|
|
60
|
+
...allowListEnv(process.env, []),
|
|
61
|
+
XDG_DATA_HOME: sub("data"),
|
|
62
|
+
XDG_CONFIG_HOME: sub("config"),
|
|
63
|
+
XDG_STATE_HOME: sub("state"),
|
|
64
|
+
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
|
65
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({ $schema: "https://opencode.ai/config.json", share: "disabled", autoupdate: false }),
|
|
66
|
+
}
|
|
67
|
+
let server: { url: string; close(): void } | null = null
|
|
68
|
+
try {
|
|
69
|
+
server = await startServer(bin, env, 60_000)
|
|
70
|
+
const client = createOpencodeClient({ baseUrl: server.url, directory: dir })
|
|
71
|
+
const methods = (await client.provider.auth()).data?.[COPILOT_PROVIDER_ID] || []
|
|
72
|
+
const method = methods.findIndex((m) => m.type === "oauth")
|
|
73
|
+
if (method < 0) throw new Error("This agent runtime has no GitHub Copilot sign-in. Run forgeqe setup to install the version ForgeQE pins.")
|
|
74
|
+
const inputs: Record<string, string> = enterprise ? { deploymentType: "enterprise", enterpriseUrl: enterprise } : { deploymentType: "github.com" }
|
|
75
|
+
const started = await client.provider.oauth.authorize({ providerID: COPILOT_PROVIDER_ID, method, inputs })
|
|
76
|
+
if (!started.data) throw new Error(`GitHub did not start the sign-in: ${message(started.error)}`)
|
|
77
|
+
const url = verificationUrl(started.data.url, enterprise)
|
|
78
|
+
const code = userCode(started.data.instructions)
|
|
79
|
+
if (!url || !code) throw new Error("The agent runtime's sign-in did not give a GitHub page and one-time code.")
|
|
80
|
+
io.showCode(code, url)
|
|
81
|
+
|
|
82
|
+
// The callback returns once GitHub has the code entered (or the code expires); a stop gives up waiting.
|
|
83
|
+
const waited = await new Promise<{ ok: boolean; why?: string }>((resolve) => {
|
|
84
|
+
const timer = setTimeout(() => resolve({ ok: false, why: "The one-time code expired before it was entered." }), DEVICE_CODE_MS)
|
|
85
|
+
const stop = () => resolve({ ok: false, why: "Cancelled." })
|
|
86
|
+
io.signal?.addEventListener("abort", stop, { once: true })
|
|
87
|
+
client.provider.oauth
|
|
88
|
+
.callback({ providerID: COPILOT_PROVIDER_ID, method })
|
|
89
|
+
.then((r) => resolve(r.error ? { ok: false, why: `GitHub did not finish the sign-in: ${message(r.error)}` } : { ok: true }))
|
|
90
|
+
.catch((err) => resolve({ ok: false, why: `GitHub did not finish the sign-in: ${(err as Error).message}` }))
|
|
91
|
+
.finally(() => {
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
io.signal?.removeEventListener("abort", stop)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
if (!waited.ok) throw new Error(waited.why)
|
|
97
|
+
|
|
98
|
+
let saved: unknown
|
|
99
|
+
try {
|
|
100
|
+
saved = (JSON.parse(readFileSync(path.join(env.XDG_DATA_HOME, "opencode", "auth.json"), "utf8")) as Record<string, unknown>)[COPILOT_PROVIDER_ID]
|
|
101
|
+
} catch {}
|
|
102
|
+
const auth = parseCopilotAuth(saved)
|
|
103
|
+
if (!auth) throw new Error("The agent runtime did not save a GitHub Copilot sign-in.")
|
|
104
|
+
if ((auth.enterpriseUrl || undefined) !== enterprise) throw new Error("The Copilot sign-in is for a different GitHub than the one chosen.")
|
|
105
|
+
|
|
106
|
+
let models: string[] = []
|
|
107
|
+
try {
|
|
108
|
+
const list = (await client.provider.list()).data
|
|
109
|
+
const copilot = list?.all?.find((p) => p.id === COPILOT_PROVIDER_ID)
|
|
110
|
+
models = Object.keys(copilot?.models || {}).sort()
|
|
111
|
+
} catch {}
|
|
112
|
+
return { auth, models }
|
|
113
|
+
} finally {
|
|
114
|
+
server?.close()
|
|
115
|
+
rmSync(dir, { recursive: true, force: true })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The GitHub account a Copilot sign-in belongs to, or undefined when GitHub does not say. */
|
|
120
|
+
export async function copilotAccount(auth: CopilotAuth, fetchImpl: typeof fetch = fetch): Promise<string | undefined> {
|
|
121
|
+
const api = auth.enterpriseUrl ? `https://api.${auth.enterpriseUrl}` : "https://api.github.com"
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetchImpl(`${api}/user`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${auth.refresh}` }, signal: AbortSignal.timeout(10_000) })
|
|
124
|
+
if (!res.ok) return undefined
|
|
125
|
+
const login = ((await res.json()) as { login?: unknown }).login
|
|
126
|
+
return typeof login === "string" && /^[A-Za-z0-9-]{1,39}$/.test(login) ? login : undefined
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/auth/signin.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process"
|
|
2
2
|
|
|
3
3
|
import { azCliAuth, resetAzCliAuth } from "../cases/ado.ts"
|
|
4
|
+
import { ghAuthToken } from "../cases/github.ts"
|
|
5
|
+
import { ensureOpencode, INSTALL_HINT } from "../code/runtime.ts"
|
|
6
|
+
import { COPILOT_AUTH_ENV, enterpriseHost, parseCopilotAuth, runtimeHasCopilot } from "../copilot.ts"
|
|
4
7
|
import { basic, http } from "../cases/http.ts"
|
|
5
8
|
import type { AppConfig } from "../config.ts"
|
|
6
9
|
import type { AskQuestion } from "../mcp/tools.ts"
|
|
7
10
|
import { withoutTelemetry } from "../privacy.ts"
|
|
11
|
+
import { copilotAccount, copilotDeviceLogin } from "./copilot.ts"
|
|
8
12
|
import { forgetSystem, fromSaved, readIndex, savedSecrets, saveSystem, storeName, type Via } from "./store.ts"
|
|
9
13
|
|
|
10
14
|
/** How a sign-in talks to the user: the terminal (`forgeqe auth`) or the shell's dialogs (/auth). */
|
|
@@ -19,11 +23,12 @@ export type AuthIO = {
|
|
|
19
23
|
signal?: AbortSignal
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
export type SystemId = "azure_devops" | "github" | "jira" | "confluence" | "testrail" | "zephyr" | "qtest" | "xray" | "browserstack" | "testmu"
|
|
26
|
+
export type SystemId = "azure_devops" | "github" | "github_copilot" | "jira" | "confluence" | "testrail" | "zephyr" | "qtest" | "xray" | "browserstack" | "testmu"
|
|
23
27
|
|
|
24
28
|
export const SYSTEMS: { id: SystemId; label: string; aliases: string[] }[] = [
|
|
25
29
|
{ id: "azure_devops", label: "Azure DevOps", aliases: ["ado", "azure", "devops", "azdo", "azure-devops"] },
|
|
26
30
|
{ id: "github", label: "GitHub", aliases: ["gh"] },
|
|
31
|
+
{ id: "github_copilot", label: "GitHub Copilot", aliases: ["copilot", "github-copilot", "ghcp"] },
|
|
27
32
|
{ id: "jira", label: "Jira", aliases: ["atlassian"] },
|
|
28
33
|
{ id: "confluence", label: "Confluence", aliases: ["wiki"] },
|
|
29
34
|
{ id: "testrail", label: "TestRail", aliases: [] },
|
|
@@ -295,6 +300,51 @@ async function signInGithub(io: AuthIO): Promise<SignInResult> {
|
|
|
295
300
|
return finish(io, "github", { via: "gh", env: {}, secrets: {}, account }, "GitHub CLI")
|
|
296
301
|
}
|
|
297
302
|
|
|
303
|
+
/**
|
|
304
|
+
* GitHub Copilot as the model for ForgeQE's agent and Scribe: the agent runtime's own Copilot device sign-in, kept in
|
|
305
|
+
* ForgeQE's credential store. The browser engine uses GitHub Models with the GitHub CLI's sign-in, so say whether it has one.
|
|
306
|
+
*/
|
|
307
|
+
async function signInCopilot(io: AuthIO, cfg?: AppConfig): Promise<SignInResult> {
|
|
308
|
+
const where =
|
|
309
|
+
(await io.choose({
|
|
310
|
+
title: "Sign in to GitHub Copilot",
|
|
311
|
+
detail:
|
|
312
|
+
"ForgeQE's agent and Scribe then run on your Copilot seat (Pro, Pro+, Business or Enterprise), through the agent runtime's GitHub Copilot sign-in. " +
|
|
313
|
+
"The browser engine uses GitHub Models with your GitHub CLI sign-in.",
|
|
314
|
+
options: [
|
|
315
|
+
{ id: "github.com", label: "GitHub.com" },
|
|
316
|
+
{ id: "enterprise", label: "GitHub Enterprise (data residency, e.g. acme.ghe.com)" },
|
|
317
|
+
{ id: "cancel", label: "Cancel" },
|
|
318
|
+
],
|
|
319
|
+
})) || "cancel"
|
|
320
|
+
if (where === "cancel") throw new Cancelled()
|
|
321
|
+
let enterprise: string | undefined
|
|
322
|
+
if (where === "enterprise") {
|
|
323
|
+
const raw = await ask(io, "Your GitHub Enterprise domain (e.g. acme.ghe.com)")
|
|
324
|
+
enterprise = enterpriseHost(raw) || undefined
|
|
325
|
+
if (!enterprise) throw new Error(`"${raw.slice(0, 80)}" is not a GitHub Enterprise domain: give its host, like acme.ghe.com.`)
|
|
326
|
+
}
|
|
327
|
+
const bin = await ensureOpencode(cfg?.code.opencode, (text) => io.say(text))
|
|
328
|
+
if (!bin) throw new Error(`GitHub Copilot signs in through the agent runtime, which is not installed. ${INSTALL_HINT}`)
|
|
329
|
+
const { auth, models } = await copilotDeviceLogin(
|
|
330
|
+
bin,
|
|
331
|
+
{
|
|
332
|
+
showCode: (code, url) => {
|
|
333
|
+
io.say(`Your one-time code is ${code}. Enter it on the GitHub page that opens in your browser (${url}), and authorize with the account that has the Copilot seat.`)
|
|
334
|
+
io.openUrl(url)
|
|
335
|
+
},
|
|
336
|
+
signal: io.signal,
|
|
337
|
+
},
|
|
338
|
+
enterprise,
|
|
339
|
+
)
|
|
340
|
+
const account = await copilotAccount(auth)
|
|
341
|
+
const result = await finish(io, "github_copilot", { via: "token", env: {}, secrets: { [COPILOT_AUTH_ENV]: JSON.stringify(auth) }, account }, "Copilot device sign-in")
|
|
342
|
+
if (models.length) io.say(`Copilot models for this seat: ${models.join(", ")}. Put one in llms.json as the github_copilot profile's "model".`)
|
|
343
|
+
if (!process.env.GITHUB_TOKEN?.trim() && !ghAuthToken())
|
|
344
|
+
io.say("The browser engine reaches GitHub Models with a GitHub sign-in, and there is none yet: run forgeqe auth login github, or set GITHUB_TOKEN.")
|
|
345
|
+
return result
|
|
346
|
+
}
|
|
347
|
+
|
|
298
348
|
async function signInAtlassian(io: AuthIO, cfg: AppConfig | undefined, which: "jira" | "confluence"): Promise<SignInResult> {
|
|
299
349
|
const names = envNames(cfg)[which]
|
|
300
350
|
const label = which === "jira" ? "Jira" : "Confluence"
|
|
@@ -441,6 +491,8 @@ export async function signIn(io: AuthIO, system: string | undefined, cfg?: AppCo
|
|
|
441
491
|
return await signInAdo(io, cfg)
|
|
442
492
|
case "github":
|
|
443
493
|
return await signInGithub(io)
|
|
494
|
+
case "github_copilot":
|
|
495
|
+
return await signInCopilot(io, cfg)
|
|
444
496
|
case "jira":
|
|
445
497
|
case "confluence":
|
|
446
498
|
return await signInAtlassian(io, cfg, id)
|
|
@@ -507,6 +559,7 @@ export function authStatus(cfg?: AppConfig): string[] {
|
|
|
507
559
|
const lines = [
|
|
508
560
|
row("azure_devops", "Azure DevOps", ado && `${ado}, ${adoUrl}`),
|
|
509
561
|
`${"GitHub".padEnd(14)}${ghUser ? `GitHub CLI as ${ghUser}` : Bun.which("gh") ? "not signed in" : "GitHub CLI (gh) not installed"}`,
|
|
562
|
+
row("github_copilot", "Copilot", parseCopilotAuth(process.env[COPILOT_AUTH_ENV]) ? (fromSaved.has(COPILOT_AUTH_ENV) ? "saved sign-in" : "environment") : runtimeHasCopilot() ? "agent runtime's own sign-in" : null),
|
|
510
563
|
row("jira", "Jira", jira && `${jira}, ${jiraUrl}`),
|
|
511
564
|
row("confluence", "Confluence", confHow && `${confHow}, ${conf!.url}`),
|
|
512
565
|
row("testrail", "TestRail", cfg?.sources.testrail || process.env[n.testrail.url] ? source([n.testrail.email, n.testrail.token]) : null),
|
package/src/brain/index.ts
CHANGED
|
@@ -9,11 +9,13 @@ import { externalMcp, externalNotes, type ExternalPlan } from "../mcp/external.t
|
|
|
9
9
|
import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
|
|
10
10
|
import { TOOLS, environmentNotes, type ToolContext } from "../mcp/tools.ts"
|
|
11
11
|
import { createRouter } from "../mcp/routing.ts"
|
|
12
|
+
import { CapabilityScope, capabilityIndex } from "../mcp/scope.ts"
|
|
12
13
|
import { brainPrompt, BRAIN_SCHEMA, cleanFollowUps, type BrainReport, type FollowUp } from "./prompt.ts"
|
|
13
14
|
import { redactSecrets } from "../sessionLogins.ts"
|
|
14
15
|
import { logEvent } from "../sessionLog.ts"
|
|
15
16
|
import { loadSkills } from "../skills/registry.ts"
|
|
16
17
|
import { domainNotes } from "../domain.ts"
|
|
18
|
+
import { guardrailsNotes } from "../guardrails.ts"
|
|
17
19
|
|
|
18
20
|
export const BRAIN_AGENT = "forgeqe"
|
|
19
21
|
|
|
@@ -119,12 +121,19 @@ export function carryOver(turns: { user: string; reply: string }[], text: string
|
|
|
119
121
|
/** Routing (src/mcp/routing.ts) is on unless FORGEQE_ROUTING=off, kept for comparing and for finding a routing fault. */
|
|
120
122
|
export const routingOn = (env: Record<string, string | undefined> = process.env) => !/^(off|0|false|no)$/i.test(env.FORGEQE_ROUTING || "")
|
|
121
123
|
|
|
124
|
+
/** Per-turn tool scope (src/mcp/scope.ts), part of routing; FORGEQE_TOOL_SCOPE=off gives every tool on every turn. */
|
|
125
|
+
export const toolScopeOn = (env: Record<string, string | undefined> = process.env) => routingOn(env) && !/^(off|0|false|no)$/i.test(env.FORGEQE_TOOL_SCOPE || "")
|
|
126
|
+
|
|
127
|
+
/** Continuations a turn may get after use_capabilities added capabilities, so a confused agent cannot loop. */
|
|
128
|
+
const MAX_SCOPE_CONTINUATIONS = 2
|
|
129
|
+
|
|
122
130
|
export async function openBrain(opts: BrainOptions): Promise<Brain> {
|
|
123
131
|
const { config } = opts
|
|
124
132
|
const cwd = realpathOr(opts.cwd)
|
|
125
133
|
const context = (): ToolContext => ({ ...opts.context(), config, cwd })
|
|
126
134
|
// Tools listed in one line and playbooks on demand keep every model call small; FORGEQE_ROUTING=off sends it all.
|
|
127
|
-
const
|
|
135
|
+
const scope = toolScopeOn() ? new CapabilityScope(config, TOOLS.map((t) => t.name)) : undefined
|
|
136
|
+
const router = routingOn() ? createRouter(TOOLS, { scope }) : undefined
|
|
128
137
|
const tools = localToolServer({ tools: TOOLS, context, router, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
|
|
129
138
|
const external = opts.external || externalMcp(config, Bun.which, process.env, { cwd })
|
|
130
139
|
const notes = [environmentNotes(context()), ...externalNotes(external), ...(opts.notes || [])].filter(Boolean).join("\n")
|
|
@@ -142,6 +151,7 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
|
|
|
142
151
|
repo: cwd,
|
|
143
152
|
profile: config.llm,
|
|
144
153
|
domain: domainNotes(config),
|
|
154
|
+
guardrails: guardrailsNotes(config),
|
|
145
155
|
model: config.code.model,
|
|
146
156
|
bin: config.code.opencode,
|
|
147
157
|
readDirs,
|
|
@@ -153,10 +163,11 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
|
|
|
153
163
|
agent: {
|
|
154
164
|
name: BRAIN_AGENT,
|
|
155
165
|
description: "ForgeQE: browser runs, test cases, tests, and CI pipelines",
|
|
156
|
-
prompt: `${router ? router.parts.core : brainPrompt()}\n\nThis session:\n${notes}`,
|
|
166
|
+
prompt: `${router ? router.parts.core : brainPrompt()}${scope ? `\n\n${capabilityIndex()}` : ""}\n\nThis session:\n${notes}`,
|
|
157
167
|
skills: "forgeqe",
|
|
158
168
|
// Systems are read through MCP or query_api, never by scripts in the shell that handle tokens themselves.
|
|
159
|
-
|
|
169
|
+
// Container services' secrets are the tests' and the containers', never the agent's to read in its shell.
|
|
170
|
+
permission: { bash: agentBashPermission([...privateEnvNames(config), ...Object.values(config.containers?.services || {}).flatMap((s) => s.secrets)]) },
|
|
160
171
|
},
|
|
161
172
|
skills: loadSkills(config.skills, cwd).skills,
|
|
162
173
|
schema: BRAIN_SCHEMA,
|
|
@@ -184,7 +195,18 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
|
|
|
184
195
|
async ask(text) {
|
|
185
196
|
stopped = false
|
|
186
197
|
logEvent("agent.ask", { model: session.model, characters: text.length })
|
|
187
|
-
const
|
|
198
|
+
const turn = scope?.beginTurn(text)
|
|
199
|
+
if (turn) logEvent("agent.scope", { capabilities: turn.choice.reasons })
|
|
200
|
+
let out = await session.prompt(text, turn ? { tools: turn.tools } : undefined)
|
|
201
|
+
for (let i = 0; scope && i < MAX_SCOPE_CONTINUATIONS; i++) {
|
|
202
|
+
const added = scope.takePending()
|
|
203
|
+
if (!added.length || stopped || out.error === "stopped" || opts.signal?.aborted) break
|
|
204
|
+
logEvent("agent.scope.added", { capabilities: added })
|
|
205
|
+
out = await session.prompt(
|
|
206
|
+
`ForgeQE added ${added.join(", ")}: their tools are available now. Carry on with the user's request from where you stopped; do not repeat what is done.`,
|
|
207
|
+
{ tools: scope.toggles() },
|
|
208
|
+
)
|
|
209
|
+
}
|
|
188
210
|
// Written after the turn, when credentials the message gave are remembered and so redacted.
|
|
189
211
|
logEvent("agent.turn", { ask: text, error: out.error, report: out.report, text: out.text })
|
|
190
212
|
if (stopped || out.error === "stopped" || opts.signal?.aborted) return { reply: "", outcome: "stopped" }
|
|
@@ -28,6 +28,8 @@ export const GUIDE_EXCLUDED = ["HOW_TO_USE.md#agent-map"]
|
|
|
28
28
|
|
|
29
29
|
const AREA_NOTE: Record<Area, string> = {
|
|
30
30
|
Browser: "Goals carried out in a real browser, and the logins they use",
|
|
31
|
+
Desktop: "Windows thick-client apps: explore a goal, record by hand, replay the saved workflow",
|
|
32
|
+
Mobile: "Android and iOS apps through Appium or Maestro: explore a goal, replay, run the team's flows",
|
|
31
33
|
"Test cases": "Run, check, write, and explore manual test cases",
|
|
32
34
|
"Automated tests": "Write, heal, find, run, and stabilise tests in your repository",
|
|
33
35
|
"Pull requests": "Reviews, impact, and the tests a change can break",
|