@foldspace_npm/harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -0
- package/bin/attach.mjs +399 -0
- package/bin/build-cli.mjs +13 -0
- package/bin/build.ts +82 -0
- package/bin/buildExtension.mjs +90 -0
- package/bin/cli.mjs +33 -0
- package/bin/deploy.mjs +220 -0
- package/bin/inject.mjs +524 -0
- package/bin/packageExtension.mjs +29 -0
- package/package.json +23 -0
- package/src/init.mjs +301 -0
- package/src/transports/README.md +24 -0
- package/templates/agent-starter/CLAUDE.md +90 -0
- package/templates/agent-starter/README.md +54 -0
- package/templates/agent-starter/agent/actions/_example.ts +18 -0
- package/templates/agent-starter/agent/actions/index.ts +10 -0
- package/templates/agent-starter/agent/api/.gitkeep +1 -0
- package/templates/agent-starter/agent/constants.ts +3 -0
- package/templates/agent-starter/agent/utils.ts +15 -0
- package/templates/agent-starter/foldspace.dev.json +17 -0
- package/templates/agent-starter/gitignore +6 -0
- package/templates/agent-starter/package.json +17 -0
- package/templates/agent-starter/tsconfig.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# @foldspace_npm/harness
|
|
2
|
+
|
|
3
|
+
Build, inject and verify Foldspace agent experiences against a live app.
|
|
4
|
+
|
|
5
|
+
**This package is the part that is the same in both architectures.** Everything
|
|
6
|
+
else about how a builder runs differs between them; this does not.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## The two architectures
|
|
11
|
+
|
|
12
|
+
| | **A — local** *(today)* | **B — remote** *(designed, unbuilt)* |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Who runs the agent | Claude Code on the builder's desktop | Claude Agent SDK in GCP |
|
|
15
|
+
| Whose browser | an isolated Chrome the harness launches | the user's **own** Chrome |
|
|
16
|
+
| How code reaches the page | CDP injection | Web Store extension injects a `<script src>` from the CDN |
|
|
17
|
+
| Prerequisites | Claude Code, a client repo, Node, an isolated Chrome profile | the extension installed |
|
|
18
|
+
| Session and cookies | in a profile the harness creates | the user's own, untouched |
|
|
19
|
+
| Code and history | a git repo on the builder's machine | a workspace in the hosted environment |
|
|
20
|
+
|
|
21
|
+
"Export to repo" moves a build from B to A — **and it is only a copy, not a
|
|
22
|
+
rewrite, while `agent/` is identical in both.** Keeping it identical is the
|
|
23
|
+
constraint this package exists to protect.
|
|
24
|
+
|
|
25
|
+
### What is genuinely shared
|
|
26
|
+
|
|
27
|
+
- `agent/actions/*` and `agent/api/*` — a handler is `fetch` plus `runTask`,
|
|
28
|
+
nothing more. The actions built for Figma run unmodified in either track.
|
|
29
|
+
- `foldspace-build` — esbuild → `dist/index.js`
|
|
30
|
+
- `foldspace-deploy` — publish to `agent/actions/<env>/<productId>/<agentApiName>`
|
|
31
|
+
- fixtures and tests
|
|
32
|
+
- **the verb interface** below
|
|
33
|
+
|
|
34
|
+
### What differs — only the transport
|
|
35
|
+
|
|
36
|
+
| Verb | A (CDP) | B (extension) |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `loadBundle` | `addScriptToEvaluateOnNewDocument` | `<script src>` at the deployed CDN path |
|
|
39
|
+
| `evaluate` | `Runtime.evaluate` | content-script message |
|
|
40
|
+
| `screenshot` | `Page.captureScreenshot` | `tabs.captureVisibleTab` |
|
|
41
|
+
| `relaxCSP` | `Page.setBypassCSP` | `declarativeNetRequest` header rules |
|
|
42
|
+
| `navigate` | `Page.navigate` | `tabs.update` |
|
|
43
|
+
|
|
44
|
+
Detail and status: [`src/transports/README.md`](src/transports/README.md).
|
|
45
|
+
|
|
46
|
+
### Why one package rather than two
|
|
47
|
+
|
|
48
|
+
Splitting `harness-local` and `harness-remote` forks `build` and `deploy` — the
|
|
49
|
+
exact failure this package was created to end. And the verb interface only stays
|
|
50
|
+
honest while both implementations sit behind it; separate them and "the same
|
|
51
|
+
action runs in both modes" quietly stops being true.
|
|
52
|
+
|
|
53
|
+
A hosted container installs this and never calls `inject`. That is kilobytes of
|
|
54
|
+
dead weight, against a contract that cannot drift.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Use
|
|
59
|
+
|
|
60
|
+
### Create a project
|
|
61
|
+
|
|
62
|
+
Create a configured actions project from the template bundled with the installed
|
|
63
|
+
harness version:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm exec --package=@foldspace_npm/harness -- foldspace init my-agent \
|
|
67
|
+
--product-id FR8JUQZAQRZB \
|
|
68
|
+
--agent-api-name my-agent \
|
|
69
|
+
--domain app.example.com \
|
|
70
|
+
--name "My Agent"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
When running directly from a harness checkout during development:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
node bin/cli.mjs init ../my-agent \
|
|
77
|
+
--product-id FR8JUQZAQRZB \
|
|
78
|
+
--agent-api-name my-agent \
|
|
79
|
+
--domain app.example.com
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Until `@foldspace_npm/harness` is published, install the local checkout in the
|
|
83
|
+
generated project instead of running the standard install step:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
cd ../my-agent
|
|
87
|
+
npm install --ignore-scripts --save-dev /absolute/path/to/harness
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`--name` is optional and defaults to the target directory name. The product ID
|
|
91
|
+
must be the bare ID, not an `EU-…` SDK key. The domain may be a hostname or an
|
|
92
|
+
HTTP(S) URL without a port or path.
|
|
93
|
+
|
|
94
|
+
For safety, `init` requires a target path that does not exist. It does not
|
|
95
|
+
install dependencies, initialize Git, or overwrite files. After creation:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
cd my-agent
|
|
99
|
+
npm install --ignore-scripts
|
|
100
|
+
npm run build
|
|
101
|
+
npm run inject
|
|
102
|
+
npm run attach
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The generated npm scripts intentionally remain the normal project interface;
|
|
106
|
+
`foldspace init` is the one-time project creation command.
|
|
107
|
+
|
|
108
|
+
### Add the harness to an existing project
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm i -D @foldspace_npm/harness
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{ "scripts": {
|
|
116
|
+
"dev": "foldspace-build --watch",
|
|
117
|
+
"build": "foldspace-build",
|
|
118
|
+
"inject": "foldspace-inject",
|
|
119
|
+
"attach": "foldspace-attach",
|
|
120
|
+
"deploy": "foldspace-deploy"
|
|
121
|
+
} }
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Command | A | B |
|
|
125
|
+
|---|:--:|:--:|
|
|
126
|
+
| `foldspace init` | ✅ | ✅ |
|
|
127
|
+
| `foldspace-build` | ✅ | ✅ |
|
|
128
|
+
| `foldspace-deploy` | ✅ | ✅ — it *is* the delivery mechanism |
|
|
129
|
+
| `foldspace-inject` | ✅ | — no browser to launch |
|
|
130
|
+
| `foldspace-attach` | ✅ | replaced by the extension bridge |
|
|
131
|
+
|
|
132
|
+
Every command resolves the **consuming** repo — `process.cwd()`, or
|
|
133
|
+
`FOLDSPACE_PROJECT_DIR` so a hosted builder can point it at a workspace it
|
|
134
|
+
controls. Config comes from the consumer's `foldspace.dev.json`.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Why this package exists at all
|
|
139
|
+
|
|
140
|
+
`scripts/` used to be copied into every client repo, and copies fork. By
|
|
141
|
+
2026-08-25 `client-template` held one fix and a client repo held four others,
|
|
142
|
+
and **neither had all five**:
|
|
143
|
+
|
|
144
|
+
| Fix | Why it matters |
|
|
145
|
+
|---|---|
|
|
146
|
+
| SDK load guard + retry | On an app with no `document.body` at document-start the append throws; the old guard tested for the stub, so nothing retried and the agent silently never loaded |
|
|
147
|
+
| Worker release on auto-attach | Auto-attach pauses every worker until the attaching client releases it — the harness froze the app's own workers for the whole session |
|
|
148
|
+
| `agentIds` enumeration | `agent({apiName})` returns the OVERLAY handle; arming it on an embedded copilot leaves real conversations untagged |
|
|
149
|
+
| `--no-test-mode` | Test mode was armed unconditionally, hiding exactly what initial setup needs to see |
|
|
150
|
+
| Honest badge | It asserted `TEST MODE` whether or not test mode was on |
|
|
151
|
+
|
|
152
|
+
Two further changes came from packaging it: the bundle is **injected** rather
|
|
153
|
+
than fetched from a dev server — which removed `express` and `concurrently`, 95
|
|
154
|
+
packages, and is the precondition for driving a browser that cannot reach the
|
|
155
|
+
build machine — and `inject` now records the port that `attach` reads, since
|
|
156
|
+
they previously disagreed by construction.
|
package/bin/attach.mjs
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Attach to the Chrome launched by inject.mjs and serve local actions to it.
|
|
4
|
+
*
|
|
5
|
+
* Two modes, picked automatically:
|
|
6
|
+
*
|
|
7
|
+
* swap (default) The client app already embeds Foldspace. We
|
|
8
|
+
* intercept the remote-actions bundle request and fulfill it
|
|
9
|
+
* with dist/index.js from disk. This is the CDP equivalent of
|
|
10
|
+
* the production extension's redirect rule, and it reuses the
|
|
11
|
+
* page's own agent instead of creating a second one.
|
|
12
|
+
*
|
|
13
|
+
* --bootstrap The app has no Foldspace. Inject the SDK bootstrap from the
|
|
14
|
+
* generated extension/index.js before page scripts run.
|
|
15
|
+
*
|
|
16
|
+
* Chrome 136+ removed --load-extension and does not run content scripts for
|
|
17
|
+
* CDP-loaded extensions, so neither path uses the extension at runtime.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "fs";
|
|
20
|
+
import path from "path";
|
|
21
|
+
import { fileURLToPath } from "url";
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
// Resolve the CONSUMING repo, not this package. FOLDSPACE_PROJECT_DIR lets a
|
|
25
|
+
// hosted builder point the harness at a workspace it controls.
|
|
26
|
+
const root = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
|
|
27
|
+
const extDir = path.join(root, ".foldspace-dev", "extension");
|
|
28
|
+
const bundlePath = path.join(root, "dist", "index.js");
|
|
29
|
+
|
|
30
|
+
// Prefer what inject actually launched, then an explicit override, then the
|
|
31
|
+
// legacy default. Guessing here means attaching to the wrong browser.
|
|
32
|
+
function portFromState() {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(path.join(root, ".foldspace-dev", "state.json"), "utf8")).debugPort;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const portArgIndex = process.argv.indexOf("--port");
|
|
40
|
+
const port = String(
|
|
41
|
+
(portArgIndex > -1 && process.argv[portArgIndex + 1]) ||
|
|
42
|
+
process.env.CDP_PORT ||
|
|
43
|
+
portFromState() ||
|
|
44
|
+
"9222",
|
|
45
|
+
);
|
|
46
|
+
const bootstrap = process.argv.includes("--bootstrap");
|
|
47
|
+
// Test mode keeps conversations out of the customer's default list. Turn it off
|
|
48
|
+
// only when you WANT the conversations and action calls to show up in the
|
|
49
|
+
// Foldspace dashboard — a brand-new agent during initial setup, where there is
|
|
50
|
+
// no production traffic to pollute.
|
|
51
|
+
const noTestMode = process.argv.includes("--no-test-mode");
|
|
52
|
+
// The badge used to hardcode "TEST MODE" whether or not test mode was on — a
|
|
53
|
+
// string that asserts you are safe while you are not. It now states what is
|
|
54
|
+
// actually true. --no-badge drops it entirely, for recording a demo.
|
|
55
|
+
const noBadge = process.argv.includes("--no-badge");
|
|
56
|
+
const badgeText = noTestMode
|
|
57
|
+
? "FOLDSPACE DEV \u00b7 local actions \u00b7 LIVE"
|
|
58
|
+
: "FOLDSPACE DEV \u00b7 local actions \u00b7 TEST MODE";
|
|
59
|
+
|
|
60
|
+
// Agent api name for setTestMode — from the same config inject.mjs used.
|
|
61
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(root, "foldspace.dev.json"), "utf8"));
|
|
62
|
+
const cfgTarget = cfg.targets[cfg.defaultTarget] || {};
|
|
63
|
+
const agentApiName =
|
|
64
|
+
process.env.AGENT_API_NAME ||
|
|
65
|
+
(process.argv.includes("--agent")
|
|
66
|
+
? process.argv[process.argv.indexOf("--agent") + 1]
|
|
67
|
+
: cfgTarget.agentApiName);
|
|
68
|
+
|
|
69
|
+
// Refuse to run against an uninitialised template. Without this, attach injects
|
|
70
|
+
// the literal placeholder as an agent api name and the page fails with an opaque
|
|
71
|
+
// "Failed to fetch agent config: status 400" — after having already broken the
|
|
72
|
+
// app's own agent.
|
|
73
|
+
const PLACEHOLDERS = ["AGENT_API_NAME", "PRODUCT_ID", "__APP_DOMAIN__", "__APP_ORIGIN__"];
|
|
74
|
+
const unresolved = PLACEHOLDERS.filter((ph) =>
|
|
75
|
+
agentApiName === ph || JSON.stringify(cfgTarget).includes(ph)
|
|
76
|
+
);
|
|
77
|
+
if (unresolved.length) {
|
|
78
|
+
console.error(
|
|
79
|
+
`attach: foldspace.dev.json still contains placeholders: ${unresolved.join(", ")}\n` +
|
|
80
|
+
` Run "node scripts/init.mjs" first, or pass --agent <apiName>.\n` +
|
|
81
|
+
` Refusing to run — injecting a placeholder breaks the app's agent.`
|
|
82
|
+
);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(extDir, "manifest.json"), "utf8"));
|
|
87
|
+
const hostPatterns = manifest.content_scripts[0].matches.map((m) =>
|
|
88
|
+
m.replace("*://", "").replace("/*", "")
|
|
89
|
+
);
|
|
90
|
+
const hostMatches = (url) => {
|
|
91
|
+
let h;
|
|
92
|
+
try { h = new URL(url).hostname; } catch { return false; }
|
|
93
|
+
return hostPatterns.some((p) =>
|
|
94
|
+
p.startsWith("*.") ? h === p.slice(2) || h.endsWith(p.slice(1)) : h === p
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Any remote-actions bundle, dev or prod, for any product/agent.
|
|
99
|
+
const ACTIONS_PATTERN = "*/agent/actions/*";
|
|
100
|
+
|
|
101
|
+
const badgeSrc = `(() => {
|
|
102
|
+
if (window.top !== window.self) return;
|
|
103
|
+
const add = () => {
|
|
104
|
+
if (document.getElementById("foldspace-dev-badge") || !document.body) return;
|
|
105
|
+
const b = document.createElement("div");
|
|
106
|
+
b.id = "foldspace-dev-badge";
|
|
107
|
+
b.textContent = ${JSON.stringify(badgeText)};
|
|
108
|
+
b.style.cssText = "position:fixed;top:0;left:0;z-index:2147483647;background:#3247F2;color:#fff;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.12em;padding:5px 10px;border-bottom-right-radius:4px;pointer-events:none;box-shadow:0 1px 6px rgba(0,0,0,.35)";
|
|
109
|
+
document.body.appendChild(b);
|
|
110
|
+
};
|
|
111
|
+
add();
|
|
112
|
+
document.addEventListener("DOMContentLoaded", add);
|
|
113
|
+
setInterval(add, 2000);
|
|
114
|
+
})();`;
|
|
115
|
+
|
|
116
|
+
// Flag every local session as test traffic so it is excluded from analytics and
|
|
117
|
+
// hidden from the default conversation list.
|
|
118
|
+
// https://docs.foldspace.ai/reference/test-mode.md
|
|
119
|
+
//
|
|
120
|
+
// A handle is scoped by mode: window.foldspace.agent({apiName}) alone returns
|
|
121
|
+
// the OVERLAY instance. On an app that embeds the copilot that is NOT the
|
|
122
|
+
// instance serving the chat, so arming it succeeds, logs success, and leaves
|
|
123
|
+
// the real conversations untagged — the failure that actually leaks. Enumerate
|
|
124
|
+
// foldspace.agentIds() — each id is shaped "<mode>-<apiName>" — and arm every
|
|
125
|
+
// one. See the playbook's test-mode rule.
|
|
126
|
+
const testModeSrc = `(() => {
|
|
127
|
+
if (window.top !== window.self) return;
|
|
128
|
+
const AGENT = ${JSON.stringify(agentApiName)};
|
|
129
|
+
// Log once per instance; setTestMode itself is re-applied on every pass so an
|
|
130
|
+
// instance that is torn down and rebuilt gets re-armed.
|
|
131
|
+
const logged = new Set();
|
|
132
|
+
|
|
133
|
+
const armAll = () => {
|
|
134
|
+
const fs = window.foldspace;
|
|
135
|
+
if (!fs) return;
|
|
136
|
+
|
|
137
|
+
let ids = null;
|
|
138
|
+
try {
|
|
139
|
+
if (typeof fs.agentIds === "function") ids = fs.agentIds();
|
|
140
|
+
} catch (e) {}
|
|
141
|
+
|
|
142
|
+
// Older SDK with no agentIds(): the unscoped handle is all there is.
|
|
143
|
+
if (!ids) {
|
|
144
|
+
try {
|
|
145
|
+
fs.agent({ apiName: AGENT }).setTestMode(true);
|
|
146
|
+
if (!logged.has(AGENT)) {
|
|
147
|
+
logged.add(AGENT);
|
|
148
|
+
console.log("[foldspace-dev] test mode ON (" + AGENT + " — SDK has no agentIds(), overlay handle only)");
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.warn("[foldspace-dev] setTestMode failed:", e && e.message);
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const id of ids) {
|
|
157
|
+
const cut = id.indexOf("-");
|
|
158
|
+
if (cut < 1) continue;
|
|
159
|
+
try {
|
|
160
|
+
fs.agent({ apiName: id.slice(cut + 1), mode: id.slice(0, cut).toUpperCase() })
|
|
161
|
+
.setTestMode(true);
|
|
162
|
+
if (!logged.has(id)) {
|
|
163
|
+
logged.add(id);
|
|
164
|
+
console.log("[foldspace-dev] test mode ON (" + id + ")");
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
console.warn("[foldspace-dev] setTestMode failed for " + id + ":", e && e.message);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const arm = () => {
|
|
173
|
+
try { window.foldspace("when", "ready", armAll); } catch (e) {}
|
|
174
|
+
armAll();
|
|
175
|
+
|
|
176
|
+
// The instance serving the chat is built when the copilot panel opens, long
|
|
177
|
+
// after "ready" — arming once is too early. Keep arming.
|
|
178
|
+
setInterval(armAll, 1000);
|
|
179
|
+
|
|
180
|
+
let queued = false;
|
|
181
|
+
const armSoon = () => {
|
|
182
|
+
if (queued) return;
|
|
183
|
+
queued = true;
|
|
184
|
+
setTimeout(() => { queued = false; armAll(); }, 200);
|
|
185
|
+
};
|
|
186
|
+
try {
|
|
187
|
+
const obs = new MutationObserver(armSoon);
|
|
188
|
+
const observe = () => {
|
|
189
|
+
if (document.body) obs.observe(document.body, { childList: true, subtree: true });
|
|
190
|
+
};
|
|
191
|
+
if (document.body) observe();
|
|
192
|
+
else document.addEventListener("DOMContentLoaded", observe);
|
|
193
|
+
} catch (e) {}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if (window.foldspace) return arm();
|
|
197
|
+
// The app bootstraps its own SDK — wait for it rather than racing it.
|
|
198
|
+
let n = 0;
|
|
199
|
+
const t = setInterval(() => {
|
|
200
|
+
if (window.foldspace) { clearInterval(t); arm(); }
|
|
201
|
+
else if (++n > 200) clearInterval(t);
|
|
202
|
+
}, 50);
|
|
203
|
+
})();`;
|
|
204
|
+
|
|
205
|
+
let nextId = 1;
|
|
206
|
+
const pending = new Map();
|
|
207
|
+
const prepared = new Set();
|
|
208
|
+
// targetId -> sessionId, so a target that navigates INTO a matching host can be
|
|
209
|
+
// prepared later. Without this, opening Chrome on a new tab and then browsing
|
|
210
|
+
// to the app never arms the swap.
|
|
211
|
+
const sessions = new Map();
|
|
212
|
+
let served = 0;
|
|
213
|
+
|
|
214
|
+
const call = (ws, method, params = {}, sessionId) =>
|
|
215
|
+
new Promise((resolve) => {
|
|
216
|
+
const id = nextId++;
|
|
217
|
+
pending.set(id, resolve);
|
|
218
|
+
ws.send(JSON.stringify({ id, method, params, sessionId }));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
async function prepare(ws, sessionId, url) {
|
|
222
|
+
if (prepared.has(sessionId)) return;
|
|
223
|
+
prepared.add(sessionId);
|
|
224
|
+
|
|
225
|
+
await call(ws, "Page.enable", {}, sessionId);
|
|
226
|
+
await call(ws, "Runtime.enable", {}, sessionId);
|
|
227
|
+
await call(ws, "Page.setBypassCSP", { enabled: true }, sessionId);
|
|
228
|
+
|
|
229
|
+
// Workers spawned by this page attach through THIS session, not the browser
|
|
230
|
+
// one, so the browser-level setAutoAttach does not cover them and they start
|
|
231
|
+
// paused. Say so here too, or the app's workers freeze for the session.
|
|
232
|
+
await call(ws, "Target.setAutoAttach", {
|
|
233
|
+
autoAttach: true, waitForDebuggerOnStart: false, flatten: true,
|
|
234
|
+
}, sessionId);
|
|
235
|
+
|
|
236
|
+
await call(ws, "Fetch.enable", {
|
|
237
|
+
patterns: [{ urlPattern: ACTIONS_PATTERN, requestStage: "Request" }],
|
|
238
|
+
}, sessionId);
|
|
239
|
+
|
|
240
|
+
// The action bundle, injected rather than fetched. This is what lets the
|
|
241
|
+
// harness drop the dev server — and what makes a non-local browser possible,
|
|
242
|
+
// since a remote Chrome can never reach localhost.
|
|
243
|
+
const scripts = [];
|
|
244
|
+
if (fs.existsSync(bundlePath)) {
|
|
245
|
+
scripts.push(fs.readFileSync(bundlePath, "utf8"));
|
|
246
|
+
} else {
|
|
247
|
+
console.log(` !! ${path.relative(root, bundlePath)} missing — run "npm run dev"`);
|
|
248
|
+
}
|
|
249
|
+
if (!noBadge) scripts.push(badgeSrc);
|
|
250
|
+
if (!noTestMode) scripts.push(testModeSrc);
|
|
251
|
+
if (bootstrap) scripts.push(fs.readFileSync(path.join(extDir, "index.js"), "utf8"));
|
|
252
|
+
for (const source of scripts) {
|
|
253
|
+
await call(ws, "Page.addScriptToEvaluateOnNewDocument", { source, runImmediately: true }, sessionId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log(` attached -> ${url}`);
|
|
257
|
+
await call(ws, "Page.reload", { ignoreCache: true }, sessionId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function onEvent(ws, msg) {
|
|
261
|
+
const { method, params, sessionId } = msg;
|
|
262
|
+
|
|
263
|
+
if (method === "Target.attachedToTarget") {
|
|
264
|
+
const t = params.targetInfo;
|
|
265
|
+
if (t.type === "page") {
|
|
266
|
+
sessions.set(t.targetId, params.sessionId);
|
|
267
|
+
if (hostMatches(t.url)) await prepare(ws, params.sessionId, t.url);
|
|
268
|
+
} else {
|
|
269
|
+
// Auto-attach pauses workers on start until the attaching client says go.
|
|
270
|
+
// We do not instrument workers — but if we never release them they stay
|
|
271
|
+
// frozen for the whole session. On an app that does real work in workers
|
|
272
|
+
// that is a broken app, and Chrome tells the user "Debugger paused in
|
|
273
|
+
// another tab". Only this session can release them; a second CDP client
|
|
274
|
+
// sending runIfWaitingForDebugger has no effect.
|
|
275
|
+
await call(ws, "Runtime.runIfWaitingForDebugger", {}, params.sessionId);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A target we already know about navigated. If it just entered a matching
|
|
281
|
+
// host, arm it now.
|
|
282
|
+
if (method === "Target.targetInfoChanged") {
|
|
283
|
+
const t = params.targetInfo;
|
|
284
|
+
if (t.type === "page" && hostMatches(t.url)) {
|
|
285
|
+
const sid = sessions.get(t.targetId);
|
|
286
|
+
if (sid) await prepare(ws, sid, t.url);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (method === "Fetch.requestPaused") {
|
|
292
|
+
const { requestId, request } = params;
|
|
293
|
+
if (!fs.existsSync(bundlePath)) {
|
|
294
|
+
console.log(` !! dist/index.js missing — run "npm run dev"`);
|
|
295
|
+
await call(ws, "Fetch.continueRequest", { requestId }, sessionId);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const body = fs.readFileSync(bundlePath);
|
|
299
|
+
served++;
|
|
300
|
+
console.log(` served local actions (${(body.length / 1024).toFixed(0)}kb) for ${request.url.slice(0, 78)}`);
|
|
301
|
+
await call(ws, "Fetch.fulfillRequest", {
|
|
302
|
+
requestId,
|
|
303
|
+
responseCode: 200,
|
|
304
|
+
responseHeaders: [
|
|
305
|
+
{ name: "content-type", value: "application/javascript; charset=utf-8" },
|
|
306
|
+
{ name: "access-control-allow-origin", value: "*" },
|
|
307
|
+
{ name: "cache-control", value: "no-store" },
|
|
308
|
+
],
|
|
309
|
+
body: body.toString("base64"),
|
|
310
|
+
}, sessionId);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (method === "Runtime.consoleAPICalled") {
|
|
315
|
+
const text = (params.args || [])
|
|
316
|
+
.map((a) => (a.value !== undefined ? a.value : a.description || ""))
|
|
317
|
+
.join(" ");
|
|
318
|
+
if (/foldspace-dev|remote actions|identified|Error/i.test(text) && !/IO (entry|callback)/.test(text)) {
|
|
319
|
+
console.log(` [page] ${text.slice(0, 160)}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let ws;
|
|
325
|
+
|
|
326
|
+
// Declared before connect() wires ws.onclose: a socket that closes during
|
|
327
|
+
// startup would otherwise reach onDisconnect() while keepAlive is still in the
|
|
328
|
+
// temporal dead zone, throwing instead of reconnecting.
|
|
329
|
+
const keepAlive = setInterval(() => {}, 1 << 30);
|
|
330
|
+
|
|
331
|
+
async function connect() {
|
|
332
|
+
const ver = await (await fetch(`http://localhost:${port}/json/version`)).json();
|
|
333
|
+
ws = new WebSocket(ver.webSocketDebuggerUrl);
|
|
334
|
+
await new Promise((resolve, reject) => {
|
|
335
|
+
ws.onopen = resolve;
|
|
336
|
+
ws.onerror = reject;
|
|
337
|
+
});
|
|
338
|
+
ws.onmessage = (e) => {
|
|
339
|
+
const m = JSON.parse(e.data);
|
|
340
|
+
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
|
|
341
|
+
else if (m.method) onEvent(ws, m);
|
|
342
|
+
};
|
|
343
|
+
ws.onclose = () => { void onDisconnect(); };
|
|
344
|
+
await call(ws, "Target.setDiscoverTargets", { discover: true });
|
|
345
|
+
await call(ws, "Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
346
|
+
return ver;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const ver = await connect();
|
|
350
|
+
|
|
351
|
+
console.log(`Attached to ${ver.Browser} on :${port}`);
|
|
352
|
+
console.log(`Mode: ${bootstrap ? "bootstrap (inject SDK)" : "swap (reuse the app's agent)"}`);
|
|
353
|
+
console.log(`Test: ${noTestMode ? "OFF — conversations WILL appear in the dashboard" : "on"}`);
|
|
354
|
+
console.log(`Hosts: ${hostPatterns.join(", ")}`);
|
|
355
|
+
console.log(`Serving: ${path.relative(root, bundlePath)}\n`);
|
|
356
|
+
|
|
357
|
+
process.on("SIGINT", () => {
|
|
358
|
+
console.log(`\nDetached. Served the local bundle ${served} time(s).`);
|
|
359
|
+
process.exit(0);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// A closed socket does not mean the browser closed. Chrome drops the CDP
|
|
363
|
+
// connection on target churn and transient errors too, and exiting on those
|
|
364
|
+
// silently ends the swap while the user keeps browsing — the page then loads
|
|
365
|
+
// the deployed bundle with no signal that anything changed. Reconnect, and
|
|
366
|
+
// only give up once the debugging port is genuinely gone.
|
|
367
|
+
async function browserIsUp() {
|
|
368
|
+
try {
|
|
369
|
+
const r = await fetch(`http://localhost:${port}/json/version`);
|
|
370
|
+
return r.ok;
|
|
371
|
+
} catch {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function onDisconnect() {
|
|
377
|
+
prepared.clear();
|
|
378
|
+
sessions.clear();
|
|
379
|
+
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
380
|
+
await new Promise((r) => setTimeout(r, Math.min(500 * attempt, 3000)));
|
|
381
|
+
if (!(await browserIsUp())) {
|
|
382
|
+
clearInterval(keepAlive);
|
|
383
|
+
console.log("\nBrowser closed. Detaching.");
|
|
384
|
+
process.exit(0);
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
console.log(` connection lost — reconnecting (${attempt}/10)…`);
|
|
388
|
+
await connect();
|
|
389
|
+
console.log(" reconnected. Swap is live again.");
|
|
390
|
+
return;
|
|
391
|
+
} catch {
|
|
392
|
+
// fall through and retry
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
clearInterval(keepAlive);
|
|
396
|
+
console.error("\nCould not reconnect after 10 attempts. Detaching — "
|
|
397
|
+
+ "the page is now loading the DEPLOYED bundle, not your local build.");
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin launcher so the TypeScript build script runs without the consumer
|
|
3
|
+
// needing tsx wired up themselves.
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const child = spawn(
|
|
9
|
+
process.execPath,
|
|
10
|
+
[path.join(here, "..", "node_modules", "tsx", "dist", "cli.mjs"), path.join(here, "build.ts"), ...process.argv.slice(2)],
|
|
11
|
+
{ stdio: "inherit", env: { ...process.env, FOLDSPACE_PROJECT_DIR: process.env.FOLDSPACE_PROJECT_DIR || process.cwd() } },
|
|
12
|
+
);
|
|
13
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
package/bin/build.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as esbuild from "esbuild";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
|
|
8
|
+
const distDir = path.join(projectDir, "dist");
|
|
9
|
+
const agentActionsEntry = path.join(projectDir, "agent", "actions", "index.ts");
|
|
10
|
+
|
|
11
|
+
const watchMode = process.argv.includes("--watch");
|
|
12
|
+
|
|
13
|
+
function buildOptions(): esbuild.BuildOptions {
|
|
14
|
+
return {
|
|
15
|
+
entryPoints: [agentActionsEntry],
|
|
16
|
+
outfile: path.join(distDir, "index.js"),
|
|
17
|
+
bundle: true,
|
|
18
|
+
format: "iife",
|
|
19
|
+
platform: "browser",
|
|
20
|
+
target: "es2022",
|
|
21
|
+
minify: true,
|
|
22
|
+
sourcemap: false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rebuildPlugin(): esbuild.Plugin {
|
|
27
|
+
return {
|
|
28
|
+
name: "rebuild-notify",
|
|
29
|
+
setup(build) {
|
|
30
|
+
build.onEnd((result) => {
|
|
31
|
+
const time = new Date().toLocaleTimeString();
|
|
32
|
+
if (result.errors.length === 0) {
|
|
33
|
+
console.log(`[${time}] Rebuilt: dist/index.js`);
|
|
34
|
+
} else {
|
|
35
|
+
console.log(`[${time}] Error: dist/index.js`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function build() {
|
|
43
|
+
console.log(`Building agent actions...\n`);
|
|
44
|
+
|
|
45
|
+
if (!fs.existsSync(agentActionsEntry)) {
|
|
46
|
+
console.error(`Entry point not found: ${agentActionsEntry}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(distDir)) {
|
|
51
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
if (watchMode) {
|
|
56
|
+
console.log("Watch mode — watching for changes...\n");
|
|
57
|
+
|
|
58
|
+
const context = await esbuild.context({
|
|
59
|
+
...buildOptions(),
|
|
60
|
+
plugins: [rebuildPlugin()],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await context.watch();
|
|
64
|
+
console.log("Initial build complete! Watching for changes...\n");
|
|
65
|
+
|
|
66
|
+
process.on("SIGINT", async () => {
|
|
67
|
+
console.log("\nStopping watcher...");
|
|
68
|
+
await context.dispose();
|
|
69
|
+
process.exit(0);
|
|
70
|
+
});
|
|
71
|
+
} else {
|
|
72
|
+
await esbuild.build(buildOptions());
|
|
73
|
+
console.log("Built: dist/index.js");
|
|
74
|
+
console.log("\nBuild complete!");
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error("Build failed:", error);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
build();
|