426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
354 lines
12 KiB
JavaScript
354 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Local test console server: one process that serves the console UI, holds
|
|
* the operator's saved credentials, and drives test runs with live status.
|
|
*
|
|
* Launched via `bun run test:console`. Binds 127.0.0.1 only — it holds raw
|
|
* API keys and can execute repo code, so it must never listen on a routable
|
|
* interface; there is deliberately no auth layer beyond that. The browser
|
|
* gets presence/suffix hints for saved secrets, never the values.
|
|
*
|
|
* Surface:
|
|
* GET / console UI (ui/index.html, self-contained)
|
|
* GET /api/state registry + connections + run snapshot + history
|
|
* GET /api/events SSE stream of run/task/log events
|
|
* POST /api/connections/:id save credential values DELETE — remove
|
|
* POST /api/connections/:id/verify live probe
|
|
* POST /api/gates toggle an opt-in gate
|
|
* POST /api/run start a run {mode, lane, labels?, concurrency?}
|
|
* POST /api/run/cancel cancel the active run
|
|
* GET /api/runs/:id/log?task=<label> persisted log text
|
|
* POST /api/cloud/login/start + GET /api/cloud/login/poll device-code login
|
|
* GET /oauth/google/start + /oauth/google/callback loopback OAuth
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { connectionById, connectionStatus } from "./lib/connections.mjs";
|
|
import {
|
|
completeGoogleFlow,
|
|
DEFAULT_CLOUD_BASE_URL,
|
|
pollCloudLogin,
|
|
refreshGoogleAccessToken,
|
|
startCloudLogin,
|
|
startGoogleFlow,
|
|
} from "./lib/oauth.mjs";
|
|
import { buildRegistry, discoverPlan } from "./lib/registry.mjs";
|
|
import { RunManager } from "./lib/runner.mjs";
|
|
import {
|
|
consoleDir,
|
|
credentialsToEnv,
|
|
listRuns,
|
|
loadCredentials,
|
|
loadHistory,
|
|
loadRun,
|
|
loadSettings,
|
|
removeConnection,
|
|
runLogPath,
|
|
saveSettings,
|
|
setConnection,
|
|
} from "./lib/store.mjs";
|
|
import { verifyConnection } from "./lib/verify.mjs";
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const PORT = Number(process.env.ELIZA_TEST_CONSOLE_PORT || 31338);
|
|
const HOST = "127.0.0.1";
|
|
|
|
const runManager = new RunManager();
|
|
const sseClients = new Set();
|
|
|
|
runManager.on("event", (event) => {
|
|
const frame = `data: ${JSON.stringify(event)}\n\n`;
|
|
for (const client of sseClients) client.write(frame);
|
|
});
|
|
|
|
function json(res, status, body) {
|
|
const text = JSON.stringify(body);
|
|
res.writeHead(status, {
|
|
"Content-Type": "application/json",
|
|
"Content-Length": Buffer.byteLength(text),
|
|
});
|
|
res.end(text);
|
|
}
|
|
|
|
async function readBody(req) {
|
|
let data = "";
|
|
for await (const chunk of req) {
|
|
data += chunk;
|
|
if (data.length > 1_000_000) throw new Error("body too large");
|
|
}
|
|
return data ? JSON.parse(data) : {};
|
|
}
|
|
|
|
function currentState() {
|
|
const settings = loadSettings();
|
|
const registry = buildRegistry({
|
|
savedCredentials: loadCredentials(),
|
|
optInToggles: settings.optInToggles ?? {},
|
|
history: loadHistory(),
|
|
});
|
|
return {
|
|
consoleDir: consoleDir(),
|
|
registry,
|
|
settings: {
|
|
optInToggles: settings.optInToggles ?? {},
|
|
cloudBaseUrl: settings.cloudBaseUrl ?? DEFAULT_CLOUD_BASE_URL,
|
|
},
|
|
run: runManager.snapshot(),
|
|
runs: listRuns()
|
|
.slice(0, 25)
|
|
.map(({ runId, lane, startedAt, finishedAt, cancelled, counts }) => ({
|
|
runId,
|
|
lane,
|
|
startedAt,
|
|
finishedAt,
|
|
cancelled,
|
|
counts,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function selectTasks({ mode, labels }) {
|
|
const plan = discoverPlan();
|
|
const history = loadHistory();
|
|
const all = [...plan.tasks];
|
|
if (plan.cloudStep) {
|
|
all.push({
|
|
label: plan.cloudStep.label,
|
|
relativeDir: "packages/cloud",
|
|
scriptName: "test",
|
|
parallelSafe: false,
|
|
});
|
|
}
|
|
if (mode === "selection") {
|
|
const wanted = new Set(labels ?? []);
|
|
return all.filter((t) => wanted.has(t.label));
|
|
}
|
|
if (mode === "failed") {
|
|
return all.filter((t) => history[t.label]?.status === "failed");
|
|
}
|
|
return all;
|
|
}
|
|
|
|
const routes = {
|
|
"GET /api/state": (req, res) => json(res, 200, currentState()),
|
|
|
|
"POST /api/gates": async (req, res) => {
|
|
const { key, on } = await readBody(req);
|
|
const settings = loadSettings();
|
|
settings.optInToggles = { ...settings.optInToggles, [key]: Boolean(on) };
|
|
saveSettings(settings);
|
|
json(res, 200, { ok: true });
|
|
},
|
|
|
|
"POST /api/run": async (req, res) => {
|
|
const {
|
|
mode = "all",
|
|
lane = "pr",
|
|
labels,
|
|
concurrency,
|
|
} = await readBody(req);
|
|
if (runManager.isRunning())
|
|
return json(res, 409, { error: "run already in progress" });
|
|
const tasks = selectTasks({ mode, labels });
|
|
if (tasks.length === 0)
|
|
return json(res, 400, { error: "no tasks selected" });
|
|
const settings = loadSettings();
|
|
const extraEnv = credentialsToEnv();
|
|
for (const [gate, on] of Object.entries(settings.optInToggles ?? {})) {
|
|
if (on) extraEnv[gate] = "1";
|
|
}
|
|
// Google access tokens expire hourly; a saved refresh token lets live
|
|
// runs re-mint GOOGLE_CALENDAR_ACCESS_TOKEN so the calendar suite stays
|
|
// armed without the operator re-pasting a token every hour.
|
|
const google = loadCredentials()["google-oauth"] ?? {};
|
|
if (
|
|
lane === "live" &&
|
|
google.GOOGLE_CLIENT_ID &&
|
|
google.GOOGLE_CLIENT_SECRET &&
|
|
google.GOOGLE_OAUTH_REFRESH_TOKEN
|
|
) {
|
|
try {
|
|
const { accessToken } = await refreshGoogleAccessToken({
|
|
clientId: google.GOOGLE_CLIENT_ID,
|
|
clientSecret: google.GOOGLE_CLIENT_SECRET,
|
|
refreshToken: google.GOOGLE_OAUTH_REFRESH_TOKEN,
|
|
});
|
|
setConnection("google-calendar", {
|
|
GOOGLE_CALENDAR_ACCESS_TOKEN: accessToken,
|
|
});
|
|
extraEnv.GOOGLE_CALENDAR_ACCESS_TOKEN = accessToken;
|
|
} catch (error) {
|
|
// error-policy:J4 explicit user-facing degrade — the run proceeds and
|
|
// the calendar suite self-skips loudly; the operator sees why here.
|
|
runManager.emit("event", {
|
|
type: "warning",
|
|
message: `Google token refresh failed: ${error?.message ?? error}`,
|
|
});
|
|
}
|
|
}
|
|
const runId = runManager.startRun({
|
|
tasks,
|
|
lane,
|
|
extraEnv,
|
|
concurrency: Number(concurrency) || 3,
|
|
});
|
|
json(res, 200, { runId, taskCount: tasks.length });
|
|
},
|
|
|
|
"POST /api/run/cancel": (req, res) =>
|
|
json(res, 200, { cancelled: runManager.cancel() }),
|
|
|
|
"POST /api/cloud/login/start": async (req, res) => {
|
|
const settings = loadSettings();
|
|
const result = await startCloudLogin({
|
|
baseUrl: settings.cloudBaseUrl ?? DEFAULT_CLOUD_BASE_URL,
|
|
});
|
|
json(res, 200, result);
|
|
},
|
|
|
|
"GET /api/cloud/login/poll": async (req, res, url) => {
|
|
const settings = loadSettings();
|
|
const result = await pollCloudLogin({
|
|
sessionId: url.searchParams.get("sessionId"),
|
|
baseUrl: settings.cloudBaseUrl ?? DEFAULT_CLOUD_BASE_URL,
|
|
});
|
|
if (result.status === "authenticated" && result.apiKey) {
|
|
setConnection("eliza-cloud", {
|
|
ELIZAOS_CLOUD_API_KEY: result.apiKey,
|
|
ELIZA_CLOUD_API_KEY: result.apiKey,
|
|
});
|
|
}
|
|
json(res, 200, { status: result.status });
|
|
},
|
|
|
|
"GET /oauth/google/start": (req, res, url) => {
|
|
const saved = loadCredentials()["google-oauth"] ?? {};
|
|
const clientId = saved.GOOGLE_CLIENT_ID ?? process.env.GOOGLE_CLIENT_ID;
|
|
const clientSecret =
|
|
saved.GOOGLE_CLIENT_SECRET ?? process.env.GOOGLE_CLIENT_SECRET;
|
|
if (!clientId || !clientSecret) {
|
|
return json(res, 400, {
|
|
error:
|
|
"save GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET on the Google OAuth connection first",
|
|
});
|
|
}
|
|
const { authorizeUrl } = startGoogleFlow({
|
|
clientId,
|
|
clientSecret,
|
|
redirectUri: `http://${HOST}:${PORT}/oauth/google/callback`,
|
|
});
|
|
res.writeHead(302, { Location: authorizeUrl });
|
|
res.end();
|
|
},
|
|
|
|
"GET /oauth/google/callback": async (req, res, url) => {
|
|
const code = url.searchParams.get("code");
|
|
const state = url.searchParams.get("state");
|
|
const tokens = await completeGoogleFlow({ state, code });
|
|
const saved = loadCredentials()["google-oauth"] ?? {};
|
|
setConnection("google-oauth", {
|
|
...saved,
|
|
...(tokens.refreshToken
|
|
? { GOOGLE_OAUTH_REFRESH_TOKEN: tokens.refreshToken }
|
|
: {}),
|
|
});
|
|
setConnection("google-calendar", {
|
|
GOOGLE_CALENDAR_ACCESS_TOKEN: tokens.accessToken,
|
|
});
|
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
res.end(
|
|
"<body style='font-family:system-ui;background:#111;color:#eee;display:grid;place-items:center;height:100vh'><div>Google connected — you can close this tab and return to the test console.</div></body>",
|
|
);
|
|
},
|
|
};
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://${HOST}:${PORT}`);
|
|
|
|
try {
|
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
const html = fs.readFileSync(path.join(here, "ui", "index.html"));
|
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
return res.end(html);
|
|
}
|
|
|
|
if (url.pathname === "/api/events") {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
});
|
|
res.write(`data: ${JSON.stringify({ type: "hello" })}\n\n`);
|
|
sseClients.add(res);
|
|
req.on("close", () => sseClients.delete(res));
|
|
return;
|
|
}
|
|
|
|
// Connection CRUD + verify (path-parameterized, matched by prefix).
|
|
const connectionMatch = url.pathname.match(
|
|
/^\/api\/connections\/([a-z0-9-]+)(\/verify)?$/,
|
|
);
|
|
if (connectionMatch) {
|
|
const [, id, verifySuffix] = connectionMatch;
|
|
const connection = connectionById(id);
|
|
if (!connection)
|
|
return json(res, 404, { error: `unknown connection ${id}` });
|
|
if (req.method === "POST" && verifySuffix) {
|
|
const saved = loadCredentials()[id] ?? {};
|
|
const { values } = connectionStatus(connection, saved);
|
|
const result = await verifyConnection(connection, values);
|
|
return json(res, 200, result);
|
|
}
|
|
if (req.method === "POST") {
|
|
const { values } = await readBody(req);
|
|
// Merge over what's saved: the browser only ever sends fields the
|
|
// operator typed (it never holds raw saved secrets to echo back),
|
|
// so a blank field means "keep the existing value".
|
|
const merged = { ...(loadCredentials()[id] ?? {}) };
|
|
for (const field of connection.fields) {
|
|
const value = values?.[field.key];
|
|
if (typeof value === "string" && value.trim() !== "")
|
|
merged[field.key] = value.trim();
|
|
}
|
|
setConnection(id, merged);
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
if (req.method === "DELETE") {
|
|
removeConnection(id);
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
}
|
|
|
|
const logMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/log$/);
|
|
if (logMatch && req.method === "GET") {
|
|
const runId = decodeURIComponent(logMatch[1]);
|
|
const run = loadRun(runId);
|
|
const label = url.searchParams.get("task");
|
|
const entry = run?.tasks?.find((t) => t.label === label);
|
|
if (!entry) return json(res, 404, { error: "unknown run/task" });
|
|
const file = runLogPath(runId, path.basename(entry.log, ".log"));
|
|
if (!fs.existsSync(file)) return json(res, 404, { error: "log missing" });
|
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
return fs.createReadStream(file).pipe(res);
|
|
}
|
|
|
|
const handler = routes[`${req.method} ${url.pathname}`];
|
|
if (handler) return await handler(req, res, url);
|
|
|
|
json(res, 404, { error: "not found" });
|
|
} catch (error) {
|
|
// error-policy:J1 boundary translation — every route failure becomes a
|
|
// structured 500 so the UI renders an error state instead of hanging.
|
|
json(res, 500, { error: String(error?.message ?? error) });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`[TestConsole] listening on http://${HOST}:${PORT}`);
|
|
console.log(`[TestConsole] state dir: ${consoleDir()}`);
|
|
});
|