diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c8c1b458c1..af09564cd08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,7 @@ jobs: # step. A missing or malformed filter output must fail this job instead # of silently making every expensive job skip. ci: ${{ steps.scope.outputs.ci }} + desktop: ${{ steps.scope.outputs.desktop }} native: ${{ steps.matrices.outputs.native }} # Matrix include lists for keyring-smoke and npm-global-smoke, built and # shape-checked by the same validation step as `native`. @@ -265,6 +266,20 @@ jobs: - '.github/workflows/ci.yml' gui: - 'gui/**' + # Building both Linux package formats and booting their real payloads is + # substantially heavier than the Rust-only desktop-shell check. Keep it + # scoped to inputs that can change the packaged shell, dashboard or + # standalone sidecar. The workflow names itself so edits to this lane + # cannot skip their own E2E. + desktop: + - 'desktop/**' + - 'gui/**' + - 'src/**' + - 'scripts/build-standalone.ts' + - 'scripts/standalone-targets.ts' + - 'package.json' + - 'bun.lock' + - '.github/workflows/ci.yml' # The docs site is built by nothing else on a pull request. `ci` above # deliberately omits `docs-site/**` -- a prose edit has no business # starting the cross-platform suite -- and `deploy-docs.yml` triggers @@ -342,6 +357,7 @@ jobs: shell: bash env: CI_SCOPE: ${{ steps.filter.outputs.ci }} + DESKTOP_SCOPE: ${{ steps.filter.outputs.desktop }} run: | set -euo pipefail case "$CI_SCOPE" in @@ -353,6 +369,15 @@ jobs: exit 1 ;; esac + case "$DESKTOP_SCOPE" in + true|false) + printf 'desktop=%s\n' "$DESKTOP_SCOPE" >> "$GITHUB_OUTPUT" + ;; + *) + printf '::error::changes.outputs.desktop was %q, expected true or false\n' "$DESKTOP_SCOPE" + exit 1 + ;; + esac - name: Assert the native and matrix outputs are usable id: matrices @@ -1345,11 +1370,11 @@ jobs: desktop-shell: name: desktop shell needs: [changes, gates] - # Native-gated like platform-macos: the Rust shell is formatted, linted - # and tested only when native-capable paths changed. - if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.native == 'true') + # Native shell changes run the Rust checks; package-affecting changes also run the real Linux + # bundle acceptance. The aggregate gate below mirrors this union exactly. + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && (needs.changes.outputs.native == 'true' || needs.changes.outputs.desktop == 'true')) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -1359,7 +1384,11 @@ jobs: - name: Install Tauri Linux dependencies run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf dbus-x11 xvfb xauth wmctrl xdotool openbox + + - name: Setup Bun for packaged E2E + if: needs.changes.outputs.desktop == 'true' + uses: ./.github/actions/setup-project-bun - name: Setup Rust uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master @@ -1385,6 +1414,79 @@ jobs: - name: Run Rust tests run: cargo test --manifest-path desktop/src-tauri/Cargo.toml + - name: Install packaged E2E dependencies + if: needs.changes.outputs.desktop == 'true' + run: | + bun install --frozen-lockfile + cd desktop + bun install --frozen-lockfile + + - name: Build dashboard and bundled sidecar + if: needs.changes.outputs.desktop == 'true' + run: | + bun run build:gui + bun desktop/scripts/prepare-sidecar.ts --target x86_64-unknown-linux-gnu + + # Build separately. One format failing must not delete or hide the other + # format's evidence, and neither verification artifact needs an updater key. + - name: Preserve the compiled Linux sidecar + if: needs.changes.outputs.desktop == 'true' + run: chmod +x desktop/scripts/appimage-patchelf.py + + - name: Build Linux AppImage + if: needs.changes.outputs.desktop == 'true' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-appimage-target + PATCHELF: ${{ github.workspace }}/desktop/scripts/appimage-patchelf.py + run: bunx tauri build --ci --bundles appimage --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Build Linux deb + if: needs.changes.outputs.desktop == 'true' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-deb-target + run: bunx tauri build --ci --bundles deb --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Stage isolated Linux bundles + if: needs.changes.outputs.desktop == 'true' + env: + APPIMAGE_BUNDLE: ${{ runner.temp }}/opencodex-appimage-target/release/bundle/appimage + DEB_BUNDLE: ${{ runner.temp }}/opencodex-deb-target/release/bundle/deb + BUNDLE_ROOT: ${{ runner.temp }}/opencodex-linux-bundles + run: | + set -euo pipefail + mkdir -p "$BUNDLE_ROOT/appimage" "$BUNDLE_ROOT/deb" + cp -a "$APPIMAGE_BUNDLE/." "$BUNDLE_ROOT/appimage/" + cp -a "$DEB_BUNDLE/." "$BUNDLE_ROOT/deb/" + chmod -R a-w "$BUNDLE_ROOT" + + - name: Run Linux packaged-shell E2E + if: needs.changes.outputs.desktop == 'true' + env: + REPORT_PATH: ${{ runner.temp }}/opencodex-linux-e2e/report.json + run: | + set -euo pipefail + mkdir -p "$(dirname "$REPORT_PATH")" + dbus-run-session -- xvfb-run -a -s '-screen 0 1440x900x24' bash -lc ' + openbox >"$RUNNER_TEMP/opencodex-openbox.log" 2>&1 & + wm_pid=$! + trap '\''kill "$wm_pid" 2>/dev/null || true'\'' EXIT + bun desktop/scripts/linux-packaged-e2e.ts \ + --bundle-root "$RUNNER_TEMP/opencodex-linux-bundles" \ + --report "$REPORT_PATH" \ + --version "$(jq -r .version package.json)" + ' + + - name: Upload Linux packaged-shell E2E report + if: always() && needs.changes.outputs.desktop == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-packaged-shell-e2e + path: ${{ runner.temp }}/opencodex-linux-e2e/report.json + if-no-files-found: warn + retention-days: 7 + ci: name: ci if: always() @@ -1414,6 +1516,7 @@ jobs: CHANGES_SETUP_ACTION: ${{ needs.changes.outputs.setup_action }} CHANGES_REMOTE_HELPER: ${{ needs.changes.outputs.remote_helper }} CHANGES_NATIVE: ${{ needs.changes.outputs.native }} + CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -1434,8 +1537,8 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_CI" != "true" ]; then scoped=not-requested fi - # platform-macos, widget and desktop-shell carry a compound - # condition: the ordinary scope gate AND the native path filter. + # platform-macos and widget carry the ordinary scope gate AND the native path filter. + # desktop-shell accepts that native set plus the package-E2E set. # This mirrors that expression exactly; where it disagrees with the # jobs' own `if:`, the gate fails by name instead of demanding # success from a job that was deliberately left unselected. @@ -1443,6 +1546,10 @@ jobs: if [ "$EVENT_NAME" != "pull_request" ] || { [ "$CHANGES_CI" = "true" ] && [ "$CHANGES_NATIVE" = "true" ]; }; then native=requested fi + desktop_shell=not-requested + if [ "$EVENT_NAME" != "pull_request" ] || { [ "$CHANGES_CI" = "true" ] && { [ "$CHANGES_NATIVE" = "true" ] || [ "$CHANGES_DESKTOP" = "true" ]; }; }; then + desktop_shell=requested + fi packaging=not-requested if [ "$CHANGES_PACKAGING" = "true" ]; then packaging=requested @@ -1499,8 +1606,9 @@ jobs: changes|select-windows-runner) echo requested ;; test|storage-policy|api-usage|gates|keyring-smoke|docker-smoke) echo "$scoped" ;; - platform-macos|widget|desktop-shell) + platform-macos|widget) echo "$native" ;; + desktop-shell) echo "$desktop_shell" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; structure-gate) echo "$structure" ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 498fa9246e7..8857697a6a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -414,6 +414,7 @@ jobs: # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. - name: Build desktop bundles + if: runner.os != 'Linux' working-directory: desktop env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -429,9 +430,48 @@ jobs: # diagnostics on the first attempt; Apple signing commands stay non-verbose. run: bunx tauri ${{ runner.os == 'Linux' && '--verbose' || '' }} build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config "${{ runner.os == 'Windows' && format('{0}/opencodex-msi.json', runner.temp) || '{}' }}" + # Tauri patches a bundle-type marker into the application binary for each Linux format. + # Keep each format in its own Cargo target so the deb cannot inherit the AppImage marker + # and linuxdeploy cannot mutate the binary later consumed by the deb build. + - name: Build Linux AppImage bundle + if: runner.os == 'Linux' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-appimage-target + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles appimage + + - name: Build Linux deb bundle + if: runner.os == 'Linux' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-deb-target + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles deb + + - name: Stage isolated Linux release bundles + if: runner.os == 'Linux' + shell: bash + env: + DESKTOP_TARGET: ${{ matrix.target }} + APPIMAGE_TARGET: ${{ runner.temp }}/opencodex-appimage-target + DEB_TARGET: ${{ runner.temp }}/opencodex-deb-target + run: | + set -euo pipefail + bundle_root="$RUNNER_TEMP/opencodex-linux-release-bundles" + mkdir -p "$bundle_root/appimage" "$bundle_root/deb" + cp -a "$APPIMAGE_TARGET/$DESKTOP_TARGET/release/bundle/appimage/." "$bundle_root/appimage/" + cp -a "$DEB_TARGET/$DESKTOP_TARGET/release/bundle/deb/." "$bundle_root/deb/" + chmod -R a-w "$bundle_root" + echo "DESKTOP_BUNDLE_ROOT=$bundle_root" >> "$GITHUB_ENV" + + # After the isolated AppImage exists, and against that staged copy: the default Cargo target + # holds no Linux bundle any more, so verifying there would fail or check a stale artifact. - name: Verify the packaged Linux sidecar if: runner.os == 'Linux' - run: bash desktop/scripts/verify-linux-sidecar.sh + run: bash desktop/scripts/verify-linux-sidecar.sh "$DESKTOP_BUNDLE_ROOT/appimage" - name: Rename release assets shell: bash @@ -439,10 +479,15 @@ jobs: RELEASE_VERSION: ${{ inputs.version }} DESKTOP_TARGET: ${{ matrix.target }} run: | - bun desktop/scripts/collect-release-assets.ts \ + args=( \ --version "$RELEASE_VERSION" \ --target "$DESKTOP_TARGET" \ - --out dist/release + --out dist/release \ + ) + if [[ -n "${DESKTOP_BUNDLE_ROOT:-}" ]]; then + args+=(--bundle-root "$DESKTOP_BUNDLE_ROOT") + fi + bun desktop/scripts/collect-release-assets.ts "${args[@]}" # After the bundle exists, not before: a sweep that runs first passes by finding nothing. - name: Verify every Mach-O in the bundle carries the release identity diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 55729b02c3f..c7f847542d7 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -36,7 +36,7 @@ import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; -import { detectInstallFromPath } from "../src/update/install-detection.mjs"; +import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs"; import { pnpmOwnerInvocation, resolvePnpmGlobalOwner, @@ -71,7 +71,8 @@ try { } const require = createRequire(import.meta.url); const here = dirname(fileURLToPath(import.meta.url)); -const installMethod = detectInstallFromPath(here, { exists: existsSync }); +const installOwnership = detectInstallOwnershipFromPath(here, { exists: existsSync }); +const installMethod = installOwnership.installer; const cliPath = join(here, "..", "src", "cli", "index.ts"); const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT"; const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof="; @@ -924,6 +925,19 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") { process.exit(1); } +if (process.argv[2] === "update" && installMethod === "mise") { + if (installOwnership.owner) { + console.error( + `opencodex: this installation is externally managed by mise; update it with: mise upgrade ${installOwnership.owner.tool}`, + ); + } else { + console.error( + "opencodex: this installation appears to be managed by mise, but its ownership metadata is unreadable or inconsistent; repair the mise installation metadata before updating.", + ); + } + process.exit(1); +} + if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) { if (installMethod === "npm") runNpmSelfUpdate(); if (installMethod === "pnpm") runPnpmSelfUpdate(); diff --git a/desktop/package.json b/desktop/package.json index 5966c9235f2..7e469873090 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,6 +5,7 @@ "dev": "tauri dev", "build": "tauri build", "build:local": "bun scripts/build-local.ts", + "e2e:linux-packaged": "bun scripts/linux-packaged-e2e.ts", "icons": "bun scripts/generate-icons.ts", "icons:check": "bun scripts/generate-icons.ts --check", "prepare-sidecar": "bun scripts/prepare-sidecar.ts", diff --git a/desktop/scripts/appimage-patchelf.py b/desktop/scripts/appimage-patchelf.py index 4c7e73cd930..63e78ef7416 100644 --- a/desktop/scripts/appimage-patchelf.py +++ b/desktop/scripts/appimage-patchelf.py @@ -5,17 +5,51 @@ import sys +APPDIR_SIDECAR_TAIL = ( + "release", + "bundle", + "appimage", + "OpenCodex.AppDir", + "usr", + "bin", + "ocx", +) + + +def prepared_sidecar(root, candidate, target_root): + """Return the one prepared Linux CLI that the AppDir sidecar exactly mirrors.""" + try: + relative = candidate.resolve().relative_to(target_root.resolve()) + except ValueError: + return None + if tuple(relative.parts[-len(APPDIR_SIDECAR_TAIL):]) != APPDIR_SIDECAR_TAIL: + return None + prefix = relative.parts[:-len(APPDIR_SIDECAR_TAIL)] + if len(prefix) > 1: + return None + + binaries = root / "desktop/src-tauri/binaries" + candidates = sorted(path for path in binaries.glob("ocx-*-linux-gnu") if path.is_file()) + if prefix: + candidates = [path for path in candidates if path.name == f"ocx-{prefix[0]}"] + matches = [path for path in candidates if path.read_bytes() == candidate.read_bytes()] + return matches[0] if len(matches) == 1 else None + + def main(args): root = Path(__file__).resolve().parents[2] - triple = "x86_64-unknown-linux-gnu" - original = root / "desktop/src-tauri/binaries" / f"ocx-{triple}" - sidecar = root / "desktop/src-tauri/target" / triple / "release/bundle/appimage/OpenCodex.AppDir/usr/bin/ocx" - if len(args) == 3 and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] and Path(args[2]).resolve() == sidecar.resolve(): + target_root = Path(os.environ.get("CARGO_TARGET_DIR", root / "desktop/src-tauri/target")) + sidecar = Path(args[2]) if len(args) == 3 else None + if ( + sidecar is not None + and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] + and prepared_sidecar(root, sidecar, target_root) is not None + ): # linuxdeploy's nested GTK pass runs ldd again after patching. Its # patchelf rewrite breaks the compiled Bun ELF. This sidecar depends # only on host glibc libraries; it needs no AppDir library search path. # Never bless an already-modified binary or a different executable. - if sidecar.is_symlink() or original.read_bytes() != sidecar.read_bytes(): + if sidecar.is_symlink(): raise RuntimeError("AppImage sidecar differs from the prepared CLI") print("Preserving compiled ocx bytes (no AppDir RPATH required)", file=sys.stderr) return diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts index 2c97de39d44..1d1266283e0 100644 --- a/desktop/scripts/collect-release-assets.ts +++ b/desktop/scripts/collect-release-assets.ts @@ -42,6 +42,7 @@ export interface CollectReleaseAssetsOptions { target: string; out: string; repoRoot?: string; + bundleRoot?: string; } function findBundle(directory: string, kind: BundleKind): string { @@ -61,13 +62,17 @@ export function collectReleaseAssets(options: CollectReleaseAssetsOptions): stri const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); const bundles = bundlesByTarget[options.target]; if (!bundles) throw new Error(`Unsupported desktop target: ${options.target}`); + const bundleRoot = resolve( + options.bundleRoot + ?? join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle"), + ); const output = resolve(options.out); mkdirSync(output, { recursive: true }); const written: string[] = []; for (const bundle of bundles) { const source = findBundle( - join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle", bundle.dir), + join(bundleRoot, bundle.dir), bundle.kind, ); const destinationName = `OpenCodex-${options.version}-${bundle.name}`; @@ -98,8 +103,11 @@ if (import.meta.main) { const version = argument("--version"); const target = argument("--target"); const out = argument("--out"); + const bundleRoot = argument("--bundle-root"); if (!version || !target || !out) { throw new Error("Usage: collect-release-assets.ts --version --target --out "); } - for (const path of collectReleaseAssets({ version, target, out })) console.log(`Wrote ${path}`); + const options: CollectReleaseAssetsOptions = { version, target, out }; + if (bundleRoot) options.bundleRoot = bundleRoot; + for (const path of collectReleaseAssets(options)) console.log(`Wrote ${path}`); } diff --git a/desktop/scripts/linux-packaged-e2e.ts b/desktop/scripts/linux-packaged-e2e.ts new file mode 100644 index 00000000000..9ca92352668 --- /dev/null +++ b/desktop/scripts/linux-packaged-e2e.ts @@ -0,0 +1,566 @@ +#!/usr/bin/env bun +/** + * Hosted Linux packaged-shell acceptance. + * + * This is deliberately narrower than installed-gate.ts. It extracts, rather than + * installs, the AppImage and deb payloads so a hosted runner never mutates its package + * database or the runner account's real OpenCodex home. What it proves is the common + * packaged path: the real application executable and bundled resources can show a + * window in a session with no tray host, start their bundled sidecar, identify that + * runtime, and drain both processes when the only window closes. + * + * Real dpkg/AppImage installation, elevation, takeover, and in-place updates remain the + * responsibility of installed-gate.ts on an approved disposable GUI runner. + */ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + closeSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { createServer } from "node:net"; + +export type LinuxBundleFormat = "appimage" | "deb"; + +export interface LinuxE2eOptions { + bundleRoot: string; + reportPath: string; + version: string; +} + +export interface BundleArtifacts { + appimage: string; + deb: string; +} + +interface RuntimeRecord { + pid: number; + port: number; +} + +interface HealthObservation { + status: number; + body: Record; +} + +interface ReservedLoopbackPort { + port: number; + release: () => Promise; +} + +interface FormatReport { + format: LinuxBundleFormat; + artifact: string; + ok: boolean; + durationMs: number; + windowId?: string; + appPid?: number; + appExitCode?: number | null; + appExitSignal?: string | null; + runtimePid?: number; + runtimeVersion?: string; + configuredPort?: number; + readyMs?: number; + processTreeRssKiB?: number; + error?: string; + stdoutTail?: string[]; + stderrTail?: string[]; +} + +interface AcceptanceReport { + schema: "opencodex-linux-packaged-e2e/1"; + version: string; + startedAt: string; + finishedAt: string; + ok: boolean; + formats: FormatReport[]; +} + +const READY_DEADLINE_MS = 45_000; +const EXIT_DEADLINE_MS = 30_000; +const POLL_MS = 200; +const LOG_TAIL_LINES = 80; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +function argument(argv: string[], name: string): string | undefined { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; +} + +export function parseArguments(argv: string[]): LinuxE2eOptions { + const bundleRoot = argument(argv, "--bundle-root"); + const reportPath = argument(argv, "--report"); + const version = argument(argv, "--version"); + if (!bundleRoot || !reportPath || !version) { + throw new Error("--bundle-root, --report and --version are required"); + } + if (!VERSION.test(version)) throw new Error("--version must be a strict semver"); + return { + bundleRoot: resolve(bundleRoot), + reportPath: resolve(reportPath), + version, + }; +} + +function files(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory) + .map(name => join(directory, name)) + .filter(path => statSync(path).isFile()); +} + +function exactlyOne(paths: string[], label: string): string { + if (paths.length !== 1) { + throw new Error(`expected exactly one ${label}, found ${paths.length}`); + } + return paths[0]!; +} + +export function locateArtifacts(bundleRoot: string): BundleArtifacts { + return { + appimage: exactlyOne( + files(join(bundleRoot, "appimage")).filter(path => path.endsWith(".AppImage")), + "AppImage", + ), + deb: exactlyOne( + files(join(bundleRoot, "deb")).filter(path => path.endsWith(".deb")), + "deb", + ), + }; +} + +function command( + file: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): void { + const result = spawnSync(file, args, { + cwd: options.cwd, + env: options.env, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "no output").trim(); + throw new Error(`${basename(file)} exited ${result.status ?? "without a status"}: ${detail}`); + } +} + +function executableFiles(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory) + .map(name => join(directory, name)) + .filter(path => { + const stat = statSync(path); + return stat.isFile() && (stat.mode & 0o111) !== 0; + }); +} + +export function extractedExecutable( + format: LinuxBundleFormat, + artifact: string, + destination: string, +): string { + mkdirSync(destination, { recursive: true }); + if (format === "appimage") { + command(artifact, ["--appimage-extract"], { cwd: destination }); + const appRun = join(destination, "squashfs-root", "AppRun"); + if (!existsSync(appRun)) throw new Error("AppImage extraction did not produce AppRun"); + return appRun; + } + + command("dpkg-deb", ["--extract", artifact, destination]); + const candidates = executableFiles(join(destination, "usr", "bin")); + return selectDebExecutable(candidates); +} + +export function selectDebExecutable(candidates: string[]): string { + // The package contains the desktop host and its `ocx` sidecar. The sidecar is deliberately + // executable, but it is not the process whose WebView/window lifecycle this acceptance owns. + return exactlyOne( + candidates.filter(candidate => basename(candidate) !== "ocx"), + "deb desktop executable under usr/bin", + ); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function reserveLoopbackPort(): Promise { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("could not reserve a temporary loopback port")); + return; + } + let released = false; + resolvePort({ + port: address.port, + release: async () => { + if (released) return; + released = true; + await new Promise((resolveClose, rejectClose) => { + server.close(error => error ? rejectClose(error) : resolveClose()); + }); + }, + }); + }); + }); +} + +async function waitFor(read: () => T | undefined | Promise, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await read(); + if (value !== undefined) return value; + await sleep(POLL_MS); + } + throw new Error(`condition did not settle within ${timeoutMs}ms`); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +export function readRuntimeRecord(path: string): RuntimeRecord | undefined { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record; + const pid = positiveInteger(parsed.pid); + const port = positiveInteger(parsed.port); + if (pid === undefined || port === undefined || port > 65_535) return undefined; + return { pid, port }; + } catch { + return undefined; + } +} + +export function assertRuntimeRecordPort(record: RuntimeRecord, configuredPort: number): RuntimeRecord { + if (record.port !== configuredPort) { + throw new Error( + `packaged runtime recorded port ${record.port}, expected isolated port ${configuredPort}`, + ); + } + return record; +} + +function processAlive(pid: number | undefined): boolean { + if (pid === undefined) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM"; + } +} + +function processRows(): Array<{ pid: number; ppid: number; rssKiB: number }> { + const result = spawnSync("ps", ["-e", "-o", "pid=,ppid=,rss="], { encoding: "utf8" }); + if (result.status !== 0) return []; + return result.stdout + .trim() + .split(/\r?\n/u) + .map(line => line.trim().split(/\s+/u).map(Number)) + .filter(parts => parts.length === 3 && parts.every(Number.isFinite)) + .map(parts => ({ pid: parts[0]!, ppid: parts[1]!, rssKiB: parts[2]! })); +} + +export interface AppExit { + code: number | null; + signal: string | null; +} + +/** + * The close request goes through the window manager (EWMH _NET_CLOSE_WINDOW), the same path a + * person's close button takes. xdotool's windowclose destroys the X window instead, which can end + * the process without ever running Tauri's close/drain handling and still look like a clean exit. + */ +export function windowManagerCloseArgs(windowId: string): string[] { + const id = Number(windowId); + if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`invalid X11 window id: ${windowId}`); + return ["-i", "-c", `0x${id.toString(16)}`]; +} + +/** A graceful close exits 0 on its own; a signal or a nonzero code is a crash, not a drain. */ +export function assertCleanExit(exit: AppExit | undefined): AppExit { + if (!exit) throw new Error("desktop app did not exit after the close request"); + if (exit.signal !== null || exit.code !== 0) { + throw new Error(`desktop app exited with code ${exit.code ?? "none"} and signal ${exit.signal ?? "none"} instead of a clean close`); + } + return exit; +} + +export function processTreeRssKiB(rootPid: number, rows = processRows()): number { + const selected = new Set([rootPid]); + let changed = true; + while (changed) { + changed = false; + for (const row of rows) { + if (selected.has(row.ppid) && !selected.has(row.pid)) { + selected.add(row.pid); + changed = true; + } + } + } + return rows.filter(row => selected.has(row.pid)).reduce((sum, row) => sum + row.rssKiB, 0); +} + +function xdotoolWindow(): string | undefined { + // WebKit exposes an auxiliary `opencodex-desktop` X11 window before the titled top-level + // `OpenCodex` window. A loose match selected that helper and `windowclose` merely destroyed the + // web process surface, never exercising Tauri's close/drain path. + const result = spawnSync( + "xdotool", + ["search", "--onlyvisible", "--name", "^OpenCodex$"], + { encoding: "utf8" }, + ); + if (result.status !== 0) return undefined; + return result.stdout.trim().split(/\r?\n/u).find(Boolean); +} + +async function health(record: RuntimeRecord): Promise { + try { + const response = await fetch(`http://127.0.0.1:${record.port}/healthz`, { + signal: AbortSignal.timeout(1_000), + cache: "no-store", + }); + const body = await response.json(); + return typeof body === "object" && body !== null + ? { status: response.status, body: body as Record } + : undefined; + } catch { + return undefined; + } +} + +function tail(path: string): string[] { + try { + return readFileSync(path, "utf8").split(/\r?\n/u).filter(Boolean).slice(-LOG_TAIL_LINES); + } catch { + return []; + } +} + +async function stopGroup(child: ChildProcess): Promise { + if (!child.pid || !processAlive(child.pid)) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + try { + await waitFor(() => processAlive(child.pid) ? undefined : true, 5_000); + return; + } catch { + // Escalate only inside the detached process group this test created. + } + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } +} + +async function runFormat( + format: LinuxBundleFormat, + artifact: string, + version: string, + root: string, +): Promise { + const started = Date.now(); + const directory = join(root, format); + const extracted = join(directory, "payload"); + const home = join(directory, "home"); + const opencodexHome = join(home, ".opencodex"); + const codexHome = join(home, ".codex"); + const configHome = join(home, ".config"); + const cacheHome = join(home, ".cache"); + const dataHome = join(home, ".local", "share"); + for (const path of [home, opencodexHome, codexHome, configHome, cacheHome, dataHome]) { + mkdirSync(path, { recursive: true, mode: 0o700 }); + } + const stdoutPath = join(directory, "stdout.log"); + const stderrPath = join(directory, "stderr.log"); + mkdirSync(directory, { recursive: true }); + const stdout = openSync(stdoutPath, "w", 0o600); + const stderr = openSync(stderrPath, "w", 0o600); + let child: ChildProcess | undefined; + let runtimePid: number | undefined; + let configuredPort: number | undefined; + let reservedPort: ReservedLoopbackPort | undefined; + try { + const executable = extractedExecutable(format, artifact, extracted); + reservedPort = await reserveLoopbackPort(); + configuredPort = reservedPort.port; + writeFileSync( + join(opencodexHome, "config.json"), + `${JSON.stringify({ port: configuredPort }, null, 2)}\n`, + { mode: 0o600 }, + ); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: configHome, + XDG_CACHE_HOME: cacheHome, + XDG_DATA_HOME: dataHome, + OPENCODEX_HOME: opencodexHome, + CODEX_HOME: codexHome, + NO_PROXY: "127.0.0.1,localhost", + no_proxy: "127.0.0.1,localhost", + WEBKIT_DISABLE_COMPOSITING_MODE: "1", + }; + // Hold the listener while preparing the isolated home so no unrelated process can claim the + // selected port. Release it only at the spawn boundary; the packaged runtime can then bind it. + await reservedPort.release(); + reservedPort = undefined; + child = spawn(executable, [], { + cwd: dirname(executable), + env, + detached: true, + stdio: ["ignore", stdout, stderr], + }); + if (!child.pid) throw new Error("desktop app did not report a pid"); + const appPid = child.pid; + let appExit: AppExit | undefined; + child.once("exit", (code, signal) => { + appExit = { code, signal }; + }); + const windowId = await waitFor(xdotoolWindow, READY_DEADLINE_MS); + const recordPath = join(opencodexHome, "runtime-port.json"); + const record = assertRuntimeRecordPort( + await waitFor(() => readRuntimeRecord(recordPath), READY_DEADLINE_MS), + configuredPort, + ); + runtimePid = record.pid; + let lastHealth: HealthObservation | undefined; + let ready: Record; + try { + ready = await waitFor(async () => { + const observed = await health(record); + if (!observed) return undefined; + lastHealth = observed; + const body = observed.body; + return observed.status >= 200 && observed.status < 300 + && body.service === "opencodex" + && body.pid === record.pid + && body.port === record.port + && body.version === version + ? body + : undefined; + }, READY_DEADLINE_MS); + } catch { + const observed = lastHealth + ? `status ${lastHealth.status}, body ${JSON.stringify(lastHealth.body)}` + : "no readable /healthz response"; + throw new Error(`packaged runtime health identity did not become ready (${observed})`); + } + const readyMs = Date.now() - started; + const rssKiB = processTreeRssKiB(appPid); + + command("wmctrl", windowManagerCloseArgs(windowId)); + await waitFor( + () => appExit && !processAlive(runtimePid) ? true : undefined, + EXIT_DEADLINE_MS, + ); + const exit = assertCleanExit(appExit); + return { + format, + artifact: basename(artifact), + ok: true, + durationMs: Date.now() - started, + windowId, + appPid, + appExitCode: exit.code, + appExitSignal: exit.signal, + runtimePid, + runtimeVersion: typeof ready.version === "string" ? ready.version : undefined, + configuredPort, + readyMs, + processTreeRssKiB: rssKiB, + stdoutTail: tail(stdoutPath), + stderrTail: tail(stderrPath), + }; + } catch (error) { + return { + format, + artifact: basename(artifact), + ok: false, + durationMs: Date.now() - started, + ...(child?.pid ? { appPid: child.pid } : {}), + ...(runtimePid ? { runtimePid } : {}), + ...(configuredPort ? { configuredPort } : {}), + error: error instanceof Error ? error.message : String(error), + stdoutTail: tail(stdoutPath), + stderrTail: tail(stderrPath), + }; + } finally { + await reservedPort?.release(); + if (child) await stopGroup(child); + closeSync(stdout); + closeSync(stderr); + } +} + +export async function runAcceptance(options: LinuxE2eOptions): Promise { + if (process.platform !== "linux") throw new Error("Linux packaged E2E runs only on Linux"); + for (const dependency of ["dpkg-deb", "ps", "wmctrl", "xdotool"]) { + const probe = spawnSync("sh", ["-c", `command -v ${dependency}`]); + if (probe.status !== 0) throw new Error(`missing required command: ${dependency}`); + } + if (!process.env.DISPLAY) throw new Error("DISPLAY is required; run under Xvfb"); + + const artifacts = locateArtifacts(options.bundleRoot); + const root = mkdtempSync(join(tmpdir(), "opencodex-linux-e2e-")); + const startedAt = new Date().toISOString(); + let formats: FormatReport[] = []; + try { + formats = [ + await runFormat("appimage", artifacts.appimage, options.version, root), + await runFormat("deb", artifacts.deb, options.version, root), + ]; + } finally { + const report: AcceptanceReport = { + schema: "opencodex-linux-packaged-e2e/1", + version: options.version, + startedAt, + finishedAt: new Date().toISOString(), + ok: formats.length === 2 && formats.every(format => format.ok), + formats, + }; + mkdirSync(dirname(options.reportPath), { recursive: true }); + writeFileSync(options.reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + rmSync(root, { recursive: true, force: true }); + } + return JSON.parse(readFileSync(options.reportPath, "utf8")) as AcceptanceReport; +} + +async function main(): Promise { + const options = parseArguments(process.argv.slice(2)); + const report = await runAcceptance(options); + for (const format of report.formats) { + console.log(`${format.ok ? "PASS" : "FAIL"} ${format.format}: ${format.error ?? `${format.readyMs}ms ready, ${format.processTreeRssKiB} KiB RSS`}`); + } + process.exitCode = report.ok ? 0 : 1; +} + +if (import.meta.main) { + main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/desktop/scripts/prepare-sidecar.ts b/desktop/scripts/prepare-sidecar.ts index 502127870dc..2403e130933 100644 --- a/desktop/scripts/prepare-sidecar.ts +++ b/desktop/scripts/prepare-sidecar.ts @@ -1,5 +1,6 @@ import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs"; import { join, resolve } from "node:path"; +import { adHocSignSidecar, shouldAdHocSignSidecar } from "./sidecar-signing"; const targetByTriple: Record = { "aarch64-apple-darwin": "bun-darwin-arm64", @@ -55,5 +56,9 @@ mkdirSync(binaries, { recursive: true }); mkdirSync(resources, { recursive: true }); const destination = join(binaries, `ocx-${triple}${target.startsWith("bun-windows-") ? ".exe" : ""}`); copyFileSync(executable, destination); +if (shouldAdHocSignSidecar(process.platform, target)) { + const signed = adHocSignSidecar(destination); + if (signed !== 0) process.exit(signed); +} cpSync(join(repoRoot, "gui", "dist"), resources, { recursive: true }); console.log(`Prepared ${destination}`); diff --git a/desktop/scripts/sidecar-signing.ts b/desktop/scripts/sidecar-signing.ts new file mode 100644 index 00000000000..a41642b3062 --- /dev/null +++ b/desktop/scripts/sidecar-signing.ts @@ -0,0 +1,31 @@ +// Ad-hoc signing of the prepared desktop sidecar on macOS. +// +// Bun's linker-signed standalone output is killed by macOS page validation +// (CODESIGNING "Invalid Page"), so the copied sidecar is resealed with an +// ad-hoc signature before Tauri bundles it. Only a macOS host preparing a +// bun-darwin-* target signs: a Mac cross-preparing a Linux or Windows sidecar +// must never run codesign on that file. Release builds re-sign the bundled +// binary with Developer ID afterwards; this step only has to leave a runnable +// input. + +export const CODESIGN_PATH = "/usr/bin/codesign"; + +export function shouldAdHocSignSidecar(hostPlatform: string, bunTarget: string): boolean { + return hostPlatform === "darwin" && bunTarget.startsWith("bun-darwin-"); +} + +export function adHocSignArgv(destination: string): string[] { + return [CODESIGN_PATH, "-s", "-", "-f", destination]; +} + +export type SidecarSignSpawn = (argv: string[]) => { exitCode: number | null }; + +const inheritSpawn: SidecarSignSpawn = (argv) => + Bun.spawnSync(argv, { stdout: "inherit", stderr: "inherit" }); + +/** Returns 0 on success, otherwise the nonzero exit code the caller should exit with. */ +export function adHocSignSidecar(destination: string, spawn: SidecarSignSpawn = inheritSpawn): number { + const result = spawn(adHocSignArgv(destination)); + if (result.exitCode === 0) return 0; + return result.exitCode ?? 1; +} diff --git a/desktop/scripts/verify-linux-sidecar.sh b/desktop/scripts/verify-linux-sidecar.sh index 88c5df2ba34..7695767ebe6 100644 --- a/desktop/scripts/verify-linux-sidecar.sh +++ b/desktop/scripts/verify-linux-sidecar.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash # Run only on a Linux packaging runner, against the completed AppImage. +# Usage: verify-linux-sidecar.sh [appimage-bundle-dir] +# The release workflow builds each Linux format in its own Cargo target and stages the AppImage +# into an isolated read-only directory, which it passes here; a local build keeps the default. set -euo pipefail root="$(cd "$(dirname "$0")/../.." && pwd)" -bundle="$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage" +bundle="${1:-$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage}" original="$root/desktop/src-tauri/binaries/ocx-x86_64-unknown-linux-gnu" shopt -s nullglob images=("$bundle"/*.AppImage) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e9467013ebb..3ae6359e872 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -135,9 +135,7 @@ impl Default for AppState { #[tauri::command] fn show_dashboard(app: tauri::AppHandle) { popup::hide(&app); - if let Some(window) = app.get_webview_window("main") { - window::show(&window); - } + startup::open_dashboard(&app); } #[tauri::command] @@ -191,10 +189,8 @@ fn decide_takeover(app: tauri::AppHandle, approved: bool) { pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - if let Some(window) = app.get_webview_window("main") { - popup::hide(app); - window::show(&window); - } + popup::hide(app); + startup::open_dashboard(app); })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 3a693e37cda..4b6e8bf059d 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -365,6 +365,18 @@ pub struct Startup { /// before `live`, and never held across an await. reporting: Mutex<()>, running: AtomicBool, + /// Whether this window has already left the bundled bootstrap surface. + /// + /// Explicit open actions can arrive repeatedly from the tray, the single-instance hook, and + /// the shell command. Navigating on every action would recreate the React application and + /// discard renderer state, so the transition is owned here and consumed exactly once per run. + dashboard_loaded: AtomicBool, + /// Whether a person asked for the dashboard during this run. + /// + /// An explicit open that arrives while startup is still running only shows the bootstrap page; + /// `finish` reads this after it has recorded Ready, and `open_dashboard` sets it before it + /// reads progress, so whichever of the two runs second sees the other and navigates. + dashboard_requested: AtomicBool, /// Which run the state belongs to. /// /// A run's deadline guard outlives the run it was started for, and a retry that begins before @@ -385,6 +397,8 @@ impl Startup { }), reporting: Mutex::new(()), running: AtomicBool::new(false), + dashboard_loaded: AtomicBool::new(false), + dashboard_requested: AtomicBool::new(false), generation: AtomicU64::new(0), registered: Mutex::new(None), } @@ -460,6 +474,33 @@ impl Startup { live.consent = ConsentState::Idle; live.reported.clear(); live.latest = Progress::new(Phase::NotStarted, 0); + self.dashboard_loaded.store(false, Ordering::SeqCst); + self.dashboard_requested.store(false, Ordering::SeqCst); + } + + fn should_navigate_dashboard(&self) -> bool { + !self.dashboard_loaded.swap(true, Ordering::SeqCst) + } + + /// Give the one navigation back when the WebView refused the script, so the next open retries. + fn navigation_failed(&self) { + self.dashboard_loaded.store(false, Ordering::SeqCst); + } + + fn request_dashboard(&self) { + self.dashboard_requested.store(true, Ordering::SeqCst); + } + + fn dashboard_requested(&self) -> bool { + self.dashboard_requested.load(Ordering::SeqCst) + } + + /// The dashboard URL once this run is Ready, otherwise nothing. + fn ready_dashboard(&self) -> Option { + let progress = self.latest(); + (progress.phase == Phase::Ready.id()) + .then_some(progress.dashboard) + .flatten() } /// Whether the run has already said how it ended. @@ -1379,10 +1420,72 @@ fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { return; } if let Some(window) = app.get_webview_window("main") { - // justified: replacing the bootstrap page with the dashboard is how this window has always - // navigated, and the string is a URL this process resolved, not anything a page supplied. - let _ = window.eval(format!("window.location.replace({dashboard:?})")); + let visible = window.is_visible().unwrap_or(true); + let startup = app.try_state::(); + let requested = startup + .as_ref() + .is_some_and(|startup| startup.dashboard_requested()); + if loads_dashboard_on_ready(LaunchOrigin::detect(), visible, requested) { + match startup { + Some(startup) => { + navigate_once(&startup, &dashboard, |url| navigate_dashboard(&window, url)); + } + None => { + navigate_dashboard(&window, &dashboard); + } + } + } + } +} + +/// Open the full dashboard only when a person asks for it. +/// +/// A hidden login launch deliberately leaves its WebView on the tiny bundled startup surface after +/// the runtime becomes ready. The tray, a second ordinary application launch, or the bootstrap +/// command reaches this function and pays the dashboard cost at that point. If startup is still in +/// progress the bootstrap is merely shown; `finish` observes the now-visible window and performs +/// the navigation once the endpoint is ready. +pub fn open_dashboard(app: &AppHandle) { + let startup = app.try_state::(); + let Some(window) = app.get_webview_window("main") else { + return; + }; + if let Some(startup) = startup { + // The request is recorded before progress is read; see `dashboard_requested`. + startup.request_dashboard(); + if let Some(dashboard) = startup.ready_dashboard() { + navigate_once(&startup, &dashboard, |url| navigate_dashboard(&window, url)); + } + } + crate::window::show(&window); +} + +fn loads_dashboard_on_ready(origin: LaunchOrigin, window_visible: bool, requested: bool) -> bool { + origin == LaunchOrigin::User || window_visible || requested +} + +/// Perform this run's single dashboard navigation through `navigate`. +/// +/// `navigate` reports whether the WebView accepted the script. Acceptance is not proof that the +/// page finished loading, but a refusal certainly left the bootstrap page in place, so the claim is +/// returned and the next explicit open tries again instead of being suppressed for the whole run. +fn navigate_once(startup: &Startup, dashboard: &str, navigate: impl FnOnce(&str) -> bool) -> bool { + if !startup.should_navigate_dashboard() { + return false; } + if navigate(dashboard) { + return true; + } + startup.navigation_failed(); + false +} + +fn navigate_dashboard(window: &tauri::WebviewWindow, dashboard: &str) -> bool { + // justified: replacing the bootstrap page with the dashboard is how this window has always + // navigated, and the string is a URL this process resolved, not anything a page supplied. + window + .eval(format!("window.location.replace({dashboard:?})")) + .is_ok() } #[allow(clippy::too_many_arguments)] @@ -1489,9 +1592,9 @@ fn elapsed(started: Instant) -> u64 { #[cfg(test)] mod tests { use super::{ - approval_still_current, attach_plan, claim_after_silence, shows_window, - stop_after_approval, unavailable, AttachPlan, ConsentState, Expiry, LaunchOrigin, Phase, - Progress, Startup, AUTOSTART_FLAG, DEADLINE, PHASES, POLL, + approval_still_current, attach_plan, claim_after_silence, loads_dashboard_on_ready, + navigate_once, shows_window, stop_after_approval, unavailable, AttachPlan, ConsentState, + Expiry, LaunchOrigin, Phase, Progress, Startup, AUTOSTART_FLAG, DEADLINE, PHASES, POLL, }; use crate::claim::ClaimResult; use crate::ownership::{Claim, Consent, Owner, Recorded}; @@ -1773,6 +1876,102 @@ mod tests { )); } + #[test] + fn only_a_hidden_login_launch_defers_the_full_dashboard() { + assert!(loads_dashboard_on_ready(LaunchOrigin::User, false, false)); + assert!(loads_dashboard_on_ready(LaunchOrigin::User, true, false)); + assert!(loads_dashboard_on_ready( + LaunchOrigin::Autostart, + true, + false + )); + assert!(!loads_dashboard_on_ready( + LaunchOrigin::Autostart, + false, + false + )); + // An open that arrived during startup counts even if the queued show has not landed yet. + assert!(loads_dashboard_on_ready( + LaunchOrigin::Autostart, + false, + true + )); + } + + #[test] + fn explicit_dashboard_navigation_is_consumed_once_per_run() { + let startup = Startup::new(); + let mut navigations = Vec::new(); + assert!(navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |url| { + navigations.push(url.to_string()); + true + } + )); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |url| { + navigations.push(url.to_string()); + true + } + )); + assert_eq!( + navigations, + vec!["http://127.0.0.1:10100/#/usage".to_string()] + ); + + startup.restart(); + assert!(navigate_once( + &startup, + "http://127.0.0.1:10101/#/usage", + |_| true + )); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10101/#/usage", + |_| true + )); + } + + #[test] + fn a_refused_dashboard_navigation_is_retried_on_the_next_open() { + let startup = Startup::new(); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |_| false + )); + let mut attempts = 0; + assert!(navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |_| { + attempts += 1; + true + } + )); + assert_eq!(attempts, 1); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |_| true + )); + } + + #[test] + fn an_open_during_startup_is_remembered_until_the_run_restarts() { + let startup = Startup::new(); + assert!(!startup.dashboard_requested()); + assert_eq!(startup.ready_dashboard(), None); + startup.request_dashboard(); + assert!(startup.dashboard_requested()); + startup.restart(); + assert!(!startup.dashboard_requested()); + } + #[test] fn a_login_launch_hides_only_where_there_is_a_tray_to_hide_in() { assert!(!shows_window( diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index ef50233140c..32ef5406a9e 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -162,9 +162,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let _ = popup::show(app, endpoint, anchor); } "open-dashboard" => { - if let Some(window) = app.get_webview_window("main") { - window::show(&window); - } + crate::startup::open_dashboard(app); } "open-browser" => { let Some(endpoint) = app diff --git a/docs-site/src/content/docs/fr/guides/desktop-app.md b/docs-site/src/content/docs/fr/guides/desktop-app.md index 7f4c9215d5e..ee633d9205a 100644 --- a/docs-site/src/content/docs/fr/guides/desktop-app.md +++ b/docs-site/src/content/docs/fr/guides/desktop-app.md @@ -40,7 +40,7 @@ L’icône de zone de notification nécessite un environnement de bureau compati ## Premier lancement -L’application demande à son CLI intégré d’exécuter `ocx resolve --json` et se connecte à un proxy local accessible s’il en existe déjà un. Elle ne démarre son environnement d’exécution intégré que lorsque le CLI établit l’absence de proxy ; un résultat incertain est affiché comme un échec de démarrage. Le tableau de bord s’ouvre alors dans la vue web de l’application, au point de terminaison loopback trouvé. +L’application demande à son CLI intégré d’exécuter `ocx resolve --json` et se connecte à un proxy local accessible s’il en existe déjà un. Elle ne démarre son environnement d’exécution intégré que lorsque le CLI établit l’absence de proxy ; un résultat incertain est affiché comme un échec de démarrage. Le tableau de bord s’ouvre alors dans la vue web de l’application, au point de terminaison loopback trouvé. Un lancement à l’ouverture de session qui démarre masqué dans la zone de notification conserve plutôt la page de démarrage légère et ne charge le tableau de bord qu’à sa première ouverture depuis la zone de notification ou à un nouveau lancement de l’application. Utilisez l’action **Open dashboard** ou **Open in browser** de la zone de notification pour passer du tableau de bord intégré à votre navigateur habituel. Le menu permet aussi de rechercher les mises à jour. diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index c736d44c0ac..ff64a5510ff 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -23,6 +23,12 @@ Pour observer une installation Windows x64, consultez [`attest`](/fr/reference/c L’affichage d’une liste ou d’un état est l’action par défaut lorsqu’il n’y a aucune ambiguïté. Utilisez `--json` pour obtenir des instantanés structurés et `ocx observe logs --follow --jsonl` pour suivre un flux de journaux de requêtes. Le thème, la langue, la navigation et les autres états purement visuels du navigateur n’ont pas d’équivalent dans la CLI. La configuration de Cloudflare Tunnel ne fait pas partie de cet ensemble de commandes. +## Plafond des sondes de disponibilité + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex` et `ocx ready` trouvent le proxy en cours d'exécution grâce à une courte sonde : 750 ms par tentative par défaut, 1500 ms avec nouvelles tentatives pour les décisions d'arrêt et de démarrage. Si une couche de sécurité (filtre de contenu, extension réseau de type EDR) ajoute un coût fixe à chaque connexion loopback, ces plafonds peuvent expirer avant qu'un proxy sain réponde. + +Définissez `OCX_PROBE_TIMEOUT_MS` pour relever les plafonds, par exemple `OCX_PROBE_TIMEOUT_MS=5000 ocx status`. La valeur est un nombre entier de millisecondes entre 1 et 30000. Elle ne peut que relever : le défaut de 750 ms et les budgets d'arrêt/démarrage de 1500 ms gardent leur plancher, donc `1000` n'allonge que la sonde par défaut. Une valeur absente, vide, fractionnaire, négative, nulle ou supérieure est ignorée. + ## Codes de sortie et confirmation Une commande réussie renvoie le code 0. Une syntaxe non valide, une commande ou une ressource inconnue, l’échec d’une opération d’API ou l’indisponibilité d’un service requis produit un code non nul. Plus précisément, `ocx health` renvoie 0 uniquement lorsque le proxy est sain, et 1 dans le cas contraire ; cette commande peut donc servir de sonde de service. Les scripts doivent tester le code de sortie plutôt que d’analyser le texte destiné aux utilisateurs. diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index f59ebe9916b..99ac08205c3 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -317,6 +317,8 @@ Ouvre le [tableau de bord Web](/fr/guides/web-dashboard/) à l’adresse `http:/ ### `ocx update [--tag latest|preview]` +Lorsque OpenCodex est installé avec mise, cette commande échoue avant d'arrêter le proxy ou de modifier les fichiers du paquet et affiche `mise upgrade ` avec l'alias mise local vérifié. La vérification des mises à jour reste disponible et signale une gestion externe. Des métadonnées de propriété mise illisibles ou incohérentes bloquent aussi toute modification sans deviner le nom de l'outil, et `--tag preview` ne change jamais la sélection configurée dans mise. + Met à jour opencodex depuis npm. Les installations stables utilisent `@latest` ; les préversions restent sur `@preview`, sauf si vous indiquez `--tag latest|preview`. La commande détecte un dépôt de sources et vous invite alors à exécuter `git pull && bun install`. Elle ne fait rien si la version la plus récente correspondant à cette balise est déjà installée. Avant tout arrêt, les installations npm effectuent sous Unix un contrôle borné de la propriété et de l’accès au cache. Les liens symboliques imbriqués sont examinés avec `lstat`, sans être suivis ; Windows ignore explicitement ce contrôle propre à Unix. En cas d’échec, l’opération s’interrompt tandis que l’icône et le proxy fonctionnent encore. Le proxy actif est ensuite arrêté avant le remplacement des fichiers. Un service installé est reconstruit et redémarré automatiquement ; pour une installation au premier plan, la commande indique `ocx start` comme étape suivante. Avant leur conservation, les enregistrements de mise à jour du tableau de bord masquent les chemins de profil et de cache ainsi que les valeurs UID/GID. diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index bcf47866a2d..df6541e21f6 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -52,7 +52,9 @@ The tray icon requires an AppIndicator-capable desktop environment. The app asks its bundled CLI to run `ocx resolve --json` and attaches to a reachable local proxy if one is already running. It starts the bundled runtime only when the CLI proves absence; an uncertain result is shown as a startup failure. The dashboard then opens in -the app's webview at the resolved loopback endpoint. +the app's webview at the resolved loopback endpoint. A login launch that starts hidden in the +tray keeps the lightweight startup page instead, and loads the dashboard the first time you open +it from the tray or launch the app again. Use the tray's **Open dashboard** or **Open in browser** action to move between the embedded dashboard and your normal browser. The tray also provides update checks. diff --git a/docs-site/src/content/docs/ja/guides/desktop-app.md b/docs-site/src/content/docs/ja/guides/desktop-app.md index 753bf95a2ad..c9ceaba2685 100644 --- a/docs-site/src/content/docs/ja/guides/desktop-app.md +++ b/docs-site/src/content/docs/ja/guides/desktop-app.md @@ -40,7 +40,7 @@ sudo apt install ./OpenCodex--linux-amd64.deb ## 初回起動 -アプリは同梱 CLI に `ocx resolve --json` を実行させ、到達可能なローカルプロキシが既に動いていれば接続します。CLI が不在を証明した場合にだけ同梱ランタイムを起動します。結果が不確かな場合は起動エラーとして表示します。その後、特定されたループバックエンドポイントのダッシュボードがアプリ内の WebView で開きます。 +アプリは同梱 CLI に `ocx resolve --json` を実行させ、到達可能なローカルプロキシが既に動いていれば接続します。CLI が不在を証明した場合にだけ同梱ランタイムを起動します。結果が不確かな場合は起動エラーとして表示します。その後、特定されたループバックエンドポイントのダッシュボードがアプリ内の WebView で開きます。ログイン時にトレイへ隠れて起動した場合は軽量な起動画面のままにし、トレイから初めて開いたときかアプリを再度起動したときにダッシュボードを読み込みます。 トレイの **Open dashboard** または **Open in browser** で、埋め込みダッシュボードと通常のブラウザを切り替えられます。トレイから更新の確認もできます。 diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index b3ef24a0365..2329a76087e 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -26,6 +26,12 @@ Windows x64 インストールの観測は [`attest` コマンド](/ja/reference リストまたはステータスは、明確なデフォルトです。構造化スナップショットには `--json` を使用し、ストリーミング リクエスト ログ フィードには `ocx observe logs --follow --jsonl` を使用します。テーマ、言語、ナビゲーション、その他の純粋に視覚的なブラウザーの状態には、同等の CLI がありません。 Cloudflare Tunnel のセットアップはこのコマンド セットの外にあります。 +## ライブネスプローブの上限の上書き + +`ocx health`、`ocx status`、`ocx account *`、`ocx login codex`、`ocx ready` は短いライブネスプローブで実行中のプロキシを探します。既定は1回あたり750 msで、停止と起動の判断ではリトライ付きの1500 msです。セキュリティ層(コンテンツフィルターやEDR系のネットワーク拡張)がループバック接続ごとに固定の遅延を加えるホストでは、正常なプロキシが応答する前にこの上限を超えることがあります。 + +そのようなホストでは `OCX_PROBE_TIMEOUT_MS` で上限を引き上げます(例: `OCX_PROBE_TIMEOUT_MS=5000 ocx status`)。値は1から30000までの整数ミリ秒です。上書きは引き上げのみで、750 msの既定値と1500 msの停止・起動予算は下限を保つため、`1000` は既定のプローブだけを延ばします。未設定、空、小数、負数、0、上限を超える値は無視されます。 + ## 終了コードと確認 成功したコマンドは 0 で終了します。無効な使用法、不明なコマンドまたはリソース、失敗した API 操作、および利用できない必要なサービスは 0 以外で終了します。 `ocx health` は、特にプロキシが正常な場合にのみ 0 で終了し、それ以外の場合は 1 で終了するため、サービス プローブとして使用できます。スクリプトは、人間が判読できる出力をスクレイピングするのではなく、終了コードをテストする必要があります。 diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 877954e9755..c822da6d7b8 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -286,6 +286,8 @@ Windows ステータス トレイ アイコンをインストールして制御 ### `ocx update [--tag latest|preview]` +OpenCodex が mise 経由でインストールされている場合、このコマンドはプロキシの停止やパッケージファイルの変更前に失敗終了し、検証済みのローカル mise エイリアスを使った `mise upgrade ` を表示します。更新確認は引き続き利用でき、外部管理として報告されます。mise の所有権メタデータを読み取れない場合や整合しない場合もツール名を推測せずに変更を拒否し、`--tag preview` は mise の設定済み選択を変更しません。 + npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。npm インストールでは、何かを停止する前に Unix キャッシュの所有権とアクセスを上限付きで検査します。ネストされたシンボリックリンクは `lstat` で確認しますが追跡しません。Windows では、この Unix 専用検査を明示的にスキップします。検査に失敗した場合、トレイとプロキシを実行したまま更新を中止します。その後、実行中のプロキシはファイルが置き換えられる前に停止されます。インストールされたサービスは再構築されて自動的に開始されますが、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。ダッシュボードの更新記録では、保存前にプロファイル/キャッシュのパスと UID/GID 値が秘匿されます。 ```bash diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md index 0cbceba4f53..d910373bb80 100644 --- a/docs-site/src/content/docs/ko/guides/desktop-app.md +++ b/docs-site/src/content/docs/ko/guides/desktop-app.md @@ -40,7 +40,7 @@ sudo apt install ./OpenCodex--linux-amd64.deb ## 첫 실행 -앱은 번들 CLI에 `ocx resolve --json` 실행을 요청합니다. 이미 실행 중인 로컬 프록시에 연결할 수 있으면 그 프록시를 사용합니다. CLI가 프록시의 부재를 확인한 경우에만 번들 런타임을 시작하고, 결과가 불확실하면 시작 실패로 표시합니다. 그 뒤 확인된 loopback 엔드포인트의 대시보드를 앱 webview에서 엽니다. +앱은 번들 CLI에 `ocx resolve --json` 실행을 요청합니다. 이미 실행 중인 로컬 프록시에 연결할 수 있으면 그 프록시를 사용합니다. CLI가 프록시의 부재를 확인한 경우에만 번들 런타임을 시작하고, 결과가 불확실하면 시작 실패로 표시합니다. 그 뒤 확인된 loopback 엔드포인트의 대시보드를 앱 webview에서 엽니다. 로그인 시 트레이에 숨겨진 채로 시작한 경우에는 가벼운 시작 화면을 유지하고, 트레이에서 처음 열거나 앱을 다시 실행할 때 대시보드를 불러옵니다. 트레이의 **Open dashboard** 또는 **Open in browser**를 사용하면 내장 대시보드와 일반 브라우저를 오갈 수 있습니다. 트레이에서는 업데이트도 확인할 수 있습니다. diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index d90c479077f..da0ed2b0248 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -23,6 +23,12 @@ Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참 뜻이 분명하면 `list`나 `status`가 기본입니다. 구조화된 스냅샷은 `--json`을, 스트리밍 요청 로그 피드는 `ocx observe logs --follow --jsonl`을 사용합니다. 테마, 언어, 내비게이션처럼 순수하게 시각적인 브라우저 상태에는 CLI 대응이 없습니다. Cloudflare Tunnel 설정은 이 명령 집합 밖입니다. +## 라이브니스 프로브 상한 재정의 + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex`, `ocx ready`는 짧은 라이브니스 프로브로 실행 중인 프록시를 찾습니다. 기본값은 시도당 750 ms이고, 중지와 시작 판단에는 재시도를 포함해 1500 ms를 씁니다. 콘텐츠 필터나 EDR 계열 네트워크 확장 같은 보안 계층이 루프백 연결마다 고정 지연을 더하는 호스트에서는 정상 프록시가 응답하기 전에 이 상한이 끝날 수 있습니다. + +이런 호스트에서는 `OCX_PROBE_TIMEOUT_MS`로 상한을 올리세요. 예: `OCX_PROBE_TIMEOUT_MS=5000 ocx status`. 값은 1부터 30000까지의 정수 밀리초입니다. 재정의는 올리기만 합니다. 750 ms 기본값과 1500 ms 중지·시작 예산은 하한을 유지하므로 `1000`은 기본 프로브만 늘립니다. 설정하지 않았거나 비어 있거나 소수, 음수, 0, 상한을 넘는 값은 무시됩니다. + ## 종료 코드와 확인 성공한 명령은 종료 코드 0을 반환합니다. 잘못된 사용법, 알 수 없는 명령이나 리소스, 실패한 API 작업, 필요한 서비스가 없음은 0이 아닌 종료 코드를 반환합니다. `ocx health`는 프록시가 건강할 때만 0을, 그렇지 않으면 1을 반환하므로 서비스 probe로 쓸 수 있습니다. 스크립트는 사람이 읽는 출력 대신 종료 코드를 확인해야 합니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 97048c3768e..e17cdfa03f3 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -406,6 +406,8 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 ### `ocx update [--tag latest|preview]` +OpenCodex가 mise를 통해 설치된 경우 이 명령은 프록시를 중지하거나 패키지 파일을 변경하기 전에 실패하며 검증된 로컬 mise 별칭을 사용한 `mise upgrade `을 표시합니다. 업데이트 확인은 계속 사용할 수 있고 외부 관리 설치로 보고합니다. mise 소유권 메타데이터를 읽을 수 없거나 일관되지 않아도 도구 이름을 추측하지 않고 변경을 거부하며, `--tag preview`는 mise에 구성된 선택을 변경하지 않습니다. + npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@latest`를 사용하고, 미리보기 설치는 `--tag latest|preview`를 주지 않으면 `@preview`를 유지합니다. 소스 체크아웃을 감지하면 대신 `git pull && bun install`을 실행하라고 안내하고, 해당 태그에서 이미 최신 버전이면 아무 동작도 하지 diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 7e32c2752f4..52e8db3c8e1 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -68,6 +68,21 @@ List or status is the default where unambiguous. Use `--json` for structured sna and other purely visual browser state have no CLI equivalent; Cloudflare Tunnel setup is outside this command set. +## Liveness probe ceiling override + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex`, and `ocx ready` find the running +proxy through a short liveness probe: 750 ms per attempt by default, and 1500 ms with retries for +stop and start decisions. On hosts where a security layer (a content filter or an EDR-style network +extension) adds a fixed cost to every loopback connection, those ceilings can expire before a +healthy proxy answers, so these commands report the proxy as down while +`curl http://127.0.0.1:10100/healthz` succeeds. + +Set `OCX_PROBE_TIMEOUT_MS` to raise the ceilings on such hosts, for example +`OCX_PROBE_TIMEOUT_MS=5000 ocx status`. The value is whole milliseconds from 1 to 30000. The +override only raises: the 750 ms default and the 1500 ms stop/start budgets keep their floors, so +`1000` lengthens only the default probe. Unset, empty, fractional, negative, zero, or larger values +are ignored and the shipped ceilings apply. + ## Exit codes and confirmation Successful commands exit 0. Invalid usage, unknown commands or resources, failed API operations, diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index d60545cd84b..e8f401f7207 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -700,6 +700,8 @@ package registry or install an update. ### `ocx update [--tag latest|preview]` +When OpenCodex is installed through mise, this command exits unsuccessfully before stopping the proxy or changing package files and shows `mise upgrade `, using the verified local mise alias. Update checks remain available and report the installation as externally managed. An unreadable or inconsistent mise ownership record fails closed without guessing a tool name, and `--tag preview` never changes mise's configured selection. + Self-update opencodex from npm. Stable installs use `@latest`; preview installs stay on `@preview` unless you pass `--tag latest|preview`. It detects a source checkout and tells you to `git pull && bun install` instead, and is a no-op if you are already on the newest version for that diff --git a/docs-site/src/content/docs/ru/guides/desktop-app.md b/docs-site/src/content/docs/ru/guides/desktop-app.md index c2da2fe6afe..d52917226c8 100644 --- a/docs-site/src/content/docs/ru/guides/desktop-app.md +++ b/docs-site/src/content/docs/ru/guides/desktop-app.md @@ -54,6 +54,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb локальному прокси, если тот уже работает. Оно запускает встроенную среду выполнения, только если CLI подтвердил отсутствие прокси; неопределённый результат показывается как ошибка запуска. Затем дашборд открывается в webview приложения по найденному loopback-адресу. +Если приложение запущено при входе в систему и скрыто в системной панели, оно оставляет лёгкую +страницу запуска и загружает дашборд при первом открытии из панели или при повторном запуске. Используйте действия **Open dashboard** или **Open in browser** в системной панели, чтобы переключаться между встроенным дашбордом и обычным браузером. Там же доступны проверки обновлений. diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 1bbde76795f..63893b3781f 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -40,6 +40,12 @@ runtime port и проверку identity, а не поддерживая вто `ocx observe logs --follow --jsonl`. Theme, language, navigation и прочее чисто визуальное browser-state CLI не покрывает; настройка Cloudflare Tunnel тоже вне этого набора команд. +## Переопределение потолка проб доступности + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex` и `ocx ready` находят запущенный прокси короткой пробой доступности: по умолчанию 750 мс на попытку и 1500 мс с повторами для решений об остановке и запуске. Если слой безопасности (контент-фильтр или сетевое расширение класса EDR) добавляет фиксированную задержку к каждому loopback-соединению, эти потолки могут истечь раньше, чем ответит исправный прокси. + +На таких хостах задайте `OCX_PROBE_TIMEOUT_MS`, например `OCX_PROBE_TIMEOUT_MS=5000 ocx status`. Значение — целое число миллисекунд от 1 до 30000. Переопределение только повышает потолки: 750 мс по умолчанию и 1500 мс для остановки и запуска сохраняют нижнюю границу, поэтому `1000` удлиняет только пробу по умолчанию. Пустые, дробные, отрицательные, нулевые и большие значения игнорируются. + ## Коды выхода и подтверждение Успешные команды завершаются с кодом 0. Некорректное использование, неизвестные команды или diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 6719e9cf2cb..c446c0d20ba 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -411,6 +411,8 @@ one-click управление прокси. `start` и `stop` управляю ### `ocx update [--tag latest|preview]` +Если OpenCodex установлен через mise, команда завершается с ошибкой до остановки прокси или изменения файлов пакета и показывает `mise upgrade ` с проверенным локальным псевдонимом mise. Проверка обновлений остаётся доступной и сообщает о внешнем управлении. Нечитаемые или противоречивые метаданные владельца mise также запрещают изменения без угадывания имени инструмента, а `--tag preview` никогда не меняет выбранную в mise версию. + Самообновить opencodex из npm. Стабильные установки используют `@latest`; preview-установки остаются на `@preview`, если только вы не передадите `--tag latest|preview`. Команда распознаёт source checkout и предлагает вместо этого `git pull && bun install`, а если у вас уже новейшая diff --git a/docs-site/src/content/docs/tr/guides/desktop-app.md b/docs-site/src/content/docs/tr/guides/desktop-app.md index 4ec471c8464..d0c8c59320b 100644 --- a/docs-site/src/content/docs/tr/guides/desktop-app.md +++ b/docs-site/src/content/docs/tr/guides/desktop-app.md @@ -40,7 +40,7 @@ Tepsi simgesi, AppIndicator destekleyen bir masaüstü ortamı gerektirir. ## İlk açılış -Uygulama, paketindeki CLI'dan `ocx resolve --json` çalıştırmasını ister ve zaten çalışan erişilebilir bir yerel proxy varsa ona bağlanır. Paketindeki çalışma zamanını yalnızca CLI yokluğunu kanıtlarsa başlatır; belirsiz sonuç başlangıç hatası olarak gösterilir. Ardından kontrol paneli, uygulamanın web görünümünde çözümlenen geri döngü uç noktasında açılır. +Uygulama, paketindeki CLI'dan `ocx resolve --json` çalıştırmasını ister ve zaten çalışan erişilebilir bir yerel proxy varsa ona bağlanır. Paketindeki çalışma zamanını yalnızca CLI yokluğunu kanıtlarsa başlatır; belirsiz sonuç başlangıç hatası olarak gösterilir. Ardından kontrol paneli, uygulamanın web görünümünde çözümlenen geri döngü uç noktasında açılır. Oturum açılışında tepside gizli başlayan bir uygulama ise hafif başlangıç sayfasını korur ve kontrol panelini tepsiden ilk açtığınızda ya da uygulamayı yeniden başlattığınızda yükler. Gömülü kontrol paneli ile normal tarayıcınız arasında geçmek için tepsideki **Open dashboard** veya **Open in browser** eylemini kullanın. Tepsi, güncelleme denetimlerini de sunar. diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index 903367ba13b..33d7199bd13 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -46,6 +46,12 @@ logs --follow --jsonl` kullanın. Tema, dil, gezinme ve diğer tamamen görsel tarayıcı durumlarının CLI eşdeğeri yoktur; Cloudflare Tünel kurulumu bu komut kümesinin dışındadır. +## Canlılık yoklaması üst sınırının geçersiz kılınması + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex` ve `ocx ready`, çalışan proxy'yi kısa bir canlılık yoklamasıyla bulur: varsayılan olarak deneme başına 750 ms, durdurma ve başlatma kararlarında yeniden denemelerle 1500 ms. Bir güvenlik katmanının (içerik filtresi veya EDR tarzı ağ uzantısı) her loopback bağlantısına sabit bir gecikme eklediği ana makinelerde bu sınırlar, sağlıklı bir proxy yanıt vermeden dolabilir. + +Bu durumda sınırları `OCX_PROBE_TIMEOUT_MS` ile yükseltin, örneğin `OCX_PROBE_TIMEOUT_MS=5000 ocx status`. Değer 1 ile 30000 arasında tam sayı milisaniyedir. Geçersiz kılma yalnızca yükseltir: 750 ms varsayılan ve 1500 ms durdurma/başlatma bütçeleri alt sınırlarını korur, bu yüzden `1000` yalnızca varsayılan yoklamayı uzatır. Boş, kesirli, negatif, sıfır veya daha büyük değerler yok sayılır. + ## Çıkış kodları ve onaylama Başarılı komutlar 0 ile çıkar. Geçersiz kullanım, bilinmeyen komutlar veya diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index f1c30392609..b7afadb21be 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -486,6 +486,8 @@ adresindeki [web kontrol panelini](/tr/guides/web-dashboard/) açın; hub'da yö ### `ocx update [--tag latest|preview]` +OpenCodex mise üzerinden kurulduğunda bu komut proxy'yi durdurmadan veya paket dosyalarını değiştirmeden önce başarısız olur ve doğrulanmış yerel mise diğer adını kullanarak `mise upgrade ` komutunu gösterir. Güncelleme denetimi kullanılabilir kalır ve kurulumun harici olarak yönetildiğini bildirir. Okunamayan veya tutarsız mise sahiplik meta verileri de araç adını tahmin etmeden değişikliği reddeder; `--tag preview` mise içinde yapılandırılmış seçimi değiştirmez. + opencodex'i npm'den kendi kendine güncelleyin. Kararlı kurulumlar `@latest` kullanır; önizleme kurulumları `--tag latest|preview` iletmediğiniz sürece `@preview` üzerinde kalır. Bir kaynak kod kopyasını algılar ve bunun yerine `git diff --git a/docs-site/src/content/docs/zh-cn/guides/desktop-app.md b/docs-site/src/content/docs/zh-cn/guides/desktop-app.md index dcb2af1568d..2e94868ce1d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/desktop-app.md +++ b/docs-site/src/content/docs/zh-cn/guides/desktop-app.md @@ -40,7 +40,7 @@ sudo apt install ./OpenCodex--linux-amd64.deb ## 首次启动 -应用会让内置 CLI 运行 `ocx resolve --json`;如果已有可访问的本地 proxy,就连接到它。只有 CLI 证实不存在运行时,应用才会启动内置运行时;结果不确定时会显示启动失败。随后,仪表盘会在应用的 webview 中通过找到的 loopback 端点打开。 +应用会让内置 CLI 运行 `ocx resolve --json`;如果已有可访问的本地 proxy,就连接到它。只有 CLI 证实不存在运行时,应用才会启动内置运行时;结果不确定时会显示启动失败。随后,仪表盘会在应用的 webview 中通过找到的 loopback 端点打开。登录时隐藏在托盘中启动的应用会保留轻量的启动页,直到你第一次从托盘打开或再次启动应用时才加载仪表盘。 使用托盘中的 **Open dashboard** 或 **Open in browser**,可在内嵌仪表盘与常用浏览器之间切换。托盘也提供更新检查。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 530de2d5d26..1b042e81b38 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -23,6 +23,12 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 在语义明确时,默认操作是 `list` 或 `status`。使用 `--json` 获取结构化快照,使用 `ocx observe logs --follow --jsonl` 获取流式请求日志。主题、语言、导航以及其他纯视觉浏览器状态都没有 CLI 对应项;Cloudflare Tunnel 的设置不在这组命令之内。 +## 存活探测上限覆盖 + +`ocx health`、`ocx status`、`ocx account *`、`ocx login codex` 和 `ocx ready` 通过短时存活探测查找正在运行的代理:默认每次 750 ms,停止和启动判断使用带重试的 1500 ms。如果安全层(内容过滤器或 EDR 类网络扩展)给每个回环连接增加固定延迟,健康的代理可能来不及响应就已超时。 + +在这类主机上可设置 `OCX_PROBE_TIMEOUT_MS` 提高上限,例如 `OCX_PROBE_TIMEOUT_MS=5000 ocx status`。取值为 1 到 30000 的整数毫秒。覆盖只会提高上限:750 ms 默认值和 1500 ms 停止/启动预算保留下限,因此 `1000` 只会延长默认探测。未设置、空值、小数、负数、0 或更大的值都会被忽略。 + ## 退出码与确认 成功的命令退出码为 0。无效用法、未知命令或资源、API 操作失败,以及必需服务不可用时,退出码都非零。`ocx health` 只有在代理健康时才以 0 退出,否则以 1 退出,因此可作为服务探针。脚本应检查退出码,而不是解析人类可读输出。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index b1fdef059a3..a2061e36693 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -273,6 +273,8 @@ ocx codex-shim uninstall ### `ocx update [--tag latest|preview]` +当 OpenCodex 由 mise 安装时,此命令会在停止代理或修改软件包文件之前以失败状态退出,并使用经过验证的本地 mise 别名显示 `mise upgrade `。更新检查仍然可用,并会报告该安装由外部管理。无法读取或不一致的 mise 所有权元数据也会阻止修改,且不会猜测工具名称;`--tag preview` 绝不会更改 mise 中配置的选择。 + 从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。对于 npm 安装,它会在停止任何进程之前,对 Unix 缓存的所有权和访问权限执行有界检查。嵌套符号链接会通过 `lstat` 检查但不会跟随;Windows 会明确跳过这项仅适用于 Unix 的检查。检查失败时,更新会在托盘和代理仍运行的情况下中止。随后才会在替换文件之前停止正在运行的代理;已安装的服务会自动重建并启动,而前台安装则会打印 `ocx start` 作为下一步。持久化前,仪表板更新记录会隐去用户配置文件/缓存路径以及 UID/GID 值。 ```bash diff --git a/docs-site/src/content/docs/zh-tw/guides/desktop-app.md b/docs-site/src/content/docs/zh-tw/guides/desktop-app.md index d1a1670b6b5..111f96f84eb 100644 --- a/docs-site/src/content/docs/zh-tw/guides/desktop-app.md +++ b/docs-site/src/content/docs/zh-tw/guides/desktop-app.md @@ -40,7 +40,7 @@ sudo apt install ./OpenCodex--linux-amd64.deb ## 首次啟動 -應用程式會要求內附的 CLI 執行 `ocx resolve --json`;若既有本機代理可連線,就會附著其上。只有 CLI 證實代理不存在時,才會啟動內附執行環境;結果不確定時會顯示啟動失敗。接著儀表板會在應用程式的 webview 中,以找到的 loopback 端點開啟。 +應用程式會要求內附的 CLI 執行 `ocx resolve --json`;若既有本機代理可連線,就會附著其上。只有 CLI 證實代理不存在時,才會啟動內附執行環境;結果不確定時會顯示啟動失敗。接著儀表板會在應用程式的 webview 中,以找到的 loopback 端點開啟。登入時隱藏在系統匣中啟動的應用程式會保留輕量的啟動頁,直到你第一次從系統匣開啟或再次啟動應用程式時才載入儀表板。 透過系統匣的 **Open dashboard** 或 **Open in browser**,可以在內嵌儀表板與一般瀏覽器間切換。系統匣也提供更新檢查。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index 1919b7e5b0a..0b34df2be26 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -36,6 +36,12 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 `ocx observe logs --follow --jsonl` 取得串流的請求 log feed。佈景主題、語言、導覽與 其他純視覺的瀏覽器狀態沒有 CLI 對應;Cloudflare Tunnel 設定不在此命令集內。 +## 存活探測上限覆寫 + +`ocx health`、`ocx status`、`ocx account *`、`ocx login codex` 與 `ocx ready` 透過短時存活探測尋找執行中的代理:預設每次 750 ms,停止與啟動判斷使用含重試的 1500 ms。若安全層(內容過濾器或 EDR 類網路擴充)替每個回送連線增加固定延遲,健康的代理可能來不及回應就已逾時。 + +在這類主機上可設定 `OCX_PROBE_TIMEOUT_MS` 提高上限,例如 `OCX_PROBE_TIMEOUT_MS=5000 ocx status`。值為 1 到 30000 的整數毫秒。覆寫只會提高上限:750 ms 預設值與 1500 ms 停止/啟動預算保留下限,因此 `1000` 只會延長預設探測。未設定、空值、小數、負數、0 或更大的值都會被忽略。 + ## 離開碼與確認 成功的命令離開 0。無效用法、未知命令或資源、失敗的 API 操作以及無法使用的必要服務 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 4317ef37b30..957a9f0ec40 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -258,6 +258,8 @@ ocx codex-shim uninstall ### `ocx update [--tag latest|preview]` +當 OpenCodex 由 mise 安裝時,此命令會在停止代理或修改套件檔案之前以失敗狀態結束,並使用經過驗證的本機 mise 別名顯示 `mise upgrade `。更新檢查仍可使用,並會回報該安裝由外部管理。無法讀取或不一致的 mise 擁有權中繼資料也會阻止修改,且不會猜測工具名稱;`--tag preview` 絕不會變更 mise 中設定的選擇。 + 從 npm 自我更新 opencodex。穩定安裝使用 `@latest`;預覽安裝停留在 `@preview`,除非你傳入 `--tag latest|preview`。它偵測原始碼 checkout 並告訴你改用 `git pull && bun install`,且若你已是該 tag 的最新版本則為 no-op。執行中的代理會在檔案被替換前停止;已安裝的服務會自動重建並啟動,而前景安裝會印出 `ocx start` 作為下一步。 diff --git a/gui/src/components/sidebar-github-row.tsx b/gui/src/components/sidebar-github-row.tsx index 350cf0cb43d..e6e02c239d8 100644 --- a/gui/src/components/sidebar-github-row.tsx +++ b/gui/src/components/sidebar-github-row.tsx @@ -28,6 +28,7 @@ interface StarStatus { interface UpdateBadge { updateAvailable?: boolean; latestVersion?: string | null; + installer?: "bun" | "mise" | "npm" | "pnpm" | "source"; /** True when no cached registry answer exists, so "no update" is unproven. */ unknown?: boolean; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 18c5c951ca9..85a992dabef 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -435,6 +435,8 @@ export const de: Record = { "dash.updateReason.source_checkout": "Quellcode-Checkout", "dash.updateReason.latest_unavailable": "npm-Registry nicht erreichbar", "dash.updateReason.already_latest": "bereits auf dem neuesten Stand", + "dash.updateReason.externally_managed": "extern von mise verwaltet; führe den angezeigten Befehl aus", + "dash.updateReason.external_ownership_invalid": "mise-Eigentümerdaten sind nicht lesbar oder widersprüchlich", "dash.updateReason.unknown": "Update nicht verfügbar", "dash.updateRestart": "Nach Update neu starten", "dash.updateRestartHint": "Empfohlen. Die aktuelle GUI läuft weiter mit altem Code, bis der Proxy neu startet.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6621a81428c..d48d0ed10bb 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -456,6 +456,8 @@ export const en = { "dash.updateReason.source_checkout": "source checkout", "dash.updateReason.latest_unavailable": "npm registry unreachable", "dash.updateReason.already_latest": "already on latest", + "dash.updateReason.externally_managed": "managed externally by mise; run the shown command", + "dash.updateReason.external_ownership_invalid": "mise ownership metadata is unreadable or inconsistent", "dash.updateReason.unknown": "update unavailable", "dash.updateRestart": "Restart after update", "dash.updateRestartHint": "Recommended. The current GUI keeps running the old code until the proxy restarts.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 658c8cddf70..659ea434de2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -446,6 +446,8 @@ export const fr: Record = { "dash.updateReason.source_checkout": "extraction du code source", "dash.updateReason.latest_unavailable": "registre npm inaccessible", "dash.updateReason.already_latest": "dernière version déjà installée", + "dash.updateReason.externally_managed": "géré par mise ; exécutez la commande affichée", + "dash.updateReason.external_ownership_invalid": "les métadonnées de propriété mise sont illisibles ou incohérentes", "dash.updateReason.unknown": "mise à jour indisponible", "dash.updateRestart": "Redémarrer après la mise à jour", "dash.updateRestartHint": "Recommandé. L’interface graphique actuelle continue d’exécuter l’ancien code jusqu’au redémarrage du proxy.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7a60eda1f86..2136f3322e1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -444,6 +444,8 @@ export const ja: Record = { "dash.updateReason.source_checkout": "ソースチェックアウト", "dash.updateReason.latest_unavailable": "npm レジストリに到達できません", "dash.updateReason.already_latest": "最新です", + "dash.updateReason.externally_managed": "mise によって外部管理されています。表示されたコマンドを実行してください", + "dash.updateReason.external_ownership_invalid": "mise の所有権メタデータを読み取れないか、整合していません", "dash.updateReason.unknown": "更新は利用できません", "dash.updateRestart": "更新後に再起動", "dash.updateRestartHint": "推奨。プロキシが再起動されるまで現在の GUI は古いコードを実行し続けます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5d13494c1af..b7a459d52de 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -442,6 +442,8 @@ export const ko: Record = { "dash.updateReason.source_checkout": "소스 체크아웃", "dash.updateReason.latest_unavailable": "npm 레지스트리에 연결할 수 없음", "dash.updateReason.already_latest": "이미 최신 버전", + "dash.updateReason.externally_managed": "mise에서 외부 관리 중입니다. 표시된 명령을 실행하세요", + "dash.updateReason.external_ownership_invalid": "mise 소유권 메타데이터를 읽을 수 없거나 일관되지 않습니다", "dash.updateReason.unknown": "업데이트 불가", "dash.updateRestart": "업데이트 후 재시작", "dash.updateRestartHint": "권장. 프록시를 재시작하기 전까지 현재 GUI는 이전 코드로 계속 실행됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 295ac60c796..93965606aa7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -444,6 +444,8 @@ export const ru: Record = { "dash.updateReason.source_checkout": "установка из исходного кода", "dash.updateReason.latest_unavailable": "реестр npm недоступен", "dash.updateReason.already_latest": "уже установлена последняя версия", + "dash.updateReason.externally_managed": "управляется mise; выполните показанную команду", + "dash.updateReason.external_ownership_invalid": "метаданные владельца mise недоступны или противоречивы", "dash.updateReason.unknown": "обновление недоступно", "dash.updateRestart": "Перезапустить после обновления", "dash.updateRestartHint": "Рекомендуется. Текущий GUI продолжает работать на старом коде, пока прокси не перезапустится.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 0ce90f09561..1856735a5bc 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -448,6 +448,8 @@ export const tr: Record = { "dash.updateReason.source_checkout": "kaynak kod kopyası", "dash.updateReason.latest_unavailable": "npm sunucusuna ulaşılamıyor", "dash.updateReason.already_latest": "zaten en son sürümde", + "dash.updateReason.externally_managed": "mise tarafından harici olarak yönetiliyor; gösterilen komutu çalıştırın", + "dash.updateReason.external_ownership_invalid": "mise sahiplik meta verileri okunamıyor veya tutarsız", "dash.updateReason.unknown": "güncelleme kullanılamıyor", "dash.updateRestart": "Güncellemeden sonra yeniden başlat", "dash.updateRestartHint": "Önerilir. Proxy yeniden başlayana kadar mevcut GUI eski kodu çalıştırmaya devam eder.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 026d9e97d63..a1b8502f152 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -446,6 +446,8 @@ export const vi: Record = { "dash.updateReason.source_checkout": "checkout mã nguồn", "dash.updateReason.latest_unavailable": "không thể kết nối với registry npm", "dash.updateReason.already_latest": "đã ở phiên bản mới nhất", + "dash.updateReason.externally_managed": "được mise quản lý bên ngoài; hãy chạy lệnh được hiển thị", + "dash.updateReason.external_ownership_invalid": "siêu dữ liệu quyền sở hữu của mise không đọc được hoặc không nhất quán", "dash.updateReason.unknown": "cập nhật không khả dụng", "dash.updateRestart": "Khởi động lại sau khi cập nhật", "dash.updateRestartHint": "Khuyên dùng. GUI hiện tại vẫn tiếp tục chạy mã nguồn cũ cho đến khi proxy khởi động lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a4df6f942d3..eba5ce1b2ea 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -334,6 +334,8 @@ export const zhTW: Record = { "dash.updateReason.source_checkout": "原始碼檢出", "dash.updateReason.latest_unavailable": "無法連線 npm 登入檔", "dash.updateReason.already_latest": "已是最新版本", + "dash.updateReason.externally_managed": "由 mise 外部管理;請執行顯示的命令", + "dash.updateReason.external_ownership_invalid": "mise 擁有權中繼資料無法讀取或不一致", "dash.updateReason.unknown": "無法更新", "dash.updateRestart": "更新後重新啟動", "dash.updateRestartHint": "推薦開啟。代理重新啟動前,當前 GUI 仍執行舊程式碼。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 06f31de2362..fe9424b8151 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -439,6 +439,8 @@ export const zh: Record = { "dash.updateReason.source_checkout": "源码检出", "dash.updateReason.latest_unavailable": "无法连接 npm 注册表", "dash.updateReason.already_latest": "已是最新版本", + "dash.updateReason.externally_managed": "由 mise 外部管理;请运行显示的命令", + "dash.updateReason.external_ownership_invalid": "mise 所有权元数据无法读取或不一致", "dash.updateReason.unknown": "无法更新", "dash.updateRestart": "更新后重启", "dash.updateRestartHint": "推荐开启。代理重启前,当前 GUI 仍运行旧代码。", diff --git a/gui/src/pages/dashboard-dialogs.tsx b/gui/src/pages/dashboard-dialogs.tsx index 4b96651d0fa..f0f10050680 100644 --- a/gui/src/pages/dashboard-dialogs.tsx +++ b/gui/src/pages/dashboard-dialogs.tsx @@ -72,7 +72,9 @@ export function DashboardDialogs(d: Dash) { {updateCheck.updateAvailable ? t("dash.updateAvailable") : t("dash.updateCurrent")} -
{t("dash.updateCommand")} {updateCheck.command}
+ {updateCheck.command && ( +
{t("dash.updateCommand")} {updateCheck.command}
+ )} {updateCheck.reason === "source_checkout" && (
{t("dash.updateSource")}
)} diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index ae24f861a2e..d96ec065cdd 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -125,7 +125,7 @@ export interface SidecarPatch { export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } export type UsageSummary30d = import("../usage-summary-resource").UsageReadMetadata & { summary: { requests: number; totalTokens: number; coverageRatio: number } }; export type UpdateChannel = "latest" | "preview"; -export type Installer = "npm" | "bun" | "source"; +export type Installer = "bun" | "mise" | "npm" | "pnpm" | "source"; export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; export interface SyncResult { ok: boolean; @@ -189,6 +189,8 @@ export function updateReasonLabel(reason: string | undefined, t: (key: TKey) => case "source_checkout": return t("dash.updateReason.source_checkout"); case "latest_unavailable": return t("dash.updateReason.latest_unavailable"); case "already_latest": return t("dash.updateReason.already_latest"); + case "externally_managed": return t("dash.updateReason.externally_managed"); + case "external_ownership_invalid": return t("dash.updateReason.external_ownership_invalid"); default: return t("dash.updateReason.unknown"); } } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d8534b72812..fbd96f49bbf 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -651,6 +651,9 @@ "config-user-edits.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", + "gui-desktop-sidecar-signing.test.ts": "gui", + "linux-desktop-packaged-ci.test.ts": "ci-workflows", + "probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing", "consume-for-inspection-cancel.test.ts": "server", "container-bootstrap.test.ts": "service", @@ -999,6 +1002,7 @@ "legacy-shell-compat.test.ts": "responses", "live-call-bindings.test.ts": "server", "live-service-manager-guard.test.ts": "service", + "linux-desktop-packaged-e2e.test.ts": "ci-workflows", "local-aside-sync-capability.test.ts": "server", "local-destinations.test.ts": "lib", "local-management-attestation.test.ts": "server", @@ -1620,6 +1624,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-mise.test.ts": "update", "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 5682c76ac71..c8cf409f332 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -834,14 +834,14 @@ Restart the Codex desktop app and app-servers. | Flag | Value | Meaning | |---|---|---| -| `--yes` | boolean | Required: fully quits and relaunches the operator's Codex desktop app and restarts its app-servers. | +| `--yes` | boolean | Required: fully quits and relaunches the operator's Codex desktop app, which may discard unsaved composer drafts, model-picker selections, and pending approval prompts; also restarts its app-servers. | | `--json` | boolean | Emit the restart result as JSON. | JSON mode: `payload`. - `sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request. - Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request. -- --yes is mandatory because this interrupts a running editor session, which must never happen because an agent guessed a subcommand. +- --yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand. ### `ocx integration native` diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index cb117bcba05..bb153d987c2 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -784,7 +784,7 @@ export const CAPABILITIES: readonly Capability[] = [ summary: "Restart the Codex desktop app and app-servers.", routes: [{ method: "POST", path: "/api/system/codex-restart" }], flags: [ - { name: "--yes", value: "boolean", summary: "Required: fully quits and relaunches the operator's Codex desktop app and restarts its app-servers." }, + { name: "--yes", value: "boolean", summary: "Required: fully quits and relaunches the operator's Codex desktop app, which may discard unsaved composer drafts, model-picker selections, and pending approval prompts; also restarts its app-servers." }, { name: "--json", value: "boolean", summary: "Emit the restart result as JSON." }, ], mutates: true, @@ -792,7 +792,7 @@ export const CAPABILITIES: readonly Capability[] = [ details: [ "`sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request.", "Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request.", - "--yes is mandatory because this interrupts a running editor session, which must never happen because an agent guessed a subcommand.", + "--yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand.", ], }, { diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 846a8c921e6..1ae294c82ad 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -485,7 +485,8 @@ export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Pr // A proxy that has only just bound can miss a single probe while its event loop // is still settling startup work — the same just-started race the stop paths // already retry for (#764, SERVICE_STOP_LIVENESS). Only the attempts budget is - // borrowed here; the probe timeout remains DEFAULT_PROBE_TIMEOUT_MS (750 ms). + // borrowed here; the probe timeout remains DEFAULT_PROBE_TIMEOUT_MS (750 ms unless + // OCX_PROBE_TIMEOUT_MS raises it). // Without this, `ocx claude` can spawn a second proxy while the first is serving. const live = await (deps.findLiveProxy ?? findLiveProxy)({ attempts: 3 }); if (live) return live.port; diff --git a/src/cli/ready.ts b/src/cli/ready.ts index de918600f41..876344ac839 100644 --- a/src/cli/ready.ts +++ b/src/cli/ready.ts @@ -199,7 +199,7 @@ export async function runReady(args: ReadyArgs, io: ReadyIo = {}): Promise PackageTreeObservation | null; -export type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source"; +export type PackageTreeRuntimeInstall = "bun" | "mise" | "npm" | "pnpm" | "source"; const packageManifestUrl = new URL("../../package.json", import.meta.url); diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 5e004c52aa5..21dd8bb021a 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -84,12 +84,47 @@ export interface LivenessIo { createChallengeFn?: () => string; } +/** + * Operator override for the per-probe fetch ceilings below (`OCX_PROBE_TIMEOUT_MS`), + * integer milliseconds in [1, MAX_PROBE_TIMEOUT_MS]. + * + * Some hosts put a security layer (content filter, EDR network extension) in front of + * loopback TCP that adds a fixed per-connect cost, measured at about one second on an + * affected macOS machine. The shipped 750 ms probe then aborts before a healthy proxy can + * answer, and every CLI liveness consumer reports the proxy as down while a direct + * `curl /healthz` succeeds. + * + * The override only raises: each ceiling keeps its shipped floor (750 ms for the shared + * default, 1500 ms for the stop/start ownership budgets that guard against a duplicate + * proxy, #764, #5004), so a small value can never shorten them. Values above the 30 s + * ceiling are ignored rather than clamped: a stop multiplies its budget by the attempt + * count, and a typo must not turn a stop into a wait of minutes or days. Parsed once at + * module load; anything malformed falls back to the defaults and never breaks startup. + */ +export const MAX_PROBE_TIMEOUT_MS = 30_000; +const SHARED_PROBE_FLOOR_MS = 750; +const OWNERSHIP_PROBE_FLOOR_MS = 1500; + +export function parseProbeTimeoutOverrideMs(raw: string | undefined): number | undefined { + const trimmed = raw?.trim(); + if (!trimmed || !/^\d+$/.test(trimmed)) return undefined; + const n = Number(trimmed); + return n > 0 && n <= MAX_PROBE_TIMEOUT_MS ? n : undefined; +} + +/** The ceiling for a probe whose shipped value is `floorMs`, raised by a valid override only. */ +export function probeCeilingMs(floorMs: number, override: number | undefined): number { + return Math.max(floorMs, override ?? 0); +} + +const probeTimeoutOverrideMs = parseProbeTimeoutOverrideMs(process.env.OCX_PROBE_TIMEOUT_MS); + /** Default per-probe fetch ceiling shared by liveness and readiness probes. */ -export const DEFAULT_PROBE_TIMEOUT_MS = 750; +export const DEFAULT_PROBE_TIMEOUT_MS = probeCeilingMs(SHARED_PROBE_FLOOR_MS, probeTimeoutOverrideMs); /** Default probe options for service stop / orphan cleanup — a just-bound proxy can miss a single 750ms probe. */ export const SERVICE_STOP_LIVENESS: Pick = { - timeoutMs: 1500, + timeoutMs: probeCeilingMs(OWNERSHIP_PROBE_FLOOR_MS, probeTimeoutOverrideMs), attempts: 3, }; @@ -105,7 +140,7 @@ export const SERVICE_STOP_LIVENESS: Pick = * the stop path already uses for the mirror-image decision. */ export const START_OWNERSHIP_LIVENESS: Pick = { - timeoutMs: 1500, + timeoutMs: probeCeilingMs(OWNERSHIP_PROBE_FLOOR_MS, probeTimeoutOverrideMs), attempts: 3, }; diff --git a/src/update/badge.ts b/src/update/badge.ts index 0c373c1b715..fabfe746141 100644 --- a/src/update/badge.ts +++ b/src/update/badge.ts @@ -22,6 +22,7 @@ export interface UpdateBadge { currentVersion: string; latestVersion: string | null; channel: Channel; + installer: ReturnType; /** False for source checkouts, where the GUI cannot offer a one-click update. */ canUpdate: boolean; /** True when no cached registry answer exists yet, so "no update" is unproven. */ @@ -53,7 +54,8 @@ export function readUpdateBadge(deps: UpdateBadgeDeps = defaultDeps): UpdateBadg currentVersion: current, latestVersion: null, channel, - canUpdate: installer !== "source", + installer, + canUpdate: installer !== "source" && installer !== "mise", unknown: true, }; // A source checkout has nothing to compare against, so "unknown" is not useful there. diff --git a/src/update/check-types.ts b/src/update/check-types.ts new file mode 100644 index 00000000000..bbf5ebdbd28 --- /dev/null +++ b/src/update/check-types.ts @@ -0,0 +1,9 @@ +import type { Channel, Installer, InstallOwnership } from "./index"; + +export interface UpdateCheckDeps { + currentVersion: () => string; + detectInstall: () => Installer; + detectInstallOwnership?: () => InstallOwnership; + latestVersion: (tag: Channel) => string | null; + miseUpdateCommand?: (ownership: InstallOwnership) => string | null; +} diff --git a/src/update/index.ts b/src/update/index.ts index 26acd3e92cf..84aa404b757 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -15,7 +15,15 @@ import { unprivilegedOwnershipMutationEnvironment } from "../service/ownership-m import { withUpdateOwnershipLease, readUpdateRuntimeTarget } from "./ownership-transaction"; import { npmInvocation } from "./npm-invocation.mjs"; import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; -import { detectInstallFromPath } from "./install-detection.mjs"; +import { + detectInstallFromPath, + detectInstallOwnershipFromPath, +} from "./install-detection.mjs"; +import type { + DetectedInstall, + InstallOwnership, + MiseInstallOwner, +} from "./install-detection.d.mts"; import { pnpmOwnerInvocation, readPnpmGlobalPackage, @@ -50,14 +58,28 @@ export function historyRestoreIncomplete(configDir = getConfigDir()): boolean { export const PKG = "@bitkyc08/opencodex"; const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src/update -export type Installer = "bun" | "npm" | "pnpm" | "source"; +export type Installer = DetectedInstall; export type Channel = "latest" | "preview"; +export type { InstallOwnership, MiseInstallOwner }; /** Infer how opencodex is installed from the running module's path. */ export function detectInstall(): Installer { return detectInstallFromPath(HERE, { exists: existsSync }); } +/** Resolve installer ownership and verified mise update guidance for this package. */ +export function detectInstallOwnership(): InstallOwnership { + return detectInstallOwnershipFromPath(HERE, { exists: existsSync }); +} + +export function miseUpdateCommand( + ownership: InstallOwnership = detectInstallOwnership(), +): string | null { + return ownership.installer === "mise" && ownership.owner + ? `mise upgrade ${ownership.owner.tool}` + : null; +} + function packageRoot(): string { return resolve(HERE, "..", ".."); } @@ -269,6 +291,9 @@ export function latestVersion( /** The global-install command opencodex would run to update on this channel. */ export function updateCommand(installer: Installer, tag: Channel, resolvedVersion?: string | null): { bin: string; args: string[] } { + if (installer === "mise") { + throw new Error("mise-owned installations must be upgraded through mise"); + } // Immutable target: when the registry resolved a concrete version, install exactly // that version — the dist-tag can move between resolution and install (TOCTOU). const target = resolvedVersion || tag; @@ -361,11 +386,25 @@ async function resolvedRuntimeOwnership(): Promise * Bun binary. */ export async function runUpdate(): Promise { - const installer = detectInstall(); + const ownership = detectInstallOwnership(); + const installer = ownership.installer; const current = currentVersion(); const tag = updateTag(current); console.log(`opencodex v${current} (installed via ${installer}, tag ${tag})`); + if (installer === "mise") { + const command = miseUpdateCommand(ownership); + if (command) { + console.error(`OpenCodex is externally managed by mise. Update it with: ${command}`); + } else { + console.error( + "OpenCodex appears to be managed by mise, but its ownership metadata is unreadable or inconsistent. Repair the mise installation metadata before updating.", + ); + } + process.exitCode = 1; + return; + } + if (installer === "source") { console.log("Running from a source checkout — update with: git pull && bun install"); return; diff --git a/src/update/install-detection.d.mts b/src/update/install-detection.d.mts index 88f68ba7318..7ede7997c27 100644 --- a/src/update/install-detection.d.mts +++ b/src/update/install-detection.d.mts @@ -1,6 +1,33 @@ -export type DetectedInstall = "bun" | "npm" | "pnpm" | "source"; +export type DetectedInstall = "bun" | "mise" | "npm" | "pnpm" | "source"; + +export interface MiseInstallOwner { + tool: string; + backend: string; + installPath: string; + toolRoot: string; +} + +export type InstallOwnership = + | { installer: Exclude } + | { + installer: "mise"; + owner: MiseInstallOwner | null; + error?: "metadata_unreadable" | "metadata_inconsistent"; + }; + +export interface InstallDetectionDeps { + exists?: (path: string) => boolean; + probe?: (path: string) => "present" | "absent" | "unreadable"; + readFile?: (path: string) => string; + realpath?: (path: string) => string; +} export declare function detectInstallFromPath( packagePath: string, - deps?: { exists?: (path: string) => boolean; realpath?: (path: string) => string }, + deps?: InstallDetectionDeps, ): DetectedInstall; + +export declare function detectInstallOwnershipFromPath( + packagePath: string, + deps?: InstallDetectionDeps, +): InstallOwnership; diff --git a/src/update/install-detection.mjs b/src/update/install-detection.mjs index e21064c9b54..6b1c3ac531d 100644 --- a/src/update/install-detection.mjs +++ b/src/update/install-detection.mjs @@ -1,4 +1,26 @@ -import { realpathSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; + +const OPENCODEX_MISE_BACKEND = "npm:@bitkyc08/opencodex"; +const OPENCODEX_MISE_BACKEND_DIR = "npm-bitkyc08-opencodex"; + +/** + * @typedef {{ + * tool: string; + * backend: string; + * installPath: string; + * toolRoot: string; + * }} MiseInstallOwner + */ + +/** + * @typedef {{ + * installer: "bun" | "npm" | "pnpm" | "source"; + * } | { + * installer: "mise"; + * owner: MiseInstallOwner | null; + * error?: "metadata_unreadable" | "metadata_inconsistent"; + * }} InstallOwnership + */ /** * Infer the package manager from the path of the running package. @@ -14,7 +36,30 @@ import { realpathSync } from "node:fs"; * virtual store. */ export function detectInstallFromPath(packagePath, deps = {}) { - const exists = deps.exists; + return detectInstallOwnershipFromPath(packagePath, deps).installer; +} + +/** + * Infer the outer owner of the running package. + * + * mise's npm backend deliberately contains an ordinary npm/aube installation, so + * package-manager layout alone reports npm. The adjacent backend record is the + * stronger ownership signal: it identifies the mise alias and canonical backend, + * while containment proves that the running package belongs to that installation. + * + * @param {string} packagePath + * @param {{ + * exists?: (path: string) => boolean; + * probe?: (path: string) => "present" | "absent" | "unreadable"; + * readFile?: (path: string) => string; + * realpath?: (path: string) => string; + * }} deps + * @returns {InstallOwnership} + */ +export function detectInstallOwnershipFromPath(packagePath, deps = {}) { + const exists = deps.exists ?? existsSync; + const probe = deps.probe ?? probeMetadata; + const readFile = deps.readFile ?? (path => readFileSync(path, "utf8")); const candidates = [String(packagePath)]; try { const resolved = (deps.realpath ?? realpathSync)(String(packagePath)); @@ -24,17 +69,151 @@ export function detectInstallFromPath(packagePath, deps = {}) { // realpath. The lexical path still carries the evidence when it is available. } - let sawNodeModules = false; + let detectedManager = "source"; + /** @type {MiseInstallOwner[]} */ + const miseOwners = []; + /** @type {"metadata_unreadable" | "metadata_inconsistent" | undefined} */ + let miseError; for (const candidate of candidates) { + const mise = detectMiseOwner(candidate, { probe, readFile }); + if (mise.recognized) { + if (mise.owner) miseOwners.push(mise.owner); + else miseError = mise.error; + } const detected = detectInstallCandidate(candidate, exists); - if (detected === "pnpm" || detected === "bun") return detected; - if (detected === "npm") sawNodeModules = true; + if (detected === "pnpm" || detected === "bun") detectedManager = detected; + else if (detected === "npm" && detectedManager === "source") detectedManager = "npm"; } - return sawNodeModules ? "npm" : "source"; + // Any recognized ownership error on either spelling takes precedence over every verified + // owner. Keeping a command from the other candidate could authorize mutation across a + // lexical/resolved-path mismatch, so fail closed without recovery guidance. + if (miseError) return { installer: "mise", owner: null, error: miseError }; + const miseOwner = miseOwners.at(-1); + if (miseOwner) { + // The lexical and resolved spellings of one install differ when an ancestor such as the + // mise data directory is a symlink (macOS /var -> /private/var). Both still name the same + // physical tool directory and the same backend file, so compare canonical directories; + // any other disagreement stays inconsistent. + const realpath = deps.realpath ?? realpathSync; + const consistent = miseOwners.every(owner => + owner.tool === miseOwner.tool + && owner.backend === miseOwner.backend + && sameDirectory(owner.toolRoot, miseOwner.toolRoot, realpath) + ); + return consistent + ? { installer: "mise", owner: miseOwner } + : { installer: "mise", owner: null, error: "metadata_inconsistent" }; + } + return { installer: detectedManager }; +} + +function parseBackendMetadata(content) { + const fields = new Map(); + for (const line of String(content).split(/\r?\n/)) { + const match = /^\s*(short|full)\s*=\s*("(?:[^"\\]|\\.)*"|'[^']*')\s*(?:#.*)?$/.exec(line); + if (!match) continue; + if (fields.has(match[1])) return null; + try { + fields.set( + match[1], + match[2].startsWith('"') ? JSON.parse(match[2]) : match[2].slice(1, -1), + ); + } catch { + return null; + } + } + const tool = fields.get("short"); + const backend = fields.get("full"); + return typeof tool === "string" && typeof backend === "string" + ? { tool, backend } + : null; +} + +function detectMiseOwner(packagePath, deps) { + const windowsPath = /^[A-Za-z]:[\\/]/.test(String(packagePath)) + || String(packagePath).startsWith("\\\\"); + const normalized = (windowsPath ? String(packagePath).replaceAll("\\", "/") : String(packagePath)) + .replace(/\/+$/, ""); + const lower = normalized.toLowerCase(); + let marker = -1; + let installPath; + let toolRoot; + let metadataPath; + while ((marker = lower.indexOf("/node_modules/", marker + 1)) >= 1) { + installPath = normalized.slice(0, marker); + const slash = installPath.lastIndexOf("/"); + if (slash < 1) continue; + toolRoot = installPath.slice(0, slash); + metadataPath = `${toolRoot}/.mise.backend.toml`; + const metadataState = deps.probe(metadataPath); + if (metadataState === "present") break; + if (metadataState === "unreadable") { + return { recognized: true, owner: null, error: "metadata_unreadable" }; + } + metadataPath = undefined; + } + if (!metadataPath || !installPath || !toolRoot) return { recognized: false }; + + let metadata; + try { + metadata = parseBackendMetadata(deps.readFile(metadataPath)); + } catch { + return { recognized: true, owner: null, error: "metadata_unreadable" }; + } + const toolDir = toolRoot.slice(toolRoot.lastIndexOf("/") + 1); + const expectedToolDir = metadata?.tool === OPENCODEX_MISE_BACKEND + ? OPENCODEX_MISE_BACKEND_DIR + : metadata?.tool; + if ( + !metadata + || metadata.backend !== OPENCODEX_MISE_BACKEND + || (metadata.tool !== OPENCODEX_MISE_BACKEND + && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(metadata.tool)) + || !samePath(expectedToolDir, toolDir, windowsPath) + ) { + return { recognized: true, owner: null, error: "metadata_inconsistent" }; + } + return { + recognized: true, + owner: { + tool: metadata.tool, + backend: metadata.backend, + installPath, + toolRoot, + }, + }; +} + +function probeMetadata(path) { + try { + statSync(path); + return "present"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? error.code + : undefined; + return code === "ENOENT" || code === "ENOTDIR" ? "absent" : "unreadable"; + } +} + +function sameDirectory(left, right, realpath) { + if (samePath(left, right)) return true; + try { + return samePath(realpath(left), realpath(right)); + } catch { + return false; + } +} + +function samePath(left, right, windows = /^[A-Za-z]:\//.test(left) && /^[A-Za-z]:\//.test(right)) { + return windows ? left.toLowerCase() === right.toLowerCase() : left === right; } function detectInstallCandidate(packagePath, exists) { - const normalized = String(packagePath).replaceAll("\\", "/"); + const path = String(packagePath); + const normalized = /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\") + ? path.replaceAll("\\", "/") + : path; const segments = normalized.split("/").filter(Boolean); // Windows paths are case-insensitive. Treating the structural marker this way also // keeps a preserved-symlink path from being downgraded merely because its casing came diff --git a/src/update/job.ts b/src/update/job.ts index 03765e3f8e0..565a593400e 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -34,19 +34,22 @@ import { import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service"; import { runUpdateRestartWithOwnershipLease, type ServiceOwnershipResolution } from "./restart-ownership"; import { - type Channel, - type Installer, + type Channel, type Installer, PKG, checkUpdatePackageIntegrity, currentVersion, defaultUpdateTag, detectInstall, + detectInstallOwnership, latestVersion, + miseUpdateCommand, updateCommand, updateCommandStr, resolveCurrentPnpmGlobalOwner, resolvePnpmActiveLauncher, } from "./index"; +import type { UpdateCheckDeps } from "./check-types"; +export type { UpdateCheckDeps } from "./check-types"; import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs"; import { isNewer } from "./notify"; import { isRealBunBinary } from "../lib/bun-binary-validator.mjs"; @@ -114,12 +117,6 @@ export class UpdateJobError extends Error { } } -export interface UpdateCheckDeps { - currentVersion: () => string; - detectInstall: () => Installer; - latestVersion: (tag: Channel) => string | null; -} - interface UpdateWorkerProcess { pid?: number; unref(): void; @@ -136,7 +133,9 @@ export interface StartUpdateJobDeps { const defaultCheckDeps: UpdateCheckDeps = { currentVersion, detectInstall, + detectInstallOwnership, latestVersion, + miseUpdateCommand, }; function nodeBin(): string { @@ -489,16 +488,22 @@ export function checkForUpdate( deps: UpdateCheckDeps = defaultCheckDeps, ): UpdateCheckResult { const current = deps.currentVersion(); - const installer = deps.detectInstall(); + const ownership = deps.detectInstallOwnership?.(); + const installer = ownership?.installer ?? deps.detectInstall(); const channel = requestedChannel ?? normalizeUpdateChannel(null, current); const latest = installer === "source" ? null : deps.latestVersion(channel); const updateAvailable = !!latest && isNewer(latest, current, channel); let reason: string | undefined; - let command = installer === "source" ? manualSourceCommand() : updateExecutionCommand(installer, channel).display; + let command = installer === "source" + ? manualSourceCommand() + : installer === "mise" + ? (ownership && deps.miseUpdateCommand?.(ownership)) ?? "" + : updateExecutionCommand(installer, channel).display; if (installer === "source") { reason = "source_checkout"; - command = manualSourceCommand(); + } else if (installer === "mise") { + reason = command ? "externally_managed" : "external_ownership_invalid"; } else if (!latest) { reason = "latest_unavailable"; } else if (!updateAvailable) { @@ -511,7 +516,7 @@ export function checkForUpdate( channel, installer, updateAvailable, - canUpdate: installer !== "source" && updateAvailable, + canUpdate: installer !== "source" && installer !== "mise" && updateAvailable, command, releaseNotesUrl: RELEASE_NOTES_URL, ...(reason ? { reason } : {}), diff --git a/src/update/notify.ts b/src/update/notify.ts index 5764af3992c..9331440aa4d 100644 --- a/src/update/notify.ts +++ b/src/update/notify.ts @@ -139,7 +139,8 @@ export function interactiveGuardOk(): boolean { * the one-time star prompt has already run (first-run yield, O1). */ export function shouldConsider(): { channel: Channel; current: string } | null { - if (detectInstall() === "source") return null; + const installer = detectInstall(); + if (installer === "source" || installer === "mise") return null; const current = currentVersion(); if (current === "?" || isSourceBuildVersion(current)) return null; if (!interactiveGuardOk()) return null; diff --git a/structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md b/structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md new file mode 100644 index 00000000000..9c512cae17b --- /dev/null +++ b/structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md @@ -0,0 +1,12 @@ +# ADR-5493 — decision recorded under "Linux packaged-shell acceptance" + +- Contract owner: [desktop-shell.md](../desktop-shell.md#linux-packaged-shell-acceptance) + +## Decision record + +- 목적과 의도: Make ordinary Linux pull requests prove the package users launch instead of proving only that the Rust shell compiles. +- 기존 구현 및 제약 조건: The Rust-only job used empty sidecar and resource stubs, while the real-install gate needs published releases, operator GUI hooks, and a protected self-hosted runner. Linux keeps Bun as Tauri's external binary through a byte-identity-checked patchelf wrapper, and sequential Linux formats must not share Tauri's patched release binary. +- 검토한 주요 대안: Install deb packages directly on hosted runners; require the privileged installed-artifact gate for every pull request; replace Linux externalBin with a separate resource launcher; or extract both package payloads and exercise their shared runtime path with format-local build roots. +- 선택한 방식: Preserve the existing verified externalBin packaging, build AppImage and deb under independent Cargo targets, stage both outputs read-only, and run the extracted payloads under isolated homes, a loopback port held until spawn, Xvfb, Openbox, and D-Bus. +- 다른 대안 대신 이 방식을 선택한 이유: The selected path covers bundle layout, WebKit startup, the real bundled sidecar, no-tray behavior, and coordinated exit without replacing the already-landed sidecar-integrity boundary, changing the hosted runner's package database, or granting workflow write permissions. +- 장점, 단점 및 영향: Desktop changes gain bounded Linux package acceptance and diagnostic evidence. The lane does not prove dpkg maintainer scripts, desktop integration, elevation, signed updates, or a physical compositor; those remain the installed-artifact gate's responsibility. diff --git a/structure/decisions/ADR-5494-lightweight-background-startup.md b/structure/decisions/ADR-5494-lightweight-background-startup.md new file mode 100644 index 00000000000..7575131ebe7 --- /dev/null +++ b/structure/decisions/ADR-5494-lightweight-background-startup.md @@ -0,0 +1,12 @@ +# ADR-5494 — decision recorded under "Startup, quit and the tray" + +- Contract owner: [desktop-shell.md](../desktop-shell.md#startup-quit-and-the-tray) + +## Decision record + +- 목적과 의도: Keep a login-started desktop shell ready in the background without paying the full dashboard's render and polling cost before a person opens it. +- 기존 구현 및 제약 조건: The shell already owns a small bundled startup surface, but every successful startup replaced it with the loopback dashboard even when the main window remained hidden behind a usable tray. +- 검토한 주요 대안: Destroy and recreate the WebView on every open; add a second dashboard window; suspend individual dashboard pollers; or retain the existing startup surface until the first explicit open. +- 선택한 방식: A hidden autostart launch stays on the bundled ready surface. Manual launches and any visible no-tray launch keep eager dashboard navigation; tray Open Dashboard, a second ordinary launch, and the shell command lazily navigate before showing. +- 다른 대안 대신 이 방식을 선택한 이유: It removes background React work without adding a window, renderer lifecycle, daemon, or new state owner, and it preserves the already-tested visible startup and failure surface. +- 장점, 단점 및 영향: Background login uses less work and explicit opens remain immediate after one navigation. The first open after hidden startup now pays the dashboard load once, while visible/manual behavior is unchanged. diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 462406f7970..99a8d347b71 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -5,9 +5,11 @@ discovers the loopback proxy, lazily retries management authentication, starts the bundled `ocx` sidecar only when the configured endpoint is unreachable, and owns the tray, autostart, single-instance, and window lifecycle behavior. -`desktop/ui/` is the startup surface. Once the runtime reports healthy the shell navigates the -webview to the proxy's loopback dashboard (`/#/usage`) rather than bundling or serving `gui/dist` -itself. The page renders what the shell tells it and probes nothing on its own; it asks +`desktop/ui/` is the startup surface. Once the runtime reports healthy, a visible or manually +launched shell navigates the webview to the proxy's loopback dashboard (`/#/usage`) rather than +bundling or serving `gui/dist` itself. A hidden login launch retains the small bundled ready surface +until a person explicitly opens the dashboard. The page renders what the shell tells it and probes +nothing on its own; it asks `startup_phases` for the state list rather than restating it, takes the current state from `startup_snapshot` on load because the first states finish in milliseconds, and then follows the `startup-phase` event. `startup_snapshot` always answers with a state; it used to be able to @@ -74,6 +76,18 @@ manual launch shows its window before the sequence begins, a login launch after Registering happens once per process, so a retry re-runs only the runtime half and cannot build a second tray icon with its own refresh loop. +A hidden login launch does not preload the full dashboard after Ready. `finish` keeps the bundled +startup surface while the main window remains hidden; Open Dashboard, a second ordinary app launch, +and the shell's explicit open command all pass through `startup::open_dashboard`, which performs the +one lazy navigation before showing the window. A no-tray login launch is already visible and keeps +the eager behavior, as does every manual launch. If a person opens during startup, the bootstrap is +shown immediately and the open is recorded before progress is read; `finish` reads that request +after it records Ready, so whichever side runs second navigates, and the one-shot claim keeps it to +one navigation. A WebView that refuses the navigation script gives the claim back, so the next open +retries instead of being suppressed for the run. Both the claim and the request reset with each run. + +> Decision record: [ADR-5494](decisions/ADR-5494-lightweight-background-startup.md) + `desktop/src-tauri/src/exit.rs` owns what ends the process. Where there is a usable tray, closing the window and the platform's quit gesture both hide; only the tray's Quit asks to end, and an installed update asks for a coordinated restart. Where there is no usable tray, closing the window @@ -230,11 +244,43 @@ marker, which the GUI detects to identify the shell without using IPC. ## Release packaging and updater +### Linux packaged-shell acceptance + +The ordinary hosted Linux lane builds both AppImage and deb bundles with updater artifacts disabled, +extracts each payload into a disposable directory, and boots its real application executable under a +private Xvfb, Openbox, and D-Bus session. Openbox supplies only the window-manager close protocol; +it does not supply a tray host. `desktop/scripts/linux-packaged-e2e.ts` gives each format fresh +`HOME`, `XDG_*`, `CODEX_HOME`, and `OPENCODEX_HOME` roots plus a loopback port held until the app +spawn boundary, then requires a visible OpenCodex window, the bundled sidecar's matching `/healthz` +identity, port and version. It then asks the window manager to close the only window (`wmctrl -i -c`, +the path a close button takes) and requires the app to exit on its own with code 0 and no signal and +the runtime to be gone; destroying the X window or a crash does not count as a drain. Its +report records readiness time and whole app-process-tree RSS as evidence; those observations are not +pass/fail budgets until a reviewed cross-platform baseline exists. + +Extraction is intentional. A GitHub-hosted runner is disposable but its package database is still a +shared job resource, and a normal pull request does not need passwordless package installation or GUI +elevation to prove that the packaged executable and resources boot together. The separate +`desktop-installed-gate.yml` remains the authority for real installation, package-manager ownership, +takeover consent, elevation cancellation/acceptance, and in-place updater behavior on explicitly +approved disposable GUI runners. Passing the hosted lane must never be described as passing those +privileged installation flows. + +AppImage and deb are built with independent `CARGO_TARGET_DIR` roots in hosted acceptance and release +jobs, then copied into a read-only staging layout for verification and collection. Tauri patches a +per-format updater marker into the release binary while bundling; sharing one Cargo target lets one +format observe a binary mutated for the other. The isolated roots make the marker and every other +bundler mutation format-local. + +> Decision record: [ADR-5493](decisions/ADR-5493-linux-packaged-shell-acceptance.md) + Linux AppImage packaging uses `desktop/scripts/appimage-patchelf.py` to preserve the compiled Bun CLI when linuxdeploy sets the executable RPATH. Only the exact -AppDir sidecar, still byte-identical to the prepared CLI, is exempt; other ELF +AppDir sidecar under the active `CARGO_TARGET_DIR`, still byte-identical to the +prepared target-matching CLI, is exempt; other ELF operations use the system patchelf. `desktop/scripts/verify-linux-sidecar.sh` -extracts the completed AppImage, compares its CLI bytes and runs its version command +extracts the completed AppImage (the release passes the staged isolated AppImage directory; a local +build keeps the default Cargo target path), compares its CLI bytes and runs its version command on the hosted runner before any release asset is collected. The macOS release combines both prepared CLI architectures with `lipo` into the universal external binary Tauri expects, and checks that both slices are present. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index ff2c772cfa5..cf34f48315c 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -235,3 +235,14 @@ Malformed or unreadable records remain unknown. Recovery requires the same compl identity and proven-dead liveness; unknown or transferred ownership never starts another proxy. Direct recovery retains the lease until readiness or its bounded deadline. The normal successful manual-runtime update still prints the existing restart hint. + +The probe ceilings are module-load constants in `src/server/proxy-liveness.ts`: 750 ms for the +shared default and 1500 ms (three attempts) for `SERVICE_STOP_LIVENESS` and +`START_OWNERSHIP_LIVENESS`. `OCX_PROBE_TIMEOUT_MS` (whole milliseconds, 1 to 30000) only raises +them for hosts whose loopback connects are slowed by a security layer; each ceiling keeps its floor, +so an override can never shorten the budgets that prevent a duplicate proxy, and a value above the +30 s ceiling is ignored so the single-shot stop deadline (`timeoutMs * attempts + 250` in +`src/service/orchestration.ts`) stays bounded. `tests/server/probe-timeout-env.test.ts` reads the +constants in child processes. + +`src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. Two verified owners whose tool roots differ only by a symlinked ancestor (macOS `/var` -> `/private/var`) are compared by canonical directory and count as one install. An unreadable or contradictory ownership boundary on either path takes precedence over a verified owner on the other path, refusing mutation without inventing a tool name or recovery command. `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. The package-tree integrity guard remains active for mise packages. diff --git a/structure/runtime.md b/structure/runtime.md index 27c72b23ac8..22495496528 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -65,7 +65,7 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output ## CLI Codex restart scope -`ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope in its summary and `--yes` description; `src/cli/system-command.ts` explains the desktop interruption when confirmation is missing and sends no restart request. Human output says the restart was requested, while `--json` preserves the complete server result, including skipped or refused desktop outcomes. An armed test process never reaches the real desktop app. When the test preload's `OCX_TEST_HOME_GUARD=1` is set and the caller injected no `execFile`, `restartCodexDesktopApp` in `src/codex/desktop-app-restart.ts` returns the skipped reason `test_environment` before discovery or signalling, and `handleDesktopAppRestart` in `src/cli/restart-scope.ts` reports that skip. The flag, not `NODE_ENV`, decides, so a real `NODE_ENV=test ocx ...` still restarts the app; adapter tests that inject `execFile` still exercise the full path. `tests/clients/desktop-app-restart.test.ts` covers the skip. +`ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope and warns that unsaved composer drafts, model-picker selections, and pending approval prompts may be discarded. `src/cli/system-command.ts` repeats that concrete state-loss warning both when confirmation is missing and after a confirmed human-readable request; the unconfirmed path sends no restart request. `--json` preserves the complete server result, including skipped or refused desktop outcomes. An armed test process never reaches the real desktop app. When the test preload's `OCX_TEST_HOME_GUARD=1` is set and the caller injected no `execFile`, `restartCodexDesktopApp` in `src/codex/desktop-app-restart.ts` returns the skipped reason `test_environment` before discovery or signalling, and `handleDesktopAppRestart` in `src/cli/restart-scope.ts` reports that skip. The flag, not `NODE_ENV`, decides, so a real `NODE_ENV=test ocx ...` still restarts the app; adapter tests that inject `execFile` still exercise the full path. `tests/clients/desktop-app-restart.test.ts` covers the skip. After a CLI catalog/cache write, advisory restart guidance compares each running Codex app-server's start time with the written catalog mtime. It reports only processes proven stale; a fresh or diff --git a/tests/ci-workflows/ci-privacy-gate.test.ts b/tests/ci-workflows/ci-privacy-gate.test.ts index 75709e518d4..365062a9119 100644 --- a/tests/ci-workflows/ci-privacy-gate.test.ts +++ b/tests/ci-workflows/ci-privacy-gate.test.ts @@ -108,6 +108,7 @@ describe.skipIf(cannotRunAggregate)("the aggregate ci gate, executed", () => { LANE: "", CHANGES_CI: "false", CHANGES_NATIVE: "false", + CHANGES_DESKTOP: "false", CHANGES_PACKAGING: "false", CHANGES_DOCS: "false", CHANGES_STRUCTURE: "false", diff --git a/tests/ci-workflows/ci-scope-reduction.test.ts b/tests/ci-workflows/ci-scope-reduction.test.ts index 253179401e0..2023c551b58 100644 --- a/tests/ci-workflows/ci-scope-reduction.test.ts +++ b/tests/ci-workflows/ci-scope-reduction.test.ts @@ -52,7 +52,7 @@ const NATIVE_GATED = ["platform-macos", "widget", "desktop-shell"] as const; /** The two smoke jobs whose matrix legs shrink with the native selection. */ const MATRIX_JOBS = ["keyring-smoke", "npm-global-smoke"] as const; -type SelectionInputs = { event_name: string; ci: string; native: string }; +type SelectionInputs = { event_name: string; ci: string; native: string; desktop?: string }; function term(source: string, inputs: SelectionInputs): string | boolean { const text = source.trim(); @@ -69,6 +69,7 @@ function term(source: string, inputs: SelectionInputs): string | boolean { if (output) { if (output[1] === "ci") return inputs.ci; if (output[1] === "native") return inputs.native; + if (output[1] === "desktop") return inputs.desktop ?? "false"; } throw new Error(`unsupported expression term: ${text}`); } @@ -345,9 +346,15 @@ describe("the native-gated jobs", () => { const condition = jobs["platform-macos"]?.if ?? ""; test("are exactly platform-macos, widget and desktop-shell on one shared condition", () => { - for (const name of NATIVE_GATED) { - expect(`${name}:${jobs[name]?.if}`).toBe(`${name}:${condition}`); - } + expect(`widget:${jobs.widget?.if}`).toBe(`widget:${condition}`); + // desktop-shell widens only the native term: package-affecting changes also select it so the + // Linux packaged-shell E2E runs. Everything else about the condition is shared. + const widened = condition.replace( + "needs.changes.outputs.native == 'true'", + "(needs.changes.outputs.native == 'true' || needs.changes.outputs.desktop == 'true')", + ); + expect(widened).not.toBe(condition); + expect(`desktop-shell:${jobs["desktop-shell"]?.if}`).toBe(`desktop-shell:${widened}`); // A fourth job carrying the native output would silently join the gate, and // a gate the aggregate does not know about is the failure this file exists // for — so name the full set rather than sampling it. @@ -368,6 +375,20 @@ describe("the native-gated jobs", () => { } }); +describe("the packaged desktop selection", () => { + test("a pull request that changes only package inputs selects desktop-shell and nothing else native", () => { + const inputs = { event_name: "pull_request", ci: "true", native: "false", desktop: "true" }; + expect(evaluate(jobs["desktop-shell"]?.if ?? "", inputs)).toBe(true); + expect(evaluate(jobs["platform-macos"]?.if ?? "", inputs)).toBe(false); + expect(evaluate(jobs.widget?.if ?? "", inputs)).toBe(false); + }); + + test("an out-of-scope pull request never selects desktop-shell through the package filter", () => { + const inputs = { event_name: "pull_request", ci: "false", native: "false", desktop: "true" }; + expect(evaluate(jobs["desktop-shell"]?.if ?? "", inputs)).toBe(false); + }); +}); + describe("the smoke matrices", () => { test("consume their matrices from validated changes outputs", () => { for (const jobName of MATRIX_JOBS) { diff --git a/tests/ci-workflows/linux-desktop-packaged-ci.test.ts b/tests/ci-workflows/linux-desktop-packaged-ci.test.ts new file mode 100644 index 00000000000..abd1d14d1f5 --- /dev/null +++ b/tests/ci-workflows/linux-desktop-packaged-ci.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +// Workflow wiring for the Linux packaged-shell E2E. The driver's own behaviour is covered by +// linux-desktop-packaged-e2e.test.ts; this file only reads .github/workflows/ci.yml. +describe("Linux packaged desktop E2E in CI", () => { + test("CI scopes the real package build and keeps the E2E unprivileged", () => { + const workflow = Bun.YAML.parse(readFileSync(repoPath(".github", "workflows", "ci.yml"), "utf8")) as { + permissions?: Record; + jobs?: Record; + steps?: Array<{ + name?: string; + uses?: string; + if?: string; + run?: string; + env?: Record; + with?: Record; + }>; + }>; + }; + expect(workflow.permissions).toEqual({ contents: "read" }); + const changes = workflow.jobs?.changes; + expect(changes?.outputs?.desktop).toBe("${{ steps.scope.outputs.desktop }}"); + const filter = changes?.steps?.find(step => step.name === "Detect changed areas"); + const filters = String(filter?.with?.filters ?? ""); + expect(filters).toContain("desktop:"); + expect(filters).toContain("'desktop/**'"); + expect(filters).toContain("'src/**'"); + expect(filters).toContain("'.github/workflows/ci.yml'"); + + const shell = workflow.jobs?.["desktop-shell"]; + expect(shell?.if).toContain("needs.changes.outputs.desktop == 'true'"); + const checkResources = shell?.steps?.find(step => step.name === "Prepare desktop check resources"); + expect(checkResources?.run).toContain("binaries/ocx-"); + expect(checkResources?.run).not.toContain("resources/sidecar/ocx"); + const preserve = shell?.steps?.find(step => step.name === "Preserve the compiled Linux sidecar"); + expect(preserve?.run).toContain("chmod +x desktop/scripts/appimage-patchelf.py"); + const appImageBuild = shell?.steps?.find(step => step.name === "Build Linux AppImage"); + const debBuild = shell?.steps?.find(step => step.name === "Build Linux deb"); + expect(appImageBuild?.env?.CARGO_TARGET_DIR).toContain("opencodex-appimage-target"); + expect(appImageBuild?.env?.PATCHELF).toContain("desktop/scripts/appimage-patchelf.py"); + expect(debBuild?.env?.CARGO_TARGET_DIR).toContain("opencodex-deb-target"); + expect(appImageBuild?.env?.CARGO_TARGET_DIR).not.toBe(debBuild?.env?.CARGO_TARGET_DIR); + const stage = shell?.steps?.find(step => step.name === "Stage isolated Linux bundles"); + expect(stage?.run).toContain("$APPIMAGE_BUNDLE/."); + expect(stage?.run).toContain("$DEB_BUNDLE/."); + expect(stage?.run).toContain('chmod -R a-w "$BUNDLE_ROOT"'); + + const aggregate = workflow.jobs?.ci?.steps?.find(step => step.name === "Assert every job this event requested succeeded"); + expect(aggregate?.env?.CHANGES_DESKTOP).toBe("${{ needs.changes.outputs.desktop }}"); + expect(aggregate?.run).toContain("desktop-shell) echo \"$desktop_shell\""); + + const e2e = shell?.steps?.find(step => step.name === "Run Linux packaged-shell E2E"); + expect(e2e?.if).toBe("needs.changes.outputs.desktop == 'true'"); + expect(e2e?.run).toContain("dbus-run-session -- xvfb-run"); + expect(e2e?.run).toContain("openbox"); + expect(e2e?.run).toContain("linux-packaged-e2e.ts"); + expect(e2e?.run).toContain("opencodex-linux-bundles"); + expect(e2e?.run).not.toContain("sudo"); + expect(e2e?.run).not.toContain("dpkg -i"); + const deps = shell?.steps?.find(step => step.name === "Install Tauri Linux dependencies"); + expect(deps?.run).toContain("wmctrl"); + + const upload = shell?.steps?.find(step => step.name === "Upload Linux packaged-shell E2E report"); + expect(upload?.uses).toMatch(/^actions\/upload-artifact@[0-9a-f]{40}$/u); + expect(upload?.if).toContain("always()"); + }); +}); diff --git a/tests/ci-workflows/linux-desktop-packaged-e2e.test.ts b/tests/ci-workflows/linux-desktop-packaged-e2e.test.ts new file mode 100644 index 00000000000..5c8593f06a2 --- /dev/null +++ b/tests/ci-workflows/linux-desktop-packaged-e2e.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertCleanExit, + assertRuntimeRecordPort, + locateArtifacts, + parseArguments, + processTreeRssKiB, + readRuntimeRecord, + selectDebExecutable, + windowManagerCloseArgs, +} from "../../desktop/scripts/linux-packaged-e2e"; +import { repoPath } from "../helpers/repo-root"; + +function temporaryDirectory(): string { + return mkdtempSync(join(tmpdir(), "opencodex-linux-e2e-test-")); +} + +describe("Linux packaged desktop E2E driver", () => { + test("requires an explicit bundle root, report and strict version", () => { + expect(() => parseArguments([])).toThrow("required"); + expect(() => parseArguments([ + "--bundle-root", "/bundles", + "--report", "/report.json", + "--version", "latest", + ])).toThrow("strict semver"); + expect(parseArguments([ + "--bundle-root", "/bundles", + "--report", "/report.json", + "--version", "2.61.0-preview.1", + ]).version).toBe("2.61.0-preview.1"); + }); + + test("requires exactly one AppImage and deb from their bundle directories", () => { + const root = temporaryDirectory(); + try { + mkdirSync(join(root, "appimage")); + mkdirSync(join(root, "deb")); + writeFileSync(join(root, "appimage", "OpenCodex.AppImage"), "appimage"); + writeFileSync(join(root, "deb", "OpenCodex.deb"), "deb"); + expect(locateArtifacts(root)).toEqual({ + appimage: join(root, "appimage", "OpenCodex.AppImage"), + deb: join(root, "deb", "OpenCodex.deb"), + }); + writeFileSync(join(root, "deb", "stale.deb"), "deb"); + expect(() => locateArtifacts(root)).toThrow("exactly one deb"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("selects the deb desktop host without mistaking the ocx sidecar for the app", () => { + expect(selectDebExecutable(["/payload/usr/bin/ocx", "/payload/usr/bin/opencodex-desktop"])) + .toBe("/payload/usr/bin/opencodex-desktop"); + expect(() => selectDebExecutable(["/payload/usr/bin/ocx"])) + .toThrow("expected exactly one deb desktop executable"); + }); + + test("accepts only a complete positive runtime record", () => { + const root = temporaryDirectory(); + try { + const record = join(root, "runtime-port.json"); + writeFileSync(record, JSON.stringify({ pid: 42, port: 10100 })); + expect(readRuntimeRecord(record)).toEqual({ pid: 42, port: 10100 }); + expect(assertRuntimeRecordPort({ pid: 42, port: 10100 }, 10100)).toEqual({ + pid: 42, + port: 10100, + }); + expect(() => assertRuntimeRecordPort({ pid: 42, port: 10101 }, 10100)) + .toThrow("recorded port 10101, expected isolated port 10100"); + for (const invalid of [ + { pid: 0, port: 10100 }, + { pid: 42, port: 0 }, + { pid: 42, port: 65_536 }, + { pid: "42", port: 10100 }, + ]) { + writeFileSync(record, JSON.stringify(invalid)); + expect(readRuntimeRecord(record)).toBeUndefined(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("measures only the selected process tree", () => { + const rows = [ + { pid: 10, ppid: 1, rssKiB: 100 }, + { pid: 11, ppid: 10, rssKiB: 50 }, + { pid: 12, ppid: 11, rssKiB: 25 }, + { pid: 20, ppid: 1, rssKiB: 1_000 }, + ]; + expect(processTreeRssKiB(10, rows)).toBe(175); + expect(processTreeRssKiB(20, rows)).toBe(1_000); + }); + + test("closes through the window manager and accepts only a clean app exit", () => { + expect(windowManagerCloseArgs("4194310")).toEqual(["-i", "-c", "0x400006"]); + expect(() => windowManagerCloseArgs("0")).toThrow("invalid X11 window id"); + expect(() => windowManagerCloseArgs("abc")).toThrow("invalid X11 window id"); + expect(assertCleanExit({ code: 0, signal: null })).toEqual({ code: 0, signal: null }); + expect(() => assertCleanExit(undefined)).toThrow("did not exit"); + expect(() => assertCleanExit({ code: null, signal: "SIGKILL" })).toThrow("signal SIGKILL"); + expect(() => assertCleanExit({ code: 1, signal: null })).toThrow("code 1"); + }); + + test("the driver isolates each package from a runtime already using the default port", () => { + const driver = readFileSync( + repoPath("desktop", "scripts", "linux-packaged-e2e.ts"), + "utf8", + ); + expect(driver).toContain('server.listen(0, "127.0.0.1"'); + expect(driver).toContain('join(opencodexHome, "config.json")'); + expect(driver).toContain("JSON.stringify({ port: configuredPort }"); + expect(driver).not.toContain('port: 10100'); + expect(driver).toContain('["search", "--onlyvisible", "--name", "^OpenCodex$"]'); + expect(driver).toContain('command("wmctrl", windowManagerCloseArgs(windowId))'); + expect(driver).not.toContain('"windowclose"'); + expect(driver).toContain("const exit = assertCleanExit(appExit);"); + }); +}); diff --git a/tests/ci-workflows/package-tree-integrity.test.ts b/tests/ci-workflows/package-tree-integrity.test.ts index 29c119a6ffc..bf5db8e6f58 100644 --- a/tests/ci-workflows/package-tree-integrity.test.ts +++ b/tests/ci-workflows/package-tree-integrity.test.ts @@ -118,7 +118,7 @@ describe("package tree integrity", () => { expect(installedGuard.status()).toEqual({ ok: false, reason: "package_tree_replaced" }); }); - test.each(["npm", "bun"] as const)("%s installs still refuse a replaced package tree", installer => { + test.each(["npm", "bun", "mise"] as const)("%s installs still refuse a replaced package tree", installer => { let observation: PackageTreeObservation = { device: 1n, inode: 10n, diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 037b2f65ad3..0dd5865942f 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -123,6 +123,43 @@ describe("desktop release scripts", () => { } }); + test("collects Linux formats from an explicitly staged isolated bundle root", () => { + const root = temporaryDirectory(); + try { + const bundleRoot = join(root, "isolated-linux-bundles"); + mkdirSync(join(bundleRoot, "appimage"), { recursive: true }); + mkdirSync(join(bundleRoot, "deb"), { recursive: true }); + writeFileSync(join(bundleRoot, "appimage", "OpenCodex.AppImage"), "appimage"); + writeFileSync(join(bundleRoot, "deb", "OpenCodex.deb"), "deb"); + + const files = collectReleaseAssets({ + version: "2.61.0", + target: "x86_64-unknown-linux-gnu", + out: join(root, "release"), + repoRoot: root, + bundleRoot, + }); + + expect(files.map(path => basename(path))).toEqual([ + "OpenCodex-2.61.0-linux-x86_64.AppImage", + "OpenCodex-2.61.0-linux-x86_64.AppImage.sha256", + "OpenCodex-2.61.0-linux-amd64.deb", + "OpenCodex-2.61.0-linux-amd64.deb.sha256", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("the Linux sidecar verifier takes the staged AppImage directory and keeps the local default", () => { + const verifier = readFileSync(repoPath("desktop", "scripts", "verify-linux-sidecar.sh"), "utf8"); + expect(verifier).toContain('bundle="${1:-$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage}"'); + const wrapper = readFileSync(repoPath("desktop", "scripts", "appimage-patchelf.py"), "utf8"); + expect(wrapper).toContain('os.environ.get("CARGO_TARGET_DIR"'); + expect(wrapper).toContain("APPDIR_SIDECAR_TAIL"); + expect(wrapper).not.toContain('desktop/src-tauri/target" / triple'); + }); + test("rejects ambiguous bundle matches", () => { const root = temporaryDirectory(); try { @@ -408,6 +445,31 @@ describe("the desktop build toolchain carries the bundle-type marker", () => { || (major === minimumCliWithBundlePatch.major && minor! >= minimumCliWithBundlePatch.minor), ).toBe(true); }); + + test("the release workflow gives AppImage and deb independent Cargo targets", () => { + const workflow = Bun.YAML.parse( + readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"), + ) as { + jobs?: Record }>; + }>; + }; + const steps = workflow.jobs?.["package-desktop"]?.steps ?? []; + const appImage = steps.find(step => step.name === "Build Linux AppImage bundle"); + const deb = steps.find(step => step.name === "Build Linux deb bundle"); + expect(appImage?.env?.CARGO_TARGET_DIR).toContain("opencodex-appimage-target"); + expect(deb?.env?.CARGO_TARGET_DIR).toContain("opencodex-deb-target"); + expect(appImage?.env?.CARGO_TARGET_DIR).not.toBe(deb?.env?.CARGO_TARGET_DIR); + expect(appImage?.run).toContain("--bundles appimage"); + expect(deb?.run).toContain("--bundles deb"); + + const stage = steps.find(step => step.name === "Stage isolated Linux release bundles"); + expect(stage?.run).toContain("$APPIMAGE_TARGET/$DESKTOP_TARGET/release/bundle/appimage/."); + expect(stage?.run).toContain("$DEB_TARGET/$DESKTOP_TARGET/release/bundle/deb/."); + expect(stage?.run).toContain('chmod -R a-w "$bundle_root"'); + const collect = steps.find(step => step.run?.includes("collect-release-assets.ts")); + expect(collect?.run).toContain('--bundle-root "$DESKTOP_BUNDLE_ROOT"'); + }); }); describe("widget extension signing", () => { @@ -454,10 +516,14 @@ describe("widget extension signing", () => { expect(preserve?.if).toBe("runner.os == 'Linux'"); expect(preserve?.run).toContain("PATCHELF=$GITHUB_WORKSPACE/desktop/scripts/appimage-patchelf.py"); expect(verify?.if).toBe("runner.os == 'Linux'"); - expect(verify?.run).toBe("bash desktop/scripts/verify-linux-sidecar.sh"); - expect(indexOfStep(preserve!.name!)).toBeLessThan(indexOfStep("Build desktop bundles")); - expect(indexOfStep(verify!.name!)).toBeGreaterThan(indexOfStep("Build desktop bundles")); + // The Linux AppImage is built in its own Cargo target and staged read-only; the verifier runs + // after that staging, against the staged copy, and before any asset is collected. + expect(verify?.run).toBe('bash desktop/scripts/verify-linux-sidecar.sh "$DESKTOP_BUNDLE_ROOT/appimage"'); + expect(indexOfStep(preserve!.name!)).toBeLessThan(indexOfStep("Build Linux AppImage bundle")); + expect(indexOfStep(verify!.name!)).toBeGreaterThan(indexOfStep("Build Linux AppImage bundle")); + expect(indexOfStep(verify!.name!)).toBeGreaterThan(indexOfStep("Stage isolated Linux release bundles")); expect(indexOfStep(verify!.name!)).toBeLessThan(indexOfStep("Rename release assets")); + expect(steps.find(step => step.name === "Build desktop bundles")?.if).toBe("runner.os != 'Linux'"); }); test("the release build hands the widget a signing identity and forbids an ad-hoc fallback", () => { diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 1b0096372d3..1fb04d53e9e 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -30,6 +30,9 @@ describe("ocx system codex-restart confirmation", () => { const warning = errors.mock.calls.flat().join(" "); expect(warning).toContain("requires --yes"); expect(warning).toContain("fully quits and relaunches the Codex desktop app"); + expect(warning).toContain("unsaved composer drafts"); + expect(warning).toContain("model-picker selections"); + expect(warning).toContain("pending approval prompts"); } finally { errors.mockRestore(); } }); @@ -48,6 +51,9 @@ describe("ocx system codex-restart confirmation", () => { else { expect(text).toContain("Codex desktop app"); expect(text).toContain("restart requested."); + expect(text).toContain("Unsaved composer drafts"); + expect(text).toContain("model-picker selections"); + expect(text).toContain("pending approval prompts"); expect(text).not.toContain("restarted"); } } finally { output.mockRestore(); } diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index 5903800ac8b..693cc528e3d 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -159,6 +159,35 @@ describe("desktop startup surface", () => { expect(page).toContain("progress.failedPhase"); }); + test("a hidden login launch keeps the lightweight surface until an explicit open", () => { + const finish = startup.slice( + startup.indexOf("fn finish("), + startup.indexOf("pub fn diagnostic("), + ); + expect(finish).toContain("loads_dashboard_on_ready(LaunchOrigin::detect(), visible, requested)"); + expect(finish).toContain("window.is_visible()"); + expect(finish).toContain("startup.dashboard_requested()"); + expect(finish).toContain("pub fn open_dashboard("); + expect(finish).toContain("startup.request_dashboard();"); + expect(finish).toContain("startup.ready_dashboard()"); + expect(finish).toContain("crate::window::show(&window)"); + // The request is recorded before progress is read, so an open racing Ready is never lost. + const open = finish.slice(finish.indexOf("pub fn open_dashboard(")); + expect(open.indexOf("startup.request_dashboard();")).toBeLessThan(open.indexOf("startup.ready_dashboard()")); + // The Rust behavioral tests own the navigation outcomes; this only pins that they exist. + for (const name of [ + "fn explicit_dashboard_navigation_is_consumed_once_per_run()", + "fn a_refused_dashboard_navigation_is_retried_on_the_next_open()", + "fn an_open_during_startup_is_remembered_until_the_run_restarts()", + ]) expect(startup).toContain(name); + + expect(lib).toContain("startup::open_dashboard(&app)"); + expect(lib).toContain("startup::open_dashboard(app)"); + const tray = code(repoPath(`${SRC}/tray.rs`)); + expect(tray).toContain('"open-dashboard" =>'); + expect(tray).toContain("crate::startup::open_dashboard(app)"); + }); + test("the snapshot answers with a state rather than with nothing", () => { // The page returns early on a falsy progress, so an absent answer was not a neutral one: it // was a window frozen on its own markup, with no diagnostic in it and no event coming. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1181ae53918..8053457b5b6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -483,6 +483,9 @@ "config-user-edits.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", + "gui-desktop-sidecar-signing.test.ts": "gui", + "linux-desktop-packaged-ci.test.ts": "ci-workflows", + "probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing", "consume-for-inspection-cancel.test.ts": "server", "container-bootstrap.test.ts": "service", @@ -831,6 +834,7 @@ "legacy-shell-compat.test.ts": "responses", "live-call-bindings.test.ts": "server", "live-service-manager-guard.test.ts": "service", + "linux-desktop-packaged-e2e.test.ts": "ci-workflows", "local-aside-sync-capability.test.ts": "server", "local-destinations.test.ts": "lib", "local-management-attestation.test.ts": "server", @@ -1452,6 +1456,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-mise.test.ts": "update", "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", diff --git a/tests/gui/gui-desktop-sidecar-signing.test.ts b/tests/gui/gui-desktop-sidecar-signing.test.ts new file mode 100644 index 00000000000..21c591a5b8d --- /dev/null +++ b/tests/gui/gui-desktop-sidecar-signing.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { + CODESIGN_PATH, + adHocSignArgv, + adHocSignSidecar, + shouldAdHocSignSidecar, +} from "../../desktop/scripts/sidecar-signing"; +import { repoPath } from "../helpers/repo-root"; + +test("only a macOS host preparing a darwin target signs the sidecar", () => { + expect(shouldAdHocSignSidecar("darwin", "bun-darwin-arm64")).toBe(true); + expect(shouldAdHocSignSidecar("darwin", "bun-darwin-x64")).toBe(true); + for (const target of ["bun-linux-x64", "bun-linux-arm64", "bun-windows-x64"]) { + expect(shouldAdHocSignSidecar("darwin", target)).toBe(false); + } + for (const host of ["linux", "win32"]) { + expect(shouldAdHocSignSidecar(host, "bun-darwin-arm64")).toBe(false); + } +}); + +test("signing runs the absolute codesign with an ad-hoc forced signature", () => { + const calls: string[][] = []; + const code = adHocSignSidecar("/tmp/binaries/ocx-aarch64-apple-darwin", (argv) => { + calls.push(argv); + return { exitCode: 0 }; + }); + expect(code).toBe(0); + expect(calls).toEqual([[CODESIGN_PATH, "-s", "-", "-f", "/tmp/binaries/ocx-aarch64-apple-darwin"]]); + expect(adHocSignArgv("x")[0]).toBe("/usr/bin/codesign"); +}); + +test("a failed or unlaunchable codesign stops preparation with a nonzero code", () => { + expect(adHocSignSidecar("x", () => ({ exitCode: 3 }))).toBe(3); + expect(adHocSignSidecar("x", () => ({ exitCode: null }))).toBe(1); +}); + +test("prepare-sidecar signs through the guarded helper right after copying", async () => { + const script = await Bun.file(repoPath("desktop", "scripts", "prepare-sidecar.ts")).text(); + const copy = script.indexOf("copyFileSync(executable, destination);"); + const guard = script.indexOf("if (shouldAdHocSignSidecar(process.platform, target))"); + const resources = script.indexOf("cpSync(join(repoRoot, \"gui\", \"dist\")"); + expect(copy).toBeGreaterThan(-1); + expect(guard).toBeGreaterThan(copy); + expect(resources).toBeGreaterThan(guard); + expect(script).not.toContain("codesign\", \"-s\""); +}); diff --git a/tests/helpers/update-bun-ownership-child.ts b/tests/helpers/update-bun-ownership-child.ts index a7f8990ede3..fa8c53a9692 100644 --- a/tests/helpers/update-bun-ownership-child.ts +++ b/tests/helpers/update-bun-ownership-child.ts @@ -62,7 +62,11 @@ mock.module(repoPath("src/config/process-state.ts"), () => ({ ...state, readRuntimePort: () => running ? { pid: 4321, port, hostname: "127.0.0.1" } : null, getRuntimePortPath: () => runtime, })); -mock.module(repoPath("src/update/install-detection.mjs"), () => ({ detectInstallFromPath: () => "bun" })); +// runUpdate reads ownership (installer plus any external owner) rather than the bare installer. +mock.module(repoPath("src/update/install-detection.mjs"), () => ({ + detectInstallFromPath: () => "bun", + detectInstallOwnershipFromPath: () => ({ installer: "bun" }), +})); mock.module(repoPath("src/update/registry-integrity.mjs"), () => ({ checkRegistryPackageIntegrity: () => ({ ok: true, integrity: "sha512-fixture" }) })); const liveness = await import("../../src/server/proxy-liveness"); const actualIdentity = liveness.proxyIdentityAt; diff --git a/tests/server/probe-timeout-env.test.ts b/tests/server/probe-timeout-env.test.ts new file mode 100644 index 00000000000..bfe4fa983e6 --- /dev/null +++ b/tests/server/probe-timeout-env.test.ts @@ -0,0 +1,89 @@ +/** + * OCX_PROBE_TIMEOUT_MS: the probe ceilings are module-load constants, so each wiring case + * runs in a fresh child interpreter with its own environment. Nothing in this process's + * environment or module registry changes, so no other test file can observe an override. + */ +import { describe, expect, test } from "bun:test"; +import { + MAX_PROBE_TIMEOUT_MS, + parseProbeTimeoutOverrideMs, + probeCeilingMs, +} from "../../src/server/proxy-liveness"; +import { repoPath } from "../helpers/repo-root"; + +type Ceilings = { defaultMs: number; stopMs: number; stopAttempts: number; startMs: number; startAttempts: number }; + +function ceilingsUnder(value: string | undefined): Ceilings { + const env: Record = { ...process.env }; + if (value === undefined) delete env.OCX_PROBE_TIMEOUT_MS; + else env.OCX_PROBE_TIMEOUT_MS = value; + const script = [ + `const m = await import(${JSON.stringify(repoPath("src", "server", "proxy-liveness.ts"))});`, + "console.log(JSON.stringify({", + " defaultMs: m.DEFAULT_PROBE_TIMEOUT_MS,", + " stopMs: m.SERVICE_STOP_LIVENESS.timeoutMs, stopAttempts: m.SERVICE_STOP_LIVENESS.attempts,", + " startMs: m.START_OWNERSHIP_LIVENESS.timeoutMs, startAttempts: m.START_OWNERSHIP_LIVENESS.attempts,", + "}));", + ].join("\n"); + const child = Bun.spawnSync([process.execPath, "-e", script], { env, stdout: "pipe", stderr: "pipe" }); + if (child.exitCode !== 0) throw new Error(child.stderr.toString()); + const lines = child.stdout.toString().trim().split("\n"); + return JSON.parse(lines[lines.length - 1]!) as Ceilings; +} + +describe("parseProbeTimeoutOverrideMs", () => { + test("accepts positive integer milliseconds up to the ceiling", () => { + expect(parseProbeTimeoutOverrideMs("1")).toBe(1); + expect(parseProbeTimeoutOverrideMs(" 4321 ")).toBe(4321); + expect(parseProbeTimeoutOverrideMs(String(MAX_PROBE_TIMEOUT_MS))).toBe(30_000); + }); + + test("ignores absent, malformed, zero, and over-ceiling values", () => { + for (const raw of [undefined, "", " ", "abc", "1.5", "-5", "+7", "0", "30001", "2147483648", "99999999999999999999"]) { + expect(parseProbeTimeoutOverrideMs(raw)).toBeUndefined(); + } + }); +}); + +describe("probeCeilingMs keeps each shipped floor", () => { + test("an override can raise a ceiling but never lower it", () => { + expect(probeCeilingMs(750, undefined)).toBe(750); + expect(probeCeilingMs(750, 1)).toBe(750); + expect(probeCeilingMs(750, 749)).toBe(750); + expect(probeCeilingMs(750, 1000)).toBe(1000); + expect(probeCeilingMs(1500, 1000)).toBe(1500); + expect(probeCeilingMs(1500, 30_000)).toBe(30_000); + }); +}); + +describe("OCX_PROBE_TIMEOUT_MS wiring at module load", () => { + test("unset keeps the shipped ceilings", () => { + expect(ceilingsUnder(undefined)).toEqual({ defaultMs: 750, stopMs: 1500, stopAttempts: 3, startMs: 1500, startAttempts: 3 }); + }); + + test("values below a floor never shorten it", () => { + for (const value of ["1", "749", "750"]) { + const c = ceilingsUnder(value); + expect(c.defaultMs).toBe(750); + expect(c.stopMs).toBe(1500); + expect(c.startMs).toBe(1500); + } + }); + + test("a value between the floors raises only the shared default", () => { + expect(ceilingsUnder("1000")).toEqual({ defaultMs: 1000, stopMs: 1500, stopAttempts: 3, startMs: 1500, startAttempts: 3 }); + }); + + test("the ceiling raises every budget and keeps the stop wait bounded", () => { + const c = ceilingsUnder("30000"); + expect(c).toEqual({ defaultMs: 30_000, stopMs: 30_000, stopAttempts: 3, startMs: 30_000, startAttempts: 3 }); + // The single-shot stop deadline in src/service/orchestration.ts is timeoutMs * attempts + 250. + expect(c.stopMs * c.stopAttempts + 250).toBeLessThanOrEqual(90_250); + }); + + test("over-ceiling and malformed values fall back to the shipped ceilings", () => { + for (const value of ["30001", "2147483647", "not-a-number", "0"]) { + expect(ceilingsUnder(value)).toEqual({ defaultMs: 750, stopMs: 1500, stopAttempts: 3, startMs: 1500, startAttempts: 3 }); + } + }); +}); diff --git a/tests/update/update-mise.test.ts b/tests/update/update-mise.test.ts new file mode 100644 index 00000000000..d3b3a52cce2 --- /dev/null +++ b/tests/update/update-mise.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import { delimiter, dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { + detectInstallFromPath, + detectInstallOwnershipFromPath, +} from "../../src/update/install-detection.mjs"; +import { checkForUpdate, startUpdateJob, UpdateJobError } from "../../src/update/job"; +import { readUpdateBadge } from "../../src/update/badge"; +import type { InstallOwnership } from "../../src/update/index"; + +const BACKEND = 'short = "ocx-local"\nfull = "npm:@bitkyc08/opencodex"\nexplicit_backend = false\n'; +const metadataProbe = (exists: (path: string) => boolean) => + (path: string): "present" | "absent" => exists(path) ? "present" : "absent"; + +function misePackage(root: string, version = "2.59.0"): string { + const toolRoot = join(root, "custom mise data", "installs", "ocx-local"); + const packagePath = join( + toolRoot, + version, + "node_modules", + ".mise", + "@bitkyc08+opencodex@2.59.0", + "node_modules", + "@bitkyc08", + "opencodex", + "bin", + ); + mkdirSync(packagePath, { recursive: true }); + writeFileSync(join(toolRoot, ".mise.backend.toml"), BACKEND); + return packagePath; +} + +describe("mise installation ownership", () => { + test("recognises a custom data directory, local alias, and nested aube package", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-owner-"))); + try { + const packagePath = misePackage(root); + expect(detectInstallOwnershipFromPath(packagePath)).toEqual({ + installer: "mise", + owner: { + tool: "ocx-local", + backend: "npm:@bitkyc08/opencodex", + installPath: join(root, "custom mise data", "installs", "ocx-local", "2.59.0"), + toolRoot: join(root, "custom mise data", "installs", "ocx-local"), + }, + }); + expect(detectInstallFromPath(packagePath)).toBe("mise"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("uses the resolved exact version behind a floating link", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-link-"))); + try { + const exact = misePackage(root); + const toolRoot = join(root, "custom mise data", "installs", "ocx-local"); + const latest = join(toolRoot, "latest"); + if (process.platform === "win32") { + symlinkSync(join(toolRoot, "2.59.0"), latest, "junction"); + } else { + symlinkSync("2.59.0", latest, "dir"); + } + const floating = join(toolRoot, "latest", exact.slice(join(toolRoot, "2.59.0").length + 1)); + expect(detectInstallOwnershipFromPath(floating)).toMatchObject({ + installer: "mise", + owner: { tool: "ocx-local", installPath: join(toolRoot, "2.59.0") }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("a symlinked data directory ancestor is the same install, not a contradiction", () => { + const real = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-ancestor-"))); + const linkParent = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-ancestor-link-"))); + const linked = join(linkParent, "data"); + try { + misePackage(real); + symlinkSync(real, linked, process.platform === "win32" ? "junction" : "dir"); + const lexical = misePackage(linked); + expect(detectInstallOwnershipFromPath(lexical)).toMatchObject({ + installer: "mise", + owner: { tool: "ocx-local", backend: "npm:@bitkyc08/opencodex" }, + }); + } finally { + rmSync(linkParent, { recursive: true, force: true }); + rmSync(real, { recursive: true, force: true }); + } + }); + + test("does not infer mise ownership from a .mise path or mise on PATH", () => { + const path = "/tmp/.mise/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(path, { + exists: () => false, + probe: () => "absent", + realpath: value => value, + })).toEqual({ installer: "npm" }); + }); + + test("finds the install boundary when the custom data directory contains node_modules", () => { + const path = "/tmp/node_modules/mise-data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/tmp/node_modules/mise-data/installs/ocx-local/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + exists: value => value === metadata, + probe: metadataProbe(value => value === metadata), + readFile: () => BACKEND, + realpath: value => value, + })).toMatchObject({ + installer: "mise", + owner: { installPath: "/tmp/node_modules/mise-data/installs/ocx-local/2.59.0" }, + }); + }); + + test("preserves literal backslashes in POSIX install paths", () => { + const path = "/tmp/mise\\state/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/tmp/mise\\state/installs/ocx-local/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + probe: metadataProbe(value => value === metadata), + readFile: () => BACKEND, + realpath: value => value, + })).toMatchObject({ + installer: "mise", + owner: { toolRoot: "/tmp/mise\\state/installs/ocx-local" }, + }); + }); + + test("accepts mise's full npm identifier and encoded directory name", () => { + const path = "/data/installs/npm-bitkyc08-opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/data/installs/npm-bitkyc08-opencodex/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + probe: metadataProbe(value => value === metadata), + readFile: () => 'short = "npm:@bitkyc08/opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: value => value, + })).toMatchObject({ + installer: "mise", + owner: { tool: "npm:@bitkyc08/opencodex" }, + }); + }); + + test("fails closed when adjacent ownership metadata is unreadable or contradictory", () => { + const path = "/data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/data/installs/ocx-local/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + exists: value => value === metadata, + probe: metadataProbe(value => value === metadata), + readFile: () => { throw new Error("denied"); }, + realpath: value => value, + })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); + + expect(detectInstallOwnershipFromPath(path, { + exists: value => value === metadata, + probe: metadataProbe(value => value === metadata), + readFile: () => 'short = "different-alias"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: value => value, + })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + + test("fails closed when lexical and resolved ownership evidence disagree", () => { + const lexical = "/data/installs/ocx-local/latest/node_modules/@bitkyc08/opencodex/bin"; + const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => value.endsWith("/.mise.backend.toml"), + probe: metadataProbe(value => value.endsWith("/.mise.backend.toml")), + readFile: value => value.startsWith("/data/") + ? BACKEND + : 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: () => resolved, + })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + + test("keeps a broken ownership boundary authoritative when the other path verifies", () => { + const lexical = "/data/installs/ocx-local/latest/node_modules/@bitkyc08/opencodex/bin"; + const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => value.endsWith("/.mise.backend.toml"), + probe: metadataProbe(value => value.endsWith("/.mise.backend.toml")), + readFile: value => { + if (value.startsWith("/other/")) throw new Error("denied"); + return BACKEND; + }, + realpath: () => resolved, + })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); + }); + + test("does not let a verified owner override contradictory metadata on the other path", () => { + const lexical = "/data/installs/ocx-local/latest/node_modules/@bitkyc08/opencodex/bin"; + const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => value.endsWith("/.mise.backend.toml"), + probe: metadataProbe(value => value.endsWith("/.mise.backend.toml")), + readFile: value => value.startsWith("/data/") + ? BACKEND + : 'short = "different-alias"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: () => resolved, + })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + + test("handles Windows spelling without treating path case as an ownership mismatch", () => { + const lexical = "C:\\Data Root\\mise\\installs\\OpenCodex\\2.59.0\\node_modules\\@bitkyc08\\opencodex\\bin"; + const resolved = "C:/Data Root/mise/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = new Set([ + "C:/Data Root/mise/installs/OpenCodex/.mise.backend.toml", + "C:/Data Root/mise/installs/opencodex/.mise.backend.toml", + ]); + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => metadata.has(value), + probe: metadataProbe(value => metadata.has(value)), + readFile: () => 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: () => resolved, + })).toMatchObject({ installer: "mise", owner: { tool: "opencodex" } }); + }); + + test("fails closed when probing adjacent metadata is unreadable", () => { + const path = "/data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(path, { + exists: () => false, + probe: () => "unreadable", + realpath: value => value, + })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); + }); +}); + +describe("mise update refusal", () => { + const ownership: InstallOwnership = { + installer: "mise", + owner: { + tool: "ocx-local", + backend: "npm:@bitkyc08/opencodex", + installPath: "/data/installs/ocx-local/2.59.0", + toolRoot: "/data/installs/ocx-local", + }, + }; + + test("read-only checks succeed with actionable external-management guidance", () => { + const result = checkForUpdate("preview", { + currentVersion: () => "2.59.0", + detectInstall: () => "npm", + detectInstallOwnership: () => ownership, + latestVersion: () => "2.60.0-preview.1", + miseUpdateCommand: value => value.installer === "mise" && value.owner + ? `mise upgrade ${value.owner.tool}` + : null, + }); + + expect(result).toMatchObject({ + installer: "mise", + canUpdate: false, + reason: "externally_managed", + command: "mise upgrade ocx-local", + channel: "preview", + }); + }); + + test("invalid metadata never invents a tool name", () => { + const result = checkForUpdate("latest", { + currentVersion: () => "2.59.0", + detectInstall: () => "mise", + detectInstallOwnership: () => ({ + installer: "mise", + owner: null, + error: "metadata_inconsistent", + }), + latestVersion: () => "2.60.0", + miseUpdateCommand: () => null, + }); + expect(result).toMatchObject({ + installer: "mise", + canUpdate: false, + reason: "external_ownership_invalid", + command: "", + }); + }); + + test("the sidebar badge can report availability without offering mutation", () => { + const badge = readUpdateBadge({ + currentVersion: () => "2.59.0", + detectInstall: () => "mise", + readCache: () => ({ + latest_version: "2.60.0", + last_checked_at: new Date().toISOString(), + tag: "latest", + }), + }); + expect(badge.updateAvailable).toBe(true); + expect(badge.canUpdate).toBe(false); + expect(badge.installer).toBe("mise"); + }); + + test("dashboard update requests are rejected before a worker is created", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-mise-job-")); + const previousHome = process.env.OPENCODEX_HOME; + let spawned = false; + process.env.OPENCODEX_HOME = root; + try { + let thrown: unknown; + try { + startUpdateJob("latest", true, { + checkForUpdateFn: () => ({ + currentVersion: "2.59.0", + latestVersion: "2.60.0", + channel: "latest", + installer: "mise", + updateAvailable: true, + canUpdate: false, + reason: "externally_managed", + command: "mise upgrade ocx-local", + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + spawnWorkerFn: () => { + spawned = true; + throw new Error("must not spawn"); + }, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(UpdateJobError); + expect(thrown).toMatchObject({ code: "externally_managed", status: 409 }); + expect(spawned).toBe(false); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(root, { recursive: true, force: true }); + } + }); + + function runLauncherUpdate(backend: string) { + const root = mkdtempSync(join(tmpdir(), "ocx-mise-launcher-")); + const toolRoot = join(root, "data root", "installs", "ocx-local"); + const packageParent = join(toolRoot, "2.59.0", "node_modules", "@bitkyc08"); + const packagePath = join(packageParent, "opencodex"); + const fakeBin = join(root, "fake-bin"); + const npmCalled = join(fakeBin, "npm-called"); + mkdirSync(packageParent, { recursive: true }); + mkdirSync(fakeBin); + // A junction needs no privilege on Windows; a directory symlink is the POSIX equivalent. + symlinkSync( + join(import.meta.dir, "..", ".."), + packagePath, + process.platform === "win32" ? "junction" : "dir", + ); + writeFileSync(join(toolRoot, ".mise.backend.toml"), backend); + // The fake npm records that it ran: a refusal must happen before any npm invocation. + if (process.platform === "win32") { + writeFileSync(join(fakeBin, "npm.cmd"), `@echo off\r\necho called> "${npmCalled}"\r\necho 2.59.0\r\n`); + } else { + const fakeNpm = join(fakeBin, "npm"); + writeFileSync(fakeNpm, `#!/bin/sh\n: > '${npmCalled}'\nprintf '%s\n' 2.59.0\n`); + chmodSync(fakeNpm, 0o755); + } + const result = spawnSync( + "node", + ["--preserve-symlinks-main", join(packagePath, "bin", "ocx.mjs"), "update", "--tag", "preview"], + { + encoding: "utf8", + env: { + ...process.env, + OPENCODEX_HOME: join(root, "state"), + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + }, + }, + ); + return { root, result, npmRan: existsSync(npmCalled), stateCreated: existsSync(join(root, "state")) }; + } + + test("the published Node launcher refuses before npm or Bun update handling", () => { + const { root, result, npmRan } = runLauncherUpdate(BACKEND); + try { + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("externally managed by mise"); + expect(result.stderr).toContain("mise upgrade ocx-local"); + expect(result.stderr).not.toContain("tag preview"); + expect(npmRan).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("the launcher refuses contradictory mise metadata without naming a tool or running npm", () => { + const contradictory = 'short = "ocx-local"\nfull = "npm:some-other-package"\nexplicit_backend = false\n'; + const { root, result, npmRan } = runLauncherUpdate(contradictory); + try { + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("ownership metadata is unreadable or inconsistent"); + expect(result.stderr).not.toContain("mise upgrade"); + expect(npmRan).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});