@foldspace_npm/harness 0.1.32 → 0.1.34
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/CLAUDE.md +169 -49
- package/README.md +30 -2
- package/bin/inject.mjs +2 -18
- package/bin/observe.mjs +18 -0
- package/bin/record.mjs +2 -0
- package/bin/share.mjs +63 -0
- package/bin/sync.mjs +48 -0
- package/package.json +1 -1
- package/recipes/INDEX.md +1 -1
- package/recipes/bottom-bar/README.md +24 -11
- package/recipes/bottom-bar/agent/bottomBar.ts +76 -38
- package/recipes/bottom-bar/fixtures/configuration.sent.json +13 -12
- package/recipes/swap-the-login-method/README.md +1 -1
- package/recipes/swap-the-login-method/agent/utils.ts +8 -7
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +8 -7
- package/src/chrome-binary.mjs +33 -0
- package/src/cli-registry.mjs +96 -5
- package/src/customer-subdomain.mjs +149 -0
- package/src/lint/index.mjs +23 -3
- package/src/lint/rules.mjs +46 -2
- package/src/lint/safety.mjs +785 -0
- package/src/observe-core.mjs +1 -1
- package/src/runtime/VERSION +1 -1
- package/src/runtime/config.ts +15 -2
- package/src/runtime/http.ts +40 -3
- package/src/runtime/index.ts +1 -0
- package/src/sync-core.mjs +374 -0
- package/src/upgrade.mjs +36 -7
- package/src/video/compose.mjs +12 -3
- package/src/video/recorder.mjs +15 -7
- package/src/video/share.mjs +284 -0
- package/templates/agent-starter/agent/constants.ts +15 -1
- package/templates/agent-starter/agent/utils.ts +3 -0
- package/templates/agent-starter/claude-settings.json +3 -1
- package/templates/agent-starter/skills/review-experience/SKILL.md +135 -0
- package/templates/agent-starter/skills/share-a-video/SKILL.md +47 -4
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// A product that gives each customer their own subdomain - acme.example.com,
|
|
2
|
+
// globex.example.com - must never ship one customer's address: the agent is
|
|
3
|
+
// built signed in to one customer, and every user of every customer would get
|
|
4
|
+
// the code. This module is the one place that decides what "looks like one
|
|
5
|
+
// customer's subdomain" means, for `observe` (which asks the builder) and for
|
|
6
|
+
// lint (which refuses the fixed address). Pure: no network, no filesystem.
|
|
7
|
+
|
|
8
|
+
import { siteOf } from "./observe-core.mjs";
|
|
9
|
+
|
|
10
|
+
export const SUBDOMAIN_PLACEHOLDER = "{subdomain}";
|
|
11
|
+
|
|
12
|
+
// First parts of an address that name a part of the product, a region or an
|
|
13
|
+
// environment - not a customer. Wrong in either direction costs little: a
|
|
14
|
+
// miss here only means the builder is asked one question too many.
|
|
15
|
+
const GENERIC_LABELS = new Set([
|
|
16
|
+
"app", "apps", "api", "apis", "www", "web", "my", "portal", "dashboard", "admin", "console",
|
|
17
|
+
"secure", "login", "auth", "accounts", "account", "id", "sso", "oauth", "cloud", "platform",
|
|
18
|
+
"go", "dev", "develop", "staging", "stage", "test", "qa", "uat", "sandbox", "demo", "beta",
|
|
19
|
+
"preview", "prod", "production", "live", "global", "m", "mobile", "mail", "help", "support",
|
|
20
|
+
"docs", "status", "cdn", "static", "assets", "media", "files", "home", "client", "clients",
|
|
21
|
+
"members", "manage", "manager", "internal", "backoffice", "ops", "studio", "hub", "workspace",
|
|
22
|
+
"office", "online", "service", "services", "gateway", "data", "reports", "reporting",
|
|
23
|
+
"analytics", "insights", "billing", "pay", "payments", "shop", "store", "events", "connect",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// app2, app-eu1, api-staging, eu, us-east-1, na3 ...
|
|
27
|
+
const GENERIC_PATTERN =
|
|
28
|
+
/^(?:app|api|web|www|eu|us|na|ap|uk|au|ca|de|fr|jp|in|sg|emea|apac|latam)(?:[-_]?[a-z]*\d+|(?:[-_][a-z]+)+(?:[-_]?\d+)?)?$/;
|
|
29
|
+
|
|
30
|
+
export function isGenericLabel(label) {
|
|
31
|
+
const value = String(label || "").toLowerCase();
|
|
32
|
+
return GENERIC_LABELS.has(value) || GENERIC_PATTERN.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hostOf(value) {
|
|
36
|
+
const text = String(value || "").trim().toLowerCase();
|
|
37
|
+
if (!text) return "";
|
|
38
|
+
try {
|
|
39
|
+
return new URL(/^[a-z][a-z0-9+.-]*:\/\//.test(text) ? text : `https://${text}`).hostname.replace(/\.$/, "");
|
|
40
|
+
} catch {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The first part of a host in front of the product's own domain (`acme` in acme.example.com), or null. A leading `www.` is skipped. */
|
|
46
|
+
export function leftmostLabel(hostOrUrl) {
|
|
47
|
+
const host = hostOf(hostOrUrl);
|
|
48
|
+
if (!host || /^[\d.]+$/.test(host) || host === "localhost" || host.endsWith(".localhost")) return null;
|
|
49
|
+
const labels = host.split(".");
|
|
50
|
+
if (labels[0] === "www") labels.shift();
|
|
51
|
+
const siteLabels = siteOf(labels.join(".")).split(".").length;
|
|
52
|
+
return labels.length > siteLabels ? labels[0] : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The customer label to watch for in addresses, given the app's own host and
|
|
57
|
+
* what the project says (`PER_CUSTOMER_SUBDOMAIN`): `false` - none; `true` -
|
|
58
|
+
* the first part of the host, whatever it is; undecided - the first part only
|
|
59
|
+
* when it does not read as a part of the product, a region or an environment.
|
|
60
|
+
*/
|
|
61
|
+
export function customerLabel(appHost, decided) {
|
|
62
|
+
if (decided === false) return null;
|
|
63
|
+
const label = leftmostLabel(appHost);
|
|
64
|
+
if (!label) return null;
|
|
65
|
+
if (decided === true) return label;
|
|
66
|
+
return isGenericLabel(label) ? null : label;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether an address names that customer: a part of its host, or a segment of its path. `{subdomain}` never counts. */
|
|
70
|
+
export function addressNamesLabel(url, label) {
|
|
71
|
+
if (!label) return false;
|
|
72
|
+
const want = label.toLowerCase();
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = new URL(String(url).replace(/\{subdomain\}/g, "x-subdomain-x"));
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const parts = (text) => text.toLowerCase().split(/[./]/).filter(Boolean);
|
|
80
|
+
const named = (part) => part === want || part.split(/[-_]/).includes(want);
|
|
81
|
+
return parts(parsed.hostname).some(named) || parts(decodeURIComponent(parsed.pathname)).some(named);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What API_BASE should say instead of a fixed address that names one
|
|
86
|
+
* customer: the path alone when the backend is on the app's own host,
|
|
87
|
+
* otherwise the address with `{subdomain}` where the customer was.
|
|
88
|
+
*/
|
|
89
|
+
export function suggestedApiBase(url, appHost, label) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = new URL(url);
|
|
93
|
+
} catch {
|
|
94
|
+
return `/api`;
|
|
95
|
+
}
|
|
96
|
+
const path = parsed.pathname.replace(/\/$/, "");
|
|
97
|
+
if (parsed.hostname.toLowerCase() === hostOf(appHost)) return path || "/";
|
|
98
|
+
const swap = (part) =>
|
|
99
|
+
part.toLowerCase() === label.toLowerCase()
|
|
100
|
+
? SUBDOMAIN_PLACEHOLDER
|
|
101
|
+
: part
|
|
102
|
+
.split("-")
|
|
103
|
+
.map((piece) => (piece.toLowerCase() === label.toLowerCase() ? SUBDOMAIN_PLACEHOLDER : piece))
|
|
104
|
+
.join("-");
|
|
105
|
+
const host = parsed.hostname.split(".").map(swap).join(".");
|
|
106
|
+
const swappedPath = path.split("/").map(swap).join("/");
|
|
107
|
+
return `${parsed.protocol}//${host}${parsed.port ? `:${parsed.port}` : ""}${swappedPath}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `PER_CUSTOMER_SUBDOMAIN` as written in agent/constants.ts: true, false, or undefined while undecided. */
|
|
111
|
+
export function readCustomerSubdomainSetting(constantsText) {
|
|
112
|
+
const match = /export\s+const\s+PER_CUSTOMER_SUBDOMAIN\b[^=]*=\s*(true|false)\b/.exec(String(constantsText || ""));
|
|
113
|
+
return match ? match[1] === "true" : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* For `observe`: when the page's address looks like one customer's own
|
|
118
|
+
* subdomain and the project has not settled it, say so and what to ask.
|
|
119
|
+
* `requestUrls` are the calls the page made; one that names the same
|
|
120
|
+
* customer is the strongest sign, and is reported.
|
|
121
|
+
*/
|
|
122
|
+
export function customerSubdomainHint(pageUrl, requestUrls = [], decided) {
|
|
123
|
+
if (decided !== undefined) return null;
|
|
124
|
+
const pageHost = hostOf(pageUrl);
|
|
125
|
+
const label = customerLabel(pageHost, undefined);
|
|
126
|
+
if (!label) return null;
|
|
127
|
+
const seenIn = [];
|
|
128
|
+
for (const url of requestUrls) {
|
|
129
|
+
let host;
|
|
130
|
+
try {
|
|
131
|
+
host = new URL(url).hostname.toLowerCase();
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (addressNamesLabel(url, label) && !seenIn.includes(host)) seenIn.push(host);
|
|
136
|
+
if (seenIn.length >= 4) break;
|
|
137
|
+
}
|
|
138
|
+
const site = siteOf(pageHost.replace(/^www\./, ""));
|
|
139
|
+
return {
|
|
140
|
+
label,
|
|
141
|
+
pageHost,
|
|
142
|
+
...(seenIn.length ? { backendHosts: seenIn } : {}),
|
|
143
|
+
hint:
|
|
144
|
+
`The address starts with "${label}." - that may be one customer's own subdomain, with other customers on other.${site}. ` +
|
|
145
|
+
`Ask the human once, as a choice: does each of their customers get their own address like ${pageHost}? ` +
|
|
146
|
+
`Yes: set PER_CUSTOMER_SUBDOMAIN = true in agent/constants.ts and write API_BASE from the page's address ("/api", or "https://{subdomain}.…") - never the address you observed. ` +
|
|
147
|
+
`No: set PER_CUSTOMER_SUBDOMAIN = false.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/lint/index.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listSourceFiles, parseFile, shouldSkipActionFile } from "./ast.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
lintActionFile,
|
|
6
|
+
lintViewsFile,
|
|
7
|
+
ruleNoTestModeInBundle,
|
|
8
|
+
ruleRegistryIntegrity,
|
|
9
|
+
ruleStartersLimit,
|
|
10
|
+
} from "./rules.mjs";
|
|
11
|
+
import { lintSafetyFile, readProjectFacts, ruleNoSecrets } from "./safety.mjs";
|
|
5
12
|
|
|
6
13
|
export { bundleTurnsOnTestMode } from "./rules.mjs";
|
|
7
14
|
|
|
@@ -47,12 +54,25 @@ export function lintProject(projectDir) {
|
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
// Any file under agent/ can end up in the bundle, including code that runs
|
|
50
|
-
// as soon as the bundle loads, so
|
|
57
|
+
// as soon as the bundle loads, so these rules read all of it - not only
|
|
51
58
|
// actions and views.
|
|
52
59
|
for (const filePath of listSourceFiles(path.join(projectDir, "agent"))) {
|
|
53
|
-
|
|
60
|
+
const sourceFile = parseFile(filePath);
|
|
61
|
+
const relative = path.relative(projectDir, filePath);
|
|
62
|
+
ruleNoTestModeInBundle(sourceFile, relative, findings);
|
|
63
|
+
ruleStartersLimit(sourceFile, relative, findings);
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
// Safety: every file under agent/ the project owns - not agent/runtime/,
|
|
67
|
+
// which is the harness's own copy - and, for secrets, the whole project.
|
|
68
|
+
const facts = readProjectFacts(projectDir);
|
|
69
|
+
for (const filePath of listSourceFiles(path.join(projectDir, "agent"))) {
|
|
70
|
+
const relative = path.relative(projectDir, filePath);
|
|
71
|
+
if (relative.split(path.sep)[1] === "runtime") continue;
|
|
72
|
+
lintSafetyFile(filePath, relative, findings, facts);
|
|
73
|
+
}
|
|
74
|
+
ruleNoSecrets(projectDir, findings);
|
|
75
|
+
|
|
56
76
|
ruleRegistryIntegrity({
|
|
57
77
|
indexFile: entry,
|
|
58
78
|
indexSource: parseFile(entry),
|
package/src/lint/rules.mjs
CHANGED
|
@@ -22,9 +22,11 @@ import {
|
|
|
22
22
|
import { ts } from "./ts.mjs";
|
|
23
23
|
|
|
24
24
|
/*
|
|
25
|
+
Safety rules (one customer's address, the user's token, secrets, known
|
|
26
|
+
hosts) live in ./safety.mjs.
|
|
27
|
+
|
|
25
28
|
Backlog, not this slice:
|
|
26
|
-
- no-
|
|
27
|
-
- no-unsafe-return (stack / cookies / Authorization / ApiResult.detail)
|
|
29
|
+
- no-unsafe-return (stack / ApiResult.detail)
|
|
28
30
|
- snake_case action keys
|
|
29
31
|
- credentials: "include" on every fetch
|
|
30
32
|
- _gen2 vs _v2 taskKey
|
|
@@ -804,6 +806,48 @@ export function ruleNoTestModeInBundle(sourceFile, relative, findings) {
|
|
|
804
806
|
walk(sourceFile, undefined);
|
|
805
807
|
}
|
|
806
808
|
|
|
809
|
+
// The most starters one page (or the agent-wide default) shows, both groups
|
|
810
|
+
// together. `setConversationStarters` also sets the panel's opening screen,
|
|
811
|
+
// and a long list there buries the one that matters (a customer default had 7).
|
|
812
|
+
export const MAX_STARTERS = 3;
|
|
813
|
+
|
|
814
|
+
// Any object with a KNOWLEDGE or ACTION list - a setConversationStarters
|
|
815
|
+
// call, a page's entry in the bottom bar's list, conversationStartersSettings
|
|
816
|
+
// - is counted. Only lists written out in the file are counted: a list built
|
|
817
|
+
// at run time (`.map`, a spread) is left alone.
|
|
818
|
+
export function ruleStartersLimit(sourceFile, relative, findings) {
|
|
819
|
+
const bindings = collectModuleBindings(sourceFile);
|
|
820
|
+
const visit = (node) => {
|
|
821
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
822
|
+
let count = 0;
|
|
823
|
+
let counted = false;
|
|
824
|
+
for (const prop of objectProperties(node)) {
|
|
825
|
+
const name = propertyName(prop);
|
|
826
|
+
if (name !== "KNOWLEDGE" && name !== "ACTION") continue;
|
|
827
|
+
const list = resolveNode(propertyValue(prop), bindings);
|
|
828
|
+
if (!list || !ts.isArrayLiteralExpression(list)) continue;
|
|
829
|
+
counted = true;
|
|
830
|
+
count += list.elements.filter((element) => !ts.isSpreadElement(element)).length;
|
|
831
|
+
}
|
|
832
|
+
if (counted && count > MAX_STARTERS) {
|
|
833
|
+
findings.push(
|
|
834
|
+
finding({
|
|
835
|
+
rule: "starters-limit",
|
|
836
|
+
severity: "error",
|
|
837
|
+
sourceFile,
|
|
838
|
+
node,
|
|
839
|
+
file: relative,
|
|
840
|
+
message: `${count} conversation starters in one set; at most ${MAX_STARTERS}, KNOWLEDGE and ACTION together. The same list is the agent panel's opening screen.`,
|
|
841
|
+
fix: `Keep the ${MAX_STARTERS} that matter most on this page - each one something the agent can answer today.`,
|
|
842
|
+
}),
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
ts.forEachChild(node, visit);
|
|
847
|
+
};
|
|
848
|
+
visit(sourceFile);
|
|
849
|
+
}
|
|
850
|
+
|
|
807
851
|
// What the build checks in the bundle it wrote: minify keeps property names,
|
|
808
852
|
// so a bundle that can turn test mode on still says `setTestMode`. Calls with
|
|
809
853
|
// `false` (`!1` once minified) are allowed; any other mention is not - the
|