From 19326890475eedd496fe1bd286e48fda2ab1bbb7 Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Mon, 28 Sep 2026 15:49:49 -0400 Subject: [PATCH 1/3] feat(linux): systemd switchyard service Signed-off-by: Greg Clark --- Makefile | 16 +++ scripts/linux/common.sh | 45 ++++++++ scripts/linux/install.sh | 219 +++++++++++++++++++++++++++++++++++++ scripts/linux/uninstall.sh | 60 ++++++++++ 4 files changed, 340 insertions(+) create mode 100644 Makefile create mode 100644 scripts/linux/common.sh create mode 100755 scripts/linux/install.sh create mode 100755 scripts/linux/uninstall.sh diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..ff96e4506 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +.PHONY: install-linux install-linux-dry-run uninstall-linux + +## Install the Switchyard background server as a systemd user service. +install-linux: + @scripts/linux/install.sh + +## Print what install-linux would do, without changing anything. +install-linux-dry-run: + @scripts/linux/install.sh --dry-run + +## Remove the systemd user service, the sy Codex profile, and the codex alias. +uninstall-linux: + @scripts/linux/uninstall.sh diff --git a/scripts/linux/common.sh b/scripts/linux/common.sh new file mode 100644 index 000000000..886d14b8d --- /dev/null +++ b/scripts/linux/common.sh @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Paths, markers, and helpers shared by install.sh and uninstall.sh. +# The markers must match between the two, which is why they live here. + +SY_HOME="${SY_HOME:-$HOME/.switchyard}" +SY_PORT="${SY_PORT:-4123}" +SERVICE_NAME="switchyard.service" +SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" +CODEX_CONFIG="$CODEX_DIR/config.toml" +# Codex reads `--profile sy` from its own file next to config.toml. A +# [profiles.sy] table in config.toml is rejected outright as legacy config. +CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" +ALIAS_START="# >>> switchyard codex alias >>>" +ALIAS_END="# <<< switchyard codex alias <<<" +PROFILE_START="# >>> switchyard sy profile >>>" +PROFILE_END="# <<< switchyard sy profile <<<" + +say() { printf '%s\n' "$*"; } +step() { printf '\n==> %s\n' "$*"; } + +# Deletes the marked block, inclusive, leaving the rest of the file alone. +strip_block() { + local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" + if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then + return 1 + fi + if (( DRY_RUN )); then + say " would remove $label from $path" + return 0 + fi + local temp + temp="$(mktemp)" + awk -v start="$start" -v end="$end" ' + index($0, start) { skipping = 1 } + !skipping { print } + index($0, end) { skipping = 0 } + ' "$path" > "$temp" + cat "$temp" > "$path" + rm -f "$temp" + say " removed $label from $path" + return 0 +} diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh new file mode 100755 index 000000000..836f17a7c --- /dev/null +++ b/scripts/linux/install.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Installs the Switchyard background server as a systemd --user service, sets +# up a `sy` Codex profile, and aliases `codex` to use it. +# +# Every step is idempotent and never overwrites a file you have edited. +# Run with --dry-run to print what would happen. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +# shellcheck source=scripts/linux/common.sh +source "$SCRIPT_DIR/common.sh" + +# Runs a command, or prints it when dry running. +run() { + if (( DRY_RUN )); then + say " would run: $*" + else + "$@" + fi +} + +# Writes stdin to a file, leaving an existing file untouched. +write_once() { + local path="$1" + if [[ -f "$path" ]]; then + say " keeping existing $path" + cat >/dev/null + return + fi + if (( DRY_RUN )); then + say " would create $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " created $path" + fi +} + +# Writes stdin to a file, keeping any existing version as a timestamped backup. +write_with_backup() { + local path="$1" + if (( DRY_RUN )); then + [[ -f "$path" ]] && say " would back up $path" + say " would write $path" + cat >/dev/null + return + fi + mkdir -p "$(dirname "$path")" + local incoming + incoming="$(mktemp)" + cat > "$incoming" + if [[ -f "$path" ]] && cmp -s "$incoming" "$path"; then + say " $path is already up to date" + rm -f "$incoming" + return + fi + if [[ -f "$path" ]]; then + local backup + backup="$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" + cp "$path" "$backup" + say " backed up $path to $backup" + fi + cat "$incoming" > "$path" + rm -f "$incoming" + say " wrote $path" +} + +# Writes stdin to a file, replacing it. Used only for files this script owns. +write_always() { + local path="$1" + if (( DRY_RUN )); then + say " would write $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " wrote $path" + fi +} + +if [[ "$(uname -s)" != "Linux" ]]; then + say "This installer is for Linux only." >&2 + exit 1 +fi + +step "Building release binary" +run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" -p switchyard-server + +step "Installing binary into $SY_HOME/bin" +run mkdir -p "$SY_HOME/bin" "$SY_HOME/logs" +run install -m 755 "$REPO_ROOT/target/release/switchyard-server" "$SY_HOME/bin/switchyard-server" + +step "Writing server config" +# The composite router from examples/run_codex.sh: Terra classifies each user +# turn and sets the tier, Stage drives the tool loop underneath it. +write_once "$SY_HOME/composite.toml" <<'EOF' +schema_version = 1 + +[llm_clients.chatgpt_backend] +format = "openai_responses" +base_url = "https://chatgpt.com/backend-api/codex" +forward_auth = true + +[targets.capable] +id = "gpt-5.6-sol" +llm_client = "chatgpt_backend" + +[targets.efficient] +id = "gpt-5.6-luna" +llm_client = "chatgpt_backend" + +# chatgpt.com/backend-api/codex is Codex CLI's own private endpoint, not the +# public OpenAI Responses API. It 400s unless store=false and stream=true are +# set explicitly, and it rejects max_output_tokens outright, so the +# classifier's own token cap has to be dropped before the request goes out. +[targets.terra] +id = "gpt-5.6-terra" +llm_client = "chatgpt_backend" +extra_body = { store = false, stream = true } +omit_body_fields = ["max_output_tokens"] + +[routes.switchyard] +id = "switchyard" +type = "composite" + +[routes.switchyard.classifier] +target = "terra" +base_threshold = 0.5 +classify_trigger = "user_turn" + +[routes.switchyard.stage] +capable_target = "capable" +efficient_target = "efficient" +confidence_threshold = 0.5 +EOF + +step "Validating the server config" +if (( DRY_RUN )); then + say " would run: $SY_HOME/bin/switchyard-server --config $SY_HOME/composite.toml --dry-run" +else + "$SY_HOME/bin/switchyard-server" --config "$SY_HOME/composite.toml" --dry-run +fi + +step "Writing the systemd user service" +write_always "$SYSTEMD_USER_DIR/$SERVICE_NAME" <> "$rc" + say " added the codex alias to $rc" + fi +done + +step "Done" +say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" +say "Manage it with: systemctl --user {status,restart,stop} $SERVICE_NAME" +say "Open a new shell, or run: alias codex=\"codex --profile sy\"" +say "" +say "If you want the server running even when you are logged out, run:" +say " loginctl enable-linger \$USER" diff --git a/scripts/linux/uninstall.sh b/scripts/linux/uninstall.sh new file mode 100755 index 000000000..684dbf8b2 --- /dev/null +++ b/scripts/linux/uninstall.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Removes what install.sh added: the systemd user service, the `sy` Codex +# profile, and the codex alias. Your config, routing log, and binaries stay +# put; the paths are printed so you can delete them yourself. +# +# Run with --dry-run to print what would happen. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +# shellcheck source=scripts/linux/common.sh +source "$SCRIPT_DIR/common.sh" + +# Deletes a file this script owns. +remove_file() { + local path="$1" + if [[ ! -e "$path" ]]; then + say " nothing to remove at $path" + elif (( DRY_RUN )); then + say " would delete $path" + else + rm -f "$path" + say " deleted $path" + fi +} + +step "Stopping the systemd user service" +if (( DRY_RUN )); then + say " would run: systemctl --user disable --now $SERVICE_NAME" + say " would delete $SYSTEMD_USER_DIR/$SERVICE_NAME" +else + systemctl --user disable --now "$SERVICE_NAME" 2>/dev/null || true + rm -f "$SYSTEMD_USER_DIR/$SERVICE_NAME" + systemctl --user daemon-reload + say " stopped and removed $SERVICE_NAME" +fi + +step "Removing the sy Codex profile" +remove_file "$CODEX_PROFILE_CONFIG" +# Older installs of this script put the profile in config.toml instead. +strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || + say " no legacy profile in $CODEX_CONFIG" + +step "Removing the codex alias" +for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do + strip_block "$rc" "$ALIAS_START" "$ALIAS_END" "the codex alias" || + say " no alias in $rc" +done + +step "Done" +say "Left in place, delete them if you want:" +say " $SY_HOME (binary, config, routing log, logs)" +say " $CODEX_PROFILE_CONFIG.switchyard-backup.* (backups taken at install time)" From 51a6e80b172317cab2e160c98045f12421584715 Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Tue, 29 Sep 2026 10:30:35 -0400 Subject: [PATCH 2/3] chore: cleanup Signed-off-by: Greg Clark --- scripts/linux/common.sh | 3 --- scripts/linux/install.sh | 7 +++---- scripts/linux/uninstall.sh | 3 --- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/scripts/linux/common.sh b/scripts/linux/common.sh index 886d14b8d..12ad46f0f 100644 --- a/scripts/linux/common.sh +++ b/scripts/linux/common.sh @@ -9,14 +9,11 @@ SY_PORT="${SY_PORT:-4123}" SERVICE_NAME="switchyard.service" SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" -CODEX_CONFIG="$CODEX_DIR/config.toml" # Codex reads `--profile sy` from its own file next to config.toml. A # [profiles.sy] table in config.toml is rejected outright as legacy config. CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" ALIAS_START="# >>> switchyard codex alias >>>" ALIAS_END="# <<< switchyard codex alias <<<" -PROFILE_START="# >>> switchyard sy profile >>>" -PROFILE_END="# <<< switchyard sy profile <<<" say() { printf '%s\n' "$*"; } step() { printf '\n==> %s\n' "$*"; } diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh index 836f17a7c..b7dea2c45 100755 --- a/scripts/linux/install.sh +++ b/scripts/linux/install.sh @@ -180,10 +180,9 @@ fi step "Adding the sy Codex profile" # `codex --profile sy` reads ~/.codex/sy.config.toml. A [profiles.sy] table in -# config.toml is legacy config that Codex now refuses to start with, so an -# earlier install of this script has to be cleaned up first. -strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || true - +# config.toml is legacy config that Codex refuses to start with, so the +# profile lives in its own file instead. +# # This profile only changes which router answers. Approval and sandbox # settings are deliberately left out, so the profile cannot loosen how Codex # asks before it acts. Set those yourself if you want them. diff --git a/scripts/linux/uninstall.sh b/scripts/linux/uninstall.sh index 684dbf8b2..70bbe5f91 100755 --- a/scripts/linux/uninstall.sh +++ b/scripts/linux/uninstall.sh @@ -44,9 +44,6 @@ fi step "Removing the sy Codex profile" remove_file "$CODEX_PROFILE_CONFIG" -# Older installs of this script put the profile in config.toml instead. -strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || - say " no legacy profile in $CODEX_CONFIG" step "Removing the codex alias" for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do From 14b856fbee28aeffe467dce043a335c80b47a74b Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Wed, 30 Sep 2026 16:42:07 -0400 Subject: [PATCH 3/3] chore: review feedback Signed-off-by: Greg Clark --- INSTALLATION.md | 41 ++++++++ Makefile | 8 +- README.md | 2 +- scripts/linux/common.sh | 9 +- scripts/linux/install.sh | 62 ++++++------ scripts/linux/uninstall.sh | 31 +++--- tests/test_linux_install.py | 195 ++++++++++++++++++++++++++++++++++++ 7 files changed, 300 insertions(+), 48 deletions(-) create mode 100644 tests/test_linux_install.py diff --git a/INSTALLATION.md b/INSTALLATION.md index 6354f120f..15755f0c1 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -36,6 +36,47 @@ See [Getting Started](docs/getting_started.md#server-path) for a complete TOML deployment and [`switchyard-server`](crates/switchyard-server/README.md) for the configuration reference. +## Linux Codex Service + +This setup is for single-user Linux machines. It requires a systemd user +session, the Rust toolchain listed above, and Codex CLI 0.134.0 or newer +logged in with a ChatGPT account. Another user's process could take the local +port while the service is stopped and receive your login and prompts. + +From a checkout, preview or install the service: + +```bash +make install-linux-dry-run +make install-linux +systemctl --user status switchyard +codex -p sy +``` + +The installer builds and installs `~/.switchyard/bin/switchyard-server`. +It creates `~/.switchyard/composite.toml` if missing and keeps existing edits. +It replaces `~/.config/systemd/user/switchyard.service` and restarts the service. +It writes `~/.codex/sy.config.toml`, backing up a changed profile first. +It leaves shell rc files unchanged. Use `codex -p sy` to select the profile; +`codex login` and other management commands still work as usual. +The profile format requires [Codex 0.134.0 or newer](https://developers.openai.com/codex/config-advanced#profiles). +Remove any old `[profiles.sy]` table from `~/.codex/config.toml` before using it. + +Set `SY_HOME` or `SY_PORT` to change the server directory or port (default 4123). +`SY_HOME` must not contain whitespace, control characters, or a trailing +backslash; `SY_PORT` must contain only digits. `XDG_CONFIG_HOME` and `CODEX_HOME` +set the systemd and Codex config directories. + +Read server logs with `journalctl --user -u switchyard`. Routing records are +stored in `~/.switchyard/routing.jsonl`. Use `systemctl --user edit switchyard` +for service changes that survive reinstalling. + +Remove the service and profile with `make uninstall-linux`. This also removes +marked Codex aliases left by older installs from existing `.bashrc` and +`.zshrc` files. If upgrading an older install, uninstall first and run +`unalias codex` in any open shell. The server directory, routing records, +profile backups, and systemd drop-in files stay in place. Use the same path +overrides when installing and uninstalling. + ## Rust Libraries Add the crates needed by an embedded application: diff --git a/Makefile b/Makefile index ff96e4506..eadc87cff 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -.PHONY: install-linux install-linux-dry-run uninstall-linux +.DEFAULT_GOAL := help +.PHONY: help install-linux install-linux-dry-run uninstall-linux + +help: + @echo "install-linux Install the systemd user service and Codex profile" + @echo "install-linux-dry-run Preview installation without changes" + @echo "uninstall-linux Remove the service and Codex profile" ## Install the Switchyard background server as a systemd user service. install-linux: diff --git a/README.md b/README.md index 460ddb6a3..59963b09c 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ releases. Pin the version you integrate. | `switchyard-libsy` | **Beta** | Routing embedded in your own gateway or harness. You own model calls, credentials, and retries. | Trial integrations. API will change before v1.0. | | `switchyard-llm-client` | **Alpha** | HTTP model calls and protocol translation alongside libsy. | Experiments and pilots. | | `switchyard-runner` | **Alpha** | Running configured routes inside another runtime, such as NeMo Relay. | Integration work and supervised pilots. | -| `switchyard-server` | **Demo** | A standalone OpenAI- and Anthropic-compatible proxy. | Demos and evaluation only. Not for production. | +| `switchyard-server` | **Demo** | A standalone OpenAI- and Anthropic-compatible proxy, including a local Codex service. | Demos, evaluation, and personal use on single-user machines. Not for production. | ## Community and license diff --git a/scripts/linux/common.sh b/scripts/linux/common.sh index 12ad46f0f..25b16dd83 100644 --- a/scripts/linux/common.sh +++ b/scripts/linux/common.sh @@ -9,8 +9,8 @@ SY_PORT="${SY_PORT:-4123}" SERVICE_NAME="switchyard.service" SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" -# Codex reads `--profile sy` from its own file next to config.toml. A -# [profiles.sy] table in config.toml is rejected outright as legacy config. +# Since Codex 0.134.0, `--profile sy` reads sy.config.toml and fails if +# config.toml still has [profiles.sy]. CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" ALIAS_START="# >>> switchyard codex alias >>>" ALIAS_END="# <<< switchyard codex alias <<<" @@ -24,6 +24,10 @@ strip_block() { if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then return 1 fi + if ! grep -qF "$end" "$path"; then + say " $path has no end marker for $label; not editing it" >&2 + return 1 + fi if (( DRY_RUN )); then say " would remove $label from $path" return 0 @@ -34,6 +38,7 @@ strip_block() { index($0, start) { skipping = 1 } !skipping { print } index($0, end) { skipping = 0 } + END { if (skipping) exit 1 } ' "$path" > "$temp" cat "$temp" > "$path" rm -f "$temp" diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh index b7dea2c45..7bf19e422 100755 --- a/scripts/linux/install.sh +++ b/scripts/linux/install.sh @@ -2,10 +2,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Installs the Switchyard background server as a systemd --user service, sets -# up a `sy` Codex profile, and aliases `codex` to use it. +# Installs the Switchyard background server as a systemd --user service and sets +# up a `sy` Codex profile. # -# Every step is idempotent and never overwrites a file you have edited. +# Keeps existing composite.toml, replaces the service unit, and backs up +# sy.config.toml before replacing it. Use systemctl --user edit switchyard +# for service changes that survive reinstalling. # Run with --dry-run to print what would happen. set -euo pipefail @@ -14,11 +16,24 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" DRY_RUN=0 -[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 +case "$#:${1:-}" in + 0:) ;; + 1:--dry-run) DRY_RUN=1 ;; + *) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;; +esac # shellcheck source=scripts/linux/common.sh source "$SCRIPT_DIR/common.sh" +if [[ "$SY_HOME" =~ [[:space:][:cntrl:]] || "$SY_HOME" == *\\ ]]; then + say "SY_HOME must not contain whitespace, control characters, or a trailing backslash." >&2 + exit 1 +fi +if [[ ! "$SY_PORT" =~ ^[0-9]+$ ]]; then + say "SY_PORT must contain only digits." >&2 + exit 1 +fi + # Runs a command, or prints it when dry running. run() { if (( DRY_RUN )); then @@ -97,7 +112,7 @@ step "Building release binary" run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" -p switchyard-server step "Installing binary into $SY_HOME/bin" -run mkdir -p "$SY_HOME/bin" "$SY_HOME/logs" +run mkdir -p "$SY_HOME/bin" run install -m 755 "$REPO_ROOT/target/release/switchyard-server" "$SY_HOME/bin/switchyard-server" step "Writing server config" @@ -161,28 +176,22 @@ Type=simple ExecStart=$SY_HOME/bin/switchyard-server --config $SY_HOME/composite.toml --host 127.0.0.1 --port $SY_PORT --routing-log-file $SY_HOME/routing.jsonl Restart=on-failure Environment=RUST_LOG=info -StandardOutput=append:$SY_HOME/logs/server.log -StandardError=append:$SY_HOME/logs/server.err.log [Install] WantedBy=default.target EOF step "Loading the systemd user service" -if (( DRY_RUN )); then - say " would run: systemctl --user daemon-reload" - say " would run: systemctl --user enable --now $SERVICE_NAME" -else - systemctl --user daemon-reload - systemctl --user enable --now "$SERVICE_NAME" - say " enabled and started $SERVICE_NAME" +run systemctl --user daemon-reload +run systemctl --user enable "$SERVICE_NAME" +run systemctl --user restart "$SERVICE_NAME" +run sleep 2 +if (( ! DRY_RUN )) && ! systemctl --user is-active --quiet "$SERVICE_NAME"; then + say "Service failed to start. See: journalctl --user -u switchyard" >&2 + exit 1 fi step "Adding the sy Codex profile" -# `codex --profile sy` reads ~/.codex/sy.config.toml. A [profiles.sy] table in -# config.toml is legacy config that Codex refuses to start with, so the -# profile lives in its own file instead. -# # This profile only changes which router answers. Approval and sandbox # settings are deliberately left out, so the profile cannot loosen how Codex # asks before it acts. Set those yourself if you want them. @@ -197,22 +206,11 @@ wire_api = "responses" requires_openai_auth = true EOF -step "Aliasing codex" -for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do - if [[ -f "$rc" ]] && grep -qF "$ALIAS_START" "$rc"; then - say " alias already in $rc" - elif (( DRY_RUN )); then - say " would add the codex alias to $rc" - else - printf '\n%s\nalias codex="codex --profile sy"\n%s\n' "$ALIAS_START" "$ALIAS_END" >> "$rc" - say " added the codex alias to $rc" - fi -done - step "Done" -say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" +say "Server: http://127.0.0.1:$SY_PORT" +say "Logs: journalctl --user -u switchyard" say "Manage it with: systemctl --user {status,restart,stop} $SERVICE_NAME" -say "Open a new shell, or run: alias codex=\"codex --profile sy\"" +say "Use it with: codex -p sy (requires Codex CLI 0.134.0 or newer)" say "" say "If you want the server running even when you are logged out, run:" say " loginctl enable-linger \$USER" diff --git a/scripts/linux/uninstall.sh b/scripts/linux/uninstall.sh index 70bbe5f91..fe019ce27 100755 --- a/scripts/linux/uninstall.sh +++ b/scripts/linux/uninstall.sh @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 # # Removes what install.sh added: the systemd user service, the `sy` Codex -# profile, and the codex alias. Your config, routing log, and binaries stay +# profile, and any codex alias from an older install. Config and binaries stay # put; the paths are printed so you can delete them yourself. # # Run with --dry-run to print what would happen. @@ -13,7 +13,11 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DRY_RUN=0 -[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 +case "$#:${1:-}" in + 0:) ;; + 1:--dry-run) DRY_RUN=1 ;; + *) printf 'Usage: %s [--dry-run]\n' "$0" >&2; exit 2 ;; +esac # shellcheck source=scripts/linux/common.sh source "$SCRIPT_DIR/common.sh" @@ -31,6 +35,18 @@ remove_file() { fi } +step "Removing the sy Codex profile" +remove_file "$CODEX_PROFILE_CONFIG" + +step "Removing the codex alias" +for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do + if [[ -f "$rc" ]] && grep -qF "$ALIAS_START" "$rc"; then + strip_block "$rc" "$ALIAS_START" "$ALIAS_END" "the codex alias" + else + say " no alias in $rc" + fi +done + step "Stopping the systemd user service" if (( DRY_RUN )); then say " would run: systemctl --user disable --now $SERVICE_NAME" @@ -42,16 +58,7 @@ else say " stopped and removed $SERVICE_NAME" fi -step "Removing the sy Codex profile" -remove_file "$CODEX_PROFILE_CONFIG" - -step "Removing the codex alias" -for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do - strip_block "$rc" "$ALIAS_START" "$ALIAS_END" "the codex alias" || - say " no alias in $rc" -done - step "Done" say "Left in place, delete them if you want:" -say " $SY_HOME (binary, config, routing log, logs)" +say " $SY_HOME (binary, config, routing log)" say " $CODEX_PROFILE_CONFIG.switchyard-backup.* (backups taken at install time)" diff --git a/tests/test_linux_install.py b/tests/test_linux_install.py new file mode 100644 index 000000000..e41dde9f7 --- /dev/null +++ b/tests/test_linux_install.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +START = "# >>> switchyard codex alias >>>" +END = "# <<< switchyard codex alias <<<" +RC = f"before\n{START}\nalias codex='codex -p sy'\n{END}\nafter\n" + + +@pytest.fixture +def setup(tmp_path): + scripts = tmp_path / "repo" / "scripts" / "linux" + shutil.copytree(REPO / "scripts" / "linux", scripts) + home = tmp_path / "home" + home.mkdir() + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + env = { + **os.environ, + "HOME": str(home), + "SY_HOME": str(home / ".switchyard"), + "SY_PORT": "4123", + "XDG_CONFIG_HOME": str(home / ".config"), + "CODEX_HOME": str(home / ".codex"), + "TMPDIR": str(tmp_path), + "PATH": f"{bin_dir}:/usr/bin:/bin", + "SYSTEMCTL_LOG": str(tmp_path / "systemctl.log"), + "FAIL_SYSTEMCTL": "", + } + stubs = { + "cargo": "exit 0\n", + "uname": "echo Linux\n", + "sleep": "exit 0\n", + "install": 'printf "#!/bin/sh\\nexit 0\\n" > "$4"\nchmod +x "$4"\n', + "systemctl": 'echo "$*" >> "$SYSTEMCTL_LOG"\n' + '[[ "$*" != *"$FAIL_SYSTEMCTL"* || -z "$FAIL_SYSTEMCTL" ]]\n', + } + for name, body in stubs.items(): + stub = bin_dir / name + stub.write_text("#!/bin/bash\nset -eu\n" + body) + stub.chmod(0o755) + return scripts, home, bin_dir, env + + +def run(setup, script, *args): + scripts, _, _, env = setup + return subprocess.run( + ["bash", str(scripts / script), *args], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + +@pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"]) +@pytest.mark.parametrize("args", [["--dryrun"], ["--help"], ["-n"], ["--dry-run", "extra"], [""]]) +def test_bad_arguments_leave_files_unchanged(setup, script, args): + _, home, _, env = setup + rc = home / ".bashrc" + rc.write_text(RC) + result = run(setup, script, *args) + assert result.returncode == 2 + assert "Usage:" in result.stderr + assert rc.read_text() == RC + assert not Path(env["SYSTEMCTL_LOG"]).exists() + + +@pytest.mark.parametrize("failure", ["mktemp", "awk", "missing-end", "second-missing-end"]) +def test_uninstall_failure_preserves_shell_files(setup, failure): + _, home, bin_dir, _ = setup + contents = RC + if failure == "missing-end": + contents = RC.replace(END + "\n", "") + elif failure == "second-missing-end": + contents += f"{START}\nalias codex='codex -p sy'\ntail\n" + else: + (bin_dir / failure).write_text("#!/bin/bash\nexit 1\n") + (bin_dir / failure).chmod(0o755) + for name in [".zshrc", ".bashrc"]: + (home / name).write_text(contents) + result = run(setup, "uninstall.sh") + assert result.returncode != 0 + for name in [".zshrc", ".bashrc"]: + assert (home / name).read_text() == contents + + +def test_uninstall_cleans_profile_and_aliases_before_systemctl_failure(setup): + _, home, _, env = setup + env["FAIL_SYSTEMCTL"] = "--user" + profile = Path(env["CODEX_HOME"]) / "sy.config.toml" + profile.parent.mkdir() + profile.write_text("old profile\n") + for name in [".zshrc", ".bashrc"]: + (home / name).write_text(RC) + result = run(setup, "uninstall.sh") + assert result.returncode != 0 + assert not profile.exists() + for name in [".zshrc", ".bashrc"]: + assert (home / name).read_text() == "before\nafter\n" + + +@pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"]) +def test_dry_run_leaves_files_unchanged(setup, script): + _, home, _, env = setup + (home / ".bashrc").write_text(RC) + result = run(setup, script, "--dry-run") + assert result.returncode == 0, result.stderr + assert (home / ".bashrc").read_text() == RC + assert sorted(path.name for path in home.iterdir()) == [".bashrc"] + assert not Path(env["SYSTEMCTL_LOG"]).exists() + + +@pytest.mark.parametrize( + "key,value", + [ + ("SY_HOME", "/tmp/with space"), + ("SY_HOME", "/tmp/line\nbreak"), + ("SY_HOME", "/tmp/control\x01"), + ("SY_HOME", "/tmp/backslash\\"), + ("SY_PORT", "bad"), + ("SY_PORT", "4123\n"), + ], +) +def test_invalid_unit_values_fail_before_install(setup, key, value): + _, home, _, env = setup + env[key] = value + result = run(setup, "install.sh") + assert result.returncode != 0 + assert key in result.stderr + assert not list(home.iterdir()) + + +def test_reinstall_restarts_service_and_keeps_user_config(setup): + _, home, _, env = setup + (home / ".bashrc").write_text("user settings\n") + assert run(setup, "install.sh").returncode == 0 + config = Path(env["SY_HOME"]) / "composite.toml" + config.write_text("user config\n") + env["SY_PORT"] = "5000" + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + assert config.read_text() == "user config\n" + assert (home / ".bashrc").read_text() == "user settings\n" + assert not (home / ".zshrc").exists() + profile = Path(env["CODEX_HOME"]) / "sy.config.toml" + assert "127.0.0.1:5000/v1" in profile.read_text() + backups = list(profile.parent.glob("sy.config.toml.switchyard-backup.*")) + assert len(backups) == 1 + assert "127.0.0.1:4123/v1" in backups[0].read_text() + calls = Path(env["SYSTEMCTL_LOG"]).read_text().splitlines() + assert ( + calls + == [ + "--user daemon-reload", + "--user enable switchyard.service", + "--user restart switchyard.service", + "--user is-active --quiet switchyard.service", + ] + * 2 + ) + + +def test_failed_start_leaves_existing_profile_unchanged(setup): + _, _, _, env = setup + env["FAIL_SYSTEMCTL"] = "is-active" + profile = Path(env["CODEX_HOME"]) / "sy.config.toml" + profile.parent.mkdir() + profile.write_text("user profile\n") + result = run(setup, "install.sh") + assert result.returncode != 0 + assert "journalctl" in result.stderr + assert profile.read_text() == "user profile\n" + + +def test_default_make_only_prints_help(setup): + _, home, _, env = setup + result = subprocess.run( + ["make", "-f", str(REPO / "Makefile")], + cwd=REPO, + env=env, + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr + assert "install-linux-dry-run" in result.stdout + assert not list(home.iterdir())