engine-kit 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/kit/ensure.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // engine-kit/ensure.mjs — `npx engine-kit ensure <motor>`: instala um motor com um comando, sem clonar o repo.
2
+ //
3
+ // O mesmo caminho do doctor --fix (resolve → provision: sha256 + Ed25519 fail-closed, lock, swap), com uma
4
+ // diferença na raiz de confiança: a chave pública do PRIMEIRO contato vem do manifest.json que viaja DENTRO deste
5
+ // pacote (publicado no npm com proveniência), não do registry baixado na hora. Ela entra como `trustedKeys`: se o
6
+ // registry servir outra chave, é ABORT já na primeira instalação — quem toma o repositório não troca a chave sem
7
+ // também publicar um engine-kit novo, e essa publicação deixa rastro público (proveniência do npm).
8
+ //
9
+ // Motor que não está no manifest deste pacote é recusado: sem a chave embarcada não há primeiro contato
10
+ // verificável, e cair no TOFU calado seria justamente o buraco que o pacote fecha. Saída: atualizar o kit.
11
+ // NUNCA lança: devolve { ok, reason } para o cli decidir o exit code.
12
+ import { readFileSync, existsSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { resolve } from "./resolve.mjs";
15
+ import { provision, installedVersion, satisfiesPin } from "./provision.mjs";
16
+ import { shutdown } from "./lifecycle.mjs";
17
+
18
+ const BUNDLED_MANIFEST = new URL("../manifest.json", import.meta.url);
19
+
20
+ /** Manifest que viaja dentro do pacote (lança se ilegível; `ensureCommand` transforma em reason). */
21
+ export function bundledManifest() {
22
+ return JSON.parse(readFileSync(BUNDLED_MANIFEST, "utf8"));
23
+ }
24
+
25
+ /**
26
+ * @param {string} name motor no registry.
27
+ * @param {object} opts
28
+ * @param {string=} opts.version pin de versão (default: a do registry).
29
+ * @param {string=} opts.registryUrl manifest remoto (default: registry público).
30
+ * @param {object=} opts.bundled manifest embarcado (default: o deste pacote; testes injetam).
31
+ * @param {object=} opts.manifest manifest do registry já carregado (testes; pula rede e cache).
32
+ * @param {Function=} opts.fetcher download injetável (testes).
33
+ * @param {boolean=} opts.autoUpdate false = motor já instalado fica na versão dele e o retorno avisa a nova
34
+ * (`available`). Default true. Um `version` explícito sempre vale: foi pedido.
35
+ * @returns {Promise<{ok:true, entryPath:string, version:string, kind:string, reused?:boolean, signed?:boolean,
36
+ * previous?:string, available?:string} | {ok:false, reason:string}>} `previous` = versão substituída.
37
+ */
38
+ export async function ensureCommand(name, { version, registryUrl, bundled, manifest, fetcher, autoUpdate = true, log = () => {} } = {}) {
39
+ let local;
40
+ try { local = bundled ?? bundledManifest(); }
41
+ catch (e) { return { ok: false, reason: `manifest embarcado ilegível: ${e.message}` }; }
42
+
43
+ const pinned = (local.engines ?? []).find((e) => e.name === name);
44
+ if (!pinned) {
45
+ return { ok: false, reason: `motor "${name}" não está no manifest deste engine-kit → atualize o kit (npx engine-kit@latest ensure ${name})` };
46
+ }
47
+ const publicKey = pinned.install?.publicKey;
48
+ if (!publicKey) {
49
+ return { ok: false, reason: `${name}: o manifest embarcado não traz publicKey → sem chave do primeiro contato não instalo (ABORT)` };
50
+ }
51
+
52
+ // refresh: instalar quer a verdade do registry, não um descritor de cache.
53
+ const r = await resolve(name, { version, registryUrl, refresh: true, ...(manifest ? { manifest } : {}) });
54
+ if (!r.ok) { return { ok: false, reason: r.reason }; }
55
+
56
+ const engine = r.engine;
57
+ const entryPath = join(engine.homeDir, engine.install.entry);
58
+ const previous = existsSync(entryPath) ? installedVersion(join(engine.homeDir, engine.install.extractTo ?? "bin")) : null;
59
+ if (!autoUpdate && !version && previous && !satisfiesPin(previous, engine.version)) {
60
+ return { ok: true, entryPath, version: previous, kind: engine.kind, reused: true, available: engine.version };
61
+ }
62
+
63
+ const p = await provision(r.engine, {
64
+ log,
65
+ trustedKeys: { [name]: publicKey },
66
+ ...(fetcher ? { fetcher } : {}),
67
+ // Windows: arquivo em uso não se sobrescreve. Derruba o daemon pelo shutdownPath que o registry declara.
68
+ onBeforeReplace: async () => {
69
+ const s = await shutdown(r.engine, { log });
70
+ if (!s.ok) { log(`aviso: ${s.reason} — o swap pode falhar com o motor vivo`); }
71
+ },
72
+ });
73
+ if (!p.ok) {
74
+ // Rotação legítima de chave chega ao registry antes de chegar a este pacote: a saída é o kit novo.
75
+ const hint = /FIXADA|não declara publicKey/.test(p.reason) ? ` — se a chave foi rotacionada de propósito, rode npx engine-kit@latest ensure ${name}` : "";
76
+ return { ok: false, reason: p.reason + hint };
77
+ }
78
+ return {
79
+ ok: true, entryPath: p.entryPath, version: p.version, kind: engine.kind, reused: p.reused === true, signed: p.signed,
80
+ ...(p.installed && previous && previous !== p.version ? { previous } : {}),
81
+ };
82
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // engine-kit/gen-key.mjs — gera o par Ed25519 de UM motor.
3
+ //
4
+ // node kit/gen-key.mjs <motor>
5
+ //
6
+ // Uma chave POR MOTOR (não uma chave do publisher): comprometer uma chave obriga a republicar
7
+ // aquele motor, não a casa inteira. A privada fica só nesta máquina, em ~/.engine-signing/, e
8
+ // NUNCA entra em CI — é o que garante que um CI comprometido não consegue forjar um release.
9
+ //
10
+ // A pública sai no stdout para você colar em `install.publicKey` do manifest.json.
11
+ import { mkdirSync, writeFileSync, existsSync, chmodSync } from "node:fs";
12
+ import { generateKeyPairHex, publicKeyFromHex, signBlob, verifyBlob } from "./signature.mjs";
13
+ import { SIGNING_DIR, keyPathFor } from "./keystore.mjs";
14
+
15
+ const die = (msg) => { console.error(`FALHOU: ${msg}`); process.exit(1); };
16
+
17
+ const engine = process.argv[2];
18
+ if (!engine || !/^[a-z0-9][a-z0-9-]*$/.test(engine)) {
19
+ die("uso: node kit/gen-key.mjs <motor> (nome em minúsculas, ex.: mcp-gateway)");
20
+ }
21
+
22
+ const keyPath = keyPathFor(engine);
23
+ if (existsSync(keyPath)) {
24
+ // Sobrescrever invalidaria TODAS as releases já assinadas deste motor: os consumidores têm a
25
+ // pública antiga pinada e passariam a recusar tudo. Recusa alto em vez de destruir em silêncio.
26
+ die(`já existe uma chave para "${engine}" em ${keyPath}.\n` +
27
+ " Sobrescrever invalidaria todas as releases assinadas com ela (os consumidores têm a pública pinada).\n" +
28
+ " Se a rotação for intencional, mova o arquivo antigo à mão e rode de novo.");
29
+ }
30
+ const { publicKeyHex, privateKeyHex } = generateKeyPairHex();
31
+
32
+ // Auto-prova antes de gravar: o par gerado assina e verifica de verdade, e recusa adulterado.
33
+ // Sem isto, um par quebrado só apareceria na primeira instalação de um consumidor.
34
+ const probe = Buffer.from(`engine-kit gen-key probe :: ${engine}`);
35
+ const sig = signBlob(probe, privateKeyHex);
36
+ if (!verifyBlob(probe, sig, publicKeyHex).ok) { die("o par gerado não se auto-verificou — abortando"); }
37
+ if (verifyBlob(Buffer.concat([probe, Buffer.from("x")]), sig, publicKeyHex).ok) {
38
+ die("o par gerado ACEITOU conteúdo adulterado — abortando");
39
+ }
40
+ publicKeyFromHex(publicKeyHex); // prova que o envelope DER importa no Node desta máquina
41
+
42
+ mkdirSync(SIGNING_DIR, { recursive: true });
43
+ writeFileSync(keyPath, privateKeyHex, { encoding: "utf8", mode: 0o600 });
44
+ try { chmodSync(keyPath, 0o600); } catch { /* NTFS ignora o modo; o ACL do perfil já restringe */ }
45
+
46
+ console.log(`chave privada : ${keyPath} (NÃO versionar, NÃO subir para CI)`);
47
+ console.log(`chave pública : ${publicKeyHex}`);
48
+ console.log("");
49
+ console.log(`Cole no manifest.json, no motor "${engine}":`);
50
+ console.log(JSON.stringify({
51
+ install: { publicKey: publicKeyHex, signatureAlgorithm: "ed25519-sha256-raw", signatureRequired: true },
52
+ }, null, 2));
package/kit/index.mjs ADDED
@@ -0,0 +1,54 @@
1
+ // engine-kit — a dependência que as extensões importam para consumir um MOTOR.
2
+ //
3
+ // Um motor (embed-house, vox-engine, mcp-gateway…) é infraestrutura compartilhada: instala UMA vez
4
+ // por máquina, em `~/.<motor>`, e serve N consumidores. O consumidor não empacota o motor nem
5
+ // duplica a lógica de "achar ou baixar" — declara a dependência e chama `ensureEngine`.
6
+ //
7
+ // import { ensureEngine } from "engine-kit";
8
+ // const r = await ensureEngine("embed-house", { healthCheck: async (rt) => { ... } });
9
+ // if (!r.available) { /* degrade sinalizado — NUNCA silencioso */ }
10
+ //
11
+ // Contrato: nenhuma função lança. Falha vira `{ ok:false | available:false, reason }` para o
12
+ // consumidor decidir (fail-loud no caller, não no kit).
13
+ export { resolve, DEFAULT_REGISTRY_URL, expandHome, target, fill } from "./resolve.mjs";
14
+ export { provision, satisfiesPin, fetchArtifact } from "./provision.mjs";
15
+ export { lifecycle, readRuntime, readFreshRuntime, shutdown } from "./lifecycle.mjs";
16
+ export {
17
+ verifyBlob, signBlob, generateKeyPairHex, publicKeyFromHex, privateKeyFromHex, publicKeyHexFromPrivateHex,
18
+ SUPPORTED_ALGORITHMS, DEFAULT_ALGORITHM, SIGNATURE_BYTES,
19
+ } from "./signature.mjs";
20
+ export { SIGNING_DIR, keyPathFor } from "./keystore.mjs";
21
+
22
+ import { resolve } from "./resolve.mjs";
23
+ import { provision } from "./provision.mjs";
24
+ import { lifecycle } from "./lifecycle.mjs";
25
+
26
+ /**
27
+ * RESOLVE → PROVISION → LIFECYCLE numa chamada: o caminho que 99% dos consumidores querem.
28
+ * @param {string} name nome do motor no registry.
29
+ * @param {object} opts
30
+ * @param {(runtime:object)=>Promise<object|null>} opts.healthCheck validação de saúde/protocolo (obrigatória).
31
+ * @param {string=} opts.version pin de versão (default: a do registry).
32
+ * @param {boolean=} opts.allowNetwork permite baixar/atualizar (default true).
33
+ * @param {Record<string,string>=} opts.trustedKeys chaves públicas FIXADAS pelo consumidor
34
+ * (`{ "<motor>": "<hex>" }`). Divergência com o registry = ABORT. Sem isso vale TOFU:
35
+ * a chave da 1ª instalação é gravada e qualquer troca posterior aborta.
36
+ * @param {string=} opts.registryUrl URL do manifest (default: registry público; testes apontam para um local).
37
+ * @param {(url:string,timeoutMs:number)=>Promise<Buffer>=} opts.fetcher download injetável (testes).
38
+ * @param {()=>Promise<void>=} opts.onBeforeReplace shutdown do motor antes de sobrescrever (Windows).
39
+ * @returns {Promise<{available:true, runtime:object, health:object, engine:object} | {available:false, reason:string}>}
40
+ */
41
+ export async function ensureEngine(name, opts = {}) {
42
+ const { healthCheck, version, allowNetwork = true, registryUrl, log = () => {}, bootTimeoutMs, onBeforeReplace, env, trustedKeys, refresh = false, fetcher } = opts;
43
+
44
+ const r = await resolve(name, { version, allowNetwork, registryUrl, refresh });
45
+ if (!r.ok) { return { available: false, reason: r.reason }; }
46
+
47
+ const p = await provision(r.engine, { log, allowNetwork, onBeforeReplace, trustedKeys, ...(fetcher ? { fetcher } : {}) });
48
+ if (!p.ok) { return { available: false, reason: p.reason }; }
49
+
50
+ const l = await lifecycle(r.engine, p.entryPath, { healthCheck, log, bootTimeoutMs, env });
51
+ if (!l.available) { return l; }
52
+
53
+ return { ...l, engine: { ...r.engine, installedVersion: p.version } };
54
+ }
@@ -0,0 +1,29 @@
1
+ // engine-kit/keystore.mjs — ONDE moram as chaves privadas de assinatura nesta máquina.
2
+ //
3
+ // Módulo separado de propósito: `gen-key.mjs` e `sign.mjs` são CLIs (executam ao serem
4
+ // carregados). Se um importasse o outro só para reaproveitar o caminho da chave, o CLI do outro
5
+ // dispararia junto. O caminho é dado — vive num módulo sem efeito colateral.
6
+ //
7
+ // Uma chave POR MOTOR: o raio de explosão de um vazamento é um motor, não a casa toda.
8
+ //
9
+ // OVERRIDE por ambiente: `ENGINE_SIGNING_KEY_<MOTOR>` (hífens viram `_`, tudo em maiúsculas).
10
+ // Existe por um motivo concreto: a chave do `vox-engine` já vive em
11
+ // `~/.action/signing/vox_engine_ed25519_private.key` e a PÚBLICA dela está pinada em mais de dez
12
+ // cópias vendorizadas do SDK, espalhadas por outros repositórios. Mover o arquivo por estética
13
+ // arriscaria invalidar todas elas. O padrão da casa continua sendo `~/.engine-signing/`; o
14
+ // override é a porta de entrada para as chaves que já existiam antes dele.
15
+ import { join } from "node:path";
16
+ import { homedir } from "node:os";
17
+
18
+ export const SIGNING_DIR = join(homedir(), ".engine-signing");
19
+
20
+ /** Nome da variável de ambiente que sobrescreve o caminho da chave de um motor. */
21
+ export const envVarFor = (engine) => `ENGINE_SIGNING_KEY_${String(engine).replace(/-/g, "_").toUpperCase()}`;
22
+
23
+ /** Caminho canônico da chave privada de um motor (respeita o override por ambiente). */
24
+ export function keyPathFor(engine) {
25
+ const override = process.env[envVarFor(engine)];
26
+ if (override && override.trim()) { return override.trim(); }
27
+ return join(SIGNING_DIR, `${engine}_ed25519_private.key`);
28
+ }
29
+
@@ -0,0 +1,160 @@
1
+ // engine-kit/lifecycle.mjs — LIFECYCLE: garante o motor VIVO e devolve como falar com ele.
2
+ // Generalização do ensureDaemon.mjs do embed-house. Regra do kit: ele NUNCA adivinha a saúde do
3
+ // motor — `healthCheck` é sempre um callback de quem conhece o protocolo (handshake de versão,
4
+ // modelo, dimensão…). O kit cuida do que é genérico: ler o runtime.json fresco, subir o processo
5
+ // destacado e esperar o auto-anúncio. NUNCA lança.
6
+ //
7
+ // Dois tipos de motor (`kind`):
8
+ // • daemon — processo longo que anuncia `runtime.json` e responde health. É o fluxo abaixo.
9
+ // • cli — executável invocado POR EVENTO (ex.: o dispatcher de hooks): não há processo para
10
+ // manter vivo, nem runtime, nem health. Provisionar já é entregar. Forçar um daemon
11
+ // aqui seria inventar um ciclo de vida que o motor não tem.
12
+ import { spawn } from "node:child_process";
13
+ import { readFileSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ /**
19
+ * Janela padrão de frescor quando o motor não declara `heartbeatMs`.
20
+ * NUNCA usar Infinity aqui: um `runtime.json` de dias atrás passaria como "vivo" e o
21
+ * lifecycle devolveria um motor morto. Sem heartbeat declarado, 2 minutos é o teto.
22
+ */
23
+ const DEFAULT_STALE_MS = 120000;
24
+
25
+ /** Lê o runtime.json do motor, sem julgar idade. `null` se ausente/ilegível. */
26
+ export function readRuntime(engine) {
27
+ try {
28
+ const rt = join(engine.homeDir, engine.runtime?.runtimeFile ?? "runtime.json");
29
+ return JSON.parse(readFileSync(rt, "utf8"));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** Lê o runtime.json do motor SE estiver fresco (heartbeat vivo). null se ausente/obsoleto. */
36
+ export function readFreshRuntime(engine, { staleMs } = {}) {
37
+ const rt = join(engine.homeDir, engine.runtime?.runtimeFile ?? "runtime.json");
38
+ const hb = engine.runtime?.heartbeatMs;
39
+ const limit = staleMs ?? (hb ? hb * 3 : DEFAULT_STALE_MS);
40
+ try {
41
+ if (Date.now() - statSync(rt).mtimeMs > limit) { return null; }
42
+ return JSON.parse(readFileSync(rt, "utf8"));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Localiza um motor VIVO: lê o runtime (dica de onde procurar) e pergunta ao próprio motor
50
+ * (prova de que está vivo).
51
+ *
52
+ * A ordem importa e custou um bug de produção: antes, o frescor do `runtime.json` **vetava** o
53
+ * handshake — um motor que escreve o arquivo só no boot (o caso do mcp-gateway) era declarado
54
+ * morto depois de 2 minutos de uptime, e o kit tentava subir outro por cima do que estava
55
+ * servindo. Arquivo é DICA (diz a porta); handshake é AUTORIDADE (prova que atende, e que é o
56
+ * motor certo — o healthCheck confere o nome). Runtime velho apontando para porta alheia
57
+ * continua seguro: o handshake não reconhece e o fluxo segue para o spawn.
58
+ */
59
+ async function findAlive(engine, healthCheck) {
60
+ const rt = readRuntime(engine);
61
+ if (!rt) { return null; }
62
+ const health = await healthCheck(rt);
63
+ return health ? { runtime: rt, health } : null;
64
+ }
65
+
66
+ /**
67
+ * Pede ao motor que se encerre, usando SÓ o que o registry declara.
68
+ *
69
+ * Existe porque no Windows um arquivo em uso não pode ser sobrescrito: atualizar um daemon vivo
70
+ * falha com EPERM no meio do swap. O kit continua não adivinhando nada — se o descritor não
71
+ * declara `shutdownPath`, ele não tenta; se declara um token, o token vem do campo declarado do
72
+ * runtime.json, no cabeçalho declarado. Sem declaração, sem token (e não se inventa um: seria um
73
+ * endpoint de desligar aberto no loopback).
74
+ *
75
+ * @returns {Promise<{ok:true, stopped:boolean} | {ok:false, reason:string}>} nunca lança.
76
+ */
77
+ export async function shutdown(engine, { log = () => {}, timeoutMs = 5000 } = {}) {
78
+ const path = engine.runtime?.shutdownPath;
79
+ if (!path) { return { ok: true, stopped: false }; }
80
+
81
+ // Runtime SEM gate de frescor: um motor que só escreve o arquivo no boot (caso do mcp-gateway)
82
+ // seria considerado "morto" aqui e o shutdown viraria no-op — e aí o swap de arquivos falharia
83
+ // no Windows com EPERM, exatamente o problema que esta função existe para evitar.
84
+ const rt = readRuntime(engine);
85
+ if (!rt?.port) { return { ok: true, stopped: false }; } // nada vivo para derrubar
86
+
87
+ const headers = {};
88
+ const field = engine.runtime?.shutdownTokenField;
89
+ const header = engine.runtime?.shutdownTokenHeader;
90
+ if (field && header && rt[field]) { headers[header] = rt[field]; }
91
+
92
+ try {
93
+ const res = await fetch(`http://127.0.0.1:${rt.port}${path}`, {
94
+ method: "POST", headers, signal: AbortSignal.timeout(timeoutMs),
95
+ });
96
+ if (!res.ok) { return { ok: false, reason: `${engine.name}: shutdown respondeu HTTP ${res.status}` }; }
97
+ log(`[engine-kit] ${engine.name}: shutdown pedido (pid ${rt.pid ?? "?"})`);
98
+ // O motor responde ANTES de sair. Sem esta espera, o swap ainda encontra o arquivo em uso.
99
+ await sleep(1500);
100
+ return { ok: true, stopped: true };
101
+ } catch (e) {
102
+ return { ok: false, reason: `${engine.name}: shutdown falhou (${e?.message || e})` };
103
+ }
104
+ }
105
+
106
+ /** Sobe o motor destacado, com ambiente limpo (fora do fork do host). */
107
+ function spawnEngine(nodePath, entryPath, log, extraEnv) {
108
+ try {
109
+ const child = spawn(nodePath, [entryPath], {
110
+ detached: true,
111
+ stdio: "ignore",
112
+ windowsHide: true,
113
+ env: { ...process.env, NODE_OPTIONS: "", COPILOT_SDK_PATH: "", ...extraEnv },
114
+ });
115
+ child.unref();
116
+ log(`[engine-kit] motor disparado: ${nodePath} ${entryPath}`);
117
+ return true;
118
+ } catch (e) {
119
+ log(`[engine-kit] falha ao subir motor (sinalizado): ${e?.message || e}`);
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Garante o motor vivo.
126
+ * @param engine descritor resolvido (resolve.mjs)
127
+ * @param entryPath caminho do entrypoint instalado (provision.mjs)
128
+ * @param healthCheck (runtimeInfo) => Promise<object|null> — validação de saúde/protocolo DO MOTOR.
129
+ * @returns {Promise<{available:true, runtime:object, health:object} | {available:false, reason:string}>}
130
+ */
131
+ export async function lifecycle(engine, entryPath, { healthCheck, log = () => {}, bootTimeoutMs = 60000, nodePath = process.execPath, env } = {}) {
132
+ // 0) MOTOR CLI: não existe daemon a subir nem runtime a esperar — o binário provisionado É a
133
+ // entrega. Sai ANTES da exigência de healthCheck: cobrar handshake de quem não tem protocolo
134
+ // de rede seria uma barreira artificial.
135
+ if (engine.kind === "cli") {
136
+ log(`[engine-kit] ${engine.name}: motor kind=cli (invocado por evento) — sem processo para manter vivo`);
137
+ return { available: true, kind: "cli", bin: entryPath, runtime: null, health: null };
138
+ }
139
+
140
+ if (typeof healthCheck !== "function") {
141
+ return { available: false, reason: "healthCheck é obrigatório (o kit não adivinha o protocolo do motor)" };
142
+ }
143
+
144
+ // 1) FAST PATH: já vivo? (runtime diz onde; o handshake do motor prova que sim)
145
+ const alive = await findAlive(engine, healthCheck);
146
+ if (alive) { log(`[engine-kit] ${engine.name}: fast-path (já vivo)`); return { available: true, ...alive }; }
147
+
148
+ // 2) SOBE. A corrida por singleton é resolvida DENTRO do motor (port-lock/bind); quem perde sai.
149
+ if (!spawnEngine(nodePath, entryPath, log, env)) {
150
+ return { available: false, reason: `${engine.name}: spawn falhou` };
151
+ }
152
+
153
+ const deadline = Date.now() + bootTimeoutMs;
154
+ while (Date.now() < deadline) {
155
+ const up = await findAlive(engine, healthCheck);
156
+ if (up) { return { available: true, ...up }; }
157
+ await sleep(1000);
158
+ }
159
+ return { available: false, reason: `${engine.name}: não anunciou em ${bootTimeoutMs}ms` };
160
+ }