diff --git a/AGENTS.md b/AGENTS.md index 266491c..35c47b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ OpsQuest is a Go 1.26 CLI game with 23 in-memory Linux missions and 6 optional D - `internal/mission`: JSON schema, strict embedded catalog loading, track-local world/stage derivation, integrity checks, and `data/` mission content. - `internal/sandbox`: parser, dispatcher, virtual filesystem, virtual processes, archives, and the supported command subset. - `internal/dockerlab`: optional Docker capability detection, typed teaching commands, isolated resource lifecycle, and Docker observations. +- `internal/webapp`: loopback-only web companion, one-time browser pairing, sanitized attempt projection, and embedded static assets. - `internal/profile`: versioned profile model and atomic JSON persistence. - `scripts`: deterministic repository checks used by both Make and CI. - `docs/game`: learning principles and the high-level product explanation. @@ -35,6 +36,7 @@ Safe local edits, tests, builds, and documentation updates within the requested - Never execute player-entered commands through a host shell or against the host filesystem. - Sandbox changes must preserve isolation from host processes and files. - Docker labs must reject raw command passthrough and operate only on exact resources owned and labeled by the current attempt. +- The web companion must remain loopback-only and must not accept player command text, mutate mission state, or approve completion. - Mission validators must check observable outcomes, not require one exact command sequence. - Mission content remains declarative in embedded JSON; engine behavior belongs in Go. - Mission schema and persisted-profile changes are compatibility-sensitive and require explicit migration or compatibility reasoning. diff --git a/README.md b/README.md index 639c249..09fcf08 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ OpsQuest validates the result, not a prescribed command. Equivalent supported so - Quote-aware globs and variables, pipelines, redirection, and command history - A compact virtual `vi` and bounded virtual shell scripts - Progressive hints, free command guidance, XP, ranks, and six achievements +- An optional loopback-only web companion for mission guidance and live progress - World maps, direct mission practice, replay, and persistent profile progress - Outcome validation for output, paths, content, permissions, processes, environment, and Docker state @@ -63,12 +64,14 @@ $ opsquest guide $ opsquest map $ opsquest play --world 2 $ opsquest play --once 4 +$ opsquest play --web $ opsquest profile $ opsquest achievements $ opsquest doctor ``` The complete player guide covers [installation and first play](docs/play/quick-start.md), +[the local web companion](docs/play/web-companion.md), [missions and progress](docs/play/missions-and-progress.md), and [controls and commands](docs/play/controls-and-commands.md). @@ -93,6 +96,8 @@ Player-entered Linux commands—including virtual scripts—are parsed and execu Docker input is parsed into a small set of typed teaching actions. OpsQuest constructs Docker arguments itself, uses only exact resources labeled for the current attempt, applies resource restrictions, and verifies ownership again during cleanup. It does not expose arbitrary Docker passthrough, privileged mode, host mounts, host networking, devices, or the Docker socket. +The optional web companion binds only to an ephemeral `127.0.0.1` port, uses a one-time pairing URL, and exposes a read-only mission projection. It cannot submit commands, mutate the profile or attempt, address Docker resources, or approve completion. + Read [Sandbox and safety](docs/technical/sandbox-and-safety.md) for trust boundaries, quotas, cleanup behavior, and threat controls. ## Documentation @@ -119,6 +124,7 @@ internal/mission/ Strict embedded catalog, worlds, and mission JSON internal/profile/ XP, ranks, achievements, and atomic persistence internal/sandbox/ Virtual filesystem, shell parser, and commands internal/dockerlab/ Optional, label-scoped Docker adapter +internal/webapp/ Loopback-only mission companion and embedded browser UI docs/ Zensical documentation source and diagrams project/ Repository-only decisions and delivery history ``` diff --git a/docs/README.md b/docs/README.md index 39a3ab6..5bf1fa6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ The game describes an observable objective, gives you a disposable environment, | I want to… | Start here | | --- | --- | | Install the game and begin a mission | [Quick start](play/quick-start.md) | +| Move mission guidance into a local browser | [Web mission companion](play/web-companion.md) | | Understand objectives, hints, routes, and rewards | [Missions, hints, and progress](play/missions-and-progress.md) | | Look up terminal controls and supported commands | [Controls and commands](play/controls-and-commands.md) | | See what every world and mission teaches | [Mission map](play/mission-map.md) | @@ -34,12 +35,16 @@ The game describes an observable objective, gives you a disposable environment, - Outcome-based validation rather than one required command transcript - An in-memory filesystem, process table, environment, archives, editor, and shell scripts - Persistent XP, ranks, command practice, hints, completions, and achievements +- An optional local web companion with live objective progress - Interactive completion, editing, history, and mission navigation ## The safety promise Player-entered Linux commands are parsed and executed by OpsQuest's Go teaching shell. They never reach a host shell or host path. Docker gameplay is opt-in, accepts only typed teaching actions, and operates on exact disposable resources labeled for the current attempt. +The optional browser companion is a loopback-only read model. It receives +sanitized mission snapshots but cannot execute commands or declare success. + Read [Sandbox and safety](technical/sandbox-and-safety.md) for the complete trust model and limits. ## About these docs diff --git a/docs/play/README.md b/docs/play/README.md index ab4d82e..7dfba5e 100644 --- a/docs/play/README.md +++ b/docs/play/README.md @@ -11,8 +11,9 @@ OpsQuest teaches through short incidents inside disposable environments. You ins ## Start here 1. [Install and launch OpsQuest](quick-start.md). -2. Learn how [missions, hints, routes, and rewards work](missions-and-progress.md). -3. Keep the [controls and command reference](controls-and-commands.md) nearby while playing. +2. Optionally move guidance into the [local web companion](web-companion.md). +3. Learn how [missions, hints, routes, and rewards work](missions-and-progress.md). +4. Keep the [controls and command reference](controls-and-commands.md) nearby while playing. The default route follows the four Linux worlds in the [mission map](mission-map.md). The separate [Docker Foundations](docker-foundations.md) track is optional and diff --git a/docs/play/controls-and-commands.md b/docs/play/controls-and-commands.md index 74600d5..418cc37 100644 --- a/docs/play/controls-and-commands.md +++ b/docs/play/controls-and-commands.md @@ -8,6 +8,10 @@ status: current OpsQuest provides line editing and a focused Linux teaching shell inside each mission. +With `opsquest play --web`, command entry, completion, history, and raw command +output remain in the terminal. Mission narrative, objectives, revealed hints, +outcome checks, and the field guide appear in the [web mission companion](web-companion.md). + ## Interactive editing | Keys | Action | diff --git a/docs/play/missions-and-progress.md b/docs/play/missions-and-progress.md index b00fdcc..b507e6d 100644 --- a/docs/play/missions-and-progress.md +++ b/docs/play/missions-and-progress.md @@ -43,6 +43,7 @@ recorded while a mission is incomplete, so quitting cannot erase the cost. - `opsquest play --world N` stays within one Linux world. - A global mission number or stable ID begins there and follows that track. - `--once` returns after one completed mission. +- `--web` moves mission guidance and live outcome progress into a local browser companion while commands remain in the terminal. - Completed missions remain replayable and never award duplicate XP. Inside a mission, `play N` means Stage N of the current world. A stable ID from diff --git a/docs/play/quick-start.md b/docs/play/quick-start.md index b00c5e1..0287b24 100644 --- a/docs/play/quick-start.md +++ b/docs/play/quick-start.md @@ -42,6 +42,7 @@ $ opsquest map --ids # include stable mission IDs $ opsquest play --world 2 # stay within Linux World 2 $ opsquest play 4 # begin at global Mission 4 $ opsquest play --once 4 # return after one mission +$ opsquest play --web # show guidance in a local browser $ opsquest show 16 # preview without starting $ opsquest profile # view XP, rank, and progress $ opsquest commands # view practiced commands @@ -54,6 +55,7 @@ $ opsquest doctor # check catalog, profile, and Docker readiness ## Next steps - [Missions, hints, and progress](missions-and-progress.md) +- [Web mission companion](web-companion.md) - [Controls and commands](controls-and-commands.md) - [Mission map](mission-map.md) - [Optional Docker setup](docker-foundations.md) diff --git a/docs/play/web-companion.md b/docs/play/web-companion.md new file mode 100644 index 0000000..d11ab34 --- /dev/null +++ b/docs/play/web-companion.md @@ -0,0 +1,65 @@ +--- +description: Run the local OpsQuest browser companion while commands remain in the teaching terminal. +audience: players +status: current +--- + +# Web mission companion + +The optional web companion separates guidance from command entry. OpsQuest +continues to execute and validate every command in the CLI-owned teaching +environment, while a local browser page shows the incident, objective, +suggested tools, progressive hints, live outcome checks, rewards, and the +completion explanation. + +Start any Linux or Docker route with `--web`: + +```console +$ opsquest play --web +$ opsquest play --web --once 5 +$ opsquest play --web --track docker +``` + +OpsQuest prints a one-time URL such as +`http://127.0.0.1:49152/pair?token=...`. Open it in a browser before entering +commands. The URL exchanges its token for a browser-session cookie and cannot +be paired a second time. + +## Split-screen play + +Keep the terminal and browser visible together: + +- Enter lab commands, mission navigation, and controls in the terminal. +- Read the incident, objective, suggested tools, and outcome checklist in the browser. +- Type `hint` in the terminal to reveal the next hint in the browser. The usual XP penalty and persisted hint progress still apply. +- Type `status` to force a fresh outcome observation. Successful lab commands also refresh progress automatically. +- Type `restart` to rebuild the disposable environment and reset its outcome checks. + +In companion mode the terminal intentionally omits the mission narrative, +hint text, detailed progress checklist, and completion explanation. Raw command +output remains there because it is both player feedback and, for some +missions, validation input. + +## Lifetime and reconnection + +The companion exists only while that `opsquest play --web` process is active. +Reloading or reconnecting from the paired browser restores the latest complete +snapshot without replaying commands. Continuous play reuses the same page as +the route advances to another mission. Quitting or interrupting the CLI closes +the local server; the already-rendered page may remain visible but no longer +receives updates. + +## Safety boundary + +The server binds only to an ephemeral IPv4 loopback port. It uses one-time +pairing, an HTTP-only same-site cookie, exact Host and Origin checks, restrictive +browser security headers, and no permissive CORS policy. + +The browser receives a presentation projection rather than the mission's raw +setup or validation structures. It has no API for submitting command text, +selecting Docker resources, mutating the profile, or declaring completion. +`game.Session` still evaluates observable outcomes, closes the active +environment, and saves the profile before publishing a completed state. + +The companion adds no hosted service, account, external network requirement, +or third-party production dependency. diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 844f619..f3b70a3 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -14,13 +14,17 @@ OpsQuest separates product flow, declarative mission content, isolated execution Editable source: [`system-landscape.excalidraw`](diagrams/system-landscape.excalidraw) -There are three materially different state domains: +The landscape diagram focuses on the command-execution and persistence path; +the optional read-only companion projection is detailed separately below. + +There are four materially different state domains: | Domain | Owner | Lifetime | Host interaction | | --- | --- | --- | --- | | Mission definition | `internal/mission` | Embedded in the binary | Reads embedded JSON only | | Mission attempt | `internal/sandbox` or `internal/dockerlab` | One attempt or restart | In-memory for Linux; exact labeled Docker resources for Docker labs | | Player progress | `internal/profile` | Across processes | Atomic `profile.json` replacement in the platform config directory | +| Companion projection | `internal/webapp` | One `play --web` process | Loopback HTTP only; no durable writes or command execution | Linux mission state is never persisted. Completing, quitting, switching, or restarting a Linux mission discards its virtual filesystem, environment, processes, archives, and history. Hints, command practice, completions, XP, and achievements belong to the profile instead. @@ -38,6 +42,7 @@ Editable source: [`component-architecture.mmd`](diagrams/component-architecture. | `internal/mission` | Strict schema, embedded catalog, tracks, worlds, and defensive copies | Content stays declarative; rejects invalid catalogs before play starts | | `internal/sandbox` | Teaching-shell lexer, parser, dispatcher, virtual filesystem/processes/archives | Never invokes a host shell or accesses host paths | | `internal/dockerlab` | Optional Docker-compatible engine availability, typed teaching actions, fixtures, observations, and cleanup | Only this adapter may launch the Docker CLI, using constructed arguments and tracked resource IDs | +| `internal/webapp` | Optional embedded mission companion, one-time pairing, current snapshot, and server-sent events | Serves a read-only projection on IPv4 loopback; accepts no command or completion input | | `internal/profile` | Versioned progress model and atomic JSON storage | The only normal durable state written by the application | | `internal/ui` | Terminal capability detection and semantic ANSI roles | Styling stays out of execution results and validators | | `internal/buildinfo` | Release-managed executable version | Updated by release automation | @@ -54,7 +59,7 @@ OrbStack does not introduce a second execution backend. On macOS it is selected 2. Load and validate every embedded mission with `mission.LoadCatalog`. 3. Resolve the profile store with `profile.DefaultStore`. 4. Construct the CLI with standard streams, catalog, store, and the combined environment factory. -5. Dispatch `os.Args[1:]` through `cli.App.Run`. +5. Dispatch `os.Args[1:]` through `cli.App.Run`; `play --web` additionally starts an ephemeral loopback companion for that command. 6. Render any returned error once at the process boundary and exit non-zero. Catalog loading is fail-fast. JSON decoding disallows unknown fields, mission numbers must be globally contiguous, IDs and numbers must be unique, setup and validation fields must match the selected environment, and campaigns must remain contiguous within each track. @@ -76,6 +81,7 @@ The runtime flow is: 7. If any outcome is missing, the same attempt continues. `status` describes satisfied and missing outcomes without prescribing a command sequence. 8. Once every outcome passes, the session closes the environment **before** awarding XP. This prevents a Docker attempt with unresolved cleanup from being recorded as complete. 9. First completion records hint-adjusted XP and achievements. Replays retain the original completion and award no duplicate XP. +10. When a companion reporter is configured, the session publishes a sanitized complete snapshot after start, hints, observations, restart, pause, and persisted completion. Publishing never participates in the validation decision. `restart` closes the active environment and creates a new one from the same declarative mission. Mission switching returns a validated mission ID to the CLI, which starts a separate fresh session. @@ -104,6 +110,31 @@ The contract carries several design decisions: The simulated adapter wraps one `sandbox.Sandbox`. The Docker adapter maps logical mission aliases to generated names and exact container IDs, so neither the session nor player sees engine-owned resource identifiers. +## Web companion contract + +`play --web` starts `internal/webapp` on `127.0.0.1` with an ephemeral port. +The printed one-time pairing URL establishes an HTTP-only, same-site browser +session, then the browser loads static HTML, CSS, and JavaScript embedded in the +same executable. The current state is available as a JSON snapshot, and +subsequent updates use Server-Sent Events. A reconnect receives the latest +complete snapshot rather than querying the environment or replaying commands. + +`game.AttemptSnapshot` is a dedicated public projection. It contains narrative, +suggested tool names, revealed hints, described outcome status, placement, +reward information, and completion feedback. It does not contain mission setup +objects, condition structs, player command text, terminal output, Docker IDs, +or a profile mutation capability. + +The direction of authority is one-way: + +1. Terminal input reaches `game.Session` and the selected `Environment`. +2. `game.Session` evaluates outcomes and persists progress exactly as in terminal-only play. +3. Only after those decisions does it publish a presentation snapshot. +4. `internal/webapp` retains and broadcasts the snapshot without calling back into the session. + +Completion is not published until environment cleanup and profile persistence +both succeed. This retains the existing Docker cleanup-before-XP invariant. + ## Mission and world model Each JSON file decodes into a `mission.Mission` containing narrative fields, suggested tools, hints, setup, validation conditions, and rewards. The catalog sorts missions by stable global number and builds lookup indexes by ID and number. @@ -135,6 +166,7 @@ Changing one of these requires explicit compatibility reasoning even when the Go | Add a validation outcome | `internal/mission`, `internal/game`, environment adapters | Strict schema checks and canonical mission coverage | | Change attempt controls or progression | `internal/game` | `internal/cli` routes and profile persistence | | Add a top-level command | `internal/cli` | Smoke test and README examples | +| Change the browser companion or projection | `internal/webapp`, `internal/game` | Pairing/HTTP isolation, lifecycle ordering, CLI fallback, and race tests | | Expand Docker teaching behavior | `internal/dockerlab` | Parser rejection tests, ownership cleanup, real integration gate | | Change colors or terminal presentation | `internal/ui` | CLI/session rendering tests and non-terminal output | | Change durable progress | `internal/profile` | Migration/compatibility tests and atomic-write behavior | diff --git a/docs/technical/contributing.md b/docs/technical/contributing.md index d09cdff..2b05158 100644 --- a/docs/technical/contributing.md +++ b/docs/technical/contributing.md @@ -17,6 +17,7 @@ Start with the repository [`AGENTS.md`](https://github.com/Aleksandergreg/go-cli | Mission schema, catalog, worlds, or JSON content | `internal/mission` | | Teaching-shell parsing, files, processes, archives, or commands | `internal/sandbox` | | Optional Docker actions, fixtures, observation, or cleanup | `internal/dockerlab` | +| Local browser companion, pairing, HTTP projection, or assets | `internal/webapp` | | Durable progress and migrations | `internal/profile` | | Terminal color policy | `internal/ui` | diff --git a/docs/technical/diagrams/component-architecture.mmd b/docs/technical/diagrams/component-architecture.mmd index 9673a8b..e044a56 100644 --- a/docs/technical/diagrams/component-architecture.mmd +++ b/docs/technical/diagrams/component-architecture.mmd @@ -7,6 +7,7 @@ flowchart TB PROFILE["internal/profile
progress and atomic storage"] SANDBOX["internal/sandbox
in-memory teaching shell"] DOCKER["internal/dockerlab
typed Docker adapter"] + WEB["internal/webapp
loopback companion"] UI["internal/ui
terminal-aware styling"] BUILD["internal/buildinfo
release version"] @@ -22,6 +23,7 @@ flowchart TB CLI --> MISSION CLI --> PROFILE CLI --> UI + CLI --> WEB GAME --> MISSION GAME --> PROFILE @@ -31,6 +33,7 @@ flowchart TB SANDBOX --> MISSION DOCKER --> GAME DOCKER --> MISSION + WEB --> GAME classDef entry fill:#172554,color:#ffffff,stroke:#172554,stroke-width:2px classDef orchestration fill:#dbeafe,color:#172554,stroke:#2563eb,stroke-width:2px @@ -42,4 +45,4 @@ flowchart TB class CLI,GAME orchestration class MISSION,PROFILE domain class SANDBOX,DOCKER adapter - class UI,BUILD support + class UI,BUILD,WEB support diff --git a/docs/technical/diagrams/component-architecture.svg b/docs/technical/diagrams/component-architecture.svg index af57383..916ab0d 100644 --- a/docs/technical/diagrams/component-architecture.svg +++ b/docs/technical/diagrams/component-architecture.svg @@ -1,7 +1,7 @@ OpsQuest component architecture - Package dependency diagram showing the composition root, CLI and game orchestration, mission and profile domains, sandbox and Docker adapters, and UI and build information support packages. + Package dependency diagram showing the composition root, CLI and game orchestration, mission and profile domains, sandbox and Docker adapters, and UI, web companion, and build information support packages. @@ -38,6 +38,8 @@ + + @@ -80,6 +82,11 @@ internal/dockerlab typed optional Docker adapter + + + internal/webapp + loopback mission companion + internal/ui diff --git a/docs/technical/diagrams/mission-runtime-sequence.mmd b/docs/technical/diagrams/mission-runtime-sequence.mmd index 8802fd7..e9d06b5 100644 --- a/docs/technical/diagrams/mission-runtime-sequence.mmd +++ b/docs/technical/diagrams/mission-runtime-sequence.mmd @@ -8,14 +8,24 @@ sequenceDiagram participant Factory as game.Factory participant Env as Environment participant Validator as outcome evaluator + participant Web as webapp.Server + actor Browser Player->>CLI: opsquest play [route] CLI->>Store: Load() Store-->>CLI: normalized profile CLI->>CLI: select mission and route + opt play --web + CLI->>Web: Start(loopback, one-time pairing) + Browser->>Web: pair and subscribe + end CLI->>Session: Run(mission, profile, factory) Session->>Factory: Create(context, mission) Factory-->>Session: fresh managed environment + opt play --web + Session-->>Web: sanitized attempt snapshot + Web-->>Browser: current state + SSE updates + end loop Until quit, switch, or complete Player->>Session: command or mission control @@ -31,12 +41,20 @@ sequenceDiagram Env-->>Validator: satisfied? end Validator-->>Session: outcome results + opt web companion active + Session-->>Web: sanitized outcome snapshot + Web-->>Browser: SSE snapshot + end alt Outcomes remain Session-->>Player: progress and missing-count guidance else Every outcome passes Session->>Env: Close() Env-->>Session: cleanup complete Session->>Store: Save(completion, XP, achievements) + opt web companion active + Session-->>Web: persisted completion snapshot + Web-->>Browser: SSE completion + end Session-->>CLI: completed SessionResult CLI-->>Player: completion and next route end diff --git a/docs/technical/diagrams/mission-runtime-sequence.svg b/docs/technical/diagrams/mission-runtime-sequence.svg index c366970..6adcf3a 100644 --- a/docs/technical/diagrams/mission-runtime-sequence.svg +++ b/docs/technical/diagrams/mission-runtime-sequence.svg @@ -1,7 +1,7 @@ - + OpsQuest mission runtime sequence - Sequence from player route selection through profile load, environment creation, repeated command execution and outcome evaluation, cleanup, and completion persistence. + Sequence from player route selection through profile load, optional loopback browser pairing, environment creation, repeated command execution and outcome projection, cleanup, and completion persistence. @@ -11,7 +11,7 @@ .life{stroke:#94a3b8;stroke-width:1.5;stroke-dasharray:6 6}.call{stroke:#334155;stroke-width:1.8;marker-end:url(#arrow)}.return{stroke:#2563eb;stroke-width:1.8;stroke-dasharray:6 4;marker-end:url(#arrowBlue)} - + One mission attempt The environment closes successfully before first-completion XP is persisted. @@ -23,20 +23,26 @@ game.Factory Environment Outcome evaluator + webapp.Server + Browser - - - - + + + + + play [route] Load() normalized profile select mission + route + play --web: start ephemeral loopback companion + one-time pair + subscribe Run(mission, profile, factory) Create(context, mission) fresh managed environment + sanitized attempt snapshot LOOP · until quit, switch, or complete @@ -50,12 +56,16 @@ Observe(state condition) satisfied? outcome results · continue if incomplete + sanitized outcome snapshot + SSE update EVERY OUTCOME PASSES Close() · cleanup first cleanup complete Save(completion + XP) - completed SessionResult - completion + next route + persisted completion snapshot + SSE completion + completed SessionResult + completion + next route diff --git a/docs/technical/sandbox-and-safety.md b/docs/technical/sandbox-and-safety.md index 0c7c114..d89eca9 100644 --- a/docs/technical/sandbox-and-safety.md +++ b/docs/technical/sandbox-and-safety.md @@ -6,10 +6,11 @@ status: current # OpsQuest sandbox and safety -OpsQuest has two execution models with different trust boundaries: +OpsQuest has two execution models and one optional presentation boundary: - Linux missions use a teaching shell implemented entirely in Go over in-memory state. - Optional Docker missions parse a deliberately small command language and translate typed actions into fixed Docker CLI arguments for attempt-owned resources on the active Docker-compatible engine. +- `play --web` serves sanitized mission snapshots from an ephemeral IPv4 loopback port; the browser cannot submit commands or approve completion. Neither model passes a raw player line to a host shell. @@ -19,10 +20,34 @@ Neither model passes a raw player line to a host shell. Editable source: [`trust-boundaries.excalidraw`](diagrams/trust-boundaries.excalidraw) -The player controls command text and navigation choices. Trusted OpsQuest code decides whether that text is a session control, a simulated-shell command, or one of the supported Docker teaching forms. Only two components normally interact with durable or external host facilities: +The diagram focuses on command and storage authority. The separate loopback +presentation boundary is described in [Web companion boundary](#web-companion-boundary). + +The player controls command text and navigation choices. Trusted OpsQuest code decides whether that text is a session control, a simulated-shell command, or one of the supported Docker teaching forms. Only three components normally interact with durable or external host facilities: - `profile.Store` reads and atomically replaces one configured profile file. Player commands cannot address this path. - `dockerlab.execRunner` invokes the discovered Docker executable with arguments constructed inside `internal/dockerlab`. It never invokes `sh -c` and never forwards the original line. +- `webapp.Server` listens on `127.0.0.1` for the lifetime of one `play --web` command. It has no environment, runner, profile store, or command-input dependency. + +## Web companion boundary + +The companion uses an ephemeral port and a cryptographically random one-time +pairing token. A successful exchange creates an HTTP-only, same-site cookie and +redirects away from the token-bearing URL. Exact Host and Origin checks reduce +loopback confusion and DNS-rebinding exposure; restrictive CSP, frame, referrer, +content-type, permissions, and cross-origin headers constrain the embedded page. + +After pairing, the page may read the latest sanitized snapshot and subscribe to +Server-Sent Events. It cannot submit terminal lines, address virtual files, +choose Docker aliases or IDs, alter profile data, reveal a hint without the CLI +control, or mark an objective complete. Static assets use no external scripts, +fonts, trackers, or network service. + +Snapshots are complete current projections with monotonically increasing event +IDs. A slow subscriber skips obsolete queued snapshots and converges on the +latest state. They exclude raw mission setup and condition objects, command +text, terminal output, and internal resource identifiers. The page constructs +all mission text with DOM text nodes rather than HTML interpolation. ## Linux command execution @@ -133,6 +158,9 @@ The Docker-compatible engine remains a powerful external dependency; OpsQuest re | Cleanup removes another container | Exact ID plus managed/schema/session/mission/alias label verification | `internal/dockerlab/environment.go` and tests | | Partial Docker setup leaks resources silently | Factory may return a partial environment; managed cleanup and retryable `Close` | `internal/dockerlab/factory.go`, environment contract tests | | Persisted display text injects terminal controls | Profile names reject non-printable characters and normalize legacy values | `internal/profile/profile.go` and tests | +| Another site reaches the loopback companion | One-time capability pairing, exact Host/Origin checks, same-site HTTP-only cookie, no permissive CORS | `internal/webapp/server.go` and tests | +| Browser input reaches a mission environment | Companion exposes only read-only state and SSE routes; `game.Session` has no companion command callback | `internal/webapp`, `internal/game/companion.go` | +| Browser observes private attempt internals | Dedicated sanitized snapshot type; no setup, raw condition, transcript, or resource-ID fields | `internal/game/companion.go` and tests | ## Safety review checklist diff --git a/internal/cli/app.go b/internal/cli/app.go index 76fe0c0..d859de5 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -16,18 +16,20 @@ import ( "github.com/aleksandergregersen/opsquest/internal/mission" "github.com/aleksandergregersen/opsquest/internal/profile" "github.com/aleksandergregersen/opsquest/internal/ui" + "github.com/aleksandergregersen/opsquest/internal/webapp" ) type App struct { - in io.Reader - out io.Writer - errOut io.Writer - catalog mission.Catalog - store profile.Store - ctx context.Context - factory game.Factory - style ui.Style - errorStyle ui.Style + in io.Reader + out io.Writer + errOut io.Writer + catalog mission.Catalog + store profile.Store + ctx context.Context + factory game.Factory + startCompanion CompanionStarter + style ui.Style + errorStyle ui.Style } type playRouteKind uint8 @@ -43,17 +45,31 @@ type playRoute struct { worldNumber int } +// Companion is the lifecycle and presentation boundary used by web-assisted +// play. It receives sanitized game snapshots and never accepts player command +// text or completion decisions. +type Companion interface { + game.AttemptReporter + URL() string + Close(context.Context) error +} + +// CompanionStarter creates one companion for the lifetime of a play command. +type CompanionStarter func(context.Context) (Companion, error) + // Config contains the process-level dependencies required by the CLI. The // executable supplies persistent storage and the combined environment factory; -// focused tests may omit Context and Factory to use safe in-process defaults. +// focused tests may omit Context, Factory, and StartCompanion to use safe local +// defaults. type Config struct { - Context context.Context - In io.Reader - Out io.Writer - ErrOut io.Writer - Catalog mission.Catalog - Store profile.Store - Factory game.Factory + Context context.Context + In io.Reader + Out io.Writer + ErrOut io.Writer + Catalog mission.Catalog + Store profile.Store + Factory game.Factory + StartCompanion CompanionStarter } func New(config Config) *App { @@ -72,16 +88,22 @@ func New(config Config) *App { if config.Factory == nil { config.Factory = game.SandboxFactory{} } + if config.StartCompanion == nil { + config.StartCompanion = func(ctx context.Context) (Companion, error) { + return webapp.Start(ctx) + } + } return &App{ - in: config.In, - out: config.Out, - errOut: config.ErrOut, - catalog: config.Catalog, - store: config.Store, - ctx: config.Context, - factory: config.Factory, - style: ui.Auto(config.Out), - errorStyle: ui.Auto(config.ErrOut), + in: config.In, + out: config.Out, + errOut: config.ErrOut, + catalog: config.Catalog, + store: config.Store, + ctx: config.Context, + factory: config.Factory, + startCompanion: config.StartCompanion, + style: ui.Auto(config.Out), + errorStyle: ui.Auto(config.ErrOut), } } @@ -156,16 +178,17 @@ func (a *App) Run(args []string) error { } } -func (a *App) runPlay(args []string) error { +func (a *App) runPlay(args []string) (returnErr error) { flags := a.newFlagSet("play", func() { a.printPlayUsage(a.errOut) }) track := flags.String("track", mission.TrackLinux, "play the linux or docker track") worldNumber := flags.Int("world", 0, "start the next incomplete stage in a world") once := flags.Bool("once", false, "return after one completed mission") + web := flags.Bool("web", false, "show mission guidance in a local browser companion") if help, err := parseFlags(flags, args); help || err != nil { return err } if flags.NArg() > 1 { - return fmt.Errorf("usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [MISSION]") + return fmt.Errorf("usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [--web] [MISSION]") } trackProvided, worldProvided := flagProvided(flags, "track"), flagProvided(flags, "world") selectedTrack := strings.ToLower(strings.TrimSpace(*track)) @@ -212,8 +235,27 @@ func (a *App) runPlay(args []string) error { return nil } } + var activeCompanion Companion + if *web { + activeCompanion, err = a.startCompanion(a.ctx) + if err != nil { + return fmt.Errorf("start web companion: %w", err) + } + defer func() { + closeContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := activeCompanion.Close(closeContext); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("close web companion: %w", err)) + } + }() + fmt.Fprintln(a.out, a.style.Header("WEB MISSION COMPANION")) + fmt.Fprintf(a.out, "Open this one-time local URL in your browser:\n%s\n", a.style.Accent(activeCompanion.URL())) + fmt.Fprintln(a.out, a.style.Muted("Keep this terminal for commands. The browser can display progress but cannot execute anything.")) + } if item.EffectiveTrack() == mission.TrackLinux && isPristineProfile(player) { - a.printQuickStart() + if !*web { + a.printQuickStart() + } player.Onboarded = true if err := a.store.Save(player); err != nil { return err @@ -225,14 +267,16 @@ func (a *App) runPlay(args []string) error { currentTrack := item.EffectiveTrack() wasComplete := player.IsComplete(item.ID) session := game.Session{ - Mission: item, - Player: &player, - Saver: a.store, - In: a.in, - Out: a.out, - ErrOut: a.errOut, - Reader: reader, - Catalog: a.catalog, + Mission: item, + Player: &player, + Saver: a.store, + Reporter: activeCompanion, + Companion: activeCompanion != nil, + In: a.in, + Out: a.out, + ErrOut: a.errOut, + Reader: reader, + Catalog: a.catalog, ListMissions: func(args []string) error { if !hasFlag(args, "--track") { args = append([]string{"--track", currentTrack}, args...) @@ -793,10 +837,11 @@ func (a *App) runHelp(args []string) error { } func (a *App) printPlayUsage(out io.Writer) { - fmt.Fprintln(out, "Usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [MISSION]") + fmt.Fprintln(out, "Usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [--web] [MISSION]") fmt.Fprintln(out, "Without a selector, resume the first incomplete mission and continue the recommended path.") fmt.Fprintln(out, "A mission number or ID follows catalog order from that exact stage; --world stays inside one world.") fmt.Fprintln(out, "Use --once to return after one completed mission.") + fmt.Fprintln(out, "Use --web to show guidance and live objective progress in a local browser companion.") } func (a *App) printListUsage(out io.Writer) { diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 924dbcd..6d5af61 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -49,6 +49,23 @@ func testApp(t *testing.T, input string, store profile.Store) (*App, *bytes.Buff }), out, errOut } +type testWebCompanion struct { + events []game.AttemptEvent + closeCount int + closeErr error +} + +func (c *testWebCompanion) ReportAttempt(event game.AttemptEvent) { + c.events = append(c.events, game.CloneAttemptEvent(event)) +} + +func (c *testWebCompanion) URL() string { return "http://127.0.0.1:43210/pair?token=test" } + +func (c *testWebCompanion) Close(context.Context) error { + c.closeCount++ + return c.closeErr +} + func seedCompletedMissions(t *testing.T, store profile.Store, missionIDs ...string) { t.Helper() player := profile.New("alex") @@ -148,6 +165,101 @@ func TestPlayAwardsHintAdjustedXPAndPersistsProgress(t *testing.T) { } } +func TestWebPlayUsesCompanionForMissionPresentationAndLiveProgress(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "hint\npwd\n", store) + companion := &testWebCompanion{} + app.startCompanion = func(context.Context) (Companion, error) { return companion, nil } + + if err := app.Run([]string{"play", "--web", "--once", "1"}); err != nil { + t.Fatalf("web play error = %v; stderr = %s", err, errOut.String()) + } + if companion.closeCount != 1 { + t.Fatalf("companion close count = %d, want 1", companion.closeCount) + } + if len(companion.events) != 4 || companion.events[0].Type != game.AttemptStarted || companion.events[1].Type != game.AttemptHint || companion.events[3].Type != game.AttemptCompleted { + t.Fatalf("companion event types = %#v", eventTypesForCLI(companion.events)) + } + output := out.String() + for _, visible := range []string{ + "WEB MISSION COMPANION", + companion.URL(), + "Keep this terminal for commands", + "Mission 01 ready in the web companion", + "Hint 1/2 revealed in the web companion", + "/home/operator", + "✓ Mission complete!", + } { + if !strings.Contains(output, visible) { + t.Errorf("web play output missing %q:\n%s", visible, output) + } + } + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("1") + for _, hidden := range []string{item.Story, item.Objective, item.Hints[0], item.Explanation, "FIRST SHIFT BRIEFING", "Commands you may need to solve this level:"} { + if strings.Contains(output, hidden) { + t.Errorf("web play terminal leaked companion content %q:\n%s", hidden, output) + } + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if player.XP != 30 || !player.IsComplete(item.ID) || !player.Onboarded { + t.Fatalf("web play profile = %#v", player) + } +} + +func TestWebPlayFailsBeforeOnboardingWhenCompanionCannotStart(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, _ := testApp(t, "", store) + app.startCompanion = func(context.Context) (Companion, error) { + return nil, fmt.Errorf("loopback unavailable") + } + + err := app.Run([]string{"play", "--web", "1"}) + if err == nil || !strings.Contains(err.Error(), "start web companion: loopback unavailable") { + t.Fatalf("web companion start error = %v", err) + } + player, loadErr := store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + if player.Onboarded || len(player.Completed) != 0 || player.XP != 0 { + t.Fatalf("failed companion start changed profile = %#v", player) + } +} + +func TestWebPlayReportsCompanionCloseFailureAfterPersistingCompletion(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, _ := testApp(t, "pwd\n", store) + companion := &testWebCompanion{closeErr: fmt.Errorf("shutdown stalled")} + app.startCompanion = func(context.Context) (Companion, error) { return companion, nil } + + err := app.Run([]string{"play", "--web", "--once", "1"}) + if err == nil || !strings.Contains(err.Error(), "close web companion: shutdown stalled") { + t.Fatalf("web companion close error = %v", err) + } + player, loadErr := store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + if !player.IsComplete("linux-orientation") || player.XP != 40 { + t.Fatalf("completion was not persisted before companion close error: %#v", player) + } +} + +func eventTypesForCLI(events []game.AttemptEvent) []game.AttemptEventType { + types := make([]game.AttemptEventType, len(events)) + for index, event := range events { + types[index] = event.Type + } + return types +} + func TestReplayDoesNotAwardXPAgain(t *testing.T) { store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") first, _, _ := testApp(t, "pwd\n", store) diff --git a/internal/game/companion.go b/internal/game/companion.go new file mode 100644 index 0000000..d571cad --- /dev/null +++ b/internal/game/companion.go @@ -0,0 +1,96 @@ +package game + +import "slices" + +// AttemptEventType identifies one presentation-safe mission lifecycle update. +// Companion consumers may render these events, but they never participate in +// command execution or outcome validation. +type AttemptEventType string + +const ( + AttemptStarted AttemptEventType = "attempt_started" + AttemptProgress AttemptEventType = "attempt_progress" + AttemptHint AttemptEventType = "hint_revealed" + AttemptRestarted AttemptEventType = "attempt_restarted" + AttemptPaused AttemptEventType = "attempt_paused" + AttemptCompleted AttemptEventType = "attempt_completed" +) + +const ( + AttemptStateActive = "active" + AttemptStatePaused = "paused" + AttemptStateCompleted = "completed" +) + +// AttemptReporter receives sanitized snapshots for a presentation companion. +// ReportAttempt must return promptly and must not call back into Session. A +// reporter is not an authority boundary: Session remains the only component +// that executes commands, validates outcomes, and records completion. +type AttemptReporter interface { + ReportAttempt(AttemptEvent) +} + +// AttemptEvent carries a complete current snapshot so a browser can reconnect +// without replaying terminal input or querying the active environment. +type AttemptEvent struct { + Type AttemptEventType `json:"type"` + Snapshot AttemptSnapshot `json:"snapshot"` +} + +// AttemptSnapshot is the public projection of one mission attempt. It +// deliberately excludes setup data, raw validation structures, command text, +// terminal output, and environment internals. +type AttemptSnapshot struct { + MissionID string `json:"mission_id"` + Number int `json:"number"` + Title string `json:"title"` + Track string `json:"track"` + WorldNumber int `json:"world_number,omitempty"` + WorldTotal int `json:"world_total,omitempty"` + WorldName string `json:"world_name,omitempty"` + StageNumber int `json:"stage_number,omitempty"` + StageTotal int `json:"stage_total,omitempty"` + Difficulty string `json:"difficulty"` + Story string `json:"story"` + Objective string `json:"objective"` + SuggestedCommands []string `json:"suggested_commands"` + RevealedHints []string `json:"revealed_hints"` + HintCount int `json:"hint_count"` + HintsUsed int `json:"hints_used"` + Outcomes []AttemptOutcome `json:"outcomes"` + SatisfiedOutcomes int `json:"satisfied_outcomes"` + RewardAvailable int `json:"reward_available"` + BaseReward int `json:"base_reward"` + Replaying bool `json:"replaying"` + State string `json:"state"` + Explanation string `json:"explanation,omitempty"` + XPAwarded int `json:"xp_awarded,omitempty"` + FirstCompletion bool `json:"first_completion,omitempty"` + PracticedCommands []string `json:"practiced_commands,omitempty"` + DiscoveredCommands []string `json:"discovered_commands,omitempty"` + UnlockedAchievements []string `json:"unlocked_achievements,omitempty"` +} + +// AttemptOutcome exposes the same human-readable observable check available to +// the terminal status control without exposing its mission-schema structure. +type AttemptOutcome struct { + Description string `json:"description"` + Satisfied bool `json:"satisfied"` +} + +func cloneAttemptSnapshot(snapshot AttemptSnapshot) AttemptSnapshot { + snapshot.SuggestedCommands = slices.Clone(snapshot.SuggestedCommands) + snapshot.RevealedHints = slices.Clone(snapshot.RevealedHints) + snapshot.Outcomes = slices.Clone(snapshot.Outcomes) + snapshot.PracticedCommands = slices.Clone(snapshot.PracticedCommands) + snapshot.DiscoveredCommands = slices.Clone(snapshot.DiscoveredCommands) + snapshot.UnlockedAchievements = slices.Clone(snapshot.UnlockedAchievements) + return snapshot +} + +// CloneAttemptEvent returns a defensive copy suitable for retaining beyond a +// ReportAttempt call. +func CloneAttemptEvent(event AttemptEvent) AttemptEvent { + event.Snapshot = cloneAttemptSnapshot(event.Snapshot) + return event +} diff --git a/internal/game/companion_test.go b/internal/game/companion_test.go new file mode 100644 index 0000000..0bd0ee7 --- /dev/null +++ b/internal/game/companion_test.go @@ -0,0 +1,201 @@ +package game + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +type recordingAttemptReporter struct { + events []AttemptEvent + report func(AttemptEvent) +} + +func (r *recordingAttemptReporter) ReportAttempt(event AttemptEvent) { + event = CloneAttemptEvent(event) + r.events = append(r.events, event) + if r.report != nil { + r.report(event) + } +} + +func (r *recordingAttemptReporter) event(eventType AttemptEventType) (AttemptEvent, bool) { + for _, event := range r.events { + if event.Type == eventType { + return event, true + } + } + return AttemptEvent{}, false +} + +func TestCompanionSessionPublishesGuidanceProgressHintsAndCompletion(t *testing.T) { + catalog, item := seamCatalogMission(t, "1") + player := profile.New("tester") + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester") + reporter := &recordingAttemptReporter{} + out := &bytes.Buffer{} + session := Session{ + Mission: item, + Player: &player, + Saver: store, + Reporter: reporter, + Companion: true, + Out: out, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"hint", "pwd"}}, + Catalog: catalog, + } + + result, err := session.Run() + if err != nil { + t.Fatal(err) + } + if !result.Completed || result.XPAwarded != 30 { + t.Fatalf("result = %#v", result) + } + wantTypes := []AttemptEventType{AttemptStarted, AttemptHint, AttemptProgress, AttemptCompleted} + if len(reporter.events) != len(wantTypes) { + t.Fatalf("event types = %#v, want %#v", eventTypes(reporter.events), wantTypes) + } + for index, want := range wantTypes { + if reporter.events[index].Type != want { + t.Fatalf("event %d type = %q, want %q", index, reporter.events[index].Type, want) + } + } + + started := reporter.events[0].Snapshot + if started.State != AttemptStateActive || started.Title != item.Title || started.Objective != item.Objective || started.SatisfiedOutcomes != 0 { + t.Fatalf("started snapshot = %#v", started) + } + if started.WorldNumber != 1 || started.StageNumber != 1 || started.HintCount != 2 || len(started.RevealedHints) != 0 { + t.Fatalf("started placement and hints = %#v", started) + } + hinted := reporter.events[1].Snapshot + if hinted.HintsUsed != 1 || len(hinted.RevealedHints) != 1 || hinted.RevealedHints[0] != item.Hints[0] || hinted.RewardAvailable != 30 { + t.Fatalf("hint snapshot = %#v", hinted) + } + completed := reporter.events[3].Snapshot + if completed.State != AttemptStateCompleted || completed.SatisfiedOutcomes != 1 || completed.XPAwarded != 30 || !completed.FirstCompletion { + t.Fatalf("completed snapshot = %#v", completed) + } + if completed.Explanation != item.Explanation || len(completed.DiscoveredCommands) != 1 || completed.DiscoveredCommands[0] != "pwd" { + t.Fatalf("completed explanation and commands = %#v", completed) + } + + terminal := out.String() + for _, hidden := range []string{item.Story, item.Objective, item.Hints[0], item.Explanation, "Commands you may need to solve this level:"} { + if strings.Contains(terminal, hidden) { + t.Errorf("companion terminal output leaked presentation text %q:\n%s", hidden, terminal) + } + } + for _, visible := range []string{"Mission 01 ready in the web companion", "Hint 1/2 revealed in the web companion", "/home/operator", "✓ Mission complete!", "+30 XP"} { + if !strings.Contains(terminal, visible) { + t.Errorf("companion terminal output missing %q:\n%s", visible, terminal) + } + } + + encoded, err := json.Marshal(completed) + if err != nil { + t.Fatal(err) + } + for _, private := range []string{`"setup"`, `"validation"`, "WELCOME.txt"} { + if strings.Contains(string(encoded), private) { + t.Errorf("public companion snapshot contains private mission data %q: %s", private, encoded) + } + } +} + +func TestCompanionPublishesCompletionOnlyAfterCleanupAndPersistence(t *testing.T) { + catalog, item := seamCatalogMission(t, "1") + player := profile.New("tester") + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester") + environment := &seamEnvironment{ + prompt: "/generic", + execute: func(_ context.Context, _ string) (Execution, error) { + return Execution{Output: "/home/operator\n"}, nil + }, + } + reporter := &recordingAttemptReporter{} + reporter.report = func(event AttemptEvent) { + if event.Type != AttemptCompleted { + return + } + if environment.closeCount != 1 { + t.Errorf("completion published after %d closes, want 1", environment.closeCount) + } + persisted, err := store.Load() + if err != nil { + t.Errorf("load profile during completion event: %v", err) + return + } + if !persisted.IsComplete(item.ID) { + t.Error("completion published before profile persistence") + } + } + session := Session{ + Mission: item, + Player: &player, + Saver: store, + Reporter: reporter, + Companion: true, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"solve"}}, + Catalog: catalog, + Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { + return environment, nil + }), + } + if _, err := session.Run(); err != nil { + t.Fatal(err) + } + if _, found := reporter.event(AttemptCompleted); !found { + t.Fatal("completion event was not published") + } +} + +func TestCompanionReportsRestartAndPause(t *testing.T) { + catalog, item := seamCatalogMission(t, "4") + player := profile.New("tester") + reporter := &recordingAttemptReporter{} + session := Session{ + Mission: item, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Reporter: reporter, + Companion: true, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"touch report.txt", "restart", "quit"}}, + Catalog: catalog, + } + result, err := session.Run() + if err != nil { + t.Fatal(err) + } + if !result.Quit { + t.Fatalf("result = %#v", result) + } + restarted, found := reporter.event(AttemptRestarted) + if !found || restarted.Snapshot.SatisfiedOutcomes != 0 || restarted.Snapshot.State != AttemptStateActive { + t.Fatalf("restart event = %#v, found = %v", restarted, found) + } + paused, found := reporter.event(AttemptPaused) + if !found || paused.Snapshot.State != AttemptStatePaused { + t.Fatalf("pause event = %#v, found = %v", paused, found) + } +} + +func eventTypes(events []AttemptEvent) []AttemptEventType { + types := make([]AttemptEventType, len(events)) + for index, event := range events { + types[index] = event.Type + } + return types +} diff --git a/internal/game/session.go b/internal/game/session.go index e1b526f..a990ab5 100644 --- a/internal/game/session.go +++ b/internal/game/session.go @@ -20,6 +20,8 @@ type Session struct { Mission mission.Mission Player *profile.Profile Saver ProfileSaver + Reporter AttemptReporter + Companion bool In io.Reader Out io.Writer ErrOut io.Writer @@ -85,7 +87,19 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { replaying := s.Player.IsComplete(s.Mission.ID) hintsUsed := s.Player.MissionHints(s.Mission.ID) - printMission(s.Out, s.Mission, hintsUsed, replaying, s.Catalog, s.Style) + currentOutcomes := []outcomeResult(nil) + if s.Reporter != nil { + currentOutcomes, err = evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, "") + if err != nil { + return SessionResult{}, fmt.Errorf("prepare companion mission status: %w", err) + } + s.reportAttempt(AttemptStarted, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, nil, nil, nil) + } + if s.Companion { + fmt.Fprintf(s.Out, "\nMission %02d ready in the web companion. Enter lab commands below.\n", s.Mission.Number) + } else { + printMission(s.Out, s.Mission, hintsUsed, replaying, s.Catalog, s.Style) + } reader := s.Reader if reader == nil { reader = NewCommandLineReader(s.In, s.Out) @@ -102,6 +116,7 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { line, readErr := reader.ReadLine(s.Style.Prompt(environment.PromptLabel()), environment.CompletionSource()) if errors.Is(readErr, io.EOF) { fmt.Fprintln(s.Out) + s.reportAttempt(AttemptPaused, AttemptStatePaused, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) return SessionResult{Quit: true, HintsUsed: hintsUsed}, nil } if readErr != nil { @@ -230,6 +245,7 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { switch line { case "quit", "exit", ":q": fmt.Fprintln(s.Out, s.Style.Accent("Mission paused. Your profile progress is safe.")) + s.reportAttempt(AttemptPaused, AttemptStatePaused, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) return SessionResult{Quit: true, HintsUsed: hintsUsed}, nil case "hint": if hintsUsed >= len(s.Mission.Hints) { @@ -251,18 +267,34 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { if cost == 0 { costLabel = "no XP cost" } - prefix := fmt.Sprintf("Hint %d/%d (%s):", hintsUsed, len(s.Mission.Hints), costLabel) - fmt.Fprintf(s.Out, "%s %s\n", s.Style.Warning(prefix), s.Mission.Hints[hintsUsed-1]) + s.reportAttempt(AttemptHint, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) + if s.Companion { + message := fmt.Sprintf("Hint %d/%d revealed in the web companion (%s).", hintsUsed, len(s.Mission.Hints), costLabel) + fmt.Fprintln(s.Out, s.Style.Warning(message)) + } else { + prefix := fmt.Sprintf("Hint %d/%d (%s):", hintsUsed, len(s.Mission.Hints), costLabel) + fmt.Fprintf(s.Out, "%s %s\n", s.Style.Warning(prefix), s.Mission.Hints[hintsUsed-1]) + } continue case "objective": - fmt.Fprintf(s.Out, "%s\n%s\n\n%s\n", s.Style.Section("OBJECTIVE"), s.Mission.Objective, s.Style.CommandGuide(s.Mission.SuggestedCommands)) + if s.Companion { + fmt.Fprintln(s.Out, s.Style.Accent("The objective and suggested commands are shown in the web companion.")) + } else { + fmt.Fprintf(s.Out, "%s\n%s\n\n%s\n", s.Style.Section("OBJECTIVE"), s.Mission.Objective, s.Style.CommandGuide(s.Mission.SuggestedCommands)) + } continue case "status": outcomes, err := evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, lastOutput) if err != nil { return SessionResult{}, fmt.Errorf("check mission status: %w", err) } - printOutcomeStatus(s.Out, outcomes, s.Style) + currentOutcomes = outcomes + s.reportAttempt(AttemptProgress, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) + if s.Companion { + fmt.Fprintln(s.Out, s.Style.Accent("Mission status refreshed in the web companion.")) + } else { + printOutcomeStatus(s.Out, outcomes, s.Style) + } continue case "restart": if err := environment.close(); err != nil { @@ -273,10 +305,21 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { return SessionResult{}, fmt.Errorf("restart mission: %w", err) } lastOutput = "" + if s.Reporter != nil { + currentOutcomes, err = evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, "") + if err != nil { + return SessionResult{}, fmt.Errorf("refresh companion mission status: %w", err) + } + s.reportAttempt(AttemptRestarted, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) + } fmt.Fprintln(s.Out, s.Style.Accent("Mission environment restarted. Hints and command mastery are retained.")) continue case "?", "guide": - printMissionControls(s.Out, s.Style) + if s.Companion { + fmt.Fprintln(s.Out, s.Style.Accent("Mission controls and guidance are shown in the web companion.")) + } else { + printMissionControls(s.Out, s.Style) + } continue } @@ -336,10 +379,14 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { if err != nil { return SessionResult{}, fmt.Errorf("validate mission: %w", err) } + currentOutcomes = outcomes + s.reportAttempt(AttemptProgress, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, unlocked) if !allOutcomesSatisfied(outcomes) { printAchievements(s.Out, unlocked, s.Style) - message := fmt.Sprintf("Progress — %d/%d outcome checks satisfied. Type status to see what remains.", satisfiedOutcomeCount(outcomes), len(outcomes)) - fmt.Fprintln(s.Out, s.Style.Accent(message)) + if !s.Companion { + message := fmt.Sprintf("Progress — %d/%d outcome checks satisfied. Type status to see what remains.", satisfiedOutcomeCount(outcomes), len(outcomes)) + fmt.Fprintln(s.Out, s.Style.Accent(message)) + } continue } if err := environment.close(); err != nil { @@ -357,11 +404,70 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { if err := s.Saver.Save(*s.Player); err != nil { return SessionResult{}, err } - printCompletion(s.Out, s.Mission, xp, firstCompletion, practiced, discovered, unlocked, s.Style) + s.reportAttempt(AttemptCompleted, AttemptStateCompleted, currentOutcomes, hintsUsed, replaying, xp, firstCompletion, practiced, discovered, unlocked) + if s.Companion { + printCompanionCompletion(s.Out, xp, firstCompletion, unlocked, s.Style) + } else { + printCompletion(s.Out, s.Mission, xp, firstCompletion, practiced, discovered, unlocked, s.Style) + } return SessionResult{Completed: true, XPAwarded: xp, HintsUsed: hintsUsed, WorldRoute: worldRoute}, nil } } +func (s Session) reportAttempt(eventType AttemptEventType, state string, outcomes []outcomeResult, hintsUsed int, replaying bool, xp int, firstCompletion bool, practiced, discovered []string, unlocked []profile.Achievement) { + if s.Reporter == nil { + return + } + placement, _ := s.Catalog.Placement(s.Mission.ID) + revealedCount := min(hintsUsed, len(s.Mission.Hints)) + revealedHints := slices.Clone(s.Mission.Hints[:revealedCount]) + publicOutcomes := make([]AttemptOutcome, len(outcomes)) + for index, outcome := range outcomes { + publicOutcomes[index] = AttemptOutcome{Description: outcome.Description, Satisfied: outcome.Satisfied} + } + achievements := make([]string, len(unlocked)) + for index, achievement := range unlocked { + achievements[index] = achievement.Title + } + explanation := "" + if state == AttemptStateCompleted { + explanation = s.Mission.Explanation + } + s.Reporter.ReportAttempt(AttemptEvent{ + Type: eventType, + Snapshot: AttemptSnapshot{ + MissionID: s.Mission.ID, + Number: s.Mission.Number, + Title: s.Mission.Title, + Track: s.Mission.EffectiveTrack(), + WorldNumber: placement.WorldNumber, + WorldTotal: placement.WorldTotal, + WorldName: placement.WorldName, + StageNumber: placement.StageNumber, + StageTotal: placement.StageTotal, + Difficulty: s.Mission.Difficulty, + Story: s.Mission.Story, + Objective: s.Mission.Objective, + SuggestedCommands: slices.Clone(s.Mission.SuggestedCommands), + RevealedHints: revealedHints, + HintCount: len(s.Mission.Hints), + HintsUsed: hintsUsed, + Outcomes: publicOutcomes, + SatisfiedOutcomes: satisfiedOutcomeCount(outcomes), + RewardAvailable: AdjustedReward(s.Mission, hintsUsed), + BaseReward: s.Mission.Rewards.XP, + Replaying: replaying, + State: state, + Explanation: explanation, + XPAwarded: xp, + FirstCompletion: firstCompletion, + PracticedCommands: slices.Clone(practiced), + DiscoveredCommands: slices.Clone(discovered), + UnlockedAchievements: achievements, + }, + }) +} + func printMission(out io.Writer, item mission.Mission, hintsUsed int, completed bool, catalog mission.Catalog, style ui.Style) { heading := fmt.Sprintf("MISSION %02d: %s", item.Number, item.Title) fmt.Fprintf(out, "\n%s\n", style.Header(heading)) @@ -568,6 +674,17 @@ func printCompletion(out io.Writer, item mission.Mission, xp int, first bool, pr fmt.Fprintf(out, "\n%s\n", item.Explanation) } +func printCompanionCompletion(out io.Writer, xp int, first bool, unlocked []profile.Achievement, style ui.Style) { + fmt.Fprintf(out, "\n%s\n", style.Success("✓ Mission complete!")) + if first { + fmt.Fprintln(out, style.Reward(fmt.Sprintf("+%d XP", xp))) + } else { + fmt.Fprintln(out, style.Accent("Replay complete — XP was already claimed.")) + } + printAchievements(out, unlocked, style) + fmt.Fprintln(out, style.Accent("The explanation and completed objective are shown in the web companion.")) +} + func printAchievements(out io.Writer, unlocked []profile.Achievement, style ui.Style) { for _, achievement := range unlocked { fmt.Fprintf(out, "%s %s — %s\n", style.Achievement("★ Achievement unlocked:"), achievement.Title, achievement.Description) diff --git a/internal/webapp/server.go b/internal/webapp/server.go new file mode 100644 index 0000000..896b661 --- /dev/null +++ b/internal/webapp/server.go @@ -0,0 +1,353 @@ +// Package webapp serves the local, read-only OpsQuest mission companion. +package webapp + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "embed" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/aleksandergregersen/opsquest/internal/game" +) + +const companionCookie = "opsquest_companion" + +//go:embed static/* +var staticFiles embed.FS + +type publication struct { + id uint64 + data []byte +} + +// Server is a loopback-only HTTP projection of the active game session. +// Command execution and validation never enter this package. +type Server struct { + baseURL string + host string + pairToken string + sessionToken string + httpServer *http.Server + done chan struct{} + closeOnce sync.Once + + pairMu sync.Mutex + paired bool + + mu sync.Mutex + sequence uint64 + current publication + hasCurrent bool + subscribers map[chan publication]struct{} +} + +// Start creates a companion bound to an ephemeral IPv4 loopback port. The +// returned URL performs a one-time pairing exchange and must be opened while +// the play command remains active. +func Start(ctx context.Context) (*Server, error) { + if ctx == nil { + ctx = context.Background() + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen on loopback: %w", err) + } + pairToken, err := randomToken() + if err != nil { + _ = listener.Close() + return nil, fmt.Errorf("create pairing token: %w", err) + } + sessionToken, err := randomToken() + if err != nil { + _ = listener.Close() + return nil, fmt.Errorf("create companion session: %w", err) + } + + host := listener.Addr().String() + server := &Server{ + baseURL: "http://" + host, + host: host, + pairToken: pairToken, + sessionToken: sessionToken, + done: make(chan struct{}), + subscribers: make(map[chan publication]struct{}), + } + mux := http.NewServeMux() + mux.HandleFunc("/pair", server.handlePair) + mux.HandleFunc("/api/state", server.requireSession(server.handleState)) + mux.HandleFunc("/api/events", server.requireSession(server.handleEvents)) + mux.HandleFunc("/app.css", server.requireSession(server.handleCSS)) + mux.HandleFunc("/app.js", server.requireSession(server.handleJavaScript)) + mux.HandleFunc("/", server.requireSession(server.handleIndex)) + server.httpServer = &http.Server{ + Handler: server.validateRequest(mux), + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 75 * time.Second, + MaxHeaderBytes: 16 * 1024, + } + + go func() { + _ = server.httpServer.Serve(listener) + }() + go func() { + select { + case <-ctx.Done(): + shutdownContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Close(shutdownContext) + case <-server.done: + } + }() + return server, nil +} + +func randomToken() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} + +// URL returns the one-time local pairing URL. +func (s *Server) URL() string { + return s.baseURL + "/pair?token=" + url.QueryEscape(s.pairToken) +} + +// Close stops accepting companion requests and closes active event streams. +func (s *Server) Close(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + var closeErr error + s.closeOnce.Do(func() { + close(s.done) + closeErr = s.httpServer.Shutdown(ctx) + if errors.Is(closeErr, http.ErrServerClosed) { + closeErr = nil + } + }) + return closeErr +} + +// ReportAttempt publishes the latest sanitized session snapshot without +// blocking command execution. Slow browser subscribers skip stale snapshots +// and converge on the newest one. +func (s *Server) ReportAttempt(event game.AttemptEvent) { + event = game.CloneAttemptEvent(event) + data, err := json.Marshal(event) + if err != nil { + return + } + + s.mu.Lock() + s.sequence++ + item := publication{id: s.sequence, data: data} + s.current = item + s.hasCurrent = true + for subscriber := range s.subscribers { + select { + case subscriber <- item: + default: + select { + case <-subscriber: + default: + } + select { + case subscriber <- item: + default: + } + } + } + s.mu.Unlock() +} + +func (s *Server) validateRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + s.writeSecurityHeaders(writer) + if request.Host != s.host { + http.Error(writer, "invalid companion host", http.StatusMisdirectedRequest) + return + } + if origin := request.Header.Get("Origin"); origin != "" && origin != s.baseURL { + http.Error(writer, "invalid companion origin", http.StatusForbidden) + return + } + next.ServeHTTP(writer, request) + }) +} + +func (s *Server) writeSecurityHeaders(writer http.ResponseWriter) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") + writer.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + writer.Header().Set("Permissions-Policy", "camera=(), geolocation=(), microphone=()") + writer.Header().Set("Referrer-Policy", "no-referrer") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("X-Frame-Options", "DENY") +} + +func (s *Server) handlePair(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + token := request.URL.Query().Get("token") + s.pairMu.Lock() + valid := !s.paired && constantTimeEqual(token, s.pairToken) + if valid { + s.paired = true + } + s.pairMu.Unlock() + if !valid { + http.Error(writer, "pairing link is invalid or has already been used", http.StatusUnauthorized) + return + } + http.SetCookie(writer, &http.Cookie{ + Name: companionCookie, + Value: s.sessionToken, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + http.Redirect(writer, request, "/", http.StatusSeeOther) +} + +func constantTimeEqual(actual, expected string) bool { + return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 +} + +func (s *Server) requireSession(next http.HandlerFunc) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + cookie, err := request.Cookie(companionCookie) + if err != nil || !constantTimeEqual(cookie.Value, s.sessionToken) { + http.Error(writer, "open the pairing URL printed by OpsQuest", http.StatusUnauthorized) + return + } + next(writer, request) + } +} + +func (s *Server) handleIndex(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || request.URL.Path != "/" { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + http.NotFound(writer, request) + return + } + s.serveStatic(writer, "static/index.html", "text/html; charset=utf-8") +} + +func (s *Server) handleCSS(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.serveStatic(writer, "static/app.css", "text/css; charset=utf-8") +} + +func (s *Server) handleJavaScript(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.serveStatic(writer, "static/app.js", "text/javascript; charset=utf-8") +} + +func (s *Server) serveStatic(writer http.ResponseWriter, name, contentType string) { + content, err := staticFiles.ReadFile(name) + if err != nil { + http.Error(writer, "companion asset unavailable", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", contentType) + _, _ = writer.Write(content) +} + +func (s *Server) handleState(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.mu.Lock() + item, available := s.current, s.hasCurrent + s.mu.Unlock() + if !available { + writer.WriteHeader(http.StatusNoContent) + return + } + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("X-OpsQuest-Event-ID", fmt.Sprint(item.id)) + _, _ = writer.Write(item.data) +} + +func (s *Server) handleEvents(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + flusher, supported := writer.(http.Flusher) + if !supported { + http.Error(writer, "streaming unsupported", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "text/event-stream") + writer.Header().Set("Connection", "keep-alive") + writer.Header().Set("X-Accel-Buffering", "no") + + subscriber := make(chan publication, 1) + s.mu.Lock() + s.subscribers[subscriber] = struct{}{} + current, available := s.current, s.hasCurrent + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.subscribers, subscriber) + s.mu.Unlock() + }() + + if available { + writeSSE(writer, current) + } else { + fmt.Fprint(writer, "event: waiting\ndata: {}\n\n") + } + flusher.Flush() + + keepAlive := time.NewTicker(15 * time.Second) + defer keepAlive.Stop() + for { + select { + case item := <-subscriber: + writeSSE(writer, item) + flusher.Flush() + case <-keepAlive.C: + fmt.Fprint(writer, ": keep-alive\n\n") + flusher.Flush() + case <-request.Context().Done(): + return + case <-s.done: + return + } + } +} + +func writeSSE(writer http.ResponseWriter, item publication) { + fmt.Fprintf(writer, "id: %d\nevent: snapshot\ndata: %s\n\n", item.id, item.data) +} diff --git a/internal/webapp/server_test.go b/internal/webapp/server_test.go new file mode 100644 index 0000000..a21159f --- /dev/null +++ b/internal/webapp/server_test.go @@ -0,0 +1,290 @@ +package webapp + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/game" +) + +func startTestServer(t *testing.T) *Server { + t.Helper() + server, err := Start(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + closeContext, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.Close(closeContext); err != nil { + t.Errorf("close companion: %v", err) + } + }) + return server +} + +func pairedClient(t *testing.T, server *Server) (*http.Client, string) { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + response, err := client.Get(server.URL()) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK || response.Request.URL.Path != "/" { + t.Fatalf("pairing response = %d at %s", response.StatusCode, response.Request.URL) + } + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "OpsQuest Mission Companion") { + t.Fatalf("paired index missing companion title: %s", body) + } + parsed, err := url.Parse(server.URL()) + if err != nil { + t.Fatal(err) + } + return client, parsed.Scheme + "://" + parsed.Host +} + +func TestServerRequiresOneTimePairingAndSetsSecurityHeaders(t *testing.T) { + server := startTestServer(t) + parsed, err := url.Parse(server.URL()) + if err != nil { + t.Fatal(err) + } + baseURL := parsed.Scheme + "://" + parsed.Host + + response, err := http.Get(baseURL + "/") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("unpaired status = %d, want %d", response.StatusCode, http.StatusUnauthorized) + } + response, err = http.Get(baseURL + "/pair?token=wrong") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("invalid pairing status = %d, want %d", response.StatusCode, http.StatusUnauthorized) + } + + client, _ := pairedClient(t, server) + response, err = client.Get(baseURL + "/app.css") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/css") { + t.Fatalf("authorized CSS response = %d %q", response.StatusCode, response.Header.Get("Content-Type")) + } + for _, header := range []string{"Content-Security-Policy", "Referrer-Policy", "X-Content-Type-Options", "X-Frame-Options"} { + if response.Header.Get(header) == "" { + t.Errorf("authorized response missing %s", header) + } + } + + secondResponse, err := http.Get(server.URL()) + if err != nil { + t.Fatal(err) + } + secondResponse.Body.Close() + if secondResponse.StatusCode != http.StatusUnauthorized { + t.Fatalf("reused pairing URL status = %d, want %d", secondResponse.StatusCode, http.StatusUnauthorized) + } +} + +func TestServerPublishesCurrentStateAndLiveEvents(t *testing.T) { + server := startTestServer(t) + client, baseURL := pairedClient(t, server) + + response, err := client.Get(baseURL + "/api/state") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusNoContent { + t.Fatalf("empty state status = %d, want %d", response.StatusCode, http.StatusNoContent) + } + + started := game.AttemptEvent{ + Type: game.AttemptStarted, + Snapshot: game.AttemptSnapshot{ + MissionID: "linux-orientation", + Number: 1, + Title: "Where Am I?", + Objective: "Print the current path.", + SuggestedCommands: []string{"pwd"}, + Outcomes: []game.AttemptOutcome{{Description: "Output matches", Satisfied: false}}, + State: game.AttemptStateActive, + }, + } + server.ReportAttempt(started) + started.Snapshot.SuggestedCommands[0] = "changed-after-publish" + started.Snapshot.Outcomes[0].Description = "changed-after-publish" + + response, err = client.Get(baseURL + "/api/state") + if err != nil { + t.Fatal(err) + } + var current game.AttemptEvent + if err := json.NewDecoder(response.Body).Decode(¤t); err != nil { + response.Body.Close() + t.Fatal(err) + } + response.Body.Close() + if current.Snapshot.SuggestedCommands[0] != "pwd" || current.Snapshot.Outcomes[0].Description != "Output matches" { + t.Fatalf("server retained caller-owned slices: %#v", current) + } + if response.Header.Get("X-OpsQuest-Event-ID") != "1" { + t.Fatalf("state event ID = %q, want 1", response.Header.Get("X-OpsQuest-Event-ID")) + } + + streamContext, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(streamContext, http.MethodGet, baseURL+"/api/events", nil) + if err != nil { + t.Fatal(err) + } + stream, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer stream.Body.Close() + reader := bufio.NewReader(stream.Body) + first := readSSEEvent(t, reader) + if !strings.Contains(first, `"type":"attempt_started"`) || !strings.Contains(first, "id: 1") { + t.Fatalf("initial SSE event = %q", first) + } + + completed := current + completed.Type = game.AttemptCompleted + completed.Snapshot.State = game.AttemptStateCompleted + completed.Snapshot.Outcomes[0].Satisfied = true + completed.Snapshot.SatisfiedOutcomes = 1 + server.ReportAttempt(completed) + second := readSSEEvent(t, reader) + if !strings.Contains(second, `"type":"attempt_completed"`) || !strings.Contains(second, "id: 2") { + t.Fatalf("live SSE event = %q", second) + } +} + +func TestServerRejectsConfusedHostsOriginsAndMethods(t *testing.T) { + server := startTestServer(t) + client, baseURL := pairedClient(t, server) + + request, err := http.NewRequest(http.MethodGet, baseURL+"/api/state", nil) + if err != nil { + t.Fatal(err) + } + request.Host = "attacker.example" + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusMisdirectedRequest { + t.Fatalf("confused Host status = %d, want %d", response.StatusCode, http.StatusMisdirectedRequest) + } + + request, err = http.NewRequest(http.MethodGet, baseURL+"/api/state", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", "https://attacker.example") + response, err = client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("cross-origin status = %d, want %d", response.StatusCode, http.StatusForbidden) + } + + request, err = http.NewRequest(http.MethodPost, baseURL+"/api/state", nil) + if err != nil { + t.Fatal(err) + } + response, err = client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusMethodNotAllowed || response.Header.Get("Allow") != http.MethodGet { + t.Fatalf("POST state response = %d Allow %q", response.StatusCode, response.Header.Get("Allow")) + } +} + +func TestServerClosesWhenApplicationContextIsCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + server, err := Start(ctx) + if err != nil { + t.Fatal(err) + } + cancel() + select { + case <-server.done: + case <-time.After(3 * time.Second): + t.Fatal("companion did not close after application context cancellation") + } +} + +func TestEmbeddedAssetsStaySelfContainedAndUseTextOnlyRendering(t *testing.T) { + html, err := staticFiles.ReadFile("static/index.html") + if err != nil { + t.Fatal(err) + } + javascript, err := staticFiles.ReadFile("static/app.js") + if err != nil { + t.Fatal(err) + } + stylesheet, err := staticFiles.ReadFile("static/app.css") + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"http://", "https://", "style="} { + if strings.Contains(string(html), forbidden) || strings.Contains(string(stylesheet), forbidden) { + t.Errorf("embedded browser assets contain forbidden external or inline value %q", forbidden) + } + } + for _, forbidden := range []string{"innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "eval(", ".style."} { + if strings.Contains(string(javascript), forbidden) { + t.Errorf("companion JavaScript uses forbidden rendering sink %q", forbidden) + } + } + if !strings.Contains(string(javascript), "textContent") { + t.Error("companion JavaScript does not use text-only DOM rendering") + } +} + +func readSSEEvent(t *testing.T, reader *bufio.Reader) string { + t.Helper() + var lines []string + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read SSE event: %v", err) + } + line = strings.TrimSuffix(line, "\n") + if line == "" { + return strings.Join(lines, "\n") + } + lines = append(lines, line) + } +} diff --git a/internal/webapp/static/app.css b/internal/webapp/static/app.css new file mode 100644 index 0000000..e3ddd38 --- /dev/null +++ b/internal/webapp/static/app.css @@ -0,0 +1,292 @@ +:root { + color-scheme: dark; + --bg: #07110f; + --surface: rgba(15, 31, 27, 0.88); + --surface-strong: #122620; + --line: rgba(184, 214, 197, 0.14); + --text: #eef8f2; + --muted: #9db4a8; + --green: #77f2a1; + --green-strong: #2bd66f; + --amber: #ffc86b; + --blue: #8bd8ff; + --danger: #ff8f8f; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.35); +} + +* { box-sizing: border-box; } + +html { min-height: 100%; } + +body { + min-height: 100vh; + margin: 0; + overflow-x: hidden; + color: var(--text); + background: + linear-gradient(rgba(119, 242, 161, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(119, 242, 161, 0.025) 1px, transparent 1px), + var(--bg); + background-size: 38px 38px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body::before { + position: fixed; + inset: 0; + z-index: -2; + content: ""; + background: radial-gradient(circle at 50% -10%, rgba(43, 214, 111, 0.13), transparent 43%); +} + +.ambient { + position: fixed; + z-index: -1; + width: 22rem; + height: 22rem; + border-radius: 999px; + filter: blur(90px); + opacity: 0.12; + pointer-events: none; +} + +.ambient-one { top: 18%; left: -12rem; background: var(--green); } +.ambient-two { right: -12rem; bottom: 8%; background: var(--blue); } + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 74px; + padding: 0 4vw; + border-bottom: 1px solid var(--line); + background: rgba(7, 17, 15, 0.82); + backdrop-filter: blur(18px); +} + +.brand { + display: flex; + gap: 0.75rem; + align-items: center; + color: var(--text); + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 2.45rem; + height: 2.45rem; + place-items: center; + border: 1px solid rgba(119, 242, 161, 0.45); + border-radius: 0.65rem; + color: var(--green); + background: rgba(119, 242, 161, 0.08); + box-shadow: inset 0 0 18px rgba(119, 242, 161, 0.07); + font: 700 0.8rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.05em; +} + +.brand strong, .brand small { display: block; } +.brand strong { font-size: 0.96rem; letter-spacing: 0.02em; } +.brand small { margin-top: 0.12rem; color: var(--muted); font-size: 0.72rem; } + +.connection { + display: flex; + gap: 0.55rem; + align-items: center; + padding: 0.48rem 0.72rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + background: rgba(255, 255, 255, 0.025); + font: 600 0.72rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.connection-dot { + width: 0.52rem; + height: 0.52rem; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 12px currentColor; +} + +.connection[data-state="live"] { color: var(--green); } +.connection[data-state="live"] .connection-dot { background: var(--green); } +.connection[data-state="offline"] { color: var(--danger); } +.connection[data-state="offline"] .connection-dot { background: var(--danger); } + +.shell { + width: min(1180px, 92vw); + margin: 0 auto; + padding: 3.5rem 0 2rem; +} + +.panel { + border: 1px solid var(--line); + border-radius: 1rem; + background: var(--surface); + box-shadow: var(--shadow); + backdrop-filter: blur(15px); +} + +.waiting { + max-width: 680px; + margin: 10vh auto 0; + padding: 3.5rem; + text-align: center; +} + +.terminal-glyph { + display: grid; + width: 4.5rem; + height: 4.5rem; + margin: 0 auto 1.5rem; + place-items: center; + border: 1px solid rgba(119, 242, 161, 0.4); + border-radius: 1rem; + color: var(--green); + background: rgba(119, 242, 161, 0.06); + font: 700 1.2rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.waiting h1 { margin: 0.35rem 0 0.8rem; font-size: clamp(2rem, 5vw, 3.4rem); } +.waiting > p:last-child { max-width: 32rem; margin: auto; color: var(--muted); line-height: 1.7; } + +.mission-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 1.1rem; +} + +.hero, .objective, .completion { grid-column: 1; } +.hero { padding: clamp(1.5rem, 4vw, 3.2rem); } + +.mission-kicker, .section-heading, .hero-title-row, .progress-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.eyebrow { + margin: 0; + color: var(--green); + font: 700 0.72rem/1.25 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.difficulty, .state-pill, .hint-count { + padding: 0.36rem 0.58rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font: 650 0.68rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; + text-transform: uppercase; +} + +.hero-title-row { align-items: end; margin-top: 2rem; } +.mission-number { margin: 0 0 0.4rem; color: var(--muted); font: 600 0.78rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +h1, h2 { letter-spacing: -0.035em; } +.hero h1 { max-width: 18ch; margin: 0; font-size: clamp(2.2rem, 6vw, 4.9rem); line-height: 0.98; } + +.reward { + min-width: 7.4rem; + padding: 0.9rem; + border: 1px solid rgba(255, 200, 107, 0.24); + border-radius: 0.85rem; + text-align: right; + background: rgba(255, 200, 107, 0.055); +} + +.reward span { display: block; color: var(--amber); font: 760 1.8rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.reward small { display: block; margin-top: 0.25rem; color: var(--muted); font-size: 0.68rem; } + +.story { max-width: 69ch; margin: 2.5rem 0 0; color: #cadbd2; font-size: 1.03rem; line-height: 1.75; } + +.objective { padding: clamp(1.4rem, 3vw, 2.2rem); } +.panel-accent { border-top-color: rgba(119, 242, 161, 0.52); } +.section-heading { align-items: flex-start; } +.section-heading.tight { align-items: center; } +.section-heading h2 { max-width: 25ch; margin: 0.55rem 0 0; font-size: clamp(1.35rem, 3vw, 2.15rem); line-height: 1.22; } +.state-pill[data-state="completed"] { border-color: rgba(119, 242, 161, 0.35); color: var(--green); background: rgba(119, 242, 161, 0.08); } +.state-pill[data-state="paused"] { color: var(--amber); } + +.progress-summary { margin: 2rem 0 1rem; } +.progress-track { flex: 1; height: 0.45rem; overflow: hidden; border: 0; border-radius: 1rem; appearance: none; background: rgba(255, 255, 255, 0.07); } +.progress-track::-webkit-progress-bar { border-radius: 1rem; background: rgba(255, 255, 255, 0.07); } +.progress-track::-webkit-progress-value { border-radius: 1rem; background: linear-gradient(90deg, var(--green-strong), var(--green)); transition: width 260ms ease; } +.progress-track::-moz-progress-bar { border-radius: 1rem; background: linear-gradient(90deg, var(--green-strong), var(--green)); transition: width 260ms ease; } +.progress-summary strong { min-width: 6.5rem; color: var(--muted); font: 650 0.75rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: right; } + +.outcomes { display: grid; gap: 0.55rem; margin: 0; padding: 0; list-style: none; } +.outcome { display: flex; gap: 0.72rem; align-items: center; padding: 0.8rem 0.9rem; border-radius: 0.72rem; color: var(--muted); background: rgba(255, 255, 255, 0.025); transition: color 180ms ease, background 180ms ease; } +.outcome-marker { display: grid; flex: 0 0 1.35rem; width: 1.35rem; height: 1.35rem; place-items: center; border: 1px solid rgba(157, 180, 168, 0.32); border-radius: 50%; font-size: 0.72rem; } +.outcome.complete { color: var(--text); background: rgba(119, 242, 161, 0.055); } +.outcome.complete .outcome-marker { border-color: rgba(119, 242, 161, 0.55); color: var(--green); background: rgba(119, 242, 161, 0.08); } + +.sidebar { grid-column: 2; grid-row: 1 / span 3; display: grid; align-content: start; gap: 1.1rem; } +.compact { padding: 1.25rem; box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22); } +.command-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; } +code, .command-chip { border: 1px solid rgba(139, 216, 255, 0.18); border-radius: 0.38rem; color: var(--blue); background: rgba(139, 216, 255, 0.055); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +code { padding: 0.08rem 0.3rem; } +.command-chip { padding: 0.45rem 0.58rem; font-size: 0.76rem; } +.muted { margin: 1rem 0 0; color: var(--muted); font-size: 0.78rem; line-height: 1.55; } + +.hints { display: grid; gap: 0.55rem; margin-top: 1rem; } +.hint { padding: 0.82rem; border: 1px solid var(--line); border-radius: 0.7rem; color: #d7e6de; background: rgba(255, 255, 255, 0.025); font-size: 0.83rem; line-height: 1.55; } +.hint strong { display: block; margin-bottom: 0.35rem; color: var(--amber); font: 700 0.66rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.hint.locked { color: #71847a; } +.hint.locked strong { color: #71847a; } + +.field-guide details { border-bottom: 1px solid var(--line); } +.field-guide details:last-of-type { border-bottom: 0; } +.field-guide summary { padding: 0.78rem 0; color: #d8e7df; cursor: pointer; font-size: 0.8rem; font-weight: 650; } +.field-guide summary::marker { color: var(--green); } +.field-guide dl { display: grid; gap: 0.1rem; margin: 0 0 0.65rem; } +.field-guide dl div { display: grid; grid-template-columns: 5.3rem 1fr; gap: 0.55rem; padding: 0.62rem 0; border-bottom: 1px solid var(--line); } +.field-guide dl div:last-child { border-bottom: 0; } +.field-guide dt, .field-guide dd { margin: 0; } +.field-guide dd { color: var(--muted); font-size: 0.78rem; line-height: 1.45; } +.guide-copy { margin: 0 0 0.9rem; color: var(--muted); font-size: 0.78rem; line-height: 1.55; } +.safety-note { margin: 1rem 0 0; padding: 0.75rem; border-left: 2px solid rgba(119, 242, 161, 0.45); color: var(--muted); background: rgba(119, 242, 161, 0.035); font-size: 0.75rem; line-height: 1.5; } +.safety-note span { color: var(--green); } + +.completion { display: grid; grid-template-columns: auto 1fr; gap: 1.15rem; padding: 1.6rem; border-color: rgba(119, 242, 161, 0.38); background: linear-gradient(125deg, rgba(43, 214, 111, 0.11), var(--surface)); } +.completion-mark { display: grid; width: 2.8rem; height: 2.8rem; place-items: center; border-radius: 50%; color: #062714; background: var(--green); font-weight: 900; } +.completion h2 { margin: 0.35rem 0 0.6rem; } +.completion p:not(.eyebrow) { margin: 0; color: #c8dbd1; line-height: 1.65; } +.completion-meta { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; } +.completion-meta span { padding: 0.38rem 0.56rem; border: 1px solid rgba(119, 242, 161, 0.2); border-radius: 999px; color: var(--green); background: rgba(119, 242, 161, 0.05); font: 650 0.68rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; } + +footer { display: flex; align-items: center; justify-content: center; gap: 0.75rem; padding: 1.5rem 1rem 2.5rem; color: #667b70; font: 650 0.68rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: 0.08em; } +.footer-divider { width: 2.5rem; height: 1px; background: var(--line); } +.hidden { display: none !important; } + +@media (max-width: 850px) { + .mission-layout { grid-template-columns: 1fr; } + .hero, .objective, .completion, .sidebar { grid-column: 1; grid-row: auto; } + .sidebar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .field-guide { grid-column: 1 / -1; } +} + +@media (max-width: 580px) { + .topbar { padding: 0 1rem; } + .connection-label { max-width: 9rem; } + .shell { width: min(100% - 1.2rem, 1180px); padding-top: 1.2rem; } + .waiting { padding: 2rem 1.3rem; } + .hero-title-row { align-items: flex-start; flex-direction: column; } + .reward { min-width: 0; text-align: left; } + .story { margin-top: 1.7rem; } + .section-heading:not(.tight) { flex-direction: column; } + .state-pill { align-self: flex-start; } + .sidebar { grid-template-columns: 1fr; } + .field-guide { grid-column: auto; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } +} diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js new file mode 100644 index 0000000..cb51251 --- /dev/null +++ b/internal/webapp/static/app.js @@ -0,0 +1,153 @@ +"use strict"; + +const byID = (id) => document.getElementById(id); +let latestState = ""; +const elements = { + waiting: byID("waiting"), + layout: byID("mission-layout"), + connection: byID("connection"), + connectionLabel: byID("connection-label"), + location: byID("location"), + difficulty: byID("difficulty"), + missionNumber: byID("mission-number"), + title: byID("title"), + reward: byID("reward"), + story: byID("story"), + objective: byID("objective"), + statePill: byID("state-pill"), + progressTrack: byID("progress-track"), + progressLabel: byID("progress-label"), + outcomes: byID("outcomes"), + commands: byID("commands"), + hintCount: byID("hint-count"), + hints: byID("hints"), + hintInstruction: byID("hint-instruction"), + completion: byID("completion"), + completionTitle: byID("completion-title"), + explanation: byID("explanation"), + completionMeta: byID("completion-meta"), +}; + +function textNode(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + node.textContent = text; + return node; +} + +function setConnection(state, label) { + elements.connection.dataset.state = state; + elements.connectionLabel.textContent = label; +} + +function locationLabel(snapshot) { + const track = snapshot.track === "docker" ? "Docker" : "Linux"; + if (!snapshot.world_number) return track; + return `${track} · World ${snapshot.world_number}/${snapshot.world_total}: ${snapshot.world_name} · Stage ${snapshot.stage_number}/${snapshot.stage_total}`; +} + +function renderOutcomes(snapshot) { + elements.outcomes.replaceChildren(); + for (const outcome of snapshot.outcomes || []) { + const row = textNode("li", `outcome${outcome.satisfied ? " complete" : ""}`, ""); + row.append(textNode("span", "outcome-marker", outcome.satisfied ? "✓" : "")); + row.append(textNode("span", "", outcome.description)); + elements.outcomes.append(row); + } +} + +function renderCommands(snapshot) { + elements.commands.replaceChildren(); + for (const command of snapshot.suggested_commands || []) { + elements.commands.append(textNode("code", "command-chip", command)); + } +} + +function renderHints(snapshot) { + elements.hints.replaceChildren(); + const revealed = snapshot.revealed_hints || []; + for (let index = 0; index < snapshot.hint_count; index += 1) { + const visible = index < revealed.length; + const hint = textNode("div", `hint${visible ? "" : " locked"}`, ""); + hint.append(textNode("strong", "", `Hint ${index + 1}`)); + hint.append(textNode("span", "", visible ? revealed[index] : "Locked — reveal from the terminal")); + elements.hints.append(hint); + } + elements.hintCount.textContent = `${snapshot.hints_used} / ${snapshot.hint_count}`; + elements.hintInstruction.classList.toggle("hidden", snapshot.hints_used >= snapshot.hint_count); +} + +function addMeta(text) { + elements.completionMeta.append(textNode("span", "", text)); +} + +function renderCompletion(snapshot) { + const complete = snapshot.state === "completed"; + elements.completion.classList.toggle("hidden", !complete); + if (!complete) return; + elements.completionTitle.textContent = snapshot.first_completion + ? `Objective confirmed · +${snapshot.xp_awarded} XP` + : "Replay objective confirmed"; + elements.explanation.textContent = snapshot.explanation || "The observable mission outcome has been confirmed."; + elements.completionMeta.replaceChildren(); + for (const command of snapshot.discovered_commands || []) addMeta(`New command: ${command}`); + for (const achievement of snapshot.unlocked_achievements || []) addMeta(`Achievement: ${achievement}`); +} + +function render(event) { + const snapshot = event && event.snapshot; + if (!snapshot) return; + latestState = snapshot.state; + elements.waiting.classList.add("hidden"); + elements.layout.classList.remove("hidden"); + elements.location.textContent = locationLabel(snapshot); + elements.difficulty.textContent = snapshot.difficulty; + elements.missionNumber.textContent = `Mission ${String(snapshot.number).padStart(2, "0")}`; + elements.title.textContent = snapshot.title; + elements.reward.textContent = snapshot.replaying ? "Claimed" : String(snapshot.reward_available); + elements.story.textContent = snapshot.story; + elements.objective.textContent = snapshot.objective; + elements.statePill.textContent = snapshot.state; + elements.statePill.dataset.state = snapshot.state; + + const total = (snapshot.outcomes || []).length; + const satisfied = snapshot.satisfied_outcomes || 0; + elements.progressLabel.textContent = `${satisfied} / ${total} checks`; + elements.progressTrack.max = total || 1; + elements.progressTrack.value = satisfied; + + renderOutcomes(snapshot); + renderCommands(snapshot); + renderHints(snapshot); + renderCompletion(snapshot); +} + +async function loadCurrentState() { + try { + const response = await fetch("/api/state", { cache: "no-store" }); + if (response.status === 204) return; + if (!response.ok) throw new Error(`state request failed: ${response.status}`); + render(await response.json()); + } catch (_error) { + setConnection("offline", "CLI unavailable"); + } +} + +function connect() { + const events = new EventSource("/api/events"); + events.addEventListener("open", () => setConnection("live", "CLI connected")); + events.addEventListener("snapshot", (message) => { + try { + render(JSON.parse(message.data)); + } catch (_error) { + setConnection("offline", "Invalid update"); + } + }); + events.addEventListener("error", () => { + const ended = latestState === "completed" || latestState === "paused"; + setConnection("offline", ended ? "CLI session ended" : "Reconnecting to CLI"); + }); +} + +loadCurrentState(); +connect(); diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html new file mode 100644 index 0000000..aab3718 --- /dev/null +++ b/internal/webapp/static/index.html @@ -0,0 +1,135 @@ + + + + + + OpsQuest Mission Companion + + + +
+
+
+ + + + OpsQuest + Mission companion + + +
+ + Connecting to CLI +
+
+ +
+
+ +

Local session

+

Waiting for the mission

+

Keep this page open and return to your terminal. The objective will appear as soon as OpsQuest prepares the isolated lab.

+
+ + +
+ + + + + diff --git a/mkdocs.yml b/mkdocs.yml index 4663fea..6d4eae4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Play: - play/README.md - Quick start: play/quick-start.md + - Web mission companion: play/web-companion.md - Missions, hints, and progress: play/missions-and-progress.md - Controls and commands: play/controls-and-commands.md - Mission map: play/mission-map.md diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 7a92927..311d57b 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -91,6 +91,14 @@ once_output="$(printf 'pwd\n' | run_fresh_opsquest play --once 1)" assert_contains "one-shot play" "${once_output}" "NEXT RECOMMENDED" assert_not_contains "one-shot play" "${once_output}" "MISSION 02: Configuration Crawl" +web_output="$(printf 'pwd\n' | run_fresh_opsquest play --web --once 1)" +assert_no_ansi "web companion play" "${web_output}" +assert_contains "web companion play" "${web_output}" "WEB MISSION COMPANION" +assert_contains "web companion play" "${web_output}" "http://127.0.0.1:" +assert_contains "web companion play" "${web_output}" "Mission 01 ready in the web companion" +assert_contains "web companion play" "${web_output}" "✓ Mission complete!" +assert_not_contains "web companion play" "${web_output}" "Your first ticket simply says" + printf 'pwd\n' | run_route_opsquest play --once 1 >/dev/null replay_hints_output="$(printf 'hint\nhint\nhint\nquit\n' | run_route_opsquest play 1)" assert_contains "replay hints" "${replay_hints_output}" "Hint 1/2 (no XP cost):"