@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.
Files changed (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -0,0 +1,48 @@
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
+ const EXACT = new Set([
20
+ "PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
21
+ "TZ", "TERM", "SHELL", "USER", "LOGNAME",
22
+ // The host's way to the network is the child's way to the network.
23
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
24
+ "SSL_CERT_FILE", "SSL_CERT_DIR",
25
+ // Windows equivalents, harmless elsewhere.
26
+ "SYSTEMROOT", "COMSPEC", "PATHEXT", "WINDIR",
27
+ ]);
28
+ const PREFIXES = ["LC_", "NODE_", "FOLDRUN_RUN_", "FOLDRUN_STEP_"];
29
+ /** Does this name pass the allowlist? Exported so a test can pin the rule
30
+ * rather than the list. */
31
+ export function hostEnvAllowed(name) {
32
+ return EXACT.has(name) || PREFIXES.some((p) => name.startsWith(p));
33
+ }
34
+ /**
35
+ * process.env, reduced to the allowlist. A fresh object every call, so a
36
+ * caller that layers secrets on top never writes into the host's own env.
37
+ */
38
+ export function hostSafeEnv(from = process.env) {
39
+ const out = {};
40
+ for (const [k, v] of Object.entries(from)) {
41
+ if (typeof v === "string" && hostEnvAllowed(k))
42
+ out[k] = v;
43
+ }
44
+ // Typed as ProcessEnv, not a string map: a host whose type augmentation
45
+ // makes a key required (Next declares NODE_ENV so) refuses a plain map at
46
+ // every spawn. The value is the same either way.
47
+ return out;
48
+ }
@@ -0,0 +1,12 @@
1
+ export interface SchemaError {
2
+ /** JSON pointer to the offending value: "" is the root, "/leads/0/url". */
3
+ path: string;
4
+ message: string;
5
+ }
6
+ /** Is `value` what `schema` describes? Empty means yes. */
7
+ export declare function validateSchema(value: unknown, schema: unknown): SchemaError[];
8
+ /** The errors as one sentence for a trace line. */
9
+ export declare function describeSchemaErrors(errors: SchemaError[]): string;
10
+ /** Is this a schema at all? An object (or a boolean, which JSON Schema
11
+ * allows) — not a string, not a list. */
12
+ export declare function looksLikeSchema(schema: unknown): boolean;
@@ -0,0 +1,202 @@
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
+ const MAX_ERRORS = 8;
15
+ /** Is `value` what `schema` describes? Empty means yes. */
16
+ export function validateSchema(value, schema) {
17
+ const errors = [];
18
+ check(value, schema, "", schema, errors, 0);
19
+ return errors.slice(0, MAX_ERRORS);
20
+ }
21
+ /** The errors as one sentence for a trace line. */
22
+ export function describeSchemaErrors(errors) {
23
+ return errors.map((e) => `${e.path || "the value"}: ${e.message}`).join("; ");
24
+ }
25
+ /** Is this a schema at all? An object (or a boolean, which JSON Schema
26
+ * allows) — not a string, not a list. */
27
+ export function looksLikeSchema(schema) {
28
+ return typeof schema === "boolean" || (typeof schema === "object" && schema !== null && !Array.isArray(schema));
29
+ }
30
+ const typeOf = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" ? (Number.isInteger(v) ? "integer" : "number") : typeof v;
31
+ const matchesType = (v, t) => {
32
+ const actual = typeOf(v);
33
+ return t === actual || (t === "number" && actual === "integer");
34
+ };
35
+ function resolveRef(ref, root) {
36
+ if (!ref.startsWith("#"))
37
+ return null;
38
+ let node = root;
39
+ for (const raw of ref.slice(1).split("/").filter(Boolean)) {
40
+ const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
41
+ if (typeof node !== "object" || node === null)
42
+ return null;
43
+ node = node[key];
44
+ }
45
+ return typeof node === "object" && node !== null ? node : typeof node === "boolean" ? node : null;
46
+ }
47
+ function check(value, schema, path, root, errors, depth) {
48
+ if (errors.length >= MAX_ERRORS || depth > 64)
49
+ return;
50
+ if (schema === true)
51
+ return;
52
+ if (schema === false)
53
+ return void errors.push({ path, message: "nothing is allowed here" });
54
+ if (typeof schema !== "object" || schema === null)
55
+ return;
56
+ const s = schema;
57
+ const fail = (message) => errors.push({ path, message });
58
+ if (typeof s.$ref === "string") {
59
+ const target = resolveRef(s.$ref, root);
60
+ if (target === null)
61
+ return fail(`schema: unresolvable $ref ${s.$ref}`);
62
+ check(value, target, path, root, errors, depth + 1);
63
+ return;
64
+ }
65
+ // type — one, or a list of alternatives; "nullable: true" (OpenAPI's
66
+ // spelling) lets null through.
67
+ if (s.type !== undefined) {
68
+ const types = Array.isArray(s.type) ? s.type : [String(s.type)];
69
+ const ok = types.some((t) => matchesType(value, t)) || (s.nullable === true && value === null);
70
+ if (!ok)
71
+ return fail(`expected ${types.join(" or ")}, got ${typeOf(value)}`);
72
+ }
73
+ if (Array.isArray(s.enum) && !s.enum.some((e) => deepEqual(e, value))) {
74
+ return fail(`must be one of ${s.enum.map((e) => JSON.stringify(e)).join(", ")}`);
75
+ }
76
+ if ("const" in s && !deepEqual(s.const, value))
77
+ return fail(`must be ${JSON.stringify(s.const)}`);
78
+ if (typeof value === "string") {
79
+ if (typeof s.minLength === "number" && value.length < s.minLength)
80
+ fail(`shorter than ${s.minLength} characters`);
81
+ if (typeof s.maxLength === "number" && value.length > s.maxLength)
82
+ fail(`longer than ${s.maxLength} characters`);
83
+ if (typeof s.pattern === "string") {
84
+ try {
85
+ if (!new RegExp(s.pattern, "u").test(value))
86
+ fail(`does not match ${s.pattern}`);
87
+ }
88
+ catch {
89
+ fail(`schema: invalid pattern ${s.pattern}`);
90
+ }
91
+ }
92
+ }
93
+ if (typeof value === "number") {
94
+ if (typeof s.minimum === "number" && value < s.minimum)
95
+ fail(`below the minimum ${s.minimum}`);
96
+ if (typeof s.maximum === "number" && value > s.maximum)
97
+ fail(`above the maximum ${s.maximum}`);
98
+ if (typeof s.exclusiveMinimum === "number" && value <= s.exclusiveMinimum)
99
+ fail(`must be above ${s.exclusiveMinimum}`);
100
+ if (typeof s.exclusiveMaximum === "number" && value >= s.exclusiveMaximum)
101
+ fail(`must be below ${s.exclusiveMaximum}`);
102
+ if (typeof s.multipleOf === "number" && s.multipleOf > 0 && Math.abs(value / s.multipleOf - Math.round(value / s.multipleOf)) > 1e-9) {
103
+ fail(`not a multiple of ${s.multipleOf}`);
104
+ }
105
+ }
106
+ if (Array.isArray(value)) {
107
+ if (typeof s.minItems === "number" && value.length < s.minItems)
108
+ fail(`fewer than ${s.minItems} items`);
109
+ if (typeof s.maxItems === "number" && value.length > s.maxItems)
110
+ fail(`more than ${s.maxItems} items`);
111
+ if (s.uniqueItems === true) {
112
+ const seen = new Set(value.map((v) => JSON.stringify(v)));
113
+ if (seen.size !== value.length)
114
+ fail("items are not unique");
115
+ }
116
+ if (s.items !== undefined && !Array.isArray(s.items)) {
117
+ value.forEach((item, i) => check(item, s.items, `${path}/${i}`, root, errors, depth + 1));
118
+ }
119
+ else if (Array.isArray(s.items) || Array.isArray(s.prefixItems)) {
120
+ const tuple = (Array.isArray(s.prefixItems) ? s.prefixItems : s.items);
121
+ tuple.forEach((sub, i) => i < value.length && check(value[i], sub, `${path}/${i}`, root, errors, depth + 1));
122
+ }
123
+ }
124
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
125
+ const obj = value;
126
+ const props = (typeof s.properties === "object" && s.properties !== null ? s.properties : {});
127
+ if (Array.isArray(s.required)) {
128
+ for (const key of s.required)
129
+ if (!(key in obj))
130
+ fail(`missing required field "${key}"`);
131
+ }
132
+ for (const [key, sub] of Object.entries(props)) {
133
+ if (key in obj)
134
+ check(obj[key], sub, `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`, root, errors, depth + 1);
135
+ }
136
+ const patterns = (typeof s.patternProperties === "object" && s.patternProperties !== null ? s.patternProperties : {});
137
+ for (const key of Object.keys(obj)) {
138
+ if (key in props)
139
+ continue;
140
+ const pattern = Object.entries(patterns).find(([re]) => {
141
+ try {
142
+ return new RegExp(re, "u").test(key);
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ });
148
+ if (pattern) {
149
+ check(obj[key], pattern[1], `${path}/${key}`, root, errors, depth + 1);
150
+ }
151
+ else if (s.additionalProperties === false) {
152
+ fail(`unexpected field "${key}"`);
153
+ }
154
+ else if (typeof s.additionalProperties === "object" && s.additionalProperties !== null) {
155
+ check(obj[key], s.additionalProperties, `${path}/${key}`, root, errors, depth + 1);
156
+ }
157
+ }
158
+ if (typeof s.minProperties === "number" && Object.keys(obj).length < s.minProperties)
159
+ fail(`fewer than ${s.minProperties} fields`);
160
+ if (typeof s.maxProperties === "number" && Object.keys(obj).length > s.maxProperties)
161
+ fail(`more than ${s.maxProperties} fields`);
162
+ }
163
+ if (Array.isArray(s.allOf)) {
164
+ for (const sub of s.allOf)
165
+ check(value, sub, path, root, errors, depth + 1);
166
+ }
167
+ if (Array.isArray(s.anyOf)) {
168
+ const ok = s.anyOf.some((sub) => validateSchema(value, withRoot(sub, root)).length === 0);
169
+ if (!ok)
170
+ fail("matches none of the allowed shapes (anyOf)");
171
+ }
172
+ if (Array.isArray(s.oneOf)) {
173
+ const n = s.oneOf.filter((sub) => validateSchema(value, withRoot(sub, root)).length === 0).length;
174
+ if (n !== 1)
175
+ fail(n === 0 ? "matches none of the allowed shapes (oneOf)" : `matches ${n} of the shapes, must match exactly one (oneOf)`);
176
+ }
177
+ if (s.not !== undefined && validateSchema(value, withRoot(s.not, root)).length === 0)
178
+ fail("matches a shape that is not allowed (not)");
179
+ }
180
+ /** A sub-schema checked on its own still resolves `$ref` against the
181
+ * document it came from: carry the root's definitions along. */
182
+ function withRoot(sub, root) {
183
+ if (typeof sub !== "object" || sub === null || typeof root !== "object" || root === null)
184
+ return sub;
185
+ const defs = {};
186
+ for (const key of ["$defs", "definitions"])
187
+ if (key in root)
188
+ defs[key] = root[key];
189
+ return { ...defs, ...sub };
190
+ }
191
+ function deepEqual(a, b) {
192
+ if (a === b)
193
+ return true;
194
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== "object")
195
+ return false;
196
+ if (Array.isArray(a) !== Array.isArray(b))
197
+ return false;
198
+ const ka = Object.keys(a), kb = Object.keys(b);
199
+ if (ka.length !== kb.length)
200
+ return false;
201
+ return ka.every((k) => deepEqual(a[k], b[k]));
202
+ }
package/dist/src/kinds.js CHANGED
@@ -62,7 +62,7 @@ name: ${name}
62
62
  description: What this agent is for, in one line.
63
63
  model: fast
64
64
  effort: high
65
- tools: [files]
65
+ tools: [write]
66
66
  ---
67
67
 
68
68
  # ${name}
@@ -0,0 +1,17 @@
1
+ export interface LanguageChoice {
2
+ /** BCP-47, normalised: `en`, `en-AU`, `fa`, `pt-BR`. */
3
+ language: string;
4
+ from: "agent" | "flow" | "workspace" | "account" | "env" | "default";
5
+ /** A level that wrote something unreadable, said once and skipped. */
6
+ lines: string[];
7
+ }
8
+ /** `en`, `en-AU`, `en_au`, ` FA-ir ` → `en`, `en-AU`, `en-AU`, `fa-IR`; anything else null. */
9
+ export declare function normalizeLanguage(raw: unknown): string | null;
10
+ /** Null when unset (inherit) or readable; else the sentence check prints. */
11
+ export declare function languageProblem(raw: unknown): string | null;
12
+ export declare function resolveLanguage(levels: {
13
+ level: LanguageChoice["from"];
14
+ value: unknown;
15
+ }[], env?: Record<string, string | undefined>): LanguageChoice;
16
+ /** How a person would name it in a prompt: `fa-IR` → "Persian (Iran)". */
17
+ export declare function languageName(tag: string): string;
@@ -0,0 +1,55 @@
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
+ /** `en`, `en-AU`, `en_au`, ` FA-ir ` → `en`, `en-AU`, `en-AU`, `fa-IR`; anything else null. */
12
+ export function normalizeLanguage(raw) {
13
+ if (typeof raw !== "string")
14
+ return null;
15
+ const m = /^([a-z]{2,3})(?:[-_]([a-z]{2}))?$/i.exec(raw.trim());
16
+ if (!m)
17
+ return null;
18
+ return m[2] ? `${m[1].toLowerCase()}-${m[2].toUpperCase()}` : m[1].toLowerCase();
19
+ }
20
+ /** Null when unset (inherit) or readable; else the sentence check prints. */
21
+ export function languageProblem(raw) {
22
+ if (raw === undefined || raw === null || raw === "")
23
+ return null;
24
+ if (normalizeLanguage(raw))
25
+ return null;
26
+ 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).`;
27
+ }
28
+ export function resolveLanguage(levels, env = process.env) {
29
+ const lines = [];
30
+ for (const { level, value } of levels) {
31
+ if (value === undefined || value === null || value === "")
32
+ continue;
33
+ const tag = normalizeLanguage(value);
34
+ if (tag)
35
+ return { language: tag, from: level, lines };
36
+ lines.push(`language: ${level} says ${JSON.stringify(value)}, which is not a language tag — using the next level`);
37
+ }
38
+ const fromEnv = normalizeLanguage(env.FOLDRUN_LANGUAGE);
39
+ if (fromEnv)
40
+ return { language: fromEnv, from: "env", lines };
41
+ return { language: "en", from: "default", lines };
42
+ }
43
+ /** How a person would name it in a prompt: `fa-IR` → "Persian (Iran)". */
44
+ export function languageName(tag) {
45
+ try {
46
+ const [lang, region] = tag.split("-");
47
+ const names = new Intl.DisplayNames(["en"], { type: "language" });
48
+ const regions = new Intl.DisplayNames(["en"], { type: "region" });
49
+ const base = names.of(lang) ?? lang;
50
+ return region ? `${base} (${regions.of(region) ?? region})` : base;
51
+ }
52
+ catch {
53
+ return tag;
54
+ }
55
+ }
@@ -0,0 +1,69 @@
1
+ /** Where an account keeps its workspaces. `projects/` is the pre-rename name,
2
+ * still read so an existing tree keeps working. */
3
+ export declare const WORKSPACES_DIRS: readonly ["workspaces", "projects"];
4
+ /**
5
+ * A workspace is a directory holding `agents/` or `flows/`.
6
+ *
7
+ * Not AGENTS.md: an account root has one of those too, and a test that cannot
8
+ * tell the two apart is the bug this module exists to prevent.
9
+ */
10
+ export declare function isWorkspaceDir(dir: string): boolean;
11
+ /** `<dir>/workspaces` when `dir` is an account root, else null. */
12
+ export declare function accountWorkspacesDir(dir: string): string | null;
13
+ /** The workspaces under an account root, by name, sorted. */
14
+ export declare function listWorkspaceDirs(accountRoot: string): string[];
15
+ export type LayoutKind = "account" | "workspace-in-account" | "flat" | "empty";
16
+ export interface Layout {
17
+ /**
18
+ * `account` — an account folder, pointed at its root.
19
+ * `workspace-in-account` — inside one of its workspaces.
20
+ * `flat` — a lone workspace, the shape `foldrun init` made until today.
21
+ * `empty` — neither: a bare or brand-new directory.
22
+ */
23
+ kind: LayoutKind;
24
+ /** Where AGENTS.md and `library/` live — the account scope. */
25
+ accountRoot: string;
26
+ /** `<accountRoot>/workspaces`, or null when there is no account layout. */
27
+ workspacesDir: string | null;
28
+ /** The workspace we are in, absolute. Null at an account root. */
29
+ workspaceDir: string | null;
30
+ /** Its name. Null at an account root. */
31
+ workspace: string | null;
32
+ /** Every workspace this account holds, by name. A flat folder holds one. */
33
+ workspaces: string[];
34
+ }
35
+ /**
36
+ * Which of the two shapes `from` is in, and the directories that follow from
37
+ * the answer.
38
+ *
39
+ * Walks up, remembering the first workspace directory it passes and stopping
40
+ * at the first account root. A path anywhere inside a workspace resolves to
41
+ * that workspace, so `foldrun status` works from `agents/writer/` the way git
42
+ * works from anywhere in a checkout.
43
+ */
44
+ export declare function detectLayout(from: string): Layout;
45
+ /**
46
+ * The account scope for a workspace directory.
47
+ *
48
+ * For a flat workspace that is simply its parent — `my-desk/` sits beside the
49
+ * `AGENTS.md` and `library/` that cover it, which is what `libraryDir` has
50
+ * always assumed. For a workspace inside an account it is two levels up, NOT
51
+ * one: one level up is `workspaces/`, which holds no library and no config,
52
+ * and pointing the account scope there is how a local account folder used to
53
+ * lose its shared library the moment the runtime asked for it.
54
+ */
55
+ export declare function accountRootFor(workspaceDir: string): string;
56
+ /**
57
+ * The data root of the installation this account belongs to, or null when the
58
+ * account is a folder someone made on their laptop.
59
+ *
60
+ * `<data>/<tenant>/workspaces/<name>` and `<my-account>/workspaces/<name>` are
61
+ * the same shape, so the shape cannot tell them apart. What can is the
62
+ * installation's own marker, which sits at the data root and nowhere else:
63
+ * `.foldrun-install`, written by the platform at boot. `.secret-key` and
64
+ * `keys.json` are the older markers — kept for installs from before it, and
65
+ * no longer enough alone, because keys move into the database and a key held
66
+ * in FOLDRUN_SECRET_KEY never had a file.
67
+ */
68
+ export declare const INSTALL_MARKER = ".foldrun-install";
69
+ export declare function installationDataRoot(accountRoot: string): string | null;
@@ -0,0 +1,200 @@
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
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ /** Where an account keeps its workspaces. `projects/` is the pre-rename name,
34
+ * still read so an existing tree keeps working. */
35
+ export const WORKSPACES_DIRS = ["workspaces", "projects"];
36
+ function isDir(p) {
37
+ try {
38
+ return fs.statSync(p).isDirectory();
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * A workspace is a directory holding `agents/` or `flows/`.
46
+ *
47
+ * Not AGENTS.md: an account root has one of those too, and a test that cannot
48
+ * tell the two apart is the bug this module exists to prevent.
49
+ */
50
+ export function isWorkspaceDir(dir) {
51
+ return isDir(path.join(dir, "agents")) || isDir(path.join(dir, "flows"));
52
+ }
53
+ /** `<dir>/workspaces` when `dir` is an account root, else null. */
54
+ export function accountWorkspacesDir(dir) {
55
+ for (const name of WORKSPACES_DIRS) {
56
+ const full = path.join(dir, name);
57
+ if (isDir(full))
58
+ return full;
59
+ }
60
+ return null;
61
+ }
62
+ /** The workspaces under an account root, by name, sorted. */
63
+ export function listWorkspaceDirs(accountRoot) {
64
+ const dir = accountWorkspacesDir(accountRoot);
65
+ if (!dir)
66
+ return [];
67
+ try {
68
+ return fs
69
+ .readdirSync(dir)
70
+ .filter((n) => !n.startsWith("."))
71
+ .filter((n) => isDir(path.join(dir, n)))
72
+ .sort();
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
78
+ /** How far up to look before giving up: a project root (`.git`) is a boundary
79
+ * a layout does not cross, and so is the filesystem root. */
80
+ function* upward(from) {
81
+ let dir = path.resolve(from);
82
+ for (;;) {
83
+ yield dir;
84
+ if (isDir(path.join(dir, ".git")))
85
+ return;
86
+ const up = path.dirname(dir);
87
+ if (up === dir)
88
+ return;
89
+ dir = up;
90
+ }
91
+ }
92
+ /**
93
+ * Which of the two shapes `from` is in, and the directories that follow from
94
+ * the answer.
95
+ *
96
+ * Walks up, remembering the first workspace directory it passes and stopping
97
+ * at the first account root. A path anywhere inside a workspace resolves to
98
+ * that workspace, so `foldrun status` works from `agents/writer/` the way git
99
+ * works from anywhere in a checkout.
100
+ */
101
+ export function detectLayout(from) {
102
+ const start = path.resolve(from);
103
+ let workspaceDir = null;
104
+ let accountRoot = null;
105
+ for (const dir of upward(start)) {
106
+ if (!workspaceDir && isWorkspaceDir(dir))
107
+ workspaceDir = dir;
108
+ const ws = accountWorkspacesDir(dir);
109
+ // An account root holds `workspaces/`. The workspace directory we may
110
+ // have just found must be *inside* it — a workspace that happens to have
111
+ // its own `workspaces/` folder of something else is not an account.
112
+ if (ws && (!workspaceDir || workspaceDir.startsWith(ws + path.sep))) {
113
+ accountRoot = dir;
114
+ break;
115
+ }
116
+ }
117
+ if (accountRoot) {
118
+ const workspaces = listWorkspaceDirs(accountRoot);
119
+ if (workspaceDir) {
120
+ return {
121
+ kind: "workspace-in-account",
122
+ accountRoot,
123
+ workspacesDir: accountWorkspacesDir(accountRoot),
124
+ workspaceDir,
125
+ workspace: path.basename(workspaceDir),
126
+ workspaces,
127
+ };
128
+ }
129
+ return {
130
+ kind: "account",
131
+ accountRoot,
132
+ workspacesDir: accountWorkspacesDir(accountRoot),
133
+ workspaceDir: null,
134
+ workspace: null,
135
+ workspaces,
136
+ };
137
+ }
138
+ if (workspaceDir) {
139
+ return {
140
+ kind: "flat",
141
+ accountRoot: path.dirname(workspaceDir),
142
+ workspacesDir: null,
143
+ workspaceDir,
144
+ workspace: path.basename(workspaceDir),
145
+ workspaces: [path.basename(workspaceDir)],
146
+ };
147
+ }
148
+ return {
149
+ kind: "empty",
150
+ accountRoot: path.dirname(start),
151
+ workspacesDir: null,
152
+ workspaceDir: start,
153
+ workspace: path.basename(start),
154
+ workspaces: [],
155
+ };
156
+ }
157
+ /**
158
+ * The account scope for a workspace directory.
159
+ *
160
+ * For a flat workspace that is simply its parent — `my-desk/` sits beside the
161
+ * `AGENTS.md` and `library/` that cover it, which is what `libraryDir` has
162
+ * always assumed. For a workspace inside an account it is two levels up, NOT
163
+ * one: one level up is `workspaces/`, which holds no library and no config,
164
+ * and pointing the account scope there is how a local account folder used to
165
+ * lose its shared library the moment the runtime asked for it.
166
+ */
167
+ export function accountRootFor(workspaceDir) {
168
+ const dir = path.resolve(workspaceDir);
169
+ const parent = path.dirname(dir);
170
+ if (WORKSPACES_DIRS.includes(path.basename(parent))) {
171
+ return path.dirname(parent);
172
+ }
173
+ return parent;
174
+ }
175
+ /**
176
+ * The data root of the installation this account belongs to, or null when the
177
+ * account is a folder someone made on their laptop.
178
+ *
179
+ * `<data>/<tenant>/workspaces/<name>` and `<my-account>/workspaces/<name>` are
180
+ * the same shape, so the shape cannot tell them apart. What can is the
181
+ * installation's own marker, which sits at the data root and nowhere else:
182
+ * `.foldrun-install`, written by the platform at boot. `.secret-key` and
183
+ * `keys.json` are the older markers — kept for installs from before it, and
184
+ * no longer enough alone, because keys move into the database and a key held
185
+ * in FOLDRUN_SECRET_KEY never had a file.
186
+ */
187
+ export const INSTALL_MARKER = ".foldrun-install";
188
+ export function installationDataRoot(accountRoot) {
189
+ const above = path.dirname(path.resolve(accountRoot));
190
+ for (const marker of [INSTALL_MARKER, ".secret-key", "keys.json"]) {
191
+ try {
192
+ if (fs.statSync(path.join(above, marker)).isFile())
193
+ return above;
194
+ }
195
+ catch {
196
+ /* not this one */
197
+ }
198
+ }
199
+ return null;
200
+ }
@@ -9,6 +9,15 @@ export interface LibraryEntry {
9
9
  description: string;
10
10
  /** For skills: true when the folder bundles scripts/. */
11
11
  hasScripts?: boolean;
12
+ /**
13
+ * Every file this entry is made of, relative to the kind's directory,
14
+ * `path` included. One entry stays one tool — the shelf must not grow a
15
+ * second row called browser — but a caller copying the library needs the
16
+ * code beside the manifest, and the listing is the only place that knows
17
+ * what is in the folder. Absent for a single-file entry, where `path` is
18
+ * already the whole of it.
19
+ */
20
+ files?: string[];
12
21
  /**
13
22
  * For tools: how this one connects — http, script or mcp. Surfaced so a
14
23
  * list of tools reads as one vocabulary with three transports, rather