From 0b3c0b3f5fec3e8ca6a2fc10b405d3ae817e3dd7 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 21 May 2026 16:22:17 -0700 Subject: [PATCH 1/3] Fix the agent to use relative paths --- packages/core/src/agent/agent/http/index.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index dc010a919..494ed2018 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -335,6 +335,10 @@ export class HttpAgent implements Agent { const host = determineHost(options.host); this.host = new URL(host); + // Ensure trailing slash so relative API path construction preserves any base path prefix + if (!this.host.pathname.endsWith('/')) { + this.host.pathname += '/'; + } // Rewrite to avoid redirects and normalize the host before using it for namespacing caches if (this.host.hostname.endsWith(IC0_SUB_DOMAIN)) { this.host.hostname = IC0_DOMAIN; @@ -563,7 +567,7 @@ export class HttpAgent implements Agent { try { // Attempt v4 sync call const requestSync = () => { - const url = new URL(`/api/v4/canister/${ecid.toText()}/call`, this.host); + const url = new URL(`api/v4/canister/${ecid.toText()}/call`, this.host); this.log.print(`fetching "${url.pathname}" with request:`, transformedRequest); return this.#fetch(url, { ...this.#callOptions, @@ -573,7 +577,7 @@ export class HttpAgent implements Agent { }; const requestAsync = () => { - const url = new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host); + const url = new URL(`api/v2/canister/${ecid.toText()}/call`, this.host); this.log.print(`fetching "${url.pathname}" with request:`, transformedRequest); return this.#fetch(url, { ...this.#callOptions, @@ -808,7 +812,7 @@ export class HttpAgent implements Agent { const delay = tries === 0 ? 0 : backoff.next(); - const url = new URL(`/api/v3/canister/${ecid.toString()}/query`, this.host); + const url = new URL(`api/v3/canister/${ecid.toString()}/query`, this.host); this.log.print(`fetching "${url.pathname}" with tries:`, { tries, @@ -1275,7 +1279,7 @@ export class HttpAgent implements Agent { transformedRequest = await this.createReadStateRequest(fields, identity); } - const url = new URL(`/api/v3/canister/${canister.toString()}/read_state`, this.host); + const url = new URL(`api/v3/canister/${canister.toString()}/read_state`, this.host); return await this.#readStateInner(url, { canisterId: canister }, transformedRequest, requestId); } @@ -1293,7 +1297,7 @@ export class HttpAgent implements Agent { await this.#rootKeyGuard(); const subnet = Principal.from(subnetId); - const url = new URL(`/api/v3/subnet/${subnet.toString()}/read_state`, this.host); + const url = new URL(`api/v3/subnet/${subnet.toString()}/read_state`, this.host); const transformedRequest: ReadStateRequest = await this.createReadStateRequest( options, this.#identity ?? undefined, @@ -1522,7 +1526,7 @@ export class HttpAgent implements Agent { } : {}; - const url = new URL(`/api/v2/status`, this.host); + const url = new URL(`api/v2/status`, this.host); this.log.print(`fetching "${url.pathname}"`); const backoff = this.#backoffStrategy(); From 4e67007313a6768cffdb21f46cd202e099be8f67 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 21 May 2026 16:22:39 -0700 Subject: [PATCH 2/3] Update tests to use the config URL instead of the gateway URL --- e2e/node/basic/basic.test.ts | 4 ++-- e2e/node/basic/canisterStatus.test.ts | 5 +---- e2e/node/basic/counter.test.ts | 24 +++++++----------------- e2e/node/basic/trap.test.ts | 13 ++++--------- e2e/node/global-setup.ts | 6 +++--- e2e/node/pic-helpers.ts | 3 ++- e2e/node/setup-pic.ts | 6 +++--- e2e/node/utils/agent.ts | 10 ++++++---- 8 files changed, 28 insertions(+), 43 deletions(-) diff --git a/e2e/node/basic/basic.test.ts b/e2e/node/basic/basic.test.ts index f7e9c1403..8c621fd33 100644 --- a/e2e/node/basic/basic.test.ts +++ b/e2e/node/basic/basic.test.ts @@ -2,7 +2,7 @@ import type { LookupPathResultFound } from '@icp-sdk/core/agent'; import { Certificate, LookupPathStatus } from '@icp-sdk/core/agent'; import { IDL, PipeArrayBuffer } from '@icp-sdk/core/candid'; import { Principal } from '@icp-sdk/core/principal'; -import agent from '../utils/agent.ts'; +import agent, { gatewayPort } from '../utils/agent.ts'; import { test, expect } from 'vitest'; import { utf8ToBytes } from '@noble/hashes/utils'; @@ -11,7 +11,7 @@ import { utf8ToBytes } from '@noble/hashes/utils'; * @returns the default effective canister id */ export async function getDefaultEffectiveCanisterId() { - const res = await fetch(`http://127.0.0.1:${process.env.REPLICA_PORT}/_/topology`); + const res = await fetch(`http://127.0.0.1:${gatewayPort}/_/topology`); const data = (await res.json()) as Record>; const id = data['default_effective_canister_id']['canister_id']; // decode from base64 diff --git a/e2e/node/basic/canisterStatus.test.ts b/e2e/node/basic/canisterStatus.test.ts index 8df873e88..e310628e5 100644 --- a/e2e/node/basic/canisterStatus.test.ts +++ b/e2e/node/basic/canisterStatus.test.ts @@ -33,10 +33,7 @@ describe('canister status', () => { }); it('should throw an error if fetchRootKey has not been called', async () => { const counterCanisterId = Principal.fromText(requireEnv('CANISTER_ID_COUNTER')); - const agent = HttpAgent.createSync({ - host: `http://127.0.0.1:${process.env.REPLICA_PORT ?? 4943}`, - verifyQuerySignatures: false, - }); + const agent = await makeAgent({ verifyQuerySignatures: false, shouldFetchRootKey: false }); expect.assertions(1); try { await CanisterStatus.request({ diff --git a/e2e/node/basic/counter.test.ts b/e2e/node/basic/counter.test.ts index 47f69f9a8..262750cef 100644 --- a/e2e/node/basic/counter.test.ts +++ b/e2e/node/basic/counter.test.ts @@ -1,7 +1,8 @@ import type { ActorMethod } from '@icp-sdk/core/agent'; -import { Actor, HttpAgent } from '@icp-sdk/core/agent'; +import { Actor } from '@icp-sdk/core/agent'; import { counterActor, counter2Actor, counterCanisterId, idl } from '../canisters/counter.ts'; import { it, expect, describe, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { makeAgent } from '../utils/agent.ts'; describe.sequential('counter', () => { beforeAll(async () => { @@ -58,10 +59,7 @@ describe.sequential('counter', () => { // Create counter actor with preSignReadStateRequest enabled const counter = await Actor.createActor(idl, { canisterId: counterCanisterId, - agent: await HttpAgent.create({ - host: `http://127.0.0.1:${process.env.REPLICA_PORT}`, - shouldFetchRootKey: true, - }), + agent: await makeAgent(), pollingOptions: { preSignReadStateRequest: true, }, @@ -83,12 +81,9 @@ describe.sequential('counter', () => { it('should allow method-specific pollingOptions override', async () => { // Create counter actor without preSignReadStateRequest - const counter = await Actor.createActor(idl, { + const counter = Actor.createActor(idl, { canisterId: counterCanisterId, - agent: await HttpAgent.create({ - host: `http://127.0.0.1:${process.env.REPLICA_PORT}`, - shouldFetchRootKey: true, - }), + agent: await makeAgent(), }); // Reset counter to 0 @@ -131,14 +126,9 @@ describe('retrytimes', () => { return fetch.apply(null, args as [input: string | Request, init?: RequestInit | undefined]); }); - const counter = await Actor.createActor(idl, { + const counter = Actor.createActor(idl, { canisterId: counterCanisterId, - agent: await HttpAgent.create({ - fetch: fetchMock as typeof fetch, - retryTimes: 3, - host: `http://127.0.0.1:${process.env.REPLICA_PORT}`, - shouldFetchRootKey: true, - }), + agent: await makeAgent({ fetch: fetchMock as typeof fetch, retryTimes: 3 }), }); const result = await counter.greet('counter'); diff --git a/e2e/node/basic/trap.test.ts b/e2e/node/basic/trap.test.ts index 7310d1c4b..db9057f6c 100644 --- a/e2e/node/basic/trap.test.ts +++ b/e2e/node/basic/trap.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect } from 'vitest'; import type { ActorMethod } from '@icp-sdk/core/agent'; -import { Actor, HttpAgent, AgentError, CertifiedRejectErrorCode } from '@icp-sdk/core/agent'; +import { Actor, AgentError, CertifiedRejectErrorCode } from '@icp-sdk/core/agent'; import type { IDL } from '@icp-sdk/core/candid'; import { requireEnv } from '../test-setup.ts'; +import { makeAgent } from '../utils/agent.ts'; const trapCanisterId = requireEnv('CANISTER_ID_TRAP'); @@ -20,10 +21,7 @@ export interface _SERVICE { describe('trap', () => { it('should trap', async () => { - const agent = await HttpAgent.create({ - host: `http://127.0.0.1:${process.env.REPLICA_PORT}`, - shouldFetchRootKey: true, - }); + const agent = await makeAgent(); const actor = Actor.createActor<_SERVICE>(idlFactory, { canisterId: trapCanisterId, agent }); expect.assertions(3); try { @@ -36,10 +34,7 @@ describe('trap', () => { } }); it('should trap', async () => { - const agent = await HttpAgent.create({ - host: `http://127.0.0.1:${process.env.REPLICA_PORT}`, - shouldFetchRootKey: true, - }); + const agent = await makeAgent(); const actor = Actor.createActor<_SERVICE>(idlFactory, { canisterId: trapCanisterId, agent }); expect.assertions(3); try { diff --git a/e2e/node/global-setup.ts b/e2e/node/global-setup.ts index fb43888f7..f2f8062cb 100644 --- a/e2e/node/global-setup.ts +++ b/e2e/node/global-setup.ts @@ -13,13 +13,13 @@ let pic: PocketIc; /** Start PocketIC and deploy canisters, or reuse an existing instance. */ export async function setup(): Promise { - // If a .env file already exists with REPLICA_PORT set (e.g., from setup-pic.ts), + // If a .env file already exists with GATEWAY_PORT set (e.g., from setup-pic.ts), // verify the server is actually reachable before reusing it. if (existsSync(ENV_PATH)) { const existing = dotenv.parse(readFileSync(ENV_PATH)); - if (existing.REPLICA_PORT && existing.CANISTER_ID_COUNTER) { + if (existing.GATEWAY_PORT && existing.CANISTER_ID_COUNTER) { try { - const res = await fetch(`http://127.0.0.1:${existing.REPLICA_PORT}/api/v2/status`); + const res = await fetch(`http://127.0.0.1:${existing.GATEWAY_PORT}/api/v2/status`); if (res.ok) { Object.assign(process.env, existing); return; diff --git a/e2e/node/pic-helpers.ts b/e2e/node/pic-helpers.ts index d1ec603d8..3cd0d8105 100644 --- a/e2e/node/pic-helpers.ts +++ b/e2e/node/pic-helpers.ts @@ -75,7 +75,8 @@ export async function startPocketIC({ const port = gwData.Created.port; const envVars: Record = { - REPLICA_PORT: String(port), + GATEWAY_PORT: String(port), + SERVER_URL: serverUrl, CANISTER_ID_COUNTER: counterCanisterId, CANISTER_ID_COUNTER2: counter2CanisterId, CANISTER_ID_COUNTER3: counter3CanisterId, diff --git a/e2e/node/setup-pic.ts b/e2e/node/setup-pic.ts index 8f5afaade..8a9449f7d 100644 --- a/e2e/node/setup-pic.ts +++ b/e2e/node/setup-pic.ts @@ -6,7 +6,7 @@ * npx tsx setup-pic.ts [--gateway-port PORT] Start PocketIC and keep alive * npx tsx setup-pic.ts --wait [--timeout SEC] Wait for a running instance to be ready * - * Writes a .env file with REPLICA_PORT and canister IDs. + * Writes a .env file with GATEWAY_PORT, SERVER_URL, and canister IDs. * The PocketIC server stays running until this process is killed. */ import { existsSync, readFileSync, writeFileSync } from 'fs'; @@ -27,7 +27,7 @@ if (args.includes('--wait')) { while (Date.now() < deadline) { if (existsSync(ENV_PATH)) { const content = readFileSync(ENV_PATH, 'utf-8'); - if (content.includes('REPLICA_PORT')) { + if (content.includes('GATEWAY_PORT')) { // eslint-disable-next-line no-console console.log(content.trim()); process.exit(0); @@ -51,7 +51,7 @@ const envContent = Object.entries(envVars) writeFileSync(ENV_PATH, `${envContent}\n`); // eslint-disable-next-line no-console -console.log(`PocketIC gateway listening on port ${envVars.REPLICA_PORT}`); +console.log(`PocketIC gateway listening on port ${envVars.GATEWAY_PORT}`); // eslint-disable-next-line no-console console.log(`Canister IDs written to .env`); diff --git a/e2e/node/utils/agent.ts b/e2e/node/utils/agent.ts index da185a6d8..e9f94a416 100644 --- a/e2e/node/utils/agent.ts +++ b/e2e/node/utils/agent.ts @@ -5,14 +5,16 @@ import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; export const identity = Ed25519KeyIdentity.generate(); export const principal = identity.getPrincipal(); -export const port = parseInt(process.env['REPLICA_PORT'] || '4943', 10); -if (Number.isNaN(port)) { - throw new Error('The environment variable REPLICA_PORT is not a number.'); +export const gatewayPort = parseInt(process.env['GATEWAY_PORT'] || '4943', 10); +if (Number.isNaN(gatewayPort)) { + throw new Error('The environment variable GATEWAY_PORT is not a number.'); } export const makeAgent = async (options?: HttpAgentOptions) => { return await HttpAgent.create({ - host: `http://127.0.0.1:${process.env.REPLICA_PORT ?? 4943}`, + host: process.env.SERVER_URL + ? new URL('/instances/0/', process.env.SERVER_URL).toString() + : `http://127.0.0.1:${gatewayPort}`, shouldFetchRootKey: true, ...options, }); From 609d40687fbe8fd8c634c8ff707eb914dbe9263f Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 21 May 2026 20:04:28 -0700 Subject: [PATCH 3/3] Fix audit --- pnpm-lock.yaml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b602bd145..085f8a975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1364,14 +1364,14 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.0.3: - resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -3249,8 +3249,8 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4574,16 +4574,16 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@1.1.13: + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.3: + brace-expansion@2.1.0: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -5648,7 +5648,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.20.0 + ws: 8.20.1 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -5821,15 +5821,15 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.13 + brace-expansion: 1.1.14 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 2.1.0 minimist@1.2.8: {} @@ -6615,7 +6615,7 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 - ws@8.20.0: + ws@8.20.1: optional: true xml-name-validator@5.0.0: