@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,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
@@ -116,7 +116,7 @@ name: ${name}
116
116
  description: What this agent is for, in one line.
117
117
  model: fast
118
118
  effort: high
119
- tools: [files]
119
+ tools: [write]
120
120
  ---
121
121
 
122
122
  # ${name}
@@ -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
- const base = path.join(path.resolve(single), "..", "library");
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
- if (content.length > 256 * 1024) throw new Error("file too large");
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
- return readToolDir(libraryDir(tenant, "tools"), "account");
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. */