@warpgogol/forge 4.2.3 → 4.2.4

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/AGENTS.md CHANGED
@@ -7,7 +7,7 @@ Portable governance skills and command modules extracted from the engine (RFC-03
7
7
  - `src/` — portable, no kernel imports. Contains skill schema, registry, validators, onboarding handlers, config module, canonical types, and utilities.
8
8
  - `os/` — ForgeModule registrations. RFC-0556: `os/compass/` and `os/werkstatt/` are fully autonomous — all command handlers are inlined in `os/*/handlers/` and no longer dynamically import `@warpgogol/*` packages. Other `os/` modules may still use dynamic imports where kernel integration is needed. RFC-0940: all `os/*.module.ts` files declare a required `runtime` field (`"autonomous"` or `"werkstatt-adapter"`). Only `os/werkstatt/` may import `@warpgogol/werkstatt-engine` — all other `os/` directories are autonomous. ADR-0019: `@warpgogol/werkstatt-shared` is no longer a dependency — `scanDirectoryForImports` is inlined in `os/core/handlers/forge-autonomy-validate.ts`. `forge.autonomy.validate` enforces FORGE-AUTONOMY-01. Type-only imports (`import type`) are exempt.
9
9
  - `bin/` — CLI entrypoint (`forge` command) for autonomous usage without `@warpgogol/werkstatt-engine`. **New `os/` modules MUST be registered manually in `bin/cli.ts` `buildRegistry()`** — the standalone CLI has no module auto-discovery. A module exported via `package.json` and wired in `tools/kernel.config.ts` profiles is still invisible to `forge <cmd>` until added to the registry array. Discovered 2026-09-16: `forge-adr` (and 6 other modules) existed in source but were unreachable via the CLI.
10
- - `skills/` — forge-managed skill definitions (29 fo skills + 5 shared + 3 meta = 37 skills). Project-declared skill packs (RFC-0539) live outside forge and are discovered via `discoverPackSkills` from `forge.yaml` `skillPacks` config.
10
+ - `skills/` — forge-managed skill definitions (29 fo skills + 6 shared + 3 meta = 38 skills). Project-declared skill packs (RFC-0539) live outside forge and are discovered via `discoverPackSkills` from `forge.yaml` `skillPacks` config.
11
11
 
12
12
  ## RFC-0855 program control-plane boundary
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warpgogol/forge",
3
- "version": "4.2.3",
3
+ "version": "4.2.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: extension-e2e-mitm
3
+ description: Intercept Chrome-extension tab navigations in Playwright e2e via a local CONNECT MITM proxy — the only reliable seam when page.route cannot see chrome.tabs.create traffic.
4
+ invocation: user
5
+ category: shared
6
+ concerns: code-mutation
7
+ dependsOn: []
8
+ languagePolicy: ref(PREFERENCES.md)
9
+ ---
10
+
11
+ # extension-e2e-mitm
12
+
13
+ Before starting, read `PREFERENCES.md` at the repository root. If the file is missing or `aiLanguage` is unset, ask the operator once and create the file using the `my-preferences` skill semantics.
14
+
15
+ Use this skill when a Playwright e2e suite must control what a Chrome MV3 extension's tabs load — and `context.route` / `page.route` cannot see the traffic.
16
+
17
+ ## When this applies
18
+
19
+ - The extension's service worker opens tabs via `chrome.tabs.create` (or `windows.create`).
20
+ - Playwright route interception is installed but the fixture HTML never reaches the tab.
21
+ - Symptom: `context.on("page")` fires with the real URL already in flight — the navigation started before the page surfaced to Playwright, so no route handler ever runs.
22
+
23
+ This is a hard platform limitation, not a misconfiguration: extension-spawned navigations bypass the Playwright route layer entirely. Do not retry routing with different scopes (`context.route`, per-page `page.route`, `routeFromHAR`) — none of them see the request. Intercept at the network layer instead.
24
+
25
+ ## The pattern
26
+
27
+ 1. **Local CONNECT proxy** (`fixture-proxy.ts` in this skill's directory — copy it into the project's e2e folder). It listens on `127.0.0.1`, accepts CONNECT only for an allowlist of host suffixes, answers TLS with a per-run self-signed cert, and serves the currently-installed fixture HTML for every request. Non-allowlisted CONNECTs are refused — the proxy never tunnels real traffic.
28
+ 2. **Browser flags** at `chromium.launchPersistentContext`:
29
+ - `--proxy-server=http://127.0.0.1:<port>` — all browser traffic through the proxy.
30
+ - `--proxy-bypass-list=127.0.0.1;localhost` — the local API/backend must NOT go through the proxy.
31
+ - `--ignore-certificate-errors-spki-list=<spki>` — whitelist ONLY the proxy cert's SPKI fingerprint (exposed as `proxy.spkiFingerprint`). Never use blanket `--ignore-certificate-errors` — it disables TLS verification for the whole browser.
32
+ 3. **Fixture swap** — `proxy.setContent(html)` changes what every intercepted request returns. Install fixtures per spec, not per suite.
33
+
34
+ ## Integration steps
35
+
36
+ 1. Copy `fixture-proxy.ts` into the project's e2e directory.
37
+ 2. Pass the site's host suffixes to `startFixtureProxy({ hosts: [...] })` — the cert CN/SAN and the CONNECT allowlist derive from them.
38
+ 3. Launch the browser with the three flags above (proxy server, bypass list, spki-list).
39
+ 4. In each spec, `proxy.setContent(fixtureHtml)` before triggering the extension flow; assert on backend state (D1/API), not on page DOM.
40
+ 5. Close the proxy with the browser context (`context.on("close", () => proxy.close())`).
41
+
42
+ ## Parameters and limits
43
+
44
+ - `hosts` — suffix list; `host === suffix || host.endsWith("." + suffix)` matches. Keep it tight: only the fixture-served sites.
45
+ - The cert is generated per run via `openssl req -x509` into a temp dir — openssl must be on PATH; no secrets touch the repo.
46
+ - The CONNECT parser reads the first TCP chunk — sufficient on loopback; do not reuse this proxy for real forwarding.
47
+ - Headed browser required for MV3 service workers — wrap CI in `xvfb-run`.
48
+
49
+ ## Anti-patterns
50
+
51
+ - Do not add runtime hooks to the extension to make it "testable" — the proxy keeps production code untouched.
52
+ - Do not copy fixture HTML into the e2e folder — serve the canonical fixtures from their owning package.
53
+ - Do not broaden the allowlist to "just make it work" — a refused CONNECT is the signal that a new host needs an explicit decision.
@@ -0,0 +1,113 @@
1
+ /*
2
+ @ai-invariant Fixture serving happens at the network layer — a local CONNECT proxy MITMs the allowlisted hosts.
3
+ <MODULE_CONTRACT>
4
+ <purpose>Starts a minimal CONNECT proxy on 127.0.0.1 that answers TLS for the allowlisted hosts with a per-run self-signed cert and serves the current fixture HTML for every request. Extension-spawned tab navigations bypass Playwright's route interception — the proxy intercepts them at the network layer instead.</purpose>
5
+ <non-goals>
6
+ <item>Does not tunnel or forward real traffic — non-allowlisted CONNECTs are refused; the backend bypasses the proxy via --proxy-bypass-list.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>Ported from dater RFC-0015 — parameterized host allowlist and cert SANs.</item>
11
+ </CHANGE_SUMMARY>
12
+ */
13
+
14
+ import { createServer, type Server } from "node:net";
15
+ import { TLSSocket, createSecureContext } from "node:tls";
16
+ import { execFileSync } from "node:child_process";
17
+ import { X509Certificate, createHash } from "node:crypto";
18
+ import { readFileSync, mkdtempSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { tmpdir } from "node:os";
21
+
22
+ export interface FixtureProxy {
23
+ port: number;
24
+ /** Base64 SHA-256 of the leaf cert's SPKI — for --ignore-certificate-errors-spki-list. */
25
+ spkiFingerprint: string;
26
+ /** Swap the HTML served for every intercepted request. */
27
+ setContent(html: string): void;
28
+ close(): void;
29
+ }
30
+
31
+ export interface FixtureProxyOptions {
32
+ /** Host suffixes the proxy may MITM — e.g. ["example.com"] also covers sub.example.com. */
33
+ hosts: string[];
34
+ }
35
+
36
+ function generateCert(dir: string, hosts: string[]): { key: Buffer; cert: Buffer } {
37
+ const keyPath = join(dir, "key.pem");
38
+ const certPath = join(dir, "cert.pem");
39
+ const primary = hosts[0];
40
+ if (!primary) throw new Error("fixture proxy needs at least one host");
41
+ const san = hosts.flatMap((h) => [`DNS:${h}`, `DNS:*.${h}`]).join(",");
42
+ execFileSync("openssl", [
43
+ "req", "-x509", "-newkey", "rsa:2048", "-nodes",
44
+ "-keyout", keyPath,
45
+ "-out", certPath,
46
+ "-days", "1",
47
+ "-subj", `/CN=*.${primary}`,
48
+ "-addext", `subjectAltName=${san}`,
49
+ ]);
50
+ return { key: readFileSync(keyPath), cert: readFileSync(certPath) };
51
+ }
52
+
53
+ /** Base64 SHA-256 of the cert's SubjectPublicKeyInfo — Chromium's spki-list format. */
54
+ function spkiFingerprintOf(cert: Buffer): string {
55
+ const spki = new X509Certificate(cert).publicKey.export({ format: "der", type: "spki" });
56
+ return createHash("sha256").update(spki).digest("base64");
57
+ }
58
+
59
+ /**
60
+ * Start a CONNECT proxy that MITMs the allowlisted hosts. Launch the browser
61
+ * with --proxy-server pointing here, --proxy-bypass-list for the local
62
+ * backend, and --ignore-certificate-errors-spki-list scoped to
63
+ * proxy.spkiFingerprint.
64
+ */
65
+ export async function startFixtureProxy(options: FixtureProxyOptions): Promise<FixtureProxy> {
66
+ const dir = mkdtempSync(join(tmpdir(), "e2e-fixture-cert-"));
67
+ const { key, cert } = generateCert(dir, options.hosts);
68
+ const secureContext = createSecureContext({ key, cert });
69
+ const spkiFingerprint = spkiFingerprintOf(cert);
70
+ let content = "<html><body>no fixture installed</body></html>";
71
+
72
+ const server: Server = createServer((sock) => {
73
+ sock.once("data", (head) => {
74
+ const line = head.toString("utf8").split("\r\n")[0] ?? "";
75
+ const m = line.match(/^CONNECT ([^:]+):(\d+)/);
76
+ if (!m) {
77
+ sock.destroy();
78
+ return;
79
+ }
80
+ const host = m[1]!;
81
+ if (!options.hosts.some((s) => host === s || host.endsWith(`.${s}`))) {
82
+ sock.destroy();
83
+ return;
84
+ }
85
+ sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
86
+ const tls = new TLSSocket(sock, { secureContext, isServer: true });
87
+ tls.once("secure", () => {
88
+ tls.once("data", () => {
89
+ const body = content;
90
+ tls.end(
91
+ `HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`,
92
+ );
93
+ });
94
+ });
95
+ tls.on("error", () => sock.destroy());
96
+ });
97
+ sock.on("error", () => sock.destroy());
98
+ });
99
+
100
+ await new Promise<void>((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
101
+ const port = (server.address() as { port: number }).port;
102
+
103
+ return {
104
+ port,
105
+ spkiFingerprint,
106
+ setContent(html: string) {
107
+ content = html;
108
+ },
109
+ close() {
110
+ server.close();
111
+ },
112
+ };
113
+ }