@vendoai/vendo 0.4.2 → 0.4.4

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 (55) hide show
  1. package/dist/capability-misses.js +2 -3
  2. package/dist/cli/cloud/client.d.ts +2 -5
  3. package/dist/cli/cloud/client.js +4 -4
  4. package/dist/cli/cloud/device-login.d.ts +11 -0
  5. package/dist/cli/cloud/device-login.js +52 -1
  6. package/dist/cli/dep-versions.d.ts +1 -1
  7. package/dist/cli/dep-versions.js +1 -1
  8. package/dist/cli/doctor-codes.d.ts +7 -0
  9. package/dist/cli/doctor-codes.js +7 -0
  10. package/dist/cli/doctor-live.js +11 -1
  11. package/dist/cli/doctor.js +122 -11
  12. package/dist/cli/extract/apply.js +1 -1
  13. package/dist/cli/extract/delegate.js +9 -1
  14. package/dist/cli/extract/extraction.d.ts +4 -0
  15. package/dist/cli/extract/extraction.js +29 -2
  16. package/dist/cli/extract/harness.d.ts +12 -0
  17. package/dist/cli/extract/harness.js +3 -0
  18. package/dist/cli/extract/stages.js +5 -1
  19. package/dist/cli/framework.d.ts +17 -2
  20. package/dist/cli/framework.js +32 -5
  21. package/dist/cli/init-scaffolds.d.ts +32 -1
  22. package/dist/cli/init-scaffolds.js +153 -9
  23. package/dist/cli/init.d.ts +14 -4
  24. package/dist/cli/init.js +212 -43
  25. package/dist/cli/playground/app/fake-client.js +11 -5
  26. package/dist/cli/playground/app/main.js +23 -13
  27. package/dist/cli/playground/app/scenario-mount.js +4 -1
  28. package/dist/cli/playground/app/theme-editor.js +27 -27
  29. package/dist/cli/playground/bundle.gen.d.ts +1 -1
  30. package/dist/cli/playground/bundle.gen.js +1 -1
  31. package/dist/cli/playground/embed-bundle.gen.d.ts +1 -1
  32. package/dist/cli/playground/embed-bundle.gen.js +1 -1
  33. package/dist/cli/shared.d.ts +1 -1
  34. package/dist/cli/shared.js +1 -1
  35. package/dist/cli/sync.d.ts +1 -1
  36. package/dist/cli/sync.js +1 -1
  37. package/dist/cloud-apps.js +2 -1
  38. package/dist/cloud-key-fetch.d.ts +16 -0
  39. package/dist/cloud-key-fetch.js +40 -0
  40. package/dist/cloud-tools.js +2 -1
  41. package/dist/connections.js +2 -2
  42. package/dist/dev-creds/model-edge.d.ts +21 -0
  43. package/dist/dev-creds/model-edge.js +24 -0
  44. package/dist/hosted-store.js +2 -1
  45. package/dist/mastra.js +71 -2
  46. package/dist/react.d.ts +1 -0
  47. package/dist/react.js +7 -0
  48. package/dist/refine.js +2 -1
  49. package/dist/sandbox.js +2 -2
  50. package/dist/server.d.ts +1 -1
  51. package/dist/server.js +56 -22
  52. package/dist/wire/apps.js +30 -1
  53. package/dist/wire/shared.d.ts +2 -2
  54. package/dist/wire/shared.js +1 -1
  55. package/package.json +20 -11
@@ -1,6 +1,6 @@
1
1
  import { canonicalJson, sha256Hex, } from "@vendoai/core";
2
2
  import { envOptOut, loadConfig } from "@vendoai/telemetry";
3
- import { cloudFetch } from "./cli/cloud/client.js";
3
+ import { cloudKeyFetch } from "./cloud-key-fetch.js";
4
4
  const DEFAULT_DATA_DIR = ".vendo/data";
5
5
  const DEFAULT_BATCH_SIZE = 100;
6
6
  const DEFAULT_QUEUE_LIMIT = 1_000;
@@ -60,8 +60,7 @@ function createMissUploader(options) {
60
60
  unrefTimer(timeout);
61
61
  try {
62
62
  const surface = await options.surface;
63
- const response = await cloudFetch("/api/v1/misses", {
64
- auth: "key",
63
+ const response = await cloudKeyFetch("/api/v1/misses", {
65
64
  apiKey: options.apiKey,
66
65
  env: options.env,
67
66
  fetchImpl: options.fetchImpl,
@@ -5,11 +5,8 @@ export declare class CloudError extends Error {
5
5
  readonly status: number;
6
6
  constructor(code: string, message: string, status: number);
7
7
  }
8
- export interface CloudUrlOptions {
9
- apiUrl?: string;
10
- env?: Record<string, string | undefined>;
11
- }
12
- export declare function resolveCloudBaseUrl(options?: CloudUrlOptions): string;
8
+ export { resolveCloudBaseUrl, type CloudUrlOptions } from "../../cloud-key-fetch.js";
9
+ import { type CloudUrlOptions } from "../../cloud-key-fetch.js";
13
10
  export interface SessionStore {
14
11
  read(): Promise<CloudSession | null>;
15
12
  write(session: CloudSession): Promise<void>;
@@ -15,10 +15,10 @@ export class CloudError extends Error {
15
15
  this.status = status;
16
16
  }
17
17
  }
18
- export function resolveCloudBaseUrl(options = {}) {
19
- const value = options.apiUrl ?? (options.env ?? process.env).VENDO_CLOUD_URL ?? DEFAULT_CLOUD_URL;
20
- return value.replace(/\/+$/, "");
21
- }
18
+ // Base-URL resolution lives with the portable runtime helper so server-side
19
+ // code (capability misses) never imports this Node/CLI module.
20
+ export { resolveCloudBaseUrl } from "../../cloud-key-fetch.js";
21
+ import { resolveCloudBaseUrl } from "../../cloud-key-fetch.js";
22
22
  function requestUrl(path, options) {
23
23
  const base = resolveCloudBaseUrl(options);
24
24
  const suffix = base.endsWith("/api/v1") && path.startsWith("/api/v1/")
@@ -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"
@@ -92,6 +117,18 @@ export async function runDeviceLogin(args, options = {}) {
92
117
  const waitMs = waitSeconds === undefined ? undefined : waitSeconds * 1000;
93
118
  const pendingHome = options.home === undefined ? {} : { home: options.home };
94
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
+ }
95
132
  try {
96
133
  // A still-open claim from a dead process (#479): resume polling it so the
97
134
  // human's late approval — against the code they were already shown —
@@ -176,7 +213,21 @@ export async function runDeviceLogin(args, options = {}) {
176
213
  if (typeof key !== "string" || !isVendoKey(key)) {
177
214
  throw new Error("Vendo Cloud returned an invalid credential");
178
215
  }
179
- await upsertEnvLocal(root, "VENDO_API_KEY", key);
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
+ }
180
231
  await deletePendingClaim(claimCwd, pendingHome);
181
232
  // Never print the key itself — .env.local is the hand-off, last4 the
182
233
  // receipt. A resumed run names the full path: it may differ from cwd.
@@ -23,4 +23,4 @@ export declare function installedAiVersion(root: string): Promise<string | null>
23
23
  * Read the host project's dependency versions (deps + devDeps) for telemetry.
24
24
  * Non-throwing: a missing or malformed package.json returns {}.
25
25
  */
26
- export declare function detectDepVersions(root: string, framework: HostFramework): Promise<DepVersions>;
26
+ export declare function detectDepVersions(root: string, framework: HostFramework | "custom"): Promise<DepVersions>;
@@ -56,7 +56,7 @@ export async function detectDepVersions(root, framework) {
56
56
  const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
57
57
  const dependencies = { ...manifest.devDependencies, ...manifest.dependencies };
58
58
  const out = {};
59
- const frameworkPackage = framework === "unknown" ? undefined : FRAMEWORK_PACKAGE[framework];
59
+ const frameworkPackage = framework === "unknown" || framework === "custom" ? undefined : FRAMEWORK_PACKAGE[framework];
60
60
  const frameworkVersion = frameworkPackage === undefined ? undefined : bareVersion(dependencies[frameworkPackage]);
61
61
  if (frameworkVersion !== undefined)
62
62
  out.frameworkVersion = frameworkVersion;
@@ -17,9 +17,13 @@ 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)";
21
+ readonly "E-WIRE-007": "no createVendo server wiring found in an unknown-framework host";
22
+ readonly "E-WIRE-008": "no <VendoRoot> found in an unknown-framework host's source";
20
23
  readonly "E-CFG-001": "a required .vendo/ config file is missing";
21
24
  readonly "E-CFG-002": ".vendo/data/.gitignore is missing";
22
25
  readonly "E-DEP-001": "the installed ai package is a major version @vendoai/vendo does not support";
26
+ readonly "E-DEP-002": "the running wire serves a different @vendoai/vendo version than this CLI (split-brain install)";
23
27
  readonly "E-UI-001": "an ejected surface predates the installed @vendoai/ui";
24
28
  readonly "E-DEV-001": "the dev server could not be started for the probe";
25
29
  readonly "E-LIVE-001": "/status returned an invalid composition response";
@@ -27,6 +31,7 @@ export declare const DOCTOR_ERROR_CODES: {
27
31
  readonly "E-LIVE-003": "/status returned an invalid execution venue";
28
32
  readonly "E-LIVE-004": "no execution venue is configured";
29
33
  readonly "E-LIVE-005": "the host /status does not report an execution venue (version skew)";
34
+ readonly "E-LIVE-006": "the app's root page returns a server error while the wire answers";
30
35
  readonly "E-AUTH-001": "present credentials did not reach the host API";
31
36
  readonly "E-AUTH-002": "the present credential probe is unreachable";
32
37
  readonly "E-AUTH-003": "the present credential probe cannot run while the dev server is down";
@@ -46,6 +51,8 @@ export declare const DOCTOR_ERROR_CODES: {
46
51
  readonly "E-TURN-001": "the live model turn did not answer";
47
52
  readonly "E-TURN-002": "the live model turn cannot run while the dev server is down";
48
53
  readonly "E-CLOUD-001": "VENDO_API_KEY is set but not usable";
54
+ readonly "E-TOOLS-001": "every extracted host tool is disabled or excluded (zero live host tools)";
55
+ readonly "E-TOOLS-002": "the extracted tool surface is empty (zero host tools)";
49
56
  };
50
57
  export type DoctorErrorCode = keyof typeof DOCTOR_ERROR_CODES;
51
58
  /** Complete list of every code doctor can emit, for CI enumeration. */
@@ -18,9 +18,13 @@ 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)",
22
+ "E-WIRE-007": "no createVendo server wiring found in an unknown-framework host",
23
+ "E-WIRE-008": "no <VendoRoot> found in an unknown-framework host's source",
21
24
  "E-CFG-001": "a required .vendo/ config file is missing",
22
25
  "E-CFG-002": ".vendo/data/.gitignore is missing",
23
26
  "E-DEP-001": "the installed ai package is a major version @vendoai/vendo does not support",
27
+ "E-DEP-002": "the running wire serves a different @vendoai/vendo version than this CLI (split-brain install)",
24
28
  "E-UI-001": "an ejected surface predates the installed @vendoai/ui",
25
29
  "E-DEV-001": "the dev server could not be started for the probe",
26
30
  "E-LIVE-001": "/status returned an invalid composition response",
@@ -28,6 +32,7 @@ export const DOCTOR_ERROR_CODES = {
28
32
  "E-LIVE-003": "/status returned an invalid execution venue",
29
33
  "E-LIVE-004": "no execution venue is configured",
30
34
  "E-LIVE-005": "the host /status does not report an execution venue (version skew)",
35
+ "E-LIVE-006": "the app's root page returns a server error while the wire answers",
31
36
  "E-AUTH-001": "present credentials did not reach the host API",
32
37
  "E-AUTH-002": "the present credential probe is unreachable",
33
38
  "E-AUTH-003": "the present credential probe cannot run while the dev server is down",
@@ -47,6 +52,8 @@ export const DOCTOR_ERROR_CODES = {
47
52
  "E-TURN-001": "the live model turn did not answer",
48
53
  "E-TURN-002": "the live model turn cannot run while the dev server is down",
49
54
  "E-CLOUD-001": "VENDO_API_KEY is set but not usable",
55
+ "E-TOOLS-001": "every extracted host tool is disabled or excluded (zero live host tools)",
56
+ "E-TOOLS-002": "the extracted tool surface is empty (zero host tools)",
50
57
  };
51
58
  /** Complete list of every code doctor can emit, for CI enumeration. */
52
59
  export const doctorErrorCodes = Object.keys(DOCTOR_ERROR_CODES);
@@ -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,
@@ -5,6 +5,7 @@ import { cloudDoctor, liveModelTurn, startDevServerForProbe, } from "./doctor-li
5
5
  import { installedAiVersion } from "./dep-versions.js";
6
6
  import { doctorFixRef } from "./doctor-codes.js";
7
7
  import { EJECT_MANIFEST_FILE } from "./eject.js";
8
+ import { overridesFileSchema, toolsFileSchema } from "@vendoai/actions";
8
9
  import { detectFramework, detectVendoWiring } from "./framework.js";
9
10
  import { walk } from "./theme/walk.js";
10
11
  import { remoteUrls, sameUrl, validateRegistryServer } from "./mcp/registry.js";
@@ -93,8 +94,40 @@ export async function runDoctor(options) {
93
94
  const warn = (id, code, message) => { warnings += 1; checks.push({ id, status: "warning", message, error_code: code, fix_ref: doctorFixRef(code) }); if (!json)
94
95
  output.error(`warning: ${message}`); };
95
96
  const framework = await detectFramework(root);
96
- if (framework === "express") {
97
- const wiring = await detectVendoWiring(root);
97
+ // The generated vendo/vendo-root.tsx wrapper carries the <VendoRoot> AND
98
+ // <VendoOverlay /> markers itself, so an unexcluded scan would pass the
99
+ // client/surface gates even when NO layout mounts the wrapper — the exact
100
+ // doctor-green-but-invisible failure E-WIRE-006 exists to catch. Mirror
101
+ // init's layout decision: exclude the wrapper from the scan, then let a
102
+ // user-code <VendoRoot> mount next to an overlay-bearing wrapper satisfy
103
+ // the surface (the wrapper renders the overlay once a layout mounts it).
104
+ const wrapperCandidates = [
105
+ join(root, "vendo", "vendo-root.tsx"),
106
+ join(root, "src", "vendo", "vendo-root.tsx"),
107
+ ];
108
+ let wrapperWithOverlay = false;
109
+ for (const candidate of wrapperCandidates) {
110
+ const source = await readFile(candidate, "utf8").catch(() => null);
111
+ if (source !== null && source.includes("<VendoOverlay"))
112
+ wrapperWithOverlay = true;
113
+ }
114
+ const scanned = await detectVendoWiring(root, { exclude: wrapperCandidates });
115
+ const wiring = { ...scanned, surface: scanned.surface || (scanned.client && wrapperWithOverlay) };
116
+ if (framework === "unknown") {
117
+ // No framework to pattern-match (field case: a Cloudflare Worker + Vite
118
+ // host failed E-WIRE-003/004 forever) — judge the wiring by the same
119
+ // bounded source scan init uses, never by another framework's file
120
+ // layout. The surface check below still runs; it is source-generic.
121
+ if (scanned.server)
122
+ pass("wiring/server", "createVendo server wiring found");
123
+ else
124
+ fail("wiring/server", "E-WIRE-007", "no createVendo server wiring found — import createVendo from @vendoai/vendo/server and mount vendo.handler on your runtime's request entry");
125
+ if (scanned.client)
126
+ pass("wiring/client", "<VendoRoot> wraps the client");
127
+ else
128
+ warn("wiring/client", "E-WIRE-008", "no <VendoRoot> found in the host source — the @vendoai/ui hooks and embeds need it; ignore this if the host renders a fully custom surface");
129
+ }
130
+ else if (framework === "express") {
98
131
  if (wiring.server)
99
132
  pass("wiring/express-server", "Express server is wired");
100
133
  else
@@ -113,21 +146,32 @@ export async function runDoctor(options) {
113
146
  pass("wiring/next-route", "catch-all handler is wired");
114
147
  else
115
148
  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")];
149
+ // The mount may live in ANY layout, not just the root one (i18n/route-group
150
+ // hosts mount in e.g. app/[locale]/layout.tsx — the literal root-layout
151
+ // grep fought exactly that correct wiring in the 0.4.1 E2E cert), and the
152
+ // correct scaffold mounts the generated vendo/vendo-root.tsx wrapper —
153
+ // whose export is also named VendoRoot, so the marker holds there too.
117
154
  let rootWired = false;
118
- for (const path of layoutCandidates) {
119
- try {
120
- if ((await readFile(path, "utf8")).includes("<VendoRoot"))
155
+ for (const appDir of [join(root, "app"), join(root, "src", "app")]) {
156
+ for (const path of await walk(appDir, (rel) => /(^|[\\/])layout\.(?:tsx|jsx|js)$/.test(rel))) {
157
+ const source = await readFile(path, "utf8").catch(() => "");
158
+ if (source.includes("<VendoRoot") || source.includes("<VendoProvider"))
121
159
  rootWired = true;
122
160
  }
123
- catch {
124
- // Try the other layout convention.
125
- }
126
161
  }
127
162
  if (rootWired)
128
163
  pass("wiring/next-root", "<VendoRoot> wraps the app");
129
164
  else
130
- fail("wiring/next-root", "E-WIRE-004", "root layout is not wrapped in <VendoRoot>");
165
+ 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");
166
+ }
167
+ // Visible surface (0.4.1 E2E cert B3): <VendoRoot> is a context provider
168
+ // that renders NOTHING — two certified stacks ended doctor-green with no
169
+ // way for a user to reach the agent. Green must mean visible.
170
+ if (wiring.surface) {
171
+ pass("wiring/surface", "a visible agent surface is mounted (<VendoOverlay /> or an equivalent)");
172
+ }
173
+ else {
174
+ 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
175
  }
132
176
  if (await hasDependency(root))
133
177
  pass("wiring/dependency", "@vendoai/vendo dependency is declared");
@@ -155,6 +199,42 @@ export async function runDoctor(options) {
155
199
  }
156
200
  if (!await exists(join(root, ".vendo", "data", ".gitignore")))
157
201
  warn("config/data-gitignore", "E-CFG-002", ".vendo/data/.gitignore is missing");
202
+ // The core promise, statically checkable: does the agent have any HOST
203
+ // tool it may actually call? All-disabled is an explicit misconfiguration
204
+ // (fail); an empty extraction is a strong warning — connector-only hosts
205
+ // are legitimate, but a fresh install landing here means extraction found
206
+ // nothing user-facing (field case: an infra product whose surface was all
207
+ // internal endpoints ended with tools: [] and a silently useless agent).
208
+ const toolsRaw = await readOptional(join(root, ".vendo", "tools.json"));
209
+ const overridesRaw = await readOptional(join(root, ".vendo", "overrides.json"));
210
+ if (toolsRaw !== null) {
211
+ try {
212
+ const toolsFile = toolsFileSchema.parse(JSON.parse(toolsRaw));
213
+ let overridesTools = {};
214
+ if (overridesRaw !== null) {
215
+ try {
216
+ overridesTools = overridesFileSchema.parse(JSON.parse(overridesRaw)).tools;
217
+ }
218
+ catch {
219
+ // Malformed overrides are their own (pre-existing) failure surface.
220
+ }
221
+ }
222
+ const live = toolsFile.tools.filter((tool) => (overridesTools[tool.name]?.disabled ?? tool.disabled ?? false) !== true);
223
+ if (toolsFile.tools.length === 0) {
224
+ warn("tools/live-surface", "E-TOOLS-002", "the extracted tool surface is empty — the agent cannot act on this product's API; re-run `vendo init` extraction (or ignore if this deployment is connector-only)");
225
+ }
226
+ else if (live.length === 0) {
227
+ fail("tools/live-surface", "E-TOOLS-001", `zero live host tools — all ${toolsFile.tools.length} extracted tools are disabled or excluded; review the audience exclusions in .vendo/overrides.json and re-enable the end-user surface (disabled: false)`);
228
+ }
229
+ else {
230
+ pass("tools/live-surface", `${live.length} live host tool${live.length === 1 ? "" : "s"}`);
231
+ }
232
+ }
233
+ catch {
234
+ // Not the vendo/tools@1 shape (e.g. a placeholder {}) — the config
235
+ // checks above already govern presence; nothing to grade here.
236
+ }
237
+ }
158
238
  // §4 customization ladder — ejected chrome drift. The ejected pixels are the
159
239
  // host's code, so a version gap is awareness (warn), never breakage (fail):
160
240
  // the hooks/wire dependency keeps working; only new presentation is missed.
@@ -235,6 +315,18 @@ export async function runDoctor(options) {
235
315
  else {
236
316
  pass("live/status", `/status live round-trip (${body.version}, ${body.posture})`);
237
317
  liveComposition = true;
318
+ // Split-brain guard (0.4.2 re-run, invoify defect 13): a direct
319
+ // @vendoai/vendo dependency pinned to an older range beats the vendoai
320
+ // umbrella's for the APP import, so `npm install vendoai@latest` runs a
321
+ // new CLI while /status silently serves the old runtime. Any CLI/wire
322
+ // version disagreement — split-brain or just a dev server started
323
+ // before the upgrade — means doctor is not certifying what users run.
324
+ if (body.version === CLI_VERSION) {
325
+ pass("deps/version-skew", `CLI and running wire agree on @vendoai/vendo ${CLI_VERSION}`);
326
+ }
327
+ else {
328
+ 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.`);
329
+ }
238
330
  // 10-mcp §1 — the door flag lives under blocks.mcp.
239
331
  mcpEnabled = body.blocks.mcp === true;
240
332
  sandboxVenue = body.blocks.sandbox;
@@ -254,7 +346,26 @@ export async function runDoctor(options) {
254
346
  }
255
347
  }
256
348
  catch {
257
- fail("live/status", "E-LIVE-002", `/status is unreachable at ${statusUrl}/status`);
349
+ 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`);
350
+ }
351
+ // Render gate (0.4.1 E2E cert M3): a live wire proves nothing about the
352
+ // PAGES — the certified invoify install had every page 500ing (registry
353
+ // passed across the Server Component boundary) while doctor exited 0. One
354
+ // cheap GET of the app root catches a site that is down for users.
355
+ if (liveComposition) {
356
+ try {
357
+ const response = await fetchImpl(`${new URL(statusUrl).origin}/`, { headers: { accept: "text/html" } });
358
+ if (response.status >= 500) {
359
+ 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.`);
360
+ }
361
+ else {
362
+ pass("live/render", `the app's root page renders (HTTP ${response.status})`);
363
+ }
364
+ }
365
+ catch {
366
+ // The wire answered but the origin root didn't resolve at all — hosts
367
+ // that serve no page at / are not doctor's business; skip silently.
368
+ }
258
369
  }
259
370
  if (!liveComposition) {
260
371
  fail("auth/present", "E-AUTH-003", `present credential probe cannot run; start the dev server at ${statusUrl} and retry`);
@@ -1,6 +1,6 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { z } from "zod";
3
- import { vendoSync } from "@vendoai/actions";
3
+ import { vendoSync } from "@vendoai/actions/sync";
4
4
  import { detectDepVersions } from "../dep-versions.js";
5
5
  import { detectFramework } from "../framework.js";
6
6
  import { applyDraft, reportApplied } from "./extraction.js";
@@ -41,6 +41,10 @@ export const EXTRACTION_DRAFT_JSON_SCHEMA = {
41
41
  type: "boolean",
42
42
  description: "false = wake a statically-unclassifiable tool (requires reasoning and risk).",
43
43
  },
44
+ audience: {
45
+ enum: ["end-user", "operator", "internal"],
46
+ description: "Who the handler's own auth admits; non-end-user grades exclude the tool by default.",
47
+ },
44
48
  reasoning: { type: "string", maxLength: 500 },
45
49
  },
46
50
  },
@@ -66,10 +70,14 @@ export function composeDelegatedInstructions(tools, appName) {
66
70
  "",
67
71
  "Rules:",
68
72
  "- Produce ONE json document matching the provided draftSchema:",
69
- ' { "brief": string, "tools": [{ "name", "description", "risk"?, "critical"?, "disabled"?, "reasoning"? }], "missedSurfaces"?: string[] }',
73
+ ' { "brief": string, "tools": [{ "name", "description", "risk"?, "critical"?, "disabled"?, "audience"?, "reasoning"? }], "missedSurfaces"?: string[] }',
70
74
  "- tools: include ONLY names from the list above. Rewrite each description so an agent choosing tools understands what it actually does (read the handler source). <= 200 chars each.",
71
75
  "- risk: you may RAISE risk (read->write->destructive) when the handler is more dangerous than labeled; never lower it. Mark irreversible operations critical: true.",
72
76
  "- A tool listed as disabled was statically unclassifiable. If you can read its handler and grade it, set disabled: false WITH a risk and one-line reasoning. Leave it out otherwise.",
77
+ "- audience: who the handler's own auth admits — \"end-user\" (a signed-in customer acting on their own data),",
78
+ " \"operator\" (admin/staff/support consoles), or \"internal\" (machine-to-machine: webhooks, cron, reconciliation,",
79
+ " service tokens). Read the auth checks, not the route name. When unsure, default to internal — non-end-user",
80
+ " tools are excluded from the embedded agent by default, and a wrong \"end-user\" grade exposes a privileged surface.",
73
81
  "- missedSurfaces: API surfaces you found that the list is missing (path + one line). Do not invent tools for them.",
74
82
  "- brief: one paragraph — what the product does, who uses it, the jobs the agent should help with. Written from the actual code, no marketing fluff.",
75
83
  `- Save the draft to a json file and apply it with \`${APPLY_COMMAND}\` — deterministic guards validate every entry before anything lands in .vendo/.`,
@@ -8,6 +8,10 @@ export interface AppliedSummary {
8
8
  riskRaised: number;
9
9
  critical: number;
10
10
  woken: number;
11
+ /** Non-end-user-audience tools excluded (disabled) this apply. */
12
+ excluded: number;
13
+ /** Tools still live after this apply — zero means the agent cannot act on the host API at all. */
14
+ enabledAfter: number;
11
15
  briefWritten: boolean;
12
16
  refused: string[];
13
17
  missedSurfaces: string[];
@@ -50,7 +50,7 @@ export async function applyDraft(input) {
50
50
  ? { format: "vendo/overrides@1", tools: {} }
51
51
  : overridesSchema.parse(JSON.parse(raw));
52
52
  const summary = {
53
- described: 0, riskRaised: 0, critical: 0, woken: 0,
53
+ described: 0, riskRaised: 0, critical: 0, woken: 0, excluded: 0, enabledAfter: 0,
54
54
  briefWritten: false, refused: [], missedSurfaces: input.draft.missedSurfaces ?? [],
55
55
  };
56
56
  for (const entry of input.draft.tools) {
@@ -70,7 +70,10 @@ export async function applyDraft(input) {
70
70
  // evidence, so a reasoned wake carries the model's grade without tripping
71
71
  // the downgrade guard (Greptile P1 / Devin on #363). A human-set risk or
72
72
  // disabled decision always wins.
73
- const isWake = entry.disabled === false && fact.disabled === true;
73
+ // A wake claiming a non-end-user audience is contradictory; audience
74
+ // exclusion (fail-closed) wins and the wake leg never runs.
75
+ const nonEndUser = entry.audience === "operator" || entry.audience === "internal";
76
+ const isWake = entry.disabled === false && fact.disabled === true && !nonEndUser;
74
77
  if (isWake && existing.disabled === undefined) {
75
78
  if (entry.reasoning === undefined || entry.risk === undefined) {
76
79
  summary.refused.push(`${entry.name}: waking a disabled tool needs reasoning and a risk grade`);
@@ -96,9 +99,27 @@ export async function applyDraft(input) {
96
99
  next.critical = true;
97
100
  summary.critical += 1;
98
101
  }
102
+ // Audience: recorded as provenance; a non-end-user grade excludes the
103
+ // tool by default. A human decision on either field always wins — a
104
+ // hand-set disabled (true OR false) or audience is never touched.
105
+ if (entry.audience !== undefined && existing.audience === undefined) {
106
+ next.audience = entry.audience;
107
+ if (nonEndUser && existing.disabled === undefined && next.disabled !== false) {
108
+ if (next.disabled !== true)
109
+ summary.excluded += 1;
110
+ next.disabled = true;
111
+ }
112
+ }
99
113
  if (JSON.stringify(next) !== JSON.stringify(existing))
100
114
  overrides.tools[entry.name] = next;
101
115
  }
116
+ // The number that actually matters to an end user: how many tools the
117
+ // agent can still call once static grades and overrides settle. Zero means
118
+ // the product's core promise silently degraded — reportApplied warns.
119
+ summary.enabledAfter = input.tools.filter((tool) => {
120
+ const override = overrides.tools[tool.name] ?? {};
121
+ return (override.disabled ?? tool.disabled ?? false) !== true;
122
+ }).length;
102
123
  await writeText(overridesPath, `${JSON.stringify(overrides, null, 2)}\n`);
103
124
  const briefPath = join(input.root, ".vendo", "brief.md");
104
125
  const currentBrief = ((await readOptional(briefPath)) ?? "").trim();
@@ -117,6 +138,7 @@ export function reportApplied(input) {
117
138
  ...(applied.riskRaised > 0 ? [`${applied.riskRaised} risk raises`] : []),
118
139
  ...(applied.critical > 0 ? [`${applied.critical} critical marks`] : []),
119
140
  ...(applied.woken > 0 ? [`${applied.woken} tools woken`] : []),
141
+ ...(applied.excluded > 0 ? [`${applied.excluded} operator/internal tools excluded`] : []),
120
142
  ...(input.briefDrafted ? ["brief drafted"] : []),
121
143
  ];
122
144
  output.log(`AI polish applied: ${parts.join(" · ")} → .vendo/overrides.json, .vendo/brief.md${input.artifactsNote ?? ""}`);
@@ -124,6 +146,11 @@ export function reportApplied(input) {
124
146
  output.error(` ${note}`);
125
147
  for (const refused of applied.refused)
126
148
  output.error(` refused: ${refused}`);
149
+ if (applied.enabledAfter === 0) {
150
+ output.error(" WARNING: zero live tools after apply — every extracted tool is disabled or excluded, so the embedded "
151
+ + "agent cannot act on this product's API. Review the audience exclusions in .vendo/overrides.json "
152
+ + "(re-enable the end-user surface with disabled: false) or re-run extraction.");
153
+ }
127
154
  for (const missed of applied.missedSurfaces)
128
155
  output.log(` missed surface (not extracted yet): ${missed}`);
129
156
  }
@@ -15,10 +15,14 @@ export declare const draftToolSchema: z.ZodObject<{
15
15
  critical: z.ZodOptional<z.ZodBoolean>;
16
16
  /** false = wake a statically-unclassifiable tool (needs reasoning). */
17
17
  disabled: z.ZodOptional<z.ZodBoolean>;
18
+ /** Who the handler's own auth admits; non-end-user grades exclude the tool
19
+ * by default (applyDraft). */
20
+ audience: z.ZodOptional<z.ZodEnum<["end-user", "operator", "internal"]>>;
18
21
  reasoning: z.ZodOptional<z.ZodString>;
19
22
  }, "strip", z.ZodTypeAny, {
20
23
  name: string;
21
24
  description: string;
25
+ audience?: "end-user" | "operator" | "internal" | undefined;
22
26
  risk?: "read" | "write" | "destructive" | undefined;
23
27
  critical?: boolean | undefined;
24
28
  disabled?: boolean | undefined;
@@ -26,6 +30,7 @@ export declare const draftToolSchema: z.ZodObject<{
26
30
  }, {
27
31
  name: string;
28
32
  description: string;
33
+ audience?: "end-user" | "operator" | "internal" | undefined;
29
34
  risk?: "read" | "write" | "destructive" | undefined;
30
35
  critical?: boolean | undefined;
31
36
  disabled?: boolean | undefined;
@@ -42,10 +47,14 @@ export declare const extractionDraftSchema: z.ZodObject<{
42
47
  critical: z.ZodOptional<z.ZodBoolean>;
43
48
  /** false = wake a statically-unclassifiable tool (needs reasoning). */
44
49
  disabled: z.ZodOptional<z.ZodBoolean>;
50
+ /** Who the handler's own auth admits; non-end-user grades exclude the tool
51
+ * by default (applyDraft). */
52
+ audience: z.ZodOptional<z.ZodEnum<["end-user", "operator", "internal"]>>;
45
53
  reasoning: z.ZodOptional<z.ZodString>;
46
54
  }, "strip", z.ZodTypeAny, {
47
55
  name: string;
48
56
  description: string;
57
+ audience?: "end-user" | "operator" | "internal" | undefined;
49
58
  risk?: "read" | "write" | "destructive" | undefined;
50
59
  critical?: boolean | undefined;
51
60
  disabled?: boolean | undefined;
@@ -53,6 +62,7 @@ export declare const extractionDraftSchema: z.ZodObject<{
53
62
  }, {
54
63
  name: string;
55
64
  description: string;
65
+ audience?: "end-user" | "operator" | "internal" | undefined;
56
66
  risk?: "read" | "write" | "destructive" | undefined;
57
67
  critical?: boolean | undefined;
58
68
  disabled?: boolean | undefined;
@@ -65,6 +75,7 @@ export declare const extractionDraftSchema: z.ZodObject<{
65
75
  tools: {
66
76
  name: string;
67
77
  description: string;
78
+ audience?: "end-user" | "operator" | "internal" | undefined;
68
79
  risk?: "read" | "write" | "destructive" | undefined;
69
80
  critical?: boolean | undefined;
70
81
  disabled?: boolean | undefined;
@@ -76,6 +87,7 @@ export declare const extractionDraftSchema: z.ZodObject<{
76
87
  tools: {
77
88
  name: string;
78
89
  description: string;
90
+ audience?: "end-user" | "operator" | "internal" | undefined;
79
91
  risk?: "read" | "write" | "destructive" | undefined;
80
92
  critical?: boolean | undefined;
81
93
  disabled?: boolean | undefined;
@@ -15,6 +15,9 @@ export const draftToolSchema = z.object({
15
15
  critical: z.boolean().optional(),
16
16
  /** false = wake a statically-unclassifiable tool (needs reasoning). */
17
17
  disabled: z.boolean().optional(),
18
+ /** Who the handler's own auth admits; non-end-user grades exclude the tool
19
+ * by default (applyDraft). */
20
+ audience: z.enum(["end-user", "operator", "internal"]).optional(),
18
21
  reasoning: z.string().max(500).optional(),
19
22
  });
20
23
  export const extractionDraftSchema = z.object({