# Headless laboratory and Observatory ## ChatGPT OAuth bridge (24 October 2026) The existing private `preylab-laboratory` plugin version 0.1.1 now targets `https://nysa.quest/preylab-mcp/mcp`. `headless/oauth-bridge.py` runs as the separate **PreyLab ChatGPT OAuth Bridge** scheduled task on loopback port 18772. Caddy forwards the MCP route and OAuth metadata/registration/authorization/token routes. The bridge follows the working Chult Diplomacy pattern: OAuth authorization code with S256 PKCE, an owner connection-code consent form, hashed durable OAuth tokens, and a separate private credential for calls to the original lab MCP at `127.0.0.1:18771/mcp`. The OAuth database is bound to the PreyLab resource and separate from Chult. Its files are under `C:/nq-private/preylab-chatgpt-mcp/` with ACLs limited to the host administrator, SYSTEM and Administrators. The owner connection code is in `owner-connection-code.txt`; never paste it into a chat or publish it. The original direct bearer MCP remains available for Codex. Both credentials resolve to the same PreyLab owner ID, so runs appear in the same owner-scoped Observatory. Revoking the bridge requires removing its dedicated lab token from the private lab config, restarting the runner, and stopping the bridge; connected OAuth sessions must also be revoked or the bridge stopped. The authenticated `lab_reference` MCP tool returns the generated family roster, gene bounds, DNA/environment/protocol schemas and two exact DNA examples. It is a fallback for ChatGPT web-fetch failures, not an alternate genome registry. Public DNS lookups through 1.1.1.1 and 8.8.8.8 both resolved `nysa.quest` to the site's address, and ordinary HTTPS fetched the manifest with 200 and JSON content. ChatGPT's web reader still reported a cache miss, so no site-header or DNS change was made without evidence of a site failure. `C:/Python312/python.exe scripts/verify-preylab-chatgpt-bridge.py` passed public HTTPS protected-resource and authorization-server discovery, DCR, consent, PKCE token exchange, MCP initialize, discovery of 14 tools and an actual gene-reference read. The optional `--run-trial` then validated, created, started, observed telemetry and preview for an actual 1,000-ms finite Foundation experiment through that OAuth bridge. Run `65f11c87-7fb8-42dc-85f2-3db2707022c6` completed at 61,000 simulated ms/revision 3 in the owner-scoped Observatory. The runner was idle before this trial. Five headless model tests and `npm run build` passed. A real ChatGPT sign-in and finite run remain the final host acceptance test; the published plugin file readback confirms the new URL and version. PreyLab's headless process runs the same JavaScript `stepPopulation` model used by the microscope. It runs on this Windows Lucee/MySQL host, listening on `127.0.0.1:18771`. Caddy proxies `https://nysa.quest/thefairiesquest/preylab/lab-api/` to that loopback process. The [Observatory](../lab-api/observatory) is the owner's read-only view of runs, with authenticated start, pause, speed and takeover controls. [OpenAPI](./headless-openapi.yaml) describes the HTTP interface. The private `preylab-laboratory` plugin exposes the same operations through MCP at `/lab-api/mcp`. ## Finite experiment The v1 envelope uses the existing Foundation trial builder **or** Environment v1, Protocol v2 and exact DNA v4 inoculum records. It accepts at most 32 explicitly placed cells, a 1–3,600 second simulated duration, and only 1× or 2× requested speed. The operator may lower the maximum and change the default in the private configuration; the service reports the effective limits in capabilities. All XYZ positions are normalized 0–1 world coordinates and checked by the existing body placement path. Creation is paused. The preview reports accepted/rejected placements and initial resources without stepping the dish. A stable 8–100 character `requestKey` makes create retries idempotent. ```json { "format": "preylab.lab-experiment", "schemaVersion": 1, "name": "Short scent trial", "foundationTrial": {"id": "competing-gradients"}, "durationMs": 30000, "speed": 1, "requestKey": "short-scent-trial-001" } ``` Use `POST /experiments/validate`, then `POST /experiments`, then `POST /runs/{id}/start` with a separate stable request key. `GET /runs/{id}/telemetry` and `GET /runs/{id}/preview` do not advance biology. The Observatory lists the same run ID, revision and simulated clock. ## Ownership and timing One run executes at a time; at most eight others queue without advancing. A 20 ms biological step is unchanged. The scheduler admits at most five steps per callback, caps pending debt, and reports requested/achieved speed and lag. It slows under load instead of skipping biology or catching up after downtime. Duration is finite simulated time. The current process starts jobs paused, queued or running; commands use stable request keys retained in the durable checkpoint. `cancel` marks the run completed and retains the dish. The browser writes a guarded Lucee archive checkpoint before transferring. The runner checks that browser's current write token, changes archive ownership to `runner`, and fences further browser writes. Browser handoff and takeover each require a stable request key so a lost response can be retried without transferring twice. Takeover checkpoints the current ordinary step, changes ownership to `browser`, and returns a fresh write token to the microscope. If that checkpoint fails, the runner restores the run's prior active state; an uncertain ownership write leaves it stopped for review rather than risking two writers. The browser cannot safely run while its dish is runner-owned. A failed handoff keeps its prior writer unless the response was lost; consult the Observatory before resuming in that case. An explicit global browser pause remains separate from a headless pause. After takeover, modern microscope saves send an exact `stateJson` string. Lucee validates its parsed state but archives the original numeric text and appends the server's stored handoff metadata. A client-supplied `headless` field is rejected; an older page using the legacy parsed-state save format receives a refresh-required conflict for a transferred dish, avoiding silent resource rounding. MySQL `preylab_dishes.state_json` holds complete continuing state and `write_token` fences stale writers. The runner checkpoints every 5,000 simulated ms and on commands, completion and transfer. Status, telemetry, preview and checkpoint reads wait for the current model/checkpoint transaction, so a reported completed age and revision are already durable. A crash can lose up to the last checkpoint interval of simulated progress. Restart resumes the last durable compatible checkpoint with no elapsed wall-time catch-up. It records a recovery notice. If the executable model/registry digest or schema differs, it enters `recovery-needed`; it must be reviewed rather than silently running under changed biology. No archive is automatically deleted. Existing browser archive revision and local recovery rules remain in force. On a model-step failure, the runner attempts to mark the run `recovery-needed` from its last durable checkpoint when the archive is reachable, rather than archiving a partially stepped organism state. If the archive has already changed owner, the stale in-memory run is discarded. Startup scans all runner-owned archive rows; it no longer silently omits rows beyond a fixed directory limit. The separate `thefairiesquest.preylab_lab_runs` table is now installed through [DDL](../headless/run-index.sql), and `runIndexTable` is enabled in the private lab config. The runner backfills its loaded runs and mirrors later checkpoints, creations and handoffs into bounded, indexed metadata and telemetry. The mirror is derived and best effort: a failed mirror logs an error and never replaces the guarded dish checkpoint. Its upsert rejects an older revision overwriting a newer projection. On this host the first SQL execution put an unused empty table in `thefairiesquest_ambassadors`; the schema-qualified DDL was subsequently applied to the correct application database. The index is a snapshot of the headless run through handoff, not a live mirror of subsequent browser simulation. A comparison of all five index rows found one browser-continued dish whose current archive age exceeded its last headless age; consumers must not present that row as live browser telemetry. Startup still scans authoritative JSON archives for runner-owned runs, because the best-effort projection could miss a checkpoint after a crash or index write failure. Indexed recovery reads and reconciliation remain unfinished. The SVG preview is generated from a read-only current model snapshot at most once every 10 wall seconds per selected run; the response includes capture age and revision headers. It is a simplified organism-position view, not the microscope's full art renderer or a biological measurement. The Observatory requests a new image about every 12 seconds, polls telemetry every 3 seconds, and labels stale/disconnected status. Only the selected run gets the detailed view. ## Credentials and service The one-time host setup is `node scripts/setup-preylab-lab.mjs`. It creates `C:/nq-private/preylab-lab.json` with Windows ACL restricted to the host administrator, SYSTEM and Administrators. The owner may issue separate revocable token records there with `observe`, `create`, `run`, and `transfer` scopes; restarting the runner reloads them. Never put a token in a URL, portable record, screenshot, source file or chat. The Observatory exchanges a credential for a secure HttpOnly session cookie bound to that specific credential's fingerprint; removing the credential and restarting invalidates its session even if another credential has the same owner ID. The MCP connection uses a Bearer credential. Dish access is bound to the token's owner ID. On this Windows account, Codex's user config has an enabled `preylab_lab` HTTP MCP entry for `https://nysa.quest/thefairiesquest/preylab/lab-api/mcp`, with `bearer_token_env_var = "PREYLAB_LAB_TOKEN"`. The owner token is stored in the account's user environment and the private host credential file, not in `config.toml`; `codex mcp list` reports it as an enabled bearer-token connection. A fresh Codex CLI process inherited the saved token and passed a complete finite experiment. After the owner restarted Codex, the active desktop chat also exposed all 13 `preylab_lab` tools and completed a real run through that connection. The separately registered cloud plugin and an independently off-host assistant session have not been verified. Revocation requires removing the token from the private config, clearing the user environment variable, restarting the runner, and restarting Codex. The Windows Scheduled Task is **PreyLab Headless Laboratory**. Run `Start-ScheduledTask -TaskName 'PreyLab Headless Laboratory'` and check `GET /health` through the HTTPS route; after editing server/model code, `Stop-ScheduledTask` then `Start-ScheduledTask`, and wait until health returns 200. Health reports `runIndex.enabled` and `runIndex.ready`; both were true after a real restart and table preflight. The task uses this machine's user logon trigger, restarts after failure, and does **not** guarantee operation before that Windows account logs in or while this host is powered off. Caddy and Lucee/MySQL must also run. The Caddy route terminates HTTPS and proxies only the lab prefix; the Node process is loopback-only. An off-host assistant connection has not been independently verified here. Plugin connection authorization in the assistant client remains owner-specific. Payloads are limited to 100 KiB, credentials to 180 requests per minute, concurrent execution to one, queue to eight and telemetry to 120 history points plus 40 events. An invalid request returns a structured `error.code`, message and where available validation details. HTTP and MCP tools do not expose file execution, arbitrary URLs, mutable genes in running dishes or database administration. ## Current implementation and evidence Source: `headless/server.mjs`, `experiment.mjs`, `mcp.mjs`, `observatory.html/.js/.css`, `api/dishes.cfm`, and the microscope's `preylab.js`. Setup and probes: `scripts/setup-preylab-lab.mjs`, `scripts/verify-preylab-lab-local.mjs`, `scripts/verify-preylab-browser-handoff.mjs`, `scripts/verify-preylab-portable-api.mjs`, `scripts/verify-preylab-mcp-https.mjs`, `scripts/verify-preylab-runner-recovery.mjs`, `scripts/verify-preylab-model-pin.mjs`, `scripts/verify-preylab-lab-scopes.mjs`, and `scripts/verify-preylab-lab-queue.mjs`; focused model checks: `test/preylab-headless.test.mjs`. Local Lucee/MySQL tests created and resumed finite dishes, rejected stale writes in both transfer directions, and preserved live identities across browser → runner → browser. The test compares the complete runner and microscope population/environment snapshots exactly, including RNG, resources and pending commitments. Lucee's GET route returns the stored JSON subtree directly so it does not round a runner checkpoint's floating-point values during takeover. Handoff/takeover retries with one request key returned the original authority, while changed terms and a stale post-browser replay were rejected. An authenticated HTTPS API session also validated an actual Environment v1 + Protocol v2 + DNA v4 inoculum, rejected a same-position collision with a specific reason, ran the one-cell dish for 1,000 simulated ms, reported balanced accounting and transferred it to the microscope. An authenticated MCP SDK client using the HTTPS URL validated, created, started, polled, previewed and took over a finite Foundation run (model 4, final age 61,000 ms, revision 3 before takeover); its takeover replay returned the same fenced token. The test client ran on this host. An observe-only credential received capabilities (200) but could neither create (403) nor inspect/list a different owner's run (404/empty). A second observe-only credential sharing the owner's ID could view that owner's runs but its browser cookie could not create (403). Both test credentials were removed. A separate interruption test stopped the Scheduled Task for two wall seconds after a durable checkpoint; its resumed age was 65,120 ms versus 65,080 ms immediately before stopping, without a two-second catch-up, and replayed the durable start request key after restart. A 128-cell/200× mixed model timing sample while the full suite was also active took 433.8 ms for 20 steps (21.69 ms per step, with profiling), so sustained 2× is not established for that scene. The idle runner process occupied about 62 MB working set and 74 MB private memory on this host. The registered private plugin exists, but its owner-side authorization and an independently off-host assistant session remain unverified. The current scheduled task and host must stay online for unattended operation. The authenticated Observatory was browser-checked in the in-app desktop viewport and at 390×667. Both showed the run directory, control state, readout and a real SVG preview without document-wide horizontal overflow. An API `start` command advanced the selected run from 65.12 to 79.80 simulated seconds while the Observatory independently reported its running age, owner, revision and preview; the API then paused and checkpointed it at revision 8. A temporary visual-test credential was removed, the runner restarted, and reloading the browser returned to sign-in. This is an emulated viewport check, not physical-phone testing. It does not prove the private plugin has been connected in a separate assistant session. A two-run queue test left the second dish at age 60,000 ms while queued; cancelling it retained the same age and its dish, while the first run used the execution slot. Both were safely transferred out afterward. The model-pin probe created a disposable paused archive, changed only its stored executable digest, restarted the Scheduled Task and observed `recovery-needed`. Both start and microscope takeover returned `model_incompatible` without stepping or changing ownership. It restored the original checkpoint, restarted, and transferred that disposable dish out. This tests rejection and recovery of a deliberately incompatible checkpoint; it is not a migration implementation. A fresh local Codex CLI assistant passed the direct MCP connection on 2026-10-24: `lab_capabilities` returned model 4; the assistant validated an exact v1 Foundation envelope, created run `edf02da0-d3b7-44c3-a9fd-684c8bfc7055`, started it, read telemetry and a revision-matched preview, confirmed the same run in the directory, then took it into browser ownership. Completion and preview both reported 61,000 simulated ms and revision 3; takeover reached revision 5. Material actual and expected were both 216.695732052 with residual 4.55e-13. A MySQL join of `preylab_lab_runs` and `preylab_dishes` confirmed browser ownership, age 61,000 ms and revision 5 in both. The [assistant transcript](../evidence/codex-mcp-finite-run.txt) contains no credential or write token. An initial assistant attempt omitted `format` and failed validation without creating a dish; MCP tool descriptions now spell out the required envelope. Five focused tests and build passed after the description change; the Scheduled Task was restarted and HTTPS health returned model 4 with the index ready. This establishes a real assistant MCP workflow on this host. The cloud plugin's owner authorization and an independently off-host assistant run are still unverified. After the desktop restart, this active chat independently used `preylab_lab` to validate, create, start, inspect telemetry and preview, list and take over run `d8ca9451-dcd6-4d61-8b85-d825c30e96ca`. The completed telemetry, directory and SVG preview agreed at age 61,000 ms/revision 3. Takeover reached browser revision 5, and a real MySQL join confirmed matching browser owner/revision/age in both archive and run index. During this test the MCP validation response exposed the full prepared state, so the server now returns only `valid`, `errors` and bounded `preview`, matching the HTTP validation endpoint. The rechecked MCP response was 685 bytes. [Evidence](../evidence/active-chat-mcp-acceptance-2026-10-24.txt) omits credentials and the returned browser write token. The runner was restarted; HTTPS health, five focused tests and build passed. The earlier authenticated Observatory desktop/390×667 pass watched a run started through the same API; the present run was observed through MCP telemetry and preview rather than a simultaneous browser sign-in. After the read-serialization and model-pin changes, a fresh authenticated MCP SDK client over the HTTPS route validated, created, started, polled, captured an SVG image and took over a finite Foundation run. It reported model 4, simulated age 61,000 ms, completed revision 3, preview revision 3 at the same age, and an idempotent takeover replay at revision 5. The SDK process ran on this host, so this is current MCP protocol evidence, not an off-host assistant session. After the run index was enabled, another finite HTTPS MCP run completed and transferred out. A real MySQL join showed its indexed and authoritative archive records both at revision 5, browser ownership and 61,000 simulated ms. A deliberately stale revision 4 index upsert left those revision 5 values unchanged. Startup backfilled four existing runner-owned runs. A fresh transferred run also exposed Lucee numeric rounding on a legacy browser save. The revised browser/Lucee path preserved the entire population subtree exactly across the real save, retained server-owned handoff metadata, rejected a forged client field with 400, and rejected a legacy transferred-dish save with 409. The five focused headless tests pass, including bounded projection and secret exclusion. The largest of 557 saved dishes occupied 3,829,282 bytes of state JSON; its `stateJson` request envelope would use 4,184,433 bytes. The archive endpoint now allows a 5,000,000-character request envelope while retaining the 4,000,000-character state limit. A real 4,173,225-byte HTTPS request made from that saved shape with a deliberately invalid schema reached Lucee and returned a validation 400, not a size rejection; it did not save or alter the large dish. The project-wide `npm test` run on this source tree reported 362 tests, 330 passes, 31 failures and one skip; a captured rerun reported 328 passes, 33 failures and one skip. Failures span older Guild, biology, CRISPR, visual and archive expectations, and some scene outcomes vary between runs. The four focused headless model tests pass. `npm run build` passes and now includes the headless source and manuals in `dist/`. These broad red tests require triage; the headless acceptance is not inferred from the broad suite.