Skip to content

feat(console): VTA management console in the wallet, on the Trust Context tree - #155

Merged
stormer78 merged 3 commits into
vta-managementfrom
feat/vta-management-console
Sep 1, 2026
Merged

feat(console): VTA management console in the wallet, on the Trust Context tree#155
stormer78 merged 3 commits into
vta-managementfrom
feat/vta-management-console

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Brings a full VTA management console into the wallet, with Trust Contexts as its navigation spine.

Targets vta-management (working branch), not main.

Why now

Everything an operator does to their agent — contexts, keys, ACL, approvals, policy, services — has only been reachable from pnm-cli, i.e. from a laptop with a keyring. Two things made this the moment:

  • The client library already existed. @openvtc/pnm-core/admin is 13 modules / 57 functions, and mapped against pnm-cli's command tree the only gaps are backup, bootstrap and auth-credential. Its doc comments were written for a console it did not yet have. This was a UI project, not a protocol one.
  • The plugin is the only place the operator's identity can be used. The signing identity is PRF-wrapped with chrome.runtime.id as the rpId, so no web origin can unlock it — and signOutboundTask already attaches a proof at the channel, so every admin task inherits SPEC §7.2 item 7a signing for free.

What's in it

A full-page console at manager.html, opened from the options rail. Nine panes across three acts (the grouping and its colours follow the GDC workshop deck's rail):

Act Panes
Identity & custody (purple) Contexts · Keys · DIDs
Wire & execution (lime) Transports · Audit
Authority & graph (gold) Access · Approvals · Policy · Sessions

The context tree is a persistent column, not a page. ContextRecord already carries parent/basePath and contextId is a filter parameter on keysList, aclList and auditList — so selecting work/eng once scopes every pane to its right. The tree also draws a placeholder for a parent the caller's ACL cannot reach: the obvious build (attach to byId[parent], keep what attached) silently drops exactly the children you do administer, and renders a shorter, entirely plausible tree.

The invariant this deliberately changes

packages/core/src/admin is operator authority, and CI grepped all of packages/extension/dist/ for 17 of its task URIs on the grounds that "a wallet has no business shipping any of it". The console makes that false on purpose, so the guard is narrowed, not deleted: banned everywhere in dist/ except manager.js.

That exception is structural rather than a convention. vite.config.manager.ts builds the console alone with codeSplitting: false, so Rollup has no other entry to hoist a shared chunk into — and a second CI assertion fails if it ever emits more than one chunk, since the first guard names exactly one exception and an extra chunk is a file nothing checks.

The one new idea: only type and payload cross the bridge

request-task.ts is explicit that the device mints the envelope — a wallet that counter-signs a document composed elsewhere attests to fields it never checked. That does not soften because the composer is an extension page.

So the envelope an admin/* helper builds is a carrier: carrier.ts takes its type and payload and discards the rest, and the existing OFFSCREEN_REQUEST_TASK mints the real one. offscreen.ts needed no change at all, and the console inherits transport selection and fallback, TransportHealth recording, outbound signing, and the same-browser approver ceremony.

Two consequences worth stating:

  • The console holds no key material. An XSS there cannot exfiltrate a key.
  • admin/*, vta/contexts.ts and webvh/dids.ts now type their envelope parties as TaskParty (just a DID) rather than Identity/RemoteDidcommEndpoint. Only .did was ever read — and a surface typed on Identity can only be called from somewhere holding a private key, which is what would have forced key material into the console. The REST convenience wrappers keep the stricter pair, because they build a channel and a channel signs.

The relay is gated on sender.url against chrome.runtime.getURL(""), since every content script carries this extension's id. It is absent from PAGE_FACING_RUNTIME_TYPES and from content.ts's dispatch table, and does not prompt per call — the operator is the caller, and twelve identical dialogs to draw one screen is dismissal, not consent.

Where the agent draws a distinction, the pane draws it too

  • Transports pairs advertised state against observed, and renders unknown as "not observed" — a constructed REST channel is not evidence of anything. Only down means the wallet tried and could not.
  • Audit renders truncated above the table: a warning under a long list is a warning nobody reads, and concluding "nothing else happened" from a partial page is the failure an audit trail exists to prevent.
  • Sessions is headed "your sessions" — the task returns only the caller's, and the agent's all-sessions route is deliberately not wired here.
  • Keys marks internal origin as unrecoverable, and deliberately does not surface keysSign: signing is use, not administration, and a "sign this" box turns key management into an oracle.
  • Access renders no-scopes as "everywhere" and no-expiry as "never", both in the caution colour. aclChangeRole carries fromRole from the row it opened on.
  • Policy carries expectedVersion on upsert and delete — without it, two operators editing one module is last-write-wins and the loser never finds out.
  • Every irreversible action previews from the agent (contextPreviewDelete renders the real keys and DIDs), with force as its own tick.
  • A consentRequired is a typed ConsentRequiredError rendered as a match-code ceremony, never a red string.

Verification

npm run clean && npm run lint && npm run build && npm test from cold — 718 tests pass. Both CI guards confirmed by hand: admin URIs appear only in manager.js (and do appear there, so the guard is not passing on an empty file), and the console is a single bundle.

New tests: manager-sender.test.mts (a carrier with forged issuer/recipient/id must not reach the bridge; a consent refusal keeps its digest), manager-surface.test.mts (not page-facing, not in the content-script table, gated on sender.url), manager-context-tree.test.mts (an orphan under an unreachable parent is still drawn).

Rendered the built bundle in Chrome against stubbed extension APIs, light and dark, walking every pane and a destructive preview. That caught three real bugs before commit — a filename collision, a crushed table column, an act rail too faint to do its job.

Not verified against a live VTA. That needs the unpacked extension and an onboarded agent. When doing so: check the whoAmI banner resolves against whichever host the wallet is actually onboarded with rather than an assumed one, and cross-check one list against pnm contexts list / pnm acl list.

Not built: backup, bootstrap, auth-credential — the only gaps against the CLI, and they have no pnm-core client functions yet.

Pre-merge checklist

- [x] No new reqwest::Client::new() / bare fetch(); all clients have finite timeouts (R1.2)
      No new network client. The console reaches the agent through the existing
      offscreen channels, which already carry their timeouts.
- [x] No lock held across a network await (R1.3)
- [x] No local state committed before its remote effect, or the flow is resumable with an idempotency key (R2.1)
      The console writes nothing locally. Every mutation is a remote task; the
      UI refetches after it, and after failure shows the agent's answer.
- [x] Every retry is bounded + backed off; non-idempotent ops are not blind-retried (R1.4)
      Nothing retries. A failed mutation surfaces and the operator decides.
- [x] Accept/poll/listen loops survive transient errors (R1.5)  — none added
- [x] Acks/deletes happen only after durable handoff (R1.6)  — inbound path untouched
- [x] New/changed wire types: camelCase, deny_unknown_fields where security-relevant, schema registered, all consumers (incl. JS) updated (R3.*)
      No wire change. Task URIs and payloads come from the generated
      @openvtc/trust-tasks bindings; `TaskParty` is a TypeScript widening only.
- [~] Config absence = most restrictive; fail-closed if enforcement can't start (R5.*)
      Fail-closed where it enforces: `isExtensionPageSender` rejects a sender
      with no `sender.url`. DEVIATION, deliberate: when `whoAmI` fails the
      panes leave controls enabled rather than disabling them. `hasRole` is
      advisory — the agent's ACL is the enforcement point and refuses
      regardless — and greying out every control with "you need the admin role"
      would be a claim we cannot support when authority is simply unresolved.
      The banner shows the failure prominently instead. Flagged per §9.
- [x] Logs/status claim only what was verified; background-job failures are surfaced (R6.*)
      The strongest part of this change: "not observed" vs "down", audit
      truncation surfaced above the table, "your sessions" not "all sessions",
      "chained: yes" without claiming the chain was verified (that needs the
      whole chain, not a page of it), and redacted fields shown rather than
      silently absent.
- [x] "Process dies on the next line" answered for every mutation touched (R2.1)
      The console holds no state that could be left inconsistent: a task either
      applied at the agent or did not, and a reload refetches the truth.
- [x] Deviations from this guide flagged explicitly with rule numbers  — see R5.* above

… tree

Everything an operator does *to* their agent — contexts, keys, ACL,
approvals, policy, services — has only ever been reachable from the `pnm`
CLI, which means from a laptop with a keyring. The client library for it
already existed: `@openvtc/pnm-core/admin` covers all of the CLI's command
tree bar backup, bootstrap and auth-credential, and its doc comments were
written for a console it did not yet have.

This is the spine of that console, plus Contexts end to end.

Trust contexts are the navigation, not a section. `ContextRecord` already
carries `parent`/`basePath` and `contextId` is a filter parameter on
`keysList`, `aclList` and `auditList` — so the tree is a persistent column
and the selection scopes every pane to its right.

Two properties are load-bearing:

The console holds no key material. It composes typed documents with the
`admin/*` helpers and the offscreen document mints and signs them, so
`RUNTIME_MANAGER_TASK` carries only `type` and `payload` — the rule that the
device mints the envelope does not soften because the composer is an
extension page. Reusing `OFFSCREEN_REQUEST_TASK` inherits transport
selection, health recording and the approver ceremony; `offscreen.ts` is
unchanged. This is also why `admin/*` now types its envelope parties as
`TaskParty` (just a DID) rather than `Identity`: only `.did` was ever read,
and a surface typed on `Identity` can only be called from somewhere holding
a private key.

The wallet still ships no operator authority. The CI guard is narrowed
rather than dropped — admin task URIs are banned everywhere in `dist/`
except `manager.js`, which is its own vite build with `codeSplitting: false`
so Rollup cannot hoist a shared chunk into a wallet surface. A second
assertion fails if it ever emits more than one chunk.

The relay is gated on `sender.url`, since every content script carries this
extension's id. It does not prompt per call — the operator is the caller,
and twelve dialogs to draw one screen is dismissal, not consent; the agent's
ACL and policy engine remain the authority, and a `requireConsent` renders
as a match-code ceremony rather than a red error string.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…audit

Fills out the console's remaining eight panes on the spine landed in the
previous commit. Every one is scoped by the persistent context selection
where the underlying task takes a `contextId`, so choosing `work/eng` once
scopes Keys, DIDs, Access, Policy and Audit together.

Shared vocabulary rather than nine copies of it: `table.tsx` separates the
three states a list actually has — being fetched, refused, and answered with
nothing — because written per-pane they collapse and an operator reads "you
have no keys" off a permission error. `use-async.ts` keeps `data` null until
the agent answers for the same reason.

Where the agent draws a distinction, the pane draws it too:

- Keys shows `internal` origin as unrecoverable, because such a key derives
  from no seed and nothing reconstitutes it. `keysSign` is deliberately not
  surfaced — signing is use, not administration, and a console that offers a
  "sign this" box turns key management into an oracle.
- Access renders an entry with no scopes as "everywhere" and no expiry as
  "never", both in the caution colour. `aclChangeRole` carries `fromRole`
  from the row it was opened on, so a role someone else changed in between
  rejects instead of silently overwriting them.
- Policy carries `expectedVersion` on upsert *and* delete — without it two
  operators editing one module is last-write-wins and the loser never finds
  out.
- Transports pairs advertised state against observed, and presents `unknown`
  as "not observed" rather than a failure; a constructed REST channel is not
  evidence of anything.
- Audit renders `truncated` above the table, not below: a warning under a
  long list is a warning nobody reads, and concluding "nothing else
  happened" from a partial page is the failure an audit trail exists to
  prevent.
- Sessions is headed "your sessions" — the task returns only the caller's,
  and the agent's all-sessions route is deliberately not wired here.

`webvh/dids.ts` takes the same `TaskParty` widening as `admin/*`: it too read
only `.did` off its envelope parties.

Verified by rendering the built bundle against stubbed extension APIs in
Chrome, light and dark. Admin surface remains confined to manager.js.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
carrier.ts defines it; sender.ts re-exported it, so half the panes reached
for one path and half the other. Two routes to one symbol invite the question
of whether they could ever be different classes — which matters here, because
every pane distinguishes this class from Error by identity to decide between
rendering a consent ceremony and rendering a red string.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 9a3c726 into vta-management Sep 1, 2026
3 checks passed
@stormer78
stormer78 deleted the feat/vta-management-console branch September 1, 2026 08:19
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