@capagents/forgeqe 2.2.2 → 2.2.3

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.
@@ -86,9 +86,11 @@ browser:
86
86
  max_steps: 40 # a checkpoint: a run still making progress carries on, up to 3x
87
87
  timeout_seconds: 600 # whole run
88
88
  vision_detail: low # low | high
89
- viewport:
89
+ viewport: # headless page size; a visible window too with window: viewport
90
90
  width: 1280
91
91
  height: 800
92
+ window: maximized # visible browser: maximized | viewport
93
+ video: true # video.mp4 of each run, shown in its report
92
94
  allowed_hosts: []
93
95
  judge: false # true: a second model call checks each finished run against the goal and can fail it
94
96
  overlay: true # visible browser: ForgeQE start page, edge glow, a bar with the current step, your mouse and keyboard locked while the agent works
package/llms.example.json CHANGED
@@ -50,10 +50,8 @@
50
50
  },
51
51
  "copilot": {
52
52
  "provider": "github_copilot",
53
- "model": "claude-sonnet-4.6",
54
- "engine_model": "openai/gpt-4.1",
55
- "github_org": "",
56
- "description": "GitHub Copilot seat for ForgeQE's agent and Scribe (forgeqe auth login copilot); the browser engine uses GitHub Models with your GitHub sign-in"
53
+ "model": "gpt-4.1",
54
+ "description": "GitHub Copilot seat for the browser engine, through GitHub's Copilot SDK (forgeqe auth login copilot: a fine-grained token with Copilot Requests, or the GitHub CLI's sign-in)"
57
55
  }
58
56
  }
59
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -40,11 +40,13 @@
40
40
  "forgeqe": "bun src/index.ts",
41
41
  "start": "bun src/index.ts start",
42
42
  "test": "bun test",
43
+ "test:release": "bun scripts/release-tests.ts",
43
44
  "typecheck": "tsc --noEmit",
44
45
  "build:report": "cd report-ui && npm install --no-audit --no-fund && npm run build && cp dist/index.html ../src/report/template.html",
45
46
  "build": "bun scripts/build.ts",
46
47
  "notices": "bun scripts/gen-notices.ts",
47
- "prepack": "bun scripts/build.ts && bun scripts/gen-notices.ts"
48
+ "prepack": "bun scripts/build.ts && bun scripts/gen-notices.ts",
49
+ "prepublishOnly": "bun run typecheck && bun scripts/release-tests.ts"
48
50
  },
49
51
  "keywords": [
50
52
  "forgeqe",
@@ -192,7 +192,20 @@ def _testmu_caps(remote: Dict[str, Any]) -> Dict[str, Any]:
192
192
  }
193
193
 
194
194
 
195
+ # browser.provider docker: ForgeQE starts the browser container and passes its gateway's endpoint (with its token) here.
196
+ DOCKER_CDP_ENV = "FORGEQE_BROWSER_CDP_URL"
197
+
198
+
199
+ def docker_browser_launch_kwargs() -> Dict[str, Any]:
200
+ url = (os.getenv(DOCKER_CDP_ENV) or "").strip()
201
+ if not url.startswith("ws://127.0.0.1:"):
202
+ raise RuntimeError(f"docker: {DOCKER_CDP_ENV} is not set to the browser container's local endpoint; ForgeQE starts that container itself.")
203
+ return {"cdp_url": url}
204
+
205
+
195
206
  def remote_browser_launch_kwargs(provider: str, remote: Dict[str, Any]) -> Dict[str, Any]:
207
+ if provider == "docker":
208
+ return docker_browser_launch_kwargs()
196
209
  endpoint = str(remote.get("endpoint") or "").strip().rstrip("/")
197
210
  if not endpoint:
198
211
  raise RuntimeError(f"{provider}: a remote endpoint is required.")
@@ -212,6 +225,8 @@ def browser_launch_kwargs(*, provider: str, headless: bool, remote: Optional[Dic
212
225
  def browser_connection_label(provider: str, launch: Dict[str, Any], remote: Optional[Dict[str, Any]] = None) -> str:
213
226
  if provider == "local":
214
227
  return str(launch.get("channel") or "chrome")
228
+ if provider == "docker":
229
+ return "Chromium in a container"
215
230
  browser = str((remote or {}).get("browser") or "")
216
231
  target = str((remote or {}).get("platform") or "").strip() or " ".join(
217
232
  x for x in [str((remote or {}).get("os") or "").strip(), str((remote or {}).get("os_version") or (remote or {}).get("osVersion") or "").strip()] if x
@@ -296,6 +311,93 @@ async def reopen_in_fresh_tab(browser: Any, url: str) -> None:
296
311
  await close.event_result(raise_if_any=False, raise_if_none=False)
297
312
 
298
313
 
314
+ VIDEO_FILE = "video.mp4"
315
+
316
+
317
+ def _size(value: Any) -> Optional[Dict[str, int]]:
318
+ """browser.viewport as [width, height], or None when it is not two sizes a screen can have."""
319
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
320
+ return None
321
+ try:
322
+ w, h = int(value[0]), int(value[1])
323
+ except (TypeError, ValueError):
324
+ return None
325
+ return {"width": w, "height": h} if 200 <= w <= 10000 and 200 <= h <= 10000 else None
326
+
327
+
328
+ def window_launch_kwargs(browser_cfg: Dict[str, Any], *, headless: bool) -> Dict[str, Any]:
329
+ """
330
+ The size of a local browser. Headless: the page is browser.viewport. Visible: the window is that size with
331
+ browser.window: viewport, else the library's screen-sized window, which `maximize_window` then maximises.
332
+ """
333
+ if str(browser_cfg.get("provider") or "local") != "local":
334
+ return {}
335
+ size = _size(browser_cfg.get("viewport"))
336
+ if not size:
337
+ return {}
338
+ if headless:
339
+ return {"viewport": size}
340
+ return {"window_size": size} if browser_cfg.get("window") == "viewport" else {}
341
+
342
+
343
+ def wants_maximized(browser_cfg: Dict[str, Any], *, headless: bool) -> bool:
344
+ return not headless and str(browser_cfg.get("provider") or "local") == "local" and browser_cfg.get("window") != "viewport"
345
+
346
+
347
+ async def maximize_window(browser: Any) -> bool:
348
+ """Maximise the visible browser's window (the library only sizes it to the screen, under the menu bar and Dock)."""
349
+ try:
350
+ session = await browser.get_or_create_cdp_session()
351
+ send = browser.cdp_client.send.Browser
352
+ found = await send.getWindowForTarget(params={"targetId": session.target_id})
353
+ window_id = found["windowId"]
354
+ await send.setWindowBounds(params={"windowId": window_id, "bounds": {"windowState": "maximized"}})
355
+ # The page takes the new size a moment later; the video is sized from it.
356
+ for _ in range(10):
357
+ bounds = (await send.getWindowBounds(params={"windowId": window_id})).get("bounds") or {}
358
+ if bounds.get("windowState") == "maximized":
359
+ break
360
+ await asyncio.sleep(0.1)
361
+ await asyncio.sleep(0.3)
362
+ return True
363
+ except Exception as ex:
364
+ logger.debug("maximise window failed: %s", ex)
365
+ return False
366
+
367
+
368
+ def video_available() -> bool:
369
+ try:
370
+ from browser_use.browser.video_recorder import IMAGEIO_AVAILABLE
371
+ except Exception:
372
+ return False
373
+ return bool(IMAGEIO_AVAILABLE)
374
+
375
+
376
+ async def start_video(browser: Any, out_dir: Path) -> Optional[Path]:
377
+ """Record the agent's tab to <out_dir>/video.mp4 until `stop_video`; None when it could not start."""
378
+ watchdog = getattr(browser, "_recording_watchdog", None)
379
+ if watchdog is None or not video_available():
380
+ return None
381
+ try:
382
+ return Path(await watchdog.start_recording(out_dir / VIDEO_FILE))
383
+ except Exception as ex:
384
+ logger.debug("video recording did not start: %s", ex)
385
+ return None
386
+
387
+
388
+ async def stop_video(browser: Any) -> Optional[Path]:
389
+ """Finish the video file; None when nothing was recorded."""
390
+ watchdog = getattr(browser, "_recording_watchdog", None)
391
+ if watchdog is None or not getattr(watchdog, "is_recording", False):
392
+ return None
393
+ try:
394
+ path = await asyncio.wait_for(watchdog.stop_recording(), timeout=120.0)
395
+ except Exception as ex:
396
+ logger.debug("video recording did not finish: %s", ex)
397
+ return None
398
+ return Path(path) if path and Path(path).is_file() and Path(path).stat().st_size else None
399
+
400
+
299
401
  async def _shutdown_browser_instance(browser: Any) -> None:
300
402
  """Stop the library gracefully, falling back to kill()."""
301
403
  if browser is None:
@@ -348,7 +450,10 @@ async def browser_profile(browser: Any, launch: Dict[str, Any], *, provider: str
348
450
  "extra_http_headers": {},
349
451
  "provider": provider,
350
452
  }
351
- if provider != "local":
453
+ if provider == "docker":
454
+ # Not a browser on another machine: the coding agent's replay and live browser use the same kind of container.
455
+ profile["container"] = True
456
+ elif provider != "local":
352
457
  host = urlparse(str(launch.get("cdp_url") or "")).netloc
353
458
  profile["remote_browser"] = f"{'BrowserStack' if provider == 'browserstack' else 'TestMu AI'} via {host}"
354
459
  if remote:
@@ -13,6 +13,7 @@ Engine process protocol, used by the Bun CLI:
13
13
  python -m forgeqe_engine doctor
14
14
  python -m forgeqe_engine token (stdin: an llms.json profile with auth: entra; prints {token, expires_on})
15
15
  python -m forgeqe_engine probe (stdin: an llms.json profile; prints {ok, ms, structured, vision, error?})
16
+ python -m forgeqe_engine copilot-gateway (GitHub Copilot as an OpenAI-style endpoint on 127.0.0.1; see copilot_gateway.py)
16
17
  """
17
18
  from __future__ import annotations
18
19
 
@@ -73,6 +74,14 @@ def _doctor(out: TextIO) -> int:
73
74
  info["azure_identity"] = version("azure-identity")
74
75
  except Exception:
75
76
  pass
77
+ try:
78
+ info["copilot_sdk"] = version("github-copilot-sdk")
79
+ except Exception:
80
+ pass
81
+ try:
82
+ info["video"] = version("imageio")
83
+ except Exception:
84
+ pass
76
85
  try:
77
86
  from .service import local_browser_launch_kwargs
78
87
 
@@ -252,5 +261,9 @@ def main(argv: list[str] | None = None) -> int:
252
261
  return _token(out)
253
262
  if cmd == "probe":
254
263
  return _probe(out)
264
+ if cmd == "copilot-gateway":
265
+ from .copilot_gateway import main as copilot_gateway
266
+
267
+ return copilot_gateway(out)
255
268
  out.write(json.dumps({"type": "done", "result": {"success": False, "error": f"Unknown command {cmd}"}}) + "\n")
256
269
  return 2
@@ -0,0 +1,369 @@
1
+ """
2
+ GitHub Copilot as an OpenAI-style chat endpoint on this machine, through GitHub's Copilot SDK.
3
+
4
+ Copilot has no model API for other tools: the SDK runs Copilot "sessions" on the user's seat. The browser engine and
5
+ the desktop agent speak OpenAI chat completions, so this gateway turns each request into one SDK session with no
6
+ tools of its own (the system prompt replaced, screenshots attached, a JSON schema passed on) and answers in the
7
+ OpenAI shape.
8
+
9
+ python -m forgeqe_engine copilot-gateway
10
+ env COPILOT_GITHUB_TOKEN the GitHub token (fine-grained with Copilot Requests, or the GitHub CLI's)
11
+ FORGEQE_COPILOT_GATEWAY_KEY the one-time key every request must carry as "Bearer <key>"
12
+ stdout one line: {"port": N, "login": "<account>"} once it listens, or {"error": "..."}
13
+ stdin end of input stops it, so it never outlives ForgeQE
14
+
15
+ It listens on 127.0.0.1 only, compares the key in constant time and refuses requests a web page sends.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import hmac
21
+ import json
22
+ import os
23
+ import sys
24
+ import tempfile
25
+ import time
26
+ import uuid
27
+ from typing import Any, Dict, List, Optional, TextIO, Tuple
28
+
29
+ TOKEN_ENV = "COPILOT_GITHUB_TOKEN"
30
+ KEY_ENV = "FORGEQE_COPILOT_GATEWAY_KEY"
31
+
32
+ MAX_BODY = 32 * 1024 * 1024
33
+ # A screenshot step can take minutes on a slow model; past this the request fails rather than hangs.
34
+ REQUEST_TIMEOUT_S = 300.0
35
+ LOCAL_ORIGINS = ("http://127.0.0.1", "http://localhost", "https://127.0.0.1", "https://localhost")
36
+
37
+
38
+ class GatewayError(Exception):
39
+ def __init__(self, status: int, message: str):
40
+ super().__init__(message)
41
+ self.status = status
42
+ self.message = message
43
+
44
+
45
+ # ---------------------------------------------------------------------------------------------------------------------
46
+ # OpenAI chat completions → one Copilot session
47
+
48
+
49
+ def _parts(content: Any) -> List[Dict[str, Any]]:
50
+ if isinstance(content, str):
51
+ return [{"type": "text", "text": content}]
52
+ if isinstance(content, list):
53
+ return [p for p in content if isinstance(p, dict)]
54
+ return []
55
+
56
+
57
+ def _data_url(url: str) -> Optional[Tuple[str, str]]:
58
+ """data:image/png;base64,AAAA → ("image/png", "AAAA"); None for anything else (remote images are not fetched)."""
59
+ if not url.startswith("data:") or ";base64," not in url:
60
+ return None
61
+ head, data = url[5:].split(";base64,", 1)
62
+ mime = head.split(";", 1)[0].strip().lower()
63
+ if not mime.startswith("image/") or not data:
64
+ return None
65
+ return mime, data
66
+
67
+
68
+ def translate(body: Dict[str, Any]) -> Dict[str, Any]:
69
+ """The session a chat completions request becomes: {model, system, prompt, attachments, schema, json_only}."""
70
+ if not isinstance(body, dict):
71
+ raise GatewayError(400, "The request body must be a JSON object.")
72
+ model = str(body.get("model") or "").strip()
73
+ if not model:
74
+ raise GatewayError(400, "The request names no model.")
75
+ if body.get("stream"):
76
+ raise GatewayError(400, "Streaming is not supported by ForgeQE's Copilot gateway.")
77
+ if body.get("tools") or body.get("functions"):
78
+ raise GatewayError(400, "Tool calls are not supported by ForgeQE's Copilot gateway yet.")
79
+ messages = body.get("messages")
80
+ if not isinstance(messages, list) or not messages:
81
+ raise GatewayError(400, "The request has no messages.")
82
+
83
+ system: List[str] = []
84
+ turns: List[Tuple[str, List[Dict[str, Any]]]] = []
85
+ for m in messages:
86
+ if not isinstance(m, dict):
87
+ continue
88
+ role = str(m.get("role") or "user")
89
+ parts = _parts(m.get("content"))
90
+ if role in ("system", "developer"):
91
+ system.extend(str(p.get("text") or "") for p in parts if p.get("type") == "text")
92
+ else:
93
+ turns.append((role, parts))
94
+ if not turns:
95
+ raise GatewayError(400, "The request has no user message.")
96
+
97
+ # Only the latest message's images go to the model; the browser agent sends the current screenshot there.
98
+ last_with_images = max((i for i, (_, parts) in enumerate(turns) if any(p.get("type") == "image_url" for p in parts)), default=-1)
99
+ attachments: List[Dict[str, Any]] = []
100
+ lines: List[str] = []
101
+ for i, (role, parts) in enumerate(turns):
102
+ texts: List[str] = []
103
+ for p in parts:
104
+ if p.get("type") == "text":
105
+ texts.append(str(p.get("text") or ""))
106
+ elif p.get("type") == "image_url":
107
+ url = p.get("image_url", {}).get("url") if isinstance(p.get("image_url"), dict) else p.get("image_url")
108
+ img = _data_url(str(url or ""))
109
+ if img and i == last_with_images:
110
+ attachments.append({"type": "blob", "mimeType": img[0], "data": img[1], "displayName": f"screenshot-{len(attachments) + 1}"})
111
+ texts.append(f"[image attached: screenshot-{len(attachments)}]")
112
+ else:
113
+ texts.append("[earlier image omitted]")
114
+ text = "\n".join(t for t in texts if t)
115
+ lines.append(text if len(turns) == 1 else f"<{role}>\n{text}\n</{role}>")
116
+ prompt = "\n\n".join(lines)
117
+ if len(turns) > 1:
118
+ prompt = "The conversation so far, oldest first. Reply as the assistant to the last message.\n\n" + prompt
119
+
120
+ schema = None
121
+ json_only = False
122
+ fmt = body.get("response_format")
123
+ if isinstance(fmt, dict):
124
+ if fmt.get("type") == "json_schema" and isinstance(fmt.get("json_schema"), dict):
125
+ schema = fmt["json_schema"].get("schema")
126
+ if not isinstance(schema, dict):
127
+ raise GatewayError(400, "response_format.json_schema has no schema.")
128
+ elif fmt.get("type") == "json_object":
129
+ json_only = True
130
+ prompt += "\n\nReply with a single JSON object and nothing else."
131
+ return {"model": model, "system": "\n\n".join(s for s in system if s), "prompt": prompt, "attachments": attachments, "schema": schema, "json_only": json_only}
132
+
133
+
134
+ def completion(model: str, content: str, usage: Dict[str, int]) -> Dict[str, Any]:
135
+ """An OpenAI chat.completion object."""
136
+ prompt_tokens = int(usage.get("input", 0))
137
+ completion_tokens = int(usage.get("output", 0))
138
+ out: Dict[str, Any] = {
139
+ "id": f"chatcmpl-{uuid.uuid4().hex}",
140
+ "object": "chat.completion",
141
+ "created": int(time.time()),
142
+ "model": model,
143
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
144
+ "usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens},
145
+ }
146
+ if usage.get("cached"):
147
+ out["usage"]["prompt_tokens_details"] = {"cached_tokens": int(usage["cached"])}
148
+ return out
149
+
150
+
151
+ def strip_fences(text: str) -> str:
152
+ """```json … ``` → the JSON inside, for models that wrap structured replies in a code fence."""
153
+ t = text.strip()
154
+ if t.startswith("```") and t.endswith("```"):
155
+ t = t[3:-3]
156
+ first, _, rest = t.partition("\n")
157
+ return (rest if first.strip().isalpha() or not first.strip() else t).strip()
158
+ return text
159
+
160
+
161
+ # ---------------------------------------------------------------------------------------------------------------------
162
+ # Requests from this machine only
163
+
164
+
165
+ def authorized(headers: Dict[str, str], key: str) -> bool:
166
+ expected = f"Bearer {key}".encode()
167
+ got = headers.get("authorization", "").encode()
168
+ return bool(key) and hmac.compare_digest(got, expected)
169
+
170
+
171
+ def local_origin(headers: Dict[str, str]) -> bool:
172
+ """True when no browser page sent the request: no Origin, or one on this machine."""
173
+ origin = headers.get("origin")
174
+ if origin is None:
175
+ return True
176
+ o = origin.strip().lower()
177
+ return any(o == base or o.startswith(base + ":") for base in LOCAL_ORIGINS)
178
+
179
+
180
+ # ---------------------------------------------------------------------------------------------------------------------
181
+ # The Copilot SDK
182
+
183
+
184
+ class Copilot:
185
+ def __init__(self, token: str):
186
+ from copilot import CopilotClient
187
+
188
+ # The SDK keeps its state under this folder, apart from the user's own Copilot CLI.
189
+ home = os.path.join(os.environ.get("FORGEQE_HOME") or os.path.join(os.path.expanduser("~"), ".forgeqe"), "copilot")
190
+ os.makedirs(home, exist_ok=True)
191
+ # An empty working folder: the session reads no project files and no custom instructions.
192
+ self.workdir = tempfile.mkdtemp(prefix="forgeqe-copilot-")
193
+ self.client = CopilotClient(github_token=token, use_logged_in_user=False, base_directory=home, working_directory=self.workdir, log_level="error")
194
+
195
+ async def start(self) -> None:
196
+ await self.client.start()
197
+ status = await self.client.get_auth_status()
198
+ self.login = status.login or ""
199
+ if not status.isAuthenticated:
200
+ raise GatewayError(401, "GitHub Copilot did not accept the token: use a fine-grained token with the Copilot Requests permission, or the GitHub CLI's sign-in, for an account with a Copilot seat.")
201
+
202
+ async def stop(self) -> None:
203
+ try:
204
+ await self.client.stop()
205
+ except Exception:
206
+ pass
207
+
208
+ async def models(self) -> List[Dict[str, Any]]:
209
+ out = []
210
+ for m in await self.client.list_models():
211
+ supports = getattr(getattr(m, "capabilities", None), "supports", None)
212
+ out.append({"id": m.id, "object": "model", "owned_by": "github-copilot", "vision": bool(getattr(supports, "vision", False))})
213
+ return out
214
+
215
+ async def complete(self, req: Dict[str, Any]) -> Dict[str, Any]:
216
+ from copilot.generated.rpc import PermissionDecisionReject
217
+ from copilot.session_events import AssistantMessageData, AssistantUsageData, SessionErrorData
218
+
219
+ usage = {"input": 0, "output": 0, "cached": 0}
220
+ errors: List[str] = []
221
+
222
+ def on_event(event: Any) -> None:
223
+ data = getattr(event, "data", None)
224
+ if isinstance(data, AssistantUsageData):
225
+ usage["input"] += data.input_tokens or 0
226
+ usage["output"] += data.output_tokens or 0
227
+ usage["cached"] += data.cache_read_tokens or 0
228
+ elif isinstance(data, SessionErrorData):
229
+ errors.append(data.message or data.error_type or "error")
230
+
231
+ session = await self.client.create_session(
232
+ model=req["model"],
233
+ # The caller's prompt is the whole prompt; without one the session still gets no tools.
234
+ system_message={"mode": "replace", "content": req["system"] or "You are a helpful assistant."},
235
+ available_tools=[],
236
+ on_permission_request=lambda *_: PermissionDecisionReject(),
237
+ working_directory=self.workdir,
238
+ skip_custom_instructions=True,
239
+ enable_config_discovery=False,
240
+ enable_skills=False,
241
+ enable_session_store=False,
242
+ on_event=on_event,
243
+ )
244
+ try:
245
+ reply = await session.send_and_wait(req["prompt"], attachments=req["attachments"] or None, response_schema=req["schema"], timeout=REQUEST_TIMEOUT_S)
246
+ except TimeoutError:
247
+ raise GatewayError(504, f"GitHub Copilot did not answer within {int(REQUEST_TIMEOUT_S)} seconds.")
248
+ finally:
249
+ try:
250
+ await session.disconnect()
251
+ except Exception:
252
+ pass
253
+ if errors:
254
+ msg = errors[-1]
255
+ raise GatewayError(401 if "auth" in msg.lower() or "401" in msg else 502, f"GitHub Copilot: {msg}")
256
+ data = getattr(reply, "data", None)
257
+ if not isinstance(data, AssistantMessageData):
258
+ raise GatewayError(502, "GitHub Copilot sent no reply.")
259
+ content = data.content or ""
260
+ if req["schema"] is not None or req["json_only"]:
261
+ content = strip_fences(content)
262
+ return completion(req["model"], content, usage)
263
+
264
+
265
+ # ---------------------------------------------------------------------------------------------------------------------
266
+ # A small HTTP/1.1 server: one request per connection
267
+
268
+
269
+ def _response(status: int, payload: Dict[str, Any]) -> bytes:
270
+ body = json.dumps(payload).encode()
271
+ reason = {200: "OK", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 413: "Payload Too Large", 502: "Bad Gateway", 504: "Gateway Timeout"}.get(status, "Error")
272
+ head = f"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n"
273
+ return head.encode() + body
274
+
275
+
276
+ def _error(status: int, message: str) -> bytes:
277
+ return _response(status, {"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
278
+
279
+
280
+ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, copilot: Any, key: str) -> None:
281
+ try:
282
+ try:
283
+ head = await reader.readuntil(b"\r\n\r\n")
284
+ except (asyncio.IncompleteReadError, asyncio.LimitOverrunError):
285
+ return
286
+ lines = head.decode("latin-1").split("\r\n")
287
+ method, path = (lines[0].split(" ") + ["", ""])[:2]
288
+ headers = {}
289
+ for line in lines[1:]:
290
+ if ":" in line:
291
+ k, v = line.split(":", 1)
292
+ headers[k.strip().lower()] = v.strip()
293
+ if not local_origin(headers):
294
+ writer.write(_error(403, "Requests from web pages are refused."))
295
+ return
296
+ if not authorized(headers, key):
297
+ writer.write(_error(401, "Unauthorized"))
298
+ return
299
+ path = path.split("?", 1)[0].rstrip("/")
300
+ if path.startswith("/v1/"):
301
+ path = path[3:]
302
+ if method == "GET" and path == "/models":
303
+ writer.write(_response(200, {"object": "list", "data": await copilot.models()}))
304
+ return
305
+ if method != "POST" or path != "/chat/completions":
306
+ writer.write(_error(404, f"{method} {path} is not served here."))
307
+ return
308
+ try:
309
+ length = int(headers.get("content-length", "0"))
310
+ except ValueError:
311
+ length = -1
312
+ if length < 0 or length > MAX_BODY:
313
+ writer.write(_error(413, "The request body is too large."))
314
+ return
315
+ raw = await reader.readexactly(length)
316
+ try:
317
+ body = json.loads(raw.decode("utf-8"))
318
+ except ValueError:
319
+ writer.write(_error(400, "The request body is not JSON."))
320
+ return
321
+ writer.write(_response(200, await copilot.complete(translate(body))))
322
+ except GatewayError as e:
323
+ writer.write(_error(e.status, e.message))
324
+ except Exception as e: # The SDK's own errors: report them rather than drop the connection.
325
+ writer.write(_error(502, f"GitHub Copilot: {type(e).__name__}: {e}"[:2000]))
326
+ finally:
327
+ try:
328
+ await writer.drain()
329
+ writer.close()
330
+ except Exception:
331
+ pass
332
+
333
+
334
+ async def serve(out: TextIO, copilot: Any = None) -> int:
335
+ key = os.environ.get(KEY_ENV, "")
336
+ token = os.environ.get(TOKEN_ENV, "").strip()
337
+ if len(key) < 32:
338
+ out.write(json.dumps({"error": f"{KEY_ENV} must hold the gateway's one-time key."}) + "\n")
339
+ return 2
340
+ if copilot is None:
341
+ if not token:
342
+ out.write(json.dumps({"error": "No GitHub token for Copilot: run forgeqe auth login copilot."}) + "\n")
343
+ return 1
344
+ if token.startswith("ghp_"):
345
+ out.write(json.dumps({"error": "GitHub Copilot does not take classic tokens (ghp_): use a fine-grained token with the Copilot Requests permission."}) + "\n")
346
+ return 1
347
+ try:
348
+ copilot = Copilot(token)
349
+ await copilot.start()
350
+ except GatewayError as e:
351
+ out.write(json.dumps({"error": e.message}) + "\n")
352
+ return 1
353
+ except Exception as e:
354
+ out.write(json.dumps({"error": f"GitHub Copilot could not start: {type(e).__name__}: {e}"[:1500]}) + "\n")
355
+ return 1
356
+ server = await asyncio.start_server(lambda r, w: handle(r, w, copilot, key), host="127.0.0.1", port=0, limit=64 * 1024)
357
+ port = server.sockets[0].getsockname()[1]
358
+ out.write(json.dumps({"port": port, "login": getattr(copilot, "login", "")}) + "\n")
359
+ out.flush()
360
+ loop = asyncio.get_running_loop()
361
+ # Stop when ForgeQE closes stdin (or exits).
362
+ await loop.run_in_executor(None, sys.stdin.read)
363
+ server.close()
364
+ await copilot.stop()
365
+ return 0
366
+
367
+
368
+ def main(out: TextIO) -> int:
369
+ return asyncio.run(serve(out))
@@ -46,11 +46,18 @@ from .llm import (
46
46
  _agent_usage,
47
47
  )
48
48
  from .browser import (
49
+ VIDEO_FILE,
49
50
  FreshTabAfterPassword,
50
51
  browser_connection_label,
51
52
  browser_launch_kwargs,
52
53
  local_browser_launch_kwargs,
54
+ maximize_window,
53
55
  settle_page,
56
+ start_video,
57
+ stop_video,
58
+ video_available,
59
+ wants_maximized,
60
+ window_launch_kwargs,
54
61
  _shutdown_browser_instance,
55
62
  _stop_agent_instance,
56
63
  browser_profile,
@@ -1004,6 +1011,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
1004
1011
  browser_provider = str(browser_cfg.get("provider") or "local")
1005
1012
  browser_remote = browser_cfg.get("remote") if isinstance(browser_cfg.get("remote"), dict) else {}
1006
1013
  browser_kw = browser_launch_kwargs(provider=browser_provider, remote=browser_remote, headless=headless)
1014
+ browser_kw.update(window_launch_kwargs(browser_cfg, headless=headless))
1007
1015
  if allowed_domains is not None:
1008
1016
  browser_kw["allowed_domains"] = allowed_domains
1009
1017
  logins = parse_logins(req.logins)
@@ -1142,6 +1150,21 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
1142
1150
  shared = mem.store.shared_start(goal, normalized_initial)
1143
1151
  if shared:
1144
1152
  flow, kind = shared, "shared"
1153
+ maximize = wants_maximized(browser_cfg, headless=headless)
1154
+ video = browser_cfg.get("video") is True
1155
+ if video and not video_available():
1156
+ emit({"type": "log", "level": "warn", "text": "No video for this run: the engine's video packages are missing. Run: forgeqe setup"})
1157
+ video = False
1158
+ if maximize or video:
1159
+ # Started before the agent so the window is maximised, and the video sized, before the first page opens.
1160
+ try:
1161
+ await browser.start()
1162
+ if maximize and not await maximize_window(browser):
1163
+ emit({"type": "log", "level": "debug", "text": "The browser window could not be maximised"})
1164
+ if video and await start_video(browser, out_dir) is None:
1165
+ emit({"type": "log", "level": "warn", "text": "No video for this run: recording did not start"})
1166
+ except Exception as ex:
1167
+ logger.debug("early browser start failed: %s", ex)
1145
1168
  if flow and kind and flow.get("steps"):
1146
1169
  if kind == "interrupted":
1147
1170
  replay_text = (
@@ -1668,6 +1691,8 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
1668
1691
  await _save_logins()
1669
1692
  if overlay is not None:
1670
1693
  overlay.close()
1694
+ if await stop_video(browser):
1695
+ emit({"type": "log", "level": "info", "text": "Video saved: video.mp4"})
1671
1696
  await _shutdown_browser_instance(browser)
1672
1697
 
1673
1698
 
@@ -1694,5 +1719,9 @@ async def run_with_events(req: RunRequest, emit: Emit, cancel_event: asyncio.Eve
1694
1719
  except Exception as ex:
1695
1720
  logger.warning("artifact write failed: %s", ex)
1696
1721
  out["files"] = []
1722
+ # The video is finished when the browser closes, after the other files were written.
1723
+ video = Path(req.out_dir) / VIDEO_FILE
1724
+ if video.is_file() and str(video) not in out["files"]:
1725
+ out["files"].append(str(video))
1697
1726
  emit({"type": "done", "result": {k: v for k, v in out.items() if k != "step_events"}})
1698
1727
  return out