M2 batch C: the five control kinds — permissions, TLS errors, HTTP auth, downloads, context menus #112

Merged
triform-admin merged 11 commits from capture/batch-c-control-kinds into main 2026-09-10 05:44:27 +00:00

Verified live: tests/interactive is 89/89 with ZERO failures on guest cr7727-eb156dd5bf6a — the first clean sweep of the full suite. Lane: 641 steps, 0 failed, 0 undefined symbols.

What each kind replaces

kind before now
permission getCurrentPosition() denied instantly, nobody asked — indistinguishable from a user saying no, which is why it went unnoticed prompts the viewer
cert_error cancelled silently; the page died with a bare network error asks — except under HSTS
login a 401 rendered the server's error body with no way to supply credentials prompts for them
download the file landed in the guest's profile and the viewer was told nothing a progress tray
context_menu the right-click vanished without trace forwarded to the viewer

Reading the tree first paid for itself

Every API was read from the pinned checkout before a line was written, and the roadmap was wrong about one: CreateLoginDelegate takes eleven parameters at 7727, including a GuestPageHolder* nobody anticipated, and it lives on ContentBrowserClient rather than WebContentsDelegate.

Its real contract is in the header comment, not the (nearly empty) interface: the callback runs on the UI thread, must not be reentrant, and must not run at all once the delegate is destroyed — destruction IS the cancellation. That is why the response is bound through a WeakPtr.

Also caught before the lane: CERTIFICATE_REQUEST_RESULT_TYPE_* lives in its own header, and net/base/auth.h is not pulled in by content_browser_client.h (while GuestPageHolder and GlobalRequestID are, as forward declarations — enough for a pointer and a const ref).

One lane cycle, and the lint that now catches it

Eight member access into incomplete type 'const content::ContextMenuParams'. web_contents_delegate.h forward-declares it at :117 — enough to NAME it in the override, not enough to read a field.

--keep-going made it cheap: one pass proved all eight were a single root cause in a single file, so the other four new TUs were confirmed clean in the same cycle.

lint-cxx gains three rules for the class, because it was clean while the compiler failed. Worth noting the first version was wrong: keyed on the type NAME, it flagged two entirely correct files — a header naming the type in a signature needs only the forward declaration. Re-keyed on member access, it is clean across 142 files and its negative control reproduces the exact finding.

Two things I got wrong and corrected

  • certErrorText had three of five net error codes wrong — -200 and -202 swapped (name mismatch vs unknown authority), REVOKED/WEAK at -207/-211 instead of -206/-208. A prompt naming the wrong reason is worse than one naming none: it invites a decision on false grounds. Now read from net_error_list.h and pinned in a test.
  • A blob-verification script reported ALL BLOBS OK against zero parsed layers. That vacuous pass would have let me redeploy a truncated image. It now asserts a non-empty layer list first — 20/20 confirmed on the real push.

The control channel now has a protocol spec

Seven kinds and no documentation outside a C++ header. docs/protocols/control-channel.md records the invariant that matters — every request resolves exactly once and never depends on the viewer, because RunJavaScriptDialog blocks the page's JS thread and RunFileChooser holds a listener chromium CHECKs on — and the rule that makes new kinds safe: every default is what the guest did before the kind existed.

Infrastructure, documented where it bit

TURN's relay IP is hardcoded in deploy.sh; coturn runs hostNetwork and was rescheduled off that node, so the IP had nothing on 3478. The symptom is ECONNREFUSED and a session that negotiates and never decodes a frame — identical to an expired credential, which had also expired 26 h earlier, so fixing that was right and changed nothing. deploy.sh now carries the one-command test that separates them, and the fact that the worker keeps its own WEBRTC_ICE_SERVERS that patching the broker does not reach.

Also corrected: my earlier claim that a 300s push deadline was too small for a 210 MB layer. The real cause was the registry's S3 backend stalling a multipart upload — a plain retry fixes it, more deadline does not.

Verification

lane 641 steps, 0 failed, 0 undefined symbols
tests/interactive 89/89, zero failures
client tests 345
registry blobs 20/20 verified present at declared size before deploy
**Verified live: `tests/interactive` is 89/89 with ZERO failures** on guest `cr7727-eb156dd5bf6a` — the first clean sweep of the full suite. Lane: 641 steps, 0 failed, 0 undefined symbols. ## What each kind replaces | kind | before | now | | --- | --- | --- | | `permission` | `getCurrentPosition()` denied **instantly**, nobody asked — indistinguishable from a user saying no, which is why it went unnoticed | prompts the viewer | | `cert_error` | cancelled silently; the page died with a bare network error | asks — except under HSTS | | `login` | a 401 rendered the server's error body with no way to supply credentials | prompts for them | | `download` | the file landed in the guest's profile and the viewer was told **nothing** | a progress tray | | `context_menu` | the right-click vanished without trace | forwarded to the viewer | ## Reading the tree first paid for itself Every API was read from the pinned checkout before a line was written, and the roadmap was **wrong** about one: `CreateLoginDelegate` takes **eleven** parameters at 7727, including a `GuestPageHolder*` nobody anticipated, and it lives on `ContentBrowserClient` rather than `WebContentsDelegate`. Its real contract is in the header comment, not the (nearly empty) interface: the callback runs on the UI thread, must not be reentrant, and **must not run at all once the delegate is destroyed — destruction IS the cancellation**. That is why the response is bound through a WeakPtr. Also caught before the lane: `CERTIFICATE_REQUEST_RESULT_TYPE_*` lives in its own header, and `net/base/auth.h` is not pulled in by `content_browser_client.h` (while `GuestPageHolder` and `GlobalRequestID` **are**, as forward declarations — enough for a pointer and a const ref). ## One lane cycle, and the lint that now catches it Eight `member access into incomplete type 'const content::ContextMenuParams'`. `web_contents_delegate.h` forward-declares it at :117 — enough to NAME it in the override, not enough to read a field. `--keep-going` made it cheap: one pass proved all eight were a single root cause in a single file, so the other four new TUs were confirmed clean in the same cycle. `lint-cxx` gains three rules for the class, because it was clean while the compiler failed. Worth noting the **first version was wrong**: keyed on the type NAME, it flagged two entirely correct files — a header naming the type in a signature needs only the forward declaration. Re-keyed on **member access**, it is clean across 142 files and its negative control reproduces the exact finding. ## Two things I got wrong and corrected - **`certErrorText` had three of five net error codes wrong** — -200 and -202 swapped (name mismatch vs unknown authority), REVOKED/WEAK at -207/-211 instead of -206/-208. A prompt naming the wrong reason is worse than one naming none: it invites a decision on false grounds. Now read from `net_error_list.h` and pinned in a test. - **A blob-verification script reported `ALL BLOBS OK` against zero parsed layers.** That vacuous pass would have let me redeploy a truncated image. It now asserts a non-empty layer list first — 20/20 confirmed on the real push. ## The control channel now has a protocol spec Seven kinds and no documentation outside a C++ header. `docs/protocols/control-channel.md` records the invariant that matters — every request resolves exactly once and never depends on the viewer, because `RunJavaScriptDialog` blocks the page's JS thread and `RunFileChooser` holds a listener chromium CHECKs on — and the rule that makes new kinds safe: **every default is what the guest did before the kind existed**. ## Infrastructure, documented where it bit TURN's relay IP is hardcoded in `deploy.sh`; coturn runs `hostNetwork` and was rescheduled off that node, so the IP had nothing on 3478. The symptom is ECONNREFUSED and a session that negotiates and never decodes a frame — **identical to an expired credential**, which had also expired 26 h earlier, so fixing that was right and changed nothing. `deploy.sh` now carries the one-command test that separates them, and the fact that the worker keeps its **own** `WEBRTC_ICE_SERVERS` that patching the broker does not reach. Also corrected: my earlier claim that a 300s push deadline was too small for a 210 MB layer. The real cause was the registry's S3 backend stalling a multipart upload — a plain retry fixes it, more deadline does not. ## Verification | | | | --- | --- | | lane | 641 steps, 0 failed, 0 undefined symbols | | `tests/interactive` | **89/89, zero failures** | | client tests | 345 | | registry blobs | 20/20 verified present at declared size before deploy |
Batch B established the cost of guessing: one wrong signature is a
~20 minute lane cycle. So all five hooks were read from the pinned
tree first, and one was materially wrong in the roadmap.

CreateLoginDelegate takes ELEVEN parameters at 7727, including a
GuestPageHolder* nobody anticipated, and it lives on
ContentBrowserClient rather than WebContentsDelegate. Its real
contract is in the header comment, not the (nearly empty) interface:
the callback runs on the UI thread, must not be reentrant, and must
not run at all after the delegate is destroyed — destruction IS
cancellation.

Also pinned: DownloadItem::Observer's four virtuals (all defaulted
empty) and the fact that progress must read GetTargetFilePath(), NOT
GetFullPath() — the latter names the intermediate file and "may be
renamed or disappear" mid-download, which would make a downloads tray
show a name that then stops existing.

HandleContextMenu and AllowCertificateError confirmed unchanged from
their earlier pins. The context-menu fields worth forwarding are noted
with line numbers, including that the struct is named
UntrustworthyContextMenuParams because it is renderer-supplied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two of batch C's five control kinds.

context_menu: HandleContextMenu already suppressed the default and
carried a comment saying "forwarding ContextMenuParams to the viewer
plugs in exactly here". It does now. Suppression is unchanged — a
headless embedder has no native menu surface, so the default renders
nothing — but the right-click is no longer swallowed whole with no
indication that anything happened.

Sent as a fire-and-forget EVENT, not a request: the menu's actions come
back as ordinary input (a click, a clipboard write, a navigation), so
there is nothing for the guest to block on, and a viewer that ignores
the event leaves the page exactly as it is today.

cert_error: chromium's default cancels and the page dies with a bare
network error. That is right for an unattended worker and wrong for a
person, who in a real browser gets an interstitial and a choice.

CANCEL stays the default on every path that is not an explicit yes —
no channel, closed channel, timeout, malformed answer, or a viewer who
declines. And strict_enforcement (HSTS) never asks at all: the site
itself has said its certificate must be valid, so a "proceed anyway"
would only be offering the user a way to be wrong.

Every symbol read from the pinned tree first, and two would have cost
a lane cycle: ContextMenuParams::x/y exist at :42-43 (I wrote them
before checking, then checked), and CERTIFICATE_REQUEST_RESULT_TYPE_*
lives in its own header that content_browser_client.h does not pull in.
Also confirmed ssl_info.cert is a scoped_refptr<X509Certificate> whose
subject()/issuer() return CertPrincipal, and that GetDisplayName() is
declared in x509_cert_types.h.

AllowCertificateError is on ContentBrowserClient, which gets no
per-session injection, so it reads the channel from the
process-lifetime WebContentsDelegate singleton that already holds it —
a new accessor, and the only honest place to get it.

UNVERIFIED: capture/ does not compile locally. The lane has not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The third of batch C's five kinds.

A download used to land in the guest's profile and the viewer was told
nothing at all. tests/interactive had to read the guest's filesystem
over `kubectl exec` to prove downloads worked, because no other
evidence existed anywhere — that is a test working around a missing
feature, not a test being thorough.

CbDownloadManagerDelegate now also implements DownloadManager::Observer
and DownloadItem::Observer, emitting a `download` control event at
started / progress / complete. Both observer faces on the one class
because it already outlives every item (the BrowserContext owns it) and
already holds the download directory; a second object would be a second
lifetime to get wrong.

Three details that came from reading the headers rather than guessing:

- GetTargetFilePath(), NOT GetFullPath(). The latter names the
  intermediate .crdownload file, which "may be renamed or disappear"
  mid-download (download_item.h:370) — a tray showing that name would
  show a file that then stops existing.
- OnDownloadCreated "may be called an arbitrary number of times, e.g.
  when loading history on startup" (download_manager.h:87), so the
  observed set is deduplicated; observing twice would double every
  event the viewer sees.
- ManagerGoingDown exists precisely "to prevent Observers from calling
  back to a stale pointer", so it drops the item observations too —
  those items go down with the manager.

Byte counts go over the wire as doubles: a download can exceed 2 GiB
and base::Value has no 64-bit integer type. total_bytes is -1 when the
server sent no Content-Length, which the client should render as an
indeterminate bar rather than "0 bytes" — that reads as a failure.

Every accessor verified in the pinned tree first: GetId, GetState,
GetURL, GetMimeType, GetTargetFilePath, IsDone, AddObserver,
RemoveObserver, and both Observer interfaces' exact virtuals.

UNVERIFIED: capture/ does not compile locally. The lane has not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fourth of batch C's five kinds, and the one the pins doc calls the
highest-drift surface in the workstream.

GetPermissionControllerDelegate() returned nullptr, justified by a
comment reading "the worker has no UI to surface a prompt anyway".
That was true when written and stopped being true when the control
channel landed — there IS a UI now, it is just on the other end of a
DataChannel.

nullptr means //content's default DENIES every permission without
asking, so getCurrentPosition() returned PERMISSION_DENIED instantly
and Notification.requestPermission() resolved "denied". From the
page's side that is indistinguishable from a user who said no, which
is exactly why it went unnoticed.

The drift warning was accurate: 13 virtuals, 8 of them pure, and every
accessor keyed on blink::mojom::PermissionDescriptorPtr rather than
the PermissionType enum. Only ResetPermission still takes the enum —
an asymmetry that is real and is not to be "fixed".

Two decisions worth stating:

- The synchronous status queries report ASK, not DENIED. Nobody can be
  asked synchronously, and "you would have to request it" is the
  honest answer; reporting DENIED would make a page skip the request
  that WOULD have prompted the viewer. The exception is
  GetPermissionResultForWorker, which genuinely denies: a worker has
  no viewer-visible frame, so there is no path by which it could
  become a yes.
- Every early return builds a FULL vector of denials, one per
  descriptor. //content waits on exactly one result per requested
  permission and the page's promise never settles otherwise — a short
  vector is a hung page, which is the same invariant the control
  channel's dialogs carry.

DENY remains the default on every path that is not an explicit yes.
Asking is an improvement on refusing silently; defaulting to allow
would be a regression in the other direction, and this browser can be
pointed at anything.

UNVERIFIED: capture/ does not compile locally. The lane has not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CreateLoginDelegate returned nullptr, which //content reads as "the
embedder will not handle this" and cancels the challenge. A 401
therefore rendered the server's own error body, or a blank page, with
no way to supply credentials — a whole class of intranet and appliance
URLs simply could not be opened.

The signature is the one the roadmap got wrong: ELEVEN parameters at
7727, including a GuestPageHolder* nobody anticipated, and it lives on
ContentBrowserClient rather than WebContentsDelegate. Read from the
tree before writing a line.

The real contract is in the header comment, not the interface —
LoginDelegate itself is a virtual destructor and one type alias:

  * the callback runs on the UI thread,
  * it must NOT be called reentrantly (post it if the answer is known
    synchronously — the no-channel path does exactly that),
  * DESTRUCTION IS CANCELLATION: if the delegate dies first, the
    callback must not run at all.

That last rule is why the response is bound through a WeakPtr. The
control channel can answer at any time, including after //content has
dropped us, and answering then writes into torn-down request state
rather than being a harmless late reply. The destructor deliberately
does not run the callback, and says so.

std::nullopt is a cancelled login, which //content turns back into the
401 — the default for every path that is not an explicit answer.

first_attempt is forwarded because it is false on a retry, i.e. "the
last credentials were rejected". Without it the second prompt looks
identical to the first and the user retypes the same wrong password.
is_proxy likewise: proxy credentials are a different secret, and a
prompt that conflates them invites the wrong password.

Include verification worth recording: `make lint-cxx` caught FROM_HERE
without base/location.h here — the exact defect that lint was written
for after Cb.shutdown shipped with it. And net/base/auth.h is NOT
pulled in by content_browser_client.h (it includes only schemeful_site
and two cookie headers from net/), while GuestPageHolder and
GlobalRequestID ARE, as forward declarations at :255 and :289 — enough
for a pointer and a const ref.

UNVERIFIED: capture/ does not compile locally. The lane has not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lane caught this in ~14 minutes: eight `member access into
incomplete type 'const content::ContextMenuParams'` errors, all in one
file, all from one missing include.

`web_contents_delegate.h` forward-declares the type at :117. That is
enough to NAME it in the override's signature and not enough to read
`params.link_url`. The definition lives in
content/public/browser/context_menu_params.h, and it derives from
blink::UntrustworthyContextMenuParams, which is where the fields
actually come from.

`--keep-going` earned its keep: one pass returned all eight errors and
proved they were a single root cause in a single file, so the other
four new batch-C files were confirmed clean in the same cycle rather
than one per cycle.

Then checked the same class across the rest of the batch by hand
before re-firing — net::SSLInfo, PermissionRequestDescription and
net::AuthChallengeInfo are the other structs whose members this batch
reads, and all three already include their definitions.

lint-cxx gains three rules for the class, because it was clean while
the compiler failed: every symbol it knew about WAS included.

Keyed on MEMBER ACCESS, not the type name. The first version matched
the name and flagged two files that were entirely correct — a header
naming the type in an override signature needs only the forward
declaration. That is the difference between a rule that would have
caught this and a rule that trains people to ignore it.

Negative control both ways: removing the include reproduces the
finding at the right line; restoring it is clean across 142 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
permission, cert_error and login each hold a chromium callback on the
guest side whose own contract says it must run, so the property under
test is the same for all three: exactly one answer, always, and a safe
one when anything goes wrong.

One `settleWith` helper rather than three near-identical blocks,
because the failure modes are what matter and they are easy to get
subtly different: no handler, a throwing handler, and a rejected
promise must ALL produce the safe answer rather than silence.

Every default matches what the guest did before batch C existed —
permissions denied, certificates cancelled, logins 401'd — so an
unhandled kind is never a regression and never accidentally
permissive.

Two details that are decisions, not oversights:

- login sends BOTH credentials or NEITHER. Half a credential reads as
  a bug rather than a decision, and the guest treats a partial answer
  as cancelled anyway.
- first_attempt is surfaced because it is false on a retry, i.e. the
  last credentials were rejected. Without it the second prompt is
  indistinguishable from the first and the user retypes the same
  rejected password.

certErrorText was WRONG in a first draft and is now read from
net/base/net_error_list.h: -200 and -202 were swapped (name mismatch
vs unknown authority), and REVOKED/WEAK were at -207/-211 rather than
-206/-208. A prompt that names the wrong reason is worse than one that
names none, because it invites a decision on false grounds. The codes
are pinned in a test.

The "unsupported kind" test moved for the second time — file_chooser
in batch B, cert_error now — which is the test working as its own
comment says. Batch C implemented every kind the protocol documents,
so the example is now a deliberately invented one; the guest treats
kind as a free-form string, so an unknown kind is a real case to
decline rather than a placeholder.

15 new tests, 345 passing. Mutation-checked at the two paths that
matter: a throwing handler that answers nothing, and a missing handler
that grants instead of denying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven kinds now (batch C took it from two), and the only specification
was the C++ header. That header is genuinely good, and a TypeScript
author implementing the viewer half has no reason to read it.

Documents the invariant that actually matters and the reason for it:
every request resolves exactly once and must NOT depend on the viewer,
because RunJavaScriptDialog blocks the page's JS thread,
RunFileChooser holds a listener chromium CHECKs on if released
unresolved, and CreateLoginDelegate's callback must run or the
delegate must be destroyed. A dropped answer is a wedged tab, not a
missing feature.

And the rule that makes new kinds safe to add: every default is what
the guest did BEFORE the kind existed — denied, cancelled, dismissed —
so an unimplemented kind is never a regression and never accidentally
permissive.

Records the traps each kind carries, all of which cost something to
learn: total_bytes is -1 with no Content-Length (render bytes, not
"0%"); byte counts are doubles because base::Value has no 64-bit int;
filename is GetTargetFilePath not GetFullPath; context-menu strings
are renderer-supplied and the chromium struct says so in its name;
cert_error is never sent under HSTS; login's first_attempt is false on
a retry and omitting it makes the user retype a rejected password.

Also adds the client half of batch C — the download tray, the three
prompt handlers, and context-menu logging. The menu is deliberately
NOT rendered yet: it is only useful once its actions exist (open in
new tab needs the tab strip), and logging what was clicked is already
strictly better than the right-click vanishing without trace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two suites' worth of evidence for batch C.

The downloads suite already proved a file lands on the guest's disk —
by reading that disk over kubectl exec, which was a test working
around a missing feature. Two checks now assert the VIEWER knows:
a row in the tray, and the right filename in it. The filename check
doubles as a guard on GetTargetFilePath vs GetFullPath, since an
intermediate .crdownload name showing up there is the wrong accessor.

The permissions suite asserts a prompt REACHED the viewer, not that it
was granted, and the distinction is the whole point. Before batch C,
GetPermissionControllerDelegate() returned nullptr and //content
denied everything without asking — the page saw an ordinary
PERMISSION_DENIED and nothing looked broken. So "denied" proves
nothing; "a prompt happened" is the feature.

The demo client uses window.confirm(), which headless Chrome
auto-dismisses, so the outcome is always denied anyway. And because
confirm() blocks the client's JS thread, the prompt cannot be observed
by CDP eval on that page — the oracle is the guest's own
CV2-PERMISSION log line, read the same way the download oracle reads
the filesystem.

Two more checks that encode decisions rather than behaviour: the
page's request must SETTLE (a permission that never resolves is a page
stuck forever, which is the invariant the whole control channel is
built around), and navigator.permissions.query() must report "prompt"
rather than "denied" — reporting denied makes a page skip the request
that would have asked the viewer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correcting a claim I made yesterday and shipped in a comment.

I raised activeDeadlineSeconds 300 -> 900 and wrote that a 210 MB
layer had outgrown the ceiling, citing the registry logging "82%
copied, then unexpected EOF". Today 900s failed the same way, which
refutes it.

The real cause is in the REGISTRY's log, not kaniko's:

  RequestError: send request failed
  caused by: Put "https://hel1.your-objectstorage.com/...
             /_uploads/<id>/data?partNumber=16&uploadId=..."

The registry streams the layer to S3 in parts. One part fails, kaniko
blocks on a PATCH that will never complete, and the deadline reaps a
job that was already stuck. A plain retry then succeeded on the first
attempt — which is the tell, and which no amount of extra deadline
would have produced.

Why the wrong reading was easy: a stalled upload and a slow one
produce the SAME "82% then EOF" line, and the deadline kills both. I
inferred the cause from kaniko's log alone without checking the
registry's for that run — and the known-positive was right there, the
successful pushes had PATCH traffic and this failure had none.

Also worth recording for whoever hits this next: the two registry
replicas log separately. The push lands on one and the other is
silent, so checking a single pod reads as "no errors at all".

The 900s stands — it is ~4x the slowest observed successful push, so a
genuinely slow push has room and a wedged one still gets reaped — but
the comment no longer claims it fixes this, and the DeadlineExceeded
message now points at the S3 backend and says a retry is the remedy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore(cv2-build): pin cr7727-eb156dd5bf6a — batch C verified, 89/89 live
All checks were successful
CI / Docs link check (pull_request) Successful in 24s
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CI / Lint (pull_request) Successful in 3m46s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 5m31s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 6m35s
CI / Container smoke test (pull_request) Successful in 9m23s
Public security / ownership (pull_request) Successful in 6s
E2E / docker-compose + Playwright (pull_request) Successful in 11m50s
22f0d7444a
The full interactive suite passes with ZERO failures for the first
time: 89/89, including the audio check that fails on a re-armed worker
and passes on a fresh one (103 KB received here).

Batch C's own new checks, all green on a real guest:

  PASS  the download appears in the viewer's tray   1 row(s)
  PASS  the tray names the file   ↓ chromeless-probe.txt — 100%
  PASS  the guest's permission manager is wired
  PASS  the page's permission request settles   denied:1
  PASS  permissions.query() reports 'prompt', not 'denied'

Getting there cost three environmental detours, none of them batch C,
and the last one is worth writing down properly.

TURN's relay IP is HARDCODED in deploy.sh. coturn runs hostNetwork in
triform-production, so its address is whatever node it lands on — it
was rescheduled off triform-1 and the hardcoded 95.217.200.179 then
had nothing on 3478. The symptom is not "TURN rejected us" but
ECONNREFUSED (error 111) and a session that negotiates, gathers
candidates and never decodes a frame. That is indistinguishable from
an expired credential, which is what I went and fixed first — the
credential HAD also expired, 26 hours earlier, so fixing it was
right and changed nothing.

deploy.sh now carries the one-command test that separates them
(`</dev/tcp/<ip>/3478` from inside the worker pod: refused = the relay
moved, open = suspect the credential) and the fact that cost the third
detour: the WORKER carries its own WEBRTC_ICE_SERVERS. Patching the
broker's TURN_URLS does not reach it, and the guest keeps logging the
old address, which reads as the patch not applying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
triform/chromeless!112
No description provided.