@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 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 what inject actually launched, then an explicit override, then the
65
- // legacy default. Guessing here means attaching to the wrong browser.
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 savedLaunchMatches =
86
- !explicitTarget || explicitTarget === devState.target;
87
- const useSavedLaunch =
88
- !explicitPort &&
89
- !process.env.CDP_PORT &&
90
- savedLaunchMatches;
91
- const port = String(
92
- explicitPort ||
93
- process.env.CDP_PORT ||
94
- (useSavedLaunch ? devState.debugPort : null) ||
95
- "9222",
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 = await (await fetch(`http://localhost:${port}/json/version`)).json();
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 the
264
- // dev extension is not required for them.
265
- async function loadTheme() {
266
- const endpoint = `http://127.0.0.1:${debugPort}/json/version`;
267
- for (let i = 0; i < 40; i++) {
268
- try {
269
- const version = await (await fetch(endpoint)).json();
270
- const ws = new WebSocket(version.webSocketDebuggerUrl);
271
- await new Promise((resolve, reject) => {
272
- ws.addEventListener("open", resolve);
273
- ws.addEventListener("error", reject);
274
- });
275
- const done = new Promise((resolve) => {
276
- ws.addEventListener("message", (event) => {
277
- const message = JSON.parse(event.data);
278
- if (message.id === 1) resolve(message);
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.8",
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
  },
@@ -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
+ }
@@ -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, saved launch state, then 9222",
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
+ }