From e1744c5be3a22b2f08fac2c04b15cd71b87491cb Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Mon, 25 May 2026 01:27:26 +0200 Subject: [PATCH 1/2] Add launcher preview foundation --- .github/ISSUE_TEMPLATE/bug-report.yml | 54 ++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature-request.yml | 40 + .github/pull_request_template.md | 16 + .github/workflows/ci.yml | 21 + CONTRIBUTING.md | 66 ++ Makefile | 26 + README.md | 189 +++- SECURITY.md | 44 + assets/window-preview.svg | 51 ++ compat/README.md | 5 + desktop/README.md | 3 + desktop/codex-ubuntu.desktop.in | 12 + desktop/codex-ubuntu.svg | 17 + docs/architecture.md | 108 +++ .../0001-ubuntu-first-secure-agnostic.md | 42 + docs/faq.md | 42 + docs/roadmap.md | 34 + docs/security.md | 97 ++ launcher/codex-ubuntu | 855 ++++++++++++++++++ packaging/deb/README.md | 3 + packaging/deb/control.in | 9 + packaging/deb/postinst | 5 + packaging/deb/postrm | 5 + providers/README.md | 12 + providers/browser-shell.md | 67 ++ providers/contract.md | 59 ++ scripts/README.md | 3 + scripts/build-deb.sh | 35 + scripts/install-local.sh | 20 + scripts/render-desktop-file.sh | 19 + tests/fixtures/fake_browser.sh | 9 + tests/fixtures/fake_codex_app_linux.py | 128 +++ .../fixtures/fake_unverified_health_server.py | 80 ++ tests/fixtures/xdotool | 22 + tests/launcher_smoke.sh | 363 ++++++++ 36 files changed, 2550 insertions(+), 12 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 assets/window-preview.svg create mode 100644 compat/README.md create mode 100644 desktop/README.md create mode 100644 desktop/codex-ubuntu.desktop.in create mode 100644 desktop/codex-ubuntu.svg create mode 100644 docs/architecture.md create mode 100644 docs/decisions/0001-ubuntu-first-secure-agnostic.md create mode 100644 docs/faq.md create mode 100644 docs/roadmap.md create mode 100644 docs/security.md create mode 100755 launcher/codex-ubuntu create mode 100644 packaging/deb/README.md create mode 100644 packaging/deb/control.in create mode 100755 packaging/deb/postinst create mode 100755 packaging/deb/postrm create mode 100644 providers/README.md create mode 100644 providers/browser-shell.md create mode 100644 providers/contract.md create mode 100644 scripts/README.md create mode 100755 scripts/build-deb.sh create mode 100755 scripts/install-local.sh create mode 100755 scripts/render-desktop-file.sh create mode 100755 tests/fixtures/fake_browser.sh create mode 100755 tests/fixtures/fake_codex_app_linux.py create mode 100755 tests/fixtures/fake_unverified_health_server.py create mode 100755 tests/fixtures/xdotool create mode 100755 tests/launcher_smoke.sh diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..6da023c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,54 @@ +name: Bug report +description: Report a launcher, packaging, or desktop integration bug. +title: "[bug] " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this. Please include enough detail to reproduce the issue on Ubuntu without guessing. + - type: input + id: ubuntu-version + attributes: + label: Ubuntu version + placeholder: "24.04 LTS" + validations: + required: true + - type: input + id: install-path + attributes: + label: Install path + placeholder: "local install, .deb build, or custom setup" + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened + placeholder: Describe the actual behavior. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + placeholder: Describe the expected behavior. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Reproduction steps + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs or screenshots + description: Please redact sensitive values before posting. + placeholder: Paste short excerpts only. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..d48c4d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,40 @@ +name: Feature request +description: Suggest an improvement for launcher behavior, packaging, or provider design. +title: "[feature] " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem or gap + placeholder: What is missing or frustrating today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed change + placeholder: What should happen instead? + validations: + required: true + - type: textarea + id: why + attributes: + label: Why this matters + placeholder: Tell us about the user impact, not just the implementation. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - launcher + - packaging + - desktop integration + - provider contract + - documentation + - security + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..cabb512 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + +Explain the user-facing change in a few sentences. + +## Why + +What problem does this solve? + +## Validation + +- [ ] `make test` +- [ ] `make build-deb` + +## Notes + +Call out security, provider-contract, or desktop-integration implications here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d255c51 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + launcher: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Make scripts executable + run: chmod +x launcher/codex-ubuntu scripts/*.sh tests/*.sh tests/fixtures/* + - name: Syntax checks + run: make check + - name: Smoke tests + run: make test + - name: Build Debian package + run: make build-deb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a962748 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing + +Thanks for helping make `codex-ubuntu` better. + +This project is still early, but it is not casual about launcher safety. Small-looking changes in runtime discovery, PID handling, or browser launch behavior can break trust fast, so please read the docs before editing core flows. + +## Before you open a pull request + +Read: + +- [README.md](README.md) +- [docs/architecture.md](docs/architecture.md) +- [docs/security.md](docs/security.md) +- [providers/contract.md](providers/contract.md) + +## Local development + +Recommended environment: + +- Ubuntu 22.04 or 24.04 +- `bash` +- `python3` +- `curl` +- `xdg-utils` + +Useful commands: + +```bash +make test +make build-deb +make install-local +``` + +## Contribution priorities + +Good first contributions: + +- launcher hardening +- test coverage improvements +- desktop integration polish +- packaging cleanup +- documentation that reduces ambiguity + +Changes that need extra care: + +- process stop or reuse logic +- provider contract changes +- token handling or logging changes +- anything that touches auth, runtime metadata, or port ownership + +## Style expectations + +- prefer simple Bash over clever Bash +- keep paths XDG-aware +- avoid user-specific assumptions +- document why a safety rule exists when it is not obvious +- favor honest docs over ambitious docs + +## Pull request checklist + +- explain the user-facing problem clearly +- mention any security or runtime-behavior impact +- include tests when behavior changes +- call out assumptions and limitations directly + +If a proposed change makes the repo look better but reduces truthfulness, it is probably the wrong trade. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a872e73 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +SHELL := /usr/bin/env bash + +.PHONY: check validate-desktop test install-local build-deb + +check: validate-desktop + bash -n launcher/codex-ubuntu + bash -n scripts/install-local.sh + bash -n scripts/build-deb.sh + bash -n tests/launcher_smoke.sh + +validate-desktop: + @tmpfile="$$(mktemp --suffix=.desktop)"; \ + scripts/render-desktop-file.sh "/usr/bin/codex-ubuntu" "codex-ubuntu" "$$tmpfile"; \ + if command -v desktop-file-validate >/dev/null 2>&1; then \ + desktop-file-validate "$$tmpfile"; \ + fi; \ + rm -f "$$tmpfile" + +test: check + tests/launcher_smoke.sh + +install-local: + scripts/install-local.sh + +build-deb: + scripts/build-deb.sh diff --git a/README.md b/README.md index 4026ede..29d5dcd 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,189 @@ ![Hero](assets/hero.svg) -Unofficial Ubuntu-first launcher and packaging project for Ubuntu. +[![Status](https://img.shields.io/badge/status-preview-orange)](docs/roadmap.md) +[![Ubuntu](https://img.shields.io/badge/ubuntu-22.04%20%7C%2024.04-E95420)](docs/architecture.md) +[![Packaging](https://img.shields.io/badge/package-.deb%20first-0E7490)](packaging/deb/README.md) +[![Security](https://img.shields.io/badge/runtime-stop%20verified-2E7D32)](docs/security.md) +[![License](https://img.shields.io/badge/license-MIT-111111)](LICENSE) -This repository is being staged in public with a small landing commit first so the implementation can land through a real draft pull request instead of an artificial push straight to `main`. +Unofficial Ubuntu-first launcher and packaging project for Codex. -## Planned first preview +`codex-ubuntu` is trying to solve a very specific problem well: make the app feel at home on Ubuntu without pretending Linux support is already finished upstream, and without baking in fragile local assumptions that break as soon as the machine changes. -- Ubuntu-first launcher flow -- dedicated app-window behavior -- XDG-aware state handling -- verified runtime ownership before stop or reuse -- Debian packaging path -- smoke-test coverage +## Why this exists -## Status +Ubuntu users currently end up choosing between: -Bootstrap branch only. +- browser-first one-off launch scripts +- brittle personal-machine wrappers +- heavy unofficial ports with a large maintenance surface -The full launcher foundation is prepared on the first implementation branch for review. +This repository takes the narrower path: + +- real Ubuntu launcher behavior +- explicit security rules around runtime ownership +- `.deb`-first packaging direction +- provider boundaries that leave room for future runtime strategies + +## What works today + +The current repository is not just a design memo. It ships a working launcher foundation with: + +- dedicated app-window flow +- XDG config, cache, and state handling +- verified process ownership before stop or reuse +- runtime and browser discovery without hardcoded personal paths +- local install flow +- `.deb` build path +- smoke tests and CI + +![Preview](assets/window-preview.svg) + +## What it is not claiming + +This repository is not yet: + +- an official Linux desktop release +- a full upstream desktop-payload repackager +- a finished updater +- a promise to redistribute proprietary upstream app assets + +The local repackager path is still exploratory, not a committed milestone. + +## Current strategy + +The recommended v1 is intentionally launcher-first. + +That means: + +1. the Ubuntu launcher and desktop integration are real now +2. the browser-shell runtime path is implemented now +3. the provider contract is defined now +4. future runtime modes can be added without rewriting the project shape + +### Runtime model + +Implemented today: + +- `browser-shell` + +Planned but not implemented: + +- `app-server` +- `desktop-payload` + +See [providers/contract.md](providers/contract.md), [providers/browser-shell.md](providers/browser-shell.md), and [docs/architecture.md](docs/architecture.md). + +## Feature matrix + +| Area | Current | Notes | +| --- | --- | --- | +| Ubuntu launcher | Yes | Dedicated app-window flow and desktop identity | +| Process-safe stop/reuse | Yes | Refuses to kill unverified runtimes | +| XDG state layout | Yes | Config, cache, and state are separated | +| Local install | Yes | `make install-local` | +| Debian package build | Yes | `make build-deb` | +| CI | Yes | Syntax, smoke tests, packaging | +| App Server provider | Not yet | Tracked as a future provider | +| Desktop-payload repackager | Exploratory | Not tied to a committed release phase | +| Updater | Not yet | Deliberately deferred | + +## Quick start + +### 1. Install runtime prerequisites + +The current launcher expects: + +- `codex-app-linux` on `PATH`, or `CODEX_UBUNTU_APP_LINUX_CMD` set +- a supported browser on `PATH`, or `CODEX_UBUNTU_BROWSER` set +- `python3`, `curl`, and `xdg-utils` + +### 2. Install locally + +```bash +make install-local +``` + +That installs: + +- `~/.local/bin/codex-ubuntu` +- `~/.local/share/applications/codex-ubuntu.desktop` +- `~/.local/share/icons/hicolor/scalable/apps/codex-ubuntu.svg` + +### 3. Launch + +```bash +codex-ubuntu +``` + +Or open `Codex Ubuntu (Unofficial)` from the Ubuntu app grid. + +## Architecture at a glance + +```mermaid +flowchart LR + user["User"] --> desktop["Ubuntu desktop entry"] + desktop --> launcher["Launcher"] + launcher --> provider["Runtime provider"] + launcher --> state["XDG state, cache, config"] + provider --> runtime["Local web runtime"] +``` + +More detail lives in [docs/architecture.md](docs/architecture.md). + +## Security baseline + +This project is opinionated about launcher safety: + +- never trust a stale PID file by itself +- verify runtime ownership before stop or reuse +- keep user state inside XDG directories +- avoid logging token-bearing URLs +- do not hardcode personal machine paths +- do not globally fake another operating system + +Read [docs/security.md](docs/security.md) before extending runtime control logic. + +## Repository layout + +- `launcher/` launcher executable +- `desktop/` desktop entry template and icon assets +- `packaging/deb/` Debian packaging templates +- `providers/` runtime/provider contract docs +- `tests/` smoke tests and fixtures +- `scripts/` install and build helpers +- `docs/` architecture, security, roadmap, FAQs, and ADRs + +## Development + +Run checks: + +```bash +make test +``` + +Build a Debian package: + +```bash +make build-deb +``` + +Read before contributing: + +- [CONTRIBUTING.md](CONTRIBUTING.md) +- [SECURITY.md](SECURITY.md) +- [docs/faq.md](docs/faq.md) + +## Design review questions + +These are the intended pushback points: + +1. Is launcher-first still the right v1? +2. Is the provider contract concrete enough? +3. When should the exploratory repackager track graduate into an explicit milestone? +4. Is the current browser-shell runtime boundary too narrow or appropriately conservative? +5. What must be true before calling the project a public preview? ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d71e6ce --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security policy + +`codex-ubuntu` is an unofficial Ubuntu launcher project, but it still handles: + +- local auth-bearing URLs +- process reuse and process termination +- local web runtimes on loopback +- desktop launch surfaces + +That means security bugs here are real bugs, even when the runtime is local-only. + +## Supported focus + +The current security focus is: + +- launcher stop and reuse safety +- token redaction and log hygiene +- XDG path handling +- desktop integration correctness + +## Reporting a vulnerability + +Please do not open a public issue for a vulnerability that could expose user data, local tokens, or unsafe process control behavior. + +Instead: + +1. describe the issue privately to the maintainer +2. include affected version or commit +3. include reproduction steps +4. include expected versus actual behavior + +If you are not sure whether something is security-sensitive, err on the side of private disclosure first. + +## High-priority bug classes + +- stale PID reuse that can kill unrelated processes +- token leakage into logs or persistent browser history beyond what the runtime already requires +- loopback runtime unexpectedly binding beyond localhost +- unsafe provider metadata trust +- desktop entry or protocol handler behavior that enables unintended command execution + +## Out of scope + +The repository does not claim to secure proprietary upstream services or official upstream binaries beyond the local launcher logic it owns. diff --git a/assets/window-preview.svg b/assets/window-preview.svg new file mode 100644 index 0000000..3fd1530 --- /dev/null +++ b/assets/window-preview.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Public preview direction + Launcher-first, honest, Ubuntu-shaped + Future providers stay possible + diff --git a/compat/README.md b/compat/README.md new file mode 100644 index 0000000..59ee3fe --- /dev/null +++ b/compat/README.md @@ -0,0 +1,5 @@ +# compat + +Compatibility shims and narrowly scoped patch notes belong here. + +No compatibility code is shipped in v1 yet. diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..5097e21 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,3 @@ +# desktop + +Desktop integration assets for `codex-ubuntu`. diff --git a/desktop/codex-ubuntu.desktop.in b/desktop/codex-ubuntu.desktop.in new file mode 100644 index 0000000..f8cc3ea --- /dev/null +++ b/desktop/codex-ubuntu.desktop.in @@ -0,0 +1,12 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Codex Ubuntu (Unofficial) +Comment=Launch Codex on Ubuntu through the unofficial launcher +Exec=__EXEC__ %U +Terminal=false +Categories=Development;IDE; +StartupNotify=true +StartupWMClass=codex-ubuntu +Icon=__ICON__ +Keywords=Ubuntu;Development;AI;Assistant; diff --git a/desktop/codex-ubuntu.svg b/desktop/codex-ubuntu.svg new file mode 100644 index 0000000..6a95cd0 --- /dev/null +++ b/desktop/codex-ubuntu.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7a97fdc --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,108 @@ +# Architecture + +## Positioning + +`codex-ubuntu` is Ubuntu-first and implementation-conscious. + +The repository should not assume: + +- one browser +- one user-specific install path +- one long-term runtime strategy +- one future packaging outcome + +The repository should define stable boundaries and implement only the parts that are mature enough to own today. + +## Layer model + +```text +user + -> desktop integration + -> launcher + -> runtime provider + -> auth/state/logging + -> packaging/install path +``` + +## Current implemented architecture + +### V1 + +- launcher-first +- browser-shell runtime provider +- local install flow +- Debian packaging skeleton +- smoke tests and CI + +### Future track + +- optional local repackager path if legal and maintenance tradeoffs are acceptable + +The local repackager path is intentionally exploratory. It is not assigned to a committed version milestone yet. + +## Boundaries + +### Launcher + +Responsibilities: + +- discover runtime and browser commands +- manage XDG config/cache/state +- own process-safety rules +- own app-window launch behavior +- own local health checks and stop behavior + +Should not: + +- hardcode personal machine paths +- assume all providers share the same startup semantics +- smuggle Debian packaging logic directly into runtime control + +### Runtime provider + +The launcher talks to a provider contract. + +Implemented provider: + +- `browser-shell` + +Planned providers: + +- `app-server` +- `desktop-payload` + +The provider contract is defined in [providers/contract.md](../providers/contract.md). + +### Desktop integration + +Responsibilities: + +- `.desktop` entry +- icon registration +- GNOME/BAMF-friendly identity +- protocol handler integration when ready + +### Packaging + +Primary package target: + +- `.deb` + +Secondary or later: + +- AppImage + +Deferred: + +- Snap +- Flatpak + +## Why launcher-first + +Launcher-first is the strongest v1 because it delivers a real Ubuntu app experience without pretending we already own: + +- a stable upstream desktop repackager +- a final upstream asset redistribution model +- a mature updater pipeline + +It also lets the repo publish a concrete product now instead of a pure design memo. diff --git a/docs/decisions/0001-ubuntu-first-secure-agnostic.md b/docs/decisions/0001-ubuntu-first-secure-agnostic.md new file mode 100644 index 0000000..cf37982 --- /dev/null +++ b/docs/decisions/0001-ubuntu-first-secure-agnostic.md @@ -0,0 +1,42 @@ +# 0001 Ubuntu-first secure agnostic foundation + +## Status + +Proposed + +## Context + +The project needs a public-facing foundation before the final implementation path is settled. + +There are multiple viable directions: + +- secure browser-shell launcher +- CLI/App Server-backed Ubuntu client +- local desktop-payload repackager + +Choosing one too early would hide tradeoffs instead of surfacing them. + +## Decision + +Start with a secure, Ubuntu-first, implementation-agnostic repository structure. + +That means: + +- launcher-first scaffolding +- security-first process model +- Debian packaging as the package target +- pluggable runtime/provider framing +- no assumption that repackaging is v1 + +## Consequences + +### Positive + +- faster to review +- lower chance of locking into a brittle path +- clearer public intent + +### Negative + +- less flashy than a concrete repackager prototype +- requires discipline to keep boundaries real diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..2a4a51d --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,42 @@ +# FAQ + +## Is this a native Ubuntu app? + +Not yet in the fully native sense. + +Today the implemented path is a launcher-first browser-shell experience with real Ubuntu desktop integration. It behaves more like an app than a random browser tab, but it is not pretending to be a finished upstream Linux desktop port. + +## Why not jump straight to Electron or repackaging? + +Because the maintenance bill is real. + +A launcher-first path gives the project something honest and usable to ship now while keeping the heavier repackager path exploratory until the legal and maintenance tradeoffs are clearer. + +## Why Ubuntu-first instead of generic Linux-first? + +Because support claims are expensive. + +Ubuntu-first keeps the scope narrow enough to do packaging, desktop integration, and runtime assumptions properly before expanding outward. + +## Is the repackager path dead? + +No. + +It is deliberately kept as a future track rather than a committed milestone. That keeps the repo honest while still leaving room for a higher-fidelity desktop path later. + +## What does secure mean here? + +At minimum: + +- the launcher should not kill unrelated processes +- stale runtime state should not be blindly trusted +- token-bearing URLs should not be sprayed into logs +- paths should not assume one specific personal machine layout + +See [docs/security.md](security.md). + +## Can this repo redistribute proprietary upstream assets? + +Not by default. + +The current repository code and docs are MIT-licensed, but that does not grant rights to upstream proprietary desktop assets. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..33755b0 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,34 @@ +# Roadmap + +## Phase 1 + +Launcher-first public preview. + +- secure Ubuntu launcher +- runtime discovery +- browser-shell provider +- local install flow +- Debian packaging skeleton +- smoke tests and CI + +## Phase 2 + +Hardening. + +- better lock/race handling +- cleaner logging and redaction +- stronger desktop integration +- more installer polish + +## Future track + +Exploratory local repackager path. + +This is intentionally not tied to a fixed release phase yet. + +Promotion criteria: + +- legal/distribution model is clear +- maintenance burden is acceptable +- upstream patch drift looks manageable +- the launcher-first path has reached diminishing returns diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..34c4034 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,97 @@ +# Security + +## Threat model + +This project manages: + +- local auth state +- local loopback services +- launcher-to-runtime trust +- desktop launch surfaces +- process reuse and process termination + +That means a local-only Ubuntu launcher can still create serious damage if it stops the wrong process or leaks the wrong token. + +## Security priorities + +### P0 + +- do not kill unrelated processes +- do not trust stale PID files +- do not log token-bearing URLs or equivalent secrets +- do not expose runtime services beyond loopback by default + +### P1 + +- verify runtime ownership before reusing it +- use XDG state/config/cache paths consistently +- avoid hardcoded personal path assumptions +- keep launcher state separate from provider-owned runtime metadata + +### P2 + +- add locking to reduce double-start races +- improve port allocation race handling +- document and enforce redaction rules + +## Provider/runtime contract requirement + +The launcher may only reuse or stop a runtime when the provider exposes verifiable runtime metadata. + +Minimum required fields: + +1. `pid` +2. `bind` +3. `port` +4. `startedAt` + +Optional but strongly preferred: + +1. `tokenFile` +2. `provider` +3. `authDisabled` + +For restart-safe reuse, the launcher should also persist its own provenance record for the trusted runtime, such as a process fingerprint captured after a healthy launch. + +See [providers/contract.md](../providers/contract.md). + +## Process ownership policy + +The launcher must not stop a process only because: + +- a PID file exists +- a PID is live + +The launcher should require: + +1. validated structured runtime metadata +2. matching process identity from `/proc//cmdline` +3. matching bind/port expectations +4. matching token-file expectation when the provider supports token auth +5. matching launcher-managed runtime provenance captured from a previously trusted launch + +If ownership cannot be proven, the launcher should refuse to kill the process. + +The same rule applies to reuse. A healthy loopback service that cannot be tied back to a trusted launcher provenance record must be treated as unverified and must not be reused. + +## Token handling policy + +- bind locally by default +- avoid logging token-bearing URLs +- avoid printing login commands with live tokens into persisted logs +- prefer redacted auth hints in logs + +## Path policy + +Disallowed in production design: + +- hardcoded `~/.nvm/...` +- hardcoded personal home paths +- hidden dependence on one user profile path + +Preferred: + +- `command -v` +- explicit overrides via environment +- XDG paths +- provider-owned metadata diff --git a/launcher/codex-ubuntu b/launcher/codex-ubuntu new file mode 100755 index 0000000..b9d3a54 --- /dev/null +++ b/launcher/codex-ubuntu @@ -0,0 +1,855 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_ID="codex-ubuntu" +APP_NAME="Codex Ubuntu (Unofficial)" +APP_VERSION="${CODEX_UBUNTU_VERSION:-0.1.0}" +PROVIDER="${CODEX_UBUNTU_PROVIDER:-browser-shell}" +WEB_BIND="${CODEX_UBUNTU_BIND:-127.0.0.1}" +DEFAULT_WEB_PORT="${CODEX_UBUNTU_PORT:-8080}" + +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/${APP_ID}" +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/${APP_ID}" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/${APP_ID}" +PROFILE_DIR="${CONFIG_DIR}/browser-profile" +TOKEN_FILE="${STATE_DIR}/token" +RUNTIME_FILE="${TOKEN_FILE}.runtime" +RUNTIME_FINGERPRINT_FILE="${STATE_DIR}/runtime.fingerprint.json" +PID_FILE="${STATE_DIR}/web.pid" +PORT_FILE="${STATE_DIR}/web.port" +LOCK_FILE="${STATE_DIR}/launcher.lock" +LAUNCHER_LOG="${CACHE_DIR}/launcher.log" +WEB_LOG="${CACHE_DIR}/web.log" +LOCK_HELD=0 +ENSURE_SERVER_PORT="" +ENSURE_SERVER_ACTION="" + +usage() { + cat <<'EOF' +Usage: + codex-ubuntu Start Codex Ubuntu in a dedicated app window + codex-ubuntu --browser Open Codex Ubuntu in a normal browser tab + codex-ubuntu --status Show current launcher/runtime status + codex-ubuntu --doctor Show runtime and browser discovery details + codex-ubuntu --stop Stop the verified local web runtime + codex-ubuntu --help Show this help + codex-ubuntu --version Show launcher version +EOF +} + +log() { + mkdir -p "$CACHE_DIR" + printf '[%s] %s\n' "$(date -Is)" "$*" >>"$LAUNCHER_LOG" +} + +notify_failure() { + local message="$1" + if command -v notify-send >/dev/null 2>&1; then + notify-send "$APP_NAME" "$message" || true + fi +} + +ensure_dirs() { + mkdir -p "$CONFIG_DIR" "$CACHE_DIR" "$STATE_DIR" "$PROFILE_DIR" +} + +with_lock() { + ensure_dirs + if command -v flock >/dev/null 2>&1; then + if [ "$LOCK_HELD" = "1" ]; then + return 0 + fi + exec 9>"$LOCK_FILE" + flock 9 + LOCK_HELD=1 + fi +} + +release_lock() { + if [ "$LOCK_HELD" != "1" ]; then + return 0 + fi + + if command -v flock >/dev/null 2>&1; then + flock -u 9 >/dev/null 2>&1 || true + exec 9>&- || true + fi + + LOCK_HELD=0 +} + +clear_launcher_state() { + rm -f \ + "$PID_FILE" \ + "$PORT_FILE" \ + "$RUNTIME_FILE" \ + "$RUNTIME_FINGERPRINT_FILE" \ + "$TOKEN_FILE" +} + +is_unsigned_int() { + case "${1:-}" in + ""|*[!0-9]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +executable_candidate() { + local candidate="$1" + [ -n "$candidate" ] || return 1 + [ -x "$candidate" ] || return 1 + printf '%s\n' "$candidate" +} + +search_nvm_tool() { + local name="$1" + local latest="" + + if [ -d "${HOME}/.nvm/versions/node" ]; then + latest="$(find "${HOME}/.nvm/versions/node" -path "*/bin/${name}" -type f 2>/dev/null | sort -V | tail -n 1 || true)" + if executable_candidate "$latest" >/dev/null 2>&1; then + printf '%s\n' "$latest" + return 0 + fi + fi + + return 1 +} + +resolve_tool() { + local env_name="$1" + local fallback_name="$2" + local env_value="${!env_name:-}" + local candidate="" + local npm_prefix="" + + if executable_candidate "$env_value" >/dev/null 2>&1; then + executable_candidate "$env_value" + return 0 + fi + + if command -v "$fallback_name" >/dev/null 2>&1; then + command -v "$fallback_name" + return 0 + fi + + for candidate in \ + "${HOME}/.local/bin/${fallback_name}" \ + "${HOME}/bin/${fallback_name}" \ + "/usr/local/bin/${fallback_name}" \ + "/usr/bin/${fallback_name}" \ + "/snap/bin/${fallback_name}" + do + if executable_candidate "$candidate" >/dev/null 2>&1; then + executable_candidate "$candidate" + return 0 + fi + done + + if command -v npm >/dev/null 2>&1; then + npm_prefix="$(npm prefix -g 2>/dev/null || true)" + if executable_candidate "${npm_prefix}/bin/${fallback_name}" >/dev/null 2>&1; then + executable_candidate "${npm_prefix}/bin/${fallback_name}" + return 0 + fi + fi + + search_nvm_tool "$fallback_name" +} + +resolve_runtime_command() { + case "$PROVIDER" in + browser-shell) + resolve_tool "CODEX_UBUNTU_APP_LINUX_CMD" "codex-app-linux" + ;; + *) + printf 'Unsupported provider: %s\n' "$PROVIDER" >&2 + return 1 + ;; + esac +} + +resolve_browser_command() { + local env_value="${CODEX_UBUNTU_BROWSER:-}" + local candidate="" + + if executable_candidate "$env_value" >/dev/null 2>&1; then + executable_candidate "$env_value" + return 0 + fi + + for candidate in \ + google-chrome-stable \ + google-chrome \ + chromium \ + chromium-browser \ + microsoft-edge \ + microsoft-edge-stable \ + vivaldi-stable \ + vivaldi + do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + + return 1 +} + +runtime_field() { + local field="$1" + + [ -f "$RUNTIME_FILE" ] || return 1 + python3 - "$RUNTIME_FILE" "$field" <<'PY' +import json +import sys + +runtime_path = sys.argv[1] +field_name = sys.argv[2] + +try: + with open(runtime_path, "r", encoding="utf-8") as handle: + value = json.load(handle).get(field_name) +except Exception: + raise SystemExit(1) + +if value is None: + raise SystemExit(1) + +print(value) +PY +} + +runtime_pid() { + runtime_field pid +} + +runtime_port() { + runtime_field port +} + +saved_port() { + [ -f "$PORT_FILE" ] || return 1 + tr -d '[:space:]' <"$PORT_FILE" +} + +pid_is_live() { + local pid="$1" + is_unsigned_int "$pid" && kill -0 "$pid" >/dev/null 2>&1 +} + +capture_runtime_fingerprint() { + python3 - "$RUNTIME_FILE" "$RUNTIME_FINGERPRINT_FILE" "$TOKEN_FILE" "$WEB_BIND" "$PROVIDER" <<'PY' +import hashlib +import json +import os +import pathlib +import sys +import time + +runtime_path = pathlib.Path(sys.argv[1]) +fingerprint_path = pathlib.Path(sys.argv[2]) +expected_token_file = sys.argv[3] +expected_bind = sys.argv[4] +provider = sys.argv[5] + +try: + runtime = json.loads(runtime_path.read_text(encoding="utf-8")) +except Exception: + raise SystemExit(1) + +pid = str(runtime.get("pid", "")) +port = str(runtime.get("port", "")) +bind = str(runtime.get("bind", "")) +token_file = runtime.get("tokenFile") + +if not pid.isdigit() or not port.isdigit(): + raise SystemExit(1) +if bind != expected_bind: + raise SystemExit(1) +if token_file != expected_token_file: + raise SystemExit(1) + +cmdline_path = pathlib.Path(f"/proc/{pid}/cmdline") +exe_path = pathlib.Path(f"/proc/{pid}/exe") + +try: + raw = cmdline_path.read_bytes() +except Exception: + raise SystemExit(1) + +parts = [part.decode("utf-8", "ignore") for part in raw.split(b"\0") if part] +if not parts: + raise SystemExit(1) + +required_pairs = { + "--token-file": expected_token_file, + "--bind": expected_bind, + "--port": port, +} + +for flag, expected in required_pairs.items(): + try: + index = parts.index(flag) + except ValueError: + raise SystemExit(1) + if index + 1 >= len(parts) or parts[index + 1] != expected: + raise SystemExit(1) + +try: + proc_exe = os.readlink(exe_path) +except OSError: + proc_exe = "" + +fingerprint = { + "provider": provider, + "pidAtCapture": int(pid), + "bind": expected_bind, + "port": int(port), + "tokenFile": expected_token_file, + "procExe": proc_exe, + "cmdlineSha256": hashlib.sha256(raw).hexdigest(), + "capturedAt": int(time.time() * 1000), +} + +fingerprint_path.write_text(json.dumps(fingerprint) + "\n", encoding="utf-8") +PY +} + +process_matches_runtime_metadata() { + local pid="$1" + local port="$2" + + pid_is_live "$pid" || return 1 + is_unsigned_int "$port" || return 1 + + python3 - "$pid" "$RUNTIME_FILE" "$TOKEN_FILE" "$WEB_BIND" "$port" <<'PY' +import json +import pathlib +import sys + +pid, runtime_path, token_file, bind, port = sys.argv[1:] +cmdline_path = pathlib.Path(f"/proc/{pid}/cmdline") + +try: + runtime = json.loads(pathlib.Path(runtime_path).read_text(encoding="utf-8")) +except Exception: + raise SystemExit(1) + +if str(runtime.get("pid")) != pid: + raise SystemExit(1) +if str(runtime.get("bind")) != bind: + raise SystemExit(1) +if str(runtime.get("port")) != port: + raise SystemExit(1) +if runtime.get("tokenFile") != token_file: + raise SystemExit(1) + +try: + raw = cmdline_path.read_bytes() +except Exception: + raise SystemExit(1) + +parts = [part.decode("utf-8", "ignore") for part in raw.split(b"\0") if part] +if not parts: + raise SystemExit(1) + +required_pairs = { + "--token-file": token_file, + "--bind": bind, + "--port": port, +} + +for flag, expected in required_pairs.items(): + try: + index = parts.index(flag) + except ValueError: + raise SystemExit(1) + if index + 1 >= len(parts) or parts[index + 1] != expected: + raise SystemExit(1) + +raise SystemExit(0) +PY +} + +process_matches_runtime_server() { + local pid="$1" + local port="$2" + + process_matches_runtime_metadata "$pid" "$port" || return 1 + + python3 - "$pid" "$RUNTIME_FINGERPRINT_FILE" "$PROVIDER" <<'PY' +import hashlib +import json +import os +import pathlib +import sys + +pid, fingerprint_path, provider = sys.argv[1:] +cmdline_path = pathlib.Path(f"/proc/{pid}/cmdline") +exe_path = pathlib.Path(f"/proc/{pid}/exe") + +try: + fingerprint = json.loads(pathlib.Path(fingerprint_path).read_text(encoding="utf-8")) +except Exception: + raise SystemExit(1) + +if fingerprint.get("provider") != provider: + raise SystemExit(1) + +try: + raw = cmdline_path.read_bytes() +except Exception: + raise SystemExit(1) + +if hashlib.sha256(raw).hexdigest() != fingerprint.get("cmdlineSha256"): + raise SystemExit(1) + +try: + proc_exe = os.readlink(exe_path) +except OSError: + proc_exe = "" + +fingerprint_exe = fingerprint.get("procExe", "") +if fingerprint_exe and proc_exe != fingerprint_exe: + raise SystemExit(1) + +raise SystemExit(0) +PY +} + +kill_runtime_pid() { + local pid="$1" + + [ -n "$pid" ] || return 1 + pid_is_live "$pid" || return 0 + + kill "$pid" >/dev/null 2>&1 || true + + for _ in $(seq 1 20); do + if ! pid_is_live "$pid"; then + return 0 + fi + sleep 0.2 + done + + if pid_is_live "$pid"; then + kill -9 "$pid" >/dev/null 2>&1 || true + fi +} + +verified_runtime_pid() { + local pid="" + local port="" + + pid="$(runtime_pid 2>/dev/null || true)" + port="$(runtime_port 2>/dev/null || true)" + + if process_matches_runtime_server "$pid" "$port"; then + printf '%s\n' "$pid" + return 0 + fi + + return 1 +} + +verified_runtime_port() { + local pid="" + local port="" + + pid="$(verified_runtime_pid 2>/dev/null || true)" + port="$(runtime_port 2>/dev/null || true)" + + if [ -n "$pid" ] && is_unsigned_int "$port"; then + printf '%s\n' "$port" + return 0 + fi + + return 1 +} + +sync_state_from_runtime() { + local pid="" + local port="" + + pid="$(verified_runtime_pid 2>/dev/null || true)" + port="$(verified_runtime_port 2>/dev/null || true)" + + if [ -z "$pid" ] || [ -z "$port" ]; then + return 1 + fi + + printf '%s\n' "$pid" >"$PID_FILE" + printf '%s\n' "$port" >"$PORT_FILE" +} + +port_is_listening() { + local port="$1" + python3 - "$WEB_BIND" "$port" <<'PY' +import socket +import sys + +host = sys.argv[1] +port = int(sys.argv[2]) + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.5) + raise SystemExit(0 if sock.connect_ex((host, port)) == 0 else 1) +PY +} + +server_healthy() { + local port="$1" + curl -fsS "http://${WEB_BIND}:${port}/__webstrapper/healthz" >/dev/null 2>&1 +} + +pick_port() { + python3 - "$WEB_BIND" "$DEFAULT_WEB_PORT" <<'PY' +import socket +import sys + +host = sys.argv[1] +preferred = int(sys.argv[2]) + +for port in (preferred, *range(preferred + 1, preferred + 25)): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((host, port)) + except OSError: + continue + print(port) + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +redact_stream() { + python3 - <<'PY' +import re +import sys + +for line in sys.stdin: + line = re.sub(r'token=[^"&\s]+', 'token=', line) + if line.startswith("Local login command: "): + line = "Local login command: \n" + sys.stdout.write(line) +PY +} + +open_url() { + local url="$1" + + if command -v xdg-open >/dev/null 2>&1; then + xdg-open "$url" >/dev/null 2>&1 && return 0 + fi + if command -v gio >/dev/null 2>&1; then + gio open "$url" >/dev/null 2>&1 && return 0 + fi + if command -v sensible-browser >/dev/null 2>&1; then + sensible-browser "$url" >/dev/null 2>&1 && return 0 + fi + + return 1 +} + +cleanup_stale_state() { + local pid="" + local port="" + + pid="$(verified_runtime_pid 2>/dev/null || true)" + port="$(verified_runtime_port 2>/dev/null || runtime_port 2>/dev/null || saved_port 2>/dev/null || true)" + + if [ -n "$pid" ] && [ -n "$port" ]; then + sync_state_from_runtime || true + return 0 + fi + + if [ -n "$port" ] && is_unsigned_int "$port" && server_healthy "$port"; then + log "Discarding unverified healthy web runtime at http://${WEB_BIND}:${port}/" + fi + + clear_launcher_state +} + +stop_server() { + local pid="" + local port="" + + with_lock + pid="$(verified_runtime_pid 2>/dev/null || true)" + port="$(verified_runtime_port 2>/dev/null || runtime_port 2>/dev/null || saved_port 2>/dev/null || true)" + + if [ -n "$pid" ] && pid_is_live "$pid"; then + kill_runtime_pid "$pid" + elif [ -n "$port" ] && is_unsigned_int "$port" && server_healthy "$port"; then + log "Refusing to stop healthy web runtime at http://${WEB_BIND}:${port}/ because the owning process could not be verified" + printf 'Refusing to stop healthy web runtime at http://%s:%s/ because the owning process could not be verified.\n' "$WEB_BIND" "$port" >&2 + clear_launcher_state + release_lock + return 1 + fi + + clear_launcher_state + release_lock +} + +ensure_server() { + local runtime_cmd="" + local codex_cli="" + local pid="" + local port="" + local started_pid="" + + ENSURE_SERVER_PORT="" + ENSURE_SERVER_ACTION="" + with_lock + ensure_dirs + cleanup_stale_state + + runtime_cmd="$(resolve_runtime_command)" + codex_cli="$(resolve_tool "CODEX_CLI_PATH" "codex" 2>/dev/null || true)" + if [ -n "$codex_cli" ]; then + export CODEX_CLI_PATH="$codex_cli" + fi + + pid="$(verified_runtime_pid 2>/dev/null || true)" + port="$(verified_runtime_port 2>/dev/null || runtime_port 2>/dev/null || saved_port 2>/dev/null || true)" + + if [ -n "$port" ] && is_unsigned_int "$port" && server_healthy "$port"; then + sync_state_from_runtime || true + log "Reusing healthy web runtime at http://${WEB_BIND}:${port}/" + ENSURE_SERVER_PORT="$port" + ENSURE_SERVER_ACTION="reused" + release_lock + return 0 + fi + + if [ -n "$pid" ] && pid_is_live "$pid"; then + stop_server + fi + + if [ -n "$port" ] && is_unsigned_int "$port" && port_is_listening "$port" && ! server_healthy "$port"; then + port="" + fi + + if [ -z "$port" ]; then + port="$(pick_port)" + fi + + log "Starting ${PROVIDER} runtime on ${WEB_BIND}:${port}" + nohup "$runtime_cmd" web \ + --bind "$WEB_BIND" \ + --port "$port" \ + --token-file "$TOKEN_FILE" \ + 9>&- > >( + exec 9>&- + redact_stream >>"$WEB_LOG" + ) 2>&1 & + started_pid="$!" + + rm -f "$PID_FILE" + printf '%s\n' "$port" >"$PORT_FILE" + + for _ in $(seq 1 60); do + if server_healthy "$port"; then + if ! capture_runtime_fingerprint; then + local unverified_pid="" + unverified_pid="$(runtime_pid 2>/dev/null || true)" + log "Web runtime became healthy but could not be fingerprinted" + if process_matches_runtime_metadata "$unverified_pid" "$port"; then + kill_runtime_pid "$unverified_pid" + fi + clear_launcher_state + printf 'The local runtime started but could not be verified for safe reuse.\n' >&2 + release_lock + return 1 + fi + sync_state_from_runtime || true + log "Web runtime became healthy" + ENSURE_SERVER_PORT="$port" + ENSURE_SERVER_ACTION="started" + release_lock + return 0 + fi + + if ! pid_is_live "$started_pid"; then + break + fi + + sleep 0.25 + done + + log "Web runtime failed to become healthy" + notify_failure "The local runtime did not start. Check ${WEB_LOG}." + printf 'The local runtime did not start. Check %s\n' "$WEB_LOG" >&2 + stop_server || true + release_lock + return 1 +} + +auth_url() { + local port="$1" + + if [ ! -f "$TOKEN_FILE" ]; then + printf 'http://%s:%s/\n' "$WEB_BIND" "$port" + return 0 + fi + + python3 - "$TOKEN_FILE" "$WEB_BIND" "$port" <<'PY' +import pathlib +import sys +import urllib.parse + +token_path = pathlib.Path(sys.argv[1]) +web_bind = sys.argv[2] +web_port = sys.argv[3] +root_url = f"http://{web_bind}:{web_port}/" +token = token_path.read_text(encoding="utf-8").strip() + +if not token: + print(root_url) +else: + print(f"{root_url}?token={urllib.parse.quote(token)}") +PY +} + +focus_existing_window() { + local window_id="" + + if ! command -v xdotool >/dev/null 2>&1; then + return 1 + fi + + window_id="$(xdotool search --onlyvisible --class "$APP_ID" 2>/dev/null | tail -n 1 || true)" + if [ -z "$window_id" ]; then + return 1 + fi + + xdotool windowactivate "$window_id" >/dev/null 2>&1 || true + return 0 +} + +launch_app_window() { + local browser_cmd="" + local port="" + local url="" + + ensure_server + port="$ENSURE_SERVER_PORT" + url="$(auth_url "$port")" + + if [ "$ENSURE_SERVER_ACTION" = "reused" ] && focus_existing_window; then + log "Focused existing app window" + return 0 + fi + + browser_cmd="$(resolve_browser_command || true)" + if [ -z "$browser_cmd" ]; then + log "No supported app browser found; falling back to URL open" + if ! open_url "$url"; then + notify_failure "Could not open your browser. Open ${url} manually." + printf 'Open this URL manually: %s\n' "$url" >&2 + return 1 + fi + return 0 + fi + + log "Launching app window via ${browser_cmd}" + exec "$browser_cmd" \ + --no-first-run \ + --no-default-browser-check \ + --new-window \ + --class="$APP_ID" \ + --user-data-dir="$PROFILE_DIR" \ + --app="$url" +} + +launch_browser_mode() { + local port="" + local url="" + + ensure_server + port="$ENSURE_SERVER_PORT" + url="$(auth_url "$port")" + log "Opening browser tab" + + if ! open_url "$url"; then + notify_failure "Could not open your browser. Open ${url} manually." + printf 'Open this URL manually: %s\n' "$url" >&2 + return 1 + fi +} + +status() { + local runtime_cmd="" + local browser_cmd="" + local port="" + local pid="" + + runtime_cmd="$(resolve_runtime_command 2>/dev/null || true)" + browser_cmd="$(resolve_browser_command 2>/dev/null || true)" + port="$(verified_runtime_port 2>/dev/null || runtime_port 2>/dev/null || saved_port 2>/dev/null || true)" + pid="$(verified_runtime_pid 2>/dev/null || runtime_pid 2>/dev/null || true)" + + printf 'app=%s\n' "$APP_NAME" + printf 'provider=%s\n' "$PROVIDER" + printf 'runtime_command=%s\n' "$runtime_cmd" + printf 'browser_command=%s\n' "$browser_cmd" + printf 'state_dir=%s\n' "$STATE_DIR" + printf 'cache_dir=%s\n' "$CACHE_DIR" + printf 'config_dir=%s\n' "$CONFIG_DIR" + printf 'runtime_pid=%s\n' "$pid" + printf 'runtime_port=%s\n' "$port" + + if [ -n "$port" ] && is_unsigned_int "$port" && server_healthy "$port"; then + printf 'healthy=yes\n' + else + printf 'healthy=no\n' + fi +} + +doctor() { + status + printf 'token_file=%s\n' "$TOKEN_FILE" + printf 'runtime_file=%s\n' "$RUNTIME_FILE" + printf 'runtime_fingerprint_file=%s\n' "$RUNTIME_FINGERPRINT_FILE" + printf 'launcher_log=%s\n' "$LAUNCHER_LOG" + printf 'web_log=%s\n' "$WEB_LOG" +} + +case "${1:-}" in + --browser) + shift + launch_browser_mode "$@" + ;; + --status) + status + ;; + --doctor) + doctor + ;; + --stop) + stop_server + ;; + --help|-h|help) + usage + ;; + --version|-v|version) + printf '%s\n' "$APP_VERSION" + ;; + "") + launch_app_window + ;; + *) + printf 'Unknown argument: %s\n\n' "${1:-}" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/packaging/deb/README.md b/packaging/deb/README.md new file mode 100644 index 0000000..888a68d --- /dev/null +++ b/packaging/deb/README.md @@ -0,0 +1,3 @@ +# packaging/deb + +Debian packaging templates for the launcher-first v1. diff --git a/packaging/deb/control.in b/packaging/deb/control.in new file mode 100644 index 0000000..1d678b3 --- /dev/null +++ b/packaging/deb/control.in @@ -0,0 +1,9 @@ +Package: codex-ubuntu +Version: __VERSION__ +Section: devel +Priority: optional +Architecture: all +Maintainer: codex-ubuntu contributors +Depends: bash, curl, python3, xdg-utils +Description: Unofficial Ubuntu launcher for Codex + A launcher-first Ubuntu desktop integration package for Codex. diff --git a/packaging/deb/postinst b/packaging/deb/postinst new file mode 100755 index 0000000..1017b55 --- /dev/null +++ b/packaging/deb/postinst @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +update-desktop-database /usr/share/applications >/dev/null 2>&1 || true +gtk-update-icon-cache -f -t /usr/share/icons/hicolor >/dev/null 2>&1 || true diff --git a/packaging/deb/postrm b/packaging/deb/postrm new file mode 100755 index 0000000..1017b55 --- /dev/null +++ b/packaging/deb/postrm @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +update-desktop-database /usr/share/applications >/dev/null 2>&1 || true +gtk-update-icon-cache -f -t /usr/share/icons/hicolor >/dev/null 2>&1 || true diff --git a/providers/README.md b/providers/README.md new file mode 100644 index 0000000..94c5866 --- /dev/null +++ b/providers/README.md @@ -0,0 +1,12 @@ +# providers + +Runtime/provider contract docs live here. + +Implemented now: + +- `browser-shell` + +Planned: + +- `app-server` +- `desktop-payload` diff --git a/providers/browser-shell.md b/providers/browser-shell.md new file mode 100644 index 0000000..943002e --- /dev/null +++ b/providers/browser-shell.md @@ -0,0 +1,67 @@ +# browser-shell provider + +## Status + +Implemented + +## Runtime + +The current implementation expects a runtime command that supports: + +```text +codex-app-linux web --bind --port --token-file +``` + +The launcher does not bless a runtime by filename alone. + +Instead, a runtime becomes manageable when: + +1. it starts with the expected CLI shape +2. it writes valid runtime metadata +3. the live process arguments match the launcher expectations +4. the launcher captures and persists a process fingerprint for later reuse and stop checks + +## Runtime metadata + +The provider is expected to write a JSON file at: + +```text +.runtime +``` + +Expected fields: + +- `pid` +- `bind` +- `port` +- `startedAt` +- `tokenFile` +- `authDisabled` + +For the `browser-shell` provider, `tokenFile` is treated as required in practice because the launcher uses it as part of the ownership check. + +## Launcher fingerprint + +After a healthy launch, the launcher writes a managed fingerprint file at: + +```text +${XDG_STATE_HOME:-$HOME/.local/state}/codex-ubuntu/runtime.fingerprint.json +``` + +That fingerprint is required for later reuse and stop behavior. A merely healthy loopback service without a matching launcher fingerprint is treated as unverified and will not be reused. + +## Health check + +The launcher currently checks: + +```text +http://:/__webstrapper/healthz +``` + +## Stop model + +The launcher will only stop the runtime when: + +1. runtime metadata is valid +2. `/proc//cmdline` still matches the expected bind, port, and token file +3. the live process still matches the stored launcher fingerprint diff --git a/providers/contract.md b/providers/contract.md new file mode 100644 index 0000000..669447d --- /dev/null +++ b/providers/contract.md @@ -0,0 +1,59 @@ +# Provider contract + +## Purpose + +The launcher needs one stable contract so runtime strategies can change without rewriting process-safety logic every time. + +## Required launcher-facing capabilities + +Every implemented provider must define: + +1. how it starts +2. how it publishes runtime metadata +3. how it is health-checked +4. how launcher ownership is verified +5. how it should be stopped +6. how launcher-managed provenance is persisted across restarts + +## Required runtime metadata + +Structured metadata must be written to a provider-known path. + +Required fields: + +- `pid` +- `bind` +- `port` +- `startedAt` + +Recommended fields: + +- `provider` +- `tokenFile` +- `authDisabled` + +Providers that expect safe reuse across launcher restarts should also support a launcher-managed provenance record, such as a persisted process fingerprint derived from the live runtime. + +## Ownership verification rules + +The launcher must verify: + +1. the metadata file is parseable +2. the PID is live +3. the process command line matches the expected provider arguments +4. the bind/port values match launcher expectations +5. the token file matches when token auth is used +6. the live process matches the last trusted launcher-managed provenance record + +If any of these checks fail, the launcher must not kill the process. + +## Provider statuses + +### Implemented + +- `browser-shell` + +### Planned + +- `app-server` +- `desktop-payload` diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..e8abe6e --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,3 @@ +# scripts + +Developer automation lives here. diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100755 index 0000000..91f713f --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +VERSION="$(tr -d '[:space:]' <"${REPO_DIR}/VERSION")" +DIST_DIR="${REPO_DIR}/dist" +PKGROOT="${DIST_DIR}/pkgroot" +DEBIAN_DIR="${PKGROOT}/DEBIAN" +PACKAGE_PATH="${DIST_DIR}/codex-ubuntu_${VERSION}_all.deb" + +command -v dpkg-deb >/dev/null 2>&1 || { + printf 'dpkg-deb is required to build the Debian package.\n' >&2 + exit 1 +} + +rm -rf "$PKGROOT" +mkdir -p \ + "$DEBIAN_DIR" \ + "${PKGROOT}/usr/bin" \ + "${PKGROOT}/usr/share/applications" \ + "${PKGROOT}/usr/share/icons/hicolor/scalable/apps" + +install -m 755 "${REPO_DIR}/launcher/codex-ubuntu" "${PKGROOT}/usr/bin/codex-ubuntu" +install -m 644 "${REPO_DIR}/desktop/codex-ubuntu.svg" "${PKGROOT}/usr/share/icons/hicolor/scalable/apps/codex-ubuntu.svg" +"${REPO_DIR}/scripts/render-desktop-file.sh" "/usr/bin/codex-ubuntu" "codex-ubuntu" "${PKGROOT}/usr/share/applications/codex-ubuntu.desktop" + +sed "s|__VERSION__|${VERSION}|g" "${REPO_DIR}/packaging/deb/control.in" >"${DEBIAN_DIR}/control" +install -m 755 "${REPO_DIR}/packaging/deb/postinst" "${DEBIAN_DIR}/postinst" +install -m 755 "${REPO_DIR}/packaging/deb/postrm" "${DEBIAN_DIR}/postrm" + +mkdir -p "$DIST_DIR" +dpkg-deb --build "$PKGROOT" "$PACKAGE_PATH" >/dev/null + +printf 'Built %s\n' "$PACKAGE_PATH" diff --git a/scripts/install-local.sh b/scripts/install-local.sh new file mode 100755 index 0000000..9c22017 --- /dev/null +++ b/scripts/install-local.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +BIN_DIR="${HOME}/.local/bin" +APP_DIR="${HOME}/.local/share/applications" +ICON_DIR="${HOME}/.local/share/icons/hicolor/scalable/apps" + +mkdir -p "$BIN_DIR" "$APP_DIR" "$ICON_DIR" + +install -m 755 "${REPO_DIR}/launcher/codex-ubuntu" "${BIN_DIR}/codex-ubuntu" +install -m 644 "${REPO_DIR}/desktop/codex-ubuntu.svg" "${ICON_DIR}/codex-ubuntu.svg" +"${REPO_DIR}/scripts/render-desktop-file.sh" "${BIN_DIR}/codex-ubuntu" "codex-ubuntu" "${APP_DIR}/codex-ubuntu.desktop" + +update-desktop-database "${APP_DIR}" >/dev/null 2>&1 || true +gtk-update-icon-cache -f -t "${HOME}/.local/share/icons/hicolor" >/dev/null 2>&1 || true + +printf 'Installed local launcher to %s\n' "${BIN_DIR}/codex-ubuntu" diff --git a/scripts/render-desktop-file.sh b/scripts/render-desktop-file.sh new file mode 100755 index 0000000..bf96258 --- /dev/null +++ b/scripts/render-desktop-file.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 3 ]; then + printf 'Usage: %s \n' "$0" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +TEMPLATE="${REPO_DIR}/desktop/codex-ubuntu.desktop.in" +EXEC_PATH="$1" +ICON_NAME="$2" +OUTPUT_PATH="$3" + +sed \ + -e "s|__EXEC__|${EXEC_PATH}|g" \ + -e "s|__ICON__|${ICON_NAME}|g" \ + "$TEMPLATE" >"$OUTPUT_PATH" diff --git a/tests/fixtures/fake_browser.sh b/tests/fixtures/fake_browser.sh new file mode 100755 index 0000000..5548664 --- /dev/null +++ b/tests/fixtures/fake_browser.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +[ -n "${CODEX_UBUNTU_TEST_BROWSER_LOG:-}" ] || { + printf 'CODEX_UBUNTU_TEST_BROWSER_LOG is required\n' >&2 + exit 1 +} + +printf '%s\n' "$*" >>"$CODEX_UBUNTU_TEST_BROWSER_LOG" diff --git a/tests/fixtures/fake_codex_app_linux.py b/tests/fixtures/fake_codex_app_linux.py new file mode 100755 index 0000000..aa00139 --- /dev/null +++ b/tests/fixtures/fake_codex_app_linux.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +import json +import os +import signal +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def usage() -> int: + sys.stderr.write("usage: fake_codex_app_linux.py web --bind --port --token-file \n") + return 1 + + +def parse_web_args(argv: list[str]) -> dict[str, str]: + bind = "127.0.0.1" + port = None + token_file = None + + index = 0 + while index < len(argv): + arg = argv[index] + if arg == "--bind" and index + 1 < len(argv): + bind = argv[index + 1] + index += 2 + elif arg == "--port" and index + 1 < len(argv): + port = argv[index + 1] + index += 2 + elif arg == "--token-file" and index + 1 < len(argv): + token_file = argv[index + 1] + index += 2 + else: + index += 1 + + if port is None or token_file is None: + raise ValueError("missing required args") + + return {"bind": bind, "port": port, "token_file": token_file} + + +class Handler(BaseHTTPRequestHandler): + token = "" + + def do_GET(self) -> None: + if self.path == "/__webstrapper/healthz": + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"ok") + return + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + body = f"fake runtime token={self.token}".encode("utf-8") + self.wfile.write(body) + + def log_message(self, fmt: str, *args: object) -> None: + sys.stdout.write((fmt % args) + "\n") + + +def main() -> int: + if len(sys.argv) >= 2 and sys.argv[1] in {"--version", "-v", "version"}: + sys.stdout.write("fake-launcher.1\n") + return 0 + + if len(sys.argv) < 2 or sys.argv[1] != "web": + return usage() + + try: + config = parse_web_args(sys.argv[2:]) + except ValueError: + return usage() + + bind = config["bind"] + port = int(config["port"]) + token_file = config["token_file"] + runtime_file = f"{token_file}.runtime" + + os.makedirs(os.path.dirname(token_file), exist_ok=True) + token = "fake-token" + with open(token_file, "w", encoding="utf-8") as handle: + handle.write(token) + + with open(runtime_file, "w", encoding="utf-8") as handle: + json.dump( + { + "bind": bind, + "port": port, + "tokenFile": token_file, + "authDisabled": False, + "pid": os.getpid(), + "startedAt": int(time.time() * 1000), + }, + handle, + ) + handle.write("\n") + + server = ThreadingHTTPServer((bind, port), Handler) + Handler.token = token + shutting_down = threading.Event() + + def shutdown(_signum: int, _frame: object) -> None: + if shutting_down.is_set(): + return + shutting_down.set() + try: + os.unlink(runtime_file) + except FileNotFoundError: + pass + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + try: + server.serve_forever() + finally: + server.server_close() + try: + os.unlink(runtime_file) + except FileNotFoundError: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/fake_unverified_health_server.py b/tests/fixtures/fake_unverified_health_server.py new file mode 100755 index 0000000..56c30f4 --- /dev/null +++ b/tests/fixtures/fake_unverified_health_server.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +import signal +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def usage() -> int: + sys.stderr.write("usage: fake_unverified_health_server.py --bind --port \n") + return 1 + + +def parse_args(argv: list[str]) -> tuple[str, int]: + bind = "127.0.0.1" + port = None + + index = 0 + while index < len(argv): + arg = argv[index] + if arg == "--bind" and index + 1 < len(argv): + bind = argv[index + 1] + index += 2 + elif arg == "--port" and index + 1 < len(argv): + port = int(argv[index + 1]) + index += 2 + else: + index += 1 + + if port is None: + raise ValueError("missing port") + + return bind, port + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path == "/__webstrapper/healthz": + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"ok") + return + + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"unverified") + + def log_message(self, fmt: str, *args: object) -> None: + return + + +def main() -> int: + try: + bind, port = parse_args(sys.argv[1:]) + except ValueError: + return usage() + + server = ThreadingHTTPServer((bind, port), Handler) + shutting_down = threading.Event() + + def shutdown(_signum: int, _frame: object) -> None: + if shutting_down.is_set(): + return + shutting_down.set() + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + try: + server.serve_forever() + finally: + server.server_close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/xdotool b/tests/fixtures/xdotool new file mode 100755 index 0000000..b602218 --- /dev/null +++ b/tests/fixtures/xdotool @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -n "${CODEX_UBUNTU_TEST_XDOTOOL_LOG:-}" ]; then + printf '%s\n' "$*" >>"$CODEX_UBUNTU_TEST_XDOTOOL_LOG" +fi + +case "${1:-}" in + search) + if [ -n "${CODEX_UBUNTU_TEST_XDOTOOL_WINDOW_ID:-}" ]; then + printf '%s\n' "$CODEX_UBUNTU_TEST_XDOTOOL_WINDOW_ID" + exit 0 + fi + exit 1 + ;; + windowactivate) + exit 0 + ;; + *) + exit 0 + ;; +esac diff --git a/tests/launcher_smoke.sh b/tests/launcher_smoke.sh new file mode 100755 index 0000000..f39fb1b --- /dev/null +++ b/tests/launcher_smoke.sh @@ -0,0 +1,363 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +LAUNCHER="${REPO_DIR}/launcher/codex-ubuntu" +FAKE_RUNTIME="${REPO_DIR}/tests/fixtures/fake_codex_app_linux.py" +FAKE_BROWSER="${REPO_DIR}/tests/fixtures/fake_browser.sh" +FAKE_HEALTH_SERVER="${REPO_DIR}/tests/fixtures/fake_unverified_health_server.py" +FAKE_XDOTOOL_DIR="${REPO_DIR}/tests/fixtures" +TEST_TMPDIRS=() +TEST_PIDS=() + +assert_eq() { + local expected="$1" + local actual="$2" + local message="$3" + if [ "$expected" != "$actual" ]; then + printf 'assert_eq failed: %s (expected=%s actual=%s)\n' "$message" "$expected" "$actual" >&2 + exit 1 + fi +} + +assert_contains() { + local file="$1" + local needle="$2" + if ! grep -Fq -- "$needle" "$file"; then + printf 'assert_contains failed: %s missing %s\n' "$file" "$needle" >&2 + exit 1 + fi +} + +assert_not_contains() { + local file="$1" + local needle="$2" + if grep -Fq -- "$needle" "$file"; then + printf 'assert_not_contains failed: %s unexpectedly contained %s\n' "$file" "$needle" >&2 + exit 1 + fi +} + +assert_ne() { + local left="$1" + local right="$2" + local message="$3" + if [ "$left" = "$right" ]; then + printf 'assert_ne failed: %s (left=%s right=%s)\n' "$message" "$left" "$right" >&2 + exit 1 + fi +} + +register_tmpdir() { + TEST_TMPDIRS+=("$1") +} + +register_pid() { + TEST_PIDS+=("$1") +} + +cleanup_test_artifacts() { + local pid="" + local tmpdir="" + + for pid in "${TEST_PIDS[@]}"; do + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" 2>/dev/null || true + done + + for tmpdir in "${TEST_TMPDIRS[@]}"; do + if [ -d "$tmpdir" ]; then + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" --stop >/dev/null 2>&1 || true + rm -rf "$tmpdir" + fi + done +} + +pick_test_port() { + python3 - <<'PY' +import socket + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +read_runtime_field() { + local runtime_file="$1" + local field="$2" + + python3 - <<'PY' "$runtime_file" "$field" +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as handle: + print(json.load(handle)[sys.argv[2]]) +PY +} + +test_browser_launch_creates_verified_state() { + local tmpdir browser_log requested_port runtime_pid runtime_port pid_file port_file runtime_file fingerprint_file + + tmpdir="$(mktemp -d)" + register_tmpdir "$tmpdir" + browser_log="${tmpdir}/browser.log" + requested_port="$(pick_test_port)" + + CODEX_UBUNTU_APP_LINUX_CMD="$FAKE_RUNTIME" \ + CODEX_UBUNTU_BROWSER="$FAKE_BROWSER" \ + CODEX_UBUNTU_TEST_BROWSER_LOG="$browser_log" \ + CODEX_UBUNTU_PORT="$requested_port" \ + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" + + runtime_file="${tmpdir}/state/codex-ubuntu/token.runtime" + fingerprint_file="${tmpdir}/state/codex-ubuntu/runtime.fingerprint.json" + pid_file="${tmpdir}/state/codex-ubuntu/web.pid" + port_file="${tmpdir}/state/codex-ubuntu/web.port" + + for _ in $(seq 1 40); do + [ -f "$runtime_file" ] && break + sleep 0.1 + done + + runtime_pid="$(read_runtime_field "$runtime_file" pid)" + runtime_port="$(read_runtime_field "$runtime_file" port)" + + assert_eq "$runtime_pid" "$(tr -d '[:space:]' <"$pid_file")" "runtime pid should be synced" + assert_eq "$runtime_port" "$(tr -d '[:space:]' <"$port_file")" "port should be persisted" + [ -f "$fingerprint_file" ] || { + printf 'runtime fingerprint file was not created\n' >&2 + exit 1 + } + assert_contains "$browser_log" "--class=codex-ubuntu" + assert_contains "$browser_log" "--app=http://127.0.0.1:${runtime_port}/?token=fake-token" + + CODEX_UBUNTU_APP_LINUX_CMD="$FAKE_RUNTIME" \ + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" --stop +} + +test_stop_does_not_kill_unrelated_process() { + local tmpdir sleeper + + tmpdir="$(mktemp -d)" + register_tmpdir "$tmpdir" + mkdir -p "${tmpdir}/state/codex-ubuntu" "${tmpdir}/cache" "${tmpdir}/config" + sleep 30 & + sleeper="$!" + printf '%s\n' "$sleeper" >"${tmpdir}/state/codex-ubuntu/web.pid" + + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" --stop + + if ! kill -0 "$sleeper" >/dev/null 2>&1; then + printf 'stop killed unrelated process\n' >&2 + exit 1 + fi + + kill "$sleeper" >/dev/null 2>&1 || true + wait "$sleeper" 2>/dev/null || true +} + +test_stop_verified_runtime_removes_state() { + local tmpdir requested_port runtime_file runtime_pid + + tmpdir="$(mktemp -d)" + register_tmpdir "$tmpdir" + requested_port="$(pick_test_port)" + + CODEX_UBUNTU_APP_LINUX_CMD="$FAKE_RUNTIME" \ + CODEX_UBUNTU_PORT="$requested_port" \ + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" --browser >/dev/null 2>&1 || true + + runtime_file="${tmpdir}/state/codex-ubuntu/token.runtime" + for _ in $(seq 1 40); do + [ -f "$runtime_file" ] && break + sleep 0.1 + done + + runtime_pid="$(read_runtime_field "$runtime_file" pid)" + + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" --stop + + if kill -0 "$runtime_pid" >/dev/null 2>&1; then + printf 'verified runtime is still alive after stop\n' >&2 + exit 1 + fi + + if [ -e "${tmpdir}/state/codex-ubuntu/token.runtime" ]; then + printf 'runtime metadata still exists after stop\n' >&2 + exit 1 + fi +} + +test_unverified_healthy_server_is_not_reused() { + local tmpdir browser_log requested_port runtime_file runtime_port health_pid + + tmpdir="$(mktemp -d)" + register_tmpdir "$tmpdir" + browser_log="${tmpdir}/browser.log" + requested_port="$(pick_test_port)" + mkdir -p "${tmpdir}/state/codex-ubuntu" "${tmpdir}/cache" "${tmpdir}/config" + printf '%s\n' "$requested_port" >"${tmpdir}/state/codex-ubuntu/web.port" + printf 'stale-secret-token\n' >"${tmpdir}/state/codex-ubuntu/token" + + python3 "$FAKE_HEALTH_SERVER" --bind 127.0.0.1 --port "$requested_port" >/dev/null 2>&1 & + health_pid="$!" + register_pid "$health_pid" + + for _ in $(seq 1 40); do + if curl -fsS "http://127.0.0.1:${requested_port}/__webstrapper/healthz" >/dev/null 2>&1; then + break + fi + sleep 0.1 + done + + CODEX_UBUNTU_APP_LINUX_CMD="$FAKE_RUNTIME" \ + CODEX_UBUNTU_BROWSER="$FAKE_BROWSER" \ + CODEX_UBUNTU_TEST_BROWSER_LOG="$browser_log" \ + CODEX_UBUNTU_PORT="$requested_port" \ + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" + + runtime_file="${tmpdir}/state/codex-ubuntu/token.runtime" + for _ in $(seq 1 40); do + [ -f "$runtime_file" ] && break + sleep 0.1 + done + + runtime_port="$(read_runtime_field "$runtime_file" port)" + + assert_ne "$requested_port" "$runtime_port" "launcher should not reuse unverified healthy server" + assert_contains "$browser_log" "--app=http://127.0.0.1:${runtime_port}/?token=fake-token" + assert_not_contains "$browser_log" "stale-secret-token" + assert_not_contains "$browser_log" "--app=http://127.0.0.1:${requested_port}/?token=stale-secret-token" + + CODEX_UBUNTU_APP_LINUX_CMD="$FAKE_RUNTIME" \ + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" --stop +} + +test_compatible_runtime_is_stoppable() { + local tmpdir browser_log requested_port runtime_file runtime_pid compatible_runtime + + tmpdir="$(mktemp -d)" + register_tmpdir "$tmpdir" + browser_log="${tmpdir}/browser.log" + requested_port="$(pick_test_port)" + compatible_runtime="${tmpdir}/compatible_runtime.py" + cp "$FAKE_RUNTIME" "$compatible_runtime" + chmod +x "$compatible_runtime" + + CODEX_UBUNTU_APP_LINUX_CMD="$compatible_runtime" \ + CODEX_UBUNTU_BROWSER="$FAKE_BROWSER" \ + CODEX_UBUNTU_TEST_BROWSER_LOG="$browser_log" \ + CODEX_UBUNTU_PORT="$requested_port" \ + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" + + runtime_file="${tmpdir}/state/codex-ubuntu/token.runtime" + for _ in $(seq 1 40); do + [ -f "$runtime_file" ] && break + sleep 0.1 + done + + runtime_pid="$(read_runtime_field "$runtime_file" pid)" + + CODEX_UBUNTU_APP_LINUX_CMD="$compatible_runtime" \ + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" --stop + + if kill -0 "$runtime_pid" >/dev/null 2>&1; then + printf 'compatible runtime is still alive after stop\n' >&2 + exit 1 + fi + + if [ -e "$runtime_file" ]; then + printf 'compatible runtime metadata still exists after stop\n' >&2 + exit 1 + fi +} + +test_fresh_runtime_relaunches_even_if_window_exists() { + local tmpdir browser_log requested_port_one requested_port_two + + tmpdir="$(mktemp -d)" + register_tmpdir "$tmpdir" + browser_log="${tmpdir}/browser.log" + requested_port_one="$(pick_test_port)" + requested_port_two="$(pick_test_port)" + + PATH="${FAKE_XDOTOOL_DIR}:$PATH" \ + CODEX_UBUNTU_APP_LINUX_CMD="$FAKE_RUNTIME" \ + CODEX_UBUNTU_BROWSER="$FAKE_BROWSER" \ + CODEX_UBUNTU_TEST_BROWSER_LOG="$browser_log" \ + CODEX_UBUNTU_TEST_XDOTOOL_WINDOW_ID="4242" \ + CODEX_UBUNTU_PORT="$requested_port_one" \ + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" + + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" --stop + + PATH="${FAKE_XDOTOOL_DIR}:$PATH" \ + CODEX_UBUNTU_APP_LINUX_CMD="$FAKE_RUNTIME" \ + CODEX_UBUNTU_BROWSER="$FAKE_BROWSER" \ + CODEX_UBUNTU_TEST_BROWSER_LOG="$browser_log" \ + CODEX_UBUNTU_TEST_XDOTOOL_WINDOW_ID="4242" \ + CODEX_UBUNTU_PORT="$requested_port_two" \ + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" + + assert_eq "2" "$(wc -l <"$browser_log" | tr -d '[:space:]')" "fresh runtime should relaunch browser even if a window exists" + assert_contains "$browser_log" "--app=http://127.0.0.1:${requested_port_one}/?token=fake-token" + assert_contains "$browser_log" "--app=http://127.0.0.1:${requested_port_two}/?token=fake-token" + + XDG_CONFIG_HOME="${tmpdir}/config" \ + XDG_CACHE_HOME="${tmpdir}/cache" \ + XDG_STATE_HOME="${tmpdir}/state" \ + "$LAUNCHER" --stop +} + +chmod +x "$FAKE_RUNTIME" "$FAKE_BROWSER" "$FAKE_HEALTH_SERVER" "${FAKE_XDOTOOL_DIR}/xdotool" +trap cleanup_test_artifacts EXIT + +test_browser_launch_creates_verified_state +test_stop_does_not_kill_unrelated_process +test_stop_verified_runtime_removes_state +test_unverified_healthy_server_is_not_reused +test_compatible_runtime_is_stoppable +test_fresh_runtime_relaunches_even_if_window_exists + +printf '[INFO] launcher smoke tests passed\n' From a630796ddef4d581979586d9d0d97dfc6b73ad84 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Mon, 25 May 2026 01:31:16 +0200 Subject: [PATCH 2/2] Remove generated artwork from docs --- README.md | 4 --- assets/hero.svg | 64 --------------------------------------- assets/window-preview.svg | 51 ------------------------------- 3 files changed, 119 deletions(-) delete mode 100644 assets/hero.svg delete mode 100644 assets/window-preview.svg diff --git a/README.md b/README.md index 29d5dcd..185bef6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # codex-ubuntu -![Hero](assets/hero.svg) - [![Status](https://img.shields.io/badge/status-preview-orange)](docs/roadmap.md) [![Ubuntu](https://img.shields.io/badge/ubuntu-22.04%20%7C%2024.04-E95420)](docs/architecture.md) [![Packaging](https://img.shields.io/badge/package-.deb%20first-0E7490)](packaging/deb/README.md) @@ -39,8 +37,6 @@ The current repository is not just a design memo. It ships a working launcher fo - `.deb` build path - smoke tests and CI -![Preview](assets/window-preview.svg) - ## What it is not claiming This repository is not yet: diff --git a/assets/hero.svg b/assets/hero.svg deleted file mode 100644 index 3ac89d4..0000000 --- a/assets/hero.svg +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - codex-ubuntu - Ubuntu-first launcher and packaging project - Secure runtime checks - Debian path - Verified stop and reuse rules - Installer and package scaffolding - diff --git a/assets/window-preview.svg b/assets/window-preview.svg deleted file mode 100644 index 3fd1530..0000000 --- a/assets/window-preview.svg +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Public preview direction - Launcher-first, honest, Ubuntu-shaped - Future providers stay possible -