@vendoai/vendo 0.4.1 → 0.4.3

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.
@@ -19,6 +19,17 @@ export interface DeviceLoginOptions {
19
19
  file that lets a fresh run resume a still-open ceremony (#479). */
20
20
  home?: string;
21
21
  }
22
+ /**
23
+ * Write-preflight (0.4.1 E2E cert M4): prove `.env.local` is writable BEFORE
24
+ * any claim is opened or redeemed. Sandboxed agent runs — headless Claude
25
+ * Code protects env files even under --dangerously-skip-permissions — can
26
+ * deny the write; without this check the ceremony redeems the single-use
27
+ * claim, the key mints server-side, and the write failure loses it. A real
28
+ * append-mode open is the probe (permission checks like `access(W_OK)` don't
29
+ * see sandbox policies, which deny at open time); a probe-created empty file
30
+ * is removed again. Returns the failure detail, or null when writable.
31
+ */
32
+ export declare function preflightEnvLocalWrite(root: string): Promise<string | null>;
22
33
  /**
23
34
  * `vendo login` — the top-level command surface: the identical ceremony
24
35
  * wrapped in one `command_run` row (command "login", TELEMETRY.md). The
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { open, stat, unlink } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { option } from "./args.js";
4
5
  import { isVendoKey, resolveCloudBaseUrl } from "./client.js";
@@ -27,6 +28,30 @@ function defaultOpenBrowser(url) {
27
28
  const { command, args } = browserOpenCommand(process.platform, url);
28
29
  execFile(command, args, () => undefined); // best-effort: the printed URL is the fallback
29
30
  }
31
+ /**
32
+ * Write-preflight (0.4.1 E2E cert M4): prove `.env.local` is writable BEFORE
33
+ * any claim is opened or redeemed. Sandboxed agent runs — headless Claude
34
+ * Code protects env files even under --dangerously-skip-permissions — can
35
+ * deny the write; without this check the ceremony redeems the single-use
36
+ * claim, the key mints server-side, and the write failure loses it. A real
37
+ * append-mode open is the probe (permission checks like `access(W_OK)` don't
38
+ * see sandbox policies, which deny at open time); a probe-created empty file
39
+ * is removed again. Returns the failure detail, or null when writable.
40
+ */
41
+ export async function preflightEnvLocalWrite(root) {
42
+ const path = join(root, ".env.local");
43
+ const existedBefore = await stat(path).then(() => true, () => false);
44
+ try {
45
+ const handle = await open(path, "a");
46
+ await handle.close();
47
+ if (!existedBefore)
48
+ await unlink(path).catch(() => undefined);
49
+ return null;
50
+ }
51
+ catch (error) {
52
+ return error instanceof Error ? error.message : String(error);
53
+ }
54
+ }
30
55
  function ceremonyFrom(value) {
31
56
  const body = value;
32
57
  if (typeof body?.claim_token !== "string"
@@ -91,13 +116,28 @@ export async function runDeviceLogin(args, options = {}) {
91
116
  const waitSeconds = waitRaw !== undefined && /^\d+$/.test(waitRaw) ? Number(waitRaw) : undefined;
92
117
  const waitMs = waitSeconds === undefined ? undefined : waitSeconds * 1000;
93
118
  const pendingHome = options.home === undefined ? {} : { home: options.home };
119
+ const claimCwd = options.root ?? process.cwd();
120
+ // Prove the key's landing file is writable BEFORE the ceremony touches the
121
+ // network: a claim is single-use, so a post-approval write failure loses
122
+ // the minted key (M4). This is a WRITE failure, distinct from a timeout —
123
+ // the error says so instead of the generic polling copy.
124
+ const writeProblem = await preflightEnvLocalWrite(claimCwd);
125
+ if (writeProblem !== null) {
126
+ output.error(`Cannot write ${join(claimCwd, ".env.local")} (${writeProblem}). ` +
127
+ "`vendo login` lands the minted key there, so the ceremony was NOT started and no key was minted. " +
128
+ "This is a file-write problem, not a timeout: fix write access (sandboxed agent runs often deny env-file " +
129
+ "writes — re-run outside the sandbox or have your human run `vendo login`), then re-run.");
130
+ return 1;
131
+ }
94
132
  try {
95
133
  // A still-open claim from a dead process (#479): resume polling it so the
96
134
  // human's late approval — against the code they were already shown —
97
- // still lands the key. An expired or unreadable file is discarded (a
98
- // fresh ceremony overwrites it below).
99
- const pending = await readPendingClaim(pendingHome);
100
- const resume = pending !== null && pending.api_url === base && pending.expires_at > now()
135
+ // still lands the key. Claims are scoped per project directory (0.4.2):
136
+ // only THIS cwd's ceremony is ever resumed, so concurrent logins in other
137
+ // repos neither clobber this one nor get resumed by it. An expired or
138
+ // unreadable file is discarded (a fresh ceremony overwrites it below).
139
+ const pending = await readPendingClaim(claimCwd, pendingHome);
140
+ const resume = pending !== null && pending.api_url === base && pending.cwd === claimCwd && pending.expires_at > now()
101
141
  ? pending
102
142
  : null;
103
143
  let claimToken;
@@ -173,8 +213,22 @@ export async function runDeviceLogin(args, options = {}) {
173
213
  if (typeof key !== "string" || !isVendoKey(key)) {
174
214
  throw new Error("Vendo Cloud returned an invalid credential");
175
215
  }
176
- await upsertEnvLocal(root, "VENDO_API_KEY", key);
177
- await deletePendingClaim(pendingHome);
216
+ try {
217
+ await upsertEnvLocal(root, "VENDO_API_KEY", key);
218
+ }
219
+ catch (writeError) {
220
+ // The claim is consumed and the key minted — a resume can never
221
+ // succeed, so the pending file goes; and this must read as a WRITE
222
+ // failure, never the timeout/polling copy (M4). The key is never
223
+ // printed, so the minted key is unrecoverable here: name the
224
+ // console cleanup explicitly.
225
+ await deletePendingClaim(claimCwd, pendingHome);
226
+ throw new Error(`Approved and the key was minted, but writing it to ${join(root, ".env.local")} failed ` +
227
+ `(${writeError instanceof Error ? writeError.message : String(writeError)}). ` +
228
+ "The key was NOT saved and is not shown anywhere: revoke it in the console (Keys page), " +
229
+ "fix write access to .env.local, and run `vendo login` again.");
230
+ }
231
+ await deletePendingClaim(claimCwd, pendingHome);
178
232
  // Never print the key itself — .env.local is the hand-off, last4 the
179
233
  // receipt. A resumed run names the full path: it may differ from cwd.
180
234
  output.log(`Approved — wrote VENDO_API_KEY (…${key.slice(-4)}) to ${resume !== null ? join(root, ".env.local") : ".env.local"}.`);
@@ -190,11 +244,11 @@ export async function runDeviceLogin(args, options = {}) {
190
244
  if (error === "slow_down")
191
245
  return "slow_down"; // RFC 8628 §3.5
192
246
  if (error === "expired_token") {
193
- await deletePendingClaim(pendingHome);
247
+ await deletePendingClaim(claimCwd, pendingHome);
194
248
  throw new Error("The code expired before it was approved; run `vendo login` again.");
195
249
  }
196
250
  if (error === "access_denied") {
197
- await deletePendingClaim(pendingHome);
251
+ await deletePendingClaim(claimCwd, pendingHome);
198
252
  throw new Error("Your human denied the request — no key was minted.");
199
253
  }
200
254
  // Any other token error is terminal for THIS claim (invalid_grant =
@@ -202,7 +256,7 @@ export async function runDeviceLogin(args, options = {}) {
202
256
  // again). Delete the pending file so the next `vendo login` opens a
203
257
  // fresh claim instead of resuming into the same error forever — the
204
258
  // exact trap a live install hit.
205
- await deletePendingClaim(pendingHome);
259
+ await deletePendingClaim(claimCwd, pendingHome);
206
260
  const description = poll.body?.error_description;
207
261
  throw new Error(typeof description === "string"
208
262
  ? description
@@ -230,7 +284,7 @@ export async function runDeviceLogin(args, options = {}) {
230
284
  if (result === "slow_down")
231
285
  intervalMs += 5000;
232
286
  }
233
- await deletePendingClaim(pendingHome);
287
+ await deletePendingClaim(claimCwd, pendingHome);
234
288
  throw new Error("The code expired before it was approved; run `vendo login` again.");
235
289
  }
236
290
  // Bounded budget: poll immediately, then pace by interval, stopping at
@@ -243,7 +297,7 @@ export async function runDeviceLogin(args, options = {}) {
243
297
  if (result === "slow_down")
244
298
  intervalMs += 5000;
245
299
  if (now() >= deadline) {
246
- await deletePendingClaim(pendingHome);
300
+ await deletePendingClaim(claimCwd, pendingHome);
247
301
  throw new Error("The code expired before it was approved; run `vendo login` again.");
248
302
  }
249
303
  if (now() >= pollDeadline)
@@ -23,7 +23,10 @@ export interface PendingClaimOptions {
23
23
  home?: string;
24
24
  }
25
25
  /** An unreadable or malformed file reads as "no pending claim" — the caller
26
- discards it by opening (and persisting) a fresh ceremony. */
27
- export declare function readPendingClaim(options?: PendingClaimOptions): Promise<PendingClaim | null>;
26
+ discards it by opening (and persisting) a fresh ceremony. Only a claim
27
+ opened for THIS cwd is ever returned. A pre-0.4.2 machine-global file is
28
+ honored (and migrated) only when its recorded cwd matches; someone else's
29
+ ceremony is never resumed. */
30
+ export declare function readPendingClaim(cwd: string, options?: PendingClaimOptions): Promise<PendingClaim | null>;
28
31
  export declare function writePendingClaim(claim: PendingClaim, options?: PendingClaimOptions): Promise<void>;
29
- export declare function deletePendingClaim(options?: PendingClaimOptions): Promise<void>;
32
+ export declare function deletePendingClaim(cwd: string, options?: PendingClaimOptions): Promise<void>;
@@ -1,7 +1,17 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
- function pendingClaimPath(options = {}) {
5
+ /** Claims are keyed by the project directory they were opened for. A single
6
+ machine-global file let two concurrent `vendo login` runs clobber each
7
+ other, and let project A resume (and receive the key for) project B's
8
+ ceremony — found by the 0.4.1 E2E certification campaign. */
9
+ function pendingClaimPath(cwd, options = {}) {
10
+ const name = `${createHash("sha256").update(cwd).digest("hex").slice(0, 16)}.json`;
11
+ return join(options.home ?? homedir(), ".vendo", "pending-claims", name);
12
+ }
13
+ /** The pre-0.4.2 machine-global location — read once for migration. */
14
+ function legacyPendingClaimPath(options = {}) {
5
15
  return join(options.home ?? homedir(), ".vendo", "pending-claim.json");
6
16
  }
7
17
  function isPendingClaim(value) {
@@ -16,23 +26,37 @@ function isPendingClaim(value) {
16
26
  && typeof claim.api_url === "string"
17
27
  && typeof claim.cwd === "string";
18
28
  }
19
- /** An unreadable or malformed file reads as "no pending claim" — the caller
20
- discards it by opening (and persisting) a fresh ceremony. */
21
- export async function readPendingClaim(options = {}) {
29
+ async function readClaimFile(path) {
22
30
  try {
23
- const value = JSON.parse(await readFile(pendingClaimPath(options), "utf8"));
31
+ const value = JSON.parse(await readFile(path, "utf8"));
24
32
  return isPendingClaim(value) ? value : null;
25
33
  }
26
34
  catch {
27
35
  return null;
28
36
  }
29
37
  }
38
+ /** An unreadable or malformed file reads as "no pending claim" — the caller
39
+ discards it by opening (and persisting) a fresh ceremony. Only a claim
40
+ opened for THIS cwd is ever returned. A pre-0.4.2 machine-global file is
41
+ honored (and migrated) only when its recorded cwd matches; someone else's
42
+ ceremony is never resumed. */
43
+ export async function readPendingClaim(cwd, options = {}) {
44
+ const scoped = await readClaimFile(pendingClaimPath(cwd, options));
45
+ if (scoped !== null)
46
+ return scoped.cwd === cwd ? scoped : null;
47
+ const legacy = await readClaimFile(legacyPendingClaimPath(options));
48
+ if (legacy === null || legacy.cwd !== cwd)
49
+ return null;
50
+ await writePendingClaim(legacy, options);
51
+ await rm(legacyPendingClaimPath(options), { force: true });
52
+ return legacy;
53
+ }
30
54
  export async function writePendingClaim(claim, options = {}) {
31
- const path = pendingClaimPath(options);
55
+ const path = pendingClaimPath(claim.cwd, options);
32
56
  await mkdir(join(path, ".."), { recursive: true, mode: 0o700 });
33
57
  await writeFile(path, `${JSON.stringify(claim, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
34
58
  await chmod(path, 0o600);
35
59
  }
36
- export async function deletePendingClaim(options = {}) {
37
- await rm(pendingClaimPath(options), { force: true });
60
+ export async function deletePendingClaim(cwd, options = {}) {
61
+ await rm(pendingClaimPath(cwd, options), { force: true });
38
62
  }
@@ -17,9 +17,11 @@ export declare const DOCTOR_ERROR_CODES: {
17
17
  readonly "E-WIRE-003": "the Next.js catch-all handler app/api/vendo/[...vendo]/route.ts is missing";
18
18
  readonly "E-WIRE-004": "the Next.js root layout is not wrapped in <VendoRoot>";
19
19
  readonly "E-WIRE-005": "the @vendoai/vendo (or vendoai alias) dependency is not declared";
20
+ readonly "E-WIRE-006": "no visible agent surface is mounted (<VendoRoot> alone renders nothing)";
20
21
  readonly "E-CFG-001": "a required .vendo/ config file is missing";
21
22
  readonly "E-CFG-002": ".vendo/data/.gitignore is missing";
22
23
  readonly "E-DEP-001": "the installed ai package is a major version @vendoai/vendo does not support";
24
+ readonly "E-DEP-002": "the running wire serves a different @vendoai/vendo version than this CLI (split-brain install)";
23
25
  readonly "E-UI-001": "an ejected surface predates the installed @vendoai/ui";
24
26
  readonly "E-DEV-001": "the dev server could not be started for the probe";
25
27
  readonly "E-LIVE-001": "/status returned an invalid composition response";
@@ -27,6 +29,7 @@ export declare const DOCTOR_ERROR_CODES: {
27
29
  readonly "E-LIVE-003": "/status returned an invalid execution venue";
28
30
  readonly "E-LIVE-004": "no execution venue is configured";
29
31
  readonly "E-LIVE-005": "the host /status does not report an execution venue (version skew)";
32
+ readonly "E-LIVE-006": "the app's root page returns a server error while the wire answers";
30
33
  readonly "E-AUTH-001": "present credentials did not reach the host API";
31
34
  readonly "E-AUTH-002": "the present credential probe is unreachable";
32
35
  readonly "E-AUTH-003": "the present credential probe cannot run while the dev server is down";
@@ -18,9 +18,11 @@ export const DOCTOR_ERROR_CODES = {
18
18
  "E-WIRE-003": "the Next.js catch-all handler app/api/vendo/[...vendo]/route.ts is missing",
19
19
  "E-WIRE-004": "the Next.js root layout is not wrapped in <VendoRoot>",
20
20
  "E-WIRE-005": "the @vendoai/vendo (or vendoai alias) dependency is not declared",
21
+ "E-WIRE-006": "no visible agent surface is mounted (<VendoRoot> alone renders nothing)",
21
22
  "E-CFG-001": "a required .vendo/ config file is missing",
22
23
  "E-CFG-002": ".vendo/data/.gitignore is missing",
23
24
  "E-DEP-001": "the installed ai package is a major version @vendoai/vendo does not support",
25
+ "E-DEP-002": "the running wire serves a different @vendoai/vendo version than this CLI (split-brain install)",
24
26
  "E-UI-001": "an ejected surface predates the installed @vendoai/ui",
25
27
  "E-DEV-001": "the dev server could not be started for the probe",
26
28
  "E-LIVE-001": "/status returned an invalid composition response",
@@ -28,6 +30,7 @@ export const DOCTOR_ERROR_CODES = {
28
30
  "E-LIVE-003": "/status returned an invalid execution venue",
29
31
  "E-LIVE-004": "no execution venue is configured",
30
32
  "E-LIVE-005": "the host /status does not report an execution venue (version skew)",
33
+ "E-LIVE-006": "the app's root page returns a server error while the wire answers",
31
34
  "E-AUTH-001": "present credentials did not reach the host API",
32
35
  "E-AUTH-002": "the present credential probe is unreachable",
33
36
  "E-AUTH-003": "the present credential probe cannot run while the dev server is down",
@@ -158,7 +158,17 @@ export async function startDevServerForProbe(options) {
158
158
  log.push(`spawn error: ${error.message}\n`);
159
159
  failSpawn();
160
160
  });
161
- const stop = () => { child.kill("SIGTERM"); };
161
+ // Destroy the piped streams too: `npm run dev` grandchildren (the actual
162
+ // next/vite process) can survive the SIGTERM and keep the inherited pipe
163
+ // fds open, and the live 'data' listeners would then hold doctor's event
164
+ // loop — the process would hang after printing its verdict instead of
165
+ // exiting with it (exit-code honesty, 0.4.1 E2E cert defect 13 family).
166
+ const stop = () => {
167
+ child.kill("SIGTERM");
168
+ child.stdout?.destroy();
169
+ child.stderr?.destroy();
170
+ child.unref?.(); // injected test doubles may not implement it
171
+ };
162
172
  const up = await Promise.race([
163
173
  waitForStatus(options.statusUrl, fetchImpl, options.timeoutMs ?? 120_000),
164
174
  spawnFailed,
@@ -93,8 +93,26 @@ export async function runDoctor(options) {
93
93
  const warn = (id, code, message) => { warnings += 1; checks.push({ id, status: "warning", message, error_code: code, fix_ref: doctorFixRef(code) }); if (!json)
94
94
  output.error(`warning: ${message}`); };
95
95
  const framework = await detectFramework(root);
96
+ // The generated vendo/vendo-root.tsx wrapper carries the <VendoRoot> AND
97
+ // <VendoOverlay /> markers itself, so an unexcluded scan would pass the
98
+ // client/surface gates even when NO layout mounts the wrapper — the exact
99
+ // doctor-green-but-invisible failure E-WIRE-006 exists to catch. Mirror
100
+ // init's layout decision: exclude the wrapper from the scan, then let a
101
+ // user-code <VendoRoot> mount next to an overlay-bearing wrapper satisfy
102
+ // the surface (the wrapper renders the overlay once a layout mounts it).
103
+ const wrapperCandidates = [
104
+ join(root, "vendo", "vendo-root.tsx"),
105
+ join(root, "src", "vendo", "vendo-root.tsx"),
106
+ ];
107
+ let wrapperWithOverlay = false;
108
+ for (const candidate of wrapperCandidates) {
109
+ const source = await readFile(candidate, "utf8").catch(() => null);
110
+ if (source !== null && source.includes("<VendoOverlay"))
111
+ wrapperWithOverlay = true;
112
+ }
113
+ const scanned = await detectVendoWiring(root, { exclude: wrapperCandidates });
114
+ const wiring = { ...scanned, surface: scanned.surface || (scanned.client && wrapperWithOverlay) };
96
115
  if (framework === "express") {
97
- const wiring = await detectVendoWiring(root);
98
116
  if (wiring.server)
99
117
  pass("wiring/express-server", "Express server is wired");
100
118
  else
@@ -113,21 +131,32 @@ export async function runDoctor(options) {
113
131
  pass("wiring/next-route", "catch-all handler is wired");
114
132
  else
115
133
  fail("wiring/next-route", "E-WIRE-003", "missing app/api/vendo/[...vendo]/route.ts");
116
- const layoutCandidates = [join(root, "app", "layout.tsx"), join(root, "src", "app", "layout.tsx")];
134
+ // The mount may live in ANY layout, not just the root one (i18n/route-group
135
+ // hosts mount in e.g. app/[locale]/layout.tsx — the literal root-layout
136
+ // grep fought exactly that correct wiring in the 0.4.1 E2E cert), and the
137
+ // correct scaffold mounts the generated vendo/vendo-root.tsx wrapper —
138
+ // whose export is also named VendoRoot, so the marker holds there too.
117
139
  let rootWired = false;
118
- for (const path of layoutCandidates) {
119
- try {
120
- if ((await readFile(path, "utf8")).includes("<VendoRoot"))
140
+ for (const appDir of [join(root, "app"), join(root, "src", "app")]) {
141
+ for (const path of await walk(appDir, (rel) => /(^|[\\/])layout\.(?:tsx|jsx|js)$/.test(rel))) {
142
+ const source = await readFile(path, "utf8").catch(() => "");
143
+ if (source.includes("<VendoRoot") || source.includes("<VendoProvider"))
121
144
  rootWired = true;
122
145
  }
123
- catch {
124
- // Try the other layout convention.
125
- }
126
146
  }
127
147
  if (rootWired)
128
148
  pass("wiring/next-root", "<VendoRoot> wraps the app");
129
149
  else
130
- fail("wiring/next-root", "E-WIRE-004", "root layout is not wrapped in <VendoRoot>");
150
+ fail("wiring/next-root", "E-WIRE-004", "no app layout mounts <VendoRoot> — wrap the app in the generated vendo/vendo-root.tsx wrapper (its export is also named VendoRoot), in the root layout or any layout that covers your pages");
151
+ }
152
+ // Visible surface (0.4.1 E2E cert B3): <VendoRoot> is a context provider
153
+ // that renders NOTHING — two certified stacks ended doctor-green with no
154
+ // way for a user to reach the agent. Green must mean visible.
155
+ if (wiring.surface) {
156
+ pass("wiring/surface", "a visible agent surface is mounted (<VendoOverlay /> or an equivalent)");
157
+ }
158
+ else {
159
+ fail("wiring/surface", "E-WIRE-006", "no visible agent surface is mounted — <VendoRoot> renders nothing by itself; mount <VendoOverlay /> (init generates vendo/vendo-root.tsx for this), or render your own surface (<VendoThread />, <VendoToolResult>, the BYO embeds)");
131
160
  }
132
161
  if (await hasDependency(root))
133
162
  pass("wiring/dependency", "@vendoai/vendo dependency is declared");
@@ -235,6 +264,18 @@ export async function runDoctor(options) {
235
264
  else {
236
265
  pass("live/status", `/status live round-trip (${body.version}, ${body.posture})`);
237
266
  liveComposition = true;
267
+ // Split-brain guard (0.4.2 re-run, invoify defect 13): a direct
268
+ // @vendoai/vendo dependency pinned to an older range beats the vendoai
269
+ // umbrella's for the APP import, so `npm install vendoai@latest` runs a
270
+ // new CLI while /status silently serves the old runtime. Any CLI/wire
271
+ // version disagreement — split-brain or just a dev server started
272
+ // before the upgrade — means doctor is not certifying what users run.
273
+ if (body.version === CLI_VERSION) {
274
+ pass("deps/version-skew", `CLI and running wire agree on @vendoai/vendo ${CLI_VERSION}`);
275
+ }
276
+ else {
277
+ fail("deps/version-skew", "E-DEP-002", `the running wire serves @vendoai/vendo ${body.version} but this CLI is ${CLI_VERSION} — likely a split-brain install (a direct @vendoai/vendo dependency pinned to an older range wins over the vendoai umbrella's). Fix: npm install @vendoai/vendo@${CLI_VERSION} (or remove the direct @vendoai/vendo dependency and reinstall), then restart the dev server and re-run doctor.`);
278
+ }
238
279
  // 10-mcp §1 — the door flag lives under blocks.mcp.
239
280
  mcpEnabled = body.blocks.mcp === true;
240
281
  sandboxVenue = body.blocks.sandbox;
@@ -254,7 +295,26 @@ export async function runDoctor(options) {
254
295
  }
255
296
  }
256
297
  catch {
257
- fail("live/status", "E-LIVE-002", `/status is unreachable at ${statusUrl}/status`);
298
+ fail("live/status", "E-LIVE-002", `/status is unreachable at ${statusUrl}/status — doctor expects the WIRE BASE (your app origin plus the mount path, e.g. http://localhost:3000/api/vendo); a bare site origin passed to --url is missing the /api/vendo part`);
299
+ }
300
+ // Render gate (0.4.1 E2E cert M3): a live wire proves nothing about the
301
+ // PAGES — the certified invoify install had every page 500ing (registry
302
+ // passed across the Server Component boundary) while doctor exited 0. One
303
+ // cheap GET of the app root catches a site that is down for users.
304
+ if (liveComposition) {
305
+ try {
306
+ const response = await fetchImpl(`${new URL(statusUrl).origin}/`, { headers: { accept: "text/html" } });
307
+ if (response.status >= 500) {
308
+ fail("live/render", "E-LIVE-006", `the app's root page returned ${response.status} — the site is crashing for users even though the wire answers (typical cause: the component registry imported in a Server Component layout; mount it via the generated vendo/vendo-root.tsx wrapper instead). Check the dev server log.`);
309
+ }
310
+ else {
311
+ pass("live/render", `the app's root page renders (HTTP ${response.status})`);
312
+ }
313
+ }
314
+ catch {
315
+ // The wire answered but the origin root didn't resolve at all — hosts
316
+ // that serve no page at / are not doctor's business; skip silently.
317
+ }
258
318
  }
259
319
  if (!liveComposition) {
260
320
  fail("auth/present", "E-AUTH-003", `present credential probe cannot run; start the dev server at ${statusUrl} and retry`);
@@ -2,7 +2,22 @@ export type HostFramework = "next" | "express" | "unknown";
2
2
  export interface VendoWiring {
3
3
  server: boolean;
4
4
  client: boolean;
5
+ /** A VISIBLE agent surface is mounted — <VendoRoot> alone is a context
6
+ provider that renders nothing (0.4.1 E2E cert B3: by-the-book installs
7
+ ended doctor-green with nothing on screen). */
8
+ surface: boolean;
5
9
  }
10
+ /** What counts as a visible surface: the shipped chrome (<VendoOverlay> and
11
+ the pieces it is built from), the BYO embeds a host chat renders, and the
12
+ hooks a host uses to drive a custom surface. Deliberately generous — this
13
+ list gates a doctor FAILURE, so a host with any plausible surface of its
14
+ own must pass. */
15
+ export declare const SURFACE_MARKERS: readonly string[];
6
16
  export declare function detectFramework(root: string): Promise<HostFramework>;
7
- /** Bounded source scan shared by init and doctor so their wiring verdicts agree. */
8
- export declare function detectVendoWiring(root: string): Promise<VendoWiring>;
17
+ /** Bounded source scan shared by init and doctor so their wiring verdicts
18
+ agree. `exclude` skips generated files whose own markers would count as
19
+ host wiring (init's layout decision excludes the vendo-root wrapper: its
20
+ <VendoOverlay /> is only real once a layout mounts the wrapper itself). */
21
+ export declare function detectVendoWiring(root: string, options?: {
22
+ exclude?: string[];
23
+ }): Promise<VendoWiring>;
@@ -1,6 +1,24 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { walk } from "./theme/walk.js";
4
+ /** What counts as a visible surface: the shipped chrome (<VendoOverlay> and
5
+ the pieces it is built from), the BYO embeds a host chat renders, and the
6
+ hooks a host uses to drive a custom surface. Deliberately generous — this
7
+ list gates a doctor FAILURE, so a host with any plausible surface of its
8
+ own must pass. */
9
+ export const SURFACE_MARKERS = [
10
+ "<VendoOverlay",
11
+ "<VendoThread",
12
+ "<VendoTrigger",
13
+ "<VendoPalette",
14
+ "<VendoSlot",
15
+ "<VendoAppEmbed",
16
+ "<VendoApprovalEmbed",
17
+ "<VendoToolResult",
18
+ "useVendoOverlay(",
19
+ "useVendoThread(",
20
+ "useSlotApp(",
21
+ ];
4
22
  const SOURCE_FILE = /\.(?:[cm]?[jt]sx?)$/;
5
23
  const SOURCE_SCAN_MAX_FILES = 2_000;
6
24
  export async function detectFramework(root) {
@@ -17,20 +35,29 @@ export async function detectFramework(root) {
17
35
  return "unknown";
18
36
  }
19
37
  }
20
- /** Bounded source scan shared by init and doctor so their wiring verdicts agree. */
21
- export async function detectVendoWiring(root) {
38
+ /** Bounded source scan shared by init and doctor so their wiring verdicts
39
+ agree. `exclude` skips generated files whose own markers would count as
40
+ host wiring (init's layout decision excludes the vendo-root wrapper: its
41
+ <VendoOverlay /> is only real once a layout mounts the wrapper itself). */
42
+ export async function detectVendoWiring(root, options = {}) {
22
43
  let server = false;
23
44
  let client = false;
45
+ let surface = false;
46
+ const excluded = new Set(options.exclude ?? []);
24
47
  const files = await walk(root, (relativePath) => SOURCE_FILE.test(relativePath), SOURCE_SCAN_MAX_FILES);
25
48
  for (const file of files) {
49
+ if (excluded.has(file))
50
+ continue;
26
51
  const source = await readFile(file, "utf8").catch(() => "");
27
52
  const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
28
53
  if (code.includes("@vendoai/vendo/server") && /\bcreateVendo\s*\(/.test(code))
29
54
  server = true;
30
- if (code.includes("<VendoRoot"))
55
+ if (code.includes("<VendoRoot") || code.includes("<VendoProvider"))
31
56
  client = true;
32
- if (server && client)
57
+ if (SURFACE_MARKERS.some((marker) => code.includes(marker)))
58
+ surface = true;
59
+ if (server && client && surface)
33
60
  break;
34
61
  }
35
- return { server, client };
62
+ return { server, client, surface };
36
63
  }
@@ -8,6 +8,28 @@ export declare function authConfigLines(auth: AuthMatch): string;
8
8
  as `catalog` (data fields only), `<VendoRoot components={registry}>` reads
9
9
  the component references. Generated only while absent — never clobbered. */
10
10
  export declare function registrySource(variant: "tsx" | "mjs"): string;
11
+ /** The anonymous-composition principal line (no auth preset wired). The
12
+ subject matches the demo principal both existing-agents quickstarts set in
13
+ their chat routes — the wire route MUST resolve the same subject as the
14
+ host's agent loop, or every app/approval created in chat is invisible to
15
+ the embeds, which call this route directly (0.4.1 E2E cert blocker B4:
16
+ a `() => null` wire against a demo-user chat route rendered an infinite
17
+ skeleton). Replaced wholesale when an auth preset is wired. */
18
+ export declare function anonymousPrincipalLines(): string;
19
+ /**
20
+ * The client mount wrapper (visible-surface fix, 0.4.1 E2E cert B3/M3): one
21
+ * generated "use client" boundary owns the registry + theme imports and
22
+ * mounts `<VendoOverlay />` — the launcher pill + panel end users actually
23
+ * see. The registry holds component references, so importing it in a Server
24
+ * Component layout and passing it into the client provider fails RSC
25
+ * serialization ("Only plain objects can be passed to Client Components");
26
+ * this wrapper is the layout-safe mount. Generated only while absent.
27
+ */
28
+ export declare function vendoRootWrapperSource(options: {
29
+ /** Posix-style import specifier from the wrapper's dir to .vendo/theme.json,
30
+ or null when resolveJsonModule is explicitly disabled. */
31
+ themeSpecifier: string | null;
32
+ }): string;
11
33
  export declare function routeSource(options: {
12
34
  serverActions: boolean;
13
35
  auth: AuthMatch | null;
@@ -48,6 +48,56 @@ export function registrySource(variant) {
48
48
  ? `${header}import type { ComponentRegistry } from "@vendoai/vendo";\n\nexport const registry = {} satisfies ComponentRegistry;\n`
49
49
  : `${header}export const registry = {};\n`;
50
50
  }
51
+ /** The anonymous-composition principal line (no auth preset wired). The
52
+ subject matches the demo principal both existing-agents quickstarts set in
53
+ their chat routes — the wire route MUST resolve the same subject as the
54
+ host's agent loop, or every app/approval created in chat is invisible to
55
+ the embeds, which call this route directly (0.4.1 E2E cert blocker B4:
56
+ a `() => null` wire against a demo-user chat route rendered an infinite
57
+ skeleton). Replaced wholesale when an auth preset is wired. */
58
+ export function anonymousPrincipalLines() {
59
+ return ` // Who the wire's callers act as. This must resolve the SAME subject your\n` +
60
+ ` // agent loop uses (the docs' chat routes set this demo principal), or apps\n` +
61
+ ` // and approvals created in chat are invisible to the embeds, which call\n` +
62
+ ` // this route directly. Replace both sides with your real session lookup.\n` +
63
+ ` principal: async () => ({ kind: "user" as const, subject: "demo-user" }),\n`;
64
+ }
65
+ /**
66
+ * The client mount wrapper (visible-surface fix, 0.4.1 E2E cert B3/M3): one
67
+ * generated "use client" boundary owns the registry + theme imports and
68
+ * mounts `<VendoOverlay />` — the launcher pill + panel end users actually
69
+ * see. The registry holds component references, so importing it in a Server
70
+ * Component layout and passing it into the client provider fails RSC
71
+ * serialization ("Only plain objects can be passed to Client Components");
72
+ * this wrapper is the layout-safe mount. Generated only while absent.
73
+ */
74
+ export function vendoRootWrapperSource(options) {
75
+ const withTheme = options.themeSpecifier !== null;
76
+ return `"use client";\n\n` +
77
+ `/**\n` +
78
+ ` * The Vendo client mount — generated by \`vendo init\`, then yours.\n` +
79
+ ` * This "use client" boundary owns the registry${withTheme ? " and theme" : ""} imports: the\n` +
80
+ ` * registry carries component references, which cannot cross a Server\n` +
81
+ ` * Component boundary as props (RSC serialization). <VendoOverlay /> is the\n` +
82
+ ` * visible surface — the launcher pill + panel. Swap it for your own\n` +
83
+ ` * surface (<VendoThread />, the BYO embeds) whenever you like.\n` +
84
+ ` */\n` +
85
+ `import { VendoOverlay, VendoRoot as VendoClientRoot } from "@vendoai/vendo/react";\n` +
86
+ `import type { ReactNode } from "react";\n` +
87
+ `import { registry } from "./registry";\n` +
88
+ (withTheme
89
+ ? `import theme from ${JSON.stringify(options.themeSpecifier)};\n` +
90
+ `import type { VendoTheme } from "@vendoai/vendo";\n`
91
+ : "") +
92
+ `\nexport function VendoRoot({ children }: { children: ReactNode }) {\n` +
93
+ ` return (\n` +
94
+ ` <VendoClientRoot components={registry}${withTheme ? " theme={theme as VendoTheme}" : ""}>\n` +
95
+ ` {children}\n` +
96
+ ` <VendoOverlay />\n` +
97
+ ` </VendoClientRoot>\n` +
98
+ ` );\n` +
99
+ `}\n`;
100
+ }
51
101
  /** The preset's own import line (its own subpath, never "@vendoai/vendo/server"
52
102
  — corpus-triage Task 9: a shared barrel meant any host importing the
53
103
  server entry statically re-resolved every preset's optional peer dep,
@@ -61,7 +111,7 @@ export function routeSource(options) {
61
111
  (options.serverActions ? `import { serverActions } from "./vendo-actions";\n` : "") +
62
112
  `import { registry } from ${JSON.stringify(options.registrySpecifier)};\n` +
63
113
  `\nconst vendo = createVendo({\n` +
64
- (options.auth === null ? ` principal: async () => null,\n` : authConfigLines(options.auth)) +
114
+ (options.auth === null ? anonymousPrincipalLines() : authConfigLines(options.auth)) +
65
115
  ` catalog: registry,\n` +
66
116
  (options.serverActions ? ` serverActions,\n` : "") +
67
117
  ` policy: {}, // .vendo/policy.json: destructive asks, reads run\n` +
@@ -143,17 +193,19 @@ export function expressServerSource(typescript, auth = null) {
143
193
  const registrySpecifier = typescript ? "./registry" : "./registry.mjs";
144
194
  const clientHint = typescript
145
195
  ? ` * // in the client entry — theme.json adopts the host brand (08 §4);\n` +
146
- ` * // the cast narrows TypeScript's widened JSON-module string literals:\n` +
147
- ` * import { VendoRoot } from "@vendoai/vendo/react";\n` +
196
+ ` * // the cast narrows TypeScript's widened JSON-module string literals;\n` +
197
+ ` * // <VendoOverlay /> is the visible surface (launcher pill + panel):\n` +
198
+ ` * import { VendoOverlay, VendoRoot } from "@vendoai/vendo/react";\n` +
148
199
  ` * import { registry } from "<path-to>/vendo/registry";\n` +
149
200
  ` * import theme from "<path-to>/.vendo/theme.json";\n` +
150
201
  ` * import type { VendoTheme } from "@vendoai/vendo";\n` +
151
- ` * root.render(<VendoRoot components={registry} theme={theme as VendoTheme}><App /></VendoRoot>);\n`
152
- : ` * // in the client entry — theme.json adopts the host brand (08 §4):\n` +
153
- ` * import { VendoRoot } from "@vendoai/vendo/react";\n` +
202
+ ` * root.render(<VendoRoot components={registry} theme={theme as VendoTheme}><App /><VendoOverlay /></VendoRoot>);\n`
203
+ : ` * // in the client entry — theme.json adopts the host brand (08 §4);\n` +
204
+ ` * // <VendoOverlay /> is the visible surface (launcher pill + panel):\n` +
205
+ ` * import { VendoOverlay, VendoRoot } from "@vendoai/vendo/react";\n` +
154
206
  ` * import { registry } from "<path-to>/vendo/registry.mjs";\n` +
155
207
  ` * import theme from "<path-to>/.vendo/theme.json";\n` +
156
- ` * root.render(<VendoRoot components={registry} theme={theme}><App /></VendoRoot>);\n`;
208
+ ` * root.render(<VendoRoot components={registry} theme={theme}><App /><VendoOverlay /></VendoRoot>);\n`;
157
209
  return `/**\n` +
158
210
  ` * Add these wiring lines in your host:\n` +
159
211
  ` * app.use("/api/vendo", mountVendo());\n` +
@@ -165,7 +217,7 @@ export function expressServerSource(typescript, auth = null) {
165
217
  `import { registry } from ${JSON.stringify(registrySpecifier)};\n` +
166
218
  types +
167
219
  `\nconst vendo = createVendo({\n` +
168
- (auth === null ? ` principal: async () => null,\n` : authConfigLines(auth)) +
220
+ (auth === null ? anonymousPrincipalLines() : authConfigLines(auth)) +
169
221
  ` catalog: registry,\n` +
170
222
  ` policy: {}, // .vendo/policy.json: destructive asks, reads run\n` +
171
223
  `});\n\n` +