diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 127630a2d..e701a6ba8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1571,8 +1571,8 @@ guide in each artifact checksum list so extracted documentation matches its buil The distribution includes the sandbox-node binary. An enabled local node uses a persistent private state directory, explicitly separate from read-only configuration. Docker grants Core write access only to that node-state mount; native Core uses the -same installation-owned directory. Zero-node installs create neither node identity -state nor sandbox administrator credentials. +same installation-owned directory. Zero-node installs create no node identity +state, but retain the paired administrator credential for first setup. One Runtime image contains the existing daemon, shared helpers and three native harness packages. Their differences remain in the adapters. Core keeps exclusive @@ -1586,14 +1586,23 @@ must not change on a repeated install. `services/core-console` serves the production Web build and forwards public `/v1` requests to one configured Core using its project bearer, after console Basic -authentication. Its explicit sandbox administration routes instead require a -unique browser-supplied Bearer credential and forward it unchanged for Core to -verify; console Basic access does not confer deployment administration. Never -substitute the project bearer on those routes or give the console a shared admin -credential. The installer keeps the administrator key and digests separate from -project configuration and exposes only the digest file to Core. The Web keeps an -entered administrator credential in memory. Node registration, identity and -WebSocket transport are not console routes; nodes connect directly to Core. +authentication. The paired console uses the same login for allowlisted sandbox +management routes and supplies its private server-side administrator token from +`CORE_CONSOLE_SANDBOX_ADMIN_TOKEN_FILE`. The browser receives only capability +flags through `/console/config`, never the deployment bearer. Project API keys +retain their separate authority. The installer mounts only the administrator +key file into Web and only its digest file into Core. Node/daemon transport uses +its own authenticated finite routes and credentials, never that admin token. + +The Web manager offers no manual administrator-key fallback. A console without +paired management configuration shows setup guidance; direct remote project API +connections do not silently administer the console's configured deployment. +Chinese/English sandbox text, status and diagnostic formatting live in the shared +`apps/web/src/lib/` locale modules. A persisted explicit language preference wins +before the first browser language; unrelated product surfaces are outside this +translation scope. Preserve zero-node setup and node installation behavior when +localizing their controls. + Both proxy paths retain fixed-origin, cross-site, safe-path, redirect and Upgrade restrictions through the standard Go reverse proxy with streaming/cancellation. The console implements no product identity, resource semantics, Runtime discovery diff --git a/apps/web/e2e/fixture-sandbox.mjs b/apps/web/e2e/fixture-sandbox.mjs index 69347f3d5..fe15d6736 100644 --- a/apps/web/e2e/fixture-sandbox.mjs +++ b/apps/web/e2e/fixture-sandbox.mjs @@ -35,8 +35,9 @@ export function handleSandboxFixture(request, response, url, sendJson, sendError sendJson(response, { node_id: "node-local", node_name: "Core server", available: !diagnostic, state: "active", compute_phase: "running", diagnostic }); return true; } if (!path.startsWith("/core/v1/sandbox/")) return false; - calls.push({ path, method: request.method, authorized: request.headers.authorization === "Bearer fixture-admin-key" }); - if (request.headers.authorization !== "Bearer fixture-admin-key") { sendError(response, 401, "A deployment admin key is required.", "invalid_admin_key"); return true; } + calls.push({ path, method: request.method, authorization: request.headers.authorization ?? null }); + // This fixture represents authenticated console routes, not direct Core administration. + if (request.headers.authorization) { sendError(response, 400, "Browser admin credentials are not accepted.", "unexpected_authorization"); return true; } const deployment = () => ({ installation_id: "fixture-installation", provider, core_url: coreUrl, maintenance: false, owner_epoch: 1 }); if (path.endsWith("/deployment") && request.method === "POST") { let body = ""; diff --git a/apps/web/e2e/sandbox-manager.spec.ts b/apps/web/e2e/sandbox-manager.spec.ts index 547a22c2b..871f00d60 100644 --- a/apps/web/e2e/sandbox-manager.spec.ts +++ b/apps/web/e2e/sandbox-manager.spec.ts @@ -1,24 +1,22 @@ import { expect, test, type Page } from "@playwright/test"; const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; -async function connectAdmin(page: Page) { +async function openManager(page: Page) { await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); - await page.getByLabel("Deployment admin key").fill("fixture-admin-key"); - await page.getByRole("button", { name: "Connect admin", exact: true }).click(); await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible(); } test.beforeEach(async ({ page, request }) => { + await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: false }) })); await request.post(`${fixture}/__fixture/reset`); await page.goto("/"); await expect(page.getByRole("button", { name: "Sessions", exact: true })).toBeVisible(); }); -test("admin access, node health, guarded removal and enrollment remain separate from project credentials", async ({ page, request }) => { - await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); - expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(0); - await page.getByLabel("Deployment admin key").fill("project-key"); - await page.getByRole("button", { name: "Connect admin", exact: true }).click(); - await expect(page.getByRole("alert")).toContainText("deployment admin key is required"); - await page.getByRole("button", { name: "Disconnect admin" }).click(); - await connectAdmin(page); +test("console access needs no browser admin credential; removal and enrollment are guarded", async ({ page, request }) => { + await openManager(page); + await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); + await expect(page.getByRole("button", { name: /Connect admin|Disconnect admin/ })).toHaveCount(0); + const calls = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls; + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call: { authorization: unknown }) => call.authorization === null)).toBe(true); await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Provider ready"); await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Host metrics unavailable"); await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("session_snapshot"); @@ -38,20 +36,20 @@ test("admin access, node health, guarded removal and enrollment remain separate expect(storage).not.toContain("fixture-admin-key"); expect(storage).not.toContain("fixture-once-token"); expect(page.url()).not.toContain("fixture-admin-key"); await page.reload(); - await expect(page.getByLabel("Deployment admin key")).toHaveValue(""); + await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); }); test("microsandbox shares the manager and mobile tables stay contained", async ({ page, request }) => { await request.post(`${fixture}/__fixture/sandbox-microsandbox`); await page.setViewportSize({ width: 390, height: 844 }); - await connectAdmin(page); + await openManager(page); await expect(page.locator(".sandbox-summary")).toContainText("microsandbox"); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); const table = page.getByRole("region", { name: "Sandbox nodes", exact: true }); await expect(table).toBeVisible(); const bounds = await table.boundingBox(); expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390); - await expect(page.getByRole("button", { name: "Disconnect admin" })).toBeInViewport(); + await expect(page.getByLabel("Language / 语言")).toBeVisible(); await page.getByLabel("Core URL reachable from the node").scrollIntoViewIfNeeded(); await expect(page.getByLabel("Core URL reachable from the node")).toBeInViewport(); }); @@ -86,7 +84,7 @@ test("Session details show the actual Core placement", async ({ page }) => { }); test("empty nodes and a failed refresh have distinct states", async ({ page }) => { await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ data: [] }) })); - await connectAdmin(page); + await openManager(page); await expect(page.getByText("No nodes registered. Add a node to provide hosted capacity.")).toBeVisible(); await expect(page.getByText("No sandbox allocations.")).toBeVisible(); await page.route("**/core/v1/sandbox/deployment", (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { message: "Deployment unavailable." } }) })); @@ -120,7 +118,7 @@ test("late placement reads cannot replace another Session's placement", async ({ }); test("disconnect diagnostics clear after reconnection in manager and Session details", async ({ page, request }) => { await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=node_unavailable`); - await connectAdmin(page); + await openManager(page); await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("Node disconnected"); await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("Existing resources stay assigned"); await page.getByRole("button", { name: "Sessions", exact: true }).click(); @@ -132,13 +130,13 @@ test("disconnect diagnostics clear after reconnection in manager and Session det await expect(dialog).toContainText("Available · Recorded allocation"); await expect(dialog).not.toContainText("Node disconnected"); await page.getByRole("button", { name: "Close dialog", exact: true }).click(); - await connectAdmin(page); + await openManager(page); await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("No reported issue"); await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).not.toContainText("Node disconnected"); }); test("a missing resource preserves ownership and offers inspection without replacement", async ({ page, request }) => { await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=resource_missing`); - await connectAdmin(page); + await openManager(page); const allocations = page.getByRole("region", { name: "Sandbox allocations", exact: true }); await expect(allocations).toContainText("Sandbox resource missing"); await expect(allocations).toContainText("retains the ownership record"); @@ -150,3 +148,74 @@ test("a missing resource preserves ownership and offers inspection without repla const requests = await (await request.get(`${fixture}/__fixture/requests`)).json(); expect(requests.filter((entry: { method: string; path: string }) => entry.method === "POST" && entry.path === "/v1/agents/sessions")).toHaveLength(0); }); + +test("Chinese defaults from browser preference, persists, and translates manager actions and diagnostics", async ({ page, request }) => { + await page.addInitScript(() => Object.defineProperty(navigator, "languages", { get: () => ["zh-CN", "en-US"] })); + await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=resource_missing`); + await page.reload(); + await page.getByRole("button", { name: "托管沙箱管理", exact: true }).click(); + await expect(page.getByRole("heading", { name: "节点", exact: true })).toBeVisible(); + const allocations = page.getByRole("region", { name: "沙箱资源分配", exact: true }); + await expect(allocations).toContainText("沙箱资源缺失"); + await expect(allocations).toContainText("活跃"); + await expect(allocations).toContainText("运行中"); + await expect(page.getByRole("region", { name: "沙箱节点", exact: true })).toContainText("主机指标不可用(心跳已过期)"); + await page.getByRole("button", { name: "移除 Core server", exact: true }).click(); + await page.getByRole("button", { name: "确认移除", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText("节点仍有活跃分配或保留资源"); + await page.getByRole("button", { name: "取消移除", exact: true }).click(); + await page.getByRole("button", { name: "刷新沙箱状态" }).click(); + await expect(page.getByRole("alert")).toHaveCount(0); + await page.getByLabel("节点可访问的 Core 地址").fill("https://core.example"); + await page.getByRole("button", { name: "生成注册命令", exact: true }).click(); + await expect(page.getByLabel("一次性注册命令")).toHaveValue(/fixture-once-token/); + await page.getByLabel("Language / 语言").selectOption("en"); + await expect(page.getByRole("heading", { name: "Hosted Sandbox Manager" })).toBeVisible(); + await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); + await page.reload(); + await expect(page.getByLabel("Language / 语言")).toHaveValue("en"); + await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); + await page.getByLabel("Language / 语言").selectOption("zh"); + await page.route("**/core/v1/sandbox/deployment", (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "sandbox_admin_not_configured", message: "Sandbox administration is not configured on this console" } }) })); + await page.getByRole("button", { name: "刷新沙箱状态" }).click(); + await expect(page.getByRole("alert")).toContainText("此控制台尚未配置沙箱管理权限"); +}); + +test("a direct Core connection never requests sandbox administration and clears enrollment", async ({ page, request }) => { + await openManager(page); + await page.getByLabel("Core URL reachable from the node").fill("https://core.example"); + await page.getByRole("button", { name: "Generate enrollment command" }).click(); + await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); + const before = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls.length; + await page.getByRole("button", { name: "Configure Agent Core connection", exact: true }).click(); + const connection = page.getByRole("dialog", { name: "Connect an Agent Core", exact: true }); + await connection.getByRole("radio", { name: /Other compatible Core/ }).check(); + await connection.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); + await connection.getByLabel("Bearer token").fill("project-only"); + await connection.getByRole("button", { name: "Apply connection", exact: true }).click(); + await expect(page.getByText("Sandbox management is available through the signed-in console connection.", { exact: false })).toBeVisible(); + await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toHaveCount(0); + expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(before); +}); + +test("an uncertain enrollment write is not retried and a late response cannot survive navigation", async ({ page }) => { + await openManager(page); + let attempts = 0; + let release: () => void = () => {}; + const pending = new Promise((resolve) => { release = resolve; }); + await page.route("**/core/v1/sandbox/enrollment-tokens", async (route) => { + attempts += 1; + await pending; + await route.fulfill({ contentType: "application/json", body: JSON.stringify({ token: "stale-token", expires_at: "2026-09-24T00:00:00Z" }) }).catch(() => {}); + }); + await page.getByLabel("Core URL reachable from the node").fill("https://core.example"); + await page.getByRole("button", { name: "Generate enrollment command" }).click(); + await expect.poll(() => attempts).toBe(1); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await openManager(page); + release(); + await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); + await expect(page.getByLabel("Core URL reachable from the node")).toHaveValue(new URL(page.url()).origin); + expect(attempts).toBe(1); +}); diff --git a/apps/web/e2e/sandbox-setup.spec.ts b/apps/web/e2e/sandbox-setup.spec.ts index dc361a319..0ca7bd8b6 100644 --- a/apps/web/e2e/sandbox-setup.spec.ts +++ b/apps/web/e2e/sandbox-setup.spec.ts @@ -2,14 +2,12 @@ import { expect, test, type Page } from "@playwright/test"; const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; const setupUrl = `${fixture}/core/v1/sandbox/deployment`; -const adminHeaders = { Authorization: "Bearer fixture-admin-key" }; async function openSetup(page: Page) { await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); - await page.getByLabel("Deployment admin key").fill("fixture-admin-key"); - await page.getByRole("button", { name: "Connect admin", exact: true }).click(); await expect(page.getByRole("heading", { name: "Set up hosted sandboxes" })).toBeVisible(); } test.beforeEach(async ({ page, request }) => { + await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: false }) })); await request.post(`${fixture}/__fixture/reset`); await request.post(`${fixture}/__fixture/sandbox-uninitialized`); await page.goto("/"); @@ -21,7 +19,7 @@ test("bundled console needs no extra admin key and provides one install command const browserAuthorizations: Array = []; await page.route("**/core/v1/sandbox/**", async (route) => { browserAuthorizations.push(route.request().headers().authorization); - await route.continue({ headers: { ...route.request().headers(), authorization: "Bearer fixture-admin-key" } }); + await route.continue(); }); await page.reload(); await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); @@ -88,8 +86,8 @@ test("concurrent setup conflict requires refresh and displays the committed prov await page.getByLabel("Sandbox provider").selectOption("docker"); await page.getByLabel("Core origin reachable from nodes and guests").fill("https://core.example"); const winner = { provider: "microsandbox", core_url: "https://other-core.example" }; - expect((await request.post(setupUrl, { headers: adminHeaders, data: winner })).status()).toBe(200); - expect((await request.post(setupUrl, { headers: adminHeaders, data: winner })).status()).toBe(200); + expect((await request.post(setupUrl, { data: winner })).status()).toBe(200); + expect((await request.post(setupUrl, { data: winner })).status()).toBe(200); await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); await expect(page.getByRole("alert")).toContainText("already configured"); await expect(page.getByRole("button", { name: "Initialize sandbox deployment" })).toBeDisabled(); @@ -117,7 +115,7 @@ test("a lost setup response is not retried and refresh recovers the saved deploy await page.getByRole("button", { name: "Refresh sandbox state" }).click(); await expect(page.locator(".sandbox-summary")).toContainText("Docker"); expect(writes).toBe(1); - expect((await (await request.get(setupUrl, { headers: adminHeaders })).json()).provider).toBe("docker"); + expect((await (await request.get(setupUrl)).json()).provider).toBe("docker"); }); test("a failed setup refresh keeps setup disabled until a successful read", async ({ page }) => { @@ -128,7 +126,7 @@ test("a failed setup refresh keeps setup disabled until a successful read", asyn await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); await expect(page.getByRole("alert")).toContainText("Refresh sandbox state to confirm"); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); - await expect(page.getByRole("alert")).toContainText("Deployment unavailable"); + await expect(page.getByRole("alert")).toContainText("The sandbox service is unavailable"); await expect(page.getByLabel("Sandbox provider")).toBeDisabled(); await page.unroute("**/core/v1/sandbox/deployment"); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); @@ -137,7 +135,7 @@ test("a failed setup refresh keeps setup disabled until a successful read", asyn }); for (const operation of ["setup", "enrollment"] as const) { - test(`Core connection changes discard credentials and a late ${operation} result`, async ({ page, request }) => { + test(`Core connection changes discard forms and a late ${operation} result`, async ({ page, request }) => { await page.addInitScript((operation) => { const target = window as Window & { releaseSandboxResponse?: () => void }; const originalFetch = window.fetch.bind(window); @@ -159,7 +157,7 @@ for (const operation of ["setup", "enrollment"] as const) { await page.getByLabel("Core origin reachable from nodes and guests").fill("https://core.example"); await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); } else { - await request.post(setupUrl, { headers: adminHeaders, data: { provider: "docker", core_url: "https://core.example" } }); + await request.post(setupUrl, { data: { provider: "docker", core_url: "https://core.example" } }); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); await page.getByRole("button", { name: "Generate enrollment command" }).click(); } @@ -172,11 +170,50 @@ for (const operation of ["setup", "enrollment"] as const) { await connection.getByLabel("Bearer token").fill("replacement-token"); await connection.getByRole("button", { name: "Apply connection", exact: true }).click(); await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); - await expect(page.getByLabel("Deployment admin key")).toHaveValue(""); + await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); + await expect(page.locator(".sandbox-manager")).toContainText("Switch the Core connection to /v1"); await page.evaluate(() => { (window as Window & { releaseSandboxResponse?: () => void }).releaseSandboxResponse?.(); }); - await expect(page.getByLabel("Deployment admin key")).toHaveValue(""); + await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); + await expect(page.locator(".sandbox-manager")).toContainText("Switch the Core connection to /v1"); await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); await expect(page.locator(".sandbox-manager")).not.toContainText("old-installation"); expect(await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }))).not.toMatch(/fixture-admin-key|old-secret-token/); }); } + +test("unpaired or unavailable consoles show setup guidance without admin credentials or manager requests", async ({ page, request }) => { + for (const body of [{ sandbox_admin: false, node_installer: true }, null]) { + await page.route("**/console/config", (route) => route.fulfill(body ? { contentType: "application/json", body: JSON.stringify(body) } : { status: 404, body: "Not found" })); + await page.reload(); + await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText("Sandbox administration is not configured"); + await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); + expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(0); + } + await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: false }) })); + await page.getByRole("button", { name: "Refresh sandbox state" }).click(); + await expect(page.getByRole("heading", { name: "Set up hosted sandboxes" })).toBeVisible(); +}); + +test("Chinese setup and the generated installer command preserve polling and language switching", async ({ page, request }) => { + await page.route("**/console/config", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }) })); + await page.getByLabel("Language / 语言").selectOption("zh"); + await page.getByRole("button", { name: "托管沙箱管理", exact: true }).click(); + await expect(page.getByRole("heading", { name: "配置托管沙箱" })).toBeVisible(); + await page.getByLabel("沙箱运行后端").selectOption("docker"); + await expect(page.locator(".sandbox-manager")).toContainText("节点命令会安装匹配的运行时镜像"); + await page.getByLabel("节点和沙箱可访问的 Core 地址").fill("https://core.example"); + await page.getByRole("button", { name: "初始化沙箱部署" }).click(); + await page.getByRole("button", { name: "生成节点命令" }).click(); + await expect(page.getByLabel("一次性注册命令")).toHaveValue(/PARSAR_NODE_ENROLLMENT_TOKEN='fixture-once-token' python3 -c/); + await expect(page.getByRole("button", { name: "复制节点命令" })).toBeVisible(); + await expect(page.locator(".sandbox-manager")).toContainText("每隔几秒自动刷新"); + await request.post(`${fixture}/__fixture/sandbox-add-node`); + await expect(page.getByRole("region", { name: "沙箱节点", exact: true })).toContainText("Enrolled host", { timeout: 10000 }); + await expect(page.getByRole("region", { name: "沙箱节点", exact: true })).toContainText("后端就绪"); + await page.getByLabel("Language / 语言").selectOption("en"); + await expect(page.getByRole("button", { name: "Copy node command" })).toBeVisible(); + await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); + await page.getByRole("button", { name: "Clear enrollment command" }).click(); + await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); +}); diff --git a/apps/web/src/components/SystemNavigation.tsx b/apps/web/src/components/SystemNavigation.tsx index 6561a2078..e0a3e452a 100644 --- a/apps/web/src/components/SystemNavigation.tsx +++ b/apps/web/src/components/SystemNavigation.tsx @@ -1,12 +1,15 @@ import { Layers3, Server } from "lucide-react"; +import { useLocale } from "../lib/LocaleProvider"; export type SystemView = "system" | "sandbox"; export function SystemNavigation({ active, onSelect }: { active: SystemView | null; onSelect: (view: SystemView) => void }) { - return