Skip to content

fix(extension): resolve agent names from a stage 1 a browser can read - #120

Merged
stormer78 merged 1 commit into
mainfrom
fix/agent-name-browser-stage-1
Aug 18, 2026
Merged

fix(extension): resolve agent names from a stage 1 a browser can read#120
stormer78 merged 1 commit into
mainfrom
fix/agent-name-browser-stage-1

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Typing an agent name (dids.firstperson.dev/@geoffturk) into the agent-address
field always failed with a bare "Failed to fetch". Pasting the full
did:webvh:… worked. Agent names have never resolved from the extension.

Root cause

Stage 1 read the DID out of a Location header:

const res = await fetch(url, { redirect: "manual" });
return { status: res.status, location: res.headers.get("location") };

A browser can never do that, for two independent reasons — both confirmed
live in Chrome against the real server:

  1. redirect: "manual" does not hand back the redirect. It returns an
    opaque-redirect response — status 0, no headers at all — so
    headers.get("location") is null for every redirect, on every host.
  2. The contract's own answer is a did: URI, and Chrome rejects a redirect to
    a non-web-safe scheme down in the network stack (net::ERR_UNSAFE_REDIRECT)
    before any redirect mode is consulted. The fetch rejects outright with
    TypeError: Failed to fetch — the string in the bug report.

Measured, from a page on an unrelated origin:

request result
fetch(name, {redirect: "manual"}) TypeError: Failed to fetch
fetch(name, {redirect: "follow"}) TypeError: Failed to fetch
fetch(name, {redirect: "error"}) TypeError: Failed to fetch
fetch("https://dids.firstperson.dev/") 200, type: "cors"

So it is neither CORS nor the host permission — the host is reachable and the
302 even carries access-control-allow-origin: *. It is the did: target.

No extension API rescues this either: webRequest never fires the callback
that would carry the header, because the redirect notification is suppressed
below it. The code passed its tests only because Node's fetch does expose
Location, so the unit tests now cover both transports.

The fix

affinidi-webvh-service already content-negotiates
(did-hosting-server/src/routes/resolve_agent_name.rs): a browser-shaped
Accept gets an ordinary same-origin 302 → /{mnemonic}/did.jsonl, which
fetch follows normally. Stage 1 now asks for an answer it can read.

  • fetchAgentName (background.ts) sends
    Accept: application/json, application/did+json;q=0.9, text/html;q=0.8,
    follows the redirect, and stream-reads the body under a 256 KiB cap
    (R1.2 foreign-fetch profile — text() then slice() is the same unbounded
    read wearing a cap, and content-length is no substitute since a server that
    omits it is the one worth defending against).
  • A refused request becomes a coded agent-name/unreadable error naming
    the way out ("paste the full did:…") instead of leaking Failed to fetch.
    Deliberately distinct from no-redirect, which means the server was read
    and named no DID (R3.7 — callers branch on the code, never the message).
  • didFromNameResponse (agent-name.ts) implements the guide's
    deliberately permissive redirect contract in one place: a bare did: in
    Location, ?did=, the final path segment percent-decoded, or a body
    carrying {did}, a document id, didDocument.id, or a did:webvh log's
    state.id. It ignores the request URL itself, so a DID is never derived from
    the name's own spelling.

Stages 2 and 3 are untouched and still mandatory, which is what makes the
permissive extraction safe: whatever stage 1 finds is a candidate DID that
must still resolve and claim the name back via alsoKnownAs. A wrong guess
fails closed rather than granting anything — covered by a test that feeds stage
1 somebody else's DID and asserts not-authorized.

Also moves two doc comments that had drifted onto handleApproverState back
onto the functions they describe.

Verification

End to end against the live server:

dids.firstperson.dev/@geoffturk
  → 302 Location: /motion-knife/did.jsonl        (Accept includes text/html)
  → did:webvh:Qmaye5…:dids.firstperson.dev:motion-knife
  → latest log entry: alsoKnownAs ["https://dids.firstperson.dev/@geoffturk"]  ✓ stage 3

Five new unit tests: the browser path (followed redirect + JSONL log), every
DID-carrying shape, a landing page that names nothing, a candidate that fails
stage 3, and an unreadable transport. 94/94 extension, 215/215 core,
51/51 tsp-js; lint + build clean; MV3 single-bundle invariant holds.

Known gap (not introduced here)

A name server that only ever answers with a bare did: redirect stays
unresolvable from any browser — no client-side change can fix that. The design
guide marks the redirect contract "unpinned"; it should require a
browser-readable form. Worth a follow-up in agent-names-design-guide.md, since
affinidi-webvh-service already does the right thing but nothing requires it.

Pre-merge checklist

- [x] No new reqwest::Client::new() / bare fetch(); all clients have finite timeouts (R1.2)
      — 15s AbortSignal.timeout, body stream-capped at 256 KiB. Deviation: fetch
        offers no redirect-hop cap, so "redirects tightly bounded" rests on the
        browser's own 20-hop limit. Following is now mandatory, not optional.
- [x] No lock held across a network await (R1.3) — n/a
- [x] No local state committed before its remote effect (R2.1) — nothing persisted here
- [x] Every retry is bounded + backed off (R1.4) — no retries added
- [x] Accept/poll/listen loops survive transient errors (R1.5) — n/a
- [x] Acks/deletes happen only after durable handoff (R1.6) — n/a
- [x] New/changed wire types: camelCase, all consumers updated (R3.*) — internal
      bridge type only (`RuntimeResolveAgentNameResponse.code`, unchanged shape);
      new failure code `agent-name/unreadable` is machine-readable (R3.7)
- [x] Config absence = most restrictive; fail-closed (R5.*) — an unreadable stage
      1 fails resolution; there is no permissive fallback to the redirect target
- [x] Logs/status claim only what was verified (R6.*) — the error now says which
      half failed instead of reporting a transport refusal as a server fault
- [x] "Process dies on the next line" answered for every mutation touched (R2.1) — no mutations
- [x] Deviations flagged with rule numbers — see R1.2 above

Typing an agent name into the agent-address field always failed with a bare
"Failed to fetch". Stage 1 read the DID out of a `Location` header:

    const res = await fetch(url, { redirect: "manual" });
    return { status: res.status, location: res.headers.get("location") };

A browser can never do that, for two independent reasons:

  - `redirect: "manual"` does not hand back the redirect. It returns an
    opaque-redirect response — status 0, no headers — so `get("location")`
    is null for every redirect, on every host.
  - The contract's own answer is a `did:` URI, and Chrome rejects a redirect
    to a non-web-safe scheme in the network stack (`net::ERR_UNSAFE_REDIRECT`)
    before any redirect mode is consulted. The fetch rejects outright with
    `TypeError: Failed to fetch`, which is the string users saw.

No extension API rescues this: `webRequest` never fires the callback that
would carry the header, because the redirect notification is suppressed below
it. The code only passed its tests because Node's `fetch` does expose
`Location` — so the tests now cover both transports.

The webvh hosting service already content-negotiates (`resolve_agent_name.rs`):
a browser-shaped `Accept` gets an ordinary same-origin 302 to the DID's log,
which fetch follows normally. So stage 1 now asks for an answer it can read.

  - `fetchAgentName` (background.ts) sends `Accept: application/json,
    application/did+json;q=0.9, text/html;q=0.8`, follows the redirect, and
    stream-reads the body under a 256 KiB cap (R1.2's foreign-fetch profile —
    `text()` then `slice()` is the same unbounded read wearing a cap).
  - A refused request becomes a coded `agent-name/unreadable` error that names
    the way out — paste the full `did:…` — instead of leaking "Failed to
    fetch". Distinct from `no-redirect`, which means the server *was* read and
    named no DID (R3.7: callers branch on the code, never the message).
  - `didFromNameResponse` (agent-name.ts) implements the guide's deliberately
    permissive contract in one place: a bare `did:` in `Location`, `?did=`,
    the final path segment percent-decoded, or a body carrying `{did}`, a
    document `id`, `didDocument.id`, or a did:webvh log's `state.id`. It
    ignores the request URL itself, so a DID is never derived from the name's
    own spelling.

Stages 2 and 3 are untouched and still mandatory. That is what makes the
permissive extraction safe: whatever stage 1 finds is a *candidate* DID that
must still resolve and claim the name back via `alsoKnownAs`, so a wrong guess
fails closed rather than granting anything.

Verified end to end against the live server: `dids.firstperson.dev/@geoffturk`
lands on `/motion-knife/did.jsonl`, yields
`did:webvh:Qmaye5…:dids.firstperson.dev:motion-knife`, whose latest log entry
carries `alsoKnownAs: ["https://dids.firstperson.dev/@geoffturk"]`.

Also moves two doc comments that had drifted onto `handleApproverState` back
onto the functions they describe.

Known gap, unchanged by this: a name server that only ever answers with a bare
`did:` redirect stays unresolvable from any browser. The design guide marks the
redirect contract "unpinned"; it should require a browser-readable form.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 force-pushed the fix/agent-name-browser-stage-1 branch from 4c2816c to fe6bae8 Compare August 18, 2026 12:23
@stormer78
stormer78 merged commit bc497f4 into main Aug 18, 2026
3 checks passed
@stormer78
stormer78 deleted the fix/agent-name-browser-stage-1 branch August 18, 2026 12:27
stormer78 added a commit that referenced this pull request Aug 19, 2026
`fix(extension): resolve agent names from a stage 1 a browser can read (#120)`
landed on main and touched the same import block this branch rewrites.

The conflict is the two changes meeting head-on: main added
`AGENT_NAME_UNREADABLE` to the `agent-name.js` import and still imports
`cookie-scope.js` beside it, while this branch deletes `cookie-scope.ts`
outright along with the password-site login that needed it.

Resolution keeps main's `AGENT_NAME_UNREADABLE` — it is used at two call sites
in `background.ts` — and drops the `cookie-scope.js` import, whose module no
longer exists. Nothing else in the tree references `checkInjectableOrigin` or
`cookieDomainScope`.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant