From b67ba66209b7efa05bfb7330604ccf2606afd85c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 10:47:44 +0000 Subject: [PATCH 01/18] Restrict update calls to canisters an app declares (service discoverability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading the Internet Computer is open; writing is not. A state-changing call runs against someone's live application, and a canister being publicly callable is not a statement by its operators that they want an agent driving it. `canister_update_call` is now made ONLY to a canister the owning app DECLARES in its service-discoverability manifest at /.well-known/ic-architecture — publishing that manifest is, per the published guide, exactly how an app's operators opt in. The manifest lives at the app's origin, not on chain, so the tool takes a new `app_url` argument (open_app already returns it) naming the app whose manifest authorizes the write; it falls back to `derivation_origin` when the app serves its manifest at that same origin. A successful call echoes `declared_by`/`declared_at`, so a write's provenance is visible in the reply, not only to the gate. The new `discoverability` module holds the policy and every refusal text, alongside `compliance` (the financial-methods gate). Refusals distinguish their four causes — no origin given, origin unreachable, no manifest published, published but this canister is not in it (that one lists what the app does declare) — and each one names the standard path, links the guide so an app owner can act on a relayed refusal, and states that reads are unaffected, so an agent answers what it can rather than concluding the whole app is off limits. The module doc is explicit about what the gate is not: it is a consent-and-provenance gate, not proof of ownership (whoever controls a domain controls what its manifest says), and not a substitute for the canister's own authorization. Supporting protocol work in `discover`, needed because the gate keys on the protocol rather than on this server's earlier proposal: - /.well-known/ic-architecture is now read during discovery, ranked just under the App Connect main-backend meta and above the legacy path. The legacy /.well-known/ic-app.json is still read, at lower authority and still gate-eligible, so the apps that adopted the proposal are not cut off the day the standard path lands — the one known adopter today publishes only that path. - /.well-known/ii-derivation-origin (Layer 5) now takes precedence over the legacy manifest's `derivation_origin` field. Without it, an app that follows the published guide and pins a custom derivation origin would resolve to the wrong principal — a bug the gate would otherwise have created, since it makes the protocol the thing writes key on. - The three well-known probes on each path run concurrently, so honouring both the protocol and the legacy path costs no extra round trip. - Manifest labels fall back to the protocol's `name` when no `role` is given, so a spec-shaped manifest yields a label, not a bare principal. Everything fails closed on the misconfiguration the guide calls out as the most common one: an SPA catch-all answering /.well-known/* with index.html, which returns 200. A manifest body that is not a manifest document reads as "publishes none" rather than "declares nothing", and an HTML first line is not an origin, so a mis-served Layer 5 file leaves the app deriving against its visible origin instead of garbage. Verified against the live reference app, which today serves exactly that shape at both protocol paths. Reads are untouched: canister_query, get_canister_candid, get_canister_api_doc, the OQL surface and every discovery tool work on any canister, as before. Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB Co-authored-by: Claude --- README.md | 124 +++-- crates/imcp2-core/src/calls.rs | 17 + crates/imcp2-core/src/discover.rs | 564 ++++++++++++++++++----- crates/imcp2-core/src/discoverability.rs | 399 ++++++++++++++++ crates/imcp2-core/src/lib.rs | 1 + crates/imcp2-core/src/tools.rs | 183 +++++++- 6 files changed, 1143 insertions(+), 145 deletions(-) create mode 100644 crates/imcp2-core/src/discoverability.rs diff --git a/README.md b/README.md index 612f8fd..7820b58 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ encoding/decoding and signing against the IC via [`ic-agent`](https://github.com/dfinity/agent-rs). The MCP layer is the [official Rust SDK](https://github.com/modelcontextprotocol/rust-sdk) (`rmcp`). +**Writes go only to apps that opted in.** Reading the Internet Computer is open — +every read tool here works on any canister. A *state-changing* call runs against +someone's live application, so `canister_update_call` is made **only** to a +canister the owning app declares in its +[service-discoverability](https://docs.internetcomputer.org/guides/frontends/service-discoverability/) +manifest at `/.well-known/ic-architecture`. Publishing that manifest is how an +app's operators opt in; an app that publishes none stays fully readable and +discoverable, but cannot be written to through this server. See +[Writes are gated on the discoverability manifest](#writes-are-gated-on-the-discoverability-manifest). + **Not for financial operations.** IMCP2 is infrastructure tooling for reading, building, and operating canisters — it is not a wallet or trading tool, and financial operations (token transfers, spending approvals, payments, trades) @@ -146,15 +156,15 @@ results). | Tool | Args | Returns | |------|------|---------| -| `open_app` | `app` (name **or** URL) | **One-call entry point** when a user names/links an app: resolves the Internet Identity `derivation_origin` *and* discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name, or a URL with no IC evidence, is *refused* (never guessed). Also probes the app's own canisters and reports per-canister `oql`/`api_doc_available` capability flags plus a caller-gated data-access note (which canister holds the app data, and the origin to read it as the user). Wraps `resolve_app` + `discover_app_canisters`; no auth | -| `discover_app_canisters` | `domain` | Canister ids behind a web domain — app-declared App Connect metadata first (`/ai-connect.html`'s `ic:canister-id` meta, `/.well-known/ic-app.json` manifest), then the frontend via `x-ic-canister-id` and backend candidates via `/env.json` + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) `oql`/`api_doc_available` capability flags from a one-shot Candid probe | +| `open_app` | `app` (name **or** URL) | **One-call entry point** when a user names/links an app: resolves the Internet Identity `derivation_origin` *and* discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name, or a URL with no IC evidence, is *refused* (never guessed). Also probes the app's own canisters and reports per-canister `oql`/`api_doc_available` capability flags plus a caller-gated data-access note (which canister holds the app data, and the origin to read it as the user). Returns the `app_url` it used — keep it: `canister_update_call` needs it to read the app's discoverability manifest before any write. Wraps `resolve_app` + `discover_app_canisters`; no auth | +| `discover_app_canisters` | `domain` | Canister ids behind a web domain — app-declared metadata first (`/ai-connect.html`'s `ic:canister-id` meta, the `/.well-known/ic-architecture` service-discoverability manifest, and the same manifest at the legacy `/.well-known/ic-app.json` path), then the frontend via `x-ic-canister-id` and backend candidates via `/env.json` + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) `oql`/`api_doc_available` capability flags from a one-shot Candid probe | | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result in every case — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next` so an expected absence is distinct from an unreachable canister | | `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `derivation_origin?`, `account?`, `candid?` | READ a canister — provide EITHER a Candid `query` `method` (with `args`) OR an `oql` query (a JSON object string, run against `execute`). A Candid `method` query may be anonymous or as your account and returns textual Candid; an `oql` query **requires** `derivation_origin` and returns `columns` + `rows` (a table) with `has_more`, validating `start` against the schema on an empty result. On an OQL canister a Candid `method` query is rejected — use `oql`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | -| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, to protect the user — the refusal directs the user to act themselves — in a wallet they control (e.g. [oisy.com](https://oisy.com)), or, for canister creation, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | +| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `app_url?`, `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **The target must be declared by its app**: the call is made only when the app at `app_url` (from `open_app`; falling back to `derivation_origin` when no `app_url` is given) declares `canister_id` in its `/.well-known/ic-architecture` manifest — see [Writes are gated on the discoverability manifest](#writes-are-gated-on-the-discoverability-manifest). The reply echoes `declared_by` / `declared_at` (which origin authorized the write, and at which path). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers, to protect the user — the refusal directs the user to act themselves — in a wallet they control (e.g. [oisy.com](https://oisy.com)), or, for canister creation, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | | `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app, without a call. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`). Echoes `derived_for_origin` / `requested` so an origin mismatch is visible | | `list_app_accounts` | `derivation_origin` | The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, last-used, and the derivation origin they were listed for. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`) | -| `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` | +| `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared by the app in `/.well-known/ii-derivation-origin`, else in the legacy `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` | | `icp_oql_guide` | — | The OQL query-surface dialect guide (for canisters where `get_canister_candid` reports `oql: true`): the JSON query object, predicate grammar, edges, and paged result shape. The entity/field names come from `get_canister_oql_schema` and queries run through `canister_query` (the `oql` argument) | | `get_canister_oql_schema` | `canister_id`, `derivation_origin`, `account?` | The canister's OQL schema catalogue (entities, primary keys, fields, edges) as JSON — wraps its `schema` method — plus a ready-to-run `canister_query` example per entity. **`derivation_origin` is required**: the schema is caller-gated, so an anonymous read is rejected (for now) with guidance, rather than returning an empty list | @@ -182,7 +192,7 @@ Acting **for the user** at an app: unrelated or squatted site. The tool enforces this: a bare *unknown* name is refused (find the real URL — web-search or ask the user), and a URL that resolves to `app_url_default` while showing **no IC evidence** (no valid `x-ic-canister-id` - gateway header, no `ic-app.json` derivation origin) is refused too; when the host + gateway header, no declared derivation origin) is refused too; when the host resembles a known app the error names it and gives the real URL (a "did you mean" repair). For a single step, the narrower tools remain: **`resolve_app(url)`** (origin only), **`discover_app_canisters(url)`** @@ -199,8 +209,9 @@ Acting **for the user** at an app: passing the `derivation_origin` — an OQL read **requires** it (an anonymous per-app read is rejected for now, and a Candid `method` query is rejected on an OQL canister). Otherwise pass a Candid `method`. -7. **Act** with `canister_update_call`, passing `derivation_origin` + `account` - to act as the user. +7. **Act** with `canister_update_call`, passing the `app_url` from step 0–2 (it + gates the write on the app's declared manifest) plus `derivation_origin` + + `account` to act as the user. Genuinely public reads via a `canister_query` Candid `method` query or the public-metadata tools (`get_canister_candid`, `discover_app_canisters`) skip steps @@ -209,35 +220,90 @@ independent of the identity steps (3/4), so they can run in parallel. Managing y **own** canisters is not part of this connector: create and manage them with the [`icp` CLI](https://github.com/dfinity/icp-cli) in your own terminal. -### App-declared canister metadata (App Connect) +### App-declared canister metadata -Apps that adopt **Internet Computer App Connect** serve a bridge page at -`/ai-connect.html` whose `` declares the app's -**main backend** canister (spec §4.7/§6.1). Discovery reads that meta from the -raw served markup (no JavaScript is executed) and reports it as the -top-priority finding, labelled `main backend (App Connect)`. +The authoritative statement about which canisters an app comprises is the one +the app publishes itself. Two such statements are read, both from bytes the app +serves: -The App Connect spec **defers** multi-canister enumeration (§6.3: how an app -lists *all* the canisters it comprises, with roles). To fill that gap, this -server also reads a proposed convention: a `/.well-known/ic-app.json` manifest -the app serves itself — +**The service-discoverability manifest** (`/.well-known/ic-architecture`) — +[Layer 1 of the protocol](https://docs.internetcomputer.org/guides/frontends/service-discoverability/) +— enumerates *every* canister the app comprises, with roles: ```json { - "derivation_origin": "https://.icp0.io", + "version": "1.0.0", "canisters": [ - { "id": "aaaaa-…-cai", "role": "backend", "description": "orders + inventory API" }, - { "id": "bbbbb-…-cai", "role": "ledger" } + { "id": "aaaaa-…-cai", "name": "backend", "role": "the backend", + "description": "orders + inventory API; call getApiDoc() first" }, + { "id": "bbbbb-…-cai", "name": "frontend", "role": "the frontend" } ] } ``` -Each entry needs an `id` (a canister principal); `role` and `description` are -optional and become the finding's label (`role — description`). Unknown fields -are ignored, so the format can grow. Both sources are the app's own claim about -its composition — stronger than anything mined from client code — but an -SPA catch-all serving HTML at these paths simply yields no findings (no meta -tag; JSON parse fails), and every id is still validated as a principal. +Each entry needs an `id` (a canister principal); `name`, `role`, and +`description` are optional and become the finding's label (`role — description`, +falling back to `name`). Unknown fields are ignored, so the format can grow. +This manifest is also the **only** thing that permits a write — see +[Writes are gated on the discoverability manifest](#writes-are-gated-on-the-discoverability-manifest). + +Before the protocol was published this server proposed the same document at +`/.well-known/ic-app.json`, with an extra top-level `derivation_origin` field. +That path is still read, at lower authority, so the apps that adopted the +proposal keep working; new apps should publish `/.well-known/ic-architecture` +and, if they pin a custom derivation origin, `/.well-known/ii-derivation-origin` +(the protocol's Layer 5 — one canonical `https://host` on a single line, which +takes precedence over the legacy field). + +**Internet Computer App Connect** (`/ai-connect.html`) is the third source: its +`` declares the app's **main backend** canister +(spec §4.7/§6.1). Discovery reads that meta from the raw served markup (no +JavaScript is executed) and reports it as the top-priority finding, labelled +`main backend (App Connect)`. App Connect **defers** multi-canister enumeration +(§6.3), which is the gap the manifest above fills. + +All three are the app's own claim about its composition — stronger than anything +mined from client code. All three also **fail closed** on the most common +misconfiguration, an SPA catch-all serving `index.html` at these paths: there is +no meta tag to find, the JSON parse fails, and the derivation-origin file's first +line is not an origin. Every id is validated as a principal before it is kept. + +### Writes are gated on the discoverability manifest + +Reading is open; writing is not. A state-changing call runs against someone's +live application, and a canister being publicly callable is not a statement by +its operators that they want an agent driving it. So `canister_update_call` is +restricted to canisters an app **declares** in its `/.well-known/ic-architecture` +manifest (or its legacy `/.well-known/ic-app.json` equivalent). Publishing that +manifest is a deliberate act, and per the protocol guide it is exactly how an +app's operators say "these are my canisters; an agent handed my URL may work them +out and use them". + +The manifest lives at the app's origin, not on chain, so the tool has to be told +which app owns the target: that is the `app_url` argument (`open_app` returns it), +falling back to `derivation_origin` when the app serves its manifest at that same +origin. The gate then fetches the manifest — under the same SSRF hardening and +size caps as the rest of discovery — and refuses unless the target is listed. +Every refusal names the standard path, links the guide, distinguishes its cause +(no origin given / origin unreachable / no manifest published / published but this +canister is not in it — that last one lists what the app *does* declare), and says +explicitly that reads are unaffected, so an agent answers what it can rather than +concluding the whole app is off limits. A successful call echoes `declared_by` and +`declared_at`, so a write's provenance is visible in the reply. + +Three things this gate is not: + +- **It is not proof of ownership.** Whoever controls a domain controls what its + manifest says, so a manifest can name a canister its publisher does not own. + What the gate establishes is that *someone* published a document, at an origin + the caller named, claiming that canister as part of their app — and that a + write can be traced back to that claim. +- **It is not a substitute for the canister's own authorization.** The IC still + decides what the calling principal may do; this only decides what this server + is willing to send. +- **It is not a read restriction.** `canister_query`, `get_canister_candid`, + `get_canister_api_doc`, the OQL surface and every discovery tool work on any + canister, exactly as before. Discovery fetches are **SSRF-hardened** (CWE-918). Only `https` URLs with a real host are fetched, and every outbound fetch runs under a redirect guard (a 3xx may only @@ -291,7 +357,8 @@ token (see Auth). > URL. A derivation origin is a *stable per-app value*, so you **resolve it once** > and reuse it: `open_app` (or `resolve_app`) turns an app name/URL into it and > reports how — `derivation_origin_source`: **declared** -> (`/.well-known/ic-app.json` → `derivation_origin`), else a built-in **known-app** +> (`/.well-known/ii-derivation-origin`, else the legacy `/.well-known/ic-app.json` +> → `derivation_origin`), else a built-in **known-app** > value for a few apps that pin a custom origin without declaring it (an app's > own declaration always overrides this), else the app origin *assumed* > (**app_url_default**). Feeding that resolved origin to an identity tool records @@ -904,7 +971,8 @@ mcp_get_delegation : *domain-based* derivation: a raw `derivation_origin` is canonicalized and used verbatim, with no recovery of a custom derivation origin from it. When an `app_url` is passed instead, `resolve_app` resolves the derivation origin by - precedence **declared** (`/.well-known/ic-app.json` `derivation_origin`) > + precedence **declared** (`/.well-known/ii-derivation-origin`, else the legacy + `/.well-known/ic-app.json` `derivation_origin`) > built-in **known-app** registry > application origin, so a custom origin an app declares (or that ships in the registry, e.g. `oisy.com`) **is** honoured, and the app's `/.well-known/ii-alternative-origins` list is fetched and surfaced by diff --git a/crates/imcp2-core/src/calls.rs b/crates/imcp2-core/src/calls.rs index ee7a638..9536b59 100644 --- a/crates/imcp2-core/src/calls.rs +++ b/crates/imcp2-core/src/calls.rs @@ -167,6 +167,15 @@ pub struct CanisterUpdateCallArgs { /// Arguments in textual Candid syntax, e.g. `()` or `(record { owner = principal "..." })`. #[serde(default = "default_args")] pub args: String, + /// The WEBSITE URL of the app that owns `canister_id` (e.g. + /// "https://app.example.com") — open_app returns it as `app_url`. An update + /// call is only made to a canister the app DECLARES in its + /// service-discoverability manifest (`/.well-known/ic-architecture`), and this + /// is the origin that manifest is read from. Omit it only when + /// `derivation_origin` is the same origin as the app's website (the common + /// case); if the check fails, pass this explicitly. + #[serde(default)] + pub app_url: Option, /// Call AS the user's account at an app, identified by its exact canonical /// Internet Identity derivation origin — NOT necessarily the visible URL (do /// not infer it from an alternativeOrigins list). Get it from open_app / @@ -221,6 +230,14 @@ pub struct CanisterUpdateCallOutput { /// Always present so a text-only client can tell an anonymous call from an /// authenticated one. pub is_anonymous: bool, + /// The app origin whose service-discoverability manifest DECLARES this + /// canister — the app whose published manifest authorized this write. Always + /// present on a successful call: without a declaration the call is refused. + pub declared_by: String, + /// The well-known path that declaration was read from: + /// `/.well-known/ic-architecture` (the protocol) or `/.well-known/ic-app.json` + /// (this server's legacy pre-protocol path). + pub declared_at: String, } /// Arguments for `canister_query` — a READ that runs EITHER a Candid `query` method diff --git a/crates/imcp2-core/src/discover.rs b/crates/imcp2-core/src/discover.rs index 73072d8..8bdf35d 100644 --- a/crates/imcp2-core/src/discover.rs +++ b/crates/imcp2-core/src/discover.rs @@ -2,11 +2,15 @@ //! Internet Computer, folding together the patterns we've seen across apps: //! //! 1. **App-declared metadata** (most authoritative — the app says so): -//! the `ic:canister-id` `` on `/ai-connect.html` (the App Connect -//! bridge page, spec §4.7/§6.1 — the app's MAIN backend), and the -//! `/.well-known/ic-app.json` manifest enumerating ALL the app's -//! canisters with roles (our proposed convention for the spec's deferred -//! §6.3 "multi-canister applications" — see README). +//! the `/.well-known/ic-architecture` manifest enumerating ALL the app's +//! canisters with roles — Layer 1 of the IC service-discoverability +//! protocol ([`SERVICE_DISCOVERABILITY_GUIDE`]) and the ONE signal the +//! update-call gate in `discoverability` keys on; the legacy +//! `/.well-known/ic-app.json` manifest of the same shape (this server's +//! pre-protocol proposal for App Connect's deferred §6.3, still read so +//! the apps that adopted it keep working); and the `ic:canister-id` +//! `` on `/ai-connect.html` (the App Connect bridge page, spec +//! §4.7/§6.1 — the app's MAIN backend). //! 2. `x-ic-canister-id` response header — the frontend/asset canister. This //! is the one universal signal (the HTTP gateway sets it). //! 3. a runtime config asset (`/env.json`) carrying `*canister_id*` keys — @@ -39,8 +43,8 @@ pub struct Found { /// A human label if one was attached (App Connect role, env.json key, /// bundle constant name, or "frontend"); None for a bare bundle literal. pub label: Option, - /// Where it was found: "ai-connect.html", "ic-app.json", "header", - /// "env.json", "bundle: