@foldrun/core 0.3.0 → 0.5.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 +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/host-env.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The environment a step's children start from, on the in-process path.
|
|
2
|
+
//
|
|
3
|
+
// Scripts, `verify:` shells and the SDK's own Bash tool used to inherit the
|
|
4
|
+
// whole of process.env — which on a platform holds FOLDRUN_SECRET_KEY (the
|
|
5
|
+
// key every tenant's vault and hook token derives from), the database URL,
|
|
6
|
+
// the object-store and mail credentials, the Stripe key. `console.log(
|
|
7
|
+
// process.env)` in a script tool was cross-tenant compromise. tool-test.ts
|
|
8
|
+
// built a stripped base for the Test button; this is that base, made the one
|
|
9
|
+
// definition, used everywhere a child is spawned host-side.
|
|
10
|
+
//
|
|
11
|
+
// A run container needs none of this: its process.env IS the boundary,
|
|
12
|
+
// assembled from an allowlist on the host before the container started.
|
|
13
|
+
//
|
|
14
|
+
// What passes is what an interpreter needs to be found and to run, the
|
|
15
|
+
// locale, the clock, the network's proxy settings, and the identifiers the
|
|
16
|
+
// runner stamps on a step (FOLDRUN_RUN_*, FOLDRUN_STEP_*) so a script can
|
|
17
|
+
// say which run made its records. The secrets an agent declares are layered
|
|
18
|
+
// on top by the caller — by name, from the vault, never from the host.
|
|
19
|
+
|
|
20
|
+
const EXACT = new Set([
|
|
21
|
+
"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
|
|
22
|
+
"TZ", "TERM", "SHELL", "USER", "LOGNAME",
|
|
23
|
+
// The host's way to the network is the child's way to the network.
|
|
24
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
|
|
25
|
+
"SSL_CERT_FILE", "SSL_CERT_DIR",
|
|
26
|
+
// Windows equivalents, harmless elsewhere.
|
|
27
|
+
"SYSTEMROOT", "COMSPEC", "PATHEXT", "WINDIR",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const PREFIXES = ["LC_", "NODE_", "FOLDRUN_RUN_", "FOLDRUN_STEP_"];
|
|
31
|
+
|
|
32
|
+
/** Does this name pass the allowlist? Exported so a test can pin the rule
|
|
33
|
+
* rather than the list. */
|
|
34
|
+
export function hostEnvAllowed(name: string): boolean {
|
|
35
|
+
return EXACT.has(name) || PREFIXES.some((p) => name.startsWith(p));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* process.env, reduced to the allowlist. A fresh object every call, so a
|
|
40
|
+
* caller that layers secrets on top never writes into the host's own env.
|
|
41
|
+
*/
|
|
42
|
+
export function hostSafeEnv(from: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
43
|
+
const out: Record<string, string> = {};
|
|
44
|
+
for (const [k, v] of Object.entries(from)) {
|
|
45
|
+
if (typeof v === "string" && hostEnvAllowed(k)) out[k] = v;
|
|
46
|
+
}
|
|
47
|
+
// Typed as ProcessEnv, not a string map: a host whose type augmentation
|
|
48
|
+
// makes a key required (Next declares NODE_ENV so) refuses a plain map at
|
|
49
|
+
// every spawn. The value is the same either way.
|
|
50
|
+
return out as NodeJS.ProcessEnv;
|
|
51
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// A JSON Schema validator small enough to read, with no dependency.
|
|
2
|
+
//
|
|
3
|
+
// `schema:` beside `output: json` says what shape the reply's value must
|
|
4
|
+
// have, and a value that does not have it fails the step with a sentence
|
|
5
|
+
// naming the field. The subset here is the part of JSON Schema people write
|
|
6
|
+
// by hand for a handoff — types, required fields, enums, ranges, patterns,
|
|
7
|
+
// arrays, the combinators, and `$ref` to a definition in the same document.
|
|
8
|
+
// Formats (`date-time`, `email`) are accepted and not checked; a check that
|
|
9
|
+
// needs one belongs in a `verify:`. Anything the validator does not know is
|
|
10
|
+
// ignored, the way JSON Schema itself says unknown keywords are.
|
|
11
|
+
//
|
|
12
|
+
// Not ajv: it sits in node_modules as a dependency of a dependency, which is
|
|
13
|
+
// nothing to build on, and the whole of it is far more than a step needs.
|
|
14
|
+
|
|
15
|
+
export interface SchemaError {
|
|
16
|
+
/** JSON pointer to the offending value: "" is the root, "/leads/0/url". */
|
|
17
|
+
path: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Schema = Record<string, unknown> | boolean;
|
|
22
|
+
|
|
23
|
+
const MAX_ERRORS = 8;
|
|
24
|
+
|
|
25
|
+
/** Is `value` what `schema` describes? Empty means yes. */
|
|
26
|
+
export function validateSchema(value: unknown, schema: unknown): SchemaError[] {
|
|
27
|
+
const errors: SchemaError[] = [];
|
|
28
|
+
check(value, schema as Schema, "", schema as Schema, errors, 0);
|
|
29
|
+
return errors.slice(0, MAX_ERRORS);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The errors as one sentence for a trace line. */
|
|
33
|
+
export function describeSchemaErrors(errors: SchemaError[]): string {
|
|
34
|
+
return errors.map((e) => `${e.path || "the value"}: ${e.message}`).join("; ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Is this a schema at all? An object (or a boolean, which JSON Schema
|
|
38
|
+
* allows) — not a string, not a list. */
|
|
39
|
+
export function looksLikeSchema(schema: unknown): boolean {
|
|
40
|
+
return typeof schema === "boolean" || (typeof schema === "object" && schema !== null && !Array.isArray(schema));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const typeOf = (v: unknown): string =>
|
|
44
|
+
v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" ? (Number.isInteger(v) ? "integer" : "number") : typeof v;
|
|
45
|
+
|
|
46
|
+
const matchesType = (v: unknown, t: string): boolean => {
|
|
47
|
+
const actual = typeOf(v);
|
|
48
|
+
return t === actual || (t === "number" && actual === "integer");
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function resolveRef(ref: string, root: Schema): Schema | null {
|
|
52
|
+
if (!ref.startsWith("#")) return null;
|
|
53
|
+
let node: unknown = root;
|
|
54
|
+
for (const raw of ref.slice(1).split("/").filter(Boolean)) {
|
|
55
|
+
const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
56
|
+
if (typeof node !== "object" || node === null) return null;
|
|
57
|
+
node = (node as Record<string, unknown>)[key];
|
|
58
|
+
}
|
|
59
|
+
return typeof node === "object" && node !== null ? (node as Schema) : typeof node === "boolean" ? node : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function check(value: unknown, schema: Schema, path: string, root: Schema, errors: SchemaError[], depth: number) {
|
|
63
|
+
if (errors.length >= MAX_ERRORS || depth > 64) return;
|
|
64
|
+
if (schema === true) return;
|
|
65
|
+
if (schema === false) return void errors.push({ path, message: "nothing is allowed here" });
|
|
66
|
+
if (typeof schema !== "object" || schema === null) return;
|
|
67
|
+
const s = schema as Record<string, unknown>;
|
|
68
|
+
const fail = (message: string) => errors.push({ path, message });
|
|
69
|
+
|
|
70
|
+
if (typeof s.$ref === "string") {
|
|
71
|
+
const target = resolveRef(s.$ref, root);
|
|
72
|
+
if (target === null) return fail(`schema: unresolvable $ref ${s.$ref}`);
|
|
73
|
+
check(value, target, path, root, errors, depth + 1);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// type — one, or a list of alternatives; "nullable: true" (OpenAPI's
|
|
78
|
+
// spelling) lets null through.
|
|
79
|
+
if (s.type !== undefined) {
|
|
80
|
+
const types = Array.isArray(s.type) ? (s.type as string[]) : [String(s.type)];
|
|
81
|
+
const ok = types.some((t) => matchesType(value, t)) || (s.nullable === true && value === null);
|
|
82
|
+
if (!ok) return fail(`expected ${types.join(" or ")}, got ${typeOf(value)}`);
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(s.enum) && !s.enum.some((e) => deepEqual(e, value))) {
|
|
85
|
+
return fail(`must be one of ${s.enum.map((e) => JSON.stringify(e)).join(", ")}`);
|
|
86
|
+
}
|
|
87
|
+
if ("const" in s && !deepEqual(s.const, value)) return fail(`must be ${JSON.stringify(s.const)}`);
|
|
88
|
+
|
|
89
|
+
if (typeof value === "string") {
|
|
90
|
+
if (typeof s.minLength === "number" && value.length < s.minLength) fail(`shorter than ${s.minLength} characters`);
|
|
91
|
+
if (typeof s.maxLength === "number" && value.length > s.maxLength) fail(`longer than ${s.maxLength} characters`);
|
|
92
|
+
if (typeof s.pattern === "string") {
|
|
93
|
+
try {
|
|
94
|
+
if (!new RegExp(s.pattern, "u").test(value)) fail(`does not match ${s.pattern}`);
|
|
95
|
+
} catch {
|
|
96
|
+
fail(`schema: invalid pattern ${s.pattern}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === "number") {
|
|
101
|
+
if (typeof s.minimum === "number" && value < s.minimum) fail(`below the minimum ${s.minimum}`);
|
|
102
|
+
if (typeof s.maximum === "number" && value > s.maximum) fail(`above the maximum ${s.maximum}`);
|
|
103
|
+
if (typeof s.exclusiveMinimum === "number" && value <= s.exclusiveMinimum) fail(`must be above ${s.exclusiveMinimum}`);
|
|
104
|
+
if (typeof s.exclusiveMaximum === "number" && value >= s.exclusiveMaximum) fail(`must be below ${s.exclusiveMaximum}`);
|
|
105
|
+
if (typeof s.multipleOf === "number" && s.multipleOf > 0 && Math.abs(value / s.multipleOf - Math.round(value / s.multipleOf)) > 1e-9) {
|
|
106
|
+
fail(`not a multiple of ${s.multipleOf}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
if (typeof s.minItems === "number" && value.length < s.minItems) fail(`fewer than ${s.minItems} items`);
|
|
111
|
+
if (typeof s.maxItems === "number" && value.length > s.maxItems) fail(`more than ${s.maxItems} items`);
|
|
112
|
+
if (s.uniqueItems === true) {
|
|
113
|
+
const seen = new Set(value.map((v) => JSON.stringify(v)));
|
|
114
|
+
if (seen.size !== value.length) fail("items are not unique");
|
|
115
|
+
}
|
|
116
|
+
if (s.items !== undefined && !Array.isArray(s.items)) {
|
|
117
|
+
value.forEach((item, i) => check(item, s.items as Schema, `${path}/${i}`, root, errors, depth + 1));
|
|
118
|
+
} else if (Array.isArray(s.items) || Array.isArray(s.prefixItems)) {
|
|
119
|
+
const tuple = (Array.isArray(s.prefixItems) ? s.prefixItems : s.items) as Schema[];
|
|
120
|
+
tuple.forEach((sub, i) => i < value.length && check(value[i], sub, `${path}/${i}`, root, errors, depth + 1));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
124
|
+
const obj = value as Record<string, unknown>;
|
|
125
|
+
const props = (typeof s.properties === "object" && s.properties !== null ? s.properties : {}) as Record<string, Schema>;
|
|
126
|
+
if (Array.isArray(s.required)) {
|
|
127
|
+
for (const key of s.required as string[]) if (!(key in obj)) fail(`missing required field "${key}"`);
|
|
128
|
+
}
|
|
129
|
+
for (const [key, sub] of Object.entries(props)) {
|
|
130
|
+
if (key in obj) check(obj[key], sub, `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`, root, errors, depth + 1);
|
|
131
|
+
}
|
|
132
|
+
const patterns = (typeof s.patternProperties === "object" && s.patternProperties !== null ? s.patternProperties : {}) as Record<string, Schema>;
|
|
133
|
+
for (const key of Object.keys(obj)) {
|
|
134
|
+
if (key in props) continue;
|
|
135
|
+
const pattern = Object.entries(patterns).find(([re]) => {
|
|
136
|
+
try {
|
|
137
|
+
return new RegExp(re, "u").test(key);
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
if (pattern) {
|
|
143
|
+
check(obj[key], pattern[1], `${path}/${key}`, root, errors, depth + 1);
|
|
144
|
+
} else if (s.additionalProperties === false) {
|
|
145
|
+
fail(`unexpected field "${key}"`);
|
|
146
|
+
} else if (typeof s.additionalProperties === "object" && s.additionalProperties !== null) {
|
|
147
|
+
check(obj[key], s.additionalProperties as Schema, `${path}/${key}`, root, errors, depth + 1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (typeof s.minProperties === "number" && Object.keys(obj).length < s.minProperties) fail(`fewer than ${s.minProperties} fields`);
|
|
151
|
+
if (typeof s.maxProperties === "number" && Object.keys(obj).length > s.maxProperties) fail(`more than ${s.maxProperties} fields`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (Array.isArray(s.allOf)) {
|
|
155
|
+
for (const sub of s.allOf as Schema[]) check(value, sub, path, root, errors, depth + 1);
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(s.anyOf)) {
|
|
158
|
+
const ok = (s.anyOf as Schema[]).some((sub) => validateSchema(value, withRoot(sub, root)).length === 0);
|
|
159
|
+
if (!ok) fail("matches none of the allowed shapes (anyOf)");
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(s.oneOf)) {
|
|
162
|
+
const n = (s.oneOf as Schema[]).filter((sub) => validateSchema(value, withRoot(sub, root)).length === 0).length;
|
|
163
|
+
if (n !== 1) fail(n === 0 ? "matches none of the allowed shapes (oneOf)" : `matches ${n} of the shapes, must match exactly one (oneOf)`);
|
|
164
|
+
}
|
|
165
|
+
if (s.not !== undefined && validateSchema(value, withRoot(s.not as Schema, root)).length === 0) fail("matches a shape that is not allowed (not)");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A sub-schema checked on its own still resolves `$ref` against the
|
|
169
|
+
* document it came from: carry the root's definitions along. */
|
|
170
|
+
function withRoot(sub: Schema, root: Schema): Schema {
|
|
171
|
+
if (typeof sub !== "object" || sub === null || typeof root !== "object" || root === null) return sub;
|
|
172
|
+
const defs: Record<string, unknown> = {};
|
|
173
|
+
for (const key of ["$defs", "definitions"]) if (key in (root as Record<string, unknown>)) defs[key] = (root as Record<string, unknown>)[key];
|
|
174
|
+
return { ...defs, ...(sub as Record<string, unknown>) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
178
|
+
if (a === b) return true;
|
|
179
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
|
|
180
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
181
|
+
const ka = Object.keys(a as object), kb = Object.keys(b as object);
|
|
182
|
+
if (ka.length !== kb.length) return false;
|
|
183
|
+
return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]));
|
|
184
|
+
}
|
package/src/kinds.ts
CHANGED
package/src/language.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The language an agent works in — the same cascade as its clock, for the
|
|
2
|
+
// same reason: a value written once at the level it belongs to, inherited
|
|
3
|
+
// below it, and never a hardcoded default inside a tool.
|
|
4
|
+
//
|
|
5
|
+
// Prose in AGENTS.md reaches the model and nothing else. A search engine
|
|
6
|
+
// asked in English answers in English whatever the agent was told, a
|
|
7
|
+
// browser reports en-US to every page, a fetch sends no Accept-Language.
|
|
8
|
+
// `language:` is what those read; the prompt sentence it adds is the small
|
|
9
|
+
// part. English is the default, so nothing changes for an agent that never
|
|
10
|
+
// says.
|
|
11
|
+
|
|
12
|
+
export interface LanguageChoice {
|
|
13
|
+
/** BCP-47, normalised: `en`, `en-AU`, `fa`, `pt-BR`. */
|
|
14
|
+
language: string;
|
|
15
|
+
from: "agent" | "flow" | "workspace" | "account" | "env" | "default";
|
|
16
|
+
/** A level that wrote something unreadable, said once and skipped. */
|
|
17
|
+
lines: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `en`, `en-AU`, `en_au`, ` FA-ir ` → `en`, `en-AU`, `en-AU`, `fa-IR`; anything else null. */
|
|
21
|
+
export function normalizeLanguage(raw: unknown): string | null {
|
|
22
|
+
if (typeof raw !== "string") return null;
|
|
23
|
+
const m = /^([a-z]{2,3})(?:[-_]([a-z]{2}))?$/i.exec(raw.trim());
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
return m[2] ? `${m[1].toLowerCase()}-${m[2].toUpperCase()}` : m[1].toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Null when unset (inherit) or readable; else the sentence check prints. */
|
|
29
|
+
export function languageProblem(raw: unknown): string | null {
|
|
30
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
31
|
+
if (normalizeLanguage(raw)) return null;
|
|
32
|
+
return `language: ${JSON.stringify(raw)} is not a language tag — write it like \`en\`, \`en-AU\` or \`fa\` (a two-letter language, and optionally a two-letter region).`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveLanguage(
|
|
36
|
+
levels: { level: LanguageChoice["from"]; value: unknown }[],
|
|
37
|
+
env: Record<string, string | undefined> = process.env,
|
|
38
|
+
): LanguageChoice {
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
for (const { level, value } of levels) {
|
|
41
|
+
if (value === undefined || value === null || value === "") continue;
|
|
42
|
+
const tag = normalizeLanguage(value);
|
|
43
|
+
if (tag) return { language: tag, from: level, lines };
|
|
44
|
+
lines.push(`language: ${level} says ${JSON.stringify(value)}, which is not a language tag — using the next level`);
|
|
45
|
+
}
|
|
46
|
+
const fromEnv = normalizeLanguage(env.FOLDRUN_LANGUAGE);
|
|
47
|
+
if (fromEnv) return { language: fromEnv, from: "env", lines };
|
|
48
|
+
return { language: "en", from: "default", lines };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** How a person would name it in a prompt: `fa-IR` → "Persian (Iran)". */
|
|
52
|
+
export function languageName(tag: string): string {
|
|
53
|
+
try {
|
|
54
|
+
const [lang, region] = tag.split("-");
|
|
55
|
+
const names = new Intl.DisplayNames(["en"], { type: "language" });
|
|
56
|
+
const regions = new Intl.DisplayNames(["en"], { type: "region" });
|
|
57
|
+
const base = names.of(lang) ?? lang;
|
|
58
|
+
return region ? `${base} (${regions.of(region) ?? region})` : base;
|
|
59
|
+
} catch {
|
|
60
|
+
return tag;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// What shape is this folder?
|
|
2
|
+
//
|
|
3
|
+
// There are two shapes a foldrun tree on a laptop can have, and every command
|
|
4
|
+
// has to answer the same question about the one it was pointed at.
|
|
5
|
+
//
|
|
6
|
+
// flat account
|
|
7
|
+
// my-desk/ my-account/
|
|
8
|
+
// ├── AGENTS.md ├── AGENTS.md ← account scope
|
|
9
|
+
// ├── agents/ ├── library/ ← shared by every workspace
|
|
10
|
+
// └── flows/ └── workspaces/
|
|
11
|
+
// └── my-desk/
|
|
12
|
+
// ├── AGENTS.md
|
|
13
|
+
// ├── agents/
|
|
14
|
+
// └── flows/
|
|
15
|
+
//
|
|
16
|
+
// The second is the platform's own layout: `<data>/<account>/library` and
|
|
17
|
+
// `<data>/<account>/workspaces/<name>` are exactly these directories with a
|
|
18
|
+
// tenant directory around them. The first is what `foldrun init` made until
|
|
19
|
+
// today, so it is what every existing user has, and it must keep working
|
|
20
|
+
// forever — a folder shape is a file format.
|
|
21
|
+
//
|
|
22
|
+
// This module is the ONE place that decides. Everything else — the CLI's
|
|
23
|
+
// commands, `accountDir`, `libraryDir` — asks here rather than testing for a
|
|
24
|
+
// `workspaces` directory itself, because the last time that test was written
|
|
25
|
+
// inline it was written as "is my parent called workspaces", which is true of
|
|
26
|
+
// a real installation and of this layout and means something different in
|
|
27
|
+
// each.
|
|
28
|
+
//
|
|
29
|
+
// Deliberately free of every other import: `paths.ts` needs it, and `paths.ts`
|
|
30
|
+
// is what the rest of the codebase imports first.
|
|
31
|
+
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
|
|
35
|
+
/** Where an account keeps its workspaces. `projects/` is the pre-rename name,
|
|
36
|
+
* still read so an existing tree keeps working. */
|
|
37
|
+
export const WORKSPACES_DIRS = ["workspaces", "projects"] as const;
|
|
38
|
+
|
|
39
|
+
function isDir(p: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
return fs.statSync(p).isDirectory();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A workspace is a directory holding `agents/` or `flows/`.
|
|
49
|
+
*
|
|
50
|
+
* Not AGENTS.md: an account root has one of those too, and a test that cannot
|
|
51
|
+
* tell the two apart is the bug this module exists to prevent.
|
|
52
|
+
*/
|
|
53
|
+
export function isWorkspaceDir(dir: string): boolean {
|
|
54
|
+
return isDir(path.join(dir, "agents")) || isDir(path.join(dir, "flows"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `<dir>/workspaces` when `dir` is an account root, else null. */
|
|
58
|
+
export function accountWorkspacesDir(dir: string): string | null {
|
|
59
|
+
for (const name of WORKSPACES_DIRS) {
|
|
60
|
+
const full = path.join(dir, name);
|
|
61
|
+
if (isDir(full)) return full;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The workspaces under an account root, by name, sorted. */
|
|
67
|
+
export function listWorkspaceDirs(accountRoot: string): string[] {
|
|
68
|
+
const dir = accountWorkspacesDir(accountRoot);
|
|
69
|
+
if (!dir) return [];
|
|
70
|
+
try {
|
|
71
|
+
return fs
|
|
72
|
+
.readdirSync(dir)
|
|
73
|
+
.filter((n) => !n.startsWith("."))
|
|
74
|
+
.filter((n) => isDir(path.join(dir, n)))
|
|
75
|
+
.sort();
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type LayoutKind = "account" | "workspace-in-account" | "flat" | "empty";
|
|
82
|
+
|
|
83
|
+
export interface Layout {
|
|
84
|
+
/**
|
|
85
|
+
* `account` — an account folder, pointed at its root.
|
|
86
|
+
* `workspace-in-account` — inside one of its workspaces.
|
|
87
|
+
* `flat` — a lone workspace, the shape `foldrun init` made until today.
|
|
88
|
+
* `empty` — neither: a bare or brand-new directory.
|
|
89
|
+
*/
|
|
90
|
+
kind: LayoutKind;
|
|
91
|
+
/** Where AGENTS.md and `library/` live — the account scope. */
|
|
92
|
+
accountRoot: string;
|
|
93
|
+
/** `<accountRoot>/workspaces`, or null when there is no account layout. */
|
|
94
|
+
workspacesDir: string | null;
|
|
95
|
+
/** The workspace we are in, absolute. Null at an account root. */
|
|
96
|
+
workspaceDir: string | null;
|
|
97
|
+
/** Its name. Null at an account root. */
|
|
98
|
+
workspace: string | null;
|
|
99
|
+
/** Every workspace this account holds, by name. A flat folder holds one. */
|
|
100
|
+
workspaces: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** How far up to look before giving up: a project root (`.git`) is a boundary
|
|
104
|
+
* a layout does not cross, and so is the filesystem root. */
|
|
105
|
+
function* upward(from: string): Generator<string> {
|
|
106
|
+
let dir = path.resolve(from);
|
|
107
|
+
for (;;) {
|
|
108
|
+
yield dir;
|
|
109
|
+
if (isDir(path.join(dir, ".git"))) return;
|
|
110
|
+
const up = path.dirname(dir);
|
|
111
|
+
if (up === dir) return;
|
|
112
|
+
dir = up;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Which of the two shapes `from` is in, and the directories that follow from
|
|
118
|
+
* the answer.
|
|
119
|
+
*
|
|
120
|
+
* Walks up, remembering the first workspace directory it passes and stopping
|
|
121
|
+
* at the first account root. A path anywhere inside a workspace resolves to
|
|
122
|
+
* that workspace, so `foldrun status` works from `agents/writer/` the way git
|
|
123
|
+
* works from anywhere in a checkout.
|
|
124
|
+
*/
|
|
125
|
+
export function detectLayout(from: string): Layout {
|
|
126
|
+
const start = path.resolve(from);
|
|
127
|
+
let workspaceDir: string | null = null;
|
|
128
|
+
let accountRoot: string | null = null;
|
|
129
|
+
|
|
130
|
+
for (const dir of upward(start)) {
|
|
131
|
+
if (!workspaceDir && isWorkspaceDir(dir)) workspaceDir = dir;
|
|
132
|
+
const ws = accountWorkspacesDir(dir);
|
|
133
|
+
// An account root holds `workspaces/`. The workspace directory we may
|
|
134
|
+
// have just found must be *inside* it — a workspace that happens to have
|
|
135
|
+
// its own `workspaces/` folder of something else is not an account.
|
|
136
|
+
if (ws && (!workspaceDir || workspaceDir.startsWith(ws + path.sep))) {
|
|
137
|
+
accountRoot = dir;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (accountRoot) {
|
|
143
|
+
const workspaces = listWorkspaceDirs(accountRoot);
|
|
144
|
+
if (workspaceDir) {
|
|
145
|
+
return {
|
|
146
|
+
kind: "workspace-in-account",
|
|
147
|
+
accountRoot,
|
|
148
|
+
workspacesDir: accountWorkspacesDir(accountRoot),
|
|
149
|
+
workspaceDir,
|
|
150
|
+
workspace: path.basename(workspaceDir),
|
|
151
|
+
workspaces,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
kind: "account",
|
|
156
|
+
accountRoot,
|
|
157
|
+
workspacesDir: accountWorkspacesDir(accountRoot),
|
|
158
|
+
workspaceDir: null,
|
|
159
|
+
workspace: null,
|
|
160
|
+
workspaces,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (workspaceDir) {
|
|
165
|
+
return {
|
|
166
|
+
kind: "flat",
|
|
167
|
+
accountRoot: path.dirname(workspaceDir),
|
|
168
|
+
workspacesDir: null,
|
|
169
|
+
workspaceDir,
|
|
170
|
+
workspace: path.basename(workspaceDir),
|
|
171
|
+
workspaces: [path.basename(workspaceDir)],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
kind: "empty",
|
|
177
|
+
accountRoot: path.dirname(start),
|
|
178
|
+
workspacesDir: null,
|
|
179
|
+
workspaceDir: start,
|
|
180
|
+
workspace: path.basename(start),
|
|
181
|
+
workspaces: [],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The account scope for a workspace directory.
|
|
187
|
+
*
|
|
188
|
+
* For a flat workspace that is simply its parent — `my-desk/` sits beside the
|
|
189
|
+
* `AGENTS.md` and `library/` that cover it, which is what `libraryDir` has
|
|
190
|
+
* always assumed. For a workspace inside an account it is two levels up, NOT
|
|
191
|
+
* one: one level up is `workspaces/`, which holds no library and no config,
|
|
192
|
+
* and pointing the account scope there is how a local account folder used to
|
|
193
|
+
* lose its shared library the moment the runtime asked for it.
|
|
194
|
+
*/
|
|
195
|
+
export function accountRootFor(workspaceDir: string): string {
|
|
196
|
+
const dir = path.resolve(workspaceDir);
|
|
197
|
+
const parent = path.dirname(dir);
|
|
198
|
+
if ((WORKSPACES_DIRS as readonly string[]).includes(path.basename(parent))) {
|
|
199
|
+
return path.dirname(parent);
|
|
200
|
+
}
|
|
201
|
+
return parent;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The data root of the installation this account belongs to, or null when the
|
|
206
|
+
* account is a folder someone made on their laptop.
|
|
207
|
+
*
|
|
208
|
+
* `<data>/<tenant>/workspaces/<name>` and `<my-account>/workspaces/<name>` are
|
|
209
|
+
* the same shape, so the shape cannot tell them apart. What can is the
|
|
210
|
+
* installation's own marker, which sits at the data root and nowhere else:
|
|
211
|
+
* `.foldrun-install`, written by the platform at boot. `.secret-key` and
|
|
212
|
+
* `keys.json` are the older markers — kept for installs from before it, and
|
|
213
|
+
* no longer enough alone, because keys move into the database and a key held
|
|
214
|
+
* in FOLDRUN_SECRET_KEY never had a file.
|
|
215
|
+
*/
|
|
216
|
+
export const INSTALL_MARKER = ".foldrun-install";
|
|
217
|
+
|
|
218
|
+
export function installationDataRoot(accountRoot: string): string | null {
|
|
219
|
+
const above = path.dirname(path.resolve(accountRoot));
|
|
220
|
+
for (const marker of [INSTALL_MARKER, ".secret-key", "keys.json"]) {
|
|
221
|
+
try {
|
|
222
|
+
if (fs.statSync(path.join(above, marker)).isFile()) return above;
|
|
223
|
+
} catch {
|
|
224
|
+
/* not this one */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
package/src/library.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// own file beats its workspace's, which beats the account's. So a team can set
|
|
12
12
|
// a house style once and one agent can still override it.
|
|
13
13
|
|
|
14
|
+
import { platform } from "./platform.ts";
|
|
14
15
|
import fs from "node:fs";
|
|
15
16
|
import path from "node:path";
|
|
16
|
-
import { dataRoot, singleWorkspace } from "./paths.ts";
|
|
17
|
+
import { dataRoot, singleWorkspace, singleAccountRoot, MAX_EDITABLE_FILE } from "./paths.ts";
|
|
17
18
|
import matter from "gray-matter";
|
|
18
19
|
import { KINDS } from "./kinds.ts";
|
|
19
20
|
import { recordRevision, registerTreeReader } from "./history.ts";
|
|
@@ -43,7 +44,9 @@ export function libraryDir(tenant: string, kind?: LibraryKind) {
|
|
|
43
44
|
// library is still possible on a laptop without inventing a home directory.
|
|
44
45
|
const single = singleWorkspace();
|
|
45
46
|
if (single) {
|
|
46
|
-
|
|
47
|
+
// The account scope, which is the workspace's parent only when the
|
|
48
|
+
// workspace is a flat one — see singleAccountRoot.
|
|
49
|
+
const base = path.join(singleAccountRoot()!, "library");
|
|
47
50
|
return kind ? path.join(base, kind) : base;
|
|
48
51
|
}
|
|
49
52
|
assertSafeName(tenant, "tenant");
|
|
@@ -74,6 +77,15 @@ export interface LibraryEntry {
|
|
|
74
77
|
description: string;
|
|
75
78
|
/** For skills: true when the folder bundles scripts/. */
|
|
76
79
|
hasScripts?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Every file this entry is made of, relative to the kind's directory,
|
|
82
|
+
* `path` included. One entry stays one tool — the shelf must not grow a
|
|
83
|
+
* second row called browser — but a caller copying the library needs the
|
|
84
|
+
* code beside the manifest, and the listing is the only place that knows
|
|
85
|
+
* what is in the folder. Absent for a single-file entry, where `path` is
|
|
86
|
+
* already the whole of it.
|
|
87
|
+
*/
|
|
88
|
+
files?: string[];
|
|
77
89
|
/**
|
|
78
90
|
* For tools: how this one connects — http, script or mcp. Surfaced so a
|
|
79
91
|
* list of tools reads as one vocabulary with three transports, rather
|
|
@@ -86,6 +98,25 @@ export interface LibraryEntry {
|
|
|
86
98
|
updatedAt: string;
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
/** Every file inside a folder entry, as paths relative to the kind's
|
|
102
|
+
* directory. Depth-first so a nested `scripts/` comes with it. */
|
|
103
|
+
function folderFiles(dir: string, prefix: string): string[] {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
const walk = (at: string, rel: string) => {
|
|
106
|
+
for (const e of fs.readdirSync(at, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
107
|
+
const here = rel ? `${rel}/${e.name}` : e.name;
|
|
108
|
+
if (e.isDirectory()) walk(path.join(at, e.name), here);
|
|
109
|
+
else out.push(`${prefix}/${here}`);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
walk(dir, "");
|
|
114
|
+
} catch {
|
|
115
|
+
return [`${prefix}/tool.md`];
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
export function listLibrary(tenant: string, kind: LibraryKind): LibraryEntry[] {
|
|
90
121
|
const dir = libraryDir(tenant, kind);
|
|
91
122
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -112,6 +143,7 @@ export function listLibrary(tenant: string, kind: LibraryKind): LibraryEntry[] {
|
|
|
112
143
|
out.push({
|
|
113
144
|
...describe(manifest, entry.name),
|
|
114
145
|
path: `${entry.name}/tool.md`,
|
|
146
|
+
files: folderFiles(full, entry.name),
|
|
115
147
|
...toolShape(manifest, entry.name),
|
|
116
148
|
updatedAt: fs.statSync(manifest).mtime.toISOString(),
|
|
117
149
|
});
|
|
@@ -137,6 +169,7 @@ export function listLibrary(tenant: string, kind: LibraryKind): LibraryEntry[] {
|
|
|
137
169
|
...describe(skillMd, entry),
|
|
138
170
|
path: `${entry}/SKILL.md`,
|
|
139
171
|
hasScripts: fs.existsSync(path.join(full, "scripts")),
|
|
172
|
+
files: folderFiles(full, entry),
|
|
140
173
|
updatedAt: fs.statSync(skillMd).mtime.toISOString(),
|
|
141
174
|
});
|
|
142
175
|
} else if (entry.endsWith(".md")) {
|
|
@@ -204,7 +237,9 @@ export function writeLibraryFile(
|
|
|
204
237
|
content: string,
|
|
205
238
|
meta: { by?: string; message?: string } = {},
|
|
206
239
|
) {
|
|
207
|
-
|
|
240
|
+
// 512 KB: a folder tool's program is one file, and the gallery browser's is
|
|
241
|
+
// past 256 KB. Still a file a person edits, not a store for data.
|
|
242
|
+
if (content.length > MAX_EDITABLE_FILE) throw new Error("file too large");
|
|
208
243
|
const norm = assertLibraryPath(kind, rel);
|
|
209
244
|
if (norm.endsWith(".md")) matter(content); // reject broken frontmatter
|
|
210
245
|
const p = path.join(libraryDir(tenant, kind), norm);
|
|
@@ -255,7 +290,18 @@ export function deleteLibraryPath(
|
|
|
255
290
|
|
|
256
291
|
/** Library tool definitions, keyed by name — http or script, both grantable by name in `tools:`. */
|
|
257
292
|
export function libraryTools(tenant: string): Record<string, ToolDef> {
|
|
258
|
-
|
|
293
|
+
// The platform's gallery first, the account's own copies over it: a tool
|
|
294
|
+
// the platform ships is granted by name in every account without anyone
|
|
295
|
+
// installing it, and an account that installed (copied) one keeps its
|
|
296
|
+
// copy — that is what "install to customise" means. Both read at account
|
|
297
|
+
// scope, so a folder tool's `run:` resolves to /library/tools/<name>/…
|
|
298
|
+
// in the sandbox either way; the staging step lays the gallery down
|
|
299
|
+
// first and the account library over it for the same reason.
|
|
300
|
+
const gallery = platform.galleryDir();
|
|
301
|
+
return {
|
|
302
|
+
...(gallery ? readToolDir(path.join(gallery, "tools"), "account") : {}),
|
|
303
|
+
...readToolDir(libraryDir(tenant, "tools"), "account"),
|
|
304
|
+
};
|
|
259
305
|
}
|
|
260
306
|
|
|
261
307
|
/** Shared memory index — derived from the files, like every other scope. */
|