File size: 29,552 Bytes
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
"""web_agent.py β€” THE SEAM between an automation step and a browser that runs somewhere else.

CONTRACT C5 (wave 31, ruling R10 / D-51). E ships this; **C mounts it** in
`automation_engine.py`'s action dispatch for `web_read` and adds the `verify_wiring` row β€”
β›” E cannot verify its own mounting, and an unmounted runner is a whole, correct, UNREACHABLE
feature, which is exactly how five wave-29 features shipped behind green gates.

    result, error = web_agent.run_step(step, ctx)      # -> (dict|None, str)

β›” **EXACTLY ONE OF THE TWO IS TRUTHY, ALWAYS.** Success is `(result, "")`; failure is
`(None, "<one sentence naming the cause>")`. Never `(None, "")` β€” a failure that writes nothing
and says nothing is a recorded defect class here, and it is the shape that makes an automation
loop forever writing blanks while every gate stays green. This module NEVER raises: every
exception is caught at the boundary and turned into a sentence, because the caller is a record
walk that must not die on one bad URL.

────────────────────────────────────────────────────────────────────────────────────────────────
WHY A JOB AND NOT A BROWSER HERE

Two laws, neither negotiable: the browser runs on cloud compute and never on a developer's
laptop (W27/R6 + the `/browse` law), and it lives in an EPHEMERAL job so the Space image does not
grow and €0 holds (D-51/R2). So this module submits `jobs/web_agent_job.py` to HF Jobs, polls
**past `SCHEDULING`** to a forward-progress marker the job's own script printed, reads the result
out of the log, and returns it. `aios-web/api/verify_web_agent.py` fails if any shipped module
could launch a browser in-process, this one included.

⚠ IT BLOCKS. Measured cold start is **9.3 s warm / ~24 s on a cold node** on the fast profile and
~28-32 s on the portable one (`waves/wave31/proto/web-agent-job.md` Β§4). That is fine for a
foreground step of an automation RUN and far too slow for a route a person is waiting on. The
cost is per JOB, not per step β€” `run_plan` sends a whole flow's steps in ONE job for that reason.

────────────────────────────────────────────────────────────────────────────────────────────────
β›” THREE THINGS ARE TRUE OF PRODUCTION TODAY AND ARE NOT DEFECTS IN THIS FILE. `capability()`
reports each as a sentence, and `verify_web_agent.py` holds each to a check, so none of them can
be discovered by a customer instead of by us:

  1. THE SPACE'S TOKEN CANNOT PAY FOR A JOB. `deploy_web.py` pushes `HF_TOKEN`, which is scoped to
     the org `royal-imports`; HF answers **402 `Pre-paid credit balance is insufficient`** there
     and 0 jobs have ever run under it. `AIOS_HF_TOKEN` (user `fsanyoto`, 100 jobs) works and is
     NOT pushed. Measured 2026-08-12.
  2. THE SPACE DOES NOT SHIP THE JOB SCRIPT. `deploy_web.py` uploads `api/*.py` and
     `platform/{core,modules,harness}`; `jobs/` is in no manifest. And `check_upload()` walks
     IMPORTS, so it structurally cannot see a file opened by PATH β€” its own comments say so, about
     `aios_grid_fields.json`, which crashed a Space for exactly this reason.
  3. `routes_web_agent.py` IS NOT MOUNTED. `main.py` is another lane's file this wave.

Each needs one line somebody else owns. Until then this module answers with a sentence rather
than a silence, which is the whole of R6's second half.
"""
import base64
import hashlib
import json
import os
import pathlib
import time

#: ⭐ ONE PAIR OF CONSTANTS, AND THEY MUST NAME THE SAME VERSION. The fast profile runs on a
#: Microsoft image that ships the BROWSERS for one Playwright version and NOT the Python package
#: (measured β€” `proto/web-agent-job.md` Β§6), so the image TAG and the pip PIN are a single
#: decision wearing two spellings. `verify_web_agent.py` asserts they agree, and also that
#: `jobs/requirements-web-agent.txt` and the job's own PEP-723 block agree with both: a constant
#: two features share is a defect class this repo has already paid for.
PLAYWRIGHT_VERSION = "1.62.0"

#: A profile is (image, how to reach an interpreter that HAS playwright, what to install in-job).
#: Two exist for one reason: the fast one depends on a THIRD-PARTY REGISTRY, and a capability with
#: no second route dies the day that registry blinks.
#: ⚠ `pip` IS PER-PROFILE AND IT IS NOT COSMETIC. The prebuilt image carries no Python packages
#: at all, so whatever the job imports must be listed here β€” and the CHECKPOINT import is
#: conditional, so the list is too. This was a real defect: `huggingface_hub` was absent, the
#: checkpoint upload raised `ModuleNotFoundError`, and the run still reported a correct result
#: because the boundary caught it β€” i.e. the resume half was silently off while the read half
#: looked perfect. `uv` needs no list: it reads the job's own PEP-723 block.
PROFILES = {
    # 9.3 s warm to a parsed value. Browsers preinstalled; only the driver is pip-installed.
    "prebuilt": {
        "image": f"mcr.microsoft.com/playwright/python:v{PLAYWRIGHT_VERSION}-noble",
        "pip": [f"playwright=={PLAYWRIGHT_VERSION}"],
        "pip_checkpoint": ["huggingface_hub"],
        "python": "python3 -u",
        "install_deps": "0", "install_browser": "0",
    },
    # ~28-32 s. No external registry and no version coupling: uv reads the job's own PEP-723
    # block, then the job apt-installs the libraries and downloads the browser itself.
    "portable": {
        "image": "ghcr.io/astral-sh/uv:python3.12-bookworm",
        "pip": [], "pip_checkpoint": [],
        "python": "uv run",
        "install_deps": "1", "install_browser": "1",
    },
}
DEFAULT_PROFILE = "prebuilt"

#: The log protocol. `jobs/web_agent_job.py` spells these too; they are a cross-file contract
#: between two processes that never import each other, so the gate asserts both sides agree.
MARK = "[web-agent]"
BOOT_MARK = f"{MARK} BOOT"
RESULT_BEGIN = f"{MARK} RESULT-BEGIN"
RESULT_END = f"{MARK} RESULT-END"

#: ⭐⭐ ALL FIVE WEB ACTIONS RUN β€” owner ruling 2026-08-12, superseding D-51/R5.
#: R5 made any web action that WRITES to a third party default to "needs approval". The owner has
#: revoked it, verbatim: *"make sure we unblock all web actions, we don't need approval step
#: first wtf, I never ask for that."* There is no approval gate and no per-kind ban.
#: ⚠ The runner enforces the same set independently, so neither end has to trust the other.
RUNNABLE_KINDS = ("web_read", "web_goto", "web_fill", "web_click", "web_repair")

#: What each kind cannot run without. Checked HERE as well as in the job because a 10-second
#: round trip to be told "you gave me no selector" is a round trip nobody should pay for.
REQUIRED_FIELDS = {
    "web_goto": ("url",),
    "web_read": ("selector",),
    "web_fill": ("selector", "value"),
    "web_click": ("selector",),
    "web_repair": ("selector",),
}
#: Kinds that CHANGE something on somebody else's site. Not a wall β€” nothing here refuses them
#: (the owner's ruling above). It exists so the run log, the dry-run affordance and any future
#: audit surface can all ask one question in one place instead of re-listing three strings.
WRITES_TO_THIRD_PARTY = ("web_fill", "web_click")

#: β›” THE SCRIPT LIVES AT A DIFFERENT DEPTH IN THE REPO AND IN THE CONTAINER, so a single
#: `parents[N]` is right in exactly one of the two places this module runs. Measured at wave 31
#: close, by A, before deploy #2:
#:   repo      `<repo>/aios-web/api/web_agent.py`  -> parents[2] == `<repo>`  -> `<repo>/jobs` βœ…
#:   Space     `/app/api/web_agent.py`             -> parents[2] == `/`       -> `/jobs`      ❌
#: because `aios-web/Dockerfile` flattens `api/` to `/app/api/`. The original constant was correct
#: locally and silently wrong in production β€” `capability()` would have reported "the browser job's
#: script is missing from this deployment": honest, and useless, which is exactly the outcome this
#: module's own header warns about. Both candidates are tried, nearest first, so neither layout is
#: privileged and a future move breaks loudly rather than quietly.
_HERE = pathlib.Path(__file__).resolve()
_CANDIDATES = (_HERE.parents[1] / "jobs",      # container: /app/api -> /app/jobs
               _HERE.parents[2] / "jobs")      # repo:      aios-web/api -> <repo>/jobs
JOB_SCRIPT = next((c / "web_agent_job.py" for c in _CANDIDATES
                   if (c / "web_agent_job.py").is_file()),
                  _CANDIDATES[-1] / "web_agent_job.py")

#: Env knobs, all optional. Names are spelled once and read through `_cfg` so the gate can find
#: them and `capability()` can report them without a second list.
ENV_TOKEN = ("AIOS_WEB_AGENT_TOKEN", "AIOS_HF_TOKEN", "HF_TOKEN")
ENV_NAMESPACE = "AIOS_WEB_AGENT_NAMESPACE"
ENV_PROFILE = "AIOS_WEB_AGENT_PROFILE"
ENV_FLAVOR = "AIOS_WEB_AGENT_FLAVOR"
ENV_REPO = "AIOS_WEB_AGENT_REPO"          # optional checkpoint store; absent is fine and stated

START_TIMEOUT = float(os.environ.get("AIOS_WEB_AGENT_START_TIMEOUT") or 180)
RUN_TIMEOUT = float(os.environ.get("AIOS_WEB_AGENT_RUN_TIMEOUT") or 300)
JOB_TIMEOUT = os.environ.get("AIOS_WEB_AGENT_JOB_TIMEOUT") or "10m"
DEFAULT_FLAVOR = "cpu-basic"              # measured no slower than cpu-upgrade, and 3x cheaper
POLL_SECONDS = 2.0
MAX_STEPS = 20                            # matches the runner's own ceiling and MAX_ACTIONS

_ns_cache = {}


def _token():
    for key in ENV_TOKEN:
        val = (os.environ.get(key) or "").strip()
        if val:
            return val, key
    return "", ""


def _profile():
    name = (os.environ.get(ENV_PROFILE) or DEFAULT_PROFILE).strip()
    return (name if name in PROFILES else DEFAULT_PROFILE), PROFILES.get(name) or \
        PROFILES[DEFAULT_PROFILE]


def _api(token):
    from huggingface_hub import HfApi          # already a dependency: the store is HF-backed
    return HfApi(token=token)


def _namespace(token):
    """Which HF namespace the job is billed to. Explicit env wins; otherwise the token's own
    owner, resolved ONCE per process β€” `whoami` is rate-limited and this answer never changes."""
    fixed = (os.environ.get(ENV_NAMESPACE) or "").strip()
    if fixed:
        return fixed, ""
    if token in _ns_cache:
        return _ns_cache[token], ""
    try:
        name = str(_api(token).whoami().get("name") or "")
    except Exception as exc:                    # noqa: BLE001 β€” a boundary
        return "", (f"Hugging Face would not say who this deployment's token belongs to "
                    f"({type(exc).__name__}), so there is no namespace to bill a browser job to.")
    if not name:
        return "", "Hugging Face returned no account name for this deployment's token."
    _ns_cache[token] = name
    return name, ""


def _status_of(exc):
    """The HTTP status of an HF error, from the RESPONSE β€” never by grepping the message.

    β›” THIS WAS A REAL DEFECT AND IT LIED IN THE MOST EXPENSIVE DIRECTION. The first version asked
    `if "402" in text`, and an `HfHubHTTPError` message carries the URL and a random hex Request
    ID β€” so a genuine **403 "Make sure your token has the correct permissions"** was reported to
    the operator as *"the account has no prepaid Jobs credit"*, sending them to buy credit for a
    problem credit cannot fix. Measured 2026-08-12, both directions, on live HF. A status code is
    a NUMBER the response already carries; matching it as a substring of free text is matching it
    against every request id in the world. [[one-question-two-normalizers]]
    """
    resp = getattr(exc, "response", None)
    code = getattr(resp, "status_code", None)
    return int(code) if isinstance(code, int) else None


def _sentence_for(exc, namespace):
    """Turn an HF exception into ONE sentence a person can act on.

    402 and 403 get their own arms because neither is a bug in our code and both read like one β€”
    and they need OPPOSITE fixes, which is exactly why they must not be confused: 402 is money on
    an account we are allowed to bill, 403 is a token that may not bill this account at all.
    """
    status, text = _status_of(exc), f"{exc}"
    where = namespace or "the configured account"
    if status == 402:
        return (f"Hugging Face refused the browser job: the account it would be billed to "
                f"({where}) has no prepaid Jobs credit. Either add credit to that account, or "
                f"point this deployment at one that has it ({ENV_NAMESPACE} / {ENV_TOKEN[0]}). "
                f"Nothing was read.")
    if status in (401, 403):
        return (f"Hugging Face refused the browser job as unauthorised for {where} β€” this "
                f"deployment's token does not carry `job.write` on that account. Point it at an "
                f"account the token owns ({ENV_NAMESPACE}), or configure a token that owns this "
                f"one ({ENV_TOKEN[0]}). Nothing was read.")
    return (f"The browser job could not be submitted "
            f"({type(exc).__name__}{f' {status}' if status else ''}: "
            f"{text.splitlines()[0][:200]}). Nothing was read.")


def capability():
    """Can this deployment run a web step AT ALL, and if not, WHY β€” WITHOUT calling HF.

    This exists so the answer is visible BEFORE a customer's automation fails at 3am. It reports
    configuration, not liveness: a green `ready` means nothing is missing here, not that HF will
    accept the next submission (only `run_step` can learn that, and it reports it as a sentence).
    """
    token, token_key = _token()
    name, prof = _profile()
    reasons = []
    if not token:
        reasons.append(f"no Hugging Face token is configured ({' / '.join(ENV_TOKEN)})")
    if not JOB_SCRIPT.is_file():
        reasons.append(f"the browser job's script is missing from this deployment "
                       f"(expected {JOB_SCRIPT.name} under jobs/)")
    return {
        "ready": not reasons,
        "reason": ("; ".join(reasons) or "configured"),
        "tokenKey": token_key,
        "namespace": (os.environ.get(ENV_NAMESPACE) or "").strip() or "(the token's own account)",
        "profile": name,
        "image": prof["image"],
        "flavor": (os.environ.get(ENV_FLAVOR) or DEFAULT_FLAVOR),
        "scriptPresent": JOB_SCRIPT.is_file(),
        "checkpointRepo": (os.environ.get(ENV_REPO) or "").strip(),
        "runnableKinds": list(RUNNABLE_KINDS),
        "playwrightVersion": PLAYWRIGHT_VERSION,
    }


def _clean_step(step, index, first=True):
    """A step as the runner will see it, or a sentence. Validated HERE as well as in the job:
    a 10-second round trip to be told a URL is empty is a round trip nobody should pay for.

    `first` says whether this step opens the journey. Only the FIRST step must carry a `url` β€”
    every later one may continue on the page its predecessor left behind, which is the whole
    point of a journey (open, fill, click, read).
    """
    kind = str((step or {}).get("kind") or "")
    if kind not in RUNNABLE_KINDS:
        return None, (f"This step asks for `{kind or '(nothing)'}`, which is not a web action. "
                      f"The ones that run are {', '.join(RUNNABLE_KINDS)}.")

    url = str(step.get("url") or "").strip()
    if url and not url.lower().startswith(("http://", "https://")):
        return None, (f"Step {index} has a web address that is not http or https ({url!r}), so "
                      f"there is nothing to open.")
    if first and not url:
        return None, (f"Step {index} is the first step of this journey, so it needs a web "
                      f"address to open. Later steps continue on the same page.")

    for field in REQUIRED_FIELDS.get(kind, ()):
        if field == "url":
            continue                            # handled above, with a better sentence
        if field == "value":
            if step.get("value") is None:
                return None, (f"Step {index} fills a field but carries no value to type into "
                              f"it.")
            continue
        if not str(step.get(field) or "").strip():
            return None, (f"Step {index} ({kind}) names no element ({field}). Give it a CSS "
                          f"selector, such as `#username` or `button[type=submit]`.")

    out = {"id": str(step.get("id") or f"s{index}"), "kind": kind,
           "timeoutMs": int(step.get("timeoutMs") or 20000)}
    if url:
        out["url"] = url
    for key in ("selector", "attr", "waitFor", "hint"):
        if step.get(key):
            out[key] = str(step[key])
    if kind == "web_fill":
        out["value"] = str(step.get("value"))
        # An explicit `secret` wins; otherwise the runner infers it from the field name. Carried
        # through so the decision is made once, by whoever knows, and not re-guessed downstream.
        if step.get("secret") is not None:
            out["secret"] = bool(step["secret"])
    if step.get("all"):
        out["all"] = True
    if step.get("dryRun"):
        out["dryRun"] = True
    return out, ""


def _shell_command(plan, prof):
    """The job's whole command: the script, base64-embedded, then the profile's runner.

    β›” EMBEDDED RATHER THAN UPLOADED, DELIBERATELY. `run_uv_job`'s local-file path uploads to an
    HF bucket and mounts it, which adds a storage dependency and a second thing to go stale; the
    base64 route is self-contained, costs no repo commit, and cannot ship a version of the script
    that differs from the one on disk.
    """
    src = JOB_SCRIPT.read_bytes()
    b64 = base64.b64encode(src).decode("ascii")
    plan_b64 = base64.b64encode(json.dumps(plan).encode("utf-8")).decode("ascii")
    repo_configured = bool((os.environ.get(ENV_REPO) or "").strip())
    pkgs = list(prof["pip"]) + (list(prof["pip_checkpoint"]) if repo_configured else [])
    prelude = (f"pip install --break-system-packages -q {' '.join(pkgs)} && " if pkgs else "")
    shell = (f"printf '%s' '{b64}' | base64 -d > /tmp/web_agent_job.py && "
             f"{prelude}{prof['python']} /tmp/web_agent_job.py")
    env = {"WEB_AGENT_PLAN": plan_b64,
           "WEB_AGENT_INSTALL_DEPS": prof["install_deps"],
           "WEB_AGENT_INSTALL_BROWSER": prof["install_browser"]}
    #: ⭐ THE JOB GETS A CREDENTIAL ONLY WHEN IT HAS SOMEWHERE TO WRITE. With no checkpoint repo
    #: configured, no token is handed to the browser container at all β€” which is the better
    #: posture and the default. `secrets` takes actual VALUES (never a key name, never an array:
    #: `run_uv_job`'s array form 400s), and HF delivers them as env inside the container.
    secrets = {}
    repo = (os.environ.get(ENV_REPO) or "").strip()
    if repo:
        env["WEB_AGENT_REPO"] = repo
        token, _ = _token()
        if token:
            secrets["HF_TOKEN"] = token
    return ["/bin/sh", "-c", shell], env, secrets


def run_plan(steps, ctx=None):
    """Run a whole flow's web steps in ONE job. `run_step` is the one-step case.

    Returns `(results, "")` where results is a list aligned to `steps`, or `(None, sentence)`.
    """
    ctx = ctx or {}
    log = ctx.get("log") or (lambda _m: None)

    if not steps:
        return None, "No web step was given, so nothing was read."
    if len(steps) > MAX_STEPS:
        return None, (f"A single browser job carries at most {MAX_STEPS} web steps and this flow "
                      f"has {len(steps)}. Split the flow.")

    cleaned = []
    for idx, step in enumerate(steps, 1):
        one, why = _clean_step(step, idx, first=(idx == 1))
        if why:
            return None, why
        cleaned.append(one)

    cap = capability()
    if not cap["ready"]:
        return None, (f"This deployment cannot run a web step: {cap['reason']}. Nothing was read.")

    token, _ = _token()
    namespace, why = _namespace(token)
    if why:
        return None, why
    prof_name, prof = _profile()
    #: β›”β›” THE CHECKPOINT KEY IS (caller's runId + THE WORK), NEVER THE runId ALONE, and getting
    #: this wrong returns one page's content for a different page's request β€” silently, and only
    #: once a checkpoint store is configured. The job resumes on a runId hit by returning the
    #: prior envelope UNCHANGED, so any caller that reuses an id across different work gets the
    #: first answer forever. Two real callers would have done exactly that: this module's own
    #: `/web-agent/test` route keys on `test-<tenant>-<user>` (constant per person β€” test URL A,
    #: then URL B, get A back), and the automation engine walks records with ONE run id, so
    #: records 2..N would all receive record 1's scraped value. Hashing the cleaned steps in makes
    #: "resume" mean *this exact work already completed*, which is the only thing it may mean.
    #: ⚠ `cleaned` is used, not the caller's raw dict: it is normalised and ordered, so two
    #: spellings of one step share a key and a genuine difference cannot hide in a default.
    run_id = str(ctx.get("runId") or "").strip() or f"auto-{int(time.time())}"
    work = hashlib.sha256(
        json.dumps(cleaned, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
    plan = {"runId": f"{run_id}-{work[:12]}", "steps": cleaned, "callerRunId": run_id}

    try:
        command, env, secrets = _shell_command(plan, prof)
    except OSError as exc:
        return None, (f"The browser job's script could not be read from this deployment "
                      f"({type(exc).__name__}). Nothing was read.")

    api = _api(token)
    started = time.monotonic()
    try:
        job = api.run_job(image=prof["image"], command=command, env=env, secrets=secrets,
                          flavor=(os.environ.get(ENV_FLAVOR) or DEFAULT_FLAVOR),
                          timeout=JOB_TIMEOUT, namespace=namespace,
                          labels={"aios": "web-agent", "profile": prof_name})
    except Exception as exc:                     # noqa: BLE001 β€” a boundary
        return None, _sentence_for(exc, namespace)

    job_id = str(getattr(job, "id", "") or "")
    log(f"[web-agent] submitted {job_id} ({prof_name}, {namespace}) for {len(cleaned)} step(s)")

    result, why = _await(api, job_id, namespace, started, log)
    if why:
        return None, why
    rows = result.get("steps", [])
    for row in rows:
        row["jobId"] = job_id

    # β›” A JOURNEY THAT STOPPED HALFWAY IS A FAILURE, and returning its rows with an empty error
    # is how a caller "succeeds" on a login that never happened. Measured: a 3-step plan whose
    # click matched nothing came back `(rows, "")` because the envelope carried per-step errors
    # and no top-level one β€” every row was inspectable and nothing forced anybody to inspect
    # them. The contract this module publishes is exactly-one-truthy, so it has to hold for a
    # plan too. The sentence names the failing step AND what completed before it, because that
    # is the whole diagnostic value the rows would have carried.
    failed = [r for r in rows if not r.get("ok") and r.get("attempted") is not False]
    if failed:
        first = failed[0]
        reason = str(first.get("error") or "no reason given")
        # ⚠ ONE step is not a "journey", and wrapping its sentence in journey prose would make
        # the single-step case β€” the one the automation engine actually calls β€” read worse than
        # before. The wrapper earns its place only when there were other steps to lose.
        if len(rows) == 1:
            return None, reason
        done = [str(r.get("id") or r.get("kind")) for r in rows if r.get("ok")]
        skipped = [r for r in rows if r.get("attempted") is False]
        return None, (f"Step {rows.index(first) + 1} of {len(rows)} ({first.get('kind')}) failed "
                      f"and the journey stopped: {reason}"
                      + (f" Completed first: {', '.join(done)}." if done else "")
                      + (f" Not attempted: {len(skipped)} later step(s)." if skipped else ""))
    return rows, ""


def run_step(step, ctx=None):
    """C5's seam: ONE declarative web step -> `(result, error_sentence)`.

    `step`  {"kind": "web_read", "url", "selector", "attr"?, "all"?, "waitFor"?, "timeoutMs"?}
    `ctx`   optional {"tenant", "automationId", "runId", "log"}
    """
    try:
        rows, why = run_plan([step], ctx)
    except Exception as exc:                     # noqa: BLE001 β€” the LAST boundary
        # Nothing below may reach the caller as an exception: this runs inside a record walk.
        return None, (f"The web step failed unexpectedly ({type(exc).__name__}: "
                      f"{str(exc).splitlines()[0][:200]}). Nothing was read.")
    if why:
        return None, why
    if not rows:
        # Belt and braces for the contract's own edge: a job that returned ok with no rows.
        return None, ("The browser job finished without returning anything for this step. "
                      "Nothing was read.")
    row = rows[0]
    if not row.get("ok"):
        return None, str(row.get("error") or
                         "The web step did not succeed and the job gave no reason.")
    return row, ""


def _await(api, job_id, namespace, started, log):
    """Poll past `SCHEDULING`, tail to the job's OWN marker, return its result envelope.

    β›” NEVER 'RUNNING' ON SUBMISSION ALONE β€” the HF-jobs rule, and the reason this function is
    longer than a sleep. Two deadlines, because the two failures are different and a person needs
    to be told which one happened: a job that never STARTS is a platform queue problem, and a job
    that starts and never SPEAKS is ours.
    """
    seen, state, booted = 0, "", False
    envelope = None

    while True:
        waited = time.monotonic() - started
        try:
            info = api.inspect_job(job_id=job_id, namespace=namespace)
            state = str(getattr(info.status, "stage", info.status) or "")
        except Exception as exc:                 # noqa: BLE001
            return None, (f"The browser job {job_id} was submitted but its status could not be "
                          f"read ({type(exc).__name__}). Nothing was read.")

        if state in ("RUNNING", "COMPLETED", "ERROR", "CANCELED"):
            # ⚠ `fetch_job_logs` REPLAYS THE WHOLE LOG on every call, so the index is the dedupe.
            # A content-based one would swallow legitimately repeated lines.
            try:
                lines = list(api.fetch_job_logs(job_id=job_id, namespace=namespace))
            except Exception:                    # noqa: BLE001 β€” logs lag a fresh container
                lines = []
            fresh, seen = lines[seen:], max(seen, len(lines))
            buf, capture = [], False
            for raw in fresh:
                line = (raw or "").rstrip()
                if not line:
                    continue
                if not booted and BOOT_MARK in line:
                    booted = True
                    log(f"[web-agent] job {job_id} is alive at "
                        f"+{time.monotonic() - started:.1f}s")
                if RESULT_BEGIN in line:
                    capture, buf = True, []
                    continue
                if RESULT_END in line:
                    capture = False
                    try:
                        envelope = json.loads("".join(buf))
                    except Exception:            # noqa: BLE001
                        envelope = None
                    continue
                if capture:
                    buf.append(line)

        if envelope is not None:
            break
        if state in ("COMPLETED", "ERROR", "CANCELED"):
            return None, (f"The browser job {job_id} ended {state} without returning a result"
                          f"{' β€” it never started printing' if not booted else ''}. "
                          f"Nothing was read.")
        if not booted and waited > START_TIMEOUT:
            # The submitted-but-never-started case, named as itself.
            try:
                api.cancel_job(job_id=job_id, namespace=namespace)
            except Exception:                    # noqa: BLE001 β€” best effort; it may be gone
                pass
            return None, (f"The browser job {job_id} was accepted but had not started after "
                          f"{int(START_TIMEOUT)}s (it stayed {state or 'queued'}), so it was "
                          f"cancelled. Nothing was read β€” try again, or the platform is busy.")
        if waited > RUN_TIMEOUT:
            try:
                api.cancel_job(job_id=job_id, namespace=namespace)
            except Exception:                    # noqa: BLE001
                pass
            return None, (f"The browser job {job_id} ran for more than {int(RUN_TIMEOUT)}s "
                          f"without finishing and was cancelled. The page may be too slow or may "
                          f"never load. Nothing was read.")
        time.sleep(POLL_SECONDS)

    if not envelope.get("ok") and envelope.get("error"):
        # The runner's OWN sentence, passed through untouched. Rewriting it here would put two
        # vocabularies on one failure.
        return None, str(envelope["error"])
    return envelope, ""