Merge remote-tracking branch 'origin/main' into feat/voice-aurora-stage
This commit is contained in:
commit
8e779d2895
@ -25,6 +25,10 @@ PUBLIC_POSTHOG_KEY=
|
|||||||
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||||
SIGNUPS_ENABLED=true
|
SIGNUPS_ENABLED=true
|
||||||
SIGNUP_ALLOWLIST=
|
SIGNUP_ALLOWLIST=
|
||||||
|
# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem.
|
||||||
|
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
|
||||||
|
# deployment owner's own onboarding discover local projects. Other accounts never scan the host.
|
||||||
|
# ONBOARDING_SCAN_HOME=
|
||||||
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091
|
SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"previousSha": "HEAD",
|
|
||||||
"deployedSha": "is now at 2fe3dc5 feat(api): real onboarding scan — filesystem, Git repos, profile compilation",
|
|
||||||
"ref": "2fe3dc5971d55ca9604757bc79aae94feb9ec21d",
|
|
||||||
"status": "success",
|
|
||||||
"at": "2026-09-23T23:52:26Z"
|
|
||||||
}
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -53,3 +53,11 @@ playwright-report-www
|
|||||||
vendor/
|
vendor/
|
||||||
*.gguf
|
*.gguf
|
||||||
*.onnx
|
*.onnx
|
||||||
|
|
||||||
|
# Never track backups, agent handoffs, personal exports or deploy runtime state
|
||||||
|
*.bak
|
||||||
|
*.bak-*
|
||||||
|
.env.bak*
|
||||||
|
.claude-handoff*/
|
||||||
|
apple-notes*
|
||||||
|
.evo-deploy/
|
||||||
|
|||||||
@ -476,6 +476,7 @@ export async function createApp(
|
|||||||
defaultModel: env.defaultModel,
|
defaultModel: env.defaultModel,
|
||||||
teamChatJudgeProvider: env.teamChatJudgeProvider,
|
teamChatJudgeProvider: env.teamChatJudgeProvider,
|
||||||
teamChatJudgeModel: env.teamChatJudgeModel,
|
teamChatJudgeModel: env.teamChatJudgeModel,
|
||||||
|
onboardingScanHome: env.onboardingScanHome,
|
||||||
deploymentModelKey: env.deploymentModelKey,
|
deploymentModelKey: env.deploymentModelKey,
|
||||||
webOrigin: env.webOrigin,
|
webOrigin: env.webOrigin,
|
||||||
privacyPolicyUrl: env.privacyPolicyUrl,
|
privacyPolicyUrl: env.privacyPolicyUrl,
|
||||||
|
|||||||
@ -35,6 +35,13 @@ describe("loadEnv", () => {
|
|||||||
expect(env.wakeupDriver).toBe("memory");
|
expect(env.wakeupDriver).toBe("memory");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the onboarding scan off the host unless a scan home is configured", () => {
|
||||||
|
expect(loadEnv(base).onboardingScanHome).toBeUndefined();
|
||||||
|
expect(loadEnv({ ...base, ONBOARDING_SCAN_HOME: " /srv/home " }).onboardingScanHome).toBe(
|
||||||
|
"/srv/home",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("loads a trading bot origin and refuses credentials", () => {
|
it("loads a trading bot origin and refuses credentials", () => {
|
||||||
expect(loadEnv(base).tradingBotUrl).toBeUndefined();
|
expect(loadEnv(base).tradingBotUrl).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export interface AppEnv {
|
|||||||
apiHost: string;
|
apiHost: string;
|
||||||
signupsEnabled: string | undefined;
|
signupsEnabled: string | undefined;
|
||||||
signupAllowlist: string | undefined;
|
signupAllowlist: string | undefined;
|
||||||
|
onboardingScanHome: string | undefined;
|
||||||
encryptionKey: string;
|
encryptionKey: string;
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
/** Opt-in Pi JSONL session recording under DATA_DIR/pi-sessions. Default off. */
|
/** Opt-in Pi JSONL session recording under DATA_DIR/pi-sessions. Default off. */
|
||||||
@ -116,6 +117,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
|||||||
apiHost: source.API_HOST ?? "127.0.0.1",
|
apiHost: source.API_HOST ?? "127.0.0.1",
|
||||||
signupsEnabled: source.SIGNUPS_ENABLED,
|
signupsEnabled: source.SIGNUPS_ENABLED,
|
||||||
signupAllowlist: source.SIGNUP_ALLOWLIST,
|
signupAllowlist: source.SIGNUP_ALLOWLIST,
|
||||||
|
onboardingScanHome: optional(source.ONBOARDING_SCAN_HOME),
|
||||||
encryptionKey: resolveEncryptionKey(source),
|
encryptionKey: resolveEncryptionKey(source),
|
||||||
dataDir: source.DATA_DIR ?? "./data",
|
dataDir: source.DATA_DIR ?? "./data",
|
||||||
piSessionRecording: source.PI_SESSION_RECORDING === "true",
|
piSessionRecording: source.PI_SESSION_RECORDING === "true",
|
||||||
|
|||||||
64
apps/api/src/onboarding-scan.test.ts
Normal file
64
apps/api/src/onboarding-scan.test.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||||
|
|
||||||
|
const owner = { userId: "user-owner", isDeploymentOwner: true };
|
||||||
|
const member = { userId: "user-member", isDeploymentOwner: false };
|
||||||
|
|
||||||
|
async function scanHomeWithOneFile(): Promise<string> {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||||
|
await mkdir(join(home, "Projects"));
|
||||||
|
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completed(scanId: string, userId: string) {
|
||||||
|
return await vi.waitFor(
|
||||||
|
async () => {
|
||||||
|
const status = await getScanStatus(scanId, userId);
|
||||||
|
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
{ timeout: 10_000, interval: 50 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("onboarding scan", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides a scan from every account except the one that started it", async () => {
|
||||||
|
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
||||||
|
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
||||||
|
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
||||||
|
status: expect.any(String),
|
||||||
|
});
|
||||||
|
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reads the host without a configured scan home", async () => {
|
||||||
|
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
||||||
|
const status = await completed(scanId, owner.userId);
|
||||||
|
expect(status.status).toBe("complete");
|
||||||
|
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets only the deployment owner discover the configured scan home", async () => {
|
||||||
|
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
|
||||||
|
vi.stubEnv("AGENT_RUNTIME", "");
|
||||||
|
vi.stubEnv("SANDBOX_PROVIDER", "");
|
||||||
|
const scanHome = await scanHomeWithOneFile();
|
||||||
|
|
||||||
|
const asMember = await startOnboardingScan({ actor: member, scanHome }, {});
|
||||||
|
const memberStatus = await completed(asMember.scanId, member.userId);
|
||||||
|
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
||||||
|
|
||||||
|
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
||||||
|
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
||||||
|
expect(ownerStatus.status).toBe("complete");
|
||||||
|
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,6 +3,7 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { access, readdir, stat } from "node:fs/promises";
|
import { access, readdir, stat } from "node:fs/promises";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
import type { Actor } from "@rakazo/contracts";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@ -62,6 +63,7 @@ interface CreatedProject {
|
|||||||
interface ScanRecord {
|
interface ScanRecord {
|
||||||
id: string;
|
id: string;
|
||||||
botId: string;
|
botId: string;
|
||||||
|
ownerUserId: string;
|
||||||
status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed";
|
status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed";
|
||||||
progress: number;
|
progress: number;
|
||||||
message: string;
|
message: string;
|
||||||
@ -113,14 +115,22 @@ async function runSimulatedScan(record: ScanRecord) {
|
|||||||
record.completedAt = new Date();
|
record.completedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OnboardingScanRequest {
|
||||||
|
actor: Pick<Actor, "userId" | "isDeploymentOwner">;
|
||||||
|
botId?: string;
|
||||||
|
/** Home-style directory to discover. Unset keeps the scan off the host filesystem. */
|
||||||
|
scanHome?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function startOnboardingScan(
|
export async function startOnboardingScan(
|
||||||
botId: string | undefined,
|
{ actor, botId, scanHome }: OnboardingScanRequest,
|
||||||
prisma: any,
|
prisma: any,
|
||||||
): Promise<{ scanId: string }> {
|
): Promise<{ scanId: string }> {
|
||||||
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const record: ScanRecord = {
|
const record: ScanRecord = {
|
||||||
id: scanId,
|
id: scanId,
|
||||||
botId: botId ?? "",
|
botId: botId ?? "",
|
||||||
|
ownerUserId: actor.userId,
|
||||||
status: "scanning",
|
status: "scanning",
|
||||||
progress: 0,
|
progress: 0,
|
||||||
message: "Scanning your computer...",
|
message: "Scanning your computer...",
|
||||||
@ -133,12 +143,18 @@ export async function startOnboardingScan(
|
|||||||
// Run async
|
// Run async
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (process.env.AGENT_RUNTIME === "scripted" || process.env.SANDBOX_PROVIDER === "fake") {
|
// The host is the deployment owner's machine: only their onboarding may discover it, and
|
||||||
|
// only inside the directory the deployment explicitly configured.
|
||||||
|
if (
|
||||||
|
!scanHome ||
|
||||||
|
!actor.isDeploymentOwner ||
|
||||||
|
process.env.AGENT_RUNTIME === "scripted" ||
|
||||||
|
process.env.SANDBOX_PROVIDER === "fake"
|
||||||
|
) {
|
||||||
await runSimulatedScan(record);
|
await runSimulatedScan(record);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const homeDir = process.env.HOME ?? "/home/cynar";
|
const result = await executeFullScan(scanHome, botId ?? "", prisma);
|
||||||
const result = await executeFullScan(homeDir, botId ?? "", prisma);
|
|
||||||
record.status = "complete";
|
record.status = "complete";
|
||||||
record.progress = 100;
|
record.progress = 100;
|
||||||
record.message = "Scan complete — profile ready";
|
record.message = "Scan complete — profile ready";
|
||||||
@ -154,14 +170,17 @@ export async function startOnboardingScan(
|
|||||||
return { scanId };
|
return { scanId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getScanStatus(scanId: string): Promise<{
|
export async function getScanStatus(
|
||||||
|
scanId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{
|
||||||
status: ScanRecord["status"];
|
status: ScanRecord["status"];
|
||||||
progress: number;
|
progress: number;
|
||||||
message: string;
|
message: string;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
}> {
|
}> {
|
||||||
const record = scanRegistry.get(scanId);
|
const record = scanRegistry.get(scanId);
|
||||||
if (!record) throw new Error("Scan not found");
|
if (!record || record.ownerUserId !== userId) throw new Error("Scan not found");
|
||||||
const result = record.result ? JSON.parse(record.result) : undefined;
|
const result = record.result ? JSON.parse(record.result) : undefined;
|
||||||
return { status: record.status, progress: record.progress, message: record.message, result };
|
return { status: record.status, progress: record.progress, message: record.message, result };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -453,6 +453,7 @@ export interface RouterDeps {
|
|||||||
agentRuntime: string;
|
agentRuntime: string;
|
||||||
teamChatJudgeProvider?: string;
|
teamChatJudgeProvider?: string;
|
||||||
teamChatJudgeModel?: string;
|
teamChatJudgeModel?: string;
|
||||||
|
onboardingScanHome?: string;
|
||||||
defaultProvider: string;
|
defaultProvider: string;
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
deploymentModelKey?: string;
|
deploymentModelKey?: string;
|
||||||
@ -3318,11 +3319,14 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
scan: authed.onboarding.scan.handler(async ({ context, input }) => {
|
scan: authed.onboarding.scan.handler(async ({ context, input }) => {
|
||||||
const { scanId } = await startOnboardingScan(input.botId, deps.prisma);
|
const { scanId } = await startOnboardingScan(
|
||||||
|
{ actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome },
|
||||||
|
deps.prisma,
|
||||||
|
);
|
||||||
return { scanId };
|
return { scanId };
|
||||||
}),
|
}),
|
||||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ input }) => {
|
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => {
|
||||||
return await getScanStatus(input.scanId);
|
return await getScanStatus(input.scanId, context.actor.userId);
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
integrationSetup: {
|
integrationSetup: {
|
||||||
|
|||||||
@ -1,296 +0,0 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
|
||||||
import type { ClientRequest, IncomingMessage } from "node:http";
|
|
||||||
import http from "node:http";
|
|
||||||
import https from "node:https";
|
|
||||||
import net from "node:net";
|
|
||||||
import path from "node:path";
|
|
||||||
import tls from "node:tls";
|
|
||||||
import { lingui } from "@lingui/vite-plugin";
|
|
||||||
import type { DesktopStackProbeResponse } from "@rakazo/contracts";
|
|
||||||
import {
|
|
||||||
safeScreenProxyResponseHeaders,
|
|
||||||
stripSensitiveHandshakeHeaders,
|
|
||||||
} from "@rakazo/core/node/screen-proxy-response";
|
|
||||||
import babel from "@rolldown/plugin-babel";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import type { PreviewServer, ViteDevServer } from "vite";
|
|
||||||
import { defineConfig, loadEnv } from "vite";
|
|
||||||
import { resolveScreenProxySecret } from "../../packages/core/src/secrets-guard.ts";
|
|
||||||
import {
|
|
||||||
resolveNovncTarget,
|
|
||||||
safeProxyHeaders,
|
|
||||||
watchScreenAuthorization,
|
|
||||||
} from "./src/screen-proxy.js";
|
|
||||||
|
|
||||||
const webPort = Number(process.env.WEB_PORT ?? 5173);
|
|
||||||
const DESKTOP_STACK_PROBE_PATH = "/.well-known/rakazo-desktop-stack";
|
|
||||||
const DESKTOP_STACK_TOKEN_HEADER = "x-rakazo-desktop-stack-token";
|
|
||||||
|
|
||||||
function equalStackToken(expected: string, supplied: string | string[] | undefined) {
|
|
||||||
if (expected === "" || typeof supplied !== "string") return false;
|
|
||||||
const expectedBytes = Buffer.from(expected);
|
|
||||||
const suppliedBytes = Buffer.from(supplied);
|
|
||||||
return (
|
|
||||||
expectedBytes.byteLength === suppliedBytes.byteLength &&
|
|
||||||
timingSafeEqual(expectedBytes, suppliedBytes)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachDesktopStackProbe(
|
|
||||||
server: ViteDevServer | PreviewServer,
|
|
||||||
token: string,
|
|
||||||
imageTag: string,
|
|
||||||
) {
|
|
||||||
server.middlewares.use((req, res, next) => {
|
|
||||||
if (req.url?.split("?", 1)[0] !== DESKTOP_STACK_PROBE_PATH) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!equalStackToken(token, req.headers[DESKTOP_STACK_TOKEN_HEADER])) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end("Not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body: DesktopStackProbeResponse = { ok: true, imageTag };
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader("cache-control", "no-store");
|
|
||||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
||||||
res.end(JSON.stringify(body));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachNovncProxy(server: ViteDevServer | PreviewServer, secret: string, api: string) {
|
|
||||||
server.middlewares.use(async (req, res, next) => {
|
|
||||||
if (!req.url?.startsWith("/novnc/")) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = await resolveNovncTarget(req.url, secret, api);
|
|
||||||
if (res.destroyed) return;
|
|
||||||
if (!target) {
|
|
||||||
res.statusCode = 403;
|
|
||||||
res.end("Invalid or expired screen capability");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const headers = {
|
|
||||||
...safeProxyHeaders(req.headers),
|
|
||||||
host: `${target.hostname}:${target.port}`,
|
|
||||||
};
|
|
||||||
const transport = target.protocol === "https:" ? https : http;
|
|
||||||
let upstream: ClientRequest | undefined;
|
|
||||||
let retries = 0;
|
|
||||||
let retryPending = false;
|
|
||||||
let stopChecking: () => void = () => undefined;
|
|
||||||
const retryable = req.method === "GET";
|
|
||||||
const scheduleRetry = (incoming?: IncomingMessage) => {
|
|
||||||
if (!retryable || retryPending || retries >= 3 || res.destroyed) return false;
|
|
||||||
retryPending = true;
|
|
||||||
retries += 1;
|
|
||||||
const delayMs = 50 * retries;
|
|
||||||
const retry = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
retryPending = false;
|
|
||||||
if (!res.destroyed) requestUpstream();
|
|
||||||
}, delayMs);
|
|
||||||
};
|
|
||||||
if (incoming && !incoming.destroyed) {
|
|
||||||
incoming.once("close", retry);
|
|
||||||
incoming.destroy();
|
|
||||||
} else {
|
|
||||||
retry();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
function requestUpstream() {
|
|
||||||
if (res.destroyed) return;
|
|
||||||
upstream = transport.request(
|
|
||||||
{
|
|
||||||
hostname: target.hostname,
|
|
||||||
port: target.port,
|
|
||||||
path: target.path,
|
|
||||||
method: req.method,
|
|
||||||
headers,
|
|
||||||
...(target.protocol === "https:" ? { servername: target.hostname } : {}),
|
|
||||||
},
|
|
||||||
(incoming) => {
|
|
||||||
if ((incoming.statusCode ?? 502) >= 500 && scheduleRetry(incoming)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.writeHead(
|
|
||||||
incoming.statusCode ?? 502,
|
|
||||||
safeScreenProxyResponseHeaders(incoming.headers),
|
|
||||||
);
|
|
||||||
incoming.pipe(res);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
upstream.on("error", () => {
|
|
||||||
if (retryable && !res.headersSent && !res.destroyed && (retryPending || scheduleRetry())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stopChecking();
|
|
||||||
if (res.headersSent) {
|
|
||||||
res.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.statusCode = 502;
|
|
||||||
res.end("Screen unavailable");
|
|
||||||
});
|
|
||||||
if (req.method === "GET" || req.readableEnded) upstream.end();
|
|
||||||
else req.pipe(upstream);
|
|
||||||
}
|
|
||||||
stopChecking = watchScreenAuthorization(
|
|
||||||
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
|
|
||||||
() => {
|
|
||||||
upstream?.destroy();
|
|
||||||
res.destroy();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
res.once("close", () => {
|
|
||||||
stopChecking();
|
|
||||||
upstream?.destroy();
|
|
||||||
});
|
|
||||||
requestUpstream();
|
|
||||||
});
|
|
||||||
|
|
||||||
server.httpServer?.on("upgrade", async (req, socket, head) => {
|
|
||||||
if (!req.url?.startsWith("/novnc/")) return;
|
|
||||||
const target = await resolveNovncTarget(req.url, secret, api);
|
|
||||||
if (socket.destroyed) return;
|
|
||||||
if (!target) {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const upstream =
|
|
||||||
target.protocol === "https:"
|
|
||||||
? tls.connect({ port: target.port, host: target.hostname, servername: target.hostname })
|
|
||||||
: net.connect(target.port, target.hostname);
|
|
||||||
const stopChecking = watchScreenAuthorization(
|
|
||||||
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
|
|
||||||
() => {
|
|
||||||
socket.destroy();
|
|
||||||
upstream.destroy();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
socket.once("close", () => {
|
|
||||||
stopChecking();
|
|
||||||
upstream.destroy();
|
|
||||||
});
|
|
||||||
upstream.once("close", () => {
|
|
||||||
stopChecking();
|
|
||||||
socket.destroy();
|
|
||||||
});
|
|
||||||
upstream.once(target.protocol === "https:" ? "secureConnect" : "connect", () => {
|
|
||||||
const headerLines = [
|
|
||||||
`${req.method ?? "GET"} ${target.path} HTTP/1.1`,
|
|
||||||
`Host: ${target.hostname}:${target.port}`,
|
|
||||||
];
|
|
||||||
for (const [key, value] of Object.entries(safeProxyHeaders(req.headers))) {
|
|
||||||
headerLines.push(`${key}: ${Array.isArray(value) ? value.join(",") : value}`);
|
|
||||||
}
|
|
||||||
upstream.write(`${headerLines.join("\r\n")}\r\n\r\n`);
|
|
||||||
if (head.length) upstream.write(head);
|
|
||||||
socket.pipe(upstream);
|
|
||||||
const responseChunks: Buffer[] = [];
|
|
||||||
let responseSize = 0;
|
|
||||||
let responseTail = Buffer.alloc(0);
|
|
||||||
const forwardHandshake = (chunk: Buffer) => {
|
|
||||||
responseChunks.push(chunk);
|
|
||||||
responseSize += chunk.length;
|
|
||||||
if (responseSize > 64 * 1024) {
|
|
||||||
socket.destroy();
|
|
||||||
upstream.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const boundarySearch = Buffer.concat([responseTail, chunk]);
|
|
||||||
if (boundarySearch.indexOf("\r\n\r\n") < 0) {
|
|
||||||
responseTail = Buffer.from(boundarySearch.subarray(-3));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const responseHead = Buffer.concat(responseChunks, responseSize);
|
|
||||||
const safe = stripSensitiveHandshakeHeaders(responseHead);
|
|
||||||
if (!safe) {
|
|
||||||
socket.destroy();
|
|
||||||
upstream.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
upstream.off("data", forwardHandshake);
|
|
||||||
socket.write(safe);
|
|
||||||
upstream.pipe(socket);
|
|
||||||
};
|
|
||||||
upstream.on("data", forwardHandshake);
|
|
||||||
});
|
|
||||||
upstream.on("error", () => socket.destroy());
|
|
||||||
socket.on("error", () => upstream.destroy());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
const rootEnv = loadEnv(mode, path.resolve(import.meta.dirname, "../.."), "");
|
|
||||||
const api = process.env.API_PROXY_TARGET ?? rootEnv.API_PROXY_TARGET ?? "http://127.0.0.1:3100";
|
|
||||||
const previewHost = process.env.RAKAZO_HOST ?? rootEnv.RAKAZO_HOST ?? "localhost";
|
|
||||||
const screenProxySecret = () =>
|
|
||||||
resolveScreenProxySecret({
|
|
||||||
...process.env,
|
|
||||||
SCREEN_PROXY_SECRET: process.env.SCREEN_PROXY_SECRET ?? rootEnv.SCREEN_PROXY_SECRET,
|
|
||||||
SANDBOX_SUPERVISOR_TOKEN:
|
|
||||||
process.env.SANDBOX_SUPERVISOR_TOKEN ?? rootEnv.SANDBOX_SUPERVISOR_TOKEN,
|
|
||||||
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? rootEnv.BETTER_AUTH_SECRET,
|
|
||||||
});
|
|
||||||
const performanceAssetDelayMs = Number(process.env.RAKAZO_PERFORMANCE_ASSET_DELAY_MS ?? 0);
|
|
||||||
const desktopStackToken =
|
|
||||||
process.env.RAKAZO_DESKTOP_STACK_TOKEN ?? rootEnv.RAKAZO_DESKTOP_STACK_TOKEN ?? "";
|
|
||||||
const imageTag = process.env.RAKAZO_IMAGE_TAG ?? rootEnv.RAKAZO_IMAGE_TAG ?? "edge";
|
|
||||||
return {
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
babel({ plugins: ["@lingui/babel-plugin-lingui-macro"] }),
|
|
||||||
lingui(),
|
|
||||||
tailwindcss(),
|
|
||||||
{
|
|
||||||
name: "rakazo-desktop-stack-probe",
|
|
||||||
configureServer: (server) => attachDesktopStackProbe(server, desktopStackToken, imageTag),
|
|
||||||
configurePreviewServer: (server) =>
|
|
||||||
attachDesktopStackProbe(server, desktopStackToken, imageTag),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rakazo-performance-asset-delay",
|
|
||||||
configurePreviewServer(server) {
|
|
||||||
if (!Number.isFinite(performanceAssetDelayMs) || performanceAssetDelayMs <= 0) return;
|
|
||||||
server.middlewares.use((req, _res, next) => {
|
|
||||||
const pathname = req.url?.split("?", 1)[0] ?? "/";
|
|
||||||
if (["/api", "/rpc", "/novnc"].some((prefix) => pathname.startsWith(prefix))) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(next, performanceAssetDelayMs);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rakazo-novnc-proxy",
|
|
||||||
configureServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
|
|
||||||
configurePreviewServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
server: {
|
|
||||||
host: "0.0.0.0",
|
|
||||||
port: webPort,
|
|
||||||
strictPort: true,
|
|
||||||
allowedHosts: true,
|
|
||||||
proxy: {
|
|
||||||
"/api": { target: api, changeOrigin: true },
|
|
||||||
"/rpc": { target: api, changeOrigin: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
host: "0.0.0.0",
|
|
||||||
port: Number(process.env.WEB_PORT ?? 5173),
|
|
||||||
allowedHosts: [previewHost],
|
|
||||||
proxy: {
|
|
||||||
"/api": { target: api, changeOrigin: true },
|
|
||||||
"/rpc": { target: api, changeOrigin: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,296 +0,0 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
|
||||||
import type { ClientRequest, IncomingMessage } from "node:http";
|
|
||||||
import http from "node:http";
|
|
||||||
import https from "node:https";
|
|
||||||
import net from "node:net";
|
|
||||||
import path from "node:path";
|
|
||||||
import tls from "node:tls";
|
|
||||||
import { lingui } from "@lingui/vite-plugin";
|
|
||||||
import type { DesktopStackProbeResponse } from "@rakazo/contracts";
|
|
||||||
import {
|
|
||||||
safeScreenProxyResponseHeaders,
|
|
||||||
stripSensitiveHandshakeHeaders,
|
|
||||||
} from "@rakazo/core/node/screen-proxy-response";
|
|
||||||
import babel from "@rolldown/plugin-babel";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import type { PreviewServer, ViteDevServer } from "vite";
|
|
||||||
import { defineConfig, loadEnv } from "vite";
|
|
||||||
import { resolveScreenProxySecret } from "../../packages/core/src/secrets-guard.ts";
|
|
||||||
import {
|
|
||||||
resolveNovncTarget,
|
|
||||||
safeProxyHeaders,
|
|
||||||
watchScreenAuthorization,
|
|
||||||
} from "./src/screen-proxy.js";
|
|
||||||
|
|
||||||
const webPort = Number(process.env.WEB_PORT ?? 5173);
|
|
||||||
const DESKTOP_STACK_PROBE_PATH = "/.well-known/rakazo-desktop-stack";
|
|
||||||
const DESKTOP_STACK_TOKEN_HEADER = "x-rakazo-desktop-stack-token";
|
|
||||||
|
|
||||||
function equalStackToken(expected: string, supplied: string | string[] | undefined) {
|
|
||||||
if (expected === "" || typeof supplied !== "string") return false;
|
|
||||||
const expectedBytes = Buffer.from(expected);
|
|
||||||
const suppliedBytes = Buffer.from(supplied);
|
|
||||||
return (
|
|
||||||
expectedBytes.byteLength === suppliedBytes.byteLength &&
|
|
||||||
timingSafeEqual(expectedBytes, suppliedBytes)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachDesktopStackProbe(
|
|
||||||
server: ViteDevServer | PreviewServer,
|
|
||||||
token: string,
|
|
||||||
imageTag: string,
|
|
||||||
) {
|
|
||||||
server.middlewares.use((req, res, next) => {
|
|
||||||
if (req.url?.split("?", 1)[0] !== DESKTOP_STACK_PROBE_PATH) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!equalStackToken(token, req.headers[DESKTOP_STACK_TOKEN_HEADER])) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end("Not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body: DesktopStackProbeResponse = { ok: true, imageTag };
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader("cache-control", "no-store");
|
|
||||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
||||||
res.end(JSON.stringify(body));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachNovncProxy(server: ViteDevServer | PreviewServer, secret: string, api: string) {
|
|
||||||
server.middlewares.use(async (req, res, next) => {
|
|
||||||
if (!req.url?.startsWith("/novnc/")) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = await resolveNovncTarget(req.url, secret, api);
|
|
||||||
if (res.destroyed) return;
|
|
||||||
if (!target) {
|
|
||||||
res.statusCode = 403;
|
|
||||||
res.end("Invalid or expired screen capability");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const headers = {
|
|
||||||
...safeProxyHeaders(req.headers),
|
|
||||||
host: `${target.hostname}:${target.port}`,
|
|
||||||
};
|
|
||||||
const transport = target.protocol === "https:" ? https : http;
|
|
||||||
let upstream: ClientRequest | undefined;
|
|
||||||
let retries = 0;
|
|
||||||
let retryPending = false;
|
|
||||||
let stopChecking: () => void = () => undefined;
|
|
||||||
const retryable = req.method === "GET";
|
|
||||||
const scheduleRetry = (incoming?: IncomingMessage) => {
|
|
||||||
if (!retryable || retryPending || retries >= 3 || res.destroyed) return false;
|
|
||||||
retryPending = true;
|
|
||||||
retries += 1;
|
|
||||||
const delayMs = 50 * retries;
|
|
||||||
const retry = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
retryPending = false;
|
|
||||||
if (!res.destroyed) requestUpstream();
|
|
||||||
}, delayMs);
|
|
||||||
};
|
|
||||||
if (incoming && !incoming.destroyed) {
|
|
||||||
incoming.once("close", retry);
|
|
||||||
incoming.destroy();
|
|
||||||
} else {
|
|
||||||
retry();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
function requestUpstream() {
|
|
||||||
if (res.destroyed) return;
|
|
||||||
upstream = transport.request(
|
|
||||||
{
|
|
||||||
hostname: target.hostname,
|
|
||||||
port: target.port,
|
|
||||||
path: target.path,
|
|
||||||
method: req.method,
|
|
||||||
headers,
|
|
||||||
...(target.protocol === "https:" ? { servername: target.hostname } : {}),
|
|
||||||
},
|
|
||||||
(incoming) => {
|
|
||||||
if ((incoming.statusCode ?? 502) >= 500 && scheduleRetry(incoming)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.writeHead(
|
|
||||||
incoming.statusCode ?? 502,
|
|
||||||
safeScreenProxyResponseHeaders(incoming.headers),
|
|
||||||
);
|
|
||||||
incoming.pipe(res);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
upstream.on("error", () => {
|
|
||||||
if (retryable && !res.headersSent && !res.destroyed && (retryPending || scheduleRetry())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stopChecking();
|
|
||||||
if (res.headersSent) {
|
|
||||||
res.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.statusCode = 502;
|
|
||||||
res.end("Screen unavailable");
|
|
||||||
});
|
|
||||||
if (req.method === "GET" || req.readableEnded) upstream.end();
|
|
||||||
else req.pipe(upstream);
|
|
||||||
}
|
|
||||||
stopChecking = watchScreenAuthorization(
|
|
||||||
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
|
|
||||||
() => {
|
|
||||||
upstream?.destroy();
|
|
||||||
res.destroy();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
res.once("close", () => {
|
|
||||||
stopChecking();
|
|
||||||
upstream?.destroy();
|
|
||||||
});
|
|
||||||
requestUpstream();
|
|
||||||
});
|
|
||||||
|
|
||||||
server.httpServer?.on("upgrade", async (req, socket, head) => {
|
|
||||||
if (!req.url?.startsWith("/novnc/")) return;
|
|
||||||
const target = await resolveNovncTarget(req.url, secret, api);
|
|
||||||
if (socket.destroyed) return;
|
|
||||||
if (!target) {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const upstream =
|
|
||||||
target.protocol === "https:"
|
|
||||||
? tls.connect({ port: target.port, host: target.hostname, servername: target.hostname })
|
|
||||||
: net.connect(target.port, target.hostname);
|
|
||||||
const stopChecking = watchScreenAuthorization(
|
|
||||||
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
|
|
||||||
() => {
|
|
||||||
socket.destroy();
|
|
||||||
upstream.destroy();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
socket.once("close", () => {
|
|
||||||
stopChecking();
|
|
||||||
upstream.destroy();
|
|
||||||
});
|
|
||||||
upstream.once("close", () => {
|
|
||||||
stopChecking();
|
|
||||||
socket.destroy();
|
|
||||||
});
|
|
||||||
upstream.once(target.protocol === "https:" ? "secureConnect" : "connect", () => {
|
|
||||||
const headerLines = [
|
|
||||||
`${req.method ?? "GET"} ${target.path} HTTP/1.1`,
|
|
||||||
`Host: ${target.hostname}:${target.port}`,
|
|
||||||
];
|
|
||||||
for (const [key, value] of Object.entries(safeProxyHeaders(req.headers))) {
|
|
||||||
headerLines.push(`${key}: ${Array.isArray(value) ? value.join(",") : value}`);
|
|
||||||
}
|
|
||||||
upstream.write(`${headerLines.join("\r\n")}\r\n\r\n`);
|
|
||||||
if (head.length) upstream.write(head);
|
|
||||||
socket.pipe(upstream);
|
|
||||||
const responseChunks: Buffer[] = [];
|
|
||||||
let responseSize = 0;
|
|
||||||
let responseTail = Buffer.alloc(0);
|
|
||||||
const forwardHandshake = (chunk: Buffer) => {
|
|
||||||
responseChunks.push(chunk);
|
|
||||||
responseSize += chunk.length;
|
|
||||||
if (responseSize > 64 * 1024) {
|
|
||||||
socket.destroy();
|
|
||||||
upstream.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const boundarySearch = Buffer.concat([responseTail, chunk]);
|
|
||||||
if (boundarySearch.indexOf("\r\n\r\n") < 0) {
|
|
||||||
responseTail = Buffer.from(boundarySearch.subarray(-3));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const responseHead = Buffer.concat(responseChunks, responseSize);
|
|
||||||
const safe = stripSensitiveHandshakeHeaders(responseHead);
|
|
||||||
if (!safe) {
|
|
||||||
socket.destroy();
|
|
||||||
upstream.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
upstream.off("data", forwardHandshake);
|
|
||||||
socket.write(safe);
|
|
||||||
upstream.pipe(socket);
|
|
||||||
};
|
|
||||||
upstream.on("data", forwardHandshake);
|
|
||||||
});
|
|
||||||
upstream.on("error", () => socket.destroy());
|
|
||||||
socket.on("error", () => upstream.destroy());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
const rootEnv = loadEnv(mode, path.resolve(import.meta.dirname, "../.."), "");
|
|
||||||
const api = process.env.API_PROXY_TARGET ?? rootEnv.API_PROXY_TARGET ?? "http://127.0.0.1:3100";
|
|
||||||
const previewHost = process.env.RAKAZO_HOST ?? rootEnv.RAKAZO_HOST ?? "localhost";
|
|
||||||
const screenProxySecret = () =>
|
|
||||||
resolveScreenProxySecret({
|
|
||||||
...process.env,
|
|
||||||
SCREEN_PROXY_SECRET: process.env.SCREEN_PROXY_SECRET ?? rootEnv.SCREEN_PROXY_SECRET,
|
|
||||||
SANDBOX_SUPERVISOR_TOKEN:
|
|
||||||
process.env.SANDBOX_SUPERVISOR_TOKEN ?? rootEnv.SANDBOX_SUPERVISOR_TOKEN,
|
|
||||||
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? rootEnv.BETTER_AUTH_SECRET,
|
|
||||||
});
|
|
||||||
const performanceAssetDelayMs = Number(process.env.RAKAZO_PERFORMANCE_ASSET_DELAY_MS ?? 0);
|
|
||||||
const desktopStackToken =
|
|
||||||
process.env.RAKAZO_DESKTOP_STACK_TOKEN ?? rootEnv.RAKAZO_DESKTOP_STACK_TOKEN ?? "";
|
|
||||||
const imageTag = process.env.RAKAZO_IMAGE_TAG ?? rootEnv.RAKAZO_IMAGE_TAG ?? "edge";
|
|
||||||
return {
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
babel({ plugins: ["@lingui/babel-plugin-lingui-macro"] }),
|
|
||||||
lingui(),
|
|
||||||
tailwindcss(),
|
|
||||||
{
|
|
||||||
name: "rakazo-desktop-stack-probe",
|
|
||||||
configureServer: (server) => attachDesktopStackProbe(server, desktopStackToken, imageTag),
|
|
||||||
configurePreviewServer: (server) =>
|
|
||||||
attachDesktopStackProbe(server, desktopStackToken, imageTag),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rakazo-performance-asset-delay",
|
|
||||||
configurePreviewServer(server) {
|
|
||||||
if (!Number.isFinite(performanceAssetDelayMs) || performanceAssetDelayMs <= 0) return;
|
|
||||||
server.middlewares.use((req, _res, next) => {
|
|
||||||
const pathname = req.url?.split("?", 1)[0] ?? "/";
|
|
||||||
if (["/api", "/rpc", "/novnc"].some((prefix) => pathname.startsWith(prefix))) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(next, performanceAssetDelayMs);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rakazo-novnc-proxy",
|
|
||||||
configureServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
|
|
||||||
configurePreviewServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
server: {
|
|
||||||
host: "0.0.0.0",
|
|
||||||
port: webPort,
|
|
||||||
strictPort: true,
|
|
||||||
allowedHosts: true,
|
|
||||||
proxy: {
|
|
||||||
"/api": { target: api, changeOrigin: true },
|
|
||||||
"/rpc": { target: api, changeOrigin: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
host: "0.0.0.0",
|
|
||||||
port: Number(process.env.WEB_PORT ?? 5173),
|
|
||||||
allowedHosts: [previewHost],
|
|
||||||
proxy: {
|
|
||||||
"/api": { target: api, changeOrigin: true },
|
|
||||||
"/rpc": { target: api, changeOrigin: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
2500
package-lock.json
generated
2500
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,21 +0,0 @@
|
|||||||
const { chromium } = require('playwright');
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
|
||||||
await page.goto('http://100.114.51.16:5173/app', { waitUntil: 'networkidle', timeout: 30000 });
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
|
|
||||||
await page.waitForSelector('[data-sidebar-group]', { timeout: 10000 }).catch(() => {});
|
|
||||||
|
|
||||||
await page.screenshot({ path: '/tmp/screenshot-sidebar.png', fullPage: false });
|
|
||||||
console.log('Screenshot saved');
|
|
||||||
|
|
||||||
const groups = await page.locator('[data-sidebar-group]').all();
|
|
||||||
console.log(`Found ${groups.length} sidebar groups`);
|
|
||||||
|
|
||||||
const titles = await page.locator('[data-sidebar-group] button[aria-label]').allTextContents();
|
|
||||||
console.log('Sidebar buttons:', titles.slice(0, 10));
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
})();
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
const { chromium } = require('playwright');
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
|
||||||
|
|
||||||
// Capture console messages
|
|
||||||
page.on('console', msg => console.log(`CONSOLE: ${msg.type()} - ${msg.text()}`));
|
|
||||||
page.on('pageerror', err => console.log(`PAGE_ERROR: ${err.message}`));
|
|
||||||
|
|
||||||
await page.goto('http://100.114.51.16:5173/app', { waitUntil: 'networkidle', timeout: 30000 });
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
|
|
||||||
await page.screenshot({ path: '/tmp/screenshot-sidebar.png', fullPage: false });
|
|
||||||
console.log('Screenshot saved');
|
|
||||||
|
|
||||||
const bodyText = await page.evaluate(() => document.body.innerText);
|
|
||||||
console.log('Body text:', bodyText.substring(0, 500));
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
})();
|
|
||||||
Loading…
x
Reference in New Issue
Block a user