diff --git a/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py b/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py new file mode 100644 index 0000000000..676ec5ed67 --- /dev/null +++ b/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py @@ -0,0 +1,79 @@ +"""Real account HTTP/store with synthetic native process IO; no auth or model.""" +from __future__ import annotations + +import importlib +import json +from pathlib import Path +import sys +import tempfile +import threading + +repo = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(repo)) +sys.path.insert(0, str(repo / "packages/loopx-codex-provider-routing/src")) + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.presentation.codex_accounts_api import CodexAccountService + + +def main() -> None: + native = importlib.import_module("loopx_codex_provider_routing.native") + native.discover = lambda: {"synthetic": True} + clients = [ + {"pid": 303, "kind": "unmanaged", "process": "virtual-machine", "restartable": False, + "manager": {"pid": 304, "process": "limactl", "instance": "colima-example", "status": "Running"}, + "restart_blocker": "external_workload_unverified"}, + {"pid": 404, "kind": "unmanaged", "process": "standalone-client", "restartable": False, + "restart_blocker": "manager_unverified"}, + {"pid": 202, "kind": "service", "process": "codex", "restartable": True, + "service": "dev.example.renamed-chat-with-a-long-installed-service-label"}, + *[{"pid": 1000 + i, "kind": "app", "process": "codex" if i == 0 else "node", + "restartable": True} for i in range(18)], + ] + mode, calls = "many", [] + + def transport(adapter, operation, **parameters): + calls.append(operation) + if operation == "status": + return {"slots": ["a", "b", "c"], "active_slot": "a", "selection_revision": "synthetic", + "current_login_matches": True, "recovery_required": False} + assert operation == "clients", "Reading settings cannot switch, signal, observe quota or recover" + if mode == "unavailable": + raise native.HostError("ownership_unverified") + rows = [] if mode == "empty" else clients if mode in {"many", "partial"} else clients[:1] + return {"complete": mode != "partial", "clients": rows} + + with tempfile.TemporaryDirectory(prefix="loopx-account-clients-") as directory: + runtime = Path(directory).resolve() + provider = native.NativeAccounts(runtime, transport=transport) + provider.configure(expected_revision=0, enabled=False, slots=["a", "b", "c"], restart_clients=True) + before = provider.path.read_bytes() + calls.clear() + service = CodexAccountService(runtime) + service.provider = provider + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.codex_account_service = service + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + print(json.dumps({"origin": f"http://127.0.0.1:{server.server_port}"}), flush=True) + try: + for line in sys.stdin: + command = line.strip() + if command == "inspect": + print(json.dumps({"state_unchanged": provider.path.read_bytes() == before, + "calls": calls, "watcher_started": service.watcher.thread is not None}), flush=True) + elif command in {"many", "sparse", "empty", "partial", "unavailable"}: + mode = command + print(json.dumps({"mode": mode}), flush=True) + else: + raise ValueError("Unknown fixture command") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + service.close() + + +if __name__ == "__main__": + main() diff --git a/apps/presentation/dashboard/smoke/codex-account-clients-smoke.mjs b/apps/presentation/dashboard/smoke/codex-account-clients-smoke.mjs new file mode 100644 index 0000000000..3d39be3cd0 --- /dev/null +++ b/apps/presentation/dashboard/smoke/codex-account-clients-smoke.mjs @@ -0,0 +1,103 @@ +// Packaged settings + real Chat HTTP/native account store; synthetic process IO. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createInterface } from "node:readline"; +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { resolveTestPython } from "../../../../scripts/test-python.mjs"; +import { cleanupBrowserSmoke, launchBrowser, loadPlaywright, waitForHttp } from "../../../../examples/dashboard-browser-smoke-support.mjs"; + +process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1"; +const { repoRoot, outputDir, port, startServer } = await import("../../../../examples/personal-workspace-browser/fixture.mjs"); +const { openWorkspacePage, fetchWorkspaceBackend } = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); +const fixture = spawn(resolveTestPython({ repoRoot }), ["-u", "apps/presentation/dashboard/smoke/codex-account-clients-fixture.py"], + { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] }); +const exited = once(fixture, "exit"); +let stderr = ""; +fixture.stderr.on("data", value => { stderr += String(value); }); +const iterator = createInterface({ input: fixture.stdout })[Symbol.asyncIterator](); +async function next() { + const line = await iterator.next(); + assert.equal(line.done, false, stderr); + return JSON.parse(line.value); +} +async function command(value) { fixture.stdin.write(`${value}\n`); return next(); } +let server, browser, workspace; +const requests = []; +try { + const { origin } = await next(); + server = await startServer(); + const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`; + await waitForHttp(url); + browser = await launchBrowser(loadPlaywright().chromium); + workspace = await openWorkspacePage({newPage: options => browser.newPage({locale: "zh-CN", ...options})}, url, { + beforeGoto(_api, page) { + return page.route("**/api/chat/codex-accounts**", async route => { + requests.push({method: route.request().method(), path: new URL(route.request().url()).pathname}); + assert.equal(route.request().method(), "GET", "Inspection must never start an account operation"); + await route.fulfill({response: await fetchWorkspaceBackend(route, origin)}); + }); + }, + }); + const { page, api } = workspace; + await page.getByRole("button", {name: "设置", exact: true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button", {name: "模型 Provider 配置", exact: true}).click(); + const panel = page.getByTestId("codex-account-holders"); + const summary = panel.locator("summary"); + await summary.filter({hasText: "21 个占用 · 2 个暂不能自动重启"}).waitFor(); + assert.equal(await panel.getAttribute("open"), null, "The process table must not displace account status"); + await mkdir(outputDir, {recursive: true}); + await page.screenshot({path: resolve(outputDir, "auth-holders-default-desktop.png"), animations: "disabled"}); + await summary.focus(); + await page.keyboard.press("Enter"); + await panel.getByText("colima-example", {exact: false}).waitFor(); + assert.equal(await panel.locator("li").count(), 4, "Group one App's helpers without repeating restart copy"); + for (let pid = 1000; pid < 1018; pid++) assert.ok((await panel.innerText()).includes(String(pid)), "Every observed PID remains readable"); + assert.match(await panel.locator("li").first().innerText(), /外部工作负载/); + assert.match(await panel.innerText(), /dev.example.renamed-chat-with-a-long-installed-service-label/); + await page.screenshot({path: resolve(outputDir, "auth-holders-expanded-desktop.png"), animations: "disabled"}); + const inventoryReads = () => requests.filter(row => row.path.endsWith("/clients")).length; + const beforePolling = inventoryReads(); + await page.waitForTimeout(5500); // Cross one existing status timer tick. + assert.equal(inventoryReads(), beforePolling, "The status timer must not repeatedly scan processes"); + await page.setViewportSize({width: 390, height: 844}); + await panel.scrollIntoViewIfNeeded(); + assert.ok(await page.locator("body").evaluate(body => body.scrollWidth <= innerWidth + 1)); + await page.screenshot({path: resolve(outputDir, "auth-holders-expanded-narrow.png"), animations: "disabled"}); + for (const mode of ["unavailable", "partial", "empty", "sparse", "many"]) { + await command(mode); + await panel.getByRole("button", {name: "重新检查占用", exact: true}).click(); + const expected = mode === "unavailable" || mode === "partial" ? "占用情况暂时无法确认" + : mode === "empty" ? "本次未发现登录占用" : mode === "sparse" ? "1 个占用 · 1 个暂不能自动重启" : "21 个占用 · 2 个暂不能自动重启"; + await summary.filter({hasText: expected}).waitFor(); + assert.equal(await panel.locator("li").count(), mode === "many" ? 4 : mode === "sparse" ? 1 : 0); + if (mode === "unavailable") await page.screenshot({path: resolve(outputDir, "auth-holders-unavailable-narrow.png"), animations: "disabled"}); + } + await page.setViewportSize({width: 1512, height: 982}); + await page.locator(".personal-settings-tabs").getByRole("button", {name: "语言", exact: true}).click(); + await page.getByRole("radio", {name: "English", exact: true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button", {name: "Model provider", exact: true}).click(); + await page.getByTestId("codex-account-holders").locator("summary").filter({hasText: "21 holders · 2 cannot restart automatically"}).waitFor(); + await page.getByTestId("codex-account-holders").locator("summary").click(); + await page.getByText("External workload idle and recovery conditions are unverified", {exact: true}).waitFor(); + await page.screenshot({path: resolve(outputDir, "auth-holders-expanded-en.png"), animations: "disabled"}); + const state = await command("inspect"); + assert.equal(state.state_unchanged, true); + assert.equal(state.watcher_started, false); + assert.ok(state.calls.every(operation => ["status", "clients"].includes(operation))); + assert.equal(api.turnRequests.length, 0); + assert.deepEqual(workspace.errors, []); + console.log(JSON.stringify({ok: true, boundary: "packaged UI / real HTTP and native policy store / synthetic process IO", + inventoryReads: inventoryReads(), effects: 0, fullOwnerCount: 21, recovery: ["unavailable", "partial", "empty", "sparse", "many"], languages: ["zh-CN", "en"]})); +} catch (error) { + if (workspace) console.error((await workspace.page.locator("body").innerText()).slice(0, 2500)); + throw error; +} finally { + await workspace?.close(); + await browser?.close(); + fixture.stdin.end(); + if (fixture.exitCode === null) await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 5000))]); + if (fixture.exitCode === null) fixture.kill("SIGTERM"); + await cleanupBrowserSmoke({server, fixturePaths: []}); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.css b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.css index 4022c0a61c..7dbbdb449a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.css @@ -23,9 +23,17 @@ .personal-codex-accounts details { border-top: 1px solid var(--pw-line); padding-top: 16px; } .personal-codex-accounts summary { cursor: pointer; padding: 4px 0; } .personal-codex-accounts details p { margin: 12px 0; } +.codex-account-holders ul { list-style: none; padding: 0; margin: 16px 0 0; } +.codex-account-holders summary { color: var(--pw-text); } +.codex-account-holders li { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; padding: 12px 0; border-top: 1px solid var(--pw-line); overflow-wrap: anywhere; } +.codex-account-holders li > div { display: grid; gap: 4px; } +.codex-account-holders strong { color: var(--pw-text); font-weight: 500; } +.codex-account-holders li > span { color: var(--pw-text); } +.codex-account-holders .codex-account-actions { margin-top: 12px; } .personal-codex-accounts [role="alert"] { display: flex; gap: 8px; align-items: flex-start; color: var(--pw-red); } @media (max-width: 640px) { .codex-account-list { grid-template-columns: 1fr; gap: 20px; } .codex-account-list dl { grid-template-columns: 1fr 1fr; width: 100%; } .codex-account-list article > button { width: 100%; } + .codex-account-holders li { grid-template-columns: 1fr; gap: 8px; } } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx index ba2c23ac97..514f3e4909 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/codex-account-settings.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { z } from "zod"; import { RefreshCw, CheckCircle2, Circle, AlertCircle } from "lucide-react"; import { requestJson } from "../../data/chat"; import { useWorkspaceI18n } from "./i18n"; @@ -17,6 +18,20 @@ type AccountStatus = { credits_available: boolean | null }>; }; +// The provider owns classification and restart eligibility. Missing/partial +// reads cannot be presented as an empty or restartable inventory. +const inventorySchema = z.object({ + ok: z.literal(true), complete: z.literal(true), + clients: z.array(z.object({ + pid: z.number().int().min(2), process: z.string().min(1), + kind: z.enum(["app", "service", "unmanaged"]), restartable: z.boolean(), + service: z.string().optional(), restart_blocker: z.string().optional(), + manager: z.object({ pid: z.number().int().min(2), process: z.string().min(1), + instance: z.string().optional(), status: z.string().optional() }).optional(), + })), +}); +type ClientInventory = z.infer; + const copy = { "zh-CN": { title: "Codex 原生账号", enabled: "自动接续已启用", disabled: "自动接续已关闭", @@ -27,7 +42,7 @@ const copy = { restart: "允许自动退出并重开 GPT 客户端", restarting: "正在退出 GPT、切换账号并重新打开", forceQuit: "正常退出失败时自动强制关闭 GPT,无需再次确认", effectForce: "仅使用下方已登记账号。额度耗尽后先正常退出 GPT;退出失败时自动强制结束该实例,再切换账号并重新打开。强制退出会中断该实例的当前工作。", - reopen: "重新打开 GPT", other_clients_busy: "还有其他进程占用 GPT 登录;关闭占用后检查额度重试", + reopen: "重新打开 GPT", other_clients_busy: "仍有暂不能自动重启的登录占用,可在下方查看", client_quit_refused: "GPT 未接受正常退出;本次未换号,自动尝试已暂停", client_quit_timeout: "GPT 尚未退出;本次未换号,自动尝试已暂停", client_changed: "GPT 进程已变化;已暂停本次切换", handoff_pending: "独立切换进程正在完成接续,请稍候", handoff_interrupted: "接续进程中断;请检查并恢复本次切换", quota_stale: "额度记录已过期,正在重新检查", @@ -45,6 +60,13 @@ const copy = { native_unavailable: "无法连接原生槽位管理器,请检查本机安装后重试。", launchFailed: "账号已切换,但界面未能重新打开。可以从原来的 GPT 入口打开;请勿重复切换。", failure: "操作未完成。刷新状态后可以重试。", recoveryHint: "恢复会还原未完成事务;撤销会还原最近一次切换。两者都需要客户端关闭,且不会覆盖后续登录修改。", + clients: "登录占用", clientsRefresh: "重新检查占用", clientsChecking: "正在检查登录占用", + clientsUnknown: "占用情况暂时无法确认", clientsEmpty: "本次未发现登录占用", + clientsCount: "个占用", clientsHeld: "个暂不能自动重启", clientsManaged: "支持受控重启", + clientsApp: "GPT 客户端", clientsService: "后台服务", clientsOther: "外部进程", clientsProcesses: "个进程", + clientsDetail: "清单记录检查时的进程归属,切换时会重新核验。支持受控重启仍需已启用退出与重开权限;外部工作负载须先核实空闲及恢复条件。检查占用不读取凭证内容、不换号、不结束进程。", + manager_unverified: "尚未核实管理器,保留占用等待", + external_workload_unverified: "外部工作负载的空闲与恢复条件尚未核实", }, en: { title: "Native Codex accounts", enabled: "Automatic handoff enabled", disabled: "Automatic handoff off", @@ -55,7 +77,7 @@ const copy = { restart: "Allow automatic GPT client quit and restart", restarting: "Quitting GPT, switching accounts and reopening", forceQuit: "Force quit GPT if normal quit fails, without another confirmation", effectForce: "Use only the registered accounts below. After exhaustion, try normal quit first; if it fails, force quit that GPT instance, switch accounts and reopen. Force quit interrupts work in that instance.", - reopen: "Reopen GPT", other_clients_busy: "Another process still owns the GPT login; close it and check quota to retry", + reopen: "Reopen GPT", other_clients_busy: "A login holder cannot be restarted automatically; inspect it below", client_quit_refused: "GPT refused normal quit; no account changed and automatic retries are paused", client_quit_timeout: "GPT has not exited; no account changed and automatic retries are paused", client_changed: "The GPT process changed; this switch was paused", handoff_pending: "The independent handoff is still finishing; please wait", handoff_interrupted: "The handoff process stopped; inspect and recover this switch", quota_stale: "Quota observations expired; checking again", @@ -73,6 +95,13 @@ const copy = { native_unavailable: "Cannot connect to the native slot manager. Check the local installation and retry.", launchFailed: "The account switched, but the interface could not reopen. Open the original GPT entry; do not repeat the switch.", failure: "The operation did not finish. Refresh its status before retrying.", recoveryHint: "Recovery restores an interrupted transaction; undo restores the last switch. Both require closed clients and refuse to overwrite later login changes.", + clients: "Login holders", clientsRefresh: "Recheck holders", clientsChecking: "Checking login holders", + clientsUnknown: "Login holders could not be confirmed", clientsEmpty: "No login holders found in this check", + clientsCount: "holders", clientsHeld: "cannot restart automatically", clientsManaged: "Supports controlled restart", + clientsApp: "GPT client", clientsService: "Background service", clientsOther: "External process", clientsProcesses: "processes", + clientsDetail: "This inventory records process ownership at check time; switching revalidates it. Controlled restart still requires quit-and-reopen permission. External workloads need verified idle and recovery conditions. This check reads no credential contents, switches no account and ends no process.", + manager_unverified: "Manager not verified; the holder remains a hold", + external_workload_unverified: "External workload idle and recovery conditions are unverified", }, }; @@ -82,6 +111,10 @@ export function CodexAccountSettings() { const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(false); + const [inventory, setInventory] = useState(null); + const [inventoryBusy, setInventoryBusy] = useState(false); + const [inventoryChecked, setInventoryChecked] = useState(null); + const inventoryRequest = useRef(0); const endpoint = "/api/chat/codex-accounts"; const label = (key: string | null | undefined) => c[key as keyof typeof c] ?? c.unavailable; const read = useCallback(async () => { @@ -91,26 +124,53 @@ export function CodexAccountSettings() { setError(false); } catch { setError(true); } }, []); + const readInventory = useCallback(async () => { + const request = ++inventoryRequest.current; + setInventoryBusy(true); + setInventory(null); + setInventoryChecked(null); + try { + const result = inventorySchema.safeParse(await requestJson(`${endpoint}/clients`)); + if (request === inventoryRequest.current && result.success) { + setInventory(result.data); + setInventoryChecked(new Date()); + } + } catch { /* Unknown stays visible; quota status and policy are independent. */ } + finally { if (request === inventoryRequest.current) setInventoryBusy(false); } + }, []); useEffect(() => { void read(); const timer = window.setInterval(() => { if (!document.hidden) void read(); }, 5000); return () => window.clearInterval(timer); }, [read]); + useEffect(() => { + // Inspect once on entry; the ordinary status timer must not scan processes. + if (status?.available) void readInventory(); + return () => { inventoryRequest.current += 1; }; + }, [status?.available, readInventory]); async function act(operation: string, parameters = {}) { setBusy(true); setError(false); + inventoryRequest.current += 1; + setInventory(null); + setInventoryChecked(null); try { const result = await requestJson(endpoint, { method: "POST", body: JSON.stringify({ operation, parameters }), }); setStatus(result); } catch { await read(); setError(true); } - finally { setBusy(false); } + finally { setBusy(false); void readInventory(); } } const enabled = status?.policy?.enabled === true; const restartClients = status?.policy?.restart_clients === true; const forceQuitClients = status?.policy?.force_quit_clients === true; const problem = status?.code || (status?.ok === false ? "native_unavailable" : null); + const holders = [...(inventory?.clients ?? [])].sort((a, b) => Number(a.restartable) - Number(b.restartable)); + const held = holders.filter(client => !client.restartable).length; + const appHolders = holders.filter(client => client.kind === "app" && client.restartable); + const appProcesses = new Map(); + for (const client of appHolders) appProcesses.set(client.process, [...(appProcesses.get(client.process) ?? []), client.pid]); return

{c.title}

{status?.active_slot ? `${c.current} ${status.active_slot.toUpperCase()} · ` : ""}{enabled ? c.enabled : c.disabled}
{!status ?

{c.checking}

: null} @@ -134,6 +194,23 @@ export function CodexAccountSettings() { : } {label(status.recovery_required ? "recovery_required" : status.action)} +
+ {c.clients} · {inventoryBusy ? c.clientsChecking : inventory + ? holders.length ? `${holders.length} ${c.clientsCount}${held ? ` · ${held} ${c.clientsHeld}` : ""}` : c.clientsEmpty + : c.clientsUnknown} +
+ {inventoryChecked ? {c.last} {inventoryChecked.toLocaleTimeString(locale)} : null}
+

{c.clientsDetail}

+ {inventory ?
    {holders.filter(client => client.kind !== "app" || !client.restartable).map(client =>
  • +
    {client.service || client.process}PID {client.pid} · {client.kind === "app" + ? c.clientsApp : client.kind === "service" ? c.clientsService : c.clientsOther} + {client.manager ? {client.manager.process}{client.manager.instance ? ` · ${client.manager.instance}` : ""} · PID {client.manager.pid} : null}
    + {client.restartable ? c.clientsManaged : label(client.restart_blocker || "external_workload_unverified")} +
  • )}{appHolders.length ?
  • {c.clientsApp} · {appHolders.length} {c.clientsProcesses} + {[...appProcesses].map(([process, pids]) => {process} · PID {pids.join(", ")})}
    + {c.clientsManaged}
  • : null}
: null} +
{status.slots.map(slot => { const row = status.accounts?.find(a => a.slot === slot); diff --git a/docs/assets/personal-workspace/auth-holders/README.md b/docs/assets/personal-workspace/auth-holders/README.md new file mode 100644 index 0000000000..0ccde400e0 --- /dev/null +++ b/docs/assets/personal-workspace/auth-holders/README.md @@ -0,0 +1,28 @@ +# Native account login holders + +Public-safe synthetic process inventory on the built settings page. The page +uses the production Chat HTTP account service and a disposable native policy +store; native process IO is synthetic. No credentials, model, account switch, +process termination or live workload is used. + +The original settings component and stylesheet at +`beede96e0ade200353ee0e4646a0b52d81576c37` produce `before-desktop.png`. +The remaining captures show the changed component with the same 21-holder +fixture: 18 helpers from one App, one renamed service and two held external +processes. App helpers share one row, with every PID preserved. + +The default view keeps account status visible. Expanding shows held owners +first; the 390px view retains full names, PID lists and reasons. Failed and +incomplete reads clear the old list and show unknown. Empty and sparse reads, +retry, keyboard expansion, English and the absence of process scans on normal +status ticks are also exercised. These captures do not certify VM restart or +new-auth adoption. + +Reproduce from the repository root after building the Chat bundle: + +```sh +node apps/presentation/dashboard/smoke/codex-account-clients-smoke.mjs +``` + +Use the source test environment and the browser setup from the normal dashboard +smoke guide. The fixture has no dependency on a real installed slot manager. diff --git a/docs/assets/personal-workspace/auth-holders/before-desktop.png b/docs/assets/personal-workspace/auth-holders/before-desktop.png new file mode 100644 index 0000000000..444c24c3e3 Binary files /dev/null and b/docs/assets/personal-workspace/auth-holders/before-desktop.png differ diff --git a/docs/assets/personal-workspace/auth-holders/default-desktop.png b/docs/assets/personal-workspace/auth-holders/default-desktop.png new file mode 100644 index 0000000000..e76affa01a Binary files /dev/null and b/docs/assets/personal-workspace/auth-holders/default-desktop.png differ diff --git a/docs/assets/personal-workspace/auth-holders/expanded-desktop.png b/docs/assets/personal-workspace/auth-holders/expanded-desktop.png new file mode 100644 index 0000000000..faf9a2ad88 Binary files /dev/null and b/docs/assets/personal-workspace/auth-holders/expanded-desktop.png differ diff --git a/docs/assets/personal-workspace/auth-holders/expanded-narrow.png b/docs/assets/personal-workspace/auth-holders/expanded-narrow.png new file mode 100644 index 0000000000..8607adce30 Binary files /dev/null and b/docs/assets/personal-workspace/auth-holders/expanded-narrow.png differ diff --git a/docs/assets/personal-workspace/auth-holders/unavailable-narrow.png b/docs/assets/personal-workspace/auth-holders/unavailable-narrow.png new file mode 100644 index 0000000000..d4d9690a3b Binary files /dev/null and b/docs/assets/personal-workspace/auth-holders/unavailable-narrow.png differ diff --git a/loopx/presentation/codex_accounts_api.py b/loopx/presentation/codex_accounts_api.py index 59d6dc770c..15a8cbdfaa 100644 --- a/loopx/presentation/codex_accounts_api.py +++ b/loopx/presentation/codex_accounts_api.py @@ -40,6 +40,8 @@ def call(self, operation: str, parameters: dict[str, Any]) -> dict[str, Any]: try: if operation == "status": value = self.provider.status() + elif operation == "clients": + value = self.provider.clients() elif operation == "configure": with self.mutex: if parameters.get("enabled") is False: @@ -92,6 +94,9 @@ class CodexAccountsRequestMixin: def _codex_accounts_read(self) -> None: cast(_HTTP, self)._send_json(self.server.codex_account_service.call("status", {})) + def _codex_account_clients(self) -> None: + cast(_HTTP, self)._send_json(self.server.codex_account_service.call("clients", {})) + def _codex_accounts_write(self) -> None: http = cast(_HTTP, self) try: diff --git a/loopx/presentation/configuration_api.py b/loopx/presentation/configuration_api.py index f330fcb3ed..3551e7016e 100644 --- a/loopx/presentation/configuration_api.py +++ b/loopx/presentation/configuration_api.py @@ -46,6 +46,7 @@ def _configuration_get_routes(self) -> dict[str, Callable[[], None]]: return { f"{media_api.CONTENT_MEDIA_PATH}/file": self._content_media_file, accounts_api.CODEX_ACCOUNTS_PATH: self._codex_accounts_read, + f"{accounts_api.CODEX_ACCOUNTS_PATH}/clients": self._codex_account_clients, attached_api.CODEX_ATTACHED_PATH: self._codex_attached_read, attached_api.CODEX_ATTACHED_LIVE_PATH: self._codex_attached_live, request_api.REQUEST_CONTROL_PATH: self._request_control_read, diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 3f73bd4fa6..9c0d8e933e 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -131,14 +131,22 @@ catalog's running instance; raw arguments, environment and catalog paths stay private. Missing SPI/permissions, changed launch identities, ambiguous instances or unavailable manager reads preserve an explicit hold. No guest command runs. The existing App/LaunchAgent restart path is unchanged; externally managed -workloads require their own idle/recovery qualification. This read-only CLI -diagnostic is a staged prerequisite: the settings page still shows its existing -busy cause, and VM recovery/new-auth adoption remain unqualified. +workloads require their own idle/recovery qualification. In **Settings → Model +provider → Native Codex accounts**, **Login holders** reads this same inventory. +Its collapsed summary retains held and unknown observations; expanding it shows +all observed PIDs, services and corroborated managers. **Recheck holders** only +repeats the read. It does not check quota, enable a policy, reconcile recovery, +switch accounts or end a process. The ordinary status timer does not repeat the +process scan. A failed or incomplete read clears the earlier inventory and stays +unknown, rather than reporting that no client remains. Restart eligibility is +an observation, not a guarantee or another restart permission. Actual handoff +still revalidates ownership through the original account transaction. 0.12.9 的只读占用清单会核对脱离 App 进程树的 XPC 责任进程,并对已核验的 Lima/Colima 管理器交叉读取原实例。识别到管理器不等于业务空闲或可以重启; -未知或变化的身份保留等待,不执行 guest 命令,不新增强关目标。设置页的完整 -占用诊断与外部工作负载恢复仍待交付,不能把这一步视为真实换号已生效。 +未知或变化的身份保留等待,不执行 guest 命令,不新增强关目标。设置页的 +「登录占用」复用同一清单,可展开全部进程并只读重查;失败或部分读取显示未知。 +这不改变原切换权限,VM 恢复及真实换号后的新认证采用仍需分别验收。 An operator-owned service independent of the App can run the same observer: diff --git a/tests/presentation/test_codex_account_clients.py b/tests/presentation/test_codex_account_clients.py new file mode 100644 index 0000000000..5a15fe9f96 --- /dev/null +++ b/tests/presentation/test_codex_account_clients.py @@ -0,0 +1,90 @@ +"""The settings inventory reads the optional provider without account effects.""" +from __future__ import annotations + +import importlib +import json +from pathlib import Path +import threading +import urllib.error +import urllib.request + +import pytest + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.presentation.codex_accounts_api import CodexAccountService + + +@pytest.fixture +def inventory_server(tmp_path, monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / + "packages/loopx-codex-provider-routing/src")) + native = importlib.import_module("loopx_codex_provider_routing.native") + calls = [] + clients = [ + {"pid": 101, "kind": "app", "process": "codex", "restartable": True}, + {"pid": 202, "kind": "service", "process": "codex", "restartable": True, + "service": "dev.example.renamed-chat"}, + {"pid": 303, "kind": "unmanaged", "process": "virtual-machine", "restartable": False, + "manager": {"pid": 304, "process": "limactl", "instance": "colima-example", "status": "Running"}, + "restart_blocker": "external_workload_unverified"}, + ] + fault = [] + + def transport(adapter, operation, **parameters): + calls.append((operation, parameters)) + assert operation == "clients", "Inventory must not observe quota, reconcile, switch or reopen" + if fault: + raise native.HostError("client_changed") + return {"complete": True, "clients": clients} + + monkeypatch.setattr(native, "discover", lambda: {"synthetic": True}) + service = CodexAccountService(tmp_path / "runtime") + service.provider = native.NativeAccounts(tmp_path / "runtime", transport=transport) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.codex_account_service = service + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", service, clients, calls, fault + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + service.close() + + +def test_clients_read_uses_original_owner_and_preserves_default_off(inventory_server): + origin, service, clients, calls, _ = inventory_server + assert not service.provider.root.exists() + with urllib.request.urlopen(origin + "/api/chat/codex-accounts/clients") as response: + result = json.load(response) + assert result == {"ok": True, "complete": True, "clients": clients} + assert calls == [("clients", {})] + assert not service.provider.root.exists() + assert service.watcher.thread is None + + +def test_clients_failure_is_unknown_and_can_recover_without_effects(inventory_server): + origin, service, clients, calls, fault = inventory_server + fault.append(True) + with urllib.request.urlopen(origin + "/api/chat/codex-accounts/clients") as response: + result = json.load(response) + assert result["ok"] is False and result["code"] == "client_changed" + assert "clients" not in result and result.get("complete") is not True + fault.clear() + with urllib.request.urlopen(origin + "/api/chat/codex-accounts/clients") as response: + assert json.load(response)["clients"] == clients + assert calls == [("clients", {}), ("clients", {})] + assert not service.provider.root.exists() + + +def test_clients_route_does_not_accept_effect_parameters(inventory_server): + origin, _, _, calls, _ = inventory_server + request = urllib.request.Request(origin + "/api/chat/codex-accounts", method="POST", + headers={"Content-Type": "application/json"}, + data=json.dumps({"operation": "clients", "parameters": {"force_quit_clients": True}}).encode()) + with pytest.raises(urllib.error.HTTPError) as error: + urllib.request.urlopen(request) + assert error.value.code == 409 + assert calls == [] diff --git a/tests/presentation/test_codex_accounts.py b/tests/presentation/test_codex_accounts.py index 8080ff62ed..47e31ecdf9 100644 --- a/tests/presentation/test_codex_accounts.py +++ b/tests/presentation/test_codex_accounts.py @@ -662,6 +662,7 @@ def client_host(native, monkeypatch, tmp_path): monkeypatch.setattr(module, "process_identity", lambda pid: identity if pid == 11 else None) adapter.inspect_client_helpers = lambda: ((state["pid"], module.process_identity(state["pid"])), {}) adapter._home_owners = lambda: [] + adapter._consumer_agents = lambda owners: [] adapter.revision = lambda: state["revision"] adapter.status = lambda: {"active_slot": state["active"], "selection_revision": state["revision"], "current_login_matches": True, "recovery_required": False,