592 lines · 21.7 KB · TypeScript
RawDownload
/** * End-to-end smoke test for the bundled executor binary. * * Catches "works in dev, breaks in --compile" regressions: bunfs asset * loading (embedded web UI, QuickJS WASM), native * .node loaders (keychain), and the MCP → engine → QuickJS → tool path. * * Flow: *   1. Spin up a tiny local OpenAPI server (one operation, returns 42). *   2. Spawn the compiled `executor daemon run --foreground --port 0` *      and parse the ready URL. *   3. Connect via MCP streamable HTTP, call the `execute` tool with code *      that registers and invokes the OpenAPI tool, assert the answer *      round-trips as 42. * * Run after `bun ./scripts/build-sidecar.ts`. Exits non-zero on any * deviation so it can gate CI. */import { chmod, cp, mkdtemp, rename, rm } from "node:fs/promises";import { createHash } from "node:crypto";import { createRequire } from "node:module";import { tmpdir } from "node:os";import { basename, dirname, join, resolve } from "node:path";import { spawn, type Subprocess } from "bun";import { Database } from "bun:sqlite";import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const ROOT = resolve(import.meta.dir, "..");const REPO_ROOT = resolve(ROOT, "../..");const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle-legacy-v1");const BINARY = resolve(  ROOT,  "resources/executor",  process.platform === "win32" ? "executor.exe" : "executor",); const AUTH_TOKEN = "smoke-test-token";const AUTH_HEADER = `Bearer ${AUTH_TOKEN}`;const READY_TIMEOUT_MS = 30_000;const EXECUTOR_BINARY_NAME = process.platform === "win32" ? "executor.exe" : "executor";const requireFromOnePasswordPlugin = createRequire(  join(REPO_ROOT, "packages/plugins/onepassword/package.json"),);const requireFromOnePasswordSdk = createRequire(  requireFromOnePasswordPlugin.resolve("@1password/sdk/package.json"),);const ONEPASSWORD_SDK_CORE_WASM = join(  dirname(requireFromOnePasswordSdk.resolve("@1password/sdk-core/package.json")),  "nodejs/core_bg.wasm",); // Throw instead of process.exit so main()'s finally still tears down the// spawned daemon + temp dirs — exiting here leaks a running process.const fail = (msg: string): never => {  // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: standalone smoke harness surfaces failures as a thrown error  throw new Error(`[smoke-sidecar] FAIL: ${msg}`);}; type ToolCallResult = Awaited<ReturnType<Client["callTool"]>>; const isRecord = (value: unknown): value is Record<string, unknown> =>  typeof value === "object" && value !== null && !Array.isArray(value); const makeScopeId = (cwd: string): string => {  const folder = basename(cwd) || cwd;  const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 8);  return `${folder}-${hash}`;}; const readLegacyMigrations = async (): Promise<readonly { sql: string; hash: string }[]> => {  const journal = (await Bun.file(join(APPS_LOCAL_DRIZZLE, "meta/_journal.json")).json()) as {    readonly entries: readonly { readonly idx: number; readonly tag: string }[];  };   const migrations: { sql: string; hash: string }[] = [];  for (const entry of [...journal.entries].sort((left, right) => left.idx - right.idx)) {    const query = await Bun.file(join(APPS_LOCAL_DRIZZLE, `${entry.tag}.sql`)).text();    migrations.push({      sql: query,      hash: createHash("sha256").update(query).digest("hex"),    });  }  return migrations;}; const seedLegacyScopedSqlite = async (dataDir: string, scopeId: string): Promise<void> => {  const db = new Database(join(dataDir, "data.db"));  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone smoke harness closes the SQLite handle before spawning the sidecar  try {    // Replay the real legacy v1 chain so the fixture matches the migration    // history the sidecar's embedded copy expects. A hand-rolled partial    // schema that claims the full history is applied makes the v1→v2 data    // migration read tables that don't exist.    const migrations = await readLegacyMigrations();    for (const migration of migrations) {      // `--> statement-breakpoint` markers are SQL line comments, so the      // whole file executes as one multi-statement batch.      db.exec(migration.sql);    }     db.exec(`      CREATE TABLE "__drizzle_migrations" (        id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,        hash text NOT NULL,        created_at numeric      );    `);    const insertMigration = db.prepare(      `INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)`,    );    for (const migration of migrations) {      insertMigration.run(migration.hash, Date.now());    }     db.prepare(      `INSERT INTO source (        scope_id, id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,    ).run(      scopeId,      "legacy-smoke",      "smoke",      "remote",      "Legacy Smoke Source",      null,      1,      0,      1,      1_700_000_000_000,      1_700_000_001_000,    );    db.prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)").run(      `${scopeId}/smoke`,      "legacy",      "{}",    );    db.exec("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode = DELETE;");  } finally {    db.close();  }}; // The v1→v2 migration moves the legacy SQLite file set aside as// `data.db.v1-v2-<ts>-<nonce>` before writing the new v2 database. Assert the// backup exists and still holds the seeded legacy row — that proves the// migration path ran (rather than the sidecar treating the DB as fresh) and// preserved the original data.const assertV1MigrationCompleted = async (dataDir: string): Promise<void> => {  const entries = await Array.fromAsync(new Bun.Glob("data.db.v1-v2-*").scan({ cwd: dataDir }));  const backups = entries.filter((name) => !name.endsWith("-wal") && !name.endsWith("-shm"));  if (backups.length !== 1) {    fail(`expected exactly one v1→v2 backup in ${dataDir}, found: ${JSON.stringify(entries)}`);  }   const backup = new Database(join(dataDir, backups[0]!), { readonly: true });  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone smoke harness closes the SQLite handle it opened  try {    const row = backup.prepare("SELECT name FROM source WHERE id = 'legacy-smoke'").get() as {      name?: string;    } | null;    if (row?.name !== "Legacy Smoke Source") {      fail(`v1→v2 backup is missing the seeded legacy source row: ${JSON.stringify(row)}`);    }  } finally {    backup.close();  }}; // Petstore-style spec: GET list + GET by id. Exercises path params,// multi-step orchestration, and array/object response shapes against a real// running HTTP server, all the way through the compiled binary →// MCP → QuickJS → openapi-invoker → HttpClient chain.const startOpenApiServer = () => {  const Pet = {    type: "object",    properties: {      id: { type: "integer" },      name: { type: "string" },      tag: { type: "string" },    },    required: ["id", "name"],  };   const spec = {    openapi: "3.0.0",    info: { title: "Petstore Smoke API", version: "0.0.1" },    paths: {      "/pets": {        get: {          operationId: "listPets",          responses: {            "200": {              description: "ok",              content: {                "application/json": { schema: { type: "array", items: Pet } },              },            },          },        },      },      "/pets/{petId}": {        get: {          operationId: "getPet",          parameters: [            {              name: "petId",              in: "path",              required: true,              schema: { type: "integer" },            },          ],          responses: {            "200": {              description: "ok",              content: { "application/json": { schema: Pet } },            },            "404": { description: "not found" },          },        },      },    },  };   // Seed the in-memory store so the GET-driven smoke can verify list +  // path-param round-trips. Body-bearing POST/PUT is gated by the  // executor's approval flow and is covered by separate non-compiled tests.  const pets: Array<{ id: number; name: string; tag?: string }> = [    { id: 1, name: "Fido", tag: "dog" },    { id: 2, name: "Whiskers", tag: "cat" },  ];   const server = Bun.serve({    port: 0,    hostname: "127.0.0.1",    fetch(req) {      const url = new URL(req.url);      if (url.pathname === "/openapi.json") return Response.json(spec);       if (url.pathname === "/pets" && req.method === "GET") {        return Response.json(pets);      }       const match = /^\/pets\/(\d+)$/.exec(url.pathname);      if (match && req.method === "GET") {        const pet = pets.find((p) => p.id === Number(match[1]));        if (!pet) return new Response("not found", { status: 404 });        return Response.json(pet);      }       return new Response("not found", { status: 404 });    },  });  return { server, origin: `http://127.0.0.1:${server.port}` };}; const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<number> =>  // oxlint-disable-next-line executor/no-promise-reject -- boundary: standalone build-time smoke harness, no Effect runtime  new Promise((resolveReady, rejectReady) => {    const deadline = setTimeout(() => {      // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: standalone smoke harness reporting a build-time timeout      rejectReady(new Error(`daemon did not announce ready within ${READY_TIMEOUT_MS}ms`));    }, READY_TIMEOUT_MS);     let stdoutBuf = "";    const decoder = new TextDecoder();    const reader = proc.stdout.getReader();     const stderrReader = proc.stderr.getReader();    void (async () => {      while (true) {        const { value, done } = await stderrReader.read();        if (done) return;        process.stderr.write(`[executor-stderr] ${decoder.decode(value)}`);      }    })();     void (async () => {      while (true) {        const { value, done } = await reader.read();        if (done) {          clearTimeout(deadline);          // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: standalone smoke harness, stdout-closed surfaced as rejection          rejectReady(new Error("daemon stdout closed before ready"));          return;        }        const chunk = decoder.decode(value);        process.stdout.write(`[executor-stdout] ${chunk}`);        stdoutBuf += chunk;        const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuf);        if (match) {          clearTimeout(deadline);          resolveReady(parseInt(match[1]!, 10));          return;        }      }    })();  }); const completePausedResult = async (  client: Client,  initial: ToolCallResult,): Promise<Record<string, unknown>> => {  let result = initial;  for (let attempt = 0; attempt < 5; attempt++) {    if (result.isError) {      fail(`tool returned isError: ${JSON.stringify(result.content)}`);    }     const structured = result.structuredContent;    if (!isRecord(structured)) {      fail(`tool returned no structured content: ${JSON.stringify(result.content)}`);    }    const structuredRecord = structured as Record<string, unknown>;     if (structuredRecord.status !== "waiting_for_interaction") {      return structuredRecord;    }     const executionId = structuredRecord.executionId;    if (typeof executionId !== "string" || executionId.length === 0) {      fail(`paused result missing executionId: ${JSON.stringify(structuredRecord)}`);    }     console.log(`[smoke-sidecar] auto-accepting paused execution ${executionId}`);    result = await client.callTool({      name: "resume",      arguments: { executionId, action: "accept", content: "{}" },    });  }   return fail("execute still paused after 5 resume attempts");}; const stageSidecarWithUnavailableOp = async (  sidecarRoot: string,): Promise<{ readonly binary: string }> => {  const stagedDir = join(sidecarRoot, "executor");  await cp(dirname(BINARY), stagedDir, { recursive: true });   const fakeOpPath = join(stagedDir, process.platform === "win32" ? "op.cmd" : "op");  const fakeOp =    process.platform === "win32"      ? "@echo off\r\necho [ERROR] 2026/01/01 00:00:00 op: command not found 1>&2\r\nexit /b 127\r\n"      : "#!/bin/sh\necho '[ERROR] 2026/01/01 00:00:00 op: command not found' >&2\nexit 127\n";  await Bun.write(fakeOpPath, fakeOp);  if (process.platform !== "win32") {    await chmod(fakeOpPath, 0o755);  }   return { binary: join(stagedDir, EXECUTOR_BINARY_NAME) };}; interface HiddenWorkspaceWasm {  readonly original: string;  readonly hidden: string;} const hideWorkspaceOnePasswordWasm = async (): Promise<HiddenWorkspaceWasm> => {  const hidden = `${ONEPASSWORD_SDK_CORE_WASM}.hidden`;  if (!(await Bun.file(ONEPASSWORD_SDK_CORE_WASM).exists())) {    fail(`workspace 1Password SDK WASM not found at ${ONEPASSWORD_SDK_CORE_WASM}`);  }   await rm(hidden, { force: true });  await rename(ONEPASSWORD_SDK_CORE_WASM, hidden);  console.log(`[smoke-sidecar] hid workspace 1Password SDK WASM: ${ONEPASSWORD_SDK_CORE_WASM}`);  return { original: ONEPASSWORD_SDK_CORE_WASM, hidden };}; const restoreWorkspaceOnePasswordWasm = async (  hiddenWorkspaceWasm: HiddenWorkspaceWasm | null,): Promise<void> => {  if (!hiddenWorkspaceWasm) return;   if (await Bun.file(hiddenWorkspaceWasm.original).exists()) {    await rm(hiddenWorkspaceWasm.hidden, { force: true });    return;  }   if (!(await Bun.file(hiddenWorkspaceWasm.hidden).exists())) {    fail(`workspace 1Password SDK WASM restore source missing at ${hiddenWorkspaceWasm.hidden}`);  }   await rename(hiddenWorkspaceWasm.hidden, hiddenWorkspaceWasm.original);  console.log(`[smoke-sidecar] restored workspace 1Password SDK WASM`);}; const assertOnePasswordSdkLoads = async (origin: string): Promise<void> => {  const url = new URL("/api/onepassword/vaults", origin);  url.searchParams.set("authKind", "service-account");  url.searchParams.set("account", "ops_fake_smoke_token");  const response = await fetch(url, { headers: { Authorization: AUTH_HEADER } });  const body = await response.text();   if (response.status !== 502) {    fail(`expected 1Password invalid-token 502, got ${response.status}: ${body}`);  }  if (    body.includes("createClient is not a function") ||    body.includes("undefined is not a constructor") ||    body.includes("did not expose createClient and DesktopAuth")  ) {    fail(`1Password SDK namespace did not load in compiled binary: ${body}`);  }  if (!body.includes("invalid service account token")) {    fail(`1Password SDK did not reach token validation: ${body}`);  }   console.log("[smoke-sidecar] OK - 1Password SDK reached service-account token validation");}; const main = async () => {  if (!(await Bun.file(BINARY).exists())) {    fail(      `binary not found at ${BINARY}. Run \`bun ./scripts/build-sidecar.ts\` from apps/desktop first.`,    );  }   const scopeDir = await mkdtemp(join(tmpdir(), "executor-smoke-scope-"));  const dataDir = await mkdtemp(join(tmpdir(), "executor-smoke-data-"));  const sidecarRoot = await mkdtemp(join(tmpdir(), "executor-smoke-sidecar-"));  const sidecar = await stageSidecarWithUnavailableOp(sidecarRoot);  let hiddenWorkspaceWasm: HiddenWorkspaceWasm | null = null;  await seedLegacyScopedSqlite(dataDir, makeScopeId(scopeDir));  // v2 connections reference credentials by provider item instead of carrying  // raw values, so seed the file-secrets provider (auth.json under  // XDG_DATA_HOME) with the token the sandbox's connections.create points at.  const xdgDir = await mkdtemp(join(tmpdir(), "executor-smoke-xdg-"));  await Bun.write(    join(xdgDir, "executor", "auth.json"),    `${JSON.stringify({ "petstore-token": "smoke-token" })}\n`,  );  const openapi = startOpenApiServer();   console.log(`[smoke-sidecar] scope:   ${scopeDir}`);  console.log(`[smoke-sidecar] data:    ${dataDir}`);  console.log(`[smoke-sidecar] openapi: ${openapi.origin}`);  console.log(`[smoke-sidecar] sidecar: ${sidecar.binary}`);   const proc = spawn({    cmd: [      sidecar.binary,      "daemon",      "run",      "--foreground",      "--port",      "0",      "--hostname",      "127.0.0.1",      "--auth-token",      AUTH_TOKEN,    ],    env: {      ...process.env,      EXECUTOR_SCOPE_DIR: scopeDir,      EXECUTOR_DATA_DIR: dataDir,      EXECUTOR_CLIENT: "desktop",      XDG_DATA_HOME: xdgDir,    },    stdin: "ignore",    stdout: "pipe",    stderr: "pipe",  });   let exitCode: number | null = null;  void proc.exited.then((code) => {    exitCode = code;  });   const cleanup = async () => {    if (exitCode === null) {      proc.kill("SIGTERM");      await Promise.race([proc.exited, Bun.sleep(3000)]);      if (exitCode === null) proc.kill("SIGKILL");    }    openapi.server.stop(true);    await restoreWorkspaceOnePasswordWasm(hiddenWorkspaceWasm);    // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort tempdir cleanup in a standalone smoke harness    await rm(scopeDir, { recursive: true, force: true }).catch(() => {});    // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort tempdir cleanup in a standalone smoke harness    await rm(dataDir, { recursive: true, force: true }).catch(() => {});    // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort tempdir cleanup in a standalone smoke harness    await rm(xdgDir, { recursive: true, force: true }).catch(() => {});    // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort tempdir cleanup in a standalone smoke harness    await rm(sidecarRoot, { recursive: true, force: true }).catch(() => {});  };   // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone smoke harness needs a finally to tear down the spawned binary + http server  try {    const port = await waitForReadyPort(proc);    await assertV1MigrationCompleted(dataDir);    const mcpUrl = new URL(`http://127.0.0.1:${port}/mcp`);    console.log(`[smoke-sidecar] ready on ${mcpUrl.origin}`);    hiddenWorkspaceWasm = await hideWorkspaceOnePasswordWasm();    await assertOnePasswordSdkLoads(mcpUrl.origin);     const transport = new StreamableHTTPClientTransport(mcpUrl, {      requestInit: { headers: { Authorization: AUTH_HEADER } },    });

Lines 1–500 of 592.