@alisio/core 0.5.2 → 0.7.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 (204) hide show
  1. package/dist/analysis/janitor.d.ts +48 -0
  2. package/dist/analysis/janitor.js +170 -18
  3. package/dist/analysis/jobs.d.ts +11 -2
  4. package/dist/analysis/jobs.js +13 -0
  5. package/dist/analysis/oci.d.ts +11 -2
  6. package/dist/analysis/oci.js +20 -4
  7. package/dist/analysis/python/sources.js +2 -1
  8. package/dist/analysis/rerun.d.ts +1 -1
  9. package/dist/analysis/status.d.ts +3 -1
  10. package/dist/analysis/status.js +3 -0
  11. package/dist/application.d.ts +28 -2
  12. package/dist/application.js +189 -4
  13. package/dist/changelog/data.js +42 -0
  14. package/dist/config.d.ts +110 -2
  15. package/dist/config.js +188 -6
  16. package/dist/core/call-internals.d.ts +22 -0
  17. package/dist/core/call-internals.js +9 -0
  18. package/dist/core/contracts.d.ts +2 -2
  19. package/dist/core/runner.js +111 -38
  20. package/dist/eval/canonical.d.ts +14 -0
  21. package/dist/eval/canonical.js +40 -0
  22. package/dist/eval/cassette.d.ts +180 -0
  23. package/dist/eval/cassette.js +424 -0
  24. package/dist/eval/compare.d.ts +147 -0
  25. package/dist/eval/compare.js +436 -0
  26. package/dist/eval/engine.d.ts +144 -0
  27. package/dist/eval/engine.js +728 -0
  28. package/dist/eval/export.d.ts +40 -0
  29. package/dist/eval/export.js +90 -0
  30. package/dist/eval/fingerprint.d.ts +88 -0
  31. package/dist/eval/fingerprint.js +186 -0
  32. package/dist/eval/normalize.d.ts +68 -0
  33. package/dist/eval/normalize.js +138 -0
  34. package/dist/eval/paths.d.ts +11 -0
  35. package/dist/eval/paths.js +50 -0
  36. package/dist/eval/plan.d.ts +26 -0
  37. package/dist/eval/plan.js +44 -0
  38. package/dist/eval/report.d.ts +53 -0
  39. package/dist/eval/report.js +258 -0
  40. package/dist/eval/run-suite.d.ts +68 -0
  41. package/dist/eval/run-suite.js +94 -0
  42. package/dist/eval/secrets.d.ts +12 -0
  43. package/dist/eval/secrets.js +45 -0
  44. package/dist/eval/stats.d.ts +17 -0
  45. package/dist/eval/stats.js +50 -0
  46. package/dist/eval/suite.d.ts +20 -0
  47. package/dist/eval/suite.js +165 -0
  48. package/dist/eval/variants.d.ts +35 -0
  49. package/dist/eval/variants.js +137 -0
  50. package/dist/eval/verifiers.d.ts +49 -0
  51. package/dist/eval/verifiers.js +264 -0
  52. package/dist/forge/broker.d.ts +51 -0
  53. package/dist/forge/broker.js +244 -0
  54. package/dist/forge/budget.d.ts +27 -0
  55. package/dist/forge/budget.js +60 -0
  56. package/dist/forge/catalog-view.d.ts +24 -0
  57. package/dist/forge/catalog-view.js +43 -0
  58. package/dist/forge/errors.d.ts +26 -0
  59. package/dist/forge/errors.js +49 -0
  60. package/dist/forge/guides/javascript.d.ts +25 -0
  61. package/dist/forge/guides/javascript.js +90 -0
  62. package/dist/forge/guides/provider.d.ts +42 -0
  63. package/dist/forge/guides/provider.js +96 -0
  64. package/dist/forge/guides/python-environment.d.ts +15 -0
  65. package/dist/forge/guides/python-environment.js +81 -0
  66. package/dist/forge/guides/python.d.ts +38 -0
  67. package/dist/forge/guides/python.js +170 -0
  68. package/dist/forge/javascript/engine.d.ts +44 -0
  69. package/dist/forge/javascript/engine.js +229 -0
  70. package/dist/forge/javascript/hosts/factory.d.ts +25 -0
  71. package/dist/forge/javascript/hosts/factory.js +21 -0
  72. package/dist/forge/javascript/hosts/framing.d.ts +17 -0
  73. package/dist/forge/javascript/hosts/framing.js +49 -0
  74. package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
  75. package/dist/forge/javascript/hosts/isolate-main.js +63 -0
  76. package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
  77. package/dist/forge/javascript/hosts/process-entry.js +3 -0
  78. package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
  79. package/dist/forge/javascript/hosts/process-host.js +160 -0
  80. package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
  81. package/dist/forge/javascript/hosts/worker-entry.js +3 -0
  82. package/dist/forge/javascript/hosts/worker-host.d.ts +27 -0
  83. package/dist/forge/javascript/hosts/worker-host.js +116 -0
  84. package/dist/forge/javascript/prelude.d.ts +2 -0
  85. package/dist/forge/javascript/prelude.js +333 -0
  86. package/dist/forge/javascript/probe.d.ts +2 -0
  87. package/dist/forge/javascript/probe.js +53 -0
  88. package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
  89. package/dist/forge/javascript/runtime-manifest.js +14 -0
  90. package/dist/forge/javascript/vm-runner.d.ts +21 -0
  91. package/dist/forge/javascript/vm-runner.js +301 -0
  92. package/dist/forge/javascript/wasm-asset.d.ts +8 -0
  93. package/dist/forge/javascript/wasm-asset.js +8 -0
  94. package/dist/forge/javascript/wasm.d.ts +15 -0
  95. package/dist/forge/javascript/wasm.js +54 -0
  96. package/dist/forge/journal.d.ts +105 -0
  97. package/dist/forge/journal.js +174 -0
  98. package/dist/forge/kernel.d.ts +76 -0
  99. package/dist/forge/kernel.js +339 -0
  100. package/dist/forge/limits.d.ts +38 -0
  101. package/dist/forge/limits.js +73 -0
  102. package/dist/forge/nested.d.ts +96 -0
  103. package/dist/forge/nested.js +214 -0
  104. package/dist/forge/ports.d.ts +120 -0
  105. package/dist/forge/ports.js +1 -0
  106. package/dist/forge/progress.d.ts +24 -0
  107. package/dist/forge/progress.js +76 -0
  108. package/dist/forge/protocol.d.ts +110 -0
  109. package/dist/forge/protocol.js +171 -0
  110. package/dist/forge/python/backend-selector.d.ts +38 -0
  111. package/dist/forge/python/backend-selector.js +111 -0
  112. package/dist/forge/python/backend.d.ts +89 -0
  113. package/dist/forge/python/backend.js +1 -0
  114. package/dist/forge/python/engine.d.ts +84 -0
  115. package/dist/forge/python/engine.js +279 -0
  116. package/dist/forge/python/frame-parser.d.ts +24 -0
  117. package/dist/forge/python/frame-parser.js +107 -0
  118. package/dist/forge/python/frame-transport.d.ts +40 -0
  119. package/dist/forge/python/frame-transport.js +101 -0
  120. package/dist/forge/python/import-scan.d.ts +22 -0
  121. package/dist/forge/python/import-scan.js +133 -0
  122. package/dist/forge/python/managed-backend.d.ts +30 -0
  123. package/dist/forge/python/managed-backend.js +111 -0
  124. package/dist/forge/python/oci-backend.d.ts +27 -0
  125. package/dist/forge/python/oci-backend.js +84 -0
  126. package/dist/forge/python/pyodide-backend.d.ts +40 -0
  127. package/dist/forge/python/pyodide-backend.js +289 -0
  128. package/dist/forge/python/pyodide-install.d.ts +41 -0
  129. package/dist/forge/python/pyodide-install.js +167 -0
  130. package/dist/forge/python/pyodide-manifest.d.ts +42 -0
  131. package/dist/forge/python/pyodide-manifest.js +376 -0
  132. package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
  133. package/dist/forge/python/pyodide-runner-source.js +3 -0
  134. package/dist/forge/python/rss.d.ts +2 -0
  135. package/dist/forge/python/rss.js +33 -0
  136. package/dist/forge/python/scratch-copy.d.ts +6 -0
  137. package/dist/forge/python/scratch-copy.js +61 -0
  138. package/dist/forge/python/settle.d.ts +6 -0
  139. package/dist/forge/python/settle.js +21 -0
  140. package/dist/forge/python/status.d.ts +8 -0
  141. package/dist/forge/python/status.js +15 -0
  142. package/dist/forge/python/wasm-node.d.ts +59 -0
  143. package/dist/forge/python/wasm-node.js +220 -0
  144. package/dist/forge/python/wiring.d.ts +39 -0
  145. package/dist/forge/python/wiring.js +47 -0
  146. package/dist/forge/registry.d.ts +26 -0
  147. package/dist/forge/registry.js +83 -0
  148. package/dist/forge/result.d.ts +58 -0
  149. package/dist/forge/result.js +86 -0
  150. package/dist/forge/routing-rule.d.ts +19 -0
  151. package/dist/forge/routing-rule.js +31 -0
  152. package/dist/forge/runtime.d.ts +64 -0
  153. package/dist/forge/runtime.js +88 -0
  154. package/dist/forge/scratch-files.d.ts +36 -0
  155. package/dist/forge/scratch-files.js +94 -0
  156. package/dist/forge/scratch-provider.d.ts +41 -0
  157. package/dist/forge/scratch-provider.js +79 -0
  158. package/dist/forge/scratch-quota.d.ts +8 -0
  159. package/dist/forge/scratch-quota.js +25 -0
  160. package/dist/forge/selector.d.ts +35 -0
  161. package/dist/forge/selector.js +51 -0
  162. package/dist/forge/session-dir.d.ts +59 -0
  163. package/dist/forge/session-dir.js +239 -0
  164. package/dist/forge/session-path.d.ts +31 -0
  165. package/dist/forge/session-path.js +61 -0
  166. package/dist/forge/slots.d.ts +16 -0
  167. package/dist/forge/slots.js +47 -0
  168. package/dist/forge/state-delta.d.ts +54 -0
  169. package/dist/forge/state-delta.js +99 -0
  170. package/dist/forge/state-machine.d.ts +20 -0
  171. package/dist/forge/state-machine.js +40 -0
  172. package/dist/forge/state-store.d.ts +25 -0
  173. package/dist/forge/state-store.js +50 -0
  174. package/dist/forge/task-log.d.ts +9 -0
  175. package/dist/forge/task-log.js +42 -0
  176. package/dist/forge/verdict.d.ts +20 -0
  177. package/dist/forge/verdict.js +28 -0
  178. package/dist/forge-isolate.d.ts +6 -0
  179. package/dist/forge-isolate.js +6 -0
  180. package/dist/index.d.ts +23 -1
  181. package/dist/index.js +23 -1
  182. package/dist/permissions/decide.d.ts +42 -0
  183. package/dist/permissions/decide.js +24 -0
  184. package/dist/plan/exit-plan.js +2 -1
  185. package/dist/resources/context.d.ts +2 -1
  186. package/dist/resources/context.js +2 -1
  187. package/dist/runtime/access.d.ts +34 -0
  188. package/dist/runtime/access.js +80 -0
  189. package/dist/runtime/ids.d.ts +21 -0
  190. package/dist/runtime/ids.js +41 -1
  191. package/dist/runtime/process.d.ts +2 -1
  192. package/dist/runtime/process.js +5 -5
  193. package/dist/runtime/store.d.ts +11 -0
  194. package/dist/runtime/store.js +106 -2
  195. package/dist/tools/analysis.d.ts +25 -2
  196. package/dist/tools/analysis.js +315 -267
  197. package/dist/tools/execute.js +6 -7
  198. package/dist/tools/forge-guide.d.ts +6 -0
  199. package/dist/tools/forge-guide.js +60 -0
  200. package/dist/tools/js-run.d.ts +19 -0
  201. package/dist/tools/js-run.js +126 -0
  202. package/dist/tools/standard.d.ts +7 -1
  203. package/dist/tools/standard.js +44 -43
  204. package/package.json +7 -2
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { DEFAULT_DECISIONS_CONFIG } from "./decisions/service.js";
6
+ import { DEFAULT_FORGE_LIMITS, MAX_FORGE_LIMITS } from "./forge/limits.js";
6
7
  import { exists, readJson } from "./runtime/fs.js";
7
8
  import { isPathSpec } from "./runtime/modules.js";
8
9
  const providerSchema = z
@@ -82,7 +83,42 @@ const serversSchema = (value) => z.record(z.string(), value).superRefine((server
82
83
  */
83
84
  export const OCI_IMAGE_PATTERN = /^[a-zA-Z0-9][^\s@]*@sha256:[a-f0-9]{64}$/;
84
85
  /** Analysis keys only the user (global) layer decides; a project layer cannot set them. */
85
- const GLOBAL_ONLY_ANALYSIS = ["runtime", "oci", "retention"];
86
+ const GLOBAL_ONLY_ANALYSIS = ["runtime", "isolation", "oci", "wasm", "retention"];
87
+ /**
88
+ * Forja keys only the user (global) layer decides (spec §10). A project may still lower
89
+ * `timeoutMs`, `maxToolCalls`, `maxConcurrency` and `maxOutputChars` and switch a feature off.
90
+ */
91
+ const GLOBAL_ONLY_FORGE = [
92
+ "javascript.host",
93
+ "javascript.memoryBytes",
94
+ "javascript.stackBytes",
95
+ "state.maxTotalBytes",
96
+ "scratch",
97
+ ];
98
+ /** The limits a project layer may lower: the same list `mergeLimits` honours. */
99
+ const FORGE_PROJECT_LOWERABLE = [
100
+ "timeoutMs",
101
+ "maxToolCalls",
102
+ "maxConcurrency",
103
+ "maxOutputChars",
104
+ ];
105
+ /** A Forja limit: a positive integer up to its hard global maximum, with the spec default. */
106
+ const forgeLimit = (key) => z.number().int().min(1).max(MAX_FORGE_LIMITS[key]).default(DEFAULT_FORGE_LIMITS[key]);
107
+ const FORGE_DEFAULTS = {
108
+ javascript: {
109
+ enabled: true,
110
+ host: "auto",
111
+ timeoutMs: DEFAULT_FORGE_LIMITS.timeoutMs,
112
+ memoryBytes: DEFAULT_FORGE_LIMITS.memoryBytes,
113
+ stackBytes: DEFAULT_FORGE_LIMITS.stackBytes,
114
+ maxToolCalls: DEFAULT_FORGE_LIMITS.maxToolCalls,
115
+ maxConcurrency: DEFAULT_FORGE_LIMITS.maxConcurrency,
116
+ maxOutputChars: DEFAULT_FORGE_LIMITS.maxOutputChars,
117
+ },
118
+ state: { enabled: true, maxTotalBytes: 512 * 1024 },
119
+ scratch: { enabled: true, root: null, retentionDays: 7, maxBytes: 256 * 1024 * 1024 },
120
+ guides: { enabled: true },
121
+ };
86
122
  /** Decision Intelligence keys only the user (global) layer decides: the provider and telemetry. */
87
123
  const GLOBAL_ONLY_DECISIONS = ["provider", "telemetry"];
88
124
  /** Web UI keys only the user (global) layer decides: the server picks which theme it serves. */
@@ -289,11 +325,43 @@ const configObjectSchema = z
289
325
  */
290
326
  smartDashboard: z.boolean().default(true),
291
327
  /**
292
- * Where `python_run` executes. `managed` is the discovered Python (not a sandbox); `oci`
293
- * is a Docker or Podman container started from a pinned image. GLOBAL ONLY: a project
328
+ * Where `python_run` executes. `auto` (default) picks the most isolated backend that is
329
+ * ready: `oci`, then `wasm`, then `managed` (the last only under `isolation: preferred`).
330
+ * `managed` is the discovered Python (not a sandbox); `oci` is a Docker or Podman
331
+ * container started from a pinned image; `wasm` is Pyodide in a restricted Node child
332
+ * process. An explicit value is honored or fails, never replaced. GLOBAL ONLY: a project
294
333
  * layer cannot choose the runtime or the image (it is ignored with a diagnostic).
295
334
  */
296
- runtime: z.enum(["managed", "oci"]).default("managed"),
335
+ runtime: z.enum(["auto", "managed", "oci", "wasm"]).default("auto"),
336
+ /**
337
+ * What `auto` may use: `preferred` falls back to the host when no isolated backend is
338
+ * ready, `required` never runs on the host, `strict` admits only backends whose network
339
+ * the OS denies (global only).
340
+ */
341
+ isolation: z.enum(["preferred", "required", "strict"]).default("preferred"),
342
+ /** Pyodide backend settings (global only). Read by `wasm`, and by `auto` when it is ready. */
343
+ wasm: z
344
+ .object({
345
+ /** Soft limit: the parent samples the child's RSS and kills it above this. */
346
+ memoryMb: z.number().int().min(256).max(65_536).default(3072),
347
+ /** Margin for loading Pyodide, added to the run's own deadline and reported apart. */
348
+ startupMs: z.number().int().min(1000).max(300_000).default(30_000),
349
+ /** Inputs above this many bytes keep `auto` away from wasm. */
350
+ maxInputBytes: z
351
+ .number()
352
+ .int()
353
+ .positive()
354
+ .default(100 * 1024 * 1024),
355
+ /** A `node` >= 22.22 / 24.13 / 25.3 for the child (needed under Bun; the running Node otherwise). */
356
+ nodePath: z.string().min(1).nullable().default(null),
357
+ })
358
+ .strict()
359
+ .default(() => ({
360
+ memoryMb: 3072,
361
+ startupMs: 30_000,
362
+ maxInputBytes: 100 * 1024 * 1024,
363
+ nodePath: null,
364
+ })),
297
365
  /** Container runtime settings (global only). Used when `runtime` is `oci`. */
298
366
  oci: z
299
367
  .object({
@@ -383,7 +451,14 @@ const configObjectSchema = z
383
451
  .default(() => ({
384
452
  enabled: true,
385
453
  smartDashboard: true,
386
- runtime: "managed",
454
+ runtime: "auto",
455
+ isolation: "preferred",
456
+ wasm: {
457
+ memoryMb: 3072,
458
+ startupMs: 30_000,
459
+ maxInputBytes: 100 * 1024 * 1024,
460
+ nodePath: null,
461
+ },
387
462
  oci: { engine: "docker", memoryMb: 2048, cpus: 2 },
388
463
  retention: { jobsDays: 30, intermediateDays: 7, artifactsDays: 0 },
389
464
  limits: {
@@ -505,6 +580,58 @@ const configObjectSchema = z
505
580
  })
506
581
  .strict()
507
582
  .default(() => ({ active: "build" })),
583
+ /**
584
+ * Forja: isolated code execution (spec §10). `javascript.enabled` is `true` since the
585
+ * JavaScript engine passed its gate (Phase 3b): `js_run` replaces `execute`; `false` brings
586
+ * `execute` and the previous system prompt back. `javascript.host`, `memoryBytes`, `stackBytes`,
587
+ * `state.maxTotalBytes` and `scratch.*` are GLOBAL ONLY; a project may only lower
588
+ * `timeoutMs`, `maxToolCalls`, `maxConcurrency` and `maxOutputChars` and switch a feature off.
589
+ */
590
+ forge: z
591
+ .object({
592
+ javascript: z
593
+ .object({
594
+ enabled: z.boolean().default(FORGE_DEFAULTS.javascript.enabled),
595
+ host: z.enum(["auto", "worker", "process"]).default("auto"),
596
+ timeoutMs: forgeLimit("timeoutMs"),
597
+ memoryBytes: forgeLimit("memoryBytes"),
598
+ stackBytes: forgeLimit("stackBytes"),
599
+ maxToolCalls: forgeLimit("maxToolCalls"),
600
+ maxConcurrency: forgeLimit("maxConcurrency"),
601
+ maxOutputChars: forgeLimit("maxOutputChars"),
602
+ })
603
+ .strict()
604
+ .default(() => ({ ...FORGE_DEFAULTS.javascript })),
605
+ state: z
606
+ .object({
607
+ enabled: z.boolean().default(true),
608
+ maxTotalBytes: z
609
+ .number()
610
+ .int()
611
+ .min(1)
612
+ .max(8 * 1024 * 1024)
613
+ .default(FORGE_DEFAULTS.state.maxTotalBytes),
614
+ })
615
+ .strict()
616
+ .default(() => ({ ...FORGE_DEFAULTS.state })),
617
+ scratch: z
618
+ .object({
619
+ enabled: z.boolean().default(true),
620
+ /** Base of the volatile session directories; `ALISIO_TMPDIR` wins over it. */
621
+ root: z.string().min(1).nullable().default(null),
622
+ /** Days without activity before a session folder goes; `0` disables the cleanup. */
623
+ retentionDays: z.number().int().min(0).max(3650).default(7),
624
+ maxBytes: z.number().int().min(1).default(FORGE_DEFAULTS.scratch.maxBytes),
625
+ })
626
+ .strict()
627
+ .default(() => ({ ...FORGE_DEFAULTS.scratch })),
628
+ guides: z
629
+ .object({ enabled: z.boolean().default(true) })
630
+ .strict()
631
+ .default(() => ({ ...FORGE_DEFAULTS.guides })),
632
+ })
633
+ .strict()
634
+ .default(() => structuredClone(FORGE_DEFAULTS)),
508
635
  /**
509
636
  * Web UI preferences. `iconTheme` names the active `icon-theme` extension point provider
510
637
  * (`"none"` keeps the built-in inline icons). GLOBAL ONLY: the server decides which theme its
@@ -572,6 +699,44 @@ async function canonicalDirectory(path) {
572
699
  export function overridesSavedProviderProfile(provenance, hasEndpointOverride) {
573
700
  return hasEndpointOverride || !!provenance.selectedLayer?.hasLegacyProvider;
574
701
  }
702
+ /** Whether a dotted path (`javascript.host`) is written in a raw `forge` block. */
703
+ function forgeHas(raw, path) {
704
+ let current = raw;
705
+ for (const part of path.split(".")) {
706
+ if (!current || typeof current !== "object" || !(part in current))
707
+ return false;
708
+ current = current[part];
709
+ }
710
+ return true;
711
+ }
712
+ /**
713
+ * The project layer over the global one for `forge`: global-only keys always come from the global
714
+ * layer, a lowerable limit takes the smaller value when the project wrote it, and a feature switch
715
+ * the project wrote can only turn the feature off (a defaulted value never changes the global one).
716
+ */
717
+ function overlayForge(global, project, raw) {
718
+ const javascript = { ...global.javascript };
719
+ for (const key of FORGE_PROJECT_LOWERABLE)
720
+ if (forgeHas(raw, `javascript.${key}`))
721
+ javascript[key] = Math.min(global.javascript[key], project.javascript[key]);
722
+ if (forgeHas(raw, "javascript.enabled"))
723
+ javascript.enabled = global.javascript.enabled && project.javascript.enabled;
724
+ return {
725
+ javascript,
726
+ state: {
727
+ ...global.state,
728
+ enabled: forgeHas(raw, "state.enabled")
729
+ ? global.state.enabled && project.state.enabled
730
+ : global.state.enabled,
731
+ },
732
+ scratch: global.scratch,
733
+ guides: {
734
+ enabled: forgeHas(raw, "guides.enabled")
735
+ ? global.guides.enabled && project.guides.enabled
736
+ : global.guides.enabled,
737
+ },
738
+ };
739
+ }
575
740
  export const configHome = () => process.env.ALISIO_CONFIG_HOME ??
576
741
  join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "alisio");
577
742
  export const stateHome = () => process.env.ALISIO_STATE_HOME ??
@@ -625,6 +790,9 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
625
790
  const analysisRaw = object.analysis && typeof object.analysis === "object" && !Array.isArray(object.analysis)
626
791
  ? object.analysis
627
792
  : {};
793
+ const forgeRaw = object.forge && typeof object.forge === "object" && !Array.isArray(object.forge)
794
+ ? object.forge
795
+ : {};
628
796
  const tasksRaw = object.tasks && typeof object.tasks === "object" && !Array.isArray(object.tasks)
629
797
  ? object.tasks
630
798
  : {};
@@ -641,6 +809,7 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
641
809
  : {};
642
810
  const ignored = [
643
811
  ...GLOBAL_ONLY_ANALYSIS.filter((key) => key in analysisRaw).map((key) => `analysis.${key}`),
812
+ ...GLOBAL_ONLY_FORGE.filter((path) => forgeHas(forgeRaw, path)).map((path) => `forge.${path}`),
644
813
  ...GLOBAL_ONLY_DECISIONS.filter((key) => key in decisionsRaw).map((key) => `decisions.${key}`),
645
814
  ...GLOBAL_ONLY_WEB.filter((key) => key in webRaw).map((key) => `web.${key}`),
646
815
  ...Object.entries(overridesRaw)
@@ -653,6 +822,8 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
653
822
  keys: new Set(Object.keys(object)),
654
823
  /** Raw keys of the layer's `analysis` block (a defaulted key is not an expressed one). */
655
824
  analysisKeys: new Set(Object.keys(analysisRaw)),
825
+ /** Raw `forge` block of the layer, to tell a written key from a defaulted one. */
826
+ forgeRaw,
656
827
  sources,
657
828
  ignored,
658
829
  file,
@@ -723,11 +894,13 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
723
894
  continue;
724
895
  }
725
896
  if (key === "analysis") {
726
- // `runtime`, `oci` and `retention` are global-only (a repository must not pick the binary
897
+ // `runtime`, `isolation`, `oci`, `wasm` and `retention` are global-only (a repository must not pick the binary
727
898
  // that runs scripts, nor shorten the retention of every workspace's data).
728
899
  overlaid.analysis = {
729
900
  ...selected.config.analysis,
730
901
  runtime: global.config.analysis.runtime,
902
+ isolation: global.config.analysis.isolation,
903
+ wasm: global.config.analysis.wasm,
731
904
  oci: global.config.analysis.oci,
732
905
  retention: global.config.analysis.retention,
733
906
  // A defaulted `true` must not undo the global `false`: only a key the layer wrote wins.
@@ -737,6 +910,10 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
737
910
  };
738
911
  continue;
739
912
  }
913
+ if (key === "forge") {
914
+ overlaid.forge = overlayForge(global.config.forge, selected.config.forge, selected.forgeRaw);
915
+ continue;
916
+ }
740
917
  if (key === "decisions") {
741
918
  // `provider` and `telemetry` are global-only (a repository cannot pick the decision
742
919
  // engine nor its telemetry policy); the rest follows the selected layer.
@@ -912,7 +1089,10 @@ const SETTABLE_SECTIONS = {
912
1089
  goal: configObjectSchema.shape.goal.removeDefault(),
913
1090
  plan: configObjectSchema.shape.plan.removeDefault(),
914
1091
  web: configObjectSchema.shape.web.removeDefault(),
1092
+ forge: configObjectSchema.shape.forge.removeDefault(),
915
1093
  };
1094
+ const FORGE_JAVASCRIPT = SETTABLE_SECTIONS.forge.shape.javascript.removeDefault();
1095
+ const FORGE_GUIDES = SETTABLE_SECTIONS.forge.shape.guides.removeDefault();
916
1096
  const ANALYSIS_LIMITS = SETTABLE_SECTIONS.analysis.shape.limits.removeDefault();
917
1097
  const ANALYSIS_RETENTION = SETTABLE_SECTIONS.analysis.shape.retention.removeDefault();
918
1098
  const SETTABLE_KEYS = {
@@ -962,6 +1142,8 @@ const SETTABLE_KEYS = {
962
1142
  "plan.diagrams": SETTABLE_SECTIONS.plan.shape.diagrams,
963
1143
  "plan.maxDiagrams": SETTABLE_SECTIONS.plan.shape.maxDiagrams,
964
1144
  "web.iconTheme": SETTABLE_SECTIONS.web.shape.iconTheme,
1145
+ "forge.javascript.enabled": FORGE_JAVASCRIPT.shape.enabled,
1146
+ "forge.guides.enabled": FORGE_GUIDES.shape.enabled,
965
1147
  };
966
1148
  export function isSettableSettingKey(key) {
967
1149
  return Object.prototype.hasOwnProperty.call(SETTABLE_KEYS, key);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * What only the core's own tools may do from inside a call (spec D-23, D-24): flag the running
3
+ * call as `effectful` and reach the run's nested-call dispatcher. It is NOT part of the SDK
4
+ * `ToolContext`: the runner binds it to the context object of a call in a module-private
5
+ * `WeakMap`, so a plugin tool, which sees only the SDK type, has no property to find and no module
6
+ * to import.
7
+ */
8
+ import type { ToolContext } from "@alisio/sdk";
9
+ import type { NestedCallDispatcher } from "../forge/nested.ts";
10
+ export interface CallInternals {
11
+ /**
12
+ * Flags the running call: it may have left effects, so a cancellation must leave it pending for
13
+ * recovery even though its own effect is `read`. Idempotent.
14
+ */
15
+ markEffectful(): void;
16
+ /** The dispatcher for tool calls made from inside this call (built on first use). */
17
+ nested(): NestedCallDispatcher;
18
+ }
19
+ /** The runner binds the internals of a call to the context it hands the tool. */
20
+ export declare function bindCallInternals(context: ToolContext, internals: CallInternals): void;
21
+ /** The internals of the call this context belongs to; undefined for any other context. */
22
+ export declare function callInternals(context: ToolContext): CallInternals | undefined;
@@ -0,0 +1,9 @@
1
+ const bound = new WeakMap();
2
+ /** The runner binds the internals of a call to the context it hands the tool. */
3
+ export function bindCallInternals(context, internals) {
4
+ bound.set(context, internals);
5
+ }
6
+ /** The internals of the call this context belongs to; undefined for any other context. */
7
+ export function callInternals(context) {
8
+ return bound.get(context);
9
+ }
@@ -209,7 +209,7 @@ export interface ApprovalRequest {
209
209
  /** Capability approvals: the first 40 lines of the script. */
210
210
  preview?: string;
211
211
  /** Capability approvals: how the code runs (`managed` is not sandboxed). */
212
- runtime?: "managed" | "oci";
212
+ runtime?: "auto" | "managed" | "oci" | "wasm";
213
213
  /** `analysis.install`: the packages, the size estimate and the network need (once or deny). */
214
214
  install?: InstallPreview;
215
215
  }
@@ -233,7 +233,7 @@ export interface CapabilityGate {
233
233
  /** Recorded as the source of interactive decisions (`tui` or `web`). */
234
234
  source?: "tui" | "web";
235
235
  /** Runtime mode shown with `analysis.run` approvals (read live: the configuration can change). */
236
- runtime?: "managed" | "oci";
236
+ runtime?: "auto" | "managed" | "oci" | "wasm";
237
237
  /**
238
238
  * The script to show in an approval whose input does not carry it (a rerun runs a saved script).
239
239
  * Absent or undefined: the preview is taken from `input.code`.
@@ -1,4 +1,8 @@
1
1
  import { isEphemeralRunEventType, textProjection, textResult, } from "@alisio/sdk";
2
+ import { createNestedDispatcher } from "../forge/nested.js";
3
+ import { hasScratchEstimator, scratchIncomingBytes } from "../forge/scratch-quota.js";
4
+ import { decideEffect, effectAskable } from "../permissions/decide.js";
5
+ import { bindCallInternals } from "./call-internals.js";
2
6
  import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
7
  import { HumanWaits } from "./human-wait.js";
4
8
  import { EXIT_PLAN_TOOL, optInAllows } from "./opt-in.js";
@@ -48,11 +52,6 @@ const MIN_MESSAGE_BUDGET_FLOOR = 4_000;
48
52
  * would pay for a summarizer call. The hard-limit last-chance compaction ignores it.
49
53
  */
50
54
  const RECOMPACT_GROWTH_MARGIN = 0.1;
51
- const allowed = (policy, effect) => effect === "read" ||
52
- effect === "internal" ||
53
- ((effect === "write" || effect === "process" || effect === "external") && !!policy[effect]);
54
- /** A tool's capability is pre-allowed by the policy alone (`--allow-analysis`). */
55
- const capabilityAllowed = (policy, tool) => tool.capability === "analysis.run" && !!policy.analysis;
56
55
  /** First 40 lines of a script input, for capability approvals. */
57
56
  const scriptPreview = (input) => typeof input.code === "string" ? input.code.split(/\r?\n/).slice(0, 40).join("\n") : undefined;
58
57
  /** A short id for a tool call the user started from a UI (alphanumeric, 9 characters). */
@@ -164,16 +163,13 @@ export class AgentRunner {
164
163
  return false;
165
164
  if (!optInAllows(t.name, run.optInTools))
166
165
  return false;
167
- const effect = t.effect ?? "external";
168
- if (allowed(policy, effect))
169
- return true;
170
- if (t.capability) {
171
- if (capabilityAllowed(policy, t))
172
- return true;
173
- if (sessionId && o.capabilities?.granted(t.capability, sessionId))
174
- return true;
175
- }
176
- return approvals && (effect === "write" || effect === "process" || effect === "external");
166
+ // Offered when the call would run or could be asked: anything but a denial.
167
+ return (decideEffect(policy, t, {
168
+ approvals,
169
+ capabilityGranted: !!(t.capability &&
170
+ sessionId &&
171
+ o.capabilities?.granted(t.capability, sessionId)),
172
+ }).kind !== "deny");
177
173
  });
178
174
  }
179
175
  /** Abort signal of the session's active run, if any (used to cascade cancellation). */
@@ -415,6 +411,11 @@ export class AgentRunner {
415
411
  const { call } = p;
416
412
  let result;
417
413
  const started = Date.now();
414
+ /** Set by the call itself (see `CallInternals`): it may have left effects. */
415
+ let effectful = false;
416
+ let dispatcher;
417
+ /** Held from the scratchpad quota check until the write is done (L1). */
418
+ let releaseScratch;
418
419
  // `effect` rides along so the TUI can group consecutive calls by capability kind
419
420
  // (read batches, write batches, commands…) without a name heuristic. Additive field.
420
421
  emit("tool_started", {
@@ -436,6 +437,7 @@ export class AgentRunner {
436
437
  // resolved map keeps "allow once" scoped to this one call, with no shared mutable
437
438
  // state that parallel read batches could race on.
438
439
  const resolved = new Map();
440
+ const targets = [];
439
441
  if (pathAccess)
440
442
  for (const declared of p.tool.paths?.(p.input) ?? [])
441
443
  if (!resolved.has(declared)) {
@@ -450,6 +452,7 @@ export class AgentRunner {
450
452
  // ask a second time for an "allow once" decision.
451
453
  resolved.set(declared, target);
452
454
  resolved.set(target, target);
455
+ targets.push(target);
453
456
  }
454
457
  scope.guard?.(effect);
455
458
  if ((options.toolFilter && !options.toolFilter(p.tool)) ||
@@ -457,26 +460,62 @@ export class AgentRunner {
457
460
  throw new Error(`Tool ${call.name} is not available in this session`);
458
461
  const capability = p.tool.capability;
459
462
  const gate = o.capabilities;
460
- if (capability && (allowed(policy, effect) || capabilityAllowed(policy, p.tool))) {
461
- // A broad process grant or --allow-analysis: allowed, audited as a flag decision.
462
- gate?.record({
463
- capability,
464
- sessionId,
465
- scope: "once",
466
- decision: "allow",
467
- source: "flag",
468
- callId: call.id,
469
- runId,
470
- ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
471
- });
463
+ let decision = decideEffect(policy, p.tool, {
464
+ approvals: !!o.approve && approvals,
465
+ capabilityGranted: !!capability && !!gate?.granted(capability, sessionId),
466
+ });
467
+ // The session's scratchpad is an authorized write root (Forja, spec §9.4): a write confined
468
+ // to it needs no approval in any mode, headless runs included. What is lifted is a call that
469
+ // WOULD ask a person; a run locked down on purpose (`--read-only`, which has no scratchpad
470
+ // root, or a child run with approvals turned off) keeps its denial.
471
+ if (decision.kind !== "allow" &&
472
+ effect === "write" &&
473
+ !capability &&
474
+ options.approvals !== false &&
475
+ hasScratchEstimator(p.tool.name) &&
476
+ pathAccess &&
477
+ targets.length > 0 &&
478
+ decideEffect(policy, p.tool, { approvals: true, capabilityGranted: false }).kind ===
479
+ "ask" &&
480
+ (await Promise.all(targets.map((t) => pathAccess.isScratchPath(t, sessionId)))).every(Boolean))
481
+ decision = { kind: "allow", via: "effect" };
482
+ // A write into the scratchpad also has a quota (FJ-AC-34), whatever allowed it: a full
483
+ // scratchpad refuses new writes with a plain error and deletes nothing.
484
+ if (decision.kind === "allow" &&
485
+ effect === "write" &&
486
+ !capability &&
487
+ pathAccess &&
488
+ targets.length > 0 &&
489
+ (await Promise.all(targets.map((t) => pathAccess.isScratchPath(t, sessionId)))).every(Boolean)) {
490
+ releaseScratch = await pathAccess.lockScratch(sessionId);
491
+ const problem = await pathAccess.scratchQuotaProblem(sessionId, await scratchIncomingBytes(p.tool.name, p.input, targets), targets);
492
+ if (problem)
493
+ throw new Error(problem);
494
+ }
495
+ if (decision.kind === "allow") {
496
+ // A broad process grant or --allow-analysis is audited as a flag decision; a plain effect
497
+ // and a persisted "allow for this session" grant of the root session leave no record here.
498
+ if (capability && decision.via === "flag")
499
+ gate?.record({
500
+ capability,
501
+ sessionId,
502
+ scope: "once",
503
+ decision: "allow",
504
+ source: "flag",
505
+ callId: call.id,
506
+ runId,
507
+ ...(options.correlationId !== undefined
508
+ ? { correlationId: options.correlationId }
509
+ : {}),
510
+ });
472
511
  }
473
- else if (capability && gate?.granted(capability, sessionId)) {
474
- // A persisted "allow for this session" grant of the root session.
512
+ else if (decision.kind === "deny") {
513
+ throw new Error(`Capability denied: ${capability ?? effect}`);
475
514
  }
476
- else if (!allowed(policy, effect)) {
477
- if (!o.approve ||
478
- !approvals ||
479
- (effect !== "write" && effect !== "process" && effect !== "external"))
515
+ else {
516
+ const approve = o.approve;
517
+ // `ask` is only ever decided for an askable effect with a handler (see `decideEffect`).
518
+ if (!approve || !effectAskable(effect))
480
519
  throw new Error(`Capability denied: ${capability ?? effect}`);
481
520
  const preview = capability
482
521
  ? (scriptPreview(p.input) ?? (await gate?.preview?.(p.input, sessionId)))
@@ -488,7 +527,6 @@ export class AgentRunner {
488
527
  ...(options.label ? { label: options.label } : {}),
489
528
  ...(capability ? { capability } : {}),
490
529
  });
491
- const approve = o.approve;
492
530
  const callInput = p.input;
493
531
  // Waiting for the person is not work: the run's time limit does not count it.
494
532
  const decision = await this.humanWaits.track(sessionId, () => approve({
@@ -593,7 +631,7 @@ export class AgentRunner {
593
631
  });
594
632
  return allowedNow ? "once" : "deny";
595
633
  };
596
- result = await p.tool.execute(p.input, {
634
+ const toolContext = {
597
635
  signal: combined,
598
636
  workspace,
599
637
  session: sessionId,
@@ -625,7 +663,35 @@ export class AgentRunner {
625
663
  },
626
664
  }
627
665
  : {}),
666
+ };
667
+ // Only the core's own tools can find these (see `CallInternals`); the SDK context is unchanged.
668
+ bindCallInternals(toolContext, {
669
+ markEffectful: () => {
670
+ effectful = true;
671
+ },
672
+ nested: () => (dispatcher ??= createNestedDispatcher({
673
+ registry: o.registry,
674
+ policy,
675
+ approvals: !!o.approve && approvals,
676
+ scratchWrites: options.approvals !== false,
677
+ ...(options.toolFilter ? { toolFilter: options.toolFilter } : {}),
678
+ ...(options.optInTools ? { optInTools: options.optInTools } : {}),
679
+ workspace,
680
+ session: sessionId,
681
+ ...(options.label ? { label: options.label } : {}),
682
+ runId,
683
+ parentCallId: call.id,
684
+ signal: combined,
685
+ ...(pathAccess ? { pathAccess } : {}),
686
+ ...(publisher ? { artifacts: publisher } : {}),
687
+ ...(decisions ? { decisions } : {}),
688
+ ...(scope.guard ? { guard: scope.guard } : {}),
689
+ markEffectful: () => {
690
+ effectful = true;
691
+ },
692
+ })),
628
693
  });
694
+ result = await p.tool.execute(p.input, toolContext);
629
695
  // The bound applies to what the model sees (the text projection): display-only
630
696
  // ui/image parts are bounded by their producers and must not push text over it.
631
697
  const projected = textProjection(result);
@@ -640,10 +706,14 @@ export class AgentRunner {
640
706
  // the user to inspect. A `read` call cannot have left any (and `exit_plan`, which is one,
641
707
  // is exactly what a stop or the run limit interrupts while a person decides): it is closed
642
708
  // with the cancellation, so the next message continues normally instead of asking to
643
- // "recover" a call nobody needs to inspect.
644
- if (!combined.aborted || p.tool?.effect === "read")
709
+ // "recover" a call nobody needs to inspect. A read that flagged itself `effectful` (js_run
710
+ // after a nested write) is the exception: it stays pending like any other effectful call.
711
+ if (!combined.aborted || (p.tool?.effect === "read" && !effectful))
645
712
  o.store.endCall(sessionId, call, result);
646
713
  }
714
+ finally {
715
+ releaseScratch?.();
716
+ }
647
717
  emit("tool_completed", {
648
718
  id: call.id,
649
719
  name: call.name,
@@ -1262,7 +1332,10 @@ export class AgentRunner {
1262
1332
  const execute = (p) => this.executeCall(scope, p);
1263
1333
  const results = [];
1264
1334
  // Bounded parallel batches for consecutive read or explicitly concurrent tools.
1265
- const parallel = (p) => !!p?.tool && (p.tool.effect === "read" || p.tool.concurrent === true);
1335
+ // `concurrent: false` keeps a tool out of the batch even when it is a read (js_run).
1336
+ const parallel = (p) => !!p?.tool &&
1337
+ p.tool.concurrent !== false &&
1338
+ (p.tool.effect === "read" || p.tool.concurrent === true);
1266
1339
  for (let i = 0; i < prepared.length;) {
1267
1340
  const current = prepared[i];
1268
1341
  if (!current)
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Canonical JSON of the reliability lab (specs/010 D10): equal values give equal text, so the same
3
+ * text can be hashed for the configuration hash, argument keys and cassette fingerprints.
4
+ *
5
+ * Rules: object keys sorted by UTF-16 code unit (the default string order), `undefined` members
6
+ * dropped (in arrays they become `null`, as `JSON.stringify` does), `null` kept, `-0` written as
7
+ * `0`, non-finite numbers rejected (JSON cannot carry them), string VALUES normalized to NFC
8
+ * (keys are left as written).
9
+ */
10
+ /** JSON text with the canonical rules above. */
11
+ export declare function canonicalJson(value: unknown): string;
12
+ export declare const sha256Hex: (data: string | Buffer) => string;
13
+ /** `sha256:<64 hex>` of the canonical JSON of `value`. */
14
+ export declare function hashCanonical(value: unknown): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Canonical JSON of the reliability lab (specs/010 D10): equal values give equal text, so the same
3
+ * text can be hashed for the configuration hash, argument keys and cassette fingerprints.
4
+ *
5
+ * Rules: object keys sorted by UTF-16 code unit (the default string order), `undefined` members
6
+ * dropped (in arrays they become `null`, as `JSON.stringify` does), `null` kept, `-0` written as
7
+ * `0`, non-finite numbers rejected (JSON cannot carry them), string VALUES normalized to NFC
8
+ * (keys are left as written).
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ function normalize(value) {
12
+ if (typeof value === "string")
13
+ return value.normalize("NFC");
14
+ if (typeof value === "number") {
15
+ if (!Number.isFinite(value))
16
+ throw new TypeError(`Cannot canonicalize a non-finite number (${value}).`);
17
+ return value === 0 ? 0 : value;
18
+ }
19
+ if (Array.isArray(value))
20
+ return value.map((item) => (item === undefined ? null : normalize(item)));
21
+ if (value && typeof value === "object") {
22
+ const out = {};
23
+ for (const key of Object.keys(value).sort()) {
24
+ const member = value[key];
25
+ if (member !== undefined)
26
+ out[key] = normalize(member);
27
+ }
28
+ return out;
29
+ }
30
+ return value;
31
+ }
32
+ /** JSON text with the canonical rules above. */
33
+ export function canonicalJson(value) {
34
+ return JSON.stringify(normalize(value));
35
+ }
36
+ export const sha256Hex = (data) => createHash("sha256").update(data).digest("hex");
37
+ /** `sha256:<64 hex>` of the canonical JSON of `value`. */
38
+ export function hashCanonical(value) {
39
+ return `sha256:${sha256Hex(canonicalJson(value))}`;
40
+ }