Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ on:
branches: [main]

jobs:
test-codex-plugin-lifecycle:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '22'

- name: Install supported Codex CLI
run: npm install --global @openai/codex@0.144.6

- name: Test real Codex plugin lifecycle
env:
CODEX_LIFECYCLE_EXPECTED_VERSION: 0.144.6
run: ./scripts/test-codex-plugin-lifecycle.sh

test-installation:
runs-on: ubuntu-latest
steps:
Expand Down
70 changes: 70 additions & 0 deletions scripts/install-codex.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ REPO_SLUG="${GOPHER_AI_REPO:-gopherguides/gopher-ai}"
REPO_REF="${GOPHER_AI_REF:-main}"
ARCHIVE_URL="${GOPHER_AI_ARCHIVE_URL:-https://codeload.github.com/${REPO_SLUG}/tar.gz/refs/heads/${REPO_REF}}"
BOOTSTRAP_DIR=""
CACHE_BACKUP_DIRS=()

usage() {
cat <<'EOF'
Expand Down Expand Up @@ -56,6 +57,13 @@ cleanup() {
if [[ -n "$BOOTSTRAP_DIR" && -d "$BOOTSTRAP_DIR" ]]; then
rm -rf "$BOOTSTRAP_DIR"
fi
local cache_backup_dir
for cache_backup_dir in "${CACHE_BACKUP_DIRS[@]-}"; do
[[ -n "$cache_backup_dir" ]] || continue
if [[ -d "$cache_backup_dir" ]]; then
rm -rf "$cache_backup_dir"
fi
done
}

trap cleanup EXIT
Expand Down Expand Up @@ -442,6 +450,48 @@ cache_root_matches_plugin() {
return 0
}

backup_published_plugin_roots() {
local marketplace_cache="$HOME/.codex/plugins/cache/gopher-ai"
CACHE_BACKUP_DIR=""
[[ -d "$marketplace_cache" ]] || return 0

local temp_base="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"
CACHE_BACKUP_DIR="$(mktemp -d "${temp_base%/}/gopher-ai-codex-cache.XXXXXX")"
CACHE_BACKUP_DIRS+=("$CACHE_BACKUP_DIR")

local plugin_cache plugin_name cache_root
for plugin_cache in "$marketplace_cache"/*; do
[[ -d "$plugin_cache" ]] || continue
plugin_name="$(basename "$plugin_cache")"
mkdir -p "$CACHE_BACKUP_DIR/$plugin_name"
for cache_root in "$plugin_cache"/*; do
[[ -d "$cache_root" ]] || continue
cp -R -p "$cache_root" "$CACHE_BACKUP_DIR/$plugin_name/"
done
done
}

restore_removed_plugin_roots() {
local backup_dir="$1"
[[ -n "$backup_dir" && -d "$backup_dir" ]] || return 0

local plugin_backup plugin_name plugin_cache backup_root version target_root
for plugin_backup in "$backup_dir"/*; do
[[ -d "$plugin_backup" ]] || continue
plugin_name="$(basename "$plugin_backup")"
plugin_cache="$HOME/.codex/plugins/cache/gopher-ai/$plugin_name"
for backup_root in "$plugin_backup"/*; do
[[ -d "$backup_root" ]] || continue
version="$(basename "$backup_root")"
mkdir -p "$plugin_cache"
target_root="$plugin_cache/$version"
rm -rf "${target_root:?}"
cp -R -p "$backup_root" "$target_root"
Comment on lines +488 to +489

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip restoring broken current cache roots

If a prior --user run was interrupted and left the current-version cache root incomplete, this restore path backs up that broken directory, lets codex plugin add recreate a good one, and then deletes the fresh target and copies the broken backup back over it. In that recovery scenario the installer exits successfully but the plugin remains missing files or executable hooks, so a normal rerun cannot heal the cache; only restore roots that were actually removed/superseded, or avoid overwriting an existing freshly installed target.

Useful? React with 👍 / 👎.

done
done
rm -rf "$backup_dir"
}

install_user_plugins() {
require_cmd codex
if ! codex plugin add --help >/dev/null 2>&1; then
Expand All @@ -456,13 +506,24 @@ install_user_plugins() {
return 1
fi

backup_published_plugin_roots
local cache_backup_dir="$CACHE_BACKUP_DIR"
local marketplace_status=0
set +e
if jq -e '.marketplaces[]? | select(.name == "gopher-ai" and .marketplaceSource != null)' \
>/dev/null <<<"$marketplaces"; then
echo "marketplace already registered — upgrading..."
codex plugin marketplace upgrade gopher-ai 2>&1 | sed 's/^/ /'
marketplace_status=${PIPESTATUS[0]}
else
echo "registering gopher-ai marketplace..."
codex plugin marketplace add "$REPO_SLUG" --ref "$REPO_REF" 2>&1 | sed 's/^/ /'
marketplace_status=${PIPESTATUS[0]}
fi
set -e
if [[ "$marketplace_status" -ne 0 ]]; then
restore_removed_plugin_roots "$cache_backup_dir"
return "$marketplace_status"
fi

local installed=0
Expand All @@ -473,9 +534,18 @@ install_user_plugins() {
local plugin_name
plugin_name="$(basename "$plugin_dir")"
echo "installing $plugin_name@gopher-ai..."
local add_status=0
set +e
codex plugin add "${plugin_name}@gopher-ai" 2>&1 | sed 's/^/ /'
add_status=${PIPESTATUS[0]}
set -e
if [[ "$add_status" -ne 0 ]]; then
restore_removed_plugin_roots "$cache_backup_dir"
return "$add_status"
fi
installed=$((installed + 1))
done
restore_removed_plugin_roots "$cache_backup_dir"

if [[ "$installed" -eq 0 ]]; then
echo "error: no Codex-capable plugins found in $ROOT_DIR/plugins." >&2
Expand Down
107 changes: 107 additions & 0 deletions scripts/test-codex-plugin-lifecycle-server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env python3

import argparse
import json
import threading
import time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path


class LifecycleHandler(SimpleHTTPRequestHandler):
request_count = 0
request_lock = threading.Lock()
state_dir = Path()

def do_POST(self):
if self.path != "/v1/responses":
self.send_error(404)
return

content_length = int(self.headers.get("Content-Length", "0"))
self.rfile.read(content_length)

with self.request_lock:
type(self).request_count += 1
request_number = type(self).request_count

self.state_dir.joinpath(f"{request_number}.requested").write_text(
self.path, encoding="utf-8"
)
release_path = self.state_dir / f"{request_number}.release"
deadline = time.monotonic() + 90
while not release_path.exists() and time.monotonic() < deadline:
time.sleep(0.05)

if not release_path.exists():
self.send_error(504, "response release timed out")
return

response_id = f"response_{request_number}"
events = [
{
"type": "response.created",
"response": {"id": response_id},
},
{
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": response_id,
"content": [
{"type": "output_text", "text": "lifecycle-ok"}
],
},
},
{
"type": "response.completed",
"response": {
"id": response_id,
"usage": {
"input_tokens": 0,
"input_tokens_details": None,
"output_tokens": 0,
"output_tokens_details": None,
"total_tokens": 0,
},
},
},
]
body = "".join(
f"event: {event['type']}\ndata: {json.dumps(event, separators=(',', ':'))}\n\n"
for event in events
).encode("utf-8")

self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--directory", required=True, type=Path)
parser.add_argument("--port-file", required=True, type=Path)
parser.add_argument("--state-dir", required=True, type=Path)
args = parser.parse_args()

args.state_dir.mkdir(parents=True, exist_ok=True)
LifecycleHandler.state_dir = args.state_dir

def handler(*handler_args, **handler_kwargs):
return LifecycleHandler(
*handler_args, directory=str(args.directory), **handler_kwargs
)

server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port_path = args.port_file.with_suffix(".tmp")
port_path.write_text(str(server.server_port), encoding="utf-8")
port_path.replace(args.port_file)
server.serve_forever()


if __name__ == "__main__":
main()
Loading
Loading