fix(agent): detect conflicting ic_env cookies instead of taking the first - #1386
Conversation
…irst
`getCanisterEnv` resolved the environment with `document.cookie.split(';').find(...)`,
taking whichever `ic_env` cookie came first. Several can coexist on one origin, since a
cookie is keyed on (name, domain, path) and partitioned cookies live in their own jar --
for example after a frontend canister is upgraded between asset canister implementations
that disagree on `Partitioned`.
The order is not arbitrary: RFC 6265 section 5.4 lists longer paths first and, among equal
paths, earlier creation-times first. First-match therefore prefers the *stalest* copy, and
the app is silently configured against a canister that may no longer exist. The resulting
call rejection names nothing about cookies, so it reads as a broken replica.
Collect every copy instead. Identical copies are common and stay silent; when they
disagree, throw `ConflictingCanisterEnvErrorCode` naming the conflict.
Copies are compared on a canonical form, since writers legitimately differ in
percent-encoding (`_` vs `%5F`) and in the order they emit variables -- comparing the
decoded strings directly would report false conflicts between the asset canister and a
dev server serving the same environment.
Also replaces `cookie.split('=')[1]`, which truncated any value containing an unencoded
`=`, with the same first-separator slice `parseEnvVars` already used.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
✅ No security or compliance issues detected. Reviewed everything up to 2ea4433. Security Overview
Detected Code Changes
|
size-limit report 📦
|
There was a problem hiding this comment.
Pull request overview
This PR updates the experimental canister-environment cookie reader to detect and surface ambiguous configuration when multiple ic_env cookies exist on the same origin, avoiding silent selection of a potentially stale value.
Changes:
- Collect all matching cookie values, canonicalize them, and throw a new
ConflictingCanisterEnvErrorCodewhen distinct environments are detected. - Improve cookie value parsing by slicing on the first
name=prefix rather thansplit('=')[1]. - Add test coverage for duplicate-cookie scenarios, including ordering/encoding normalization and
safeGetCanisterEnvbehavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/core/src/agent/errors.ts | Adds a new error code to report conflicting canister-env cookie values. |
| packages/core/src/agent/canister-env/index.ts | Reads all cookie copies, deduplicates by canonicalized env-var ordering, and throws on disagreement. |
| packages/core/src/agent/canister-env/index.test.ts | Adds tests covering duplicate cookies (identical, normalized-equal, conflicting, prefix collisions) and safeGetCanisterEnv. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Addresses review feedback on the ic_env conflict detection. Decoding every copy rather than just the first widened the surface for `URIError`: a corrupt stale duplicate could now break a call that a valid sibling would have served, and `URIError` is not the `InputError` the docstring promises. Decode defensively instead. A copy that cannot be decoded carries no environment, so it is not a candidate to choose between -- drop it, and report `MalformedCookieErrorCode` only when no copy survives. The conflict message counted distinct environments while calling them cookies, so three cookies holding two environments reported "Found 2 'ic_env' cookies". Count values instead. Also corrects the rationale on `distinctEnvValues`: decoding is what normalises the writers' differing percent-encoding, so sorting is justified by variable order alone. The comparison itself was already right; the comment explaining it was not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All three valid — applied in 1aae9f2. Notes on each, since two of them changed my reasoning and not just the wording.
Decoding defensively now. On the question of what an undecodable copy means: I didn't surface it as a conflict as suggested, because a copy that can't be decoded carries no environment at all — it isn't a second candidate to choose between, so treating it as one would break working setups on corrupt data. It's dropped, and Conflict message count — right, and it undercut the point of the PR. Three cookies holding two environments reported "Found 2
Full suite green (647 passed, 36 snapshots), typecheck and lint clean. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/core/src/agent/canister-env/index.ts:232
distinctEnvValuescanonicalizes by sorting the rawkey=valuesegments. If a cookie contains duplicate keys (e.g.A=1&A=2), the resulting environment depends on segment order becauseparseEnvVarsultimately usesObject.fromEntries(last value wins). Sorting makesA=1&A=2andA=2&A=1look identical, which can incorrectly suppress a real conflict between two cookies whose effective parsed env differs.
function distinctEnvValues(decodedValues: string[]): string[] {
const byCanonicalForm = new Map<string, string>();
for (const value of decodedValues) {
const canonicalForm = value.split(ENV_VAR_SEPARATOR).sort().join(ENV_VAR_SEPARATOR);
if (!byCanonicalForm.has(canonicalForm)) {
byCanonicalForm.set(canonicalForm, value);
}
}
return Array.from(byCanonicalForm.values());
The canonical form sorted the raw `key=value` segments, so two cookies holding the same variables compared equal even when they resolved differently. Repeated variables are resolved last-wins by `Object.fromEntries`, so `A=1&A=2` and `A=2&A=1` yield different values for `A` while sorting to identical text -- a genuine conflict, silently suppressed, which is the failure class this whole change exists to remove. Resolve repeated variables before sorting so the comparison reflects the environment a cookie actually produces. The entry splitting is now shared with `parseEnvVars` rather than duplicated, so the two cannot drift apart on how they read the same format. No writer emits repeated variables today, so this is unreachable in practice. Fixing it anyway keeps the canonical form honest about what it claims to compare. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The suppressed comment on Sorting the raw Fixed in 2ea4433 by resolving repeated variables before sorting, so the canonical form reflects the environment a cookie actually produces rather than its raw text. The entry splitting is now shared with Worth being clear about reachability, since Copilot suppressed this for a reason and I agree with its confidence assessment: no writer emits repeated variables. Two tests added: one asserting the conflict is now detected, one asserting that repeated variables in a consistent order still count as a single environment and resolve last-wins. Full suite green (649 total, 646 passed, 36 snapshots), typecheck and lint clean. |
Closes #1384.
Problem
getCanisterEnvresolved the environment with:Several
ic_envcookies can coexist on one origin — a cookie is keyed on (name, domain, path), and partitioned cookies live in their own jar. The realistic trigger is a frontend canister upgraded between asset canister implementations that disagree onPartitioned:dfinity/sdk'sic-certified-assetswritesSameSite=Lax,dfinity/certified-assetswritesSecure; SameSite=None; Partitioned(confirmed against the live header onskills.internetcomputer.org). The two land in different jars on the same host and both stay readable.The issue framed this as "whichever the browser happens to list first", but the order is specified: RFC 6265 §5.4 lists longer paths first and, among equal paths, earlier creation-times first. So first-match isn't arbitrary — it systematically prefers the stalest copy. The app is then configured against a canister that may no longer exist, and the resulting call rejection names nothing about cookies, so it reads as a broken replica.
Change
Collect every copy. Identical copies are common and stay silent; when they disagree, throw a new
ConflictingCanisterEnvErrorCodenaming the conflict.safeGetCanisterEnvkeeps returningundefined.No precedence rule is invented, deliberately. Sorting by
PathorPartitionedisn't possible here —document.cookieexposes onlyname=value, and the attributes aren't there.cookieStore.getAll()would have them but is Chromium-only. And "prefer the non-partitioned copy" would be backwards: the authoritative mainnet writer is the partitioned one. Conflicting boot config is a broken environment; guessing is what produced the bug.Copies are compared on the environment they resolve to, not on their text, because writers differ in ways that carry no meaning. Decoding handles percent-encoding —
encodeURIComponentleaves_alone while the asset canister encodes it as%5F. Resolving repeated variables last-wins (asparseEnvVarsdoes) and then sorting handles emission order. Comparing raw or merely-decoded strings would report false conflicts between an asset canister and a dev server serving the same environment, which would be worse than the bug being fixed; sorting raw variables without resolving repeats would do the opposite and mask a real difference.A copy whose percent-encoding is malformed can't be decoded at all. It carries no environment, so it isn't a second candidate to choose between — it's dropped, letting a valid sibling still be used, and
MalformedCookieErrorCodeis reported only when no copy survives. This also keepsdecodeURIComponent'sURIErrorfrom escaping an API that documentsInputError.Also replaces
cookie.split('=')[1], which truncated any value containing an unencoded=, with the same first-separator sliceparseEnvVarsalready used. It failed closed (MissingRootKeyErrorCode), so this is robustness rather than a fix.Scope
Deliberately minimal. The investigation on #1384 also considered aligning cookie attributes across
dfinity/certified-assets,dfinity/candid,dfinity/sdkand ~38 dev-server configs. That's dropped: local frontends are served athttp://<canister-id>.localhost:8000/and these are host-only cookies, so the dev-server/asset-canister collision that would have justified it doesn't occur. This reader change is also the only one that reaches canisters and projects already deployed — every writer change is upgrade-gated.No new public functions — the only additions to the surface are the two error codes.
getAllCanisterEnvswas considered as an escape hatch and left out until someone needs it.Testing
Ten tests added: identical copies; copies differing only in encoding or variable order; disagreeing copies; three cookies holding two environments (pinning the count in the message); copies whose difference only repeated variables reveal; repeated variables in a consistent order resolving last-wins as one environment; a cookie whose name merely shares a prefix (
ic_env_backup); a malformed copy alongside a valid one; no decodable copy at all; and thesafeGetCanisterEnvpath. Full suite green (646 passed, 36 snapshots), typecheck and lint clean.The module is
@experimental, so the behaviour change — throwing where it previously returned a guess — is in budget. It strictly dominates silently building an actor against a dead canister.🤖 Generated with Claude Code