diff --git a/.env.example b/.env.example index 08aac75..5d588c8 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,10 @@ PUBLIC_POSTHOG_KEY= PUBLIC_POSTHOG_HOST=https://us.i.posthog.com SIGNUPS_ENABLED=true 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 DATA_DIR=./data SANDBOX_SUPERVISOR_URL=http://127.0.0.1:7091 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fd905dc..69f76d3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -476,6 +476,7 @@ export async function createApp( defaultModel: env.defaultModel, teamChatJudgeProvider: env.teamChatJudgeProvider, teamChatJudgeModel: env.teamChatJudgeModel, + onboardingScanHome: env.onboardingScanHome, deploymentModelKey: env.deploymentModelKey, webOrigin: env.webOrigin, privacyPolicyUrl: env.privacyPolicyUrl, diff --git a/apps/api/src/env.test.ts b/apps/api/src/env.test.ts index 029eb12..6e92002 100644 --- a/apps/api/src/env.test.ts +++ b/apps/api/src/env.test.ts @@ -35,6 +35,13 @@ describe("loadEnv", () => { 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", () => { expect(loadEnv(base).tradingBotUrl).toBeUndefined(); expect( diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 45e2936..c88c2da 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -26,6 +26,7 @@ export interface AppEnv { apiHost: string; signupsEnabled: string | undefined; signupAllowlist: string | undefined; + onboardingScanHome: string | undefined; encryptionKey: string; dataDir: string; /** 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", signupsEnabled: source.SIGNUPS_ENABLED, signupAllowlist: source.SIGNUP_ALLOWLIST, + onboardingScanHome: optional(source.ONBOARDING_SCAN_HOME), encryptionKey: resolveEncryptionKey(source), dataDir: source.DATA_DIR ?? "./data", piSessionRecording: source.PI_SESSION_RECORDING === "true", diff --git a/apps/api/src/onboarding-scan.test.ts b/apps/api/src/onboarding-scan.test.ts new file mode 100644 index 0000000..ea2e625 --- /dev/null +++ b/apps/api/src/onboarding-scan.test.ts @@ -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 { + 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 } }); + }); +}); diff --git a/apps/api/src/onboarding-scan.ts b/apps/api/src/onboarding-scan.ts index 595a116..8d1c786 100644 --- a/apps/api/src/onboarding-scan.ts +++ b/apps/api/src/onboarding-scan.ts @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; import { access, readdir, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; +import type { Actor } from "@rakazo/contracts"; // --------------------------------------------------------------------------- // Types @@ -62,6 +63,7 @@ interface CreatedProject { interface ScanRecord { id: string; botId: string; + ownerUserId: string; status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed"; progress: number; message: string; @@ -113,14 +115,22 @@ async function runSimulatedScan(record: ScanRecord) { record.completedAt = new Date(); } +export interface OnboardingScanRequest { + actor: Pick; + botId?: string; + /** Home-style directory to discover. Unset keeps the scan off the host filesystem. */ + scanHome?: string; +} + export async function startOnboardingScan( - botId: string | undefined, + { actor, botId, scanHome }: OnboardingScanRequest, prisma: any, ): Promise<{ scanId: string }> { const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const record: ScanRecord = { id: scanId, botId: botId ?? "", + ownerUserId: actor.userId, status: "scanning", progress: 0, message: "Scanning your computer...", @@ -133,12 +143,18 @@ export async function startOnboardingScan( // Run async void (async () => { 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); return; } - const homeDir = process.env.HOME ?? "/home/cynar"; - const result = await executeFullScan(homeDir, botId ?? "", prisma); + const result = await executeFullScan(scanHome, botId ?? "", prisma); record.status = "complete"; record.progress = 100; record.message = "Scan complete — profile ready"; @@ -154,14 +170,17 @@ export async function startOnboardingScan( return { scanId }; } -export async function getScanStatus(scanId: string): Promise<{ +export async function getScanStatus( + scanId: string, + userId: string, +): Promise<{ status: ScanRecord["status"]; progress: number; message: string; result?: unknown; }> { 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; return { status: record.status, progress: record.progress, message: record.message, result }; } diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index f5d3fc8..03e1a87 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -453,6 +453,7 @@ export interface RouterDeps { agentRuntime: string; teamChatJudgeProvider?: string; teamChatJudgeModel?: string; + onboardingScanHome?: string; defaultProvider: string; defaultModel: string; deploymentModelKey?: string; @@ -3318,11 +3319,14 @@ export function createRouter(deps: RouterDeps) { return { ok: true as const }; }), 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 }; }), - scanStatus: authed.onboarding.scanStatus.handler(async ({ input }) => { - return await getScanStatus(input.scanId); + scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => { + return await getScanStatus(input.scanId, context.actor.userId); }), }, integrationSetup: {