diff --git a/AGENTS.md b/AGENTS.md index b3bfef92f7..0137e32ec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,8 +232,9 @@ metadata, test, example or narrow-cleanup PRs when all these conditions hold: - none of the protected behavior surfaces above is changed; - the diff is easy to review and required checks/focused smokes passed; - its exact head has the published review, and - `uv run --extra test loopx pr-review --goal-id GOAL --check-merge-readiness NUMBER@HEAD_OID` - returned ready for that unchanged head; + `uv run --extra test loopx pr-review --check-merge-readiness NUMBER@HEAD_OID` + returned ready for that unchanged head (add `--goal-id GOAL` for already + Goal-managed work; standalone delivery requires no Goal); - no private state, raw benchmark evidence, credentials, local paths or generated logs are included; - no runtime behavior, benchmark adapter, permission, destructive Git or public diff --git a/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py b/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py index 676ec5ed67..de1b86106d 100644 --- a/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py +++ b/apps/presentation/dashboard/smoke/codex-account-clients-fixture.py @@ -3,10 +3,12 @@ import importlib import json +import os from pathlib import Path import sys import tempfile import threading +import time repo = Path(__file__).resolve().parents[4] sys.path.insert(0, str(repo)) @@ -18,6 +20,9 @@ def main() -> None: native = importlib.import_module("loopx_codex_provider_routing.native") + keys_module = importlib.import_module("loopx_codex_provider_routing.openai_keys") + key_check = "models_readable" + native.NativeAccounts.api_keys = lambda self: keys_module.OpenAIKeys(self, checker=lambda _: key_check) native.discover = lambda: {"synthetic": True} clients = [ {"pid": 303, "kind": "unmanaged", "process": "virtual-machine", "restartable": False, @@ -31,12 +36,37 @@ def main() -> None: "restartable": True} for i in range(18)], ] mode, calls = "many", [] + api_fixture = os.environ.get("LOOPX_API_FALLBACK_FIXTURE") == "1" + active, selection, api_slot, recovered, now = "a", "synthetic", None, False, time.time() + slots, registration_busy = ["a", "b", "c"], False + if api_fixture: + relay_module = importlib.import_module("loopx_codex_provider_routing.openai_relay") + relay_module.ensure = lambda _: {"slot": "openai-api", "api_key": "synthetic-relay-token", "base_url": "http://127.0.0.1:32100/v1"} def transport(adapter, operation, **parameters): + nonlocal active, selection, api_slot, slots calls.append(operation) if operation == "status": - return {"slots": ["a", "b", "c"], "active_slot": "a", "selection_revision": "synthetic", - "current_login_matches": True, "recovery_required": False} + return {"slots": slots, "active_slot": active, "selection_revision": selection, + "current_login_matches": True, "recovery_required": False, + "api_fallback_supported": api_fixture, "api_slot": api_slot} + if operation == "enroll_current": + assert parameters["expected_revision"] == selection and slots == [] + if registration_busy: + raise native.HostError("waiting_for_idle") + slots, active, selection = ["primary"], "primary", selection + "-registered" + return transport(adapter, "status") + if api_fixture and operation == "enroll_api": + api_slot = "openai-api" + return transport(adapter, "status") + if api_fixture and operation == "observe": + return {"observations": [{"slot": slot, "status": "ok", "observed_at": now, + "usage": {"rate_limit": {"primary_window": {"used_percent": 10 if recovered and slot == "b" else 100, + "reset_at": now + 3600}}, "credits": {"has_credits": False}}} for slot in parameters["slots"]]} + if api_fixture and operation == "switch": + assert parameters["expected_revision"] == selection + active, selection = parameters["slot"], selection + "-switched" + return transport(adapter, "status") | {"launch_failed": False} assert operation == "clients", "Reading settings cannot switch, signal, observe quota or recover" if mode == "unavailable": raise native.HostError("ownership_unverified") @@ -45,30 +75,74 @@ def transport(adapter, operation, **parameters): with tempfile.TemporaryDirectory(prefix="loopx-account-clients-") as directory: runtime = Path(directory).resolve() - provider = native.NativeAccounts(runtime, transport=transport) + provider = native.NativeAccounts(runtime, transport=transport, **({"clock": lambda: now} if api_fixture else {})) 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.watcher.close() service.provider = provider + service.watcher = service._new_observer(native) 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() + observer_hold = None 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) + "calls": calls, "policy": provider._load()["policy"], + "watcher_started": service.watcher.thread is not None}), flush=True) + elif command == "authorize-a" and api_fixture: + result = service.call("configure", {"expected_revision": provider.status()["revision"], + "enabled": True, "slots": ["a"], "api_fallback": False}) + assert result["ok"] + print(json.dumps({"policy": result["policy"]}), flush=True) + elif command == "observer-stale" and api_fixture: + service.watcher.close() + observer_hold = provider._lock(name="observer.lock") + observer_hold.__enter__() + native.atomic(provider.root / "observer.json", {"schema_version": native.OBSERVER_SCHEMA, + "build": native.OBSERVER_BUILD, "checked_at": time.time() - 100, + "deadline_at": time.time() - 1, "phase": "waiting"}) + print(json.dumps({"observer_stale": True, "revision": provider._load()["revision"]}), flush=True) + elif command == "observer-resume" and api_fixture: + observer_hold.__exit__(None, None, None) + observer_hold = None + print(json.dumps({"observer_resumed": True}), flush=True) elif command in {"many", "sparse", "empty", "partial", "unavailable"}: mode = command print(json.dumps({"mode": mode}), flush=True) + elif command in {"key-models_readable", "key-authentication_failed", "key-permission_denied", "key-rate_limited", "key-unavailable"}: + key_check = command.removeprefix("key-") + print(json.dumps({"key_check": key_check}), flush=True) + elif command == "subscription-recovered" and api_fixture: + recovered, now = True, now + 301 + print(json.dumps({"subscription_recovered": True}), flush=True) + elif command == "api-observation" and api_fixture: + keys = provider.api_keys() + keys.record(keys.status()["keys"][0]["id"], status=200, code=None, + model="example-selected-model", headers={ + "x-ratelimit-remaining-requests": "42", "x-ratelimit-remaining-tokens": "12000"}) + print(json.dumps({"api_observation": True}), flush=True) + elif command == "unregistered": + slots, active, selection, registration_busy = [], None, "unregistered", True + result = service.call("configure", {"expected_revision": provider.status()["revision"], + "enabled": False, "slots": [], "api_fallback": False}) + assert result["ok"] + print(json.dumps({"unregistered": True}), flush=True) + elif command == "registration-ready": + registration_busy = False + print(json.dumps({"registration_ready": True}), flush=True) else: raise ValueError("Unknown fixture command") finally: + if observer_hold: + observer_hold.__exit__(None, None, None) server.shutdown() server.server_close() thread.join(timeout=5) diff --git a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs index 2f81904e1e..6eae2bb0b1 100644 --- a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs @@ -232,7 +232,10 @@ try { // the parent message refresh must not replace it with the old page count. await fixture('report-window'); const nextPage = english.getByRole('button', {name: 'Load next page', exact: true}); - await nextPage.focus(); await page.keyboard.press('Enter'); await rows(13); + // A parent refresh can temporarily aria-disable paging after the fixture + // returns. Click waits for actionability; raw Enter can be ignored while + // busy. Keyboard activation and result focus are exercised above. + await nextPage.click(); await rows(13); assert.equal(await english.getByRole('button', {name: 'Load next page', exact: true}).count(), 0); assert.equal(await page.evaluate(() => document.activeElement?.textContent), '13 / 13 shown'); await english.getByRole('button', {name: 'Awaiting follow-up', exact: true}).click(); await rows(11); diff --git a/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs b/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs new file mode 100644 index 0000000000..3bea06a9d6 --- /dev/null +++ b/apps/presentation/dashboard/smoke/openai-key-settings-smoke.mjs @@ -0,0 +1,222 @@ +// Packaged settings, real HTTP and private store; synthetic OpenAI/native 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"; +process.env.LOOPX_API_FALLBACK_FIXTURE = "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; +let loseNextAdd = false; +let addRequests = 0; +const rejectedRequests = []; +const syntheticKey = index => `sk-${"synthetic-only-".repeat(4)}${index}`; +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 => { + const request = route.request(); + const body = request.method() === "POST" ? request.postDataJSON() : null; + if (body?.operation === "keys-add") addRequests++; + const response = await fetchWorkspaceBackend(route, origin); + const responseText = await response.text(); + assert.ok(!responseText.includes("synthetic-only-"), "No response may contain the complete key"); + if (response.status() >= 400) rejectedRequests.push({operation: body?.operation, code: JSON.parse(responseText).code}); + if (body?.operation === "keys-add" && loseNextAdd) { + loseNextAdd = false; + await route.abort("failed"); + } else await route.fulfill({response}); + }); + }, + }); + 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("openai-key-settings"); + await panel.getByText("尚未添加 Key", {exact: true}).waitFor(); + await mkdir(outputDir, {recursive: true}); + await page.screenshot({path: resolve(outputDir, "openai-keys-empty-desktop.png"), animations: "disabled"}); + async function add(index, label) { + await panel.getByRole("button", {name: "添加 Key", exact: true}).click(); + await panel.getByLabel("名称", {exact: true}).fill(label); + await panel.getByLabel("API Key", {exact: true}).fill(syntheticKey(index)); + await panel.getByRole("button", {name: "保存 Key", exact: true}).click(); + await panel.getByText(label, {exact: true}).waitFor(); + if (await panel.locator('input[type="password"]').count()) { + assert.equal(await panel.locator('input[type="password"]').inputValue(), "", "A lost response must not retain a key draft"); + } + } + await add(1, "Primary project"); + await command("key-permission_denied"); + await panel.locator("article").first().getByRole("button", {name: "检查连接"}).click(); + await panel.getByText("无模型列表读取权限", {exact: true}).waitFor(); + await command("key-models_readable"); + await panel.locator("article").first().getByRole("button", {name: "检查连接"}).click(); + await panel.getByText("模型列表读取成功", {exact: true}).waitFor(); + await add(2, "第二个项目:用于检验很长的配置名称在窄屏上仍然完整可读"); + await command("key-rate_limited"); + await panel.locator("article").nth(1).getByRole("button", {name: "检查连接"}).click(); + await panel.getByText("检查被限流,余额仍未知", {exact: true}).waitFor(); + loseNextAdd = true; + await add(3, "Recovery project"); + await panel.getByRole("alert").waitFor(); + await panel.getByRole("button", {name: "取消", exact: true}).click(); + assert.equal(addRequests, 3, "The application must read back a lost response without another submit"); + assert.equal(await panel.locator("article").count(), 3); + await page.screenshot({path: resolve(outputDir, "openai-keys-populated-desktop.png"), animations: "disabled"}); + const native = page.getByTestId("codex-account-settings"); + await native.getByRole("checkbox", {name: "额度耗尽后自动切换", exact: true}).click(); + await page.waitForFunction(() => document.querySelector('[data-testid="codex-account-settings"] input[type="checkbox"]')?.checked); + await panel.getByRole("checkbox", {name: "订阅耗尽后使用 API(按用量计费)", exact: true}).click(); + await page.waitForFunction(() => document.querySelector('[data-testid="openai-key-settings"] input[type="checkbox"]')?.checked); + await native.getByRole("button", {name: "检查额度", exact: true}).click(); + await native.locator("header").getByText(/当前 OPENAI-API/).waitFor(); + await command("api-observation"); + await panel.getByText(/API 已接受请求 · example-selected-model/).waitFor(); + await panel.getByText(/限流窗口请求余量 42 · 限流窗口 Token 余量 12000/).waitFor(); + await panel.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "openai-fallback-active-desktop.png"), animations: "disabled"}); + await command("subscription-recovered"); + await native.getByRole("button", {name: "检查额度", exact: true}).click(); + await native.locator("header").getByText(/当前 B/).waitFor(); + 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, "openai-keys-populated-narrow.png"), animations: "disabled"}); + await panel.getByRole("button", {name: "API Key 与额度", exact: true}).click(); + await page.getByText(/项目 Key 无法查询账单余额/).waitFor(); + await page.screenshot({path: resolve(outputDir, "openai-keys-help-narrow.png"), animations: "disabled"}); + await page.keyboard.press("Escape"); + await panel.locator("article").last().getByRole("button", {name: "移除", exact: true}).click(); + await page.waitForFunction(() => document.querySelectorAll('[data-testid="openai-key-settings"] article').length === 2); + 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("openai-key-settings").getByText("2 saved", {exact: true}).waitFor(); + const state = await command("inspect"); + assert.equal(state.state_unchanged, false, "Explicit handoff configuration must persist"); + assert.equal(state.watcher_started, true); + assert.equal(state.calls.filter(operation => operation === "switch").length, 2, "API fallback then recovered subscription return"); + assert.equal(api.turnRequests.length, 0); + const observer = native.getByTestId("codex-observer-health"); + await observer.getByText("Background observation healthy", {exact: true}).waitFor(); + const held = await command("observer-stale"); + await observer.getByText("Background observation heartbeat overdue", {exact: true}).waitFor(); + assert.equal(await observer.getAttribute("role"), "alert"); + await observer.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "openai-observer-overdue-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await observer.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "openai-observer-overdue-narrow.png"), animations: "disabled"}); + await command("observer-resume"); + await observer.getByRole("button", {name: "Recheck", exact: true}).click(); + await observer.getByText("Background observation healthy", {exact: true}).waitFor(); + assert.equal((await command("inspect")).policy.enabled, true); + const healthStatus = await (await fetch(`${origin}/api/chat/codex-accounts`)).json(); + assert.equal(healthStatus.revision, held.revision, "Recovery must not rewrite the policy or its permissions"); + await page.setViewportSize({width: 1512, height: 982}); + await observer.evaluate(node => node.scrollIntoView({block: "center", behavior: "instant"})); + await page.waitForFunction(() => { + const rect = document.querySelector('[data-testid="codex-observer-health"]')?.getBoundingClientRect(); + return rect && rect.top >= 140 && rect.bottom < innerHeight; + }); + await page.screenshot({path: resolve(outputDir, "openai-observer-healthy-desktop.png"), animations: "disabled"}); + const automatic = native.getByRole("checkbox", {name: "Switch automatically when quota runs out", exact: true}); + const fallback = panel.getByRole("checkbox", {name: "Use API after subscription exhaustion (usage based billing)", exact: true}); + async function configure(control) { + const acknowledged = page.waitForResponse(response => response.url().endsWith("/api/chat/codex-accounts") + && response.request().method() === "POST" && response.request().postDataJSON().operation === "configure"); + await control.click(); + const response = await acknowledged; + assert.equal(response.status(), 200); + const result = await response.json(); + await page.waitForFunction(() => document.querySelector('[data-testid="codex-account-settings"]')?.getAttribute("aria-busy") === "false"); + assert.deepEqual((await command("inspect")).policy, result.policy, "The acknowledged policy must be durable"); + return result.policy; + } + await command("authorize-a"); + await page.waitForFunction(() => !document.querySelector('[data-testid="openai-key-settings"] input[type="checkbox"]')?.checked); + await native.getByRole("button", {name: "Check quota", exact: true}).click(); + assert.deepEqual((await configure(fallback)).slots, ["a"], "An API-only edit must retain the subscription allowlist"); + const restart = native.getByRole("checkbox", {name: "Allow automatic GPT client quit and restart", exact: true}); + assert.deepEqual((await configure(restart)).slots, ["a"], "Restart permission cannot authorize another account"); + await configure(restart); + await configure(automatic); + assert.equal(await fallback.isChecked(), true); + assert.equal(await fallback.isEnabled(), true, "API permission must remain revocable while routing is off"); + assert.deepEqual((await command("inspect")).policy.slots, ["a"]); + await panel.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "openai-api-revoke-paused-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await page.screenshot({path: resolve(outputDir, "openai-api-revoke-paused-narrow.png"), animations: "disabled"}); + assert.equal((await configure(fallback)).api_fallback, false); + assert.equal(await fallback.isEnabled(), false, "Enabling still requires automatic routing"); + assert.deepEqual((await configure(automatic)).slots, ["a"]); + await native.getByRole("button", {name: "Check quota", exact: true}).click(); + await configure(fallback); + await configure(automatic); + for (let remaining = 1; remaining >= 0; remaining--) { + await panel.locator("article").first().getByRole("button", {name: "Remove", exact: true}).click(); + await page.waitForFunction(count => document.querySelectorAll('[data-testid="openai-key-settings"] article').length === count, remaining); + } + assert.equal(await fallback.isChecked(), true); + assert.equal(await fallback.isEnabled(), true, "Removing the last key cannot prevent permission revocation"); + await page.setViewportSize({width: 1512, height: 982}); + await panel.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "openai-api-revoke-no-keys-desktop.png"), animations: "disabled"}); + const revoked = await configure(fallback); + assert.deepEqual({enabled: revoked.enabled, api_fallback: revoked.api_fallback, slots: revoked.slots}, + {enabled: false, api_fallback: false, slots: ["a"]}); + assert.equal(await fallback.isEnabled(), false); + await command("unregistered"); + const register = native.getByRole("button", {name: "Register current Codex login", exact: true}); + await register.waitFor(); + await register.click(); + await native.getByRole("alert").waitFor(); + await command("registration-ready"); + await register.click(); + await native.locator("header").getByText(/Current PRIMARY/).waitFor(); + assert.equal(await native.getByRole("checkbox", {name: "Switch automatically when quota runs out", exact: true}).isChecked(), false); + assert.equal(await panel.getByRole("checkbox", {name: "Use API after subscription exhaustion (usage based billing)", exact: true}).isChecked(), false); + // Quota reads may contend with the real observer. Permission writes must + // still acknowledge and persist; only the deliberate registration hold and + // that read-only conflict can return an error here. + assert.deepEqual(rejectedRequests.filter(request => request.operation !== "refresh"), + [{operation: "enroll-current", code: "waiting_for_idle"}]); + assert.ok(rejectedRequests.every(request => request.operation === "enroll-current" + || (request.operation === "refresh" && request.code === "native_unavailable")), JSON.stringify(rejectedRequests)); + assert.equal(workspace.errors.filter(error => error.includes("status of 409")).length, rejectedRequests.length); + assert.ok(workspace.errors.every(error => error.includes("net::ERR_FAILED") || error.includes("status of 409")), workspace.errors.join("\n")); + console.log(JSON.stringify({ok: true, boundary: "packaged UI / real HTTP, native policy and private store / synthetic process and OpenAI IO", added: 3, removed: 3, applicationWriteReplayed: false, automaticSwitches: 2, registrationRecovery: true, allowlistPreserved: true, revocationWhilePaused: true, revocationWithoutKeys: true})); +} 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 7dbbdb449a..b62d322964 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 @@ -31,6 +31,13 @@ .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); } +.openai-key-settings { border-top: 1px solid var(--pw-line); padding-top: 24px; } +.openai-key-settings header > span { margin-left: auto; } +.openai-key-settings .codex-account-name { overflow-wrap: anywhere; } +.openai-key-settings form { display: grid; gap: 16px; max-width: 480px; } +.openai-key-settings form label { display: grid; gap: 8px; } +.openai-key-settings input { min-width: 0; min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: inherit; } +.openai-add-key, .openai-key-settings form > button { justify-self: start; } @media (max-width: 640px) { .codex-account-list { grid-template-columns: 1fr; gap: 20px; } .codex-account-list dl { grid-template-columns: 1fr 1fr; width: 100%; } 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 514f3e4909..7aa8c133dd 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 @@ -4,14 +4,17 @@ import { RefreshCw, CheckCircle2, Circle, AlertCircle } from "lucide-react"; import { requestJson } from "../../data/chat"; import { useWorkspaceI18n } from "./i18n"; import { ContextHelp } from "./context-help"; +import { OpenAIKeySettings } from "./openai-key-settings"; import "./codex-account-settings.css"; type AccountStatus = { ok: boolean; available: boolean; code?: string | null; revision?: number; active_slot?: string | null; selection_revision?: string; action?: string; target?: string | null; recovery_required?: boolean; launch_failed?: boolean; + api_fallback_supported?: boolean; api_active?: boolean; + observer?: { state: string; checked_at: number | null; next_check_at: number | null }; policy?: { enabled: boolean; slots: string[]; cooldown_seconds: number; restart_clients?: boolean; - force_quit_clients?: boolean }; + force_quit_clients?: boolean; api_fallback?: boolean }; slots?: string[]; last_checked_at?: number | null; last_switch_at?: number | null; accounts?: Array<{ slot: string; availability: string; observed_at: number; primary_used: number | null; secondary_used: number | null; reset_at: number | null; @@ -35,6 +38,7 @@ type ClientInventory = z.infer; const copy = { "zh-CN": { title: "Codex 原生账号", enabled: "自动接续已启用", disabled: "自动接续已关闭", + enroll: "登记当前 Codex 登录", enrollHint: "关闭使用此登录的 Codex 客户端后登记;保留原来的模型设置和会话。", native_login_required: "请先在原生 Codex 中使用 ChatGPT 完成登录。", enable: "额度耗尽后自动切换", refresh: "检查额度", retry: "重新连接", selectionHelp: "自动选择顺序", selectionDetail: "优先选择剩余额度比例最多的可用账号;短、长周期取剩余较少的一项。平手从当前账号之后按已登记顺序选择。这里比较的是额度百分比,无法获知绝对剩余 token。当前账号可用时不会主动切换。", effect: "仅使用下方已登记账号。额度耗尽后正常退出 GPT 专用客户端,保存登录态并切换,再重新打开原来的界面。", @@ -56,7 +60,7 @@ const copy = { unknown: "额度未知", credits: "另有可用额度", help: "运行与恢复说明", last: "上次检查", detail: "检测不会调用模型、购买额度或使用重置券。401 登录失效先由原生客户端刷新;其他账号可通过官方 CLI 单独刷新。查询失败不会被判为额度耗尽。自动退出仅针对 GPT 专用实例;启用强制关闭后,正常退出失败会自动结束同一个已核验实例。其他 CLI/文件占用仍会暂停切换。独立进程完成切换和重开,即使发起切换的 Codex 已退出。切换保留模型设置和会话历史,不重放已提交工作。自动观察随 LoopX 服务运行。", extension_uninstalled: "尚未安装 Codex 账号扩展。安装 loopx-codex-provider-routing 后重新启动 LoopX 服务。", - adapter_unavailable: "未发现已安装的 Personal Apps 槽位管理器和 Codex CLI。", + adapter_unavailable: "未发现官方 Codex CLI,请安装后重试。", native_unavailable: "无法连接原生槽位管理器,请检查本机安装后重试。", launchFailed: "账号已切换,但界面未能重新打开。可以从原来的 GPT 入口打开;请勿重复切换。", failure: "操作未完成。刷新状态后可以重试。", recoveryHint: "恢复会还原未完成事务;撤销会还原最近一次切换。两者都需要客户端关闭,且不会覆盖后续登录修改。", @@ -66,10 +70,16 @@ const copy = { clientsApp: "GPT 客户端", clientsService: "后台服务", clientsOther: "外部进程", clientsProcesses: "个进程", clientsDetail: "清单记录检查时的进程归属,切换时会重新核验。支持受控重启仍需已启用退出与重开权限;外部工作负载须先核实空闲及恢复条件。检查占用不读取凭证内容、不换号、不结束进程。", manager_unverified: "尚未核实管理器,保留占用等待", + api_active: "正在使用 OpenAI API,订阅恢复后优先返回", api_disabled_active: "API 接续已关闭,等待可用订阅", + api_owner_upgrade_required: "请更新 LoopX 账号扩展以支持 API 接续", api_keys_missing: "请先添加 OpenAI API Key", api_relay_unavailable: "API 接续服务暂不可用,请重新检查", + observer_healthy: "后台观测正常", observer_retrying: "后台观测正在重试", observer_stopped: "后台观测已停止", + observer_overdue: "后台观测心跳已过期", observer_unverified: "后台观测尚未确认", observer_unavailable: "无法检查后台观测", + observer_upgrade_required: "后台观测需要升级后重启", observerCheck: "重新检查", heartbeat: "最近心跳", external_workload_unverified: "外部工作负载的空闲与恢复条件尚未核实", }, en: { title: "Native Codex accounts", enabled: "Automatic handoff enabled", disabled: "Automatic handoff off", + enroll: "Register current Codex login", enrollHint: "Close Codex clients using this login before registering. Existing model settings and history stay in place.", native_login_required: "Sign in with ChatGPT in native Codex first.", enable: "Switch automatically when quota runs out", refresh: "Check quota", retry: "Reconnect", selectionHelp: "Automatic selection order", selectionDetail: "Prefer the available account with the most remaining allowance, using the lower remaining percentage across its reported windows. Ties follow the registered order after the current account. These percentages do not establish absolute tokens left. An available current account is not preempted.", effect: "Use only the registered accounts below. After exhaustion, quit the dedicated GPT client normally, save the login, switch accounts, then reopen the same interface.", @@ -91,7 +101,7 @@ const copy = { unknown: "Quota unknown", credits: "Additional allowance available", help: "Operation and recovery", last: "Last checked", detail: "Checks do not call a model, buy credits or use reset coupons. Native Codex refreshes an expired active login; other accounts can refresh separately through the official CLI. A failed check never means exhausted quota. Automatic quit targets only the dedicated GPT instance. With force quit enabled, failed normal quit ends that same verified instance automatically. Another CLI/file owner still pauses switching. An independent process completes switching and reopening even when the calling Codex exits. Model settings and history stay intact; submitted work is never replayed. Observation runs with the LoopX service.", extension_uninstalled: "Install the optional loopx-codex-provider-routing package, then restart the LoopX service.", - adapter_unavailable: "The installed Personal Apps slot manager and Codex CLI were not found.", + adapter_unavailable: "The official Codex CLI was not found. Install it and retry.", 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.", @@ -101,6 +111,11 @@ const copy = { 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", + api_active: "Using OpenAI API; recovered subscriptions take priority", api_disabled_active: "API fallback is off; waiting for a subscription", + api_owner_upgrade_required: "Update the LoopX accounts extension for API fallback", api_keys_missing: "Add an OpenAI API key first", api_relay_unavailable: "API fallback transport unavailable; recheck", + observer_healthy: "Background observation healthy", observer_retrying: "Background observation retrying", observer_stopped: "Background observation stopped", + observer_overdue: "Background observation heartbeat overdue", observer_unverified: "Background observation unverified", observer_unavailable: "Background observation unavailable", + observer_upgrade_required: "Upgrade and restart background observation", observerCheck: "Recheck", heartbeat: "Last heartbeat", external_workload_unverified: "External workload idle and recovery conditions are unverified", }, }; @@ -163,6 +178,7 @@ export function CodexAccountSettings() { finally { setBusy(false); void readInventory(); } } const enabled = status?.policy?.enabled === true; + const authorizedSlots = status?.policy?.slots ?? []; 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); @@ -171,29 +187,42 @@ export function CodexAccountSettings() { 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
+ return <> void act("configure", { + expected_revision: status?.revision, enabled, slots: authorizedSlots, api_fallback: value, + })} />

{c.title}

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

{c.checking}

: null} {(problem && problem !== status?.action) || status?.available === false ?

{label(problem)}

: null} + {status?.available && !status.slots?.length ? <>

{c.enrollHint}

+ : null} {status?.available && status.slots?.length ? <>

{restartClients ? forceQuitClients ? c.effectForce : c.effect : c.effectWait}

+ slots: event.target.checked && !authorizedSlots.length ? status.slots : authorizedSlots })} />{c.enable}

{c.selectionDetail}

+ slots: authorizedSlots })} />{c.restart} {restartClients ? : null} + restart_clients: true, force_quit_clients: event.target.checked, slots: authorizedSlots })} />{c.forceQuit} : null}
- {status.action === "ready" || status.action === "selected" ? + {(status.action === "ready" || status.action === "selected") && (!enabled || status.observer?.state === "healthy") ? : } {label(status.recovery_required ? "recovery_required" : status.action)}
+ {enabled ?
+ {label(`observer_${status.observer?.state ?? "unverified"}`)} + {status.observer?.checked_at ? {c.heartbeat} {new Date(status.observer.checked_at * 1000).toLocaleTimeString(locale)} : null} + {status.observer?.state !== "healthy" ? : null} +
: null}
{c.clients} · {inventoryBusy ? c.clientsChecking : inventory ? holders.length ? `${holders.length} ${c.clientsCount}${held ? ` · ${held} ${c.clientsHeld}` : ""}` : c.clientsEmpty @@ -247,9 +276,9 @@ export function CodexAccountSettings() { : null} {error ?

{c.failure}

: null} {status?.code === "adapter_changed" || error ? : null} -
; +
; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/openai-key-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/openai-key-settings.tsx new file mode 100644 index 0000000000..164b992a23 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/openai-key-settings.tsx @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { z } from "zod"; +import { ChatApiError, requestJson } from "../../data/chat"; +import { ContextHelp } from "./context-help"; +import { useWorkspaceI18n } from "./i18n"; + +const statusSchema = z.object({ + ok: z.literal(true), available: z.literal(true), revision: z.number().int().nonnegative(), + balance_available: z.literal(false), + keys: z.array(z.object({ + id: z.string(), label: z.string(), masked_key: z.string(), + check_status: z.enum(["not_checked", "models_readable", "authentication_failed", "permission_denied", "rate_limited", "unavailable"]), + checked_at: z.number().nullable(), + runtime: z.object({ state: z.enum(["accepted", "quota_exhausted", "authentication_failed", "permission_denied", "rate_limited", "failed"]), + observed_at: z.number(), model: z.string(), remaining_requests: z.number().optional(), remaining_tokens: z.number().optional(), + }).nullable(), + })), +}); +type KeyStatus = z.infer; + +const copy = { + "zh-CN": { + title: "OpenAI API Key", configured: "已保存", boundary: "订阅账号优先,全部耗尽后使用 API", + help: "API Key 与额度", detail: "密钥只保存在本机,不回显完整值。启用 API 接续后,只有订阅账号均被确认耗尽才使用 API;订阅恢复后在安全边界回到订阅。API 额度或权限错误会在输出前尝试下一把 Key,保留当前模型,不重放已开始输出的请求。检查连接只读取模型列表,不能证明可消费额度。项目 Key 无法查询账单余额,费用查询需另有 Admin Key。", + auto: "订阅耗尽后使用 API(按用量计费)", autoOff: "需先启用下方的订阅自动切换", ownerMissing: "LoopX 账号扩展需要更新,暂不能启用 API 接续", + lastRequest: "上次响应", accepted: "API 已接受请求", quota_exhausted: "API 额度不足", failed: "API 请求失败", tokens: "限流窗口 Token 余量", requests: "限流窗口请求余量", + apiPermission: "API 模型权限不足", apiLimited: "API 请求限流", + balance: "API 余额不可读取", billing: "查看 OpenAI 用量", add: "添加 Key", save: "保存 Key", cancel: "取消", + name: "名称", key: "API Key", check: "检查连接", remove: "移除", reload: "刷新列表", + loading: "正在读取 Key 配置", empty: "尚未添加 Key", failure: "配置暂时无法读取,请重试。", + uncertain: "操作结果未确认,已重新读取列表。请先核对结果再操作。", + last: "上次检查", not_checked: "未检查", models_readable: "模型列表读取成功", + authentication_failed: "认证失败,请移除并更新 Key", permission_denied: "无模型列表读取权限", + rate_limited: "检查被限流,余额仍未知", unavailable: "连接检查失败,可重试", + configuration_changed: "配置已在别处更新,列表已刷新,请核对后重试。", duplicate_api_key: "这把 Key 已经保存。", + invalid_api_key: "请提供有效的 OpenAI 项目 Key,不接受 Admin Key。", invalid_key_label: "名称应为 1–80 个字符,不能包含密钥或换行。", + key_limit_reached: "最多保存 16 把 Key。", key_not_found: "这把 Key 已被移除,列表已刷新。", + }, + en: { + title: "OpenAI API keys", configured: "saved", boundary: "Subscriptions first; API only after all subscriptions are exhausted", + help: "API keys and quota", detail: "Keys stay on this machine and are never returned in full. API fallback is admitted only after every subscription is confirmed exhausted. Recovered subscriptions take priority at a safe handoff boundary. API quota or permission errors can try the next key before output, preserving the model. Requests that started output are never replayed. Connection checks only read the model list and do not establish spendable quota. Project keys cannot query billing balance; the Costs API requires a separate Admin key.", + auto: "Use API after subscription exhaustion (usage based billing)", autoOff: "Enable subscription automatic handoff below first", ownerMissing: "Update the LoopX accounts extension before enabling API fallback", + lastRequest: "Last response", accepted: "API accepted the request", quota_exhausted: "API quota exhausted", failed: "API request failed", tokens: "Rate-window tokens left", requests: "Rate-window requests left", + apiPermission: "API model permission denied", apiLimited: "API request rate limited", + balance: "API balance unavailable", billing: "View OpenAI usage", add: "Add key", save: "Save key", cancel: "Cancel", + name: "Name", key: "API key", check: "Check connection", remove: "Remove", reload: "Refresh list", + loading: "Reading key configuration", empty: "No keys added", failure: "Key configuration is unavailable. Retry the read.", + uncertain: "The operation result was not confirmed. The list was read again; inspect it before another operation.", + last: "Last checked", not_checked: "Not checked", models_readable: "Model list read successfully", + authentication_failed: "Authentication failed; remove and replace this key", permission_denied: "Model list read permission denied", + rate_limited: "Check rate limited; balance is still unknown", unavailable: "Connection check failed; retry is available", + configuration_changed: "Configuration changed elsewhere. The list was refreshed; inspect it before retrying.", duplicate_api_key: "This key is already saved.", + invalid_api_key: "Provide an OpenAI project key. Admin keys are not accepted.", invalid_key_label: "Use a name of 1–80 characters without keys or line breaks.", + key_limit_reached: "At most 16 keys can be saved.", key_not_found: "This key was removed. The list was refreshed.", + }, +}; + +export function OpenAIKeySettings({ automatic, supported, apiEnabled, onToggle, nativeBusy = false }: { + automatic: boolean; supported: boolean | null; apiEnabled: boolean; onToggle: (enabled: boolean) => void; nativeBusy?: boolean; +}) { + const { locale } = useWorkspaceI18n(); + const c = copy[locale]; + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + const [name, setName] = useState(""); + const [key, setKey] = useState(""); + const generation = useRef(0); + const endpoint = "/api/chat/codex-accounts/keys"; + const read = useCallback(async (clearError = false) => { + const request = ++generation.current; + try { + const result = statusSchema.parse(await requestJson(endpoint)); + if (request === generation.current) { + setStatus(result); + setError(previous => clearError || previous === "read" ? null : previous); + } + } catch { if (request === generation.current) {setStatus(null); setError("read");} } + }, []); + useEffect(() => { void read(); }, [read]); + useEffect(() => { + if (busy) return; + const timer = window.setInterval(() => { if (!document.hidden) void read(); }, 5000); + return () => window.clearInterval(timer); + }, [busy, read]); + + async function act(operation: string, parameters: Record) { + if (!status) return; + generation.current += 1; + setBusy(true); + setError(null); + try { + const next = statusSchema.parse(await requestJson(endpoint, { + method: "POST", body: JSON.stringify({ operation, parameters: { expected_revision: status.revision, ...parameters } }), + })); + setStatus(next); + if (operation === "keys-add") { setAdding(false); setName(""); } + } catch (failure) { + // Read back a possibly completed write; never replay a lost response. + await read(); + const code = failure instanceof ChatApiError ? failure.payload.error_code : null; + setError(typeof code === "string" && ["configuration_changed", "duplicate_api_key", "invalid_api_key", "invalid_key_label", "key_limit_reached", "key_not_found"].includes(code) ? code : "write"); + } finally { setBusy(false); } + } + + return
+

{c.title}

{status ? `${status.keys.length} ${c.configured}` : c.loading} +

{c.detail}

+

{c.boundary}

+ + {!apiEnabled && supported === false ?

{c.ownerMissing}

: !apiEnabled && !automatic ?

{c.autoOff}

: null} + {status ? <> +
+ {status.keys.map(row =>
+
{row.label}{row.masked_key}
+ {c[row.check_status]} + {row.runtime ? <>{row.runtime.state === "permission_denied" ? c.apiPermission : row.runtime.state === "rate_limited" ? c.apiLimited : c[row.runtime.state]} · {row.runtime.model} + {row.runtime.remaining_requests != null || row.runtime.remaining_tokens != null ? + {row.runtime.remaining_requests != null ? `${c.requests} ${row.runtime.remaining_requests}` : ""} + {row.runtime.remaining_tokens != null ? ` · ${c.tokens} ${row.runtime.remaining_tokens}` : ""} : null} + {c.lastRequest} {new Date(row.runtime.observed_at * 1000).toLocaleString(locale)} : null} + {row.checked_at != null ? {c.last} {new Date(row.checked_at * 1000).toLocaleString(locale)} : null} +
+ + +
+
)} +
+ {!status.keys.length ?

{c.empty}

: null} + {adding ?
{ + event.preventDefault(); + const submitted = key; + setKey(""); + void act("keys-add", { label: name, api_key: submitted }); + }}> + + +
+
+
: } +
{c.balance}{c.billing}
+ : null} + {error ? <>

{error === "write" ? c.uncertain : error === "read" ? c.failure : c[error as keyof typeof c]}

+ : null} +
; +} diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 8549eefb37..8cc1388b5b 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -23,6 +23,19 @@ LoopX Core ## Runtime Responsibilities +Python-hosted background loops share `loopx.extensions.background_runtime.HostedWorker` +for idempotent thread start, cooperative close and explicitly selected failure +retry. This is Python thread IO, not another policy or Agent supervisor. Native +account observation, manager returns/queued-source recovery and Lark private +delivery use it through their existing owners. A normal return is terminal; +retry is off unless that owner explicitly enables it. A live thread does not +establish provider health, and waiting, cooldown or disabled work is not a fault. +Keep authorization, election locks, receipts, cursors/backoff and ambiguous-write +recovery with the provider. Never use host restart to replay a model request or +discard an in-flight journal. The standalone native account wheel retains its +own process entrypoint and needs no Core import for account IO; when hosted by +Core, it delegates thread lifetime to this shared host. + The experimental [MemT Nano Turn host](../../packages/loopx-memt-turn/README.md) is a separately installed `generic-cli` execution provider. Its native memory is scoped advisory history, not canonical work state or accepted Reward Memory. diff --git a/examples/export-frontstage-share-bundle.mjs b/examples/export-frontstage-share-bundle.mjs index e1eb653f74..910fb1e736 100644 --- a/examples/export-frontstage-share-bundle.mjs +++ b/examples/export-frontstage-share-bundle.mjs @@ -77,7 +77,8 @@ function run(command, args, options = {}) { ...process.env, PATH: ["/opt/homebrew/bin", "/usr/local/bin", process.env.PATH].filter(Boolean).join(":"), }, - stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + input: options.input, + stdio: options.capture ? [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] : "inherit", }); if (result.status !== 0) { const detail = options.capture ? `${result.stderr}\n${result.stdout}`.trim() : ""; @@ -415,6 +416,20 @@ function withoutPublicSourceHrefs(text) { ); } +function credentialAssignmentsAreReferences(text, pattern) { + const values = [...text.matchAll(new RegExp(pattern.source, "gi"))] + .map(match => text.slice(match.index + match[0].length)); + // Reuse the existing credential-value owner. The public + // bundle may contain JS request fields bound to runtime input, never literals. + const script = `import json, sys +from loopx.contract import _credential_value_is_reference +print(json.dumps(all(_credential_value_is_reference(value) for value in json.load(sys.stdin)))) +`; + return JSON.parse(run(resolveTestPython(), ["-c", script], { + capture: true, input: JSON.stringify(values), + })); +} + export async function scanPublicBoundary(outDir) { const patterns = [ { label: "macOS user path", pattern: /\/Users\// }, @@ -431,6 +446,8 @@ export async function scanPublicBoundary(outDir) { const text = await readFile(path, "utf8"); for (const { label, pattern, publicSourceException } of patterns) { if (pattern.test(publicSourceException ? withoutPublicSourceHrefs(text) : text)) { + if (label === "token assignment" && /\.m?js$/i.test(path) + && credentialAssignmentsAreReferences(text, pattern)) continue; leaks.push(`${relative(outDir, path)}: ${label}`); } } diff --git a/examples/frontstage-share-bundle-smoke.mjs b/examples/frontstage-share-bundle-smoke.mjs index 8866948af3..e709862975 100644 --- a/examples/frontstage-share-bundle-smoke.mjs +++ b/examples/frontstage-share-bundle-smoke.mjs @@ -84,6 +84,19 @@ try { const probePath = resolve(boundaryProbeDir, "index.html"); await writeFile(probePath, publicCitation); await scanPublicBoundary(boundaryProbeDir); + const scriptPath = resolve(boundaryProbeDir, "credential-form.js"); + await writeFile(scriptPath, "submit({api_key:draft,auth_token:state.value})"); + await scanPublicBoundary(boundaryProbeDir); + for (const unsafeScript of [ + 'submit({api_key:"fixture-private-value"})', + 'submit({auth_token:`fixture-private-value`})', + 'submit({api_key:draft,access_token:"fixture-private-value"})', + 'submit({api_key:draft || "fixture-private-value"})', + ]) { + await writeFile(scriptPath, unsafeScript); + await assert.rejects(scanPublicBoundary(boundaryProbeDir), /Public boundary scan failed/); + } + await rm(scriptPath); for (const unsafe of [ "byte" + "dance internal workspace", 'unverified source', diff --git a/examples/pr-review-command-smoke.py b/examples/pr-review-command-smoke.py index c4f0c48f07..7c991d5b56 100644 --- a/examples/pr-review-command-smoke.py +++ b/examples/pr-review-command-smoke.py @@ -41,14 +41,24 @@ def run_cli(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [sys.executable, "-m", "loopx.cli", *args], - cwd=REPO_ROOT, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=check, - ) + # Fixture reviews must not read or depend on the operator's runtime routes. + with tempfile.TemporaryDirectory(prefix="pr-review-cli-") as temporary: + root = Path(temporary).resolve() + defaults = [] + if "--runtime-root" not in args: + defaults += ["--runtime-root", str(root / "runtime")] + if "--registry" not in args: + registry = root / "registry.json" + registry.write_text(json.dumps({"goals": []}), encoding="utf-8") + defaults += ["--registry", str(registry)] + return subprocess.run( + [sys.executable, "-m", "loopx.cli", *defaults, *args], + cwd=REPO_ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=check, + ) def assert_public_safe(payload: dict[str, object]) -> None: @@ -60,33 +70,6 @@ def assert_public_safe(payload: dict[str, object]) -> None: ) -# `--check-merge-readiness` now mandates a Goal id. Every authoritative -# invocation an agent may read has to carry it, or the canonical self-merge -# gate fails deterministically before it can record its observation. -MERGE_READINESS_GUIDANCE_PATHS = ( - REPO_ROOT / "AGENTS.md", - REPO_ROOT / "loopx" / "capabilities" / "pr_review_queue" / "README.md", - REPO_ROOT / "loopx" / "capabilities" / "pr_review_queue" / "catalog_entry.py", - PR_REVIEW_SKILL, - PR_MERGE_SKILL, -) - - -def assert_merge_readiness_invocations_require_goal_id() -> None: - for path in MERGE_READINESS_GUIDANCE_PATHS: - source = path.read_text(encoding="utf-8") - for span in re.findall(r"`[^`]*--check-merge-readiness[^`]*`", source): - assert "--goal-id" in span, ( - f"{path.name} must pass --goal-id to --check-merge-readiness: {span}" - ) - for line in source.splitlines(): - if "--check-merge-readiness" in line and "`" not in line: - assert "--goal-id" in line, ( - f"{path.name} must pass --goal-id to --check-merge-readiness: " - f"{line.strip()}" - ) - - def main() -> int: skill_source = PR_REVIEW_SKILL.read_text(encoding="utf-8") skill_text = " ".join(skill_source.split()) @@ -184,7 +167,6 @@ def main() -> int: "A merge decision without this evidence is not authorized", ): assert phrase in merge_text, phrase - assert_merge_readiness_invocations_require_goal_id() assert _github_search_date("2026-06-28T00:00:00+08:00") == "2026-06-27" assert _github_search_date("2026-06-28T00:00:00Z") == "2026-06-28" @@ -407,6 +389,16 @@ def fake_run_gh_json(args: list[str], *, cwd: Path | None = None) -> object: ], } merge_fixture_path.write_text(json.dumps(merge_fixture), encoding="utf-8") + before_standalone = sorted(str(path) for path in Path(temp_dir).rglob("*")) + standalone = json.loads(run_cli( + "--runtime-root", str(runtime_root), "--registry", str(registry_path), + "--format", "json", "pr-review", "--fixture", str(merge_fixture_path), + "--check-merge-readiness", f"4110@{merge_head}", + ).stdout) + assert standalone["ready"] is True, standalone + assert standalone["local_goal_observation_write_performed"] is False, standalone + assert "readiness_observation" not in standalone, standalone + assert sorted(str(path) for path in Path(temp_dir).rglob("*")) == before_standalone ready = json.loads( run_cli( "--runtime-root", @@ -479,6 +471,15 @@ def fake_run_gh_json(args: list[str], *, cwd: Path | None = None) -> object: "current_head_review_missing_or_invalid" in blocked["blocking_reasons"] ), blocked assert "status_checks_failed" in blocked["blocking_reasons"], blocked + standalone_blocked = run_cli( + "--runtime-root", str(runtime_root), "--registry", str(registry_path), + "--format", "json", "pr-review", "--fixture", str(merge_fixture_path), + "--check-merge-readiness", f"4110@{merge_head}", check=False, + ) + assert standalone_blocked.returncode == 1, standalone_blocked + standalone_hold = json.loads(standalone_blocked.stdout) + assert standalone_hold["blocking_reasons"] == blocked["blocking_reasons"], standalone_hold + assert standalone_hold["local_goal_observation_write_performed"] is False, standalone_hold # Merge readiness follows the typed approval verdict, not GitHub's review # state. The platform blocks self-approval, so an author-owned approval is diff --git a/loopx/capabilities/manager_context/roundtrip.py b/loopx/capabilities/manager_context/roundtrip.py index f3b08b85de..94253626e6 100644 --- a/loopx/capabilities/manager_context/roundtrip.py +++ b/loopx/capabilities/manager_context/roundtrip.py @@ -12,6 +12,8 @@ from contextlib import contextmanager, ExitStack from datetime import datetime, timezone +from ...extensions.background_runtime import HostedWorker + from . import _root, _read, _write, _hash, authority, target_authority from .tracking import _entry, _now from ...file_lock import ( @@ -588,16 +590,19 @@ class ReturnService: def __init__(self, root, registry, store, external_sender): self.args = (root, registry, store, external_sender) self.stop = threading.Event() - self.thread = threading.Thread( - target=self.run, daemon=True, name="loopx-manager-returns" - ) - self.execution_thread = threading.Thread( - target=self.run_executions, daemon=True, name="loopx-manager-executions" - ) + self._execution_pump = None + self._return_worker = HostedWorker(target=self.run, stop=self.stop, + name="loopx-manager-returns", retry_seconds=3) + self._execution_worker = HostedWorker(target=self.run_executions, stop=self.stop, + name="loopx-manager-executions", retry_seconds=3) + self.thread = self._return_worker.thread + self.execution_thread = self._execution_worker.thread def start(self) -> None: - self.thread.start() - self.execution_thread.start() + self._return_worker.start() + self._execution_worker.start() + self.thread = self._return_worker.thread + self.execution_thread = self._execution_worker.thread def run(self): try: @@ -617,7 +622,9 @@ def run_executions(self) -> None: # Native previews may wait on a subprocess. Keep result publication # independent, using the same server lifetime and one serial probe. from .execution import QueuedSourcePump - execution_pump = QueuedSourcePump(*self.args[:3]) + if self._execution_pump is None: + self._execution_pump = QueuedSourcePump(*self.args[:3]) + execution_pump = self._execution_pump while not self.stop.is_set(): try: execution_pump.tick(cancelled=self.stop.is_set) @@ -627,6 +634,5 @@ def run_executions(self) -> None: def close(self) -> None: self.stop.set() - for thread in (self.thread, self.execution_thread): - if thread.ident is not None: - thread.join(timeout=1.5) + self._return_worker.close(timeout=1.5) + self._execution_worker.close(timeout=1.5) diff --git a/loopx/capabilities/pr_review_queue/README.md b/loopx/capabilities/pr_review_queue/README.md index 63f84aeb39..0b7ef7374c 100644 --- a/loopx/capabilities/pr_review_queue/README.md +++ b/loopx/capabilities/pr_review_queue/README.md @@ -279,7 +279,7 @@ workflow or the merge-focused `loopx-pr-merge` skill. | Command | CLI reference | Intent | | --- | --- | --- | | `/loopx-pr-review` | `loopx pr-review [--repo owner/repo] [--target-exact-head NUMBER@HEAD_OID] [--state open\|merged\|all] [--review-order forward\|reverse] [--since ISO] [--fresh-audit-exact-head NUMBER@HEAD_OID]` | Review a small explicit batch with repeatable `--target-exact-head`, or list a lifecycle queue when no target is supplied. Both paths provide concrete main-regression analysis and the five-block review contract. Forward visits other authors first; reverse inverts the entire actionable queue. Saved Agent settings apply automatically. `--fresh-audit-exact-head` separately forces new evidence for an unchanged concluded head. | -| pre-merge readback | `loopx pr-review --goal-id GOAL --repo owner/repo --check-merge-readiness NUMBER@HEAD_OID` | Immediately before merge, fail closed unless the remote PR is still open at the reviewed head, its standalone conclusion approves that head, all checks are successful or skipped, review-thread pagination is complete with no unresolved thread, and merge state is compatible. The Goal-scoped command records a compact public-safe readiness observation; this read grants no merge authority. | +| pre-merge readback | `loopx pr-review [--goal-id GOAL] --repo owner/repo --check-merge-readiness NUMBER@HEAD_OID` | Immediately before merge, fail closed unless the remote PR is still open at the reviewed head, its standalone conclusion approves that head, all checks are successful or skipped, review-thread pagination is complete with no unresolved thread, and merge state is compatible. Standalone checks require no Goal and write no Goal state. Add `--goal-id GOAL` for existing Goal-managed work to retain its CI policy and record a compact public-safe observation; this read grants no merge authority. | The slash command must run the CLI first. Agentloop must not reconstruct the review window by manually calling `gh pr view` / `gh pr list` for every PR. The @@ -1263,8 +1263,9 @@ The packet should let a reviewer move through PRs in order: copied as the final risk judgement. 8. Recheck the exact head, then decide `approve`, `request changes`, `defer`, or `merge after checks`. Immediately before merge, require - `loopx pr-review --goal-id GOAL --check-merge-readiness NUMBER@HEAD_OID` to - return `ready=true`. + `loopx pr-review --check-merge-readiness NUMBER@HEAD_OID` to + return `ready=true`. Add `--goal-id GOAL` only for already Goal-managed work; + standalone delivery requires no registration or binding. A response that only lists `Open` and `Merged` PRs, scale, and recommended next order is incomplete for `/loopx-pr-review`; it should continue into the @@ -1294,10 +1295,12 @@ A first implementation is acceptable when: - `--fresh-audit-exact-head NUMBER@HEAD_OID` is the only packet-level way to turn an unchanged valid conclusion into an actionable fresh audit, and malformed, absent, or already-actionable targets fail closed; -- Goal-scoped `--goal-id GOAL --check-merge-readiness NUMBER@HEAD_OID` rejects head drift, stale review +- `--check-merge-readiness NUMBER@HEAD_OID` works without a Goal or Goal writes + and rejects head drift, stale review prose, non-approval conclusions, red/pending/unknown checks, incomplete or - unresolved review-thread evidence, and incompatible merge state, then records - the compact observation used to suppress only unchanged requalification; + unresolved review-thread evidence, and incompatible merge state; adding + `--goal-id GOAL` retains Goal policy and records the compact observation used + to suppress only unchanged requalification; - the default limit is 100, and exhaustive requests only proceed when `result_completeness.complete=true`; truncated packets provide a larger `recommended_limit` for the next read; diff --git a/loopx/capabilities/pr_review_queue/catalog_entry.py b/loopx/capabilities/pr_review_queue/catalog_entry.py index 2aecd3cbf0..b8316312da 100644 --- a/loopx/capabilities/pr_review_queue/catalog_entry.py +++ b/loopx/capabilities/pr_review_queue/catalog_entry.py @@ -33,7 +33,7 @@ ), "commands": [ { - "command": "loopx pr-review --goal-id --repo --check-merge-readiness NUMBER@HEAD_OID --format json", + "command": "loopx pr-review --repo --check-merge-readiness NUMBER@HEAD_OID --format json", "purpose": "Fail closed on exact-head, approval-body, configured CI, thread, or merge-state drift immediately before merge.", "write_boundary": "reads live public GitHub state and writes one compact public-safe Goal observation; does not approve, merge, bypass policy, or grant merge authority", }, diff --git a/loopx/cli_commands/pr_review.py b/loopx/cli_commands/pr_review.py index 3722b372db..7a36e473b8 100644 --- a/loopx/cli_commands/pr_review.py +++ b/loopx/cli_commands/pr_review.py @@ -341,9 +341,6 @@ def handle_pr_review_command( ) return 0 if payload["ok"] else 1 if args.check_merge_readiness: - if not goal_id: - raise ValueError("merge readiness requires --goal-id") - assert runtime_root is not None # the Goal configuration guard already checked this if ( args.autonomous_observation or args.observation_state_file @@ -416,25 +413,28 @@ def handle_pr_review_command( source=source, wait_for_ci=wait_for_ci, ) - observation = record_readiness_observation( - runtime_root=runtime_root, - goal_id=goal_id, - readiness=payload, - ) - payload["readiness_observation"] = { - key: observation[key] - for key in ( - "schema_version", - "goal_id", - "repository", - "exact_head", - "material_fingerprint", - "ready", - "blocking_reasons", - "observed_at", + payload["local_goal_observation_write_performed"] = False + if goal_id: + assert runtime_root is not None # checked with the Goal configuration above + observation = record_readiness_observation( + runtime_root=runtime_root, + goal_id=goal_id, + readiness=payload, ) - } - payload["local_goal_observation_write_performed"] = True + payload["readiness_observation"] = { + key: observation[key] + for key in ( + "schema_version", + "goal_id", + "repository", + "exact_head", + "material_fingerprint", + "ready", + "blocking_reasons", + "observed_at", + ) + } + payload["local_goal_observation_write_performed"] = True print_payload( payload, output_format(args), diff --git a/loopx/contract.py b/loopx/contract.py index 117841bbb2..259655804f 100644 --- a/loopx/contract.py +++ b/loopx/contract.py @@ -256,6 +256,14 @@ def _credential_match_is_reference(line: str, match: re.Match[str]) -> bool: value = value[keyword.end() :].lstrip(" \t\"'`") if last_keyword is None: return bool(CREDENTIAL_DOCUMENTED_EXAMPLE_PATTERN.fullmatch(match.group(0))) + return _credential_value_is_reference( + value, bearer=CREDENTIAL_BEARER_KEYWORD_PATTERN.fullmatch(last_keyword) is not None, + ) + + +def _credential_value_is_reference(value: str, *, bearer: bool = False) -> bool: + """Classify a credential field value for source and distribution scanners.""" + value = value.lstrip(" \t\"'`") if not value: return True if CREDENTIAL_ENVIRONMENT_REFERENCE_PATTERN.match(value): @@ -268,7 +276,7 @@ def _credential_match_is_reference(line: str, match: re.Match[str]) -> bool: return True if CREDENTIAL_HIGH_ENTROPY_VALUE_PATTERN.match(value): return False - if CREDENTIAL_BEARER_KEYWORD_PATTERN.fullmatch(last_keyword): + if bearer: return True return bool(CREDENTIAL_VARIABLE_REFERENCE_PATTERN.match(value)) diff --git a/loopx/extensions/background_runtime.py b/loopx/extensions/background_runtime.py new file mode 100644 index 0000000000..29fe638fb2 --- /dev/null +++ b/loopx/extensions/background_runtime.py @@ -0,0 +1,59 @@ +"""Python worker hosting only; providers retain permission and effect ownership. + +A live thread is not proof of provider health. Retry is explicit and applies to +the supplied host loop, never to an individual external/model request. +""" +from __future__ import annotations + +from collections.abc import Callable +import logging +import math +import threading + + +class HostedWorker: + def __init__(self, *, name: str, target: Callable[[], None], + stop: threading.Event, retry_seconds: float | None = None): + if retry_seconds is not None and (not math.isfinite(retry_seconds) or retry_seconds <= 0): + raise ValueError("Worker retry delay must be positive and finite") + self.name, self.target = name, target + self.stop, self.retry_seconds = stop, retry_seconds + self._lock = threading.Lock() + self._terminal = False + self.thread = self._new_thread() + + def _new_thread(self) -> threading.Thread: + return threading.Thread(target=self._run, daemon=True, name=self.name) + + def start(self): + with self._lock: + if self.stop.is_set() or self._terminal or self.thread.is_alive(): + return self + if self.thread.ident is not None: + self.thread = self._new_thread() + self.thread.start() + return self + + def _run(self) -> None: + while not self.stop.is_set(): + try: + self.target() + # Normal completion, including a one-shot discovery, is not a + # failure and must not be automatically replayed. + with self._lock: + self._terminal = True + return + except Exception: + logging.getLogger(__name__).warning("Background worker failed; provider details withheld") + if self.retry_seconds is None: + with self._lock: + self._terminal = True + return + if self.stop.wait(self.retry_seconds): + return + + def close(self, *, timeout: float = 2) -> None: + self.stop.set() + thread = self.thread + if thread.ident is not None and thread is not threading.current_thread(): + thread.join(timeout=timeout) diff --git a/loopx/extensions/lark/goal_topic_runtime_service.py b/loopx/extensions/lark/goal_topic_runtime_service.py index 88e5153efc..aa83896ef4 100644 --- a/loopx/extensions/lark/goal_topic_runtime_service.py +++ b/loopx/extensions/lark/goal_topic_runtime_service.py @@ -15,6 +15,7 @@ from ...chat_manager import MANAGER_AGENT_OBJECTIVE from ...file_lock import try_exclusive_file_lock +from ..background_runtime import HostedWorker from .goal_channel_contracts import bindings_for_goal from .manager_context import session_turn_effect from .team_plan_confirmation import ( @@ -59,6 +60,7 @@ def __init__( self._delivery_wakeup = threading.Event() self._startup_thread: threading.Thread | None = None self._delivery_thread: threading.Thread | None = None + self._delivery_worker: HostedWorker | None = None def start(self) -> None: """Discover existing bindings without blocking the HTTP readiness path.""" @@ -73,8 +75,10 @@ def start(self) -> None: ) self._startup_thread.start() if self.private_conversations is not None: - self._delivery_thread = threading.Thread(target=self._reconcile_private, name="loopx-lark-private-delivery", daemon=True) - self._delivery_thread.start() + self._delivery_worker = HostedWorker(target=self._reconcile_private, stop=self._closed, + name="loopx-lark-private-delivery", retry_seconds=5) + self._delivery_worker.start() + self._delivery_thread = self._delivery_worker.thread def _reconcile_private(self) -> None: # Keep slow provider writes/readbacks off the scan loop. At most four @@ -559,8 +563,8 @@ def close(self) -> None: stop.set() for _stop, thread, _fingerprint in workers: thread.join(timeout=3) - if self._delivery_thread is not None: - self._delivery_thread.join(timeout=3) + if self._delivery_worker is not None: + self._delivery_worker.close(timeout=3) __all__ = ["LarkGoalTopicRuntimeService"] diff --git a/loopx/presentation/codex_accounts_api.py b/loopx/presentation/codex_accounts_api.py index 15a8cbdfaa..eff991952e 100644 --- a/loopx/presentation/codex_accounts_api.py +++ b/loopx/presentation/codex_accounts_api.py @@ -10,6 +10,8 @@ import threading from typing import Any, Protocol, cast +from ..extensions.background_runtime import HostedWorker + CODEX_ACCOUNTS_PATH = "/api/chat/codex-accounts" @@ -18,42 +20,67 @@ def __init__(self, runtime_root: Path): self.provider: Any = None self.watcher: Any = None self.mutex = threading.Lock() + self.closed = False self.error: str | None = "extension_uninstalled" try: provider_module = importlib.import_module("loopx_codex_provider_routing.native") except ImportError: return self.provider = provider_module.NativeAccounts(runtime_root) - self.watcher = provider_module.Watcher(self.provider) + self.watcher = self._new_observer(provider_module) self.error = None # Rehydrate an explicitly enabled observer after an App restart. The # absent/default-off path neither creates state nor reads native auth. try: - if self.provider.path.exists() and self.provider._load()["policy"]["enabled"]: - self.watcher.start() + self._reconcile_observer() except Exception: self.error = "state_unavailable" + def _new_observer(self, module): + watcher = module.Watcher(self.provider) + host = getattr(watcher, "hosted_by", None) + # Already-installed older optional providers retain their existing + # entrypoint until upgrade; no credential or decision owner is copied. + return host(HostedWorker) if callable(host) else watcher + + def _reconcile_observer(self) -> None: + if self.closed: + return + if self.provider.path.exists() and self.provider._load()["policy"]["enabled"]: + if self.watcher.stop.is_set(): + module = importlib.import_module("loopx_codex_provider_routing.native") + self.watcher = self._new_observer(module) + self.watcher.start() + else: + self.watcher.close() + def call(self, operation: str, parameters: dict[str, Any]) -> dict[str, Any]: if self.provider is None: return {"ok": True, "available": False, "code": self.error} try: - if operation == "status": + if operation.startswith("keys-"): + if operation not in {"keys-status", "keys-add", "keys-remove", "keys-check"}: + raise ValueError("Unsupported key operation") + value = getattr(self.provider.api_keys(), operation.removeprefix("keys-"))(**parameters) + elif operation == "status": + with self.mutex: + self._reconcile_observer() value = self.provider.status() elif operation == "clients": value = self.provider.clients() + elif operation == "enroll-current": + value = self.provider.enroll_current(**parameters) elif operation == "configure": with self.mutex: if parameters.get("enabled") is False: self.watcher.stop.set() - value = self.provider.configure(**parameters) - if value["policy"]["enabled"]: - if self.watcher.stop.is_set(): - module = importlib.import_module("loopx_codex_provider_routing.native") - self.watcher = module.Watcher(self.provider) - self.watcher.start() - else: - self.watcher.close() + try: + value = self.provider.configure(**parameters) + finally: + # A stale/failed disable did not revoke the saved opt-in. + # Restore that policy's observer rather than leaving a + # stopped worker behind an enabled switch. + self._reconcile_observer() elif operation == "refresh": value = self.provider.tick(refresh=True) elif operation == "switch": @@ -72,12 +99,14 @@ def call(self, operation: str, parameters: dict[str, Any]) -> dict[str, Any]: code = getattr(error, "code", "native_unavailable") cached = {} try: - cached = self.provider._projection(self.provider._load()) + cached = (self.provider.api_keys().status() if operation.startswith("keys-") + else self.provider._projection(self.provider._load())) except Exception: pass return {**cached, "ok": False, "available": True, "code": code, "error_code": code} def close(self) -> None: + self.closed = True if self.watcher: self.watcher.close() @@ -97,6 +126,25 @@ def _codex_accounts_read(self) -> None: def _codex_account_clients(self) -> None: cast(_HTTP, self)._send_json(self.server.codex_account_service.call("clients", {})) + def _codex_api_keys_read(self) -> None: + cast(_HTTP, self)._send_json(self.server.codex_account_service.call("keys-status", {})) + + def _codex_api_keys_write(self) -> None: + http = cast(_HTTP, self) + try: + body = http._read_json() + fields = {"keys-add": {"expected_revision", "label", "api_key"}, + "keys-remove": {"expected_revision", "key_id"}, + "keys-check": {"expected_revision", "key_id"}} + if (set(body) != {"operation", "parameters"} or not isinstance(body["operation"], str) + or body["operation"] not in fields or not isinstance(body["parameters"], dict) + or set(body["parameters"]) != fields[body["operation"]]): + raise ValueError("Invalid key operation") + result = self.server.codex_account_service.call(body["operation"], body["parameters"]) + except (TypeError, ValueError, KeyError): + result = {"ok": False, "code": "invalid_request"} + http._send_json(result, status=200 if result["ok"] else 409) + def _codex_accounts_write(self) -> None: http = cast(_HTTP, self) try: @@ -104,7 +152,8 @@ def _codex_accounts_write(self) -> None: if set(body) != {"operation", "parameters"} or not isinstance(body["parameters"], dict): raise ValueError("Invalid account operation") operation, parameters = body["operation"], body["parameters"] - fields = {"configure": {"expected_revision", "enabled", "slots", "reconnect", "restart_clients", "force_quit_clients"}, + fields = {"configure": {"expected_revision", "enabled", "slots", "reconnect", "restart_clients", "force_quit_clients", "api_fallback"}, + "enroll-current": {"expected_revision", "expected_selection"}, "switch": {"manual_slot", "expected_selection", "operation_id"}, "recover": {"expected_revision", "expected_selection"}, "rollback": {"expected_revision", "expected_selection"}, "reopen": {"expected_selection"}, "refresh": set()} diff --git a/loopx/presentation/configuration_api.py b/loopx/presentation/configuration_api.py index 3551e7016e..16fb61f1eb 100644 --- a/loopx/presentation/configuration_api.py +++ b/loopx/presentation/configuration_api.py @@ -47,6 +47,7 @@ def _configuration_get_routes(self) -> dict[str, Callable[[], None]]: 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, + f"{accounts_api.CODEX_ACCOUNTS_PATH}/keys": self._codex_api_keys_read, 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, @@ -68,6 +69,7 @@ def _configuration_post_routes(self) -> dict[str, Callable[[], None]]: return { media_api.CONTENT_MEDIA_PATH: self._content_media_request, accounts_api.CODEX_ACCOUNTS_PATH: self._codex_accounts_write, + f"{accounts_api.CODEX_ACCOUNTS_PATH}/keys": self._codex_api_keys_write, context_api.DECISION_CONTEXT_PATH: self._decision_context_request, attached_api.CODEX_ATTACHED_PATH: self._codex_attached_send, attached_api.CODEX_ATTACHED_OPEN_PATH: self._codex_attached_open, diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 752e2ee537..9231c32e33 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -2,8 +2,10 @@ The optional `loopx-codex-provider-routing` provider replaces the recommended CPA path with native slots. It has no new Core capability id. Credentials and lifecycle -stay in this package; Core composes the App transport. Python adapts existing -private file/process owners; `native_policy.ts` owns availability and selection. +stay in this package; Core composes the App transport. Its packaged Python owner +handles credential/file/process IO; `native_policy.ts` owns availability and +selection. No dotfiles checkout, Personal Apps command or external Python module +is required. ## The user task @@ -15,6 +17,81 @@ selects/reopens when the native owner admits it. Enable automatic handoff and The page reads back accounts, windows, waiting cause and recovery. Tokens and identity claims stay private. +### OpenAI API fallback + +Version 0.13.0 adds **OpenAI API keys** on the same Model provider page. Add a +named project key, read its masked suffix, explicitly check connection, or remove +it. Keys remain in a separate private mode-0600 file under the same runtime owner; +browser/CLI responses never return their complete values. Adding a key alone does +not enable an observer, change the native login or authorize paid model calls. + +Enable subscription automatic handoff, then **Use API after subscription exhaustion +(usage based billing)**. API fallback is a separate, default-off permission. +Every authorized subscription must have fresh measured exhaustion before the +native policy selects API. A healthy subscription always ranks first; an unknown +or failed subscription check cannot opt a request into paid use. While API is +active, a recovered subscription wins at the existing idle/restart boundary and +switch cooldown. Model settings and history stay with the same GPT route. +API and client-restart permission edits retain the existing subscription +allowlist. API permission can be revoked while automatic routing is off or the +last key has been removed; enable prerequisites do not prevent revocation. + +The packaged native account owner supports `API_KEY_SLOTS_VERSION = 1`. It enrolls +one loopback relay credential and uses the same launch/account locks, idle checks, +credential/config transaction, readback and rollback as subscription switching. +Real OpenAI keys stay in this extension, outside the native account vault. The +dotfiles adapter imports this owner; it contains no second credential writer. +The native owner uses the official `openai_base_url` override on the built-in +`openai` provider and removes that override on subscription return. The provider +identity stays stable for existing conversations; other custom providers remain +outside this owner. + +A local authenticated transport sends the original selected model and Responses +body to OpenAI. An explicit upstream quota rejection, 401, 403 or temporary 429 can +try the next configured key before output. Each key is attempted at most once per +request, with typed cooldowns; model permission failures are scoped to that model. +Unknown network effects, ordinary request/server failures and any started stream +are not retried on another key. No job replay, model substitution or synthetic +completion is added. There are no background paid probes. The transport survives +the Chat/App restart used for native handoff; disabling stops new admissions and +drains already accepted requests before its process exits. With the policy off, +an existing subscription is never routed through this transport. + +**Check connection** only requests the official +[`GET /v1/models`](https://developers.openai.com/api/reference/resources/models/methods/list). +It makes no inference call and does not establish spendable quota. Its 401, 403, +429 and network states remain distinct. A models permission denial does not prove +inference is unavailable. The App shows per-key actual request outcomes, model, +last observation time and the last reported rate-window requests/tokens left when +OpenAI supplies those headers. These are observations of a rate window, not a +monthly allowance or account balance. OpenAI's +[`Costs` endpoint](https://developers.openai.com/api/reference/resources/admin/subresources/organization/subresources/usage/methods/costs) +requires an Admin API key; project keys do not expose an authoritative billing +balance. The page retains **API balance unavailable** and links to OpenAI usage. + +The same private store is available through `loopx-codex-accounts keys-status`, +`keys-add`, `keys-remove` and `keys-check`. Mutations accept JSON on stdin with +`expected_revision` from `keys-status`. `keys-add` takes `label` and `api_key`; +`keys-remove` and `keys-check` take the returned `key_id`. Never pass a key as a +command-line argument. Native `configure` accepts `api_fallback: true` alongside +its existing policy fields. A stale revision refuses a mutation; re-read before +retrying. Removing a saved key does not revoke it at OpenAI. Disable API fallback +to stop new API requests; select an available subscription through the existing +native handoff controls. The current native recovery/rollback owner retains any +incomplete credential transaction. + +0.13.0 在同一设置中提供 Key 配置、脱敏回读、只读检查和默认关闭的 API 接续开关。 +订阅始终优先:所有授权订阅均被新鲜观测确认为耗尽后才进入 API;订阅恢复后沿用原有 +空闲、重启、冷却和恢复边界返回。扩展内置唯一原生账号 owner,dotfiles 只导入并映射 +旧配置;LoopX 不依赖 dotfiles。API 上游 Key 留在扩展私有存储,原生槽位只保存本地 +中转凭据,不绕过原事务或复制会话历史。 +Key 轮换依据输出前收到的明确额度、认证、权限或限流错误,每次请求每把 Key 最多 +尝试一次;网络效果不明、普通请求错误和已开始输出的流不会重放,不替换模型,也不做 +后台付费探测。关闭后停止新请求并等待已接收请求结束。App 显示实际请求结果和上次 +限流窗口余量;项目 Key 的账单余额保持未知,连接检查成功也不代表还有可消费额度。 +API 及重启权限的编辑保留原订阅授权范围;关闭自动接续或移除最后一把 Key 后,仍可 +撤销已授予的 API 权限。 + **An open native Codex client is an occupied owner, even between turns.** With restart permission, the provider requests a normal AppKit quit of only the inspected GPT instance, waits for exit and native idle, saves/switches the login, @@ -39,12 +116,37 @@ python -m pip install ./packages/loopx-codex-provider-routing loopx-codex-accounts status ``` -The native adapter requires POSIX, the installed **Personal Apps** GPT slot owner -(`personal-apps` with adjacent `personal_apps.py` and `personal_accounts.py`), its -registered native GPT route, and the official `codex` CLI. Switching is macOS-only -in that owner. This package adapts that installation; it ships no second account -vault or launcher. Other hosts show unavailable. Node 22.18+ or 24+ runs the -TypeScript selector. Enrol accounts through the native owner's official login. +The native adapter requires POSIX and the official `codex` CLI; App switching and +controlled client restart require the macOS Codex App. Other hosts show +unavailable for those effects. Node 22.18+ or 24+ runs the TypeScript selector. +The standalone wheel includes the account vault and App IO; it does not require +LoopX Core to import or use its native CLI. + +For a new installation, sign in with ChatGPT in native Codex, close clients using +that login, then choose **Register current Codex login** on the settings page. +Registration retains model settings and history and leaves automatic switching +off. The CLI equivalent is `enroll-current`, taking `expected_revision` and +`expected_selection` from `status` as JSON on stdin. The default profile reads +`CODEX_HOME` (otherwise `~/.codex`) and `CODEX_ELECTRON_USER_DATA_PATH` (otherwise +`~/.loopx/frontends/codex`); the vault lives in +`~/.loopx/codex-native-accounts/vault`. + +When trusted version-1 `~/.config/personal/apps.json` GPT metadata already exists, +the package reuses that home/frontend, the existing +`~/.local/state/personal-apps/gpt-accounts` vault and its original locks in place. +It does not copy credentials or history. An upgrade changes the pinned owner, so +use **Reconnect** to inspect and accept that owner while retaining the previous +explicit policy permissions. The updated dotfiles commands import the installed +package and use those same files and locks. LoopX never imports dotfiles. The +installed-wheel regression runs without that command, repository or modules: + +```sh +uv run --extra test python -m pytest tests/presentation/test_native_accounts_install.py +``` + +新安装无需 dotfiles:先在原生 Codex 登录,关闭使用该登录的客户端,再从设置页登记 +当前登录。登记后自动切换仍关闭。已有个人配置原地复用旧 vault 和锁,不复制凭据、 +SQLite 或会话;升级后通过「重新连接」接受新的扩展 owner。dotfiles 是该扩展的调用方。 Click **Switch automatically when quota runs out** to authorize the listed slots as destinations. Installation alone grants no account effect. The App observer @@ -131,6 +233,27 @@ directory to rediscover that owner. Prefer the serving App/Chat observer so normal LoopX updates also refresh it; retire any separately installed legacy watcher through its original manager. +The enabled switch is durable intent, not a health claim. The settings page and +native status report the active observer's private, content-free heartbeat, +ownership lock and loaded provider build. A missing, overdue or older-build +heartbeat stays visible even when the last quota read succeeded. Quota readiness +and observer health are separate. Status reads never refresh their own heartbeat. +Only the lock holder writes it; another App or CLI observer remains a contender. + +The existing supervised Chat service owns persistence; no second account daemon +or duplicate policy is installed. It rehydrates enabled observation on restart +and upgrade. Tick failures retry in the owner loop, dead threads can restart, +and a rejected configuration edit reconciles the observer with the persisted +policy. A stale disable cannot leave `enabled=true` with observation stopped. +Disabling and service shutdown still fence new effects; an already dispatched +transaction keeps its normal completion/recovery contract. An unresponsive +owner remains a visible hold, never permission to steal its lock or replay work. + +开关表示持久授权,不等于服务健康。设置页分别展示额度状态和后台观测心跳;缺失、 +过期及旧版本观测保持可见,读取状态不会给自己刷新心跳。复用既有受监督的 Chat 服务, +重启或升级后恢复已授权观测;检查失败自动重试,线程退出可恢复。配置冲突等失败不会 +让「仍开启」的策略静默停掉观测。关闭或服务退出仍阻止新效果,不抢占未释放的 owner。 + 0.12.11 在退出 GPT App 前检查无法受控恢复的占用;原 `other_clients_busy` 暂停会 自动重新检查,阻塞解除后无需手动换号。仅有数字 FD、只读且类型明确为目录的句柄 不视为认证消费者;认证文件、写句柄、未知类型及缓存登录的 Codex 进程仍阻塞。 diff --git a/packages/loopx-codex-provider-routing/README.md b/packages/loopx-codex-provider-routing/README.md index ac92369661..c22974c407 100644 --- a/packages/loopx-codex-provider-routing/README.md +++ b/packages/loopx-codex-provider-routing/README.md @@ -2,7 +2,7 @@ This optional extension integrates **native Codex account slots** with LoopX. It observes allowance, selects an authorized available account after exhaustion, -and asks the installed native slot owner to switch at an idle boundary. Codex +and uses its packaged native account owner to switch at an idle boundary. Codex keeps its official OpenAI login, model settings and history. CPA is no longer the recommended provider path. @@ -19,10 +19,11 @@ This package is an **extension** that supplies a standalone integration provider. It is not a built-in capability: - Codex App owns task settings, turn snapshots and the session store; -- the installed Personal Apps slot manager owns credential transactions, - idle admission, login-cache preservation and rollback; -- the optional provider owns allowance observation and its default-off policy; +- this optional package owns credential transactions, idle admission, login-cache + preservation, rollback, allowance observation and its default-off policy; its TypeScript selector is shared by CLI and App; +- existing Personal Apps/dotfiles routes import the packaged owner and retain + their original vault and locks; LoopX has no dependency on that repository; - LoopX Core supplies optional lifecycle and owner-local presentation transport, without becoming a credential or model proxy. diff --git a/packages/loopx-codex-provider-routing/extension.toml b/packages/loopx-codex-provider-routing/extension.toml index 638f1656e2..0c23d2da8b 100644 --- a/packages/loopx-codex-provider-routing/extension.toml +++ b/packages/loopx-codex-provider-routing/extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-codex-provider-routing" -version = "0.12.11" +version = "0.13.0" requires_loopx_api = ">=1,<2" permissions = [] diff --git a/packages/loopx-codex-provider-routing/pyproject.toml b/packages/loopx-codex-provider-routing/pyproject.toml index 55406ab9bf..09776eb447 100644 --- a/packages/loopx-codex-provider-routing/pyproject.toml +++ b/packages/loopx-codex-provider-routing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loopx-codex-provider-routing" -version = "0.12.11" +version = "0.13.0" description = "Native Codex account slots and exhausted-account handoff for LoopX" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs b/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs index ad21b9d9a0..9b886cf95f 100644 --- a/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs +++ b/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs @@ -1,8 +1,27 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { account, decide } from "../src/loopx_codex_provider_routing/native_policy.ts"; +import { account, decide, observerHealth } from "../src/loopx_codex_provider_routing/native_policy.ts"; const now = 1000; +test("observer health requires a held owner and a fresh matching build heartbeat", () => { + const healthy = {now, owner_locked: true, build: "current", + heartbeat: {checked_at: now, deadline_at: now + 75, next_check_at: now + 60, + build: "current", phase: "waiting"}}; + assert.equal(observerHealth(healthy).state, "healthy"); + assert.equal(observerHealth({...healthy, owner_locked: false}).state, "stopped"); + assert.equal(observerHealth({...healthy, owner_locked: null}).state, "unavailable"); + assert.equal(observerHealth({...healthy, heartbeat: null}).state, "unverified"); + assert.equal(observerHealth({...healthy, now: now + 76}).state, "overdue"); + assert.equal(observerHealth({...healthy, heartbeat: {...healthy.heartbeat, build: "old"}}).state, "upgrade_required"); + assert.equal(observerHealth({...healthy, heartbeat: {...healthy.heartbeat, phase: "retrying"}}).state, "retrying"); + assert.equal(observerHealth({...healthy, heartbeat: {...healthy.heartbeat, checked_at: now + 6}}).state, "unverified"); + assert.equal(observerHealth({...healthy, heartbeat: {...healthy.heartbeat, deadline_at: Infinity}}).state, "unverified"); + const corrupt = observerHealth({...healthy, heartbeat: {...healthy.heartbeat, + checked_at: "private marker detail", next_check_at: {private: "detail"}}}); + assert.equal(corrupt.state, "unverified"); + assert.equal(corrupt.checked_at, null); + assert.equal(corrupt.next_check_at, null); +}); const sample = (slot, used = 10, extra = {}) => ({ slot, observed_at: now, status: "ok", usage: { rate_limit: { primary_window: { used_percent: used, reset_at: 2000 }, secondary_window: { used_percent: 20, reset_at: 9000 } }, credits: { has_credits: false, balance: "0" }, ...extra, @@ -103,3 +122,15 @@ test("normal client restart needs its own permission and the quota proof expires assert.equal(decide(input({ policy: allowed, manual_slot: "b" })).valid_until, null, "An explicit sign-in choice is not an automatic quota decision"); }); + +test("API is opt-in and strictly after every measured subscription; recovery returns to subscriptions", () => { + const api = {slot: "openai-api", ready: true}; + const enabled = {enabled: true, slots: ["a", "b", "c"], cooldown_seconds: 300, api_fallback: true}; + const exhausted = [sample("a", 100), sample("b", 100), sample("c", 100)]; + assert.equal(decide(input({policy: enabled, api})).target, "b", "API never outranks an available subscription"); + assert.equal(decide(input({policy: enabled, api, observations: exhausted})).target, api.slot); + assert.equal(decide(input({api, observations: exhausted})).target, null, "Absent opt-in preserves subscription-only behavior"); + assert.equal(decide(input({policy: enabled, api, observations: [sample("a", 100), sample("b", 100), {...sample("c", 100), observed_at: now - 121}]})).target, null); + assert.equal(decide(input({policy: enabled, api, active: api.slot, observations: exhausted})).action, "api_active"); + assert.equal(decide(input({policy: enabled, api, active: api.slot, observations: [sample("a", 80), sample("b", 20), sample("c", 100)]})).target, "b"); +}); diff --git a/packages/loopx-codex-provider-routing/smoke/native_host_clients_smoke.py b/packages/loopx-codex-provider-routing/smoke/native_host_clients_smoke.py index b8548239f5..1e09c02e63 100644 --- a/packages/loopx-codex-provider-routing/smoke/native_host_clients_smoke.py +++ b/packages/loopx-codex-provider-routing/smoke/native_host_clients_smoke.py @@ -65,7 +65,8 @@ def close(self, _expected, **kwargs): del self.records[100] return True - def open_app(self, *_): + def open_app(self, *_, spec): + assert spec is self.host.spec, "Reopen must retain the inspected native profile" self.app_pid = 400 def idle(self): diff --git a/packages/loopx-codex-provider-routing/smoke/openai-policy.test.mjs b/packages/loopx-codex-provider-routing/smoke/openai-policy.test.mjs new file mode 100644 index 0000000000..fe707c82d5 --- /dev/null +++ b/packages/loopx-codex-provider-routing/smoke/openai-policy.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {apiKeys, apiOutcome} from "../src/loopx_codex_provider_routing/openai_policy.ts"; + +test("known quota rejection rotates a key; unknown effects and ordinary request failures do not", () => { + const input={now:1000,status:429,code:"insufficient_quota",retry_after:null,model:"selected-model"}; + assert.equal(apiOutcome(input).state,"quota_exhausted"); + assert.equal(apiOutcome(input).retry_next_key,true); + for(const status of [400,408,500,502,503]) assert.equal(apiOutcome({...input,status}).retry_next_key,false); + assert.equal(apiOutcome({...input,status:200}).state,"accepted","HTTP success is not completed inference"); + assert.equal(apiOutcome({...input,code:"rate_limit_exceeded",retry_after:60}).blocked_until,1060); + assert.equal(apiOutcome({...input,code:"rate_limit_exceeded",retry_after:7200}).blocked_until,8200,"Never shorten an upstream minimum wait"); +}); +test("cooldowns and model-scoped permission failures retain key order without making a balance claim", () => { + const keys=[{id:"a",blocked_until:1100},{id:"b",blocked_until:1100,blocked_model:"other-model"},{id:"c"}]; + assert.deepEqual(apiKeys({now:1000,model:"selected-model",keys}),["b","c"]); + assert.deepEqual(apiKeys({now:1100,model:"selected-model",keys}),["a","b","c"]); +}); diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py index e8886a6460..aefe126ed3 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.12.11" +__version__ = "0.13.0" diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py index 681ed36fc0..cb32b3ca2e 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py @@ -7,10 +7,12 @@ import argparse from contextlib import contextmanager +import hashlib import json import os from pathlib import Path import shutil +import stat import subprocess import sys import tempfile @@ -21,15 +23,20 @@ from .native_host import HostError, discover SCHEMA = "codex_native_accounts_v1" +OBSERVER_SCHEMA = "codex_native_observer_v1" +# Capture the loaded build once: replacing files cannot make an older process +# claim the new observer implementation without actually restarting it. +OBSERVER_BUILD = hashlib.sha256(b"".join(Path(__file__).with_name(name).read_bytes() + for name in ("native.py", "native_policy.ts", "native_policy_bridge.ts"))).hexdigest() -def private_read(path: Path) -> dict: +def private_read(path: Path, *, schema: str = SCHEMA) -> dict: if any(p.is_symlink() for p in (path, *path.parents)): raise HostError("state_untrusted") if path.stat().st_mode & 0o077 or path.stat().st_uid != os.getuid(): raise HostError("state_untrusted") value = json.loads(path.read_text()) - if value.get("schema_version") != SCHEMA: + if value.get("schema_version") != schema: raise HostError("state_incompatible") return value @@ -117,6 +124,11 @@ def __init__(self, runtime_root: Path, *, transport=host, clock=time.time): self.transport, self.clock = transport, clock self.switch_allowed = lambda: True + def api_keys(self): + # Configuration works without discovering or touching a native login. + from .openai_keys import OpenAIKeys + return OpenAIKeys(self) + def _load(self) -> dict: if self.path.exists(): return private_read(self.path) @@ -155,7 +167,10 @@ def _projection(self, state: dict, native: dict | None = None) -> dict: native = native or state.get("native", {}) return {"schema_version": SCHEMA, "available": True, "revision": state["revision"], "policy": state["policy"], "slots": native.get("slots", []), + "observer": self.observer_health(state["policy"]["enabled"]), "active_slot": native.get("active_slot"), + "api_fallback_supported": native.get("api_fallback_supported", False), + "api_active": native.get("api_slot") is not None and native.get("active_slot") == native.get("api_slot"), "selection_revision": native.get("selection_revision"), "login_matches": native.get("current_login_matches"), "recovery_required": native.get("recovery_required", False) or state.get("action") == "handoff_interrupted", @@ -167,6 +182,35 @@ def _projection(self, state: dict, native: dict | None = None) -> dict: "client_running": native.get("client_running"), "automatic_hot_swap": False} + def observer_health(self, enabled: bool) -> dict: + if not enabled: + return {"state": "disabled", "checked_at": None, "next_check_at": None} + locked = None + heartbeat = None + try: + import fcntl + path = self.root / "observer.lock" + if any(p.is_symlink() for p in (path, *path.parents)): + raise HostError("state_untrusted") + if not path.exists(): + locked = False + else: + with open(path, "rb", opener=lambda p, flags: os.open(p, flags | os.O_NOFOLLOW | os.O_NONBLOCK)) as stream: + info = os.fstat(stream.fileno()) + if info.st_uid != os.getuid() or info.st_mode & 0o077 or not stat.S_ISREG(info.st_mode): + raise HostError("state_untrusted") + try: + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + locked = False + except BlockingIOError: + locked = True + if (self.root / "observer.json").exists(): + heartbeat = private_read(self.root / "observer.json", schema=OBSERVER_SCHEMA) + return policy({"operation": "observer-health", "now": time.time(), + "owner_locked": locked, "heartbeat": heartbeat, "build": OBSERVER_BUILD}) + except (HostError, OSError, ValueError, TypeError, AttributeError, ImportError, subprocess.SubprocessError): + return {"state": "unavailable", "checked_at": None, "next_check_at": None} + def status(self) -> dict: state = self._load() adapter = state.get("adapter") or discover() @@ -177,14 +221,29 @@ def clients(self) -> dict: state = self._load() return self.transport(state.get("adapter") or discover(), "clients") + def enroll_current(self, *, expected_revision: int, expected_selection: str) -> dict: + with self._lock(): + state = self._load() + if state["revision"] != expected_revision: + raise HostError("configuration_changed") + adapter = state.get("adapter") or discover() + native = self.transport(adapter, "enroll_current", expected_revision=expected_selection) + state.update(adapter=adapter, native=native, revision=state["revision"] + 1, + policy={**state["policy"], "slots": native["slots"], "enabled": False}, action="disabled") + atomic(self.path, state) + return self._projection(state) + def configure(self, *, expected_revision: int, enabled: bool, slots: list[str], reconnect: bool = False, - restart_clients: bool | None = None, force_quit_clients: bool | None = None) -> dict: + restart_clients: bool | None = None, force_quit_clients: bool | None = None, + api_fallback: bool | None = None) -> dict: if type(enabled) is not bool or not isinstance(slots, list) or len(slots) > 16 or len(slots) != len(set(slots)): raise HostError("invalid_policy") if restart_clients is not None and type(restart_clients) is not bool: raise HostError("invalid_policy") if force_quit_clients is not None and type(force_quit_clients) is not bool: raise HostError("invalid_policy") + if api_fallback is not None and type(api_fallback) is not bool: + raise HostError("invalid_policy") # Stop requests fence new effects at the Watcher first, then persist # after an already dispatched atomic switch finishes. Other mutations # retain their immediate conflict behavior. @@ -196,7 +255,8 @@ def configure(self, *, expected_revision: int, enabled: bool, slots: list[str], if pending.get("handoff_receipt") and enabled: raise HostError("handoff_pending") # Disabling works offline and never needs credentials or a client. - if enabled or reconnect: + use_api = api_fallback if api_fallback is not None else state["policy"].get("api_fallback", False) + if enabled or reconnect or api_fallback is True: adapter = discover() if reconnect or "adapter" not in state else state["adapter"] native = self.transport(adapter, "status") if reconnect and not slots and not enabled: @@ -204,17 +264,34 @@ def configure(self, *, expected_revision: int, enabled: bool, slots: list[str], if not slots or not set(slots) <= set(native["slots"]): raise HostError("invalid_slots") state.update(adapter=adapter, native=native) + if enabled and use_api: + self._validate_api(native) state.update(policy={"enabled": enabled, "slots": slots, "cooldown_seconds": 300, "restart_clients": restart_clients if restart_clients is not None else state["policy"].get("restart_clients", False), "force_quit_clients": force_quit_clients if force_quit_clients is not None - else state["policy"].get("force_quit_clients", False)}, + else state["policy"].get("force_quit_clients", False), + **({"api_fallback": use_api} if api_fallback is not None or "api_fallback" in state["policy"] else {})}, revision=state["revision"] + 1, retry_at=None, code=None, intent=pending if pending.get("handoff_receipt") else None, action="checking" if enabled else "disabled", failure_count=0) atomic(self.path, state) return self._projection(state) + def _validate_api(self, native: dict) -> None: + if native.get("api_fallback_supported") is not True: + raise HostError("api_owner_upgrade_required") + if not self.api_keys().status()["keys"]: + raise HostError("api_keys_missing") + + def _prepare_api(self, state: dict, native: dict) -> dict: + self._validate_api(native) + from .openai_relay import ensure + endpoint = ensure(self) + if native.get("api_slot") is None: + return self.transport(state["adapter"], "enroll_api", **endpoint) + return native + def tick(self, *, refresh: bool = False, manual_slot: str | None = None, expected_selection: str | None = None, operation_id: str | None = None) -> dict: if manual_slot is not None and (not expected_selection or not isinstance(operation_id, str) or not 1 <= len(operation_id) <= 128): @@ -298,7 +375,13 @@ def tick(self, *, refresh: bool = False, manual_slot: str | None = None, state["failure_count"] = 0 # Inspect the active account even when it is not an authorized # destination. Candidates stay strictly inside the allowlist. + api = None + if state["policy"].get("api_fallback") or native.get("api_slot"): + ready = bool(state["policy"].get("api_fallback") and native.get("api_fallback_supported") + and self.api_keys().status()["keys"]) + api = {"slot": native.get("api_slot") or "openai-api", "ready": ready} slots = list(dict.fromkeys([native["active_slot"], *state["policy"]["slots"]])) + slots = [slot for slot in slots if slot != native.get("api_slot")] failures = {} if refresh or manual_slot else { row["slot"]: {"revision": row["credential_revision"], "observed_at": row["observed_at"]} for row in state.get("observations", []) @@ -311,6 +394,7 @@ def tick(self, *, refresh: bool = False, manual_slot: str | None = None, decision = policy({"now": self.clock(), "policy": state["policy"], "active": native["active_slot"], "observations": public_observations, "last_switch_at": state["last_switch_at"], "recovery_required": False, "login_matches": True, + **({"api": api} if api else {}), **({"manual_slot": manual_slot} if manual_slot else {})}) state.update(observations=observed["observations"], accounts=decision["accounts"], last_checked_at=self.clock(), action=decision["action"], target=decision["target"], @@ -318,6 +402,9 @@ def tick(self, *, refresh: bool = False, manual_slot: str | None = None, if decision["action"] == "switch": if not manual_slot and not self.switch_allowed(): raise HostError("observer_stopped") + if api and decision["target"] == api["slot"]: + native = self._prepare_api(state, native) + state["native"] = native # Durable before the effect, with source selection and target. # Native owner rechecks the same fence inside its transaction. state["intent"] = {"id": operation_id or uuid.uuid4().hex, @@ -348,11 +435,13 @@ def tick(self, *, refresh: bool = False, manual_slot: str | None = None, target=None, retry_at=self.clock() + 300, launch_failed=result["launch_failed"], failure_count=0) if operation_id: state["last_operation_id"] = operation_id + if decision["action"] == "api_active" and api and api["ready"]: + self._prepare_api(state, native) except (HostError, OSError, ValueError, subprocess.SubprocessError) as error: code = error.code if isinstance(error, HostError) else "native_unavailable" # Unrelated/remaining owners still require a safe-boundary wait. # Other repeated failures stop automatic attempts until Refresh. - failures = state.get("failure_count", 0) + (code not in ("waiting_for_idle", "other_clients_busy", "handoff_pending")) + failures = state.get("failure_count", 0) + (code not in ("waiting_for_idle", "other_clients_busy", "handoff_pending", "api_relay_unavailable")) if code in ("client_quit_refused", "client_quit_timeout"): failures = 3 # Do not repeatedly close/reopen a refused client. state.update(action="waiting_for_idle" if code == "other_clients_busy" else code if code in ("waiting_for_idle", "selection_changed", "adapter_changed", "client_quit_refused", "client_quit_timeout", "client_changed", "handoff_pending") else "unavailable", @@ -407,12 +496,27 @@ def __init__(self, provider: NativeAccounts): self.provider = provider self.stop = threading.Event() self.thread: threading.Thread | None = None + self.start_lock = threading.Lock() + self._hosted = None self.provider.switch_allowed = lambda: not self.stop.is_set() + def hosted_by(self, runner_factory): + """Let Core host the loop without making standalone account IO depend on it.""" + if self.thread is not None: + raise HostError("observer_already_started") + self._hosted = runner_factory(name="codex-account-observer", target=self.run, + stop=self.stop, retry_seconds=60) + return self + def start(self): - if self.thread is None: - self.thread = threading.Thread(target=self.run, daemon=True, name="codex-account-observer") - self.thread.start() + if self._hosted is not None: + self._hosted.start() + self.thread = self._hosted.thread + return self + with self.start_lock: + if not self.stop.is_set() and (self.thread is None or not self.thread.is_alive()): + self.thread = threading.Thread(target=self.run, daemon=True, name="codex-account-observer") + self.thread.start() return self def run(self): @@ -426,21 +530,39 @@ def run(self): self._observe() except BlockingIOError: self.stop.wait(5) - except (HostError, OSError, ValueError): + except Exception: + # Supervise the observer itself as well as individual ticks. + # A held owner remains authoritative; contenders never publish + # a heartbeat or replace another observer's credentials. self.stop.wait(60) def _observe(self): while not self.stop.is_set(): delay = 60.0 try: + self._heartbeat("checking", deadline=480) value = self.provider.tick() due = value.get("retry_at") delay = max(5, min(300, due - time.time())) if due else 60 - except (HostError, OSError, ValueError, subprocess.SubprocessError): + self._heartbeat("waiting", deadline=delay + 15, next_check=time.time() + delay) + except Exception: + # A provider/programming failure must not silently kill the + # thread while the durable policy still promises observation. + # Keep exceptions private and retry under the same owner lock. + self._heartbeat("retrying", deadline=75, next_check=time.time() + 60) delay = 60 self.stop.wait(delay) + def _heartbeat(self, phase: str, *, deadline: float, next_check: float | None = None): + now = time.time() + atomic(self.provider.root / "observer.json", {"schema_version": OBSERVER_SCHEMA, + "build": OBSERVER_BUILD, "checked_at": now, "deadline_at": now + deadline, + "next_check_at": next_check, "phase": phase}) + def close(self): + if self._hosted is not None: + self._hosted.close(timeout=2) + return self.stop.set() if self.thread: self.thread.join(timeout=2) @@ -448,7 +570,8 @@ def close(self): def main() -> int: parser = argparse.ArgumentParser(prog="loopx-codex-accounts") - parser.add_argument("operation", choices=("status", "clients", "configure", "refresh", "switch", "recover", "rollback", "reopen", "watch")) + parser.add_argument("operation", choices=("status", "clients", "configure", "refresh", "switch", "recover", "rollback", "reopen", "watch", "enroll-current", + "keys-status", "keys-add", "keys-remove", "keys-check")) parser.add_argument("--runtime-root", type=Path, default=Path.home() / ".loopx") args = parser.parse_args() provider = NativeAccounts(args.runtime_root) @@ -460,11 +583,16 @@ def main() -> int: except KeyboardInterrupt: watcher.close() return 0 - request = {} if args.operation in ("status", "clients", "refresh") else json.load(sys.stdin) - if args.operation == "status": + request = {} if args.operation in ("status", "clients", "refresh", "keys-status") else json.load(sys.stdin) + if args.operation.startswith("keys-"): + operation = args.operation.removeprefix("keys-") + result = getattr(provider.api_keys(), operation)(**request) + elif args.operation == "status": result = provider.status() elif args.operation == "clients": result = provider.clients() + elif args.operation == "enroll-current": + result = provider.enroll_current(**request) elif args.operation == "configure": result = provider.configure(**request) elif args.operation in ("refresh", "switch"): diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py new file mode 100644 index 0000000000..fe85dafc82 --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_account_store.py @@ -0,0 +1,441 @@ +"""Packaged native Codex account vault, transactions and recovery. + +This is the single credential writer used by the optional native provider. +Legacy vaults retain their original binding and locks; no history is migrated. +""" +import base64 +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import tomllib +import uuid + +API_KEY_SLOTS_VERSION = 1 + + +def api_endpoint(value): + if not isinstance(value, str) or not re.fullmatch(r'http://127\.0\.0\.1:[0-9]+/v1', value): + raise ValueError('API fallback requires an explicit loopback Responses endpoint') + port = int(value.split(':')[2].split('/')[0]) + if not 1024 <= port <= 65535: + raise ValueError('API fallback requires an unprivileged loopback port') + return value + + +def auth_kind(raw): + obj = json.loads(raw) + return 'api' if obj.get('auth_mode') == 'apikey' else 'subscription' + + +def checked(path): + path = Path(path) + if any(p.is_symlink() for p in (path, *path.parents)): + raise ValueError('Account paths must not contain symlinks') + return path + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def private_dir(path): + checked(path) + missing = [] + current = path + while not current.exists(): + missing.append(current) + current = current.parent + path.mkdir(mode=0o700, parents=True, exist_ok=True) + for created in missing: + os.chmod(created, 0o700) + os.chmod(path, 0o700) + + +def atomic(path, data): + checked(path) + private_dir(path.parent) + fd, name = tempfile.mkstemp(prefix='.account-', dir=path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(name): + os.unlink(name) + + +def encoded(value): + return (json.dumps(value, indent=2) + '\n').encode() + + +@contextlib.contextmanager +def credential_lock(home, shared=False, *, lock_root=None): + folder = checked(Path(lock_root) if lock_root is not None else Path(home) / '.loopx/codex-native-accounts') + private_dir(folder) + path = checked(folder / 'account.lock') + with open(path, 'a', opener=lambda p, flags: os.open(p, flags, 0o600)) as stream: + fcntl.flock(stream, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB) + yield stream + + +def read_private(path): + path = checked(path) + if not path.is_file() or path.stat().st_mode & 0o077: + raise ValueError('Account cache/state must be a regular private file (chmod 600 locally)') + return path.read_bytes() + + +def identity(raw): + """Metadata comparison only; official Codex owns verification and refresh.""" + try: + obj = json.loads(raw) + if obj.get('auth_mode') == 'apikey': + key = obj.get('OPENAI_API_KEY') + if (not isinstance(key, str) or not key.startswith('sk-loopx-relay-') + or not 32 <= len(key) <= 512 or any(c.isspace() for c in key) or obj.get('tokens')): + raise ValueError() + endpoint = api_endpoint(obj.get('loopx_api_base_url')) + return digest(encoded(['apikey', key, endpoint])) + tokens = obj['tokens'] + if obj.get('auth_mode') not in (None, 'chatgpt') or obj.get('OPENAI_API_KEY'): + raise ValueError() + if any(not isinstance(tokens.get(k), str) or not tokens[k] + for k in ('id_token', 'access_token', 'refresh_token')): + raise ValueError() + parts = tokens['id_token'].split('.') + if len(parts) != 3: + raise ValueError() + claims = json.loads(base64.urlsafe_b64decode(parts[1] + '=' * (-len(parts[1]) % 4))) + subject = claims.get('sub') + if not isinstance(subject, str) or not subject: + raise ValueError() + account = tokens.get('account_id') or '' + if not isinstance(account, str): + raise ValueError() + return digest(encoded([subject, account])) + except (ValueError, KeyError, TypeError, AttributeError, UnicodeError): + raise ValueError('Invalid native account cache; inspect the local login or relay configuration') from None + + +def file_config(raw, auth=None): + try: + text = raw.decode() + cfg = tomllib.loads(text) + except (ValueError, UnicodeError): + raise ValueError('Invalid GPT config; repair locally without printing its contents') from None + # Preserve unused custom definitions; they do not change the native route. + if (cfg.get('model_provider', 'openai') != 'openai' + or 'openai' in (cfg.get('model_providers') or {}) + or cfg.get('model_catalog_json') or cfg.get('profiles') or cfg.get('profile')): + raise ValueError('Account switching requires an ordinary native GPT config') + if cfg.get('cli_auth_credentials_store') not in (None, 'auto', 'file'): + raise ValueError('Explicit keyring/ephemeral storage is preserved; use native sign-in or review file storage locally') + if cfg.get('forced_login_method') not in (None, 'chatgpt', 'api'): + raise ValueError('Unsupported account login method') + mode = 'api' if auth is not None and auth_kind(auth) == 'api' else 'chatgpt' + endpoint = None + if mode == 'api': + identity(auth) + endpoint = api_endpoint(json.loads(auth)['loopx_api_base_url']) + # Change auth settings and the owned API endpoint; preserve model and other config. + section = re.search(r'^\s*\[', text, re.M) + head, tail = (text[:section.start()], text[section.start():]) if section else (text, '') + settings = [('cli_auth_credentials_store', 'file'), ('forced_login_method', mode)] + if mode == 'api': + if cfg.get('openai_base_url') and cfg.get('forced_login_method') != 'api': + raise ValueError('An existing OpenAI base URL must not be replaced by API fallback') + if cfg.get('openai_base_url'): + api_endpoint(cfg['openai_base_url']) + settings.append(('openai_base_url', endpoint)) + elif cfg.get('forced_login_method') == 'api' and cfg.get('openai_base_url'): + api_endpoint(cfg['openai_base_url']) + pattern = r'^openai_base_url\s*=.*(?:\n|$)' + if len(re.findall(pattern, head, re.M)) != 1: + raise ValueError('Nonstandard API base URL syntax requires local review') + head = re.sub(pattern, '', head, flags=re.M) + for key, value in settings: + pattern = rf'^{key}\s*=.*$' + if key in cfg: + if len(re.findall(pattern, head, re.M)) != 1: + raise ValueError('Nonstandard auth setting syntax requires local review') + head = re.sub(pattern, f'{key} = "{value}"', head, flags=re.M) + else: + head = f'{key} = "{value}"\n' + head + return (head + tail).encode() + + +class Accounts: + def __init__(self, home, spec, idle, login_cli, env): + self.home, self.spec, self.idle = Path(home), spec, idle + self.login_cli, self.env = login_cli, env + self.root = checked(Path(spec.get('account_store', self.home / '.loopx/codex-native-accounts/vault'))) + self.lock_root = checked(Path(spec.get('lock_root', self.home / '.loopx/codex-native-accounts'))) + self.binding = {'home': str(spec['home']), 'frontend': str(spec['frontend'])} + self.config = checked(spec['home'] / 'config.toml') + self.auth = checked(spec['home'] / 'auth.json') + self.active = checked(self.root / 'active.json') + self.pending = checked(self.root / 'pending.json') + self.binding_file = checked(self.root / 'binding.json') + if self.binding_file.exists(): + try: + matches = json.loads(read_private(self.binding_file)) == self.binding + except ValueError: + matches = False + if not matches: + raise ValueError('Account vault belongs to a different GPT route; inspect local settings') + if self.active.exists(): + self.state() # A changed route must not silently reuse another route's vault. + + def slot(self, name): + if not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,23}', name): + raise ValueError('Account slots use lowercase letters, digits and hyphens') + return checked(self.root / 'slots' / name / 'auth.json') + + def state(self): + if not self.active.exists(): + return None + try: + obj = json.loads(read_private(self.active)) + self.slot(obj['slot']) + if obj['binding'] != self.binding or not re.fullmatch(r'[a-f0-9]{64}', obj['identity']): + raise ValueError() + return obj + except (KeyError, TypeError, ValueError): + raise ValueError('Account selection is invalid or belongs to a different GPT route') from None + + @contextlib.contextmanager + def lock(self, *, enrollment=False): + # Same lock as App launch: cooperating launch/switch processes cannot race. + folder = checked(self.lock_root) + private_dir(folder) + path = checked(folder / 'launch.lock') + with open(path, 'a', opener=lambda p, flags: os.open(p, flags, 0o600)) as stream: + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + with credential_lock(self.home, shared=enrollment, lock_root=self.lock_root): + private_dir(self.root) + yield + + def selected(self): + state = self.state() + if state: + raw = read_private(self.auth) + if identity(raw) != state['identity']: + raise ValueError('GPT login changed outside the slot manager; inspect before overwriting any account') + return state, raw + if self.auth.exists(): + raise ValueError('Register the current GPT login first: codex app enroll a --current') + return None, None + + def targets(self, key): + if key in ('auth', 'config', 'active', 'binding'): + return checked({'auth': self.auth, 'config': self.config, 'active': self.active, + 'binding': self.binding_file}[key]) + if isinstance(key, str) and key.startswith('slot:'): + return self.slot(key[5:]) + raise ValueError('Invalid account transaction target') + + def restore(self, journal): + """Validate every target and snapshot before restoring any of them.""" + try: + if (type(journal['version']) is not int or journal['version'] != 1 + or journal['binding'] != self.binding + or not re.fullmatch(r'[a-f0-9]{32}', journal['id']) + or not isinstance(journal['files'], list)): + raise ValueError() + backup = checked(self.root / 'backups' / journal['id']) + plan, seen = [], set() + for index, row in enumerate(journal['files']): + if (not isinstance(row['existed'], bool) + or not re.fullmatch(r'[a-f0-9]{64}', row['after_hash'])): + raise ValueError() + path = self.targets(row['key']) + if path in seen: + raise ValueError() + seen.add(path) + old = read_private(backup / str(index)) if row['existed'] else None + if (digest(old) if old is not None else None) != row['before_hash']: + raise ValueError() + current = digest(path.read_bytes()) if path.exists() else None + if current not in (row['before_hash'], row['after_hash']): + raise ValueError() + plan.append((path, old)) + if not plan: + raise ValueError() + except (KeyError, TypeError, ValueError): + raise ValueError('Recovery rejected changed files or an invalid account snapshot; inspect locally') from None + for path, old in reversed(plan): + if old is None: + path.unlink(missing_ok=True) + else: + atomic(path, old) + + def transaction(self, updates, operation): + if self.pending.exists(): + raise ValueError('Interrupted account operation; run codex app recover with GPT closed') + if not self.binding_file.exists(): + updates = [('binding', encoded(self.binding)), *updates] + ident = uuid.uuid4().hex + backup = checked(self.root / 'backups' / ident) + private_dir(backup) + rows = [] + for index, (key, raw) in enumerate(updates): + path = self.targets(key) + existed = path.exists() + old = path.read_bytes() if existed else None + if existed: + atomic(backup / str(index), old) + rows.append({'key': key, 'existed': existed, + 'before_hash': digest(old) if existed else None, 'after_hash': digest(raw)}) + journal = {'version': 1, 'id': ident, 'binding': self.binding, 'operation': operation, 'files': rows} + atomic(backup / 'manifest.json', encoded(journal)) + atomic(self.pending, encoded(journal)) + try: + self.idle() # Recheck immediately before the first mutation. + for key, raw in updates: + path = self.targets(key) + atomic(path, raw) + if path.read_bytes() != raw: + raise ValueError('Account writeback verification failed') + atomic(self.root / 'last.json', encoded(journal)) + except BaseException: + self.restore(journal) + self.pending.unlink() + raise + self.pending.unlink() + return {'operation': operation, 'backup': str(backup), 'history_untouched': True} + + def status(self): + names = [] + kinds = {} + folder = checked(self.root / 'slots') + if folder.exists(): + for child in sorted(folder.iterdir()): + identity(read_private(self.slot(child.name))) + names.append(child.name) + kinds[child.name] = auth_kind(read_private(self.slot(child.name))) + state = self.state() + matches = None + if state and self.auth.exists(): + matches = identity(read_private(self.auth)) == state['identity'] + return {'slots': names, 'slot_kinds': kinds, 'active_slot': state['slot'] if state else None, + 'current_login_matches': matches, 'recovery_required': self.pending.exists(), + 'home': str(self.spec['home']), 'frontend': str(self.spec['frontend']), + 'live_authentication_verified': False} + + def enroll_api(self, name, *, api_key, base_url): + """Enroll an explicit local relay credential without selecting it.""" + raw = encoded({'auth_mode': 'apikey', 'OPENAI_API_KEY': api_key, + 'loopx_api_base_url': api_endpoint(base_url)}) + ident = identity(raw) + target = self.slot(name) + with self.lock(enrollment=True): + if self.pending.exists(): + raise ValueError('Recover an interrupted operation before enrollment') + if target.exists(): + if read_private(target) == raw: + return {'slot': name, 'activated': False, 'credentials_printed': False} + raise ValueError('Existing slots are never overwritten') + if any(identity(read_private(self.slot(other))) == ident for other in self.status()['slots']): + raise ValueError('That credential is already registered') + if not self.binding_file.exists(): + atomic(self.binding_file, encoded(self.binding)) + atomic(target, raw) + return {'slot': name, 'activated': False, 'credentials_printed': False} + + def enroll(self, name, current=False): + target = self.slot(name) + if current: + self.idle() + # Staged login never reads or changes the active credentials. Share the + # CLI lease, but serialize slot creation and switching with launch.lock. + with self.lock(enrollment=not current): + if self.pending.exists() or target.exists(): + raise ValueError('Recover any interrupted operation first; existing slots are never overwritten') + if current: + converted = file_config(self.config.read_bytes() if self.config.exists() else b'') + raw = read_private(self.auth) + ident = identity(raw) + state = self.state() + if state and state['identity'] != ident: + raise ValueError('The selected account changed externally; inspect before enrollment') + else: + private_dir(self.root / 'login-staging') + with tempfile.TemporaryDirectory(dir=self.root / 'login-staging', prefix='login-') as temp: + staging = Path(temp) + atomic(staging / 'config.toml', b'cli_auth_credentials_store="file"\nforced_login_method="chatgpt"\n') + command = [self.login_cli(), 'login'] + if subprocess.run(command, env=self.env(dict(self.spec, home=staging))).returncode: + raise ValueError('Official login did not complete; the active GPT account is unchanged') + raw = read_private(staging / 'auth.json') + ident = identity(raw) + existing = self.status()['slots'] + if any(identity(read_private(self.slot(other))) == ident for other in existing): + raise ValueError('That ChatGPT account is already registered; select its existing slot') + if current: + updates = [('slot:' + name, raw), ('config', converted), + ('active', encoded({'slot': name, 'identity': ident, 'binding': self.binding}))] + result = self.transaction(updates, 'enroll-current') + else: + # Only an immutable new slot is published. Do not create a live + # recovery journal or replace the last credential-switch backup. + if not self.binding_file.exists(): + atomic(self.binding_file, encoded(self.binding)) + atomic(target, raw) + result = {'operation': 'enroll', 'history_untouched': True} + return dict(result, slot=name, activated=current, credentials_printed=False) + + def switch(self, name, dry_run=False): + target = self.slot(name) + self.idle() + if self.pending.exists(): + raise ValueError('Interrupted account operation; run codex app recover with GPT closed') + raw = read_private(target) + ident = identity(raw) + converted = file_config(self.config.read_bytes() if self.config.exists() else b'', raw) + state, outgoing = self.selected() + if dry_run: + return {'preview': True, 'slot': name, 'home': str(self.spec['home']), + 'frontend': str(self.spec['frontend']), 'history_untouched': True} + with self.lock(): + self.idle() + # Reread under the lock: retain refresh-token rotation from the last use. + state, outgoing = self.selected() + raw = outgoing if state and state['slot'] == name else read_private(target) + ident = identity(raw) + converted = file_config(self.config.read_bytes() if self.config.exists() else b'', raw) + updates = [('slot:' + state['slot'], outgoing)] if state else [] + updates += [('config', converted), ('auth', raw), + ('active', encoded({'slot': name, 'identity': ident, 'binding': self.binding}))] + return dict(self.transaction(updates, 'switch'), slot=name, + home=str(self.spec['home']), frontend=str(self.spec['frontend'])) + + def recover(self, rollback=False): + self.idle() + with self.lock(): + path = self.root / ('last.json' if rollback else 'pending.json') + if rollback and self.pending.exists(): + raise ValueError('Recover the interrupted operation before rolling back a completed operation') + if not path.exists(): + raise ValueError('No matching account recovery snapshot') + try: + journal = json.loads(read_private(path)) + except ValueError: + raise ValueError('Invalid private recovery journal') from None + self.idle() + self.restore(journal) + path.unlink() + return {'action': 'rolled-back' if rollback else 'recovered', 'history_untouched': True} diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_app.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_app.py new file mode 100644 index 0000000000..41a380bb82 --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_app.py @@ -0,0 +1,262 @@ +"""Packaged macOS Codex profile and App lifecycle IO. + +Only native Codex is supported. Optional legacy metadata is read as a profile +reference; no external Personal Apps executable or Python module is imported. +""" +from __future__ import annotations + +import fcntl +import json +import os +from pathlib import Path +import plistlib +import re +import subprocess +import tomllib + +from . import native_account_store as accounts + +def managed_path(raw, home): + path = Path(str(raw).replace('~/', str(home) + '/', 1)).absolute() + for part in (path, *path.parents): + if part.is_symlink(): + raise ValueError('App state paths must not contain symlinks') + return path + + +def route(home, role="gpt"): + if role != "gpt": + raise ValueError("Only the native Codex profile is supported") + home = Path(home) + metadata = managed_path(home / ".config/personal/apps.json", home) + if metadata.exists(): + info = metadata.stat() + if info.st_uid not in (0, os.getuid()) or info.st_mode & 0o022: + raise ValueError("Native profile metadata is not trusted") + value = json.loads(metadata.read_text()) + if value.get("version") != 1: + raise ValueError("Unsupported native profile metadata") + native = value["apps"]["gpt"] + if set(native) - {"app", "home", "frontend"}: + raise ValueError("Unsupported native profile field") + spec = {key: managed_path(native[key], home) for key in ("home", "frontend")} + spec["app"] = native.get("app", "") + spec["legacy_profile"] = True + spec["lock_root"] = home / ".local/state/personal-apps" + spec["account_store"] = spec["lock_root"] / "gpt-accounts" + else: + spec = {"home": managed_path(os.environ.get("CODEX_HOME") or home / ".codex", home), + "frontend": managed_path(os.environ.get("CODEX_ELECTRON_USER_DATA_PATH") or home / ".loopx/frontends/codex", home), + "app": "", "lock_root": home / ".loopx/codex-native-accounts", + "account_store": home / ".loopx/codex-native-accounts/vault", "legacy_profile": False} + for key in ("home", "frontend", "lock_root", "account_store"): + path = managed_path(spec[key], home) + if not path.is_relative_to(home) or path == home: + raise ValueError("Native profile state must stay inside its user home") + spec[key] = path + if spec["home"] == spec["frontend"] or spec["home"] in spec["frontend"].parents or spec["frontend"] in spec["home"].parents: + raise ValueError("Codex home and frontend must remain separate") + return spec + + +def app_bundle(spec, role, home): + if spec['app']: + path = managed_path(spec['app'], home) + if path.is_dir() and path.suffix == '.app': + return path + raise ValueError('Configured App bundle is missing') + for root in (Path('/Applications'), home / 'Applications'): + for name in (('ChatGPT.app', 'Codex.app') if spec.get('legacy_profile') else ('Codex.app', 'ChatGPT.app')): + if (root / name).is_dir(): + return root / name + raise ValueError('Install the native Codex App before using client restart') + + +def executable(bundle): + info = plistlib.loads((bundle / 'Contents/Info.plist').read_bytes()) + name = info.get('CFBundleExecutable', '') + if not name or '/' in name or name in ('.', '..'): + raise ValueError('Invalid App executable metadata') + path = bundle / 'Contents/MacOS' / name + if not path.is_file() or not os.access(path, os.X_OK): + raise ValueError('App executable is missing or not executable') + return path + + +def clean_env(spec, home=None): + env = {k: v for k, v in os.environ.items() + if not k.startswith(('CODEX_', 'OPENAI_', 'DEEPSEEK_', 'ELECTRON_', 'DYLD_')) + and k not in ('NODE_OPTIONS', 'DISABLE_AUTO_UPDATE')} + env['CODEX_HOME'] = str(spec['home']) + # Finder does not read zprofile. Keep the caller's active environment first, + # then add the same installed fallbacks used by the public shell. + home = Path.home() if home is None else Path(home) + candidates = [home / '.local/bin', home / '.cargo/bin', home / '.npm-global/bin', home / '.mybin', + Path('/opt/homebrew/bin'), Path('/opt/homebrew/sbin'), Path('/usr/local/bin'), + Path('/usr/local/sbin'), Path('/opt/homebrew/opt/node@24/bin'), + Path('/usr/local/opt/node@24/bin'), Path('/opt/homebrew/opt/python@3.12/libexec/bin'), + Path('/usr/local/opt/python@3.12/libexec/bin')] + paths = [p for p in env.get('PATH', '/usr/bin:/bin:/usr/sbin:/sbin').split(os.pathsep) if p] + for path in candidates: + if path.is_dir() and str(path) not in paths: + paths.append(str(path)) + env['PATH'] = os.pathsep.join(paths) + return env + + +def owners(exe, frontend, ps_text): + """Exact frontend ownership, including a Finder root identified by its child.""" + rows = {} + for line in ps_text.splitlines(): + fields = line.strip().split(maxsplit=2) + if len(fields) == 3 and fields[0].isdigit() and fields[1].isdigit(): + rows[int(fields[0])] = (int(fields[1]), fields[2]) + marker = re.compile(re.escape('--user-data-dir=' + str(frontend)) + r'(?=$|\s--|\s*$)') + roots = {pid for pid, (_, cmd) in rows.items() + if re.match(r'^/.*?/Contents/MacOS/\S+(?=\s|$)', cmd) and ' --type=' not in cmd} + found = set() + for pid, (_, cmd) in rows.items(): + if not marker.search(cmd): + continue + current, seen = pid, set() + while current in rows and current not in seen: + if current in roots: + command = rows[current][1] + if not (command == str(exe) or command.startswith(str(exe) + ' ')): + raise ValueError('Frontend is owned by a different App executable') + found.add(current) + break + seen.add(current) + current = rows[current][0] + # A frontend owned by a different App build must not launch a second writer. + if current not in roots: + raise ValueError('Frontend owner could not be identified; inspect before opening') + if len(found) > 1: + raise ValueError('Multiple App processes own this frontend; inspect before opening') + return next(iter(found), None) + + +FOCUS = '''ObjC.import("AppKit"); +function run(argv) { + var app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(Number(argv[0])); + if (!app || app.isTerminated) throw Error("App terminated"); + var target = $.NSAppleEventDescriptor.descriptorWithProcessIdentifier(Number(argv[0])); + var event = $.NSAppleEventDescriptor.appleEventWithEventClassEventIDTargetDescriptorReturnIDTransactionID( + 0x61657674, 0x72617070, target, -1, 0); + var error = Ref(); event.sendEventWithOptionsTimeoutError(1, 5, error); + if (ObjC.unwrap(error[0]) || !app.activateWithOptions(3)) throw Error("Could not focus App"); +}''' + + +def check_provider(spec, role="gpt"): + if role != "gpt": + raise ValueError("Only native Codex is supported") + path = spec["home"] / "config.toml" + cfg = tomllib.loads(path.read_text()) if path.exists() else {} + if (cfg.get("model_provider", "openai") != "openai" or cfg.get("model_catalog_json") + or "openai" in (cfg.get("model_providers") or {})): + raise ValueError("Account switching requires the built-in OpenAI provider") + if cfg.get("forced_login_method") == "api": + accounts.api_endpoint(cfg.get("openai_base_url")) + + +def account_preflight(home, spec): + manager = accounts.Accounts(home, spec, None, None, None) + if manager.pending.exists(): + raise ValueError('Interrupted account operation; run codex app recover with GPT closed') + if manager.state(): + manager.selected() # Compare identity only; refreshed tokens may differ. + + +def account_idle(home, spec, read_only_owners=()): + """Never kill clients; explicit service exceptions must remain read-only.""" + exe = executable(app_bundle(spec, 'gpt', home)) + if running(exe, spec): + raise ValueError('Quit the GPT App normally before switching; DS can remain open') + if not spec['home'].exists(): + return + result = subprocess.run(['/usr/sbin/lsof', '-Fpcfa', '+d', str(spec['home'])], + capture_output=True, text=True, timeout=10) + if result.returncode not in (0, 1): + raise ValueError('Cannot verify GPT home ownership; no account was changed') + owners, owner, descriptor = [], None, None + for line in result.stdout.splitlines(): + if line.startswith('p') and line[1:].isdigit(): + owner = {'pid': int(line[1:]), 'command': 'unknown', 'files': []} + owners.append(owner) + descriptor = None + elif line.startswith('c') and owner is not None: + owner['command'] = line[1:] + elif line.startswith('f') and owner is not None: + # lsof marks some descriptors with a leading '*' (duplicate handles); + # keep the numeric identity so read-only owners can still be allowed. + descriptor = {'fd': line[1:].lstrip('*'), 'access': None} + owner['files'].append(descriptor) + elif line.startswith('a') and descriptor is not None: + descriptor['access'] = line[1:] + elif line: + raise ValueError('Cannot parse GPT home ownership; no account was changed') + blocked = [owner for owner in owners if not ( + owner['pid'] in read_only_owners and owner['files'] and all( + item['fd'].isdigit() and item['access'] == 'r' for item in owner['files']))] + if blocked: + details = ', '.join(f"{owner['pid']} ({owner['command']})" for owner in blocked) + raise ValueError('GPT home is still open by an App/CLI process or filesystem service; ' + f'close the owning client before switching: {details}. ' + 'New-account enrollment without --current can continue while the App is open.') + + +def running(exe, spec): + ps = subprocess.run(['/bin/ps', '-axo', 'pid=,ppid=,command='], + capture_output=True, text=True, check=True).stdout + return owners(exe, spec['frontend'], ps) + + +def open_app(home, role, files, dry_run=False, *, spec=None): + if role != "gpt" or files: + raise ValueError("Native Codex launch accepts no workspace arguments") + spec = route(home, role) if spec is None else spec + def preflight(): + check_provider(spec, role) + account_preflight(home, spec) + return open_codex(home, spec, preflight=preflight, role=role, dry_run=dry_run) + + +def open_codex(home, spec, *, preflight, role="gpt", dry_run=False): + """Shared Codex process launch; callers retain credential/provider admission.""" + bundle = app_bundle(spec, role, home) + exe = executable(bundle) + preflight() + pid = running(exe, spec) + result = {'action': 'focus' if pid else 'launch', 'role': role, + 'home': str(spec['home']), 'frontend': str(spec['frontend']), 'pid': pid} + if dry_run: + return dict(result, preview=True) + lock_dir = managed_path(spec['lock_root'], home) + lock_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + lock_path = managed_path(lock_dir / 'launch.lock', home) + with open(lock_path, 'a', opener=lambda p, flags: os.open(p, flags, 0o600)) as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + preflight() + pid = running(exe, spec) + if pid: + subprocess.run(['/usr/bin/osascript', '-l', 'JavaScript', '-', str(pid)], + input=FOCUS, text=True, check=True, capture_output=True) + return dict(result, action='focused', pid=pid) + subprocess.run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(bundle)], + check=True, capture_output=True) + spec['frontend'].mkdir(mode=0o700, parents=True, exist_ok=True) + env = clean_env(spec, home) + env['CODEX_ELECTRON_USER_DATA_PATH'] = str(spec['frontend']) + child = subprocess.Popen([str(exe), '--user-data-dir=' + str(spec['frontend'])], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + # Hold the lock until the child owns its frontend, preventing a double-click race. + import time + for _ in range(50): + if child.poll() is not None: + raise ValueError('App exited during startup; inspect the App locally') + if running(exe, spec) == child.pid: + return dict(result, action='launch-requested', pid=child.pid) + time.sleep(0.1) + raise ValueError('App ownership was not confirmed within five seconds; inspect before retrying') diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py index 21bdaf746f..ada6c3092c 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py @@ -1,15 +1,13 @@ -"""IO adapter to the installed Personal Apps native account owner. +"""Native Codex IO using the account and App owners shipped in this package. -No copied account writer: the installed owner's locks, idle check, transaction, -readback and recovery remain authoritative. Paths/pins and credentials stay here, -outside policy input and browser projections. Run only through the opt-in host. +Private profiles/pins and credentials stay outside policy input and browser +projections. Existing vaults retain their original locks and recovery contract. """ from __future__ import annotations import hashlib from contextlib import ExitStack, contextmanager import ctypes -import importlib import json import math import os @@ -28,6 +26,10 @@ import urllib.parse from .native_processes import codex_home, lima_pidfile, responsible_pid +from . import native_account_store, native_app + +OWNER = "loopx_native_codex_accounts_v1" +OWNER_FILES = ("native_account_store.py", "native_app.py") class HostError(Exception): @@ -115,31 +117,33 @@ def trusted_file(path: Path) -> Path: return path -def discover() -> dict: - command = shutil.which("personal-apps") +def discover(*, user_home: Path | None = None) -> dict: codex = shutil.which("codex") - if not command or not codex: + if not codex: raise HostError("adapter_unavailable") - source = trusted_file(Path(command)).parent + source = Path(__file__).parent pins = {name: hashlib.sha256(trusted_file(source / name).read_bytes()).hexdigest() - for name in ("personal_apps.py", "personal_accounts.py")} - return {"source": str(source), "pins": pins, "codex": str(trusted_file(Path(codex)))} + for name in OWNER_FILES} + profile = native_app.route(user_home or Path.home(), "gpt") + return {"owner": OWNER, "pins": pins, "codex": str(trusted_file(Path(codex))), + "profile": {key: str(value) for key, value in profile.items()}} class NativeHost: def __init__(self, adapter: dict, *, user_home: Path | None = None, reconcile_services: bool = True): - source = Path(adapter["source"]) - for name in ("personal_apps.py", "personal_accounts.py"): + if adapter.get("owner") != OWNER: + raise HostError("adapter_changed") + source = Path(__file__).parent + for name in OWNER_FILES: if hashlib.sha256(trusted_file(source / name).read_bytes()).hexdigest() != adapter["pins"][name]: raise HostError("adapter_changed") self.codex = trusted_file(Path(adapter["codex"])) - # This adapter runs in its own process; module names cannot collide with - # an earlier owner version loaded by another request. - sys.path.insert(0, str(source)) - self.apps = importlib.import_module("personal_apps") - self.accounts = importlib.import_module("personal_accounts") + self.apps = native_app + self.accounts = native_account_store self.home = user_home or Path.home() self.spec = self.apps.route(self.home, "gpt") + if {key: str(value) for key, value in self.spec.items()} != adapter.get("profile"): + raise HostError("adapter_changed") self.expected = None self.not_after = None @@ -181,11 +185,31 @@ def status(self) -> dict: result = {key: value[key] for key in ( "slots", "active_slot", "current_login_matches", "recovery_required" )} | {"selection_revision": self.revision()} + kinds = value.get("slot_kinds", {}) + result["slots"] = [slot for slot in result["slots"] if kinds.get(slot) != "api"] + result["api_slot"] = "openai-api" if kinds.get("openai-api") == "api" else None + result["api_fallback_supported"] = getattr(self.accounts, "API_KEY_SLOTS_VERSION", 0) == 1 if sys.platform == "darwin": exe = self.apps.executable(self.apps.app_bundle(self.spec, "gpt", self.home)) result["client_running"] = self.apps.running(exe, self.spec) is not None return result + def enroll_api(self, slot: str, api_key: str, base_url: str) -> dict: + if slot != "openai-api" or getattr(self.accounts, "API_KEY_SLOTS_VERSION", 0) != 1: + raise HostError("api_owner_upgrade_required") + self.manager.enroll_api(slot, api_key=api_key, base_url=base_url) + return self.status() + + def enroll_current(self, expected: str) -> dict: + if self.revision() != expected or self.manager.status()["active_slot"] is not None: + raise HostError("selection_changed") + if (not self.manager.auth.exists() + or self.accounts.auth_kind(self.accounts.read_private(self.manager.auth)) != "subscription"): + raise HostError("native_login_required") + self.expected = expected + self.manager.enroll("primary", current=True) + return self.status() + def close_client(self, expected: str, *, timeout: float = 25, force: bool = False, inspected: tuple | None = None) -> bool: exe = self.apps.executable(self.apps.app_bundle(self.spec, "gpt", self.home)) @@ -294,7 +318,7 @@ def reopen(self, expected: str) -> dict: value = self.status() if value["selection_revision"] != expected or value["recovery_required"] or value["current_login_matches"] is not True: raise HostError("selection_changed") - self.apps.open_app(self.home, "gpt", []) + self.apps.open_app(self.home, "gpt", [], spec=self.spec) return self.status() def _home_owners(self) -> list[int]: @@ -824,7 +848,7 @@ def switch(self, slot: str, expected: str, *, reopen: bool, restart_clients: boo launch_failed = False if reopen: try: - self.apps.open_app(self.home, "gpt", []) + self.apps.open_app(self.home, "gpt", [], spec=self.spec) except (OSError, ValueError, subprocess.SubprocessError): launch_failed = True after = self.status() @@ -1072,6 +1096,10 @@ def main() -> int: value = host.clients() elif operation == "observe": value = host.observe(request["slots"], request["expected_revision"], request.get("auth_failures")) + elif operation == "enroll_api": + value = host.enroll_api(request["slot"], request["api_key"], request["base_url"]) + elif operation == "enroll_current": + value = host.enroll_current(request["expected_revision"]) elif operation == "switch": value = host.switch(request["slot"], request["expected_revision"], reopen=request["reopen"], restart_clients=request.get("restart_clients", False), diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts index 13b7c2da51..0d47109c0e 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts @@ -18,7 +18,31 @@ export type Account = { credits_available: boolean | null; }; export type Policy = { enabled: boolean; slots: string[]; cooldown_seconds: number; - restart_clients?: boolean; force_quit_clients?: boolean }; + restart_clients?: boolean; force_quit_clients?: boolean; api_fallback?: boolean }; + +/** Diagnostic liveness only; this never grants switching or process authority. */ +export type ObserverHealthState = "unavailable" | "stopped" | "unverified" | "upgrade_required" + | "overdue" | "retrying" | "healthy"; +export function observerHealth(input: { + now: number; owner_locked: boolean | null; build: string; + heartbeat: { checked_at?: number; deadline_at?: number; next_check_at?: number; + build?: string; phase?: string } | null; +}) { + const row = input.heartbeat; + const result = (state: ObserverHealthState) => ({state, + checked_at: Number.isFinite(row?.checked_at) ? row!.checked_at : null, + next_check_at: Number.isFinite(row?.next_check_at) ? row!.next_check_at : null}); + if (input.owner_locked === null) return result("unavailable"); + if (!input.owner_locked) return result("stopped"); + if (!row || !Number.isFinite(row.checked_at) || !Number.isFinite(row.deadline_at) + || row.checked_at! > input.now + 5 || row.deadline_at! <= row.checked_at! + || row.deadline_at! > row.checked_at! + 600 + || typeof row.build !== "string" + || !["checking", "waiting", "retrying"].includes(row.phase ?? "")) return result("unverified"); + if (row.build !== input.build) return result("upgrade_required"); + if (row.deadline_at! < input.now) return result("overdue"); + return result(row.phase === "retrying" ? "retrying" : "healthy"); +} const number = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); const percent = (v: unknown): number | null => number(v) && v >= 0 && v <= 100 ? v : null; @@ -69,21 +93,25 @@ export function decide(input: { now: number; policy: Policy; active: string | null; observations: Observation[]; last_switch_at: number | null; recovery_required: boolean; login_matches: boolean; manual_slot?: string; + api?: { slot: string; ready: boolean }; }) { const accounts = input.observations.map(o => account(o, input.now)); + const onApi = input.api?.slot === input.active; const output = (action: string, target: string | null = null, retry_at: number | null = null) => ({ action, target, retry_at, accounts, restart_clients: action === "switch" && input.policy.restart_clients === true, force_quit_clients: action === "switch" && input.policy.restart_clients === true && input.policy.force_quit_clients === true, valid_until: action === "switch" && !input.manual_slot - ? Math.min(...accounts.filter(a => a.slot === input.active || a.slot === target).map(a => a.observed_at + 120)) + ? Math.min(...accounts.filter(a => target === input.api?.slot + ? a.slot === input.active || input.policy.slots.includes(a.slot) + : a.slot === input.active || a.slot === target).map(a => a.observed_at + 120)) : null }); if (input.recovery_required) return output("recovery_required"); if (!input.login_matches || !input.active) return output("selection_changed"); if (!input.manual_slot && !input.policy.enabled) return output("disabled"); const current = accounts.find(a => a.slot === input.active); - if (!input.manual_slot && current?.availability !== "exhausted") { + if (!input.manual_slot && !onApi && current?.availability !== "exhausted") { return output(current?.availability === "available" ? "ready" : current?.availability === "auth_required" ? "auth_required" : "quota_unknown", null, input.now + 60); } @@ -104,6 +132,13 @@ export function decide(input: { ? [input.manual_slot].find(slot => slot !== input.active && allowed.includes(slot)) : candidates.sort((a, b) => b.remaining - a.remaining)[0]?.slot; if (target) return output("switch", target); + if (!input.manual_slot && onApi) return output(input.policy.api_fallback ? "api_active" : "api_disabled_active", null, input.now + 60); + // A failed/stale subscription read is not permission to incur API costs. + // Every authorized subscription must have fresh, measured exhaustion. + if (!input.manual_slot && input.policy.api_fallback === true && input.api?.ready + && allowed.length > 0 && allowed.every(slot => accounts.find(a => a.slot === slot)?.availability === "exhausted")) { + return output("switch", input.api.slot); + } const resets = accounts.filter(a => allowed.includes(a.slot) && a.availability === "exhausted") .flatMap(a => a.reset_at && a.reset_at > input.now ? [a.reset_at] : []); const uncertain = accounts.some(a => allowed.includes(a.slot) && a.availability === "unknown"); diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy_bridge.ts b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy_bridge.ts index 6c5bdea9f1..fb3eb6477d 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy_bridge.ts +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy_bridge.ts @@ -1,7 +1,8 @@ -import { decide } from "./native_policy.ts"; +import { decide, observerHealth } from "./native_policy.ts"; let raw = ""; for await (const chunk of process.stdin) { raw += chunk; if (raw.length > 131072) throw Error("Policy request exceeds limit"); } -process.stdout.write(JSON.stringify(decide(JSON.parse(raw)))); +const input = JSON.parse(raw); +process.stdout.write(JSON.stringify(input.operation === "observer-health" ? observerHealth(input) : decide(input))); diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_keys.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_keys.py new file mode 100644 index 0000000000..3d587e2321 --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_keys.py @@ -0,0 +1,162 @@ +"""Private OpenAI keys and content-free observations for the opt-in API route.""" +from __future__ import annotations + +import json +import math +from email.utils import parsedate_to_datetime +import re +import urllib.error +import urllib.request +import uuid +import shutil +import subprocess +from pathlib import Path + +from .native import atomic, private_read +from .native_host import HostError + +SCHEMA = "codex_openai_api_keys_v1" + + +def api_policy(value: dict): + node = shutil.which("node") + if not node: + raise HostError("node_unavailable") + result = subprocess.run([node, "--no-warnings", "--experimental-strip-types", + str(Path(__file__).with_name("openai_policy_bridge.ts"))], + input=json.dumps(value), capture_output=True, text=True, timeout=10) + if result.returncode: + raise HostError("policy_unavailable") + return json.loads(result.stdout) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + raise HostError("key_check_unavailable") + + +def check_models(api_key: str) -> str: + """No prompts, billing probes, redirects, retries or raw response storage.""" + request = urllib.request.Request("https://api.openai.com/v1/models", headers={ + "Authorization": "Bearer " + api_key, "Accept": "application/json", + }) + try: + with urllib.request.build_opener(NoRedirect()).open(request, timeout=10) as response: + encoded = response.read(1048577) + if len(encoded) > 1048576: + return "unavailable" + value = json.loads(encoded) + return "models_readable" if isinstance(value, dict) and isinstance(value.get("data"), list) else "unavailable" + except urllib.error.HTTPError as error: + error.close() + return {401: "authentication_failed", 403: "permission_denied", 429: "rate_limited"}.get(error.code, "unavailable") + except (HostError, OSError, ValueError): + return "unavailable" + + +class OpenAIKeys: + def __init__(self, owner, *, checker=check_models): + self.owner, self.checker = owner, checker + self.path = owner.root / "openai-keys.json" + + def _load(self) -> dict: + return private_read(self.path, schema=SCHEMA) if self.path.exists() else { + "schema_version": SCHEMA, "revision": 0, "keys": [], + } + + @staticmethod + def _projection(state: dict) -> dict: + return {"schema_version": SCHEMA, "available": True, "revision": state["revision"], + "balance_available": False, + "keys": [{"id": row["id"], "label": row["label"], + "masked_key": "••••" + row["api_key"][-4:], + "check_status": row.get("check_status", "not_checked"), + "checked_at": row.get("checked_at"), + "runtime": row.get("runtime")} + for row in state["keys"]]} + + def status(self) -> dict: + # Default-off read neither creates state nor contacts a provider. + return self._projection(self._load()) + + def _revision(self, expected_revision: int) -> dict: + state = self._load() + if type(expected_revision) is not int or state["revision"] != expected_revision: + raise HostError("configuration_changed") + return state + + def add(self, *, expected_revision: int, label: str, api_key: str) -> dict: + if (not isinstance(api_key, str) or not re.fullmatch(r"sk-[A-Za-z0-9_-]{20,509}", api_key) + or api_key.startswith("sk-admin-")): + raise HostError("invalid_api_key") + if (not isinstance(label, str) or not 1 <= len(label.strip()) <= 80 + or any(ord(c) < 32 for c in label) or "sk-" in label): + raise HostError("invalid_key_label") + with self.owner._lock(name="openai-keys.lock"): + state = self._revision(expected_revision) + if any(row["api_key"] == api_key for row in state["keys"]): + raise HostError("duplicate_api_key") + if len(state["keys"]) >= 16: + raise HostError("key_limit_reached") + state["keys"].append({"id": uuid.uuid4().hex, "label": label.strip(), "api_key": api_key}) + state["revision"] += 1 + atomic(self.path, state) + return self._projection(state) + + def _row(self, state: dict, key_id: str) -> dict: + if not isinstance(key_id, str): + raise HostError("key_not_found") + row = next((row for row in state["keys"] if row["id"] == key_id), None) + if row is None: + raise HostError("key_not_found") + return row + + def remove(self, *, expected_revision: int, key_id: str) -> dict: + with self.owner._lock(name="openai-keys.lock"): + state = self._revision(expected_revision) + state["keys"].remove(self._row(state, key_id)) + state["revision"] += 1 + atomic(self.path, state) + return self._projection(state) + + def check(self, *, expected_revision: int, key_id: str) -> dict: + with self.owner._lock(name="openai-keys.lock"): + state = self._revision(expected_revision) + row = self._row(state, key_id) + row.update(check_status=self.checker(row["api_key"]), checked_at=self.owner.clock()) + state["revision"] += 1 + atomic(self.path, state) + return self._projection(state) + + def candidates(self, model: str) -> list[dict]: + state = self._load() + ids = api_policy({"operation": "candidates", "now": self.owner.clock(), "model": model, + "keys": [{"id": row["id"], **{k: row.get("runtime", {}).get(k) + for k in ("blocked_until", "blocked_model")}} for row in state["keys"]]}) + return [row for row in state["keys"] if row["id"] in ids] + + def record(self, key_id: str, *, status: int, code: str | None, model: str, headers: dict) -> dict: + try: + retry = float(headers.get("retry-after", "")) + retry = retry if math.isfinite(retry) else None + except (TypeError, ValueError): + try: + retry = max(1, parsedate_to_datetime(headers.get("retry-after", "")).timestamp() - self.owner.clock()) + except (TypeError, ValueError, OverflowError): + retry = None + outcome = api_policy({"operation": "outcome", "now": self.owner.clock(), "status": status, + "code": code, "retry_after": retry, "model": model}) + with self.owner._lock(name="openai-keys.lock", wait_seconds=15): + state = self._load() + row = next((row for row in state["keys"] if row["id"] == key_id), None) + if row is not None: + runtime = {k: outcome[k] for k in ("state", "blocked_until", "blocked_model")} + runtime.update(observed_at=self.owner.clock(), model=model) + for field in ("requests", "tokens"): + raw = headers.get("x-ratelimit-remaining-" + field, "") + if isinstance(raw, str) and raw.isdigit() and len(raw) <= 15: + runtime["remaining_" + field] = int(raw) + row["runtime"] = runtime + # Runtime observations do not invalidate a configuration edit. + atomic(self.path, state) + return outcome diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy.ts b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy.ts new file mode 100644 index 0000000000..8da07a547c --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy.ts @@ -0,0 +1,20 @@ +/** OpenAI transport outcomes. No credentials, request contents or IO. */ +export type KeyRuntime = { id: string; blocked_until?: number; blocked_model?: string | null }; +export function apiKeys(input: { now: number; model: string; keys: KeyRuntime[] }) { + return input.keys.filter(key => !(typeof key.blocked_until === "number" + && key.blocked_until > input.now && (!key.blocked_model || key.blocked_model === input.model))).map(key => key.id); +} +export function apiOutcome(input: { now: number; status: number; code: string | null; retry_after: number | null; model: string }) { + const quota = input.status === 429 && ["insufficient_quota", "organization_spend_limit_exceeded", + "project_spend_limit_exceeded", "organization_usage_limit_exceeded"].includes(input.code ?? ""); + const authentication = input.status === 401; + const permission = input.status === 403; + const limited = input.status === 429; + const delay = typeof input.retry_after === "number" && Number.isFinite(input.retry_after) + ? Math.max(1, input.retry_after) : quota || authentication ? 300 : 30; + return { state: quota ? "quota_exhausted" : authentication ? "authentication_failed" : permission + ? "permission_denied" : limited ? "rate_limited" : input.status >= 200 && input.status < 300 ? "accepted" : "failed", + retry_next_key: quota || authentication || permission || limited, + blocked_until: quota || authentication || permission || limited ? input.now + delay : 0, + blocked_model: permission ? input.model : null }; +} diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy_bridge.ts b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy_bridge.ts new file mode 100644 index 0000000000..54609e9995 --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_policy_bridge.ts @@ -0,0 +1,8 @@ +import { apiKeys, apiOutcome } from "./openai_policy.ts"; +let raw = ""; +for await (const chunk of process.stdin) { + raw += chunk; + if (raw.length > 131072) throw Error("Policy request exceeds limit"); +} +const input = JSON.parse(raw); +process.stdout.write(JSON.stringify(input.operation === "candidates" ? apiKeys(input) : apiOutcome(input))); diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_relay.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_relay.py new file mode 100644 index 0000000000..10a0744787 --- /dev/null +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/openai_relay.py @@ -0,0 +1,223 @@ +"""Loopback Responses transport for the explicitly enabled API fallback. + +Native login transactions stay in the packaged account owner. This transport holds only +upstream API keys, never subscription credentials, prompts or response logs. +""" +from __future__ import annotations + +import argparse +import hmac +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import secrets +import re +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request + +from .native import NativeAccounts, atomic, private_read +from .native_host import HostError +from .openai_keys import NoRedirect + +SCHEMA = "codex_openai_api_relay_v1" +API_SLOT = "openai-api" + + +def route(owner: NativeAccounts) -> dict: + value = private_read(owner.root / "api-relay.json", schema=SCHEMA) + if type(value.get("port")) is not int or not 1024 <= value["port"] <= 65535 or not isinstance(value.get("token"), str) or len(value["token"]) < 32: + raise HostError("api_relay_unavailable") + return value + + +def healthy(value: dict) -> bool: + request = urllib.request.Request(f"http://127.0.0.1:{value['port']}/health", headers={"Authorization": "Bearer " + value["token"]}) + try: + with urllib.request.build_opener(NoRedirect()).open(request, timeout=1) as response: + return response.read(128) == b'{"ok":true}' + except (OSError, HostError): + return False + + +def ensure(owner: NativeAccounts) -> dict: + with owner._lock(name="api-relay-state.lock", wait_seconds=5): + path = owner.root / "api-relay.json" + if not path.exists(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + atomic(path, {"schema_version": SCHEMA, "port": port, "token": "sk-loopx-relay-" + secrets.token_urlsafe(32)}) + value = route(owner) + if not healthy(value): + # Independent of a Chat/App process that the handoff may restart. + subprocess.Popen([sys.executable, "-m", "loopx_codex_provider_routing.openai_relay", + "--runtime-root", str(owner.root.parents[1])], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not healthy(value): + time.sleep(.1) + if not healthy(value): + raise HostError("api_relay_unavailable") + return {"slot": API_SLOT, "api_key": value["token"], "base_url": f"http://127.0.0.1:{value['port']}/v1"} + + +class RelayServer(ThreadingHTTPServer): + daemon_threads = False + allow_reuse_address = True + + def __init__(self, owner: NativeAccounts, value: dict, *, opener=None): + self.owner, self.token = owner, value["token"] + self.opener = opener or urllib.request.build_opener(NoRedirect()) + super().__init__(("127.0.0.1", value["port"]), RelayHandler) + + +class RelayHandler(BaseHTTPRequestHandler): + server: RelayServer + + def log_message(self, *_args): + pass + + def _json(self, status: int, code: str): + data = json.dumps({"error": {"type": "api_fallback_error", "code": code, + "message": "OpenAI API fallback request could not complete."}}).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _authorized(self): + return not self.headers.get("Origin") and hmac.compare_digest( + self.headers.get("Authorization", ""), "Bearer " + self.server.token) + + def _admitted(self): + state = self.server.owner._load() + policy = state["policy"] + native = state.get("native", {}) + selecting_api = state.get("intent", {}).get("target") == API_SLOT if state.get("intent") else False + return (policy.get("enabled") is True and policy.get("api_fallback") is True + and (native.get("active_slot") == API_SLOT or selecting_api)) + + def do_GET(self): + if not self._authorized(): + self._json(401, "relay_auth_required") + return + if self.path == "/health": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", "11") + self.end_headers() + self.wfile.write(b'{"ok":true}') + else: + self._json(404, "unsupported_route") + + def do_POST(self): + if not self._authorized(): + self._json(401, "relay_auth_required") + return + if self.path not in ("/v1/responses", "/v1/responses/compact"): + self._json(404, "unsupported_route") + return + if not self._admitted(): + self._json(503, "api_fallback_disabled") + return + try: + length = int(self.headers.get("Content-Length", "0")) + if not 1 <= length <= 32 * 1024 * 1024: + raise ValueError() + body = self.rfile.read(length) + value = json.loads(body) + model = value["model"] + if not isinstance(model, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,160}", model) or model.startswith("sk-"): + raise ValueError() + except (TypeError, ValueError, KeyError): + self._json(400, "invalid_request") + return + keys = self.server.owner.api_keys() + candidates = keys.candidates(model) + last_status, last_code = 429, "api_keys_unavailable" + for row in candidates: + # Removing/disabling configuration before dispatch wins. A request + # already admitted upstream is never cancelled or replayed here. + if not self._admitted(): + self._json(503, "api_fallback_disabled") + return + current = next((r for r in keys._load()["keys"] if r["id"] == row["id"]), None) + if current is None: + continue + request = urllib.request.Request("https://api.openai.com" + self.path, data=body, headers={ + "Authorization": "Bearer " + current["api_key"], "Content-Type": "application/json", + "Accept": self.headers.get("Accept", "text/event-stream"), + }, method="POST") + try: + response = self.server.opener.open(request, timeout=300) + except urllib.error.HTTPError as error: + encoded = error.read(65537) + try: + code = json.loads(encoded).get("error", {}).get("code") if len(encoded) <= 65536 else None + code = code if isinstance(code, str) and code.isascii() and code.replace("_", "").isalnum() else None + except (TypeError, ValueError, AttributeError): + code = None + headers = {k.lower(): v for k, v in error.headers.items()} + status = error.code + error.close() + outcome = keys.record(row["id"], status=status, code=code, model=model, headers=headers) + last_status, last_code = status, code or "upstream_rejected" + if outcome["retry_next_key"]: + continue + self._json(status, last_code) + return + except (OSError, HostError): + # Unknown upstream effects never authorize trying another key. + self._json(502, "upstream_unavailable") + return + with response: + headers = {k.lower(): v for k, v in response.headers.items()} + keys.record(row["id"], status=response.status, code=None, model=model, headers=headers) + self.send_response(response.status) + self.send_header("Content-Type", headers.get("content-type", "text/event-stream")) + self.send_header("Cache-Control", "no-store") + self.end_headers() + # Once headers/output are admitted, disconnects and SSE errors + # end this request. No key rotation, synthetic completion or job replay. + try: + read = getattr(response, "read1", response.read) + while chunk := read(8192): + self.wfile.write(chunk) + self.wfile.flush() + except (OSError, TimeoutError): + self.close_connection = True + return + self._json(last_status, last_code) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--runtime-root", type=Path, required=True) + owner = NativeAccounts(parser.parse_args().runtime_root) + try: + with owner._lock(name="api-relay.lock"): + server = RelayServer(owner, route(owner)) + server.timeout = 5 + started = time.monotonic() + try: + while True: + server.handle_request() + policy = owner._load()["policy"] + if time.monotonic() - started > 10 and (not policy.get("enabled") or not policy.get("api_fallback")): + break + finally: + # Drain already admitted requests; disabling never replays them. + server.server_close() + except (BlockingIOError, OSError, HostError): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/loopx-pr-merge/SKILL.md b/skills/loopx-pr-merge/SKILL.md index d7c24a6796..a18516a58f 100644 --- a/skills/loopx-pr-merge/SKILL.md +++ b/skills/loopx-pr-merge/SKILL.md @@ -34,10 +34,12 @@ authorized: a diff read, green CI, or pull-request metadata is not a substitute. remote reviewed head. The source-checkout `loopx canary premerge` includes the same direct blocking check. A failed check holds merge regardless of CI waiting or authorized admin bypass. Working-tree changes cannot satisfy it. -4. Immediately before the merge, run `loopx --format json pr-review --goal-id - GOAL --repo OWNER/REPO --check-merge-readiness NUMBER@HEAD_OID`. Merge only - when it returns `ready=true` for that unchanged head; this records the - compact Goal readiness observation consumed by future review queues. +4. Immediately before the merge, run `loopx --format json pr-review --repo + OWNER/REPO --check-merge-readiness NUMBER@HEAD_OID`. Merge only when it + returns `ready=true` for that unchanged head. For work already managed by a + Goal, pass `--goal-id GOAL` to retain its configured CI policy and record the + compact readiness observation. Standalone work needs no Goal, registration + or binding; its readiness check performs no Goal write. A rebase or head update restarts review, and admin bypass never overrides this gate: it needs explicit owner authorization and never substitutes for the diff --git a/skills/loopx-pr-review/SKILL.md b/skills/loopx-pr-review/SKILL.md index 9c3a7bde7f..4826bd4f38 100644 --- a/skills/loopx-pr-review/SKILL.md +++ b/skills/loopx-pr-review/SKILL.md @@ -134,10 +134,12 @@ and return its URL. After APPROVE (incl. existing approval), execute owner/GitHub authority, preserving discussion and unresolved reviews. Merge routes through `loopx-pr-merge`; `APPROVE` is not merge authority; a public blocker belongs on the PR. -Immediately before every merge, run `loopx --format json pr-review --goal-id GOAL ---repo OWNER/REPO --check-merge-readiness NUMBER@HEAD_OID`; require `ready=true`. Its compact -Goal observation suppresses only unchanged requalification; material change reopens it, admin -bypass never overrides this gate, and author fallback needs user authority. +Immediately before every merge, run `loopx --format json pr-review --repo OWNER/REPO +--check-merge-readiness NUMBER@HEAD_OID`; require `ready=true`. Pass `--goal-id GOAL` +when the work is already Goal-managed; its compact observation suppresses only unchanged +requalification. Standalone work requires no Goal and writes no Goal observation. +Material change reopens qualification, admin bypass never overrides this gate, and author +fallback needs user authority. ## Full PR Review And Bilingual Format diff --git a/tests/extensions/test_background_runtime.py b/tests/extensions/test_background_runtime.py new file mode 100644 index 0000000000..96a50fc6bd --- /dev/null +++ b/tests/extensions/test_background_runtime.py @@ -0,0 +1,77 @@ +"""Thread hosting must not imply provider health, replay or execution authority.""" +import threading + +from loopx.extensions.background_runtime import HostedWorker + + +def test_start_is_idempotent_and_close_preserves_a_cooperative_wait(): + entered, release, stop = threading.Event(), threading.Event(), threading.Event() + calls = [] + + def target(): + calls.append(True) + entered.set() + release.wait(3) + + worker = HostedWorker(name="synthetic-wait", target=target, stop=stop) + worker.start() + worker.start() + assert entered.wait(1) and len(calls) == 1 + worker.close(timeout=.01) + assert stop.is_set() and worker.thread.is_alive(), "A live drain is not a completed shutdown" + release.set() + worker.thread.join(timeout=2) + worker.start() + assert len(calls) == 1 and not worker.thread.is_alive(), "Explicit stop cannot restart" + + +def test_close_before_start_has_no_effect(): + called = threading.Event() + worker = HostedWorker(name="synthetic-off", target=called.set, stop=threading.Event()) + worker.close() + worker.start() + assert not called.is_set() + + +def test_only_explicit_retry_restarts_a_failed_host_loop(caplog): + recovered = threading.Event() + calls = [] + + def target(): + calls.append(True) + if len(calls) == 1: + raise AssertionError("private provider detail") + recovered.set() + + worker = HostedWorker(name="synthetic-recovery", target=target, stop=threading.Event(), retry_seconds=.01) + worker.start() + assert recovered.wait(2) + worker.thread.join(timeout=2) + assert len(calls) == 2, "Normal completion must not replay even with retry enabled" + assert "private provider detail" not in caplog.text + worker.close() + + +def test_failure_does_not_replay_without_explicit_retry(): + calls = [] + + def target(): + calls.append(True) + raise RuntimeError("synthetic failure") + + worker = HostedWorker(name="synthetic-once", target=target, stop=threading.Event()) + worker.start() + worker.thread.join(timeout=2) + worker.start() + assert calls == [True] + worker.close() + + +def test_completed_work_is_not_replayed_by_another_start(): + calls = [] + worker = HostedWorker(name="synthetic-completed", target=lambda: calls.append(True), stop=threading.Event()) + worker.start() + worker.thread.join(timeout=2) + worker.start() + assert calls == [True] + worker.close() diff --git a/tests/presentation/test_codex_accounts.py b/tests/presentation/test_codex_accounts.py index 7b88b14e45..ab5dee9f3b 100644 --- a/tests/presentation/test_codex_accounts.py +++ b/tests/presentation/test_codex_accounts.py @@ -828,7 +828,8 @@ def switch(slot): state.update(active=slot, revision="selected", switch=state["switch"] + 1) return {"history_untouched": True} - def reopen(*_): + def reopen(*_, spec): + assert spec is adapter.spec, "Reopening must retain the inspected profile" state.update(pid=22, open=state["open"] + 1) def quit(command, **parameters): diff --git a/tests/presentation/test_native_account_store.py b/tests/presentation/test_native_account_store.py new file mode 100644 index 0000000000..f9885d89e4 --- /dev/null +++ b/tests/presentation/test_native_account_store.py @@ -0,0 +1,437 @@ +"""Synthetic login caches only; never load a developer's real accounts.""" +import base64 +import importlib +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tempfile +import tomllib +import unittest +from unittest.mock import Mock, patch + +import pytest + +if os.name != "posix": + pytest.skip("Native account IO requires POSIX locks", allow_module_level=True) + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "packages/loopx-codex-provider-routing/src")) +accounts = importlib.import_module("loopx_codex_provider_routing.native_account_store") +apps = importlib.import_module("loopx_codex_provider_routing.native_app") + + +def fake_login(subject, rotation='initial'): + claims = base64.urlsafe_b64encode(json.dumps({'sub': 'synthetic-' + subject}).encode()).decode().rstrip('=') + return accounts.encoded({'auth_mode': 'chatgpt', 'tokens': { + 'id_token': 'synthetic.' + claims + '.not-a-signature', + 'access_token': 'synthetic-access-' + rotation, + 'refresh_token': 'synthetic-refresh-' + rotation, 'account_id': 'synthetic-personal'}}) + + +class AccountTests(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.home = Path(temp.name).resolve() / 'home with spaces' + self.home.mkdir() + with patch.dict(os.environ, {"CODEX_HOME": str(self.home / ".codex"), + "CODEX_ELECTRON_USER_DATA_PATH": str(self.home / "frontend")}): + self.spec = apps.route(self.home, 'gpt') + self.spec['home'].mkdir(parents=True, mode=0o700) + config = self.spec['home'] / 'config.toml' + config.write_text('model_provider="openai"\n') + config.chmod(0o600) + self.idle = Mock() + self.manager = accounts.Accounts(self.home, self.spec, self.idle, + lambda: '/synthetic/bin/codex', apps.clean_env) + self.a, self.b = fake_login('a'), fake_login('b') + accounts.atomic(self.manager.auth, self.a) + + def current(self): + return self.manager.enroll('a', current=True) + + def enroll_b(self): + def login(cmd, env): + self.assertEqual(cmd, ['/synthetic/bin/codex', 'login']) + stage = Path(env['CODEX_HOME']) + self.assertNotEqual(stage, self.spec['home']) + self.assertEqual((stage / 'config.toml').read_text(), + 'cli_auth_credentials_store="file"\nforced_login_method="chatgpt"\n') + accounts.atomic(stage / 'auth.json', self.b) + return subprocess.CompletedProcess(cmd, 0) + with patch.object(accounts.subprocess, 'run', side_effect=login): + return self.manager.enroll('b') + + def ready(self): + self.current() + self.enroll_b() + + def test_api_enrollment_never_selects_or_restarts_a_subscription(self): + self.current() + before = {p: p.read_bytes() for p in (self.manager.auth, self.manager.config, self.manager.active)} + self.idle.reset_mock() + result = self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', base_url='http://127.0.0.1:32100/v1') + self.assertFalse(result['activated']) + self.idle.assert_not_called() + self.assertEqual({p: p.read_bytes() for p in before}, before) + self.assertEqual(self.manager.status()['slot_kinds'], {'a': 'subscription', 'api': 'api'}) + self.assertNotIn('sk-loopx-relay-synthetic-local-token', json.dumps(self.manager.status())) + + def test_api_slot_rejects_an_upstream_project_key(self): + self.current() + with self.assertRaises(ValueError): + self.manager.enroll_api('api', api_key='sk-proj-' + 'synthetic-project-key' * 2, + base_url='http://127.0.0.1:32100/v1') + self.assertFalse(self.manager.slot('api').exists()) + + def test_api_switch_and_subscription_return_reuse_transaction_recovery(self): + self.current() + original = self.manager.config.read_bytes() + self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', base_url='http://127.0.0.1:32100/v1') + self.manager.switch('api') + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(cfg.get('model_provider', 'openai'), 'openai') + self.assertEqual(cfg['forced_login_method'], 'api') + self.assertEqual(cfg['openai_base_url'], 'http://127.0.0.1:32100/v1') + self.assertEqual(self.manager.selected()[0]['slot'], 'api') + self.manager.recover(rollback=True) + self.assertEqual(self.manager.auth.read_bytes(), self.a) + self.assertEqual(self.manager.config.read_bytes(), original) + self.manager.switch('api') + self.manager.switch('a') + cfg = tomllib.loads(self.manager.config.read_text()) + self.assertEqual(cfg.get('model_provider', 'openai'), 'openai') + self.assertEqual(cfg['forced_login_method'], 'chatgpt') + self.assertEqual(self.manager.auth.read_bytes(), self.a) + + def test_api_fallback_refuses_non_loopback_and_changed_owned_provider(self): + self.current() + for endpoint in ['https://outside.example/v1', 'http://localhost:32100/v1', + 'http://127.0.0.1:80/v1', 'http://127.0.0.1:32100/v1?forward=other']: + with self.subTest(endpoint=endpoint), self.assertRaises(ValueError): + self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', base_url=endpoint) + self.manager.enroll_api('api', api_key='sk-loopx-relay-synthetic-local-token', base_url='http://127.0.0.1:32100/v1') + self.manager.switch('api') + changed = self.manager.config.read_text().replace('http://127.0.0.1:32100/v1', 'https://outside.example/v1') + self.manager.config.write_text(changed) + with self.assertRaises(ValueError): + self.manager.switch('a') + self.assertEqual(self.manager.config.read_text(), changed) + + def test_readonly_status_creates_nothing_and_prints_no_identity(self): + result = self.manager.status() + self.assertFalse(self.manager.root.exists()) + self.assertEqual(result['slots'], []) + self.assertFalse(result['live_authentication_verified']) + self.assertNotIn('synthetic-access', json.dumps(result)) + self.assertNotIn('synthetic-a', json.dumps(result)) + + def test_current_registration_preserves_auth_model_and_comments(self): + self.manager.config.write_text('# kept\nmodel="gpt-example"\nmodel_provider="openai"\n[mcp_servers.demo]\ncommand="demo"\n') + result = self.current() + self.assertTrue(result['activated']) + self.assertEqual(self.manager.auth.read_bytes(), self.a) + self.assertIn('# kept\nmodel="gpt-example"', self.manager.config.read_text()) + self.assertEqual(self.manager.status()['active_slot'], 'a') + for path in self.manager.root.rglob('*'): + self.assertEqual(path.stat().st_mode & 0o777, 0o700 if path.is_dir() else 0o600) + + def test_native_enrollment_does_not_select_or_change_current_login(self): + self.current() + result = self.enroll_b() + self.assertFalse(result['activated']) + self.assertEqual(self.manager.auth.read_bytes(), self.a) + self.assertEqual(self.manager.status()['active_slot'], 'a') + self.assertEqual(list((self.manager.root / 'login-staging').iterdir()), []) + + def test_unused_provider_survives_account_enrollment_switch_and_rollback(self): + tail = ('# Keep optional transport and unrelated sections verbatim.\n' + '[model_providers.optional_http]\nname="Optional HTTP"\n' + 'wire_api="responses"\nrequires_openai_auth=true\n' + 'supports_websockets=false\n' + '[mcp_servers.demo]\ncommand="demo"\n') + for default in ('', 'model_provider="openai"\n'): + with self.subTest(default=default): + raw = ('# kept\nmodel="gpt-example"\n' + default + tail).encode() + updated = accounts.file_config(raw) + self.assertTrue(updated.endswith(tail.encode())) + self.assertIn(('model="gpt-example"\n' + default).encode(), updated) + self.manager.config.write_bytes(raw) + self.ready() + before = self.manager.config.read_bytes() + self.manager.switch('b') + self.assertEqual(self.manager.auth.read_bytes(), self.b) + self.assertEqual(self.manager.config.read_bytes(), before) + self.manager.recover(rollback=True) + self.assertEqual(self.manager.auth.read_bytes(), self.a) + self.assertEqual(self.manager.config.read_bytes(), before) + self.assertTrue(before.endswith(tail.encode())) + + def test_non_native_routes_and_profiles_are_not_converted(self): + for raw in (b'model_provider="optional_http"\n', + b'model_catalog_json="models.json"\n', + b'[model_providers.openai]\n', + b'profile="custom"\n', + b'[profiles.custom]\nmodel_provider="optional_http"\n'): + with self.subTest(raw=raw): + with self.assertRaisesRegex(ValueError, 'native GPT'): + accounts.file_config(raw) + + def test_live_enrollment_shares_cli_lease_and_preserves_switch_recovery(self): + self.current() + before = {p: p.read_bytes() for p in (self.manager.auth, self.manager.config, + self.manager.active, self.manager.root / 'last.json')} + self.idle.reset_mock() + self.idle.side_effect = ValueError('App is active') + with accounts.credential_lock(self.home, shared=True): + result = self.enroll_b() + self.assertFalse(result['activated']) + self.idle.assert_not_called() + self.assertEqual({p: p.read_bytes() for p in before}, before) + self.assertFalse(self.manager.pending.exists()) + self.assertEqual(self.manager.slot('b').read_bytes(), self.b) + + def test_live_enrollment_without_current_slot_does_not_change_active_files(self): + before = {p: p.read_bytes() for p in (self.manager.auth, self.manager.config)} + self.idle.side_effect = ValueError('App is active') + self.enroll_b() + self.assertFalse(self.manager.active.exists()) + self.assertFalse(self.manager.pending.exists()) + self.assertFalse((self.manager.root / 'last.json').exists()) + self.assertEqual({p: p.read_bytes() for p in before}, before) + self.assertEqual(self.manager.status()['slots'], ['b']) + + def test_switch_preserves_real_sqlite_rollout_and_one_route(self): + self.ready() + db = self.spec['home'] / 'state_5.sqlite' + with sqlite3.connect(db) as conn: + conn.execute('create table example (title text)') + conn.execute('insert into example values (?)', ('synthetic history',)) + conn.close() + rollout = self.spec['home'] / 'sessions/example.jsonl' + rollout.parent.mkdir() + rollout.write_text('synthetic immutable history\n') + before = {p: p.read_bytes() for p in (db, rollout)} + result = self.manager.switch('b') + self.assertEqual(result['home'], str(self.spec['home'])) + self.assertEqual(result['frontend'], str(self.spec['frontend'])) + self.assertEqual(self.manager.auth.read_bytes(), self.b) + self.assertEqual(self.manager.status()['active_slot'], 'b') + self.assertEqual({p: p.read_bytes() for p in before}, before) + self.assertEqual(list(self.home.glob('.codex-gpt-account-*')), []) + + def test_rotated_credentials_saved_before_leaving_and_reused_on_return(self): + self.ready() + rotated = fake_login('a', 'rotated') + accounts.atomic(self.manager.auth, rotated) + self.manager.switch('b') + self.assertEqual(self.manager.slot('a').read_bytes(), rotated) + self.manager.switch('a') + self.assertEqual(self.manager.auth.read_bytes(), rotated) + + def test_selecting_current_slot_never_restores_older_refresh_token(self): + self.current() + rotated = fake_login('a', 'newer') + accounts.atomic(self.manager.auth, rotated) + self.manager.switch('a') + self.assertEqual(self.manager.auth.read_bytes(), rotated) + self.assertEqual(self.manager.slot('a').read_bytes(), rotated) + + def test_dry_run_changes_no_cache_journal_or_history(self): + self.ready() + before = {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()} + result = self.manager.switch('b', dry_run=True) + self.assertTrue(result['preview']) + self.assertEqual({p: p.read_bytes() for p in before}, before) + + def test_unknown_slot_and_untracked_current_login_rejected(self): + with self.assertRaises(ValueError): + self.manager.switch('missing') + self.enroll_b() + with self.assertRaisesRegex(ValueError, 'current GPT login first'): + self.manager.switch('b') + self.assertEqual(self.manager.auth.read_bytes(), self.a) + + def test_blank_home_can_select_first_official_enrollment(self): + self.manager.auth.unlink() + self.enroll_b() + self.manager.switch('b') + self.assertEqual(self.manager.auth.read_bytes(), self.b) + + def test_duplicate_account_and_slot_never_overwritten(self): + self.current() + with self.assertRaises(ValueError): + self.manager.enroll('a', current=True) + with self.assertRaisesRegex(ValueError, 'already registered'): + self.manager.enroll('alias', current=True) + self.assertFalse(self.manager.slot('alias').exists()) + + def test_external_login_change_cannot_overwrite_saved_slot(self): + self.ready() + accounts.atomic(self.manager.auth, fake_login('different')) + with self.assertRaisesRegex(ValueError, 'changed outside'): + self.manager.switch('b') + self.assertEqual(self.manager.slot('a').read_bytes(), self.a) + with self.assertRaises(ValueError): + apps.account_preflight(self.home, self.spec) + + def test_active_owner_refusal_has_no_vault_side_effects(self): + self.idle.side_effect = ValueError('synthetic running process') + with self.assertRaises(ValueError): + self.current() + self.assertFalse(self.manager.root.exists()) + + def test_failed_login_preserves_current_route(self): + with patch.object(accounts.subprocess, 'run', return_value=subprocess.CompletedProcess(['codex'], 1)): + with self.assertRaisesRegex(ValueError, 'did not complete'): + self.manager.enroll('b') + self.assertEqual(self.manager.auth.read_bytes(), self.a) + self.assertFalse(self.manager.slot('b').exists()) + + def test_failed_write_rolls_back_without_unsettled_journal(self): + self.ready() + original = accounts.atomic + failed = False + def failing(path, raw): + nonlocal failed + if path == self.manager.auth and raw == self.b and not failed: + failed = True + raise OSError('synthetic storage failure') + return original(path, raw) + with patch.object(accounts, 'atomic', side_effect=failing): + with self.assertRaises(OSError): + self.manager.switch('b') + self.assertEqual(self.manager.auth.read_bytes(), self.a) + self.assertEqual(self.manager.status()['active_slot'], 'a') + self.assertFalse(self.manager.pending.exists()) + + def test_completed_operation_rollback_and_interrupted_recovery(self): + self.ready() + self.manager.switch('b') + self.manager.recover(rollback=True) + self.assertEqual(self.manager.auth.read_bytes(), self.a) + self.manager.switch('b') + journal = (self.manager.root / 'last.json').read_bytes() + accounts.atomic(self.manager.pending, journal) # Simulate a crash before journal removal. + with self.assertRaisesRegex(ValueError, 'Interrupted'): + apps.account_preflight(self.home, self.spec) + self.manager.recover() + self.assertEqual(self.manager.auth.read_bytes(), self.a) + self.assertFalse(self.manager.pending.exists()) + + def test_rollback_cannot_overwrite_later_user_changes(self): + self.ready() + self.manager.switch('b') + self.manager.config.write_text(self.manager.config.read_text() + '# personal edit\n') + with self.assertRaisesRegex(ValueError, 'Recovery rejected'): + self.manager.recover(rollback=True) + self.assertEqual(self.manager.auth.read_bytes(), self.b) + + def test_tampered_backup_and_journal_escape_rejected_before_restore(self): + self.ready() + result = self.manager.switch('b') + journal = json.loads((self.manager.root / 'last.json').read_text()) + journal['files'][-1]['key'] = 'slot:../../escape' + accounts.atomic(self.manager.pending, accounts.encoded(journal)) + with self.assertRaises(ValueError): + self.manager.recover() + self.assertEqual(self.manager.auth.read_bytes(), self.b) + journal = json.loads((self.manager.root / 'last.json').read_text()) + accounts.atomic(self.manager.pending, accounts.encoded(journal)) + accounts.atomic(Path(result['backup']) / '0', b'synthetic-tamper') + with self.assertRaises(ValueError): + self.manager.recover() + self.assertEqual(self.manager.auth.read_bytes(), self.b) + + def test_slot_symlink_and_public_cache_permissions_rejected(self): + self.ready() + path = self.manager.slot('b') + path.unlink() + path.symlink_to(self.manager.auth) + with self.assertRaises(ValueError): + self.manager.switch('b') + path.unlink() + self.manager.auth.chmod(0o644) + with self.assertRaises(ValueError): + self.manager.selected() + for bad in ('../a', 'A', 'a/b', ''): + with self.assertRaises(ValueError): + self.manager.slot(bad) + + def test_vault_route_change_rejected_even_before_first_activation(self): + self.enroll_b() + changed = dict(self.spec, frontend=self.home / 'different frontend') + with self.assertRaisesRegex(ValueError, 'different GPT route'): + accounts.Accounts(self.home, changed, self.idle, None, None) + + def test_api_key_invalid_cache_and_explicit_keyring_are_not_converted(self): + for raw in (b'{"OPENAI_API_KEY":"synthetic-secret"}', b'{broken-synthetic-secret', b'[]'): + with self.assertRaises(ValueError) as error: + accounts.identity(raw) + self.assertNotIn('synthetic-secret', str(error.exception)) + for store in ('keyring', 'ephemeral'): + with self.assertRaises(ValueError): + accounts.file_config(f'cli_auth_credentials_store="{store}"\n'.encode()) + + def test_optional_null_workspace_id_has_stable_identity(self): + value = json.loads(self.a) + value['tokens']['account_id'] = None + null_identity = accounts.identity(accounts.encoded(value)) + del value['tokens']['account_id'] + self.assertEqual(accounts.identity(accounts.encoded(value)), null_identity) + + def test_real_open_sqlite_owner_is_detected_without_reading_it(self): + if sys.platform != 'darwin': + self.skipTest('macOS lsof ownership guard') + conn = sqlite3.connect(self.spec['home'] / 'state_5.sqlite') + self.addCleanup(conn.close) + conn.execute('create table example (n int)') + with patch.object(apps, 'app_bundle'), patch.object(apps, 'executable'), patch.object(apps, 'running', return_value=None): + with self.assertRaisesRegex(ValueError, 'App/CLI process'): + apps.account_idle(self.home, self.spec) + + def test_cooperating_cli_lease_blocks_switch_but_not_other_cli(self): + self.ready() + with accounts.credential_lock(self.home, shared=True): + with accounts.credential_lock(self.home, shared=True): + with self.assertRaises(BlockingIOError): + self.manager.switch('b') + self.assertEqual(self.manager.auth.read_bytes(), self.a) + + + + def test_app_cli_backend_ownership_checks(self): + with patch.object(apps, 'app_bundle'), patch.object(apps, 'executable'), patch.object(apps, 'running', return_value=42): + with self.assertRaisesRegex(ValueError, 'Quit the GPT'): + apps.account_idle(self.home, self.spec) + with patch.object(apps, 'app_bundle'), patch.object(apps, 'executable'), patch.object(apps, 'running', return_value=None), patch.object(apps.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, stdout='p77\ncbackend\nf10\nau\n')): + with self.assertRaisesRegex(ValueError, 'App/CLI process'): + apps.account_idle(self.home, self.spec) + + def test_explicit_read_only_service_exception_does_not_exempt_writers(self): + for output, permitted in [ + ('p77\ncfilesystem\nf10\nar\n', True), + ('p77\ncfilesystem\nf10\nau\n', False), + ('p77\ncfilesystem\nf10\nar\nf11\naw\n', False), + ('p77\ncfilesystem\nf10\n', False), + ('p77\ncfilesystem\n', False), + ('p77\ncfilesystem\nfcwd\na \n', False), + ('p88\ncbackend\nf10\nau\n', False), + ('p77\ncfilesystem\nf10\nar\np88\ncbackend\nf20\nau\n', False), + ]: + with self.subTest(output=output), patch.object(apps, 'app_bundle'), patch.object(apps, 'executable'), patch.object(apps, 'running', return_value=None), patch.object(apps.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, stdout=output)): + if permitted: + apps.account_idle(self.home, self.spec, [77]) + with self.assertRaises(ValueError): + apps.account_idle(self.home, self.spec) + else: + with self.assertRaises(ValueError): + apps.account_idle(self.home, self.spec, [77]) + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/presentation/test_native_accounts_install.py b/tests/presentation/test_native_accounts_install.py new file mode 100644 index 0000000000..420aefbbcc --- /dev/null +++ b/tests/presentation/test_native_accounts_install.py @@ -0,0 +1,96 @@ +"""Installed wheel owns native transactions without personal-apps or dotfiles.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Native account stores require POSIX locks") +ROOT = Path(__file__).resolve().parents[2] + +INSTALLED_CHECK = r''' +import base64, hashlib, json, os, plistlib, shutil, sqlite3, sys +from pathlib import Path +from unittest.mock import patch +from loopx_codex_provider_routing import native_account_store as store +from loopx_codex_provider_routing import native_app as app +from loopx_codex_provider_routing.native_host import discover, NativeHost +root = Path(sys.argv[1]) +assert Path(store.__file__).is_relative_to(Path(sys.prefix)) +assert shutil.which("personal-apps") is None +home = root / "home" +home.mkdir() +codex = home / ".codex" +codex.mkdir(mode=0o700) +bundle = home / "Applications/Codex.app" +executable = bundle / "Contents/MacOS/Codex" +executable.parent.mkdir(parents=True) +executable.write_text("#!/bin/sh\nexit 0\n") +executable.chmod(0o755) +(bundle / "Contents/Info.plist").write_bytes(plistlib.dumps({"CFBundleExecutable":"Codex"})) +(codex / "config.toml").write_text('model_provider="openai"\nmodel="example-model"\n') +claims = base64.urlsafe_b64encode(b'{"sub":"synthetic"}').decode().rstrip('=') +auth = store.encoded({"auth_mode":"chatgpt","tokens": { + "id_token":"fixture."+claims+".signature","access_token":"fixture-access", + "refresh_token":"fixture-refresh","account_id":"fixture-account"}}) +store.atomic(codex / "auth.json", auth) +with sqlite3.connect(codex / "history.sqlite") as database: + database.execute("create table fixture (text text)") + database.execute("insert into fixture values ('existing synthetic history')") +history = (codex / "history.sqlite").read_bytes() +with patch.dict(os.environ, {"CODEX_HOME": str(codex), "CODEX_ELECTRON_USER_DATA_PATH": str(home / "frontend")}): + adapter = discover(user_home=home) + host = NativeHost(adapter, user_home=home) + assert host.status()["slots"] == [] + assert not host.manager.root.exists() + # The store/backend is real; no native process is launched by this install check. + host.manager.idle = lambda: None + enrolled = host.enroll_current(host.revision()) + assert enrolled["slots"] == ["primary"] + host.enroll_api("openai-api", "sk-loopx-relay-synthetic-installed-token", "http://127.0.0.1:32100/v1") + host.manager.switch("openai-api") + assert host.status()["api_slot"] == "openai-api" + assert host.status()["current_login_matches"] is True + host.manager.recover(rollback=True) + assert host.manager.auth.read_bytes() == auth + assert (codex / "history.sqlite").read_bytes() == history + assert "personal_apps" not in sys.modules and "personal_accounts" not in sys.modules + assert not (home / ".local/state/personal-apps").exists() + assert host.manager.root.stat().st_mode & 0o777 == 0o700 +print(json.dumps({"installed_owner":True,"external_modules":False,"rollback":True,"history_preserved":True})) +''' + + +def test_installed_wheel_without_dotfiles(tmp_path): + uv = shutil.which("uv") + assert uv, "Source-checkout validation requires uv" + wheels = tmp_path / "wheels" + built = subprocess.run([uv, "build", "--wheel", "--out-dir", str(wheels), + str(ROOT / "packages/loopx-codex-provider-routing")], + text=True, capture_output=True, timeout=60) + assert built.returncode == 0, built.stderr[-1500:] + environment = tmp_path / "environment" + subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(environment)], check=True, capture_output=True, timeout=30) + python = environment / "bin/python" + installed = subprocess.run([uv, "pip", "install", "--python", str(python), "--no-deps", str(next(wheels.glob("*.whl")))], + text=True, capture_output=True, timeout=30) + assert installed.returncode == 0, installed.stderr[-1000:] + binary = tmp_path / "bin" + binary.mkdir() + stub = binary / "codex" + stub.write_text("#!/bin/sh\nexit 0\n") + stub.chmod(0o755) + clean = {key: value for key, value in os.environ.items() if key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"}} + clean["PATH"] = str(binary) + os.pathsep + os.defpath + # Exercise the installed CLI as well as the packaged native backend. + read = subprocess.run([str(environment / "bin/loopx-codex-accounts"), "keys-status", "--runtime-root", str(tmp_path / "runtime")], + cwd=tmp_path, env=clean, text=True, capture_output=True, timeout=15) + assert read.returncode == 0 and json.loads(read.stdout)["keys"] == [] + assert not (tmp_path / "runtime").exists() + result = subprocess.run([str(python), "-c", INSTALLED_CHECK, str(tmp_path)], cwd=tmp_path, env=clean, + text=True, capture_output=True, timeout=30) + assert result.returncode == 0, result.stderr[-2000:] + assert json.loads(result.stdout)["external_modules"] is False diff --git a/tests/presentation/test_native_app.py b/tests/presentation/test_native_app.py new file mode 100644 index 0000000000..ac4c3762b8 --- /dev/null +++ b/tests/presentation/test_native_app.py @@ -0,0 +1,145 @@ +"""Packaged Codex profile/launch boundaries, without a personal dotfiles install.""" +import importlib +import json +import os +from pathlib import Path +import plistlib +import sys +from unittest.mock import Mock + +import pytest + +if os.name != "posix": + pytest.skip("Native account IO requires POSIX locks", allow_module_level=True) + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "packages/loopx-codex-provider-routing/src")) +app = importlib.import_module("loopx_codex_provider_routing.native_app") + + +@pytest.fixture +def profile(tmp_path, monkeypatch): + home = tmp_path / "synthetic home" + home.mkdir() + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.delenv("CODEX_ELECTRON_USER_DATA_PATH", raising=False) + return home, app.route(home) + + +def bundle(home): + path = home / "Applications/Codex.app" + executable = path / "Contents/MacOS/Codex" + executable.parent.mkdir(parents=True) + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + (path / "Contents/Info.plist").write_bytes(plistlib.dumps({"CFBundleExecutable": "Codex"})) + return path + + +def test_default_profile_discovery_is_read_only_and_does_not_require_dotfiles(profile): + home, spec = profile + assert spec["home"] == home / ".codex" + assert spec["account_store"] == home / ".loopx/codex-native-accounts/vault" + assert list(home.iterdir()) == [] + app.check_provider(spec) # A missing config is a native default, not an IO failure. + assert list(home.iterdir()) == [] + + +def test_existing_profile_reuses_vault_and_locks_without_copying(profile): + home, _ = profile + path = home / ".config/personal/apps.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"version": 1, "apps": {"gpt": { + "home": "~/.codex-gpt", "frontend": "~/Library/Synthetic GPT", "app": ""}}})) + spec = app.route(home) + assert spec["home"] == home / ".codex-gpt" + assert spec["lock_root"] == home / ".local/state/personal-apps" + assert spec["account_store"] == spec["lock_root"] / "gpt-accounts" + assert not spec["account_store"].exists() + path.chmod(0o666) + with pytest.raises(ValueError, match="not trusted"): + app.route(home) + + +def test_profile_rejects_overlapping_and_symlinked_state(profile, monkeypatch): + home, _ = profile + for frontend in (home / ".codex", home / ".codex/child"): + monkeypatch.setenv("CODEX_ELECTRON_USER_DATA_PATH", str(frontend)) + with pytest.raises(ValueError, match="separate"): + app.route(home) + monkeypatch.delenv("CODEX_ELECTRON_USER_DATA_PATH") + (home / ".codex").symlink_to(home / "another-home") + with pytest.raises(ValueError, match="symlinks"): + app.route(home) + + +@pytest.mark.parametrize("occupied", [True, False]) +def test_launch_rechecks_caller_admission_under_same_lock(profile, monkeypatch, occupied): + home, spec = profile + spec["app"] = str(bundle(home)) + preflight = Mock() + monkeypatch.setattr(app, "running", Mock(side_effect=[42, 42] if occupied else [None, None, 42])) + run = Mock() + monkeypatch.setattr(app.subprocess, "run", run) + child = Mock(pid=42) + child.poll.return_value = None + launch = Mock(return_value=child) + monkeypatch.setattr(app.subprocess, "Popen", launch) + result = app.open_codex(home, spec, preflight=preflight) + assert preflight.call_count == 2 + assert result["pid"] == 42 + if occupied: + assert result["action"] == "focused" + assert run.call_args.args[0][-1] == "42" + assert "NSRunningApplication" in run.call_args.kwargs["input"] + launch.assert_not_called() + else: + assert result["action"] == "launch-requested" + argv = launch.call_args.args[0] + env = launch.call_args.kwargs["env"] + assert argv[1:] == ["--user-data-dir=" + str(spec["frontend"])] + assert env["CODEX_HOME"] == str(spec["home"]) + assert env["CODEX_ELECTRON_USER_DATA_PATH"] == str(spec["frontend"]) + assert spec["frontend"].stat().st_mode & 0o777 == 0o700 + + +def test_changed_admission_cannot_launch_or_focus(profile, monkeypatch): + home, spec = profile + spec["app"] = str(bundle(home)) + monkeypatch.setattr(app, "running", Mock(return_value=None)) + launch, run = Mock(), Mock() + monkeypatch.setattr(app.subprocess, "Popen", launch) + monkeypatch.setattr(app.subprocess, "run", run) + def admit(): + if (spec["lock_root"] / "launch.lock").exists(): + raise ValueError("selection changed") + with pytest.raises(ValueError, match="selection changed"): + app.open_codex(home, spec, preflight=admit) + launch.assert_not_called() + run.assert_not_called() + + +def test_clean_environment_cannot_reuse_another_codex_session(profile, monkeypatch): + home, spec = profile + for name in ("CODEX_THREAD_ID", "CODEX_APP_TOOLS_PIPE_PATH", "OPENAI_API_KEY", "NODE_OPTIONS"): + monkeypatch.setenv(name, "synthetic-inherited-setting") + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:32100") + env = app.clean_env(spec, home) + assert not set(env) & {"CODEX_THREAD_ID", "CODEX_APP_TOOLS_PIPE_PATH", "OPENAI_API_KEY", "NODE_OPTIONS"} + assert env["CODEX_HOME"] == str(spec["home"]) + assert env["HTTPS_PROXY"] == os.environ["HTTPS_PROXY"] + + +def test_register_current_requires_a_subscription_login(profile): + home, spec = profile + host_module = importlib.import_module("loopx_codex_provider_routing.native_host") + store = importlib.import_module("loopx_codex_provider_routing.native_account_store") + store.atomic(spec["home"] / "auth.json", store.encoded({"auth_mode": "apikey", "OPENAI_API_KEY": "example-key"})) + host = object.__new__(host_module.NativeHost) + host.accounts = store + host.manager = Mock(auth=spec["home"] / "auth.json") + host.manager.status.return_value = {"active_slot": None} + host.revision = lambda: "same-selection" + with pytest.raises(host_module.HostError, match="native_login_required"): + host.enroll_current("same-selection") + host.manager.enroll.assert_not_called() diff --git a/tests/presentation/test_native_observer_health.py b/tests/presentation/test_native_observer_health.py new file mode 100644 index 0000000000..6488fcd27f --- /dev/null +++ b/tests/presentation/test_native_observer_health.py @@ -0,0 +1,165 @@ +"""Real observer lifecycle and private files; no native credentials or effects.""" +import importlib +import json +import os +from pathlib import Path +import time +import threading + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Native observer ownership uses POSIX locks") + + +@pytest.fixture +def service(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / + "packages/loopx-codex-provider-routing/src")) + native = importlib.import_module("loopx_codex_provider_routing.native") + monkeypatch.setattr(native, "discover", lambda: {"synthetic": True}) + + def transport(_adapter, operation, **parameters): + if operation == "status": + return {"slots": ["a"], "active_slot": "a", "selection_revision": "synthetic", + "current_login_matches": True, "recovery_required": False} + if operation == "observe": + return {"observations": [{"slot": slot, "status": "ok", "observed_at": time.time(), + "usage": {"rate_limit": {"primary_window": {"used_percent": 20, + "reset_at": time.time() + 3600}}, + "credits": {"has_credits": False}}} + for slot in parameters["slots"]]} + raise AssertionError("No native effects are authorized: " + operation) + + provider = native.NativeAccounts(tmp_path, transport=transport) + provider.configure(expected_revision=0, enabled=True, slots=["a"]) + monkeypatch.setattr(native, "NativeAccounts", lambda _root: provider) + from loopx.presentation.codex_accounts_api import CodexAccountService + owner = CodexAccountService(tmp_path) + try: + yield owner, provider, native + finally: + owner.close() + + +def test_failed_disable_restores_the_persisted_enabled_observer(service): + owner, provider, _ = service + result = owner.call("configure", {"expected_revision": 0, "enabled": False, "slots": ["a"]}) + assert result["ok"] is False and result["code"] == "configuration_changed" + assert provider._load()["policy"]["enabled"] is True + assert owner.watcher.stop.is_set() is False, "A refused edit cannot silently stop authorized observation" + + +def test_status_recovers_a_stopped_observer_without_a_policy_write(service): + owner, provider, _ = service + original = owner.watcher + original.close() + revision = provider._load()["revision"] + result = owner.call("status", {}) + assert result["ok"] is True + assert owner.watcher.stop.is_set() is False + assert owner.watcher.thread.is_alive() + assert provider._load()["revision"] == revision + assert provider._load()["policy"]["slots"] == ["a"] + + +def test_service_shutdown_does_not_reactivate_the_observer(service): + owner, _, _ = service + owner.close() + owner.call("status", {}) + assert owner.watcher.stop.is_set() + + +def test_health_requires_a_live_lock_and_does_not_reuse_a_stale_success(service): + owner, provider, native = service + deadline = time.monotonic() + 5 + while provider.observer_health(True)["state"] != "healthy" and time.monotonic() < deadline: + time.sleep(.02) + assert provider.observer_health(True)["state"] == "healthy" + marker = provider.root / "observer.json" + assert marker.stat().st_mode & 0o777 == 0o600 + original = marker.read_bytes() + owner.close() + assert provider.observer_health(True)["state"] == "stopped" + assert marker.read_bytes() == original, "A health read must not refresh its own heartbeat" + with provider._lock(name="observer.lock"): + native.atomic(marker, {"schema_version": native.OBSERVER_SCHEMA, "build": native.OBSERVER_BUILD, + "checked_at": provider.clock() - 100, "deadline_at": provider.clock() - 1, + "phase": "waiting"}) + assert provider.observer_health(True)["state"] == "overdue" + native.atomic(marker, {"schema_version": native.OBSERVER_SCHEMA, "build": "old-build", + "checked_at": provider.clock(), "deadline_at": provider.clock() + 75, + "phase": "waiting"}) + assert provider.observer_health(True)["state"] == "upgrade_required" + marker.unlink() + assert provider.observer_health(True)["state"] == "unverified" + marker.write_text('["private synthetic marker detail"]') + marker.chmod(0o600) + assert provider.observer_health(True)["state"] == "unavailable" + assert "private synthetic marker detail" not in json.dumps(provider.observer_health(True)) + + +def test_unexpected_tick_failure_retries_and_progresses_without_leaking_details(service, monkeypatch): + owner, provider, native = service + owner.watcher.close() + watcher = native.Watcher(provider) + owner.watcher = watcher + original_tick, original_atomic, original_wait = provider.tick, native.atomic, watcher.stop.wait + phases, calls = [], [] + + def tick(): + calls.append(True) + if len(calls) == 1: + raise RuntimeError("private synthetic provider detail") + return original_tick() + + def write(path, value): + if path.name == "observer.json": + phases.append(value["phase"]) + return original_atomic(path, value) + + monkeypatch.setattr(provider, "tick", tick) + monkeypatch.setattr(native, "atomic", write) + # Shorten only the synthetic observer's waits, preserving its real loop, + # lock, heartbeat writer, policy and retry path. + monkeypatch.setattr(watcher.stop, "wait", lambda seconds: original_wait(min(seconds, .01))) + watcher.start() + deadline = time.monotonic() + 5 + while "waiting" not in phases and time.monotonic() < deadline: + time.sleep(.02) + watcher.close() + assert "retrying" in phases and "waiting" in phases and len(calls) >= 2 + assert "private synthetic provider detail" not in (provider.root / "observer.json").read_text() + assert json.loads((provider.root / "observer.json").read_text())["build"] == native.OBSERVER_BUILD + + +def test_start_replaces_a_dead_thread_but_preserves_explicit_stop(service): + owner, provider, native = service + owner.watcher.close() + owner.watcher = native.Watcher(provider) + dead = threading.Thread(target=lambda: None) + dead.start() + dead.join() + owner.watcher.thread = dead + owner.watcher.start() + assert owner.watcher.thread is not dead and owner.watcher.thread.is_alive() + owner.watcher.close() + stopped = owner.watcher.thread + owner.watcher.start() + assert owner.watcher.thread is stopped and owner.watcher.stop.is_set() + + +def test_service_restart_rehydrates_health_with_the_same_saved_permissions(service): + original, provider, _ = service + revision, policy = provider._load()["revision"], provider._load()["policy"] + original.close() + from loopx.presentation.codex_accounts_api import CodexAccountService + restarted = CodexAccountService(provider.root.parents[1]) + try: + deadline = time.monotonic() + 5 + while provider.observer_health(True)["state"] != "healthy" and time.monotonic() < deadline: + time.sleep(.02) + assert restarted.watcher.thread.is_alive() + assert provider.observer_health(True)["state"] == "healthy" + assert provider._load()["revision"] == revision and provider._load()["policy"] == policy + finally: + restarted.close() diff --git a/tests/presentation/test_openai_fallback.py b/tests/presentation/test_openai_fallback.py new file mode 100644 index 0000000000..32b49ee495 --- /dev/null +++ b/tests/presentation/test_openai_fallback.py @@ -0,0 +1,92 @@ +"""Native policy/store transaction integration; no installed login or model IO.""" +import importlib +import os +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Native policy uses POSIX locks") + + +@pytest.fixture +def setup(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "packages/loopx-codex-provider-routing/src")) + native = importlib.import_module("loopx_codex_provider_routing.native") + relay = importlib.import_module("loopx_codex_provider_routing.openai_relay") + monkeypatch.setattr(native, "discover", lambda: {"synthetic": True}) + state = {"active": "a", "revision": "initial", "api_slot": None, "supported": True, + "now": 1000, "b_used": 100, "b_status": "ok", "calls": [], "relay_starts": 0} + def ensure(_owner): + state["relay_starts"] += 1 + return {"slot": "openai-api", "api_key": "synthetic-loopback-token", "base_url": "http://127.0.0.1:32100/v1"} + monkeypatch.setattr(relay, "ensure", ensure) + def transport(adapter, operation, **parameters): + state["calls"].append(operation) + if operation == "status": + return {"slots": ["a", "b"], "active_slot": state["active"], "selection_revision": state["revision"], + "current_login_matches": True, "recovery_required": False, + "api_slot": state["api_slot"], "api_fallback_supported": state["supported"]} + if operation == "enroll_api": + assert parameters["slot"] == "openai-api" + state["api_slot"] = "openai-api" + return transport(adapter, "status") + if operation == "observe": + assert "openai-api" not in parameters["slots"], "API keys never enter the subscription quota reader" + return {"observations": [{"slot": slot, "observed_at": state["now"], + "status": state["b_status"] if slot == "b" else "ok", "usage": { + "rate_limit": {"primary_window": {"used_percent": 100 if slot == "a" else state["b_used"], "reset_at": 9000}}, + "credits": {"has_credits": False}}} for slot in parameters["slots"]]} + if operation == "switch": + assert parameters["expected_revision"] == state["revision"] + state["active"] = parameters["slot"] + state["revision"] += "-switched" + return transport(adapter, "status") | {"launch_failed": False} + raise AssertionError(operation) + provider = native.NativeAccounts(tmp_path, transport=transport, clock=lambda: state["now"]) + provider.api_keys().add(expected_revision=0, label="Fallback", api_key="sk-" + "synthetic-key-" * 3) + return provider, state + + +def test_automatic_api_handoff_then_recovered_subscription_adoption(setup): + provider, state = setup + provider.configure(expected_revision=0, enabled=True, slots=["a", "b"], api_fallback=True) + assert state["active"] == "a", "Enabling fallback never switches by itself" + result = provider.tick(refresh=True) + assert result["active_slot"] == "openai-api" and result["api_active"] is True + assert result["policy"]["slots"] == ["a", "b"], "The paid route is a separate explicit permission" + state.update(now=1301, b_used=10) + result = provider.tick(refresh=True) + assert result["active_slot"] == "b" and result["api_active"] is False + assert state["calls"].count("switch") == 2 + + +def test_available_or_unknown_subscription_never_starts_a_paid_handoff(setup): + provider, state = setup + provider.configure(expected_revision=0, enabled=True, slots=["a", "b"], api_fallback=True) + for status, used in [("ok", 50), ("unavailable", 100)]: + state.update(b_status=status, b_used=used) + if status == "ok": + # Available B must win over API. + assert provider.tick(refresh=True)["active_slot"] == "b" + else: + # Simulate an external native return; preserve its selection fence. + state.update(active="a", revision="manual-return") + provider.tick(refresh=True) + state["now"] += 301 + assert provider.tick(refresh=True)["active_slot"] == "a" + assert state["calls"].count("switch") == 1 + + +def test_saved_keys_do_not_opt_existing_subscription_policies_into_paid_usage(setup): + provider, state = setup + provider.configure(expected_revision=0, enabled=True, slots=["a", "b"]) + assert provider.tick(refresh=True)["action"] == "all_unavailable" + assert state["relay_starts"] == 0 and "enroll_api" not in state["calls"] and "switch" not in state["calls"] + + +def test_old_owner_cannot_be_bypassed_by_fallback_configuration(setup): + provider, state = setup + state["supported"] = False + with pytest.raises(Exception, match="api_owner_upgrade_required"): + provider.configure(expected_revision=0, enabled=True, slots=["a", "b"], api_fallback=True) + assert not provider.path.exists() and state["relay_starts"] == 0 diff --git a/tests/presentation/test_openai_keys.py b/tests/presentation/test_openai_keys.py new file mode 100644 index 0000000000..a6606fbbc5 --- /dev/null +++ b/tests/presentation/test_openai_keys.py @@ -0,0 +1,157 @@ +"""Key configuration through the shipped store, CLI and loopback HTTP owner.""" +import importlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import urllib.error +import urllib.request + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="The private provider store uses POSIX locks") +KEY = "sk-" + "synthetic-key-" * 4 + + +@pytest.fixture +def owner(monkeypatch, tmp_path): + source = Path(__file__).resolve().parents[2] / "packages/loopx-codex-provider-routing/src" + monkeypatch.syspath_prepend(str(source)) + native = importlib.import_module("loopx_codex_provider_routing.native") + def forbidden(*_args, **_kwargs): + raise AssertionError("Key configuration cannot access native account IO") + monkeypatch.setattr(native, "discover", forbidden) + return native.NativeAccounts(tmp_path, transport=forbidden) + + +def test_configuration_is_private_revision_fenced_and_never_activates(owner): + keys = owner.api_keys() + assert keys.status()["keys"] == [] + assert not owner.root.exists() + result = keys.add(expected_revision=0, label="Primary", api_key=KEY) + assert KEY not in json.dumps(result) + assert result["balance_available"] is False + assert result["keys"][0]["check_status"] == "not_checked" + assert keys.path.stat().st_mode & 0o777 == 0o600 + assert owner.root.stat().st_mode & 0o777 == 0o700 + assert not owner.path.exists(), "Native policy and account selection stay untouched" + with pytest.raises(Exception, match="configuration_changed"): + keys.remove(expected_revision=0, key_id=result["keys"][0]["id"]) + with pytest.raises(Exception, match="duplicate_api_key"): + keys.add(expected_revision=1, label="Duplicate", api_key=KEY) + assert owner.api_keys().status() == result, "A fresh instance reads the same saved config" + keys.remove(expected_revision=1, key_id=result["keys"][0]["id"]) + assert keys.status()["keys"] == [] + + +@pytest.mark.parametrize("label,key,code", [ + ("Name", "not-a-key", "invalid_api_key"), + ("Name", "sk-admin-" + "a" * 32, "invalid_api_key"), + (KEY, KEY, "invalid_key_label"), + ("Name\nInjected", KEY, "invalid_key_label"), +]) +def test_bad_inputs_never_create_a_store(owner, label, key, code): + with pytest.raises(Exception, match=code): + owner.api_keys().add(expected_revision=0, label=label, api_key=key) + assert not owner.root.exists() + + +def test_symlink_and_public_permissions_are_refused(owner, tmp_path): + keys = owner.api_keys() + keys.add(expected_revision=0, label="Primary", api_key=KEY) + keys.path.chmod(0o644) + with pytest.raises(Exception, match="state_untrusted"): + keys.status() + keys.path.chmod(0o600) + target = tmp_path / "target.json" + keys.path.rename(target) + keys.path.symlink_to(target) + with pytest.raises(Exception, match="state_untrusted"): + keys.status() + + +@pytest.mark.parametrize("http_status,expected", [(200, "models_readable"), (401, "authentication_failed"), + (403, "permission_denied"), (429, "rate_limited"), (503, "unavailable")]) +def test_model_listing_does_not_claim_billing_quota(owner, monkeypatch, http_status, expected): + module = importlib.import_module("loopx_codex_provider_routing.openai_keys") + seen = [] + class Opener: + def open(self, request, timeout): + seen.append(request) + assert timeout == 10 and request.get_method() == "GET" + assert request.full_url == "https://api.openai.com/v1/models" + if http_status != 200: + raise urllib.error.HTTPError(request.full_url, http_status, "private provider text", {}, io.BytesIO(KEY.encode())) + return io.BytesIO(b'{"data":[{"id":"synthetic-model"}]}') + monkeypatch.setattr(module.urllib.request, "build_opener", lambda *_: Opener()) + keys = owner.api_keys() + saved = keys.add(expected_revision=0, label="Primary", api_key=KEY) + result = keys.check(expected_revision=1, key_id=saved["keys"][0]["id"]) + assert len(seen) == 1 and result["keys"][0]["check_status"] == expected + assert result["balance_available"] is False + assert KEY not in json.dumps(result) and "private provider text" not in json.dumps(result) + + +def test_transport_failure_and_redirect_keep_the_key_without_guessing_quota(owner, monkeypatch): + module = importlib.import_module("loopx_codex_provider_routing.openai_keys") + class Offline: + def open(self, *_args, **_kwargs): + raise OSError("private network detail") + monkeypatch.setattr(module.urllib.request, "build_opener", lambda *_: Offline()) + assert module.check_models(KEY) == "unavailable" + with pytest.raises(Exception, match="key_check_unavailable"): + module.NoRedirect().redirect_request(None, None, None, None, None, None) + + +def test_http_owner_add_read_remove_and_bad_origin(owner): + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + from loopx.presentation.codex_accounts_api import CodexAccountService + service = CodexAccountService(owner.root.parents[1]) + service.provider = owner + 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() + url = f"http://127.0.0.1:{server.server_port}/api/chat/codex-accounts/keys" + def call(body=None, origin=None): + data = json.dumps(body).encode() if body else None + request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json", **({"Origin": origin} if origin else {})}) + with urllib.request.urlopen(request, timeout=5) as response: + return json.load(response) + try: + assert call()["keys"] == [] and not owner.root.exists() + added = call({"operation": "keys-add", "parameters": {"expected_revision": 0, "label": "Primary", "api_key": KEY}}) + assert KEY not in json.dumps(added) and call() == added + with pytest.raises(urllib.error.HTTPError) as error: + call({"operation": "keys-add", "parameters": {}}, origin="https://outside.example") + assert error.value.code == 403 + for operation, parameters in [("keys-check", {"expected_revision": 1, "key_id": added["keys"][0]["id"], "base_url": "https://outside.example"}), + ("switch", {"manual_slot": "a"})]: + with pytest.raises(urllib.error.HTTPError) as error: + call({"operation": operation, "parameters": parameters}) + assert error.value.code == 409 + removed = call({"operation": "keys-remove", "parameters": {"expected_revision": 1, "key_id": added["keys"][0]["id"]}}) + assert removed["keys"] == [] and not owner.path.exists() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + service.close() + + +def test_cli_takes_secrets_only_on_stdin(owner, tmp_path): + command = [sys.executable, "-m", "loopx_codex_provider_routing.native"] + arguments = ["--runtime-root", str(tmp_path)] + source = Path(__file__).resolve().parents[2] / "packages/loopx-codex-provider-routing/src" + environment = {**os.environ, "PYTHONPATH": str(source)} + result = subprocess.run([*command, "keys-add", *arguments], input=json.dumps({"expected_revision": 0, "label": "Primary", "api_key": KEY}), + env=environment, cwd=tmp_path, text=True, capture_output=True, check=True, timeout=10) + assert KEY not in result.stdout + result.stderr + assert len(json.loads(result.stdout)["keys"]) == 1 + read = subprocess.run([*command, "keys-status", *arguments], env=environment, cwd=tmp_path, + text=True, capture_output=True, check=True, timeout=10) + assert json.loads(read.stdout)["keys"][0]["label"] == "Primary" diff --git a/tests/presentation/test_openai_relay.py b/tests/presentation/test_openai_relay.py new file mode 100644 index 0000000000..2b993d31f9 --- /dev/null +++ b/tests/presentation/test_openai_relay.py @@ -0,0 +1,134 @@ +"""Real loopback HTTP and private store, with synthetic upstream transport.""" +import importlib +import io +import json +import os +from pathlib import Path +import threading +import urllib.error +import urllib.request + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Native policy uses POSIX locks") + + +class Response(io.BytesIO): + status = 200 + headers = {"Content-Type": "text/event-stream", "x-ratelimit-remaining-tokens": "1234"} + + +@pytest.fixture +def relay(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "packages/loopx-codex-provider-routing/src")) + native = importlib.import_module("loopx_codex_provider_routing.native") + module = importlib.import_module("loopx_codex_provider_routing.openai_relay") + owner = native.NativeAccounts(tmp_path, clock=lambda: 1000) + keys = owner.api_keys() + for index in range(2): + keys.add(expected_revision=index, label=f"Key {index}", api_key="sk-" + "synthetic-key-" * 3 + str(index)) + state = owner._load() + state["policy"].update(enabled=True, api_fallback=True) + state["native"] = {"active_slot": "openai-api"} + native.atomic(owner.path, state) + calls, outcomes = [], [] + class Upstream: + def open(self, request, timeout): + calls.append(request) + assert request.full_url == "https://api.openai.com/v1/responses" + assert timeout == 300 + outcome = outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return outcome + server = module.RelayServer(owner, {"port": 0, "token": "example-loopback-token"}, opener=Upstream()) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + body = {"model": "unchanged-selected-model", "input": "synthetic prompt", "stream": True, "tools": []} + def post(token="example-loopback-token", origin=None): + headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"} + if origin: + headers["Origin"] = origin + request = urllib.request.Request(f"http://127.0.0.1:{server.server_port}/v1/responses", data=json.dumps(body).encode(), headers=headers) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return response.status, response.read() + except urllib.error.HTTPError as error: + return error.code, error.read() + yield owner, keys, calls, outcomes, post, body + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def rejected(status=429, code="insufficient_quota"): + return urllib.error.HTTPError("https://api.openai.com/v1/responses", status, "private upstream text", {}, + io.BytesIO(json.dumps({"error": {"code": code, "message": "private upstream text"}}).encode())) + + +def test_real_http_rotates_only_rejected_quota_and_preserves_model_body_and_stream(relay): + owner, keys, calls, outcomes, post, body = relay + stream = b'data: {"type":"response.completed","response":{"status":"completed"}}\n\n' + outcomes.extend([rejected(), Response(stream)]) + status, result = post() + assert status == 200 and result == stream and len(calls) == 2 + assert calls[0].data == calls[1].data == json.dumps(body).encode() + assert calls[0].get_header("Authorization") != calls[1].get_header("Authorization") + rows = keys.status()["keys"] + assert rows[0]["runtime"]["state"] == "quota_exhausted" + assert rows[1]["runtime"]["remaining_tokens"] == 1234 + assert keys.status()["balance_available"] is False + assert "synthetic prompt" not in keys.path.read_text() + + +def test_network_unknown_effects_never_try_another_key(relay): + _, keys, calls, outcomes, post, _ = relay + outcomes.append(OSError("connection lost after dispatch")) + status, _ = post() + assert status == 502 and len(calls) == 1 + assert all(row["runtime"] is None for row in keys.status()["keys"]) + + +def test_started_stream_never_replays_or_synthesizes_completion(relay): + _, _, calls, outcomes, post, _ = relay + class Interrupted(Response): + def read1(self, _size): + if self.tell(): + raise OSError("upstream disconnected") + return self.read() + partial = b'data: {"type":"response.created"}\n\n' + outcomes.append(Interrupted(partial)) + status, result = post() + assert status == 200 and result == partial and len(calls) == 1 + assert b"response.completed" not in result + + +def test_pool_exhaustion_is_bounded_and_does_not_probe_in_background(relay): + _, _, calls, outcomes, post, _ = relay + outcomes.extend([rejected(), rejected()]) + status, result = post() + assert status == 429 and len(calls) == 2 + assert b"private upstream text" not in result + status, _ = post() + assert status == 429 and len(calls) == 2, "Cooling keys must not be retried on each request" + + +def test_default_off_origin_and_token_guards_precede_upstream_access(relay): + owner, _, calls, _, post, _ = relay + assert post(token="example-wrong-token")[0] == 401 + assert post(origin="https://outside.example")[0] == 401 + native = importlib.import_module("loopx_codex_provider_routing.native") + state = owner._load() + state["policy"]["api_fallback"] = False + native.atomic(owner.path, state) + assert post()[0] == 503 + assert calls == [] + + +def test_enabled_api_pool_cannot_outrank_an_active_subscription(relay): + owner, _, calls, _, post, _ = relay + native = importlib.import_module("loopx_codex_provider_routing.native") + state = owner._load() + state["native"]["active_slot"] = "subscription-a" + native.atomic(owner.path, state) + assert post()[0] == 503 and calls == [] diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index edb808caf9..a675f37e82 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -2956,8 +2956,10 @@ def delayed(self, args, cwd=None, timeout=None): transport.admit("notes-app", provider.event("notes-app", "old-status", "/status")) service = LarkGoalTopicRuntimeService(snapshot_provider=lambda: {}, runtime_root=store.root.parent, runtime_controller=runtime, private_conversations=transport) - worker = threading.Thread(target=service._reconcile_private) - worker.start() + service.start() + service.start() + worker = service._delivery_thread + assert worker is not None try: assert blocked.wait(10) transport.admit("notes-app", provider.event("notes-app", "urgent-stop", "/stop")) @@ -2975,7 +2977,7 @@ def delayed(self, args, cwd=None, timeout=None): assert store.list_sessions() == [] finally: release.set() - service._closed.set() + service.close() worker.join(10) runtime.close() assert not worker.is_alive() @@ -3007,13 +3009,18 @@ def reconcile_request(self, path): service = LarkGoalTopicRuntimeService(snapshot_provider=lambda: {}, runtime_root=tmp_path, runtime_controller=None, private_conversations=Pending()) - worker = threading.Thread(target=service._reconcile_private) - worker.start() + service.start() + worker = service._delivery_thread + assert worker is not None try: assert full.wait(5) assert len(calls) == 4 and len(set(calls)) == 4 - service._closed.set() + closer = threading.Thread(target=service.close) + closer.start() + assert service._closed.wait(1) release.set() + closer.join(5) + assert not closer.is_alive() worker.join(5) assert not worker.is_alive() assert len(calls) == 4 # Five durable requests remain; none were queued in memory. diff --git a/tests/test_manager_context_roundtrip.py b/tests/test_manager_context_roundtrip.py index 195b00e28f..7116d617b5 100644 --- a/tests/test_manager_context_roundtrip.py +++ b/tests/test_manager_context_roundtrip.py @@ -1257,6 +1257,75 @@ def test_return_service_can_close_before_start(flow): assert not service.thread.is_alive() +def test_host_recovery_returns_the_original_result_once(flow, monkeypatch): + from loopx.capabilities.manager_context import roundtrip + root, registry, store, create = flow + _, _, receipt = create(True) + rid = receipt["request_id"] + acknowledge(root, "research", "worker", rid, "no_change", "Reviewed") + report(root, "research", "worker", rid, "conclusion", "Original result after host recovery.") + original, attempts, sends = roundtrip.drain, [], [] + sent = threading.Event() + + def failed_once(*args, **kwargs): + attempts.append(True) + if len(attempts) == 1: + raise AssertionError("synthetic host failure") + return original(*args, **kwargs) + + def transport(*_): + sends.append(True) + sent.set() + return {"reply_verified": True, "idempotency_key": "sha256:synthetic-proof"} + + monkeypatch.setattr(roundtrip, "drain", failed_once) + service = ReturnService(root, registry, store, transport) + service.start() + service.start() + try: + assert sent.wait(10) + finally: + service.close() + drain(root, registry, store, transport) + assert sends == [True] + state = json.loads((_root(root) / "replies" / rid / "conclusion.delivery.json").read_text()) + assert state["status"] == "delivered" + + +def test_host_recovery_retains_the_queued_source_probe_backoff(flow, monkeypatch): + from loopx.capabilities.manager_context import execution + root, registry, store, _ = flow + original = execution.QueuedSourcePump + created, probes, deadlines = [], [], [] + recovered = threading.Event() + + def make_pump(*args): + pump = original(*args) + tick = pump.tick + created.append(pump) + + def fail_after_first_probe(**kwargs): + tick(**kwargs) + probes.append(True) + deadlines.append(pump.next_probe) + if len(probes) == 1: + raise AssertionError("synthetic host failure after a real bounded probe") + recovered.set() + + pump.tick = fail_after_first_probe + return pump + + monkeypatch.setattr(execution, "QueuedSourcePump", make_pump) + service = ReturnService(root, registry, store, lambda *_: pytest.fail("No send is authorized")) + service.start() + try: + assert recovered.wait(10) + finally: + service.close() + assert len(created) == 1 and deadlines[0] == deadlines[1] + assert store.list_sessions() == [], "Host recovery cannot enroll a new Turn" + + @pytest.mark.parametrize("project", [False, True], ids=["steward", "project"]) def test_registration_revocation_blocks_return_without_retargeting(flow, project): root, registry, store, create = flow diff --git a/tests/test_pr_review_github_scan.py b/tests/test_pr_review_github_scan.py index 1382ca6479..f617374dd7 100644 --- a/tests/test_pr_review_github_scan.py +++ b/tests/test_pr_review_github_scan.py @@ -857,16 +857,48 @@ def test_head_drift_observation_does_not_suppress_the_new_remote_head( assert item["merge_readiness_observation"] is None -def test_merge_readiness_requires_goal_scope() -> None: +@pytest.mark.parametrize("hold", [None, "head", "review", "ci", "thread", "pagination"]) +def test_standalone_merge_readiness_keeps_all_gates_without_goal_writes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + hold: str | None, +) -> None: + pull_request = _merge_ready_pr() + threads = _complete_review_threads() + if hold == "head": + pull_request["headRefOid"] = HEAD_2 + elif hold == "review": + pull_request["reviews"] = [] + elif hold == "ci": + pull_request["statusCheckRollup"][0]["conclusion"] = "FAILURE" + elif hold == "thread": + threads = _complete_review_threads(1) + elif hold == "pagination": + threads["complete"] = False + monkeypatch.setattr(pr_review_cli_module, "resolve_current_github_login", lambda: "maintainer") + monkeypatch.setattr(pr_review_cli_module, "fetch_github_pull_request", lambda **_kwargs: pull_request) + monkeypatch.setattr(pr_review_cli_module, "fetch_github_review_thread_summary", lambda **_kwargs: threads) + + def no_goal_access(**_kwargs): + raise AssertionError("Standalone readiness cannot read or write Goal observations") + + monkeypatch.setattr(pr_review_cli_module, "read_readiness_observations", no_goal_access) + monkeypatch.setattr(pr_review_cli_module, "record_readiness_observation", no_goal_access) out: list[dict[str, object]] = [] result = pr_review_cli_module.handle_pr_review_command( _merge_readiness_args(goal_id=None), + runtime_root=tmp_path, output_format=lambda _args: "json", print_payload=_capture_payload(out), ) - assert result == 1 - assert out[0]["error"] == "merge readiness requires --goal-id" + assert result == (0 if hold is None else 1) + assert out[0]["ready"] is (hold is None) + assert out[0]["source"] == "github_cli" + assert out[0]["expected_exact_head"] == f"4110@{HEAD_1}" + assert out[0]["local_goal_observation_write_performed"] is False + assert "readiness_observation" not in out[0] + assert not list(tmp_path.iterdir()) def test_pr_review_cli_uses_machine_capability_priority_when_flag_is_omitted(