toolfactory 0.1.1

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 (228) hide show
  1. package/README.md +166 -0
  2. package/dist/bindings/index.d.ts +23 -0
  3. package/dist/bindings/index.js +29 -0
  4. package/dist/bindings/python.d.ts +33 -0
  5. package/dist/bindings/python.js +805 -0
  6. package/dist/bindings/python.test.d.ts +1 -0
  7. package/dist/bindings/python.test.js +316 -0
  8. package/dist/bindings/typescript.d.ts +39 -0
  9. package/dist/bindings/typescript.js +758 -0
  10. package/dist/bindings/typescript.test.d.ts +1 -0
  11. package/dist/bindings/typescript.test.js +264 -0
  12. package/dist/commands.d.ts +188 -0
  13. package/dist/commands.js +662 -0
  14. package/dist/commands.test.d.ts +1 -0
  15. package/dist/commands.test.js +215 -0
  16. package/dist/e2e.test.d.ts +1 -0
  17. package/dist/e2e.test.js +124 -0
  18. package/dist/hosts/browser.d.ts +10 -0
  19. package/dist/hosts/browser.js +94 -0
  20. package/dist/hosts/github.d.ts +66 -0
  21. package/dist/hosts/github.js +247 -0
  22. package/dist/hosts/openclaw.d.ts +4 -0
  23. package/dist/hosts/openclaw.js +94 -0
  24. package/dist/hosts/template.d.ts +15 -0
  25. package/dist/hosts/template.js +142 -0
  26. package/dist/hosts/web.d.ts +4 -0
  27. package/dist/hosts/web.js +89 -0
  28. package/dist/identity/name.d.ts +20 -0
  29. package/dist/identity/name.js +33 -0
  30. package/dist/identity/name.test.d.ts +1 -0
  31. package/dist/identity/name.test.js +37 -0
  32. package/dist/identity/read.d.ts +9 -0
  33. package/dist/identity/read.js +106 -0
  34. package/dist/identity/read.test.d.ts +1 -0
  35. package/dist/identity/read.test.js +76 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.js +6 -0
  38. package/dist/introspect/index.d.ts +15 -0
  39. package/dist/introspect/index.js +63 -0
  40. package/dist/model.d.ts +236 -0
  41. package/dist/model.js +214 -0
  42. package/dist/ops.d.ts +255 -0
  43. package/dist/ops.js +297 -0
  44. package/dist/project/apply.d.ts +35 -0
  45. package/dist/project/apply.js +360 -0
  46. package/dist/project/apply.test.d.ts +1 -0
  47. package/dist/project/apply.test.js +285 -0
  48. package/dist/project/gate.d.ts +129 -0
  49. package/dist/project/gate.js +597 -0
  50. package/dist/project/history.d.ts +62 -0
  51. package/dist/project/history.js +41 -0
  52. package/dist/project/load.d.ts +21 -0
  53. package/dist/project/load.js +82 -0
  54. package/dist/project/lock.d.ts +28 -0
  55. package/dist/project/lock.js +42 -0
  56. package/dist/project/plan.d.ts +5 -0
  57. package/dist/project/plan.js +96 -0
  58. package/dist/report/coverage.d.ts +22 -0
  59. package/dist/report/coverage.js +72 -0
  60. package/dist/report/coverage.test.d.ts +1 -0
  61. package/dist/report/coverage.test.js +93 -0
  62. package/dist/surfaces/agent-plugins.d.ts +10 -0
  63. package/dist/surfaces/agent-plugins.js +36 -0
  64. package/dist/surfaces/agents.d.ts +35 -0
  65. package/dist/surfaces/agents.js +609 -0
  66. package/dist/surfaces/agents.template.d.ts +8 -0
  67. package/dist/surfaces/agents.template.js +21 -0
  68. package/dist/surfaces/agents.test.d.ts +1 -0
  69. package/dist/surfaces/agents.test.js +181 -0
  70. package/dist/surfaces/browser-extension.d.ts +101 -0
  71. package/dist/surfaces/browser-extension.js +1034 -0
  72. package/dist/surfaces/browser-extension.test.d.ts +1 -0
  73. package/dist/surfaces/browser-extension.test.js +145 -0
  74. package/dist/surfaces/claude.d.ts +2 -0
  75. package/dist/surfaces/claude.js +81 -0
  76. package/dist/surfaces/codex.d.ts +13 -0
  77. package/dist/surfaces/codex.js +101 -0
  78. package/dist/surfaces/codex.test.d.ts +1 -0
  79. package/dist/surfaces/codex.test.js +67 -0
  80. package/dist/surfaces/cursor.d.ts +2 -0
  81. package/dist/surfaces/cursor.js +50 -0
  82. package/dist/surfaces/distribution.test.d.ts +1 -0
  83. package/dist/surfaces/distribution.test.js +146 -0
  84. package/dist/surfaces/dsh.d.ts +25 -0
  85. package/dist/surfaces/dsh.js +147 -0
  86. package/dist/surfaces/dsh.test.d.ts +1 -0
  87. package/dist/surfaces/dsh.test.js +100 -0
  88. package/dist/surfaces/external.d.ts +9 -0
  89. package/dist/surfaces/external.js +4 -0
  90. package/dist/surfaces/gemini.d.ts +29 -0
  91. package/dist/surfaces/gemini.js +68 -0
  92. package/dist/surfaces/gemini.test.d.ts +1 -0
  93. package/dist/surfaces/gemini.test.js +74 -0
  94. package/dist/surfaces/hermes-native.d.ts +53 -0
  95. package/dist/surfaces/hermes-native.js +507 -0
  96. package/dist/surfaces/hermes-native.test.d.ts +1 -0
  97. package/dist/surfaces/hermes-native.test.js +156 -0
  98. package/dist/surfaces/kernel.d.ts +5 -0
  99. package/dist/surfaces/kernel.js +63 -0
  100. package/dist/surfaces/mcp-registry.d.ts +19 -0
  101. package/dist/surfaces/mcp-registry.js +160 -0
  102. package/dist/surfaces/mcp-registry.test.d.ts +1 -0
  103. package/dist/surfaces/mcp-registry.test.js +79 -0
  104. package/dist/surfaces/mcpb.d.ts +11 -0
  105. package/dist/surfaces/mcpb.js +118 -0
  106. package/dist/surfaces/mcpb.test.d.ts +1 -0
  107. package/dist/surfaces/mcpb.test.js +104 -0
  108. package/dist/surfaces/npm.d.ts +6 -0
  109. package/dist/surfaces/npm.js +72 -0
  110. package/dist/surfaces/openclaw-native.d.ts +93 -0
  111. package/dist/surfaces/openclaw-native.js +956 -0
  112. package/dist/surfaces/openclaw-native.test.d.ts +1 -0
  113. package/dist/surfaces/openclaw-native.test.js +246 -0
  114. package/dist/surfaces/pypi.d.ts +5 -0
  115. package/dist/surfaces/pypi.js +82 -0
  116. package/dist/surfaces/readme.d.ts +7 -0
  117. package/dist/surfaces/readme.js +181 -0
  118. package/dist/surfaces/registry.d.ts +6 -0
  119. package/dist/surfaces/registry.js +73 -0
  120. package/dist/surfaces/shared.d.ts +49 -0
  121. package/dist/surfaces/shared.js +112 -0
  122. package/dist/surfaces/skill.d.ts +7 -0
  123. package/dist/surfaces/skill.js +118 -0
  124. package/dist/surfaces/skill.test.d.ts +1 -0
  125. package/dist/surfaces/skill.test.js +87 -0
  126. package/dist/surfaces/web.d.ts +97 -0
  127. package/dist/surfaces/web.js +1285 -0
  128. package/dist/surfaces/web.test.d.ts +1 -0
  129. package/dist/surfaces/web.test.js +255 -0
  130. package/dist/surfaces/workflows.d.ts +2 -0
  131. package/dist/surfaces/workflows.js +809 -0
  132. package/dist/surfaces/workflows.test.d.ts +1 -0
  133. package/dist/surfaces/workflows.test.js +563 -0
  134. package/dist/toolfactory/cli.d.ts +2 -0
  135. package/dist/toolfactory/cli.js +65 -0
  136. package/dist/toolfactory/config.d.ts +3 -0
  137. package/dist/toolfactory/config.js +27 -0
  138. package/dist/toolfactory/mcp.d.ts +37 -0
  139. package/dist/toolfactory/mcp.js +269 -0
  140. package/dist/toolfactory/types.d.ts +31 -0
  141. package/dist/toolfactory/types.js +10 -0
  142. package/dist/toolfactory/web.d.ts +4 -0
  143. package/dist/toolfactory/web.js +40 -0
  144. package/dist/validate/agent-plugins.d.ts +5 -0
  145. package/dist/validate/agent-plugins.js +46 -0
  146. package/package.json +68 -0
  147. package/schemas/agent-plugins/mcp.schema.json +120 -0
  148. package/schemas/agent-plugins/plugin.schema.json +65 -0
  149. package/src/bindings/index.ts +47 -0
  150. package/src/bindings/python.test.ts +374 -0
  151. package/src/bindings/python.ts +862 -0
  152. package/src/bindings/typescript.test.ts +329 -0
  153. package/src/bindings/typescript.ts +818 -0
  154. package/src/commands.test.ts +253 -0
  155. package/src/commands.ts +886 -0
  156. package/src/e2e.test.ts +169 -0
  157. package/src/hosts/browser.ts +106 -0
  158. package/src/hosts/github.ts +321 -0
  159. package/src/hosts/openclaw.ts +110 -0
  160. package/src/hosts/template.ts +149 -0
  161. package/src/hosts/web.ts +111 -0
  162. package/src/identity/name.test.ts +43 -0
  163. package/src/identity/name.ts +39 -0
  164. package/src/identity/read.test.ts +92 -0
  165. package/src/identity/read.ts +119 -0
  166. package/src/index.ts +11 -0
  167. package/src/introspect/index.ts +72 -0
  168. package/src/model.ts +386 -0
  169. package/src/ops.ts +333 -0
  170. package/src/project/apply.test.ts +348 -0
  171. package/src/project/apply.ts +417 -0
  172. package/src/project/gate.ts +717 -0
  173. package/src/project/history.ts +46 -0
  174. package/src/project/load.ts +104 -0
  175. package/src/project/lock.ts +54 -0
  176. package/src/project/plan.ts +101 -0
  177. package/src/report/coverage.test.ts +108 -0
  178. package/src/report/coverage.ts +113 -0
  179. package/src/surfaces/agent-plugins.ts +47 -0
  180. package/src/surfaces/agents.template.ts +32 -0
  181. package/src/surfaces/agents.test.ts +206 -0
  182. package/src/surfaces/agents.ts +729 -0
  183. package/src/surfaces/browser-extension.test.ts +175 -0
  184. package/src/surfaces/browser-extension.ts +1095 -0
  185. package/src/surfaces/claude.ts +99 -0
  186. package/src/surfaces/codex.test.ts +71 -0
  187. package/src/surfaces/codex.ts +105 -0
  188. package/src/surfaces/cursor.ts +62 -0
  189. package/src/surfaces/distribution.test.ts +166 -0
  190. package/src/surfaces/dsh.test.ts +110 -0
  191. package/src/surfaces/dsh.ts +159 -0
  192. package/src/surfaces/external.ts +13 -0
  193. package/src/surfaces/gemini.test.ts +80 -0
  194. package/src/surfaces/gemini.ts +84 -0
  195. package/src/surfaces/hermes-native.test.ts +179 -0
  196. package/src/surfaces/hermes-native.ts +584 -0
  197. package/src/surfaces/kernel.ts +68 -0
  198. package/src/surfaces/mcp-registry.test.ts +99 -0
  199. package/src/surfaces/mcp-registry.ts +190 -0
  200. package/src/surfaces/mcpb.test.ts +112 -0
  201. package/src/surfaces/mcpb.ts +130 -0
  202. package/src/surfaces/npm.ts +78 -0
  203. package/src/surfaces/openclaw-native.test.ts +283 -0
  204. package/src/surfaces/openclaw-native.ts +1070 -0
  205. package/src/surfaces/pypi.ts +84 -0
  206. package/src/surfaces/readme.ts +218 -0
  207. package/src/surfaces/registry.ts +79 -0
  208. package/src/surfaces/shared.ts +140 -0
  209. package/src/surfaces/skill.test.ts +103 -0
  210. package/src/surfaces/skill.ts +136 -0
  211. package/src/surfaces/web.test.ts +303 -0
  212. package/src/surfaces/web.ts +1310 -0
  213. package/src/surfaces/workflows.test.ts +677 -0
  214. package/src/surfaces/workflows.ts +936 -0
  215. package/src/toolfactory/cli.ts +74 -0
  216. package/src/toolfactory/config.ts +33 -0
  217. package/src/toolfactory/mcp.ts +311 -0
  218. package/src/toolfactory/types.ts +52 -0
  219. package/src/toolfactory/web.ts +45 -0
  220. package/src/validate/agent-plugins.ts +53 -0
  221. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  222. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  223. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  224. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  225. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  226. package/web/dist/assets/index-CCXhQKK3.css +2 -0
  227. package/web/dist/assets/index-D1PwZ3qS.js +10 -0
  228. package/web/dist/index.html +14 -0
@@ -0,0 +1,1095 @@
1
+ /**
2
+ * Browser extension: one WXT project under `hosts/browser/`, built for Chromium, Firefox and
3
+ * Safari by the upstream toolchain. The surface is both halves of the boundary at once. For
4
+ * portable operations it is a *client of the kernel*, exactly like `web`: the background worker
5
+ * POSTs stateless MCP to the `mcp --http` endpoint on loopback (hence `requires: ["mcp"]`) and
6
+ * the popup renders the `web` surface's own component tree, imported. For an operation that
7
+ * needs a `browser`, it is §4.4's bridge 3, the host-native escape hatch: the content script and
8
+ * the background tail are the author's, and they turn page state into JSON arguments before the
9
+ * call.
10
+ *
11
+ * Every per-browser manifest transform — MV2 vs MV3, `background.service_worker` vs
12
+ * `background.scripts`, `action` vs `browser_action`, `host_permissions` folded into
13
+ * `permissions` — is WXT's, never this file's: the manifest is not written here but projected
14
+ * into `wxt.config.ts`, and `wxt build -b <target>` emits three of them.
15
+ *
16
+ * §8 C2, "mirror scaffolds by execution, not transcription": `plan()` is pure and therefore has
17
+ * to carry the generator's output, so every pinned value lives in BROWSER_SCAFFOLD below and
18
+ * nowhere else, and `validate()` runs `src/hosts/browser.ts`, which re-runs `wxt init` in a temp
19
+ * directory and fails naming whatever drifted. The placeholder icon set is deliberately not
20
+ * planned, for the same reason the shadcn components are not: they are WXT's binary files, and
21
+ * the drift host copies the ones the project is missing out of that same fresh init.
22
+ */
23
+ import { join } from "node:path";
24
+ import { execArgv } from "node:process";
25
+ import { DRIFT_ENTRY } from "../hosts/browser.js";
26
+ import { githubOwner } from "../identity/name.js";
27
+ import type { Operation, PlannedFile, Project, Surface, Verdict } from "../model.js";
28
+ import { includedOperations } from "../report/coverage.js";
29
+ import { has, mcpVerdict } from "./shared.js";
30
+ import { WEB_SCAFFOLD } from "./web.js";
31
+
32
+ export const HOST_DIR = "hosts/browser";
33
+ /** The path from `hosts/browser/` to the `web` surface's source tree. */
34
+ const WEB_SRC_FROM_HOST = "../../web/src";
35
+
36
+ /**
37
+ * The WXT release the scaffold, the build and the drift probe all run. Exact, not a range: the
38
+ * constants below are what *this* version's `init` writes, and the drift host runs
39
+ * `npx wxt@<pin> init` to prove it. Renovate bumps it (`renovate.json` `customManagers`).
40
+ */
41
+ export const WXT_PIN = "0.21.4";
42
+
43
+ /** The engines one codebase is built for; `wxt build -b <target>` names each. */
44
+ export const TARGETS = ["chrome", "edge", "firefox", "safari"] as const;
45
+ export type Target = (typeof TARGETS)[number];
46
+ /** The three engines a verdict can differ on; Chrome and Edge are one Chromium package. */
47
+ export const ENGINES = ["chromium", "firefox", "safari"] as const;
48
+ export type Engine = (typeof ENGINES)[number];
49
+
50
+ /** Where `<tool> mcp --http` listens by default, and therefore what the extension pairs with. */
51
+ export const DEFAULT_ENDPOINT = "http://127.0.0.1:3000/mcp";
52
+ /** MCP revision whose per-request `_meta` keys the kernel reads; the `web` surface sends the same. */
53
+ const MCP_PROTOCOL_VERSION = "2026-07-28";
54
+
55
+ /** The generated regions: everything outside them is the author's and survives every build. */
56
+ export const CONFIG_BEGIN = "// tf:config";
57
+ export const CONFIG_END = "// /tf:config";
58
+ export const BG_BEGIN = "// tf:bg";
59
+ export const BG_END = "// /tf:bg";
60
+ export const CONTENT_BEGIN = "// tf:content";
61
+ export const CONTENT_END = "// /tf:content";
62
+
63
+ /**
64
+ * Everything `npx wxt@<WXT_PIN> init <dir> --template react --pm npm` wrote, transcribed once;
65
+ * `src/hosts/browser.ts` proves it current by running that command again.
66
+ */
67
+ export const BROWSER_SCAFFOLD = {
68
+ template: "react",
69
+ packageManager: "npm",
70
+ /** The placeholder icon set the template ships; WXT auto-wires `icon/<size>.png`. */
71
+ icons: ["16", "32", "48", "96", "128"],
72
+ packageJson: {
73
+ scripts: {
74
+ dev: "wxt",
75
+ "dev:firefox": "wxt -b firefox",
76
+ build: "wxt build",
77
+ "build:firefox": "wxt build -b firefox",
78
+ zip: "wxt zip",
79
+ "zip:firefox": "wxt zip -b firefox",
80
+ compile: "tsc --noEmit",
81
+ postinstall: "wxt prepare",
82
+ },
83
+ dependencies: {
84
+ react: "^19.2.4",
85
+ "react-dom": "^19.2.4",
86
+ },
87
+ devDependencies: {
88
+ "@types/react": "^19.2.14",
89
+ "@types/react-dom": "^19.2.3",
90
+ "@wxt-dev/module-react": "^1.1.5",
91
+ typescript: "^5.9.3",
92
+ "web-ext": "^10.5.0",
93
+ wxt: "^0.21.3",
94
+ },
95
+ },
96
+ tsconfig: `{
97
+ "extends": "./.wxt/tsconfig.json",
98
+ "compilerOptions": {
99
+ "allowImportingTsExtensions": true,
100
+ "jsx": "react-jsx"
101
+ }
102
+ }
103
+ `,
104
+ gitignore: `# Logs
105
+ logs
106
+ *.log
107
+ npm-debug.log*
108
+ yarn-debug.log*
109
+ yarn-error.log*
110
+ pnpm-debug.log*
111
+ lerna-debug.log*
112
+
113
+ node_modules
114
+ .output
115
+ stats.html
116
+ stats-*.json
117
+ .wxt
118
+ web-ext.config.ts
119
+
120
+ # Editor directories and files
121
+ .vscode/*
122
+ !.vscode/extensions.json
123
+ .idea
124
+ .DS_Store
125
+ *.suo
126
+ *.ntvs*
127
+ *.njsproj
128
+ *.sln
129
+ *.sw?
130
+ `,
131
+ } as const;
132
+
133
+ /**
134
+ * toolfactory's own pins on top of the scaffold's. The React half deliberately follows the `web`
135
+ * surface's, because the popup bundles that tree and the two must resolve to one copy.
136
+ */
137
+ const PINS = {
138
+ wxt: WXT_PIN,
139
+ "@wxt-dev/module-react": "^1.2.2",
140
+ /** WXT's Firefox engine, and `web-ext lint`, the only keyless manifest validator that exists. */
141
+ "web-ext": "^10.6.0",
142
+ /** The fake extension API `WxtVitest()` installs for the unit tier. */
143
+ "@webext-core/fake-browser": "^2.0.1",
144
+ /** Matches the repository's own runner, and the vite major WXT builds with. */
145
+ vitest: "^4.1.11",
146
+ } as const;
147
+
148
+ /** The private package `hosts/browser` is; its name is what `wxt zip` names the archives. */
149
+ export function browserPackage(project: Project): string {
150
+ return `${project.identity.name}-browser`;
151
+ }
152
+
153
+ /**
154
+ * The zip `wxt zip -b <browser>` writes, by name, inside the release assets. WXT names an archive
155
+ * after `zip.name`, which the generated config sets to the tool's own name, so the assets sit
156
+ * beside the tarball and the bundle under one identity instead of the package's local one.
157
+ */
158
+ export function zipName(project: Project, browser: "chrome" | "firefox" | "edge"): string {
159
+ return `${project.identity.name}-${project.identity.version ?? "0.0.0"}-${browser}.zip`;
160
+ }
161
+
162
+ /** The sources archive `wxt zip -b firefox` writes beside it; AMO asks for it when code is minified. */
163
+ export function sourcesZipName(project: Project): string {
164
+ return `${project.identity.name}-${project.identity.version ?? "0.0.0"}-sources.zip`;
165
+ }
166
+
167
+ function options(project: Project) {
168
+ return project.tool.browserExtension ?? {};
169
+ }
170
+
171
+ export function endpoint(project: Project): string {
172
+ return options(project).endpoint ?? DEFAULT_ENDPOINT;
173
+ }
174
+
175
+ /**
176
+ * Firefox's add-on id. Required for a Firefox MV3 build and recommended for MV2, so it is
177
+ * emitted whenever it can be derived — an explicit `browserExtension.geckoId`, else the
178
+ * owner-scoped default from the identity file's repository. A tool with neither ships without
179
+ * the block, exactly as the README surface omits the lines that need a GitHub repository.
180
+ */
181
+ export function geckoId(project: Project): string | undefined {
182
+ const declared = options(project).geckoId;
183
+ if (declared) return declared;
184
+ const owner = githubOwner(project.identity.repository);
185
+ return owner ? `${project.identity.name}@${owner}.github.io` : undefined;
186
+ }
187
+
188
+ /** Match patterns the extension may act in: loopback always, plus the declared session domains. */
189
+ export function hostPermissions(project: Project): string[] {
190
+ // A port in a match pattern is Chromium-only (Firefox rejects it outright), so the portable
191
+ // all-ports form is the only one emitted.
192
+ return ["http://127.0.0.1/*", "http://localhost/*", ...(options(project).authDomains ?? [])];
193
+ }
194
+
195
+ /**
196
+ * The permission list is a capability projection, never authored: `storage` for the pairing
197
+ * store, `activeTab` when an operation declares `browser` (the store-friendly minimum for acting
198
+ * in the tab the user invoked the action on), and the two opt-ins. Anything wider — `debugger`
199
+ * and `tabGroups` for a CDP relay — is Chromium-only and belongs in the author's tail, where the
200
+ * generated per-target filter still drops it for the other engines.
201
+ */
202
+ export function permissions(project: Project, operations: Operation[]): string[] {
203
+ const declared = options(project);
204
+ return [
205
+ "storage",
206
+ ...(operations.some((operation) => operation.requires.includes("browser"))
207
+ ? ["activeTab"]
208
+ : []),
209
+ ...(declared.cookieExport ? ["cookies"] : []),
210
+ ...(declared.sidePanel ? ["sidePanel"] : []),
211
+ ];
212
+ }
213
+
214
+ /**
215
+ * The coalesced verdict, for `COVERAGE.md`'s single column: the browser is present, so a
216
+ * `browser` operation is native here (bridge 3, authored in this directory) and a `user-input`
217
+ * one is native too (the popup is the human). Nothing bridges a model or a channel.
218
+ */
219
+ export function verdict(operation: Operation): Verdict {
220
+ if (operation.requires.includes("model"))
221
+ return { kind: "excluded", reason: "excluded:no-model-bridge" };
222
+ if (operation.requires.includes("channel"))
223
+ return { kind: "excluded", reason: "excluded:no-channel-bridge" };
224
+ return { kind: "native" };
225
+ }
226
+
227
+ /**
228
+ * The same verdict per engine, which is the finer grain `TARGETS.md` carries. The vocabulary has
229
+ * one `browser` term, so the surface cannot tell a DOM operation from one that reaches for
230
+ * `chrome.debugger`; that stays prose in `TARGETS.md`. What it can derive is the session export:
231
+ * Safari Web Extensions cannot read httpOnly cookies, so an opted-in export degrades there.
232
+ */
233
+ export function browserVerdict(operation: Operation, project: Project, engine: Engine): Verdict {
234
+ const coalesced = verdict(operation);
235
+ if (coalesced.kind !== "native") return coalesced;
236
+ if (
237
+ engine === "safari" &&
238
+ options(project).cookieExport &&
239
+ operation.requires.includes("browser")
240
+ )
241
+ return { kind: "degraded", reason: "degraded:no-httponly-cookies" };
242
+ return coalesced;
243
+ }
244
+
245
+ /** The operations the reused page carries: the `web` surface's own rule, so the two agree. */
246
+ function pageOperations(project: Project): Operation[] {
247
+ return project.operations.filter((operation) => mcpVerdict(operation).kind !== "excluded");
248
+ }
249
+
250
+ /** What the Playwright smoke submits: the first page operation with an example, else the first. */
251
+ function smokeCase(project: Project): { name: string; arguments: Record<string, unknown> } | null {
252
+ const shown = pageOperations(project);
253
+ const examples = project.tool.tests.examples;
254
+ const operation = shown.find((candidate) => examples[candidate.name]) ?? shown[0];
255
+ return operation ? { name: operation.name, arguments: examples[operation.name] ?? {} } : null;
256
+ }
257
+
258
+ /** npm writes a dependency map in name order; a merge patch that does not would churn it. */
259
+ function sorted(record: Record<string, string>): Record<string, string> {
260
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
261
+ }
262
+
263
+ function packageJsonPatch(project: Project, reuseWeb: boolean): Record<string, unknown> {
264
+ const web = WEB_SCAFFOLD.packageJson;
265
+ return {
266
+ name: browserPackage(project),
267
+ description: project.identity.description,
268
+ private: true,
269
+ version: project.identity.version ?? "0.0.0",
270
+ type: "module",
271
+ scripts: {
272
+ ...BROWSER_SCAFFOLD.packageJson.scripts,
273
+ test: "vitest run",
274
+ smoke: "node tests/smoke.mjs",
275
+ },
276
+ // React follows `web/`'s pins: the popup bundles that tree, and `dedupe` resolves both to one copy.
277
+ dependencies: {
278
+ react: web.dependencies.react,
279
+ "react-dom": web.dependencies["react-dom"],
280
+ },
281
+ devDependencies: sorted({
282
+ ...BROWSER_SCAFFOLD.packageJson.devDependencies,
283
+ "@types/react": web.devDependencies["@types/react"],
284
+ "@types/react-dom": web.devDependencies["@types/react-dom"],
285
+ ...PINS,
286
+ playwright: WEB_SCAFFOLD.playwright,
287
+ ...(reuseWeb ? { "@tailwindcss/vite": web.dependencies["@tailwindcss/vite"] } : {}),
288
+ }),
289
+ };
290
+ }
291
+
292
+ /** A string array as a formatted TypeScript literal, so the generated config reads like source. */
293
+ function list(values: string[]): string {
294
+ return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
295
+ }
296
+
297
+ function configRegion(project: Project, operations: Operation[], reuseWeb: boolean): string {
298
+ const gecko = geckoId(project);
299
+ return `
300
+ // Generated by toolfactory. Do not edit this region; run \`toolfactory build\` to regenerate.
301
+ // Everything below the end marker is yours — merge into \`generated\` there and it survives a rebuild.
302
+ ${reuseWeb ? 'import { resolve } from "node:path";\nimport tailwindcss from "@tailwindcss/vite";\n' : ""}import { defineConfig } from "wxt";
303
+
304
+ /** Permissions only Chromium implements; dropped from every other target's manifest. */
305
+ export const CHROMIUM_ONLY = ["sidePanel", "debugger", "tabGroups"];
306
+ const CHROMIUM = ["chrome", "edge"];
307
+
308
+ const PERMISSIONS = ${list(permissions(project, operations))};
309
+ const HOST_PERMISSIONS = ${list(hostPermissions(project))};
310
+ ${reuseWeb ? `const web = resolve(import.meta.dirname, ${JSON.stringify(WEB_SRC_FROM_HOST)});\n` : ""}
311
+ export const generated = {
312
+ modules: ["@wxt-dev/module-react"],
313
+ // The release assets carry the tool's name, not this package's local one.
314
+ zip: { name: ${JSON.stringify(project.identity.name)} },${
315
+ reuseWeb
316
+ ? `
317
+ vite: () => ({
318
+ plugins: [
319
+ tailwindcss(),
320
+ // The popup and the options page are the \`web\` surface's own component tree, imported
321
+ // rather than duplicated. \`enforce: "post"\` is what makes this alias outrank WXT's own
322
+ // \`@\` (this directory, still reachable as \`~\`), so that tree's internal \`@/\` imports
323
+ // resolve inside it.
324
+ {
325
+ name: "toolfactory:web-alias",
326
+ enforce: "post" as const,
327
+ config: () => ({ resolve: { alias: { "@web": web, "@": web } } }),
328
+ },
329
+ ],
330
+ resolve: { dedupe: ["react", "react-dom"] },
331
+ }),`
332
+ : ""
333
+ }
334
+ // WXT emits one manifest per target from this: MV3 with a service worker for Chromium, MV2
335
+ // with background scripts for Firefox and Safari, \`host_permissions\` folded into
336
+ // \`permissions\` where the format has no such key.
337
+ manifest: ({ browser }: { browser: string }) => ({
338
+ name: ${JSON.stringify(project.identity.name)},
339
+ permissions: PERMISSIONS.filter(
340
+ (permission) => CHROMIUM.includes(browser) || !CHROMIUM_ONLY.includes(permission),
341
+ ),
342
+ host_permissions: HOST_PERMISSIONS,${
343
+ gecko
344
+ ? `
345
+ browser_specific_settings: {
346
+ gecko: {
347
+ id: ${JSON.stringify(gecko)},
348
+ // The floor for \`storage.session\` and for the data-collection key below.
349
+ strict_min_version: "140.0",
350
+ // Everything this extension sends goes to the kernel on this machine, so there is no
351
+ // collection to declare. Change it if your operations ship user data to you.
352
+ data_collection_permissions: { required: ["none"] },
353
+ },
354
+ },`
355
+ : ""
356
+ }
357
+ action: { default_title: ${JSON.stringify(project.identity.name)} },
358
+ }),
359
+ };
360
+ `;
361
+ }
362
+
363
+ const CONFIG_TAIL = `
364
+ // Yours: everything below the end marker survives \`toolfactory build\`. Merge into the generated
365
+ // config — extra \`modules\`, an \`srcDir\`, manifest keys your capability code needs:
366
+ // export default defineConfig({ ...generated, manifest: (env) => ({ ...generated.manifest(env), … }) })
367
+ export default defineConfig({ ...generated });
368
+ `;
369
+
370
+ function mcpTs(project: Project): string {
371
+ return `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
372
+ import { browser } from "wxt/browser";
373
+
374
+ /** Where \`${project.identity.name} mcp --http\` listens by default. */
375
+ export const DEFAULT_ENDPOINT = ${JSON.stringify(endpoint(project))};
376
+ /** MCP revision whose per-request \`_meta\` keys the kernel reads. */
377
+ export const MCP_PROTOCOL_VERSION = ${JSON.stringify(MCP_PROTOCOL_VERSION)};
378
+
379
+ export interface McpRequest {
380
+ method: string;
381
+ params: { name: string };
382
+ }
383
+
384
+ /** The paired kernel: its URL (persisted) and this session's token (in memory only). */
385
+ export async function paired(): Promise<{ endpoint: string; token?: string }> {
386
+ const { endpoint } = await browser.storage.local.get("endpoint");
387
+ const { token } = await browser.storage.session.get("token");
388
+ return {
389
+ endpoint: typeof endpoint === "string" && endpoint ? endpoint : DEFAULT_ENDPOINT,
390
+ token: typeof token === "string" && token ? token : undefined,
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Pair with \`<url>#<token>\`, the string \`${project.identity.name} mcp --http --pair\` prints. The
396
+ * token is kept in \`storage.session\` — in memory, cleared when the browser closes, unreachable
397
+ * from a content script — and never in \`storage.local\`.
398
+ */
399
+ export async function pair(pairing: string): Promise<{ endpoint: string; token: boolean }> {
400
+ const hash = pairing.indexOf("#");
401
+ const endpoint = (hash < 0 ? pairing : pairing.slice(0, hash)).trim() || DEFAULT_ENDPOINT;
402
+ const token = hash < 0 ? "" : pairing.slice(hash + 1).trim();
403
+ await browser.storage.local.set({ endpoint });
404
+ await browser.storage.session.set({ token });
405
+ return { endpoint, token: Boolean(token) };
406
+ }
407
+
408
+ /**
409
+ * One stateless MCP POST, from the background worker and nowhere else: an extension page's fetch
410
+ * would carry no token, and a content script's is not CORS-exempt. A worker fetch to a
411
+ * \`host_permissions\` host is, and sends no preflight, so the kernel needs no CORS middleware.
412
+ */
413
+ export async function post(request: McpRequest, endpoint?: string): Promise<string> {
414
+ const target = await paired();
415
+ const url = endpoint && /^https?:/i.test(endpoint) ? endpoint : target.endpoint;
416
+ let response: Response;
417
+ try {
418
+ response = await fetch(url, {
419
+ method: "POST",
420
+ headers: {
421
+ "Content-Type": "application/json",
422
+ Accept: "application/json, text/event-stream",
423
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
424
+ "Mcp-Method": request.method,
425
+ "Mcp-Name": request.params.name,
426
+ ...(target.token ? { Authorization: \`Bearer \${target.token}\` } : {}),
427
+ },
428
+ body: JSON.stringify(request),
429
+ });
430
+ } catch (cause) {
431
+ throw new Error(
432
+ \`\${url} refused the connection: start \\\`${project.identity.name} mcp --http\\\`, and on Chromium make sure the browser is 144 or newer (Local Network Access exempts an extension that holds the host permission).\`,
433
+ { cause },
434
+ );
435
+ }
436
+ return await response.text();
437
+ }
438
+
439
+ /** The same POST, parsed: plain JSON or the one \`data:\` SSE frame, exactly as the page reads it. */
440
+ export async function call(request: McpRequest, endpoint?: string): Promise<unknown> {
441
+ const text = await post(request, endpoint);
442
+ const framed = text.split("\\n").find((line) => line.startsWith("data:"));
443
+ let body: { error?: unknown; result?: { structuredContent?: unknown } };
444
+ try {
445
+ body = JSON.parse(framed ? framed.slice(5).trim() : text);
446
+ } catch {
447
+ throw new Error(\`not an MCP response: \${text.slice(0, 500)}\`);
448
+ }
449
+ if (body.error) throw new Error(JSON.stringify(body.error));
450
+ return body.result?.structuredContent ?? body.result;
451
+ }
452
+ `;
453
+ }
454
+
455
+ const BG_REGION = `
456
+ // Generated by toolfactory. Do not edit this region; run \`toolfactory build\` to regenerate.
457
+ // Everything below the end marker is yours: the operations that need a browser live there.
458
+ import { browser } from "wxt/browser";
459
+ import { defineBackground } from "wxt/utils/define-background";
460
+ import { call, pair, paired, post, type McpRequest } from "../utils/mcp";
461
+
462
+ export type Message =
463
+ | { type: "mcp"; request: McpRequest; endpoint?: string }
464
+ | { type: "fetch"; request: McpRequest; endpoint?: string }
465
+ | { type: "pair"; pairing: string }
466
+ | { type: "paired" };
467
+
468
+ /**
469
+ * The router: the one place a message reaches the kernel, and the only context holding the
470
+ * token. \`mcp\` answers with the operation's result — what your capability code wants — and
471
+ * \`fetch\` with the raw MCP response, which the generated page parses itself.
472
+ */
473
+ export async function route(message: Message): Promise<unknown> {
474
+ switch (message.type) {
475
+ case "mcp":
476
+ return await call(message.request, message.endpoint);
477
+ case "fetch":
478
+ return await post(message.request, message.endpoint);
479
+ case "pair":
480
+ return await pair(message.pairing);
481
+ case "paired": {
482
+ const { endpoint, token } = await paired();
483
+ return { endpoint, token: Boolean(token) };
484
+ }
485
+ }
486
+ }
487
+
488
+ export const background = defineBackground(() => {
489
+ browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
490
+ route(message as Message).then(
491
+ (value) => sendResponse({ value }),
492
+ (error: unknown) =>
493
+ sendResponse({ error: error instanceof Error ? error.message : String(error) }),
494
+ );
495
+ return true;
496
+ });
497
+ });
498
+ `;
499
+
500
+ const BG_TAIL = `
501
+ // Yours: the operations that need a browser are implemented here, and survive every build.
502
+ // Decompose, don't inject (§4.5): the page state becomes JSON arguments, and the kernel runs the
503
+ // operation.
504
+ //
505
+ // browser.runtime.onMessage.addListener(...) // your own message types
506
+ // await route({ type: "mcp", request: { method: "tools/call", params: { name: "…", arguments: {} } } })
507
+
508
+ export default background;
509
+ `;
510
+
511
+ const CONTENT_REGION = `
512
+ // Generated by toolfactory. Do not edit this region; run \`toolfactory build\` to regenerate.
513
+ import { browser } from "wxt/browser";
514
+ import { defineContentScript } from "wxt/utils/define-content-script";
515
+ `;
516
+
517
+ function contentTemplate(project: Project): string {
518
+ const matches = options(project).authDomains ?? ["https://example.com/*"];
519
+ return `${CONTENT_BEGIN}${CONTENT_REGION}${CONTENT_END}
520
+
521
+ // Yours: the shim that turns page state into JSON arguments. It runs inside the logged-in page,
522
+ // so the session is already there — read the DOM, hand the result to a portable operation, put
523
+ // the answer back. Never fetch the kernel from here: a content script's fetch uses the page's
524
+ // origin and is not CORS-exempt. Message the background worker instead, as below.
525
+ //
526
+ // This file is yours to keep working against the page it scrapes; a selector that moves is not
527
+ // something the generator can version-proof.
528
+ export default defineContentScript({
529
+ matches: ${list(matches)},
530
+ async main() {
531
+ const answer = await browser.runtime.sendMessage({
532
+ type: "mcp",
533
+ request: { method: "tools/call", params: { name: "…", arguments: {} } },
534
+ });
535
+ console.log(${JSON.stringify(project.identity.name)}, answer);
536
+ },
537
+ });
538
+ `;
539
+ }
540
+
541
+ const PAGE_TSX = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
542
+ import { useEffect, useState, type ReactNode } from "react";
543
+ import { createRoot } from "react-dom/client";
544
+ import { browser } from "wxt/browser";
545
+
546
+ async function send(message: unknown): Promise<unknown> {
547
+ const response = (await browser.runtime.sendMessage(message)) as
548
+ | { value: unknown }
549
+ | { error: string };
550
+ if (response && "error" in response) throw new Error(response.error);
551
+ return (response as { value: unknown })?.value;
552
+ }
553
+
554
+ /**
555
+ * The page never fetches: every MCP POST is forwarded to the background worker, which holds the
556
+ * paired endpoint and the token. That is what lets an extension page be the \`web\` surface's own
557
+ * component tree, unchanged — and what keeps the credential out of a document.
558
+ */
559
+ function bridgeFetch(): void {
560
+ const direct = globalThis.fetch;
561
+ globalThis.fetch = async (input, init) => {
562
+ if (init?.method !== "POST") return direct(input, init);
563
+ const endpoint =
564
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
565
+ const request = JSON.parse(String(init.body));
566
+ const text = (await send({ type: "fetch", request, endpoint })) as string;
567
+ return new Response(text, { headers: { "content-type": "application/json" } });
568
+ };
569
+ }
570
+
571
+ /** Pairing: \`<url>#<token>\`, the string the kernel prints when it starts serving HTTP. */
572
+ export function Pairing() {
573
+ const [pairing, setPairing] = useState("");
574
+ const [status, setStatus] = useState("");
575
+ const show = (state: { endpoint: string; token: boolean }) =>
576
+ setStatus(\`\${state.endpoint}\${state.token ? " (token held for this browser session)" : ""}\`);
577
+ useEffect(() => {
578
+ send({ type: "paired" }).then((state) => show(state as { endpoint: string; token: boolean }));
579
+ }, []);
580
+ return (
581
+ <form
582
+ data-slot="pairing"
583
+ style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.5rem" }}
584
+ onSubmit={(event) => {
585
+ event.preventDefault();
586
+ send({ type: "pair", pairing })
587
+ .then((state) => show(state as { endpoint: string; token: boolean }))
588
+ .catch((error: Error) => setStatus(error.message));
589
+ }}
590
+ >
591
+ <input
592
+ id="pairing"
593
+ aria-label="Pairing string"
594
+ placeholder="http://127.0.0.1:3000/mcp#token"
595
+ style={{ flex: 1, minWidth: 0 }}
596
+ value={pairing}
597
+ onChange={(event) => setPairing(event.target.value)}
598
+ />
599
+ <button type="submit">Pair</button>
600
+ <span data-slot="paired">{status}</span>
601
+ </form>
602
+ );
603
+ }
604
+
605
+ /**
606
+ * The popup is the same page in 540 pixels; this is how it becomes a real one. \`openOptionsPage\`
607
+ * is the WebExtension API for exactly that, and the options page already mounts this tree.
608
+ */
609
+ export function OpenFullPage() {
610
+ return (
611
+ <button
612
+ type="button"
613
+ data-slot="open-full-page"
614
+ style={{ marginLeft: "auto" }}
615
+ onClick={() => browser.runtime.openOptionsPage()}
616
+ >
617
+ Open full page
618
+ </button>
619
+ );
620
+ }
621
+
622
+ export function mount(children: ReactNode): void {
623
+ bridgeFetch();
624
+ createRoot(document.getElementById("root") as HTMLElement).render(children);
625
+ }
626
+ `;
627
+
628
+ const PAGE_CSS = `/* Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate. */
629
+ @import "../${WEB_SRC_FROM_HOST}/index.css";
630
+ /* Tailwind scans from this file's directory; the classes are in the web surface's tree. */
631
+ @source "../${WEB_SRC_FROM_HOST}";
632
+ `;
633
+
634
+ const ENV_D_TS = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
635
+ /**
636
+ * \`@web/*\` is the \`web\` surface's component tree, resolved by the alias in \`wxt.config.ts\`. It
637
+ * is declared here, not typechecked: \`web\`'s own build typechecks it, against its own React
638
+ * types, and pulling it into this program would compile it against a second copy of them.
639
+ */
640
+ declare module "@web/App" {
641
+ const App: () => import("react").ReactNode;
642
+ export default App;
643
+ }
644
+ `;
645
+
646
+ /** A page entrypoint's HTML. WXT reads the manifest slot and its options from the meta tags. */
647
+ function pageHtml(project: Project, kind: "popup" | "options" | "sidepanel"): string {
648
+ const meta =
649
+ kind === "popup"
650
+ ? '\n <meta name="manifest.type" content="browser_action" />'
651
+ : kind === "options"
652
+ ? '\n <meta name="manifest.open_in_tab" content="true" />'
653
+ : "";
654
+ // A popup sizes itself to its content; the reused operations page needs room to be usable.
655
+ const style =
656
+ kind === "popup"
657
+ ? "body { width: 540px; min-height: 320px; margin: 0; }"
658
+ : "body { margin: 0; }";
659
+ return `<!doctype html>
660
+ <!-- Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate. -->
661
+ <html lang="en">
662
+ <head>
663
+ <meta charset="UTF-8" />
664
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
665
+ <title>${project.identity.name}</title>${meta}
666
+ <style>
667
+ ${style}
668
+ </style>
669
+ </head>
670
+ <body>
671
+ <div id="root"></div>
672
+ <script type="module" src="./main.tsx"></script>
673
+ </body>
674
+ </html>
675
+ `;
676
+ }
677
+
678
+ /** The page entrypoint: the `web` surface's app under the pairing row, or pairing alone. */
679
+ function pageMain(reuseWeb: boolean, kind: "popup" | "options" | "sidepanel"): string {
680
+ // Only the popup needs it: it is the one page that is not already a full one.
681
+ const full = kind === "popup";
682
+ return `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
683
+ ${reuseWeb ? 'import App from "@web/App";\nimport "../../utils/page.css";\n' : ""}import { mount, ${full ? "OpenFullPage, " : ""}Pairing } from "../../utils/page";
684
+
685
+ mount(
686
+ <>
687
+ <div style={{ display: "flex", alignItems: "center" }}>
688
+ <Pairing />${full ? "\n <OpenFullPage />" : ""}
689
+ </div>${reuseWeb ? "\n <App />" : ""}
690
+ </>,
691
+ );
692
+ `;
693
+ }
694
+
695
+ const VITEST_CONFIG = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
696
+ import { defineConfig } from "vitest/config";
697
+ import { WxtVitest } from "wxt/testing/vitest-plugin";
698
+
699
+ // WxtVitest polyfills the extension APIs with @webext-core/fake-browser and applies the same
700
+ // aliases and auto-imports the build does, so a test imports an entrypoint like any module.
701
+ export default defineConfig({ plugins: [WxtVitest()] });
702
+ `;
703
+
704
+ const WEB_EXT_CONFIG = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
705
+ // What \`web-ext run\` and \`web-ext lint\` act on when run from this directory: the Firefox
706
+ // build \`wxt build -b firefox\` writes.
707
+ export default { sourceDir: ".output/firefox-mv2" };
708
+ `;
709
+
710
+ function backgroundTest(project: Project): string {
711
+ const first = pageOperations(project)[0]?.name ?? "tools/list";
712
+ return `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
713
+ /**
714
+ * The generated router, in memory: \`@webext-core/fake-browser\` stands in for the extension APIs
715
+ * and \`fetch\` is the only thing stubbed, so what is under test is the pairing store and the one
716
+ * privileged call to the kernel.
717
+ */
718
+ import { beforeEach, describe, expect, it, vi } from "vitest";
719
+ import { fakeBrowser } from "wxt/testing/fake-browser";
720
+ import { route } from "../entrypoints/background";
721
+
722
+ const OPERATION = ${JSON.stringify(first)};
723
+ const ENVELOPE = JSON.stringify({
724
+ jsonrpc: "2.0",
725
+ id: 1,
726
+ result: { structuredContent: { ok: true } },
727
+ });
728
+
729
+ describe("the background router", () => {
730
+ beforeEach(() => {
731
+ fakeBrowser.reset();
732
+ vi.restoreAllMocks();
733
+ });
734
+
735
+ it("pairs from <url>#<token> and reports what it holds", async () => {
736
+ expect(await route({ type: "pair", pairing: "http://127.0.0.1:4242/mcp#s3cret" })).toEqual({
737
+ endpoint: "http://127.0.0.1:4242/mcp",
738
+ token: true,
739
+ });
740
+ expect(await route({ type: "paired" })).toEqual({
741
+ endpoint: "http://127.0.0.1:4242/mcp",
742
+ token: true,
743
+ });
744
+ });
745
+
746
+ it("calls the paired kernel with the MCP headers and the bearer token", async () => {
747
+ const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(ENVELOPE));
748
+ await route({ type: "pair", pairing: "http://127.0.0.1:4242/mcp#s3cret" });
749
+ const result = await route({
750
+ type: "mcp",
751
+ request: { method: "tools/call", params: { name: OPERATION } },
752
+ });
753
+ expect(result).toEqual({ ok: true });
754
+ const [url, init] = fetch.mock.calls[0] as [string, RequestInit];
755
+ expect(url).toBe("http://127.0.0.1:4242/mcp");
756
+ expect(init.headers).toMatchObject({
757
+ "Mcp-Method": "tools/call",
758
+ "Mcp-Name": OPERATION,
759
+ Authorization: "Bearer s3cret",
760
+ });
761
+ });
762
+
763
+ it("says which endpoint refused when the kernel is not running", async () => {
764
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
765
+ await expect(
766
+ route({ type: "mcp", request: { method: "tools/call", params: { name: OPERATION } } }),
767
+ ).rejects.toThrow(\`\${${JSON.stringify(endpoint(project))}} refused the connection\`);
768
+ });
769
+ });
770
+ `;
771
+ }
772
+
773
+ function smokeMjs(project: Project, reuseWeb: boolean): string {
774
+ const kase = reuseWeb ? smokeCase(project) : null;
775
+ const form = !kase
776
+ ? `
777
+ const popup = await context.newPage();
778
+ await popup.goto(\`chrome-extension://\${id}/popup.html\`);
779
+ await popup.locator('[data-slot="pairing"]').waitFor();
780
+ console.log("PASS the popup renders the pairing form");
781
+ `
782
+ : `
783
+ const popup = await context.newPage();
784
+ await popup.goto(\`chrome-extension://\${id}/popup.html\`);
785
+ await popup.getByRole("button", { name: CASE.name, exact: true }).click();
786
+ for (const [name, value] of Object.entries(CASE.arguments)) {
787
+ if (typeof value === "string") await popup.locator(\`[id="\${CASE.name}-\${name}"]\`).fill(value);
788
+ }
789
+ console.log(\`PASS the popup renders the \${CASE.name} form\`);
790
+
791
+ await popup.getByRole("button", { name: "Run" }).click();
792
+ await popup.getByRole("tab", { name: "Result" }).click();
793
+ const result = popup.locator('pre[data-slot="result"]');
794
+ await result.waitFor({ state: "visible" });
795
+ const text = (await result.textContent()).trim();
796
+ if (!text.includes(MARKER)) throw new Error(\`the popup rendered no result: \${text}\`);
797
+ console.log(\`PASS \${CASE.name} answered through the worker: \${text.replace(/\\s+/g, " ")}\`);
798
+
799
+ const call = seen.at(-1);
800
+ if (!call) throw new Error("the mock kernel was never called");
801
+ if (call.headers.authorization !== \`Bearer \${TOKEN}\`) {
802
+ throw new Error(\`the worker sent no bearer token: \${call.headers.authorization}\`);
803
+ }
804
+ if (call.body.params?.name !== CASE.name) {
805
+ throw new Error(\`the worker called \${call.body.params?.name}, not \${CASE.name}\`);
806
+ }
807
+ console.log(\`PASS the worker sent Bearer <token> and Mcp-Name: \${call.headers["mcp-name"]}\`);
808
+ `;
809
+ return `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
810
+ /**
811
+ * The keyless end-to-end: the built \`chrome-mv3\` output is side-loaded into a real Chromium and
812
+ * driven the way a human drives it — the options page takes the pairing string, the popup renders
813
+ * the operation, and Run reaches a mock kernel on loopback through the background worker, which
814
+ * is the only context holding the token. No LLM, no store credentials, no real session.
815
+ *
816
+ * Run \`npm --prefix ${HOST_DIR} run build\` first; this loads \`.output/chrome-mv3\`.
817
+ */
818
+ import { mkdtempSync, rmSync } from "node:fs";
819
+ import { createServer } from "node:http";
820
+ import { tmpdir } from "node:os";
821
+ import { join } from "node:path";
822
+ import { fileURLToPath } from "node:url";
823
+ import { chromium } from "playwright";
824
+
825
+ const extension = fileURLToPath(new URL("../.output/chrome-mv3", import.meta.url));
826
+ const CASE = ${JSON.stringify(kase ?? { name: "", arguments: {} })};
827
+ const TOKEN = "smoke-token";
828
+ const MARKER = "${project.identity.name}-browser-smoke";
829
+
830
+ const seen = [];
831
+ const kernel = createServer((request, response) => {
832
+ let body = "";
833
+ request.on("data", (chunk) => {
834
+ body += chunk;
835
+ });
836
+ request.on("end", () => {
837
+ seen.push({ headers: request.headers, body: JSON.parse(body || "{}") });
838
+ response.writeHead(200, { "content-type": "application/json" });
839
+ response.end(
840
+ JSON.stringify({ jsonrpc: "2.0", id: 1, result: { structuredContent: { smoke: MARKER } } }),
841
+ );
842
+ });
843
+ });
844
+ await new Promise((resolve) => kernel.listen(0, "127.0.0.1", resolve));
845
+ const endpoint = \`http://127.0.0.1:\${kernel.address().port}/mcp\`;
846
+
847
+ const profile = mkdtempSync(join(tmpdir(), "${project.identity.name}-browser-"));
848
+ // \`channel: "chromium"\` is the full build; the headless shell cannot side-load an extension.
849
+ const context = await chromium.launchPersistentContext(profile, {
850
+ channel: "chromium",
851
+ args: [\`--disable-extensions-except=\${extension}\`, \`--load-extension=\${extension}\`],
852
+ });
853
+ const failures = [];
854
+ context.on("weberror", (error) => failures.push(error.error().message));
855
+ try {
856
+ const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
857
+ const id = new URL(worker.url()).host;
858
+ console.log(\`PASS the extension loaded as \${id}\`);
859
+
860
+ const options = await context.newPage();
861
+ await options.goto(\`chrome-extension://\${id}/options.html\`);
862
+ await options.locator("#pairing").fill(\`\${endpoint}#\${TOKEN}\`);
863
+ await options.getByRole("button", { name: "Pair" }).click();
864
+ await options.locator('[data-slot="paired"]').filter({ hasText: endpoint }).waitFor();
865
+ console.log(\`PASS paired with \${endpoint}\`);
866
+ ${form}
867
+ if (failures.length) throw new Error(\`the extension raised: \${failures.join("; ")}\`);
868
+ console.log("PASS no uncaught page errors");
869
+ } finally {
870
+ await context.close();
871
+ kernel.close();
872
+ rmSync(profile, { recursive: true, force: true });
873
+ }
874
+ `;
875
+ }
876
+
877
+ const ENGINE_LABEL: Record<Engine, string> = {
878
+ chromium: "Chromium (chrome, edge)",
879
+ firefox: "Firefox",
880
+ safari: "Safari",
881
+ };
882
+
883
+ function verdictCell(verdict: Verdict): string {
884
+ return verdict.kind === "native" ? "native" : `${verdict.kind} (${verdict.reason})`;
885
+ }
886
+
887
+ function targetsMarkdown(project: Project): string {
888
+ const rows = project.operations.map(
889
+ (operation) =>
890
+ `| \`${operation.name}\` | ${ENGINES.map((engine) => verdictCell(browserVerdict(operation, project, engine))).join(" | ")} |`,
891
+ );
892
+ const table = project.operations.length
893
+ ? [
894
+ `| Operation | ${ENGINES.map((engine) => ENGINE_LABEL[engine]).join(" | ")} |`,
895
+ `|---|${ENGINES.map(() => "---").join("|")}|`,
896
+ ...rows,
897
+ ].join("\n")
898
+ : "No operation reaches this surface; what the extension does is the authored code in `entrypoints/`.";
899
+ return `# Targets
900
+
901
+ <!-- Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate. -->
902
+
903
+ One codebase, three engines. \`wxt build -b <chrome|edge|firefox|safari>\` writes each of them, and
904
+ every per-browser manifest difference — MV2 against MV3, \`background.scripts\` against
905
+ \`background.service_worker\`, \`browser_action\` against \`action\`, \`host_permissions\` folded into
906
+ \`permissions\` — is WXT's transform, not this project's.
907
+
908
+ ${table}
909
+
910
+ ## What the table cannot tell you
911
+
912
+ - **The vocabulary has one \`browser\` term.** An operation that reads the DOM from a content
913
+ script is native on all three engines; one that reaches for \`chrome.debugger\` (a CDP relay, or
914
+ driving another tab) is **Chromium only** — Firefox never implemented the debugger protocol and
915
+ Safari has no such API. You know which one you wrote; the generator does not. Keep the
916
+ Chromium-only permissions in \`CHROMIUM_ONLY\` in \`wxt.config.ts\` so the other targets' manifests
917
+ drop them, and say so here.
918
+ - **Content scripts are yours to maintain.** A selector that follows the page it scrapes is
919
+ authored escape-hatch code: the surface can bootstrap it, never version-proof it.
920
+ - **Session export degrades on Safari.** Safari Web Extensions cannot read httpOnly cookies, so
921
+ "export the session" is Chromium and Firefox only; "act in the session" — the default, where the
922
+ request is made inside the logged-in page and only the result leaves — works on all three.
923
+
924
+ ## Installing what you built
925
+
926
+ | Engine | Install |
927
+ |---|---|
928
+ | Chromium (Chrome, Edge) | \`chrome://extensions\` → Load unpacked → \`.output/chrome-mv3\`. Unpacked installs expire after 30 days and Chrome 149 disables ever-unpacked extensions on update, so the store is the real channel. |
929
+ | Firefox | \`npm --prefix ${HOST_DIR} exec -- web-ext run\` for a temporary install, or the Mozilla-signed \`.xpi\` from the release — the one download-and-install channel that is not a store. |
930
+ | Safari | \`xcrun safari-web-extension-converter .output/safari-mv2\` on macOS, then Xcode. The payload builds anywhere; signing needs macOS and an Apple developer account. |
931
+ `;
932
+ }
933
+
934
+ function readme(project: Project, reuseWeb: boolean): string {
935
+ const name = project.identity.name;
936
+ return `# ${name} — browser extension
937
+
938
+ <!-- Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate. -->
939
+
940
+ A [WXT](https://wxt.dev) extension for Chromium, Firefox and Safari. It is a client of this
941
+ tool's own kernel: the background worker POSTs stateless MCP to \`${name} mcp --http\` on
942
+ loopback, and ${
943
+ reuseWeb
944
+ ? "the popup and options page are the `web` surface's component tree, imported rather than duplicated"
945
+ : "the popup is the pairing form"
946
+ }.
947
+
948
+ \`\`\`
949
+ npm --prefix ${HOST_DIR} install # wxt prepare runs as postinstall
950
+ npm --prefix ${HOST_DIR} run dev # Chromium, hot reload
951
+ npm --prefix ${HOST_DIR} run dev:firefox
952
+ npm --prefix ${HOST_DIR} run build # .output/chrome-mv3
953
+ npm --prefix ${HOST_DIR} run test # the router, against a fake browser
954
+ npm --prefix ${HOST_DIR} run smoke # a real Chromium, a mock kernel (build first)
955
+ \`\`\`
956
+
957
+ ## Pairing
958
+
959
+ Any local process can POST to loopback, so the kernel's token is the bar for anything
960
+ destructive. Run \`${name} mcp --http --pair\` and paste the \`<url>#<token>\` it prints into the
961
+ extension's options page. The token lives in \`storage.session\` — in memory, gone when the
962
+ browser closes, invisible to content scripts — and only the background worker ever sends it.${
963
+ reuseWeb
964
+ ? "\nThe reused page's own *MCP endpoint* field is advisory: an absolute `http(s)` URL there is\nused as typed, and anything else falls back to the paired endpoint."
965
+ : ""
966
+ }
967
+
968
+ ## What is generated and what is yours
969
+
970
+ | Path | Owner |
971
+ |---|---|
972
+ | \`wxt.config.ts\` | the region between the markers is generated (modules, aliases, the projected manifest); below it is yours |
973
+ | \`entrypoints/background.ts\` | the region is the router; below it is where operations that need a browser are implemented |
974
+ | \`entrypoints/example.content.ts\` | a template, matching \`example.com\` until you point it at your own domains (or delete it) — the whole script below the marker is yours |
975
+ | \`utils/\`, \`entrypoints/popup\`, \`entrypoints/options\`, \`tests/\` | generated |
976
+ | \`package.json\` | toolfactory owns the keys it writes; your own dependencies survive |
977
+ | \`public/icon/*.png\` | WXT's placeholder set, copied in by \`toolfactory validate\`; replace them with your own |
978
+
979
+ ${
980
+ reuseWeb
981
+ ? "`@` and `@web` resolve to the web surface's `src/` so that tree's own imports work; use WXT's\n`~/` for files in this directory.\n\n"
982
+ : ""
983
+ }## The boundary
984
+
985
+ Core logic stays in the kernel, where it is testable; the extension is transport and policy. An
986
+ operation that needs the page is *decomposed*, not injected: the content script reads the DOM,
987
+ hands the result to a portable operation as JSON arguments, and puts the answer back. That is why
988
+ \`get_professor_rating(name)\` belongs in the kernel and only the scraping belongs here.
989
+
990
+ Per-engine coverage, the Chromium-only caveats and how to install each build: \`TARGETS.md\`.
991
+ `;
992
+ }
993
+
994
+ export const surface: Surface = {
995
+ id: "browser-extension",
996
+ plan(project) {
997
+ const operations = includedOperations(project, surface);
998
+ const reuseWeb = has(project, "web");
999
+ const file = (path: string, content: string): PlannedFile => ({
1000
+ kind: "file",
1001
+ path: `${HOST_DIR}/${path}`,
1002
+ content,
1003
+ });
1004
+ const page = (kind: "popup" | "options" | "sidepanel"): PlannedFile[] => [
1005
+ file(`entrypoints/${kind}/index.html`, pageHtml(project, kind)),
1006
+ file(`entrypoints/${kind}/main.tsx`, pageMain(reuseWeb, kind)),
1007
+ ];
1008
+ return [
1009
+ file(".gitignore", BROWSER_SCAFFOLD.gitignore),
1010
+ file("README.md", readme(project, reuseWeb)),
1011
+ file("TARGETS.md", targetsMarkdown(project)),
1012
+ {
1013
+ kind: "region",
1014
+ path: `${HOST_DIR}/entrypoints/background.ts`,
1015
+ regions: [{ begin: BG_BEGIN, end: BG_END, content: BG_REGION }],
1016
+ template: `${BG_BEGIN}${BG_END}\n${BG_TAIL}`,
1017
+ },
1018
+ {
1019
+ kind: "region",
1020
+ path: `${HOST_DIR}/entrypoints/example.content.ts`,
1021
+ regions: [{ begin: CONTENT_BEGIN, end: CONTENT_END, content: CONTENT_REGION }],
1022
+ template: contentTemplate(project),
1023
+ },
1024
+ ...page("popup"),
1025
+ ...page("options"),
1026
+ ...(options(project).sidePanel ? page("sidepanel") : []),
1027
+ {
1028
+ kind: "merge",
1029
+ path: `${HOST_DIR}/package.json`,
1030
+ format: "json",
1031
+ patch: packageJsonPatch(project, reuseWeb),
1032
+ },
1033
+ file("tests/background.test.ts", backgroundTest(project)),
1034
+ file("tests/smoke.mjs", smokeMjs(project, reuseWeb)),
1035
+ file("tsconfig.json", BROWSER_SCAFFOLD.tsconfig),
1036
+ file("utils/mcp.ts", mcpTs(project)),
1037
+ file("utils/page.tsx", PAGE_TSX),
1038
+ ...(reuseWeb ? [file("utils/page.css", PAGE_CSS), file("env.d.ts", ENV_D_TS)] : []),
1039
+ file("vitest.config.ts", VITEST_CONFIG),
1040
+ file("web-ext-config.mjs", WEB_EXT_CONFIG),
1041
+ {
1042
+ kind: "region",
1043
+ path: `${HOST_DIR}/wxt.config.ts`,
1044
+ regions: [
1045
+ {
1046
+ begin: CONFIG_BEGIN,
1047
+ end: CONFIG_END,
1048
+ content: configRegion(project, operations, reuseWeb),
1049
+ },
1050
+ ],
1051
+ template: `${CONFIG_BEGIN}${CONFIG_END}\n${CONFIG_TAIL}`,
1052
+ },
1053
+ ];
1054
+ },
1055
+ validate(project) {
1056
+ // Every step runs inside the extension, against the binaries it pins as devDependencies:
1057
+ // `wxt` reads its entrypoints from the working directory, so `npm --prefix` alone would
1058
+ // point it at the repository root.
1059
+ const at = { cwd: join(project.root, HOST_DIR) };
1060
+ const own = (label: string, ...args: string[]) => ({
1061
+ label,
1062
+ command: "npm",
1063
+ args: ["exec", "--no", "--", ...args],
1064
+ ...at,
1065
+ });
1066
+ return [
1067
+ { label: "npm install (hosts/browser)", command: "npm", args: ["install"], ...at },
1068
+ own("wxt prepare", "wxt", "prepare"),
1069
+ // C2: the scaffold is mirrored by running the real generator, never by trusting constants.
1070
+ // The same run seeds WXT's placeholder icons, which plan() cannot write: they are binary.
1071
+ {
1072
+ label: "browser scaffold drift",
1073
+ command: "node",
1074
+ args: [...execArgv, DRIFT_ENTRY, project.root],
1075
+ cwd: project.root,
1076
+ },
1077
+ own("wxt build (chrome-mv3)", "wxt", "build"),
1078
+ own("wxt build (firefox-mv2)", "wxt", "build", "-b", "firefox"),
1079
+ // addons-linter, wrapped: the only keyless manifest validator any engine publishes, and it
1080
+ // reads a Chromium manifest sensibly too.
1081
+ own("web-ext lint", "web-ext", "lint", "-s", ".output/firefox-mv2"),
1082
+ // The smoke drives the Chromium this project's own playwright expects; `install` is a
1083
+ // no-op once that revision is present.
1084
+ own("playwright install chromium (hosts/browser)", "playwright", "install", "chromium"),
1085
+ { label: "vitest (hosts/browser)", command: "npm", args: ["run", "test"], ...at },
1086
+ {
1087
+ label: "playwright smoke (hosts/browser)",
1088
+ command: "node",
1089
+ args: ["tests/smoke.mjs"],
1090
+ ...at,
1091
+ },
1092
+ ];
1093
+ },
1094
+ verdict,
1095
+ };