#1293 / test1293 — host-shared OpenCode server ADMISSION GUARD (fail-closed negative controls)

Runtime: Docker, `--network none` (no network, no credentials, no spawn).
Date: 2026-10-15 (UTC)
Code under test: agent-node/src/runtime/opencode-server-reuse.ts @ 498fed1e
                 + tests/test1293-opencode-server-reuse/ (this suite)

Commands
  sg docker -c 'docker build -f tests/test1293-opencode-server-reuse/Dockerfile -t anet-test1293 .'
  sg docker -c 'docker run --rm --network none -e SOURCE_COMMIT=498fed1e \
      -v "$PWD/docs/tests:/artifacts" anet-test1293'

Image inputs (pinned, same digests other suites use)
  oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4
  node:22-bookworm-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392
  bun test v1.3.14 (0d9b296a)

Property proven
  Admission to borrow an already-running host-shared OpenCode server (the PR
  #2776 `host shared registry → create default-selects shared` path) is granted
  ONLY when the server is observed as: loopback url + same uid + live
  pid/startTicks via a real probe + matching generation.
  Everything else falls back: cross-uid, non-loopback, dead pid, missing
  pid/startTicks, absent probe (probe_unavailable), throwing probe (probe_error,
  never throws), generation mismatch, empty/malformed observations.
  Node identity is deliberately NOT a gate (isolation is carried by each
  workspace's MCP credential and session binding), so another node on the host
  may borrow the same server. The admitted facts must come from actual
  observation — the shared registry carries no pid/startTicks/uid/generation, so
  a caller must not fabricate them.
  The module has no file-reading surface: observations are supplied by the caller
  (the existing private-file registry reader), so it introduces no second
  registry and no second file-trust surface (asserted by an in-suite grep guard
  over the module source).

Captured container log
--------------------------------------------------------------------------------
test1293 host-shared OpenCode server admission guard source=498fed1e date=2026-10-15T14:19:13+00:00
L0 unit: a host-shared server is admitted ONLY when observed loopback + same uid
         + live pid/startTicks (real probe) + matching generation. Missing
         observations, absent or throwing probe, foreign uid, non-loopback and
         dead pid all fall back. Node identity is NOT a gate here (MCP
         credentials / session binding carry it), so another node may borrow.
bun test v1.3.14 (0d9b296a)

(pass) host-shared server admission > a live same-uid loopback host-shared server is admitted
(pass) host-shared server admission > a DIFFERENT node may borrow the same host-shared server (no node-id gate)
(pass) host-shared server admission > off mode preserves today's always-spawn behaviour
(pass) host-shared server admission > absent probe never admits (no admission on a registered record alone)
(pass) host-shared server admission > a throwing probe falls back and never escapes as a throw
(pass) host-shared server admission > missing startTicks is refused (PID reuse would be undetectable)
(pass) host-shared server admission > missing pid is refused (fail-closed)
(pass) host-shared server admission > non-loopback url is refused
(pass) host-shared server admission > dead server (pid not live) falls back, never reuses a stale record
(pass) host-shared server admission > foreign uid is refused
(pass) host-shared server admission > generation mismatch is refused
(pass) host-shared server admission > empty observations fall back
(pass) host-shared server admission > malformed record degrades to fallback, not throw
(pass) host-shared server admission > the first valid observation wins over earlier refusals
(pass) loopback url guard > http://127.0.0.1:4096 -> %s
(pass) loopback url guard > http://localhost:4096 -> %s
(pass) loopback url guard > http://[::1]:4096 -> %s
(pass) loopback url guard > http://192.168.1.5:4096 -> %s
(pass) loopback url guard > https://127.0.0.1:4096 -> %s
(pass) loopback url guard > http://user:pass@127.0.0.1:4096 -> %s
(pass) loopback url guard > not-a-url -> %s

 21 pass
 0 fail
 34 expect() calls
Ran 21 tests across 1 file. [27.00ms]
guard: the decision module must not read or trust any file (no second registry)
PASS: host-shared OpenCode server admission guard suite
--------------------------------------------------------------------------------

Limits
  Covers the GUARD DECISION only. It does not prove the wired create/start path,
  native V2 startup, real server sharing, TUI, or lifecycle cleanup. Production
  wiring/qualification lives with the host shared-registry path (PR #2776) and
  is tracked by #1302. Not a release; no credentials or production changes.

--------------------------------------------------------------------------------
ADDENDUM — #2777 review (通信牛, 2026-10-16): admit ONLY `127.0.0.1`

The run above is @498fed1e and its url table is SUPERSEDED. 通信牛's review of
#2777 requires the guard to accept only the production-supported literal the host
registry actually writes (`http://127.0.0.1:<port>`), and to add the matching
negative controls — `localhost` (resolver/hosts dependent) and `[::1]` are
loopback but not shapes the host can produce, so they must be refused.

Behaviour now (head on the branch, unit-verified locally):
  http://127.0.0.1:4096                  -> supported  (admitted when the rest of
                                                        the observations hold)
  http://localhost:4096                  -> refused, reason `host_not_127_0_0_1`
  http://[::1]:4096                      -> refused, reason `host_not_127_0_0_1`
  http://127.0.0.2:4096                  -> refused, reason `host_not_127_0_0_1`
  https://127.0.0.1:4096                 -> refused, reason `host_not_127_0_0_1`
  http://user:pass@127.0.0.1:4096        -> refused, reason `host_not_127_0_0_1`
  http://192.168.1.5:4096 / https://…    -> refused, reason `not_loopback`

`isLoopbackServeUrl` became `classifyServeUrl` + `isSupportedServeUrl` so the
refusal reason distinguishes "loopback but unsupported shape" from "routable".

Evidence for this revision: the suite's own `bun test
src/runtime/opencode-server-reuse.test.ts` (28 pass / 0 fail alongside the new
`host_not_127_0_0_1` and "unsupported shape does not mask a later supported
observation" controls) plus the PR's L1 job Docker run of this same suite. A full
Docker artifact for the new head will be attached when the L1 job for the new
commit completes; this addendum does not claim that run has happened.

--------------------------------------------------------------------------------
ADDENDUM 2 — #2777 review (通信牛): the URL check must match the RAW literal

The earlier addendum replaced `localhost`/`[::1]` with `127.0.0.1` only, but the
check still went through `new URL()`. That normalizes spelling the host registry
never writes, so a parsed-host comparison accepted them:

  http://127.1:4096           -> hostname 127.0.0.1
  http://2130706433:4096      -> hostname 127.0.0.1   (integer address)
  http://0x7f000001:4096      -> hostname 127.0.0.1   (hex address)
  http://127.0.0.1:4096/other?x=1#x -> same host, path/query/fragment ignored

`classifyServeUrl` now matches the RAW string against the literal endpoint
`^http://127\.0\.0\.1:([1-9]\d{0,4})$` (port <= 65535, no leading zero), and
anything that starts with the literal host but is not that endpoint is still
`unsupported_loopback` (bad port, path, query, fragment, credentials, uppercase
scheme) so the audit reason stays informative. The same defect was found and
fixed in the #1313 observation provider.

Negative controls added: 127.1 / 2130706433 / 0x7f000001 / :04096 / :0 / :65536 /
no port / HTTP:// / 127.0.0.01 / trailing path · query · fragment — all refused.
Evidence: `bun test src/runtime/opencode-server-reuse.test.ts` = 42 pass / 0 fail.
