@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.
- package/LICENSE +202 -0
- package/README.md +8 -0
- package/dist/adapter-conformance.d.ts +48 -0
- package/dist/adapter-conformance.d.ts.map +1 -0
- package/dist/adapter-conformance.js +196 -0
- package/dist/adapter-conformance.js.map +1 -0
- package/dist/agent-tools.d.ts +10 -0
- package/dist/agent-tools.d.ts.map +1 -0
- package/dist/agent-tools.js +252 -0
- package/dist/agent-tools.js.map +1 -0
- package/dist/app-data.d.ts +23 -0
- package/dist/app-data.d.ts.map +1 -0
- package/dist/app-data.js +129 -0
- package/dist/app-data.js.map +1 -0
- package/dist/app-token.d.ts +24 -0
- package/dist/app-token.d.ts.map +1 -0
- package/dist/app-token.js +61 -0
- package/dist/app-token.js.map +1 -0
- package/dist/audit.d.ts +11 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +19 -0
- package/dist/audit.js.map +1 -0
- package/dist/automation-plan.d.ts +43 -0
- package/dist/automation-plan.d.ts.map +1 -0
- package/dist/automation-plan.js +212 -0
- package/dist/automation-plan.js.map +1 -0
- package/dist/bench/client.d.ts +43 -0
- package/dist/bench/client.d.ts.map +1 -0
- package/dist/bench/client.js +129 -0
- package/dist/bench/client.js.map +1 -0
- package/dist/bench/fixtures.d.ts +42 -0
- package/dist/bench/fixtures.d.ts.map +1 -0
- package/dist/bench/fixtures.js +146 -0
- package/dist/bench/fixtures.js.map +1 -0
- package/dist/bench/judge.d.ts +9 -0
- package/dist/bench/judge.d.ts.map +1 -0
- package/dist/bench/judge.js +71 -0
- package/dist/bench/judge.js.map +1 -0
- package/dist/bench/metrics.d.ts +32 -0
- package/dist/bench/metrics.d.ts.map +1 -0
- package/dist/bench/metrics.js +172 -0
- package/dist/bench/metrics.js.map +1 -0
- package/dist/bench/prompts.d.ts +17 -0
- package/dist/bench/prompts.d.ts.map +1 -0
- package/dist/bench/prompts.js +100 -0
- package/dist/bench/prompts.js.map +1 -0
- package/dist/bench/report.d.ts +43 -0
- package/dist/bench/report.d.ts.map +1 -0
- package/dist/bench/report.js +74 -0
- package/dist/bench/report.js.map +1 -0
- package/dist/bench/runner.d.ts +15 -0
- package/dist/bench/runner.d.ts.map +1 -0
- package/dist/bench/runner.js +26 -0
- package/dist/bench/runner.js.map +1 -0
- package/dist/bench/toolfork.d.ts +18 -0
- package/dist/bench/toolfork.d.ts.map +1 -0
- package/dist/bench/toolfork.js +125 -0
- package/dist/bench/toolfork.js.map +1 -0
- package/dist/box-agent.d.ts +78 -0
- package/dist/box-agent.d.ts.map +1 -0
- package/dist/box-agent.js +137 -0
- package/dist/box-agent.js.map +1 -0
- package/dist/box-env.d.ts +26 -0
- package/dist/box-env.d.ts.map +1 -0
- package/dist/box-env.js +52 -0
- package/dist/box-env.js.map +1 -0
- package/dist/call.d.ts +23 -0
- package/dist/call.d.ts.map +1 -0
- package/dist/call.js +45 -0
- package/dist/call.js.map +1 -0
- package/dist/cloud.d.ts +4917 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +16 -0
- package/dist/cloud.js.map +1 -0
- package/dist/e2b/index.d.ts +30 -0
- package/dist/e2b/index.d.ts.map +1 -0
- package/dist/e2b/index.js +273 -0
- package/dist/e2b/index.js.map +1 -0
- package/dist/egress-approval.d.ts +73 -0
- package/dist/egress-approval.d.ts.map +1 -0
- package/dist/egress-approval.js +74 -0
- package/dist/egress-approval.js.map +1 -0
- package/dist/engine.d.ts +188 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1507 -0
- package/dist/engine.js.map +1 -0
- package/dist/fn.d.ts +22 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +108 -0
- package/dist/fn.js.map +1 -0
- package/dist/history.d.ts +21 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +158 -0
- package/dist/history.js.map +1 -0
- package/dist/inclient.d.ts +48 -0
- package/dist/inclient.d.ts.map +1 -0
- package/dist/inclient.js +68 -0
- package/dist/inclient.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/interchange.d.ts +17 -0
- package/dist/interchange.d.ts.map +1 -0
- package/dist/interchange.js +133 -0
- package/dist/interchange.js.map +1 -0
- package/dist/machine-lifecycle.d.ts +102 -0
- package/dist/machine-lifecycle.d.ts.map +1 -0
- package/dist/machine-lifecycle.js +360 -0
- package/dist/machine-lifecycle.js.map +1 -0
- package/dist/manifest.d.ts +42 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +53 -0
- package/dist/manifest.js.map +1 -0
- package/dist/open.d.ts +45 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +245 -0
- package/dist/open.js.map +1 -0
- package/dist/parked-action.d.ts +51 -0
- package/dist/parked-action.d.ts.map +1 -0
- package/dist/parked-action.js +29 -0
- package/dist/parked-action.js.map +1 -0
- package/dist/persistence.d.ts +41 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +86 -0
- package/dist/persistence.js.map +1 -0
- package/dist/pins.d.ts +231 -0
- package/dist/pins.d.ts.map +1 -0
- package/dist/pins.js +213 -0
- package/dist/pins.js.map +1 -0
- package/dist/pipeline.d.ts +142 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +1014 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/prewired-schema.d.ts +16 -0
- package/dist/prewired-schema.d.ts.map +1 -0
- package/dist/prewired-schema.js +48 -0
- package/dist/prewired-schema.js.map +1 -0
- package/dist/redaction.d.ts +13 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +67 -0
- package/dist/redaction.js.map +1 -0
- package/dist/runtime.d.ts +463 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +1715 -0
- package/dist/runtime.js.map +1 -0
- package/dist/sandbox.d.ts +90 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +2 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/schedules.d.ts +89 -0
- package/dist/schedules.d.ts.map +1 -0
- package/dist/schedules.js +334 -0
- package/dist/schedules.js.map +1 -0
- package/dist/secret-exposure.d.ts +55 -0
- package/dist/secret-exposure.d.ts.map +1 -0
- package/dist/secret-exposure.js +53 -0
- package/dist/secret-exposure.js.map +1 -0
- package/dist/ship-diff.d.ts +47 -0
- package/dist/ship-diff.d.ts.map +1 -0
- package/dist/ship-diff.js +39 -0
- package/dist/ship-diff.js.map +1 -0
- package/dist/testing/fake-box.d.ts +67 -0
- package/dist/testing/fake-box.d.ts.map +1 -0
- package/dist/testing/fake-box.js +166 -0
- package/dist/testing/fake-box.js.map +1 -0
- package/dist/testing/fake-sandbox-v2.d.ts +59 -0
- package/dist/testing/fake-sandbox-v2.d.ts.map +1 -0
- package/dist/testing/fake-sandbox-v2.js +121 -0
- package/dist/testing/fake-sandbox-v2.js.map +1 -0
- package/dist/testing/fake-sandbox.d.ts +94 -0
- package/dist/testing/fake-sandbox.d.ts.map +1 -0
- package/dist/testing/fake-sandbox.js +239 -0
- package/dist/testing/fake-sandbox.js.map +1 -0
- package/dist/testing/guard-fixture.d.ts +19 -0
- package/dist/testing/guard-fixture.d.ts.map +1 -0
- package/dist/testing/guard-fixture.js +134 -0
- package/dist/testing/guard-fixture.js.map +1 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +8 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/memory-store.d.ts +5 -0
- package/dist/testing/memory-store.d.ts.map +1 -0
- package/dist/testing/memory-store.js +4 -0
- package/dist/testing/memory-store.js.map +1 -0
- package/dist/testing/scripted-model.d.ts +32 -0
- package/dist/testing/scripted-model.d.ts.map +1 -0
- package/dist/testing/scripted-model.js +112 -0
- package/dist/testing/scripted-model.js.map +1 -0
- package/dist/testing/seed-app-row.d.ts +4 -0
- package/dist/testing/seed-app-row.d.ts.map +1 -0
- package/dist/testing/seed-app-row.js +7 -0
- package/dist/testing/seed-app-row.js.map +1 -0
- package/dist/unified-diff.d.ts +13 -0
- package/dist/unified-diff.d.ts.map +1 -0
- package/dist/unified-diff.js +127 -0
- package/dist/unified-diff.js.map +1 -0
- package/dist/version-hash.d.ts +9 -0
- package/dist/version-hash.d.ts.map +1 -0
- package/dist/version-hash.js +12 -0
- package/dist/version-hash.js.map +1 -0
- package/package.json +84 -0
package/dist/runtime.js
ADDED
|
@@ -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
|