@vendoai/vendo 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +28 -0
  3. package/bin/vendo.mjs +4 -0
  4. package/dist/ai-sdk.d.ts +35 -0
  5. package/dist/ai-sdk.js +62 -0
  6. package/dist/auth-presets/auth-js.d.ts +14 -0
  7. package/dist/auth-presets/auth-js.js +91 -0
  8. package/dist/auth-presets/auth0.d.ts +23 -0
  9. package/dist/auth-presets/auth0.js +98 -0
  10. package/dist/auth-presets/clerk.d.ts +23 -0
  11. package/dist/auth-presets/clerk.js +65 -0
  12. package/dist/auth-presets/conformance.d.ts +42 -0
  13. package/dist/auth-presets/conformance.js +139 -0
  14. package/dist/auth-presets/identity.d.ts +56 -0
  15. package/dist/auth-presets/identity.js +152 -0
  16. package/dist/auth-presets/index.d.ts +14 -0
  17. package/dist/auth-presets/index.js +12 -0
  18. package/dist/auth-presets/jwt.d.ts +16 -0
  19. package/dist/auth-presets/jwt.js +54 -0
  20. package/dist/auth-presets/shared.d.ts +35 -0
  21. package/dist/auth-presets/shared.js +1 -0
  22. package/dist/auth-presets/supabase.d.ts +42 -0
  23. package/dist/auth-presets/supabase.js +229 -0
  24. package/dist/byo-approvals.d.ts +37 -0
  25. package/dist/byo-approvals.js +189 -0
  26. package/dist/capability-misses.d.ts +38 -0
  27. package/dist/capability-misses.js +172 -0
  28. package/dist/catalog.d.ts +19 -0
  29. package/dist/catalog.js +152 -0
  30. package/dist/cli/cloud/args.d.ts +3 -0
  31. package/dist/cli/cloud/args.js +32 -0
  32. package/dist/cli/cloud/auth.d.ts +17 -0
  33. package/dist/cli/cloud/auth.js +123 -0
  34. package/dist/cli/cloud/client.d.ts +28 -0
  35. package/dist/cli/cloud/client.js +133 -0
  36. package/dist/cli/cloud/command.d.ts +22 -0
  37. package/dist/cli/cloud/command.js +73 -0
  38. package/dist/cli/cloud/deploy.d.ts +26 -0
  39. package/dist/cli/cloud/deploy.js +187 -0
  40. package/dist/cli/cloud/device-login.d.ts +32 -0
  41. package/dist/cli/cloud/device-login.js +259 -0
  42. package/dist/cli/cloud/index.d.ts +5 -0
  43. package/dist/cli/cloud/index.js +59 -0
  44. package/dist/cli/cloud/keys.d.ts +2 -0
  45. package/dist/cli/cloud/keys.js +31 -0
  46. package/dist/cli/cloud/members.d.ts +3 -0
  47. package/dist/cli/cloud/members.js +25 -0
  48. package/dist/cli/cloud/output.d.ts +4 -0
  49. package/dist/cli/cloud/output.js +12 -0
  50. package/dist/cli/cloud/pending-claim.d.ts +29 -0
  51. package/dist/cli/cloud/pending-claim.js +38 -0
  52. package/dist/cli/cloud/read.d.ts +3 -0
  53. package/dist/cli/cloud/read.js +12 -0
  54. package/dist/cli/cloud/services.d.ts +2 -0
  55. package/dist/cli/cloud/services.js +9 -0
  56. package/dist/cli/cloud/session.d.ts +13 -0
  57. package/dist/cli/cloud/session.js +41 -0
  58. package/dist/cli/cloud-init.d.ts +60 -0
  59. package/dist/cli/cloud-init.js +149 -0
  60. package/dist/cli/dep-versions.d.ts +26 -0
  61. package/dist/cli/dep-versions.js +77 -0
  62. package/dist/cli/doctor-codes.d.ts +58 -0
  63. package/dist/cli/doctor-codes.js +60 -0
  64. package/dist/cli/doctor-live.d.ts +67 -0
  65. package/dist/cli/doctor-live.js +169 -0
  66. package/dist/cli/doctor.d.ts +52 -0
  67. package/dist/cli/doctor.js +467 -0
  68. package/dist/cli/eject.d.ts +29 -0
  69. package/dist/cli/eject.js +141 -0
  70. package/dist/cli/extract/apply.d.ts +26 -0
  71. package/dist/cli/extract/apply.js +123 -0
  72. package/dist/cli/extract/claude-cli-harness.d.ts +18 -0
  73. package/dist/cli/extract/claude-cli-harness.js +132 -0
  74. package/dist/cli/extract/claude-harness.d.ts +14 -0
  75. package/dist/cli/extract/claude-harness.js +127 -0
  76. package/dist/cli/extract/codex-cli-harness.d.ts +31 -0
  77. package/dist/cli/extract/codex-cli-harness.js +154 -0
  78. package/dist/cli/extract/delegate.d.ts +21 -0
  79. package/dist/cli/extract/delegate.js +77 -0
  80. package/dist/cli/extract/extraction.d.ts +65 -0
  81. package/dist/cli/extract/extraction.js +300 -0
  82. package/dist/cli/extract/gateway-fuel.d.ts +69 -0
  83. package/dist/cli/extract/gateway-fuel.js +69 -0
  84. package/dist/cli/extract/harness.d.ts +114 -0
  85. package/dist/cli/extract/harness.js +84 -0
  86. package/dist/cli/extract/index.d.ts +9 -0
  87. package/dist/cli/extract/index.js +9 -0
  88. package/dist/cli/extract/npx-engine-harness.d.ts +73 -0
  89. package/dist/cli/extract/npx-engine-harness.js +225 -0
  90. package/dist/cli/extract/stages.d.ts +149 -0
  91. package/dist/cli/extract/stages.js +398 -0
  92. package/dist/cli/framework.d.ts +8 -0
  93. package/dist/cli/framework.js +36 -0
  94. package/dist/cli/init-auth.d.ts +67 -0
  95. package/dist/cli/init-auth.js +142 -0
  96. package/dist/cli/init-scaffolds.d.ts +28 -0
  97. package/dist/cli/init-scaffolds.js +235 -0
  98. package/dist/cli/init.d.ts +115 -0
  99. package/dist/cli/init.js +1008 -0
  100. package/dist/cli/mcp/index.d.ts +7 -0
  101. package/dist/cli/mcp/index.js +59 -0
  102. package/dist/cli/mcp/registry.d.ts +9 -0
  103. package/dist/cli/mcp/registry.js +117 -0
  104. package/dist/cli/mcp/server-json.d.ts +12 -0
  105. package/dist/cli/mcp/server-json.js +65 -0
  106. package/dist/cli/mcp/server.schema.json +574 -0
  107. package/dist/cli/mcp/verify-domain.d.ts +11 -0
  108. package/dist/cli/mcp/verify-domain.js +48 -0
  109. package/dist/cli/playground/app/embed-entry.d.ts +20 -0
  110. package/dist/cli/playground/app/embed-entry.js +46 -0
  111. package/dist/cli/playground/app/fake-client.d.ts +3 -0
  112. package/dist/cli/playground/app/fake-client.js +215 -0
  113. package/dist/cli/playground/app/fixtures.d.ts +47 -0
  114. package/dist/cli/playground/app/fixtures.js +472 -0
  115. package/dist/cli/playground/app/main.d.ts +1 -0
  116. package/dist/cli/playground/app/main.js +108 -0
  117. package/dist/cli/playground/app/scenario-mount.d.ts +13 -0
  118. package/dist/cli/playground/app/scenario-mount.js +48 -0
  119. package/dist/cli/playground/app/scenarios.d.ts +25 -0
  120. package/dist/cli/playground/app/scenarios.js +152 -0
  121. package/dist/cli/playground/app/theme-editor.d.ts +13 -0
  122. package/dist/cli/playground/app/theme-editor.js +149 -0
  123. package/dist/cli/playground/app/theme-state.d.ts +29 -0
  124. package/dist/cli/playground/app/theme-state.js +151 -0
  125. package/dist/cli/playground/bundle.gen.d.ts +2 -0
  126. package/dist/cli/playground/bundle.gen.js +2 -0
  127. package/dist/cli/playground/embed-bundle.gen.d.ts +2 -0
  128. package/dist/cli/playground/embed-bundle.gen.js +2 -0
  129. package/dist/cli/playground.d.ts +33 -0
  130. package/dist/cli/playground.js +120 -0
  131. package/dist/cli/pretty.d.ts +48 -0
  132. package/dist/cli/pretty.js +330 -0
  133. package/dist/cli/refine.d.ts +43 -0
  134. package/dist/cli/refine.js +181 -0
  135. package/dist/cli/semantics.d.ts +31 -0
  136. package/dist/cli/semantics.js +122 -0
  137. package/dist/cli/shared.d.ts +68 -0
  138. package/dist/cli/shared.js +184 -0
  139. package/dist/cli/sync.d.ts +36 -0
  140. package/dist/cli/sync.js +202 -0
  141. package/dist/cli/theme/color.d.ts +15 -0
  142. package/dist/cli/theme/color.js +188 -0
  143. package/dist/cli/theme/css-vars.d.ts +13 -0
  144. package/dist/cli/theme/css-vars.js +40 -0
  145. package/dist/cli/theme/entry-candidates.d.ts +5 -0
  146. package/dist/cli/theme/entry-candidates.js +22 -0
  147. package/dist/cli/theme/extract-theme.d.ts +184 -0
  148. package/dist/cli/theme/extract-theme.js +358 -0
  149. package/dist/cli/theme/walk.d.ts +2 -0
  150. package/dist/cli/theme/walk.js +24 -0
  151. package/dist/cli.d.ts +2 -0
  152. package/dist/cli.js +333 -0
  153. package/dist/cloud-apps.d.ts +17 -0
  154. package/dist/cloud-apps.js +46 -0
  155. package/dist/cloud-console.d.ts +32 -0
  156. package/dist/cloud-console.js +72 -0
  157. package/dist/cloud-tools.d.ts +23 -0
  158. package/dist/cloud-tools.js +196 -0
  159. package/dist/connections.d.ts +71 -0
  160. package/dist/connections.js +192 -0
  161. package/dist/deployment-identity.d.ts +12 -0
  162. package/dist/deployment-identity.js +69 -0
  163. package/dist/dev-creds/model.d.ts +65 -0
  164. package/dist/dev-creds/model.js +159 -0
  165. package/dist/dev-creds/resolve.d.ts +36 -0
  166. package/dist/dev-creds/resolve.js +57 -0
  167. package/dist/hosted-store.d.ts +52 -0
  168. package/dist/hosted-store.js +322 -0
  169. package/dist/index.d.ts +13 -0
  170. package/dist/index.js +1 -0
  171. package/dist/mastra.d.ts +46 -0
  172. package/dist/mastra.js +88 -0
  173. package/dist/react.d.ts +10 -0
  174. package/dist/react.js +32 -0
  175. package/dist/refine.d.ts +244 -0
  176. package/dist/refine.js +599 -0
  177. package/dist/remixable.d.ts +18 -0
  178. package/dist/remixable.js +42 -0
  179. package/dist/runtime-capture.d.ts +18 -0
  180. package/dist/runtime-capture.js +111 -0
  181. package/dist/sandbox-wire.d.ts +101 -0
  182. package/dist/sandbox-wire.js +101 -0
  183. package/dist/sandbox.d.ts +38 -0
  184. package/dist/sandbox.js +389 -0
  185. package/dist/server.d.ts +216 -0
  186. package/dist/server.js +1320 -0
  187. package/dist/sync-impact.d.ts +14 -0
  188. package/dist/sync-impact.js +75 -0
  189. package/dist/turn-liveness.d.ts +33 -0
  190. package/dist/turn-liveness.js +105 -0
  191. package/dist/wire/approvals.d.ts +10 -0
  192. package/dist/wire/approvals.js +59 -0
  193. package/dist/wire/apps.d.ts +5 -0
  194. package/dist/wire/apps.js +155 -0
  195. package/dist/wire/automations.d.ts +5 -0
  196. package/dist/wire/automations.js +57 -0
  197. package/dist/wire/box.d.ts +3 -0
  198. package/dist/wire/box.js +268 -0
  199. package/dist/wire/connections.d.ts +5 -0
  200. package/dist/wire/connections.js +47 -0
  201. package/dist/wire/context.d.ts +22 -0
  202. package/dist/wire/context.js +190 -0
  203. package/dist/wire/doctor.d.ts +19 -0
  204. package/dist/wire/doctor.js +116 -0
  205. package/dist/wire/misc.d.ts +18 -0
  206. package/dist/wire/misc.js +227 -0
  207. package/dist/wire/shared.d.ts +158 -0
  208. package/dist/wire/shared.js +134 -0
  209. package/dist/wire/threads.d.ts +3 -0
  210. package/dist/wire/threads.js +68 -0
  211. package/package.json +151 -0
  212. package/skills/vendo-setup/SKILL.md +128 -0
package/dist/server.js ADDED
@@ -0,0 +1,1320 @@
1
+ import { createActions, } from "@vendoai/actions";
2
+ import { createAgent } from "@vendoai/agent";
3
+ import { buildEnv, createApps, createAppTokens, pinBaselineSchema, } from "@vendoai/apps";
4
+ import { e2bInstalled, e2bSandbox } from "@vendoai/apps/e2b";
5
+ import { createAutomations, } from "@vendoai/automations";
6
+ import { VendoError, descriptorHash, semanticsFileSchema, vendoThemeSchema, } from "@vendoai/core";
7
+ import { createGuard } from "@vendoai/guard";
8
+ import { createMcpDoor } from "@vendoai/mcp";
9
+ import { adoptEphemeralSubject, createStore, envSecrets, registerEphemeralSubject, sweepEphemeralSubjects, } from "@vendoai/store";
10
+ // 02-store §5: the erase API ships on the umbrella's runtime surface so hosts
11
+ // reach it without installing @vendoai/store directly.
12
+ export { eraseStore } from "@vendoai/store";
13
+ // XCUT-3: the production-deploy path — createStore({ url }) plus the secrets
14
+ // runtime — is reachable from the umbrella itself (docs/persistence-and-deploy
15
+ // imports these from "@vendoai/vendo/server"); hosts never need to install
16
+ // @vendoai/store directly.
17
+ export { createStore, envSecrets, secretStore, storeSecrets } from "@vendoai/store";
18
+ // 09-vendo §2.1 — host-identity presets: one `auth` key fills the principal,
19
+ // actAs, and oauth seams from one config. The conformance kit + shared types
20
+ // ship here (safe — no peer deps reachable through them); the five zero-arg
21
+ // preset FUNCTIONS ship on their own subpath instead
22
+ // (@vendoai/vendo/auth/auth0, /auth/auth-js, /auth/clerk, /auth/jwt,
23
+ // /auth/supabase) so importing this server entry never forces a host to
24
+ // have every preset's optional peer dep installed (corpus-triage Task 9 —
25
+ // see auth-presets/index.ts for why).
26
+ export { hostAuthPresetConformance, } from "./auth-presets/index.js";
27
+ import { createByoApprovals } from "./byo-approvals.js";
28
+ import { initTelemetry } from "@vendoai/telemetry";
29
+ import { capabilitySurfaceSnapshot, createCapabilityMissCapture, } from "./capability-misses.js";
30
+ import { catalogThemeSummary, mergeRuntimeCatalog, normalizeCatalogConfig, runtimeCatalogFromJson } from "./catalog.js";
31
+ import { devModel } from "./dev-creds/model.js";
32
+ // install-dx v1 — `devModel()` is the env-resolving model createVendo composes
33
+ // when the host passes none; the resolver is shared by init and doctor (one
34
+ // credential story, real keys only).
35
+ export { devModel } from "./dev-creds/model.js";
36
+ import { byoConnections, cloudConnections, hasConnections, unconfiguredConnections, } from "./connections.js";
37
+ // The shipped connections adapters ride the server surface so a host can pass
38
+ // one explicitly via createVendo({ connections }) — see selectConnections.
39
+ export { byoConnections, cloudConnections, unconfiguredConnections, } from "./connections.js";
40
+ import { cloudSandbox } from "./sandbox.js";
41
+ // The Cloud sandbox adapter rides the server surface like the connections
42
+ // adapters: a host can pass it explicitly via createVendo({ sandbox }) with
43
+ // its own options instead of relying on the VENDO_API_KEY default.
44
+ export { cloudSandbox } from "./sandbox.js";
45
+ import { cloudApps } from "./cloud-apps.js";
46
+ import { cloudTools } from "./cloud-tools.js";
47
+ // The Cloud tools adapter (the execution half of the zero-key Composio seam)
48
+ // rides the server surface the same way: pass it explicitly via
49
+ // createVendo({ connectors: [cloudTools({...})] }) to scope with `apps`.
50
+ export { cloudTools } from "./cloud-tools.js";
51
+ import { HostedSessionDoorsMissingError, hostedStore } from "./hosted-store.js";
52
+ // The hosted-store adapter rides the server surface like the other Cloud
53
+ // adapters: a host can pass it explicitly via createVendo({ store }) with its
54
+ // own options instead of relying on the VENDO_API_KEY default.
55
+ export { hostedStore } from "./hosted-store.js";
56
+ import { createRuntimeCapture } from "./runtime-capture.js";
57
+ import { BASE_PATH, VERSION, dispatchRoutes, environment, errorResponse, internalError, routeSegments, } from "./wire/shared.js";
58
+ import { appRoutes } from "./wire/apps.js";
59
+ import { boxRoutes, fnProxyRoutes } from "./wire/box.js";
60
+ import { approvalRoutes, grantRoutes } from "./wire/approvals.js";
61
+ import { automationRoutes, runRoutes } from "./wire/automations.js";
62
+ import { connectionRoutes } from "./wire/connections.js";
63
+ import { createContextResolver, withAnonCookie, } from "./wire/context.js";
64
+ import { DOCTOR_ACT_AS_APP_ID, DOCTOR_ACT_AS_PRINCIPAL, doctorActAsTool, doctorPresentTool, doctorRoutes, } from "./wire/doctor.js";
65
+ import { activityRoutes, devRoutes, orgsRoutes, statusRoutes, systemRoutes, } from "./wire/misc.js";
66
+ import { threadRoutes } from "./wire/threads.js";
67
+ /** 10-mcp §5 — the door's canonical mount under the wire's own prefix. */
68
+ const MCP_MOUNT = `${BASE_PATH}/mcp`;
69
+ /** ENG-237 recommended defaults (documented in the PR body; Yousef-gated as
70
+ 09-vendo contract text). */
71
+ const DEFAULT_SESSION_TTL_MS = 30 * 60_000;
72
+ const DEFAULT_SESSION_SWEEP_INTERVAL_MS = 60_000;
73
+ /** Existing-agents — a BYO loop has no turn-driven abandonment sweep, so an
74
+ orphaned approval card in a foreign chat expires on time instead: generous
75
+ enough to walk away and come back, bounded enough that stale writes can't
76
+ be approved days later. */
77
+ const DEFAULT_PARKED_CALL_TTL_MS = 60 * 60_000;
78
+ function validateSessionsConfig(sessions) {
79
+ const ttlMs = sessions?.ttlMs ?? DEFAULT_SESSION_TTL_MS;
80
+ const sweepIntervalMs = sessions?.sweepIntervalMs ?? DEFAULT_SESSION_SWEEP_INTERVAL_MS;
81
+ // ttlMs 0 (or negative) is the documented off switch. Any other value must
82
+ // be a non-negative integer; the sweep interval must be a positive integer.
83
+ if (!Number.isInteger(ttlMs) || ttlMs < 0) {
84
+ throw new VendoError("validation", "sessions.ttlMs must be a non-negative integer (0 disables TTL eviction)");
85
+ }
86
+ if (!Number.isInteger(sweepIntervalMs) || sweepIntervalMs < 1) {
87
+ throw new VendoError("validation", "sessions.sweepIntervalMs must be a positive integer");
88
+ }
89
+ return { ttlMs, sweepIntervalMs, ...(sessions?.now === undefined ? {} : { now: sessions.now }) };
90
+ }
91
+ function validateParkedCallTtl(approvals) {
92
+ const parkedCallTtlMs = approvals?.parkedCallTtlMs ?? DEFAULT_PARKED_CALL_TTL_MS;
93
+ if (!Number.isInteger(parkedCallTtlMs) || parkedCallTtlMs < 0) {
94
+ throw new VendoError("validation", "approvals.parkedCallTtlMs must be a non-negative integer (0 disables parked-call expiry)");
95
+ }
96
+ return parkedCallTtlMs;
97
+ }
98
+ /** Operator-tuned env knobs must be positive integer milliseconds. A typo
99
+ like "8m" fails loudly here (validateSessionsConfig's posture) instead of
100
+ flowing as NaN into the machine config, where NaN defeats runBoxEdit's
101
+ `??` defaults — every box edit would time out instantly and hot-poll the
102
+ box control port. */
103
+ function positiveIntegerEnv(name) {
104
+ const raw = environment(name);
105
+ if (raw === undefined)
106
+ return undefined;
107
+ const value = Number(raw);
108
+ if (!Number.isInteger(value) || value < 1) {
109
+ throw new VendoError("validation", `${name} must be a positive integer of milliseconds, got ${JSON.stringify(raw)}`);
110
+ }
111
+ return value;
112
+ }
113
+ /** Default char cap on a single tool result before it reaches the model (03-agent §2).
114
+ Generous enough for normal host responses, small enough that a runaway payload is
115
+ truncated to a preview instead of blowing the context window. Override via config.agent. */
116
+ const DEFAULT_TOOL_OUTPUT_CAP = 32_000;
117
+ /** The shared Cloud-default leg of the ADAPTER RULE: VENDO_API_KEY fills a
118
+ seam the host left unset, VENDO_CLOUD_URL overrides the console base URL. */
119
+ function cloudKeyOptions() {
120
+ const apiKey = environment("VENDO_API_KEY");
121
+ if (apiKey === undefined)
122
+ return undefined;
123
+ const baseUrl = environment("VENDO_CLOUD_URL");
124
+ return { apiKey, ...(baseUrl === undefined ? {} : { baseUrl }) };
125
+ }
126
+ /** Sandbox leg of the ADAPTER RULE (see the block comment at
127
+ selectConnections below): explicit adapter → BYO sandbox env (e2b) →
128
+ VENDO_API_KEY defaults the Cloud managed pool → the dark venue.
129
+ The Cloud slot fills ONLY when the host passed no sandbox and no BYO
130
+ sandbox env is present, so setting a Vendo key never shadows an existing
131
+ provider account. (The v1 Modal adapter is retired with the execution-v2
132
+ seam; Modal can return behind the same seam later.) */
133
+ function selectSandbox(configured) {
134
+ if (configured !== undefined)
135
+ return { adapter: configured, venue: "custom" };
136
+ // An env key only lights a venue when its optional SDK is actually
137
+ // installed; otherwise /status would report a venue whose first
138
+ // create() dies on a missing module.
139
+ const e2bApiKey = environment("E2B_API_KEY");
140
+ if (e2bApiKey !== undefined && e2bInstalled()) {
141
+ // Wave 4 — operator knob for the provider machine lifetime. The default
142
+ // 5-minute TTL kills a box mid-way through a long in-box agent build
143
+ // (the box agent loop runs for minutes). Explicit VENDO_E2B_TIMEOUT_MS
144
+ // wins; otherwise a raised box-edit budget implies a matching machine
145
+ // lifetime (budget + 5-minute slack), so the two knobs cannot silently
146
+ // disagree.
147
+ const configured = Number(environment("VENDO_E2B_TIMEOUT_MS"));
148
+ const editBudget = Number(environment("VENDO_BOX_EDIT_TIMEOUT_MS"));
149
+ const timeoutMs = Number.isFinite(configured) && configured > 0
150
+ ? configured
151
+ : Number.isFinite(editBudget) && editBudget > 0
152
+ ? editBudget + 5 * 60_000
153
+ : undefined;
154
+ return {
155
+ adapter: e2bSandbox({
156
+ apiKey: e2bApiKey,
157
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
158
+ }),
159
+ venue: "e2b",
160
+ };
161
+ }
162
+ const cloud = cloudKeyOptions();
163
+ if (cloud !== undefined) {
164
+ return { adapter: cloudSandbox(cloud), venue: "cloud" };
165
+ }
166
+ return { adapter: undefined, venue: false };
167
+ }
168
+ /** ADAPTER RULE, connectors seam: which Connector[] feeds the actions
169
+ registry. An explicitly passed array always wins — including an empty one
170
+ ("no connectors" is a choice). Only a wholly unset slot lets
171
+ VENDO_API_KEY default the Cloud tools connector (Composio tools brokered
172
+ through the console; the connections seam below independently resolves to
173
+ the cloud broker for the SAME posture, so connect and use stay paired). */
174
+ function selectConnectors(configured) {
175
+ if (configured !== undefined)
176
+ return configured;
177
+ const apiKey = environment("VENDO_API_KEY");
178
+ if (apiKey !== undefined) {
179
+ const baseUrl = environment("VENDO_CLOUD_URL");
180
+ return [cloudTools({ apiKey, ...(baseUrl === undefined ? {} : { baseUrl }) })];
181
+ }
182
+ return [];
183
+ }
184
+ /** ADAPTER RULE (docs/superpowers/specs/2026-07-17-vendo-cloud-definition-design.md):
185
+ an infrastructure-backed block defines one adapter interface; which
186
+ implementation composes is decided HERE, at the seam where createVendo
187
+ wires blocks together — never by a hidden key-conditional inside the block.
188
+ Precedence, top to bottom:
189
+ 1. an explicitly passed adapter always wins;
190
+ 2. BYO — a connector's own connections capability (connections must live
191
+ where the connector executes);
192
+ 3. VENDO_API_KEY makes the Cloud adapter the default for the seam the
193
+ host left unfilled (VENDO_CLOUD_URL overrides the console base URL);
194
+ 4. the unconfigured fallback, which fails closed with setup guidance.
195
+ The adapters themselves never read the environment. */
196
+ function selectConnections(configured, connectors) {
197
+ if (configured !== undefined)
198
+ return configured;
199
+ if (connectors.some(hasConnections))
200
+ return byoConnections(connectors);
201
+ const cloud = cloudKeyOptions();
202
+ return cloud !== undefined ? cloudConnections(cloud) : unconfiguredConnections();
203
+ }
204
+ /** ADAPTER RULE, inference seam (cloned from selectConnections): the agent and
205
+ apps blocks consume one ai-SDK LanguageModel; which implementation composes
206
+ is decided HERE. Precedence, top to bottom:
207
+ 1. an explicitly passed model always wins (BYO-LLM — any ai-SDK model);
208
+ 2. otherwise devModel()'s env ladder composes as the default, and the
209
+ remaining rungs live INSIDE it (resolveDevCredential): a provider key
210
+ (ANTHROPIC / OPENAI / GOOGLE) via the host-installed @ai-sdk provider,
211
+ then VENDO_API_KEY via @ai-sdk/anthropic pointed at the Cloud model
212
+ gateway (`<console>/api/v1` — Anthropic-compatible /messages), then
213
+ the honest keyless failure with exact instructions on first use.
214
+ devModel is the one seam-sanctioned lazy env resolver; every other adapter
215
+ still never reads the environment. */
216
+ function selectModel(configured) {
217
+ if (configured !== undefined)
218
+ return { model: configured, venue: "custom" };
219
+ return { model: devModel(), venue: "ladder" };
220
+ }
221
+ function localSessionOps(store) {
222
+ return {
223
+ register: (subject, now) => registerEphemeralSubject(store, subject, now),
224
+ adopt: (from, to) => adoptEphemeralSubject(store, from, to),
225
+ sweep: (idleMs, now) => sweepEphemeralSubjects(store, { idleMs, now }),
226
+ };
227
+ }
228
+ function hostedSessionOps(store, touchDebounceMs) {
229
+ // Last successful WIRE touch per subject. Presence means the subject is
230
+ // registered on the console; entries retire with the session (adopt/sweep),
231
+ // so the map tracks at most the live anonymous sessions of this process.
232
+ const wireTouched = new Map();
233
+ // vendo-web@7cd0a02 (2026-07-19) removed the console's session doors per a
234
+ // newer spec (anonymous visitor = end_user row; adoption = PUT
235
+ // /users/{externalId}); against that console every door op meets a bare
236
+ // 404. The doors then go quiet for the process — one warn, no per-request
237
+ // failures, no per-interval sweep retries — because anonymous traffic must
238
+ // keep serving and there is nothing to retry INTO. The full contract catch-
239
+ // up (merge + TTL lifecycle on the new surface) is the vendo-web follow-up
240
+ // tracked in docs/verification/existing-agents/polish/hosted-sessions-404.md.
241
+ let doorsMissing = false;
242
+ const disableDoors = () => {
243
+ if (doorsMissing)
244
+ return;
245
+ doorsMissing = true;
246
+ console.warn("[vendo] Vendo Cloud console does not serve the hosted session doors (/api/v1/store/sessions/* was removed in vendo-web@7cd0a02): "
247
+ + "anonymous-session registration, the anonymous→signed-in merge, and the hosted TTL sweep are disabled for this process. "
248
+ + "Hosted anonymous sessions will not be swept until the console grows a replacement surface.");
249
+ };
250
+ return {
251
+ async register(subject, now) {
252
+ if (doorsMissing)
253
+ return;
254
+ // In-process debounce: skip the wire touch when this subject's LAST
255
+ // successful touch is younger than sweepIntervalMs/2. TTLs are hours
256
+ // while the debounce window is seconds, and the claim leg re-checks
257
+ // idleness server-side, so a touched_at that is up to one debounce
258
+ // window stale can never get a live session swept — steady-state
259
+ // anonymous traffic costs zero extra round-trips.
260
+ const last = wireTouched.get(subject);
261
+ if (last !== undefined && now - last < touchDebounceMs)
262
+ return;
263
+ try {
264
+ await store.sessions.register(subject, now);
265
+ wireTouched.set(subject, now);
266
+ }
267
+ catch (error) {
268
+ // The registry itself is gone: failing closed would 500 every
269
+ // anonymous request while protecting a sweep that cannot run.
270
+ if (error instanceof HostedSessionDoorsMissingError) {
271
+ disableDoors();
272
+ return;
273
+ }
274
+ // INVARIANT: registered ⇒ sweepable. The FIRST registration must fail
275
+ // closed — if it doesn't land, rows written under this subject would
276
+ // be unreachable by the TTL sweep forever. A subsequent touch only
277
+ // refreshes idleness, so a console blip there fails OPEN with a warn:
278
+ // the next request retries (the failed touch is not recorded), and an
279
+ // hours-long TTL absorbs the staleness.
280
+ if (last === undefined)
281
+ throw error;
282
+ console.warn(`[vendo] hosted session touch failed; will retry next request: ${error instanceof Error ? error.message : String(error)}`);
283
+ }
284
+ },
285
+ async adopt(from, to) {
286
+ // No doors, no merge report: the caller still retires the anon cookie
287
+ // (the linkage is unrecoverable either way) and skips the merge audit.
288
+ if (doorsMissing)
289
+ return null;
290
+ try {
291
+ const report = await store.sessions.adopt(from, to);
292
+ wireTouched.delete(from);
293
+ return report;
294
+ }
295
+ catch (error) {
296
+ if (!(error instanceof HostedSessionDoorsMissingError))
297
+ throw error;
298
+ disableDoors();
299
+ wireTouched.delete(from);
300
+ return null;
301
+ }
302
+ },
303
+ // The HOST-driven sweep (hosted-store one-pager): list stale candidates,
304
+ // claim each (the wire claim repeats the idleness predicate — a re-touch
305
+ // defeats it, same serialization as sweepEphemeralSubjects), and finish
306
+ // every claimed subject through the erase cascade.
307
+ async sweep(idleMs, now) {
308
+ if (doorsMissing)
309
+ return [];
310
+ const evicted = [];
311
+ try {
312
+ for (const subject of await store.sessions.stale(idleMs, now)) {
313
+ if (!(await store.sessions.claim(subject, idleMs, now)))
314
+ continue;
315
+ await store.erase.bySubject(subject);
316
+ wireTouched.delete(subject);
317
+ evicted.push(subject);
318
+ }
319
+ }
320
+ catch (error) {
321
+ if (!(error instanceof HostedSessionDoorsMissingError))
322
+ throw error;
323
+ disableDoors();
324
+ }
325
+ return evicted;
326
+ },
327
+ };
328
+ }
329
+ /** A host may also pass hostedStore({...}) explicitly via createVendo({ store });
330
+ the session doors it carries are then used as-is instead of the local SQL
331
+ engine's (any other custom store keeps the local ops — and with them
332
+ today's loud dbFor failure rather than a silent no-op). */
333
+ function isHostedStore(store) {
334
+ const candidate = store;
335
+ return typeof candidate.sessions?.register === "function"
336
+ && typeof candidate.erase?.bySubject === "function";
337
+ }
338
+ /** ADAPTER RULE, store seam (cloned from selectConnections): persistence is
339
+ one VendoStore; which implementation composes is decided HERE. Precedence,
340
+ top to bottom:
341
+ 1. an explicitly passed store always wins (BYO — the host's own Postgres
342
+ or PGlite via createStore, the hard BYO rule);
343
+ 2. VENDO_API_KEY makes the Cloud hosted store the default for the seam
344
+ the host left unfilled (VENDO_CLOUD_URL overrides the console base) —
345
+ Vendo data lives with Vendo, tenant = the key's org, resolved
346
+ server-side on every call;
347
+ 3. the local createStore default (02-store §4 re-derived: encryption is
348
+ a production-owned concern — with VENDO_STORE_ENCRYPTION_KEY set,
349
+ stored secrets encrypt at rest; without it, dev mode stores locally
350
+ unencrypted (the data dir is gitignored) while production secret
351
+ writes fail closed with instructions).
352
+ The adapters themselves never read the environment. */
353
+ function selectStore(configured, touchDebounceMs) {
354
+ if (configured !== undefined) {
355
+ return {
356
+ store: configured,
357
+ sessions: isHostedStore(configured)
358
+ ? hostedSessionOps(configured, touchDebounceMs)
359
+ : localSessionOps(configured),
360
+ };
361
+ }
362
+ const cloud = cloudKeyOptions();
363
+ if (cloud !== undefined) {
364
+ const hosted = hostedStore(cloud);
365
+ return { store: hosted, sessions: hostedSessionOps(hosted, touchDebounceMs) };
366
+ }
367
+ const encryptionKey = environment("VENDO_STORE_ENCRYPTION_KEY");
368
+ const local = createStore(encryptionKey === undefined
369
+ ? { allowUnencryptedSecrets: environment("NODE_ENV") !== "production" }
370
+ : { encryption: { key: encryptionKey } });
371
+ return { store: local, sessions: localSessionOps(local) };
372
+ }
373
+ function isJsonRequest(request) {
374
+ return request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase()
375
+ === "application/json";
376
+ }
377
+ /** 09 §4 — the .vendo/ files feeding the generation seat, read fail-soft (the
378
+ composition works without them; on non-Node runtimes they just stay unset).
379
+ Reads `node:fs` through the runtime built-in accessor so this module carries
380
+ NO static Node import and still loads/bundles for edge/Worker targets. */
381
+ function dotVendoFile(name, root) {
382
+ try {
383
+ const proc = globalThis.process;
384
+ const fs = proc?.getBuiltinModule?.("node:fs");
385
+ if (fs === undefined)
386
+ return undefined;
387
+ return fs.readFileSync(`${root === undefined ? "." : root}/.vendo/${name}`, "utf8");
388
+ }
389
+ catch {
390
+ return undefined;
391
+ }
392
+ }
393
+ /** The compose-time project root for .vendo reads that happen LATER (the
394
+ per-generation design-rules read): pinning it keeps a host that chdirs
395
+ mid-run reading the same project every other .vendo input came from. */
396
+ function dotVendoRoot() {
397
+ try {
398
+ return globalThis.process?.cwd?.();
399
+ }
400
+ catch {
401
+ return undefined;
402
+ }
403
+ }
404
+ function dotVendoTheme() {
405
+ const raw = dotVendoFile("theme.json");
406
+ if (raw === undefined)
407
+ return undefined;
408
+ try {
409
+ const parsed = vendoThemeSchema.safeParse(JSON.parse(raw));
410
+ return parsed.success ? parsed.data : undefined;
411
+ }
412
+ catch {
413
+ return undefined;
414
+ }
415
+ }
416
+ /** 06-apps §8 — load sync-captured host source into the composition. Invalid
417
+ files are warned and skipped so one bad slot cannot crash the host; an
418
+ absent directory is the normal zero-remixable-components case. */
419
+ function dotVendoPinBaselines() {
420
+ const proc = globalThis.process;
421
+ const fs = proc?.getBuiltinModule?.("node:fs");
422
+ if (fs === undefined)
423
+ return [];
424
+ const directory = ".vendo/remixable";
425
+ let names;
426
+ try {
427
+ names = fs.readdirSync(directory).filter((name) => name.endsWith(".json")).sort();
428
+ }
429
+ catch (error) {
430
+ if (error.code === "ENOENT")
431
+ return [];
432
+ console.warn(`[vendo] could not read ${directory}; pin baselines were skipped`);
433
+ return [];
434
+ }
435
+ const baselines = [];
436
+ const slots = new Set();
437
+ for (const name of names) {
438
+ const file = `${directory}/${name}`;
439
+ try {
440
+ const parsed = pinBaselineSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
441
+ if (slots.has(parsed.slot)) {
442
+ console.warn(`[vendo] duplicate pin baseline slot ${parsed.slot} in ${file}; file was skipped`);
443
+ continue;
444
+ }
445
+ slots.add(parsed.slot);
446
+ baselines.push(parsed);
447
+ }
448
+ catch {
449
+ console.warn(`[vendo] invalid pin baseline ${file}; file was skipped`);
450
+ }
451
+ }
452
+ return baselines;
453
+ }
454
+ function relativePath(url) {
455
+ if (url.pathname === BASE_PATH)
456
+ return "/";
457
+ if (!url.pathname.startsWith(`${BASE_PATH}/`))
458
+ return null;
459
+ return url.pathname.slice(BASE_PATH.length);
460
+ }
461
+ /** 10-mcp §4-5 — the paths the door owns: its own mount (plus subpaths), and the
462
+ FOUR exact origin-root discovery documents it serves — the RFC 9728/8414
463
+ path-inserted metadata for its fixed mount, and the SEP-2127 server card. We
464
+ match those four EXACTLY rather than claiming the whole `/.well-known/oauth-*`
465
+ prefixes: a boundary-free prefix would shadow a host serving its own OAuth/
466
+ OIDC metadata at the same origin (and would even swallow
467
+ `/.well-known/oauth-protected-resourceX`). These are NOT wire routes — the
468
+ door mints its own principals (§3), and the OAuth /token and /register
469
+ endpoints are form-encoded POSTs — so they bypass the wire's principal/CSRF
470
+ machinery. */
471
+ const DOOR_WELL_KNOWN_PATHS = new Set([
472
+ `/.well-known/oauth-protected-resource${MCP_MOUNT}`,
473
+ `/.well-known/oauth-authorization-server${MCP_MOUNT}`,
474
+ "/.well-known/mcp/server-card.json",
475
+ "/.well-known/mcp-server-card",
476
+ ]);
477
+ function isDoorPath(pathname) {
478
+ if (pathname === MCP_MOUNT || pathname.startsWith(`${MCP_MOUNT}/`))
479
+ return true;
480
+ return DOOR_WELL_KNOWN_PATHS.has(pathname);
481
+ }
482
+ function jsonMutationRequired(request, path) {
483
+ if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method))
484
+ return false;
485
+ // /box/ is the app-token bearer surface (execution-v2 Lane C): no cookies,
486
+ // no ambient credentials, curl-able from any language inside the box — so
487
+ // the CSRF json gate doesn't apply; JSON-bodied box routes validate their
488
+ // own content-type like the webhook surface does.
489
+ if (path === "/apps/import" || path === "/tick" || path.startsWith("/webhooks/") || path.startsWith("/box/"))
490
+ return false;
491
+ return true;
492
+ }
493
+ function telemetryClient(enabled) {
494
+ if (enabled !== true)
495
+ return undefined;
496
+ try {
497
+ return initTelemetry({ version: VERSION, runtime: true });
498
+ }
499
+ catch {
500
+ return undefined;
501
+ }
502
+ }
503
+ /** The wire route TABLE (kill-list B4): every route as (method, pattern,
504
+ handler), assembled from the per-area modules under src/wire/. Entries are
505
+ matched IN ORDER, preserving the old if-chain's precedence exactly:
506
+ 1. the dev-only injection seams (fall through in production),
507
+ 2. the doctor production gate + doctor probe routes,
508
+ 3. the machine surfaces — webhooks, tick, sync impact — all raw-path
509
+ matches ahead of any segment decoding,
510
+ 4. the user surfaces: threads → approvals → connections → grants →
511
+ the orgs cloud-required seam → apps → automations → runs →
512
+ activity/status.
513
+ A handler returning undefined falls through to later entries (grouped
514
+ handlers keep the old chain's method/operation fall-out), and no match at
515
+ all answers not-found. */
516
+ const wireRoutes = [
517
+ ...devRoutes,
518
+ ...doctorRoutes,
519
+ ...systemRoutes,
520
+ // execution-v2 Lane C: the box callback surface is a machine surface like
521
+ // webhooks/tick — raw prefix match, bearer-authenticated, ahead of the user
522
+ // surfaces; the fn proxy sits just before the grouped /apps arm so
523
+ // /apps/:id/fn/:name resolves here, not through the grouped fall-through.
524
+ ...boxRoutes,
525
+ ...threadRoutes,
526
+ ...approvalRoutes,
527
+ ...connectionRoutes,
528
+ ...grantRoutes,
529
+ ...orgsRoutes,
530
+ ...fnProxyRoutes,
531
+ ...appRoutes,
532
+ ...automationRoutes,
533
+ ...runRoutes,
534
+ ...activityRoutes,
535
+ ...statusRoutes,
536
+ ];
537
+ function createWireHandler(deps) {
538
+ // Amortized on-request sweep bookkeeping — lives in the shared handler closure
539
+ // (persists across requests), NOT per-invocation. The serverless-safe leg:
540
+ // Next.js gives no timer guarantee, so every request may trigger the sweep.
541
+ // Awaited BEFORE the request is handled (evict-on-expiry): a request arriving
542
+ // past the TTL gets a fresh, empty session rather than racing its own sweep.
543
+ // A sweep failure is caught and logged, never surfaced to the innocent
544
+ // request that triggered it (same posture as the background timer leg) — a
545
+ // failed sweep just means idle sessions live until the next interval.
546
+ let lastSweepAt = deps.sessions.now();
547
+ const maybeSweep = async () => {
548
+ if (!deps.sweepEnabled)
549
+ return;
550
+ const now = deps.sessions.now();
551
+ if (now - lastSweepAt < deps.sessions.sweepIntervalMs)
552
+ return;
553
+ lastSweepAt = now;
554
+ try {
555
+ await deps.sweep();
556
+ }
557
+ catch (error) {
558
+ console.warn(`[vendo] session sweep failed; will retry next interval: ${error instanceof Error ? error.message : String(error)}`);
559
+ }
560
+ };
561
+ // LOAD-BEARING per-request ordering relative to routing (kill-list B4 kept
562
+ // it byte-identical to the old chain):
563
+ // 1. maybeSweep — awaited BEFORE anything (evict-on-expiry, above);
564
+ // 2. the MCP door's paths — before relativePath's not-found AND the CSRF
565
+ // json-mutation gate (see the comment at the check);
566
+ // 3. relativePath → not-found for non-wire paths;
567
+ // 4. onRequestOrigin — a validated wire route teaches the same-origin
568
+ // baseUrl default;
569
+ // 5. the CSRF json-mutation gate — before ANY route handler runs;
570
+ // 6. await ready — schema before the first store touch;
571
+ // 7. the route table (wireRoutes above; tick auth and the orgs seam are
572
+ // ordinary entries at their old chain positions; the anon-session
573
+ // touch happens inside each handler's context() call);
574
+ // 8. no match → not-found;
575
+ // 9. withAnonCookie at the single exit — the minted Set-Cookie rides
576
+ // every response shape (JSON, error, SSE/stream).
577
+ return async (request) => {
578
+ await maybeSweep();
579
+ // Per-request anonymous-session state + the one shared context-resolution
580
+ // pass (see wire/shared.ts). Both MUST be minted per-invocation — the
581
+ // handler closure is shared across requests.
582
+ const anon = {};
583
+ const context = createContextResolver(deps, anon);
584
+ const respond = async () => {
585
+ try {
586
+ const url = new URL(request.url);
587
+ // 10-mcp: hand the door its own paths BEFORE any wire machinery. It runs
588
+ // ahead of relativePath's not-found rejection (the origin-root discovery
589
+ // documents fall outside BASE_PATH) and ahead of the CSRF json-mutation
590
+ // gate (OAuth /token and /register are form-encoded POSTs, not JSON). The
591
+ // door authenticates every request through oauth.principal (§3), so the
592
+ // wire's principal resolver never runs for it — and, deliberately, these
593
+ // requests do NOT teach the same-origin baseUrl default: only a request
594
+ // addressing a real Vendo WIRE route may (04 §4), and the door's paths are
595
+ // the door's, not wire routes.
596
+ if (deps.door !== undefined && isDoorPath(url.pathname)) {
597
+ await deps.ready;
598
+ return await deps.door.handler(request);
599
+ }
600
+ const path = relativePath(url);
601
+ if (path === null)
602
+ throw new VendoError("not-found", "unknown Vendo route");
603
+ // Learn the same-origin default only from a request that addresses a real
604
+ // Vendo route (defense in depth beyond the untrusted-forwarding rule).
605
+ deps.onRequestOrigin?.(url.origin);
606
+ if (jsonMutationRequired(request, path) && !isJsonRequest(request)) {
607
+ throw new VendoError("validation", "content-type must be application/json");
608
+ }
609
+ await deps.ready;
610
+ // The per-request view the route table dispatches on (kill-list B4).
611
+ // Segments decode lazily so raw-matched pre-routes (proxy, webhooks,
612
+ // doctor) never decode — preserving the old chain's decode timing.
613
+ let segmentsCache;
614
+ const wire = {
615
+ request,
616
+ url,
617
+ path,
618
+ get segments() {
619
+ return (segmentsCache ??= routeSegments(path));
620
+ },
621
+ params: {},
622
+ context: (venue) => context(request, venue),
623
+ deps,
624
+ };
625
+ const routed = await dispatchRoutes(wireRoutes, wire);
626
+ if (routed !== undefined)
627
+ return routed;
628
+ throw new VendoError("not-found", "unknown Vendo route");
629
+ }
630
+ catch (error) {
631
+ if (error instanceof VendoError)
632
+ return errorResponse(error);
633
+ // The wire response stays generic (no internals leak to clients), but
634
+ // the host operator gets the real failure on their own server log.
635
+ console.error("[vendo] unhandled wire error:", error);
636
+ return internalError();
637
+ }
638
+ };
639
+ // Attach the anon Set-Cookie (if a session was minted this request) at the
640
+ // single exit — covering JSON, error, and SSE/stream responses alike.
641
+ return withAnonCookie(await respond(), anon.setCookie);
642
+ };
643
+ }
644
+ /** 09-vendo §2 — compose every live block around the guard choke point. */
645
+ export function createVendo(config) {
646
+ // 09-vendo §2.1 — one preset or the per-seam trio, never mixed. Checked
647
+ // before anything is constructed so a miswired config leaks no resources.
648
+ if (config.auth !== undefined) {
649
+ const mixed = ["principal", "actAs", "oauth"]
650
+ .filter((key) => config[key] !== undefined);
651
+ if (mixed.length > 0) {
652
+ throw new VendoError("validation", `createVendo({ auth }) already fills the principal, actAs, and oauth seams from one preset (09-vendo §2.1); remove ${mixed.map((key) => `\`${key}\``).join(", ")} or drop \`auth\` — one preset or the per-seam trio, never mixed.`);
653
+ }
654
+ }
655
+ // The three seams the identity story fills: from the preset, from the
656
+ // per-seam trio, or — with neither `auth` nor `principal` — the anonymous
657
+ // default resolver (every session ephemeral, 00 conventions "identity
658
+ // optional" / 02-store §4). Absent preset halves leave their seams unset.
659
+ const resolvePrincipal = config.auth?.principal ?? config.principal ?? (async () => null);
660
+ const actAsSeam = config.auth === undefined ? config.actAs : config.auth.actAs;
661
+ const oauthSeam = config.auth === undefined ? config.oauth : config.auth.oauth;
662
+ // 02-store §4 (kill-list B3) — ephemeral session policy. Validated like the
663
+ // agent's context config; defaults are the recommended knobs. The store takes
664
+ // the clock per call (register/sweep), so one time source needs no seam.
665
+ // Validated FIRST because the hosted session ops derive their touch-debounce
666
+ // window from the sweep interval.
667
+ const sessionsConfig = validateSessionsConfig(config.sessions);
668
+ const sessionNow = sessionsConfig.now ?? Date.now;
669
+ // Persistence, selected by the adapter rule at this composition seam
670
+ // (selectStore above): explicit store → VENDO_API_KEY hosted store → the
671
+ // local createStore default (02-store §4 re-derived: encryption is
672
+ // production-owned — VENDO_STORE_ENCRYPTION_KEY encrypts at rest; without
673
+ // it dev stores locally unencrypted while production secret writes fail
674
+ // closed). The session doors travel with the store: SQL registry locally,
675
+ // the store wire when hosted.
676
+ // Touch-debounce window, clamped by BOTH knobs. INVARIANT: the window must
677
+ // sit well inside the TTL, so continuous traffic always refreshes
678
+ // touched_at before the sweep cutoff — with sweepIntervalMs/2 alone, a
679
+ // ttlMs shorter than the sweep interval would let an actively-used
680
+ // session's stamp go a full window stale, cross the cutoff, and the claim
681
+ // leg would re-read that SAME stale stamp and erase a live session
682
+ // mid-use. sweepIntervalMs/2 bounds the wire chatter; ttlMs/4 enforces the
683
+ // safety margin. ttlMs 0 disables the sweep entirely (runSweep), so the
684
+ // zero window it produces (every touch rides the wire) is merely
685
+ // conservative, never wrong.
686
+ const { store, sessions: sessionOps } = selectStore(config.store, Math.min(Math.floor(sessionsConfig.sweepIntervalMs / 2), Math.floor(sessionsConfig.ttlMs / 4)));
687
+ const sandbox = selectSandbox(config.sandbox);
688
+ // Inference, selected by the adapter rule at this composition seam
689
+ // (selectModel above) — the one model the agent and apps blocks consume.
690
+ const inference = selectModel(config.model);
691
+ const ready = store.ensureSchema();
692
+ // Keep eager schema readiness for hosts that reach into composed blocks,
693
+ // while preventing an unhandled rejection before the first handler/emit awaits it.
694
+ void ready.catch(() => undefined);
695
+ let resolveAppToolRisk;
696
+ const guard = createGuard({
697
+ store,
698
+ // The resolver is installed immediately after createApps below. Keeping the
699
+ // hook in guard means chat/SSE and the MCP door reach the same decision.
700
+ resolveRisk: (call, _descriptor, ctx) => resolveAppToolRisk?.(call, ctx),
701
+ ...(config.policy === undefined ? {} : { policy: config.policy }),
702
+ ...(config.judge === undefined ? {} : { judge: config.judge }),
703
+ });
704
+ let presentCredentialsWarningEmitted = false;
705
+ const warnPresentCredentialsNotForwarded = async (event) => {
706
+ if (presentCredentialsWarningEmitted)
707
+ return;
708
+ presentCredentialsWarningEmitted = true;
709
+ const action = event.reason === "untrusted-host-origin"
710
+ ? "Set VENDO_BASE_URL to the host origin and restart the server."
711
+ : "Keep present host authentication same-origin, or use actAs/connector authentication.";
712
+ try {
713
+ await guard.report({
714
+ id: `aud_${globalThis.crypto.randomUUID()}`,
715
+ at: new Date().toISOString(),
716
+ kind: "tool-call",
717
+ principal: event.ctx.principal,
718
+ venue: event.ctx.venue,
719
+ presence: event.ctx.presence,
720
+ ...(event.ctx.appId === undefined ? {} : { appId: event.ctx.appId }),
721
+ ...(event.ctx.trigger === undefined ? {} : { trigger: event.ctx.trigger }),
722
+ tool: event.tool.name,
723
+ detail: {
724
+ warning: {
725
+ code: "present-credentials-not-forwarded",
726
+ reason: event.reason,
727
+ action,
728
+ },
729
+ },
730
+ });
731
+ }
732
+ catch (error) {
733
+ // Let a later call retry the warning if the audit sink was temporarily down.
734
+ presentCredentialsWarningEmitted = false;
735
+ throw error;
736
+ }
737
+ };
738
+ // createActions reads baseUrl from this object at execution time. An explicit
739
+ // VENDO_BASE_URL is a trusted, operator-set origin (credentials forward to it).
740
+ // When unset, the handler learns the wire's own origin from a validated
741
+ // request so route bindings execute same-origin with zero configuration — but
742
+ // that learned origin is UNTRUSTED (baseUrlTrusted:false), so a spoofed Host
743
+ // can never turn it into a credential-exfiltration target (04 §4).
744
+ const configuredBaseUrl = environment("VENDO_BASE_URL");
745
+ // 09-vendo §2 (install-dx wave 1.1 — design decision 5): a literal
746
+ // NODE_ENV check, deliberately independent of the broader `development`
747
+ // flag below (which also honors an explicit config.development escape
748
+ // hatch for source capture — unrelated to credential trust).
749
+ const nodeEnv = environment("NODE_ENV");
750
+ const isDevelopmentEnv = nodeEnv === "development";
751
+ const isProductionEnv = nodeEnv === "production";
752
+ // One condition arms BOTH the boot warning and the per-call fail-closed
753
+ // policy below, so the console.error tests pin exactly what arms refusal.
754
+ const baseUrlMissingInProduction = configuredBaseUrl === undefined && isProductionEnv;
755
+ if (baseUrlMissingInProduction) {
756
+ // Loud, once, at composition — never throws (a host that never makes a
757
+ // present-mode host tool call must keep booting). The actual refusal
758
+ // happens per-call below via untrustedOriginPolicy: "fail".
759
+ console.error("[vendo] VENDO_BASE_URL is not set in production. Present-mode host tool "
760
+ + "calls that need to forward the caller's credentials will fail instead "
761
+ + "of running unauthenticated. Set VENDO_BASE_URL to this deployment's "
762
+ + "public origin and restart the server.");
763
+ }
764
+ // Connectors seam (adapter rule): explicit array wins, VENDO_API_KEY
765
+ // defaults the Cloud tools connector for a wholly unset slot.
766
+ const resolvedConnectors = selectConnectors(config.connectors);
767
+ const actionsConfig = {
768
+ dir: ".",
769
+ ...(resolvedConnectors.length === 0 ? {} : { connectors: resolvedConnectors }),
770
+ ...(actAsSeam === undefined ? {} : { actAs: actAsSeam }),
771
+ ...(config.serverActions === undefined ? {} : { serverActions: config.serverActions }),
772
+ ...(configuredBaseUrl === undefined ? {} : { baseUrl: configuredBaseUrl, baseUrlTrusted: true }),
773
+ onPresentCredentialsNotForwarded: warnPresentCredentialsNotForwarded,
774
+ // 09-vendo §2 install-dx wave 1.1: production refuses a present-mode call
775
+ // it can't authenticate rather than quietly dropping the caller's
776
+ // credentials. Dev/test keep today's warn-and-continue (dev never reaches
777
+ // "untrusted-host-origin" at all — see onRequestOrigin below).
778
+ ...(baseUrlMissingInProduction ? { untrustedOriginPolicy: "fail" } : {}),
779
+ };
780
+ const actions = createActions(actionsConfig);
781
+ const doctor = {
782
+ present(ctx) {
783
+ const probes = createActions({ ...actionsConfig, dir: undefined, tools: [doctorPresentTool] });
784
+ return probes.execute({ id: "call_vendo_doctor_present", tool: doctorPresentTool.name, args: {} }, ctx);
785
+ },
786
+ actAs() {
787
+ const grant = {
788
+ id: "grt_vendo_doctor_act_as",
789
+ subject: DOCTOR_ACT_AS_PRINCIPAL.subject,
790
+ tool: doctorActAsTool.name,
791
+ descriptorHash: descriptorHash(doctorActAsTool),
792
+ scope: { kind: "tool" },
793
+ duration: "standing",
794
+ appId: DOCTOR_ACT_AS_APP_ID,
795
+ source: "automation",
796
+ grantedAt: new Date().toISOString(),
797
+ };
798
+ const ctx = {
799
+ principal: DOCTOR_ACT_AS_PRINCIPAL,
800
+ venue: "automation",
801
+ presence: "away",
802
+ sessionId: "session_vendo_doctor_act_as",
803
+ appId: DOCTOR_ACT_AS_APP_ID,
804
+ grant,
805
+ };
806
+ const probes = createActions({ ...actionsConfig, dir: undefined, tools: [doctorActAsTool] });
807
+ return probes.execute({ id: "call_vendo_doctor_act_as", tool: doctorActAsTool.name, args: {} }, ctx);
808
+ },
809
+ };
810
+ const boundTools = guard.bind(actions);
811
+ // 04 §6: compound steps route through the guard binding — grants, approvals,
812
+ // breakers, and audit see every real call; there is no second
813
+ // execution path. createActions reads invokeTool at execution time (same
814
+ // pattern as baseUrl above), so assigning after guard.bind is sound.
815
+ actionsConfig.invokeTool = (call, ctx) => boundTools.execute(call, ctx);
816
+ // Existing-agents Lane B — parked guarded calls with no Vendo thread: the
817
+ // parking registry the BYO tool pack executes through (guardedTools below),
818
+ // the resume-on-decide subscriber (same onApprovalDecision seam apps and
819
+ // automations ride), the wire's per-approval read, and the TTL sweep leg.
820
+ const byoApprovals = createByoApprovals({ guard, tools: boundTools, store });
821
+ const parkedCallTtlMs = validateParkedCallTtl(config.approvals);
822
+ const theme = dotVendoTheme();
823
+ // App design rules (spec 2026-07-20): explicit config wins; otherwise the
824
+ // file is re-read per generation (from the compose-time root) so brief
825
+ // tuning never needs a restart.
826
+ const configDesignRules = config.apps?.designRules?.trim();
827
+ const designRulesRoot = dotVendoRoot();
828
+ const designRules = configDesignRules
829
+ ? configDesignRules
830
+ : () => dotVendoFile("design-rules.md", designRulesRoot);
831
+ const pinBaselines = dotVendoPinBaselines();
832
+ // W3 — .vendo/semantics.json (field semantics + domain manifest), written
833
+ // by `vendo sync`, host-edited, treated as generation fact. Malformed →
834
+ // loud + absent, same stance as catalog.json.
835
+ const semanticsFile = (() => {
836
+ const raw = dotVendoFile("semantics.json");
837
+ if (raw === undefined)
838
+ return undefined;
839
+ try {
840
+ return semanticsFileSchema.parse(JSON.parse(raw));
841
+ }
842
+ catch (error) {
843
+ console.error(`[vendo] Failed to load .vendo/semantics.json: ${error instanceof Error ? error.message : String(error)}. Run "vendo sync" to regenerate the file.`);
844
+ return undefined;
845
+ }
846
+ })();
847
+ const catalog = mergeRuntimeCatalog(runtimeCatalogFromJson(dotVendoFile("catalog.json")), normalizeCatalogConfig(config.catalog));
848
+ // execution-v2 Lane C — the per-app box bearer store (hash rows are the
849
+ // authority) shared by the machine-env assembler below (mint at provision)
850
+ // and the wire's /box verification.
851
+ const appTokens = createAppTokens(store);
852
+ // The box env assembler the machine lifecycle calls at provision: rotate the
853
+ // app token, compose the callback doors from the operator-set public origin
854
+ // (the wire lives under it at BASE_PATH), and inject granted secrets — the
855
+ // apps runtime resolves the app's active grants and passes them here (Lane
856
+ // E), so only declared ∩ granted secret values enter the box. A BYO model
857
+ // key is just such a secret: declare it, grant it, and it rides the same
858
+ // injection path as any other key.
859
+ // execution-v2 Wave 3 — the box's inference door (the in-box coding agent's
860
+ // model). Explicit VENDO_INFERENCE_URL/KEY win; otherwise the BYO Anthropic
861
+ // key rides api.anthropic.com; otherwise VENDO_API_KEY rides the console's
862
+ // Anthropic-compatible model gateway — the same key that provisions the
863
+ // Cloud machine funds its model (chat inference already does, via devModel's
864
+ // vendo-cloud rung; a machine without this rung fails every in-box task).
865
+ const boxInference = () => {
866
+ const url = environment("VENDO_INFERENCE_URL");
867
+ const key = environment("VENDO_INFERENCE_KEY");
868
+ const model = environment("VENDO_INFERENCE_MODEL");
869
+ if (url !== undefined && key !== undefined) {
870
+ return { url, key, ...(model === undefined ? {} : { model }) };
871
+ }
872
+ const anthropic = environment("ANTHROPIC_API_KEY");
873
+ if (anthropic !== undefined) {
874
+ return { url: "https://api.anthropic.com", key: anthropic, ...(model === undefined ? {} : { model }) };
875
+ }
876
+ const cloud = cloudKeyOptions();
877
+ if (cloud !== undefined) {
878
+ // The gateway base mirrors devModel's vendo-cloud rung: `<console>/api/v1`.
879
+ const base = (cloud.baseUrl ?? "https://console.vendo.run").replace(/\/+$/, "");
880
+ // The gateway serves curated aliases only (vendo-default / vendo-fast /
881
+ // vendo-strong); the box harness's own default is a raw claude-* id the
882
+ // gateway would grace-remap, so pin the alias unless the operator chose
883
+ // a model via VENDO_INFERENCE_MODEL.
884
+ return {
885
+ url: base.endsWith("/api/v1") ? base : `${base}/api/v1`,
886
+ key: cloud.apiKey,
887
+ model: model ?? "vendo-default",
888
+ };
889
+ }
890
+ return undefined;
891
+ };
892
+ const machineEnv = async (app, grants) => {
893
+ const record = await store.records("vendo_apps").get(app.id);
894
+ const subject = record?.refs?.["subject"];
895
+ if (typeof subject !== "string") {
896
+ throw new VendoError("not-found", `app not found: ${app.id}`);
897
+ }
898
+ if (configuredBaseUrl === undefined) {
899
+ throw new VendoError("validation", "machine provisioning requires VENDO_BASE_URL — the box's callback URLs must be this deployment's public origin");
900
+ }
901
+ const boxBase = `${configuredBaseUrl.replace(/\/+$/, "")}${BASE_PATH}/box`;
902
+ const inferenceEndpoint = boxInference();
903
+ const built = await buildEnv(app, {
904
+ granted: grants?.grantedSecrets ?? new Set(),
905
+ secrets: config.secrets ?? envSecrets(),
906
+ storeUrl: boxBase,
907
+ hostUrl: boxBase,
908
+ appToken: await appTokens.mint(app.id, subject),
909
+ // The in-box agent's model door (box-env sets VENDO_INFERENCE_URL/KEY).
910
+ ...(inferenceEndpoint === undefined ? {} : { inference: async () => ({ url: inferenceEndpoint.url, key: inferenceEndpoint.key }) }),
911
+ });
912
+ // Pass the box's model choice through as a plain env var the harness reads.
913
+ if (inferenceEndpoint?.model !== undefined)
914
+ built.env["VENDO_INFERENCE_MODEL"] = inferenceEndpoint.model;
915
+ return built.env;
916
+ };
917
+ // Lane E — the implicit skin domains for the machine egress allowlist: the
918
+ // box must always reach its own boundary (store + host-callback surface on
919
+ // the deployment origin, and — Wave 3 — the inference endpoint host), never
920
+ // subject to declaration or approval. Assembled here because this file owns
921
+ // the same URLs it injects as VENDO_STORE_URL / VENDO_HOST_URL / inference.
922
+ const implicitMachineDomains = () => {
923
+ const domains = new Set();
924
+ const add = (value) => {
925
+ if (value === undefined)
926
+ return;
927
+ try {
928
+ domains.add(new URL(value).hostname);
929
+ }
930
+ catch { /* not a URL */ }
931
+ };
932
+ add(configuredBaseUrl);
933
+ add(boxInference()?.url);
934
+ return [...domains];
935
+ };
936
+ const boxTemplate = environment("VENDO_BOX_TEMPLATE");
937
+ const boxEditTimeoutMs = positiveIntegerEnv("VENDO_BOX_EDIT_TIMEOUT_MS");
938
+ const boxEditPollMs = positiveIntegerEnv("VENDO_BOX_EDIT_POLL_MS");
939
+ // ADAPTER RULE, share/publish seam: the apps block never reads the
940
+ // environment — VENDO_API_KEY fills its CloudAppsClient slot HERE, at the
941
+ // composition seam; unfilled, share/publish refuse with cloud-required.
942
+ const appsCloud = cloudKeyOptions();
943
+ // Wave 9 — the arming seam for ladder-authored automations: filled with the
944
+ // automations engine composed BELOW (arming only happens inside requests,
945
+ // which run after createVendo returns, so the closure reference is safe —
946
+ // same pattern as the connections loadout seed).
947
+ let automationsForArming;
948
+ const apps = createApps({
949
+ store,
950
+ guard,
951
+ tools: boundTools,
952
+ model: inference.model,
953
+ catalog,
954
+ pinBaselines,
955
+ // execution-v2 Waves 4+9 — the layer-2/3 experimental opt-ins, host-config
956
+ // only (never an env var: enabling machine-backed execution or a surface
957
+ // that runs generated web apps is a deliberate per-project decision).
958
+ ...(config.apps?.experimentalServedApps === undefined ? {} : { experimentalServedApps: config.apps.experimentalServedApps }),
959
+ ...(config.apps?.experimentalMachines === undefined ? {} : { experimentalMachines: config.apps.experimentalMachines }),
960
+ // Wave 9 — a ladder-authored automation is armed through the automations
961
+ // engine's own enable(), so the 07 §3 grant-capture flow runs at creation
962
+ // and the missing standing-grant approvals surface on the edit result.
963
+ armAutomation: async (appId, armCtx) => {
964
+ if (automationsForArming === undefined) {
965
+ throw new VendoError("not-implemented", "the automations engine is not composed yet");
966
+ }
967
+ return automationsForArming.enable(appId, armCtx);
968
+ },
969
+ ...(config.paint === undefined ? {} : { paint: config.paint }),
970
+ ...(theme === undefined ? {} : { theme }),
971
+ designRules,
972
+ ...(appsCloud === undefined ? {} : { cloud: cloudApps(appsCloud) }),
973
+ ...(semanticsFile === undefined ? {} : { semantics: semanticsFile.tools, domains: semanticsFile.domains }),
974
+ secrets: config.secrets ?? envSecrets(),
975
+ // execution-v2 — the machine lifecycle's seams: the selected v2 adapter
976
+ // (every provider speaks the canonical seam since the Wave 5 Cloud port)
977
+ // and Lane C's env assembly. The box template (Node + the in-box agent
978
+ // harness) is set by VENDO_BOX_TEMPLATE.
979
+ machine: {
980
+ ...(sandbox.adapter === undefined ? {} : { sandbox: sandbox.adapter }),
981
+ buildEnv: machineEnv,
982
+ implicitDomains: implicitMachineDomains(),
983
+ ...(boxTemplate === undefined ? {} : { template: boxTemplate }),
984
+ // The in-box agent edit is a minutes-long loop; operators tune its
985
+ // long-poll budget when a base image or task needs longer than the
986
+ // 8-minute default.
987
+ ...(boxEditTimeoutMs === undefined ? {} : { boxEditTimeoutMs }),
988
+ ...(boxEditPollMs === undefined ? {} : { boxEditPollMs }),
989
+ },
990
+ });
991
+ resolveAppToolRisk = apps.agentToolRisk;
992
+ actions.add(apps.agentTools());
993
+ const missSurface = actions.descriptors()
994
+ .then(capabilitySurfaceSnapshot)
995
+ .catch(() => capabilitySurfaceSnapshot([]));
996
+ const missCapture = createCapabilityMissCapture({ surface: missSurface });
997
+ // AGENT-1/2 — 03 §3: the host product brief (init writes .vendo/brief.md)
998
+ // and the catalog+theme summary feed the system prompt; prompt.ts places
999
+ // them (brief = Product section; summary only where trees render).
1000
+ const brief = dotVendoFile("brief.md")?.trim();
1001
+ const promptCatalog = catalogThemeSummary(catalog, theme);
1002
+ const system = brief || promptCatalog !== undefined
1003
+ ? {
1004
+ ...(brief ? { product: brief } : {}),
1005
+ ...(promptCatalog === undefined ? {} : { catalog: promptCatalog }),
1006
+ }
1007
+ : undefined;
1008
+ const agent = createAgent({
1009
+ model: inference.model,
1010
+ tools: boundTools,
1011
+ guard,
1012
+ store,
1013
+ ...(system === undefined ? {} : { system }),
1014
+ context: {
1015
+ toolOutputCap: config.agent?.toolOutputCap ?? DEFAULT_TOOL_OUTPUT_CAP,
1016
+ ...(config.agent?.maxOutputTokens === undefined ? {} : { maxOutputTokens: config.agent.maxOutputTokens }),
1017
+ ...(config.agent?.historyWindow === undefined ? {} : { historyWindow: config.agent.historyWindow }),
1018
+ ...(config.agent?.maxSteps === undefined ? {} : { maxSteps: config.agent.maxSteps }),
1019
+ },
1020
+ capabilityMiss: {
1021
+ hostId: missCapture.hostId,
1022
+ surface: missSurface.then(({ hash }) => ({ format: "vendo/tools@1", hash })),
1023
+ emit: (event) => missCapture.record(event),
1024
+ },
1025
+ // ENG-252: the agent starts with a bounded loadout and discovers the rest via
1026
+ // `vendo_tools_search`. The search seam is the SAME guard-bound registry the
1027
+ // agent executes through — a searched-in tool has no unguarded path.
1028
+ toolSearch: {
1029
+ search: (query, options) => actions.search(query, options),
1030
+ // Connection-scoped loadout seed (spec 2026-07-20): each turn starts
1031
+ // with host tools + the principal's connected toolkits — never an
1032
+ // alphabetical slice of a lazy catalog. `connections` is declared below
1033
+ // this composition; turns only run after createVendo returns, so the
1034
+ // closure reference is safe.
1035
+ seed: (ctx) => loadoutSeedFor(ctx),
1036
+ ...(config.agent?.maxInitialTools === undefined ? {} : { maxInitialTools: config.agent.maxInitialTools }),
1037
+ ...(config.agent?.loadout === undefined ? {} : { loadout: config.agent.loadout }),
1038
+ },
1039
+ });
1040
+ // Per-subject connected-toolkit lookups are cached briefly so a turn never
1041
+ // pays a broker round-trip it doesn't need; failures degrade to host tools
1042
+ // only (warn, never the turn). Bounded so long-lived deployments don't grow.
1043
+ const CONNECTED_TOOLKITS_TTL_MS = 60_000;
1044
+ const connectedToolkitsCache = new Map();
1045
+ async function loadoutSeedFor(ctx) {
1046
+ const subject = ctx.principal.subject;
1047
+ const cached = connectedToolkitsCache.get(subject);
1048
+ let toolkits;
1049
+ if (cached !== undefined && Date.now() - cached.at < CONNECTED_TOOLKITS_TTL_MS) {
1050
+ toolkits = cached.toolkits;
1051
+ }
1052
+ else {
1053
+ try {
1054
+ const accounts = await connections.list(ctx.principal);
1055
+ toolkits = [...new Set(accounts.filter((account) => account.status === "active").map((account) => account.toolkit))];
1056
+ }
1057
+ catch (error) {
1058
+ console.warn("[vendo] connected-toolkits lookup failed; seeding host tools only:", error instanceof Error ? error.message : error);
1059
+ toolkits = [];
1060
+ }
1061
+ if (connectedToolkitsCache.size > 1_000)
1062
+ connectedToolkitsCache.clear();
1063
+ connectedToolkitsCache.set(subject, { at: Date.now(), toolkits });
1064
+ }
1065
+ return actions.loadoutSeed(toolkits);
1066
+ }
1067
+ // 02-store §4 (kill-list B3) TTL sweep: erase every idle ephemeral session's
1068
+ // disk rows, then cascade each swept subject into the agent's in-memory
1069
+ // threads (store-first — a concurrent request then fails closed at the store
1070
+ // rather than finding threads without store state). Disabled when ttlMs is 0.
1071
+ const runSweep = async () => {
1072
+ // Existing-agents Lane B — expire orphaned parked BYO calls on the same
1073
+ // cadence (deny path, idempotent); disabled by parkedCallTtlMs 0.
1074
+ if (parkedCallTtlMs > 0) {
1075
+ await byoApprovals.sweepExpired(parkedCallTtlMs, sessionNow());
1076
+ // Spec 2026-07-20 (#5): the same backstop over the general approvals
1077
+ // collection. Chat approvals are abandoned on the next thread turn and
1078
+ // BYO parked calls swept above, but away/automation/app approvals and
1079
+ // approvals stranded by a mid-stream turn failure have no resuming turn —
1080
+ // this TTL sweep denies them (idempotent) so the queue self-heals instead
1081
+ // of piling up. Shares the parked-call TTL; disabled by the same 0.
1082
+ if (guard.sweepExpiredApprovals !== undefined) {
1083
+ try {
1084
+ await guard.sweepExpiredApprovals(parkedCallTtlMs, sessionNow());
1085
+ }
1086
+ catch (error) {
1087
+ console.error("[vendo] approval TTL sweep failed", {
1088
+ error: error instanceof Error ? error.message : String(error),
1089
+ });
1090
+ }
1091
+ }
1092
+ }
1093
+ if (sessionsConfig.ttlMs <= 0)
1094
+ return;
1095
+ for (const subject of await sessionOps.sweep(sessionsConfig.ttlMs, sessionNow())) {
1096
+ agent.evictSubject(subject);
1097
+ }
1098
+ };
1099
+ const sweepEnabled = sessionsConfig.ttlMs > 0 || parkedCallTtlMs > 0;
1100
+ // Long-lived hosts also get a background sweep on an UNREF'd timer (automations
1101
+ // engine pattern) so an idle process still reclaims sessions with no traffic;
1102
+ // unref'd means it never keeps the event loop alive. Torn down with the store.
1103
+ if (sweepEnabled) {
1104
+ const sweepTimer = setInterval(() => {
1105
+ runSweep().catch((error) => {
1106
+ console.warn(`[vendo] session sweep failed; will retry next interval: ${error instanceof Error ? error.message : String(error)}`);
1107
+ });
1108
+ }, sessionsConfig.sweepIntervalMs);
1109
+ sweepTimer.unref?.();
1110
+ const closeStore = store.close.bind(store);
1111
+ store.close = async () => {
1112
+ clearInterval(sweepTimer);
1113
+ await closeStore();
1114
+ };
1115
+ }
1116
+ // Wave 2 (Cloud auto): a keyed deployment's schedule- and external-triggered
1117
+ // automations already run on Vendo Cloud — its scheduler fires due schedules and
1118
+ // Composio delivers external events straight to Cloud. If this LOCAL engine also
1119
+ // fired them, a keyed deployment would double-run every automation. Under the hosted
1120
+ // store, Cloud is the firing authority for those two kinds; host-event automations
1121
+ // (vendo.emit) are untouched — they're invoked directly by this host process, not
1122
+ // scheduled or delivered, so there's nothing for Cloud to duplicate. One warn per
1123
+ // composition (not per tick), same posture as hostedSessionOps' door warn above.
1124
+ const hostedStoreComposed = isHostedStore(store);
1125
+ if (hostedStoreComposed) {
1126
+ console.warn("[vendo] Vendo Cloud is the hosted store for this deployment: schedule and external-trigger "
1127
+ + "automations are Cloud's job (its scheduler and Composio delivery already fire them for this "
1128
+ + "deployment) — the local automations engine will not fire them itself, to avoid double-running "
1129
+ + "them. Host-event automations (vendo.emit) are unaffected.");
1130
+ }
1131
+ const automations = createAutomations({
1132
+ apps,
1133
+ tools: boundTools,
1134
+ guard,
1135
+ store,
1136
+ runner: agent.asRunner(),
1137
+ ...(hostedStoreComposed ? { localTriggerKinds: new Set() } : {}),
1138
+ });
1139
+ automationsForArming = automations;
1140
+ // 04-actions §3 — per-principal connected accounts, selected by the adapter
1141
+ // rule at this composition seam (selectConnections above).
1142
+ const connections = selectConnections(config.connections, resolvedConnectors);
1143
+ // 10-mcp §1 — construct the door from the parts already assembled: the SAME
1144
+ // guard-bound registry chat/apps/automations use, the guard (its core seam is
1145
+ // what the door holds for auth audit), the store (a StoreAdapter for the door's
1146
+ // own protocol state), the host's oauth seam, and an AppsPort view of `apps`.
1147
+ // `mcp: true` and `mcp: {…}` both open the door; the object form carries
1148
+ // door options (an explicit `baseUrl` overrides the VENDO_BASE_URL default).
1149
+ const mcpOptions = typeof config.mcp === "object" && config.mcp !== null
1150
+ ? config.mcp
1151
+ : config.mcp === true
1152
+ ? {}
1153
+ : undefined;
1154
+ let door;
1155
+ if (mcpOptions !== undefined) {
1156
+ if (oauthSeam === undefined) {
1157
+ throw new VendoError("validation", "createVendo({ mcp: true }) requires a HostOAuthAdapter (10-mcp §3) — from `oauth` or an `auth` preset carrying one: the door mints door principals through it and cannot open without one.");
1158
+ }
1159
+ // AppsRuntime.open adds a "resuming" variant AppsPort (tree | http) does not
1160
+ // carry. The door is a viewer + runner (10-mcp §4), so a server app still
1161
+ // waking up has no surface to hand back over MCP — signal it as an in-band
1162
+ // tool error (the door catches VendoError and preserves the code).
1163
+ const appsPort = {
1164
+ list: (ctx) => apps.list(ctx),
1165
+ async open(appId, ctx) {
1166
+ const opened = await apps.open(appId, ctx);
1167
+ if (opened.kind === "tree")
1168
+ return { kind: "tree", payload: opened.payload };
1169
+ if (opened.kind === "http")
1170
+ return { kind: "http", url: opened.url };
1171
+ throw new VendoError("not-implemented", "This is a server app resuming in-product; open it in the host to use it over MCP.");
1172
+ },
1173
+ call: (appId, ref, args, ctx) => apps.call(appId, ref, args, ctx),
1174
+ };
1175
+ // 10-mcp §5 — pin the door's canonical mount so a cold umbrella's server
1176
+ // card advertises the right transport URL (BASE_PATH/mcp) before any request
1177
+ // teaches it, and learned paths never override it. The door's canonical
1178
+ // public base (discovery origins + RFC 8707 audience) is the operator-set
1179
+ // VENDO_BASE_URL — behind a reverse proxy the request URL carries the
1180
+ // proxy-INTERNAL origin and must not shape what discovery advertises
1181
+ // (ENG-333). An explicit `mcp.baseUrl` overrides the env default for
1182
+ // compositions whose door origin differs from the route-binding origin.
1183
+ const doorBaseUrl = mcpOptions.baseUrl ?? configuredBaseUrl;
1184
+ door = createMcpDoor({
1185
+ tools: boundTools,
1186
+ guard,
1187
+ store,
1188
+ oauth: oauthSeam,
1189
+ apps: appsPort,
1190
+ mount: MCP_MOUNT,
1191
+ ...(doorBaseUrl === undefined ? {} : { baseUrl: doorBaseUrl }),
1192
+ // 10-mcp §3.1/§3.2 — broker-fronted compositions: trust the external
1193
+ // authorization server's tokens and answer its login federation.
1194
+ ...(mcpOptions.remoteAs === undefined ? {} : { remoteAs: mcpOptions.remoteAs }),
1195
+ ...(mcpOptions.federation === undefined ? {} : { federation: mcpOptions.federation }),
1196
+ ...(theme === undefined ? {} : { theme }),
1197
+ });
1198
+ }
1199
+ const sessionId = `session_${globalThis.crypto.randomUUID()}`;
1200
+ // Anonymous principals are minted per-CLIENT in the handler (opaque cookie
1201
+ // pointer; the store's vendo_sessions row is the authority — kill-list B3).
1202
+ // An https VENDO_BASE_URL means TLS terminates at a trusted proxy and requests
1203
+ // arrive here as http — anon cookies must still be Secure/__Host- then.
1204
+ const trustedBaseIsHttps = (() => {
1205
+ if (configuredBaseUrl === undefined)
1206
+ return false;
1207
+ try {
1208
+ return new URL(configuredBaseUrl).protocol === "https:";
1209
+ }
1210
+ catch {
1211
+ return false;
1212
+ }
1213
+ })();
1214
+ const development = config.development !== undefined
1215
+ ? config.development !== false
1216
+ : isDevelopmentEnv;
1217
+ const developmentPaths = typeof config.development === "object" ? config.development : {};
1218
+ const runtimeCapture = development ? createRuntimeCapture(developmentPaths) : null;
1219
+ const handler = createWireHandler({
1220
+ principal: resolvePrincipal,
1221
+ ready,
1222
+ trustedBaseIsHttps,
1223
+ sessionId,
1224
+ store,
1225
+ telemetry: telemetryClient(config.telemetry),
1226
+ agent,
1227
+ guard,
1228
+ apps,
1229
+ // execution-v2 Lane C — the /box surfaces: tool calls through the SAME
1230
+ // guard binding, bearer verification over the composed store.
1231
+ tools: boundTools,
1232
+ appTokens,
1233
+ automations,
1234
+ byoApprovals,
1235
+ connections,
1236
+ sandbox: sandbox.venue,
1237
+ model: inference.venue,
1238
+ doctor,
1239
+ mcp: mcpOptions !== undefined,
1240
+ development,
1241
+ sessions: {
1242
+ ttlMs: sessionsConfig.ttlMs,
1243
+ sweepIntervalMs: sessionsConfig.sweepIntervalMs,
1244
+ now: sessionNow,
1245
+ },
1246
+ sessionStore: sessionOps,
1247
+ sweep: runSweep,
1248
+ sweepEnabled,
1249
+ ...(door === undefined ? {} : { door }),
1250
+ ...(runtimeCapture === null ? {} : { runtimeCapture }),
1251
+ onRequestOrigin: (origin) => {
1252
+ // Same-origin default for route-binding execution (04): no VENDO_BASE_URL
1253
+ // → the wire's own origin, learned from the first VALIDATED request and
1254
+ // then fixed.
1255
+ if (actionsConfig.baseUrl === undefined) {
1256
+ actionsConfig.baseUrl = origin;
1257
+ // 09-vendo §2 install-dx wave 1.1: NODE_ENV=development trusts its own
1258
+ // learned origin — credentials forward to the wire's own route
1259
+ // bindings with zero config. Every other environment (including
1260
+ // NODE_ENV=test) keeps the learned origin UNTRUSTED exactly as
1261
+ // before, so a spoofed Host on any early request can never turn it
1262
+ // into a credential-exfiltration target (04 §4).
1263
+ actionsConfig.baseUrlTrusted = isDevelopmentEnv;
1264
+ }
1265
+ },
1266
+ });
1267
+ return {
1268
+ handler,
1269
+ async emit(event, payload, principal) {
1270
+ await ready;
1271
+ return automations.emit(event, payload, principal);
1272
+ },
1273
+ agent,
1274
+ guard,
1275
+ guardedTools: byoApprovals.registry,
1276
+ apps,
1277
+ automations,
1278
+ actions,
1279
+ connections,
1280
+ store,
1281
+ };
1282
+ }
1283
+ /** 09-vendo §2 — adapt the fetch handler to a Next.js catch-all route module.
1284
+ PATCH stays exported even with no PATCH-only wire route left: Next.js
1285
+ 405s any method the module does not export before the request ever
1286
+ reaches `vendo.handler`, so dropping it would turn e.g. `PATCH
1287
+ /api/vendo/orgs/:id/members/:subject` into a framework 405 instead of
1288
+ the wire's own `cloud-required` seam (the org routes matched ANY
1289
+ method — orgsRoutes in wire/misc.ts). PUT carries the box callback
1290
+ surface's durable-row writes (execution-v2 Lane C:
1291
+ PUT /api/vendo/box/rows/:collection/:id). */
1292
+ export function nextVendoHandler(vendo) {
1293
+ const handle = (request) => vendo.handler(request);
1294
+ return { GET: handle, POST: handle, PUT: handle, PATCH: handle, DELETE: handle };
1295
+ }
1296
+ /** 10-mcp §5 — adapt the fetch handler to a Next.js `app/.well-known/[...vendo]/
1297
+ route.ts` module. The four discovery documents the door serves (RFC 9728/
1298
+ 8414 metadata for its fixed mount, plus the SEP-2127 server card) live at
1299
+ ORIGIN-ROOT paths, outside BASE_PATH — a host's `/api/vendo` catch-all route
1300
+ never sees them, because Next.js dispatches by directory structure, not by
1301
+ the wire's own routing. This file exists so that directory gets a handler
1302
+ too, one that shares DOOR_WELL_KNOWN_PATHS with the wire itself (the SAME
1303
+ set `isDoorPath` matches) instead of a hand-copied allowlist that can drift
1304
+ from it. A request whose pathname is exactly one of those four paths
1305
+ forwards to `vendo.handler` (which independently confirms it's a door path
1306
+ and, if `mcp` is configured, serves it — the check here is only about
1307
+ which requests reach the wire at all); anything else answers 404 with an
1308
+ empty body, mirroring the hand-written route this replaces. With `mcp` left
1309
+ unconfigured, `vendo.handler` still recognizes these four paths but has no
1310
+ door to serve them, so the request falls through to the wire's ordinary
1311
+ not-found response — never a 500. */
1312
+ export function wellKnownVendoHandler(vendo) {
1313
+ const handle = (request) => {
1314
+ const { pathname } = new URL(request.url);
1315
+ return DOOR_WELL_KNOWN_PATHS.has(pathname)
1316
+ ? vendo.handler(request)
1317
+ : Promise.resolve(new Response(null, { status: 404 }));
1318
+ };
1319
+ return { GET: handle, POST: handle };
1320
+ }