Skip to content

Execution Log: configurable CORS so the hosted dashboard can reach the service

Append-only log for issue-211. Ticket: #211.

How this session ran the loop

One cloud session, one pass, no human at the other end. Two consequences a reviewer should hold while reading:

  1. phase-selection was not run as a gate. The loop's rule is that a human ticks the phases; there was nobody to tick, and the session was started by the ticket itself. Phases assumed: the full spec chain, verification, self-review. brainstorming and the opt-in design-critic-review were not taken — the ticket states the problem and the remedy precisely enough that there was nothing to brainstorm, and no second model was available to critic the design.
  2. The chain was authored before the code, but approved by nobody. The artifacts are a proposal to ratify, not a locked chain. status: draft on all four says so.

Phase transitions

PhaseEnteredReviewed/approved byNotes
phase-selection2026-08-12Not run as a gate; see above
requirements-definition2026-08-12requirements.md — 4 requirements, 3 NFRs, 6 abuse cases. Risk tier 4: autonomy.inferFromChange matches sensitivePaths: **/*schema* (the CLI config schema gains a block) and the change widens who may read the control plane, so the gate is human-approves-pr and a named human security sign-off is required (security.review.humanSignOffMinTier: 4)
design2026-08-12design.md — one config resolver, one middleware, one start-up guard; the three deliberate absences (no enabled key, no origin regex, no new network boundary)
test-planning2026-08-12testing-plan.md — 10 rows in scope, 5 n/a with reasons, and a § Coverage gaps
tasks-breakdown2026-08-12tasks.md — 10 tasks, three independent roots off the schema
implementation2026-08-12Built. Tasks 1–9 complete
verification2026-08-12Testing plan executed: every activity but T11 (needs a human at a browser). 1819 tests pass; lint, format, types, markdown and schema validation clean
needs-review2026-08-12Handed to the PR

Pull requests

PRScope / tasksStatus
claude/github-issue-211-bvoxltthe whole work itemopen, awaiting human approval + security sign-off

Progress entries

2026-08-12 — the shape of the change

Read the service's three seams before writing anything: api/config.py (how service is resolved today), api/serve.py (the exposure guard, and where a refusal belongs), and api/mcp.py (whether an app-wide middleware could hand a browser the MCP endpoint). The third one decided the scope: the MCP transport keeps its own DNS-rebinding origin allowlist, pinned to loopback, so a CORS header cannot make /mcp drivable from a page — which is why the middleware could stay app-wide instead of being scoped to a sub-app.

Three things the research changed:

  • allow_private_network is not optional for the flagship case. A public HTTPS page reaching http://127.0.0.1 is exactly what Chromium gates behind a private-network preflight, so shipping CORS without answering it would have left the ticket's own scenario broken in the most common browser. Starlette supports it natively; the kwarg is passed only when the installed version accepts it, because the package floor (fastapi>=0.110) resolves to versions that predate it.
  • The middleware must be added last. Starlette wraps the most recently added middleware outermost, so adding CORS after _audit is what makes a preflight short-circuit before the audit trail — and, more to the point, before POST /api/v1/sessions/control's route. A test asserts nothing is emitted.
  • The enabled key was dropped. An empty allowOrigins is the off switch and the same condition the code branches on, so there is no boolean that can disagree with the list it guards.

2026-08-12 — self-review findings

Both found by re-reading the diff, both fixed before the PR:

  • _as_list raised TypeError on a non-sequence scalar (allowOrigins: 8787). The CLI config is loaded without schema validation, so that would have escaped serve's except ValueError as a traceback. It now resolves to one origin that matches nothing, with a test.
  • An origin pasted as a URL (https://…/the-loop/ui/) silently matches no request. _install_cors now warns once, naming the entries, rather than letting it read as "CORS is broken".

2026-08-12 — a pre-existing gap, closed in passing

docs/config/cli/service-options.md existed since issue-161 but was in neither the VitePress sidebar nor the "Options by area" table, so the page an operator now needs was unreachable by navigation. Both fixed here rather than filed: this work item is the reason somebody would go looking.

Documentation

DocChange
docs/config/cli/service-options.mdNew § Cross-origin access: the five keys with Type/Default, and a warning block stating what the default admits
docs/config/cli/index.md · docs/.vitepress/config.mtsThe service-options page added to the "Options by area" table and the sidebar (it was in neither)
docs/cli/commands/service.mdNew § The web dashboard, and CORS — why the hosted page now works with no gateway, and that this is a read permission, not a network one
docs/capabilities/control-plane.mdCurrent behaviour restated (the "no CORS headers are sent" bullet was made false by this change), plus the middleware-ordering and MCP-origin invariants, and an issue-211 history row
docs/decisions/decision-077.mdNew — the default-origin call, its cost, and the four alternatives
ui/README.md§ Reaching a service from a hosted page rewritten: same-machine needs nothing now; the tunnel is for another machine
skills/the-loop/templates/cli-config.yamlThe service block, with the cors sub-block and its warning, so /the-loop:init scaffolds it
.the-loop/cli-config.yamlThe same block, dogfooded here

The skill and its reference/ are untouched: this changes a service's configuration, not how the loop is run.

Capability docs

docs/capabilities/control-plane.md — updated in this PR (see the table above). No other capability doc describes the service's network posture.

Verification results

In testing-plan.md § Verification results, with evidence under evidence/. Summary: everything but T11 executed and passing; T11 (the hosted page against a real service, in a real browser) needs a human and is the one thing to do before merge.

Review cycles

CycleType (self/critic/security)ReviewerOutcomeLink
1selfthe-loop (this session)new findings — the TypeError escape and the pasted-URL silence, both above, both fixed with testsconfig.py, app.py
2selfthe-loop (this session)new finding — the preflight test proved the headers but not the claim that no operation runs; it now also asserts the event log stays empty, which is what actually pins the middleware orderingtest_api_cors_integration.py
3selfthe-loop (this session)new finding — the MCP negative test passed on the Host check, not the Origin check, so it would have kept passing if the origin allowlist were widened. Pinned to a 403 with an accepted Hosttest_api_cors_integration.py
4criticnot run. reviews.critics is empty in this repo's harness config and no second harness was available to this session
5securitynot run. Risk tier 4 requires a named human security sign-off; this session cannot sign off on its own widening of a read boundary. The material for it is requirements.md § Security considerations (6 abuse cases), design.md § Security design (each one's mechanism and its negative test) and decision-077

Released under the MIT License.