@foldspace_npm/harness 0.1.7 → 0.1.9

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.
@@ -0,0 +1,464 @@
1
+ import path from "node:path";
2
+ import {
3
+ callbackParamName,
4
+ collectConstBindings,
5
+ collectModuleBindings,
6
+ finding,
7
+ forEachCallNamed,
8
+ forEachNamedFunction,
9
+ forEachReturn,
10
+ forEachStringLiteral,
11
+ isFunctionLike,
12
+ normalizeKey,
13
+ objectProperties,
14
+ parseFile,
15
+ propertyName,
16
+ propertyValue,
17
+ resolveModulePath,
18
+ resolveNode,
19
+ shouldSkipActionFile,
20
+ stringText,
21
+ } from "./ast.mjs";
22
+ import { ts } from "./ts.mjs";
23
+
24
+ /*
25
+ Backlog, not this slice:
26
+ - no-hardcoded-secrets
27
+ - no-unsafe-return (stack / cookies / Authorization / ApiResult.detail)
28
+ - snake_case action keys
29
+ - credentials: "include" on every fetch
30
+ - _gen2 vs _v2 taskKey
31
+ */
32
+
33
+ const BANNED_PROMPT_KEYS = new Set([
34
+ "directive",
35
+ "instructions",
36
+ "guidance",
37
+ "systemprompt",
38
+ "agentinstructions",
39
+ "nextprompt",
40
+ ]);
41
+
42
+ const RUNTASK_PROMPT_KEYS = new Set(["prompt", "instructions", "systemprompt"]);
43
+
44
+ const IMPERATIVE_PATTERNS = [
45
+ /\btell the user\b/i,
46
+ /\bask the user\b/i,
47
+ /\bask if they(?:'d| would)\b/i,
48
+ /\bdo not (?:repeat|call|summarize)\b/i,
49
+ /\bdon't (?:repeat|call|summarize)\b/i,
50
+ /\bsummarize the following\b/i,
51
+ /\brun the [\w-]+ action\b/i,
52
+ /\bwalk the user through\b/i,
53
+ /\bproceed to the next step\b/i,
54
+ /\blet the user know\b/i,
55
+ ];
56
+
57
+ const EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
58
+
59
+ const PROMPT_FIX =
60
+ "Put the wording in the action or agent instructions via MCP or Agent Studio; return data only.";
61
+
62
+ function mergeBindings(fn, sourceFile) {
63
+ return new Map([
64
+ ...collectModuleBindings(sourceFile),
65
+ ...collectConstBindings(fn),
66
+ ]);
67
+ }
68
+
69
+ function inspectReturnedObject(obj, bindings, sourceFile, relative, findings, origin) {
70
+ if (!obj || !ts.isObjectLiteralExpression(obj)) return;
71
+
72
+ for (const prop of objectProperties(obj)) {
73
+ const name = propertyName(prop);
74
+ const normalized = normalizeKey(name);
75
+ const valueNode = resolveNode(propertyValue(prop), bindings);
76
+ const at = prop.name || prop;
77
+
78
+ if (BANNED_PROMPT_KEYS.has(normalized)) {
79
+ findings.push(
80
+ finding({
81
+ rule: "no-static-agent-prompt",
82
+ severity: "error",
83
+ sourceFile,
84
+ node: at,
85
+ file: relative,
86
+ message: `Do not return '${name}' — that is a second instruction channel besides Agent Studio.`,
87
+ fix: PROMPT_FIX,
88
+ }),
89
+ );
90
+ }
91
+
92
+ const text = stringText(valueNode);
93
+ if (text && IMPERATIVE_PATTERNS.some((pattern) => pattern.test(text))) {
94
+ findings.push(
95
+ finding({
96
+ rule: "no-static-agent-prompt",
97
+ severity: "error",
98
+ sourceFile,
99
+ node: at,
100
+ file: relative,
101
+ message: `${origin} sends the copilot operating instructions ('${text.slice(0, 80)}').`,
102
+ fix: PROMPT_FIX,
103
+ }),
104
+ );
105
+ }
106
+
107
+ if (valueNode && ts.isObjectLiteralExpression(valueNode)) {
108
+ inspectReturnedObject(valueNode, bindings, sourceFile, relative, findings, origin);
109
+ }
110
+ }
111
+ }
112
+
113
+ export function ruleNoStaticAgentPrompt(sourceFile, relative, findings) {
114
+ forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn, name) => {
115
+ const bindings = mergeBindings(fn, sourceFile);
116
+
117
+ if (name === "execute" || name === "callback") {
118
+ forEachReturn(fn, (expression) => {
119
+ inspectReturnedObject(
120
+ resolveNode(expression, bindings),
121
+ bindings,
122
+ sourceFile,
123
+ relative,
124
+ findings,
125
+ name,
126
+ );
127
+ });
128
+ }
129
+
130
+ if (name === "render") {
131
+ const cbName = callbackParamName(fn);
132
+ forEachCallNamed(fn, cbName, (call) => {
133
+ const arg = call.arguments[0];
134
+ if (!arg) return;
135
+ inspectReturnedObject(
136
+ resolveNode(arg, bindings),
137
+ bindings,
138
+ sourceFile,
139
+ relative,
140
+ findings,
141
+ "callback",
142
+ );
143
+ });
144
+ }
145
+ });
146
+ }
147
+
148
+ function inspectRunTaskData(dataNode, bindings, sourceFile, relative, findings, call) {
149
+ const resolved = resolveNode(dataNode, bindings);
150
+ if (!resolved || !ts.isObjectLiteralExpression(resolved)) return;
151
+
152
+ for (const prop of objectProperties(resolved)) {
153
+ const name = propertyName(prop);
154
+ if (!RUNTASK_PROMPT_KEYS.has(normalizeKey(name))) continue;
155
+ const value = resolveNode(propertyValue(prop), bindings);
156
+ if (stringText(value) === null) continue;
157
+ findings.push(
158
+ finding({
159
+ rule: "no-runtask-prompt",
160
+ severity: "error",
161
+ sourceFile,
162
+ node: prop.name || call,
163
+ file: relative,
164
+ message: `runTask data.${name} is a prompt. Task-agent instructions belong in Agent Studio.`,
165
+ fix: "Keep task-agent instructions in Agent Studio; pass only extracted facts in data.",
166
+ }),
167
+ );
168
+ }
169
+ }
170
+
171
+ export function ruleNoRunTaskPrompt(sourceFile, relative, findings) {
172
+ const moduleBindings = collectModuleBindings(sourceFile);
173
+
174
+ function walk(node, fnBindings) {
175
+ if (isFunctionLike(node) && node !== sourceFile) {
176
+ const nested = mergeBindings(node, sourceFile);
177
+ ts.forEachChild(node, (child) => walk(child, nested));
178
+ return;
179
+ }
180
+
181
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
182
+ if (node.expression.name.text === "runTask") {
183
+ const arg = node.arguments[0];
184
+ const bindings = fnBindings || moduleBindings;
185
+ const config = resolveNode(arg, bindings);
186
+ if (config && ts.isObjectLiteralExpression(config)) {
187
+ for (const prop of objectProperties(config)) {
188
+ if (propertyName(prop) !== "data") continue;
189
+ inspectRunTaskData(propertyValue(prop), bindings, sourceFile, relative, findings, node);
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ ts.forEachChild(node, (child) => walk(child, fnBindings));
196
+ }
197
+
198
+ walk(sourceFile, moduleBindings);
199
+ }
200
+
201
+ function reportEmoji(node, sourceFile, relative, findings) {
202
+ const text = stringText(node);
203
+ if (!text || !EMOJI_PATTERN.test(text)) return;
204
+ findings.push(
205
+ finding({
206
+ rule: "no-emoji",
207
+ severity: "warning",
208
+ sourceFile,
209
+ node,
210
+ file: relative,
211
+ message: "Emoji in handler or widget copy reads as off-brand in a product UI that has none.",
212
+ fix: "Strip the glyph from handler/render copy. Put “no emoji” in the agent's Behavior instructions; the MCP cannot write that field, so each new action's Studio instructions must carry the line too.",
213
+ }),
214
+ );
215
+ }
216
+
217
+ export function ruleNoEmoji(sourceFile, relative, findings, { wholeFile = false } = {}) {
218
+ if (wholeFile) {
219
+ forEachStringLiteral(sourceFile, (node) => {
220
+ if (ts.isImportDeclaration(node.parent) || ts.isExportDeclaration(node.parent)) return;
221
+ reportEmoji(node, sourceFile, relative, findings);
222
+ });
223
+ return;
224
+ }
225
+
226
+ forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn) => {
227
+ const bindings = mergeBindings(fn, sourceFile);
228
+ forEachStringLiteral(
229
+ fn,
230
+ (node) => reportEmoji(node, sourceFile, relative, findings),
231
+ { skipNestedFunctions: true, root: fn },
232
+ );
233
+
234
+ function walk(current) {
235
+ if (current !== fn && isFunctionLike(current)) return;
236
+ if (ts.isIdentifier(current)) {
237
+ const resolved = resolveNode(current, bindings);
238
+ if (
239
+ resolved &&
240
+ resolved !== current &&
241
+ (resolved.getStart(sourceFile) < fn.getStart(sourceFile) ||
242
+ resolved.getEnd() > fn.getEnd())
243
+ ) {
244
+ reportEmoji(resolved, sourceFile, relative, findings);
245
+ }
246
+ }
247
+ ts.forEachChild(current, walk);
248
+ }
249
+ walk(fn);
250
+ });
251
+ }
252
+
253
+ function exportedBindings(sourceFile) {
254
+ const exports = [];
255
+
256
+ function objectHasExecute(node) {
257
+ if (!node || !ts.isObjectLiteralExpression(node)) return false;
258
+ return objectProperties(node).some((prop) => propertyName(prop) === "execute");
259
+ }
260
+
261
+ for (const stmt of sourceFile.statements) {
262
+ if (ts.isVariableStatement(stmt) && stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
263
+ for (const decl of stmt.declarationList.declarations) {
264
+ if (!ts.isIdentifier(decl.name)) continue;
265
+ const init = decl.initializer;
266
+ exports.push({
267
+ name: decl.name.text,
268
+ node: decl.name,
269
+ hasExecute: objectHasExecute(init),
270
+ isFunction: isFunctionLike(init),
271
+ });
272
+ }
273
+ }
274
+ if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
275
+ exports.push({
276
+ name: stmt.name.text,
277
+ node: stmt.name,
278
+ hasExecute: false,
279
+ isFunction: true,
280
+ });
281
+ }
282
+ }
283
+ return exports;
284
+ }
285
+
286
+ function collectImports(sourceFile, filePath) {
287
+ const map = new Map();
288
+ for (const stmt of sourceFile.statements) {
289
+ if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue;
290
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
291
+ const resolved = resolveModulePath(filePath, stmt.moduleSpecifier.text);
292
+ if (!resolved) continue;
293
+ const clause = stmt.importClause;
294
+ if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
295
+ for (const spec of clause.namedBindings.elements) {
296
+ const imported = (spec.propertyName || spec.name).text;
297
+ map.set(spec.name.text, { file: resolved, imported });
298
+ }
299
+ }
300
+ if (clause.name) {
301
+ map.set(clause.name.text, { file: resolved, imported: "default" });
302
+ }
303
+ }
304
+ return map;
305
+ }
306
+
307
+ function findRegistryObject(sourceFile) {
308
+ let found = null;
309
+
310
+ function consider(node) {
311
+ if (node && ts.isObjectLiteralExpression(node)) found = found || node;
312
+ }
313
+
314
+ function walk(node) {
315
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === "actions") {
316
+ consider(node.initializer);
317
+ }
318
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
319
+ const left = node.left.getText(sourceFile);
320
+ if (left.includes("__FOLDSPACE_REMOTE_ACTIONS__") || left.endsWith(".actions")) {
321
+ if (ts.isIdentifier(node.right) && node.right.text === "actions") {
322
+ // resolved via variable declaration
323
+ } else {
324
+ consider(node.right);
325
+ }
326
+ }
327
+ }
328
+ ts.forEachChild(node, walk);
329
+ }
330
+
331
+ walk(sourceFile);
332
+ return found;
333
+ }
334
+
335
+ export function ruleRegistryIntegrity({ indexFile, indexSource, actionFiles, projectDir, findings }) {
336
+ const relativeIndex = path.relative(projectDir, indexFile);
337
+ const registry = findRegistryObject(indexSource);
338
+ const imports = collectImports(indexSource, indexFile);
339
+ const seenKeys = new Map();
340
+ const registeredNames = new Set();
341
+
342
+ if (registry) {
343
+ for (const prop of objectProperties(registry)) {
344
+ const key = propertyName(prop);
345
+ if (!key) continue;
346
+ if (seenKeys.has(key)) {
347
+ findings.push(
348
+ finding({
349
+ rule: "registry-integrity",
350
+ severity: "warning",
351
+ sourceFile: indexSource,
352
+ node: prop.name || prop,
353
+ file: relativeIndex,
354
+ message: `Duplicate registry key '${key}'. The second entry silently wins.`,
355
+ fix: "Give each registered action a unique key that matches Agent Studio.",
356
+ }),
357
+ );
358
+ }
359
+ seenKeys.set(key, prop);
360
+
361
+ const value = propertyValue(prop);
362
+ if (isFunctionLike(value)) {
363
+ findings.push(
364
+ finding({
365
+ rule: "registry-integrity",
366
+ severity: "warning",
367
+ sourceFile: indexSource,
368
+ node: prop.name || prop,
369
+ file: relativeIndex,
370
+ message: `Registry entry '${key}' is a function. The SDK expects { execute }.`,
371
+ fix: "Register an object with an execute function.",
372
+ }),
373
+ );
374
+ continue;
375
+ }
376
+ if (ts.isObjectLiteralExpression(value)) {
377
+ const hasExecute = objectProperties(value).some((entry) => propertyName(entry) === "execute");
378
+ if (!hasExecute) {
379
+ findings.push(
380
+ finding({
381
+ rule: "registry-integrity",
382
+ severity: "warning",
383
+ sourceFile: indexSource,
384
+ node: prop.name || prop,
385
+ file: relativeIndex,
386
+ message: `Registry entry '${key}' is an object without execute.`,
387
+ fix: "Register an object with an execute function.",
388
+ }),
389
+ );
390
+ }
391
+ continue;
392
+ }
393
+ if (ts.isIdentifier(value)) {
394
+ registeredNames.add(value.text);
395
+ const imported = imports.get(value.text);
396
+ if (imported) {
397
+ const exported = exportedBindings(parseFile(imported.file)).find(
398
+ (item) => item.name === imported.imported || item.name === value.text,
399
+ );
400
+ if (exported?.isFunction && !exported.hasExecute) {
401
+ findings.push(
402
+ finding({
403
+ rule: "registry-integrity",
404
+ severity: "warning",
405
+ sourceFile: indexSource,
406
+ node: prop.name || prop,
407
+ file: relativeIndex,
408
+ message: `Registry entry '${key}' points at a bare function, not { execute }.`,
409
+ fix: "Export and register an object with an execute function.",
410
+ }),
411
+ );
412
+ } else if (exported && !exported.hasExecute && !exported.isFunction) {
413
+ findings.push(
414
+ finding({
415
+ rule: "registry-integrity",
416
+ severity: "warning",
417
+ sourceFile: indexSource,
418
+ node: prop.name || prop,
419
+ file: relativeIndex,
420
+ message: `Registry entry '${key}' points at an object without execute.`,
421
+ fix: "Export and register an object with an execute function.",
422
+ }),
423
+ );
424
+ }
425
+ }
426
+ }
427
+ }
428
+ }
429
+
430
+ for (const filePath of actionFiles) {
431
+ if (filePath === indexFile) continue;
432
+ if (shouldSkipActionFile(filePath)) continue;
433
+ const sourceFile = parseFile(filePath);
434
+ const relative = path.relative(projectDir, filePath);
435
+ for (const exported of exportedBindings(sourceFile)) {
436
+ if (!exported.hasExecute) continue;
437
+ if (registeredNames.has(exported.name) || seenKeys.has(exported.name)) continue;
438
+ findings.push(
439
+ finding({
440
+ rule: "registry-integrity",
441
+ severity: "warning",
442
+ sourceFile,
443
+ node: exported.node,
444
+ file: relative,
445
+ message: `'${exported.name}' exports { execute } but is not registered in agent/actions/index.ts.`,
446
+ fix: "Import it and add it to the actions object. An unregistered handler never fires.",
447
+ }),
448
+ );
449
+ }
450
+ }
451
+ }
452
+
453
+ export function lintActionFile(filePath, relative, findings) {
454
+ if (shouldSkipActionFile(filePath)) return;
455
+ const sourceFile = parseFile(filePath);
456
+ ruleNoStaticAgentPrompt(sourceFile, relative, findings);
457
+ ruleNoRunTaskPrompt(sourceFile, relative, findings);
458
+ ruleNoEmoji(sourceFile, relative, findings);
459
+ }
460
+
461
+ export function lintViewsFile(filePath, relative, findings) {
462
+ const sourceFile = parseFile(filePath);
463
+ ruleNoEmoji(sourceFile, relative, findings, { wholeFile: true });
464
+ }
@@ -0,0 +1,6 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ const require = createRequire(import.meta.url);
4
+
5
+ /** TypeScript compiler API, resolved from this package — not the tenant. */
6
+ export const ts = require("typescript");
@@ -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
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Tenant-specific facts for the page runtime. Set once from `agent/utils.ts`.
3
+ *
4
+ * This module cannot import the client's `constants.ts`, so the client pushes
5
+ * values in with {@link configure}. Nothing here is a Foldspace cloud setting.
6
+ */
7
+
8
+ /**
9
+ * Where the customer app keeps its own session token — not a Foldspace token.
10
+ *
11
+ * Custom headers (not Bearer) are out of scope: override `apiFetch` in
12
+ * `agent/utils.ts` instead of adding a third `kind`.
13
+ */
14
+ export interface AuthSource {
15
+ /** Where the live page stores the token. */
16
+ kind: "localStorage" | "cookie";
17
+ /** The localStorage key or cookie name observed on the page. */
18
+ name: string;
19
+ }
20
+
21
+ /**
22
+ * Values {@link configure} stores for the rest of the runtime.
23
+ *
24
+ * Stamp `apiBase` and `authSource` empty until you capture a real XHR. A
25
+ * guessed host or Bearer vs cookie looks like success and publishes to the
26
+ * wrong place.
27
+ */
28
+ export interface RuntimeConfig {
29
+ /** Foldspace agent apiName, e.g. `joist-agent`. */
30
+ agentApiName: string;
31
+ /**
32
+ * Prefix for `apiFetch` / `apiFetchBinary`. Include an explicit port if the
33
+ * app uses one — omitting `:443` can fail CORS.
34
+ */
35
+ apiBase?: string;
36
+ /**
37
+ * Prefix for `publicFetch` when the caller does not pass an origin.
38
+ * Marketing / unauthenticated hosts only.
39
+ */
40
+ publicOrigin?: string;
41
+ /** Session location for `getAuthToken`. Omit until observed. */
42
+ authSource?: AuthSource;
43
+ /** `fetch` timeout in ms. Defaults to 15_000 in `configure`. */
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ let config: RuntimeConfig | null = null;
48
+
49
+ /**
50
+ * Store tenant config for this page bundle. Call once from `agent/utils.ts`
51
+ * at module load — not from an action `execute`.
52
+ *
53
+ * Replaces the previous object; it does not merge. A second call clobbers the
54
+ * first. `timeoutMs` defaults to 15_000 if omitted.
55
+ *
56
+ * @param next - Tenant ids and observed API/auth facts
57
+ */
58
+ export function configure(next: RuntimeConfig): void {
59
+ config = { timeoutMs: 15_000, ...next };
60
+ }
61
+
62
+ /**
63
+ * Read the config {@link configure} stored.
64
+ *
65
+ * Throws if `configure` never ran (imported the runtime without going through
66
+ * `agent/utils.ts`). Empty `apiBase` / `authSource` after configure is valid;
67
+ * `apiFetch` returns `{ ok: false }` for those instead of throwing.
68
+ *
69
+ * @returns The singleton config for this bundle
70
+ */
71
+ export function getConfig(): RuntimeConfig {
72
+ if (!config) {
73
+ throw new Error(
74
+ "harness runtime is not configured — call configure() from agent/utils.ts",
75
+ );
76
+ }
77
+ return config;
78
+ }