@vendoai/apps 0.4.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 (203) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +8 -0
  3. package/dist/adapter-conformance.d.ts +48 -0
  4. package/dist/adapter-conformance.d.ts.map +1 -0
  5. package/dist/adapter-conformance.js +196 -0
  6. package/dist/adapter-conformance.js.map +1 -0
  7. package/dist/agent-tools.d.ts +10 -0
  8. package/dist/agent-tools.d.ts.map +1 -0
  9. package/dist/agent-tools.js +252 -0
  10. package/dist/agent-tools.js.map +1 -0
  11. package/dist/app-data.d.ts +23 -0
  12. package/dist/app-data.d.ts.map +1 -0
  13. package/dist/app-data.js +129 -0
  14. package/dist/app-data.js.map +1 -0
  15. package/dist/app-token.d.ts +24 -0
  16. package/dist/app-token.d.ts.map +1 -0
  17. package/dist/app-token.js +61 -0
  18. package/dist/app-token.js.map +1 -0
  19. package/dist/audit.d.ts +11 -0
  20. package/dist/audit.d.ts.map +1 -0
  21. package/dist/audit.js +19 -0
  22. package/dist/audit.js.map +1 -0
  23. package/dist/automation-plan.d.ts +43 -0
  24. package/dist/automation-plan.d.ts.map +1 -0
  25. package/dist/automation-plan.js +212 -0
  26. package/dist/automation-plan.js.map +1 -0
  27. package/dist/bench/client.d.ts +43 -0
  28. package/dist/bench/client.d.ts.map +1 -0
  29. package/dist/bench/client.js +129 -0
  30. package/dist/bench/client.js.map +1 -0
  31. package/dist/bench/fixtures.d.ts +42 -0
  32. package/dist/bench/fixtures.d.ts.map +1 -0
  33. package/dist/bench/fixtures.js +146 -0
  34. package/dist/bench/fixtures.js.map +1 -0
  35. package/dist/bench/judge.d.ts +9 -0
  36. package/dist/bench/judge.d.ts.map +1 -0
  37. package/dist/bench/judge.js +71 -0
  38. package/dist/bench/judge.js.map +1 -0
  39. package/dist/bench/metrics.d.ts +32 -0
  40. package/dist/bench/metrics.d.ts.map +1 -0
  41. package/dist/bench/metrics.js +172 -0
  42. package/dist/bench/metrics.js.map +1 -0
  43. package/dist/bench/prompts.d.ts +17 -0
  44. package/dist/bench/prompts.d.ts.map +1 -0
  45. package/dist/bench/prompts.js +100 -0
  46. package/dist/bench/prompts.js.map +1 -0
  47. package/dist/bench/report.d.ts +43 -0
  48. package/dist/bench/report.d.ts.map +1 -0
  49. package/dist/bench/report.js +74 -0
  50. package/dist/bench/report.js.map +1 -0
  51. package/dist/bench/runner.d.ts +15 -0
  52. package/dist/bench/runner.d.ts.map +1 -0
  53. package/dist/bench/runner.js +26 -0
  54. package/dist/bench/runner.js.map +1 -0
  55. package/dist/bench/toolfork.d.ts +18 -0
  56. package/dist/bench/toolfork.d.ts.map +1 -0
  57. package/dist/bench/toolfork.js +125 -0
  58. package/dist/bench/toolfork.js.map +1 -0
  59. package/dist/box-agent.d.ts +78 -0
  60. package/dist/box-agent.d.ts.map +1 -0
  61. package/dist/box-agent.js +137 -0
  62. package/dist/box-agent.js.map +1 -0
  63. package/dist/box-env.d.ts +26 -0
  64. package/dist/box-env.d.ts.map +1 -0
  65. package/dist/box-env.js +52 -0
  66. package/dist/box-env.js.map +1 -0
  67. package/dist/call.d.ts +23 -0
  68. package/dist/call.d.ts.map +1 -0
  69. package/dist/call.js +45 -0
  70. package/dist/call.js.map +1 -0
  71. package/dist/cloud.d.ts +4917 -0
  72. package/dist/cloud.d.ts.map +1 -0
  73. package/dist/cloud.js +16 -0
  74. package/dist/cloud.js.map +1 -0
  75. package/dist/e2b/index.d.ts +30 -0
  76. package/dist/e2b/index.d.ts.map +1 -0
  77. package/dist/e2b/index.js +273 -0
  78. package/dist/e2b/index.js.map +1 -0
  79. package/dist/egress-approval.d.ts +73 -0
  80. package/dist/egress-approval.d.ts.map +1 -0
  81. package/dist/egress-approval.js +74 -0
  82. package/dist/egress-approval.js.map +1 -0
  83. package/dist/engine.d.ts +188 -0
  84. package/dist/engine.d.ts.map +1 -0
  85. package/dist/engine.js +1507 -0
  86. package/dist/engine.js.map +1 -0
  87. package/dist/fn.d.ts +22 -0
  88. package/dist/fn.d.ts.map +1 -0
  89. package/dist/fn.js +108 -0
  90. package/dist/fn.js.map +1 -0
  91. package/dist/history.d.ts +21 -0
  92. package/dist/history.d.ts.map +1 -0
  93. package/dist/history.js +158 -0
  94. package/dist/history.js.map +1 -0
  95. package/dist/inclient.d.ts +48 -0
  96. package/dist/inclient.d.ts.map +1 -0
  97. package/dist/inclient.js +68 -0
  98. package/dist/inclient.js.map +1 -0
  99. package/dist/index.d.ts +24 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +25 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/interchange.d.ts +17 -0
  104. package/dist/interchange.d.ts.map +1 -0
  105. package/dist/interchange.js +133 -0
  106. package/dist/interchange.js.map +1 -0
  107. package/dist/machine-lifecycle.d.ts +102 -0
  108. package/dist/machine-lifecycle.d.ts.map +1 -0
  109. package/dist/machine-lifecycle.js +360 -0
  110. package/dist/machine-lifecycle.js.map +1 -0
  111. package/dist/manifest.d.ts +42 -0
  112. package/dist/manifest.d.ts.map +1 -0
  113. package/dist/manifest.js +53 -0
  114. package/dist/manifest.js.map +1 -0
  115. package/dist/open.d.ts +45 -0
  116. package/dist/open.d.ts.map +1 -0
  117. package/dist/open.js +245 -0
  118. package/dist/open.js.map +1 -0
  119. package/dist/parked-action.d.ts +51 -0
  120. package/dist/parked-action.d.ts.map +1 -0
  121. package/dist/parked-action.js +29 -0
  122. package/dist/parked-action.js.map +1 -0
  123. package/dist/persistence.d.ts +41 -0
  124. package/dist/persistence.d.ts.map +1 -0
  125. package/dist/persistence.js +86 -0
  126. package/dist/persistence.js.map +1 -0
  127. package/dist/pins.d.ts +231 -0
  128. package/dist/pins.d.ts.map +1 -0
  129. package/dist/pins.js +213 -0
  130. package/dist/pins.js.map +1 -0
  131. package/dist/pipeline.d.ts +142 -0
  132. package/dist/pipeline.d.ts.map +1 -0
  133. package/dist/pipeline.js +1014 -0
  134. package/dist/pipeline.js.map +1 -0
  135. package/dist/prewired-schema.d.ts +16 -0
  136. package/dist/prewired-schema.d.ts.map +1 -0
  137. package/dist/prewired-schema.js +48 -0
  138. package/dist/prewired-schema.js.map +1 -0
  139. package/dist/redaction.d.ts +13 -0
  140. package/dist/redaction.d.ts.map +1 -0
  141. package/dist/redaction.js +67 -0
  142. package/dist/redaction.js.map +1 -0
  143. package/dist/runtime.d.ts +463 -0
  144. package/dist/runtime.d.ts.map +1 -0
  145. package/dist/runtime.js +1715 -0
  146. package/dist/runtime.js.map +1 -0
  147. package/dist/sandbox.d.ts +90 -0
  148. package/dist/sandbox.d.ts.map +1 -0
  149. package/dist/sandbox.js +2 -0
  150. package/dist/sandbox.js.map +1 -0
  151. package/dist/schedules.d.ts +89 -0
  152. package/dist/schedules.d.ts.map +1 -0
  153. package/dist/schedules.js +334 -0
  154. package/dist/schedules.js.map +1 -0
  155. package/dist/secret-exposure.d.ts +55 -0
  156. package/dist/secret-exposure.d.ts.map +1 -0
  157. package/dist/secret-exposure.js +53 -0
  158. package/dist/secret-exposure.js.map +1 -0
  159. package/dist/ship-diff.d.ts +47 -0
  160. package/dist/ship-diff.d.ts.map +1 -0
  161. package/dist/ship-diff.js +39 -0
  162. package/dist/ship-diff.js.map +1 -0
  163. package/dist/testing/fake-box.d.ts +67 -0
  164. package/dist/testing/fake-box.d.ts.map +1 -0
  165. package/dist/testing/fake-box.js +166 -0
  166. package/dist/testing/fake-box.js.map +1 -0
  167. package/dist/testing/fake-sandbox-v2.d.ts +59 -0
  168. package/dist/testing/fake-sandbox-v2.d.ts.map +1 -0
  169. package/dist/testing/fake-sandbox-v2.js +121 -0
  170. package/dist/testing/fake-sandbox-v2.js.map +1 -0
  171. package/dist/testing/fake-sandbox.d.ts +94 -0
  172. package/dist/testing/fake-sandbox.d.ts.map +1 -0
  173. package/dist/testing/fake-sandbox.js +239 -0
  174. package/dist/testing/fake-sandbox.js.map +1 -0
  175. package/dist/testing/guard-fixture.d.ts +19 -0
  176. package/dist/testing/guard-fixture.d.ts.map +1 -0
  177. package/dist/testing/guard-fixture.js +134 -0
  178. package/dist/testing/guard-fixture.js.map +1 -0
  179. package/dist/testing/index.d.ts +8 -0
  180. package/dist/testing/index.d.ts.map +1 -0
  181. package/dist/testing/index.js +8 -0
  182. package/dist/testing/index.js.map +1 -0
  183. package/dist/testing/memory-store.d.ts +5 -0
  184. package/dist/testing/memory-store.d.ts.map +1 -0
  185. package/dist/testing/memory-store.js +4 -0
  186. package/dist/testing/memory-store.js.map +1 -0
  187. package/dist/testing/scripted-model.d.ts +32 -0
  188. package/dist/testing/scripted-model.d.ts.map +1 -0
  189. package/dist/testing/scripted-model.js +112 -0
  190. package/dist/testing/scripted-model.js.map +1 -0
  191. package/dist/testing/seed-app-row.d.ts +4 -0
  192. package/dist/testing/seed-app-row.d.ts.map +1 -0
  193. package/dist/testing/seed-app-row.js +7 -0
  194. package/dist/testing/seed-app-row.js.map +1 -0
  195. package/dist/unified-diff.d.ts +13 -0
  196. package/dist/unified-diff.d.ts.map +1 -0
  197. package/dist/unified-diff.js +127 -0
  198. package/dist/unified-diff.js.map +1 -0
  199. package/dist/version-hash.d.ts +9 -0
  200. package/dist/version-hash.d.ts.map +1 -0
  201. package/dist/version-hash.js +12 -0
  202. package/dist/version-hash.js.map +1 -0
  203. package/package.json +84 -0
@@ -0,0 +1,1715 @@
1
+ import { VENDO_TREE_FORMAT_V2, VendoError, checkBindingShapes, deriveShapeCard, validateAppDocument, } from "@vendoai/core";
2
+ import { createAgentTools } from "./agent-tools.js";
3
+ import { createAppData } from "./app-data.js";
4
+ import { appLifecycleEvent } from "./audit.js";
5
+ import { createAppCaller } from "./call.js";
6
+ import { createParkedActions } from "./parked-action.js";
7
+ import { applyPinFork, distinctIssues, instructionRequiresServedApp, modelEngine, prewarmModels, serverWorkRung, } from "./engine.js";
8
+ import { planAutomation } from "./automation-plan.js";
9
+ import { createAppHistory } from "./history.js";
10
+ import { createInClientApprovals } from "./inclient.js";
11
+ import { createAppInterchange } from "./interchange.js";
12
+ import { createMachineLifecycle, } from "./machine-lifecycle.js";
13
+ import { createFnCaller } from "./fn.js";
14
+ import { pushBoxEnv, readBoxManifest, requestAppWithBootRetry, runBoxEdit, } from "./box-agent.js";
15
+ import { parseVendoManifest } from "./manifest.js";
16
+ import { createAppOpener, createProgressiveQueryResolver, machinesDisabledError, servedAppsDisabledError, stripServerAuthoritativeFields } from "./open.js";
17
+ import { appRecordInput, documentFromRecord, enabledAfterDocumentEdit, listAllRecords, nextEnvStaleAt, rowFromRecord, updateAppRow } from "./persistence.js";
18
+ import { detectPinDrift, hasDefaultExport, pinComponentName, pinForkSource } from "./pins.js";
19
+ import { collectSecretValues, redactSecretJson, redactSecretText } from "./redaction.js";
20
+ import { boxAllowlist, createEgressApprovals, normalizeEgressDomain, unapprovedEgress, } from "./egress-approval.js";
21
+ import { createScheduleEngine, } from "./schedules.js";
22
+ import { createSecretExposure } from "./secret-exposure.js";
23
+ import { computeShipDiff } from "./ship-diff.js";
24
+ import { appVersionHash } from "./version-hash.js";
25
+ const allRecords = (store, refs) => listAllRecords(store.records("vendo_apps"), { refs });
26
+ const rungFor = (app, declared) => {
27
+ // execution-v2 Wave 4 — a machine-served surface is layer 3 (the v2 ladder);
28
+ // rung 4 remains only for the retired v1 `server`-backed http shape.
29
+ if (app.ui === "http")
30
+ return app.machine !== undefined ? 3 : 4;
31
+ // execution-v2 — a machine (Wave 1 Lane B) is layer 2, exactly like the
32
+ // retired v1 `server`; presence, never a stored rung, is the source of truth.
33
+ if (app.machine !== undefined || app.server !== undefined)
34
+ return declared === 3 ? 3 : 2;
35
+ return 1;
36
+ };
37
+ const generationDependencies = (config, model, toolContext, onPartial) => ({
38
+ model,
39
+ catalog: config.catalog,
40
+ theme: config.theme,
41
+ designRules: config.designRules,
42
+ pinBaselines: config.pinBaselines,
43
+ ...(config.semantics === undefined ? {} : { semantics: config.semantics }),
44
+ ...(config.domains === undefined ? {} : { domains: config.domains }),
45
+ ...toolContext,
46
+ ...(config.paint === undefined ? {} : { paint: config.paint }),
47
+ ...(config.pipeline === undefined ? {} : { pipeline: config.pipeline }),
48
+ ...(onPartial === undefined ? {} : { onPartial }),
49
+ });
50
+ /** v2 spec §1 — assemble the emitted payload: the tree plus document islands
51
+ * at payload level (the v2 renderer lifts them into the shared walk). */
52
+ const assembleTree = (source) => ({
53
+ ...structuredClone(source.tree),
54
+ ...(source.components === undefined ? {} : { components: structuredClone(source.components) }),
55
+ ...(source.componentTools === undefined ? {} : { componentTools: structuredClone(source.componentTools) }),
56
+ });
57
+ const pinnedSubtree = (app, componentName) => {
58
+ if (app.tree?.formatVersion !== VENDO_TREE_FORMAT_V2)
59
+ return [];
60
+ const tree = app.tree;
61
+ const included = new Set(tree.nodes.filter((node) => node.component === componentName).map((node) => node.id));
62
+ const pending = [...included];
63
+ while (pending.length > 0) {
64
+ const id = pending.pop();
65
+ const node = tree.nodes.find((candidate) => candidate.id === id);
66
+ for (const child of node?.children ?? []) {
67
+ if (included.has(child))
68
+ continue;
69
+ included.add(child);
70
+ pending.push(child);
71
+ }
72
+ }
73
+ return tree.nodes.filter(({ id }) => included.has(id));
74
+ };
75
+ const touchedPinSlots = (previous, next) => {
76
+ const previousPins = new Map((previous.pins ?? []).map((pin) => [pin.slot, pin]));
77
+ return (next.pins ?? []).flatMap((pin) => {
78
+ const prior = previousPins.get(pin.slot);
79
+ if (prior?.base !== pin.base)
80
+ return [pin.slot];
81
+ const componentName = pinComponentName(pin.slot);
82
+ if (previous.components?.[componentName] !== next.components?.[componentName])
83
+ return [pin.slot];
84
+ // Subtree serialization intentionally over-reports reordered nodes as touched.
85
+ return JSON.stringify(pinnedSubtree(previous, componentName)) === JSON.stringify(pinnedSubtree(next, componentName))
86
+ ? []
87
+ : [pin.slot];
88
+ });
89
+ };
90
+ /** 06-apps §1 — construct the app lifecycle, generation, execution, and interchange surface. */
91
+ export const createApps = (config) => {
92
+ // Wave 9 — the experimental-flag relationship: a served (layer-3) surface
93
+ // lives in a machine, so layer 3 cannot be enabled without layer 2. Refuse
94
+ // the combination at composition time, loudly, instead of at first use.
95
+ if (config.experimentalServedApps === true && config.experimentalMachines !== true) {
96
+ throw new VendoError("validation", "experimentalServedApps requires experimentalMachines: a served (layer-3) app surface is served BY a machine, so enable both — createVendo({ apps: { experimentalServedApps: true, experimentalMachines: true } })");
97
+ }
98
+ const engine = modelEngine;
99
+ const apps = config.store.records("vendo_apps");
100
+ const data = createAppData(config.store);
101
+ const history = createAppHistory(config.store);
102
+ // ENG-345 — per-secret × per-app in-sandbox exposure grants. A dedicated store
103
+ // collection, NEVER part of the app document, so no copy path can carry it.
104
+ const exposure = createSecretExposure(config.store);
105
+ // Lane E — parked egress approvals (approved state lives on the document's
106
+ // egressApproved field; this collection holds only undecided cards).
107
+ const egressApprovals = createEgressApprovals(config.store);
108
+ // W0 — parked in-app actions: a mutating action the guard sent to approval
109
+ // is recorded here (keyed by its approval) so onApprovalDecision can
110
+ // re-dispatch the exact call the instant the owner approves. Holds only
111
+ // undecided actions; both decisions clear it.
112
+ const parkedActions = createParkedActions(config.store);
113
+ const reportGuard = async (principalSubject, appId, ctx, detail) => {
114
+ await config.guard.report(appLifecycleEvent({ kind: "user", subject: principalSubject }, ctx, appId, detail));
115
+ };
116
+ // execution-v2 — the v2 machine lifecycle (provision/wake/sleep/destroy);
117
+ // the v1 MachineSessions cache is deleted.
118
+ const { implicitDomains, buildEnv: hostBuildEnv, boxEditPollMs, boxEditTimeoutMs, ...machineConfig } = config.machine ?? {};
119
+ const implicitEgress = (implicitDomains ?? [])
120
+ .map(normalizeEgressDomain)
121
+ .filter((domain) => domain !== "");
122
+ const lifecycle = createMachineLifecycle({
123
+ store: config.store,
124
+ ...machineConfig,
125
+ // Lane E — the runtime resolves the app's active secret grants at every
126
+ // env assembly, so the host's buildEnv injects ONLY declared ∩ granted
127
+ // secrets (per-app grants decide which keys enter the box).
128
+ ...(hostBuildEnv === undefined ? {} : {
129
+ buildEnv: async (doc) => hostBuildEnv(doc, { grantedSecrets: await exposure.activeNames(doc.id) }),
130
+ // Wave 7 — the wake-time env rebuild for grant changes (machine.envStaleAt)
131
+ // rides the same box control-port door the pre-edit re-injection uses;
132
+ // the in-box harness restarts the app with the new boundary set.
133
+ injectEnv: pushBoxEnv,
134
+ }),
135
+ // Lane E — the egress policy EVERY provision and wake consults (including
136
+ // ctx-less paths like an idle resume or a schedule fire): approved
137
+ // declaration + implicit skin domains, or a loud refusal naming the
138
+ // unapproved domains. See boxAllowlist for the assembly rules.
139
+ allowedDomains: (doc) => boxAllowlist(doc, implicitEgress),
140
+ });
141
+ const owned = async (appId, subject) => {
142
+ const record = await apps.get(appId);
143
+ if (record === null || record.refs?.subject !== subject)
144
+ return null;
145
+ return documentFromRecord(record);
146
+ };
147
+ const requireOwned = async (appId, subject) => {
148
+ const app = await owned(appId, subject);
149
+ if (app === null)
150
+ throw new VendoError("not-found", `app not found: ${appId}`);
151
+ return app;
152
+ };
153
+ const interchange = createAppInterchange({
154
+ store: config.store,
155
+ guard: config.guard,
156
+ pinBaselines: config.pinBaselines,
157
+ requireOwned,
158
+ });
159
+ // ENG-345 — turning a secret ON is a HIGH-RISK approval reusing the guard's
160
+ // existing critical-approval flow: check() with a critical descriptor parks an
161
+ // approval, and this subscription commits the parked exposure grant only when
162
+ // that approval is decided approved. Denial (or any non-approval) reverts it.
163
+ // This is the SAME onApprovalDecision seam automations use to resume a parked
164
+ // run — no parallel approval mechanism is introduced.
165
+ const EXPOSURE_TOOL = "vendo_secret_expose";
166
+ const exposureDescriptor = () => ({
167
+ name: EXPOSURE_TOOL,
168
+ description: "Expose a declared secret's real value inside this app's sandbox (high-risk, owner-only).",
169
+ inputSchema: {
170
+ type: "object",
171
+ properties: { appId: { type: "string" }, secretName: { type: "string" } },
172
+ required: ["appId", "secretName"],
173
+ },
174
+ risk: "destructive",
175
+ critical: true,
176
+ });
177
+ // Stable across the park/approve phases so the real guard's approved-replay
178
+ // match (subject + call id + args + descriptor + venue/presence/app) lines up.
179
+ const exposureCall = (appId, secretName) => ({
180
+ id: `call_expose_${appId}_${secretName}`,
181
+ tool: EXPOSURE_TOOL,
182
+ args: { appId, secretName },
183
+ });
184
+ /**
185
+ * Wave 7 — a grant change while a machine exists: resumes restore the
186
+ * SNAPSHOT's env on every provider, so mark the machine env-stale (the next
187
+ * wake rebuilds the boundary env through the box control port and the
188
+ * harness restarts the app) and put a RUNNING box to sleep so its next
189
+ * request takes that wake path. No machine → nothing to mark; an app
190
+ * deleted between park and decision is a no-op.
191
+ */
192
+ const markMachineEnvStale = async (appId) => {
193
+ let marked;
194
+ try {
195
+ marked = await updateAppDocument(appId, (doc) => doc.machine === undefined
196
+ ? doc
197
+ // Strictly-increasing marker (nextEnvStaleAt): same-millisecond flips
198
+ // must not mint equal values, or a concurrent wake's guarded clear
199
+ // would erase the newer flip after injecting the older env.
200
+ : { ...doc, machine: { ...doc.machine, envStaleAt: nextEnvStaleAt(doc.machine.envStaleAt) } });
201
+ }
202
+ catch (error) {
203
+ if (error instanceof VendoError && error.code === "not-found")
204
+ return;
205
+ throw error;
206
+ }
207
+ if (marked.machine === undefined)
208
+ return;
209
+ await lifecycle.sleep(marked).catch(() => undefined);
210
+ };
211
+ const commitExposure = async (grant) => {
212
+ await exposure.activate(grant.appId, grant.secretName);
213
+ // A machine PROVISIONED before this grant keeps its provision-time env —
214
+ // mark it stale so the next wake's control-port rebuild (and the pre-edit
215
+ // re-injection) lands the new value.
216
+ await markMachineEnvStale(grant.appId);
217
+ await reportGuard(grant.owner, grant.appId, { venue: "app", presence: "present" }, {
218
+ operation: "secret-exposure-set",
219
+ secretName: grant.secretName,
220
+ expose: true,
221
+ });
222
+ };
223
+ // Lane E — approving an app's declared egress reuses the SAME high-risk
224
+ // critical-approval flow (approval card in-client, no new ceremony types):
225
+ // check() with this descriptor parks an approval, and the shared
226
+ // onApprovalDecision subscription below commits the parked domains onto the
227
+ // app document's egressApproved field only when the owner approves.
228
+ const EGRESS_TOOL = "vendo_egress_allow";
229
+ const egressDescriptor = () => ({
230
+ name: EGRESS_TOOL,
231
+ description: "Allow this app's machine outbound network access to its declared egress domains (high-risk, owner-only).",
232
+ inputSchema: {
233
+ type: "object",
234
+ properties: {
235
+ appId: { type: "string" },
236
+ domains: { type: "array", items: { type: "string" } },
237
+ },
238
+ required: ["appId", "domains"],
239
+ },
240
+ risk: "destructive",
241
+ critical: true,
242
+ });
243
+ // Stable across the park/approve phases so the real guard's approved-replay
244
+ // match (subject + call id + args + descriptor + venue/presence/app) lines up.
245
+ const egressCall = (appId, domains) => ({
246
+ id: `call_egress_${appId}_${domains.join("_")}`,
247
+ tool: EGRESS_TOOL,
248
+ args: { appId, domains },
249
+ });
250
+ /** Bounded read-mutate-CAS on the app row (the lifecycle uses the same recipe). */
251
+ const updateAppDocument = (appId, mutate) => updateAppRow(apps, appId, mutate);
252
+ const commitEgressApproval = async (appId, domains, owner) => {
253
+ const updated = await updateAppDocument(appId, (doc) => ({
254
+ ...doc,
255
+ egressApproved: [...new Set([
256
+ ...(doc.egressApproved ?? []).map(normalizeEgressDomain),
257
+ ...domains,
258
+ ])],
259
+ }));
260
+ for (const domain of domains)
261
+ await egressApprovals.remove(appId, domain);
262
+ // A sleeping snapshot carries the pre-grant allowlist and the wake-time
263
+ // policy override fixes that — but a LIVE machine still runs the old
264
+ // network policy, so put it to sleep; its next wake applies the grant.
265
+ await lifecycle.sleep(updated).catch(() => undefined);
266
+ await reportGuard(owner, appId, { venue: "app", presence: "present" }, {
267
+ operation: "egress-approved",
268
+ domains,
269
+ });
270
+ };
271
+ /**
272
+ * Lane E — request approval for an app's declared-but-unapproved egress. On
273
+ * "block" it throws; a pre-approved replay commits immediately; otherwise it
274
+ * PARKS the approval card and returns its id and domains WITHOUT throwing, so
275
+ * a caller (graduation) can surface a pending approval as an edit outcome
276
+ * rather than a failure. This is the one seam that can ASK — it has the
277
+ * acting principal; the lifecycle's ctx-less policy callback only refuses.
278
+ */
279
+ const requestEgressApproval = async (app, ctx) => {
280
+ const unapproved = unapprovedEgress(app);
281
+ if (unapproved.length === 0)
282
+ return { status: "none" };
283
+ const guardCtx = { ...ctx, appId: app.id };
284
+ const decision = await config.guard.check(egressCall(app.id, unapproved), egressDescriptor(), guardCtx);
285
+ if (decision.action === "block") {
286
+ throw new VendoError("blocked", decision.reason);
287
+ }
288
+ if (decision.action === "run") {
289
+ // A pre-approved replay already cleared the high-risk gate — commit now.
290
+ await commitEgressApproval(app.id, unapproved, ctx.principal.subject);
291
+ return { status: "approved", domains: unapproved };
292
+ }
293
+ const requestedAt = new Date().toISOString();
294
+ for (const domain of unapproved) {
295
+ await egressApprovals.putPending({
296
+ appId: app.id,
297
+ domain,
298
+ owner: ctx.principal.subject,
299
+ approvalId: decision.approval.id,
300
+ requestedAt,
301
+ });
302
+ }
303
+ return { status: "pending", approvalId: decision.approval.id, domains: unapproved };
304
+ };
305
+ /**
306
+ * Lane E — the ctx-carrying pre-flight run by provision/wake/box surfaces:
307
+ * declared domains without a grant park the approval card and the operation
308
+ * refuses loudly until the owner decides. Graduation uses the non-throwing
309
+ * {@link requestEgressApproval} directly.
310
+ */
311
+ const ensureEgressApproved = async (app, ctx) => {
312
+ const outcome = await requestEgressApproval(app, ctx);
313
+ if (outcome.status === "pending") {
314
+ throw new VendoError("blocked", `machine egress requires approval for: ${outcome.domains.join(", ")}`, { status: "pending-approval", approvalId: outcome.approvalId, unapprovedDomains: outcome.domains });
315
+ }
316
+ };
317
+ const onApprovalDecision = async (id, approved) => {
318
+ const parked = await exposure.byApproval(id);
319
+ for (const grant of parked) {
320
+ if (grant.status !== "pending")
321
+ continue;
322
+ if (approved) {
323
+ await commitExposure(grant);
324
+ }
325
+ else {
326
+ // Denied high-risk approval leaves the secret a handle (fail closed).
327
+ await exposure.revoke(grant.appId, grant.secretName);
328
+ }
329
+ }
330
+ // Lane E — parked egress domains riding this approval commit or clear as
331
+ // one batch per app (a card's call pins a single appId, but group anyway).
332
+ const parkedEgress = await egressApprovals.byApproval(id);
333
+ if (parkedEgress.length > 0) {
334
+ const byApp = new Map();
335
+ for (const request of parkedEgress) {
336
+ const entry = byApp.get(request.appId) ?? { owner: request.owner, domains: [] };
337
+ entry.domains.push(request.domain);
338
+ byApp.set(request.appId, entry);
339
+ }
340
+ for (const [appId, entry] of byApp) {
341
+ if (approved) {
342
+ try {
343
+ await commitEgressApproval(appId, entry.domains, entry.owner);
344
+ }
345
+ catch (error) {
346
+ // The app vanished between park and decision (delete raced the
347
+ // card): there is nothing to grant — clear the orphaned records.
348
+ for (const domain of entry.domains)
349
+ await egressApprovals.remove(appId, domain);
350
+ if (!(error instanceof VendoError && error.code === "not-found"))
351
+ throw error;
352
+ }
353
+ }
354
+ else {
355
+ // Denial leaves the declaration unapproved (fail closed) and clears the card.
356
+ for (const domain of entry.domains)
357
+ await egressApprovals.remove(appId, domain);
358
+ await reportGuard(entry.owner, appId, { venue: "app", presence: "present" }, {
359
+ operation: "egress-denied",
360
+ domains: entry.domains,
361
+ });
362
+ }
363
+ }
364
+ }
365
+ // W0 — resume a parked in-app action. Approval makes the exact parked call
366
+ // eligible for the guard's one-shot approved replay, so re-dispatching it
367
+ // through the guard-bound registry runs it and lands the host effect. The
368
+ // record clears either way (approve = ran; deny = fail closed, never runs).
369
+ const parkedAction = await parkedActions.byApproval(id);
370
+ if (parkedAction !== null) {
371
+ try {
372
+ // Contained: a failed resume must never roll back the approval (the
373
+ // guard already swallows subscriber throws, but be explicit here so
374
+ // the record is always cleared).
375
+ if (approved)
376
+ await config.tools.execute(parkedAction.call, parkedAction.ctx);
377
+ }
378
+ finally {
379
+ await parkedActions.remove(id);
380
+ }
381
+ }
382
+ };
383
+ config.guard.onApprovalDecision((id, approved) => onApprovalDecision(id, approved));
384
+ const inClientApprovals = createInClientApprovals(config.store);
385
+ // execution-v2 Lane D — fn: refs on a machine-bearing app resolve over the
386
+ // v2 box door (the same wake Lane C's wire proxy rides); the wrap leaves
387
+ // every other ref on the existing caller. Queries hit this at open(),
388
+ // actions at call().
389
+ const fnCaller = createFnCaller({ wake: (app) => lifecycle.wake(app) });
390
+ const scheduleEngine = createScheduleEngine({
391
+ store: config.store,
392
+ lifecycle,
393
+ callFn: fnCaller.callFn,
394
+ audit: (event) => config.guard.report(event),
395
+ });
396
+ const caller = fnCaller.wrap(createAppCaller(config.tools, {
397
+ // W0 — remember every mutating in-app action the guard parks, so the
398
+ // approve→resume seam above can re-dispatch its exact call on approval.
399
+ onParkedAction: (app, call, appCtx, approvalId) => parkedActions.put({ approvalId, appId: app.id, owner: appCtx.principal.subject, call, ctx: appCtx }),
400
+ }));
401
+ const opener = createAppOpener(caller, config.pinBaselines, (doc) => inClientApprovals.venueStateFor(doc),
402
+ // Wave 4 (layer 3) — the served surface: wake-on-open over the machine
403
+ // lifecycle, the provider's public ingress URL for $PORT, and the theming
404
+ // handoff (host theme tokens as a query param the served app MAY consume).
405
+ {
406
+ enabled: config.experimentalServedApps === true,
407
+ urlFor: async (app) => {
408
+ const machine = await lifecycle.wake(app);
409
+ // Absorb the fresh-boot 502 race server-side so the iframe's first
410
+ // paint is the app, not a provider error (the wake latency is the
411
+ // accepted loading state — no v1 cover machinery).
412
+ await requestAppWithBootRetry(machine, { method: "GET", path: "/" }).catch(() => undefined);
413
+ const url = new URL(await machine.url());
414
+ if (config.theme !== undefined) {
415
+ url.searchParams.set("vendoTheme", JSON.stringify(config.theme));
416
+ }
417
+ return url.toString();
418
+ },
419
+ });
420
+ // 06-apps §8 — every edit result over a drifted app carries the drift report,
421
+ // so an agent or host editing a stale fork hears about it at edit time.
422
+ const withPinDrift = (result) => {
423
+ const driftedPins = detectPinDrift(result.app, config.pinBaselines ?? []);
424
+ return driftedPins.length === 0 ? result : { ...result, driftedPins };
425
+ };
426
+ const failedEdit = (app, instruction, issues, retryable = true) => withPinDrift({
427
+ app: structuredClone(app),
428
+ version: {
429
+ at: new Date().toISOString(),
430
+ intent: instruction,
431
+ rung: rungFor(app),
432
+ },
433
+ issues: [...issues],
434
+ failure: {
435
+ code: "edit-rejected",
436
+ retryable,
437
+ message: retryable
438
+ ? "Edit was not applied. Retry vendo_apps_edit on the same app with a narrower instruction; do not rebuild the app."
439
+ : "Edit was not applied and cannot be retried until the reported blocker is resolved.",
440
+ },
441
+ });
442
+ const persistEdit = async (previous, app, version, subject, pinSlots, options = {}) => {
443
+ // Best-effort optimistic concurrency. The core StoreAdapter seam (01-core §12) has
444
+ // no compare-and-swap or transactions, so a narrow TOCTOU window between the final
445
+ // check and the put remains — closing it fully needs a store-level revision column
446
+ // (a store-block follow-up). This catches the common edit-vs-undo / double-edit races.
447
+ const assertCurrent = async () => {
448
+ const current = await apps.get(previous.id);
449
+ const row = current === null ? null : rowFromRecord(current);
450
+ if (row === null
451
+ || row.subject !== subject
452
+ || JSON.stringify(row.doc) !== JSON.stringify(previous)) {
453
+ throw new VendoError("conflict", `app changed during edit: ${previous.id}`);
454
+ }
455
+ return row.enabled;
456
+ };
457
+ await assertCurrent();
458
+ // Lane E — egressApproved is grant state, written ONLY by the egress
459
+ // approval flow: an engine- or model-authored edit must never mint or
460
+ // widen it (same rule as model-forged venue/drift fields above). Pin it
461
+ // to the stored document's value.
462
+ if (previous.egressApproved === undefined) {
463
+ delete app.egressApproved;
464
+ }
465
+ else {
466
+ app.egressApproved = [...previous.egressApproved];
467
+ }
468
+ await history.append(app.id, previous, version, pinSlots ?? touchedPinSlots(previous, app));
469
+ const wasEnabled = await assertCurrent();
470
+ // A changed trigger must be re-armed — enable() re-captures and re-mints trigger state.
471
+ const enabled = options.armTrigger === true && app.trigger !== undefined
472
+ ? true
473
+ : enabledAfterDocumentEdit(previous, app, wasEnabled);
474
+ const appRow = appRecordInput(app, subject, enabled);
475
+ await apps.put(appRow);
476
+ return structuredClone(appRow.data.doc);
477
+ };
478
+ const reportLifecycle = async (operation, appId, ctx, extra = {}) => {
479
+ await config.guard.report(appLifecycleEvent(ctx.principal, ctx, appId, { operation, ...extra }));
480
+ };
481
+ // verify-v2 fixes / v2 spec §3 — shape cards from live samples: each read
482
+ // tool is sampled once per runtime (empty input, the calling user's
483
+ // authority — the same call the app's queries make); the derived shape
484
+ // feeds the generation prompt and the compiler's binding type-check, and
485
+ // the descriptor list gates query tool names. A failed sample leaves that
486
+ // tool's shape unknown (defensive `json` per the spec).
487
+ const sampledShapes = new Map();
488
+ const settledSamples = new Set();
489
+ const requiresInput = (descriptor) => {
490
+ const required = descriptor.inputSchema.required;
491
+ return Array.isArray(required) && required.length > 0;
492
+ };
493
+ const generationToolContext = async (ctx) => {
494
+ const descriptors = await config.tools.descriptors().catch(() => []);
495
+ await Promise.all(descriptors
496
+ .filter((descriptor) => descriptor.risk === "read" && !requiresInput(descriptor) && !settledSamples.has(descriptor.name))
497
+ .map(async (descriptor) => {
498
+ try {
499
+ const outcome = await config.tools.execute({ id: `call_${globalThis.crypto.randomUUID()}`, tool: descriptor.name, args: {} }, ctx);
500
+ if (outcome.status === "ok") {
501
+ settledSamples.add(descriptor.name);
502
+ sampledShapes.set(descriptor.name, deriveShapeCard(descriptor.name, [outcome.output]).output);
503
+ }
504
+ else if (outcome.status === "pending-approval" || outcome.status === "blocked") {
505
+ // The policy gates this read: never re-ask on later creates (one
506
+ // parked approval per boot at most), and leave the shape unknown.
507
+ settledSamples.add(descriptor.name);
508
+ }
509
+ // Transient errors (e.g. an unauthenticated caller) retry on the
510
+ // next create with that caller's own authority.
511
+ }
512
+ catch {
513
+ // Unknown shape stays defensive; the tool is still listed by name.
514
+ }
515
+ }));
516
+ return {
517
+ tools: descriptors.map(({ name, description, risk, inputSchema }) => ({
518
+ name,
519
+ description,
520
+ risk,
521
+ // W4 pipeline — the structured-repair payload skeleton derives from
522
+ // the tool's input schema (mutation-without-payload fixes).
523
+ ...(typeof inputSchema === "object" && inputSchema !== null && !Array.isArray(inputSchema)
524
+ ? { inputSchema: inputSchema }
525
+ : {}),
526
+ })),
527
+ ...(sampledShapes.size === 0 ? {} : { toolShapes: Object.fromEntries(sampledShapes) }),
528
+ };
529
+ };
530
+ // ─── execution-v2 Wave 3: the agent in the box + graduation ────────────────
531
+ /** The skin-contract summary carried to the in-box agent as task context.
532
+ * Values never cross — only the env-var NAMES the box will find, the /fn
533
+ * convention, the vendo.json schema, and curl shapes for the store/tools
534
+ * callback surfaces. */
535
+ const skinContractPrompt = (app) => {
536
+ const secretNames = (app.secrets ?? []).join(", ") || "(none declared)";
537
+ return [
538
+ "SKIN CONTRACT (the box boundary you build against):",
539
+ "- Listen on the PORT env var. Serve POST /fn/<name> answering {\"result\": ...} (or {\"error\":{\"code\",\"message\"}}), and GET /vendo.json returning the manifest file.",
540
+ "- Manifest vendo.json: {\"schedules\":[{\"cron\":\"0 8 * * *\",\"fn\":\"<name>\"}], \"egress\":[\"host.example.com\"]}. Declare EVERY third-party domain you fetch; undeclared egress is blocked at the network layer.",
541
+ "- .vendo/run holds ONE shell line that starts the app (e.g. \"node server.js\"). Write it; a supervisor runs it.",
542
+ "- Durable rows go through the Vendo store, NOT disk: PUT \"$VENDO_STORE_URL/rows/<collection>/<id>\" with header \"authorization: Bearer $VENDO_APP_TOKEN\" and body {\"data\":{...}}; list with GET \"$VENDO_STORE_URL/rows/<collection>\".",
543
+ "- Host tools ride POST \"$VENDO_HOST_URL/tools/<name>\" with the same bearer; approvals/audit happen host-side.",
544
+ `- Env vars available in the box: PORT, VENDO_STORE_URL, VENDO_APP_TOKEN, VENDO_HOST_URL, VENDO_INFERENCE_URL, VENDO_INFERENCE_KEY, and these declared secrets by name: ${secretNames}.`,
545
+ ].join("\n");
546
+ };
547
+ /** Wave 4 (layer 3) — the extra contract lines for a served-app build: the
548
+ * box now OWNS the app surface. Same data-only floor as everything else the
549
+ * box reads; the host still verifies the served root itself before any
550
+ * surface flip. */
551
+ const servedAppContractPrompt = () => [
552
+ "THIS TASK BUILDS THE APP SURFACE ITSELF (layer 3):",
553
+ "- START WARM: a served-app scaffold is pre-baked at /opt/vendo-box/scaffold (zero-dep Node server with the /fn envelopes, vendo.json serving, a themed entry page, and the .vendo/run entry already wired and tested). Your FIRST action: run exactly `cp -a /opt/vendo-box/scaffold/. /app/` (one command; it copies .vendo/run too — no ls, no second cp), then go straight to editing fns.js + index.html (touch server.js only for extra routes). Only if that cp fails (older box) build from scratch.",
554
+ "- Serve a REAL web app on the non-/fn paths of $PORT. GET / is the entry page and must answer 200 with text/html. Any framework or plain HTML+JS; keep it self-contained (no CDN dependencies unless their domains are declared egress).",
555
+ "- Keep every POST /fn/<name> endpoint working beside the pages; the page's own JavaScript may call relative /fn/<name> endpoints for data and actions.",
556
+ "- The page may read the OPTIONAL `vendoTheme` query param (JSON host theme tokens: colors/typography/radius/density) to match the host brand. Ignore it if absent.",
557
+ "- Verify by curling your own pages (GET / and every route you serve) until they answer 200 with the real content, then report servesUi: true.",
558
+ ].join("\n");
559
+ /**
560
+ * The box server-edit primitive: wake the (already-provisioned) machine,
561
+ * re-inject the current boundary env (grant-flip restart loop), send the
562
+ * instruction to the in-box agent, and on success sync schedules + the
563
+ * egress declaration and snapshot the new state. On failure the box is
564
+ * DISCARDED — the app rolls back to its pre-edit snapshot. Returns the box's
565
+ * (data-only) result and the synced document.
566
+ */
567
+ const editServerViaBox = async (app, instruction, _ctx, options = {}) => {
568
+ const machine = await lifecycle.wake(app);
569
+ await pushBoxEnv(machine, await lifecycle.buildAppEnv(app)).catch(() => undefined);
570
+ const result = await runBoxEdit(machine, {
571
+ prompt: instruction,
572
+ context: options.served === true
573
+ ? `${skinContractPrompt(app)}\n${servedAppContractPrompt()}`
574
+ : skinContractPrompt(app),
575
+ ...(boxEditPollMs === undefined ? {} : { pollIntervalMs: boxEditPollMs }),
576
+ ...(boxEditTimeoutMs === undefined ? {} : { timeoutMs: boxEditTimeoutMs }),
577
+ });
578
+ if (!result.ok) {
579
+ // Rollback: drop the live machine without snapshotting — the doc keeps
580
+ // its pre-edit ref (no new fork machinery, just "don't keep this").
581
+ await lifecycle.discard(app).catch(() => undefined);
582
+ return { ok: false, result };
583
+ }
584
+ // Wave 4 (layer 3) — the box's servesUi is DATA; the HOST verifies the
585
+ // served root while the machine is still awake. A surface flip downstream
586
+ // requires this check, never the claim alone.
587
+ let servedOk = false;
588
+ if (result.servesUi === true) {
589
+ const root = await requestAppWithBootRetry(machine, { method: "GET", path: "/" }).catch(() => undefined);
590
+ // Header keys are matched case-insensitively: fetch normalizes to
591
+ // lowercase, but a provider adapter is not obliged to.
592
+ const contentType = root === undefined
593
+ ? ""
594
+ : Object.entries(root.headers).find(([key]) => key.toLowerCase() === "content-type")?.[1] ?? "";
595
+ servedOk = root !== undefined
596
+ && root.status >= 200 && root.status < 300
597
+ && contentType.includes("text/html")
598
+ && root.body.length > 0;
599
+ }
600
+ // Sync schedule state while the box is awake and its egress declaration is
601
+ // not yet on the doc (so this wake's allowlist still passes).
602
+ await scheduleEngine.syncManifest(app).catch(() => undefined);
603
+ // Sync the egress DECLARATION (mirrors vendo.json) onto the doc; the
604
+ // owner-approval grant is a separate, guard-gated step (Lane E).
605
+ let egressDecl = [];
606
+ const manifestSource = await readBoxManifest(machine).catch(() => undefined);
607
+ if (manifestSource !== undefined) {
608
+ try {
609
+ egressDecl = (parseVendoManifest(manifestSource).egress ?? []).map(normalizeEgressDomain).filter((d) => d !== "");
610
+ }
611
+ catch {
612
+ // An invalid manifest declares nothing; the box just cannot egress.
613
+ }
614
+ }
615
+ const synced = await updateAppDocument(app.id, (doc) => {
616
+ const next = { ...doc };
617
+ if (egressDecl.length === 0)
618
+ delete next.egress;
619
+ else
620
+ next.egress = [...new Set(egressDecl)];
621
+ return next;
622
+ });
623
+ // Snapshot the new code + state (sleep does not consult the allowlist).
624
+ // Sleep advances machine.snapshotRef via CAS, so the post-sleep document —
625
+ // not the pre-sleep `synced` — is the current stored row a later persist
626
+ // must build on.
627
+ const slept = await lifecycle.sleep(synced);
628
+ return { ok: true, result, doc: slept, servedOk };
629
+ };
630
+ /**
631
+ * Graduation 1→2 (invisible, additive): provision a machine if the app has
632
+ * none, delegate the server work to the in-box agent, then land the tree's
633
+ * fn: bindings through the NORMAL v2 tree-edit dialect. The tree keeps
634
+ * working throughout; the user never picks a tier. A graduating edit whose
635
+ * server declares unapproved egress SURFACES the parked approval (the code is
636
+ * written and snapshotted; the fn does real egress only once approved).
637
+ */
638
+ const graduate = async (previous, instruction, ctx, options = {}) => {
639
+ if (config.model === undefined) {
640
+ throw new VendoError("not-implemented", "generation requires a model");
641
+ }
642
+ if (!lifecycle.available()) {
643
+ return failedEdit(previous, instruction, [
644
+ "this instruction needs server capability (schedule / egress / heavy logic / app state), but no sandbox adapter is configured — set machine.sandbox to graduate",
645
+ ], false);
646
+ }
647
+ // Provision on first graduation. A fresh app declares no egress, so this
648
+ // does not park anything; a re-graduation of an app with pending egress
649
+ // surfaces that card here (ensureEgressApproved throws).
650
+ let provisioned = previous;
651
+ if (previous.machine === undefined) {
652
+ await ensureEgressApproved(previous, ctx);
653
+ provisioned = await lifecycle.provision(previous);
654
+ await reportLifecycle("machine-provision", previous.id, ctx);
655
+ }
656
+ const box = await editServerViaBox(provisioned, instruction, ctx, { served: options.served === true });
657
+ if (!box.ok) {
658
+ return failedEdit(provisioned, instruction, [
659
+ `the in-box agent could not complete the server work: ${box.result.summary}`,
660
+ ], true);
661
+ }
662
+ // Park the approval card for the domains the server code declared. On the
663
+ // pre-approved-replay path this COMMITS the grant (writing egressApproved
664
+ // and sleeping the box), which mutates the stored row — so re-read the
665
+ // current document AFTER it as the base the fn-binding persist builds on,
666
+ // rather than the now-possibly-stale box.doc.
667
+ const pending = await requestEgressApproval(box.doc, ctx);
668
+ const base = await requireOwned(previous.id, ctx.principal.subject);
669
+ const pendingEgress = pending.status === "pending"
670
+ ? { pendingEgress: { approvalId: pending.approvalId, domains: pending.domains } }
671
+ : {};
672
+ const boxReport = {
673
+ box: {
674
+ ok: box.result.ok,
675
+ summary: box.result.summary,
676
+ ...(box.result.fns === undefined ? {} : { fns: box.result.fns }),
677
+ filesChanged: box.result.filesChanged,
678
+ },
679
+ };
680
+ // ── Wave 4 (layer 3): the 2→3 surface flip ─────────────────────────────
681
+ // The tree kept serving through the whole box build; only NOW — box ok,
682
+ // servesUi declared, and the host's own served-root check green — does the
683
+ // document flip to the served surface (ui: http, the tree is gone). The
684
+ // experimental flag guards the FLIP itself, not just generation: a box
685
+ // that self-declares a served app while the flag is off is refused here,
686
+ // loudly (de-graduation guard).
687
+ const extraIssues = [];
688
+ if (options.served === true || box.result.servesUi === true) {
689
+ if (config.experimentalServedApps !== true) {
690
+ extraIssues.push("the box declared a served web app, but experimentalServedApps is disabled — the surface flip was refused and the tree keeps serving (enable createVendo({ apps: { experimentalServedApps: true } }))");
691
+ }
692
+ else if (options.served === true && box.result.servesUi === true && box.servedOk) {
693
+ // The flip needs BOTH the escalation decision and the verified served
694
+ // surface: a box that spontaneously serves UI on a layer-2 instruction
695
+ // must never replace a tree the user did not ask to lose.
696
+ const flipped = structuredClone(base);
697
+ delete flipped.tree;
698
+ delete flipped.components;
699
+ delete flipped.componentTools;
700
+ delete flipped.pins;
701
+ flipped.ui = "http";
702
+ const flipVersion = {
703
+ at: new Date().toISOString(),
704
+ intent: instruction,
705
+ rung: rungFor(flipped),
706
+ };
707
+ const persisted = await persistEdit(base, flipped, flipVersion, ctx.principal.subject);
708
+ return withPinDrift({
709
+ app: persisted,
710
+ version: { ...flipVersion },
711
+ graduated: true,
712
+ ...boxReport,
713
+ ...pendingEgress,
714
+ });
715
+ }
716
+ else if (options.served === true) {
717
+ // The box work landed (machine + code snapshotted), but no verified
718
+ // served surface exists — the current surface stays live; retry edits.
719
+ return withPinDrift({
720
+ app: structuredClone(base),
721
+ version: { at: new Date().toISOString(), intent: instruction, rung: rungFor(base) },
722
+ issues: [
723
+ "the box did not produce a verified served web app (GET / must answer 200 text/html) — the surface was not flipped; retry the edit",
724
+ ],
725
+ graduated: true,
726
+ ...boxReport,
727
+ ...pendingEgress,
728
+ });
729
+ }
730
+ }
731
+ // Land fn: bindings via the normal tree-edit dialect. The app now carries a
732
+ // machine, so fn: refs validate (core machine-presence rule).
733
+ const fns = box.result.fns ?? [];
734
+ // A FOCUSED rebind directive — not the full server spec (which is noise to
735
+ // the tree-edit model). The only job here is repointing the tree's data
736
+ // queries and actions at the new fn: functions.
737
+ const treeInstruction = `The app just graduated to a machine that serves these functions: ${fns.map((fn) => `fn:${fn}`).join(", ") || "(none reported)"}. Rewire the tree to use them:\n- Repoint the query that feeds the main board/list/digest so its tool is the matching data function (e.g. change its tool to "fn:getDigest"); if no such query exists, add one with <Query id="data" tool="fn:..."/> and bind a node to it. Do not leave a stale placeholder or host-tool query where the server now provides the data.\n- Wire any submit/refresh/run control's action to the matching fn:.\n- An fn response unwraps its {"result": ...} envelope: bind paths directly against the function's result value, and NEVER carry a host tool's response envelope (e.g. a "data" segment) into an fn: binding — when you repoint a query, rewrite every binding path that read the old tool's shape.\nChange ONLY the data source and actions; keep the layout. Emit no id attributes on nodes (ids are compiler-owned); a <Query> id is its name.`;
738
+ let treeIssues = [];
739
+ let bound;
740
+ // Wave 7 H2 (the em-dash class, PR #418) — fn-result shape cards, sampled
741
+ // lazily for the fn: queries the rebind actually lands (never for unbound
742
+ // action fns), keyed like tool shapes ("fn:<name>") so the edit compiler
743
+ // and the TOOL RESPONSE SHAPES prompt pick them up on retries.
744
+ const fnShapes = {};
745
+ const sampledFns = new Set();
746
+ for (let attempt = 0; attempt < 3 && bound === undefined; attempt += 1) {
747
+ const toolContext = await generationToolContext(ctx);
748
+ const generated = await engine.edit({
749
+ app: structuredClone(base),
750
+ instruction: treeInstruction,
751
+ ...(treeIssues.length === 0 ? {} : { repairIssues: treeIssues }),
752
+ }, generationDependencies(config, config.model, {
753
+ ...toolContext,
754
+ ...(Object.keys(fnShapes).length === 0 && toolContext.toolShapes === undefined
755
+ ? {}
756
+ : { toolShapes: { ...toolContext.toolShapes, ...fnShapes } }),
757
+ }));
758
+ if (generated.kind !== "document") {
759
+ treeIssues = distinctIssues(treeIssues, generated.issues);
760
+ continue;
761
+ }
762
+ const candidate = { ...generated.document, id: base.id };
763
+ // Post-pass: check the landed fn: bindings against SAMPLED result
764
+ // shapes. The sample is the exact call the bound query makes at open()
765
+ // (fn.ts unwraps the {result} envelope), so it does nothing a rendered
766
+ // board would not do itself; a failed sample leaves that fn's shape
767
+ // unknown — defensive, like an unsampled host tool.
768
+ const tree = candidate.tree;
769
+ const queries = tree?.queries ?? [];
770
+ // Sampling adds no new authority and (at most) one extra invocation:
771
+ // a query-bound fn fires WITHOUT user action the moment the graduated
772
+ // tree is opened or emitted (the progressive resolver calls it with
773
+ // exactly this input), so an fn too dangerous to sample was already
774
+ // too dangerous for the model to wire as a query — that is a box-side
775
+ // design concern, not a host gate this pass could add.
776
+ for (const query of queries) {
777
+ if (!query.tool.startsWith("fn:") || sampledFns.has(query.tool))
778
+ continue;
779
+ sampledFns.add(query.tool);
780
+ const outcome = await fnCaller.callFn(base, query.tool.slice(3), query.input ?? {}, ctx).catch(() => undefined);
781
+ if (outcome !== undefined && outcome.status === "ok") {
782
+ fnShapes[query.tool] = deriveShapeCard(query.tool, [outcome.output]).output;
783
+ }
784
+ }
785
+ const bindingErrors = tree === undefined ? [] : checkBindingShapes(tree.nodes, queries, fnShapes);
786
+ if (bindingErrors.length === 0) {
787
+ bound = candidate;
788
+ }
789
+ else {
790
+ treeIssues = distinctIssues(treeIssues, bindingErrors.map((error) => `binding ${error.path} on node "${error.nodeId}" prop "${error.prop}": ${error.message}${error.available === undefined ? "" : ` (available: ${error.available.join(", ")})`}`));
791
+ }
792
+ }
793
+ const version = { at: new Date().toISOString(), intent: instruction, rung: rungFor(base) };
794
+ if (bound === undefined) {
795
+ // Server graduated + snapshotted, but the model couldn't validate the
796
+ // fn: bindings. The machine sticks (already persisted by editServerViaBox);
797
+ // report the miss instead of silently dropping the graduation.
798
+ return withPinDrift({
799
+ app: structuredClone(base),
800
+ version,
801
+ issues: ["graduated: machine provisioned and server code written, but the tree fn: bindings did not validate — retry the edit to wire them", ...treeIssues, ...extraIssues],
802
+ graduated: true,
803
+ ...boxReport,
804
+ ...pendingEgress,
805
+ });
806
+ }
807
+ if (bound.tree !== undefined)
808
+ stripServerAuthoritativeFields(bound.tree);
809
+ const persisted = await persistEdit(base, bound, version, ctx.principal.subject);
810
+ return withPinDrift({
811
+ app: persisted,
812
+ version: { ...version },
813
+ ...(extraIssues.length === 0 ? {} : { issues: [...extraIssues] }),
814
+ graduated: true,
815
+ ...boxReport,
816
+ ...pendingEgress,
817
+ });
818
+ };
819
+ /**
820
+ * Wave 9 — the escalation ladder's rungs (a) and (b): author a STEPS or
821
+ * AGENTIC automation for a server-shaped instruction instead of graduating
822
+ * to a box. One structured model call plans the trigger (seconds, no
823
+ * machine); the trigger and its results-collection declaration land on the
824
+ * document through the normal edit persist, ARMED so the existing
825
+ * automations engine fires it; then the tree gains a query over the results
826
+ * rows through the same tree-edit dialect graduation uses for fn: bindings.
827
+ * Grant capture stays lazy: an away run's first ungranted mutating step
828
+ * parks the standard approval card (park/resume already handles it).
829
+ */
830
+ const automate = async (previous, instruction, ctx, mode) => {
831
+ if (config.model === undefined) {
832
+ throw new VendoError("not-implemented", "generation requires a model");
833
+ }
834
+ const toolContext = await generationToolContext(ctx);
835
+ const planned = await planAutomation({
836
+ appId: previous.id,
837
+ appName: previous.name,
838
+ instruction,
839
+ mode,
840
+ tools: toolContext.tools ?? [],
841
+ ...(toolContext.toolShapes === undefined ? {} : { toolShapes: toolContext.toolShapes }),
842
+ }, config.model);
843
+ if (planned.kind === "failure") {
844
+ return failedEdit(previous, instruction, [
845
+ `this instruction needs ${mode === "steps" ? "a scheduled/triggered steps" : "an agentic"} automation, but no valid plan validated`,
846
+ ...planned.issues,
847
+ ]);
848
+ }
849
+ const { plan } = planned;
850
+ const automated = structuredClone(previous);
851
+ automated.trigger = structuredClone(plan.trigger);
852
+ if (plan.resultsCollection !== undefined && automated.storage?.[plan.resultsCollection] === undefined) {
853
+ automated.storage = {
854
+ ...automated.storage,
855
+ [plan.resultsCollection]: {
856
+ about: `Latest results written by the "${plan.name ?? "automation"}" automation for the app board.`,
857
+ kind: "records",
858
+ },
859
+ };
860
+ }
861
+ // Bind the tree to the results rows BEFORE the single persist, so one
862
+ // version entry carries the whole edit. A failed rebind never blocks the
863
+ // automation (same rule as graduation's fn: bindings): the trigger still
864
+ // lands and the miss is reported for a retry edit.
865
+ let bound = automated;
866
+ const issues = [];
867
+ if (plan.resultsCollection !== undefined && automated.tree?.formatVersion === VENDO_TREE_FORMAT_V2) {
868
+ const rebindInstruction = `The app now has a ${mode} automation${plan.name === undefined ? "" : ` ("${plan.name}")`} that runs while the user is away and writes its latest displayable result into the app data collection "${plan.resultsCollection}" (record id "latest"). Rewire the tree to show those results:
869
+ - Add (or repoint) a query over the results rows: <Query id="results" tool="vendo_apps_data_list" input={{appId:"${previous.id}", collection:"${plan.resultsCollection}"}}/> — the input is LITERAL JSON exactly as written. The tool's result shape is {records: [{id, data: <what the automation stored>}]}, so bind node props against /results/records/... paths (e.g. {results.records.0.data.summary}).
870
+ - Keep the layout; change only what is needed to surface the automation's results (add a small section if none fits).
871
+ - Emit no id attributes on nodes (ids are compiler-owned); a <Query> id is its name.`;
872
+ let treeIssues = [];
873
+ let rebound;
874
+ for (let attempt = 0; attempt < 2 && rebound === undefined; attempt += 1) {
875
+ const generated = await engine.edit({
876
+ app: structuredClone(automated),
877
+ instruction: rebindInstruction,
878
+ ...(treeIssues.length === 0 ? {} : { repairIssues: treeIssues }),
879
+ }, generationDependencies(config, config.model, toolContext));
880
+ if (generated.kind === "document") {
881
+ rebound = { ...generated.document, id: previous.id };
882
+ }
883
+ else {
884
+ treeIssues = distinctIssues(treeIssues, generated.issues);
885
+ }
886
+ }
887
+ if (rebound === undefined) {
888
+ issues.push("automation armed, but the tree binding to its results collection did not validate — retry the edit to wire the board", ...treeIssues);
889
+ }
890
+ else {
891
+ // The rebind must never drop the just-authored automation fields.
892
+ rebound.trigger = structuredClone(plan.trigger);
893
+ rebound.storage = structuredClone(automated.storage);
894
+ bound = rebound;
895
+ }
896
+ }
897
+ if (bound.tree !== undefined)
898
+ stripServerAuthoritativeFields(bound.tree);
899
+ const version = { at: new Date().toISOString(), intent: instruction, rung: rungFor(bound) };
900
+ // Arming: through the seam when the host wired one (the umbrella wires
901
+ // automations.enable — the 07 §3 grant-capture flow), directly otherwise.
902
+ const persisted = await persistEdit(previous, bound, version, ctx.principal.subject, undefined, {
903
+ armTrigger: config.armAutomation === undefined,
904
+ });
905
+ let pendingGrants;
906
+ if (config.armAutomation !== undefined) {
907
+ try {
908
+ const armed = await config.armAutomation(previous.id, ctx);
909
+ if (armed.missing.length > 0)
910
+ pendingGrants = structuredClone(armed.missing);
911
+ // A seam that answers without arming is the same miss as a thrown one:
912
+ // the trigger must never sit silently disarmed.
913
+ if (!armed.enabled) {
914
+ issues.push("the automation was authored but the arming seam left it disabled — enable it explicitly via the automations engine (automations.enable / POST /automations/:appId/enable)");
915
+ }
916
+ }
917
+ catch (error) {
918
+ // Never a silently dead automation: the trigger is on the document but
919
+ // disarmed — say so, with the arming surface to use.
920
+ issues.push(`the automation was authored but arming it failed (${error instanceof Error ? error.message : "unknown error"}) — enable it explicitly via the automations engine (automations.enable / POST /automations/:appId/enable)`);
921
+ }
922
+ }
923
+ await reportGuard(ctx.principal.subject, previous.id, ctx, {
924
+ operation: "automation-created",
925
+ mode,
926
+ triggerKind: plan.trigger.on.kind,
927
+ });
928
+ return withPinDrift({
929
+ app: persisted,
930
+ version: { ...version },
931
+ ...(issues.length === 0 ? {} : { issues }),
932
+ automation: {
933
+ mode,
934
+ trigger: structuredClone(plan.trigger),
935
+ ...(plan.resultsCollection === undefined ? {} : { resultsCollection: plan.resultsCollection }),
936
+ ...(pendingGrants === undefined ? {} : { pendingGrants }),
937
+ },
938
+ });
939
+ };
940
+ const runtime = {
941
+ async prewarm() {
942
+ const models = [config.model, config.paint?.model].filter((model) => model !== undefined);
943
+ if (models.length > 0)
944
+ await prewarmModels(models);
945
+ },
946
+ async create(input, ctx) {
947
+ if (config.model === undefined) {
948
+ throw new VendoError("not-implemented", "generation requires a model");
949
+ }
950
+ // execution-v2 Wave 4 — a prompt that needs a served web app (layer 3)
951
+ // refuses cleanly while the experimental flag is off: no tree is built
952
+ // that could only disappoint, and the error names the flag.
953
+ if (config.experimentalServedApps !== true && instructionRequiresServedApp({}, input.prompt)) {
954
+ throw servedAppsDisabledError();
955
+ }
956
+ // execution-v2 Wave 9 — same rule for a prompt whose server work only a
957
+ // box can express (the ladder's rung c) while machines are off: a typed
958
+ // refusal naming the flag, never a silent degrade. Automation-shaped
959
+ // prompts (rungs a/b) proceed — they never need a machine.
960
+ if (config.experimentalMachines !== true && serverWorkRung({ ui: "tree" }, input.prompt) === "box") {
961
+ throw machinesDisabledError();
962
+ }
963
+ // Mint before generation so every partial already carries its permanent id.
964
+ const appId = `app_${globalThis.crypto.randomUUID()}`;
965
+ const emit = (payload) => {
966
+ // 06-apps §§8–9 — the venue verdict and drift report are
967
+ // server-authoritative and a model-written tree must never smuggle
968
+ // either into the live stream: a freshly generated app has no approval
969
+ // and no drifted pins by definition.
970
+ stripServerAuthoritativeFields(payload);
971
+ input.onView?.({
972
+ type: "data-vendo-view",
973
+ appId,
974
+ payload: payload,
975
+ });
976
+ };
977
+ let latestTree;
978
+ const queryApp = {
979
+ format: "vendo/app@1",
980
+ id: appId,
981
+ name: "Generating app",
982
+ ui: "tree",
983
+ };
984
+ const queryResolver = input.onView === undefined
985
+ ? undefined
986
+ : createProgressiveQueryResolver(caller, queryApp, ctx, (data) => {
987
+ if (latestTree === undefined)
988
+ return;
989
+ emit({ ...structuredClone(latestTree), data, streaming: true });
990
+ });
991
+ const generated = await engine.create({ prompt: input.prompt }, generationDependencies(config, config.model, await generationToolContext(ctx), input.onView === undefined ? undefined : (partial) => {
992
+ // v2 spec §1 — the payload carries islands at payload level (the
993
+ // renderer lifts them); a mid-stream payload is marked streaming.
994
+ latestTree = assembleTree(partial);
995
+ emit({ ...structuredClone(latestTree), streaming: true });
996
+ queryResolver?.update(latestTree);
997
+ }));
998
+ const app = {
999
+ ...generated,
1000
+ id: appId,
1001
+ };
1002
+ // Same rule at rest: open() strips before serving, but a model-forged
1003
+ // venue or drift field has no business being persisted in the first place.
1004
+ if (app.tree !== undefined)
1005
+ stripServerAuthoritativeFields(app.tree);
1006
+ // Lane E — same rule for egress grant state: a freshly generated app
1007
+ // has approved nothing, whatever the model emitted.
1008
+ delete app.egressApproved;
1009
+ let finalTree;
1010
+ if (input.onView !== undefined && app.tree?.formatVersion === VENDO_TREE_FORMAT_V2) {
1011
+ finalTree = assembleTree({ tree: app.tree, components: app.components, componentTools: app.componentTools });
1012
+ latestTree = structuredClone(finalTree);
1013
+ queryResolver?.update(finalTree);
1014
+ finalTree.data = await queryResolver?.complete() ?? structuredClone(finalTree.data ?? {});
1015
+ }
1016
+ await apps.put(appRecordInput(app, ctx.principal.subject));
1017
+ await reportLifecycle("create", app.id, ctx);
1018
+ if (finalTree !== undefined)
1019
+ emit(finalTree);
1020
+ // execution-v2 Wave 9 — escalate on create when the prompt needs server
1021
+ // capability, walking the ladder: a steps/agentic automation (seconds,
1022
+ // no machine) before box graduation. The tree is already on screen; the
1023
+ // trigger (or the machine + fn: bindings) lands additively — the user
1024
+ // never picks a tier. Best-effort: an escalation failure leaves the
1025
+ // working tree app to retry via edit, so create never regresses to a
1026
+ // white box.
1027
+ const servedCreate = instructionRequiresServedApp(app, input.prompt);
1028
+ const rung = servedCreate ? "box" : serverWorkRung(app, input.prompt);
1029
+ // The streamed view parts are last-write-wins, and the pre-escalation
1030
+ // emit above already painted resolved query data — so this emit must
1031
+ // resolve the escalated tree's queries too (fn: refs are resolvable —
1032
+ // the machine exists; a results query reads the app's own rows). On a
1033
+ // resolver failure, emit nothing rather than a data-less tree that
1034
+ // would blank the screen.
1035
+ const emitEscalated = async (escalated) => {
1036
+ if (input.onView === undefined || escalated.tree?.formatVersion !== VENDO_TREE_FORMAT_V2)
1037
+ return;
1038
+ try {
1039
+ const tree = assembleTree({ tree: escalated.tree, components: escalated.components, componentTools: escalated.componentTools });
1040
+ stripServerAuthoritativeFields(tree);
1041
+ const escalatedResolver = createProgressiveQueryResolver(caller, escalated, ctx);
1042
+ escalatedResolver.update(tree);
1043
+ tree.data = await escalatedResolver.complete();
1044
+ emit(tree);
1045
+ }
1046
+ catch {
1047
+ // Best-effort: the pre-escalation view stands until open().
1048
+ }
1049
+ };
1050
+ if (rung === "steps" || rung === "agentic") {
1051
+ try {
1052
+ const automated = await automate(structuredClone(app), input.prompt, ctx, rung);
1053
+ if (automated.failure === undefined) {
1054
+ await emitEscalated(automated.app);
1055
+ return structuredClone(automated.app);
1056
+ }
1057
+ }
1058
+ catch {
1059
+ // Automation is best-effort on create; the working tree app stands.
1060
+ }
1061
+ }
1062
+ else if (rung === "box" && lifecycle.available()) {
1063
+ // Rung (c) reaches here only with experimentalMachines on — the
1064
+ // flag-off case refused before generation above.
1065
+ try {
1066
+ const graduated = await graduate(structuredClone(app), input.prompt, ctx, {
1067
+ served: servedCreate,
1068
+ });
1069
+ if (graduated.failure === undefined) {
1070
+ await emitEscalated(graduated.app);
1071
+ return structuredClone(graduated.app);
1072
+ }
1073
+ }
1074
+ catch {
1075
+ // Graduation is best-effort on create; the working tree app stands.
1076
+ }
1077
+ }
1078
+ return structuredClone(app);
1079
+ },
1080
+ async get(appId, ctx) {
1081
+ return owned(appId, ctx.principal.subject);
1082
+ },
1083
+ async list(ctx) {
1084
+ const records = await allRecords(config.store, { subject: ctx.principal.subject });
1085
+ const documents = [];
1086
+ for (const record of records
1087
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id))) {
1088
+ try {
1089
+ documents.push(documentFromRecord(record));
1090
+ }
1091
+ catch {
1092
+ // Corrupt rows cannot be surfaced, but must not hide valid owned apps.
1093
+ }
1094
+ }
1095
+ return documents;
1096
+ },
1097
+ async delete(appId, ctx) {
1098
+ const app = await requireOwned(appId, ctx.principal.subject);
1099
+ // execution-v2 — deleting the app reaps its machine (live sandbox +
1100
+ // stored snapshot) directly, without rewriting the doomed document: a
1101
+ // graduated tree's fn: refs would fail a machine-cleared re-validation
1102
+ // and otherwise strand the provider snapshot.
1103
+ await lifecycle.destroyResources(app);
1104
+ await scheduleEngine.clearForApp(appId);
1105
+ await data.clear(app, ctx.principal.subject, await history.documents(appId));
1106
+ await history.clear(appId);
1107
+ await inClientApprovals.clear(appId);
1108
+ await exposure.clearForApp(appId);
1109
+ await egressApprovals.clearForApp(appId);
1110
+ await parkedActions.clearForApp(appId);
1111
+ await apps.delete(appId);
1112
+ await reportLifecycle("delete", appId, ctx);
1113
+ },
1114
+ async fork(appId, ctx) {
1115
+ const source = await requireOwned(appId, ctx.principal.subject);
1116
+ // Wave 4 — a served (layer-3) app's ENTIRE surface lives in its machine,
1117
+ // and machines never travel with a copy: the fork would be an app that
1118
+ // can never open (ui: http, no tree, no machine). Refuse loudly instead
1119
+ // of minting a broken document. Scoped to machine-backed docs — a
1120
+ // retired v1 `server`-ref doc keeps its established fork semantics (the
1121
+ // copy drops the dead ref; see the 09 §3 wire test).
1122
+ if (source.ui === "http" && source.machine !== undefined) {
1123
+ throw new VendoError("conflict", "a served (layer-3) app cannot be forked: its surface lives in its machine, which never travels with a copy — create a new app instead");
1124
+ }
1125
+ const fork = {
1126
+ ...structuredClone(source),
1127
+ id: `app_${globalThis.crypto.randomUUID()}`,
1128
+ forkedFrom: source.id,
1129
+ };
1130
+ // execution-v2 — a fork never carries the machine (or the retired v1
1131
+ // server snapshot); the copy re-graduates on its own.
1132
+ delete fork.machine;
1133
+ // Lane E grant hygiene — egress approval never travels with a copy; the
1134
+ // fork re-approves its declaration.
1135
+ delete fork.egressApproved;
1136
+ delete fork.server;
1137
+ await apps.put(appRecordInput(fork, ctx.principal.subject));
1138
+ await reportLifecycle("fork", fork.id, ctx, { sourceAppId: source.id });
1139
+ return structuredClone(fork);
1140
+ },
1141
+ async agentToolRisk(call, ctx) {
1142
+ if (call.tool !== "vendo_apps_edit")
1143
+ return undefined;
1144
+ if (typeof call.args !== "object" || call.args === null || Array.isArray(call.args)) {
1145
+ return "write";
1146
+ }
1147
+ const args = call.args;
1148
+ if (typeof args.appId !== "string" || typeof args.instruction !== "string") {
1149
+ return "write";
1150
+ }
1151
+ const app = await owned(args.appId, ctx.principal.subject);
1152
+ if (app === null)
1153
+ return "write";
1154
+ // Wave 9 — any ladder rung (steps/agentic automation or box work) is a
1155
+ // write-class edit; only pure-tree instructions stay read-class.
1156
+ return serverWorkRung(app, args.instruction) !== null ? "write" : "read";
1157
+ },
1158
+ async edit(appId, instruction, ctx) {
1159
+ if (config.model === undefined) {
1160
+ throw new VendoError("not-implemented", "generation requires a model");
1161
+ }
1162
+ const previous = await requireOwned(appId, ctx.principal.subject);
1163
+ // execution-v2 Wave 4 — an instruction whose UI needs exceed the tree
1164
+ // escalates 2→3 (the box builds a real served web app). Experimental:
1165
+ // the flag refuses this path CLEANLY before any box work happens.
1166
+ const served = instructionRequiresServedApp(previous, instruction);
1167
+ if (served && config.experimentalServedApps !== true) {
1168
+ throw servedAppsDisabledError();
1169
+ }
1170
+ // execution-v2 Wave 9 — server-shaped instructions walk the escalation
1171
+ // ladder: (a) a steps automation, (b) an agentic automation (both ride
1172
+ // the existing automations engine — seconds, no machine), (c) box
1173
+ // graduation only when custom code is required (experimental). A served
1174
+ // ask is a fortiori a box ask (Wave 4), and an app that ALREADY has a
1175
+ // machine keeps its box workflow — existing apps are never rerouted.
1176
+ // A pure-UI instruction stays on the cheap tree path.
1177
+ const rung = served ? "box" : serverWorkRung(previous, instruction);
1178
+ if (rung !== null) {
1179
+ if (previous.machine === undefined && (rung === "steps" || rung === "agentic")) {
1180
+ return automate(previous, instruction, ctx, rung);
1181
+ }
1182
+ // Rung (c): NEW graduation (no machine yet) is gated by the
1183
+ // experimental flag — a typed refusal, never a silent degrade.
1184
+ if (previous.machine === undefined && config.experimentalMachines !== true) {
1185
+ throw machinesDisabledError();
1186
+ }
1187
+ return graduate(previous, instruction, ctx, { served });
1188
+ }
1189
+ let repairIssues;
1190
+ let collectedIssues = [];
1191
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1192
+ const generated = await engine.edit({
1193
+ app: structuredClone(previous),
1194
+ instruction,
1195
+ ...(repairIssues === undefined ? {} : { repairIssues }),
1196
+ }, generationDependencies(config, config.model, await generationToolContext(ctx)));
1197
+ if (generated.kind === "failure") {
1198
+ collectedIssues = distinctIssues(collectedIssues, generated.issues);
1199
+ repairIssues = collectedIssues;
1200
+ continue;
1201
+ }
1202
+ const app = { ...generated.document, id: appId };
1203
+ // Same strip-before-persist rule as create(): open() strips at serve
1204
+ // time, but a model-forged venue or drift field must not be persisted.
1205
+ if (app.tree !== undefined)
1206
+ stripServerAuthoritativeFields(app.tree);
1207
+ const version = {
1208
+ at: new Date().toISOString(),
1209
+ intent: instruction,
1210
+ rung: rungFor(app),
1211
+ };
1212
+ return withPinDrift({
1213
+ app: await persistEdit(previous, app, version, ctx.principal.subject),
1214
+ version: { ...version },
1215
+ });
1216
+ }
1217
+ return failedEdit(previous, instruction, collectedIssues.length === 0 ? ["edit failed validation"] : collectedIssues);
1218
+ },
1219
+ /**
1220
+ * ⚠️ OWNERSHIP IS THE CALLER'S RESPONSIBILITY. The frozen 06 §1 signature
1221
+ * `history(appId)` takes no RunContext, so — unlike create/get/edit/delete/fork/
1222
+ * open/call, which all scope by `ctx.principal.subject` — this handle cannot check
1223
+ * ownership itself. The umbrella wire route (`/apps/:id/history`, 09 §3) MUST resolve
1224
+ * the principal and confirm ownership before exposing `list`/`undo`; that route is the
1225
+ * system's cross-user auth boundary ("the unauthenticated surface is exactly nothing").
1226
+ * Flagged by Codex + Greptile review; closing it inside this block needs a contract
1227
+ * major to add `ctx` here — see the PR's escalation note.
1228
+ */
1229
+ history(appId) {
1230
+ const surface = history.surface(appId);
1231
+ return Object.freeze({
1232
+ list: () => surface.list(),
1233
+ undo: () => surface.undo(),
1234
+ });
1235
+ },
1236
+ async open(appId, ctx) {
1237
+ return opener(await requireOwned(appId, ctx.principal.subject), ctx);
1238
+ },
1239
+ async call(appId, ref, args, ctx) {
1240
+ const app = await requireOwned(appId, ctx.principal.subject);
1241
+ // A host-tool ref goes straight to the guard-bound registry; an fn: ref
1242
+ // settles as a contained not-implemented outcome until the v2 in-runtime
1243
+ // fn path lands (see call.ts).
1244
+ return caller.call(app, ref, args, ctx);
1245
+ },
1246
+ async exportApp(appId, ctx) {
1247
+ return interchange.exportApp(appId, ctx);
1248
+ },
1249
+ async importApp(source, ctx) {
1250
+ return interchange.importApp(source, ctx);
1251
+ },
1252
+ async share(appId, ctx) {
1253
+ const app = await requireOwned(appId, ctx.principal.subject);
1254
+ if (config.cloud === undefined) {
1255
+ throw new VendoError("cloud-required", "Vendo Cloud requires VENDO_API_KEY");
1256
+ }
1257
+ // Lane E grant hygiene — a share copy never carries the owner's egress
1258
+ // approval; whoever runs the copy approves its declaration themselves.
1259
+ const { egressApproved: _egressApproved, ...shared } = app;
1260
+ return config.cloud.share(appId, shared);
1261
+ },
1262
+ async publish(appId, ctx) {
1263
+ const app = await requireOwned(appId, ctx.principal.subject);
1264
+ if (config.cloud === undefined) {
1265
+ throw new VendoError("cloud-required", "Vendo Cloud requires VENDO_API_KEY");
1266
+ }
1267
+ // Lane E grant hygiene — same rule as share: approval never travels.
1268
+ const { egressApproved: _published, ...published } = app;
1269
+ return config.cloud.publish(appId, published);
1270
+ },
1271
+ agentTools() {
1272
+ return createAgentTools(runtime, { data, requireOwned });
1273
+ },
1274
+ inClient: {
1275
+ async shipDiff(appId, ctx) {
1276
+ const app = await requireOwned(appId, ctx.principal.subject);
1277
+ return computeShipDiff(app, config.pinBaselines ?? []);
1278
+ },
1279
+ async approvals(appId, ctx) {
1280
+ await requireOwned(appId, ctx.principal.subject);
1281
+ return inClientApprovals.list(appId);
1282
+ },
1283
+ async verdict(appId, ctx) {
1284
+ const app = await requireOwned(appId, ctx.principal.subject);
1285
+ return inClientApprovals.verdictFor(app);
1286
+ },
1287
+ async approve(input, ctx) {
1288
+ const app = await requireOwned(input.appId, ctx.principal.subject);
1289
+ const approval = await inClientApprovals.record({
1290
+ appId: app.id,
1291
+ versionHash: appVersionHash(app),
1292
+ approvedBy: input.approvedBy,
1293
+ at: new Date().toISOString(),
1294
+ });
1295
+ await reportLifecycle("in-client-approve", app.id, ctx, {
1296
+ versionHash: approval.versionHash,
1297
+ approvedBy: approval.approvedBy,
1298
+ });
1299
+ return approval;
1300
+ },
1301
+ },
1302
+ pins: {
1303
+ async drift(appId, ctx) {
1304
+ const app = await requireOwned(appId, ctx.principal.subject);
1305
+ return detectPinDrift(app, config.pinBaselines ?? []);
1306
+ },
1307
+ // Gesture-owned forking (2026-07-21) — deterministic: the captured
1308
+ // baseline is copied by the engine and the pin recorded WITHOUT a model
1309
+ // call. The recorded fork version is intents[0] of the pin's replay
1310
+ // trail, so rebase() replays exactly the user's later modifications.
1311
+ async fork(input, ctx) {
1312
+ const baseline = (config.pinBaselines ?? []).find(({ slot }) => slot === input.slot);
1313
+ if (baseline === undefined) {
1314
+ throw new VendoError("not-found", `remixable slot "${input.slot}" has no captured baseline; register the component as remixable and run vendo sync`);
1315
+ }
1316
+ const forkOnto = (base) => {
1317
+ const forked = structuredClone(base);
1318
+ // applyPinFork prefixes issues with "<ForkPin> failed:" for the
1319
+ // stored-app op compiler; a user gesture never saw that op, so the
1320
+ // prefix is stripped from the surfaced error.
1321
+ const issues = applyPinFork(forked, { slot: input.slot }, config.pinBaselines)
1322
+ .map((issue) => issue.replace(/^<ForkPin> failed: /, ""));
1323
+ if (issues.length > 0)
1324
+ throw new VendoError("conflict", issues.join("; "));
1325
+ const validation = validateAppDocument(forked);
1326
+ if (!validation.ok)
1327
+ throw new VendoError("validation", validation.error.message);
1328
+ return forked;
1329
+ };
1330
+ let previous;
1331
+ if (input.appId !== undefined) {
1332
+ previous = await requireOwned(input.appId, ctx.principal.subject);
1333
+ if (previous.tree?.formatVersion !== VENDO_TREE_FORMAT_V2) {
1334
+ throw new VendoError("conflict", "a pin fork requires a vendo-genui/v2 tree app");
1335
+ }
1336
+ }
1337
+ else {
1338
+ // The empty-slot Remix gesture: mint the minimal base document the
1339
+ // fork lands in, so the fork itself is an ordinary recorded edit
1340
+ // (undo returns to the empty base; rebase finds a full trail).
1341
+ const minted = {
1342
+ format: "vendo/app@1",
1343
+ id: `app_${globalThis.crypto.randomUUID()}`,
1344
+ name: `${baseline.slot} remix`,
1345
+ ui: "tree",
1346
+ tree: {
1347
+ formatVersion: VENDO_TREE_FORMAT_V2,
1348
+ root: "root",
1349
+ nodes: [{ id: "root", component: "Stack", source: "prewired" }],
1350
+ },
1351
+ };
1352
+ // Dry-run the fork BEFORE persisting the base, so a bad baseline
1353
+ // never strands an empty app.
1354
+ forkOnto(minted);
1355
+ await apps.put(appRecordInput(minted, ctx.principal.subject));
1356
+ await reportLifecycle("create", minted.id, ctx);
1357
+ // Re-read the stored row: persistEdit's concurrency check compares
1358
+ // against the store's own JSON round-trip of the document (a jsonb
1359
+ // store may normalize key order), never the in-memory original.
1360
+ previous = await requireOwned(minted.id, ctx.principal.subject);
1361
+ }
1362
+ const working = forkOnto(previous);
1363
+ const version = {
1364
+ at: new Date().toISOString(),
1365
+ intent: `Remix the host component "${input.slot}"`,
1366
+ rung: rungFor(working),
1367
+ };
1368
+ const persisted = await persistEdit(previous, working, version, ctx.principal.subject, [input.slot]);
1369
+ await reportLifecycle("pin-fork", persisted.id, ctx, {
1370
+ slot: input.slot,
1371
+ baseHash: baseline.hash,
1372
+ });
1373
+ const componentName = pinComponentName(input.slot);
1374
+ const result = {
1375
+ app: persisted,
1376
+ version: { ...version },
1377
+ slot: input.slot,
1378
+ componentName,
1379
+ };
1380
+ const instruction = input.instruction?.trim();
1381
+ if (instruction === undefined || instruction.length === 0)
1382
+ return result;
1383
+ // The instruction reaches the model ALREADY SCOPED: the fork exists,
1384
+ // so this is an ordinary island edit on the pinned component. A failed
1385
+ // edit never rolls the fork back — the user keeps the faithful copy
1386
+ // and the failure is loud on the result. That holds for THROWN edits
1387
+ // too (no model configured, a gated escalation, a provider error):
1388
+ // the fork is already persisted, so the gesture returns it with a
1389
+ // failure-shaped edit instead of surfacing as an error.
1390
+ try {
1391
+ const edit = await runtime.edit(persisted.id, `The remixable host slot "${input.slot}" is already forked into the generated component "${componentName}" (its island source is in CURRENT_APP). Apply this change to that component: ${instruction}`, ctx);
1392
+ return { ...result, app: edit.app, edit };
1393
+ }
1394
+ catch (error) {
1395
+ return {
1396
+ ...result,
1397
+ edit: failedEdit(persisted, instruction, [error instanceof Error ? error.message : String(error)]),
1398
+ };
1399
+ }
1400
+ },
1401
+ async rebase(input, ctx) {
1402
+ if (config.model === undefined) {
1403
+ throw new VendoError("not-implemented", "generation requires a model");
1404
+ }
1405
+ const app = await requireOwned(input.appId, ctx.principal.subject);
1406
+ const pin = (app.pins ?? []).find(({ slot }) => slot === input.slot);
1407
+ if (pin === undefined) {
1408
+ throw new VendoError("not-found", `pin not found: ${input.slot}`);
1409
+ }
1410
+ const baseline = (config.pinBaselines ?? []).find(({ slot }) => slot === input.slot);
1411
+ if (baseline === undefined) {
1412
+ throw new VendoError("conflict", `pin ${input.slot} has no captured baseline to rebase onto; re-run vendo sync`);
1413
+ }
1414
+ if (baseline.hash === pin.base) {
1415
+ throw new VendoError("conflict", `pin ${input.slot} is not drifted`);
1416
+ }
1417
+ // Replay rides the tree edit dialect; a graduated http app routes every
1418
+ // instruction to the code path, so its trail can no longer replay.
1419
+ if (app.ui === "http") {
1420
+ throw new VendoError("conflict", `pin ${input.slot} cannot rebase on a served (http) app`);
1421
+ }
1422
+ const intents = (await history.pinIntents(app.id, input.slot)).map(({ intent }) => intent);
1423
+ // No recorded fork intent means the trail cannot vouch for the fork's
1424
+ // content (e.g. the pin arrived via an app fork or import, which start
1425
+ // an empty history). A mechanical re-fork would silently discard the
1426
+ // user's remix, so fail closed instead.
1427
+ if (intents.length === 0) {
1428
+ throw new VendoError("conflict", `pin ${input.slot} has no recorded edit trail to replay; remix the updated component manually`);
1429
+ }
1430
+ // intents[0] is the forking edit by construction: the first edit that
1431
+ // can touch a slot is the fork-pin that creates it, and undo removes a
1432
+ // reverted fork's intent. Re-forking is mechanical (the captured
1433
+ // baseline source is copied through `pinForkSource`, exactly like
1434
+ // fork-pin), so replay starts after it.
1435
+ const replayIntents = intents.slice(1);
1436
+ const componentName = pinComponentName(input.slot);
1437
+ // ENG-348 — same bar as fork-pin: a baseline the jail could never
1438
+ // render must not persist as a "successful" rebase.
1439
+ const forkSource = pinForkSource(baseline.source);
1440
+ if (!hasDefaultExport(forkSource)) {
1441
+ throw new VendoError("conflict", `pin ${input.slot} baseline has no default export and no detectable named component export; export the component from its module and re-run vendo sync`);
1442
+ }
1443
+ let working = structuredClone(app);
1444
+ working.components = { ...(working.components ?? {}), [componentName]: forkSource };
1445
+ working.pins = (working.pins ?? []).map((candidate) => candidate.slot === input.slot
1446
+ ? { ...candidate, base: baseline.hash }
1447
+ : candidate);
1448
+ const replayed = [];
1449
+ const failedRebase = (intent, issues, remaining) => ({
1450
+ status: "failed",
1451
+ slot: input.slot,
1452
+ baseHash: baseline.hash,
1453
+ replayed: [...replayed],
1454
+ failed: { intent, issues },
1455
+ remaining,
1456
+ });
1457
+ for (const [index, intent] of replayIntents.entries()) {
1458
+ const generated = await engine.edit({ app: structuredClone(working), instruction: intent }, generationDependencies(config, config.model, await generationToolContext(ctx)));
1459
+ const remaining = replayIntents.slice(index + 1);
1460
+ if (generated.kind === "failure") {
1461
+ return failedRebase(intent, [...generated.issues], remaining);
1462
+ }
1463
+ const next = { ...structuredClone(generated.document), id: app.id };
1464
+ if (next.tree !== undefined)
1465
+ stripServerAuthoritativeFields(next.tree);
1466
+ const survived = (next.pins ?? []).some((candidate) => candidate.slot === input.slot && candidate.base === baseline.hash)
1467
+ && next.components?.[componentName] !== undefined;
1468
+ if (!survived) {
1469
+ return failedRebase(intent, ["replayed intent removed the rebased pin or its component source"], remaining);
1470
+ }
1471
+ working = next;
1472
+ replayed.push(intent);
1473
+ }
1474
+ const validation = validateAppDocument(working);
1475
+ if (!validation.ok) {
1476
+ throw new VendoError("validation", validation.error.message);
1477
+ }
1478
+ const version = {
1479
+ at: new Date().toISOString(),
1480
+ intent: `Rebase remixed ${input.slot} onto the updated host component`,
1481
+ rung: rungFor(working),
1482
+ };
1483
+ // The rebase version appends NO pin intent of its own: its content is
1484
+ // exactly the replayed trail on the new baseline, and replaying a
1485
+ // "rebase" instruction through the model on a future rebase would be
1486
+ // meaningless. Undo of this version therefore removes no intents.
1487
+ const persisted = await persistEdit(app, working, version, ctx.principal.subject, []);
1488
+ await reportLifecycle("pin-rebase", app.id, ctx, {
1489
+ slot: input.slot,
1490
+ fromBaseHash: pin.base,
1491
+ toBaseHash: baseline.hash,
1492
+ replayedIntents: replayed.length,
1493
+ });
1494
+ return {
1495
+ status: "rebased",
1496
+ app: persisted,
1497
+ version: { ...version },
1498
+ slot: input.slot,
1499
+ baseHash: baseline.hash,
1500
+ replayed,
1501
+ };
1502
+ },
1503
+ },
1504
+ machine: {
1505
+ async provision(appId, ctx) {
1506
+ const app = await requireOwned(appId, ctx.principal.subject);
1507
+ const alreadyProvisioned = app.machine !== undefined;
1508
+ // Wave 9 — provisioning a NEW machine is experimental (typed refusal
1509
+ // while the flag is off); an already-provisioned app stays idempotent
1510
+ // here so existing apps are never stranded.
1511
+ if (!alreadyProvisioned && config.experimentalMachines !== true) {
1512
+ throw machinesDisabledError();
1513
+ }
1514
+ // Lane E — first provision is the "approve once" moment: unapproved
1515
+ // declared egress parks the approval card and refuses loudly here.
1516
+ await ensureEgressApproved(app, ctx);
1517
+ const provisioned = await lifecycle.provision(app);
1518
+ if (!alreadyProvisioned)
1519
+ await reportLifecycle("machine-provision", appId, ctx);
1520
+ return provisioned;
1521
+ },
1522
+ async wake(appId, ctx) {
1523
+ const app = await requireOwned(appId, ctx.principal.subject);
1524
+ // Lane E — a manifest change adding domains re-prompts at the next
1525
+ // wake: the new declaration parks a fresh card for the delta only.
1526
+ await ensureEgressApproved(app, ctx);
1527
+ return lifecycle.wake(app);
1528
+ },
1529
+ async sleep(appId, ctx) {
1530
+ const app = await requireOwned(appId, ctx.principal.subject);
1531
+ return lifecycle.sleep(app);
1532
+ },
1533
+ async editApp(appId, instruction, ctx) {
1534
+ const app = await requireOwned(appId, ctx.principal.subject);
1535
+ if (app.machine === undefined) {
1536
+ throw new VendoError("validation", `app ${appId} has not graduated; use edit to graduate it first`);
1537
+ }
1538
+ // A pre-declared unapproved egress must clear (or park) before we wake
1539
+ // the box — the wake would refuse it anyway (Lane E boxAllowlist).
1540
+ await ensureEgressApproved(app, ctx);
1541
+ const outcome = await editServerViaBox(app, instruction, ctx);
1542
+ if (!outcome.ok) {
1543
+ return { ok: false, summary: outcome.result.summary, filesChanged: outcome.result.filesChanged };
1544
+ }
1545
+ const pending = await requestEgressApproval(outcome.doc, ctx);
1546
+ return {
1547
+ ok: true,
1548
+ summary: outcome.result.summary,
1549
+ ...(outcome.result.fns === undefined ? {} : { fns: outcome.result.fns }),
1550
+ filesChanged: outcome.result.filesChanged,
1551
+ app: outcome.doc,
1552
+ ...(pending.status === "pending" ? { pendingEgress: { approvalId: pending.approvalId, domains: pending.domains } } : {}),
1553
+ };
1554
+ },
1555
+ async ping(appId, ctx) {
1556
+ const app = await requireOwned(appId, ctx.principal.subject);
1557
+ if (app.machine === undefined) {
1558
+ throw new VendoError("validation", `app ${appId} has no machine to ping`);
1559
+ }
1560
+ const wasAwake = lifecycle.peek(appId) !== undefined;
1561
+ // A ping that has to WAKE rides the same egress gate as machine.wake:
1562
+ // an unapproved declared domain must never reach the provider.
1563
+ if (!wasAwake)
1564
+ await ensureEgressApproved(app, ctx);
1565
+ const machine = await lifecycle.wake(app);
1566
+ // The activity signal itself: one cheap HEAD through the idle-tracked
1567
+ // wrapper. Best-effort — a failed HEAD must not fail the keepalive
1568
+ // (the wake above already proved the machine is reachable).
1569
+ await machine.request({ method: "HEAD", path: "/" }).catch(() => undefined);
1570
+ return { state: wasAwake ? "awake" : "woke" };
1571
+ },
1572
+ async destroy(appId, ctx) {
1573
+ const app = await requireOwned(appId, ctx.principal.subject);
1574
+ const cleared = await lifecycle.destroyMachine(app);
1575
+ // De-graduation retires the cached schedule state with the machine.
1576
+ await scheduleEngine.clearForApp(appId);
1577
+ if (app.machine !== undefined)
1578
+ await reportLifecycle("machine-destroy", appId, ctx);
1579
+ return cleared;
1580
+ },
1581
+ },
1582
+ schedules: {
1583
+ tick: (at) => scheduleEngine.tick(at),
1584
+ async sync(appId, ctx) {
1585
+ const app = await requireOwned(appId, ctx.principal.subject);
1586
+ return scheduleEngine.syncManifest(app);
1587
+ },
1588
+ report: () => scheduleEngine.report(),
1589
+ },
1590
+ secrets: {
1591
+ async exposure(appId, ctx) {
1592
+ const app = await requireOwned(appId, ctx.principal.subject); // owner-only read
1593
+ const grants = new Map((await exposure.list(appId)).map((grant) => [grant.secretName, grant]));
1594
+ return (app.secrets ?? []).map((secretName) => {
1595
+ const grant = grants.get(secretName);
1596
+ if (grant === undefined)
1597
+ return { secretName, status: "handle" };
1598
+ return grant.status === "active"
1599
+ ? { secretName, status: "exposed" }
1600
+ : { secretName, status: "pending", approvalId: grant.approvalId };
1601
+ });
1602
+ },
1603
+ async setExposure(input, ctx) {
1604
+ // Owner-only: requireOwned throws not-found for any non-owner principal.
1605
+ const app = await requireOwned(input.appId, ctx.principal.subject);
1606
+ if (!(app.secrets ?? []).includes(input.secretName)) {
1607
+ throw new VendoError("validation", `secret not declared by app: ${input.secretName}`);
1608
+ }
1609
+ if (input.expose === false) {
1610
+ // Turning OFF is safe — revert to the Option B handle default at once.
1611
+ await exposure.revoke(input.appId, input.secretName);
1612
+ // Wave 7 — the revoked value is still baked into the box snapshot;
1613
+ // the stale marker makes the next wake rebuild env without it.
1614
+ await markMachineEnvStale(input.appId);
1615
+ await reportGuard(ctx.principal.subject, input.appId, ctx, {
1616
+ operation: "secret-exposure-set",
1617
+ secretName: input.secretName,
1618
+ expose: false,
1619
+ });
1620
+ return { status: "handles" };
1621
+ }
1622
+ // Turning ON is HIGH-RISK: route through the guard's existing critical
1623
+ // approval flow. appId is pinned in the guard ctx so the parked approval
1624
+ // is app-scoped (and, for the real guard, so an approved replay matches).
1625
+ const guardCtx = { ...ctx, appId: input.appId };
1626
+ const decision = await config.guard.check(exposureCall(input.appId, input.secretName), exposureDescriptor(), guardCtx);
1627
+ if (decision.action === "block") {
1628
+ throw new VendoError("blocked", decision.reason);
1629
+ }
1630
+ if (decision.action === "run") {
1631
+ // A pre-approved replay already cleared the high-risk gate — commit now.
1632
+ const approvalId = decision.grantId === undefined
1633
+ ? `apr_replayed_${globalThis.crypto.randomUUID()}`
1634
+ : `apr_${decision.grantId}`;
1635
+ await exposure.putPending({
1636
+ appId: input.appId,
1637
+ secretName: input.secretName,
1638
+ owner: ctx.principal.subject,
1639
+ approvalId,
1640
+ requestedAt: new Date().toISOString(),
1641
+ });
1642
+ await exposure.activate(input.appId, input.secretName);
1643
+ // Wave 7 — same stale marker as the approval-decided commit path.
1644
+ await markMachineEnvStale(input.appId);
1645
+ await reportGuard(ctx.principal.subject, input.appId, ctx, {
1646
+ operation: "secret-exposure-set",
1647
+ secretName: input.secretName,
1648
+ expose: true,
1649
+ });
1650
+ return { status: "exposed" };
1651
+ }
1652
+ // Parked: record the pending grant against this approval; it flips to
1653
+ // active only when onApprovalDecision fires with approved=true.
1654
+ await exposure.putPending({
1655
+ appId: input.appId,
1656
+ secretName: input.secretName,
1657
+ owner: ctx.principal.subject,
1658
+ approvalId: decision.approval.id,
1659
+ requestedAt: new Date().toISOString(),
1660
+ });
1661
+ return { status: "pending-approval", approvalId: decision.approval.id };
1662
+ },
1663
+ },
1664
+ box: {
1665
+ // v2 only: the fn door rides the machine lifecycle's wake — an
1666
+ // un-provisioned app fails loudly here (graduation provisions first);
1667
+ // the dying v1 session cache never serves a box request.
1668
+ async request(appId, request, ctx) {
1669
+ const app = await requireOwned(appId, ctx.principal.subject);
1670
+ // Lane E — the fn door wakes the machine, so it carries the same
1671
+ // egress pre-flight (and re-prompt on a grown declaration) as wake.
1672
+ await ensureEgressApproved(app, ctx);
1673
+ const machine = await lifecycle.wake(app);
1674
+ // Lane E redaction guard — a box may echo its own env (fn responses
1675
+ // are host-side artifacts that reach clients and logs): scrub every
1676
+ // known secret value out of the response, and out of any error
1677
+ // message crossing this seam.
1678
+ const secretValues = await collectSecretValues(app.secrets, config.secrets);
1679
+ try {
1680
+ const answer = await machine.request(request);
1681
+ if (secretValues.size === 0)
1682
+ return answer;
1683
+ const text = new TextDecoder().decode(answer.body);
1684
+ const scrubbed = redactSecretText(text, secretValues);
1685
+ return {
1686
+ status: answer.status,
1687
+ headers: Object.fromEntries(Object.entries(answer.headers)
1688
+ .map(([header, value]) => [header, redactSecretText(value, secretValues)])),
1689
+ // Untouched bodies pass through byte-identical (binary safety).
1690
+ body: scrubbed === text ? answer.body : new TextEncoder().encode(scrubbed),
1691
+ };
1692
+ }
1693
+ catch (error) {
1694
+ if (error instanceof Error) {
1695
+ // Mutate in place so the error keeps its type, stack, and code.
1696
+ error.message = redactSecretText(error.message, secretValues);
1697
+ }
1698
+ if (error instanceof VendoError && error.detail !== undefined) {
1699
+ error.detail = redactSecretJson(error.detail, secretValues);
1700
+ }
1701
+ throw error;
1702
+ }
1703
+ },
1704
+ async redact(appId, value) {
1705
+ const record = await apps.get(appId);
1706
+ if (record === null)
1707
+ return value;
1708
+ const secretValues = await collectSecretValues(documentFromRecord(record).secrets, config.secrets);
1709
+ return redactSecretJson(value, secretValues);
1710
+ },
1711
+ },
1712
+ };
1713
+ return runtime;
1714
+ };
1715
+ //# sourceMappingURL=runtime.js.map