@foldspace_npm/harness 0.1.8 → 0.1.10
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 +10 -0
- package/bin/attach.mjs +48 -17
- package/bin/inject.mjs +65 -49
- package/package.json +8 -1
- package/src/attach-helpers.mjs +32 -0
- package/src/cdp-ownership.mjs +241 -0
- package/src/cli-registry.mjs +2 -1
- package/src/runtime/agent.ts +66 -0
- package/src/runtime/config.ts +78 -0
- package/src/runtime/http.ts +373 -0
- package/src/runtime/index.ts +36 -0
- package/src/runtime/match.ts +87 -0
- package/src/runtime/render.ts +97 -0
- package/templates/agent-starter/CLAUDE.md +40 -5
- package/templates/agent-starter/agent/actions/_example.ts +20 -25
- package/templates/agent-starter/agent/actions/index.ts +9 -1
- package/templates/agent-starter/agent/constants.ts +25 -0
- package/templates/agent-starter/agent/utils.ts +59 -12
- package/templates/agent-starter/tsconfig.json +1 -0
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ constraint this package exists to protect.
|
|
|
27
27
|
|
|
28
28
|
- `agent/actions/*` and `agent/api/*` — a handler is `fetch` plus `runTask`,
|
|
29
29
|
nothing more. The actions built for Figma run unmodified in either track.
|
|
30
|
+
- `agent/utils.ts` — configures and re-exports `@foldspace_npm/harness/runtime`
|
|
30
31
|
- `foldspace build` — esbuild → `dist/index.js`
|
|
31
32
|
- `foldspace deploy` — publish to `agent/actions/<env>/<productId>/<agentApiName>`
|
|
32
33
|
- fixtures and tests
|
|
@@ -150,6 +151,15 @@ v1 flags:
|
|
|
150
151
|
|
|
151
152
|
Put action instructions in Agent Studio or MCP. `foldspace lint` cannot see that copy.
|
|
152
153
|
|
|
154
|
+
### Runtime helpers
|
|
155
|
+
|
|
156
|
+
Tenant actions import from `agent/utils.ts`, which configures and re-exports
|
|
157
|
+
`@foldspace_npm/harness/runtime`. That code is bundled into `dist/index.js` and
|
|
158
|
+
runs in the customer's page — it is not a CLI command.
|
|
159
|
+
|
|
160
|
+
`API_BASE` and `AUTH_SOURCE` stamp empty. Fill them from a captured XHR, not a
|
|
161
|
+
guess. Custom auth headers stay a tenant override of one function in `utils.ts`.
|
|
162
|
+
|
|
153
163
|
### Choose an attach mode
|
|
154
164
|
|
|
155
165
|
- **Swap (default):** the page already uses the configured product and agent.
|
package/bin/attach.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
hostPatternsFromTarget,
|
|
32
32
|
parseAgentId,
|
|
33
33
|
registrationVerified,
|
|
34
|
+
resolveAttachLaunch,
|
|
34
35
|
} from "../src/attach-helpers.mjs";
|
|
35
36
|
import {
|
|
36
37
|
buildReplacePrelude,
|
|
@@ -47,6 +48,12 @@ import {
|
|
|
47
48
|
createLifecycleResult,
|
|
48
49
|
} from "../src/protocol.mjs";
|
|
49
50
|
import { createCdpRequestManager } from "../src/cdp-request-manager.mjs";
|
|
51
|
+
import {
|
|
52
|
+
assertOwnedCdp,
|
|
53
|
+
chromeProfileDir,
|
|
54
|
+
ownershipErrorMessage,
|
|
55
|
+
verifyLaunchSentinel,
|
|
56
|
+
} from "../src/cdp-ownership.mjs";
|
|
50
57
|
import { buildActionObserverScript } from "../src/action-observer.mjs";
|
|
51
58
|
import { buildBootstrapScript } from "../src/bootstrap-script.mjs";
|
|
52
59
|
import {
|
|
@@ -61,8 +68,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
61
68
|
const root = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
|
|
62
69
|
const bundlePath = path.join(root, "dist", "index.js");
|
|
63
70
|
|
|
64
|
-
// Prefer
|
|
65
|
-
//
|
|
71
|
+
// Prefer an explicit override, then the port inject recorded. Do not guess
|
|
72
|
+
// 9222 — that is how we attach to a different browser than the one we launched.
|
|
66
73
|
function readDevState() {
|
|
67
74
|
try {
|
|
68
75
|
return JSON.parse(
|
|
@@ -82,18 +89,42 @@ const explicitPort =
|
|
|
82
89
|
portArgIndex > -1 ? process.argv[portArgIndex + 1] : null;
|
|
83
90
|
const explicitTarget =
|
|
84
91
|
targetArgIndex > -1 ? process.argv[targetArgIndex + 1] : null;
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
);
|
|
92
|
+
const { useSavedLaunch, port } = resolveAttachLaunch({
|
|
93
|
+
explicitPort,
|
|
94
|
+
envPort: process.env.CDP_PORT,
|
|
95
|
+
explicitTarget,
|
|
96
|
+
savedTarget: devState.target,
|
|
97
|
+
savedDebugPort: devState.debugPort,
|
|
98
|
+
});
|
|
99
|
+
if (!port) {
|
|
100
|
+
console.error(
|
|
101
|
+
`attach: no CDP port. Run "foldspace inject" first, or pass --port / CDP_PORT.\n` +
|
|
102
|
+
` Refusing to guess 9222 — that port is often someone else's Chrome.`,
|
|
103
|
+
);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const profileDir = chromeProfileDir(root);
|
|
107
|
+
const claimed = verifyLaunchSentinel({
|
|
108
|
+
profileDir,
|
|
109
|
+
token: devState.sentinel,
|
|
110
|
+
});
|
|
111
|
+
if (!claimed.ok) {
|
|
112
|
+
console.error(
|
|
113
|
+
`attach: ${ownershipErrorMessage(claimed.reason, { port, profileDir, root })}`,
|
|
114
|
+
);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
const owned = await assertOwnedCdp({
|
|
118
|
+
profileDir,
|
|
119
|
+
port,
|
|
120
|
+
token: devState.sentinel,
|
|
121
|
+
});
|
|
122
|
+
if (!owned.ok) {
|
|
123
|
+
console.error(
|
|
124
|
+
`attach: ${ownershipErrorMessage(owned.reason, { port, profileDir, root })}`,
|
|
125
|
+
);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
97
128
|
let attachMode;
|
|
98
129
|
try {
|
|
99
130
|
attachMode = attachModeFromArgs(process.argv.slice(2));
|
|
@@ -883,8 +914,8 @@ let ws;
|
|
|
883
914
|
// temporal dead zone, throwing instead of reconnecting.
|
|
884
915
|
const keepAlive = setInterval(() => {}, 1 << 30);
|
|
885
916
|
|
|
886
|
-
async function connect() {
|
|
887
|
-
const ver =
|
|
917
|
+
async function connect(version) {
|
|
918
|
+
const ver = version;
|
|
888
919
|
const socket = new WebSocket(ver.webSocketDebuggerUrl);
|
|
889
920
|
await new Promise((resolve, reject) => {
|
|
890
921
|
socket.onopen = resolve;
|
|
@@ -912,7 +943,7 @@ async function connect() {
|
|
|
912
943
|
return ver;
|
|
913
944
|
}
|
|
914
945
|
|
|
915
|
-
const ver = await connect();
|
|
946
|
+
const ver = await connect(owned.version);
|
|
916
947
|
|
|
917
948
|
console.log(`Attached to ${ver.Browser} on :${port}`);
|
|
918
949
|
console.log(`Mode: ${attachMode}`);
|
package/bin/inject.mjs
CHANGED
|
@@ -15,6 +15,11 @@ import fs from "fs";
|
|
|
15
15
|
import path from "path";
|
|
16
16
|
import os from "os";
|
|
17
17
|
import { spawn } from "child_process";
|
|
18
|
+
import {
|
|
19
|
+
ownershipErrorMessage,
|
|
20
|
+
waitForOwnedCdp,
|
|
21
|
+
writeLaunchSentinel,
|
|
22
|
+
} from "../src/cdp-ownership.mjs";
|
|
18
23
|
|
|
19
24
|
// Resolve the CONSUMING repo, not this package. FOLDSPACE_PROJECT_DIR lets a
|
|
20
25
|
// hosted builder point the harness at a workspace it controls.
|
|
@@ -180,26 +185,7 @@ if (!chrome) {
|
|
|
180
185
|
fs.mkdirSync(profileDir, { recursive: true });
|
|
181
186
|
|
|
182
187
|
const debugPort = args.port || "9222";
|
|
183
|
-
|
|
184
|
-
// Record it so attach does not have to be told again. inject and attach
|
|
185
|
-
// disagreeing about the port is how you end up attached to a different
|
|
186
|
-
// browser than the one you launched.
|
|
187
|
-
fs.mkdirSync(workDir, { recursive: true });
|
|
188
|
-
fs.writeFileSync(
|
|
189
|
-
path.join(workDir, "state.json"),
|
|
190
|
-
JSON.stringify(
|
|
191
|
-
{
|
|
192
|
-
debugPort,
|
|
193
|
-
target: targetName,
|
|
194
|
-
resolvedTarget: {
|
|
195
|
-
...target,
|
|
196
|
-
sdkUrl: cfg.sdkUrl,
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
null,
|
|
200
|
-
2,
|
|
201
|
-
),
|
|
202
|
-
);
|
|
188
|
+
const sentinel = writeLaunchSentinel(profileDir);
|
|
203
189
|
|
|
204
190
|
// Name the profile so this window is identifiable among other Chrome windows.
|
|
205
191
|
function nameProfile() {
|
|
@@ -244,6 +230,7 @@ const chromeArgs = [
|
|
|
244
230
|
"--no-first-run",
|
|
245
231
|
"--no-default-browser-check",
|
|
246
232
|
"--test-type",
|
|
233
|
+
"--remote-allow-origins=*",
|
|
247
234
|
startUrl,
|
|
248
235
|
];
|
|
249
236
|
|
|
@@ -253,6 +240,45 @@ console.log("Log in to the app once — the profile persists between runs.\n");
|
|
|
253
240
|
const child = spawn(chrome, chromeArgs, { detached: true, stdio: "ignore" });
|
|
254
241
|
child.unref();
|
|
255
242
|
|
|
243
|
+
const owned = await waitForOwnedCdp({
|
|
244
|
+
profileDir,
|
|
245
|
+
port: debugPort,
|
|
246
|
+
token: sentinel,
|
|
247
|
+
});
|
|
248
|
+
if (!owned.ok) {
|
|
249
|
+
try {
|
|
250
|
+
if (child.pid) process.kill(child.pid, "SIGTERM");
|
|
251
|
+
} catch {
|
|
252
|
+
// The isolated window may still be open without a debug port.
|
|
253
|
+
}
|
|
254
|
+
console.error(`inject: ${ownershipErrorMessage(owned.reason, {
|
|
255
|
+
port: debugPort,
|
|
256
|
+
profileDir,
|
|
257
|
+
root,
|
|
258
|
+
})}`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Record only after the listener is provably this profile. Writing the port
|
|
263
|
+
// first is how attach adopted someone else's Chrome on :9222.
|
|
264
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
265
|
+
fs.writeFileSync(
|
|
266
|
+
path.join(workDir, "state.json"),
|
|
267
|
+
JSON.stringify(
|
|
268
|
+
{
|
|
269
|
+
debugPort,
|
|
270
|
+
sentinel,
|
|
271
|
+
target: targetName,
|
|
272
|
+
resolvedTarget: {
|
|
273
|
+
...target,
|
|
274
|
+
sdkUrl: cfg.sdkUrl,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
null,
|
|
278
|
+
2,
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
|
|
256
282
|
// --- theme -------------------------------------------------------------
|
|
257
283
|
//
|
|
258
284
|
// Chrome 151 ignores --load-extension, so load the optional cosmetic theme
|
|
@@ -260,34 +286,24 @@ child.unref();
|
|
|
260
286
|
//
|
|
261
287
|
// The CDP Extensions domain still works (that is what
|
|
262
288
|
// --enable-unsafe-extension-debugging is for), so load the theme that way.
|
|
263
|
-
// Only the theme: the SDK and actions arrive over CDP from attach.mjs, and
|
|
264
|
-
// dev extension is not required for them.
|
|
265
|
-
async function loadTheme() {
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
ws.send(JSON.stringify({ id: 1, method: "Extensions.loadUnpacked", params: { path: themeDir } }));
|
|
282
|
-
const result = await done;
|
|
283
|
-
ws.close();
|
|
284
|
-
if (result.error) console.log(` theme not applied: ${result.error.message}`);
|
|
285
|
-
return;
|
|
286
|
-
} catch {
|
|
287
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
console.log(" theme not applied: Chrome did not expose CDP in time");
|
|
289
|
+
// Only the theme: the SDK and actions arrive over CDP from attach.mjs, and
|
|
290
|
+
// the dev extension is not required for them.
|
|
291
|
+
async function loadTheme(version) {
|
|
292
|
+
const ws = new WebSocket(version.webSocketDebuggerUrl);
|
|
293
|
+
await new Promise((resolve, reject) => {
|
|
294
|
+
ws.addEventListener("open", resolve);
|
|
295
|
+
ws.addEventListener("error", reject);
|
|
296
|
+
});
|
|
297
|
+
const done = new Promise((resolve) => {
|
|
298
|
+
ws.addEventListener("message", (event) => {
|
|
299
|
+
const message = JSON.parse(event.data);
|
|
300
|
+
if (message.id === 1) resolve(message);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
ws.send(JSON.stringify({ id: 1, method: "Extensions.loadUnpacked", params: { path: themeDir } }));
|
|
304
|
+
const result = await done;
|
|
305
|
+
ws.close();
|
|
306
|
+
if (result.error) console.log(` theme not applied: ${result.error.message}`);
|
|
291
307
|
}
|
|
292
308
|
|
|
293
|
-
await loadTheme();
|
|
309
|
+
await loadTheme(owned.version);
|
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldspace_npm/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Build and verify portable Foldspace action artifacts against a live app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"foldspace": "bin/cli.mjs",
|
|
8
8
|
"harness": "bin/cli.mjs"
|
|
9
9
|
},
|
|
10
|
+
"exports": {
|
|
11
|
+
"./runtime": {
|
|
12
|
+
"types": "./src/runtime/index.ts",
|
|
13
|
+
"default": "./src/runtime/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
10
17
|
"scripts": {
|
|
11
18
|
"test": "node --test test/*.test.mjs"
|
|
12
19
|
},
|
package/src/attach-helpers.mjs
CHANGED
|
@@ -15,6 +15,38 @@ export function attachModeFromArgs(argv) {
|
|
|
15
15
|
return ATTACH_MODES.SWAP;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function present(value) {
|
|
19
|
+
if (value == null) return null;
|
|
20
|
+
const text = String(value);
|
|
21
|
+
return text ? text : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the Chrome debug port attach should connect to.
|
|
26
|
+
*
|
|
27
|
+
* An explicit --port or CDP_PORT always wins. Otherwise the port inject
|
|
28
|
+
* recorded is used only when the recorded target still matches. There is no
|
|
29
|
+
* fallback to 9222: that is Chrome's conventional debugging port, and guessing
|
|
30
|
+
* it attaches to whoever is listening — often not the profile we launched.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveAttachLaunch({
|
|
33
|
+
explicitPort = null,
|
|
34
|
+
envPort = null,
|
|
35
|
+
explicitTarget = null,
|
|
36
|
+
savedTarget = null,
|
|
37
|
+
savedDebugPort = null,
|
|
38
|
+
} = {}) {
|
|
39
|
+
const portOverride = present(explicitPort);
|
|
40
|
+
const envOverride = present(envPort);
|
|
41
|
+
const savedPort = present(savedDebugPort);
|
|
42
|
+
const useSavedLaunch =
|
|
43
|
+
!portOverride &&
|
|
44
|
+
!envOverride &&
|
|
45
|
+
(!explicitTarget || explicitTarget === savedTarget);
|
|
46
|
+
const port = portOverride || envOverride || (useSavedLaunch ? savedPort : null);
|
|
47
|
+
return { useSavedLaunch, port };
|
|
48
|
+
}
|
|
49
|
+
|
|
18
50
|
export function hostPatternsFromMatches(matches) {
|
|
19
51
|
return matches.map((match) =>
|
|
20
52
|
match.replace("*://", "").replace("/*", ""),
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { createCdpRequestManager } from "./cdp-request-manager.mjs";
|
|
6
|
+
|
|
7
|
+
export const SENTINEL_FILE = ".foldspace-sentinel";
|
|
8
|
+
|
|
9
|
+
export function chromeProfileDir(root) {
|
|
10
|
+
return path.join(root, ".foldspace-dev", "chrome-profile");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sentinelPath(profileDir) {
|
|
14
|
+
return path.join(profileDir, SENTINEL_FILE);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function present(value) {
|
|
18
|
+
if (value == null) return null;
|
|
19
|
+
const text = String(value).trim();
|
|
20
|
+
return text ? text : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolvedPath(filePath) {
|
|
24
|
+
try {
|
|
25
|
+
return fs.realpathSync(filePath);
|
|
26
|
+
} catch {
|
|
27
|
+
return path.resolve(filePath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function writeLaunchSentinel(profileDir) {
|
|
32
|
+
const token = crypto.randomBytes(16).toString("hex");
|
|
33
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
34
|
+
fs.writeFileSync(sentinelPath(profileDir), `${token}\n`, "utf8");
|
|
35
|
+
return token;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readLaunchSentinel(profileDir) {
|
|
39
|
+
try {
|
|
40
|
+
return present(fs.readFileSync(sentinelPath(profileDir), "utf8"));
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function verifyLaunchSentinel({ profileDir, token } = {}) {
|
|
47
|
+
const onDisk = readLaunchSentinel(profileDir);
|
|
48
|
+
const expected = present(token);
|
|
49
|
+
if (!expected || !onDisk || onDisk !== expected) {
|
|
50
|
+
return { ok: false, reason: "sentinel_mismatch" };
|
|
51
|
+
}
|
|
52
|
+
return { ok: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseChromeVersionText(text) {
|
|
56
|
+
const body = String(text || "");
|
|
57
|
+
const profilePath = present(body.match(/Profile Path\s+(.+)/i)?.[1]);
|
|
58
|
+
const commandLine = present(
|
|
59
|
+
body.match(/Command Line\s+(.+?)(?=\n[A-Z][A-Za-z ]+\s|\n{2}|$)/s)?.[1],
|
|
60
|
+
);
|
|
61
|
+
const userDataMatch = commandLine?.match(
|
|
62
|
+
/--user-data-dir(?:=|\s+)(?:"([^"]+)"|(\S+))/,
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
profilePath,
|
|
66
|
+
commandLine,
|
|
67
|
+
userDataDir: present(userDataMatch?.[1] || userDataMatch?.[2]),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function reportedProfileMatches(profileDir, reported = {}) {
|
|
72
|
+
const expected = resolvedPath(profileDir);
|
|
73
|
+
if (reported.userDataDir) {
|
|
74
|
+
return resolvedPath(reported.userDataDir) === expected;
|
|
75
|
+
}
|
|
76
|
+
if (reported.profilePath) {
|
|
77
|
+
return resolvedPath(path.dirname(reported.profilePath)) === expected;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function verifyCdpOwnership({ profileDir, token, reported } = {}) {
|
|
83
|
+
const sentinel = verifyLaunchSentinel({ profileDir, token });
|
|
84
|
+
if (!sentinel.ok) return sentinel;
|
|
85
|
+
if (!reportedProfileMatches(profileDir, reported)) {
|
|
86
|
+
return { ok: false, reason: "profile_mismatch" };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function ownershipErrorMessage(
|
|
92
|
+
reason,
|
|
93
|
+
{ port, profileDir, root } = {},
|
|
94
|
+
) {
|
|
95
|
+
const profile =
|
|
96
|
+
root && profileDir
|
|
97
|
+
? path.relative(root, profileDir) || profileDir
|
|
98
|
+
: profileDir || ".foldspace-dev/chrome-profile";
|
|
99
|
+
const hints = {
|
|
100
|
+
sentinel_mismatch:
|
|
101
|
+
"Launch sentinel is missing or does not match. Run foldspace inject again.",
|
|
102
|
+
profile_mismatch: `The browser on :${port} is using a different user-data-dir than ${profile}.`,
|
|
103
|
+
cdp_inspect_failed: `Could not read the profile path from Chrome on :${port}.`,
|
|
104
|
+
cdp_not_ready: `Chrome did not expose CDP on :${port}.`,
|
|
105
|
+
};
|
|
106
|
+
return (
|
|
107
|
+
`Chrome on :${port} is not the Foldspace profile.\n` +
|
|
108
|
+
` ${hints[reason] || reason}\n` +
|
|
109
|
+
` Close the other debugging Chrome, or pass --port <free-port>.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function fetchCdpVersion(port) {
|
|
114
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
throw new Error(`CDP /json/version returned ${response.status}`);
|
|
117
|
+
}
|
|
118
|
+
return response.json();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function openCdpSocket(webSocketDebuggerUrl) {
|
|
122
|
+
const cdp = createCdpRequestManager({ timeoutMs: 8_000 });
|
|
123
|
+
const ws = new WebSocket(webSocketDebuggerUrl);
|
|
124
|
+
await new Promise((resolve, reject) => {
|
|
125
|
+
ws.addEventListener("open", resolve);
|
|
126
|
+
ws.addEventListener("error", reject);
|
|
127
|
+
});
|
|
128
|
+
ws.addEventListener("message", (event) => {
|
|
129
|
+
cdp.handleMessage(JSON.parse(event.data));
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
ws,
|
|
133
|
+
call: (method, params, sessionId) => cdp.call(ws, method, params, sessionId),
|
|
134
|
+
close() {
|
|
135
|
+
cdp.failAll();
|
|
136
|
+
ws.close();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function inspectCdpUserDataDir(webSocketDebuggerUrl) {
|
|
142
|
+
const session = await openCdpSocket(webSocketDebuggerUrl);
|
|
143
|
+
try {
|
|
144
|
+
const { targetId } = await session.call("Target.createTarget", {
|
|
145
|
+
url: "chrome://version/",
|
|
146
|
+
});
|
|
147
|
+
const attached = await session.call("Target.attachToTarget", {
|
|
148
|
+
targetId,
|
|
149
|
+
flatten: true,
|
|
150
|
+
});
|
|
151
|
+
const sessionId = attached.sessionId;
|
|
152
|
+
await session.call("Runtime.enable", {}, sessionId);
|
|
153
|
+
await session.call("Page.enable", {}, sessionId);
|
|
154
|
+
|
|
155
|
+
let text = "";
|
|
156
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
157
|
+
const evaluation = await session.call(
|
|
158
|
+
"Runtime.evaluate",
|
|
159
|
+
{
|
|
160
|
+
expression: "document.body ? document.body.innerText : ''",
|
|
161
|
+
returnByValue: true,
|
|
162
|
+
},
|
|
163
|
+
sessionId,
|
|
164
|
+
);
|
|
165
|
+
text = evaluation?.result?.value || "";
|
|
166
|
+
if (/Profile Path|Command Line/i.test(text)) break;
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await session.call("Target.closeTarget", { targetId });
|
|
172
|
+
} catch {
|
|
173
|
+
// Best-effort: ownership can still be decided from the text we got.
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const reported = parseChromeVersionText(text);
|
|
177
|
+
if (!reported.userDataDir && !reported.profilePath) {
|
|
178
|
+
throw new Error("chrome://version did not expose a profile path");
|
|
179
|
+
}
|
|
180
|
+
return reported;
|
|
181
|
+
} finally {
|
|
182
|
+
session.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function assertOwnedCdp({
|
|
187
|
+
profileDir,
|
|
188
|
+
port,
|
|
189
|
+
token,
|
|
190
|
+
fetchVersion = fetchCdpVersion,
|
|
191
|
+
inspectUserDataDir = inspectCdpUserDataDir,
|
|
192
|
+
} = {}) {
|
|
193
|
+
const sentinel = verifyLaunchSentinel({ profileDir, token });
|
|
194
|
+
if (!sentinel.ok) return sentinel;
|
|
195
|
+
|
|
196
|
+
let version;
|
|
197
|
+
try {
|
|
198
|
+
version = await fetchVersion(port);
|
|
199
|
+
} catch {
|
|
200
|
+
return { ok: false, reason: "cdp_not_ready", version: null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let reported;
|
|
204
|
+
try {
|
|
205
|
+
reported = await inspectUserDataDir(version.webSocketDebuggerUrl);
|
|
206
|
+
} catch {
|
|
207
|
+
return { ok: false, reason: "cdp_inspect_failed", version };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const result = verifyCdpOwnership({ profileDir, token, reported });
|
|
211
|
+
return { ...result, version, reported };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function waitForOwnedCdp({
|
|
215
|
+
profileDir,
|
|
216
|
+
port,
|
|
217
|
+
token,
|
|
218
|
+
timeoutMs = 20_000,
|
|
219
|
+
fetchVersion = fetchCdpVersion,
|
|
220
|
+
inspectUserDataDir = inspectCdpUserDataDir,
|
|
221
|
+
now = Date.now,
|
|
222
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
223
|
+
} = {}) {
|
|
224
|
+
const started = now();
|
|
225
|
+
let last = { ok: false, reason: "cdp_not_ready", version: null };
|
|
226
|
+
while (now() - started < timeoutMs) {
|
|
227
|
+
last = await assertOwnedCdp({
|
|
228
|
+
profileDir,
|
|
229
|
+
port,
|
|
230
|
+
token,
|
|
231
|
+
fetchVersion,
|
|
232
|
+
inspectUserDataDir,
|
|
233
|
+
});
|
|
234
|
+
if (last.ok) return last;
|
|
235
|
+
if (last.reason !== "cdp_not_ready" && last.reason !== "cdp_inspect_failed") {
|
|
236
|
+
return last;
|
|
237
|
+
}
|
|
238
|
+
await sleep(250);
|
|
239
|
+
}
|
|
240
|
+
return last;
|
|
241
|
+
}
|
package/src/cli-registry.mjs
CHANGED
|
@@ -194,7 +194,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
194
194
|
value(
|
|
195
195
|
"--port",
|
|
196
196
|
"number",
|
|
197
|
-
"CDP port: CLI, CDP_PORT,
|
|
197
|
+
"CDP port: CLI, CDP_PORT, or the port inject recorded",
|
|
198
198
|
),
|
|
199
199
|
value("--agent", "api-name", "Override the configured agent API name"),
|
|
200
200
|
flag(
|
|
@@ -217,6 +217,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
217
217
|
],
|
|
218
218
|
effects: [
|
|
219
219
|
"May reload and instrument matching target pages",
|
|
220
|
+
"Refuses a Chrome that is not the profile inject launched",
|
|
220
221
|
"Test mode is enabled unless --no-test-mode is passed",
|
|
221
222
|
"Never directly invokes an action handler",
|
|
222
223
|
"An empty local action registry is valid; named actions are not required",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
let cached: any | null = null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Foldspace SDK handle for this agent's **overlay** instance.
|
|
7
|
+
*
|
|
8
|
+
* Takes no `mode` on purpose: `foldspace.agent({ apiName })` returns the overlay
|
|
9
|
+
* handle. On an app that embeds the copilot in-page that is not the instance
|
|
10
|
+
* serving the chat, so arming only it succeeds and leaves real conversations
|
|
11
|
+
* untagged. For a specific instance, enumerate `window.foldspace.agentIds()`
|
|
12
|
+
* (`"<mode>-<apiName>"`) and call `foldspace.agent({ apiName, mode })`.
|
|
13
|
+
*
|
|
14
|
+
* This is a thin wrapper around the SDK, not a second agent implementation.
|
|
15
|
+
* Cached after the first successful lookup.
|
|
16
|
+
*
|
|
17
|
+
* @returns The overlay agent, or `null` if the SDK is not on the page
|
|
18
|
+
*/
|
|
19
|
+
export function getAgent(): any | null {
|
|
20
|
+
if (cached) return cached;
|
|
21
|
+
cached =
|
|
22
|
+
(window as any).foldspace?.agent({ apiName: getConfig().agentApiName }) ??
|
|
23
|
+
null;
|
|
24
|
+
return cached;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Arm every Foldspace instance on the page for test mode and remote actions.
|
|
29
|
+
*
|
|
30
|
+
* Calls SDK `setTestMode` and `setConfiguration({ remoteActionsSettings })`.
|
|
31
|
+
* Local `foldspace attach` already injects test-mode arming without putting it
|
|
32
|
+
* in `dist/index.js`. Prefer that. Importing this helper from an action can
|
|
33
|
+
* ship those calls into the production CDN bundle.
|
|
34
|
+
*
|
|
35
|
+
* Do not call from `execute`. Partial `setConfiguration` has collapsed the
|
|
36
|
+
* widget to 0×0 in a real build.
|
|
37
|
+
*
|
|
38
|
+
* @param testMode - When true, mark conversations as test traffic
|
|
39
|
+
* @returns Instance ids that armed vs failed
|
|
40
|
+
*/
|
|
41
|
+
export function armAllInstances(testMode = true): {
|
|
42
|
+
armed: string[];
|
|
43
|
+
failed: string[];
|
|
44
|
+
} {
|
|
45
|
+
const fs = (window as any).foldspace;
|
|
46
|
+
const armed: string[] = [];
|
|
47
|
+
const failed: string[] = [];
|
|
48
|
+
if (!fs || typeof fs.agentIds !== "function") return { armed, failed };
|
|
49
|
+
|
|
50
|
+
for (const id of fs.agentIds() as string[]) {
|
|
51
|
+
const cut = id.indexOf("-");
|
|
52
|
+
if (cut < 1) continue;
|
|
53
|
+
try {
|
|
54
|
+
const inst = fs.agent({
|
|
55
|
+
apiName: id.slice(cut + 1),
|
|
56
|
+
mode: id.slice(0, cut).toUpperCase(),
|
|
57
|
+
});
|
|
58
|
+
inst.setTestMode(testMode);
|
|
59
|
+
inst.setConfiguration({ remoteActionsSettings: { enabled: true } });
|
|
60
|
+
armed.push(id);
|
|
61
|
+
} catch {
|
|
62
|
+
failed.push(id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { armed, failed };
|
|
66
|
+
}
|